From 109380b9e5ea1b19936c0cb2fce18b51518f9d59 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Mon, 15 Dec 2025 17:01:04 +0900 Subject: [PATCH 001/105] =?UTF-8?q?=EC=9D=B4=EC=A0=9C=20=EB=94=94=EB=B9=84?= =?UTF-8?q?=EC=97=90=20=ED=95=9C=EA=B8=80=EB=A1=9C=20=EC=B6=9C=EB=B0=9C?= =?UTF-8?q?=EC=A7=80=20=EB=AA=A9=EC=A0=81=EC=A7=80=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EB=90=98=EB=8F=84=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LocationSwapSelectorComponent.tsx | 6 +++--- .../lib/registry/components/location-swap-selector/index.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx b/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx index 7a693ad5..88e9002a 100644 --- a/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx +++ b/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx @@ -107,10 +107,10 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) const dbTableName = config.dbTableName || "vehicles"; const dbKeyField = config.dbKeyField || "user_id"; - // 기본 옵션 (포항/광양) + // 기본 옵션 (포항/광양) - 한글로 저장 const DEFAULT_OPTIONS: LocationOption[] = [ - { value: "pohang", label: "포항" }, - { value: "gwangyang", label: "광양" }, + { value: "포항", label: "포항" }, + { value: "광양", label: "광양" }, ]; // 상태 diff --git a/frontend/lib/registry/components/location-swap-selector/index.ts b/frontend/lib/registry/components/location-swap-selector/index.ts index c4c30418..7f7447cf 100644 --- a/frontend/lib/registry/components/location-swap-selector/index.ts +++ b/frontend/lib/registry/components/location-swap-selector/index.ts @@ -26,9 +26,9 @@ export const LocationSwapSelectorDefinition = createComponentDefinition({ labelField: "location_name", // 표시 필드 codeCategory: "", // 코드 관리 카테고리 (type이 "code"일 때) staticOptions: [ - { value: "pohang", label: "포항" }, - { value: "gwangyang", label: "광양" }, - ], // 정적 옵션 (type이 "static"일 때) + { value: "포항", label: "포항" }, + { value: "광양", label: "광양" }, + ], // 정적 옵션 (type이 "static"일 때) - 한글로 저장 }, // 필드 매핑 departureField: "departure", // 출발지 저장 필드 From 85519e302fd99de8fab4308ccf104a77c79e8d01 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 5 Jan 2026 13:54:41 +0900 Subject: [PATCH 002/105] =?UTF-8?q?=ED=96=89=EC=84=A0=ED=83=9D=EC=8B=9C?= =?UTF-8?q?=EC=97=90=EB=A7=8C=20=EB=B2=84=ED=8A=BC=20=ED=99=9C=EC=84=B1?= =?UTF-8?q?=ED=99=94=20=EA=B8=B0=EB=8A=A5=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../button-primary/ButtonPrimaryComponent.tsx | 22 +++++-- .../table-list/TableListComponent.tsx | 64 +++++++++---------- 2 files changed, 49 insertions(+), 37 deletions(-) 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} 선택`} /> ); From b3ee2b50e8ad0da804de0c1f3f2e9e28e7c10388 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 5 Jan 2026 18:41:49 +0900 Subject: [PATCH 003/105] =?UTF-8?q?fix:=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20Select=20=ED=95=84=EB=93=9C=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EC=8B=9C=20=EB=9D=BC=EB=B2=A8=EA=B0=92=20=EB=8C=80=EC=8B=A0=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EA=B0=92=20=EC=A0=80=EC=9E=A5=EB=90=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UniversalFormModalComponent.tsx: 카테고리 옵션 value를 valueLabel에서 valueCode로 변경 - 제어 로직 조건 비교 정상화 및 500 에러 해결 --- .../universal-form-modal/UniversalFormModalComponent.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 5f087b71..1484d4fd 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -866,9 +866,9 @@ export function UniversalFormModalComponent({ `/table-categories/${categoryTable}/${categoryColumn}/values` ); if (response.data?.success && response.data?.data) { - // 라벨값을 DB에 저장 (화면에 표시되는 값 그대로 저장) + // 코드값을 DB에 저장하고 라벨값을 화면에 표시 options = response.data.data.map((item: any) => ({ - value: item.valueLabel || item.value_label, + value: item.valueCode || item.value_code, label: item.valueLabel || item.value_label, })); } From 4f77c382077ca2a499f76d1badf8417dcacb79ad Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 6 Jan 2026 10:27:54 +0900 Subject: [PATCH 004/105] =?UTF-8?q?=EA=B6=8C=ED=95=9C=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=9E=84=EC=8B=9C=20=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/routes/cascadingAutoFillRoutes.ts | 1 + .../src/routes/cascadingConditionRoutes.ts | 1 + .../src/routes/cascadingHierarchyRoutes.ts | 1 + .../routes/cascadingMutualExclusionRoutes.ts | 1 + backend-node/src/services/adminService.ts | 18 ++++++++++++++++++ docs/노드플로우_개선사항.md | 1 + docs/메일발송_기능_사용_가이드.md | 1 + docs/즉시저장_버튼_액션_구현_계획서.md | 1 + .../admin/screenMng/screenMngList/page.tsx | 1 + frontend/contexts/ActiveTabContext.tsx | 1 + frontend/hooks/useAutoFill.ts | 1 + ..._임베딩_및_데이터_전달_시스템_구현_계획서.md | 1 + 화면_임베딩_시스템_Phase1-4_구현_완료.md | 1 + 화면_임베딩_시스템_충돌_분석_보고서.md | 1 + 14 files changed, 31 insertions(+) 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/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/contexts/ActiveTabContext.tsx b/frontend/contexts/ActiveTabContext.tsx index 228dc990..35081225 100644 --- a/frontend/contexts/ActiveTabContext.tsx +++ b/frontend/contexts/ActiveTabContext.tsx @@ -140,3 +140,4 @@ export const useActiveTabOptional = () => { + 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/화면_임베딩_및_데이터_전달_시스템_구현_계획서.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() { + From 6bfc1a97a32e481f2242b48083fe0e050e6e727c Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 6 Jan 2026 11:43:26 +0900 Subject: [PATCH 005/105] =?UTF-8?q?=EB=B2=94=EC=9A=A9=20=ED=8F=BC=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EC=82=AC=EC=A0=84=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/entitySearchController.ts | 86 ++- .../entity-search-input/useEntitySearch.ts | 6 + .../ItemSelectionModal.tsx | 76 +- .../components/modal-repeater-table/types.ts | 3 + .../TableSectionRenderer.tsx | 43 +- .../UniversalFormModalConfigPanel.tsx | 686 +++++++++--------- .../modals/TableSectionSettingsModal.tsx | 186 +++-- 7 files changed, 689 insertions(+), 397 deletions(-) diff --git a/backend-node/src/controllers/entitySearchController.ts b/backend-node/src/controllers/entitySearchController.ts index 4d911c57..5f198c3f 100644 --- a/backend-node/src/controllers/entitySearchController.ts +++ b/backend-node/src/controllers/entitySearchController.ts @@ -107,14 +107,88 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) { } // 추가 필터 조건 (존재하는 컬럼만) + // 지원 연산자: =, !=, >, <, >=, <=, in, notIn, like + // 특수 키 형식: column__operator (예: division__in, name__like) const additionalFilter = JSON.parse(filterCondition as string); for (const [key, value] of Object.entries(additionalFilter)) { - if (existingColumns.has(key)) { - whereConditions.push(`${key} = $${paramIndex}`); - params.push(value); - paramIndex++; - } else { - logger.warn("엔티티 검색: 필터 조건에 존재하지 않는 컬럼 제외", { tableName, key }); + // 특수 키 형식 파싱: column__operator + let columnName = key; + let operator = "="; + + if (key.includes("__")) { + const parts = key.split("__"); + columnName = parts[0]; + operator = parts[1] || "="; + } + + if (!existingColumns.has(columnName)) { + logger.warn("엔티티 검색: 필터 조건에 존재하지 않는 컬럼 제외", { tableName, key, columnName }); + continue; + } + + // 연산자별 WHERE 조건 생성 + switch (operator) { + case "=": + whereConditions.push(`"${columnName}" = $${paramIndex}`); + params.push(value); + paramIndex++; + break; + case "!=": + whereConditions.push(`"${columnName}" != $${paramIndex}`); + params.push(value); + paramIndex++; + break; + case ">": + whereConditions.push(`"${columnName}" > $${paramIndex}`); + params.push(value); + paramIndex++; + break; + case "<": + whereConditions.push(`"${columnName}" < $${paramIndex}`); + params.push(value); + paramIndex++; + break; + case ">=": + whereConditions.push(`"${columnName}" >= $${paramIndex}`); + params.push(value); + paramIndex++; + break; + case "<=": + whereConditions.push(`"${columnName}" <= $${paramIndex}`); + params.push(value); + paramIndex++; + break; + case "in": + // IN 연산자: 값이 배열이거나 쉼표로 구분된 문자열 + const inValues = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim()); + if (inValues.length > 0) { + const placeholders = inValues.map((_, i) => `$${paramIndex + i}`).join(", "); + whereConditions.push(`"${columnName}" IN (${placeholders})`); + params.push(...inValues); + paramIndex += inValues.length; + } + break; + case "notIn": + // NOT IN 연산자 + const notInValues = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim()); + if (notInValues.length > 0) { + const placeholders = notInValues.map((_, i) => `$${paramIndex + i}`).join(", "); + whereConditions.push(`"${columnName}" NOT IN (${placeholders})`); + params.push(...notInValues); + paramIndex += notInValues.length; + } + break; + case "like": + whereConditions.push(`"${columnName}"::text ILIKE $${paramIndex}`); + params.push(`%${value}%`); + paramIndex++; + break; + default: + // 알 수 없는 연산자는 등호로 처리 + whereConditions.push(`"${columnName}" = $${paramIndex}`); + params.push(value); + paramIndex++; + break; } } diff --git a/frontend/lib/registry/components/entity-search-input/useEntitySearch.ts b/frontend/lib/registry/components/entity-search-input/useEntitySearch.ts index 1fac26d6..2ae71595 100644 --- a/frontend/lib/registry/components/entity-search-input/useEntitySearch.ts +++ b/frontend/lib/registry/components/entity-search-input/useEntitySearch.ts @@ -53,6 +53,12 @@ export function useEntitySearch({ limit: pagination.limit.toString(), }); + console.log("[useEntitySearch] 검색 실행:", { + tableName, + filterCondition: filterConditionRef.current, + searchText: text, + }); + const response = await apiClient.get( `/entity-search/${tableName}?${params.toString()}` ); diff --git a/frontend/lib/registry/components/modal-repeater-table/ItemSelectionModal.tsx b/frontend/lib/registry/components/modal-repeater-table/ItemSelectionModal.tsx index ad73c317..1eca9fab 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ItemSelectionModal.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ItemSelectionModal.tsx @@ -32,6 +32,7 @@ export function ItemSelectionModal({ onSelect, columnLabels = {}, modalFilters = [], + categoryColumns = [], }: ItemSelectionModalProps) { const [localSearchText, setLocalSearchText] = useState(""); const [selectedItems, setSelectedItems] = useState([]); @@ -41,6 +42,9 @@ export function ItemSelectionModal({ // 카테고리 옵션 상태 (categoryRef별로 로드된 옵션) const [categoryOptions, setCategoryOptions] = useState>({}); + + // 카테고리 코드 → 라벨 매핑 (테이블 데이터 표시용) + const [categoryLabelMap, setCategoryLabelMap] = useState>({}); // 모달 필터 값과 기본 filterCondition을 합친 최종 필터 조건 const combinedFilterCondition = useMemo(() => { @@ -152,6 +156,54 @@ export function ItemSelectionModal({ } }, [modalFilterValues]); + // 검색 결과가 변경되면 카테고리 값들의 라벨 조회 + useEffect(() => { + const loadCategoryLabels = async () => { + if (!open || categoryColumns.length === 0 || results.length === 0) { + return; + } + + // 현재 결과에서 카테고리 컬럼의 모든 고유한 값 수집 + // 쉼표로 구분된 다중 값도 개별적으로 수집 + const allCodes = new Set(); + for (const row of results) { + for (const col of categoryColumns) { + const val = row[col]; + if (val && typeof val === "string") { + // 쉼표로 구분된 다중 값 처리 + const codes = val.split(",").map((c) => c.trim()).filter(Boolean); + for (const code of codes) { + if (!categoryLabelMap[code]) { + allCodes.add(code); + } + } + } + } + } + + if (allCodes.size === 0) { + return; + } + + try { + const response = await apiClient.post("/table-categories/labels-by-codes", { + valueCodes: Array.from(allCodes), + }); + + if (response.data?.success && response.data.data) { + setCategoryLabelMap((prev) => ({ + ...prev, + ...response.data.data, + })); + } + } catch (error) { + console.error("카테고리 라벨 조회 실패:", error); + } + }; + + loadCategoryLabels(); + }, [open, results, categoryColumns]); + // 모달 필터 값 변경 핸들러 const handleModalFilterChange = (column: string, value: any) => { setModalFilterValues((prev) => ({ @@ -450,11 +502,25 @@ export function ItemSelectionModal({ )} - {validColumns.map((col) => ( - - {item[col] || "-"} - - ))} + {validColumns.map((col) => { + const rawValue = item[col]; + // 카테고리 컬럼이면 라벨로 변환 + const isCategory = categoryColumns.includes(col); + let displayValue = rawValue; + + if (isCategory && rawValue && typeof rawValue === "string") { + // 쉼표로 구분된 다중 값 처리 + const codes = rawValue.split(",").map((c) => c.trim()).filter(Boolean); + const labels = codes.map((code) => categoryLabelMap[code] || code); + displayValue = labels.join(", "); + } + + return ( + + {displayValue || "-"} + + ); + })} ); }) diff --git a/frontend/lib/registry/components/modal-repeater-table/types.ts b/frontend/lib/registry/components/modal-repeater-table/types.ts index ad373200..ba23c60e 100644 --- a/frontend/lib/registry/components/modal-repeater-table/types.ts +++ b/frontend/lib/registry/components/modal-repeater-table/types.ts @@ -202,4 +202,7 @@ export interface ItemSelectionModalProps { // 모달 내부 필터 (사용자 선택 가능) modalFilters?: ModalFilterConfig[]; + + // 카테고리 타입 컬럼 목록 (해당 컬럼은 코드 → 라벨로 변환하여 표시) + categoryColumns?: string[]; } diff --git a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx index 405e2abf..fb1b2ea3 100644 --- a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx +++ b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx @@ -381,6 +381,34 @@ export function TableSectionRenderer({ const [dynamicOptions, setDynamicOptions] = useState<{ id: string; value: string; label: string }[]>([]); const [dynamicOptionsLoading, setDynamicOptionsLoading] = useState(false); const dynamicOptionsLoadedRef = React.useRef(false); + + // 소스 테이블의 카테고리 타입 컬럼 목록 + const [sourceCategoryColumns, setSourceCategoryColumns] = useState([]); + + // 소스 테이블의 카테고리 타입 컬럼 목록 로드 + useEffect(() => { + const loadCategoryColumns = async () => { + if (!tableConfig.source.tableName) return; + + try { + const response = await apiClient.get( + `/table-categories/${tableConfig.source.tableName}/columns` + ); + + if (response.data?.success && Array.isArray(response.data.data)) { + const categoryColNames = response.data.data.map( + (col: { columnName?: string; column_name?: string }) => + col.columnName || col.column_name || "" + ).filter(Boolean); + setSourceCategoryColumns(categoryColNames); + } + } catch (error) { + console.error("카테고리 컬럼 목록 조회 실패:", error); + } + }; + + loadCategoryColumns(); + }, [tableConfig.source.tableName]); // 조건부 테이블: 동적 옵션 로드 (optionSource 설정이 있는 경우) useEffect(() => { @@ -1281,16 +1309,25 @@ export function TableSectionRenderer({ const addRowButtonText = uiConfig?.addRowButtonText || "직접 입력"; // 기본 필터 조건 생성 (사전 필터만 - 모달 필터는 ItemSelectionModal에서 처리) + // 연산자별로 특수 키 형식 사용: column__operator (예: division__in) const baseFilterCondition: Record = useMemo(() => { const condition: Record = {}; if (filters?.preFilters) { for (const filter of filters.preFilters) { - // 간단한 "=" 연산자만 처리 (확장 가능) - if (filter.operator === "=") { + if (!filter.column || filter.value === undefined || filter.value === "") continue; + + const operator = filter.operator || "="; + + if (operator === "=") { + // 기본 등호 연산자는 그대로 전달 condition[filter.column] = filter.value; + } else { + // 다른 연산자는 특수 키 형식 사용: column__operator + condition[`${filter.column}__${operator}`] = filter.value; } } } + console.log("[TableSectionRenderer] baseFilterCondition:", condition, "preFilters:", filters?.preFilters); return condition; }, [filters?.preFilters]); @@ -1892,6 +1929,7 @@ export function TableSectionRenderer({ onSelect={handleConditionalAddItems} columnLabels={columnLabels} modalFilters={modalFiltersForModal} + categoryColumns={sourceCategoryColumns} /> ); @@ -2000,6 +2038,7 @@ export function TableSectionRenderer({ onSelect={handleAddItems} columnLabels={columnLabels} modalFilters={modalFiltersForModal} + categoryColumns={sourceCategoryColumns} /> ); diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx index 27af68f1..7186ca7e 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx @@ -9,17 +9,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; -import { - Plus, - Trash2, - GripVertical, - ChevronUp, - ChevronDown, - Settings, - Database, - Layout, - Table, -} from "lucide-react"; +import { Plus, Trash2, GripVertical, ChevronUp, ChevronDown, Settings, Database, Layout, Table } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { getNumberingRules } from "@/lib/api/numberingRule"; @@ -31,11 +21,7 @@ import { MODAL_SIZE_OPTIONS, SECTION_TYPE_OPTIONS, } from "./types"; -import { - defaultSectionConfig, - defaultTableSectionConfig, - generateSectionId, -} from "./config"; +import { defaultSectionConfig, defaultTableSectionConfig, generateSectionId } from "./config"; // 모달 import import { FieldDetailSettingsModal } from "./modals/FieldDetailSettingsModal"; @@ -45,22 +31,26 @@ import { TableSectionSettingsModal } from "./modals/TableSectionSettingsModal"; // 도움말 텍스트 컴포넌트 const HelpText = ({ children }: { children: React.ReactNode }) => ( -

{children}

+

{children}

); // 부모 화면에서 전달 가능한 필드 타입 interface AvailableParentField { - name: string; // 필드명 (columnName) - label: string; // 표시 라벨 + name: string; // 필드명 (columnName) + label: string; // 표시 라벨 sourceComponent?: string; // 출처 컴포넌트 (예: "TableList", "SplitPanelLayout2") - sourceTable?: string; // 출처 테이블명 + sourceTable?: string; // 출처 테이블명 } -export function UniversalFormModalConfigPanel({ config, onChange, allComponents = [] }: UniversalFormModalConfigPanelProps) { +export function UniversalFormModalConfigPanel({ + config, + onChange, + allComponents = [], +}: UniversalFormModalConfigPanelProps) { // 테이블 목록 const [tables, setTables] = useState<{ name: string; label: string }[]>([]); const [tableColumns, setTableColumns] = useState<{ - [tableName: string]: { name: string; type: string; label: string }[]; + [tableName: string]: { name: string; type: string; label: string; inputType?: string }[]; }>({}); // 부모 화면에서 전달 가능한 필드 목록 @@ -140,7 +130,7 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents } }); } - + // 좌측 패널 테이블 컬럼도 추출 const leftTableName = compConfig.leftPanel?.tableName; if (leftTableName) { @@ -152,7 +142,7 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents const colName = col.columnName || col.column_name; const colLabel = col.displayName || col.columnComment || col.column_comment || colName; // 중복 방지 - if (!fields.some(f => f.name === colName && f.sourceTable === leftTableName)) { + if (!fields.some((f) => f.name === colName && f.sourceTable === leftTableName)) { fields.push({ name: colName, label: colLabel, @@ -179,7 +169,7 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents columns.forEach((col: any) => { const colName = col.columnName || col.column_name; const colLabel = col.displayName || col.columnComment || col.column_comment || colName; - if (!fields.some(f => f.name === colName && f.sourceTable === tableName)) { + if (!fields.some((f) => f.name === colName && f.sourceTable === tableName)) { fields.push({ name: colName, label: colLabel, @@ -198,11 +188,11 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents // 4. 버튼 컴포넌트 - openModalWithData의 fieldMappings/dataMapping에서 소스 컬럼 추출 if (compType === "button-primary" || compType === "button" || compType === "button-secondary") { const action = compConfig.action || {}; - + // fieldMappings에서 소스 컬럼 추출 const fieldMappings = action.fieldMappings || []; fieldMappings.forEach((mapping: any) => { - if (mapping.sourceColumn && !fields.some(f => f.name === mapping.sourceColumn)) { + if (mapping.sourceColumn && !fields.some((f) => f.name === mapping.sourceColumn)) { fields.push({ name: mapping.sourceColumn, label: mapping.sourceColumn, @@ -211,11 +201,11 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents }); } }); - + // dataMapping에서 소스 컬럼 추출 const dataMapping = action.dataMapping || []; dataMapping.forEach((mapping: any) => { - if (mapping.sourceColumn && !fields.some(f => f.name === mapping.sourceColumn)) { + if (mapping.sourceColumn && !fields.some((f) => f.name === mapping.sourceColumn)) { fields.push({ name: mapping.sourceColumn, label: mapping.sourceColumn, @@ -237,7 +227,7 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents columns.forEach((col: any) => { const colName = col.columnName || col.column_name; const colLabel = col.displayName || col.columnComment || col.column_comment || colName; - if (!fields.some(f => f.name === colName)) { + if (!fields.some((f) => f.name === colName)) { fields.push({ name: colName, label: colLabel, @@ -253,8 +243,8 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents } // 중복 제거 (같은 name이면 첫 번째만 유지) - const uniqueFields = fields.filter((field, index, self) => - index === self.findIndex(f => f.name === field.name) + const uniqueFields = fields.filter( + (field, index, self) => index === self.findIndex((f) => f.name === field.name), ); setAvailableParentFields(uniqueFields); @@ -276,11 +266,19 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents const data = response.data?.data; if (response.data?.success && Array.isArray(data)) { setTables( - data.map((t: { tableName?: string; table_name?: string; displayName?: string; tableLabel?: string; table_label?: string }) => ({ - name: t.tableName || t.table_name || "", - // displayName 우선, 없으면 tableLabel, 그것도 없으면 테이블명 - label: t.displayName || t.tableLabel || t.table_label || "", - })), + data.map( + (t: { + tableName?: string; + table_name?: string; + displayName?: string; + tableLabel?: string; + table_label?: string; + }) => ({ + name: t.tableName || t.table_name || "", + // displayName 우선, 없으면 tableLabel, 그것도 없으면 테이블명 + label: t.displayName || t.tableLabel || t.table_label || "", + }), + ), ); } } catch (error) { @@ -308,10 +306,13 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents displayName?: string; columnComment?: string; column_comment?: string; + inputType?: string; + input_type?: string; }) => ({ name: c.columnName || c.column_name || "", type: c.dataType || c.data_type || "text", label: c.displayName || c.columnComment || c.column_comment || c.columnName || c.column_name || "", + inputType: c.inputType || c.input_type || "text", }), ), })); @@ -359,21 +360,24 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents ); // 섹션 관리 - const addSection = useCallback((type: "fields" | "table" = "fields") => { - const newSection: FormSectionConfig = { - ...defaultSectionConfig, - id: generateSectionId(), - title: type === "table" ? `테이블 섹션 ${config.sections.length + 1}` : `섹션 ${config.sections.length + 1}`, - type, - fields: type === "fields" ? [] : undefined, - tableConfig: type === "table" ? { ...defaultTableSectionConfig } : undefined, - }; - onChange({ - ...config, - sections: [...config.sections, newSection], - }); - }, [config, onChange]); - + const addSection = useCallback( + (type: "fields" | "table" = "fields") => { + const newSection: FormSectionConfig = { + ...defaultSectionConfig, + id: generateSectionId(), + title: type === "table" ? `테이블 섹션 ${config.sections.length + 1}` : `섹션 ${config.sections.length + 1}`, + type, + fields: type === "fields" ? [] : undefined, + tableConfig: type === "table" ? { ...defaultTableSectionConfig } : undefined, + }; + onChange({ + ...config, + sections: [...config.sections, newSection], + }); + }, + [config, onChange], + ); + // 섹션 타입 변경 const changeSectionType = useCallback( (sectionId: string, newType: "fields" | "table") => { @@ -381,7 +385,7 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents ...config, sections: config.sections.map((s) => { if (s.id !== sectionId) return s; - + if (newType === "table") { return { ...s, @@ -400,9 +404,9 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents }), }); }, - [config, onChange] + [config, onChange], ); - + // 테이블 섹션 설정 모달 열기 const handleOpenTableSectionSettings = (section: FormSectionConfig) => { setSelectedSection(section); @@ -487,293 +491,310 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents }; return ( -
-
-
- {/* 모달 기본 설정 */} - - - -
- - 모달 기본 설정 -
-
- -
- - updateModalConfig({ title: e.target.value })} - className="h-9 text-sm w-full max-w-full" - /> - 모달 상단에 표시될 제목입니다 -
- -
- - - 모달 창의 크기를 선택하세요 -
- - {/* 저장 버튼 표시 설정 */} -
-
- updateModalConfig({ showSaveButton: checked === true })} - /> - +
+
+
+ {/* 모달 기본 설정 */} + + + +
+ + 모달 기본 설정
- 체크 해제 시 모달 하단의 저장 버튼이 숨겨집니다 -
- -
+ +
- + updateModalConfig({ saveButtonText: e.target.value })} - className="h-9 text-sm w-full max-w-full" + value={config.modal.title} + onChange={(e) => updateModalConfig({ title: e.target.value })} + className="h-9 w-full max-w-full text-sm" /> + 모달 상단에 표시될 제목입니다
+
- - updateModalConfig({ cancelButtonText: e.target.value })} - className="h-9 text-sm w-full max-w-full" - /> + + + 모달 창의 크기를 선택하세요
-
- - - - {/* 저장 설정 */} - - - -
- - 저장 설정 -
-
- -
-
- -

- {config.saveConfig.tableName || "(미설정)"} -

- {config.saveConfig.customApiSave?.enabled && config.saveConfig.customApiSave?.multiTable?.enabled && ( - - 다중 테이블 모드 - - )} + {/* 저장 버튼 표시 설정 */} +
+
+ updateModalConfig({ showSaveButton: checked === true })} + /> + +
+ 체크 해제 시 모달 하단의 저장 버튼이 숨겨집니다
- -
- - 데이터를 저장할 테이블과 방식을 설정합니다. -
- "저장 설정 열기"를 클릭하여 상세 설정을 변경하세요. -
- - - - {/* 섹션 구성 */} - - - -
- - 섹션 구성 - - {config.sections.length}개 - -
-
- - {/* 섹션 추가 버튼들 */} -
- -