From 3c73c202927f7facc41f728564ab2b72e5872425 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 15 Dec 2025 14:51:41 +0900 Subject: [PATCH 01/11] =?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 02/11] =?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 a2582a28e467ac66a364b0aa1cc92bb45962a5b3 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 16 Dec 2025 10:02:16 +0900 Subject: [PATCH 03/11] =?UTF-8?q?=EB=82=A0=EC=A7=9C=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=20=EC=8B=9C=20=ED=95=98=EB=A3=A8=20=EB=B0=80=EB=A6=AC=EB=8A=94?= =?UTF-8?q?=20=ED=83=80=EC=9E=84=EC=A1=B4=20=EB=B2=84=EA=B7=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/webtypes/RepeaterInput.tsx | 32 ++++++---- .../date-input/DateInputComponent.tsx | 61 +++++++++++-------- 2 files changed, 58 insertions(+), 35 deletions(-) diff --git a/frontend/components/webtypes/RepeaterInput.tsx b/frontend/components/webtypes/RepeaterInput.tsx index 0b5a1328..1595036b 100644 --- a/frontend/components/webtypes/RepeaterInput.tsx +++ b/frontend/components/webtypes/RepeaterInput.tsx @@ -182,7 +182,8 @@ export const RepeaterInput: React.FC = ({ // 항목 제거 const handleRemoveItem = (index: number) => { - if (items.length <= minItems) { + // 🆕 minItems가 0이면 모든 항목 삭제 가능, 그 외에는 minItems 이하로 줄일 수 없음 + if (minItems > 0 && items.length <= minItems) { return; } @@ -518,17 +519,26 @@ export const RepeaterInput: React.FC = ({ ); case "date": { - // 날짜 값 정규화: ISO 형식이면 YYYY-MM-DD로 변환 + // 날짜 값 정규화: ISO 형식이면 YYYY-MM-DD로 변환 (타임존 이슈 해결) let dateValue = value || ""; if (dateValue && typeof dateValue === "string") { - // ISO 형식(YYYY-MM-DDTHH:mm:ss)이면 날짜 부분만 추출 + // ISO 형식(YYYY-MM-DDTHH:mm:ss)이면 로컬 시간으로 변환하여 날짜 추출 if (dateValue.includes("T")) { - dateValue = dateValue.split("T")[0]; - } - // 유효한 날짜인지 확인 - const parsedDate = new Date(dateValue); - if (isNaN(parsedDate.getTime())) { - dateValue = ""; // 유효하지 않은 날짜면 빈 값 + const date = new Date(dateValue); + if (!isNaN(date.getTime())) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + dateValue = `${year}-${month}-${day}`; + } else { + dateValue = ""; + } + } else { + // 유효한 날짜인지 확인 + const parsedDate = new Date(dateValue); + if (isNaN(parsedDate.getTime())) { + dateValue = ""; // 유효하지 않은 날짜면 빈 값 + } } } return ( @@ -801,7 +811,7 @@ export const RepeaterInput: React.FC = ({ {/* 삭제 버튼 */} - {!readonly && !disabled && items.length > minItems && ( + {!readonly && !disabled && (minItems === 0 || items.length > minItems) && ( + + + + + + 테이블을 찾을 수 없습니다. + + {filteredTables.map((table) => ( + { + updateConfig({ targetTable: table.name, columnMappings: [] }); + setTablePopoverOpen(false); + setTableSearch(""); + }} + className="text-xs" + > + +
+ {table.label} + {table.name} +
+
+ ))} +
+
+
+
+ + + + {/* 컬럼 매핑 */} + {config.targetTable && ( +
+
+ + +
+ + {(config.columnMappings || []).length === 0 ? ( +
+ 컬럼 매핑을 추가하세요 +
+ ) : ( +
+ {(config.columnMappings || []).map((mapping, index) => ( + +
+
+ 매핑 #{index + 1} + +
+ + {/* 대상 컬럼 */} +
+ + setTargetColumnPopoverOpen((prev) => ({ ...prev, [index]: open }))} + > + + + + + + setTargetColumnSearch((prev) => ({ ...prev, [index]: v }))} + className="text-xs" + /> + + 컬럼을 찾을 수 없습니다. + + {targetColumns + .filter( + (c) => + c.name.toLowerCase().includes((targetColumnSearch[index] || "").toLowerCase()) || + c.label.toLowerCase().includes((targetColumnSearch[index] || "").toLowerCase()) + ) + .map((col) => ( + { + updateMapping(index, { targetColumn: col.name }); + setTargetColumnPopoverOpen((prev) => ({ ...prev, [index]: false })); + setTargetColumnSearch((prev) => ({ ...prev, [index]: "" })); + }} + className="text-xs" + > + +
+ {col.label} + {col.name} +
+
+ ))} +
+
+
+
+
+
+ + {/* 값 소스 타입 */} +
+ + +
+ + {/* 소스 타입별 추가 설정 */} + {mapping.sourceType === "component" && ( +
+ + setSourceComponentPopoverOpen((prev) => ({ ...prev, [index]: open }))} + > + + + + + + setSourceComponentSearch((prev) => ({ ...prev, [index]: v }))} + className="text-xs" + /> + + 컴포넌트를 찾을 수 없습니다. + + {availableComponents + .filter((comp: any) => { + const search = (sourceComponentSearch[index] || "").toLowerCase(); + const label = (comp.label || "").toLowerCase(); + const colName = (comp.columnName || "").toLowerCase(); + return label.includes(search) || colName.includes(search); + }) + .map((comp: any) => ( + { + // sourceComponentId와 함께 sourceColumnName도 저장 (formData 접근용) + updateMapping(index, { + sourceComponentId: comp.id, + sourceColumnName: comp.columnName || undefined, + }); + setSourceComponentPopoverOpen((prev) => ({ ...prev, [index]: false })); + setSourceComponentSearch((prev) => ({ ...prev, [index]: "" })); + }} + className="text-xs" + > + +
+ {comp.label || comp.columnName || comp.id} + + {comp.widgetType || comp.componentType} + +
+
+ ))} +
+
+
+
+
+
+ )} + + {mapping.sourceType === "leftPanel" && ( +
+ + updateMapping(index, { sourceColumn: e.target.value })} + className="h-7 text-xs" + /> +

+ 분할 패널 좌측에서 선택된 데이터의 컬럼명을 입력하세요 +

+
+ )} + + {mapping.sourceType === "fixed" && ( +
+ + updateMapping(index, { fixedValue: e.target.value })} + className="h-7 text-xs" + /> +
+ )} + + {mapping.sourceType === "currentUser" && ( +
+ + +
+ )} +
+
+ ))} +
+ )} +
+ )} + + {/* 저장 후 동작 설정 */} + {config.targetTable && ( +
+ + +
+ + { + updateConfig({ + afterInsert: { ...config.afterInsert, refreshData: checked }, + }); + }} + /> +
+

+ 테이블리스트, 카드 디스플레이 컴포넌트를 새로고침합니다 +

+ +
+ + { + updateConfig({ + afterInsert: { ...config.afterInsert, showSuccessMessage: checked }, + }); + }} + /> +
+ + {config.afterInsert?.showSuccessMessage && ( +
+ + { + updateConfig({ + afterInsert: { ...config.afterInsert, successMessage: e.target.value }, + }); + }} + className="h-7 text-xs" + /> +
+ )} +
+ )} + + {/* 중복 체크 설정 */} + {config.targetTable && ( +
+
+ + { + updateConfig({ + duplicateCheck: { ...config.duplicateCheck, enabled: checked }, + }); + }} + /> +
+ + {config.duplicateCheck?.enabled && ( + <> +
+ +
+ {targetColumns.length === 0 ? ( +

컬럼을 불러오는 중...

+ ) : ( +
+ {targetColumns.map((col) => { + const isChecked = (config.duplicateCheck?.columns || []).includes(col.name); + return ( +
{ + const currentColumns = config.duplicateCheck?.columns || []; + const newColumns = isChecked + ? currentColumns.filter((c) => c !== col.name) + : [...currentColumns, col.name]; + updateConfig({ + duplicateCheck: { ...config.duplicateCheck, columns: newColumns }, + }); + }} + > + {}} + className="h-3 w-3 flex-shrink-0" + /> + + {col.label}{col.label !== col.name && ` (${col.name})`} + +
+ ); + })} +
+ )} +
+

+ 선택한 컬럼들의 조합으로 중복 여부를 체크합니다 +

+
+ +
+ + { + updateConfig({ + duplicateCheck: { ...config.duplicateCheck, errorMessage: e.target.value }, + }); + }} + className="h-7 text-xs" + /> +
+ + )} +
+ )} + + {/* 사용 안내 */} +
+

+ 사용 방법: +
+ 1. 저장할 대상 테이블을 선택합니다 +
+ 2. 컬럼 매핑을 추가하여 각 컬럼에 어떤 값을 저장할지 설정합니다 +
+ 3. 버튼 클릭 시 설정된 값들이 대상 테이블에 즉시 저장됩니다 +

+
+ + ); +}; + +export default QuickInsertConfigSection; + diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 74f15d2f..b3e77cd7 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -365,7 +365,10 @@ export const DynamicComponentRenderer: React.FC = userId, // 🆕 사용자 ID userName, // 🆕 사용자 이름 companyCode, // 🆕 회사 코드 - mode, + // 🆕 화면 모드 (edit/view)와 컴포넌트 UI 모드 구분 + screenMode: mode, + // componentConfig.mode가 있으면 유지 (entity-search-input의 UI 모드) + mode: component.componentConfig?.mode || mode, isInModal, readonly: component.readonly, // 🆕 disabledFields 체크 또는 기존 readonly diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 4a7ad7e9..1f067865 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -964,6 +964,11 @@ export const ButtonPrimaryComponent: React.FC = ({ componentConfigs, // 🆕 분할 패널 부모 데이터 (좌측 화면에서 선택된 데이터) splitPanelParentData, + // 🆕 분할 패널 컨텍스트 (quickInsert 등에서 좌측 패널 데이터 접근용) + splitPanelContext: splitPanelContext ? { + selectedLeftData: splitPanelContext.selectedLeftData, + refreshRightPanel: splitPanelContext.refreshRightPanel, + } : undefined, } as ButtonActionContext; // 확인이 필요한 액션인지 확인 diff --git a/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx b/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx index db45531b..c3414677 100644 --- a/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx +++ b/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx @@ -68,6 +68,23 @@ export const CardDisplayComponent: React.FC = ({ // 필터 상태 (검색 필터 위젯에서 전달받은 필터) const [filters, setFiltersInternal] = useState([]); + // 새로고침 트리거 (refreshCardDisplay 이벤트 수신 시 증가) + const [refreshKey, setRefreshKey] = useState(0); + + // refreshCardDisplay 이벤트 리스너 + useEffect(() => { + const handleRefreshCardDisplay = () => { + console.log("📍 [CardDisplay] refreshCardDisplay 이벤트 수신 - 데이터 새로고침"); + setRefreshKey((prev) => prev + 1); + }; + + window.addEventListener("refreshCardDisplay", handleRefreshCardDisplay); + + return () => { + window.removeEventListener("refreshCardDisplay", handleRefreshCardDisplay); + }; + }, []); + // 필터 상태 변경 래퍼 const setFilters = useCallback((newFilters: TableFilter[]) => { setFiltersInternal(newFilters); @@ -357,7 +374,7 @@ export const CardDisplayComponent: React.FC = ({ }; loadTableData(); - }, [isDesignMode, tableName, component.componentConfig?.tableName, splitPanelContext?.selectedLeftData, splitPanelPosition]); + }, [isDesignMode, tableName, component.componentConfig?.tableName, splitPanelContext?.selectedLeftData, splitPanelPosition, refreshKey]); // 컴포넌트 설정 (기본값 보장) const componentConfig = { diff --git a/frontend/lib/registry/components/entity-search-input/EntitySearchInputComponent.tsx b/frontend/lib/registry/components/entity-search-input/EntitySearchInputComponent.tsx index cc6be643..6ee22a0c 100644 --- a/frontend/lib/registry/components/entity-search-input/EntitySearchInputComponent.tsx +++ b/frontend/lib/registry/components/entity-search-input/EntitySearchInputComponent.tsx @@ -3,17 +3,32 @@ import React, { useState, useEffect } from "react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; -import { Search, X } from "lucide-react"; +import { Search, X, Check, ChevronsUpDown } from "lucide-react"; import { EntitySearchModal } from "./EntitySearchModal"; import { EntitySearchInputProps, EntitySearchResult } from "./types"; import { cn } from "@/lib/utils"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { dynamicFormApi } from "@/lib/api/dynamicForm"; export function EntitySearchInputComponent({ tableName, displayField, valueField, searchFields = [displayField], - mode = "combo", + mode: modeProp, + uiMode, // EntityConfigPanel에서 저장되는 값 placeholder = "검색...", disabled = false, filterCondition = {}, @@ -24,31 +39,99 @@ export function EntitySearchInputComponent({ showAdditionalInfo = false, additionalFields = [], className, -}: EntitySearchInputProps) { + style, + // 🆕 추가 props + component, + isInteractive, + onFormDataChange, +}: EntitySearchInputProps & { + uiMode?: string; + component?: any; + isInteractive?: boolean; + onFormDataChange?: (fieldName: string, value: any) => void; +}) { + // uiMode가 있으면 우선 사용, 없으면 modeProp 사용, 기본값 "combo" + const mode = (uiMode || modeProp || "combo") as "select" | "modal" | "combo" | "autocomplete"; + const [modalOpen, setModalOpen] = useState(false); + const [selectOpen, setSelectOpen] = useState(false); const [displayValue, setDisplayValue] = useState(""); const [selectedData, setSelectedData] = useState(null); + const [options, setOptions] = useState([]); + const [isLoadingOptions, setIsLoadingOptions] = useState(false); + const [optionsLoaded, setOptionsLoaded] = useState(false); + + // filterCondition을 문자열로 변환하여 비교 (객체 참조 문제 해결) + const filterConditionKey = JSON.stringify(filterCondition || {}); + + // select 모드일 때 옵션 로드 (한 번만) + useEffect(() => { + if (mode === "select" && tableName && !optionsLoaded) { + loadOptions(); + setOptionsLoaded(true); + } + }, [mode, tableName, filterConditionKey, optionsLoaded]); + + const loadOptions = async () => { + if (!tableName) return; + + setIsLoadingOptions(true); + try { + const response = await dynamicFormApi.getTableData(tableName, { + page: 1, + pageSize: 100, // 최대 100개까지 로드 + filters: filterCondition, + }); + + if (response.success && response.data) { + setOptions(response.data); + } + } catch (error) { + console.error("옵션 로드 실패:", error); + } finally { + setIsLoadingOptions(false); + } + }; // value가 변경되면 표시값 업데이트 useEffect(() => { if (value && selectedData) { setDisplayValue(selectedData[displayField] || ""); - } else { + } else if (value && mode === "select" && options.length > 0) { + // select 모드에서 value가 있고 options가 로드된 경우 + const found = options.find(opt => opt[valueField] === value); + if (found) { + setSelectedData(found); + setDisplayValue(found[displayField] || ""); + } + } else if (!value) { setDisplayValue(""); setSelectedData(null); } - }, [value, displayField]); + }, [value, displayField, options, mode, valueField]); const handleSelect = (newValue: any, fullData: EntitySearchResult) => { setSelectedData(fullData); setDisplayValue(fullData[displayField] || ""); onChange?.(newValue, fullData); + + // 🆕 onFormDataChange 호출 (formData에 값 저장) + if (isInteractive && onFormDataChange && component?.columnName) { + onFormDataChange(component.columnName, newValue); + console.log("📤 EntitySearchInput -> onFormDataChange:", component.columnName, newValue); + } }; const handleClear = () => { setDisplayValue(""); setSelectedData(null); onChange?.(null, null); + + // 🆕 onFormDataChange 호출 (formData에서 값 제거) + if (isInteractive && onFormDataChange && component?.columnName) { + onFormDataChange(component.columnName, null); + console.log("📤 EntitySearchInput -> onFormDataChange (clear):", component.columnName, null); + } }; const handleOpenModal = () => { @@ -57,10 +140,105 @@ export function EntitySearchInputComponent({ } }; + const handleSelectOption = (option: EntitySearchResult) => { + handleSelect(option[valueField], option); + setSelectOpen(false); + }; + + // 높이 계산 (style에서 height가 있으면 사용, 없으면 기본값) + const componentHeight = style?.height; + const inputStyle: React.CSSProperties = componentHeight + ? { height: componentHeight } + : {}; + + // select 모드: 검색 가능한 드롭다운 + if (mode === "select") { + return ( +
+ + + + + + + + + + 항목을 찾을 수 없습니다. + + + {options.map((option, index) => ( + handleSelectOption(option)} + className="text-xs sm:text-sm" + > + +
+ {option[displayField]} + {valueField !== displayField && ( + + {option[valueField]} + + )} +
+
+ ))} +
+
+
+
+
+ + {/* 추가 정보 표시 */} + {showAdditionalInfo && selectedData && additionalFields.length > 0 && ( +
+ {additionalFields.map((field) => ( +
+ {field}: + {selectedData[field] || "-"} +
+ ))} +
+ )} +
+ ); + } + + // modal, combo, autocomplete 모드 return ( -
+
{/* 입력 필드 */} -
+
{displayValue && !disabled && ( @@ -97,7 +278,7 @@ export function EntitySearchInputComponent({ {/* 추가 정보 표시 */} {showAdditionalInfo && selectedData && additionalFields.length > 0 && ( -
+
{additionalFields.map((field) => (
{field}: @@ -107,19 +288,21 @@ export function EntitySearchInputComponent({
)} - {/* 검색 모달 */} - + {/* 검색 모달: modal 또는 combo 모드일 때만 렌더링 */} + {(mode === "modal" || mode === "combo") && ( + + )}
); } diff --git a/frontend/lib/registry/components/entity-search-input/EntitySearchInputConfigPanel.tsx b/frontend/lib/registry/components/entity-search-input/EntitySearchInputConfigPanel.tsx index 888cdf77..d37fe3b6 100644 --- a/frontend/lib/registry/components/entity-search-input/EntitySearchInputConfigPanel.tsx +++ b/frontend/lib/registry/components/entity-search-input/EntitySearchInputConfigPanel.tsx @@ -302,7 +302,7 @@ export function EntitySearchInputConfigPanel({ +

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

diff --git a/frontend/lib/registry/components/entity-search-input/config.ts b/frontend/lib/registry/components/entity-search-input/config.ts index 8f40faa2..651d6186 100644 --- a/frontend/lib/registry/components/entity-search-input/config.ts +++ b/frontend/lib/registry/components/entity-search-input/config.ts @@ -4,7 +4,7 @@ export interface EntitySearchInputConfig { valueField: string; searchFields?: string[]; filterCondition?: Record; - mode?: "autocomplete" | "modal" | "combo"; + mode?: "select" | "autocomplete" | "modal" | "combo"; placeholder?: string; modalTitle?: string; modalColumns?: string[]; diff --git a/frontend/lib/registry/components/entity-search-input/types.ts b/frontend/lib/registry/components/entity-search-input/types.ts index 532a240a..c1dc680e 100644 --- a/frontend/lib/registry/components/entity-search-input/types.ts +++ b/frontend/lib/registry/components/entity-search-input/types.ts @@ -11,7 +11,11 @@ export interface EntitySearchInputProps { searchFields?: string[]; // 검색 대상 필드들 (기본: [displayField]) // UI 모드 - mode?: "autocomplete" | "modal" | "combo"; // 기본: "combo" + // - select: 드롭다운 선택 (검색 가능한 콤보박스) + // - modal: 모달 팝업에서 선택 + // - combo: 입력 + 모달 버튼 (기본) + // - autocomplete: 입력하면서 자동완성 + mode?: "select" | "autocomplete" | "modal" | "combo"; // 기본: "combo" placeholder?: string; disabled?: boolean; @@ -33,6 +37,7 @@ export interface EntitySearchInputProps { // 스타일 className?: string; + style?: React.CSSProperties; } export interface EntitySearchResult { diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 394e15c2..0ae1986d 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -28,7 +28,8 @@ export type ButtonActionType = // | "empty_vehicle" // 공차등록 (위치 수집 + 상태 변경) - 운행알림으로 통합 | "operation_control" // 운행알림 및 종료 (위치 수집 + 상태 변경 + 연속 추적) | "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지) - | "transferData"; // 데이터 전달 (컴포넌트 간 or 화면 간) + | "transferData" // 데이터 전달 (컴포넌트 간 or 화면 간) + | "quickInsert"; // 즉시 저장 (선택한 데이터를 특정 테이블에 즉시 INSERT) /** * 버튼 액션 설정 @@ -211,6 +212,31 @@ export interface ButtonActionConfig { maxSelection?: number; // 최대 선택 개수 }; }; + + // 즉시 저장 (Quick Insert) 관련 + quickInsertConfig?: { + targetTable: string; // 저장할 테이블명 + columnMappings: Array<{ + targetColumn: string; // 대상 테이블의 컬럼명 + sourceType: "component" | "leftPanel" | "fixed" | "currentUser"; // 값 소스 타입 + sourceComponentId?: string; // 컴포넌트에서 값을 가져올 경우 컴포넌트 ID + sourceColumnName?: string; // 컴포넌트의 columnName (formData 접근용) + sourceColumn?: string; // 좌측 패널 또는 컴포넌트의 특정 컬럼 + fixedValue?: any; // 고정값 + userField?: "userId" | "userName" | "companyCode"; // currentUser 타입일 때 사용할 필드 + }>; + duplicateCheck?: { + enabled: boolean; // 중복 체크 활성화 여부 + columns?: string[]; // 중복 체크할 컬럼들 + errorMessage?: string; // 중복 시 에러 메시지 + }; + afterInsert?: { + refreshData?: boolean; // 저장 후 데이터 새로고침 (테이블리스트, 카드 디스플레이) + clearComponents?: boolean; // 저장 후 컴포넌트 값 초기화 + showSuccessMessage?: boolean; // 성공 메시지 표시 여부 (기본: true) + successMessage?: string; // 성공 메시지 + }; + }; } /** @@ -265,6 +291,12 @@ export interface ButtonActionContext { // 🆕 분할 패널 부모 데이터 (좌측 화면에서 선택된 데이터) splitPanelParentData?: Record; + + // 🆕 분할 패널 컨텍스트 (quickInsert 등에서 좌측 패널 데이터 접근용) + splitPanelContext?: { + selectedLeftData?: Record; + refreshRightPanel?: () => void; + }; } /** @@ -365,6 +397,9 @@ export class ButtonActionExecutor { case "swap_fields": return await this.handleSwapFields(config, context); + case "quickInsert": + return await this.handleQuickInsert(config, context); + default: console.warn(`지원되지 않는 액션 타입: ${config.type}`); return false; @@ -5190,6 +5225,313 @@ export class ButtonActionExecutor { } } + /** + * 즉시 저장 (Quick Insert) 액션 처리 + * 화면에서 선택한 데이터를 특정 테이블에 즉시 저장 + */ + private static async handleQuickInsert(config: ButtonActionConfig, context: ButtonActionContext): Promise { + try { + console.log("⚡ Quick Insert 액션 실행:", { config, context }); + + const quickInsertConfig = config.quickInsertConfig; + if (!quickInsertConfig?.targetTable) { + toast.error("대상 테이블이 설정되지 않았습니다."); + return false; + } + + const { formData, splitPanelContext, userId, userName, companyCode } = context; + + console.log("⚡ Quick Insert 상세 정보:", { + targetTable: quickInsertConfig.targetTable, + columnMappings: quickInsertConfig.columnMappings, + formData: formData, + formDataKeys: Object.keys(formData || {}), + splitPanelContext: splitPanelContext, + selectedLeftData: splitPanelContext?.selectedLeftData, + allComponents: context.allComponents, + userId, + userName, + companyCode, + }); + + // 컬럼 매핑에 따라 저장할 데이터 구성 + const insertData: Record = {}; + const columnMappings = quickInsertConfig.columnMappings || []; + + for (const mapping of columnMappings) { + console.log(`📍 매핑 처리 시작:`, mapping); + + if (!mapping.targetColumn) { + console.log(`📍 targetColumn 없음, 스킵`); + continue; + } + + let value: any = undefined; + + switch (mapping.sourceType) { + case "component": + console.log(`📍 component 타입 처리:`, { + sourceComponentId: mapping.sourceComponentId, + sourceColumnName: mapping.sourceColumnName, + targetColumn: mapping.targetColumn, + }); + + // 컴포넌트의 현재 값 + if (mapping.sourceComponentId) { + // 1. sourceColumnName이 있으면 직접 사용 (가장 확실한 방법) + if (mapping.sourceColumnName) { + value = formData?.[mapping.sourceColumnName]; + console.log(`📍 방법1 (sourceColumnName): ${mapping.sourceColumnName} = ${value}`); + } + + // 2. 없으면 컴포넌트 ID로 직접 찾기 + if (value === undefined) { + value = formData?.[mapping.sourceComponentId]; + console.log(`📍 방법2 (sourceComponentId): ${mapping.sourceComponentId} = ${value}`); + } + + // 3. 없으면 allComponents에서 컴포넌트를 찾아 columnName으로 시도 + if (value === undefined && context.allComponents) { + const comp = context.allComponents.find((c: any) => c.id === mapping.sourceComponentId); + console.log(`📍 방법3 찾은 컴포넌트:`, comp); + if (comp?.columnName) { + value = formData?.[comp.columnName]; + console.log(`📍 방법3 (allComponents): ${mapping.sourceComponentId} → ${comp.columnName} = ${value}`); + } + } + + // 4. targetColumn과 같은 이름의 키가 formData에 있으면 사용 (폴백) + if (value === undefined && mapping.targetColumn && formData?.[mapping.targetColumn] !== undefined) { + value = formData[mapping.targetColumn]; + console.log(`📍 방법4 (targetColumn 폴백): ${mapping.targetColumn} = ${value}`); + } + + // 5. 그래도 없으면 formData의 모든 키를 확인하고 로깅 + if (value === undefined) { + console.log("📍 방법5: formData에서 값을 찾지 못함. formData 키들:", Object.keys(formData || {})); + } + + // sourceColumn이 지정된 경우 해당 속성 추출 + if (mapping.sourceColumn && value && typeof value === "object") { + value = value[mapping.sourceColumn]; + console.log(`📍 sourceColumn 추출: ${mapping.sourceColumn} = ${value}`); + } + } + break; + + case "leftPanel": + console.log(`📍 leftPanel 타입 처리:`, { + sourceColumn: mapping.sourceColumn, + selectedLeftData: splitPanelContext?.selectedLeftData, + }); + // 좌측 패널 선택 데이터 + if (mapping.sourceColumn && splitPanelContext?.selectedLeftData) { + value = splitPanelContext.selectedLeftData[mapping.sourceColumn]; + console.log(`📍 leftPanel 값: ${mapping.sourceColumn} = ${value}`); + } + break; + + case "fixed": + console.log(`📍 fixed 타입 처리: fixedValue = ${mapping.fixedValue}`); + // 고정값 + value = mapping.fixedValue; + break; + + case "currentUser": + console.log(`📍 currentUser 타입 처리: userField = ${mapping.userField}`); + // 현재 사용자 정보 + switch (mapping.userField) { + case "userId": + value = userId; + break; + case "userName": + value = userName; + break; + case "companyCode": + value = companyCode; + break; + } + console.log(`📍 currentUser 값: ${value}`); + break; + + default: + console.log(`📍 알 수 없는 sourceType: ${mapping.sourceType}`); + } + + console.log(`📍 매핑 결과: targetColumn=${mapping.targetColumn}, value=${value}, type=${typeof value}`); + + if (value !== undefined && value !== null && value !== "") { + insertData[mapping.targetColumn] = value; + console.log(`📍 insertData에 추가됨: ${mapping.targetColumn} = ${value}`); + } else { + console.log(`📍 값이 비어있어서 insertData에 추가 안됨`); + } + } + + // 🆕 좌측 패널 선택 데이터에서 자동 매핑 (대상 테이블에 존재하는 컬럼만) + if (splitPanelContext?.selectedLeftData) { + const leftData = splitPanelContext.selectedLeftData; + console.log("📍 좌측 패널 자동 매핑 시작:", leftData); + + // 대상 테이블의 컬럼 목록 조회 + let targetTableColumns: string[] = []; + try { + 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); + } + + for (const [key, val] of Object.entries(leftData)) { + // 이미 매핑된 컬럼은 스킵 + if (insertData[key] !== undefined) { + console.log(`📍 자동 매핑 스킵 (이미 존재): ${key}`); + continue; + } + + // 대상 테이블에 해당 컬럼이 없으면 스킵 + if (targetTableColumns.length > 0 && !targetTableColumns.includes(key)) { + console.log(`📍 자동 매핑 스킵 (대상 테이블에 없는 컬럼): ${key}`); + continue; + } + + // 시스템 컬럼 제외 (id, created_date, updated_date, writer 등) + const systemColumns = ['id', 'created_date', 'updated_date', 'writer', 'writer_name']; + if (systemColumns.includes(key)) { + console.log(`📍 자동 매핑 스킵 (시스템 컬럼): ${key}`); + continue; + } + + // _label, _name 으로 끝나는 표시용 컬럼 제외 + if (key.endsWith('_label') || key.endsWith('_name')) { + console.log(`📍 자동 매핑 스킵 (표시용 컬럼): ${key}`); + continue; + } + + // 값이 있으면 자동 추가 + if (val !== undefined && val !== null && val !== '') { + insertData[key] = val; + console.log(`📍 자동 매핑 추가: ${key} = ${val}`); + } + } + } + + console.log("⚡ Quick Insert 최종 데이터:", insertData, "키 개수:", Object.keys(insertData).length); + + // 필수 데이터 검증 + if (Object.keys(insertData).length === 0) { + toast.error("저장할 데이터가 없습니다."); + return false; + } + + // 중복 체크 + console.log("📍 중복 체크 설정:", { + enabled: quickInsertConfig.duplicateCheck?.enabled, + columns: quickInsertConfig.duplicateCheck?.columns, + }); + + if (quickInsertConfig.duplicateCheck?.enabled && quickInsertConfig.duplicateCheck?.columns?.length > 0) { + const duplicateCheckData: Record = {}; + for (const col of quickInsertConfig.duplicateCheck.columns) { + if (insertData[col] !== undefined) { + // 백엔드가 { value, operator } 형식을 기대하므로 변환 + duplicateCheckData[col] = { value: insertData[col], operator: "equals" }; + } + } + + console.log("📍 중복 체크 조건:", duplicateCheckData); + + if (Object.keys(duplicateCheckData).length > 0) { + try { + const checkResponse = await apiClient.post( + `/table-management/tables/${quickInsertConfig.targetTable}/data`, + { + page: 1, + pageSize: 1, + search: duplicateCheckData, + } + ); + + console.log("📍 중복 체크 응답:", checkResponse.data); + + // 응답 구조: { success: true, data: { data: [...], total: N } } 또는 { success: true, data: [...] } + const existingData = checkResponse.data?.data?.data || checkResponse.data?.data || []; + console.log("📍 기존 데이터:", existingData, "길이:", Array.isArray(existingData) ? existingData.length : 0); + + if (Array.isArray(existingData) && existingData.length > 0) { + toast.error(quickInsertConfig.duplicateCheck.errorMessage || "이미 존재하는 데이터입니다."); + return false; + } + } catch (error) { + console.error("중복 체크 오류:", error); + // 중복 체크 실패해도 저장은 시도 + } + } + } else { + console.log("📍 중복 체크 비활성화 또는 컬럼 미설정"); + } + + // 데이터 저장 + const response = await apiClient.post( + `/table-management/tables/${quickInsertConfig.targetTable}/add`, + insertData + ); + + if (response.data?.success) { + console.log("✅ Quick Insert 저장 성공"); + + // 저장 후 동작 설정 로그 + console.log("📍 afterInsert 설정:", quickInsertConfig.afterInsert); + + // 🆕 데이터 새로고침 (테이블리스트, 카드 디스플레이 컴포넌트 새로고침) + // refreshData가 명시적으로 false가 아니면 기본적으로 새로고침 실행 + const shouldRefresh = quickInsertConfig.afterInsert?.refreshData !== false; + console.log("📍 데이터 새로고침 여부:", shouldRefresh); + + if (shouldRefresh) { + console.log("📍 데이터 새로고침 이벤트 발송"); + // 전역 이벤트로 테이블/카드 컴포넌트들에게 새로고침 알림 + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("refreshTable")); + window.dispatchEvent(new CustomEvent("refreshCardDisplay")); + console.log("✅ refreshTable, refreshCardDisplay 이벤트 발송 완료"); + } + } + + // 컴포넌트 값 초기화 + if (quickInsertConfig.afterInsert?.clearComponents && context.onFormDataChange) { + for (const mapping of columnMappings) { + if (mapping.sourceType === "component" && mapping.sourceComponentId) { + // sourceColumnName이 있으면 그것을 사용, 없으면 sourceComponentId 사용 + const fieldName = mapping.sourceColumnName || mapping.sourceComponentId; + context.onFormDataChange(fieldName, null); + console.log(`📍 컴포넌트 값 초기화: ${fieldName}`); + } + } + } + + if (quickInsertConfig.afterInsert?.showSuccessMessage !== false) { + toast.success(quickInsertConfig.afterInsert?.successMessage || "저장되었습니다."); + } + + return true; + } else { + toast.error(response.data?.message || "저장에 실패했습니다."); + return false; + } + } catch (error: any) { + console.error("❌ Quick Insert 오류:", error); + toast.error(error.response?.data?.message || "저장 중 오류가 발생했습니다."); + return false; + } + } + /** * 필드 값 변경 액션 처리 (예: status를 active로 변경) * 🆕 위치정보 수집 기능 추가 @@ -5643,4 +5985,9 @@ export const DEFAULT_BUTTON_ACTIONS: Record = { // 기타 label: "text-display", code: "select-basic", // 코드 타입은 선택상자 사용 - entity: "select-basic", // 엔티티 타입은 선택상자 사용 + entity: "entity-search-input", // 엔티티 타입은 전용 검색 입력 사용 category: "select-basic", // 카테고리 타입은 선택상자 사용 }; diff --git a/frontend/types/screen-management.ts b/frontend/types/screen-management.ts index 596ec241..646632f5 100644 --- a/frontend/types/screen-management.ts +++ b/frontend/types/screen-management.ts @@ -363,6 +363,8 @@ export interface EntityTypeConfig { placeholder?: string; displayFormat?: "simple" | "detailed" | "custom"; // 표시 형식 separator?: string; // 여러 컬럼 표시 시 구분자 (기본: ' - ') + // UI 모드 + uiMode?: "select" | "modal" | "combo" | "autocomplete"; // 기본: "combo" } /** @@ -428,6 +430,111 @@ export interface ButtonTypeConfig { // ButtonActionType과 관련된 설정은 control-management.ts에서 정의 } +// ===== 즉시 저장(quickInsert) 설정 ===== + +/** + * 즉시 저장 컬럼 매핑 설정 + * 저장할 테이블의 각 컬럼에 대해 값을 어디서 가져올지 정의 + */ +export interface QuickInsertColumnMapping { + /** 저장할 테이블의 대상 컬럼명 */ + targetColumn: string; + + /** 값 소스 타입 */ + sourceType: "component" | "leftPanel" | "fixed" | "currentUser"; + + // sourceType별 추가 설정 + /** component: 값을 가져올 컴포넌트 ID */ + sourceComponentId?: string; + + /** component: 컴포넌트의 columnName (formData 접근용) */ + sourceColumnName?: string; + + /** leftPanel: 좌측 선택 데이터의 컬럼명 */ + sourceColumn?: string; + + /** fixed: 고정값 */ + fixedValue?: any; + + /** currentUser: 사용자 정보 필드 */ + userField?: "userId" | "userName" | "companyCode" | "deptCode"; +} + +/** + * 즉시 저장 후 동작 설정 + */ +export interface QuickInsertAfterAction { + /** 데이터 새로고침 (테이블리스트, 카드 디스플레이 컴포넌트) */ + refreshData?: boolean; + + /** 초기화할 컴포넌트 ID 목록 */ + clearComponents?: string[]; + + /** 성공 메시지 표시 여부 */ + showSuccessMessage?: boolean; + + /** 커스텀 성공 메시지 */ + successMessage?: string; +} + +/** + * 중복 체크 설정 + */ +export interface QuickInsertDuplicateCheck { + /** 중복 체크 활성화 */ + enabled: boolean; + + /** 중복 체크할 컬럼들 */ + columns: string[]; + + /** 중복 시 에러 메시지 */ + errorMessage?: string; +} + +/** + * 즉시 저장(quickInsert) 버튼 액션 설정 + * + * 화면에서 entity 타입 선택박스로 데이터를 선택한 후, + * 버튼 클릭 시 특정 테이블에 즉시 INSERT하는 기능 + * + * @example + * ```typescript + * const config: QuickInsertConfig = { + * targetTable: "process_equipment", + * columnMappings: [ + * { + * targetColumn: "equipment_code", + * sourceType: "component", + * sourceComponentId: "equipment-select" + * }, + * { + * targetColumn: "process_code", + * sourceType: "leftPanel", + * sourceColumn: "process_code" + * } + * ], + * afterInsert: { + * refreshData: true, + * clearComponents: ["equipment-select"], + * showSuccessMessage: true + * } + * }; + * ``` + */ +export interface QuickInsertConfig { + /** 저장할 대상 테이블명 */ + targetTable: string; + + /** 컬럼 매핑 설정 */ + columnMappings: QuickInsertColumnMapping[]; + + /** 저장 후 동작 설정 */ + afterInsert?: QuickInsertAfterAction; + + /** 중복 체크 설정 (선택사항) */ + duplicateCheck?: QuickInsertDuplicateCheck; +} + /** * 플로우 단계별 버튼 표시 설정 * diff --git a/frontend/types/unified-core.ts b/frontend/types/unified-core.ts index 7ec2d0c2..0162c9fc 100644 --- a/frontend/types/unified-core.ts +++ b/frontend/types/unified-core.ts @@ -71,7 +71,9 @@ export type ButtonActionType = // 제어관리 전용 | "control" // 데이터 전달 - | "transferData"; // 선택된 데이터를 다른 컴포넌트/화면으로 전달 + | "transferData" // 선택된 데이터를 다른 컴포넌트/화면으로 전달 + // 즉시 저장 + | "quickInsert"; // 선택한 데이터를 특정 테이블에 즉시 INSERT /** * 컴포넌트 타입 정의 @@ -328,6 +330,7 @@ export const isButtonActionType = (value: string): value is ButtonActionType => "newWindow", "control", "transferData", + "quickInsert", ]; return actionTypes.includes(value as ButtonActionType); }; From 963e0c2d247a980d9c6335fc7e056ef020c889ce Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 16 Dec 2025 14:56:31 +0900 Subject: [PATCH 08/11] =?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=84=A0=ED=83=9D=EC=95=88?= =?UTF-8?q?=ED=95=A8=20=EC=98=B5=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/entityJoinController.ts | 10 +- .../card-display/CardDisplayComponent.tsx | 30 +- .../card-display/CardDisplayConfigPanel.tsx | 492 ++++++++++++------ 3 files changed, 362 insertions(+), 170 deletions(-) diff --git a/backend-node/src/controllers/entityJoinController.ts b/backend-node/src/controllers/entityJoinController.ts index 00727f1d..fbb88750 100644 --- a/backend-node/src/controllers/entityJoinController.ts +++ b/backend-node/src/controllers/entityJoinController.ts @@ -424,18 +424,16 @@ export class EntityJoinController { config.referenceTable ); - // 현재 display_column으로 사용 중인 컬럼 제외 + // 현재 display_column 정보 (참고용으로만 사용, 필터링하지 않음) const currentDisplayColumn = config.displayColumn || config.displayColumns[0]; - const availableColumns = columns.filter( - (col) => col.columnName !== currentDisplayColumn - ); - + + // 모든 컬럼 표시 (기본 표시 컬럼도 포함) return { joinConfig: config, tableName: config.referenceTable, currentDisplayColumn: currentDisplayColumn, - availableColumns: availableColumns.map((col) => ({ + availableColumns: columns.map((col) => ({ columnName: col.columnName, columnLabel: col.displayName || col.columnName, dataType: col.dataType, diff --git a/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx b/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx index c3414677..5594d266 100644 --- a/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx +++ b/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx @@ -4,6 +4,7 @@ import React, { useEffect, useState, useMemo, useCallback, useRef } from "react" import { ComponentRendererProps } from "@/types/component"; import { CardDisplayConfig } from "./types"; import { tableTypeApi } from "@/lib/api/screen"; +import { entityJoinApi } from "@/lib/api/entityJoin"; import { getFullImageUrl, apiClient } from "@/lib/api/client"; import { filterDOMProps } from "@/lib/utils/domPropsFilter"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; @@ -308,10 +309,35 @@ export const CardDisplayComponent: React.FC = ({ search: Object.keys(linkedFilterValues).length > 0 ? linkedFilterValues : undefined, }; + // 조인 컬럼 설정 가져오기 (componentConfig에서) + const joinColumnsConfig = component.componentConfig?.joinColumns || []; + const entityJoinColumns = joinColumnsConfig + .filter((col: any) => col.isJoinColumn) + .map((col: any) => ({ + columnName: col.columnName, + sourceColumn: col.sourceColumn, + referenceTable: col.referenceTable, + referenceColumn: col.referenceColumn, + displayColumn: col.referenceColumn, + label: col.label, + joinAlias: col.columnName, // 백엔드에서 필요한 joinAlias 추가 + sourceTable: tableNameToUse, // 기준 테이블 + })); // 테이블 데이터, 컬럼 정보, 입력 타입 정보를 병렬로 로드 - const [dataResponse, columnsResponse, inputTypesResponse] = await Promise.all([ - tableTypeApi.getTableData(tableNameToUse, apiParams), + // 조인 컬럼이 있으면 entityJoinApi 사용 + let dataResponse; + if (entityJoinColumns.length > 0) { + console.log("🔗 [CardDisplay] 엔티티 조인 API 사용:", entityJoinColumns); + dataResponse = await entityJoinApi.getTableDataWithJoins(tableNameToUse, { + ...apiParams, + additionalJoinColumns: entityJoinColumns, + }); + } else { + dataResponse = await tableTypeApi.getTableData(tableNameToUse, apiParams); + } + + const [columnsResponse, inputTypesResponse] = await Promise.all([ tableTypeApi.getColumns(tableNameToUse), tableTypeApi.getColumnInputTypes(tableNameToUse), ]); diff --git a/frontend/lib/registry/components/card-display/CardDisplayConfigPanel.tsx b/frontend/lib/registry/components/card-display/CardDisplayConfigPanel.tsx index 52889865..73bb79a9 100644 --- a/frontend/lib/registry/components/card-display/CardDisplayConfigPanel.tsx +++ b/frontend/lib/registry/components/card-display/CardDisplayConfigPanel.tsx @@ -1,6 +1,21 @@ "use client"; -import React from "react"; +import React, { useState, useEffect } from "react"; +import { entityJoinApi } from "@/lib/api/entityJoin"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Trash2 } from "lucide-react"; interface CardDisplayConfigPanelProps { config: any; @@ -9,9 +24,32 @@ interface CardDisplayConfigPanelProps { tableColumns?: any[]; } +interface EntityJoinColumn { + tableName: string; + columnName: string; + columnLabel: string; + dataType: string; + joinAlias: string; + suggestedLabel: string; +} + +interface JoinTable { + tableName: string; + currentDisplayColumn: string; + joinConfig?: { + sourceColumn: string; + }; + availableColumns: Array<{ + columnName: string; + columnLabel: string; + dataType: string; + description?: string; + }>; +} + /** * CardDisplay 설정 패널 - * 카드 레이아웃과 동일한 설정 UI 제공 + * 카드 레이아웃과 동일한 설정 UI 제공 + 엔티티 조인 컬럼 지원 */ export const CardDisplayConfigPanel: React.FC = ({ config, @@ -19,6 +57,40 @@ export const CardDisplayConfigPanel: React.FC = ({ screenTableName, tableColumns = [], }) => { + // 엔티티 조인 컬럼 상태 + const [entityJoinColumns, setEntityJoinColumns] = useState<{ + availableColumns: EntityJoinColumn[]; + joinTables: JoinTable[]; + }>({ availableColumns: [], joinTables: [] }); + const [loadingEntityJoins, setLoadingEntityJoins] = useState(false); + + // 엔티티 조인 컬럼 정보 가져오기 + useEffect(() => { + const fetchEntityJoinColumns = async () => { + const tableName = config.tableName || screenTableName; + if (!tableName) { + setEntityJoinColumns({ availableColumns: [], joinTables: [] }); + return; + } + + setLoadingEntityJoins(true); + try { + const result = await entityJoinApi.getEntityJoinColumns(tableName); + setEntityJoinColumns({ + availableColumns: result.availableColumns || [], + joinTables: result.joinTables || [], + }); + } catch (error) { + console.error("Entity 조인 컬럼 조회 오류:", error); + setEntityJoinColumns({ availableColumns: [], joinTables: [] }); + } finally { + setLoadingEntityJoins(false); + } + }; + + fetchEntityJoinColumns(); + }, [config.tableName, screenTableName]); + const handleChange = (key: string, value: any) => { onChange({ ...config, [key]: value }); }; @@ -28,7 +100,6 @@ export const CardDisplayConfigPanel: React.FC = ({ let newConfig = { ...config }; let current = newConfig; - // 중첩 객체 생성 for (let i = 0; i < keys.length - 1; i++) { if (!current[keys[i]]) { current[keys[i]] = {}; @@ -40,6 +111,47 @@ export const CardDisplayConfigPanel: React.FC = ({ onChange(newConfig); }; + // 컬럼 선택 시 조인 컬럼이면 joinColumns 설정도 함께 업데이트 + const handleColumnSelect = (path: string, columnName: string) => { + const joinColumn = entityJoinColumns.availableColumns.find( + (col) => col.joinAlias === columnName + ); + + if (joinColumn) { + const joinColumnsConfig = config.joinColumns || []; + const existingJoinColumn = joinColumnsConfig.find( + (jc: any) => jc.columnName === columnName + ); + + if (!existingJoinColumn) { + const joinTableInfo = entityJoinColumns.joinTables?.find( + (jt) => jt.tableName === joinColumn.tableName + ); + + const newJoinColumnConfig = { + columnName: joinColumn.joinAlias, + label: joinColumn.suggestedLabel || joinColumn.columnLabel, + sourceColumn: joinTableInfo?.joinConfig?.sourceColumn || "", + referenceTable: joinColumn.tableName, + referenceColumn: joinColumn.columnName, + isJoinColumn: true, + }; + + onChange({ + ...config, + columnMapping: { + ...config.columnMapping, + [path.split(".")[1]]: columnName, + }, + joinColumns: [...joinColumnsConfig, newJoinColumnConfig], + }); + return; + } + } + + handleNestedChange(path, columnName); + }; + // 표시 컬럼 추가 const addDisplayColumn = () => { const currentColumns = config.columnMapping?.displayColumns || []; @@ -58,122 +170,198 @@ export const CardDisplayConfigPanel: React.FC = ({ const updateDisplayColumn = (index: number, value: string) => { const currentColumns = [...(config.columnMapping?.displayColumns || [])]; currentColumns[index] = value; + + const joinColumn = entityJoinColumns.availableColumns.find( + (col) => col.joinAlias === value + ); + + if (joinColumn) { + const joinColumnsConfig = config.joinColumns || []; + const existingJoinColumn = joinColumnsConfig.find( + (jc: any) => jc.columnName === value + ); + + if (!existingJoinColumn) { + const joinTableInfo = entityJoinColumns.joinTables?.find( + (jt) => jt.tableName === joinColumn.tableName + ); + + const newJoinColumnConfig = { + columnName: joinColumn.joinAlias, + label: joinColumn.suggestedLabel || joinColumn.columnLabel, + sourceColumn: joinTableInfo?.joinConfig?.sourceColumn || "", + referenceTable: joinColumn.tableName, + referenceColumn: joinColumn.columnName, + isJoinColumn: true, + }; + + onChange({ + ...config, + columnMapping: { + ...config.columnMapping, + displayColumns: currentColumns, + }, + joinColumns: [...joinColumnsConfig, newJoinColumnConfig], + }); + return; + } + } + handleNestedChange("columnMapping.displayColumns", currentColumns); }; + // 테이블별로 조인 컬럼 그룹화 + const joinColumnsByTable: Record = {}; + entityJoinColumns.availableColumns.forEach((col) => { + if (!joinColumnsByTable[col.tableName]) { + joinColumnsByTable[col.tableName] = []; + } + joinColumnsByTable[col.tableName].push(col); + }); + + // 컬럼 선택 셀렉트 박스 렌더링 (Shadcn UI) + const renderColumnSelect = ( + value: string, + onChangeHandler: (value: string) => void, + placeholder: string = "컬럼을 선택하세요" + ) => { + return ( + + ); + }; + return (
-
카드 디스플레이 설정
+
카드 디스플레이 설정
{/* 테이블이 선택된 경우 컬럼 매핑 설정 */} {tableColumns && tableColumns.length > 0 && (
-
컬럼 매핑
+
컬럼 매핑
-
- - + {loadingEntityJoins && ( +
조인 컬럼 로딩 중...
+ )} + +
+ + {renderColumnSelect( + config.columnMapping?.titleColumn || "", + (value) => handleColumnSelect("columnMapping.titleColumn", value) + )}
-
- - +
+ + {renderColumnSelect( + config.columnMapping?.subtitleColumn || "", + (value) => handleColumnSelect("columnMapping.subtitleColumn", value) + )}
-
- - +
+ + {renderColumnSelect( + config.columnMapping?.descriptionColumn || "", + (value) => handleColumnSelect("columnMapping.descriptionColumn", value) + )}
-
- - +
+ + {renderColumnSelect( + config.columnMapping?.imageColumn || "", + (value) => handleColumnSelect("columnMapping.imageColumn", value) + )}
{/* 동적 표시 컬럼 추가 */} -
-
- - +
{(config.columnMapping?.displayColumns || []).map((column: string, index: number) => ( -
- - + +
))} {(!config.columnMapping?.displayColumns || config.columnMapping.displayColumns.length === 0) && ( -
+
"컬럼 추가" 버튼을 클릭하여 표시할 컬럼을 추가하세요
)} @@ -184,186 +372,166 @@ export const CardDisplayConfigPanel: React.FC = ({ {/* 카드 스타일 설정 */}
-
카드 스타일
+
카드 스타일
-
-
- - +
+ + handleChange("cardsPerRow", parseInt(e.target.value))} - className="w-full rounded border border-gray-300 px-2 py-1 text-sm" + className="h-8 text-xs" />
-
- - + + handleChange("cardSpacing", parseInt(e.target.value))} - className="w-full rounded border border-gray-300 px-2 py-1 text-sm" + className="h-8 text-xs" />
- handleNestedChange("cardStyle.showTitle", e.target.checked)} - className="rounded border-gray-300" + onCheckedChange={(checked) => handleNestedChange("cardStyle.showTitle", checked)} /> -
- handleNestedChange("cardStyle.showSubtitle", e.target.checked)} - className="rounded border-gray-300" + onCheckedChange={(checked) => handleNestedChange("cardStyle.showSubtitle", checked)} /> -
- handleNestedChange("cardStyle.showDescription", e.target.checked)} - className="rounded border-gray-300" + onCheckedChange={(checked) => handleNestedChange("cardStyle.showDescription", checked)} /> -
- handleNestedChange("cardStyle.showImage", e.target.checked)} - className="rounded border-gray-300" + onCheckedChange={(checked) => handleNestedChange("cardStyle.showImage", checked)} /> -
- handleNestedChange("cardStyle.showActions", e.target.checked)} - className="rounded border-gray-300" + onCheckedChange={(checked) => handleNestedChange("cardStyle.showActions", checked)} /> -
- {/* 개별 버튼 설정 (액션 버튼이 활성화된 경우에만 표시) */} + {/* 개별 버튼 설정 */} {(config.cardStyle?.showActions ?? true) && ( -
+
- handleNestedChange("cardStyle.showViewButton", e.target.checked)} - className="rounded border-gray-300" + onCheckedChange={(checked) => handleNestedChange("cardStyle.showViewButton", checked)} /> -
- handleNestedChange("cardStyle.showEditButton", e.target.checked)} - className="rounded border-gray-300" + onCheckedChange={(checked) => handleNestedChange("cardStyle.showEditButton", checked)} /> -
- handleNestedChange("cardStyle.showDeleteButton", e.target.checked)} - className="rounded border-gray-300" + onCheckedChange={(checked) => handleNestedChange("cardStyle.showDeleteButton", checked)} /> -
)}
-
- - + + handleNestedChange("cardStyle.maxDescriptionLength", parseInt(e.target.value))} - className="w-full rounded border border-gray-300 px-2 py-1 text-sm" + className="h-8 text-xs" />
{/* 공통 설정 */}
-
공통 설정
+
공통 설정
- handleChange("disabled", e.target.checked)} - className="rounded border-gray-300" + onCheckedChange={(checked) => handleChange("disabled", checked)} /> -
- handleChange("readonly", e.target.checked)} - className="rounded border-gray-300" + onCheckedChange={(checked) => handleChange("readonly", checked)} /> -
From 3a55ea3b647a72d01fbdff9cb0029c20e6cb7b3c Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 16 Dec 2025 15:32:43 +0900 Subject: [PATCH 09/11] =?UTF-8?q?=EB=B6=84=ED=95=A0=ED=8C=A8=EB=84=90=20?= =?UTF-8?q?=EB=9D=BC=EB=B2=A8=EA=B2=80=EC=83=89=20=EA=B0=80=EB=8A=A5?= =?UTF-8?q?=ED=95=98=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../split-panel-layout/SplitPanelLayoutConfigPanel.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx index bbb115e0..203e1e47 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx @@ -1522,9 +1522,9 @@ export const SplitPanelLayoutConfigPanel: React.FC ( { - updateRightPanel({ tableName: value }); + value={`${table.displayName || ""} ${table.tableName}`} + onSelect={() => { + updateRightPanel({ tableName: table.tableName }); setRightTableOpen(false); }} > From a73b37f5580c0252ce00ae331407141c45d99fcc Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 16 Dec 2025 16:13:43 +0900 Subject: [PATCH 10/11] =?UTF-8?q?=EC=A2=8C=EC=B8=A1=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=EC=97=90=20=EB=8C=80=ED=95=9C=20?= =?UTF-8?q?=EC=9A=B0=EC=B8=A1=EC=97=90=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=EB=B0=8F=20=EB=B2=84=ED=8A=BC=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/lib/api/data.ts | 9 +- frontend/lib/registry/components/index.ts | 3 + .../components/related-data-buttons/README.md | 162 +++++ .../RelatedDataButtonsComponent.tsx | 280 +++++++++ .../RelatedDataButtonsConfigPanel.tsx | 558 ++++++++++++++++++ .../RelatedDataButtonsRenderer.tsx | 29 + .../components/related-data-buttons/config.ts | 53 ++ .../components/related-data-buttons/index.ts | 71 +++ .../components/related-data-buttons/types.ts | 109 ++++ frontend/types/input-type-mapping.ts | 17 +- frontend/types/input-types.ts | 32 +- 11 files changed, 1316 insertions(+), 7 deletions(-) create mode 100644 frontend/lib/registry/components/related-data-buttons/README.md create mode 100644 frontend/lib/registry/components/related-data-buttons/RelatedDataButtonsComponent.tsx create mode 100644 frontend/lib/registry/components/related-data-buttons/RelatedDataButtonsConfigPanel.tsx create mode 100644 frontend/lib/registry/components/related-data-buttons/RelatedDataButtonsRenderer.tsx create mode 100644 frontend/lib/registry/components/related-data-buttons/config.ts create mode 100644 frontend/lib/registry/components/related-data-buttons/index.ts create mode 100644 frontend/lib/registry/components/related-data-buttons/types.ts diff --git a/frontend/lib/api/data.ts b/frontend/lib/api/data.ts index 8436dcf4..91c620d9 100644 --- a/frontend/lib/api/data.ts +++ b/frontend/lib/api/data.ts @@ -26,7 +26,14 @@ export const dataApi = { size: number; totalPages: number; }> => { - const response = await apiClient.get(`/data/${tableName}`, { params }); + // filters를 평탄화하여 쿼리 파라미터로 전달 (백엔드 ...filters 형식에 맞춤) + const { filters, ...restParams } = params || {}; + const flattenedParams = { + ...restParams, + ...(filters || {}), // filters 객체를 평탄화 + }; + + const response = await apiClient.get(`/data/${tableName}`, { params: flattenedParams }); const raw = response.data || {}; const items: any[] = (raw.data ?? raw.items ?? raw.rows ?? []) as any[]; diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index b76a4542..e28e1755 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -85,6 +85,9 @@ import "./tax-invoice-list/TaxInvoiceListRenderer"; // 세금계산서 목록, // 🆕 메일 수신자 선택 컴포넌트 import "./mail-recipient-selector/MailRecipientSelectorRenderer"; // 내부 인원 선택 + 외부 이메일 입력 +// 🆕 연관 데이터 버튼 컴포넌트 +import "./related-data-buttons/RelatedDataButtonsRenderer"; // 좌측 선택 데이터 기반 연관 테이블 버튼 표시 + /** * 컴포넌트 초기화 함수 */ diff --git a/frontend/lib/registry/components/related-data-buttons/README.md b/frontend/lib/registry/components/related-data-buttons/README.md new file mode 100644 index 00000000..67212e63 --- /dev/null +++ b/frontend/lib/registry/components/related-data-buttons/README.md @@ -0,0 +1,162 @@ +# RelatedDataButtons 컴포넌트 + +좌측 패널에서 선택한 데이터를 기반으로 연관 테이블의 데이터를 버튼으로 표시하는 컴포넌트 + +## 개요 + +- **ID**: `related-data-buttons` +- **카테고리**: data +- **웹타입**: container +- **버전**: 1.0.0 + +## 사용 사례 + +### 품목별 라우팅 버전 관리 + +``` +┌─────────────────────────────────────────────────────┐ +│ 알루미늄 프레임 [+ 라우팅 버전 추가] │ +│ ITEM001 │ +│ ┌──────────────┐ ┌─────────┐ │ +│ │ 기본 라우팅 ★ │ │ 개선버전 │ │ +│ └──────────────┘ └─────────┘ │ +└─────────────────────────────────────────────────────┘ +``` + +## 데이터 흐름 + +``` +1. 좌측 패널: item_info 선택 + ↓ SplitPanelContext.selectedLeftData +2. RelatedDataButtons: item_code로 item_routing_version 조회 + ↓ 버튼 클릭 시 이벤트 발생 +3. 하위 테이블: routing_version_id로 item_routing_detail 필터링 +``` + +## 설정 옵션 + +### 소스 매핑 (sourceMapping) + +| 속성 | 타입 | 설명 | +|------|------|------| +| sourceTable | string | 좌측 패널 테이블명 (예: item_info) | +| sourceColumn | string | 필터에 사용할 컬럼 (예: item_code) | + +### 헤더 표시 (headerDisplay) + +| 속성 | 타입 | 설명 | +|------|------|------| +| show | boolean | 헤더 표시 여부 | +| titleColumn | string | 제목으로 표시할 컬럼 (예: item_name) | +| subtitleColumn | string | 부제목으로 표시할 컬럼 (예: item_code) | + +### 버튼 데이터 소스 (buttonDataSource) + +| 속성 | 타입 | 설명 | +|------|------|------| +| tableName | string | 조회할 테이블명 (예: item_routing_version) | +| filterColumn | string | 필터링할 컬럼명 (예: item_code) | +| displayColumn | string | 버튼에 표시할 컬럼명 (예: version_name) | +| valueColumn | string | 선택 시 전달할 값 컬럼 (기본: id) | +| orderColumn | string | 정렬 컬럼 | +| orderDirection | "ASC" \| "DESC" | 정렬 방향 | + +### 버튼 스타일 (buttonStyle) + +| 속성 | 타입 | 설명 | +|------|------|------| +| variant | string | 기본 버튼 스타일 (default, outline, secondary, ghost) | +| activeVariant | string | 선택 시 버튼 스타일 | +| size | string | 버튼 크기 (sm, default, lg) | +| defaultIndicator.column | string | 기본 버전 판단 컬럼 | +| defaultIndicator.showStar | boolean | 별표 아이콘 표시 여부 | + +### 추가 버튼 (addButton) + +| 속성 | 타입 | 설명 | +|------|------|------| +| show | boolean | 추가 버튼 표시 여부 | +| label | string | 버튼 라벨 | +| position | "header" \| "inline" | 버튼 위치 | +| modalScreenId | number | 연결할 모달 화면 ID | + +### 이벤트 설정 (events) + +| 속성 | 타입 | 설명 | +|------|------|------| +| targetTable | string | 필터링할 하위 테이블명 | +| targetFilterColumn | string | 하위 테이블의 필터 컬럼명 | + +## 이벤트 + +### related-button-select + +버튼 선택 시 발생하는 커스텀 이벤트 + +```typescript +window.addEventListener("related-button-select", (e: CustomEvent) => { + const { targetTable, filterColumn, filterValue, selectedData } = e.detail; + // 하위 테이블 필터링 처리 +}); +``` + +## 사용 예시 + +### 품목별 라우팅 버전 화면 + +```typescript +const config: RelatedDataButtonsConfig = { + sourceMapping: { + sourceTable: "item_info", + sourceColumn: "item_code", + }, + headerDisplay: { + show: true, + titleColumn: "item_name", + subtitleColumn: "item_code", + }, + buttonDataSource: { + tableName: "item_routing_version", + filterColumn: "item_code", + displayColumn: "version_name", + valueColumn: "id", + }, + buttonStyle: { + variant: "outline", + activeVariant: "default", + defaultIndicator: { + column: "is_default", + showStar: true, + }, + }, + events: { + targetTable: "item_routing_detail", + targetFilterColumn: "routing_version_id", + }, + addButton: { + show: true, + label: "+ 라우팅 버전 추가", + position: "header", + }, + autoSelectFirst: true, +}; +``` + +## 분할 패널과 함께 사용 + +``` +┌─────────────────┬──────────────────────────────────────────────┐ +│ │ [RelatedDataButtons 컴포넌트] │ +│ 품목 목록 │ 품목명 표시 + 버전 버튼들 │ +│ (좌측 패널) ├──────────────────────────────────────────────┤ +│ │ [DataTable 컴포넌트] │ +│ item_info │ 공정 순서 테이블 (item_routing_detail) │ +│ │ related-button-select 이벤트로 필터링 │ +└─────────────────┴──────────────────────────────────────────────┘ +``` + +## 개발자 정보 + +- **생성일**: 2024-12 +- **경로**: `lib/registry/components/related-data-buttons/` + diff --git a/frontend/lib/registry/components/related-data-buttons/RelatedDataButtonsComponent.tsx b/frontend/lib/registry/components/related-data-buttons/RelatedDataButtonsComponent.tsx new file mode 100644 index 00000000..768edfe9 --- /dev/null +++ b/frontend/lib/registry/components/related-data-buttons/RelatedDataButtonsComponent.tsx @@ -0,0 +1,280 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Plus, Star, Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useSplitPanelContext } from "@/contexts/SplitPanelContext"; +import { dataApi } from "@/lib/api/data"; +import type { RelatedDataButtonsConfig, ButtonItem } from "./types"; + +interface RelatedDataButtonsComponentProps { + config: RelatedDataButtonsConfig; + className?: string; + style?: React.CSSProperties; +} + +export const RelatedDataButtonsComponent: React.FC = ({ + config, + className, + style, +}) => { + const [buttons, setButtons] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [loading, setLoading] = useState(false); + const [masterData, setMasterData] = useState | null>(null); + + // SplitPanel Context 연결 + const splitPanelContext = useSplitPanelContext(); + + // 좌측 패널에서 선택된 데이터 감지 + useEffect(() => { + if (!splitPanelContext?.selectedLeftData) { + setMasterData(null); + setButtons([]); + setSelectedId(null); + return; + } + + setMasterData(splitPanelContext.selectedLeftData); + }, [splitPanelContext?.selectedLeftData]); + + // 버튼 데이터 로드 + const loadButtons = useCallback(async () => { + if (!masterData || !config.buttonDataSource?.tableName) { + return; + } + + const filterValue = masterData[config.sourceMapping.sourceColumn]; + if (!filterValue) { + setButtons([]); + return; + } + + setLoading(true); + try { + const { tableName, filterColumn, displayColumn, valueColumn, orderColumn, orderDirection } = config.buttonDataSource; + + const response = await dataApi.getTableData(tableName, { + filters: { [filterColumn]: filterValue }, + sortBy: orderColumn || "created_date", + sortOrder: (orderDirection?.toLowerCase() || "asc") as "asc" | "desc", + size: 50, + }); + + if (response.data && response.data.length > 0) { + const defaultConfig = config.buttonStyle?.defaultIndicator; + + const items: ButtonItem[] = response.data.map((row: Record) => { + let isDefault = false; + if (defaultConfig?.column) { + const val = row[defaultConfig.column]; + const checkValue = defaultConfig.value || "Y"; + isDefault = val === checkValue || val === true || val === "true"; + } + + return { + id: row.id || row[valueColumn || "id"], + displayText: row[displayColumn] || row.id, + value: row[valueColumn || "id"], + isDefault, + rawData: row, + }; + }); + + setButtons(items); + + // 자동 선택: 기본 항목 또는 첫 번째 항목 + if (config.autoSelectFirst && items.length > 0) { + const defaultItem = items.find(item => item.isDefault); + const targetItem = defaultItem || items[0]; + setSelectedId(targetItem.id); + emitSelection(targetItem); + } + } + } catch (error) { + console.error("RelatedDataButtons 데이터 로드 실패:", error); + setButtons([]); + } finally { + setLoading(false); + } + }, [masterData, config.buttonDataSource, config.sourceMapping, config.buttonStyle, config.autoSelectFirst]); + + // masterData 변경 시 버튼 로드 + useEffect(() => { + if (masterData) { + setSelectedId(null); // 마스터 변경 시 선택 초기화 + loadButtons(); + } + }, [masterData, loadButtons]); + + // 선택 이벤트 발생 + const emitSelection = useCallback((item: ButtonItem) => { + if (!config.events?.targetTable || !config.events?.targetFilterColumn) { + return; + } + + // 커스텀 이벤트 발생 (하위 테이블 필터링용) + window.dispatchEvent(new CustomEvent("related-button-select", { + detail: { + targetTable: config.events.targetTable, + filterColumn: config.events.targetFilterColumn, + filterValue: item.value, + selectedData: item.rawData, + }, + })); + + console.log("📌 RelatedDataButtons 선택 이벤트:", { + targetTable: config.events.targetTable, + filterColumn: config.events.targetFilterColumn, + filterValue: item.value, + }); + }, [config.events]); + + // 버튼 클릭 핸들러 + const handleButtonClick = useCallback((item: ButtonItem) => { + setSelectedId(item.id); + emitSelection(item); + }, [emitSelection]); + + // 추가 버튼 클릭 + const handleAddClick = useCallback(() => { + if (!config.addButton?.modalScreenId) return; + + const filterValue = masterData?.[config.sourceMapping.sourceColumn]; + + window.dispatchEvent(new CustomEvent("open-screen-modal", { + detail: { + screenId: config.addButton.modalScreenId, + initialData: { + [config.buttonDataSource.filterColumn]: filterValue, + }, + onSuccess: () => { + loadButtons(); // 모달 성공 후 새로고침 + }, + }, + })); + }, [config.addButton, config.buttonDataSource.filterColumn, config.sourceMapping.sourceColumn, masterData, loadButtons]); + + // 버튼 variant 계산 + const getButtonVariant = useCallback((item: ButtonItem): "default" | "outline" | "secondary" | "ghost" => { + if (selectedId === item.id) { + return config.buttonStyle?.activeVariant || "default"; + } + return config.buttonStyle?.variant || "outline"; + }, [selectedId, config.buttonStyle]); + + // 마스터 데이터 없음 + if (!masterData) { + return ( +
+

+ 좌측에서 항목을 선택하세요 +

+
+ ); + } + + const headerConfig = config.headerDisplay; + const addButtonConfig = config.addButton; + + return ( +
+ {/* 헤더 영역 */} + {headerConfig?.show !== false && ( +
+
+ {/* 제목 (품목명 등) */} + {headerConfig?.titleColumn && masterData[headerConfig.titleColumn] && ( +

+ {masterData[headerConfig.titleColumn]} +

+ )} + {/* 부제목 (품목코드 등) */} + {headerConfig?.subtitleColumn && masterData[headerConfig.subtitleColumn] && ( +

+ {masterData[headerConfig.subtitleColumn]} +

+ )} +
+ + {/* 헤더 위치 추가 버튼 */} + {addButtonConfig?.show && addButtonConfig?.position === "header" && ( + + )} +
+ )} + + {/* 버튼 영역 */} +
+ {loading ? ( +
+ +
+ ) : buttons.length === 0 ? ( +
+

+ {config.emptyMessage || "데이터가 없습니다"} +

+ {/* 인라인 추가 버튼 (데이터 없을 때) */} + {addButtonConfig?.show && addButtonConfig?.position !== "header" && ( + + )} +
+ ) : ( +
+ {buttons.map((item) => ( + + ))} + + {/* 인라인 추가 버튼 */} + {addButtonConfig?.show && addButtonConfig?.position !== "header" && ( + + )} +
+ )} +
+
+ ); +}; + +export default RelatedDataButtonsComponent; diff --git a/frontend/lib/registry/components/related-data-buttons/RelatedDataButtonsConfigPanel.tsx b/frontend/lib/registry/components/related-data-buttons/RelatedDataButtonsConfigPanel.tsx new file mode 100644 index 00000000..6c7d026a --- /dev/null +++ b/frontend/lib/registry/components/related-data-buttons/RelatedDataButtonsConfigPanel.tsx @@ -0,0 +1,558 @@ +"use client"; + +import React, { useState, useEffect, useCallback, useMemo } from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Button } from "@/components/ui/button"; +import { Check, ChevronsUpDown } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { tableManagementApi, getTableColumns } from "@/lib/api/tableManagement"; +import type { RelatedDataButtonsConfig } from "./types"; + +interface TableInfo { + tableName: string; + displayName?: string; +} + +interface ColumnInfo { + columnName: string; + columnLabel?: string; +} + +interface RelatedDataButtonsConfigPanelProps { + config: RelatedDataButtonsConfig; + onChange: (config: RelatedDataButtonsConfig) => void; + tables?: TableInfo[]; +} + +export const RelatedDataButtonsConfigPanel: React.FC = ({ + config, + onChange, + tables: propTables = [], +}) => { + const [allTables, setAllTables] = useState([]); + const [sourceTableColumns, setSourceTableColumns] = useState([]); + const [buttonTableColumns, setButtonTableColumns] = useState([]); + + // Popover 상태 + const [sourceTableOpen, setSourceTableOpen] = useState(false); + const [buttonTableOpen, setButtonTableOpen] = useState(false); + + // 전체 테이블 로드 + useEffect(() => { + const loadTables = async () => { + try { + const response = await tableManagementApi.getTableList(); + if (response.success && response.data) { + setAllTables(response.data.map((t: any) => ({ + tableName: t.tableName || t.table_name, + displayName: t.tableLabel || t.table_label || t.displayName, + }))); + } + } catch (error) { + console.error("테이블 목록 로드 실패:", error); + } + }; + loadTables(); + }, []); + + // 소스 테이블 컬럼 로드 + useEffect(() => { + const loadColumns = async () => { + if (!config.sourceMapping?.sourceTable) { + setSourceTableColumns([]); + return; + } + try { + const response = await getTableColumns(config.sourceMapping.sourceTable); + if (response.success && response.data?.columns) { + setSourceTableColumns(response.data.columns.map((c: any) => ({ + columnName: c.columnName || c.column_name, + columnLabel: c.columnLabel || c.column_label || c.displayName, + }))); + } + } catch (error) { + console.error("소스 테이블 컬럼 로드 실패:", error); + } + }; + loadColumns(); + }, [config.sourceMapping?.sourceTable]); + + // 버튼 테이블 컬럼 로드 + useEffect(() => { + const loadColumns = async () => { + if (!config.buttonDataSource?.tableName) { + setButtonTableColumns([]); + return; + } + try { + const response = await getTableColumns(config.buttonDataSource.tableName); + if (response.success && response.data?.columns) { + setButtonTableColumns(response.data.columns.map((c: any) => ({ + columnName: c.columnName || c.column_name, + columnLabel: c.columnLabel || c.column_label || c.displayName, + }))); + } + } catch (error) { + console.error("버튼 테이블 컬럼 로드 실패:", error); + } + }; + loadColumns(); + }, [config.buttonDataSource?.tableName]); + + // 설정 업데이트 헬퍼 + const updateConfig = useCallback((updates: Partial) => { + onChange({ ...config, ...updates }); + }, [config, onChange]); + + const updateSourceMapping = useCallback((updates: Partial) => { + onChange({ + ...config, + sourceMapping: { ...config.sourceMapping, ...updates }, + }); + }, [config, onChange]); + + const updateHeaderDisplay = useCallback((updates: Partial>) => { + onChange({ + ...config, + headerDisplay: { ...config.headerDisplay, ...updates } as any, + }); + }, [config, onChange]); + + const updateButtonDataSource = useCallback((updates: Partial) => { + onChange({ + ...config, + buttonDataSource: { ...config.buttonDataSource, ...updates }, + }); + }, [config, onChange]); + + const updateButtonStyle = useCallback((updates: Partial>) => { + onChange({ + ...config, + buttonStyle: { ...config.buttonStyle, ...updates }, + }); + }, [config, onChange]); + + const updateAddButton = useCallback((updates: Partial>) => { + onChange({ + ...config, + addButton: { ...config.addButton, ...updates }, + }); + }, [config, onChange]); + + const updateEvents = useCallback((updates: Partial>) => { + onChange({ + ...config, + events: { ...config.events, ...updates }, + }); + }, [config, onChange]); + + const tables = allTables.length > 0 ? allTables : propTables; + + return ( +
+ {/* 소스 매핑 (좌측 패널 연결) */} +
+ + +
+ + + + + + + + + 테이블을 찾을 수 없습니다. + + {tables.map((table) => ( + { + updateSourceMapping({ sourceTable: table.tableName }); + setSourceTableOpen(false); + }} + > + + {table.displayName || table.tableName} + {table.displayName && ({table.tableName})} + + ))} + + + + +
+ +
+ + +
+
+ + {/* 헤더 표시 설정 */} +
+
+ + updateHeaderDisplay({ show: checked })} + /> +
+ + {config.headerDisplay?.show !== false && ( +
+
+ + +
+ +
+ + +
+
+ )} +
+ + {/* 버튼 데이터 소스 */} +
+ + +
+ + + + + + + + + 테이블을 찾을 수 없습니다. + + {tables.map((table) => ( + { + updateButtonDataSource({ tableName: table.tableName }); + setButtonTableOpen(false); + }} + > + + {table.displayName || table.tableName} + {table.displayName && ({table.tableName})} + + ))} + + + + +
+ +
+
+ + +
+ +
+ + +
+
+
+ + {/* 버튼 스타일 */} +
+ + +
+
+ + +
+ +
+ + +
+
+ + {/* 기본 표시 설정 */} +
+ + +
+ + {config.buttonStyle?.defaultIndicator?.column && ( +
+
+ updateButtonStyle({ + defaultIndicator: { + ...config.buttonStyle?.defaultIndicator, + column: config.buttonStyle?.defaultIndicator?.column || "", + showStar: checked, + }, + })} + /> + +
+
+ )} +
+ + {/* 이벤트 설정 (하위 테이블 연동) */} +
+ + +
+ + + + + + + + + 테이블을 찾을 수 없습니다. + + {tables.map((table) => ( + { + updateEvents({ targetTable: table.tableName }); + }} + > + + {table.displayName || table.tableName} + + ))} + + + + +
+ +
+ + updateEvents({ targetFilterColumn: e.target.value })} + placeholder="예: routing_version_id" + /> +
+
+ + {/* 추가 버튼 설정 */} +
+
+ + updateAddButton({ show: checked })} + /> +
+ + {config.addButton?.show && ( +
+
+ + updateAddButton({ label: e.target.value })} + placeholder="+ 버전 추가" + /> +
+ +
+ + +
+ +
+ + updateAddButton({ modalScreenId: parseInt(e.target.value) || undefined })} + placeholder="화면 ID" + /> +
+
+ )} +
+ + {/* 기타 설정 */} +
+ + +
+ updateConfig({ autoSelectFirst: checked })} + /> + +
+ +
+ + updateConfig({ emptyMessage: e.target.value })} + placeholder="데이터가 없습니다" + /> +
+
+
+ ); +}; + +export default RelatedDataButtonsConfigPanel; + diff --git a/frontend/lib/registry/components/related-data-buttons/RelatedDataButtonsRenderer.tsx b/frontend/lib/registry/components/related-data-buttons/RelatedDataButtonsRenderer.tsx new file mode 100644 index 00000000..07501e65 --- /dev/null +++ b/frontend/lib/registry/components/related-data-buttons/RelatedDataButtonsRenderer.tsx @@ -0,0 +1,29 @@ +"use client"; + +import React from "react"; +import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; +import { RelatedDataButtonsDefinition } from "./index"; +import { RelatedDataButtonsComponent } from "./RelatedDataButtonsComponent"; + +/** + * RelatedDataButtons 렌더러 + * 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록 + */ +export class RelatedDataButtonsRenderer extends AutoRegisteringComponentRenderer { + static componentDefinition = RelatedDataButtonsDefinition; + + render(): React.ReactElement { + const { component } = this.props; + + return ( + + ); + } +} + +// 자동 등록 실행 +RelatedDataButtonsRenderer.registerSelf(); diff --git a/frontend/lib/registry/components/related-data-buttons/config.ts b/frontend/lib/registry/components/related-data-buttons/config.ts new file mode 100644 index 00000000..093bf59f --- /dev/null +++ b/frontend/lib/registry/components/related-data-buttons/config.ts @@ -0,0 +1,53 @@ +import type { ComponentConfig } from "@/lib/registry/types"; + +export const relatedDataButtonsConfig: ComponentConfig = { + id: "related-data-buttons", + name: "연관 데이터 버튼", + description: "좌측 패널에서 선택한 데이터를 기반으로 연관 테이블의 데이터를 버튼으로 표시합니다. 예: 품목 선택 → 라우팅 버전 버튼들", + category: "data", + webType: "container", + version: "1.0.0", + icon: "LayoutList", + defaultConfig: { + sourceMapping: { + sourceTable: "", + sourceColumn: "", + }, + headerDisplay: { + show: true, + titleColumn: "", + subtitleColumn: "", + }, + buttonDataSource: { + tableName: "", + filterColumn: "", + displayColumn: "", + valueColumn: "id", + orderColumn: "created_date", + orderDirection: "ASC", + }, + buttonStyle: { + variant: "outline", + activeVariant: "default", + size: "default", + defaultIndicator: { + column: "", + showStar: true, + }, + }, + addButton: { + show: false, + label: "+ 버전 추가", + position: "header", + }, + events: { + targetTable: "", + targetFilterColumn: "", + }, + autoSelectFirst: true, + emptyMessage: "데이터가 없습니다", + }, + configPanelComponent: "RelatedDataButtonsConfigPanel", + rendererComponent: "RelatedDataButtonsRenderer", +}; + diff --git a/frontend/lib/registry/components/related-data-buttons/index.ts b/frontend/lib/registry/components/related-data-buttons/index.ts new file mode 100644 index 00000000..0e5eae44 --- /dev/null +++ b/frontend/lib/registry/components/related-data-buttons/index.ts @@ -0,0 +1,71 @@ +"use client"; + +import React from "react"; +import { createComponentDefinition } from "../../utils/createComponentDefinition"; +import { ComponentCategory } from "@/types/component"; +import { RelatedDataButtonsComponent } from "./RelatedDataButtonsComponent"; +import { RelatedDataButtonsConfigPanel } from "./RelatedDataButtonsConfigPanel"; + +/** + * RelatedDataButtons 컴포넌트 정의 + * 좌측 패널에서 선택한 데이터를 기반으로 연관 테이블의 데이터를 버튼으로 표시 + */ +export const RelatedDataButtonsDefinition = createComponentDefinition({ + id: "related-data-buttons", + name: "연관 데이터 버튼", + nameEng: "Related Data Buttons", + description: "좌측 패널에서 선택한 데이터를 기반으로 연관 테이블의 데이터를 버튼으로 표시합니다. 예: 품목 선택 → 라우팅 버전 버튼들", + category: ComponentCategory.DATA, + webType: "container", + component: RelatedDataButtonsComponent, + defaultConfig: { + sourceMapping: { + sourceTable: "", + sourceColumn: "", + }, + headerDisplay: { + show: true, + titleColumn: "", + subtitleColumn: "", + }, + buttonDataSource: { + tableName: "", + filterColumn: "", + displayColumn: "", + valueColumn: "id", + orderColumn: "created_date", + orderDirection: "ASC", + }, + buttonStyle: { + variant: "outline", + activeVariant: "default", + size: "default", + defaultIndicator: { + column: "", + showStar: true, + }, + }, + addButton: { + show: false, + label: "+ 버전 추가", + position: "header", + }, + events: { + targetTable: "", + targetFilterColumn: "", + }, + autoSelectFirst: true, + emptyMessage: "데이터가 없습니다", + }, + defaultSize: { width: 400, height: 120 }, + configPanel: RelatedDataButtonsConfigPanel, + icon: "LayoutList", + tags: ["버튼", "연관데이터", "마스터디테일", "라우팅"], + version: "1.0.0", + author: "개발팀", +}); + +// 타입 내보내기 +export type { RelatedDataButtonsConfig, ButtonItem } from "./types"; +export { RelatedDataButtonsComponent } from "./RelatedDataButtonsComponent"; +export { RelatedDataButtonsConfigPanel } from "./RelatedDataButtonsConfigPanel"; diff --git a/frontend/lib/registry/components/related-data-buttons/types.ts b/frontend/lib/registry/components/related-data-buttons/types.ts new file mode 100644 index 00000000..01585b6b --- /dev/null +++ b/frontend/lib/registry/components/related-data-buttons/types.ts @@ -0,0 +1,109 @@ +/** + * RelatedDataButtons 컴포넌트 타입 정의 + * + * 좌측 패널에서 선택한 데이터의 정보를 표시하고, + * 연관 테이블의 데이터를 버튼으로 표시하는 컴포넌트 + * + * 예시: 품목 선택 → 품목명/코드 표시 + 라우팅 버전 버튼들 + */ + +/** + * 헤더 표시 설정 (선택된 마스터 데이터 정보) + */ +export interface HeaderDisplayConfig { + show?: boolean; // 헤더 표시 여부 + titleColumn: string; // 제목으로 표시할 컬럼 (예: item_name) + subtitleColumn?: string; // 부제목으로 표시할 컬럼 (예: item_code) +} + +/** + * 버튼 데이터 소스 설정 + */ +export interface ButtonDataSourceConfig { + tableName: string; // 조회할 테이블명 (예: item_routing_version) + filterColumn: string; // 필터링할 컬럼명 (예: item_code) + displayColumn: string; // 버튼에 표시할 컬럼명 (예: version_name) + valueColumn?: string; // 선택 시 전달할 값 컬럼 (기본: id) + orderColumn?: string; // 정렬 컬럼 + orderDirection?: "ASC" | "DESC"; // 정렬 방향 +} + +/** + * 버튼 스타일 설정 + */ +export interface ButtonStyleConfig { + variant?: "default" | "outline" | "secondary" | "ghost"; + activeVariant?: "default" | "outline" | "secondary"; + size?: "sm" | "default" | "lg"; + // 기본 버전 표시 설정 + defaultIndicator?: { + column: string; // 기본 여부 판단 컬럼 (예: is_default) + value?: string; // 기본 값 (기본: "Y" 또는 true) + showStar?: boolean; // 별표 아이콘 표시 + badgeText?: string; // 뱃지 텍스트 (예: "기본") + }; +} + +/** + * 추가 버튼 설정 + */ +export interface AddButtonConfig { + show?: boolean; + label?: string; // 기본: "+ 버전 추가" + modalScreenId?: number; + position?: "header" | "inline"; // header: 헤더 우측, inline: 버튼들과 함께 +} + +/** + * 이벤트 설정 (하위 테이블 연동) + */ +export interface EventConfig { + // 선택 시 하위 테이블 필터링 + targetTable?: string; // 필터링할 테이블명 (예: item_routing_detail) + targetFilterColumn?: string; // 필터 컬럼명 (예: routing_version_id) + // 커스텀 이벤트 + customEventName?: string; +} + +/** + * 메인 설정 + */ +export interface RelatedDataButtonsConfig { + // 소스 매핑 (좌측 패널 연결) + sourceMapping: { + sourceTable: string; // 좌측 패널 테이블명 + sourceColumn: string; // 필터에 사용할 컬럼 (예: item_code) + }; + + // 헤더 표시 설정 + headerDisplay?: HeaderDisplayConfig; + + // 버튼 데이터 소스 + buttonDataSource: ButtonDataSourceConfig; + + // 버튼 스타일 + buttonStyle?: ButtonStyleConfig; + + // 추가 버튼 + addButton?: AddButtonConfig; + + // 이벤트 설정 + events?: EventConfig; + + // 자동 선택 + autoSelectFirst?: boolean; // 첫 번째 (또는 기본) 항목 자동 선택 + + // 빈 상태 메시지 + emptyMessage?: string; +} + +/** + * 버튼 아이템 데이터 + */ +export interface ButtonItem { + id: string; + displayText: string; + value: string; + isDefault: boolean; + rawData: Record; +} diff --git a/frontend/types/input-type-mapping.ts b/frontend/types/input-type-mapping.ts index 30d87244..4bddfe5f 100644 --- a/frontend/types/input-type-mapping.ts +++ b/frontend/types/input-type-mapping.ts @@ -8,10 +8,11 @@ import { WebType } from "./unified-core"; /** - * 9개 핵심 입력 타입 + * 핵심 입력 타입 */ export type BaseInputType = | "text" // 텍스트 + | "textarea" // 텍스트 에리어 (여러 줄) | "number" // 숫자 | "date" // 날짜 | "code" // 코드 @@ -34,16 +35,18 @@ export interface DetailTypeOption { * 입력 타입별 세부 타입 매핑 */ export const INPUT_TYPE_DETAIL_TYPES: Record = { - // 텍스트 → text, email, tel, url, textarea, password + // 텍스트 → text, email, tel, url, password text: [ { value: "text", label: "일반 텍스트", description: "기본 텍스트 입력" }, { value: "email", label: "이메일", description: "이메일 주소 입력" }, { value: "tel", label: "전화번호", description: "전화번호 입력" }, { value: "url", label: "URL", description: "웹사이트 주소 입력" }, - { value: "textarea", label: "여러 줄 텍스트", description: "긴 텍스트 입력" }, { value: "password", label: "비밀번호", description: "비밀번호 입력 (마스킹)" }, ], + // 텍스트 에리어 → textarea + textarea: [{ value: "textarea", label: "텍스트 에리어", description: "여러 줄 텍스트 입력" }], + // 숫자 → number, decimal, currency, percentage number: [ { value: "number", label: "정수", description: "정수 숫자 입력" }, @@ -102,8 +105,13 @@ export const INPUT_TYPE_DETAIL_TYPES: Record * 웹타입에서 기본 입력 타입 추출 */ export function getBaseInputType(webType: WebType): BaseInputType { + // textarea (별도 타입으로 분리) + if (webType === "textarea") { + return "textarea"; + } + // text 계열 - if (["text", "email", "tel", "url", "textarea", "password"].includes(webType)) { + if (["text", "email", "tel", "url", "password"].includes(webType)) { return "text"; } @@ -167,6 +175,7 @@ export function getDefaultDetailType(baseInputType: BaseInputType): WebType { */ export const BASE_INPUT_TYPE_OPTIONS: Array<{ value: BaseInputType; label: string; description: string }> = [ { value: "text", label: "텍스트", description: "텍스트 입력 필드" }, + { value: "textarea", label: "텍스트 에리어", description: "여러 줄 텍스트 입력" }, { value: "number", label: "숫자", description: "숫자 입력 필드" }, { value: "date", label: "날짜", description: "날짜/시간 선택" }, { value: "code", label: "코드", description: "공통 코드 선택" }, diff --git a/frontend/types/input-types.ts b/frontend/types/input-types.ts index e172e620..e3944cf1 100644 --- a/frontend/types/input-types.ts +++ b/frontend/types/input-types.ts @@ -5,9 +5,10 @@ * 주의: 이 파일을 수정할 때는 반드시 백엔드 타입도 함께 업데이트 해야 합니다. */ -// 9개 핵심 입력 타입 +// 핵심 입력 타입 export type InputType = | "text" // 텍스트 + | "textarea" // 텍스트 에리어 (여러 줄 입력) | "number" // 숫자 | "date" // 날짜 | "code" // 코드 @@ -42,6 +43,13 @@ export const INPUT_TYPE_OPTIONS: InputTypeOption[] = [ category: "basic", icon: "Type", }, + { + value: "textarea", + label: "텍스트 에리어", + description: "여러 줄 텍스트 입력", + category: "basic", + icon: "AlignLeft", + }, { value: "number", label: "숫자", @@ -130,6 +138,11 @@ export const INPUT_TYPE_DEFAULT_CONFIGS: Record> maxLength: 500, placeholder: "텍스트를 입력하세요", }, + textarea: { + maxLength: 2000, + rows: 4, + placeholder: "내용을 입력하세요", + }, number: { min: 0, step: 1, @@ -163,13 +176,17 @@ export const INPUT_TYPE_DEFAULT_CONFIGS: Record> radio: { inline: false, }, + image: { + placeholder: "이미지를 선택하세요", + accept: "image/*", + }, }; // 레거시 웹 타입 → 입력 타입 매핑 export const WEB_TYPE_TO_INPUT_TYPE: Record = { // 텍스트 관련 text: "text", - textarea: "text", + textarea: "textarea", email: "text", tel: "text", url: "text", @@ -204,6 +221,7 @@ export const WEB_TYPE_TO_INPUT_TYPE: Record = { // 입력 타입 → 웹 타입 역매핑 (화면관리 시스템 호환용) export const INPUT_TYPE_TO_WEB_TYPE: Record = { text: "text", + textarea: "textarea", number: "number", date: "date", code: "code", @@ -212,6 +230,7 @@ export const INPUT_TYPE_TO_WEB_TYPE: Record = { select: "select", checkbox: "checkbox", radio: "radio", + image: "image", }; // 입력 타입 변환 함수 @@ -226,6 +245,11 @@ export const INPUT_TYPE_VALIDATION_RULES: Record> trim: true, maxLength: 500, }, + textarea: { + type: "string", + trim: true, + maxLength: 2000, + }, number: { type: "number", allowFloat: true, @@ -258,4 +282,8 @@ export const INPUT_TYPE_VALIDATION_RULES: Record> type: "string", options: true, }, + image: { + type: "string", + required: false, + }, }; From d6f40f3cd342d63237d1128c60186e13ef42346a Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 16 Dec 2025 18:02:08 +0900 Subject: [PATCH 11/11] =?UTF-8?q?=EB=B2=84=ED=8A=BC=EB=B3=84=EB=A1=9C=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=ED=95=84=ED=84=B0=EB=A7=81?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screen/InteractiveDataTable.tsx | 45 ++- frontend/components/screen/SaveModal.tsx | 47 +++ .../config-panels/ButtonConfigPanel.tsx | 1 + .../screen/panels/UnifiedPropertiesPanel.tsx | 12 + .../lib/registry/DynamicComponentRenderer.tsx | 5 + .../EntitySearchInputComponent.tsx | 161 +++++---- .../RelatedDataButtonsComponent.tsx | 160 ++++++++- .../RelatedDataButtonsConfigPanel.tsx | 336 +++++++++++++++++- .../components/related-data-buttons/types.ts | 19 + .../table-list/TableListComponent.tsx | 52 ++- .../textarea-basic/TextareaBasicComponent.tsx | 24 +- frontend/lib/utils/buttonActions.ts | 160 +++++++++ 12 files changed, 909 insertions(+), 113 deletions(-) diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index e44a356c..a1015ac6 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -311,6 +311,41 @@ export const InteractiveDataTable: React.FC = ({ }; }, [currentPage, searchValues, loadData, component.tableName]); + // 🆕 RelatedDataButtons 선택 이벤트 리스너 (버튼 선택 시 테이블 필터링) + const [relatedButtonFilter, setRelatedButtonFilter] = useState<{ + filterColumn: string; + filterValue: any; + } | null>(null); + + useEffect(() => { + const handleRelatedButtonSelect = (event: CustomEvent) => { + const { targetTable, filterColumn, filterValue } = event.detail || {}; + + // 이 테이블이 대상 테이블인지 확인 + if (targetTable === component.tableName) { + console.log("📌 [InteractiveDataTable] RelatedDataButtons 필터 적용:", { + tableName: component.tableName, + filterColumn, + filterValue, + }); + setRelatedButtonFilter({ filterColumn, filterValue }); + } + }; + + window.addEventListener("related-button-select" as any, handleRelatedButtonSelect); + + return () => { + window.removeEventListener("related-button-select" as any, handleRelatedButtonSelect); + }; + }, [component.tableName]); + + // relatedButtonFilter 변경 시 데이터 다시 로드 + useEffect(() => { + if (relatedButtonFilter) { + loadData(1, searchValues); + } + }, [relatedButtonFilter]); + // 카테고리 타입 컬럼의 값 매핑 로드 useEffect(() => { const loadCategoryMappings = async () => { @@ -705,10 +740,17 @@ export const InteractiveDataTable: React.FC = ({ return; } + // 🆕 RelatedDataButtons 필터 적용 + let relatedButtonFilterValues: Record = {}; + if (relatedButtonFilter) { + relatedButtonFilterValues[relatedButtonFilter.filterColumn] = relatedButtonFilter.filterValue; + } + // 검색 파라미터와 연결 필터 병합 const mergedSearchParams = { ...searchParams, ...linkedFilterValues, + ...relatedButtonFilterValues, // 🆕 RelatedDataButtons 필터 추가 }; console.log("🔍 데이터 조회 시작:", { @@ -716,6 +758,7 @@ export const InteractiveDataTable: React.FC = ({ page, pageSize, linkedFilterValues, + relatedButtonFilterValues, mergedSearchParams, }); @@ -822,7 +865,7 @@ export const InteractiveDataTable: React.FC = ({ setLoading(false); } }, - [component.tableName, pageSize, component.autoFilter, splitPanelContext?.selectedLeftData], // 🆕 autoFilter, 연결필터 추가 + [component.tableName, pageSize, component.autoFilter, splitPanelContext?.selectedLeftData, relatedButtonFilter], // 🆕 autoFilter, 연결필터, RelatedDataButtons 필터 추가 ); // 현재 사용자 정보 로드 diff --git a/frontend/components/screen/SaveModal.tsx b/frontend/components/screen/SaveModal.tsx index 4e158719..88ca2534 100644 --- a/frontend/components/screen/SaveModal.tsx +++ b/frontend/components/screen/SaveModal.tsx @@ -101,6 +101,46 @@ export const SaveModal: React.FC = ({ }; }, [onClose]); + // 필수 항목 검증 + const validateRequiredFields = (): { isValid: boolean; missingFields: string[] } => { + const missingFields: string[] = []; + + components.forEach((component) => { + // 컴포넌트의 required 속성 확인 (여러 위치에서 체크) + const isRequired = + component.required === true || + component.style?.required === true || + component.componentConfig?.required === true; + + const columnName = component.columnName || component.style?.columnName; + const label = component.label || component.style?.label || columnName; + + console.log("🔍 필수 항목 검증:", { + componentId: component.id, + columnName, + label, + isRequired, + "component.required": component.required, + "style.required": component.style?.required, + "componentConfig.required": component.componentConfig?.required, + value: formData[columnName || ""], + }); + + if (isRequired && columnName) { + const value = formData[columnName]; + // 값이 비어있는지 확인 (null, undefined, 빈 문자열, 공백만 있는 문자열) + if (value === null || value === undefined || (typeof value === "string" && value.trim() === "")) { + missingFields.push(label || columnName); + } + } + }); + + return { + isValid: missingFields.length === 0, + missingFields, + }; + }; + // 저장 핸들러 const handleSave = async () => { if (!screenData || !screenId) return; @@ -111,6 +151,13 @@ export const SaveModal: React.FC = ({ return; } + // ✅ 필수 항목 검증 + const validation = validateRequiredFields(); + if (!validation.isValid) { + toast.error(`필수 항목을 입력해주세요: ${validation.missingFields.join(", ")}`); + return; + } + try { setIsSaving(true); diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index 5c61fb95..3a126c29 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -645,6 +645,7 @@ export const ButtonConfigPanel: React.FC = ({ 페이지 이동 데이터 전달 데이터 전달 + 모달 열기 + 연관 데이터 버튼 모달 열기 모달 열기 즉시 저장 제어 흐름 diff --git a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx index ecf2671c..ad34df9a 100644 --- a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx +++ b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx @@ -943,6 +943,18 @@ export const UnifiedPropertiesPanel: React.FC = ({
)} + {/* 숨김 옵션 */} +
+ { + handleUpdate("hidden", checked); + handleUpdate("componentConfig.hidden", checked); + }} + className="h-4 w-4" + /> + +
); diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index b3e77cd7..6ca5e68f 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -319,6 +319,11 @@ export const DynamicComponentRenderer: React.FC = // 숨김 값 추출 const hiddenValue = component.hidden || component.componentConfig?.hidden; + // 숨김 처리: 인터랙티브 모드(실제 뷰)에서만 숨김, 디자인 모드에서는 표시 + if (hiddenValue && isInteractive) { + return null; + } + // size.width와 size.height를 style.width와 style.height로 변환 const finalStyle: React.CSSProperties = { ...component.style, diff --git a/frontend/lib/registry/components/entity-search-input/EntitySearchInputComponent.tsx b/frontend/lib/registry/components/entity-search-input/EntitySearchInputComponent.tsx index 6ee22a0c..49d96122 100644 --- a/frontend/lib/registry/components/entity-search-input/EntitySearchInputComponent.tsx +++ b/frontend/lib/registry/components/entity-search-input/EntitySearchInputComponent.tsx @@ -7,19 +7,8 @@ import { Search, X, Check, ChevronsUpDown } from "lucide-react"; import { EntitySearchModal } from "./EntitySearchModal"; import { EntitySearchInputProps, EntitySearchResult } from "./types"; import { cn } from "@/lib/utils"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { dynamicFormApi } from "@/lib/api/dynamicForm"; export function EntitySearchInputComponent({ @@ -44,7 +33,7 @@ export function EntitySearchInputComponent({ component, isInteractive, onFormDataChange, -}: EntitySearchInputProps & { +}: EntitySearchInputProps & { uiMode?: string; component?: any; isInteractive?: boolean; @@ -52,7 +41,7 @@ export function EntitySearchInputComponent({ }) { // uiMode가 있으면 우선 사용, 없으면 modeProp 사용, 기본값 "combo" const mode = (uiMode || modeProp || "combo") as "select" | "modal" | "combo" | "autocomplete"; - + const [modalOpen, setModalOpen] = useState(false); const [selectOpen, setSelectOpen] = useState(false); const [displayValue, setDisplayValue] = useState(""); @@ -74,7 +63,7 @@ export function EntitySearchInputComponent({ const loadOptions = async () => { if (!tableName) return; - + setIsLoadingOptions(true); try { const response = await dynamicFormApi.getTableData(tableName, { @@ -82,7 +71,7 @@ export function EntitySearchInputComponent({ pageSize: 100, // 최대 100개까지 로드 filters: filterCondition, }); - + if (response.success && response.data) { setOptions(response.data); } @@ -93,28 +82,73 @@ export function EntitySearchInputComponent({ } }; - // value가 변경되면 표시값 업데이트 + // value가 변경되면 표시값 업데이트 (외래키 값으로 데이터 조회) useEffect(() => { - if (value && selectedData) { - setDisplayValue(selectedData[displayField] || ""); - } else if (value && mode === "select" && options.length > 0) { - // select 모드에서 value가 있고 options가 로드된 경우 - const found = options.find(opt => opt[valueField] === value); - if (found) { - setSelectedData(found); - setDisplayValue(found[displayField] || ""); + const loadDisplayValue = async () => { + if (value && selectedData) { + // 이미 selectedData가 있으면 표시값만 업데이트 + setDisplayValue(selectedData[displayField] || ""); + } else if (value && mode === "select" && options.length > 0) { + // select 모드에서 value가 있고 options가 로드된 경우 + const found = options.find((opt) => opt[valueField] === value); + if (found) { + setSelectedData(found); + setDisplayValue(found[displayField] || ""); + } + } else if (value && !selectedData && tableName) { + // value는 있지만 selectedData가 없는 경우 (초기 로드 시) + // API로 해당 데이터 조회 + try { + console.log("🔍 [EntitySearchInput] 초기값 조회:", { value, tableName, valueField }); + const response = await dynamicFormApi.getTableData(tableName, { + filters: { [valueField]: value }, + pageSize: 1, + }); + + if (response.success && response.data) { + // 데이터 추출 (중첩 구조 처리) + const responseData = response.data as any; + const dataArray = Array.isArray(responseData) + ? responseData + : responseData?.data + ? Array.isArray(responseData.data) + ? responseData.data + : [responseData.data] + : []; + + if (dataArray.length > 0) { + const foundData = dataArray[0]; + setSelectedData(foundData); + setDisplayValue(foundData[displayField] || ""); + console.log("✅ [EntitySearchInput] 초기값 로드 완료:", foundData); + } else { + // 데이터를 찾지 못한 경우 value 자체를 표시 + console.log("⚠️ [EntitySearchInput] 초기값 데이터 없음, value 표시:", value); + setDisplayValue(String(value)); + } + } else { + console.log("⚠️ [EntitySearchInput] API 응답 실패, value 표시:", value); + setDisplayValue(String(value)); + } + } catch (error) { + console.error("❌ [EntitySearchInput] 초기값 조회 실패:", error); + // 에러 시 value 자체를 표시 + setDisplayValue(String(value)); + } + } else if (!value) { + setDisplayValue(""); + setSelectedData(null); } - } else if (!value) { - setDisplayValue(""); - setSelectedData(null); - } - }, [value, displayField, options, mode, valueField]); + }; + + loadDisplayValue(); + }, [value, displayField, options, mode, valueField, tableName, selectedData]); const handleSelect = (newValue: any, fullData: EntitySearchResult) => { setSelectedData(fullData); setDisplayValue(fullData[displayField] || ""); onChange?.(newValue, fullData); - + // 🆕 onFormDataChange 호출 (formData에 값 저장) if (isInteractive && onFormDataChange && component?.columnName) { onFormDataChange(component.columnName, newValue); @@ -126,7 +160,7 @@ export function EntitySearchInputComponent({ setDisplayValue(""); setSelectedData(null); onChange?.(null, null); - + // 🆕 onFormDataChange 호출 (formData에서 값 제거) if (isInteractive && onFormDataChange && component?.columnName) { onFormDataChange(component.columnName, null); @@ -147,14 +181,19 @@ export function EntitySearchInputComponent({ // 높이 계산 (style에서 height가 있으면 사용, 없으면 기본값) const componentHeight = style?.height; - const inputStyle: React.CSSProperties = componentHeight - ? { height: componentHeight } - : {}; + const inputStyle: React.CSSProperties = componentHeight ? { height: componentHeight } : {}; // select 모드: 검색 가능한 드롭다운 if (mode === "select") { return ( -
+
+ {/* 라벨 렌더링 */} + {component?.label && component?.style?.labelDisplay !== false && ( + + )} - + - + - - 항목을 찾을 수 없습니다. - + 항목을 찾을 수 없습니다. {options.map((option, index) => (
{option[displayField]} {valueField !== displayField && ( - - {option[valueField]} - + {option[valueField]} )}
@@ -221,7 +244,7 @@ export function EntitySearchInputComponent({ {/* 추가 정보 표시 */} {showAdditionalInfo && selectedData && additionalFields.length > 0 && ( -
+
{additionalFields.map((field) => (
{field}: @@ -236,9 +259,16 @@ export function EntitySearchInputComponent({ // modal, combo, autocomplete 모드 return ( -
+
+ {/* 라벨 렌더링 */} + {component?.label && component?.style?.labelDisplay !== false && ( + + )} {/* 입력 필드 */} -
+
@@ -278,7 +308,7 @@ export function EntitySearchInputComponent({ {/* 추가 정보 표시 */} {showAdditionalInfo && selectedData && additionalFields.length > 0 && ( -
+
{additionalFields.map((field) => (
{field}: @@ -306,4 +336,3 @@ export function EntitySearchInputComponent({
); } - diff --git a/frontend/lib/registry/components/related-data-buttons/RelatedDataButtonsComponent.tsx b/frontend/lib/registry/components/related-data-buttons/RelatedDataButtonsComponent.tsx index 768edfe9..cd535366 100644 --- a/frontend/lib/registry/components/related-data-buttons/RelatedDataButtonsComponent.tsx +++ b/frontend/lib/registry/components/related-data-buttons/RelatedDataButtonsComponent.tsx @@ -1,13 +1,24 @@ "use client"; -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useRef } from "react"; import { Button } from "@/components/ui/button"; -import { Plus, Star, Loader2 } from "lucide-react"; +import { Plus, Star, Loader2, ExternalLink } from "lucide-react"; import { cn } from "@/lib/utils"; import { useSplitPanelContext } from "@/contexts/SplitPanelContext"; import { dataApi } from "@/lib/api/data"; import type { RelatedDataButtonsConfig, ButtonItem } from "./types"; +// 전역 상태: 현재 선택된 버튼 데이터를 외부에서 접근 가능하게 +declare global { + interface Window { + __relatedButtonsSelectedData?: { + selectedItem: ButtonItem | null; + masterData: Record | null; + config: RelatedDataButtonsConfig | null; + }; + } +} + interface RelatedDataButtonsComponentProps { config: RelatedDataButtonsConfig; className?: string; @@ -21,12 +32,27 @@ export const RelatedDataButtonsComponent: React.FC { const [buttons, setButtons] = useState([]); const [selectedId, setSelectedId] = useState(null); + const [selectedItem, setSelectedItem] = useState(null); const [loading, setLoading] = useState(false); const [masterData, setMasterData] = useState | null>(null); // SplitPanel Context 연결 const splitPanelContext = useSplitPanelContext(); + // 선택된 데이터를 전역 상태에 저장 (외부 버튼에서 접근용) + useEffect(() => { + window.__relatedButtonsSelectedData = { + selectedItem, + masterData, + config, + }; + console.log("🔄 [RelatedDataButtons] 전역 상태 업데이트:", { + selectedItem, + hasConfig: !!config, + modalLink: config?.modalLink, + }); + }, [selectedItem, masterData, config]); + // 좌측 패널에서 선택된 데이터 감지 useEffect(() => { if (!splitPanelContext?.selectedLeftData) { @@ -89,6 +115,7 @@ export const RelatedDataButtonsComponent: React.FC item.isDefault); const targetItem = defaultItem || items[0]; setSelectedId(targetItem.id); + setSelectedItem(targetItem); emitSelection(targetItem); } } @@ -104,6 +131,7 @@ export const RelatedDataButtonsComponent: React.FC { if (masterData) { setSelectedId(null); // 마스터 변경 시 선택 초기화 + setSelectedItem(null); loadButtons(); } }, [masterData, loadButtons]); @@ -134,9 +162,82 @@ export const RelatedDataButtonsComponent: React.FC { setSelectedId(item.id); + setSelectedItem(item); emitSelection(item); }, [emitSelection]); + // 모달 열기 (선택된 버튼 데이터 전달) + const openModalWithSelectedData = useCallback((targetScreenId: number) => { + if (!selectedItem) { + console.warn("선택된 버튼이 없습니다."); + return; + } + + // 데이터 매핑 적용 + const initialData: Record = {}; + + if (config.modalLink?.dataMapping) { + config.modalLink.dataMapping.forEach(mapping => { + if (mapping.sourceField === "value") { + initialData[mapping.targetField] = selectedItem.value; + } else if (mapping.sourceField === "id") { + initialData[mapping.targetField] = selectedItem.id; + } else if (selectedItem.rawData[mapping.sourceField] !== undefined) { + initialData[mapping.targetField] = selectedItem.rawData[mapping.sourceField]; + } + }); + } else { + // 기본 매핑: id를 routing_version_id로 전달 + initialData["routing_version_id"] = selectedItem.value || selectedItem.id; + } + + console.log("📤 RelatedDataButtons 모달 열기:", { + targetScreenId, + selectedItem, + initialData, + }); + + window.dispatchEvent(new CustomEvent("open-screen-modal", { + detail: { + screenId: targetScreenId, + initialData, + onSuccess: () => { + loadButtons(); // 모달 성공 후 새로고침 + }, + }, + })); + }, [selectedItem, config.modalLink, loadButtons]); + + // 외부 버튼에서 모달 열기 요청 수신 + useEffect(() => { + const handleExternalModalOpen = (event: CustomEvent) => { + const { targetScreenId, componentId } = event.detail || {}; + + // componentId가 지정되어 있고 현재 컴포넌트가 아니면 무시 + if (componentId && componentId !== config.sourceMapping?.sourceTable) { + return; + } + + if (targetScreenId && selectedItem) { + openModalWithSelectedData(targetScreenId); + } + }; + + window.addEventListener("related-buttons-open-modal" as any, handleExternalModalOpen); + return () => { + window.removeEventListener("related-buttons-open-modal" as any, handleExternalModalOpen); + }; + }, [selectedItem, config.sourceMapping, openModalWithSelectedData]); + + // 내부 모달 링크 버튼 클릭 + const handleModalLinkClick = useCallback(() => { + if (!config.modalLink?.targetScreenId) { + console.warn("모달 링크 설정이 없습니다."); + return; + } + openModalWithSelectedData(config.modalLink.targetScreenId); + }, [config.modalLink, openModalWithSelectedData]); + // 추가 버튼 클릭 const handleAddClick = useCallback(() => { if (!config.addButton?.modalScreenId) return; @@ -177,6 +278,7 @@ export const RelatedDataButtonsComponent: React.FC @@ -198,18 +300,34 @@ export const RelatedDataButtonsComponent: React.FC - {/* 헤더 위치 추가 버튼 */} - {addButtonConfig?.show && addButtonConfig?.position === "header" && ( - - )} +
+ {/* 모달 링크 버튼 (헤더 위치) */} + {modalLinkConfig?.enabled && modalLinkConfig?.triggerType === "button" && modalLinkConfig?.buttonPosition === "header" && ( + + )} + + {/* 헤더 위치 추가 버튼 */} + {addButtonConfig?.show && addButtonConfig?.position === "header" && ( + + )} +
)} @@ -258,6 +376,20 @@ export const RelatedDataButtonsComponent: React.FC ))} + {/* 모달 링크 버튼 (인라인 위치) */} + {modalLinkConfig?.enabled && modalLinkConfig?.triggerType === "button" && modalLinkConfig?.buttonPosition !== "header" && ( + + )} + {/* 인라인 추가 버튼 */} {addButtonConfig?.show && addButtonConfig?.position !== "header" && ( + + + + + + 화면을 찾을 수 없습니다. + + {screens.map((screen) => ( + { + onChange(screen.screenId, screen.tableName); + setOpen(false); + }} + className="text-xs" + > + + {screen.screenName} + ({screen.screenId}) + + ))} + + + + + + ); +}; + interface TableInfo { tableName: string; displayName?: string; @@ -37,6 +117,9 @@ export const RelatedDataButtonsConfigPanel: React.FC([]); const [sourceTableColumns, setSourceTableColumns] = useState([]); const [buttonTableColumns, setButtonTableColumns] = useState([]); + const [targetModalTableColumns, setTargetModalTableColumns] = useState([]); // 대상 모달 테이블 컬럼 + const [targetModalTableName, setTargetModalTableName] = useState(""); // 대상 모달 테이블명 + const [eventTargetTableColumns, setEventTargetTableColumns] = useState([]); // 하위 테이블 연동 대상 테이블 컬럼 // Popover 상태 const [sourceTableOpen, setSourceTableOpen] = useState(false); @@ -104,6 +187,69 @@ export const RelatedDataButtonsConfigPanel: React.FC { + const loadTargetScreenTable = async () => { + if (!config.modalLink?.targetScreenId) { + setTargetModalTableName(""); + return; + } + try { + const screenInfo = await screenApi.getScreen(config.modalLink.targetScreenId); + if (screenInfo?.tableName) { + setTargetModalTableName(screenInfo.tableName); + } + } catch (error) { + console.error("대상 모달 화면 정보 로드 실패:", error); + } + }; + loadTargetScreenTable(); + }, [config.modalLink?.targetScreenId]); + + // 대상 모달 테이블 컬럼 로드 + useEffect(() => { + const loadColumns = async () => { + if (!targetModalTableName) { + setTargetModalTableColumns([]); + return; + } + try { + const response = await getTableColumns(targetModalTableName); + if (response.success && response.data?.columns) { + setTargetModalTableColumns(response.data.columns.map((c: any) => ({ + columnName: c.columnName || c.column_name, + columnLabel: c.columnLabel || c.column_label || c.displayName, + }))); + } + } catch (error) { + console.error("대상 모달 테이블 컬럼 로드 실패:", error); + } + }; + loadColumns(); + }, [targetModalTableName]); + + // 하위 테이블 연동 대상 테이블 컬럼 로드 + useEffect(() => { + const loadColumns = async () => { + if (!config.events?.targetTable) { + setEventTargetTableColumns([]); + return; + } + try { + const response = await getTableColumns(config.events.targetTable); + if (response.success && response.data?.columns) { + setEventTargetTableColumns(response.data.columns.map((c: any) => ({ + columnName: c.columnName || c.column_name, + columnLabel: c.columnLabel || c.column_label || c.displayName, + }))); + } + } catch (error) { + console.error("하위 테이블 연동 대상 테이블 컬럼 로드 실패:", error); + } + }; + loadColumns(); + }, [config.events?.targetTable]); + // 설정 업데이트 헬퍼 const updateConfig = useCallback((updates: Partial) => { onChange({ ...config, ...updates }); @@ -151,6 +297,13 @@ export const RelatedDataButtonsConfigPanel: React.FC>) => { + onChange({ + ...config, + modalLink: { ...config.modalLink, ...updates }, + }); + }, [config, onChange]); + const tables = allTables.length > 0 ? allTables : propTables; return ( @@ -471,11 +624,27 @@ export const RelatedDataButtonsConfigPanel: React.FC - updateEvents({ targetFilterColumn: e.target.value })} - placeholder="예: routing_version_id" - /> + onValueChange={(value) => updateEvents({ targetFilterColumn: value })} + > + + + + + {eventTargetTableColumns.map((col) => ( + + {col.columnLabel || col.columnName} + + ))} + + + {eventTargetTableColumns.length === 0 && config.events?.targetTable && ( +

컬럼을 불러오는 중...

+ )} + {!config.events?.targetTable && ( +

먼저 대상 테이블을 선택하세요

+ )}
@@ -517,18 +686,165 @@ export const RelatedDataButtonsConfigPanel: React.FC
- - updateAddButton({ modalScreenId: parseInt(e.target.value) || undefined })} - placeholder="화면 ID" + + updateAddButton({ modalScreenId: screenId })} + placeholder="화면 선택" />
)}
+ {/* 모달 연동 설정 (선택된 버튼 데이터를 모달로 전달) */} +
+
+ + updateModalLink({ enabled: checked })} + /> +
+ + {config.modalLink?.enabled && ( +
+
+ + +
+ + {config.modalLink?.triggerType === "button" && ( + <> +
+ + updateModalLink({ buttonLabel: e.target.value })} + placeholder="공정 추가" + /> +
+ +
+ + +
+ + )} + +
+ + { + updateModalLink({ targetScreenId: screenId }); + if (tableName) { + setTargetModalTableName(tableName); + } + }} + placeholder="화면 선택" + /> + {targetModalTableName && ( +

+ 테이블: {targetModalTableName} +

+ )} +
+ +
+ +

+ 선택된 버튼 데이터를 모달 초기값으로 전달합니다. +

+
+
+ + +
+ +
+ + + {targetModalTableColumns.length === 0 && targetModalTableName && ( +

컬럼을 불러오는 중...

+ )} + {!targetModalTableName && ( +

먼저 대상 모달 화면을 선택하세요

+ )} +
+
+
+
+ )} +
+ {/* 기타 설정 */}
diff --git a/frontend/lib/registry/components/related-data-buttons/types.ts b/frontend/lib/registry/components/related-data-buttons/types.ts index 01585b6b..7f1849ce 100644 --- a/frontend/lib/registry/components/related-data-buttons/types.ts +++ b/frontend/lib/registry/components/related-data-buttons/types.ts @@ -65,6 +65,22 @@ export interface EventConfig { customEventName?: string; } +/** + * 모달 연동 설정 (선택된 버튼 데이터를 모달로 전달) + */ +export interface ModalLinkConfig { + enabled?: boolean; // 모달 연동 활성화 + targetScreenId?: number; // 열릴 모달 화면 ID + triggerType?: "button" | "external"; // button: 별도 버튼, external: 외부 버튼에서 호출 + buttonLabel?: string; // 버튼 텍스트 (triggerType이 button일 때) + buttonPosition?: "header" | "inline"; // 버튼 위치 + // 데이터 매핑: 선택된 버튼 데이터 → 모달 초기값 + dataMapping?: { + sourceField: string; // 버튼 데이터의 필드명 (예: "id", "value") + targetField: string; // 모달에 전달할 필드명 (예: "routing_version_id") + }[]; +} + /** * 메인 설정 */ @@ -90,6 +106,9 @@ export interface RelatedDataButtonsConfig { // 이벤트 설정 events?: EventConfig; + // 모달 연동 설정 (선택된 버튼 데이터를 모달로 전달) + modalLink?: ModalLinkConfig; + // 자동 선택 autoSelectFirst?: boolean; // 첫 번째 (또는 기본) 항목 자동 선택 diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 41a477ab..d1063049 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -304,6 +304,12 @@ export const TableListComponent: React.FC = ({ // 🆕 연결된 필터 상태 (다른 컴포넌트 값으로 필터링) const [linkedFilterValues, setLinkedFilterValues] = useState>({}); + // 🆕 RelatedDataButtons 컴포넌트에서 발생하는 필터 상태 + const [relatedButtonFilter, setRelatedButtonFilter] = useState<{ + filterColumn: string; + filterValue: any; + } | null>(null); + // TableOptions Context const { registerTable, unregisterTable, updateTableDataCount } = useTableOptions(); const [filters, setFilters] = useState([]); @@ -1548,10 +1554,21 @@ export const TableListComponent: React.FC = ({ return; } - // 검색 필터와 연결 필터 병합 + // 🆕 RelatedDataButtons 필터 값 준비 + let relatedButtonFilterValues: Record = {}; + if (relatedButtonFilter) { + relatedButtonFilterValues[relatedButtonFilter.filterColumn] = { + value: relatedButtonFilter.filterValue, + operator: "equals", + }; + console.log("🔗 [TableList] RelatedDataButtons 필터 적용:", relatedButtonFilterValues); + } + + // 검색 필터, 연결 필터, RelatedDataButtons 필터 병합 const filters = { ...(Object.keys(searchValues).length > 0 ? searchValues : {}), ...linkedFilterValues, + ...relatedButtonFilterValues, // 🆕 RelatedDataButtons 필터 추가 }; const hasFilters = Object.keys(filters).length > 0; @@ -1748,6 +1765,8 @@ export const TableListComponent: React.FC = ({ splitPanelPosition, currentSplitPosition, splitPanelContext?.selectedLeftData, + // 🆕 RelatedDataButtons 필터 추가 + relatedButtonFilter, ]); const fetchTableDataDebounced = useCallback( @@ -4764,6 +4783,37 @@ export const TableListComponent: React.FC = ({ }; }, [tableConfig.selectedTable, isDesignMode]); + // 🆕 RelatedDataButtons 선택 이벤트 리스너 (버튼 선택 시 테이블 필터링) + useEffect(() => { + const handleRelatedButtonSelect = (event: CustomEvent) => { + const { targetTable, filterColumn, filterValue } = event.detail || {}; + + // 이 테이블이 대상 테이블인지 확인 + if (targetTable === tableConfig.selectedTable) { + console.log("📌 [TableList] RelatedDataButtons 필터 적용:", { + tableName: tableConfig.selectedTable, + filterColumn, + filterValue, + }); + setRelatedButtonFilter({ filterColumn, filterValue }); + } + }; + + window.addEventListener("related-button-select" as any, handleRelatedButtonSelect); + + return () => { + window.removeEventListener("related-button-select" as any, handleRelatedButtonSelect); + }; + }, [tableConfig.selectedTable]); + + // 🆕 relatedButtonFilter 변경 시 데이터 다시 로드 + useEffect(() => { + if (relatedButtonFilter && !isDesignMode) { + console.log("🔄 [TableList] RelatedDataButtons 필터 변경으로 데이터 새로고침:", relatedButtonFilter); + setRefreshTrigger((prev) => prev + 1); + } + }, [relatedButtonFilter, isDesignMode]); + // 🎯 컬럼 너비 자동 계산 (내용 기반) const calculateOptimalColumnWidth = useCallback( (columnName: string, displayName: string): number => { diff --git a/frontend/lib/registry/components/textarea-basic/TextareaBasicComponent.tsx b/frontend/lib/registry/components/textarea-basic/TextareaBasicComponent.tsx index eea2f113..a1e441a7 100644 --- a/frontend/lib/registry/components/textarea-basic/TextareaBasicComponent.tsx +++ b/frontend/lib/registry/components/textarea-basic/TextareaBasicComponent.tsx @@ -55,29 +55,11 @@ export const TextareaBasicComponent: React.FC = ({ onClick?.(); }; - // DOM에 전달하면 안 되는 React-specific props 필터링 - const { - selectedScreen, - onZoneComponentDrop, - onZoneClick, - componentConfig: _componentConfig, - component: _component, - isSelected: _isSelected, - onClick: _onClick, - onDragStart: _onDragStart, - onDragEnd: _onDragEnd, - size: _size, - position: _position, - style: _style, - screenId: _screenId, - tableName: _tableName, - onRefresh: _onRefresh, - onClose: _onClose, - ...domProps - } = props; + // DOM에 전달하면 안 되는 React-specific props 필터링 - 모든 커스텀 props 제거 + // domProps를 사용하지 않고 필요한 props만 명시적으로 전달 return ( -
+
{/* 라벨 렌더링 */} {component.label && component.style?.labelDisplay !== false && (