From 47b23d1aa381ad935733ca7f9016ce09946dd657 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Sun, 28 Dec 2025 19:32:13 +0900 Subject: [PATCH] =?UTF-8?q?feat(universal-form-modal):=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=EB=B6=80=20=ED=85=8C=EC=9D=B4=EB=B8=94,=20=EB=8F=99?= =?UTF-8?q?=EC=A0=81=20Select=20=EC=98=B5=EC=85=98,=20=EC=84=9C=EB=B8=8C?= =?UTF-8?q?=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=88=98=EC=A0=95=20=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=EB=B6=80=20=ED=85=8C=EC=9D=B4=EB=B8=94:=20=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=EB=B0=95=EC=8A=A4/=ED=83=AD=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=EC=84=A0=ED=83=9D=20=EC=8B=9C=20=EB=8B=A4?= =?UTF-8?q?=EB=A5=B8=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EA=B4=80=EB=A6=AC=20=EB=8F=99=EC=A0=81=20Select=20?= =?UTF-8?q?=EC=98=B5=EC=85=98:=20=EC=86=8C=EC=8A=A4=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=EC=97=90=EC=84=9C=20=EB=93=9C=EB=A1=AD=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=20=EC=98=B5=EC=85=98=20=EB=8F=99=EC=A0=81=20=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=ED=96=89=20=EC=84=A0=ED=83=9D=20=EB=AA=A8=EB=93=9C?= =?UTF-8?q?:=20Select=20=EA=B0=92=20=EB=B3=80=EA=B2=BD=20=EC=8B=9C=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EC=86=8C=EC=8A=A4=20=ED=96=89=EC=9D=98=20?= =?UTF-8?q?=EC=97=B0=EA=B4=80=20=EC=BB=AC=EB=9F=BC=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=B1=84=EC=9B=80=20=EC=88=98=EC=A0=95=20=EB=AA=A8=EB=93=9C=20?= =?UTF-8?q?=EC=84=9C=EB=B8=8C=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EB=A1=9C?= =?UTF-8?q?=EB=93=9C:=20loadOnEdit=20=EC=98=B5=EC=85=98=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EB=B0=98=EB=B3=B5=20=EC=84=B9=EC=85=98=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=9E=90=EB=8F=99=20=EB=A1=9C=EB=93=9C=20SplitPane?= =?UTF-8?q?lLayout2=20=EB=A9=94=EC=9D=B8=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=EB=B3=91=ED=95=A9:=20=EC=84=9C=EB=B8=8C=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EC=88=98=EC=A0=95=20=EC=8B=9C=20=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=ED=95=A8=EA=BB=98=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=97=B0=EA=B2=B0=20=ED=95=84=EB=93=9C=20=EA=B7=B8?= =?UTF-8?q?=EB=A3=B9=20=ED=91=9C=EC=8B=9C=20=ED=98=95=EC=8B=9D:=20subDispl?= =?UTF-8?q?ayColumn=20=EC=B6=94=EA=B0=80=EB=A1=9C=20=EB=A9=94=EC=9D=B8/?= =?UTF-8?q?=EC=84=9C=EB=B8=8C=20=EC=BB=AC=EB=9F=BC=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20UX=20=EA=B0=9C=EC=84=A0:=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EC=84=A0=ED=83=9D=20UI=EB=A5=BC=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EA=B0=80=EB=8A=A5=ED=95=9C=20Combobox=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=20saveMainAsFirst=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0:=20items=20=EC=97=86=EC=96=B4=EB=8F=84=20?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EA=B0=80=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/tableManagementController.ts | 22 +- frontend/components/screen/EditModal.tsx | 8 - .../SplitPanelLayout2Component.tsx | 41 +- .../SplitPanelLayout2ConfigPanel.tsx | 59 ++ .../components/split-panel-layout2/types.ts | 14 + .../TableSectionRenderer.tsx | 951 ++++++++++++++++- .../UniversalFormModalComponent.tsx | 212 +++- .../components/universal-form-modal/config.ts | 34 + .../modals/FieldDetailSettingsModal.tsx | 362 +++++-- .../modals/SaveSettingsModal.tsx | 644 ++++++++++-- .../modals/TableColumnSettingsModal.tsx | 351 +++++++ .../modals/TableSectionSettingsModal.tsx | 961 +++++++++++++++++- .../components/universal-form-modal/types.ts | 43 +- 13 files changed, 3461 insertions(+), 241 deletions(-) diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 04fa1add..7c84898b 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -1973,15 +1973,21 @@ export async function multiTableSave( for (const subTableConfig of subTables || []) { const { tableName, linkColumn, items, options } = subTableConfig; - if (!tableName || !items || items.length === 0) { - logger.info(`서브 테이블 ${tableName} 스킵: 데이터 없음`); + // saveMainAsFirst가 활성화된 경우, items가 비어있어도 메인 데이터를 서브 테이블에 저장해야 함 + const hasSaveMainAsFirst = options?.saveMainAsFirst && + options?.mainFieldMappings && + options.mainFieldMappings.length > 0; + + if (!tableName || (!items?.length && !hasSaveMainAsFirst)) { + logger.info(`서브 테이블 ${tableName} 스킵: 데이터 없음 (saveMainAsFirst: ${hasSaveMainAsFirst})`); continue; } logger.info(`서브 테이블 ${tableName} 저장 시작:`, { - itemsCount: items.length, + itemsCount: items?.length || 0, linkColumn, options, + hasSaveMainAsFirst, }); // 기존 데이터 삭제 옵션 @@ -1999,7 +2005,15 @@ export async function multiTableSave( } // 메인 데이터도 서브 테이블에 저장 (옵션) - if (options?.saveMainAsFirst && options?.mainFieldMappings && linkColumn?.subColumn) { + // mainFieldMappings가 비어 있으면 건너뜀 (필수 컬럼 누락 방지) + logger.info(`saveMainAsFirst 옵션 확인:`, { + saveMainAsFirst: options?.saveMainAsFirst, + mainFieldMappings: options?.mainFieldMappings, + mainFieldMappingsLength: options?.mainFieldMappings?.length, + linkColumn, + mainDataKeys: Object.keys(mainData), + }); + if (options?.saveMainAsFirst && options?.mainFieldMappings && options.mainFieldMappings.length > 0 && linkColumn?.subColumn) { const mainSubItem: Record = { [linkColumn.subColumn]: savedPkValue, }; diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 1969f562..58149088 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -996,14 +996,6 @@ export const EditModal: React.FC = ({ className }) => { screenId: modalState.screenId, // 화면 ID 추가 }; - // 🔍 디버깅: enrichedFormData 확인 - console.log("🔑 [EditModal] enrichedFormData 생성:", { - "screenData.screenInfo": screenData.screenInfo, - "screenData.screenInfo?.tableName": screenData.screenInfo?.tableName, - "enrichedFormData.tableName": enrichedFormData.tableName, - "enrichedFormData.id": enrichedFormData.id, - }); - return ( { + async (item: any) => { // 수정용 모달 화면 ID 결정 (editModalScreenId 우선, 없으면 addModalScreenId 사용) const modalScreenId = config.rightPanel?.editModalScreenId || config.rightPanel?.addModalScreenId; @@ -684,13 +684,42 @@ export const SplitPanelLayout2Component: React.FC { if (selectedLeftItem) { @@ -700,9 +729,9 @@ export const SplitPanelLayout2Component: React.FC()); } else if (itemToDelete) { - // 단일 삭제 - 해당 항목 데이터를 body로 전달 + // 단일 삭제 - 해당 항목 데이터를 배열로 감싸서 body로 전달 (백엔드가 배열을 기대함) console.log(`[SplitPanelLayout2] ${deleteTargetPanel === "left" ? "좌측" : "우측"} 단일 삭제:`, itemToDelete); await apiClient.delete(`/table-management/tables/${tableName}/delete`, { - data: itemToDelete, + data: [itemToDelete], }); toast.success("항목이 삭제되었습니다."); } diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx index 5094a292..8ff83b6f 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx @@ -1343,6 +1343,65 @@ export const SplitPanelLayout2ConfigPanel: React.FC )} + + {/* 수정 시 메인 테이블 조회 설정 */} + {config.rightPanel?.showEditButton && ( +
+
+ + { + if (checked) { + updateConfig("rightPanel.mainTableForEdit", { + tableName: "", + linkColumn: { mainColumn: "", subColumn: "" }, + }); + } else { + updateConfig("rightPanel.mainTableForEdit", undefined); + } + }} + /> +
+

+ 우측 패널이 서브 테이블일 때, 수정 모달에 메인 테이블 데이터도 함께 전달 +

+ + {config.rightPanel?.mainTableForEdit && ( +
+
+ + updateConfig("rightPanel.mainTableForEdit.tableName", e.target.value)} + placeholder="예: user_info" + className="h-7 text-xs mt-1" + /> +
+
+
+ + updateConfig("rightPanel.mainTableForEdit.linkColumn.mainColumn", e.target.value)} + placeholder="예: user_id" + className="h-7 text-xs mt-1" + /> +
+
+ + updateConfig("rightPanel.mainTableForEdit.linkColumn.subColumn", e.target.value)} + placeholder="예: user_id" + className="h-7 text-xs mt-1" + /> +
+
+
+ )} +
+ )} diff --git a/frontend/lib/registry/components/split-panel-layout2/types.ts b/frontend/lib/registry/components/split-panel-layout2/types.ts index fbe8c912..ae8c71ed 100644 --- a/frontend/lib/registry/components/split-panel-layout2/types.ts +++ b/frontend/lib/registry/components/split-panel-layout2/types.ts @@ -211,6 +211,20 @@ export interface RightPanelConfig { * - 결과: 부서별 사원 목록에 이메일, 전화번호 등 개인정보 함께 표시 */ joinTables?: JoinTableConfig[]; + + /** + * 수정 시 메인 테이블 데이터 조회 설정 + * 우측 패널이 서브 테이블(예: user_dept)이고, 수정 모달이 메인 테이블(예: user_info) 기준일 때 + * 수정 버튼 클릭 시 메인 테이블 데이터를 함께 조회하여 모달에 전달합니다. + */ + mainTableForEdit?: { + tableName: string; // 메인 테이블명 (예: user_info) + linkColumn: { + mainColumn: string; // 메인 테이블의 연결 컬럼 (예: user_id) + subColumn: string; // 서브 테이블의 연결 컬럼 (예: user_id) + }; + }; + // 탭 설정 tabConfig?: TabConfig; } diff --git a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx index 047849b6..64418541 100644 --- a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx +++ b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx @@ -2,20 +2,23 @@ import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; -import { Plus, Columns, AlignJustify } from "lucide-react"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Badge } from "@/components/ui/badge"; +import { Plus, Columns, AlignJustify, Trash2, Search } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; // 기존 ModalRepeaterTable 컴포넌트 재사용 import { RepeaterTable } from "../modal-repeater-table/RepeaterTable"; import { ItemSelectionModal } from "../modal-repeater-table/ItemSelectionModal"; -import { RepeaterColumnConfig, CalculationRule, DynamicDataSourceOption } from "../modal-repeater-table/types"; +import { RepeaterColumnConfig, CalculationRule } from "../modal-repeater-table/types"; // 타입 정의 import { TableSectionConfig, TableColumnConfig, - ValueMappingConfig, TableJoinCondition, FormDataState, } from "./types"; @@ -26,9 +29,16 @@ interface TableSectionRendererProps { formData: FormDataState; onFormDataChange: (field: string, value: any) => void; onTableDataChange: (data: any[]) => void; + // 조건부 테이블용 콜백 (조건별 데이터 변경) + onConditionalTableDataChange?: (conditionValue: string, data: any[]) => void; className?: string; } +// 조건부 테이블 데이터 타입 +interface ConditionalTableData { + [conditionValue: string]: any[]; +} + /** * TableColumnConfig를 RepeaterColumnConfig로 변환 * columnModes 또는 lookup이 있으면 dynamicDataSource로 변환 @@ -319,16 +329,30 @@ export function TableSectionRenderer({ formData, onFormDataChange, onTableDataChange, + onConditionalTableDataChange, className, }: TableSectionRendererProps) { - // 테이블 데이터 상태 + // 테이블 데이터 상태 (일반 모드) const [tableData, setTableData] = useState([]); + // 조건부 테이블 데이터 상태 (조건별로 분리) + const [conditionalTableData, setConditionalTableData] = useState({}); + + // 조건부 테이블: 선택된 조건들 (체크박스 모드) + const [selectedConditions, setSelectedConditions] = useState([]); + + // 조건부 테이블: 현재 활성 탭 + const [activeConditionTab, setActiveConditionTab] = useState(""); + + // 조건부 테이블: 현재 모달이 열린 조건 (어떤 조건의 테이블에 추가할지) + const [modalCondition, setModalCondition] = useState(""); + // 모달 상태 const [modalOpen, setModalOpen] = useState(false); - // 체크박스 선택 상태 + // 체크박스 선택 상태 (조건별로 분리) const [selectedRows, setSelectedRows] = useState>(new Set()); + const [conditionalSelectedRows, setConditionalSelectedRows] = useState>>({}); // 너비 조정 트리거 (홀수: 자동맞춤, 짝수: 균등분배) const [widthTrigger, setWidthTrigger] = useState(0); @@ -341,6 +365,257 @@ export function TableSectionRenderer({ // 초기 데이터 로드 완료 플래그 (무한 루프 방지) const initialDataLoadedRef = React.useRef(false); + + // 조건부 테이블 설정 + const conditionalConfig = tableConfig.conditionalTable; + const isConditionalMode = conditionalConfig?.enabled ?? false; + + // 조건부 테이블: 동적 옵션 로드 상태 + const [dynamicOptions, setDynamicOptions] = useState<{ id: string; value: string; label: string }[]>([]); + const [dynamicOptionsLoading, setDynamicOptionsLoading] = useState(false); + const dynamicOptionsLoadedRef = React.useRef(false); + + // 조건부 테이블: 동적 옵션 로드 (optionSource 설정이 있는 경우) + useEffect(() => { + if (!isConditionalMode) return; + if (!conditionalConfig?.optionSource?.enabled) return; + if (dynamicOptionsLoadedRef.current) return; + + const { tableName, valueColumn, labelColumn, filterCondition } = conditionalConfig.optionSource; + + if (!tableName || !valueColumn) return; + + const loadDynamicOptions = async () => { + setDynamicOptionsLoading(true); + try { + // DISTINCT 값을 가져오기 위한 API 호출 + const response = await apiClient.post( + `/table-management/tables/${tableName}/data`, + { + search: filterCondition ? { _raw: filterCondition } : {}, + size: 1000, + page: 1, + } + ); + + if (response.data.success && response.data.data?.data) { + const rows = response.data.data.data; + + // 중복 제거하여 고유 값 추출 + const uniqueValues = new Map(); + for (const row of rows) { + const value = row[valueColumn]; + if (value && !uniqueValues.has(value)) { + const label = labelColumn ? (row[labelColumn] || value) : value; + uniqueValues.set(value, label); + } + } + + // 옵션 배열로 변환 + const options = Array.from(uniqueValues.entries()).map(([value, label], index) => ({ + id: `dynamic_${index}`, + value, + label, + })); + + console.log("[TableSectionRenderer] 동적 옵션 로드 완료:", { + tableName, + valueColumn, + optionCount: options.length, + options, + }); + + setDynamicOptions(options); + dynamicOptionsLoadedRef.current = true; + } + } catch (error) { + console.error("[TableSectionRenderer] 동적 옵션 로드 실패:", error); + } finally { + setDynamicOptionsLoading(false); + } + }; + + loadDynamicOptions(); + }, [isConditionalMode, conditionalConfig?.optionSource]); + + // ============================================ + // 동적 Select 옵션 (소스 테이블에서 드롭다운 옵션 로드) + // ============================================ + + // 소스 테이블 데이터 캐시 (동적 Select 옵션용) + const [sourceDataCache, setSourceDataCache] = useState([]); + const sourceDataLoadedRef = React.useRef(false); + + // 동적 Select 옵션이 있는 컬럼 확인 + const hasDynamicSelectColumns = useMemo(() => { + return tableConfig.columns?.some(col => col.dynamicSelectOptions?.enabled); + }, [tableConfig.columns]); + + // 소스 테이블 데이터 로드 (동적 Select 옵션용) + useEffect(() => { + if (!hasDynamicSelectColumns) return; + if (sourceDataLoadedRef.current) return; + if (!tableConfig.source?.tableName) return; + + const loadSourceData = async () => { + try { + // 조건부 테이블 필터 조건 적용 + const filterCondition: Record = {}; + + // 소스 필터가 활성화되어 있고 조건이 선택되어 있으면 필터 적용 + if (conditionalConfig?.sourceFilter?.enabled && activeConditionTab) { + filterCondition[conditionalConfig.sourceFilter.filterColumn] = activeConditionTab; + } + + const response = await apiClient.post( + `/table-management/tables/${tableConfig.source.tableName}/data`, + { + search: filterCondition, + size: 1000, + page: 1, + } + ); + + if (response.data.success && response.data.data?.data) { + setSourceDataCache(response.data.data.data); + sourceDataLoadedRef.current = true; + console.log("[TableSectionRenderer] 소스 데이터 로드 완료:", { + tableName: tableConfig.source.tableName, + rowCount: response.data.data.data.length, + filter: filterCondition, + }); + } + } catch (error) { + console.error("[TableSectionRenderer] 소스 데이터 로드 실패:", error); + } + }; + + loadSourceData(); + }, [hasDynamicSelectColumns, tableConfig.source?.tableName, conditionalConfig?.sourceFilter, activeConditionTab]); + + // 조건 탭 변경 시 소스 데이터 다시 로드 + useEffect(() => { + if (!hasDynamicSelectColumns) return; + if (!conditionalConfig?.sourceFilter?.enabled) return; + if (!activeConditionTab) return; + + // 조건 변경 시 캐시 리셋하고 다시 로드 + sourceDataLoadedRef.current = false; + setSourceDataCache([]); + }, [activeConditionTab, hasDynamicSelectColumns, conditionalConfig?.sourceFilter?.enabled]); + + // 컬럼별 동적 Select 옵션 생성 + const dynamicSelectOptionsMap = useMemo(() => { + const optionsMap: Record = {}; + + if (!sourceDataCache.length) return optionsMap; + + for (const col of tableConfig.columns || []) { + if (!col.dynamicSelectOptions?.enabled) continue; + + const { sourceField, labelField, distinct = true } = col.dynamicSelectOptions; + + if (!sourceField) continue; + + // 소스 데이터에서 옵션 추출 + const seenValues = new Set(); + const options: { value: string; label: string }[] = []; + + for (const row of sourceDataCache) { + const value = row[sourceField]; + if (value === undefined || value === null || value === "") continue; + + const stringValue = String(value); + + if (distinct && seenValues.has(stringValue)) continue; + seenValues.add(stringValue); + + const label = labelField ? (row[labelField] || stringValue) : stringValue; + options.push({ value: stringValue, label: String(label) }); + } + + optionsMap[col.field] = options; + } + + return optionsMap; + }, [sourceDataCache, tableConfig.columns]); + + // 행 선택 모드: 드롭다운 값 변경 시 같은 소스 행의 다른 컬럼들 자동 채움 + const handleDynamicSelectChange = useCallback( + (rowIndex: number, columnField: string, selectedValue: string, conditionValue?: string) => { + const column = tableConfig.columns?.find(col => col.field === columnField); + if (!column?.dynamicSelectOptions?.rowSelectionMode?.enabled) { + // 행 선택 모드가 아니면 일반 값 변경만 + if (conditionValue && isConditionalMode) { + const currentData = conditionalTableData[conditionValue] || []; + const newData = [...currentData]; + newData[rowIndex] = { ...newData[rowIndex], [columnField]: selectedValue }; + setConditionalTableData(prev => ({ ...prev, [conditionValue]: newData })); + onConditionalTableDataChange?.(conditionValue, newData); + } else { + const newData = [...tableData]; + newData[rowIndex] = { ...newData[rowIndex], [columnField]: selectedValue }; + handleDataChange(newData); + } + return; + } + + // 행 선택 모드: 소스 데이터에서 해당 값을 가진 행 찾기 + const { sourceField } = column.dynamicSelectOptions; + const { autoFillColumns, sourceIdColumn, targetIdField } = column.dynamicSelectOptions.rowSelectionMode; + + const sourceRow = sourceDataCache.find(row => String(row[sourceField]) === selectedValue); + + if (!sourceRow) { + console.warn(`[TableSectionRenderer] 소스 행을 찾을 수 없음: ${sourceField} = ${selectedValue}`); + return; + } + + // 현재 행 데이터 가져오기 + let currentData: any[]; + if (conditionValue && isConditionalMode) { + currentData = conditionalTableData[conditionValue] || []; + } else { + currentData = tableData; + } + + const newData = [...currentData]; + const updatedRow = { ...newData[rowIndex], [columnField]: selectedValue }; + + // 자동 채움 매핑 적용 + if (autoFillColumns) { + for (const mapping of autoFillColumns) { + const sourceValue = sourceRow[mapping.sourceColumn]; + if (sourceValue !== undefined) { + updatedRow[mapping.targetField] = sourceValue; + } + } + } + + // 소스 ID 저장 + if (sourceIdColumn && targetIdField) { + updatedRow[targetIdField] = sourceRow[sourceIdColumn]; + } + + newData[rowIndex] = updatedRow; + + // 데이터 업데이트 + if (conditionValue && isConditionalMode) { + setConditionalTableData(prev => ({ ...prev, [conditionValue]: newData })); + onConditionalTableDataChange?.(conditionValue, newData); + } else { + handleDataChange(newData); + } + + console.log("[TableSectionRenderer] 행 선택 모드 자동 채움:", { + columnField, + selectedValue, + sourceRow, + updatedRow, + }); + }, + [tableConfig.columns, sourceDataCache, tableData, conditionalTableData, isConditionalMode, handleDataChange, onConditionalTableDataChange] + ); // formData에서 초기 테이블 데이터 로드 (수정 모드에서 _groupedData 표시) useEffect(() => { @@ -360,8 +635,19 @@ export function TableSectionRenderer({ } }, [sectionId, formData]); - // RepeaterColumnConfig로 변환 - const columns: RepeaterColumnConfig[] = (tableConfig.columns || []).map(convertToRepeaterColumn); + // RepeaterColumnConfig로 변환 (동적 Select 옵션 반영) + const columns: RepeaterColumnConfig[] = useMemo(() => { + return (tableConfig.columns || []).map(col => { + const baseColumn = convertToRepeaterColumn(col); + + // 동적 Select 옵션이 있으면 적용 + if (col.dynamicSelectOptions?.enabled && dynamicSelectOptionsMap[col.field]) { + baseColumn.selectOptions = dynamicSelectOptionsMap[col.field]; + } + + return baseColumn; + }); + }, [tableConfig.columns, dynamicSelectOptionsMap]); // 계산 규칙 변환 const calculationRules: CalculationRule[] = (tableConfig.calculations || []).map(convertToCalculationRule); @@ -444,15 +730,47 @@ export function TableSectionRenderer({ [onTableDataChange, tableConfig.columns, batchAppliedFields] ); - // 행 변경 핸들러 + // 행 변경 핸들러 (동적 Select 행 선택 모드 지원) const handleRowChange = useCallback( - (index: number, newRow: any) => { + (index: number, newRow: any, conditionValue?: string) => { + const oldRow = conditionValue && isConditionalMode + ? (conditionalTableData[conditionValue]?.[index] || {}) + : (tableData[index] || {}); + + // 변경된 필드 찾기 + const changedFields: string[] = []; + for (const key of Object.keys(newRow)) { + if (oldRow[key] !== newRow[key]) { + changedFields.push(key); + } + } + + // 동적 Select 컬럼의 행 선택 모드 확인 + for (const changedField of changedFields) { + const column = tableConfig.columns?.find(col => col.field === changedField); + if (column?.dynamicSelectOptions?.rowSelectionMode?.enabled) { + // 행 선택 모드 처리 (자동 채움) + handleDynamicSelectChange(index, changedField, newRow[changedField], conditionValue); + return; // 행 선택 모드에서 처리 완료 + } + } + + // 일반 행 변경 처리 const calculatedRow = calculateRow(newRow); - const newData = [...tableData]; - newData[index] = calculatedRow; - handleDataChange(newData); + + if (conditionValue && isConditionalMode) { + const currentData = conditionalTableData[conditionValue] || []; + const newData = [...currentData]; + newData[index] = calculatedRow; + setConditionalTableData(prev => ({ ...prev, [conditionValue]: newData })); + onConditionalTableDataChange?.(conditionValue, newData); + } else { + const newData = [...tableData]; + newData[index] = calculatedRow; + handleDataChange(newData); + } }, - [tableData, calculateRow, handleDataChange] + [tableData, conditionalTableData, isConditionalMode, tableConfig.columns, calculateRow, handleDataChange, handleDynamicSelectChange, onConditionalTableDataChange] ); // 행 삭제 핸들러 @@ -778,19 +1096,35 @@ export function TableSectionRenderer({ const sourceSearchFields = source.searchColumns; const columnLabels = source.columnLabels || {}; const modalTitle = uiConfig?.modalTitle || "항목 검색 및 선택"; - const addButtonText = uiConfig?.addButtonText || "항목 검색"; + const addButtonType = uiConfig?.addButtonType || "search"; + const addButtonText = uiConfig?.addButtonText || (addButtonType === "addRow" ? "항목 추가" : "항목 검색"); const multiSelect = uiConfig?.multiSelect ?? true; // 기본 필터 조건 생성 (사전 필터만 - 모달 필터는 ItemSelectionModal에서 처리) - const baseFilterCondition: Record = {}; - if (filters?.preFilters) { - for (const filter of filters.preFilters) { - // 간단한 "=" 연산자만 처리 (확장 가능) - if (filter.operator === "=") { - baseFilterCondition[filter.column] = filter.value; + const baseFilterCondition: Record = useMemo(() => { + const condition: Record = {}; + if (filters?.preFilters) { + for (const filter of filters.preFilters) { + // 간단한 "=" 연산자만 처리 (확장 가능) + if (filter.operator === "=") { + condition[filter.column] = filter.value; + } } } - } + return condition; + }, [filters?.preFilters]); + + // 조건부 테이블용 필터 조건 생성 (선택된 조건값으로 소스 테이블 필터링) + const conditionalFilterCondition = useMemo(() => { + const filter = { ...baseFilterCondition }; + + // 조건부 테이블의 소스 필터 설정이 있고, 모달에서 선택된 조건이 있으면 적용 + if (conditionalConfig?.sourceFilter?.enabled && modalCondition) { + filter[conditionalConfig.sourceFilter.filterColumn] = modalCondition; + } + + return filter; + }, [baseFilterCondition, conditionalConfig?.sourceFilter, modalCondition]); // 모달 필터 설정을 ItemSelectionModal에 전달할 형식으로 변환 const modalFiltersForModal = useMemo(() => { @@ -806,6 +1140,553 @@ export function TableSectionRenderer({ })); }, [filters?.modalFilters]); + // ============================================ + // 조건부 테이블 관련 핸들러 + // ============================================ + + // 조건부 테이블: 조건 체크박스 토글 + const handleConditionToggle = useCallback((conditionValue: string, checked: boolean) => { + setSelectedConditions((prev) => { + if (checked) { + const newConditions = [...prev, conditionValue]; + // 첫 번째 조건 선택 시 해당 탭 활성화 + if (prev.length === 0) { + setActiveConditionTab(conditionValue); + } + return newConditions; + } else { + const newConditions = prev.filter((c) => c !== conditionValue); + // 현재 활성 탭이 제거된 경우 다른 탭으로 전환 + if (activeConditionTab === conditionValue && newConditions.length > 0) { + setActiveConditionTab(newConditions[0]); + } + return newConditions; + } + }); + }, [activeConditionTab]); + + // 조건부 테이블: 조건별 데이터 변경 + const handleConditionalDataChange = useCallback((conditionValue: string, newData: any[]) => { + setConditionalTableData((prev) => ({ + ...prev, + [conditionValue]: newData, + })); + + // 부모에게 조건별 데이터 변경 알림 + if (onConditionalTableDataChange) { + onConditionalTableDataChange(conditionValue, newData); + } + + // 전체 데이터를 flat array로 변환하여 onTableDataChange 호출 + // (저장 시 조건 컬럼 값이 자동으로 추가됨) + const conditionColumn = conditionalConfig?.conditionColumn; + const allData: any[] = []; + + // 현재 변경된 조건의 데이터 업데이트 + const updatedConditionalData = { ...conditionalTableData, [conditionValue]: newData }; + + for (const [condition, data] of Object.entries(updatedConditionalData)) { + for (const row of data) { + allData.push({ + ...row, + ...(conditionColumn ? { [conditionColumn]: condition } : {}), + }); + } + } + + onTableDataChange(allData); + }, [conditionalTableData, conditionalConfig?.conditionColumn, onConditionalTableDataChange, onTableDataChange]); + + // 조건부 테이블: 조건별 행 변경 + const handleConditionalRowChange = useCallback((conditionValue: string, index: number, newRow: any) => { + const calculatedRow = calculateRow(newRow); + const currentData = conditionalTableData[conditionValue] || []; + const newData = [...currentData]; + newData[index] = calculatedRow; + handleConditionalDataChange(conditionValue, newData); + }, [conditionalTableData, calculateRow, handleConditionalDataChange]); + + // 조건부 테이블: 조건별 행 삭제 + const handleConditionalRowDelete = useCallback((conditionValue: string, index: number) => { + const currentData = conditionalTableData[conditionValue] || []; + const newData = currentData.filter((_, i) => i !== index); + handleConditionalDataChange(conditionValue, newData); + }, [conditionalTableData, handleConditionalDataChange]); + + // 조건부 테이블: 조건별 선택 행 일괄 삭제 + const handleConditionalBulkDelete = useCallback((conditionValue: string) => { + const selected = conditionalSelectedRows[conditionValue] || new Set(); + if (selected.size === 0) return; + + const currentData = conditionalTableData[conditionValue] || []; + const newData = currentData.filter((_, index) => !selected.has(index)); + handleConditionalDataChange(conditionValue, newData); + + // 선택 상태 초기화 + setConditionalSelectedRows((prev) => ({ + ...prev, + [conditionValue]: new Set(), + })); + }, [conditionalTableData, conditionalSelectedRows, handleConditionalDataChange]); + + // 조건부 테이블: 아이템 추가 (특정 조건에) + const handleConditionalAddItems = useCallback(async (items: any[]) => { + if (!modalCondition) return; + + // 기존 handleAddItems 로직을 재사용하여 매핑된 아이템 생성 + const mappedItems = await Promise.all( + items.map(async (sourceItem) => { + const newItem: any = {}; + + for (const col of tableConfig.columns) { + const mapping = col.valueMapping; + + // 소스 필드에서 값 복사 (기본) + if (!mapping) { + const sourceField = col.sourceField || col.field; + if (sourceItem[sourceField] !== undefined) { + newItem[col.field] = sourceItem[sourceField]; + } + continue; + } + + // valueMapping 처리 + if (mapping.type === "source" && mapping.sourceField) { + const value = sourceItem[mapping.sourceField]; + if (value !== undefined) { + newItem[col.field] = value; + } + } else if (mapping.type === "manual") { + newItem[col.field] = col.defaultValue || ""; + } else if (mapping.type === "internal" && mapping.internalField) { + newItem[col.field] = formData[mapping.internalField]; + } + } + + // 원본 소스 데이터 보존 + newItem._sourceData = sourceItem; + + return newItem; + }) + ); + + // 현재 조건의 데이터에 추가 + const currentData = conditionalTableData[modalCondition] || []; + const newData = [...currentData, ...mappedItems]; + handleConditionalDataChange(modalCondition, newData); + + setModalOpen(false); + }, [modalCondition, tableConfig.columns, formData, conditionalTableData, handleConditionalDataChange]); + + // 조건부 테이블: 모달 열기 (특정 조건에 대해) + const openConditionalModal = useCallback((conditionValue: string) => { + setModalCondition(conditionValue); + setModalOpen(true); + }, []); + + // 조건부 테이블: 빈 행 추가 (addRow 모드에서 사용) + const addEmptyRowToCondition = useCallback((conditionValue: string) => { + const newRow: Record = {}; + + // 각 컬럼의 기본값으로 빈 행 생성 + for (const col of tableConfig.columns) { + if (col.defaultValue !== undefined) { + newRow[col.field] = col.defaultValue; + } else if (col.type === "number") { + newRow[col.field] = 0; + } else if (col.type === "checkbox") { + newRow[col.field] = false; + } else { + newRow[col.field] = ""; + } + } + + // 조건 컬럼에 현재 조건 값 설정 + if (conditionalConfig?.conditionColumn) { + newRow[conditionalConfig.conditionColumn] = conditionValue; + } + + // 현재 조건의 데이터에 추가 + const currentData = conditionalTableData[conditionValue] || []; + const newData = [...currentData, newRow]; + handleConditionalDataChange(conditionValue, newData); + }, [tableConfig.columns, conditionalConfig?.conditionColumn, conditionalTableData, handleConditionalDataChange]); + + // 버튼 클릭 핸들러 (addButtonType에 따라 다르게 동작) + const handleAddButtonClick = useCallback((conditionValue: string) => { + const addButtonType = tableConfig.uiConfig?.addButtonType || "search"; + + if (addButtonType === "addRow") { + // 빈 행 직접 추가 + addEmptyRowToCondition(conditionValue); + } else { + // 검색 모달 열기 + openConditionalModal(conditionValue); + } + }, [tableConfig.uiConfig?.addButtonType, addEmptyRowToCondition, openConditionalModal]); + + // 조건부 테이블: 초기 데이터 로드 (수정 모드) + useEffect(() => { + if (!isConditionalMode) return; + if (initialDataLoadedRef.current) return; + + const tableSectionKey = `_tableSection_${sectionId}`; + const initialData = formData[tableSectionKey]; + + if (Array.isArray(initialData) && initialData.length > 0) { + const conditionColumn = conditionalConfig?.conditionColumn; + + if (conditionColumn) { + // 조건별로 데이터 그룹핑 + const grouped: ConditionalTableData = {}; + const conditions = new Set(); + + for (const row of initialData) { + const conditionValue = row[conditionColumn] || ""; + if (conditionValue) { + if (!grouped[conditionValue]) { + grouped[conditionValue] = []; + } + grouped[conditionValue].push(row); + conditions.add(conditionValue); + } + } + + setConditionalTableData(grouped); + setSelectedConditions(Array.from(conditions)); + + // 첫 번째 조건을 활성 탭으로 설정 + if (conditions.size > 0) { + setActiveConditionTab(Array.from(conditions)[0]); + } + + initialDataLoadedRef.current = true; + } + } + }, [isConditionalMode, sectionId, formData, conditionalConfig?.conditionColumn]); + + // 조건부 테이블: 전체 항목 수 계산 + const totalConditionalItems = useMemo(() => { + return Object.values(conditionalTableData).reduce((sum, data) => sum + data.length, 0); + }, [conditionalTableData]); + + // ============================================ + // 조건부 테이블 렌더링 + // ============================================ + if (isConditionalMode && conditionalConfig) { + const { triggerType } = conditionalConfig; + + // 정적 옵션과 동적 옵션 병합 (동적 옵션이 있으면 우선 사용) + const effectiveOptions = conditionalConfig.optionSource?.enabled && dynamicOptions.length > 0 + ? dynamicOptions + : conditionalConfig.options || []; + + // 로딩 중이면 로딩 표시 + if (dynamicOptionsLoading) { + return ( +
+
+
+
+ 조건 옵션을 불러오는 중... +
+
+
+ ); + } + + return ( +
+ {/* 조건 선택 UI */} + {triggerType === "checkbox" && ( +
+
+ {effectiveOptions.map((option) => ( + + ))} +
+ + {selectedConditions.length > 0 && ( +
+ {selectedConditions.length}개 유형 선택됨, 총 {totalConditionalItems}개 항목 +
+ )} +
+ )} + + {triggerType === "dropdown" && ( +
+ 유형 선택: + +
+ )} + + {/* 선택된 조건들의 테이블 (탭 형태) */} + {selectedConditions.length > 0 && ( + + + {selectedConditions.map((conditionValue) => { + const option = effectiveOptions.find((o) => o.value === conditionValue); + const itemCount = conditionalTableData[conditionValue]?.length || 0; + return ( + + {option?.label || conditionValue} + {itemCount > 0 && ( + + {itemCount} + + )} + + ); + })} + + + {selectedConditions.map((conditionValue) => { + const data = conditionalTableData[conditionValue] || []; + const selected = conditionalSelectedRows[conditionValue] || new Set(); + + return ( + + {/* 테이블 상단 컨트롤 */} +
+
+ + {data.length > 0 && `${data.length}개 항목`} + {selected.size > 0 && ` (${selected.size}개 선택됨)`} + + {columns.length > 0 && ( + + )} +
+
+ {selected.size > 0 && ( + + )} + +
+
+ + {/* 테이블 */} + handleConditionalDataChange(conditionValue, newData)} + onRowChange={(index, newRow) => handleConditionalRowChange(conditionValue, index, newRow)} + onRowDelete={(index) => handleConditionalRowDelete(conditionValue, index)} + activeDataSources={activeDataSources} + onDataSourceChange={handleDataSourceChange} + selectedRows={selected} + onSelectionChange={(newSelected) => { + setConditionalSelectedRows((prev) => ({ + ...prev, + [conditionValue]: newSelected, + })); + }} + equalizeWidthsTrigger={widthTrigger} + /> +
+ ); + })} +
+ )} + + {/* tabs 모드: 모든 옵션을 탭으로 표시 (선택 UI 없음) */} + {triggerType === "tabs" && effectiveOptions.length > 0 && ( + + + {effectiveOptions.map((option) => { + const itemCount = conditionalTableData[option.value]?.length || 0; + return ( + + {option.label} + {itemCount > 0 && ( + + {itemCount} + + )} + + ); + })} + + + {effectiveOptions.map((option) => { + const data = conditionalTableData[option.value] || []; + const selected = conditionalSelectedRows[option.value] || new Set(); + + return ( + +
+
+ + {data.length > 0 && `${data.length}개 항목`} + {selected.size > 0 && ` (${selected.size}개 선택됨)`} + +
+
+ {selected.size > 0 && ( + + )} + +
+
+ + handleConditionalDataChange(option.value, newData)} + onRowChange={(index, newRow) => handleConditionalRowChange(option.value, index, newRow)} + onRowDelete={(index) => handleConditionalRowDelete(option.value, index)} + activeDataSources={activeDataSources} + onDataSourceChange={handleDataSourceChange} + selectedRows={selected} + onSelectionChange={(newSelected) => { + setConditionalSelectedRows((prev) => ({ + ...prev, + [option.value]: newSelected, + })); + }} + equalizeWidthsTrigger={widthTrigger} + /> +
+ ); + })} +
+ )} + + {/* 조건이 선택되지 않은 경우 안내 메시지 (checkbox/dropdown 모드에서만) */} + {selectedConditions.length === 0 && triggerType !== "tabs" && ( +
+

+ {triggerType === "checkbox" + ? "위에서 유형을 선택하여 검사항목을 추가하세요." + : "유형을 선택하세요."} +

+
+ )} + + {/* 옵션이 없는 경우 안내 메시지 */} + {effectiveOptions.length === 0 && ( +
+

+ 조건 옵션이 설정되지 않았습니다. +

+
+ )} + + {/* 항목 선택 모달 (조건부 테이블용) */} + o.value === modalCondition)?.label || modalCondition} - ${modalTitle}`} + alreadySelected={conditionalTableData[modalCondition] || []} + uniqueField={tableConfig.saveConfig?.uniqueField} + onSelect={handleConditionalAddItems} + columnLabels={columnLabels} + modalFilters={modalFiltersForModal} + /> +
+ ); + } + + // ============================================ + // 일반 테이블 렌더링 (기존 로직) + // ============================================ return (
{/* 추가 버튼 영역 */} @@ -848,10 +1729,34 @@ export function TableSectionRenderer({ )}
diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 64c2f826..16778725 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -212,15 +212,23 @@ export function UniversalFormModalComponent({ // 마지막으로 초기화된 데이터의 ID를 추적 (수정 모달에서 다른 항목 선택 시 재초기화 필요) const lastInitializedId = useRef(undefined); - // 초기화 - 최초 마운트 시 또는 initialData의 ID가 변경되었을 때 실행 + // 초기화 - 최초 마운트 시 또는 initialData가 변경되었을 때 실행 useEffect(() => { // initialData에서 ID 값 추출 (id, ID, objid 등) const currentId = initialData?.id || initialData?.ID || initialData?.objid; const currentIdString = currentId !== undefined ? String(currentId) : undefined; + + // 생성 모드에서 부모로부터 전달받은 데이터 해시 (ID가 없을 때만) + const createModeDataHash = !currentIdString && initialData && Object.keys(initialData).length > 0 + ? JSON.stringify(initialData) + : undefined; - // 이미 초기화되었고, ID가 동일하면 스킵 + // 이미 초기화되었고, ID가 동일하고, 생성 모드 데이터도 동일하면 스킵 if (hasInitialized.current && lastInitializedId.current === currentIdString) { - return; + // 생성 모드에서 데이터가 새로 전달된 경우는 재초기화 필요 + if (!createModeDataHash || capturedInitialData.current) { + return; + } } // 🆕 수정 모드: initialData에 데이터가 있으면서 ID가 변경된 경우 재초기화 @@ -245,7 +253,7 @@ export function UniversalFormModalComponent({ hasInitialized.current = true; initializeForm(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [initialData?.id, initialData?.ID, initialData?.objid]); // ID 값 변경 시 재초기화 + }, [initialData]); // initialData 전체 변경 시 재초기화 // config 변경 시에만 재초기화 (initialData 변경은 무시) - 채번규칙 제외 useEffect(() => { @@ -478,6 +486,82 @@ export function UniversalFormModalComponent({ setActivatedOptionalFieldGroups(newActivatedGroups); setOriginalData(effectiveInitialData || {}); + // 수정 모드에서 서브 테이블 데이터 로드 (겸직 등) + const multiTable = config.saveConfig?.customApiSave?.multiTable; + if (multiTable && effectiveInitialData) { + const pkColumn = multiTable.mainTable?.primaryKeyColumn; + const pkValue = effectiveInitialData[pkColumn]; + + // PK 값이 있으면 수정 모드로 판단 + if (pkValue) { + console.log("[initializeForm] 수정 모드 - 서브 테이블 데이터 로드 시작"); + + for (const subTableConfig of multiTable.subTables || []) { + // loadOnEdit 옵션이 활성화된 경우에만 로드 + if (!subTableConfig.enabled || !subTableConfig.options?.loadOnEdit) { + continue; + } + + const { tableName, linkColumn, repeatSectionId, fieldMappings, options } = subTableConfig; + if (!tableName || !linkColumn?.subColumn || !repeatSectionId) { + continue; + } + + try { + // 서브 테이블에서 데이터 조회 + const filters: Record = { + [linkColumn.subColumn]: pkValue, + }; + + // 서브 항목만 로드 (메인 항목 제외) + if (options?.loadOnlySubItems && options?.mainMarkerColumn) { + filters[options.mainMarkerColumn] = options.subMarkerValue ?? false; + } + + console.log(`[initializeForm] 서브 테이블 ${tableName} 조회:`, filters); + + const response = await apiClient.get(`/table-management/tables/${tableName}/data`, { + params: { + filters: JSON.stringify(filters), + page: 1, + pageSize: 100, + }, + }); + + if (response.data?.success && response.data?.data?.items) { + const subItems = response.data.data.items; + console.log(`[initializeForm] 서브 테이블 ${tableName} 데이터 ${subItems.length}건 로드됨`); + + // 역매핑: 서브 테이블 데이터 → 반복 섹션 데이터 + const repeatItems: RepeatSectionItem[] = subItems.map((item: any, index: number) => { + const repeatItem: RepeatSectionItem = { + _id: generateUniqueId("repeat"), + _index: index, + _originalData: item, // 원본 데이터 보관 (수정 시 필요) + }; + + // 필드 매핑 역변환 (targetColumn → formField) + for (const mapping of fieldMappings || []) { + if (mapping.formField && mapping.targetColumn) { + repeatItem[mapping.formField] = item[mapping.targetColumn]; + } + } + + return repeatItem; + }); + + // 반복 섹션에 데이터 설정 + newRepeatSections[repeatSectionId] = repeatItems; + setRepeatSections({ ...newRepeatSections }); + console.log(`[initializeForm] 반복 섹션 ${repeatSectionId}에 ${repeatItems.length}건 설정`); + } + } catch (error) { + console.error(`[initializeForm] 서브 테이블 ${tableName} 로드 실패:`, error); + } + } + } + } + // 채번규칙 자동 생성 console.log("[initializeForm] generateNumberingValues 호출"); await generateNumberingValues(newFormData); @@ -1142,6 +1226,20 @@ export function UniversalFormModalComponent({ } }); }); + + // 1-0. receiveFromParent 필드 값도 mainData에 추가 (서브 테이블 저장용) + // 이 필드들은 메인 테이블에는 저장되지 않지만, 서브 테이블 저장 시 필요할 수 있음 + config.sections.forEach((section) => { + if (section.repeatable || section.type === "table") return; + (section.fields || []).forEach((field) => { + if (field.receiveFromParent && !mainData[field.columnName]) { + const value = formData[field.columnName]; + if (value !== undefined && value !== null && value !== "") { + mainData[field.columnName] = value; + } + } + }); + }); // 1-1. 채번규칙 처리 (저장 시점에 실제 순번 할당) for (const section of config.sections) { @@ -1185,36 +1283,42 @@ export function UniversalFormModalComponent({ }> = []; for (const subTableConfig of multiTable.subTables || []) { - if (!subTableConfig.enabled || !subTableConfig.tableName || !subTableConfig.repeatSectionId) { + // 서브 테이블이 활성화되어 있고 테이블명이 있어야 함 + // repeatSectionId는 선택사항 (saveMainAsFirst만 사용하는 경우 없을 수 있음) + if (!subTableConfig.enabled || !subTableConfig.tableName) { continue; } const subItems: Record[] = []; - const repeatData = repeatSections[subTableConfig.repeatSectionId] || []; + + // 반복 섹션이 있는 경우에만 반복 데이터 처리 + if (subTableConfig.repeatSectionId) { + const repeatData = repeatSections[subTableConfig.repeatSectionId] || []; - // 반복 섹션 데이터를 필드 매핑에 따라 변환 - for (const item of repeatData) { - const mappedItem: Record = {}; + // 반복 섹션 데이터를 필드 매핑에 따라 변환 + for (const item of repeatData) { + const mappedItem: Record = {}; - // 연결 컬럼 값 설정 - if (subTableConfig.linkColumn?.mainField && subTableConfig.linkColumn?.subColumn) { - mappedItem[subTableConfig.linkColumn.subColumn] = mainData[subTableConfig.linkColumn.mainField]; - } - - // 필드 매핑에 따라 데이터 변환 - for (const mapping of subTableConfig.fieldMappings || []) { - if (mapping.formField && mapping.targetColumn) { - mappedItem[mapping.targetColumn] = item[mapping.formField]; + // 연결 컬럼 값 설정 + if (subTableConfig.linkColumn?.mainField && subTableConfig.linkColumn?.subColumn) { + mappedItem[subTableConfig.linkColumn.subColumn] = mainData[subTableConfig.linkColumn.mainField]; } - } - // 메인/서브 구분 컬럼 설정 (서브 데이터는 서브 마커 값) - if (subTableConfig.options?.mainMarkerColumn) { - mappedItem[subTableConfig.options.mainMarkerColumn] = subTableConfig.options?.subMarkerValue ?? false; - } + // 필드 매핑에 따라 데이터 변환 + for (const mapping of subTableConfig.fieldMappings || []) { + if (mapping.formField && mapping.targetColumn) { + mappedItem[mapping.targetColumn] = item[mapping.formField]; + } + } - if (Object.keys(mappedItem).length > 0) { - subItems.push(mappedItem); + // 메인/서브 구분 컬럼 설정 (서브 데이터는 서브 마커 값) + if (subTableConfig.options?.mainMarkerColumn) { + mappedItem[subTableConfig.options.mainMarkerColumn] = subTableConfig.options?.subMarkerValue ?? false; + } + + if (Object.keys(mappedItem).length > 0) { + subItems.push(mappedItem); + } } } @@ -1226,8 +1330,9 @@ export function UniversalFormModalComponent({ // fieldMappings에 정의된 targetColumn만 매핑 (서브 테이블에 존재하는 컬럼만) for (const mapping of subTableConfig.fieldMappings || []) { if (mapping.targetColumn) { - // 메인 데이터에서 동일한 컬럼명이 있으면 매핑 - if (mainData[mapping.targetColumn] !== undefined && mainData[mapping.targetColumn] !== null && mainData[mapping.targetColumn] !== "") { + // formData에서 동일한 컬럼명이 있으면 매핑 (receiveFromParent 필드 포함) + const formValue = formData[mapping.targetColumn]; + if (formValue !== undefined && formValue !== null && formValue !== "") { mainFieldMappings.push({ formField: mapping.targetColumn, targetColumn: mapping.targetColumn, @@ -1238,11 +1343,14 @@ export function UniversalFormModalComponent({ config.sections.forEach((section) => { if (section.repeatable || section.type === "table") return; const matchingField = (section.fields || []).find((f) => f.columnName === mapping.targetColumn); - if (matchingField && mainData[matchingField.columnName] !== undefined && mainData[matchingField.columnName] !== null && mainData[matchingField.columnName] !== "") { - mainFieldMappings!.push({ - formField: matchingField.columnName, - targetColumn: mapping.targetColumn, - }); + if (matchingField) { + const fieldValue = formData[matchingField.columnName]; + if (fieldValue !== undefined && fieldValue !== null && fieldValue !== "") { + mainFieldMappings!.push({ + formField: matchingField.columnName, + targetColumn: mapping.targetColumn, + }); + } } }); } @@ -1255,15 +1363,18 @@ export function UniversalFormModalComponent({ ); } - subTablesData.push({ - tableName: subTableConfig.tableName, - linkColumn: subTableConfig.linkColumn, - items: subItems, - options: { - ...subTableConfig.options, - mainFieldMappings, // 메인 데이터 매핑 추가 - }, - }); + // 서브 테이블 데이터 추가 (반복 데이터가 없어도 saveMainAsFirst가 있으면 추가) + if (subItems.length > 0 || subTableConfig.options?.saveMainAsFirst) { + subTablesData.push({ + tableName: subTableConfig.tableName, + linkColumn: subTableConfig.linkColumn, + items: subItems, + options: { + ...subTableConfig.options, + mainFieldMappings, // 메인 데이터 매핑 추가 + }, + }); + } } // 3. 범용 다중 테이블 저장 API 호출 @@ -1489,13 +1600,20 @@ export function UniversalFormModalComponent({ // 표시 텍스트 생성 함수 const getDisplayText = (row: Record): string => { - const displayVal = row[lfg.displayColumn || ""] || ""; - const valueVal = row[valueColumn] || ""; + // 메인 표시 컬럼 (displayColumn) + const mainDisplayVal = row[lfg.displayColumn || ""] || ""; + // 서브 표시 컬럼 (subDisplayColumn이 있으면 사용, 없으면 valueColumn 사용) + const subDisplayVal = lfg.subDisplayColumn + ? (row[lfg.subDisplayColumn] || "") + : (row[valueColumn] || ""); + switch (lfg.displayFormat) { case "code_name": - return `${valueVal} - ${displayVal}`; + // 서브 - 메인 형식 + return `${subDisplayVal} - ${mainDisplayVal}`; case "name_code": - return `${displayVal} (${valueVal})`; + // 메인 (서브) 형식 + return `${mainDisplayVal} (${subDisplayVal})`; case "custom": // 커스텀 형식: {컬럼명}을 실제 값으로 치환 if (lfg.customDisplayFormat) { @@ -1511,10 +1629,10 @@ export function UniversalFormModalComponent({ } return result; } - return String(displayVal); + return String(mainDisplayVal); case "name_only": default: - return String(displayVal); + return String(mainDisplayVal); } }; diff --git a/frontend/lib/registry/components/universal-form-modal/config.ts b/frontend/lib/registry/components/universal-form-modal/config.ts index e8b239f6..41c18043 100644 --- a/frontend/lib/registry/components/universal-form-modal/config.ts +++ b/frontend/lib/registry/components/universal-form-modal/config.ts @@ -11,6 +11,8 @@ import { TablePreFilter, TableModalFilter, TableCalculationRule, + ConditionalTableConfig, + ConditionalTableOption, } from "./types"; // 기본 설정값 @@ -133,6 +135,33 @@ export const defaultTableSectionConfig: TableSectionConfig = { multiSelect: true, maxHeight: "400px", }, + conditionalTable: undefined, +}; + +// 기본 조건부 테이블 설정 +export const defaultConditionalTableConfig: ConditionalTableConfig = { + enabled: false, + triggerType: "checkbox", + conditionColumn: "", + options: [], + optionSource: { + enabled: false, + tableName: "", + valueColumn: "", + labelColumn: "", + filterCondition: "", + }, + sourceFilter: { + enabled: false, + filterColumn: "", + }, +}; + +// 기본 조건부 테이블 옵션 설정 +export const defaultConditionalTableOptionConfig: ConditionalTableOption = { + id: "", + value: "", + label: "", }; // 기본 테이블 컬럼 설정 @@ -300,3 +329,8 @@ export const generateColumnModeId = (): string => { export const generateFilterId = (): string => { return generateUniqueId("filter"); }; + +// 유틸리티: 조건부 테이블 옵션 ID 생성 +export const generateConditionalOptionId = (): string => { + return generateUniqueId("cond"); +}; diff --git a/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx index 2404cc4c..8882d9bc 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx @@ -98,6 +98,9 @@ export function FieldDetailSettingsModal({ // Combobox 열림 상태 const [sourceTableOpen, setSourceTableOpen] = useState(false); const [targetColumnOpenMap, setTargetColumnOpenMap] = useState>({}); + const [displayColumnOpen, setDisplayColumnOpen] = useState(false); + const [subDisplayColumnOpen, setSubDisplayColumnOpen] = useState(false); // 서브 표시 컬럼 Popover 상태 + const [sourceColumnOpenMap, setSourceColumnOpenMap] = useState>({}); // open이 변경될 때마다 필드 데이터 동기화 useEffect(() => { @@ -105,6 +108,16 @@ export function FieldDetailSettingsModal({ setLocalField(field); } }, [open, field]); + + // 모달이 열릴 때 소스 테이블 컬럼 자동 로드 + useEffect(() => { + if (open && field.linkedFieldGroup?.sourceTable) { + // tableColumns에 해당 테이블 컬럼이 없으면 로드 + if (!tableColumns[field.linkedFieldGroup.sourceTable] || tableColumns[field.linkedFieldGroup.sourceTable].length === 0) { + onLoadTableColumns(field.linkedFieldGroup.sourceTable); + } + } + }, [open, field.linkedFieldGroup?.sourceTable, tableColumns, onLoadTableColumns]); // 모든 카테고리 컬럼 목록 로드 (모달 열릴 때) useEffect(() => { @@ -735,32 +748,108 @@ export function FieldDetailSettingsModal({ 값을 가져올 소스 테이블 (예: customer_mng)
+ {/* 표시 형식 선택 */}
- + + + 드롭다운에 표시할 형식을 선택합니다 +
+ + {/* 메인 표시 컬럼 */} +
+ {sourceTableColumns.length > 0 ? ( - + + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {sourceTableColumns.map((col) => ( + { + updateField({ + linkedFieldGroup: { + ...localField.linkedFieldGroup, + displayColumn: col.name, + }, + }); + setDisplayColumnOpen(false); + }} + className="text-xs" + > + + {col.name} + ({col.label}) + + ))} + + + + + ) : ( )} - 드롭다운에 표시할 컬럼 (예: customer_name) + 드롭다운에 표시할 메인 컬럼 (예: item_name)
-
- - - 드롭다운에 표시될 형식을 선택하세요 -
+ {/* 서브 표시 컬럼 - 표시 형식이 name_only가 아닌 경우에만 표시 */} + {localField.linkedFieldGroup?.displayFormat && + localField.linkedFieldGroup.displayFormat !== "name_only" && ( +
+ + {sourceTableColumns.length > 0 ? ( + + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {sourceTableColumns.map((col) => ( + { + updateField({ + linkedFieldGroup: { + ...localField.linkedFieldGroup, + subDisplayColumn: col.name, + }, + }); + setSubDisplayColumnOpen(false); + }} + className="text-xs" + > + + {col.name} + ({col.label}) + + ))} + + + + + + ) : ( + + updateField({ + linkedFieldGroup: { + ...localField.linkedFieldGroup, + subDisplayColumn: e.target.value, + }, + }) + } + placeholder="item_code" + className="h-7 text-xs mt-1" + /> + )} + + {localField.linkedFieldGroup?.displayFormat === "code_name" + ? "메인 앞에 표시될 서브 컬럼 (예: 서브 - 메인)" + : "메인 뒤에 표시될 서브 컬럼 (예: 메인 (서브))"} + +
+ )} + + {/* 미리보기 - 메인 컬럼이 선택된 경우에만 표시 */} + {localField.linkedFieldGroup?.displayColumn && ( +
+

미리보기:

+ {(() => { + const mainCol = localField.linkedFieldGroup?.displayColumn || ""; + const subCol = localField.linkedFieldGroup?.subDisplayColumn || ""; + const mainLabel = sourceTableColumns.find(c => c.name === mainCol)?.label || mainCol; + const subLabel = sourceTableColumns.find(c => c.name === subCol)?.label || subCol; + const format = localField.linkedFieldGroup?.displayFormat || "name_only"; + + let preview = ""; + if (format === "name_only") { + preview = mainLabel; + } else if (format === "code_name" && subCol) { + preview = `${subLabel} - ${mainLabel}`; + } else if (format === "name_code" && subCol) { + preview = `${mainLabel} (${subLabel})`; + } else if (format !== "name_only" && !subCol) { + preview = `${mainLabel} (서브 컬럼을 선택하세요)`; + } else { + preview = mainLabel; + } + + return ( +

{preview}

+ ); + })()} +
+ )} @@ -846,24 +1029,67 @@ export function FieldDetailSettingsModal({
{sourceTableColumns.length > 0 ? ( - + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {sourceTableColumns.map((col) => ( + { + updateLinkedFieldMapping(index, { sourceColumn: col.name }); + setSourceColumnOpenMap((prev) => ({ ...prev, [index]: false })); + }} + className="text-[9px]" + > + + {col.name} + ({col.label}) + + ))} + + + + + ) : ( >({}); + // 컬럼 검색 Popover 상태 + const [mainKeyColumnSearchOpen, setMainKeyColumnSearchOpen] = useState(false); + const [mainFieldSearchOpen, setMainFieldSearchOpen] = useState>({}); + const [subColumnSearchOpen, setSubColumnSearchOpen] = useState>({}); + const [subTableColumnSearchOpen, setSubTableColumnSearchOpen] = useState>({}); + const [markerColumnSearchOpen, setMarkerColumnSearchOpen] = useState>({}); + // open이 변경될 때마다 데이터 동기화 useEffect(() => { if (open) { setLocalSaveConfig(saveConfig); setSaveMode(saveConfig.customApiSave?.enabled && saveConfig.customApiSave?.multiTable?.enabled ? "multi" : "single"); + + // 모달이 열릴 때 기존에 설정된 테이블들의 컬럼 정보 로드 + const mainTableName = saveConfig.customApiSave?.multiTable?.mainTable?.tableName; + if (mainTableName && !tableColumns[mainTableName]) { + onLoadTableColumns(mainTableName); + } + + // 서브 테이블들의 컬럼 정보도 로드 + const subTables = saveConfig.customApiSave?.multiTable?.subTables || []; + subTables.forEach((subTable) => { + if (subTable.tableName && !tableColumns[subTable.tableName]) { + onLoadTableColumns(subTable.tableName); + } + }); } - }, [open, saveConfig]); + }, [open, saveConfig, tableColumns, onLoadTableColumns]); // 저장 설정 업데이트 함수 const updateSaveConfig = (updates: Partial) => { @@ -558,35 +579,76 @@ export function SaveSettingsModal({
{mainTableColumns.length > 0 ? ( - + + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {mainTableColumns.map((col) => ( + { + updateSaveConfig({ + customApiSave: { + ...localSaveConfig.customApiSave, + multiTable: { + ...localSaveConfig.customApiSave?.multiTable, + mainTable: { + ...localSaveConfig.customApiSave?.multiTable?.mainTable, + primaryKeyColumn: col.name, + }, + }, + }, + }); + setMainKeyColumnSearchOpen(false); + }} + className="text-xs" + > + +
+ {col.name} + {col.label && col.label !== col.name && ( + {col.label} + )} +
+
+ ))} +
+
+
+
+
) : ( {mainTableColumns.length > 0 ? ( - + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {mainTableColumns.map((col) => ( + { + updateSubTable(subIndex, { + linkColumn: { ...subTable.linkColumn, mainField: col.name }, + }); + setMainFieldSearchOpen(prev => ({ ...prev, [subIndex]: false })); + }} + className="text-[10px]" + > + +
+ {col.name} + {col.label && col.label !== col.name && ( + {col.label} + )} +
+
+ ))} +
+
+
+
+ ) : ( {subTableColumns.length > 0 ? ( - + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {subTableColumns.map((col) => ( + { + updateSubTable(subIndex, { + linkColumn: { ...subTable.linkColumn, subColumn: col.name }, + }); + setSubColumnSearchOpen(prev => ({ ...prev, [subIndex]: false })); + }} + className="text-[10px]" + > + +
+ {col.name} + {col.label && col.label !== col.name && ( + {col.label} + )} +
+
+ ))} +
+
+
+
+ ) : ( {subTableColumns.length > 0 ? ( - + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {subTableColumns.map((col) => ( + { + updateFieldMapping(subIndex, mapIndex, { targetColumn: col.name }); + setSubTableColumnSearchOpen(prev => ({ ...prev, [`${subIndex}-${mapIndex}`]: false })); + }} + className="text-[10px]" + > + +
+ {col.name} + {col.label && col.label !== col.name && ( + {col.label} + )} +
+
+ ))} +
+
+
+
+ ) : ( )}
+ + + + {/* 대표 데이터 구분 저장 옵션 */} +
+ {!subTable.options?.saveMainAsFirst ? ( + // 비활성화 상태: 추가 버튼 표시 +
+
+
+

대표/일반 구분 저장

+

+ 저장되는 데이터를 대표와 일반으로 구분합니다 +

+
+ +
+
+ ) : ( + // 활성화 상태: 설정 필드 표시 +
+
+
+

대표/일반 구분 저장

+

+ 저장되는 데이터를 대표와 일반으로 구분합니다 +

+
+ +
+ +
+
+ + {subTableColumns.length > 0 ? ( + setMarkerColumnSearchOpen(prev => ({ ...prev, [subIndex]: open }))} + > + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {subTableColumns.map((col) => ( + { + updateSubTable(subIndex, { + options: { + ...subTable.options, + mainMarkerColumn: col.name, + } + }); + setMarkerColumnSearchOpen(prev => ({ ...prev, [subIndex]: false })); + }} + className="text-[10px]" + > + +
+ {col.name} + {col.label && col.label !== col.name && ( + {col.label} + )} +
+
+ ))} +
+
+
+
+
+ ) : ( + updateSubTable(subIndex, { + options: { + ...subTable.options, + mainMarkerColumn: e.target.value, + } + })} + placeholder="is_primary" + className="h-6 text-[9px] mt-0.5" + /> + )} + 대표/일반을 구분하는 컬럼 +
+ +
+
+ + { + const val = e.target.value; + // true/false 문자열은 boolean으로 변환 + let parsedValue: any = val; + if (val === "true") parsedValue = true; + else if (val === "false") parsedValue = false; + else if (!isNaN(Number(val)) && val !== "") parsedValue = Number(val); + + updateSubTable(subIndex, { + options: { + ...subTable.options, + mainMarkerValue: parsedValue, + } + }); + }} + placeholder="true, Y, 1 등" + className="h-6 text-[9px] mt-0.5" + /> + 기본 정보와 함께 저장될 때 값 +
+
+ + { + const val = e.target.value; + let parsedValue: any = val; + if (val === "true") parsedValue = true; + else if (val === "false") parsedValue = false; + else if (!isNaN(Number(val)) && val !== "") parsedValue = Number(val); + + updateSubTable(subIndex, { + options: { + ...subTable.options, + subMarkerValue: parsedValue, + } + }); + }} + placeholder="false, N, 0 등" + className="h-6 text-[9px] mt-0.5" + /> + 겸직 추가 시 저장될 때 값 +
+
+
+
+ )} +
+ + + + {/* 수정 시 데이터 로드 옵션 */} +
+ {!subTable.options?.loadOnEdit ? ( + // 비활성화 상태: 추가 버튼 표시 +
+
+
+

수정 시 데이터 로드

+

+ 수정 모드에서 서브 테이블 데이터를 불러옵니다 +

+
+ +
+
+ ) : ( + // 활성화 상태: 설정 필드 표시 +
+
+
+

수정 시 데이터 로드

+

+ 수정 모드에서 서브 테이블 데이터를 불러옵니다 +

+
+ +
+ +
+ updateSubTable(subIndex, { + options: { + ...subTable.options, + loadOnlySubItems: checked, + } + })} + /> + +
+ + 활성화하면 겸직 데이터만 불러오고, 비활성화하면 모든 데이터를 불러옵니다 + +
+ )} +
diff --git a/frontend/lib/registry/components/universal-form-modal/modals/TableColumnSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/TableColumnSettingsModal.tsx index 797bce55..79a78d10 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/TableColumnSettingsModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/TableColumnSettingsModal.tsx @@ -699,6 +699,357 @@ export function TableColumnSettingsModal({
+ + {/* 동적 Select 옵션 (소스 테이블에서 로드) */} + +
+
+
+

동적 옵션 (소스 테이블에서 로드)

+

+ 소스 테이블에서 옵션을 동적으로 가져옵니다. 조건부 테이블 필터가 자동 적용됩니다. +

+
+ { + updateColumn({ + dynamicSelectOptions: checked + ? { + enabled: true, + sourceField: "", + distinct: true, + } + : undefined, + }); + }} + /> +
+ + {localColumn.dynamicSelectOptions?.enabled && ( +
+ {/* 소스 필드 */} +
+ +

+ 소스 테이블에서 옵션 값을 가져올 컬럼 +

+ {sourceTableColumns.length > 0 ? ( + + ) : ( + { + updateColumn({ + dynamicSelectOptions: { + ...localColumn.dynamicSelectOptions!, + sourceField: e.target.value, + }, + }); + }} + placeholder="inspection_item" + className="h-8 text-xs" + /> + )} +
+ + {/* 라벨 필드 */} +
+ +

+ 표시할 라벨 컬럼 (없으면 소스 컬럼 값 사용) +

+ {sourceTableColumns.length > 0 ? ( + + ) : ( + { + updateColumn({ + dynamicSelectOptions: { + ...localColumn.dynamicSelectOptions!, + labelField: e.target.value || undefined, + }, + }); + }} + placeholder="(비워두면 소스 컬럼 사용)" + className="h-8 text-xs" + /> + )} +
+ + {/* 행 선택 모드 */} +
+
+ { + updateColumn({ + dynamicSelectOptions: { + ...localColumn.dynamicSelectOptions!, + rowSelectionMode: checked + ? { + enabled: true, + autoFillMappings: [], + } + : undefined, + }, + }); + }} + className="scale-75" + /> +
+ +

+ 이 컬럼 선택 시 같은 소스 행의 다른 컬럼들도 자동 채움 +

+
+
+ + {localColumn.dynamicSelectOptions.rowSelectionMode?.enabled && ( +
+ {/* 소스 ID 저장 설정 */} +
+
+ + {sourceTableColumns.length > 0 ? ( + + ) : ( + { + updateColumn({ + dynamicSelectOptions: { + ...localColumn.dynamicSelectOptions!, + rowSelectionMode: { + ...localColumn.dynamicSelectOptions!.rowSelectionMode!, + sourceIdColumn: e.target.value || undefined, + }, + }, + }); + }} + placeholder="id" + className="h-7 text-xs mt-1" + /> + )} +
+
+ + { + updateColumn({ + dynamicSelectOptions: { + ...localColumn.dynamicSelectOptions!, + rowSelectionMode: { + ...localColumn.dynamicSelectOptions!.rowSelectionMode!, + targetIdField: e.target.value || undefined, + }, + }, + }); + }} + placeholder="inspection_standard_id" + className="h-7 text-xs mt-1" + /> +
+
+ + {/* 자동 채움 매핑 */} +
+
+ + +
+
+ {(localColumn.dynamicSelectOptions.rowSelectionMode.autoFillMappings || []).map((mapping, idx) => ( +
+ {sourceTableColumns.length > 0 ? ( + + ) : ( + { + const newMappings = [...(localColumn.dynamicSelectOptions?.rowSelectionMode?.autoFillMappings || [])]; + newMappings[idx] = { ...newMappings[idx], sourceColumn: e.target.value }; + updateColumn({ + dynamicSelectOptions: { + ...localColumn.dynamicSelectOptions!, + rowSelectionMode: { + ...localColumn.dynamicSelectOptions!.rowSelectionMode!, + autoFillMappings: newMappings, + }, + }, + }); + }} + placeholder="소스 컬럼" + className="h-7 text-xs flex-1" + /> + )} + + { + const newMappings = [...(localColumn.dynamicSelectOptions?.rowSelectionMode?.autoFillMappings || [])]; + newMappings[idx] = { ...newMappings[idx], targetField: e.target.value }; + updateColumn({ + dynamicSelectOptions: { + ...localColumn.dynamicSelectOptions!, + rowSelectionMode: { + ...localColumn.dynamicSelectOptions!.rowSelectionMode!, + autoFillMappings: newMappings, + }, + }, + }); + }} + placeholder="타겟 필드" + className="h-7 text-xs flex-1" + /> + +
+ ))} + {(localColumn.dynamicSelectOptions.rowSelectionMode.autoFillMappings || []).length === 0 && ( +

+ 매핑을 추가하세요 (예: inspection_criteria → inspection_standard) +

+ )} +
+
+
+ )} +
+
+ )} +
)} diff --git a/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx index effb7927..535be447 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx @@ -25,12 +25,14 @@ import { TableModalFilter, TableCalculationRule, LookupOption, - ExternalTableLookup, + LookupCondition, + ConditionalTableOption, TABLE_COLUMN_TYPE_OPTIONS, FILTER_OPERATOR_OPTIONS, MODAL_FILTER_TYPE_OPTIONS, LOOKUP_TYPE_OPTIONS, LOOKUP_CONDITION_SOURCE_OPTIONS, + CONDITIONAL_TABLE_TRIGGER_OPTIONS, } from "../types"; import { @@ -39,8 +41,10 @@ import { defaultPreFilterConfig, defaultModalFilterConfig, defaultCalculationRuleConfig, + defaultConditionalTableConfig, generateTableColumnId, generateFilterId, + generateConditionalOptionId, } from "../config"; // 도움말 텍스트 컴포넌트 @@ -48,6 +52,236 @@ const HelpText = ({ children }: { children: React.ReactNode }) => (

{children}

); +// 옵션 소스 설정 컴포넌트 (검색 가능한 Combobox) +interface OptionSourceConfigProps { + optionSource: { + enabled: boolean; + tableName: string; + valueColumn: string; + labelColumn: string; + filterCondition?: string; + }; + tables: { table_name: string; comment?: string }[]; + tableColumns: Record; + onUpdate: (updates: Partial) => void; +} + +const OptionSourceConfig: React.FC = ({ + optionSource, + tables, + tableColumns, + onUpdate, +}) => { + const [tableOpen, setTableOpen] = useState(false); + const [valueColumnOpen, setValueColumnOpen] = useState(false); + + // 선택된 테이블의 컬럼 목록 + const selectedTableColumns = useMemo(() => { + return tableColumns[optionSource.tableName] || []; + }, [tableColumns, optionSource.tableName]); + + return ( +
+ {/* 테이블 선택 Combobox */} +
+ + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {tables.map((table) => ( + { + onUpdate({ + tableName: table.table_name, + valueColumn: "", // 테이블 변경 시 컬럼 초기화 + labelColumn: "", + }); + setTableOpen(false); + }} + className="text-xs" + > + +
+ {table.table_name} + {table.comment && ( + {table.comment} + )} +
+
+ ))} +
+
+
+
+
+
+ + {/* 참조할 값 컬럼 선택 Combobox */} +
+ + + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {selectedTableColumns.map((column) => ( + { + onUpdate({ valueColumn: column.column_name }); + setValueColumnOpen(false); + }} + className="text-xs" + > + +
+ {column.column_name} + {column.comment && ( + {column.comment} + )} +
+
+ ))} +
+
+
+
+
+
+ + {/* 출력할 값 컬럼 선택 Combobox */} +
+ + + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {/* 값 컬럼 사용 옵션 */} + onUpdate({ labelColumn: "" })} + className="text-xs text-muted-foreground" + > + + (참조할 값과 동일) + + {selectedTableColumns.map((column) => ( + onUpdate({ labelColumn: column.column_name })} + className="text-xs" + > + +
+ {column.column_name} + {column.comment && ( + {column.comment} + )} +
+
+ ))} +
+
+
+
+
+

+ 비워두면 참조할 값을 그대로 표시 +

+
+
+ ); +}; + // 부모 화면에서 전달 가능한 필드 타입 interface AvailableParentField { name: string; // 필드명 (columnName) @@ -1218,6 +1452,340 @@ function ColumnSettingItem({ )} )} + + {/* 동적 Select 옵션 (소스 테이블 필터링이 활성화되고, 타입이 select일 때만 표시) */} + {col.type === "select" && tableConfig.conditionalTable?.sourceFilter?.enabled && ( +
+
+
+ +

+ 소스 테이블에서 옵션을 동적으로 로드합니다. 조건부 테이블 필터가 자동 적용됩니다. +

+
+ { + onUpdate({ + dynamicSelectOptions: checked + ? { + enabled: true, + sourceField: "", + distinct: true, + } + : undefined, + }); + }} + className="scale-75" + /> +
+ + {col.dynamicSelectOptions?.enabled && ( +
+ {/* 소스 컬럼 선택 */} +
+
+ +

+ 드롭다운 옵션으로 사용할 컬럼 +

+ {sourceTableColumns.length > 0 ? ( + + ) : ( + { + onUpdate({ + dynamicSelectOptions: { + ...col.dynamicSelectOptions!, + sourceField: e.target.value, + }, + }); + }} + placeholder="inspection_item" + className="h-7 text-xs" + /> + )} +
+ +
+ +

+ 표시할 라벨 (비워두면 소스 컬럼과 동일) +

+ {sourceTableColumns.length > 0 ? ( + + ) : ( + { + onUpdate({ + dynamicSelectOptions: { + ...col.dynamicSelectOptions!, + labelField: e.target.value, + }, + }); + }} + placeholder="(비워두면 소스 컬럼과 동일)" + className="h-7 text-xs" + /> + )} +
+
+ + {/* 행 선택 모드 */} +
+ +

+ 이 컬럼 선택 시 같은 소스 행의 다른 컬럼 값을 자동으로 채웁니다. +

+ + {col.dynamicSelectOptions.rowSelectionMode?.enabled && ( +
+
+ + +
+ + {(col.dynamicSelectOptions.rowSelectionMode?.autoFillColumns || []).length === 0 ? ( +

+ "매핑 추가" 버튼을 클릭하여 자동 채움 매핑을 추가하세요. +

+ ) : ( +
+ {(col.dynamicSelectOptions.rowSelectionMode?.autoFillColumns || []).map((mapping, mappingIndex) => ( +
+ {/* 소스 컬럼 */} +
+ + {sourceTableColumns.length > 0 ? ( + + ) : ( + { + const newMappings = [...(col.dynamicSelectOptions?.rowSelectionMode?.autoFillColumns || [])]; + newMappings[mappingIndex] = { ...newMappings[mappingIndex], sourceColumn: e.target.value }; + onUpdate({ + dynamicSelectOptions: { + ...col.dynamicSelectOptions!, + rowSelectionMode: { + ...col.dynamicSelectOptions!.rowSelectionMode!, + autoFillColumns: newMappings, + }, + }, + }); + }} + placeholder="소스 컬럼" + className="h-6 text-[10px]" + /> + )} +
+ + + + {/* 타겟 필드 */} +
+ + +
+ + {/* 삭제 버튼 */} + +
+ ))} +
+ )} + + {/* 매핑 설명 */} + {(col.dynamicSelectOptions.rowSelectionMode?.autoFillColumns || []).length > 0 && ( +
+ {col.label || col.field} 선택 시:{" "} + {(col.dynamicSelectOptions.rowSelectionMode?.autoFillColumns || []) + .filter((m) => m.sourceColumn && m.targetField) + .map((m) => { + const targetCol = tableConfig.columns?.find((c) => c.field === m.targetField); + return `${m.sourceColumn} → ${targetCol?.label || m.targetField}`; + }) + .join(", ")} +
+ )} +
+ )} +
+ + {/* 설정 요약 */} + {col.dynamicSelectOptions.sourceField && ( +
+ {sourceTableName}.{col.dynamicSelectOptions.sourceField} + {tableConfig.conditionalTable?.sourceFilter?.filterColumn && ( + <> (조건: {tableConfig.conditionalTable.sourceFilter.filterColumn} = 선택된 검사유형) + )} +
+ )} +
+ )} +
+ )} ); } @@ -2160,12 +2728,37 @@ export function TableSectionSettingsModal({

UI 설정

+
+ + +
updateUiConfig({ addButtonText: e.target.value })} - placeholder="항목 검색" + placeholder={tableConfig.uiConfig?.addButtonType === "addRow" ? "항목 추가" : "항목 검색"} className="h-8 text-xs mt-1" />
@@ -2176,7 +2769,11 @@ export function TableSectionSettingsModal({ onChange={(e) => updateUiConfig({ modalTitle: e.target.value })} placeholder="항목 검색 및 선택" className="h-8 text-xs mt-1" + disabled={tableConfig.uiConfig?.addButtonType === "addRow"} /> + {tableConfig.uiConfig?.addButtonType === "addRow" && ( +

빈 행 추가 모드에서는 모달이 열리지 않습니다

+ )}
@@ -2193,6 +2790,7 @@ export function TableSectionSettingsModal({ checked={tableConfig.uiConfig?.multiSelect ?? true} onCheckedChange={(checked) => updateUiConfig({ multiSelect: checked })} className="scale-75" + disabled={tableConfig.uiConfig?.addButtonType === "addRow"} /> 다중 선택 허용 @@ -2254,6 +2852,365 @@ export function TableSectionSettingsModal({
))}
+ + {/* 조건부 테이블 설정 */} +
+
+
+

조건부 테이블

+

+ 조건(검사유형 등)에 따라 다른 데이터를 표시하고 저장합니다. +

+
+ { + setTableConfig({ + ...tableConfig, + conditionalTable: checked + ? { ...defaultConditionalTableConfig, enabled: true } + : { ...defaultConditionalTableConfig, enabled: false }, + }); + }} + className="scale-75" + /> +
+ + {tableConfig.conditionalTable?.enabled && ( +
+ {/* 트리거 유형 및 조건 컬럼 */} +
+
+ + + + 체크박스: 다중 선택 후 탭으로 표시 / 드롭다운: 단일 선택 / 탭: 모든 옵션 표시 + +
+
+ + + 저장 시 각 행에 조건 값이 이 컬럼에 자동 저장됩니다. +
+
+ + {/* 조건 옵션 목록 */} +
+
+ +
+ +
+
+ + {/* 옵션 목록 */} +
+ {(tableConfig.conditionalTable?.options || []).map((option, index) => ( +
+ { + const newOptions = [...(tableConfig.conditionalTable?.options || [])]; + newOptions[index] = { ...newOptions[index], value: e.target.value }; + // label이 비어있으면 value와 동일하게 설정 + if (!newOptions[index].label) { + newOptions[index].label = e.target.value; + } + setTableConfig({ + ...tableConfig, + conditionalTable: { + ...tableConfig.conditionalTable!, + options: newOptions, + }, + }); + }} + placeholder="저장 값 (예: 입고검사)" + className="h-8 text-xs flex-1" + /> + { + const newOptions = [...(tableConfig.conditionalTable?.options || [])]; + newOptions[index] = { ...newOptions[index], label: e.target.value }; + setTableConfig({ + ...tableConfig, + conditionalTable: { + ...tableConfig.conditionalTable!, + options: newOptions, + }, + }); + }} + placeholder="표시 라벨 (예: 입고검사)" + className="h-8 text-xs flex-1" + /> + +
+ ))} + + {(tableConfig.conditionalTable?.options || []).length === 0 && ( +
+ 조건 옵션을 추가하세요. (예: 입고검사, 공정검사, 출고검사 등) +
+ )} +
+
+ + {/* 테이블에서 옵션 로드 설정 */} +
+
+ { + setTableConfig({ + ...tableConfig, + conditionalTable: { + ...tableConfig.conditionalTable!, + optionSource: { + ...tableConfig.conditionalTable?.optionSource, + enabled: checked, + tableName: tableConfig.conditionalTable?.optionSource?.tableName || "", + valueColumn: tableConfig.conditionalTable?.optionSource?.valueColumn || "", + labelColumn: tableConfig.conditionalTable?.optionSource?.labelColumn || "", + }, + }, + }); + }} + className="scale-75" + /> + +
+ + {tableConfig.conditionalTable?.optionSource?.enabled && ( + { + setTableConfig({ + ...tableConfig, + conditionalTable: { + ...tableConfig.conditionalTable!, + optionSource: { + ...tableConfig.conditionalTable?.optionSource!, + ...updates, + }, + }, + }); + }} + /> + )} +
+ + {/* 소스 테이블 필터링 설정 */} +
+
+ { + setTableConfig({ + ...tableConfig, + conditionalTable: { + ...tableConfig.conditionalTable!, + sourceFilter: { + enabled: checked, + filterColumn: tableConfig.conditionalTable?.sourceFilter?.filterColumn || "", + }, + }, + }); + }} + className="scale-75" + /> +
+ +

+ 조건 선택 시 소스 테이블에서 해당 조건으로 필터링합니다 +

+
+
+ + {tableConfig.conditionalTable?.sourceFilter?.enabled && ( +
+ +

+ 소스 테이블({tableConfig.source?.tableName || "미설정"})에서 조건값으로 필터링할 컬럼 +

+ {sourceTableColumns.length > 0 ? ( + + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {sourceTableColumns.map((col) => ( + { + setTableConfig({ + ...tableConfig, + conditionalTable: { + ...tableConfig.conditionalTable!, + sourceFilter: { + ...tableConfig.conditionalTable?.sourceFilter!, + filterColumn: col.column_name, + }, + }, + }); + }} + className="text-xs" + > + + {col.column_name} + {col.comment && ( + ({col.comment}) + )} + + ))} + + + + + + ) : ( + { + setTableConfig({ + ...tableConfig, + conditionalTable: { + ...tableConfig.conditionalTable!, + sourceFilter: { + ...tableConfig.conditionalTable?.sourceFilter!, + filterColumn: e.target.value, + }, + }, + }); + }} + placeholder="inspection_type" + className="h-7 text-xs" + /> + )} +

+ 예: 검사유형 "입고검사" 선택 시 → inspection_type = '입고검사' 조건 적용 +

+
+ )} +
+
+ )} +
diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts index 43377764..31388e96 100644 --- a/frontend/lib/registry/components/universal-form-modal/types.ts +++ b/frontend/lib/registry/components/universal-form-modal/types.ts @@ -80,7 +80,8 @@ export interface FormFieldConfig { linkedFieldGroup?: { enabled?: boolean; // 사용 여부 sourceTable?: string; // 소스 테이블 (예: dept_info) - displayColumn?: string; // 표시할 컬럼 (예: dept_name) - 드롭다운에 보여줄 텍스트 + displayColumn?: string; // 메인 표시 컬럼 (예: item_name) - 드롭다운에 보여줄 메인 텍스트 + subDisplayColumn?: string; // 서브 표시 컬럼 (예: item_number) - 메인과 함께 표시될 서브 텍스트 displayFormat?: "name_only" | "code_name" | "name_code" | "custom"; // 표시 형식 // 커스텀 표시 형식 (displayFormat이 "custom"일 때 사용) // 형식: {컬럼명} 으로 치환됨 (예: "{item_name} ({item_number})" → "철판 (ITEM-001)") @@ -256,6 +257,11 @@ export interface TableSectionConfig { modalTitle?: string; // 모달 제목 (기본: "항목 검색 및 선택") multiSelect?: boolean; // 다중 선택 허용 (기본: true) maxHeight?: string; // 테이블 최대 높이 (기본: "400px") + + // 추가 버튼 타입 + // - search: 검색 모달 열기 (기본값) - 기존 데이터에서 선택 + // - addRow: 빈 행 직접 추가 - 새 데이터 직접 입력 + addButtonType?: "search" | "addRow"; }; // 7. 조건부 테이블 설정 (고급) @@ -295,6 +301,13 @@ export interface ConditionalTableConfig { labelColumn: string; // 예: type_name filterCondition?: string; // 예: is_active = 'Y' }; + + // 소스 테이블 필터링 설정 + // 조건 선택 시 소스 테이블(검사기준 등)에서 해당 조건으로 필터링 + sourceFilter?: { + enabled: boolean; + filterColumn: string; // 소스 테이블에서 필터링할 컬럼 (예: inspection_type) + }; } /** @@ -373,6 +386,30 @@ export interface TableColumnConfig { // Select 옵션 (type이 "select"일 때) selectOptions?: { value: string; label: string }[]; + // 동적 Select 옵션 (소스 테이블에서 옵션 로드) + // 조건부 테이블의 sourceFilter가 활성화되어 있으면 자동으로 필터 적용 + dynamicSelectOptions?: { + enabled: boolean; + sourceField: string; // 소스 테이블에서 가져올 컬럼 (예: inspection_item) + labelField?: string; // 표시 라벨 컬럼 (없으면 sourceField 사용) + distinct?: boolean; // 중복 제거 (기본: true) + + // 행 선택 모드: 이 컬럼 선택 시 같은 소스 행의 다른 컬럼들도 자동 채움 + // 활성화하면 이 컬럼이 "대표 컬럼"이 되어 선택 시 연관 컬럼들이 자동으로 채워짐 + rowSelectionMode?: { + enabled: boolean; + // 자동 채움할 컬럼 매핑 (소스 컬럼 → 타겟 필드) + // 예: [{ sourceColumn: "inspection_criteria", targetField: "inspection_standard" }] + autoFillColumns?: { + sourceColumn: string; // 소스 테이블의 컬럼 + targetField: string; // 현재 테이블의 필드 + }[]; + // 소스 테이블의 ID 컬럼 (참조 ID 저장용) + sourceIdColumn?: string; // 예: "id" + targetIdField?: string; // 예: "inspection_standard_id" + }; + }; + // 값 매핑 (핵심 기능) - 고급 설정용 valueMapping?: ValueMappingConfig; @@ -642,6 +679,10 @@ export interface SubTableSaveConfig { // 저장 전 기존 데이터 삭제 deleteExistingBefore?: boolean; deleteOnlySubItems?: boolean; // 메인 항목은 유지하고 서브만 삭제 + + // 수정 모드에서 서브 테이블 데이터 로드 + loadOnEdit?: boolean; // 수정 시 서브 테이블 데이터 로드 여부 + loadOnlySubItems?: boolean; // 서브 항목만 로드 (메인 항목 제외) }; }