From 4b8f2b783966b25e603e0df682eb3b3ea5abd7a1 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Thu, 5 Mar 2026 11:30:31 +0900 Subject: [PATCH] feat: Update screen reference handling in V2 layouts - Enhanced the `ScreenManagementService` to include updates for V2 layouts in the `screen_layouts_v2` table. - Implemented logic to remap `screenId`, `targetScreenId`, `modalScreenId`, and other related IDs in layout data. - Added logging for the number of layouts updated in both V1 and V2, improving traceability of the update process. - This update ensures that screen references are correctly maintained across different layout versions, enhancing the overall functionality of the screen management system. --- .../src/services/screenManagementService.ts | 123 ++- .../screen/config-panels/button/ActionTab.tsx | 17 + .../screen/config-panels/button/BasicTab.tsx | 40 + .../screen/config-panels/button/DataTab.tsx | 872 ++++++++++++++++++ .../components/common/ConfigField.tsx | 264 ++++++ .../components/common/ConfigPanelBuilder.tsx | 76 ++ .../components/common/ConfigPanelTypes.ts | 56 ++ .../components/common/ConfigSection.tsx | 52 ++ .../config-panels/CommonConfigTab.tsx | 90 ++ .../config-panels/LeftPanelConfigTab.tsx | 606 ++++++++++++ .../config-panels/RightPanelConfigTab.tsx | 801 ++++++++++++++++ .../config-panels/SharedComponents.tsx | 256 +++++ .../config-panels/BasicConfigPanel.tsx | 125 +++ .../config-panels/ColumnsConfigPanel.tsx | 534 +++++++++++ .../config-panels/OptionsConfigPanel.tsx | 290 ++++++ .../config-panels/StyleConfigPanel.tsx | 129 +++ 16 files changed, 4328 insertions(+), 3 deletions(-) create mode 100644 frontend/components/screen/config-panels/button/ActionTab.tsx create mode 100644 frontend/components/screen/config-panels/button/BasicTab.tsx create mode 100644 frontend/components/screen/config-panels/button/DataTab.tsx create mode 100644 frontend/lib/registry/components/common/ConfigField.tsx create mode 100644 frontend/lib/registry/components/common/ConfigPanelBuilder.tsx create mode 100644 frontend/lib/registry/components/common/ConfigPanelTypes.ts create mode 100644 frontend/lib/registry/components/common/ConfigSection.tsx create mode 100644 frontend/lib/registry/components/v2-split-panel-layout/config-panels/CommonConfigTab.tsx create mode 100644 frontend/lib/registry/components/v2-split-panel-layout/config-panels/LeftPanelConfigTab.tsx create mode 100644 frontend/lib/registry/components/v2-split-panel-layout/config-panels/RightPanelConfigTab.tsx create mode 100644 frontend/lib/registry/components/v2-split-panel-layout/config-panels/SharedComponents.tsx create mode 100644 frontend/lib/registry/components/v2-table-list/config-panels/BasicConfigPanel.tsx create mode 100644 frontend/lib/registry/components/v2-table-list/config-panels/ColumnsConfigPanel.tsx create mode 100644 frontend/lib/registry/components/v2-table-list/config-panels/OptionsConfigPanel.tsx create mode 100644 frontend/lib/registry/components/v2-table-list/config-panels/StyleConfigPanel.tsx diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index a75fc431..4c5bdc57 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -3482,8 +3482,74 @@ export class ScreenManagementService { } console.log( - `✅ screenId/modalScreenId/targetScreenId 참조 업데이트 완료: ${result.updated}개 레이아웃`, + `✅ V1 screenId/modalScreenId/targetScreenId 참조 업데이트 완료: ${result.updated}개 레이아웃`, ); + + // V2 레이아웃(screen_layouts_v2)도 동일하게 처리 + const v2LayoutsResult = await client.query( + `SELECT screen_id, layer_id, company_code, layout_data + FROM screen_layouts_v2 + WHERE screen_id IN (${placeholders}) + AND layout_data::text ~ '"(screenId|targetScreenId|modalScreenId|leftScreenId|rightScreenId|addModalScreenId|editModalScreenId)"'`, + targetScreenIds, + ); + + console.log( + `🔍 V2 참조 업데이트 대상 레이아웃: ${v2LayoutsResult.rows.length}개`, + ); + + let v2Updated = 0; + for (const v2Layout of v2LayoutsResult.rows) { + let layoutData = v2Layout.layout_data; + if (!layoutData) continue; + + let v2HasChanges = false; + + const updateV2References = (obj: any): void => { + if (!obj || typeof obj !== "object") return; + if (Array.isArray(obj)) { + for (const item of obj) updateV2References(item); + return; + } + for (const key of Object.keys(obj)) { + const value = obj[key]; + if ( + (key === "screenId" || key === "targetScreenId" || key === "modalScreenId" || + key === "leftScreenId" || key === "rightScreenId" || + key === "addModalScreenId" || key === "editModalScreenId") + ) { + const numVal = typeof value === "number" ? value : parseInt(value); + if (!isNaN(numVal) && numVal > 0) { + const newId = screenMap.get(numVal); + if (newId) { + obj[key] = typeof value === "number" ? newId : String(newId); + v2HasChanges = true; + console.log(`🔗 V2 ${key} 매핑: ${numVal} → ${newId}`); + } + } + } + if (typeof value === "object" && value !== null) { + updateV2References(value); + } + } + }; + + updateV2References(layoutData); + + if (v2HasChanges) { + await client.query( + `UPDATE screen_layouts_v2 SET layout_data = $1, updated_at = NOW() + WHERE screen_id = $2 AND layer_id = $3 AND company_code = $4`, + [JSON.stringify(layoutData), v2Layout.screen_id, v2Layout.layer_id, v2Layout.company_code], + ); + v2Updated++; + } + } + + console.log( + `✅ V2 참조 업데이트 완료: ${v2Updated}개 레이아웃`, + ); + result.updated += v2Updated; }); return result; @@ -4610,9 +4676,60 @@ export class ScreenManagementService { } console.log( - `✅ 총 ${updateCount}개 레이아웃의 연결된 화면 ID 업데이트 완료 (버튼 + 조건부컨테이너)`, + `✅ V1: ${updateCount}개 레이아웃 업데이트 완료`, ); - return updateCount; + + // V2 레이아웃(screen_layouts_v2)에서도 targetScreenId 등 재매핑 + const v2Layouts = await query( + `SELECT screen_id, layer_id, company_code, layout_data + FROM screen_layouts_v2 + WHERE screen_id = $1 + AND layout_data IS NOT NULL`, + [screenId], + ); + + let v2UpdateCount = 0; + for (const v2Layout of v2Layouts) { + const layoutData = v2Layout.layout_data; + if (!layoutData?.components) continue; + + let v2Changed = false; + const updateV2Refs = (obj: any): void => { + if (!obj || typeof obj !== "object") return; + if (Array.isArray(obj)) { for (const item of obj) updateV2Refs(item); return; } + for (const key of Object.keys(obj)) { + const value = obj[key]; + if ( + (key === "targetScreenId" || key === "screenId" || key === "modalScreenId" || + key === "leftScreenId" || key === "rightScreenId" || + key === "addModalScreenId" || key === "editModalScreenId") + ) { + const numVal = typeof value === "number" ? value : parseInt(value); + if (!isNaN(numVal) && screenIdMapping.has(numVal)) { + obj[key] = typeof value === "number" ? screenIdMapping.get(numVal)! : screenIdMapping.get(numVal)!.toString(); + v2Changed = true; + } + } + if (typeof value === "object" && value !== null) updateV2Refs(value); + } + }; + updateV2Refs(layoutData); + + if (v2Changed) { + await query( + `UPDATE screen_layouts_v2 SET layout_data = $1, updated_at = NOW() + WHERE screen_id = $2 AND layer_id = $3 AND company_code = $4`, + [JSON.stringify(layoutData), v2Layout.screen_id, v2Layout.layer_id, v2Layout.company_code], + ); + v2UpdateCount++; + } + } + + const total = updateCount + v2UpdateCount; + console.log( + `✅ 총 ${total}개 레이아웃 업데이트 완료 (V1: ${updateCount}, V2: ${v2UpdateCount})`, + ); + return total; } /** diff --git a/frontend/components/screen/config-panels/button/ActionTab.tsx b/frontend/components/screen/config-panels/button/ActionTab.tsx new file mode 100644 index 00000000..f6c872e6 --- /dev/null +++ b/frontend/components/screen/config-panels/button/ActionTab.tsx @@ -0,0 +1,17 @@ +"use client"; + +import React from "react"; + +export interface ActionTabProps { + config: any; + onChange: (key: string, value: any) => void; + children: React.ReactNode; +} + +/** + * 동작 탭: 클릭 이벤트, 네비게이션, 모달 열기, 확인 다이얼로그 등 동작 설정 + * 실제 UI는 메인 ButtonConfigPanel에서 렌더링 후 children으로 전달 + */ +export const ActionTab: React.FC = ({ children }) => { + return
{children}
; +}; diff --git a/frontend/components/screen/config-panels/button/BasicTab.tsx b/frontend/components/screen/config-panels/button/BasicTab.tsx new file mode 100644 index 00000000..1eb7d2f7 --- /dev/null +++ b/frontend/components/screen/config-panels/button/BasicTab.tsx @@ -0,0 +1,40 @@ +"use client"; + +import React from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; + +export interface BasicTabProps { + config: any; + onChange: (key: string, value: any) => void; + localText?: string; + onTextChange?: (value: string) => void; +} + +export const BasicTab: React.FC = ({ + config, + onChange, + localText, + onTextChange, +}) => { + const text = localText !== undefined ? localText : (config.text !== undefined ? config.text : "버튼"); + + const handleChange = (newValue: string) => { + onTextChange?.(newValue); + onChange("componentConfig.text", newValue); + }; + + return ( +
+
+ + handleChange(e.target.value)} + placeholder="버튼 텍스트를 입력하세요" + /> +
+
+ ); +}; diff --git a/frontend/components/screen/config-panels/button/DataTab.tsx b/frontend/components/screen/config-panels/button/DataTab.tsx new file mode 100644 index 00000000..29b35c78 --- /dev/null +++ b/frontend/components/screen/config-panels/button/DataTab.tsx @@ -0,0 +1,872 @@ +"use client"; + +import React from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Button } from "@/components/ui/button"; +import { Check, ChevronsUpDown, Plus, X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { QuickInsertConfigSection } from "../QuickInsertConfigSection"; +import { ComponentData } from "@/types/screen"; + +export interface DataTabProps { + config: any; + onChange: (key: string, value: any) => void; + component: ComponentData; + allComponents: ComponentData[]; + currentTableName?: string; + availableTables: Array<{ name: string; label: string }>; + mappingTargetColumns: Array<{ name: string; label: string }>; + mappingSourceColumnsMap: Record>; + currentTableColumns: Array<{ name: string; label: string }>; + mappingSourcePopoverOpen: Record; + setMappingSourcePopoverOpen: React.Dispatch>>; + mappingTargetPopoverOpen: Record; + setMappingTargetPopoverOpen: React.Dispatch>>; + activeMappingGroupIndex: number; + setActiveMappingGroupIndex: React.Dispatch>; + loadMappingColumns: (tableName: string) => Promise>; + setMappingSourceColumnsMap: React.Dispatch< + React.SetStateAction>> + >; +} + +export const DataTab: React.FC = ({ + config, + onChange, + component, + allComponents, + currentTableName, + availableTables, + mappingTargetColumns, + mappingSourceColumnsMap, + currentTableColumns, + mappingSourcePopoverOpen, + setMappingSourcePopoverOpen, + mappingTargetPopoverOpen, + setMappingTargetPopoverOpen, + activeMappingGroupIndex, + setActiveMappingGroupIndex, + loadMappingColumns, + setMappingSourceColumnsMap, +}) => { + const actionType = config.action?.type; + const onUpdateProperty = (path: string, value: any) => onChange(path, value); + + if (actionType === "quickInsert") { + return ( +
+ +
+ ); + } + + if (actionType !== "transferData") { + return ( +
+ 데이터 전달 또는 즉시 저장 액션을 선택하면 설정할 수 있습니다. +
+ ); + } + + return ( +
+
+

데이터 전달 설정

+ +
+ + +

+ 레이어별로 다른 테이블이 있을 경우 "자동 탐색"을 선택하면 현재 활성화된 레이어의 테이블을 자동으로 사용합니다 +

+
+ +
+ + + {config.action?.dataTransfer?.targetType === "splitPanel" && ( +

+ 이 버튼이 분할 패널 내부에 있어야 합니다. 좌측 화면에서 우측으로, 또는 우측에서 좌측으로 데이터가 전달됩니다. +

+ )} +
+ + {config.action?.dataTransfer?.targetType === "component" && ( +
+ + +

테이블, 반복 필드 그룹 등 데이터를 받는 컴포넌트

+
+ )} + + {config.action?.dataTransfer?.targetType === "splitPanel" && ( +
+ + + onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", e.target.value) + } + placeholder="비워두면 첫 번째 수신 가능 컴포넌트로 전달" + className="h-8 text-xs" + /> +

+ 반대편 화면의 특정 컴포넌트 ID를 지정하거나, 비워두면 자동으로 첫 번째 수신 가능 컴포넌트로 전달됩니다. +

+
+ )} + +
+ + +

기존 데이터를 어떻게 처리할지 선택

+
+ +
+
+ +

데이터 전달 후 소스의 선택을 해제합니다

+
+ + onUpdateProperty("componentConfig.action.dataTransfer.clearAfterTransfer", checked) + } + /> +
+ +
+
+ +

데이터 전달 전 확인 다이얼로그를 표시합니다

+
+ + onUpdateProperty("componentConfig.action.dataTransfer.confirmBeforeTransfer", checked) + } + /> +
+ + {config.action?.dataTransfer?.confirmBeforeTransfer && ( +
+ + onUpdateProperty("componentConfig.action.dataTransfer.confirmMessage", e.target.value)} + className="h-8 text-xs" + /> +
+ )} + +
+ +
+
+ + + onUpdateProperty( + "componentConfig.action.dataTransfer.validation.minSelection", + parseInt(e.target.value) || 0, + ) + } + className="h-8 w-20 text-xs" + /> +
+
+ + + onUpdateProperty( + "componentConfig.action.dataTransfer.validation.maxSelection", + parseInt(e.target.value) || undefined, + ) + } + className="h-8 w-20 text-xs" + /> +
+
+
+ +
+ +

+ 조건부 컨테이너의 카테고리 값 등 추가 데이터를 함께 전달할 수 있습니다 +

+
+
+ + +

+ 조건부 컨테이너, 셀렉트박스 등 (카테고리 값 전달용) +

+
+
+ + + + + + + + + + 컬럼을 찾을 수 없습니다. + + { + const currentSources = config.action?.dataTransfer?.additionalSources || []; + const newSources = [...currentSources]; + if (newSources.length === 0) { + newSources.push({ componentId: "", fieldName: "" }); + } else { + newSources[0] = { ...newSources[0], fieldName: "" }; + } + onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources); + }} + className="text-xs" + > + + 선택 안 함 (전체 데이터 병합) + + {(mappingTargetColumns.length > 0 ? mappingTargetColumns : currentTableColumns).map((col) => ( + { + const currentSources = config.action?.dataTransfer?.additionalSources || []; + const newSources = [...currentSources]; + if (newSources.length === 0) { + newSources.push({ componentId: "", fieldName: col.name }); + } else { + newSources[0] = { ...newSources[0], fieldName: col.name }; + } + onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources); + }} + className="text-xs" + > + + {col.label || col.name} + {col.label && col.label !== col.name && ( + ({col.name}) + )} + + ))} + + + + + +

추가 데이터가 저장될 타겟 테이블 컬럼

+
+
+
+ +
+ +
+ + + + + + + + + + 테이블을 찾을 수 없습니다 + + {availableTables.map((table) => ( + { + onUpdateProperty("componentConfig.action.dataTransfer.targetTable", table.name); + }} + className="text-xs" + > + + {table.label} + ({table.name}) + + ))} + + + + + +
+ +
+
+ + +
+

+ 여러 소스 테이블에서 데이터를 전달할 때, 각 테이블별로 매핑 규칙을 설정합니다. 런타임에 소스 테이블을 자동 감지합니다. +

+ + {!config.action?.dataTransfer?.targetTable ? ( +
+

먼저 타겟 테이블을 선택하세요.

+
+ ) : !(config.action?.dataTransfer?.multiTableMappings || []).length ? ( +
+

매핑 그룹이 없습니다. 소스 테이블을 추가하세요.

+
+ ) : ( +
+
+ {(config.action?.dataTransfer?.multiTableMappings || []).map((group: any, gIdx: number) => ( +
+ + +
+ ))} +
+ + {(() => { + const multiMappings = config.action?.dataTransfer?.multiTableMappings || []; + const activeGroup = multiMappings[activeMappingGroupIndex]; + if (!activeGroup) return null; + + const activeSourceTable = activeGroup.sourceTable || ""; + const activeSourceColumns = mappingSourceColumnsMap[activeSourceTable] || []; + const activeRules: any[] = activeGroup.mappingRules || []; + + const updateGroupField = (field: string, value: any) => { + const mappings = [...multiMappings]; + mappings[activeMappingGroupIndex] = { ...mappings[activeMappingGroupIndex], [field]: value }; + onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", mappings); + }; + + return ( +
+
+ + + + + + + + + + 테이블을 찾을 수 없습니다 + + {availableTables.map((table) => ( + { + updateGroupField("sourceTable", table.name); + if (!mappingSourceColumnsMap[table.name]) { + const cols = await loadMappingColumns(table.name); + setMappingSourceColumnsMap((prev) => ({ ...prev, [table.name]: cols })); + } + }} + className="text-xs" + > + + {table.label} + ({table.name}) + + ))} + + + + + +
+ +
+
+ + +
+ + {!activeSourceTable ? ( +

소스 테이블을 먼저 선택하세요.

+ ) : activeRules.length === 0 ? ( +

매핑 없음 (동일 필드명 자동 매핑)

+ ) : ( + activeRules.map((rule: any, rIdx: number) => { + const popoverKeyS = `${activeMappingGroupIndex}-${rIdx}-s`; + const popoverKeyT = `${activeMappingGroupIndex}-${rIdx}-t`; + return ( +
+
+ + setMappingSourcePopoverOpen((prev) => ({ ...prev, [popoverKeyS]: open })) + } + > + + + + + + + + 컬럼 없음 + + {activeSourceColumns.map((col) => ( + { + const newRules = [...activeRules]; + newRules[rIdx] = { ...newRules[rIdx], sourceField: col.name }; + updateGroupField("mappingRules", newRules); + setMappingSourcePopoverOpen((prev) => ({ + ...prev, + [popoverKeyS]: false, + })); + }} + className="text-xs" + > + + {col.label} + {col.label !== col.name && ( + ({col.name}) + )} + + ))} + + + + + +
+ + + +
+ + setMappingTargetPopoverOpen((prev) => ({ ...prev, [popoverKeyT]: open })) + } + > + + + + + + + + 컬럼 없음 + + {mappingTargetColumns.map((col) => ( + { + const newRules = [...activeRules]; + newRules[rIdx] = { ...newRules[rIdx], targetField: col.name }; + updateGroupField("mappingRules", newRules); + setMappingTargetPopoverOpen((prev) => ({ + ...prev, + [popoverKeyT]: false, + })); + }} + className="text-xs" + > + + {col.label} + {col.label !== col.name && ( + ({col.name}) + )} + + ))} + + + + + +
+ + +
+ ); + }) + )} +
+
+ ); + })()} +
+ )} +
+
+ +
+

+ 사용 방법: +
+ 1. 소스 컴포넌트에서 데이터를 선택합니다 +
+ 2. 소스 테이블별로 필드 매핑 규칙을 설정합니다 +
+ 3. 이 버튼을 클릭하면 소스 테이블을 자동 감지하여 매핑된 데이터가 타겟으로 전달됩니다 +

+
+
+
+ ); +}; diff --git a/frontend/lib/registry/components/common/ConfigField.tsx b/frontend/lib/registry/components/common/ConfigField.tsx new file mode 100644 index 00000000..0b11780d --- /dev/null +++ b/frontend/lib/registry/components/common/ConfigField.tsx @@ -0,0 +1,264 @@ +"use client"; + +import React from "react"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Plus, X } from "lucide-react"; +import { ConfigFieldDefinition, ConfigOption } from "./ConfigPanelTypes"; + +interface ConfigFieldProps { + field: ConfigFieldDefinition; + value: any; + onChange: (key: string, value: any) => void; + tableColumns?: ConfigOption[]; +} + +export function ConfigField({ + field, + value, + onChange, + tableColumns, +}: ConfigFieldProps) { + const handleChange = (newValue: any) => { + onChange(field.key, newValue); + }; + + const renderField = () => { + switch (field.type) { + case "text": + return ( + handleChange(e.target.value)} + placeholder={field.placeholder} + className="h-8 text-xs" + /> + ); + + case "number": + return ( + + handleChange( + e.target.value === "" ? undefined : Number(e.target.value), + ) + } + placeholder={field.placeholder} + min={field.min} + max={field.max} + step={field.step} + className="h-8 text-xs" + /> + ); + + case "switch": + return ( + + ); + + case "select": + return ( + + ); + + case "textarea": + return ( +