From 2b035ce6e14a53fa70789f10e813f80bc22e9cc1 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Mon, 9 Feb 2026 15:03:29 +0900 Subject: [PATCH] Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node --- .../src/controllers/screenGroupController.ts | 2 +- backend-node/src/services/dataService.ts | 161 ++-- frontend/components/screen/ScreenDesigner.tsx | 507 ++---------- .../components/screen/ScreenSettingModal.tsx | 18 +- .../SelectedItemsDetailInputComponent.tsx | 742 +++++++----------- 5 files changed, 406 insertions(+), 1024 deletions(-) diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts index 88230f48..b53454b9 100644 --- a/backend-node/src/controllers/screenGroupController.ts +++ b/backend-node/src/controllers/screenGroupController.ts @@ -2839,4 +2839,4 @@ export const ensurePopRootGroup = async (req: AuthenticatedRequest, res: Respons logger.error("POP 루트 그룹 확보 실패:", error); res.status(500).json({ success: false, message: "POP 루트 그룹 확보에 실패했습니다.", error: error.message }); } -}; +}; \ No newline at end of file diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts index 9623d976..ff3b502a 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -1405,7 +1405,7 @@ class DataService { console.log(`✅ 기존 레코드: ${existingRecords.rows.length}개`); - // 2. 새 레코드와 기존 레코드 비교 + // 2. id 기반 UPSERT: 레코드에 id(PK)가 있으면 UPDATE, 없으면 INSERT let inserted = 0; let updated = 0; let deleted = 0; @@ -1413,125 +1413,86 @@ class DataService { // 날짜 필드를 YYYY-MM-DD 형식으로 변환하는 헬퍼 함수 const normalizeDateValue = (value: any): any => { if (value == null) return value; - - // ISO 날짜 문자열 감지 (YYYY-MM-DDTHH:mm:ss.sssZ) if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T/.test(value)) { - return value.split("T")[0]; // YYYY-MM-DD 만 추출 + return value.split("T")[0]; } - return value; }; - // 새 레코드 처리 (INSERT or UPDATE) - for (const newRecord of records) { - console.log(`🔍 처리할 새 레코드:`, newRecord); + const existingIds = new Set(existingRecords.rows.map((r: any) => r[pkColumn])); + const processedIds = new Set(); // UPDATE 처리된 id 추적 + // DEBUG: 수신된 레코드와 기존 레코드 id 확인 + console.log(`🔑 [UPSERT DEBUG] pkColumn: ${pkColumn}`); + console.log(`🔑 [UPSERT DEBUG] existingIds:`, Array.from(existingIds)); + console.log(`🔑 [UPSERT DEBUG] records received:`, records.map((r: any) => ({ id: r[pkColumn], keys: Object.keys(r) }))); + + for (const newRecord of records) { // 날짜 필드 정규화 const normalizedRecord: Record = {}; for (const [key, value] of Object.entries(newRecord)) { normalizedRecord[key] = normalizeDateValue(value); } - console.log(`🔄 정규화된 레코드:`, normalizedRecord); + const recordId = normalizedRecord[pkColumn]; // 프론트에서 보낸 기존 레코드의 id - // 전체 레코드 데이터 (parentKeys + normalizedRecord) - const fullRecord = { ...parentKeys, ...normalizedRecord }; - - // 고유 키: parentKeys 제외한 나머지 필드들 - const uniqueFields = Object.keys(normalizedRecord); - - console.log(`🔑 고유 필드들:`, uniqueFields); - - // 기존 레코드에서 일치하는 것 찾기 - const existingRecord = existingRecords.rows.find((existing) => { - return uniqueFields.every((field) => { - const existingValue = existing[field]; - const newValue = normalizedRecord[field]; - - // null/undefined 처리 - if (existingValue == null && newValue == null) return true; - if (existingValue == null || newValue == null) return false; - - // Date 타입 처리 - if (existingValue instanceof Date && typeof newValue === "string") { - return ( - existingValue.toISOString().split("T")[0] === - newValue.split("T")[0] - ); - } - - // 문자열 비교 - return String(existingValue) === String(newValue); - }); - }); - - if (existingRecord) { - // UPDATE: 기존 레코드가 있으면 업데이트 + if (recordId && existingIds.has(recordId)) { + // ===== UPDATE: id(PK)가 DB에 존재 → 해당 레코드 업데이트 ===== + const fullRecord = { ...parentKeys, ...normalizedRecord }; const updateFields: string[] = []; const updateValues: any[] = []; - let updateParamIndex = 1; + let paramIdx = 1; for (const [key, value] of Object.entries(fullRecord)) { if (key !== pkColumn) { - // Primary Key는 업데이트하지 않음 - updateFields.push(`"${key}" = $${updateParamIndex}`); + updateFields.push(`"${key}" = $${paramIdx}`); updateValues.push(value); - updateParamIndex++; + paramIdx++; } } - updateValues.push(existingRecord[pkColumn]); // WHERE 조건용 - const updateQuery = ` - UPDATE "${tableName}" - SET ${updateFields.join(", ")}, updated_date = NOW() - WHERE "${pkColumn}" = $${updateParamIndex} - `; - - await pool.query(updateQuery, updateValues); - updated++; - - console.log(`✏️ UPDATE: ${pkColumn} = ${existingRecord[pkColumn]}`); + if (updateFields.length > 0) { + updateValues.push(recordId); + const updateQuery = ` + UPDATE "${tableName}" + SET ${updateFields.join(", ")}, updated_date = NOW() + WHERE "${pkColumn}" = $${paramIdx} + `; + await pool.query(updateQuery, updateValues); + updated++; + processedIds.add(recordId); + console.log(`✏️ UPDATE by id: ${pkColumn} = ${recordId}`); + } } else { - // INSERT: 기존 레코드가 없으면 삽입 - - // 🆕 자동 필드 추가 (company_code, writer, created_date, updated_date, id) - // created_date는 프론트엔드에서 전달된 값 무시하고 항상 현재 시간 설정 - const { created_date: _, ...recordWithoutCreatedDate } = fullRecord; + // ===== INSERT: id 없음 또는 DB에 없음 → 새 레코드 삽입 ===== + const { [pkColumn]: _removedId, created_date: _cd, ...cleanRecord } = normalizedRecord; + const fullRecord = { ...parentKeys, ...cleanRecord }; + const newId = uuidv4(); const recordWithMeta: Record = { - ...recordWithoutCreatedDate, - id: uuidv4(), // 새 ID 생성 + ...fullRecord, + [pkColumn]: newId, created_date: "NOW()", updated_date: "NOW()", }; - // company_code가 없으면 userCompany 사용 (단, userCompany가 "*"가 아닐 때만) - if ( - !recordWithMeta.company_code && - userCompany && - userCompany !== "*" - ) { + if (!recordWithMeta.company_code && userCompany && userCompany !== "*") { recordWithMeta.company_code = userCompany; } - - // writer가 없으면 userId 사용 if (!recordWithMeta.writer && userId) { recordWithMeta.writer = userId; } - const insertFields = Object.keys(recordWithMeta).filter( - (key) => recordWithMeta[key] !== "NOW()" - ); const insertPlaceholders: string[] = []; const insertValues: any[] = []; - let insertParamIndex = 1; + let paramIdx = 1; for (const field of Object.keys(recordWithMeta)) { if (recordWithMeta[field] === "NOW()") { insertPlaceholders.push("NOW()"); } else { - insertPlaceholders.push(`$${insertParamIndex}`); + insertPlaceholders.push(`$${paramIdx}`); insertValues.push(recordWithMeta[field]); - insertParamIndex++; + paramIdx++; } } @@ -1541,49 +1502,21 @@ class DataService { .join(", ")}) VALUES (${insertPlaceholders.join(", ")}) `; - - console.log(`➕ INSERT 쿼리:`, { - query: insertQuery, - values: insertValues, - }); - await pool.query(insertQuery, insertValues); inserted++; - - console.log(`➕ INSERT: 새 레코드`); + processedIds.add(newId); + console.log(`➕ INSERT: 새 레코드 ${pkColumn} = ${newId}`); } } - // 3. 삭제할 레코드 찾기 (기존 레코드 중 새 레코드에 없는 것) - for (const existingRecord of existingRecords.rows) { - const uniqueFields = Object.keys(records[0] || {}); - - const stillExists = records.some((newRecord) => { - return uniqueFields.every((field) => { - const existingValue = existingRecord[field]; - const newValue = newRecord[field]; - - if (existingValue == null && newValue == null) return true; - if (existingValue == null || newValue == null) return false; - - if (existingValue instanceof Date && typeof newValue === "string") { - return ( - existingValue.toISOString().split("T")[0] === - newValue.split("T")[0] - ); - } - - return String(existingValue) === String(newValue); - }); - }); - - if (!stillExists) { - // DELETE: 새 레코드에 없으면 삭제 + // 3. 고아 레코드 삭제: 기존 레코드 중 이번에 처리되지 않은 것 삭제 + for (const existingRow of existingRecords.rows) { + const existId = existingRow[pkColumn]; + if (!processedIds.has(existId)) { const deleteQuery = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`; - await pool.query(deleteQuery, [existingRecord[pkColumn]]); + await pool.query(deleteQuery, [existId]); deleted++; - - console.log(`🗑️ DELETE: ${pkColumn} = ${existingRecord[pkColumn]}`); + console.log(`🗑️ DELETE orphan: ${pkColumn} = ${existId}`); } } diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 27e96050..9e724a3f 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -2,7 +2,6 @@ import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { Database, Cog } from "lucide-react"; -import { cn } from "@/lib/utils"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; import { @@ -133,9 +132,6 @@ interface ScreenDesignerProps { selectedScreen: ScreenDefinition | null; onBackToList: () => void; onScreenUpdate?: (updatedScreen: Partial) => void; - // POP 모드 지원 - isPop?: boolean; - defaultDevicePreview?: "mobile" | "tablet"; } import { useLayerOptional, LayerProvider, createDefaultLayer } from "@/contexts/LayerContext"; @@ -162,15 +158,7 @@ const panelConfigs: PanelConfig[] = [ }, ]; -export default function ScreenDesigner({ - selectedScreen, - onBackToList, - onScreenUpdate, - isPop = false, - defaultDevicePreview = "tablet" -}: ScreenDesignerProps) { - // POP 모드 여부에 따른 API 분기 - const USE_POP_API = isPop; +export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenUpdate }: ScreenDesignerProps) { const [layout, setLayout] = useState({ components: [], gridSettings: { @@ -512,49 +500,25 @@ export default function ScreenDesigner({ return lines; }, [layout.gridSettings?.showGrid, screenResolution.width, screenResolution.height]); - // 🆕 현재 편집 중인 레이어 ID (DB의 layer_id, 1 = 기본 레이어) - const [activeLayerId, setActiveLayerIdLocal] = useState(1); - const activeLayerIdRef = useRef(1); - const setActiveLayerIdWithRef = useCallback((id: number) => { - setActiveLayerIdLocal(id); - activeLayerIdRef.current = id; - }, []); + // 🆕 레이어 활성 상태 관리 (LayerProvider 외부에서 관리) + const [activeLayerId, setActiveLayerIdLocal] = useState("default-layer"); - // 🆕 좌측 패널 탭 상태 관리 - const [leftPanelTab, setLeftPanelTab] = useState("components"); - - // 🆕 레이어 영역 (기본 레이어에서 조건부 레이어들의 displayRegion 표시) - const [layerRegions, setLayerRegions] = useState>({}); - - // 🆕 조건부 영역 드래그 상태 (캔버스에서 드래그로 영역 설정) - const [regionDrag, setRegionDrag] = useState<{ - isDrawing: boolean; // 새 영역 그리기 모드 - isDragging: boolean; // 기존 영역 이동 모드 - isResizing: boolean; // 기존 영역 리사이즈 모드 - targetLayerId: string | null; // 대상 레이어 ID - startX: number; - startY: number; - currentX: number; - currentY: number; - resizeHandle: string | null; // 리사이즈 핸들 위치 - originalRegion: { x: number; y: number; width: number; height: number } | null; - }>({ - isDrawing: false, - isDragging: false, - isResizing: false, - targetLayerId: null, - startX: 0, - startY: 0, - currentX: 0, - currentY: 0, - resizeHandle: null, - originalRegion: null, - }); - - // 캔버스에 렌더링할 컴포넌트 (DB 기반 레이어: 각 레이어별로 별도 로드되므로 전체 표시) + // 캔버스에 렌더링할 컴포넌트 필터링 (레이어 기반) + // 활성 레이어가 있으면 해당 레이어의 컴포넌트만 표시 + // layerId가 없는 컴포넌트는 기본 레이어("default-layer")에 속한 것으로 처리 const visibleComponents = useMemo(() => { - return layout.components; - }, [layout.components]); + // 레이어 시스템이 활성화되지 않았거나 활성 레이어가 없으면 모든 컴포넌트 표시 + if (!activeLayerId) { + return layout.components; + } + + // 활성 레이어에 속한 컴포넌트만 필터링 + return layout.components.filter((comp) => { + // layerId가 없는 컴포넌트는 기본 레이어("default-layer")에 속한 것으로 처리 + const compLayerId = comp.layerId || "default-layer"; + return compLayerId === activeLayerId; + }); + }, [layout.components, activeLayerId]); // 이미 배치된 컬럼 목록 계산 const placedColumns = useMemo(() => { @@ -1483,15 +1447,9 @@ export default function ScreenDesigner({ console.warn("⚠️ 화면에 할당된 메뉴가 없습니다"); } - // V2/POP API 사용 여부에 따라 분기 + // V2 API 사용 여부에 따라 분기 let response: any; - if (USE_POP_API) { - // POP 모드: screen_layouts_pop 테이블 사용 - const popResponse = await screenApi.getLayoutPop(selectedScreen.screenId); - response = popResponse ? convertV2ToLegacy(popResponse) : null; - console.log("📱 POP 레이아웃 로드:", popResponse?.components?.length || 0, "개 컴포넌트"); - } else if (USE_V2_API) { - // 데스크톱 V2 모드: screen_layouts_v2 테이블 사용 + if (USE_V2_API) { const v2Response = await screenApi.getLayoutV2(selectedScreen.screenId); // 🐛 디버깅: API 응답에서 fieldMapping.id 확인 @@ -1574,21 +1532,6 @@ export default function ScreenDesigner({ // 파일 컴포넌트 데이터 복원 (비동기) restoreFileComponentsData(layoutWithDefaultGrid.components); - - // 🆕 레이어 영역 로드 (조건부 레이어의 displayRegion) - try { - const layers = await screenApi.getScreenLayers(selectedScreen.screenId); - const regions: Record = {}; - for (const layer of layers) { - if (layer.layer_id > 1 && layer.condition_config?.displayRegion) { - regions[layer.layer_id] = { - ...layer.condition_config.displayRegion, - layerName: layer.layer_name, - }; - } - } - setLayerRegions(regions); - } catch { /* 레이어 로드 실패 무시 */ } } } catch (error) { // console.error("레이아웃 로드 실패:", error); @@ -2026,25 +1969,37 @@ export default function ScreenDesigner({ // 현재 선택된 테이블을 화면의 기본 테이블로 저장 const currentMainTableName = tables.length > 0 ? tables[0].tableName : null; + // 🆕 레이어 정보도 함께 저장 (레이어가 있으면 레이어의 컴포넌트로 업데이트) + const updatedLayers = layout.layers?.map((layer) => ({ + ...layer, + components: layer.components.map((comp) => { + // 분할 패널 업데이트 로직 적용 + const updatedComp = updatedComponents.find((uc) => uc.id === comp.id); + return updatedComp || comp; + }), + })); + const layoutWithResolution = { ...layout, components: updatedComponents, + layers: updatedLayers, // 🆕 레이어 정보 포함 screenResolution: screenResolution, mainTableName: currentMainTableName, // 화면의 기본 테이블 }; + // 🔍 버튼 컴포넌트들의 action.type 확인 + const buttonComponents = layoutWithResolution.components.filter( + (c: any) => c.componentType?.startsWith("button") || c.type === "button" || c.type === "button-primary", + ); + // 💾 저장 로그 (디버그 완료 - 간소화) + // console.log("💾 저장 시작:", { screenId: selectedScreen.screenId, componentsCount: layoutWithResolution.components.length }); + // 분할 패널 디버그 로그 (주석 처리) - // V2/POP API 사용 여부에 따라 분기 - const v2Layout = convertLegacyToV2(layoutWithResolution); - if (USE_POP_API) { - // POP 모드: screen_layouts_pop 테이블에 저장 - await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout); - } else if (USE_V2_API) { - // 레이어 기반 저장: 현재 활성 레이어의 layout만 저장 - const currentLayerId = activeLayerIdRef.current || 1; - await screenApi.saveLayoutV2(selectedScreen.screenId, { - ...v2Layout, - layerId: currentLayerId, - }); + // V2 API 사용 여부에 따라 분기 + if (USE_V2_API) { + // 🔧 V2 레이아웃 저장 (디버그 로그 주석 처리) + const v2Layout = convertLegacyToV2(layoutWithResolution); + await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout); + // console.log("📦 V2 레이아웃 저장:", v2Layout.components.length, "개 컴포넌트"); } else { await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution); } @@ -2067,18 +2022,6 @@ export default function ScreenDesigner({ } }, [selectedScreen, layout, screenResolution, tables, onScreenUpdate]); - // POP 미리보기 핸들러 (새 창에서 열기) - const handlePopPreview = useCallback(() => { - if (!selectedScreen?.screenId) { - toast.error("화면 정보가 없습니다."); - return; - } - - const deviceType = defaultDevicePreview || "tablet"; - const previewUrl = `/pop/screens/${selectedScreen.screenId}?preview=true&device=${deviceType}`; - window.open(previewUrl, "_blank", "width=800,height=900"); - }, [selectedScreen, defaultDevicePreview]); - // 다국어 자동 생성 핸들러 const handleGenerateMultilang = useCallback(async () => { if (!selectedScreen?.screenId) { @@ -2157,10 +2100,8 @@ export default function ScreenDesigner({ // 자동 저장 (매핑 정보가 손실되지 않도록) try { - const v2Layout = convertLegacyToV2(updatedLayout); - if (USE_POP_API) { - await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout); - } else if (USE_V2_API) { + if (USE_V2_API) { + const v2Layout = convertLegacyToV2(updatedLayout); await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout); } else { await screenApi.saveLayout(selectedScreen.screenId, updatedLayout); @@ -2580,10 +2521,10 @@ export default function ScreenDesigner({ } }); - // 🆕 현재 활성 레이어에 컴포넌트 추가 (ref 사용으로 클로저 문제 방지) + // 🆕 현재 활성 레이어에 컴포넌트 추가 const componentsWithLayerId = newComponents.map((comp) => ({ ...comp, - layerId: activeLayerIdRef.current || 1, + layerId: activeLayerId || "default-layer", })); // 레이아웃에 새 컴포넌트들 추가 @@ -2602,7 +2543,7 @@ export default function ScreenDesigner({ toast.success(`${template.name} 템플릿이 추가되었습니다.`); }, - [layout, selectedScreen, saveToHistory], + [layout, selectedScreen, saveToHistory, activeLayerId], ); // 레이아웃 드래그 처리 @@ -2656,7 +2597,7 @@ export default function ScreenDesigner({ label: layoutData.label, allowedComponentTypes: layoutData.allowedComponentTypes, dropZoneConfig: layoutData.dropZoneConfig, - layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용) + layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가 } as ComponentData; // 레이아웃에 새 컴포넌트 추가 @@ -2673,7 +2614,7 @@ export default function ScreenDesigner({ toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`); }, - [layout, screenResolution, saveToHistory, zoomLevel], + [layout, screenResolution, saveToHistory, zoomLevel, activeLayerId], ); // handleZoneComponentDrop은 handleComponentDrop으로 대체됨 @@ -3264,7 +3205,7 @@ export default function ScreenDesigner({ position: snappedPosition, size: componentSize, gridColumns: gridColumns, // 컴포넌트별 그리드 컬럼 수 적용 - layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용) + layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가 componentConfig: { type: component.id, // 새 컴포넌트 시스템의 ID 사용 webType: component.webType, // 웹타입 정보 추가 @@ -3298,7 +3239,7 @@ export default function ScreenDesigner({ toast.success(`${component.name} 컴포넌트가 추가되었습니다.`); }, - [layout, selectedScreen, saveToHistory], + [layout, selectedScreen, saveToHistory, activeLayerId], ); // 드래그 앤 드롭 처리 @@ -3307,7 +3248,7 @@ export default function ScreenDesigner({ }, []); const handleDrop = useCallback( - async (e: React.DragEvent) => { + (e: React.DragEvent) => { e.preventDefault(); const dragData = e.dataTransfer.getData("application/json"); @@ -3339,41 +3280,6 @@ export default function ScreenDesigner({ return; } - // 🆕 조건부 레이어 영역 드래그인 경우 → DB condition_config에 displayRegion 저장 - if (parsedData.type === "layer-region" && parsedData.layerId && selectedScreen?.screenId) { - const canvasRect = canvasRef.current?.getBoundingClientRect(); - if (!canvasRect) return; - const dropX = Math.round((e.clientX - canvasRect.left) / zoomLevel); - const dropY = Math.round((e.clientY - canvasRect.top) / zoomLevel); - const newRegion = { - x: Math.max(0, dropX - 400), - y: Math.max(0, dropY), - width: Math.min(800, screenResolution.width), - height: 200, - }; - // DB에 displayRegion 저장 (condition_config에 포함) - try { - // 기존 condition_config를 가져와서 displayRegion만 추가/업데이트 - const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, parsedData.layerId); - const existingCondition = layerData?.conditionConfig || {}; - await screenApi.updateLayerCondition( - selectedScreen.screenId, - parsedData.layerId, - { ...existingCondition, displayRegion: newRegion } - ); - // 레이어 영역 state에 반영 (캔버스에 즉시 표시) - setLayerRegions((prev) => ({ - ...prev, - [parsedData.layerId]: { ...newRegion, layerName: parsedData.layerName }, - })); - toast.success(`"${parsedData.layerName}" 영역이 배치되었습니다.`); - } catch (error) { - console.error("레이어 영역 저장 실패:", error); - toast.error("레이어 영역 저장에 실패했습니다."); - } - return; - } - // 기존 테이블/컬럼 드래그 처리 const { type, table, column } = parsedData; @@ -3705,7 +3611,7 @@ export default function ScreenDesigner({ tableName: table.tableName, position: { x, y, z: 1 } as Position, size: { width: 300, height: 200 }, - layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용) + layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가 style: { labelDisplay: true, labelFontSize: "14px", @@ -3956,7 +3862,7 @@ export default function ScreenDesigner({ componentType: v2Mapping.componentType, // v2-input, v2-select 등 position: { x: relativeX, y: relativeY, z: 1 } as Position, size: { width: componentWidth, height: getDefaultHeight(column.widgetType) }, - layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용) + layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가 // 코드 타입인 경우 코드 카테고리 정보 추가 ...(column.widgetType === "code" && column.codeCategory && { @@ -4023,7 +3929,7 @@ export default function ScreenDesigner({ componentType: v2Mapping.componentType, // v2-input, v2-select 등 position: { x, y, z: 1 } as Position, size: { width: componentWidth, height: getDefaultHeight(column.widgetType) }, - layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용) + layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가 // 코드 타입인 경우 코드 카테고리 정보 추가 ...(column.widgetType === "code" && column.codeCategory && { @@ -4846,7 +4752,7 @@ export default function ScreenDesigner({ z: clipComponent.position.z || 1, } as Position, parentId: undefined, // 붙여넣기 시 부모 관계 해제 - layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 붙여넣기 (ref 사용) + layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 붙여넣기 }; newComponents.push(newComponent); }); @@ -4867,7 +4773,7 @@ export default function ScreenDesigner({ // console.log("컴포넌트 붙여넣기 완료:", newComponents.length, "개"); toast.success(`${newComponents.length}개 컴포넌트가 붙여넣어졌습니다.`); - }, [clipboard, layout, saveToHistory]); + }, [clipboard, layout, saveToHistory, activeLayerId]); // 🆕 플로우 버튼 그룹 생성 (다중 선택된 버튼들을 한 번에 그룹으로) // 🆕 플로우 버튼 그룹 다이얼로그 상태 @@ -5571,11 +5477,9 @@ export default function ScreenDesigner({ gridSettings: layoutWithResolution.gridSettings, screenResolution: layoutWithResolution.screenResolution, }); - // V2/POP API 사용 여부에 따라 분기 - const v2Layout = convertLegacyToV2(layoutWithResolution); - if (USE_POP_API) { - await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout); - } else if (USE_V2_API) { + // V2 API 사용 여부에 따라 분기 + if (USE_V2_API) { + const v2Layout = convertLegacyToV2(layoutWithResolution); await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout); } else { await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution); @@ -5769,152 +5673,21 @@ export default function ScreenDesigner({ }; }, [layout, selectedComponent]); - // 🆕 조건부 영역 드래그 핸들러 (이동/리사이즈, DB 기반) - const handleRegionMouseDown = useCallback(( - e: React.MouseEvent, - layerId: string, - mode: "move" | "resize", - handle?: string, - ) => { - e.stopPropagation(); - e.preventDefault(); - const lid = Number(layerId); - const region = layerRegions[lid]; - if (!region) return; - - const canvasRect = canvasRef.current?.getBoundingClientRect(); - if (!canvasRect) return; - - const x = (e.clientX - canvasRect.left) / zoomLevel; - const y = (e.clientY - canvasRect.top) / zoomLevel; - - setRegionDrag({ - isDrawing: false, - isDragging: mode === "move", - isResizing: mode === "resize", - targetLayerId: layerId, - startX: x, - startY: y, - currentX: x, - currentY: y, - resizeHandle: handle || null, - originalRegion: { x: region.x, y: region.y, width: region.width, height: region.height }, - }); - }, [layerRegions, zoomLevel]); - - // 🆕 캔버스 마우스 이벤트 (영역 이동/리사이즈) - const handleRegionCanvasMouseMove = useCallback((e: React.MouseEvent) => { - if (!regionDrag.isDragging && !regionDrag.isResizing) return; - if (!regionDrag.targetLayerId) return; - - const canvasRect = canvasRef.current?.getBoundingClientRect(); - if (!canvasRect) return; - - const x = (e.clientX - canvasRect.left) / zoomLevel; - const y = (e.clientY - canvasRect.top) / zoomLevel; - - if (regionDrag.isDragging && regionDrag.originalRegion) { - const dx = x - regionDrag.startX; - const dy = y - regionDrag.startY; - const newRegion = { - x: Math.max(0, Math.round(regionDrag.originalRegion.x + dx)), - y: Math.max(0, Math.round(regionDrag.originalRegion.y + dy)), - width: regionDrag.originalRegion.width, - height: regionDrag.originalRegion.height, - }; - const lid = Number(regionDrag.targetLayerId); - setLayerRegions((prev) => ({ - ...prev, - [lid]: { ...prev[lid], ...newRegion }, - })); - } else if (regionDrag.isResizing && regionDrag.originalRegion) { - const dx = x - regionDrag.startX; - const dy = y - regionDrag.startY; - const orig = regionDrag.originalRegion; - const newRegion = { ...orig }; - - const handle = regionDrag.resizeHandle; - if (handle?.includes("e")) newRegion.width = Math.max(50, Math.round(orig.width + dx)); - if (handle?.includes("s")) newRegion.height = Math.max(30, Math.round(orig.height + dy)); - if (handle?.includes("w")) { - newRegion.x = Math.max(0, Math.round(orig.x + dx)); - newRegion.width = Math.max(50, Math.round(orig.width - dx)); - } - if (handle?.includes("n")) { - newRegion.y = Math.max(0, Math.round(orig.y + dy)); - newRegion.height = Math.max(30, Math.round(orig.height - dy)); - } - - const lid = Number(regionDrag.targetLayerId); - setLayerRegions((prev) => ({ - ...prev, - [lid]: { ...prev[lid], ...newRegion }, - })); - } - }, [regionDrag, zoomLevel]); - - const handleRegionCanvasMouseUp = useCallback(async () => { - // 드래그 완료 시 DB에 영역 저장 - if ((regionDrag.isDragging || regionDrag.isResizing) && regionDrag.targetLayerId && selectedScreen?.screenId) { - const lid = Number(regionDrag.targetLayerId); - const region = layerRegions[lid]; - if (region) { - try { - const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, lid); - const existingCondition = layerData?.conditionConfig || {}; - await screenApi.updateLayerCondition( - selectedScreen.screenId, lid, - { ...existingCondition, displayRegion: { x: region.x, y: region.y, width: region.width, height: region.height } } - ); - } catch { - console.error("영역 저장 실패"); - } - } - } - // 드래그 상태 초기화 - setRegionDrag({ - isDrawing: false, - isDragging: false, - isResizing: false, - targetLayerId: null, - startX: 0, startY: 0, currentX: 0, currentY: 0, - resizeHandle: null, - originalRegion: null, - }); - }, [regionDrag, layerRegions, selectedScreen]); - // 🆕 레이어 변경 핸들러 - 레이어 컨텍스트에서 레이어가 변경되면 layout에도 반영 - // 주의: layout.layers에 직접 설정된 displayRegion 등 메타데이터를 보존 + // 주의: layout.components는 layerId 속성으로 레이어를 구분하므로, 여기서 덮어쓰지 않음 const handleLayersChange = useCallback((newLayers: LayerDefinition[]) => { - setLayout((prevLayout) => { - // 기존 layout.layers의 메타데이터(displayRegion 등)를 보존하며 병합 - const mergedLayers = newLayers.map((newLayer) => { - const existingLayer = prevLayout.layers?.find((l) => l.id === newLayer.id); - if (!existingLayer) return newLayer; - - // LayerContext에서 온 데이터(condition 등)를 우선하되, - // layout.layers에만 있는 데이터(캔버스에서 직접 수정한 displayRegion)도 보존 - return { - ...existingLayer, // 기존 메타데이터 보존 (displayRegion 등) - ...newLayer, // LayerContext 데이터 우선 (condition, name, isVisible 등) - // displayRegion: 양쪽 모두 있을 수 있으므로 최신 값 우선 - displayRegion: newLayer.displayRegion !== undefined - ? newLayer.displayRegion - : existingLayer.displayRegion, - }; - }); - - return { - ...prevLayout, - layers: mergedLayers, - }; - }); + setLayout((prevLayout) => ({ + ...prevLayout, + layers: newLayers, + // components는 그대로 유지 - layerId 속성으로 레이어 구분 + // components: prevLayout.components (기본값으로 유지됨) + })); }, []); // 🆕 활성 레이어 변경 핸들러 - const handleActiveLayerChange = useCallback((newActiveLayerId: number) => { - setActiveLayerIdWithRef(newActiveLayerId); - }, [setActiveLayerIdWithRef]); + const handleActiveLayerChange = useCallback((newActiveLayerId: string | null) => { + setActiveLayerIdLocal(newActiveLayerId); + }, []); // 🆕 초기 레이어 계산 - layout에서 layers가 있으면 사용, 없으면 기본 레이어 생성 // 주의: components는 layout.components에 layerId 속성으로 저장되므로, layer.components는 비워둠 @@ -5964,7 +5737,6 @@ export default function ScreenDesigner({ onBack={onBackToList} onSave={handleSave} isSaving={isSaving} - onPreview={isPop ? handlePopPreview : undefined} onResolutionChange={setScreenResolution} gridSettings={layout.gridSettings} onGridSettingsChange={updateGridSettings} @@ -5995,7 +5767,7 @@ export default function ScreenDesigner({
- + 컴포넌트 @@ -6028,41 +5800,9 @@ export default function ScreenDesigner({ /> - {/* 🆕 레이어 관리 탭 (DB 기반) */} + {/* 🆕 레이어 관리 탭 */} - { - if (!selectedScreen?.screenId) return; - try { - // 1. 현재 레이어 저장 - const curId = Number(activeLayerIdRef.current) || 1; - const v2Layout = convertLegacyToV2({ ...layout, screenResolution }); - await screenApi.saveLayoutV2(selectedScreen.screenId, { ...v2Layout, layerId: curId }); - - // 2. 새 레이어 로드 - const data = await screenApi.getLayerLayout(selectedScreen.screenId, layerId); - if (data && data.components) { - const legacy = convertV2ToLegacy(data); - if (legacy) { - setLayout((prev) => ({ ...prev, components: legacy.components })); - } else { - setLayout((prev) => ({ ...prev, components: [] })); - } - } else { - setLayout((prev) => ({ ...prev, components: [] })); - } - - setActiveLayerIdWithRef(layerId); - setSelectedComponent(null); - } catch (error) { - console.error("레이어 전환 실패:", error); - toast.error("레이어 전환에 실패했습니다."); - } - }} - components={layout.components} - /> + @@ -6635,14 +6375,6 @@ export default function ScreenDesigner({
); })()} - {/* 🆕 활성 레이어 인디케이터 (기본 레이어가 아닌 경우 표시) */} - {activeLayerId > 1 && ( -
-
- 레이어 {activeLayerId} 편집 중 -
- )} - {/* 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬 + contain 최적화 */}
{ - // 영역 이동/리사이즈 처리 - if (regionDrag.isDragging || regionDrag.isResizing) { - handleRegionCanvasMouseMove(e); - } - }} - onMouseUp={() => { - if (regionDrag.isDragging || regionDrag.isResizing) { - handleRegionCanvasMouseUp(); - } - }} - onMouseLeave={() => { - if (regionDrag.isDragging || regionDrag.isResizing) { - handleRegionCanvasMouseUp(); - } - }} onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = "copy"; @@ -6767,79 +6483,6 @@ export default function ScreenDesigner({ return ( <> - {/* 조건부 레이어 영역 (기본 레이어에서만 표시, DB 기반) */} - {activeLayerId === 1 && Object.entries(layerRegions).map(([layerIdStr, region]) => { - const layerId = Number(layerIdStr); - const resizeHandles = ["nw", "ne", "sw", "se", "n", "s", "e", "w"]; - const handleCursors: Record = { - nw: "nwse-resize", ne: "nesw-resize", sw: "nesw-resize", se: "nwse-resize", - n: "ns-resize", s: "ns-resize", e: "ew-resize", w: "ew-resize", - }; - const handlePositions: Record = { - nw: { top: -4, left: -4 }, ne: { top: -4, right: -4 }, - sw: { bottom: -4, left: -4 }, se: { bottom: -4, right: -4 }, - n: { top: -4, left: "50%", transform: "translateX(-50%)" }, - s: { bottom: -4, left: "50%", transform: "translateX(-50%)" }, - e: { top: "50%", right: -4, transform: "translateY(-50%)" }, - w: { top: "50%", left: -4, transform: "translateY(-50%)" }, - }; - return ( -
handleRegionMouseDown(e, String(layerId), "move")} - > - - 레이어 {layerId} - {region.layerName} - - {/* 리사이즈 핸들 */} - {resizeHandles.map((handle) => ( -
handleRegionMouseDown(e, String(layerId), "resize", handle)} - /> - ))} - {/* 삭제 버튼 */} - -
- ); - })} - - {/* 일반 컴포넌트들 */} {regularComponents.map((component) => { const children = diff --git a/frontend/components/screen/ScreenSettingModal.tsx b/frontend/components/screen/ScreenSettingModal.tsx index 22c7af89..fa802893 100644 --- a/frontend/components/screen/ScreenSettingModal.tsx +++ b/frontend/components/screen/ScreenSettingModal.tsx @@ -134,7 +134,6 @@ interface ScreenSettingModalProps { fieldMappings?: FieldMappingInfo[]; componentCount?: number; onSaveSuccess?: () => void; - isPop?: boolean; // POP 화면 여부 } // 검색 가능한 Select 컴포넌트 @@ -240,7 +239,6 @@ export function ScreenSettingModal({ fieldMappings = [], componentCount = 0, onSaveSuccess, - isPop = false, }: ScreenSettingModalProps) { const [activeTab, setActiveTab] = useState("overview"); const [loading, setLoading] = useState(false); @@ -521,7 +519,6 @@ export function ScreenSettingModal({ iframeKey={iframeKey} canvasWidth={canvasSize.width} canvasHeight={canvasSize.height} - isPop={isPop} />
@@ -4634,10 +4631,9 @@ interface PreviewTabProps { iframeKey?: number; // iframe 새로고침용 키 canvasWidth?: number; // 화면 캔버스 너비 canvasHeight?: number; // 화면 캔버스 높이 - isPop?: boolean; // POP 화면 여부 } -function PreviewTab({ screenId, screenName, companyCode, iframeKey = 0, canvasWidth, canvasHeight, isPop = false }: PreviewTabProps) { +function PreviewTab({ screenId, screenName, companyCode, iframeKey = 0, canvasWidth, canvasHeight }: PreviewTabProps) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const containerRef = useRef(null); @@ -4691,18 +4687,12 @@ function PreviewTab({ screenId, screenName, companyCode, iframeKey = 0, canvasWi if (companyCode) { params.set("company_code", companyCode); } - // POP 화면일 경우 디바이스 타입 추가 - if (isPop) { - params.set("device", "tablet"); - } - // POP 화면과 데스크톱 화면 경로 분기 - const screenPath = isPop ? `/pop/screens/${screenId}` : `/screens/${screenId}`; if (typeof window !== "undefined") { const baseUrl = window.location.origin; - return `${baseUrl}${screenPath}?${params.toString()}`; + return `${baseUrl}/screens/${screenId}?${params.toString()}`; } - return `${screenPath}?${params.toString()}`; - }, [screenId, companyCode, isPop]); + return `/screens/${screenId}?${params.toString()}`; + }, [screenId, companyCode]); const handleIframeLoad = () => { setLoading(false); diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx index e99fd0e5..034c3b41 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useMemo, useCallback } from "react"; +import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { useSearchParams } from "next/navigation"; import { ComponentRendererProps } from "@/types/component"; import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition, ItemData, GroupEntry, DisplayItem } from "./types"; @@ -73,18 +73,14 @@ export const SelectedItemsDetailInputComponent: React.FC state.dataRegistry); const modalData = useMemo(() => dataRegistry[dataSourceId] || [], [dataRegistry, dataSourceId]); // 전체 dataRegistry를 사용 (모든 누적 데이터에 접근 가능) - console.log("📦 [SelectedItemsDetailInput] 사용 가능한 모든 데이터:", { - keys: Object.keys(dataRegistry), - counts: Object.entries(dataRegistry).map(([key, data]: [string, any]) => ({ - table: key, - count: data.length, - })), - }); const updateItemData = useModalDataStore((state) => state.updateItemData); @@ -103,44 +99,17 @@ export const SelectedItemsDetailInputComponent: React.FC>>({}); - // 디버깅 로그 - useEffect(() => { - console.log("📍 [SelectedItemsDetailInput] 설정 확인:", { - inputMode: componentConfig.inputMode, - urlDataSourceId, - configDataSourceId: componentConfig.dataSourceId, - componentId: component.id, - finalDataSourceId: dataSourceId, - isEditing, - editingItemId, - }); - }, [ - urlDataSourceId, - componentConfig.dataSourceId, - component.id, - dataSourceId, - componentConfig.inputMode, - isEditing, - editingItemId, - ]); + // 디버깅 로그 (제거됨) // 🆕 필드에 codeCategory가 있으면 자동으로 옵션 로드 useEffect(() => { const loadCodeOptions = async () => { - console.log("🔄 [loadCodeOptions] 시작:", { - additionalFields: componentConfig.additionalFields, - targetTable: componentConfig.targetTable, - }); - - // 🆕 code/category 타입 필드 + codeCategory가 있는 필드 모두 처리 + // code/category 타입 필드 + codeCategory가 있는 필드 모두 처리 const codeFields = componentConfig.additionalFields?.filter( (field) => field.inputType === "code" || field.inputType === "category", ); - console.log("🔍 [loadCodeOptions] code/category 필드:", codeFields); - if (!codeFields || codeFields.length === 0) { - console.log("⚠️ [loadCodeOptions] code/category 타입 필드가 없습니다"); return; } @@ -173,7 +142,6 @@ export const SelectedItemsDetailInputComponent: React.FC { + const isArray = Array.isArray(sourceData); + const dataArray = isArray ? sourceData : [sourceData]; - if (dataArray.length === 0 || (dataArray.length === 1 && Object.keys(dataArray[0]).length === 0)) { - console.warn("⚠️ [SelectedItemsDetailInput] 데이터가 비어있음"); - return; - } - - console.log( - `📝 [SelectedItemsDetailInput] 수정 모드 - ${isArray ? "그룹 레코드" : "단일 레코드"} (${dataArray.length}개)`, - ); - console.log("📝 [SelectedItemsDetailInput] 데이터 소스:", { - fromGroupedData: groupedData && Array.isArray(groupedData) && groupedData.length > 0, - dataArray: JSON.stringify(dataArray, null, 2), - }); - - const groups = componentConfig.fieldGroups || []; - const additionalFields = componentConfig.additionalFields || []; - - // 🆕 첫 번째 레코드의 originalData를 기본 항목으로 설정 - const firstRecord = dataArray[0]; - const mainFieldGroups: Record = {}; - - // 🔧 각 그룹별로 고유한 엔트리만 수집 (중복 제거) - groups.forEach((group) => { - const groupFields = additionalFields.filter((field: any) => field.groupId === group.id); - - if (groupFields.length === 0) { - mainFieldGroups[group.id] = []; + if (dataArray.length === 0 || (dataArray.length === 1 && Object.keys(dataArray[0]).length === 0)) { return; } - // 🆕 각 레코드에서 그룹 데이터 추출 - const entriesMap = new Map(); + const groups = componentConfig.fieldGroups || []; + const additionalFields = componentConfig.additionalFields || []; + const firstRecord = dataArray[0]; - dataArray.forEach((record) => { - const entryData: Record = {}; + // 수정 모드: 다른 sourceTable의 데이터도 추가 로드 (예: customer_item_mapping) + let mappingData: Record | null = null; - groupFields.forEach((field: any) => { - let fieldValue = record[field.name]; + // URL의 tableName = 이미 데이터가 로드된 테이블. 그 외 sourceTable은 추가 조회 필요 + const editTableName = new URLSearchParams(window.location.search).get("tableName"); + const otherTables = groups + .filter((g) => g.sourceTable && g.sourceTable !== editTableName) + .map((g) => g.sourceTable!) + .filter((v, i, a) => a.indexOf(v) === i); // 중복 제거 - // 🆕 값이 없으면 autoFillFrom 로직 적용 - if ((fieldValue === undefined || fieldValue === null) && field.autoFillFrom) { - let sourceData: any = null; - - if (field.autoFillFromTable) { - // 특정 테이블에서 가져오기 - const tableData = dataRegistry[field.autoFillFromTable]; - if (tableData && tableData.length > 0) { - sourceData = tableData[0].originalData || tableData[0]; - console.log( - `✅ [수정모드 autoFill] ${field.name} ← ${field.autoFillFrom} (테이블: ${field.autoFillFromTable}):`, - sourceData?.[field.autoFillFrom], - ); - } else { - // 🆕 dataRegistry에 없으면 record에서 직접 찾기 (Entity Join된 경우) - sourceData = record; - console.log( - `⚠️ [수정모드 autoFill] dataRegistry에 ${field.autoFillFromTable} 없음, record에서 직접 찾기`, - ); - } - } else { - // record 자체에서 가져오기 - sourceData = record; - console.log( - `✅ [수정모드 autoFill] ${field.name} ← ${field.autoFillFrom} (레코드):`, - sourceData?.[field.autoFillFrom], - ); - } - - if (sourceData && sourceData[field.autoFillFrom] !== undefined) { - fieldValue = sourceData[field.autoFillFrom]; - console.log(`✅ [수정모드 autoFill] ${field.name} 값 설정:`, fieldValue); - } else { - // 🆕 Entity Join의 경우 sourceColumn_fieldName 형식으로도 찾기 - // 예: item_id_standard_price, customer_id_customer_name - // autoFillFromTable에서 어떤 sourceColumn인지 추론 - const possibleKeys = Object.keys(sourceData || {}).filter((key) => - key.endsWith(`_${field.autoFillFrom}`), - ); - - if (possibleKeys.length > 0) { - fieldValue = sourceData[possibleKeys[0]]; - console.log( - `✅ [수정모드 autoFill] ${field.name} Entity Join 키로 찾음 (${possibleKeys[0]}):`, - fieldValue, - ); - } else { - console.warn( - `⚠️ [수정모드 autoFill] ${field.name} ← ${field.autoFillFrom} 실패 (시도한 키들: ${field.autoFillFrom}, *_${field.autoFillFrom})`, - ); - } + if (otherTables.length > 0 && firstRecord.customer_id && firstRecord.item_id) { + try { + const { dataApi } = await import("@/lib/api/data"); + for (const otherTable of otherTables) { + // getTableData 반환: { data: any[], total, page, size } (success 필드 없음) + const response = await dataApi.getTableData(otherTable, { + filters: { + customer_id: firstRecord.customer_id, + item_id: firstRecord.item_id, + }, + }); + if (response.data && response.data.length > 0) { + mappingData = response.data[0]; } } + } catch (err) { + console.error("❌ 매핑 데이터 로드 실패:", err); + } + } - // 🔧 값이 없으면 기본값 사용 (false, 0, "" 등 falsy 값도 유효한 값으로 처리) - if (fieldValue === undefined || fieldValue === null) { - // 기본값이 있으면 사용, 없으면 필드 타입에 따라 기본값 설정 - if (field.defaultValue !== undefined) { - fieldValue = field.defaultValue; - } else if (field.type === "checkbox") { - fieldValue = false; // checkbox는 기본값 false - } else { - // 다른 타입은 null로 유지 (필수 필드가 아니면 표시 안 됨) - return; + const mainFieldGroups: Record = {}; + + groups.forEach((group) => { + const groupFields = additionalFields.filter((field: any) => field.groupId === group.id); + + if (groupFields.length === 0) { + mainFieldGroups[group.id] = []; + return; + } + + // 이 그룹의 sourceTable에 따라 데이터 소스 결정 + const editTableName = new URLSearchParams(window.location.search).get("tableName"); + const isOtherTable = group.sourceTable && group.sourceTable !== editTableName; + + if (isOtherTable && mappingData) { + // 다른 테이블 그룹 (예: customer_item_mapping) → mappingData에서 로드 + const entryData: Record = {}; + groupFields.forEach((field: any) => { + let fieldValue = mappingData![field.name]; + + // autoFillFrom 로직 + if ((fieldValue === undefined || fieldValue === null) && field.autoFillFrom) { + fieldValue = firstRecord[field.autoFillFrom] || firstRecord.item_id; } - } - // 🔧 날짜 타입이면 YYYY-MM-DD 형식으로 변환 (타임존 제거) - if (field.type === "date" || field.type === "datetime") { - const dateStr = String(fieldValue); - const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})/); - if (match) { - const [, year, month, day] = match; - fieldValue = `${year}-${month}-${day}`; // ISO 형식 유지 (시간 제거) + if (fieldValue !== undefined && fieldValue !== null) { + entryData[field.name] = fieldValue; } - } - - entryData[field.name] = fieldValue; - }); - - // 🔑 모든 필드 값을 합쳐서 고유 키 생성 (중복 제거 기준) - const entryKey = JSON.stringify(entryData); - - if (!entriesMap.has(entryKey)) { - entriesMap.set(entryKey, { - id: `${group.id}_entry_${entriesMap.size + 1}`, - ...entryData, }); + + if (Object.keys(entryData).length > 0) { + mainFieldGroups[group.id] = [{ + id: `${group.id}_entry_1`, + // DB 레코드의 고유 id(UUID PK) 보존 → 수정 시 이 id로 UPDATE + _dbRecordId: mappingData!.id || null, + ...entryData, + }]; + } else { + mainFieldGroups[group.id] = []; + } + } else { + // 현재 테이블 그룹 (예: customer_item_prices) → dataArray에서 로드 + const entriesMap = new Map(); + + dataArray.forEach((record) => { + const entryData: Record = {}; + + groupFields.forEach((field: any) => { + let fieldValue = record[field.name]; + + // 값이 없으면 autoFillFrom 로직 적용 + if ((fieldValue === undefined || fieldValue === null) && field.autoFillFrom) { + let src: any = null; + + if (field.autoFillFromTable) { + const tableData = dataRegistry[field.autoFillFromTable]; + if (tableData && tableData.length > 0) { + src = tableData[0].originalData || tableData[0]; + } else { + src = record; + } + } else { + src = record; + } + + if (src && src[field.autoFillFrom] !== undefined) { + fieldValue = src[field.autoFillFrom]; + } else { + const possibleKeys = Object.keys(src || {}).filter((key) => + key.endsWith(`_${field.autoFillFrom}`), + ); + if (possibleKeys.length > 0) { + fieldValue = src[possibleKeys[0]]; + } + } + } + + if (fieldValue === undefined || fieldValue === null) { + if (field.defaultValue !== undefined) { + fieldValue = field.defaultValue; + } else if (field.type === "checkbox") { + fieldValue = false; + } else { + return; + } + } + + // 날짜 타입이면 YYYY-MM-DD 형식으로 변환 + if (field.type === "date" || field.type === "datetime") { + const dateStr = String(fieldValue); + const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})/); + if (match) { + const [, year, month, day] = match; + fieldValue = `${year}-${month}-${day}`; + } + } + + entryData[field.name] = fieldValue; + }); + + const entryKey = JSON.stringify(entryData); + if (!entriesMap.has(entryKey)) { + // DEBUG: record.id 확인 (추후 삭제) + console.log("🔑 [LOAD] record.id:", record.id, "record keys:", Object.keys(record)); + entriesMap.set(entryKey, { + id: `${group.id}_entry_${entriesMap.size + 1}`, + // DB 레코드의 고유 id(UUID PK) 보존 → 수정 시 이 id로 UPDATE + _dbRecordId: record.id || null, + ...entryData, + }); + } + }); + + mainFieldGroups[group.id] = Array.from(entriesMap.values()); } }); - mainFieldGroups[group.id] = Array.from(entriesMap.values()); - }); + if (groups.length === 0) { + mainFieldGroups["default"] = []; + } - // 그룹이 없으면 기본 그룹 생성 - if (groups.length === 0) { - mainFieldGroups["default"] = []; - } + const newItem: ItemData = { + // 수정 모드: item_id를 우선 사용 (id는 가격레코드의 PK일 수 있음) + id: String(firstRecord.item_id || firstRecord.id || "edit"), + originalData: firstRecord, + fieldGroups: mainFieldGroups, + }; - const newItem: ItemData = { - id: String(firstRecord.id || firstRecord.item_id || "edit"), - originalData: firstRecord, // 첫 번째 레코드를 대표 데이터로 사용 - fieldGroups: mainFieldGroups, + setItems([newItem]); }; - setItems([newItem]); - - console.log("✅ [SelectedItemsDetailInput] 수정 모드 데이터 로드 완료:", { - recordCount: dataArray.length, - item: newItem, - fieldGroupsKeys: Object.keys(mainFieldGroups), - firstGroupEntries: mainFieldGroups[groups[0]?.id]?.length || 0, - }); + loadEditData(); return; } // 생성 모드: modalData에서 데이터 로드 if (modalData && modalData.length > 0) { - console.log("📦 [SelectedItemsDetailInput] 데이터 수신:", modalData); // 🆕 각 품목마다 빈 fieldGroups 객체를 가진 ItemData 생성 const groups = componentConfig.fieldGroups || []; @@ -443,12 +422,6 @@ export const SelectedItemsDetailInputComponent: React.FC g.id), - firstItem: newItems[0], - }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [modalData, component.id, componentConfig.fieldGroups, formData, groupedData]); // groupedData 의존성 추가 @@ -474,14 +447,6 @@ export const SelectedItemsDetailInputComponent: React.FC { const handleSaveRequest = async (event: Event) => { + // 중복 저장 방지 + // 항상 skipDefaultSave 설정 (buttonActions.ts의 이중 저장 방지) + if (event instanceof CustomEvent && event.detail) { + (event.detail as any).skipDefaultSave = true; + } + + if (isSavingRef.current) return; + isSavingRef.current = true; + // component.id를 문자열로 안전하게 변환 const componentKey = String(component.id || "selected_items"); - console.log("🔔 [SelectedItemsDetailInput] beforeFormSave 이벤트 수신!", { - itemsCount: items.length, - hasOnFormDataChange: !!onFormDataChange, - componentId: component.id, - componentIdType: typeof component.id, - componentKey, - }); - if (items.length === 0) { - console.warn("⚠️ [SelectedItemsDetailInput] 저장할 데이터 없음"); + isSavingRef.current = false; return; } - // parentDataMapping이 있으면 UPSERT API로 직접 저장 (생성/수정 모드 무관) + // parentDataMapping이 있으면 UPSERT API로 직접 저장 const hasParentMapping = componentConfig.parentDataMapping && componentConfig.parentDataMapping.length > 0; - console.log("📝 [SelectedItemsDetailInput] 저장 모드:", { hasParentMapping }); - if (hasParentMapping) { - // UPSERT API로 직접 DB 저장 try { - console.log("🔄 [SelectedItemsDetailInput] UPSERT 저장 시작"); - console.log("📋 [SelectedItemsDetailInput] componentConfig:", { - targetTable: componentConfig.targetTable, - parentDataMapping: componentConfig.parentDataMapping, - fieldGroups: componentConfig.fieldGroups, - additionalFields: componentConfig.additionalFields, - }); // 부모 키 추출 (parentDataMapping에서) const parentKeys: Record = {}; @@ -610,18 +555,6 @@ export const SelectedItemsDetailInputComponent: React.FC { // 1차: formData(sourceData)에서 찾기 let value = getFieldValue(sourceData, mapping.sourceField); @@ -633,33 +566,23 @@ export const SelectedItemsDetailInputComponent: React.FC 0) { const registryItem = registryData[0].originalData || registryData[0]; value = registryItem[mapping.sourceField]; - console.log( - `🔄 [parentKeys] dataRegistry["${mapping.sourceTable}"]에서 찾음: ${mapping.sourceField} =`, - value, - ); } } if (value !== undefined && value !== null) { parentKeys[mapping.targetField] = value; - console.log(`✅ [parentKeys] ${mapping.sourceField} → ${mapping.targetField}:`, value); } else { - console.warn( - `⚠️ [SelectedItemsDetailInput] 부모 키 누락: ${mapping.sourceField} → ${mapping.targetField}`, - `(sourceData, dataRegistry["${mapping.sourceTable}"] 모두 확인)`, - ); + console.warn(`⚠️ 부모 키 누락: ${mapping.sourceField} → ${mapping.targetField}`); } }); - console.log("🔑 [SelectedItemsDetailInput] 부모 키:", parentKeys); - // 🔒 parentKeys 유효성 검증 - 빈 값이 있으면 저장 중단 const parentKeyValues = Object.values(parentKeys); const hasEmptyParentKey = parentKeyValues.length === 0 || parentKeyValues.some(v => v === null || v === undefined || v === ""); if (hasEmptyParentKey) { - console.error("❌ [SelectedItemsDetailInput] parentKeys가 비어있거나 유효하지 않습니다!", parentKeys); + console.error("❌ parentKeys 비어있음:", parentKeys); window.dispatchEvent( new CustomEvent("formSaveError", { detail: { message: "부모 키 값이 비어있어 저장할 수 없습니다. 먼저 상위 데이터를 선택해주세요." }, @@ -669,14 +592,13 @@ export const SelectedItemsDetailInputComponent: React.FC t !== mainTable); const hasDetailTable = detailTables.length > 0; - console.log("🏗️ [SelectedItemsDetailInput] 저장 구조:", { - mainTable, - detailTables, - hasDetailTable, - groupsByTable: Object.fromEntries(groupsByTable), - }); - if (hasDetailTable) { // ============================================================ - // 🆕 2단계 저장: 메인 테이블 + 디테일 테이블 분리 저장 - // 예: customer_item_mapping (매핑) + customer_item_prices (가격) + // 2단계 저장: 메인 테이블 + 디테일 테이블 분리 저장 + // upsertGroupedRecords를 양쪽 모두 사용 (정확한 매칭 보장) // ============================================================ const mainGroups = groupsByTable.get(mainTable) || []; - let totalInserted = 0; - let totalUpdated = 0; for (const item of items) { - // Step 1: 메인 테이블 매핑 레코드 생성/갱신 - const mappingData: Record = { ...parentKeys }; + // item_id 추출: originalData.item_id를 최우선 사용 + // (수정 모드에서 autoFillFrom:"id"가 가격 레코드 PK를 반환하는 문제 방지) + let itemId: string | null = null; - // 메인 그룹 필드 추출 (customer_item_code, customer_item_name 등) + // 1순위: originalData에 item_id가 직접 있으면 사용 (수정 모드에서 정확한 값) + if (item.originalData && item.originalData.item_id) { + itemId = item.originalData.item_id; + } + + // 2순위: autoFillFrom 로직 (신규 등록 모드에서 사용) + if (!itemId) { + mainGroups.forEach((group) => { + const groupFields = additionalFields.filter((f) => f.groupId === group.id); + groupFields.forEach((field) => { + if (field.name === "item_id" && field.autoFillFrom && item.originalData) { + itemId = item.originalData[field.autoFillFrom] || null; + } + }); + }); + } + + // 3순위: fallback (최후의 수단) + if (!itemId && item.originalData) { + itemId = item.originalData.id || null; + } + + if (!itemId) { + console.error("❌ [2단계 저장] item_id를 찾을 수 없음:", item); + continue; + } + + // upsert 공통 parentKeys: customer_id + item_id (정확한 매칭) + const itemParentKeys = { ...parentKeys, item_id: itemId }; + + // === Step 1: 메인 테이블(customer_item_mapping) 저장 === + const mappingRecord: Record = {}; mainGroups.forEach((group) => { const entries = item.fieldGroups[group.id] || []; const groupFields = additionalFields.filter((f) => f.groupId === group.id); if (entries.length > 0) { groupFields.forEach((field) => { - if (entries[0][field.name] !== undefined) { - mappingData[field.name] = entries[0][field.name]; + const val = entries[0][field.name]; + // 사용자가 실제 입력한 값만 포함 (빈 문자열, null 제외) + if (val !== undefined && val !== null && val !== "") { + mappingRecord[field.name] = val; } }); + // 기존 DB 레코드의 고유 id(PK)가 있으면 포함 → 백엔드에서 이 id로 UPDATE + if (entries[0]._dbRecordId) { + mappingRecord.id = entries[0]._dbRecordId; + } } // autoFillFrom 필드 처리 (item_id 등) + // 단, item_id는 이미 정확한 itemId 변수를 사용 (autoFillFrom:"id"가 수정 모드에서 오작동 방지) groupFields.forEach((field) => { - if (field.autoFillFrom && item.originalData) { + if (field.name === "item_id") { + // item_id는 위에서 계산된 정확한 itemId 사용 + mappingRecord.item_id = itemId; + } else if (field.autoFillFrom && item.originalData) { const value = item.originalData[field.autoFillFrom]; if (value !== undefined && value !== null) { - mappingData[field.name] = value; + mappingRecord[field.name] = value; } } }); }); - console.log("📋 [2단계 저장] Step 1 - 매핑 데이터:", mappingData); - - // 기존 매핑 레코드 찾기 - let mappingId: string | null = null; - const searchFilters: Record = {}; - - // parentKeys + item_id로 검색 - Object.entries(parentKeys).forEach(([key, value]) => { - searchFilters[key] = value; - }); - if (mappingData.item_id) { - searchFilters.item_id = mappingData.item_id; - } - try { - const searchResult = await dataApi.getTableData(mainTable, { - filters: searchFilters, - size: 1, - }); - - if (searchResult.data && searchResult.data.length > 0) { - // 기존 매핑 업데이트 - mappingId = searchResult.data[0].id; - console.log("📌 [2단계 저장] 기존 매핑 발견:", mappingId); - await dataApi.updateRecord(mainTable, mappingId, mappingData); - totalUpdated++; - } else { - // 새 매핑 생성 - const createResult = await dataApi.createRecord(mainTable, mappingData); - if (createResult.success && createResult.data) { - mappingId = createResult.data.id; - console.log("✨ [2단계 저장] 새 매핑 생성:", mappingId); - totalInserted++; - } - } + const mappingResult = await dataApi.upsertGroupedRecords( + mainTable, + itemParentKeys, + [mappingRecord], + ); } catch (err) { - console.error("❌ [2단계 저장] 매핑 저장 실패:", err); - continue; + console.error(`❌ ${mainTable} 저장 실패:`, err); } - if (!mappingId) { - console.error("❌ [2단계 저장] mapping_id 획득 실패 - item:", mappingData.item_id); - continue; - } - - // Step 2: 디테일 테이블에 가격 레코드 저장 + // === Step 2: 디테일 테이블(customer_item_prices) 저장 === for (const detailTable of detailTables) { const detailGroups = groupsByTable.get(detailTable) || []; const priceRecords: Record[] = []; @@ -812,58 +732,69 @@ export const SelectedItemsDetailInputComponent: React.FC f.groupId === group.id); entries.forEach((entry) => { - // 실제 값이 있는 엔트리만 저장 - const hasValues = groupFields.some((field) => { + // 사용자가 실제 입력한 값이 있는지 확인 + // select/category 필드는 항상 기본값이 있으므로 제외하고 판별 + const hasUserInput = groupFields.some((field) => { + // 셀렉트/카테고리 필드는 기본값이 자동 설정되므로 무시 + if (field.type === "select" || field.inputType === "code" || field.inputType === "category") { + return false; + } const value = entry[field.name]; - return value !== undefined && value !== null && value !== ""; + if (value === undefined || value === null || value === "") return false; + if (value === 0 || value === "0" || value === "0.00") return false; + return true; }); - if (hasValues) { - const priceRecord: Record = { - mapping_id: mappingId, - // 비정규화: 직접 필터링을 위해 customer_id, item_id 포함 - ...parentKeys, - item_id: mappingData.item_id, - }; + if (hasUserInput) { + const priceRecord: Record = {}; groupFields.forEach((field) => { - if (entry[field.name] !== undefined) { - priceRecord[field.name] = entry[field.name]; + const val = entry[field.name]; + if (val !== undefined && val !== null) { + priceRecord[field.name] = val; } }); + // 기존 DB 레코드의 고유 id(PK)가 있으면 포함 → 백엔드에서 이 id로 UPDATE + if (entry._dbRecordId) { + priceRecord.id = entry._dbRecordId; + } + // DEBUG: id 전달 확인용 (추후 삭제) + console.log("🔑 [SAVE] entry._dbRecordId:", entry._dbRecordId, "→ priceRecord.id:", priceRecord.id, "entry keys:", Object.keys(entry)); priceRecords.push(priceRecord); } }); }); - if (priceRecords.length > 0) { - console.log(`📋 [2단계 저장] Step 2 - ${detailTable} 레코드:`, { - mappingId, - count: priceRecords.length, - records: priceRecords, + // 빈 항목이라도 최소 레코드 생성 (우측 패널에 표시되도록) + if (priceRecords.length === 0) { + // select/category 필드를 명시적 null로 설정 (DB DEFAULT 'KRW' 등 방지) + const emptyRecord: Record = {}; + const detailGroupFields = additionalFields.filter((f) => + detailGroups.some((g) => g.id === f.groupId), + ); + detailGroupFields.forEach((field) => { + if (field.type === "select" || field.inputType === "code" || field.inputType === "category") { + emptyRecord[field.name] = null; + } }); + priceRecords.push(emptyRecord); + } + try { const detailResult = await dataApi.upsertGroupedRecords( detailTable, - { mapping_id: mappingId }, + itemParentKeys, priceRecords, ); - if (detailResult.success) { - console.log(`✅ [2단계 저장] ${detailTable} 저장 성공:`, detailResult); - } else { - console.error(`❌ [2단계 저장] ${detailTable} 저장 실패:`, detailResult.error); + if (!detailResult.success) { + console.error(`❌ ${detailTable} 저장 실패:`, detailResult.error); } - } else { - console.log(`⏭️ [2단계 저장] ${detailTable} - 가격 레코드 없음 (빈 항목)`); + } catch (err) { + console.error(`❌ ${detailTable} 오류:`, err); } } } - console.log("✅ [SelectedItemsDetailInput] 2단계 저장 완료:", { - inserted: totalInserted, - updated: totalUpdated, - }); - // 저장 성공 이벤트 window.dispatchEvent( new CustomEvent("formSaveSuccess", { @@ -876,28 +807,15 @@ export const SelectedItemsDetailInputComponent: React.FC { - console.log("📝 [handleFieldChange] 필드 값 변경:", { - itemId, - groupId, - entryId, - fieldName, - value, - }); - setItems((prevItems) => { return prevItems.map((item) => { if (item.id !== itemId) return item; @@ -1092,13 +987,7 @@ export const SelectedItemsDetailInputComponent: React.FC 0) { // 첫 번째 항목 사용 (또는 매칭 로직 추가 가능) sourceData = tableData[0].originalData || tableData[0]; - console.log( - `✅ [autoFill 추가] ${field.name} ← ${field.autoFillFrom} (테이블: ${field.autoFillFromTable}):`, - sourceData?.[field.autoFillFrom], - ); } else { - // 🆕 dataRegistry에 없으면 item.originalData에서 찾기 (수정 모드) sourceData = item.originalData; - console.log(`⚠️ [autoFill 추가] dataRegistry에 ${field.autoFillFromTable} 없음, originalData에서 찾기`); } } else { - // 주 데이터 소스 (item.originalData) 사용 sourceData = item.originalData; - console.log( - `✅ [autoFill 추가] ${field.name} ← ${field.autoFillFrom} (주 소스):`, - sourceData?.[field.autoFillFrom], - ); } // 🆕 getFieldValue 사용하여 Entity Join 필드도 찾기 @@ -1198,9 +1066,6 @@ export const SelectedItemsDetailInputComponent: React.FC key.includes("_id_")) || possibleKeys[0]; - console.log(`🔍 [getFieldValue] "${fieldName}" → "${entityJoinKey}" =`, data[entityJoinKey]); return data[entityJoinKey]; } // 2. 직접 필드명으로 찾기 (Entity Join이 없을 때만) if (data[fieldName] !== undefined) { - console.log(`🔍 [getFieldValue] "${fieldName}" → 직접 =`, data[fieldName]); return data[fieldName]; } - - console.warn(`⚠️ [getFieldValue] "${fieldName}" 못 찾음`); return null; }, []); @@ -2074,12 +1935,6 @@ export const SelectedItemsDetailInputComponent: React.FC { - console.log("🎨 [renderGridLayout] 렌더링:", { - itemsLength: items.length, - displayColumns: componentConfig.displayColumns, - firstItemOriginalData: items[0]?.originalData, - }); - return (
{items.map((item, index) => { @@ -2093,15 +1948,6 @@ export const SelectedItemsDetailInputComponent: React.FC getFieldValue(item.originalData, col.name)) .filter(Boolean); - console.log("🔍 [renderGridLayout] 항목 렌더링:", { - index, - titleValue, - summaryValues, - displayColumns: componentConfig.displayColumns, - originalData: item.originalData, - "displayColumns[0]": componentConfig.displayColumns?.[0], - "originalData keys": Object.keys(item.originalData), - }); return ( @@ -2156,15 +2002,7 @@ export const SelectedItemsDetailInputComponent: React.FC { const editingItem = items.find((item) => item.id === editingItemId); - console.log("🔍 [Modal Mode] 편집 항목 찾기:", { - editingItemId, - itemsLength: items.length, - itemIds: items.map((i) => i.id), - editingItem: editingItem ? "찾음" : "못 찾음", - editingDetailId, - }); if (!editingItem) { - console.warn("⚠️ [Modal Mode] 편집할 항목을 찾을 수 없습니다!"); return null; } @@ -2499,14 +2337,6 @@ export const SelectedItemsDetailInputComponent: React.FC { const isModalMode = componentConfig.inputMode === "modal"; - console.log("🎨 [renderCardLayout] 렌더링 모드:", { - inputMode: componentConfig.inputMode, - isModalMode, - isEditing, - editingItemId, - itemsLength: items.length, - }); - return (
{/* Modal 모드: 추가 버튼 */} @@ -2525,15 +2355,7 @@ export const SelectedItemsDetailInputComponent: React.FC { const editingItem = items.find((item) => item.id === editingItemId); - console.log("🔍 [Modal Mode - Card] 편집 항목 찾기:", { - editingItemId, - itemsLength: items.length, - itemIds: items.map((i) => i.id), - editingItem: editingItem ? "찾음" : "못 찾음", - editingDetailId, - }); if (!editingItem) { - console.warn("⚠️ [Modal Mode - Card] 편집할 항목을 찾을 수 없습니다!"); return null; } @@ -2766,12 +2588,6 @@ export const SelectedItemsDetailInputComponent: React.FC {/* 레이아웃에 따라 렌더링 */}