diff --git a/backend-node/src/routes/cascadingAutoFillRoutes.ts b/backend-node/src/routes/cascadingAutoFillRoutes.ts index a5107448..c1d69e9f 100644 --- a/backend-node/src/routes/cascadingAutoFillRoutes.ts +++ b/backend-node/src/routes/cascadingAutoFillRoutes.ts @@ -55,3 +55,4 @@ export default router; + diff --git a/backend-node/src/routes/cascadingConditionRoutes.ts b/backend-node/src/routes/cascadingConditionRoutes.ts index 22cd2d2b..bbc9384d 100644 --- a/backend-node/src/routes/cascadingConditionRoutes.ts +++ b/backend-node/src/routes/cascadingConditionRoutes.ts @@ -51,3 +51,4 @@ export default router; + diff --git a/backend-node/src/routes/cascadingHierarchyRoutes.ts b/backend-node/src/routes/cascadingHierarchyRoutes.ts index 79a1c6e8..35ced071 100644 --- a/backend-node/src/routes/cascadingHierarchyRoutes.ts +++ b/backend-node/src/routes/cascadingHierarchyRoutes.ts @@ -67,3 +67,4 @@ export default router; + diff --git a/backend-node/src/routes/cascadingMutualExclusionRoutes.ts b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts index 352a05b5..29ac8ee4 100644 --- a/backend-node/src/routes/cascadingMutualExclusionRoutes.ts +++ b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts @@ -55,3 +55,4 @@ export default router; + diff --git a/backend-node/src/services/adminService.ts b/backend-node/src/services/adminService.ts index 1b9280db..95d8befa 100644 --- a/backend-node/src/services/adminService.ts +++ b/backend-node/src/services/adminService.ts @@ -65,6 +65,13 @@ export class AdminService { } ); + // [임시 비활성화] 메뉴 권한 그룹 체크 - 모든 사용자에게 전체 메뉴 표시 + // TODO: 권한 체크 다시 활성화 필요 + logger.info( + `⚠️ [임시 비활성화] 권한 그룹 체크 스킵 - 사용자 ${userId}(${userType})에게 전체 메뉴 표시` + ); + + /* [원본 코드 - 권한 그룹 체크] if (userType === "COMPANY_ADMIN") { // 회사 관리자: 권한 그룹 기반 필터링 적용 if (userRoleGroups.length > 0) { @@ -141,6 +148,7 @@ export class AdminService { return []; } } + */ } else if ( menuType !== undefined && userType === "SUPER_ADMIN" && @@ -412,6 +420,15 @@ export class AdminService { let queryParams: any[] = [userLang]; let paramIndex = 2; + // [임시 비활성화] 메뉴 권한 그룹 체크 - 모든 사용자에게 전체 메뉴 표시 + // TODO: 권한 체크 다시 활성화 필요 + logger.info( + `⚠️ [임시 비활성화] getUserMenuList 권한 그룹 체크 스킵 - 사용자 ${userId}(${userType})에게 전체 메뉴 표시` + ); + authFilter = ""; + unionFilter = ""; + + /* [원본 코드 - getUserMenuList 권한 그룹 체크] if (userType === "SUPER_ADMIN") { // SUPER_ADMIN: 권한 그룹 체크 없이 해당 회사의 모든 메뉴 표시 logger.info(`✅ 좌측 사이드바 (SUPER_ADMIN): 회사 ${userCompanyCode}의 모든 메뉴 표시`); @@ -471,6 +488,7 @@ export class AdminService { return []; } } + */ // 2. 회사별 필터링 조건 생성 let companyFilter = ""; diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index b714b186..8ac5989b 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -2409,11 +2409,19 @@ export class TableManagementService { } // SET 절 생성 (수정할 데이터) - 먼저 생성 + // 🔧 테이블에 존재하는 컬럼만 UPDATE (가상 컬럼 제외) const setConditions: string[] = []; const setValues: any[] = []; let paramIndex = 1; + const skippedColumns: string[] = []; Object.keys(updatedData).forEach((column) => { + // 테이블에 존재하지 않는 컬럼은 스킵 + if (!columnTypeMap.has(column)) { + skippedColumns.push(column); + return; + } + const dataType = columnTypeMap.get(column) || "text"; setConditions.push( `"${column}" = $${paramIndex}::${this.getPostgreSQLType(dataType)}` @@ -2424,6 +2432,10 @@ export class TableManagementService { paramIndex++; }); + if (skippedColumns.length > 0) { + logger.info(`⚠️ 테이블에 존재하지 않는 컬럼 스킵: ${skippedColumns.join(", ")}`); + } + // WHERE 조건 생성 (PRIMARY KEY 우선, 없으면 모든 원본 데이터 사용) let whereConditions: string[] = []; let whereValues: any[] = []; @@ -3930,9 +3942,10 @@ export class TableManagementService { `컬럼 입력타입 정보 조회: ${tableName}, company: ${companyCode}` ); - // table_type_columns에서 입력타입 정보 조회 (company_code 필터링) + // table_type_columns에서 입력타입 정보 조회 + // 회사별 설정 우선, 없으면 기본 설정(*) fallback const rawInputTypes = await query( - `SELECT + `SELECT DISTINCT ON (ttc.column_name) ttc.column_name as "columnName", COALESCE(cl.column_label, ttc.column_name) as "displayName", ttc.input_type as "inputType", @@ -3946,8 +3959,10 @@ export class TableManagementService { LEFT JOIN information_schema.columns ic ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name WHERE ttc.table_name = $1 - AND ttc.company_code = $2 - ORDER BY ttc.display_order, ttc.column_name`, + AND ttc.company_code IN ($2, '*') + ORDER BY ttc.column_name, + CASE WHEN ttc.company_code = $2 THEN 0 ELSE 1 END, + ttc.display_order`, [tableName, companyCode] ); @@ -3961,17 +3976,20 @@ export class TableManagementService { const mappingTableExists = tableExistsResult[0]?.table_exists === true; // 카테고리 컬럼의 경우, 매핑된 메뉴 목록 조회 + // 회사별 설정 우선, 없으면 기본 설정(*) fallback let categoryMappings: Map = new Map(); if (mappingTableExists) { logger.info("카테고리 매핑 조회 시작", { tableName, companyCode }); const mappings = await query( - `SELECT + `SELECT DISTINCT ON (logical_column_name, menu_objid) logical_column_name as "columnName", menu_objid as "menuObjid" FROM category_column_mapping WHERE table_name = $1 - AND company_code = $2`, + AND company_code IN ($2, '*') + ORDER BY logical_column_name, menu_objid, + CASE WHEN company_code = $2 THEN 0 ELSE 1 END`, [tableName, companyCode] ); diff --git a/docs/노드플로우_개선사항.md b/docs/노드플로우_개선사항.md index c9349b94..32757807 100644 --- a/docs/노드플로우_개선사항.md +++ b/docs/노드플로우_개선사항.md @@ -587,3 +587,4 @@ const result = await executeNodeFlow(flowId, { + diff --git a/docs/메일발송_기능_사용_가이드.md b/docs/메일발송_기능_사용_가이드.md index 42900211..8bfe484e 100644 --- a/docs/메일발송_기능_사용_가이드.md +++ b/docs/메일발송_기능_사용_가이드.md @@ -360,3 +360,4 @@ + diff --git a/docs/즉시저장_버튼_액션_구현_계획서.md b/docs/즉시저장_버튼_액션_구현_계획서.md index c392eece..8d8fb497 100644 --- a/docs/즉시저장_버튼_액션_구현_계획서.md +++ b/docs/즉시저장_버튼_액션_구현_계획서.md @@ -346,3 +346,4 @@ const getComponentValue = (componentId: string) => { + diff --git a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx index 3145d9d3..0327e122 100644 --- a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx @@ -127,3 +127,4 @@ export default function ScreenManagementPage() { ); } + diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index b28e4d01..236071ac 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -370,32 +370,13 @@ function AppLayoutInner({ children }: AppLayoutProps) { }; // 모드 전환 핸들러 - const handleModeSwitch = async () => { + const handleModeSwitch = () => { if (isAdminMode) { // 관리자 → 사용자 모드: 선택한 회사 유지 router.push("/main"); } else { - // 사용자 → 관리자 모드: WACE로 복귀 필요 (SUPER_ADMIN만) - if ((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN") { - const currentCompanyCode = (user as ExtendedUserInfo)?.companyCode; - - // 이미 WACE("*")가 아니면 WACE로 전환 후 관리자 페이지로 이동 - if (currentCompanyCode !== "*") { - const result = await switchCompany("*"); - if (result.success) { - // 페이지 새로고침 (관리자 페이지로 이동) - window.location.href = "/admin"; - } else { - toast.error("WACE로 전환 실패"); - } - } else { - // 이미 WACE면 바로 관리자 페이지로 이동 - router.push("/admin"); - } - } else { - // 일반 관리자는 바로 관리자 페이지로 이동 - router.push("/admin"); - } + // 사용자 → 관리자 모드: 선택한 회사 유지 (회사 전환 없음) + router.push("/admin"); } }; diff --git a/frontend/components/screen-embedding/EmbeddedScreen.tsx b/frontend/components/screen-embedding/EmbeddedScreen.tsx index 12496310..3bfb7a77 100644 --- a/frontend/components/screen-embedding/EmbeddedScreen.tsx +++ b/frontend/components/screen-embedding/EmbeddedScreen.tsx @@ -415,8 +415,10 @@ export const EmbeddedScreen = forwardRef +
{ + diff --git a/frontend/hooks/useAutoFill.ts b/frontend/hooks/useAutoFill.ts index caa1e826..7d78322b 100644 --- a/frontend/hooks/useAutoFill.ts +++ b/frontend/hooks/useAutoFill.ts @@ -197,3 +197,4 @@ export function applyAutoFillToFormData( + diff --git a/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx b/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx index 7a115ea3..cbd2744c 100644 --- a/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx +++ b/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx @@ -44,7 +44,42 @@ export function AutocompleteSearchInputComponent({ const displayField = config?.displayField || propDisplayField || ""; const displayFields = config?.displayFields || (displayField ? [displayField] : []); // 다중 표시 필드 const displaySeparator = config?.displaySeparator || " → "; // 구분자 - const valueField = config?.valueField || propValueField || ""; + + // valueField 결정: fieldMappings 기반으로 추론 (config.valueField가 fieldMappings에 없으면 무시) + const getValueField = () => { + // fieldMappings가 있으면 그 안에서 추론 (가장 신뢰할 수 있는 소스) + if (config?.fieldMappings && config.fieldMappings.length > 0) { + // config.valueField가 fieldMappings의 sourceField에 있으면 사용 + if (config?.valueField) { + const hasValueFieldInMappings = config.fieldMappings.some( + (m: any) => m.sourceField === config.valueField + ); + if (hasValueFieldInMappings) { + return config.valueField; + } + // fieldMappings에 없으면 무시하고 추론 + } + + // _code 또는 _id로 끝나는 필드 우선 (보통 PK나 코드 필드) + const codeMapping = config.fieldMappings.find( + (m: any) => m.sourceField?.endsWith("_code") || m.sourceField?.endsWith("_id") + ); + if (codeMapping) { + return codeMapping.sourceField; + } + + // 없으면 첫 번째 매핑 사용 + return config.fieldMappings[0].sourceField || ""; + } + + // fieldMappings가 없으면 기존 방식 + if (config?.valueField) return config.valueField; + if (propValueField) return propValueField; + + return ""; + }; + const valueField = getValueField(); + const searchFields = config?.searchFields || propSearchFields || displayFields; // 검색 필드도 다중 표시 필드 사용 const placeholder = config?.placeholder || propPlaceholder || "검색..."; @@ -76,11 +111,39 @@ export function AutocompleteSearchInputComponent({ // 선택된 데이터를 ref로도 유지 (리렌더링 시 초기화 방지) const selectedDataRef = useRef(null); const inputValueRef = useRef(""); + const initialValueLoadedRef = useRef(null); // 초기값 로드 추적 // formData에서 현재 값 가져오기 (isInteractive 모드) - const currentValue = isInteractive && formData && component?.columnName - ? formData[component.columnName] - : value; + // 우선순위: 1) component.columnName, 2) fieldMappings에서 valueField에 매핑된 targetField + const getCurrentValue = () => { + if (!isInteractive || !formData) { + return value; + } + + // 1. component.columnName으로 직접 바인딩된 경우 + if (component?.columnName && formData[component.columnName] !== undefined) { + return formData[component.columnName]; + } + + // 2. fieldMappings에서 valueField와 매핑된 targetField에서 값 가져오기 + if (config?.fieldMappings && Array.isArray(config.fieldMappings)) { + const valueFieldMapping = config.fieldMappings.find( + (mapping: any) => mapping.sourceField === valueField + ); + + if (valueFieldMapping) { + const targetField = valueFieldMapping.targetField || valueFieldMapping.targetColumn; + + if (targetField && formData[targetField] !== undefined) { + return formData[targetField]; + } + } + } + + return value; + }; + + const currentValue = getCurrentValue(); // selectedData 변경 시 ref도 업데이트 useEffect(() => { @@ -98,6 +161,79 @@ export function AutocompleteSearchInputComponent({ } }, []); + // 초기값이 있을 때 해당 값의 표시 텍스트를 조회하여 설정 + useEffect(() => { + const loadInitialDisplayValue = async () => { + // 이미 로드된 값이거나, 값이 없거나, 이미 선택된 데이터가 있으면 스킵 + if (!currentValue || selectedData || selectedDataRef.current) { + return; + } + + // 이미 같은 값을 로드한 적이 있으면 스킵 + if (initialValueLoadedRef.current === currentValue) { + return; + } + + // 테이블명과 필드 정보가 없으면 스킵 + if (!tableName || !valueField) { + return; + } + + console.log("🔄 AutocompleteSearchInput 초기값 로드:", { + currentValue, + tableName, + valueField, + displayFields, + }); + + try { + // API를 통해 해당 값의 표시 텍스트 조회 + const { apiClient } = await import("@/lib/api/client"); + const filterConditionWithValue = { + ...filterCondition, + [valueField]: currentValue, + }; + + const params = new URLSearchParams({ + searchText: "", + searchFields: searchFields.join(","), + filterCondition: JSON.stringify(filterConditionWithValue), + page: "1", + limit: "10", + }); + + const response = await apiClient.get<{ success: boolean; data: EntitySearchResult[] }>( + `/entity-search/${tableName}?${params.toString()}` + ); + + if (response.data.success && response.data.data && response.data.data.length > 0) { + const matchedItem = response.data.data.find((item: EntitySearchResult) => + String(item[valueField]) === String(currentValue) + ); + + if (matchedItem) { + const displayText = getDisplayValue(matchedItem); + console.log("✅ 초기값 표시 텍스트 로드 성공:", { + currentValue, + displayText, + matchedItem, + }); + + setSelectedData(matchedItem); + setInputValue(displayText); + selectedDataRef.current = matchedItem; + inputValueRef.current = displayText; + initialValueLoadedRef.current = currentValue; + } + } + } catch (error) { + console.error("❌ 초기값 표시 텍스트 로드 실패:", error); + } + }; + + loadInitialDisplayValue(); + }, [currentValue, tableName, valueField, displayFields, filterCondition, searchFields, selectedData]); + // value가 변경되면 표시값 업데이트 - 단, selectedData가 있으면 유지 useEffect(() => { // selectedData가 있으면 표시값 유지 (사용자가 방금 선택한 경우) @@ -107,6 +243,7 @@ export function AutocompleteSearchInputComponent({ if (!currentValue) { setInputValue(""); + initialValueLoadedRef.current = null; // 값이 없어지면 초기화 } }, [currentValue, selectedData]); diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index f311c035..a71f6e03 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -299,6 +299,20 @@ export const ButtonPrimaryComponent: React.FC = ({ // 🆕 modalDataStore에서 선택된 데이터 확인 (분할 패널 등에서 저장됨) const [modalStoreData, setModalStoreData] = useState>({}); + // 🆕 splitPanelContext?.selectedLeftData를 로컬 상태로 추적 (리렌더링 보장) + const [trackedSelectedLeftData, setTrackedSelectedLeftData] = useState | null>(null); + + // splitPanelContext?.selectedLeftData 변경 감지 및 로컬 상태 동기화 + useEffect(() => { + const newData = splitPanelContext?.selectedLeftData ?? null; + setTrackedSelectedLeftData(newData); + console.log("🔄 [ButtonPrimary] selectedLeftData 변경 감지:", { + label: component.label, + hasData: !!newData, + dataKeys: newData ? Object.keys(newData) : [], + }); + }, [splitPanelContext?.selectedLeftData, component.label]); + // modalDataStore 상태 구독 (실시간 업데이트) useEffect(() => { const actionConfig = component.componentConfig?.action; @@ -357,8 +371,8 @@ export const ButtonPrimaryComponent: React.FC = ({ // 2. 분할 패널 좌측 선택 데이터 확인 if (rowSelectionSource === "auto" || rowSelectionSource === "splitPanelLeft") { - // SplitPanelContext에서 확인 - if (splitPanelContext?.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0) { + // SplitPanelContext에서 확인 (trackedSelectedLeftData 사용으로 리렌더링 보장) + if (trackedSelectedLeftData && Object.keys(trackedSelectedLeftData).length > 0) { if (!hasSelection) { hasSelection = true; selectionCount = 1; @@ -397,7 +411,7 @@ export const ButtonPrimaryComponent: React.FC = ({ selectionCount, selectionSource, hasSplitPanelContext: !!splitPanelContext, - selectedLeftData: splitPanelContext?.selectedLeftData, + trackedSelectedLeftData: trackedSelectedLeftData, selectedRowsData: selectedRowsData?.length, selectedRows: selectedRows?.length, flowSelectedData: flowSelectedData?.length, @@ -429,7 +443,7 @@ export const ButtonPrimaryComponent: React.FC = ({ component.label, selectedRows, selectedRowsData, - splitPanelContext?.selectedLeftData, + trackedSelectedLeftData, flowSelectedData, splitPanelContext, modalStoreData, diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 7ac521af..7a787ed3 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -2043,7 +2043,7 @@ export const TableListComponent: React.FC = ({ return row.id || row.uuid || `row-${index}`; }; - const handleRowSelection = (rowKey: string, checked: boolean) => { + const handleRowSelection = (rowKey: string, checked: boolean, rowData?: any) => { const newSelectedRows = new Set(selectedRows); if (checked) { newSelectedRows.add(rowKey); @@ -2086,6 +2086,31 @@ export const TableListComponent: React.FC = ({ }); } + // 🆕 분할 패널 컨텍스트에 선택된 데이터 저장/해제 (체크박스 선택 시에도 작동) + const effectiveSplitPosition = splitPanelPosition || currentSplitPosition; + if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) { + if (checked && selectedRowsData.length > 0) { + // 선택된 경우: 첫 번째 선택된 데이터 저장 (또는 전달된 rowData) + const dataToStore = rowData || selectedRowsData[selectedRowsData.length - 1]; + splitPanelContext.setSelectedLeftData(dataToStore); + console.log("🔗 [TableList] handleRowSelection - 분할 패널 좌측 데이터 저장:", { + rowKey, + dataToStore, + }); + } else if (!checked && selectedRowsData.length === 0) { + // 모든 선택이 해제된 경우: 데이터 초기화 + splitPanelContext.setSelectedLeftData(null); + console.log("🔗 [TableList] handleRowSelection - 분할 패널 좌측 데이터 초기화"); + } else if (selectedRowsData.length > 0) { + // 일부 선택 해제된 경우: 남은 첫 번째 데이터로 업데이트 + splitPanelContext.setSelectedLeftData(selectedRowsData[0]); + console.log("🔗 [TableList] handleRowSelection - 분할 패널 좌측 데이터 업데이트:", { + remainingCount: selectedRowsData.length, + firstData: selectedRowsData[0], + }); + } + } + const allRowsSelected = filteredData.every((row, index) => newSelectedRows.has(getRowKey(row, index))); setIsAllSelected(allRowsSelected && filteredData.length > 0); }; @@ -2155,35 +2180,8 @@ export const TableListComponent: React.FC = ({ const rowKey = getRowKey(row, index); const isCurrentlySelected = selectedRows.has(rowKey); - handleRowSelection(rowKey, !isCurrentlySelected); - - // 🆕 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우) - // disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달) - // currentSplitPosition을 사용하여 정확한 위치 확인 (splitPanelPosition이 없을 수 있음) - const effectiveSplitPosition = splitPanelPosition || currentSplitPosition; - - console.log("🔗 [TableList] 행 클릭 - 분할 패널 위치 확인:", { - splitPanelPosition, - currentSplitPosition, - effectiveSplitPosition, - hasSplitPanelContext: !!splitPanelContext, - disableAutoDataTransfer: splitPanelContext?.disableAutoDataTransfer, - }); - - if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) { - if (!isCurrentlySelected) { - // 선택된 경우: 데이터 저장 - splitPanelContext.setSelectedLeftData(row); - console.log("🔗 [TableList] 분할 패널 좌측 데이터 저장:", { - row, - parentDataMapping: splitPanelContext.parentDataMapping, - }); - } else { - // 선택 해제된 경우: 데이터 초기화 - splitPanelContext.setSelectedLeftData(null); - console.log("🔗 [TableList] 분할 패널 좌측 데이터 초기화"); - } - } + // handleRowSelection에서 분할 패널 데이터 처리도 함께 수행됨 + handleRowSelection(rowKey, !isCurrentlySelected, row); console.log("행 클릭:", { row, index, isSelected: !isCurrentlySelected }); }; @@ -3918,7 +3916,7 @@ export const TableListComponent: React.FC = ({ if (enterRow) { const rowKey = getRowKey(enterRow, rowIndex); const isCurrentlySelected = selectedRows.has(rowKey); - handleRowSelection(rowKey, !isCurrentlySelected); + handleRowSelection(rowKey, !isCurrentlySelected, enterRow); } break; case " ": // Space @@ -3928,7 +3926,7 @@ export const TableListComponent: React.FC = ({ if (spaceRow) { const currentRowKey = getRowKey(spaceRow, rowIndex); const isChecked = selectedRows.has(currentRowKey); - handleRowSelection(currentRowKey, !isChecked); + handleRowSelection(currentRowKey, !isChecked, spaceRow); } break; case "F2": @@ -4142,7 +4140,7 @@ export const TableListComponent: React.FC = ({ return ( handleRowSelection(rowKey, checked as boolean)} + onCheckedChange={(checked) => handleRowSelection(rowKey, checked as boolean, row)} aria-label={`행 ${index + 1} 선택`} /> ); diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 9edf4054..5f087b71 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -216,6 +216,12 @@ export function UniversalFormModalComponent({ // 초기화 - 최초 마운트 시 또는 initialData가 변경되었을 때 실행 useEffect(() => { + console.log("[UniversalFormModal] useEffect 시작", { + initialData, + hasInitialized: hasInitialized.current, + lastInitializedId: lastInitializedId.current, + }); + // initialData에서 ID 값 추출 (id, ID, objid 등) const currentId = initialData?.id || initialData?.ID || initialData?.objid; const currentIdString = currentId !== undefined ? String(currentId) : undefined; @@ -229,9 +235,20 @@ export function UniversalFormModalComponent({ if (hasInitialized.current && lastInitializedId.current === currentIdString) { // 생성 모드에서 데이터가 새로 전달된 경우는 재초기화 필요 if (!createModeDataHash || capturedInitialData.current) { + console.log("[UniversalFormModal] 초기화 스킵 - 이미 초기화됨"); + // 🆕 채번 플래그가 true인데 formData에 값이 없으면 재생성 필요 + // (컴포넌트 remount로 인해 state가 초기화된 경우) return; } } + + // 🆕 컴포넌트 remount 감지: hasInitialized가 true인데 formData가 비어있으면 재초기화 + // (React의 Strict Mode나 EmbeddedScreen 리렌더링으로 인한 remount) + if (hasInitialized.current && !currentIdString) { + console.log("[UniversalFormModal] 컴포넌트 remount 감지 - 채번 플래그 초기화"); + numberingGeneratedRef.current = false; + isGeneratingRef.current = false; + } // 🆕 수정 모드: initialData에 데이터가 있으면서 ID가 변경된 경우 재초기화 if (hasInitialized.current && currentIdString && lastInitializedId.current !== currentIdString) { @@ -252,6 +269,7 @@ export function UniversalFormModalComponent({ console.log("[UniversalFormModal] 초기 데이터 캡처:", capturedInitialData.current); } + console.log("[UniversalFormModal] initializeForm 호출 예정"); hasInitialized.current = true; initializeForm(); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -389,6 +407,94 @@ export function UniversalFormModalComponent({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [config.sections]); + // 채번규칙 자동 생성 (중복 호출 방지) + // 중요: initializeForm에서 호출되므로 반드시 initializeForm보다 먼저 선언해야 함 + const numberingGeneratedRef = useRef(false); + const isGeneratingRef = useRef(false); // 진행 중 플래그 추가 + + const generateNumberingValues = useCallback( + async (currentFormData: FormDataState) => { + // 이미 생성되었거나 진행 중이면 스킵 + if (numberingGeneratedRef.current) { + console.log("[채번] 이미 생성됨 - 스킵"); + return; + } + + if (isGeneratingRef.current) { + console.log("[채번] 생성 진행 중 - 스킵"); + return; + } + + isGeneratingRef.current = true; // 진행 중 표시 + console.log("[채번] 생성 시작", { sectionsCount: config.sections.length }); + + const updatedData = { ...currentFormData }; + let hasChanges = false; + + for (const section of config.sections) { + console.log("[채번] 섹션 검사:", section.title, { type: section.type, repeatable: section.repeatable, fieldsCount: section.fields?.length }); + if (section.repeatable || section.type === "table") continue; + + for (const field of (section.fields || [])) { + // generateOnOpen은 기본값 true (undefined일 경우 true로 처리) + const shouldGenerateOnOpen = field.numberingRule?.generateOnOpen !== false; + console.log("[채번] 필드 검사:", field.columnName, { + hasNumberingRule: !!field.numberingRule, + enabled: field.numberingRule?.enabled, + generateOnOpen: field.numberingRule?.generateOnOpen, + shouldGenerateOnOpen, + ruleId: field.numberingRule?.ruleId, + currentValue: updatedData[field.columnName], + }); + if ( + field.numberingRule?.enabled && + shouldGenerateOnOpen && + field.numberingRule?.ruleId && + !updatedData[field.columnName] + ) { + try { + console.log(`[채번 미리보기 API 호출] ${field.columnName}, ruleId: ${field.numberingRule.ruleId}`); + // generateOnOpen: 미리보기만 표시 (DB 시퀀스 증가 안 함) + const response = await previewNumberingCode(field.numberingRule.ruleId); + if (response.success && response.data?.generatedCode) { + updatedData[field.columnName] = response.data.generatedCode; + + // 저장 시 실제 할당을 위해 ruleId 저장 (TextInput과 동일한 키 형식) + const ruleIdKey = `${field.columnName}_numberingRuleId`; + updatedData[ruleIdKey] = field.numberingRule.ruleId; + + hasChanges = true; + numberingGeneratedRef.current = true; // 생성 완료 표시 + console.log( + `[채번 미리보기 완료] ${field.columnName} = ${response.data.generatedCode} (저장 시 실제 할당)`, + ); + console.log(`[채번 규칙 ID 저장] ${ruleIdKey} = ${field.numberingRule.ruleId}`); + + // 부모 컴포넌트에도 ruleId 전달 (ModalRepeaterTable → ScreenModal) + if (onChange) { + onChange({ + ...updatedData, + [ruleIdKey]: field.numberingRule.ruleId, + }); + console.log(`[채번] 부모에게 ruleId 전달: ${ruleIdKey}`); + } + } + } catch (error) { + console.error(`채번규칙 미리보기 실패 (${field.columnName}):`, error); + } + } + } + } + + isGeneratingRef.current = false; // 진행 완료 + + if (hasChanges) { + setFormData(updatedData); + } + }, + [config, onChange], + ); + // 폼 초기화 const initializeForm = useCallback(async () => { console.log("[initializeForm] 시작"); @@ -585,82 +691,6 @@ export function UniversalFormModalComponent({ return item; }; - // 채번규칙 자동 생성 (중복 호출 방지) - const numberingGeneratedRef = useRef(false); - const isGeneratingRef = useRef(false); // 진행 중 플래그 추가 - - const generateNumberingValues = useCallback( - async (currentFormData: FormDataState) => { - // 이미 생성되었거나 진행 중이면 스킵 - if (numberingGeneratedRef.current) { - console.log("[채번] 이미 생성됨 - 스킵"); - return; - } - - if (isGeneratingRef.current) { - console.log("[채번] 생성 진행 중 - 스킵"); - return; - } - - isGeneratingRef.current = true; // 진행 중 표시 - console.log("[채번] 생성 시작"); - - const updatedData = { ...currentFormData }; - let hasChanges = false; - - for (const section of config.sections) { - if (section.repeatable || section.type === "table") continue; - - for (const field of (section.fields || [])) { - if ( - field.numberingRule?.enabled && - field.numberingRule?.generateOnOpen && - field.numberingRule?.ruleId && - !updatedData[field.columnName] - ) { - try { - console.log(`[채번 미리보기 API 호출] ${field.columnName}, ruleId: ${field.numberingRule.ruleId}`); - // generateOnOpen: 미리보기만 표시 (DB 시퀀스 증가 안 함) - const response = await previewNumberingCode(field.numberingRule.ruleId); - if (response.success && response.data?.generatedCode) { - updatedData[field.columnName] = response.data.generatedCode; - - // 저장 시 실제 할당을 위해 ruleId 저장 (TextInput과 동일한 키 형식) - const ruleIdKey = `${field.columnName}_numberingRuleId`; - updatedData[ruleIdKey] = field.numberingRule.ruleId; - - hasChanges = true; - numberingGeneratedRef.current = true; // 생성 완료 표시 - console.log( - `[채번 미리보기 완료] ${field.columnName} = ${response.data.generatedCode} (저장 시 실제 할당)`, - ); - console.log(`[채번 규칙 ID 저장] ${ruleIdKey} = ${field.numberingRule.ruleId}`); - - // 부모 컴포넌트에도 ruleId 전달 (ModalRepeaterTable → ScreenModal) - if (onChange) { - onChange({ - ...updatedData, - [ruleIdKey]: field.numberingRule.ruleId, - }); - console.log(`[채번] 부모에게 ruleId 전달: ${ruleIdKey}`); - } - } - } catch (error) { - console.error(`채번규칙 미리보기 실패 (${field.columnName}):`, error); - } - } - } - } - - isGeneratingRef.current = false; // 진행 완료 - - if (hasChanges) { - setFormData(updatedData); - } - }, - [config, onChange], - ); - // 필드 값 변경 핸들러 const handleFieldChange = useCallback( (columnName: string, value: any) => { diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 327cb87f..681e9a3f 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -995,6 +995,40 @@ export class ButtonActionExecutor { console.log("📋 [handleSave] 범용 폼 모달 공통 필드:", commonFields); } + // 🆕 루트 레벨 formData에서 RepeaterFieldGroup에 전달할 공통 필드 추출 + // 주문번호, 발주번호 등 마스터-디테일 관계에서 필요한 필드만 명시적으로 지정 + const masterDetailFields = [ + // 번호 필드 + "order_no", // 발주번호 + "sales_order_no", // 수주번호 + "shipment_no", // 출하번호 + "receipt_no", // 입고번호 + "work_order_no", // 작업지시번호 + // 거래처 필드 + "supplier_code", // 공급처 코드 + "supplier_name", // 공급처 이름 + "customer_code", // 고객 코드 + "customer_name", // 고객 이름 + // 날짜 필드 + "order_date", // 발주일 + "sales_date", // 수주일 + "shipment_date", // 출하일 + "receipt_date", // 입고일 + "due_date", // 납기일 + // 담당자/메모 필드 + "manager", // 담당자 + "memo", // 메모 + "remark", // 비고 + ]; + + for (const fieldName of masterDetailFields) { + const value = context.formData[fieldName]; + if (value !== undefined && value !== "" && value !== null && !(fieldName in commonFields)) { + commonFields[fieldName] = value; + } + } + console.log("📋 [handleSave] 최종 공통 필드 (마스터-디테일 필드 포함):", commonFields); + for (const item of parsedData) { // 메타 필드 제거 (eslint 경고 무시 - 의도적으로 분리) @@ -5931,6 +5965,69 @@ export class ButtonActionExecutor { return false; } + // ✅ allComponents가 있으면 기존 필수 항목 검증 수행 + if (context.allComponents && context.allComponents.length > 0) { + console.log("🔍 [handleQuickInsert] 필수 항목 검증 시작:", { + hasAllComponents: !!context.allComponents, + allComponentsLength: context.allComponents?.length || 0, + }); + const requiredValidation = this.validateRequiredFields(context); + if (!requiredValidation.isValid) { + console.log("❌ [handleQuickInsert] 필수 항목 누락:", requiredValidation.missingFields); + toast.error(`필수 항목을 입력해주세요: ${requiredValidation.missingFields.join(", ")}`); + return false; + } + console.log("✅ [handleQuickInsert] 필수 항목 검증 통과"); + } + + // ✅ quickInsert 전용 검증: component 타입 매핑에서 값이 비어있는지 확인 + const mappingsForValidation = quickInsertConfig.columnMappings || []; + const missingMappingFields: string[] = []; + + for (const mapping of mappingsForValidation) { + // component 타입 매핑은 필수 입력으로 간주 + if (mapping.sourceType === "component" && mapping.sourceComponentId) { + let value: any = undefined; + + // 값 가져오기 (formData에서) + if (mapping.sourceColumnName) { + value = context.formData?.[mapping.sourceColumnName]; + } + if (value === undefined || value === null) { + value = context.formData?.[mapping.sourceComponentId]; + } + // allComponents에서 컴포넌트 찾아서 columnName으로 시도 + if ((value === undefined || value === null) && context.allComponents) { + const comp = context.allComponents.find((c: any) => c.id === mapping.sourceComponentId); + if (comp?.columnName) { + value = context.formData?.[comp.columnName]; + } + } + // targetColumn으로 폴백 + if ((value === undefined || value === null) && mapping.targetColumn) { + value = context.formData?.[mapping.targetColumn]; + } + + // 값이 비어있으면 필수 누락으로 처리 + if (value === undefined || value === null || (typeof value === "string" && value.trim() === "")) { + console.log("❌ [handleQuickInsert] component 매핑 값 누락:", { + targetColumn: mapping.targetColumn, + sourceComponentId: mapping.sourceComponentId, + sourceColumnName: mapping.sourceColumnName, + value, + }); + missingMappingFields.push(mapping.targetColumn); + } + } + } + + if (missingMappingFields.length > 0) { + console.log("❌ [handleQuickInsert] 필수 입력 항목 누락:", missingMappingFields); + toast.error(`다음 항목을 입력해주세요: ${missingMappingFields.join(", ")}`); + return false; + } + console.log("✅ [handleQuickInsert] quickInsert 매핑 검증 통과"); + const { formData, splitPanelContext, userId, userName, companyCode } = context; console.log("⚡ Quick Insert 상세 정보:", { diff --git a/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md b/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md index f61ab2fb..1108475c 100644 --- a/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md +++ b/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md @@ -1689,3 +1689,4 @@ const 출고등록_설정: ScreenSplitPanel = { + diff --git a/화면_임베딩_시스템_Phase1-4_구현_완료.md b/화면_임베딩_시스템_Phase1-4_구현_완료.md index 0596216f..c20a94bc 100644 --- a/화면_임베딩_시스템_Phase1-4_구현_완료.md +++ b/화면_임베딩_시스템_Phase1-4_구현_완료.md @@ -536,3 +536,4 @@ const { data: config } = await getScreenSplitPanel(screenId); + diff --git a/화면_임베딩_시스템_충돌_분석_보고서.md b/화면_임베딩_시스템_충돌_분석_보고서.md index 4f0bfabb..77ad05b2 100644 --- a/화면_임베딩_시스템_충돌_분석_보고서.md +++ b/화면_임베딩_시스템_충돌_분석_보고서.md @@ -523,3 +523,4 @@ function ScreenViewPage() { +