From 3c73c202927f7facc41f728564ab2b72e5872425 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 15 Dec 2025 14:51:41 +0900 Subject: [PATCH 1/9] =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EB=B3=B5=EC=82=AC?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= 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/menuCopyService.ts | 79 ++++++++++++++++--- docs/노드플로우_개선사항.md | 1 + docs/메일발송_기능_사용_가이드.md | 1 + frontend/hooks/useAutoFill.ts | 1 + ..._임베딩_및_데이터_전달_시스템_구현_계획서.md | 1 + 화면_임베딩_시스템_Phase1-4_구현_완료.md | 1 + 화면_임베딩_시스템_충돌_분석_보고서.md | 1 + 11 files changed, 77 insertions(+), 12 deletions(-) diff --git a/backend-node/src/routes/cascadingAutoFillRoutes.ts b/backend-node/src/routes/cascadingAutoFillRoutes.ts index de4eb913..7aa1d825 100644 --- a/backend-node/src/routes/cascadingAutoFillRoutes.ts +++ b/backend-node/src/routes/cascadingAutoFillRoutes.ts @@ -51,3 +51,4 @@ router.get("/data/:groupCode", getAutoFillData); export default router; + diff --git a/backend-node/src/routes/cascadingConditionRoutes.ts b/backend-node/src/routes/cascadingConditionRoutes.ts index c2f12782..5f57c6ca 100644 --- a/backend-node/src/routes/cascadingConditionRoutes.ts +++ b/backend-node/src/routes/cascadingConditionRoutes.ts @@ -47,3 +47,4 @@ router.get("/filtered-options/:relationCode", getFilteredOptions); export default router; + diff --git a/backend-node/src/routes/cascadingHierarchyRoutes.ts b/backend-node/src/routes/cascadingHierarchyRoutes.ts index 71e6c418..b0e3c79a 100644 --- a/backend-node/src/routes/cascadingHierarchyRoutes.ts +++ b/backend-node/src/routes/cascadingHierarchyRoutes.ts @@ -63,3 +63,4 @@ router.get("/:groupCode/options/:levelOrder", getLevelOptions); export default router; + diff --git a/backend-node/src/routes/cascadingMutualExclusionRoutes.ts b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts index d92d7d72..0cec35d2 100644 --- a/backend-node/src/routes/cascadingMutualExclusionRoutes.ts +++ b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts @@ -51,3 +51,4 @@ router.get("/options/:exclusionCode", getExcludedOptions); export default router; + diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index a0e707c1..b12d7a4a 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -332,6 +332,8 @@ export class MenuCopyService { /** * 플로우 수집 + * - 화면 레이아웃에서 참조된 모든 flowId 수집 + * - dataflowConfig.flowConfig.flowId 및 selectedDiagramId 모두 수집 */ private async collectFlows( screenIds: Set, @@ -340,6 +342,7 @@ export class MenuCopyService { logger.info(`🔄 플로우 수집 시작: ${screenIds.size}개 화면`); const flowIds = new Set(); + const flowDetails: Array<{ flowId: number; flowName: string; screenId: number }> = []; for (const screenId of screenIds) { const layoutsResult = await client.query( @@ -352,13 +355,35 @@ export class MenuCopyService { // webTypeConfig.dataflowConfig.flowConfig.flowId const flowId = props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId; - if (flowId) { - flowIds.add(flowId); + const flowName = props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowName || "Unknown"; + + if (flowId && typeof flowId === "number" && flowId > 0) { + if (!flowIds.has(flowId)) { + flowIds.add(flowId); + flowDetails.push({ flowId, flowName, screenId }); + logger.info(` 📎 화면 ${screenId}에서 플로우 발견: id=${flowId}, name="${flowName}"`); + } + } + + // selectedDiagramId도 확인 (flowId와 동일할 수 있지만 다를 수도 있음) + const selectedDiagramId = props?.webTypeConfig?.dataflowConfig?.selectedDiagramId; + if (selectedDiagramId && typeof selectedDiagramId === "number" && selectedDiagramId > 0) { + if (!flowIds.has(selectedDiagramId)) { + flowIds.add(selectedDiagramId); + flowDetails.push({ flowId: selectedDiagramId, flowName: "SelectedDiagram", screenId }); + logger.info(` 📎 화면 ${screenId}에서 selectedDiagramId 발견: id=${selectedDiagramId}`); + } } } } - logger.info(`✅ 플로우 수집 완료: ${flowIds.size}개`); + if (flowIds.size > 0) { + logger.info(`✅ 플로우 수집 완료: ${flowIds.size}개`); + logger.info(` 📋 수집된 flowIds: [${Array.from(flowIds).join(", ")}]`); + } else { + logger.info(`📭 수집된 플로우 없음 (화면에 플로우 참조가 없음)`); + } + return flowIds; } @@ -473,15 +498,21 @@ export class MenuCopyService { } } - // flowId 매핑 (숫자 또는 숫자 문자열) - if (key === "flowId") { + // flowId, selectedDiagramId 매핑 (숫자 또는 숫자 문자열) + // selectedDiagramId는 dataflowConfig에서 flowId와 동일한 값을 참조하므로 함께 변환 + if (key === "flowId" || key === "selectedDiagramId") { const numValue = typeof value === "number" ? value : parseInt(value); - if (!isNaN(numValue)) { + if (!isNaN(numValue) && numValue > 0) { const newId = flowIdMap.get(numValue); if (newId) { obj[key] = typeof value === "number" ? newId : String(newId); // 원래 타입 유지 - logger.debug( - ` 🔗 플로우 참조 업데이트 (${currentPath}): ${value} → ${newId}` + logger.info( + ` 🔗 플로우 참조 업데이트 (${currentPath}): ${value} → ${newId}` + ); + } else { + // 매핑이 없으면 경고 로그 + logger.warn( + ` ⚠️ 플로우 매핑 없음 (${currentPath}): ${value} - 원본 플로우가 복사되지 않았을 수 있음` ); } } @@ -742,6 +773,8 @@ export class MenuCopyService { /** * 플로우 복사 + * - 대상 회사에 같은 이름+테이블의 플로우가 있으면 재사용 (ID 매핑만) + * - 없으면 새로 복사 */ private async copyFlows( flowIds: Set, @@ -757,10 +790,11 @@ export class MenuCopyService { } logger.info(`🔄 플로우 복사 중: ${flowIds.size}개`); + logger.info(` 📋 복사 대상 flowIds: [${Array.from(flowIds).join(", ")}]`); for (const originalFlowId of flowIds) { try { - // 1) flow_definition 조회 + // 1) 원본 flow_definition 조회 const flowDefResult = await client.query( `SELECT * FROM flow_definition WHERE id = $1`, [originalFlowId] @@ -772,8 +806,29 @@ export class MenuCopyService { } const flowDef = flowDefResult.rows[0]; + logger.info(` 🔍 원본 플로우 발견: id=${originalFlowId}, name="${flowDef.name}", table="${flowDef.table_name}", company="${flowDef.company_code}"`); - // 2) flow_definition 복사 + // 2) 대상 회사에 이미 같은 이름+테이블의 플로우가 있는지 확인 + const existingFlowResult = await client.query<{ id: number }>( + `SELECT id FROM flow_definition + WHERE company_code = $1 AND name = $2 AND table_name = $3 + LIMIT 1`, + [targetCompanyCode, flowDef.name, flowDef.table_name] + ); + + let newFlowId: number; + + if (existingFlowResult.rows.length > 0) { + // 기존 플로우가 있으면 재사용 + newFlowId = existingFlowResult.rows[0].id; + flowIdMap.set(originalFlowId, newFlowId); + logger.info( + ` ♻️ 기존 플로우 재사용: ${originalFlowId} → ${newFlowId} (${flowDef.name})` + ); + continue; // 스텝/연결 복사 생략 (기존 것 사용) + } + + // 3) 새 flow_definition 복사 const newFlowResult = await client.query<{ id: number }>( `INSERT INTO flow_definition ( name, description, table_name, is_active, @@ -792,11 +847,11 @@ export class MenuCopyService { ] ); - const newFlowId = newFlowResult.rows[0].id; + newFlowId = newFlowResult.rows[0].id; flowIdMap.set(originalFlowId, newFlowId); logger.info( - ` ✅ 플로우 복사: ${originalFlowId} → ${newFlowId} (${flowDef.name})` + ` ✅ 플로우 신규 복사: ${originalFlowId} → ${newFlowId} (${flowDef.name})` ); // 3) flow_step 복사 diff --git a/docs/노드플로우_개선사항.md b/docs/노드플로우_개선사항.md index 985d730a..a181ac21 100644 --- a/docs/노드플로우_개선사항.md +++ b/docs/노드플로우_개선사항.md @@ -583,3 +583,4 @@ const result = await executeNodeFlow(flowId, { + diff --git a/docs/메일발송_기능_사용_가이드.md b/docs/메일발송_기능_사용_가이드.md index 285dc6ba..916fbc54 100644 --- a/docs/메일발송_기능_사용_가이드.md +++ b/docs/메일발송_기능_사용_가이드.md @@ -356,3 +356,4 @@ - [ ] 발송 버튼의 데이터 소스가 올바르게 설정되어 있는가? + diff --git a/frontend/hooks/useAutoFill.ts b/frontend/hooks/useAutoFill.ts index 835a4886..76243569 100644 --- a/frontend/hooks/useAutoFill.ts +++ b/frontend/hooks/useAutoFill.ts @@ -193,3 +193,4 @@ export function applyAutoFillToFormData( } + diff --git a/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md b/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md index 48bae8dd..baebafe2 100644 --- a/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md +++ b/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md @@ -1685,3 +1685,4 @@ const 출고등록_설정: ScreenSplitPanel = { + diff --git a/화면_임베딩_시스템_Phase1-4_구현_완료.md b/화면_임베딩_시스템_Phase1-4_구현_완료.md index 179cdd9d..3d4ac8db 100644 --- a/화면_임베딩_시스템_Phase1-4_구현_완료.md +++ b/화면_임베딩_시스템_Phase1-4_구현_완료.md @@ -532,3 +532,4 @@ const { data: config } = await getScreenSplitPanel(screenId); + diff --git a/화면_임베딩_시스템_충돌_분석_보고서.md b/화면_임베딩_시스템_충돌_분석_보고서.md index c5a9a585..5dad3e7d 100644 --- a/화면_임베딩_시스템_충돌_분석_보고서.md +++ b/화면_임베딩_시스템_충돌_분석_보고서.md @@ -519,3 +519,4 @@ function ScreenViewPage() { + From cb38864ad8cf724f919bd9d83400c463b125a65f Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 15 Dec 2025 18:29:18 +0900 Subject: [PATCH 2/9] =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EB=94=94=EC=8A=A4?= =?UTF-8?q?=ED=94=8C=EB=A0=88=EC=9D=B4=20=EC=82=AD=EC=A0=9C=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screen/InteractiveDataTable.tsx | 16 +- .../card-display/CardDisplayComponent.tsx | 137 +++++++++++++++++- .../card-display/CardDisplayConfigPanel.tsx | 13 ++ .../registry/components/card-display/types.ts | 1 + 4 files changed, 161 insertions(+), 6 deletions(-) diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index 1abc44bc..e44a356c 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -55,6 +55,7 @@ import { useScreenPreview } from "@/contexts/ScreenPreviewContext"; import { useTableOptions } from "@/contexts/TableOptionsContext"; import { TableFilter, ColumnVisibility } from "@/types/table-options"; import { useSplitPanelContext } from "@/contexts/SplitPanelContext"; +import { useScreenContextOptional } from "@/contexts/ScreenContext"; import { useCascadingDropdown } from "@/hooks/useCascadingDropdown"; import { CascadingDropdownConfig } from "@/types/screen-management"; @@ -184,6 +185,8 @@ export const InteractiveDataTable: React.FC = ({ const { user } = useAuth(); // 사용자 정보 가져오기 const { registerTable, unregisterTable } = useTableOptions(); // Context 훅 const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트 + const screenContext = useScreenContextOptional(); // 화면 컨텍스트 (좌측/우측 위치 확인용) + const splitPanelPosition = screenContext?.splitPanelPosition; // 분할 패널 내 위치 const [data, setData] = useState[]>([]); const [loading, setLoading] = useState(false); @@ -947,7 +950,18 @@ export const InteractiveDataTable: React.FC = ({ } return newSet; }); - }, []); + + // 분할 패널 좌측에서 선택 시 컨텍스트에 데이터 저장 (연결 필터용) + if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) { + if (isSelected && data[rowIndex]) { + splitPanelContext.setSelectedLeftData(data[rowIndex]); + console.log("🔗 [InteractiveDataTable] 좌측 선택 데이터 저장:", data[rowIndex]); + } else if (!isSelected) { + splitPanelContext.setSelectedLeftData(null); + console.log("🔗 [InteractiveDataTable] 좌측 선택 데이터 초기화"); + } + } + }, [data, splitPanelContext, splitPanelPosition]); // 전체 선택/해제 핸들러 const handleSelectAll = useCallback( diff --git a/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx b/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx index a3876188..54b32143 100644 --- a/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx +++ b/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx @@ -108,6 +108,65 @@ export const CardDisplayComponent: React.FC = ({ setEditModalOpen(true); }; + // 삭제 핸들러 + const handleCardDelete = async (data: any, index: number) => { + // 사용자 확인 + if (!confirm("정말로 이 항목을 삭제하시겠습니까?")) { + return; + } + + try { + const tableNameToUse = tableName || component.componentConfig?.tableName; + if (!tableNameToUse) { + alert("테이블 정보가 없습니다."); + return; + } + + // 삭제할 데이터를 배열로 감싸기 (API가 배열을 기대함) + const deleteData = [data]; + + console.log("🗑️ [CardDisplay] 삭제 요청:", { + tableName: tableNameToUse, + data: deleteData, + }); + + // API 호출로 데이터 삭제 (POST 방식으로 변경 - DELETE는 body 전달이 불안정) + // 백엔드 API는 DELETE /api/table-management/tables/:tableName/delete 이지만 + // axios에서 DELETE body 전달 문제가 있어 직접 request 설정 사용 + const response = await apiClient.request({ + method: 'DELETE', + url: `/table-management/tables/${tableNameToUse}/delete`, + data: deleteData, + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (response.data.success) { + console.log("삭제 완료:", response.data.data?.deletedCount || 1, "건"); + alert("삭제되었습니다."); + + // 로컬 상태에서 삭제된 항목 제거 + setLoadedTableData(prev => prev.filter((item, idx) => idx !== index)); + + // 선택된 항목이면 선택 해제 + const cardKey = getCardKey(data, index); + if (selectedRows.has(cardKey)) { + const newSelectedRows = new Set(selectedRows); + newSelectedRows.delete(cardKey); + setSelectedRows(newSelectedRows); + } + } else { + console.error("삭제 실패:", response.data.error); + alert(`삭제 실패: ${response.data.message || response.data.error || "알 수 없는 오류"}`); + } + } catch (error: any) { + console.error("삭제 중 오류 발생:", error); + const errorMessage = error.response?.data?.message || error.message || "알 수 없는 오류"; + alert(`삭제 중 오류가 발생했습니다: ${errorMessage}`); + } + }; + // 편집 폼 데이터 변경 핸들러 const handleEditFormChange = (key: string, value: string) => { setEditData((prev: any) => ({ @@ -155,15 +214,72 @@ export const CardDisplayComponent: React.FC = ({ return; } + // 연결 필터 확인 (분할 패널 내부일 때) + let linkedFilterValues: Record = {}; + let hasLinkedFiltersConfigured = false; + let hasSelectedLeftData = false; + + if (splitPanelContext) { + // 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지) + const linkedFiltersConfig = splitPanelContext.linkedFilters || []; + hasLinkedFiltersConfigured = linkedFiltersConfig.some( + (filter) => filter.targetColumn?.startsWith(tableNameToUse + ".") || + filter.targetColumn === tableNameToUse + ); + + // 좌측 데이터 선택 여부 확인 + hasSelectedLeftData = splitPanelContext.selectedLeftData && + Object.keys(splitPanelContext.selectedLeftData).length > 0; + + linkedFilterValues = splitPanelContext.getLinkedFilterValues(); + // 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서) + const tableSpecificFilters: Record = {}; + for (const [key, value] of Object.entries(linkedFilterValues)) { + // key가 "테이블명.컬럼명" 형식인 경우 + if (key.includes(".")) { + const [tblName, columnName] = key.split("."); + if (tblName === tableNameToUse) { + tableSpecificFilters[columnName] = value; + hasLinkedFiltersConfigured = true; + } + } else { + // 테이블명 없이 컬럼명만 있는 경우 그대로 사용 + tableSpecificFilters[key] = value; + } + } + linkedFilterValues = tableSpecificFilters; + + console.log("🎴 [CardDisplay] 연결 필터 확인:", { + tableNameToUse, + hasLinkedFiltersConfigured, + hasSelectedLeftData, + linkedFilterValues, + }); + } + + // 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우 빈 데이터 표시 + if (splitPanelContext && hasLinkedFiltersConfigured && !hasSelectedLeftData) { + console.log("🎴 [CardDisplay] 연결 필터 활성화됨 - 좌측 선택 대기"); + setLoadedTableData([]); + setLoading(false); + return; + } + try { setLoading(true); + // API 호출 파라미터에 연결 필터 추가 (search 객체 안에 넣어야 함) + const apiParams: Record = { + page: 1, + size: 50, // 카드 표시용으로 적당한 개수 + search: Object.keys(linkedFilterValues).length > 0 ? linkedFilterValues : undefined, + }; + + console.log("🎴 [CardDisplay] API 호출 파라미터:", apiParams); + // 테이블 데이터, 컬럼 정보, 입력 타입 정보를 병렬로 로드 const [dataResponse, columnsResponse, inputTypesResponse] = await Promise.all([ - tableTypeApi.getTableData(tableNameToUse, { - page: 1, - size: 50, // 카드 표시용으로 적당한 개수 - }), + tableTypeApi.getTableData(tableNameToUse, apiParams), tableTypeApi.getColumns(tableNameToUse), tableTypeApi.getColumnInputTypes(tableNameToUse), ]); @@ -232,7 +348,7 @@ export const CardDisplayComponent: React.FC = ({ }; loadTableData(); - }, [isDesignMode, tableName, component.componentConfig?.tableName]); + }, [isDesignMode, tableName, component.componentConfig?.tableName, splitPanelContext?.selectedLeftData]); // 컴포넌트 설정 (기본값 보장) const componentConfig = { @@ -957,6 +1073,17 @@ export const CardDisplayComponent: React.FC = ({ 편집 )} + {(componentConfig.cardStyle?.showDeleteButton ?? false) && ( + + )} )} diff --git a/frontend/lib/registry/components/card-display/CardDisplayConfigPanel.tsx b/frontend/lib/registry/components/card-display/CardDisplayConfigPanel.tsx index beff4783..52889865 100644 --- a/frontend/lib/registry/components/card-display/CardDisplayConfigPanel.tsx +++ b/frontend/lib/registry/components/card-display/CardDisplayConfigPanel.tsx @@ -306,6 +306,19 @@ export const CardDisplayConfigPanel: React.FC = ({ 편집 버튼 + +
+ handleNestedChange("cardStyle.showDeleteButton", e.target.checked)} + className="rounded border-gray-300" + /> + +
)} diff --git a/frontend/lib/registry/components/card-display/types.ts b/frontend/lib/registry/components/card-display/types.ts index 7154eb72..d4174453 100644 --- a/frontend/lib/registry/components/card-display/types.ts +++ b/frontend/lib/registry/components/card-display/types.ts @@ -16,6 +16,7 @@ export interface CardStyleConfig { showActions?: boolean; // 액션 버튼 표시 여부 (전체) showViewButton?: boolean; // 상세보기 버튼 표시 여부 showEditButton?: boolean; // 편집 버튼 표시 여부 + showDeleteButton?: boolean; // 삭제 버튼 표시 여부 } /** From 4e74c7b5ba80f01f0248945ec55185b524b7fb21 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 16 Dec 2025 10:46:43 +0900 Subject: [PATCH 3/9] =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EB=94=94=EC=8A=A4?= =?UTF-8?q?=ED=94=8C=EB=A0=88=EC=9D=B4=20=EB=B6=84=ED=95=A0=ED=8C=A8?= =?UTF-8?q?=EB=84=90=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/tableManagementService.ts | 94 ++++++++++++++++++- .../card-display/CardDisplayComponent.tsx | 8 +- .../table-list/TableListComponent.tsx | 11 ++- 3 files changed, 101 insertions(+), 12 deletions(-) diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 9a8623a0..e2f26138 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -1447,7 +1447,8 @@ export class TableManagementService { tableName, columnName, actualValue, - paramIndex + paramIndex, + operator // operator 전달 (equals면 직접 매칭) ); default: @@ -1676,7 +1677,8 @@ export class TableManagementService { tableName: string, columnName: string, value: any, - paramIndex: number + paramIndex: number, + operator: string = "contains" // 연결 필터에서 "equals"로 전달되면 직접 매칭 ): Promise<{ whereClause: string; values: any[]; @@ -1688,7 +1690,7 @@ export class TableManagementService { columnName ); - // 🆕 배열 처리: IN 절 사용 + // 배열 처리: IN 절 사용 if (Array.isArray(value)) { if (value.length === 0) { // 빈 배열이면 항상 false 조건 @@ -1720,13 +1722,35 @@ export class TableManagementService { } if (typeof value === "string" && value.trim() !== "") { - const displayColumn = entityTypeInfo.displayColumn || "name"; + // equals 연산자인 경우: 직접 값 매칭 (연결 필터에서 코드 값으로 필터링 시 사용) + if (operator === "equals") { + logger.info( + `🔍 [buildEntitySearchCondition] equals 연산자 - 직접 매칭: ${columnName} = ${value}` + ); + return { + whereClause: `${columnName} = $${paramIndex}`, + values: [value], + paramCount: 1, + }; + } + + // contains 연산자 (기본): 참조 테이블의 표시 컬럼으로 검색 const referenceColumn = entityTypeInfo.referenceColumn || "id"; + const referenceTable = entityTypeInfo.referenceTable; + + // displayColumn이 비어있거나 "none"이면 참조 테이블에서 자동 감지 (entityJoinService와 동일한 로직) + let displayColumn = entityTypeInfo.displayColumn; + if (!displayColumn || displayColumn === "none" || displayColumn === "") { + displayColumn = await this.findDisplayColumnForTable(referenceTable, referenceColumn); + logger.info( + `🔍 [buildEntitySearchCondition] displayColumn 자동 감지: ${referenceTable} -> ${displayColumn}` + ); + } // 참조 테이블의 표시 컬럼으로 검색 return { whereClause: `EXISTS ( - SELECT 1 FROM ${entityTypeInfo.referenceTable} ref + SELECT 1 FROM ${referenceTable} ref WHERE ref.${referenceColumn} = ${columnName} AND ref.${displayColumn} ILIKE $${paramIndex} )`, @@ -1754,6 +1778,66 @@ export class TableManagementService { } } + /** + * 참조 테이블에서 표시 컬럼 자동 감지 (entityJoinService와 동일한 우선순위) + * 우선순위: *_name > name > label/*_label > title > referenceColumn + */ + private async findDisplayColumnForTable( + tableName: string, + referenceColumn?: string + ): Promise { + try { + const result = await query<{ column_name: string }>( + `SELECT column_name + FROM information_schema.columns + WHERE table_name = $1 + AND table_schema = 'public' + ORDER BY ordinal_position`, + [tableName] + ); + + const allColumns = result.map((r) => r.column_name); + + // entityJoinService와 동일한 우선순위 + // 1. *_name 컬럼 (item_name, customer_name, process_name 등) - company_name 제외 + const nameColumn = allColumns.find( + (col) => col.endsWith("_name") && col !== "company_name" + ); + if (nameColumn) { + return nameColumn; + } + + // 2. name 컬럼 + if (allColumns.includes("name")) { + return "name"; + } + + // 3. label 또는 *_label 컬럼 + const labelColumn = allColumns.find( + (col) => col === "label" || col.endsWith("_label") + ); + if (labelColumn) { + return labelColumn; + } + + // 4. title 컬럼 + if (allColumns.includes("title")) { + return "title"; + } + + // 5. 참조 컬럼 (referenceColumn) + if (referenceColumn && allColumns.includes(referenceColumn)) { + return referenceColumn; + } + + // 6. 기본값: 첫 번째 비-id 컬럼 또는 id + return allColumns.find((col) => col !== "id") || "id"; + } catch (error) { + logger.error(`표시 컬럼 감지 실패: ${tableName}`, error); + return referenceColumn || "id"; // 오류 시 기본값 + } + } + /** * 불린 검색 조건 구성 */ diff --git a/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx b/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx index 54b32143..620715fd 100644 --- a/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx +++ b/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx @@ -233,18 +233,20 @@ export const CardDisplayComponent: React.FC = ({ linkedFilterValues = splitPanelContext.getLinkedFilterValues(); // 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서) + // 연결 필터는 코드 값이므로 정확한 매칭(equals)을 사용해야 함 const tableSpecificFilters: Record = {}; for (const [key, value] of Object.entries(linkedFilterValues)) { // key가 "테이블명.컬럼명" 형식인 경우 if (key.includes(".")) { const [tblName, columnName] = key.split("."); if (tblName === tableNameToUse) { - tableSpecificFilters[columnName] = value; + // 연결 필터는 코드 값이므로 equals 연산자 사용 + tableSpecificFilters[columnName] = { value, operator: "equals" }; hasLinkedFiltersConfigured = true; } } else { - // 테이블명 없이 컬럼명만 있는 경우 그대로 사용 - tableSpecificFilters[key] = value; + // 테이블명 없이 컬럼명만 있는 경우 그대로 사용 (equals) + tableSpecificFilters[key] = { value, operator: "equals" }; } } linkedFilterValues = tableSpecificFilters; diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 651675b8..20aafd7f 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -1526,16 +1526,18 @@ export const TableListComponent: React.FC = ({ console.log("🔗 [TableList] 연결 필터 원본:", allLinkedFilters); // 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서) + // 연결 필터는 코드 값이므로 정확한 매칭(equals)을 사용해야 함 for (const [key, value] of Object.entries(allLinkedFilters)) { if (key.includes(".")) { const [tableName, columnName] = key.split("."); if (tableName === tableConfig.selectedTable) { - linkedFilterValues[columnName] = value; + // 연결 필터는 코드 값이므로 equals 연산자 사용 + linkedFilterValues[columnName] = { value, operator: "equals" }; hasLinkedFiltersConfigured = true; // 이 테이블에 대한 필터가 있음 } } else { - // 테이블명 없이 컬럼명만 있는 경우 그대로 사용 - linkedFilterValues[key] = value; + // 테이블명 없이 컬럼명만 있는 경우 그대로 사용 (equals) + linkedFilterValues[key] = { value, operator: "equals" }; } } @@ -1560,7 +1562,8 @@ export const TableListComponent: React.FC = ({ // 현재 테이블에 동일한 컬럼이 있는지 확인 if (tableColumns.includes(colName)) { - linkedFilterValues[colName] = colValue; + // 자동 컬럼 매칭도 equals 연산자 사용 + linkedFilterValues[colName] = { value: colValue, operator: "equals" }; hasLinkedFiltersConfigured = true; console.log(`🔗 [TableList] 자동 컬럼 매칭: ${colName} = ${colValue}`); } From d8329d31e481496b12bed07f11eabcc1c6ff1d48 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 16 Dec 2025 11:49:10 +0900 Subject: [PATCH 4/9] =?UTF-8?q?=EC=9A=B0=EC=B8=A1=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=ED=95=84=ED=84=B0=EB=A7=81=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screen-embedding/EmbeddedScreen.tsx | 17 +- .../screen-embedding/ScreenSplitPanel.tsx | 17 -- .../components/screen/widgets/TabsWidget.tsx | 44 +--- frontend/contexts/SplitPanelContext.tsx | 10 - frontend/contexts/TableOptionsContext.tsx | 6 - .../lib/registry/DynamicComponentRenderer.tsx | 50 ----- .../button-primary/ButtonPrimaryComponent.tsx | 52 +---- .../card-display/CardDisplayComponent.tsx | 201 +++++++++++------- .../ScreenSplitPanelRenderer.tsx | 21 -- .../table-list/TableListComponent.tsx | 59 ----- .../table-search-widget/TableSearchWidget.tsx | 23 -- .../components/tabs/tabs-component.tsx | 6 - 12 files changed, 128 insertions(+), 378 deletions(-) diff --git a/frontend/components/screen-embedding/EmbeddedScreen.tsx b/frontend/components/screen-embedding/EmbeddedScreen.tsx index b0d39d22..12496310 100644 --- a/frontend/components/screen-embedding/EmbeddedScreen.tsx +++ b/frontend/components/screen-embedding/EmbeddedScreen.tsx @@ -76,7 +76,6 @@ export const EmbeddedScreen = forwardRef { - console.log("📝 [EmbeddedScreen] 필드 값 변경:", { fieldName, value }); setFormData((prev) => ({ ...prev, [fieldName]: value, @@ -88,10 +87,9 @@ export const EmbeddedScreen = forwardRef { if (initialFormData && Object.keys(initialFormData).length > 0) { - console.log("📝 [EmbeddedScreen] 초기 폼 데이터 설정:", initialFormData); setFormData(initialFormData); } }, [initialFormData]); @@ -135,12 +133,6 @@ export const EmbeddedScreen = forwardRef v + 1); // 🆕 버전 증가로 컴포넌트 강제 리렌더링 }, [position, splitPanelContext, selectedLeftData, layout]); @@ -160,13 +152,6 @@ export const EmbeddedScreen = forwardRef { - console.log("📐 [ScreenSplitPanel] splitRatio 동기화:", { configSplitRatio, currentSplitRatio: splitRatio }); setSplitRatio(configSplitRatio); }, [configSplitRatio]); diff --git a/frontend/components/screen/widgets/TabsWidget.tsx b/frontend/components/screen/widgets/TabsWidget.tsx index 200e2db3..7990a2a6 100644 --- a/frontend/components/screen/widgets/TabsWidget.tsx +++ b/frontend/components/screen/widgets/TabsWidget.tsx @@ -25,12 +25,6 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge persistSelection = false, } = component; - console.log("🎨 TabsWidget 렌더링:", { - componentId: component.id, - tabs, - tabsLength: tabs.length, - component, - }); const storageKey = `tabs-${component.id}-selected`; @@ -67,15 +61,7 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge // 초기 로드 시 선택된 탭의 화면 불러오기 useEffect(() => { const currentTab = visibleTabs.find((t) => t.id === selectedTab); - console.log("🔄 초기 탭 로드:", { - selectedTab, - currentTab, - hasScreenId: !!currentTab?.screenId, - screenId: currentTab?.screenId, - }); - if (currentTab && currentTab.screenId && !screenLayouts[currentTab.screenId]) { - console.log("📥 초기 화면 로딩 시작:", currentTab.screenId); loadScreenLayout(currentTab.screenId); } }, [selectedTab, visibleTabs]); @@ -83,26 +69,20 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge // 화면 레이아웃 로드 const loadScreenLayout = async (screenId: number) => { if (screenLayouts[screenId]) { - console.log("✅ 이미 로드된 화면:", screenId); return; // 이미 로드됨 } - console.log("📥 화면 레이아웃 로딩 시작:", screenId); setLoadingScreens((prev) => ({ ...prev, [screenId]: true })); try { const { apiClient } = await import("@/lib/api/client"); const response = await apiClient.get(`/screen-management/screens/${screenId}/layout`); - console.log("📦 API 응답:", { screenId, success: response.data.success, hasData: !!response.data.data }); if (response.data.success && response.data.data) { - console.log("✅ 화면 레이아웃 로드 완료:", screenId); setScreenLayouts((prev) => ({ ...prev, [screenId]: response.data.data })); - } else { - console.error("❌ 화면 레이아웃 로드 실패 - success false"); } } catch (error) { - console.error(`❌ 화면 레이아웃 로드 실패 ${screenId}:`, error); + console.error(`화면 레이아웃 로드 실패 ${screenId}:`, error); } finally { setLoadingScreens((prev) => ({ ...prev, [screenId]: false })); } @@ -110,10 +90,9 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge // 탭 변경 핸들러 const handleTabChange = (tabId: string) => { - console.log("🔄 탭 변경:", tabId); setSelectedTab(tabId); - // 🆕 마운트된 탭 목록에 추가 (한 번 마운트되면 유지) + // 마운트된 탭 목록에 추가 (한 번 마운트되면 유지) setMountedTabs(prev => { if (prev.has(tabId)) return prev; const newSet = new Set(prev); @@ -123,10 +102,7 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge // 해당 탭의 화면 로드 const tab = visibleTabs.find((t) => t.id === tabId); - console.log("🔍 선택된 탭 정보:", { tab, hasScreenId: !!tab?.screenId, screenId: tab?.screenId }); - if (tab && tab.screenId && !screenLayouts[tab.screenId]) { - console.log("📥 탭 변경 시 화면 로딩:", tab.screenId); loadScreenLayout(tab.screenId); } }; @@ -157,7 +133,6 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge }; if (visibleTabs.length === 0) { - console.log("⚠️ 보이는 탭이 없음"); return (

탭이 없습니다

@@ -165,13 +140,6 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge ); } - console.log("🎨 TabsWidget 최종 렌더링:", { - visibleTabsCount: visibleTabs.length, - selectedTab, - screenLayoutsKeys: Object.keys(screenLayouts), - loadingScreensKeys: Object.keys(loadingScreens), - }); - return (
| null) => { - logger.info(`[SplitPanelContext] 좌측 선택 데이터 설정:`, { - hasData: !!data, - dataKeys: data ? Object.keys(data) : [], - }); setSelectedLeftData(data); }, []); @@ -323,11 +319,6 @@ export function SplitPanelProvider({ } } - logger.info(`[SplitPanelContext] 매핑된 부모 데이터 (자동+명시적):`, { - autoMappedKeys: Object.keys(selectedLeftData), - explicitMappings: parentDataMapping.length, - finalKeys: Object.keys(mappedData), - }); return mappedData; }, [selectedLeftData, parentDataMapping]); @@ -350,7 +341,6 @@ export function SplitPanelProvider({ } } - logger.info(`[SplitPanelContext] 연결 필터 값:`, filterValues); return filterValues; }, [selectedLeftData, linkedFilters]); diff --git a/frontend/contexts/TableOptionsContext.tsx b/frontend/contexts/TableOptionsContext.tsx index 5f03a8e1..d706443f 100644 --- a/frontend/contexts/TableOptionsContext.tsx +++ b/frontend/contexts/TableOptionsContext.tsx @@ -83,14 +83,8 @@ export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({ const updatedTable = { ...table, dataCount: count }; const newMap = new Map(prev); newMap.set(tableId, updatedTable); - console.log("🔄 [TableOptionsContext] 데이터 건수 업데이트:", { - tableId, - count, - updated: true, - }); return newMap; } - console.warn("⚠️ [TableOptionsContext] 테이블을 찾을 수 없음:", tableId); return prev; }); }, []); diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index dc92c38a..74f15d2f 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -226,43 +226,6 @@ export const DynamicComponentRenderer: React.FC = // 1. 새 컴포넌트 시스템에서 먼저 조회 const newComponent = ComponentRegistry.getComponent(componentType); - // 🔍 디버깅: screen-split-panel 조회 결과 확인 - if (componentType === "screen-split-panel") { - console.log("🔍 [DynamicComponentRenderer] screen-split-panel 조회:", { - componentType, - found: !!newComponent, - componentId: component.id, - componentConfig: component.componentConfig, - hasFormData: !!props.formData, - formDataKeys: props.formData ? Object.keys(props.formData) : [], - registeredComponents: ComponentRegistry.getAllComponents().map(c => c.id), - }); - } - - // 🔍 디버깅: select-basic 조회 결과 확인 - if (componentType === "select-basic") { - console.log("🔍 [DynamicComponentRenderer] select-basic 조회:", { - componentType, - found: !!newComponent, - componentId: component.id, - componentConfig: component.componentConfig, - }); - } - - // 🔍 디버깅: text-input 컴포넌트 조회 결과 확인 - if (componentType === "text-input" || component.id?.includes("text") || (component as any).webType === "text") { - console.log("🔍 [DynamicComponentRenderer] text-input 조회:", { - componentType, - componentId: component.id, - componentLabel: component.label, - componentConfig: component.componentConfig, - webTypeConfig: (component as any).webTypeConfig, - autoGeneration: (component as any).autoGeneration, - found: !!newComponent, - registeredComponents: ComponentRegistry.getAllComponents().map(c => c.id), - }); - } - if (newComponent) { // 새 컴포넌트 시스템으로 렌더링 try { @@ -324,19 +287,6 @@ export const DynamicComponentRenderer: React.FC = currentValue = formData?.[fieldName] || ""; } - // 🆕 디버깅: text-input 값 추출 확인 - if (componentType === "text-input" && formData && Object.keys(formData).length > 0) { - console.log("🔍 [DynamicComponentRenderer] text-input 값 추출:", { - componentId: component.id, - componentLabel: component.label, - columnName: (component as any).columnName, - fieldName, - currentValue, - hasFormData: !!formData, - formDataKeys: Object.keys(formData).slice(0, 10), // 처음 10개만 - }); - } - // onChange 핸들러 - 컴포넌트 타입에 따라 다르게 처리 const handleChange = (value: any) => { // autocomplete-search-input, entity-search-input은 자체적으로 onFormDataChange를 호출하므로 중복 저장 방지 diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 160591c6..4a7ad7e9 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -388,16 +388,6 @@ export const ButtonPrimaryComponent: React.FC = ({ }; } - // 🔍 디버깅: processedConfig.action 확인 - console.log("[ButtonPrimaryComponent] processedConfig.action 생성 완료", { - actionType: processedConfig.action?.type, - enableDataflowControl: processedConfig.action?.enableDataflowControl, - dataflowTiming: processedConfig.action?.dataflowTiming, - dataflowConfig: processedConfig.action?.dataflowConfig, - webTypeConfigRaw: component.webTypeConfig, - componentText: component.text, - }); - // 스타일 계산 // height: 100%로 부모(RealtimePreviewDynamic의 내부 div)의 높이를 따라감 // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) @@ -839,10 +829,6 @@ export const ButtonPrimaryComponent: React.FC = ({ groupedData.length > 0 ) { effectiveSelectedRowsData = groupedData; - console.log("🔗 [ButtonPrimaryComponent] groupedData에서 부모창 데이터 가져옴:", { - count: groupedData.length, - data: groupedData, - }); } // modalDataStore에서 선택된 데이터 가져오기 (분할 패널 등에서 선택한 데이터) @@ -858,12 +844,6 @@ export const ButtonPrimaryComponent: React.FC = ({ // originalData가 있으면 그것을 사용, 없으면 item 자체 사용 (하위 호환성) return item.originalData || item; }); - console.log("🔗 [ButtonPrimaryComponent] modalDataStore에서 선택된 데이터 가져옴:", { - tableName: effectiveTableName, - count: modalData.length, - rawData: modalData, - extractedData: effectiveSelectedRowsData, - }); } } catch (error) { console.warn("modalDataStore 접근 실패:", error); @@ -928,17 +908,7 @@ export const ButtonPrimaryComponent: React.FC = ({ } } - // 🆕 디버깅: tableName 확인 - console.log("🔍 [ButtonPrimaryComponent] context 생성:", { - propsTableName: tableName, - contextTableName: screenContext?.tableName, - effectiveTableName, - propsScreenId: screenId, - contextScreenId: screenContext?.screenId, - effectiveScreenId, - }); - - // 🆕 분할 패널 부모 데이터 가져오기 (우측 화면에서 저장 시 좌측 선택 데이터 포함) + // 분할 패널 부모 데이터 가져오기 (우측 화면에서 저장 시 좌측 선택 데이터 포함) // 조건 완화: splitPanelContext가 있고 selectedLeftData가 있으면 가져옴 // (탭 안에서도 분할 패널 컨텍스트에 접근 가능하도록) let splitPanelParentData: Record | undefined; @@ -947,13 +917,6 @@ export const ButtonPrimaryComponent: React.FC = ({ // 좌측 화면이 아닌 경우에만 부모 데이터 포함 (좌측에서 저장 시 자신의 데이터를 부모로 포함하면 안됨) if (splitPanelPosition !== "left") { splitPanelParentData = splitPanelContext.getMappedParentData(); - if (Object.keys(splitPanelParentData).length > 0) { - console.log("🔗 [ButtonPrimaryComponent] 분할 패널 부모 데이터 포함:", { - splitPanelParentData, - splitPanelPosition, - isInTab: !splitPanelPosition, // splitPanelPosition이 없으면 탭 안 - }); - } } } @@ -966,22 +929,11 @@ export const ButtonPrimaryComponent: React.FC = ({ // (일반 폼 필드는 props.formData, RepeaterFieldGroup은 screenContext.formData에 있음) let effectiveFormData = { ...propsFormData, ...screenContextFormData }; - // 🆕 분할 패널 우측이고 formData가 비어있으면 splitPanelParentData 사용 + // 분할 패널 우측이고 formData가 비어있으면 splitPanelParentData 사용 if (splitPanelPosition === "right" && Object.keys(effectiveFormData).length === 0 && splitPanelParentData) { effectiveFormData = { ...splitPanelParentData }; - console.log("🔍 [ButtonPrimary] 분할 패널 우측 - splitPanelParentData 사용:", Object.keys(effectiveFormData)); } - console.log("🔍 [ButtonPrimary] formData 선택:", { - hasScreenContextFormData: Object.keys(screenContextFormData).length > 0, - screenContextKeys: Object.keys(screenContextFormData), - hasPropsFormData: Object.keys(propsFormData).length > 0, - propsFormDataKeys: Object.keys(propsFormData), - hasSplitPanelParentData: !!splitPanelParentData && Object.keys(splitPanelParentData).length > 0, - splitPanelPosition, - effectiveFormDataKeys: Object.keys(effectiveFormData), - }); - const context: ButtonActionContext = { formData: effectiveFormData, originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용) diff --git a/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx b/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx index 620715fd..db45531b 100644 --- a/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx +++ b/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx @@ -61,20 +61,17 @@ export const CardDisplayComponent: React.FC = ({ // 테이블 데이터 상태 관리 const [loadedTableData, setLoadedTableData] = useState([]); const [loadedTableColumns, setLoadedTableColumns] = useState([]); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(true); // 초기 로딩 상태를 true로 설정 + const [initialLoadDone, setInitialLoadDone] = useState(false); // 초기 로드 완료 여부 + const [hasEverSelectedLeftData, setHasEverSelectedLeftData] = useState(false); // 좌측 데이터 선택 이력 // 필터 상태 (검색 필터 위젯에서 전달받은 필터) const [filters, setFiltersInternal] = useState([]); - // 필터 상태 변경 래퍼 (로깅용) + // 필터 상태 변경 래퍼 const setFilters = useCallback((newFilters: TableFilter[]) => { - console.log("🎴 [CardDisplay] setFilters 호출됨:", { - componentId: component.id, - filtersCount: newFilters.length, - filters: newFilters, - }); setFiltersInternal(newFilters); - }, [component.id]); + }, []); // 카테고리 매핑 상태 (카테고리 코드 -> 라벨/색상) const [columnMeta, setColumnMeta] = useState< @@ -125,10 +122,6 @@ export const CardDisplayComponent: React.FC = ({ // 삭제할 데이터를 배열로 감싸기 (API가 배열을 기대함) const deleteData = [data]; - console.log("🗑️ [CardDisplay] 삭제 요청:", { - tableName: tableNameToUse, - data: deleteData, - }); // API 호출로 데이터 삭제 (POST 방식으로 변경 - DELETE는 body 전달이 불안정) // 백엔드 API는 DELETE /api/table-management/tables/:tableName/delete 이지만 @@ -143,7 +136,6 @@ export const CardDisplayComponent: React.FC = ({ }); if (response.data.success) { - console.log("삭제 완료:", response.data.data?.deletedCount || 1, "건"); alert("삭제되었습니다."); // 로컬 상태에서 삭제된 항목 제거 @@ -157,11 +149,9 @@ export const CardDisplayComponent: React.FC = ({ setSelectedRows(newSelectedRows); } } else { - console.error("삭제 실패:", response.data.error); alert(`삭제 실패: ${response.data.message || response.data.error || "알 수 없는 오류"}`); } } catch (error: any) { - console.error("삭제 중 오류 발생:", error); const errorMessage = error.response?.data?.message || error.message || "알 수 없는 오류"; alert(`삭제 중 오류가 발생했습니다: ${errorMessage}`); } @@ -194,8 +184,7 @@ export const CardDisplayComponent: React.FC = ({ // loadTableData(); } catch (error) { - console.error("❌ 편집 저장 실패:", error); - alert("❌ 저장에 실패했습니다."); + alert("저장에 실패했습니다."); } }; @@ -204,6 +193,25 @@ export const CardDisplayComponent: React.FC = ({ const loadTableData = async () => { // 디자인 모드에서는 테이블 데이터를 로드하지 않음 if (isDesignMode) { + setLoading(false); + setInitialLoadDone(true); + return; + } + + // 우측 패널인 경우, 좌측 데이터가 선택되지 않으면 데이터 로드하지 않음 (깜빡임 방지) + // splitPanelPosition이 "right"이면 분할 패널 내부이므로 연결 필터가 있을 가능성이 높음 + const isRightPanelEarly = splitPanelPosition === "right"; + const hasSelectedLeftDataEarly = splitPanelContext?.selectedLeftData && + Object.keys(splitPanelContext.selectedLeftData).length > 0; + + if (isRightPanelEarly && !hasSelectedLeftDataEarly) { + // 우측 패널이고 좌측 데이터가 선택되지 않은 경우 - 기존 데이터 유지 (깜빡임 방지) + // 초기 로드가 아닌 경우에는 데이터를 지우지 않음 + if (!initialLoadDone) { + setLoadedTableData([]); + } + setLoading(false); + setInitialLoadDone(true); return; } @@ -211,6 +219,8 @@ export const CardDisplayComponent: React.FC = ({ const tableNameToUse = tableName || component.componentConfig?.tableName || 'user_info'; // 기본 테이블명 설정 if (!tableNameToUse) { + setLoading(false); + setInitialLoadDone(true); return; } @@ -251,19 +261,23 @@ export const CardDisplayComponent: React.FC = ({ } linkedFilterValues = tableSpecificFilters; - console.log("🎴 [CardDisplay] 연결 필터 확인:", { - tableNameToUse, - hasLinkedFiltersConfigured, - hasSelectedLeftData, - linkedFilterValues, - }); } - // 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우 빈 데이터 표시 - if (splitPanelContext && hasLinkedFiltersConfigured && !hasSelectedLeftData) { - console.log("🎴 [CardDisplay] 연결 필터 활성화됨 - 좌측 선택 대기"); + // 우측 패널이고 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우 빈 데이터 표시 + // 또는 우측 패널이고 linkedFilters 설정이 있으면 좌측 선택 필수 + // splitPanelPosition은 screenContext에서 가져오거나, splitPanelContext에서 screenId로 확인 + const isRightPanelFromContext = splitPanelPosition === "right"; + const isRightPanelFromSplitContext = screenId && splitPanelContext?.getPositionByScreenId + ? splitPanelContext.getPositionByScreenId(screenId as number) === "right" + : false; + const isRightPanel = isRightPanelFromContext || isRightPanelFromSplitContext; + const hasLinkedFiltersInConfig = splitPanelContext?.linkedFilters && splitPanelContext.linkedFilters.length > 0; + + + if (isRightPanel && (hasLinkedFiltersConfigured || hasLinkedFiltersInConfig) && !hasSelectedLeftData) { setLoadedTableData([]); setLoading(false); + setInitialLoadDone(true); return; } @@ -277,7 +291,6 @@ export const CardDisplayComponent: React.FC = ({ search: Object.keys(linkedFilterValues).length > 0 ? linkedFilterValues : undefined, }; - console.log("🎴 [CardDisplay] API 호출 파라미터:", apiParams); // 테이블 데이터, 컬럼 정보, 입력 타입 정보를 병렬로 로드 const [dataResponse, columnsResponse, inputTypesResponse] = await Promise.all([ @@ -298,7 +311,6 @@ export const CardDisplayComponent: React.FC = ({ codeCategory: item.codeCategory || item.code_category, }; }); - console.log("📋 [CardDisplay] 컬럼 메타 정보:", meta); setColumnMeta(meta); // 카테고리 타입 컬럼 찾기 및 매핑 로드 @@ -306,17 +318,14 @@ export const CardDisplayComponent: React.FC = ({ .filter(([_, m]) => m.inputType === "category") .map(([columnName]) => columnName); - console.log("📋 [CardDisplay] 카테고리 컬럼:", categoryColumns); if (categoryColumns.length > 0) { const mappings: Record> = {}; for (const columnName of categoryColumns) { try { - console.log(`📋 [CardDisplay] 카테고리 매핑 로드 시작: ${tableNameToUse}/${columnName}`); const response = await apiClient.get(`/table-categories/${tableNameToUse}/${columnName}/values`); - console.log(`📋 [CardDisplay] 카테고리 API 응답 [${columnName}]:`, response.data); if (response.data.success && response.data.data) { const mapping: Record = {}; @@ -328,29 +337,27 @@ export const CardDisplayComponent: React.FC = ({ const rawColor = item.color ?? item.badge_color; const color = (rawColor && rawColor !== "none") ? rawColor : undefined; mapping[code] = { label, color }; - console.log(`📋 [CardDisplay] 매핑 추가: ${code} -> ${label} (color: ${color})`); }); mappings[columnName] = mapping; } } catch (error) { - console.error(`❌ CardDisplay: 카테고리 매핑 로드 실패 [${columnName}]`, error); + // 카테고리 매핑 로드 실패 시 무시 } } - console.log("📋 [CardDisplay] 최종 카테고리 매핑:", mappings); setCategoryMappings(mappings); } } catch (error) { - console.error(`❌ CardDisplay: 데이터 로딩 실패`, error); setLoadedTableData([]); setLoadedTableColumns([]); } finally { setLoading(false); + setInitialLoadDone(true); } }; loadTableData(); - }, [isDesignMode, tableName, component.componentConfig?.tableName, splitPanelContext?.selectedLeftData]); + }, [isDesignMode, tableName, component.componentConfig?.tableName, splitPanelContext?.selectedLeftData, splitPanelPosition]); // 컴포넌트 설정 (기본값 보장) const componentConfig = { @@ -390,8 +397,34 @@ export const CardDisplayComponent: React.FC = ({ componentStyle.borderColor = isSelected ? "hsl(var(--ring))" : "hsl(var(--border))"; } + // 우측 패널 + 좌측 미선택 상태 체크를 위한 값들 (displayData 외부에서 계산) + const isRightPanelForDisplay = splitPanelPosition === "right" || + (screenId && splitPanelContext?.getPositionByScreenId?.(screenId as number) === "right"); + const hasLinkedFiltersForDisplay = splitPanelContext?.linkedFilters && splitPanelContext.linkedFilters.length > 0; + const selectedLeftDataForDisplay = splitPanelContext?.selectedLeftData; + const hasSelectedLeftDataForDisplay = selectedLeftDataForDisplay && + Object.keys(selectedLeftDataForDisplay).length > 0; + + // 좌측 데이터가 한 번이라도 선택된 적이 있으면 기록 + useEffect(() => { + if (hasSelectedLeftDataForDisplay) { + setHasEverSelectedLeftData(true); + } + }, [hasSelectedLeftDataForDisplay]); + + // 우측 패널이고 연결 필터가 있고, 좌측 데이터가 한 번도 선택된 적이 없는 경우에만 "선택해주세요" 표시 + // 한 번이라도 선택된 적이 있으면 깜빡임 방지를 위해 기존 데이터 유지 + const shouldHideDataForRightPanel = isRightPanelForDisplay && + !hasEverSelectedLeftData && + !hasSelectedLeftDataForDisplay; + // 표시할 데이터 결정 (로드된 테이블 데이터 우선 사용) const displayData = useMemo(() => { + // 우측 패널이고 linkedFilters가 설정되어 있지만 좌측 데이터가 선택되지 않은 경우 빈 배열 반환 + if (shouldHideDataForRightPanel) { + return []; + } + // 로드된 테이블 데이터가 있으면 항상 우선 사용 (dataSource 설정 무시) if (loadedTableData.length > 0) { return loadedTableData; @@ -408,7 +441,7 @@ export const CardDisplayComponent: React.FC = ({ // 데이터가 없으면 빈 배열 반환 return []; - }, [componentConfig.dataSource, loadedTableData, tableData, componentConfig.staticData]); + }, [shouldHideDataForRightPanel, loadedTableData, tableData, componentConfig.staticData]); // 실제 사용할 테이블 컬럼 정보 (로드된 컬럼 우선 사용) const actualTableColumns = loadedTableColumns.length > 0 ? loadedTableColumns : tableColumns; @@ -453,13 +486,8 @@ export const CardDisplayComponent: React.FC = ({ additionalData: {}, })); useModalDataStore.getState().setData(tableNameToUse, modalItems); - console.log("[CardDisplay] modalDataStore에 데이터 저장:", { - dataSourceId: tableNameToUse, - count: modalItems.length, - }); } else if (tableNameToUse && selectedRowsData.length === 0) { useModalDataStore.getState().clearData(tableNameToUse); - console.log("[CardDisplay] modalDataStore 데이터 제거:", tableNameToUse); } // 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우) @@ -467,13 +495,8 @@ export const CardDisplayComponent: React.FC = ({ if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) { if (checked) { splitPanelContext.setSelectedLeftData(data); - console.log("[CardDisplay] 분할 패널 좌측 데이터 저장:", { - data, - parentDataMapping: splitPanelContext.parentDataMapping, - }); } else { splitPanelContext.setSelectedLeftData(null); - console.log("[CardDisplay] 분할 패널 좌측 데이터 초기화"); } } }, [displayData, getCardKey, onFormDataChange, componentConfig.dataSource?.tableName, tableName, splitPanelContext, splitPanelPosition]); @@ -540,21 +563,38 @@ export const CardDisplayComponent: React.FC = ({ }, [categoryMappings]); // 필터가 변경되면 데이터 다시 로드 (테이블 리스트와 동일한 패턴) - // 초기 로드 여부 추적 - const isInitialLoadRef = useRef(true); + // 초기 로드 여부 추적 - 마운트 카운터 사용 (Strict Mode 대응) + const mountCountRef = useRef(0); useEffect(() => { + mountCountRef.current += 1; + const currentMount = mountCountRef.current; + if (!tableNameToUse || isDesignMode) return; - // 초기 로드는 별도 useEffect에서 처리하므로 스킵 - if (isInitialLoadRef.current) { - isInitialLoadRef.current = false; + // 우측 패널이고 linkedFilters가 설정되어 있지만 좌측 데이터가 선택되지 않은 경우 스킵 + const isRightPanel = splitPanelPosition === "right" || + (screenId && splitPanelContext?.getPositionByScreenId?.(screenId as number) === "right"); + const hasLinkedFiltersInConfig = splitPanelContext?.linkedFilters && splitPanelContext.linkedFilters.length > 0; + const hasSelectedLeftData = splitPanelContext?.selectedLeftData && + Object.keys(splitPanelContext.selectedLeftData).length > 0; + + // 우측 패널이고 좌측 데이터가 선택되지 않은 경우 - 기존 데이터 유지 (깜빡임 방지) + if (isRightPanel && !hasSelectedLeftData) { + // 데이터를 지우지 않고 로딩만 false로 설정 + setLoading(false); + return; + } + + // 첫 2번의 마운트는 초기 로드 useEffect에서 처리 (Strict Mode에서 2번 호출됨) + // 필터 변경이 아닌 경우 스킵 + if (currentMount <= 2 && filters.length === 0) { return; } const loadFilteredData = async () => { try { - setLoading(true); + // 로딩 상태를 true로 설정하지 않음 - 기존 데이터 유지하면서 새 데이터 로드 (깜빡임 방지) // 필터 값을 검색 파라미터로 변환 const searchParams: Record = {}; @@ -564,12 +604,6 @@ export const CardDisplayComponent: React.FC = ({ } }); - console.log("🔍 [CardDisplay] 필터 적용 데이터 로드:", { - tableName: tableNameToUse, - filtersCount: filters.length, - searchParams, - }); - // search 파라미터로 검색 조건 전달 (API 스펙에 맞게) const dataResponse = await tableTypeApi.getTableData(tableNameToUse, { page: 1, @@ -584,16 +618,14 @@ export const CardDisplayComponent: React.FC = ({ tableOptionsContext.updateTableDataCount(tableId, dataResponse.data?.length || 0); } } catch (error) { - console.error("❌ [CardDisplay] 필터 적용 실패:", error); - } finally { - setLoading(false); + // 필터 적용 실패 시 무시 } }; // 필터 변경 시 항상 데이터 다시 로드 (빈 필터 = 전체 데이터) loadFilteredData(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filters, tableNameToUse, isDesignMode, tableId]); + }, [filters, tableNameToUse, isDesignMode, tableId, splitPanelContext?.selectedLeftData, splitPanelPosition]); // 컬럼 고유 값 조회 함수 (select 타입 필터용) const getColumnUniqueValues = useCallback(async (columnName: string): Promise> => { @@ -616,7 +648,6 @@ export const CardDisplayComponent: React.FC = ({ label: mapping?.[value]?.label || value, })); } catch (error) { - console.error(`❌ [CardDisplay] 고유 값 조회 실패: ${columnName}`, error); return []; } }, [tableNameToUse]); @@ -663,10 +694,6 @@ export const CardDisplayComponent: React.FC = ({ // onFilterChange는 ref를 통해 최신 함수를 호출하는 래퍼 사용 const onFilterChangeWrapper = (newFilters: TableFilter[]) => { - console.log("🎴 [CardDisplay] onFilterChange 래퍼 호출:", { - tableId, - filtersCount: newFilters.length, - }); setFiltersRef.current(newFilters); }; @@ -686,20 +713,12 @@ export const CardDisplayComponent: React.FC = ({ getColumnUniqueValues: getColumnUniqueValuesWrapper, }; - console.log("📋 [CardDisplay] TableOptionsContext에 등록:", { - tableId, - tableName: tableNameToUse, - columnsCount: columns.length, - dataCount: loadedTableData.length, - }); - registerTableRef.current(registration); const unregister = unregisterTableRef.current; const currentTableId = tableId; return () => { - console.log("📋 [CardDisplay] TableOptionsContext에서 해제:", currentTableId); unregister(currentTableId); }; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -711,8 +730,34 @@ export const CardDisplayComponent: React.FC = ({ columnsKey, // 컬럼 변경 시에만 재등록 ]); - // 로딩 중인 경우 로딩 표시 - if (loading) { + // 우측 패널이고 좌측 데이터가 한 번도 선택된 적이 없는 경우에만 "선택해주세요" 표시 + // 한 번이라도 선택된 적이 있으면 로딩 중에도 기존 데이터 유지 (깜빡임 방지) + if (shouldHideDataForRightPanel) { + return ( +
+
+
좌측에서 항목을 선택해주세요
+
선택한 항목의 관련 데이터가 여기에 표시됩니다
+
+
+ ); + } + + // 로딩 중이고 데이터가 없는 경우에만 로딩 표시 + // 데이터가 있으면 로딩 중에도 기존 데이터 유지 (깜빡임 방지) + if (loading && displayData.length === 0 && !hasEverSelectedLeftData) { return (
diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 20aafd7f..41a477ab 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -1268,18 +1268,9 @@ export const TableListComponent: React.FC = ({ }); } - console.log(`📡 [TableList] API 호출 시작 [${columnName}]:`, { - url: `/table-categories/${targetTable}/${targetColumn}/values`, - }); const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values`); - console.log(`📡 [TableList] API 응답 [${columnName}]:`, { - success: response.data.success, - dataLength: response.data.data?.length, - rawData: response.data, - items: response.data.data, - }); if (response.data.success && response.data.data && Array.isArray(response.data.data)) { const mapping: Record = {}; @@ -1291,18 +1282,11 @@ export const TableListComponent: React.FC = ({ label: item.valueLabel, color: item.color, }; - console.log(` 🔑 [${columnName}] "${key}" => "${item.valueLabel}" (색상: ${item.color})`); }); if (Object.keys(mapping).length > 0) { // 🆕 원래 컬럼명(item_info.material)으로 매핑 저장 mappings[columnName] = mapping; - console.log(`✅ [TableList] 카테고리 매핑 로드 완료 [${columnName}]:`, { - columnName, - mappingCount: Object.keys(mapping).length, - mappingKeys: Object.keys(mapping), - mapping, - }); } else { console.warn(`⚠️ [TableList] 매핑 데이터가 비어있음 [${columnName}]`); } @@ -1342,7 +1326,6 @@ export const TableListComponent: React.FC = ({ col.columnName, })) || []; - console.log("🔍 [TableList] additionalJoinInfo 컬럼:", additionalJoinColumns); // 조인 테이블별로 그룹화 const joinedTableColumns: Record = {}; @@ -1375,7 +1358,6 @@ export const TableListComponent: React.FC = ({ }); } - console.log("🔍 [TableList] 조인 테이블별 컬럼:", joinedTableColumns); // 조인된 테이블별로 inputType 정보 가져오기 const newJoinedColumnMeta: Record = {}; @@ -1421,9 +1403,6 @@ export const TableListComponent: React.FC = ({ if (Object.keys(mapping).length > 0) { mappings[col.columnName] = mapping; - console.log(`✅ [TableList] 조인 테이블 카테고리 매핑 로드 완료 [${col.columnName}]:`, { - mappingCount: Object.keys(mapping).length, - }); } } } catch (error) { @@ -1442,16 +1421,9 @@ export const TableListComponent: React.FC = ({ console.log("✅ [TableList] 조인 컬럼 메타데이터 설정:", newJoinedColumnMeta); } - console.log("📊 [TableList] 전체 카테고리 매핑 설정:", { - mappingsCount: Object.keys(mappings).length, - mappingsKeys: Object.keys(mappings), - mappings, - }); - if (Object.keys(mappings).length > 0) { setCategoryMappings(mappings); setCategoryMappingsKey((prev) => prev + 1); - console.log("✅ [TableList] setCategoryMappings 호출 완료"); } else { console.warn("⚠️ [TableList] 매핑이 비어있어 상태 업데이트 스킵"); } @@ -1473,11 +1445,6 @@ export const TableListComponent: React.FC = ({ // ======================================== const fetchTableDataInternal = useCallback(async () => { - console.log("📡 [TableList] fetchTableDataInternal 호출됨", { - tableName: tableConfig.selectedTable, - isDesignMode, - currentPage, - }); if (!tableConfig.selectedTable || isDesignMode) { setData([]); @@ -1501,13 +1468,6 @@ export const TableListComponent: React.FC = ({ let hasLinkedFiltersConfigured = false; // 연결 필터가 설정되어 있는지 여부 let hasSelectedLeftData = false; // 좌측에서 데이터가 선택되었는지 여부 - console.log("🔍 [TableList] 분할 패널 컨텍스트 확인:", { - hasSplitPanelContext: !!splitPanelContext, - tableName: tableConfig.selectedTable, - selectedLeftData: splitPanelContext?.selectedLeftData, - linkedFilters: splitPanelContext?.linkedFilters, - splitPanelPosition: splitPanelPosition, - }); if (splitPanelContext) { // 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지) @@ -1523,7 +1483,6 @@ export const TableListComponent: React.FC = ({ splitPanelContext.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0; const allLinkedFilters = splitPanelContext.getLinkedFilterValues(); - console.log("🔗 [TableList] 연결 필터 원본:", allLinkedFilters); // 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서) // 연결 필터는 코드 값이므로 정확한 매칭(equals)을 사용해야 함 @@ -1655,7 +1614,6 @@ export const TableListComponent: React.FC = ({ }; }); - console.log("🎯 [TableList] 화면별 엔티티 설정:", screenEntityConfigs); // 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외) let excludeFilterParam: any = undefined; @@ -2146,16 +2104,6 @@ export const TableListComponent: React.FC = ({ // 분할 패널 컨텍스트가 있고, 좌측 화면인 경우에만 행 선택 및 데이터 전달 const effectiveSplitPosition = splitPanelPosition || currentSplitPosition; - console.log("🔗 [TableList] 셀 클릭 - 분할 패널 위치 확인:", { - rowIndex, - colIndex, - splitPanelPosition, - currentSplitPosition, - effectiveSplitPosition, - hasSplitPanelContext: !!splitPanelContext, - isCurrentlySelected, - }); - if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) { // 이미 선택된 행과 다른 행을 클릭한 경우에만 처리 if (!isCurrentlySelected) { @@ -2165,10 +2113,6 @@ export const TableListComponent: React.FC = ({ // 분할 패널 컨텍스트에 데이터 저장 splitPanelContext.setSelectedLeftData(row); - console.log("🔗 [TableList] 셀 클릭으로 분할 패널 좌측 데이터 저장:", { - row, - parentDataMapping: splitPanelContext.parentDataMapping, - }); // onSelectedRowsChange 콜백 호출 if (onSelectedRowsChange) { @@ -2888,7 +2832,6 @@ export const TableListComponent: React.FC = ({ try { localStorage.setItem(tableStateKey, JSON.stringify(state)); - console.log("✅ 테이블 상태 저장:", tableStateKey); } catch (error) { console.error("❌ 테이블 상태 저장 실패:", error); } @@ -2930,7 +2873,6 @@ export const TableListComponent: React.FC = ({ setHeaderFilters(filters); } - console.log("✅ 테이블 상태 복원:", tableStateKey); } catch (error) { console.error("❌ 테이블 상태 복원 실패:", error); } @@ -2951,7 +2893,6 @@ export const TableListComponent: React.FC = ({ setShowGridLines(true); setHeaderFilters({}); toast.success("테이블 설정이 초기화되었습니다."); - console.log("✅ 테이블 상태 초기화:", tableStateKey); } catch (error) { console.error("❌ 테이블 상태 초기화 실패:", error); } diff --git a/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx b/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx index af16bcea..0dde5ea9 100644 --- a/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx +++ b/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx @@ -115,21 +115,9 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table // 필터링된 결과가 없으면 모든 테이블 반환 (폴백) if (filteredTables.length === 0) { - console.log("🔍 [TableSearchWidget] 대상 패널에 테이블 없음, 전체 테이블 사용:", { - targetPanelPosition, - allTablesCount: allTableList.length, - allTableIds: allTableList.map(t => t.tableId), - }); return allTableList; } - console.log("🔍 [TableSearchWidget] 테이블 필터링:", { - targetPanelPosition, - allTablesCount: allTableList.length, - filteredCount: filteredTables.length, - filteredTableIds: filteredTables.map(t => t.tableId), - }); - return filteredTables; }, [allTableList, targetPanelPosition]); @@ -159,11 +147,6 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table // 현재 선택된 테이블이 대상 패널에 없으면 대상 패널의 첫 번째 테이블 선택 if (!selectedTableId || !isCurrentTableInTarget) { const targetTable = tableList[0]; - console.log("🔍 [TableSearchWidget] 대상 패널 테이블 자동 선택:", { - targetPanelPosition, - selectedTableId: targetTable.tableId, - tableName: targetTable.tableName, - }); setSelectedTableId(targetTable.tableId); } }, [tableList, selectedTableId, autoSelectFirstTable, setSelectedTableId, targetPanelPosition]); @@ -374,12 +357,6 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table return true; }); - console.log("🔍 [TableSearchWidget] 필터 적용:", { - currentTableId: currentTable?.tableId, - currentTableName: currentTable?.tableName, - filtersCount: filtersWithValues.length, - filtersWithValues, - }); currentTable?.onFilterChange(filtersWithValues); }; diff --git a/frontend/lib/registry/components/tabs/tabs-component.tsx b/frontend/lib/registry/components/tabs/tabs-component.tsx index dc6ee110..654a22ef 100644 --- a/frontend/lib/registry/components/tabs/tabs-component.tsx +++ b/frontend/lib/registry/components/tabs/tabs-component.tsx @@ -23,12 +23,6 @@ const TabsWidgetWrapper: React.FC = (props) => { persistSelection: tabsConfig.persistSelection || false, }; - console.log("🎨 TabsWidget 렌더링:", { - componentId: component.id, - tabs: tabsComponent.tabs, - tabsLength: tabsComponent.tabs.length, - component, - }); // TabsWidget 동적 로드 const TabsWidget = require("@/components/screen/widgets/TabsWidget").TabsWidget; From f7e3c1924c58e57412d246b0dfb8d8e7025b6ae9 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 16 Dec 2025 14:38:03 +0900 Subject: [PATCH 5/9] =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=A6=89?= =?UTF-8?q?=EC=8B=9C=EC=A0=80=EC=9E=A5=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/screenManagementService.ts | 2 +- docs/즉시저장_버튼_액션_구현_계획서.md | 345 +++++++++ .../screen/InteractiveScreenViewerDynamic.tsx | 216 ++++++ .../config-panels/ButtonConfigPanel.tsx | 16 +- .../config-panels/EntityConfigPanel.tsx | 27 + .../QuickInsertConfigSection.tsx | 658 ++++++++++++++++++ .../lib/registry/DynamicComponentRenderer.tsx | 5 +- .../button-primary/ButtonPrimaryComponent.tsx | 5 + .../card-display/CardDisplayComponent.tsx | 19 +- .../EntitySearchInputComponent.tsx | 229 +++++- .../EntitySearchInputConfigPanel.tsx | 9 +- .../components/entity-search-input/config.ts | 2 +- .../components/entity-search-input/types.ts | 7 +- frontend/lib/utils/buttonActions.ts | 349 +++++++++- frontend/lib/utils/webTypeMapping.ts | 2 +- frontend/types/screen-management.ts | 107 +++ frontend/types/unified-core.ts | 5 +- 17 files changed, 1969 insertions(+), 34 deletions(-) create mode 100644 docs/즉시저장_버튼_액션_구현_계획서.md create mode 100644 frontend/components/screen/config-panels/QuickInsertConfigSection.tsx diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 9fc0f079..92a35663 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -1751,7 +1751,7 @@ export class ScreenManagementService { // 기타 label: "text-display", code: "select-basic", - entity: "select-basic", + entity: "entity-search-input", // 엔티티는 entity-search-input 사용 category: "select-basic", }; diff --git a/docs/즉시저장_버튼_액션_구현_계획서.md b/docs/즉시저장_버튼_액션_구현_계획서.md new file mode 100644 index 00000000..6ce86286 --- /dev/null +++ b/docs/즉시저장_버튼_액션_구현_계획서.md @@ -0,0 +1,345 @@ +# 즉시 저장(quickInsert) 버튼 액션 구현 계획서 + +## 1. 개요 + +### 1.1 목적 +화면에서 entity 타입 선택박스로 데이터를 선택한 후, 버튼 클릭 시 특정 테이블에 즉시 INSERT하는 기능 구현 + +### 1.2 사용 사례 +- **공정별 설비 관리**: 좌측에서 공정 선택 → 우측에서 설비 선택 → "설비 추가" 버튼 클릭 → `process_equipment` 테이블에 즉시 저장 + +### 1.3 화면 구성 예시 +``` +┌─────────────────────────────────────────────────────────────┐ +│ [entity 선택박스] [버튼: quickInsert] │ +│ ┌─────────────────────────────┐ ┌──────────────┐ │ +│ │ MCT-01 - 머시닝센터 #1 ▼ │ │ + 설비 추가 │ │ +│ └─────────────────────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. 기술 설계 + +### 2.1 버튼 액션 타입 추가 + +```typescript +// types/screen-management.ts +type ButtonActionType = + | "save" + | "cancel" + | "delete" + | "edit" + | "add" + | "search" + | "reset" + | "submit" + | "close" + | "popup" + | "navigate" + | "custom" + | "quickInsert" // 🆕 즉시 저장 +``` + +### 2.2 quickInsert 설정 구조 + +```typescript +interface QuickInsertColumnMapping { + targetColumn: string; // 저장할 테이블의 컬럼명 + sourceType: "component" | "leftPanel" | "fixed" | "currentUser"; + + // sourceType별 추가 설정 + sourceComponentId?: string; // component: 값을 가져올 컴포넌트 ID + sourceColumn?: string; // leftPanel: 좌측 선택 데이터의 컬럼명 + fixedValue?: any; // fixed: 고정값 + userField?: string; // currentUser: 사용자 정보 필드 (userId, userName, companyCode) +} + +interface QuickInsertConfig { + targetTable: string; // 저장할 테이블명 + columnMappings: QuickInsertColumnMapping[]; + + // 저장 후 동작 + afterInsert?: { + refreshRightPanel?: boolean; // 우측 패널 새로고침 + clearComponents?: string[]; // 초기화할 컴포넌트 ID 목록 + showSuccessMessage?: boolean; // 성공 메시지 표시 + successMessage?: string; // 커스텀 성공 메시지 + }; + + // 중복 체크 (선택사항) + duplicateCheck?: { + enabled: boolean; + columns: string[]; // 중복 체크할 컬럼들 + errorMessage?: string; // 중복 시 에러 메시지 + }; +} + +interface ButtonComponentConfig { + // 기존 설정들... + actionType: ButtonActionType; + + // 🆕 quickInsert 전용 설정 + quickInsertConfig?: QuickInsertConfig; +} +``` + +### 2.3 데이터 흐름 + +``` +1. 사용자가 entity 선택박스에서 설비 선택 + └─ equipment_code = "EQ-001" (내부값) + └─ 표시: "MCT-01 - 머시닝센터 #1" + +2. 사용자가 "설비 추가" 버튼 클릭 + +3. quickInsert 핸들러 실행 + ├─ columnMappings 순회 + │ ├─ equipment_code: component에서 값 가져오기 → "EQ-001" + │ └─ process_code: leftPanel에서 값 가져오기 → "PRC-001" + │ + └─ INSERT 데이터 구성 + { + equipment_code: "EQ-001", + process_code: "PRC-001", + company_code: "COMPANY_7", // 자동 추가 + writer: "wace" // 자동 추가 + } + +4. API 호출: POST /api/table-management/tables/process_equipment/add + +5. 성공 시 + ├─ 성공 메시지 표시 + ├─ 우측 패널(카드/테이블) 새로고침 + └─ 선택박스 초기화 +``` + +--- + +## 3. 구현 계획 + +### 3.1 Phase 1: 타입 정의 및 설정 UI + +| 작업 | 파일 | 설명 | +|------|------|------| +| 1-1 | `frontend/types/screen-management.ts` | QuickInsertConfig 타입 추가 | +| 1-2 | `frontend/components/screen/config-panels/ButtonConfigPanel.tsx` | quickInsert 설정 UI 추가 | + +### 3.2 Phase 2: 버튼 액션 핸들러 구현 + +| 작업 | 파일 | 설명 | +|------|------|------| +| 2-1 | `frontend/components/screen/InteractiveScreenViewerDynamic.tsx` | quickInsert 핸들러 추가 | +| 2-2 | 컴포넌트 값 수집 로직 | 같은 화면의 다른 컴포넌트에서 값 가져오기 | + +### 3.3 Phase 3: 테스트 및 검증 + +| 작업 | 설명 | +|------|------| +| 3-1 | 공정별 설비 화면에서 테스트 | +| 3-2 | 중복 저장 방지 테스트 | +| 3-3 | 에러 처리 테스트 | + +--- + +## 4. 상세 구현 + +### 4.1 ButtonConfigPanel 설정 UI + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 버튼 액션 타입 │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 즉시 저장 (quickInsert) ▼ │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ─────────────── 즉시 저장 설정 ─────────────── │ +│ │ +│ 대상 테이블 * │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ process_equipment ▼ │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ 컬럼 매핑 [+ 추가] │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 매핑 #1 [삭제] │ │ +│ │ 대상 컬럼: equipment_code │ │ +│ │ 값 소스: 컴포넌트 선택 │ │ +│ │ 컴포넌트: [equipment-select ▼] │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 매핑 #2 [삭제] │ │ +│ │ 대상 컬럼: process_code │ │ +│ │ 값 소스: 좌측 패널 데이터 │ │ +│ │ 소스 컬럼: process_code │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ─────────────── 저장 후 동작 ─────────────── │ +│ │ +│ ☑ 우측 패널 새로고침 │ +│ ☑ 선택박스 초기화 │ +│ ☑ 성공 메시지 표시 │ +│ │ +│ ─────────────── 중복 체크 (선택) ─────────────── │ +│ │ +│ ☐ 중복 체크 활성화 │ +│ 체크 컬럼: equipment_code, process_code │ +│ 에러 메시지: 이미 등록된 설비입니다. │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 4.2 핸들러 구현 (의사 코드) + +```typescript +const handleQuickInsert = async (config: QuickInsertConfig) => { + // 1. 컬럼 매핑에서 값 수집 + const insertData: Record = {}; + + for (const mapping of config.columnMappings) { + let value: any; + + switch (mapping.sourceType) { + case "component": + // 같은 화면의 컴포넌트에서 값 가져오기 + value = getComponentValue(mapping.sourceComponentId); + break; + + case "leftPanel": + // 분할 패널 좌측 선택 데이터에서 값 가져오기 + value = splitPanelContext?.selectedLeftData?.[mapping.sourceColumn]; + break; + + case "fixed": + value = mapping.fixedValue; + break; + + case "currentUser": + value = user?.[mapping.userField]; + break; + } + + if (value !== undefined && value !== null && value !== "") { + insertData[mapping.targetColumn] = value; + } + } + + // 2. 필수값 검증 + if (Object.keys(insertData).length === 0) { + toast.error("저장할 데이터가 없습니다."); + return; + } + + // 3. 중복 체크 (설정된 경우) + if (config.duplicateCheck?.enabled) { + const isDuplicate = await checkDuplicate( + config.targetTable, + config.duplicateCheck.columns, + insertData + ); + if (isDuplicate) { + toast.error(config.duplicateCheck.errorMessage || "이미 존재하는 데이터입니다."); + return; + } + } + + // 4. API 호출 + try { + await tableTypeApi.addTableData(config.targetTable, insertData); + + // 5. 성공 후 동작 + if (config.afterInsert?.showSuccessMessage) { + toast.success(config.afterInsert.successMessage || "저장되었습니다."); + } + + if (config.afterInsert?.refreshRightPanel) { + // 우측 패널 새로고침 트리거 + onRefresh?.(); + } + + if (config.afterInsert?.clearComponents) { + // 지정된 컴포넌트 초기화 + for (const componentId of config.afterInsert.clearComponents) { + clearComponentValue(componentId); + } + } + + } catch (error) { + toast.error("저장에 실패했습니다."); + } +}; +``` + +--- + +## 5. 컴포넌트 간 통신 방안 + +### 5.1 문제점 +- 버튼 컴포넌트에서 같은 화면의 entity 선택박스 값을 가져와야 함 +- 현재는 각 컴포넌트가 독립적으로 동작 + +### 5.2 해결 방안: formData 활용 + +현재 `InteractiveScreenViewerDynamic`에서 `formData` 상태로 모든 입력값을 관리하고 있음. + +```typescript +// InteractiveScreenViewerDynamic.tsx +const [localFormData, setLocalFormData] = useState>({}); + +// entity 선택박스에서 값 변경 시 +const handleFormDataChange = (fieldName: string, value: any) => { + setLocalFormData(prev => ({ ...prev, [fieldName]: value })); +}; + +// 버튼 클릭 시 formData에서 값 가져오기 +const getComponentValue = (componentId: string) => { + // componentId로 컴포넌트의 columnName 찾기 + const component = allComponents.find(c => c.id === componentId); + if (component?.columnName) { + return formData[component.columnName]; + } + return undefined; +}; +``` + +--- + +## 6. 테스트 시나리오 + +### 6.1 정상 케이스 +1. 좌측 테이블에서 공정 "PRC-001" 선택 +2. 우측 설비 선택박스에서 "MCT-01" 선택 +3. "설비 추가" 버튼 클릭 +4. `process_equipment` 테이블에 데이터 저장 확인 +5. 우측 카드/테이블에 새 항목 표시 확인 + +### 6.2 에러 케이스 +1. 좌측 미선택 상태에서 버튼 클릭 → "좌측에서 항목을 선택해주세요" 메시지 +2. 설비 미선택 상태에서 버튼 클릭 → "설비를 선택해주세요" 메시지 +3. 중복 데이터 저장 시도 → "이미 등록된 설비입니다" 메시지 + +### 6.3 엣지 케이스 +1. 동일 설비 연속 추가 시도 +2. 네트워크 오류 시 재시도 +3. 권한 없는 사용자의 저장 시도 + +--- + +## 7. 일정 + +| Phase | 작업 | 예상 시간 | +|-------|------|----------| +| Phase 1 | 타입 정의 및 설정 UI | 1시간 | +| Phase 2 | 버튼 액션 핸들러 구현 | 1시간 | +| Phase 3 | 테스트 및 검증 | 30분 | +| **합계** | | **2시간 30분** | + +--- + +## 8. 향후 확장 가능성 + +1. **다중 행 추가**: 여러 설비를 한 번에 선택하여 추가 +2. **수정 모드**: 기존 데이터 수정 기능 +3. **조건부 저장**: 특정 조건 만족 시에만 저장 +4. **연쇄 저장**: 한 번의 클릭으로 여러 테이블에 저장 + diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index 97dc0734..5b09b092 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -584,6 +584,219 @@ export const InteractiveScreenViewerDynamic: React.FC { + // componentConfig에서 quickInsertConfig 가져오기 + const quickInsertConfig = (comp as any).componentConfig?.action?.quickInsertConfig; + + if (!quickInsertConfig?.targetTable) { + toast.error("대상 테이블이 설정되지 않았습니다."); + return; + } + + // 1. 대상 테이블의 컬럼 목록 조회 (자동 매핑용) + let targetTableColumns: string[] = []; + try { + const { default: apiClient } = await import("@/lib/api/client"); + const columnsResponse = await apiClient.get( + `/table-management/tables/${quickInsertConfig.targetTable}/columns` + ); + if (columnsResponse.data?.success && columnsResponse.data?.data) { + const columnsData = columnsResponse.data.data.columns || columnsResponse.data.data; + targetTableColumns = columnsData.map((col: any) => col.columnName || col.column_name || col.name); + console.log("📍 대상 테이블 컬럼 목록:", targetTableColumns); + } + } catch (error) { + console.error("대상 테이블 컬럼 조회 실패:", error); + } + + // 2. 컬럼 매핑에서 값 수집 + const insertData: Record = {}; + const columnMappings = quickInsertConfig.columnMappings || []; + + for (const mapping of columnMappings) { + let value: any; + + switch (mapping.sourceType) { + case "component": + // 같은 화면의 컴포넌트에서 값 가져오기 + // 방법1: sourceColumnName 사용 + if (mapping.sourceColumnName && formData[mapping.sourceColumnName] !== undefined) { + value = formData[mapping.sourceColumnName]; + console.log(`📍 컴포넌트 값 (sourceColumnName): ${mapping.sourceColumnName} = ${value}`); + } + // 방법2: sourceComponentId로 컴포넌트 찾아서 columnName 사용 + else if (mapping.sourceComponentId) { + const sourceComp = allComponents.find((c: any) => c.id === mapping.sourceComponentId); + if (sourceComp) { + const fieldName = (sourceComp as any).columnName || sourceComp.id; + value = formData[fieldName]; + console.log(`📍 컴포넌트 값 (컴포넌트 조회): ${fieldName} = ${value}`); + } + } + break; + + case "leftPanel": + // 분할 패널 좌측 선택 데이터에서 값 가져오기 + if (mapping.sourceColumn && splitPanelContext?.selectedLeftData) { + value = splitPanelContext.selectedLeftData[mapping.sourceColumn]; + } + break; + + case "fixed": + value = mapping.fixedValue; + break; + + case "currentUser": + if (mapping.userField) { + switch (mapping.userField) { + case "userId": + value = user?.userId; + break; + case "userName": + value = userName; + break; + case "companyCode": + value = user?.companyCode; + break; + case "deptCode": + value = authUser?.deptCode; + break; + } + } + break; + } + + if (value !== undefined && value !== null && value !== "") { + insertData[mapping.targetColumn] = value; + } + } + + // 3. 좌측 패널 선택 데이터에서 자동 매핑 (컬럼명이 같고 대상 테이블에 있는 경우) + if (splitPanelContext?.selectedLeftData && targetTableColumns.length > 0) { + const leftData = splitPanelContext.selectedLeftData; + console.log("📍 좌측 패널 자동 매핑 시작:", leftData); + + for (const [key, val] of Object.entries(leftData)) { + // 이미 매핑된 컬럼은 스킵 + if (insertData[key] !== undefined) { + continue; + } + + // 대상 테이블에 해당 컬럼이 없으면 스킵 + if (!targetTableColumns.includes(key)) { + continue; + } + + // 시스템 컬럼 제외 + const systemColumns = ['id', 'created_date', 'updated_date', 'writer', 'writer_name']; + if (systemColumns.includes(key)) { + continue; + } + + // _label, _name 으로 끝나는 표시용 컬럼 제외 + if (key.endsWith('_label') || key.endsWith('_name')) { + continue; + } + + // 값이 있으면 자동 추가 + if (val !== undefined && val !== null && val !== '') { + insertData[key] = val; + console.log(`📍 자동 매핑 추가: ${key} = ${val}`); + } + } + } + + console.log("🚀 quickInsert 최종 데이터:", insertData); + + // 4. 필수값 검증 + if (Object.keys(insertData).length === 0) { + toast.error("저장할 데이터가 없습니다. 값을 선택해주세요."); + return; + } + + // 5. 중복 체크 (설정된 경우) + if (quickInsertConfig.duplicateCheck?.enabled && quickInsertConfig.duplicateCheck?.columns?.length > 0) { + try { + const { default: apiClient } = await import("@/lib/api/client"); + + // 중복 체크를 위한 검색 조건 구성 + const searchConditions: Record = {}; + for (const col of quickInsertConfig.duplicateCheck.columns) { + if (insertData[col] !== undefined) { + searchConditions[col] = { value: insertData[col], operator: "equals" }; + } + } + + console.log("📍 중복 체크 조건:", searchConditions); + + // 기존 데이터 조회 + const checkResponse = await apiClient.post( + `/table-management/tables/${quickInsertConfig.targetTable}/data`, + { + page: 1, + pageSize: 1, + search: searchConditions, + } + ); + + console.log("📍 중복 체크 응답:", checkResponse.data); + + // data 배열이 있고 길이가 0보다 크면 중복 + const existingData = checkResponse.data?.data?.data || checkResponse.data?.data || []; + if (Array.isArray(existingData) && existingData.length > 0) { + toast.error(quickInsertConfig.duplicateCheck.errorMessage || "이미 존재하는 데이터입니다."); + return; + } + } catch (error) { + console.error("중복 체크 오류:", error); + // 중복 체크 실패 시 계속 진행 + } + } + + // 6. API 호출 + try { + const { default: apiClient } = await import("@/lib/api/client"); + + const response = await apiClient.post( + `/table-management/tables/${quickInsertConfig.targetTable}/add`, + insertData + ); + + if (response.data?.success) { + // 7. 성공 후 동작 + if (quickInsertConfig.afterInsert?.showSuccessMessage !== false) { + toast.success(quickInsertConfig.afterInsert?.successMessage || "저장되었습니다."); + } + + // 데이터 새로고침 (테이블리스트, 카드 디스플레이) + if (quickInsertConfig.afterInsert?.refreshData !== false) { + console.log("📍 데이터 새로고침 이벤트 발송"); + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("refreshTable")); + window.dispatchEvent(new CustomEvent("refreshCardDisplay")); + } + } + + // 지정된 컴포넌트 초기화 + if (quickInsertConfig.afterInsert?.clearComponents?.length > 0) { + for (const componentId of quickInsertConfig.afterInsert.clearComponents) { + const targetComp = allComponents.find((c: any) => c.id === componentId); + if (targetComp) { + const fieldName = (targetComp as any).columnName || targetComp.id; + onFormDataChange?.(fieldName, ""); + } + } + } + } else { + toast.error(response.data?.message || "저장에 실패했습니다."); + } + } catch (error: any) { + console.error("quickInsert 오류:", error); + toast.error(error.response?.data?.message || error.message || "저장 중 오류가 발생했습니다."); + } + }; + const handleClick = async () => { try { const actionType = config?.actionType || "save"; @@ -604,6 +817,9 @@ export const InteractiveScreenViewerDynamic: React.FC = ({ 편집 복사 (품목코드 초기화) 페이지 이동 - 📦 데이터 전달 - 데이터 전달 + 모달 열기 🆕 + 데이터 전달 + 데이터 전달 + 모달 열기 모달 열기 + 즉시 저장 제어 흐름 테이블 이력 보기 엑셀 다운로드 @@ -3068,6 +3070,16 @@ export const ButtonConfigPanel: React.FC = ({
)} + {/* 🆕 즉시 저장(quickInsert) 액션 설정 */} + {component.componentConfig?.action?.type === "quickInsert" && ( + + )} + {/* 제어 기능 섹션 */}
diff --git a/frontend/components/screen/config-panels/EntityConfigPanel.tsx b/frontend/components/screen/config-panels/EntityConfigPanel.tsx index 7c1b74eb..edb278f2 100644 --- a/frontend/components/screen/config-panels/EntityConfigPanel.tsx +++ b/frontend/components/screen/config-panels/EntityConfigPanel.tsx @@ -189,6 +189,33 @@ export const EntityConfigPanel: React.FC = ({

기본 설정

+ {/* UI 모드 선택 */} +
+ + +

+ {(localConfig as any).uiMode === "select" && "검색 가능한 드롭다운 형태로 표시됩니다."} + {(localConfig as any).uiMode === "modal" && "모달 팝업에서 데이터를 검색하고 선택합니다."} + {((localConfig as any).uiMode === "combo" || !(localConfig as any).uiMode) && "입력 필드와 검색 버튼이 함께 표시됩니다."} + {(localConfig as any).uiMode === "autocomplete" && "입력하면서 자동완성 목록이 표시됩니다."} +

+
+