From 5948799a29d7b3b04ac67cdb504cc08b510e50b4 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 24 Dec 2025 09:58:22 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=AC=ED=94=BC=ED=84=B0=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=ED=8F=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screen/InteractiveScreenViewerDynamic.tsx | 5 + .../components/unified/UnifiedRepeater.tsx | 349 +++++++++++++++++- .../UnifiedRepeaterConfigPanel.tsx | 47 ++- .../lib/registry/DynamicComponentRenderer.tsx | 10 +- frontend/lib/utils/buttonActions.ts | 60 ++- frontend/types/unified-repeater.ts | 10 + 6 files changed, 445 insertions(+), 36 deletions(-) diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index aed06109..6eebe5d2 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -541,6 +541,11 @@ export const InteractiveScreenViewerDynamic: React.FC; + } +} + export const UnifiedRepeater: React.FC = ({ config: propConfig, parentId, @@ -75,11 +89,38 @@ export const UnifiedRepeater: React.FC = ({ // 상태 - 버튼 모드 const [codeButtons, setCodeButtons] = useState<{ label: string; value: string; variant?: string }[]>([]); + // 상태 - 컬럼별 공통코드 옵션 (code 타입용) + const [columnCodeOptions, setColumnCodeOptions] = useState>({}); + // 상태 - 엔티티 표시 정보 캐시 (FK값 → 표시 데이터) const [entityDisplayCache, setEntityDisplayCache] = useState>>({}); // 상태 - 소스 테이블 컬럼 정보 (라벨 매핑용) const [sourceTableColumnMap, setSourceTableColumnMap] = useState>({}); + + // 상태 - 현재 테이블 컬럼 정보 (inputType 매핑용) + const [currentTableColumnInfo, setCurrentTableColumnInfo] = useState>({}); + + // 🆕 전역 등록 - 리피터가 있으면 메인 폼 단독 저장 건너뛰기 + useEffect(() => { + const tableName = config.dataSource?.tableName; + if (!tableName) return; + + // 전역 Set 초기화 + if (!window.__unifiedRepeaterInstances) { + window.__unifiedRepeaterInstances = new Set(); + } + + // 등록 + window.__unifiedRepeaterInstances.add(tableName); + console.log("📦 UnifiedRepeater 등록:", tableName, Array.from(window.__unifiedRepeaterInstances)); + + return () => { + // 해제 + window.__unifiedRepeaterInstances?.delete(tableName); + console.log("📦 UnifiedRepeater 해제:", tableName, Array.from(window.__unifiedRepeaterInstances || [])); + }; + }, [config.dataSource?.tableName]); // 외부 데이터 변경 시 동기화 useEffect(() => { @@ -88,6 +129,138 @@ export const UnifiedRepeater: React.FC = ({ } }, [initialData]); + // 저장 이벤트 리스너 - 상위에서 저장 버튼 클릭 시 리피터 데이터도 저장 + // 🔧 메인 폼 데이터 + 리피터 행 데이터를 병합해서 저장 + useEffect(() => { + const handleSaveEvent = async (event: CustomEvent) => { + const tableName = config.dataSource?.tableName; + const eventParentId = event.detail?.parentId; + const mainFormData = event.detail?.mainFormData || {}; // 🆕 메인 폼 데이터 + + if (!tableName || data.length === 0) { + console.log("📦 UnifiedRepeater 저장 스킵:", { tableName, dataCount: data.length, hasTable: !!tableName }); + return; + } + + console.log("📦 UnifiedRepeater 저장 이벤트 수신:", { + tableName, + dataCount: data.length, + eventParentId, + propsParentId: parentId, + mainFormDataKeys: Object.keys(mainFormData), + referenceKey: config.dataSource?.referenceKey + }); + + try { + // 새 데이터 삽입 (메인 폼 데이터 + 리피터 행 데이터 병합) + console.log("🔄 UnifiedRepeater 데이터 삽입 시작:", { dataCount: data.length, mainFormData }); + + // 🆕 테이블에 존재하는 컬럼 목록 조회 (존재하지 않는 컬럼 필터링용) + let validColumns: Set = new Set(); + try { + const columnsResponse = await apiClient.get(`/table-management/tables/${tableName}/columns`); + const columns = columnsResponse.data?.data?.columns || columnsResponse.data?.columns || columnsResponse.data || []; + validColumns = new Set(columns.map((col: any) => col.columnName || col.column_name || col.name)); + console.log("📋 테이블 유효 컬럼:", Array.from(validColumns)); + } catch { + console.warn("⚠️ 테이블 컬럼 정보 조회 실패 - 모든 필드 저장 시도"); + } + + for (let i = 0; i < data.length; i++) { + const row = data[i]; + console.log(`🔄 [${i + 1}/${data.length}] 리피터 행 데이터:`, row); + + // _display_ 필드와 임시 필드 제거 + const cleanRow = Object.fromEntries( + Object.entries(row).filter(([key]) => !key.startsWith("_")) + ); + + // 🆕 메인 폼 데이터 + 리피터 행 데이터 병합 + // 리피터 행 데이터가 우선 (같은 키가 있으면 덮어씀) + // 메인 폼에서 제외할 필드: id (각 행은 새 레코드) + const { id: _mainId, ...mainFormDataWithoutId } = mainFormData; + const mergedData = { + ...mainFormDataWithoutId, // 메인 폼 데이터 (id 제외) + ...cleanRow, // 리피터 행 데이터 (우선) + }; + + // 🆕 테이블에 존재하지 않는 컬럼 제거 + const filteredData: Record = {}; + for (const [key, value] of Object.entries(mergedData)) { + // validColumns가 비어있으면 (조회 실패) 모든 필드 포함 + // validColumns가 있으면 해당 컬럼만 포함 + if (validColumns.size === 0 || validColumns.has(key)) { + filteredData[key] = value; + } else { + console.log(`🗑️ [${i + 1}/${data.length}] 필터링된 컬럼 (테이블에 없음): ${key}`); + } + } + + console.log(`📝 [${i + 1}/${data.length}] 최종 저장 데이터:`, JSON.stringify(filteredData, null, 2)); + + try { + // /add 엔드포인트 사용 (INSERT) + const saveResponse = await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData); + console.log(`✅ [${i + 1}/${data.length}] 저장 성공:`, saveResponse.data); + } catch (saveError) { + console.error(`❌ [${i + 1}/${data.length}] 저장 실패:`, saveError); + throw saveError; + } + } + + console.log("✅ UnifiedRepeater 전체 데이터 저장 완료:", data.length, "건"); + } catch (error) { + console.error("❌ UnifiedRepeater 저장 실패:", error); + throw error; // 상위에서 에러 처리 + } + }; + + window.addEventListener("repeaterSave" as any, handleSaveEvent); + return () => { + window.removeEventListener("repeaterSave" as any, handleSaveEvent); + }; + }, [data, config.dataSource?.tableName, config.dataSource?.referenceKey, parentId]); + + // 현재 테이블 컬럼 정보 로드 (inputType 매핑용) + useEffect(() => { + const loadCurrentTableColumnInfo = async () => { + const tableName = config.dataSource?.tableName; + if (!tableName) return; + + try { + const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); + const columns = response.data?.data?.columns || response.data?.columns || response.data || []; + + const colInfo: Record = {}; + if (Array.isArray(columns)) { + columns.forEach((col: any) => { + const colName = col.columnName || col.column_name; + let detailSettings = col.detailSettings || col.detail_settings; + if (typeof detailSettings === "string") { + try { + detailSettings = JSON.parse(detailSettings); + } catch (e) { + detailSettings = null; + } + } + if (colName) { + colInfo[colName] = { + inputType: col.inputType || col.input_type || "text", + detailSettings, + }; + } + }); + } + setCurrentTableColumnInfo(colInfo); + console.log("현재 테이블 컬럼 정보 로드:", tableName, colInfo); + } catch (error) { + console.error("현재 테이블 컬럼 정보 로드 실패:", error); + } + }; + + loadCurrentTableColumnInfo(); + }, [config.dataSource?.tableName]); + // 소스 테이블 컬럼 정보 로드 (라벨 매핑용) useEffect(() => { const loadSourceTableColumns = async () => { @@ -157,6 +330,59 @@ export const UnifiedRepeater: React.FC = ({ loadCodeButtons(); }, [config.button?.sourceType, config.button?.commonCode]); + // 컬럼별 공통코드 옵션 로드 (code 타입 컬럼용) + useEffect(() => { + const loadColumnCodeOptions = async () => { + // config.columns와 currentTableColumnInfo를 함께 확인하여 code 타입 컬럼 찾기 + const codeColumnsToLoad: { key: string; codeGroup: string }[] = []; + + config.columns.forEach((col) => { + // 저장된 설정에서 codeGroup 확인 + let codeGroup = col.detailSettings?.codeGroup; + let inputType = col.inputType; + + // 저장된 정보가 없으면 현재 테이블 정보에서 확인 + if (!inputType || inputType === "text") { + const tableColInfo = currentTableColumnInfo[col.key]; + if (tableColInfo) { + inputType = tableColInfo.inputType; + if (!codeGroup && tableColInfo.detailSettings?.codeGroup) { + codeGroup = tableColInfo.detailSettings.codeGroup; + } + } + } + + if (inputType === "code" && codeGroup) { + codeColumnsToLoad.push({ key: col.key, codeGroup }); + } + }); + + if (codeColumnsToLoad.length === 0) return; + + const newOptions: Record = {}; + + await Promise.all( + codeColumnsToLoad.map(async ({ key, codeGroup }) => { + try { + const response = await commonCodeApi.codes.getList(codeGroup); + if (response.success && response.data) { + newOptions[key] = response.data.map((code) => ({ + label: code.codeName, + value: code.codeValue, + })); + } + } catch (error) { + console.error(`공통코드 로드 실패 (${codeGroup}):`, error); + } + }) + ); + + setColumnCodeOptions((prev) => ({ ...prev, ...newOptions })); + }; + + loadColumnCodeOptions(); + }, [config.columns, currentTableColumnInfo]); + // 소스 테이블 검색 (modal 모드) const searchSourceTable = useCallback(async () => { const sourceTable = config.dataSource?.sourceTable; @@ -491,6 +717,105 @@ export const UnifiedRepeater: React.FC = ({ return entityDisplayCache[fkValue] || null; }; + // inputType별 입력 컴포넌트 렌더링 + const renderColumnInput = (col: RepeaterColumnConfig, value: any, onChange: (value: any) => void) => { + // 저장된 inputType이 없거나 "text"이면 현재 테이블 정보에서 조회 + let inputType = col.inputType; + let detailSettings = col.detailSettings; + + if (!inputType || inputType === "text") { + const tableColInfo = currentTableColumnInfo[col.key]; + if (tableColInfo) { + inputType = tableColInfo.inputType; + detailSettings = tableColInfo.detailSettings || detailSettings; + } + } + inputType = inputType || "text"; + + const commonClasses = "h-8 text-sm min-w-[80px] w-full"; + + console.log("renderColumnInput:", { key: col.key, inputType, value, detailSettings }); + + switch (inputType) { + case "number": + case "decimal": + return ( + onChange(e.target.value)} + className={commonClasses} + onClick={(e) => e.stopPropagation()} + step={inputType === "decimal" ? "0.01" : "1"} + /> + ); + + case "date": + return ( + + + + + + onChange(date ? format(date, "yyyy-MM-dd") : "")} + initialFocus + /> + + + ); + + case "code": + const codeOptions = columnCodeOptions[col.key] || []; + return ( + + ); + + case "boolean": + case "checkbox": + return ( +
e.stopPropagation()}> + onChange(checked ? "Y" : "N")} + /> +
+ ); + + case "text": + default: + return ( + onChange(e.target.value)} + className={commonClasses} + onClick={(e) => e.stopPropagation()} + /> + ); + } + }; + // 테이블 렌더링 const renderTable = () => { if (config.renderMode === "button") return null; @@ -598,19 +923,17 @@ export const UnifiedRepeater: React.FC = ({ .map((col) => ( {editingRow === index && col.editable !== false ? ( - - setEditedData((prev) => ({ - ...prev, - [col.key]: e.target.value, - })) - } - className="h-7 text-xs min-w-[80px] w-full" - onClick={(e) => e.stopPropagation()} - /> + renderColumnInput( + col, + editedData[col.key], + (value) => setEditedData((prev) => ({ ...prev, [col.key]: value })) + ) ) : ( - {row[col.key] || "-"} + + {col.inputType === "code" && columnCodeOptions[col.key] + ? columnCodeOptions[col.key].find((opt) => opt.value === row[col.key])?.label || row[col.key] || "-" + : row[col.key] || "-"} + )} ))} diff --git a/frontend/components/unified/config-panels/UnifiedRepeaterConfigPanel.tsx b/frontend/components/unified/config-panels/UnifiedRepeaterConfigPanel.tsx index 152757f9..9a5fdc40 100644 --- a/frontend/components/unified/config-panels/UnifiedRepeaterConfigPanel.tsx +++ b/frontend/components/unified/config-panels/UnifiedRepeaterConfigPanel.tsx @@ -61,6 +61,13 @@ interface ColumnOption { columnName: string; displayName: string; inputType?: string; + detailSettings?: { + codeGroup?: string; + referenceTable?: string; + referenceColumn?: string; + displayColumn?: string; + format?: string; + }; } interface EntityColumnOption { @@ -194,27 +201,34 @@ export const UnifiedRepeaterConfigPanel: React.FC c.key !== column.columnName); updateConfig({ columns: newColumns }); } else { + // 컬럼의 inputType과 detailSettings 정보 포함 const newColumn: RepeaterColumnConfig = { key: column.columnName, title: column.displayName, width: "auto", visible: true, editable: true, + inputType: column.inputType || "text", + detailSettings: column.detailSettings ? { + codeGroup: column.detailSettings.codeGroup, + referenceTable: column.detailSettings.referenceTable, + referenceColumn: column.detailSettings.referenceColumn, + displayColumn: column.detailSettings.displayColumn, + format: column.detailSettings.format, + } : undefined, }; updateConfig({ columns: [...config.columns, newColumn] }); } diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index fb6a95ad..b6312c73 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -398,10 +398,12 @@ export const DynamicComponentRenderer: React.FC =