From 1e1bc0b2c68c9131781ceb812f855aac39c08c44 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Fri, 21 Nov 2025 12:22:27 +0900 Subject: [PATCH 01/29] =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=A0=80=EC=9E=A5=20=EB=B0=8F=20=EB=94=94?= =?UTF-8?q?=EC=A7=80=ED=84=B8=20=ED=8A=B8=EC=9C=88=20UX=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/yard-3d/DigitalTwinEditor.tsx | 116 +++++++++--------- .../widgets/yard-3d/DigitalTwinViewer.tsx | 21 ++-- .../widgets/yard-3d/HierarchyConfigPanel.tsx | 14 +-- .../dashboard/widgets/yard-3d/constants.ts | 30 +++++ 4 files changed, 103 insertions(+), 78 deletions(-) create mode 100644 frontend/components/admin/dashboard/widgets/yard-3d/constants.ts diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx index 88e844d3..ac9aac19 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx @@ -25,6 +25,7 @@ import { import type { MaterialData } from "@/types/digitalTwin"; import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection"; import HierarchyConfigPanel, { HierarchyConfig } from "./HierarchyConfigPanel"; +import { OBJECT_COLORS, DEFAULT_COLOR } from "./constants"; import { validateSpatialContainment, updateChildrenPositions, getAllDescendants } from "./spatialContainment"; // 백엔드 DB 객체 타입 (snake_case) @@ -702,7 +703,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi name: objectName, position: { x, y: yPosition, z }, size: defaults.size || { x: 5, y: 5, z: 5 }, - color: defaults.color || "#9ca3af", + color: OBJECT_COLORS[draggedTool] || DEFAULT_COLOR, // 타입별 기본 색상 areaKey, locaKey, locType, @@ -1169,8 +1170,8 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi 도구: {[ { type: "area" as ToolType, label: "영역", icon: Grid3x3, color: "text-blue-500" }, - { type: "location-bed" as ToolType, label: "베드", icon: Package, color: "text-emerald-500" }, - { type: "location-stp" as ToolType, label: "정차", icon: Move, color: "text-orange-500" }, + { type: "location-bed" as ToolType, label: "베드", icon: Package, color: "text-blue-600" }, + { type: "location-stp" as ToolType, label: "정차", icon: Move, color: "text-gray-500" }, // { type: "crane-gantry" as ToolType, label: "겐트리", icon: Combine, color: "text-green-500" }, { type: "crane-mobile" as ToolType, label: "크레인", icon: Truck, color: "text-yellow-500" }, { type: "rack" as ToolType, label: "랙", icon: Box, color: "text-purple-500" }, @@ -1221,54 +1222,6 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi - {/* 창고 테이블 및 컬럼 매핑 */} - {selectedDbConnection && ( -
- - - {/* 이 레이아웃의 창고 선택 */} - {hierarchyConfig?.warehouse?.tableName && hierarchyConfig?.warehouse?.keyColumn && ( -
- - {loadingWarehouses ? ( -
- -
- ) : ( - - )} -
- )} -
- )} - {/* 계층 설정 패널 (신규) */} {selectedDbConnection && ( )} + {/* 창고 선택 (HierarchyConfigPanel 아래로 이동) */} + {selectedDbConnection && hierarchyConfig?.warehouse?.tableName && hierarchyConfig?.warehouse?.keyColumn && ( +
+ + +
+ + {loadingWarehouses ? ( +
+ +
+ ) : ( + + )} +
+
+ )} + {/* Area 목록 */} {selectedDbConnection && selectedWarehouse && (
@@ -1605,7 +1605,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi handleObjectUpdate({ name: e.target.value })} className="mt-1.5 h-9 text-sm" /> @@ -1622,7 +1622,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi handleObjectUpdate({ position: { @@ -1641,7 +1641,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi handleObjectUpdate({ position: { @@ -1669,7 +1669,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi type="number" step="5" min="5" - value={selectedObject.size.x} + value={selectedObject.size?.x || 5} onChange={(e) => handleObjectUpdate({ size: { @@ -1688,7 +1688,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi handleObjectUpdate({ size: { @@ -1709,7 +1709,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi type="number" step="5" min="5" - value={selectedObject.size.z} + value={selectedObject.size?.z || 5} onChange={(e) => handleObjectUpdate({ size: { @@ -1732,7 +1732,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi handleObjectUpdate({ color: e.target.value })} className="mt-1.5 h-9" /> diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx index 3945a692..94ef98fb 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx @@ -10,6 +10,7 @@ import dynamic from "next/dynamic"; import { useToast } from "@/hooks/use-toast"; import type { PlacedObject, MaterialData } from "@/types/digitalTwin"; import { getLayoutById, getMaterials } from "@/lib/api/digitalTwin"; +import { OBJECT_COLORS, DEFAULT_COLOR } from "./constants"; const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), { ssr: false, @@ -81,7 +82,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) z: parseFloat(obj.size_z), }, rotation: obj.rotation ? parseFloat(obj.rotation) : 0, - color: getObjectColor(objectType), // 타입별 기본 색상 사용 + color: getObjectColor(objectType, obj.color), // 저장된 색상 우선, 없으면 타입별 기본 색상 areaKey: obj.area_key, locaKey: obj.loca_key, locType: obj.loc_type, @@ -225,17 +226,11 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) // 객체 타입별 기본 색상 (useMemo로 최적화) const getObjectColor = useMemo(() => { - return (type: string): string => { - const colorMap: Record = { - area: "#3b82f6", // 파란색 - "location-bed": "#2563eb", // 진한 파란색 - "location-stp": "#6b7280", // 회색 - "location-temp": "#f59e0b", // 주황색 - "location-dest": "#10b981", // 초록색 - "crane-mobile": "#8b5cf6", // 보라색 - rack: "#ef4444", // 빨간색 - }; - return colorMap[type] || "#3b82f6"; + return (type: string, savedColor?: string): string => { + // 저장된 색상이 있으면 우선 사용 + if (savedColor) return savedColor; + // 없으면 타입별 기본 색상 사용 + return OBJECT_COLORS[type] || DEFAULT_COLOR; }; }, []); @@ -383,7 +378,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
{typeLabel}
diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx index 8a6f4bfd..d309c92f 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx @@ -257,7 +257,7 @@ export default function HierarchyConfigPanel({
handleLevelChange(level.level, "name", e.target.value)} className="h-7 w-32 text-xs" placeholder="레벨명" @@ -276,7 +276,7 @@ export default function HierarchyConfigPanel({
handleLevelChange(level.level, "keyColumn", val)} > @@ -319,7 +319,7 @@ export default function HierarchyConfigPanel({
handleLevelChange(level.level, "parentKeyColumn", val)} > @@ -422,7 +422,7 @@ export default function HierarchyConfigPanel({
handleMaterialChange("locationKeyColumn", val)} > diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/constants.ts b/frontend/components/admin/dashboard/widgets/yard-3d/constants.ts new file mode 100644 index 00000000..d4959a6c --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/yard-3d/constants.ts @@ -0,0 +1,30 @@ +/** + * 디지털 트윈 3D 야드 - 공통 상수 + */ + +// 객체 타입별 색상 매핑 (HEX 코드) +export const OBJECT_COLORS: Record = { + area: "#3b82f6", // 파란색 + "location-bed": "#2563eb", // 진한 파란색 + "location-stp": "#6b7280", // 회색 + "location-temp": "#f59e0b", // 주황색 + "location-dest": "#10b981", // 초록색 + "crane-mobile": "#8b5cf6", // 보라색 + rack: "#ef4444", // 빨간색 +}; + +// Tailwind 색상 클래스 매핑 (아이콘용) +export const OBJECT_COLOR_CLASSES: Record = { + area: "text-blue-500", + "location-bed": "text-blue-600", + "location-stp": "text-gray-500", + "location-temp": "text-orange-500", + "location-dest": "text-emerald-500", + "crane-mobile": "text-purple-500", + rack: "text-red-500", +}; + +// 기본 색상 +export const DEFAULT_COLOR = "#3b82f6"; +export const DEFAULT_COLOR_CLASS = "text-blue-500"; + From 6ccaa854133405a6f5493f7d54e9a20059b66be5 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Fri, 21 Nov 2025 14:29:17 +0900 Subject: [PATCH 02/29] =?UTF-8?q?=EC=9A=B0=EC=B8=A1=ED=8C=A8=EB=84=90=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20=EC=BB=AC=EB=9F=BC=20=EC=9D=B8=ED=92=8B=20?= =?UTF-8?q?=EB=8B=A4=20=EC=A7=80=EC=9A=B0=EB=A9=B4=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=EA=B0=92=EC=9D=B4=20=EB=93=A4=EC=96=B4=EC=98=A4=EB=8A=94=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx index d309c92f..4def424c 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx @@ -526,7 +526,7 @@ export default function HierarchyConfigPanel({ {col} {isSelected && ( { const currentDisplay = localConfig.material?.displayColumns || []; const newDisplay = currentDisplay.map((d) => From dd913d3ecfb595891727fd5d7e1ebaa8a15b0170 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Fri, 21 Nov 2025 15:44:52 +0900 Subject: [PATCH 03/29] =?UTF-8?q?3d=EC=97=90=EC=84=9C=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EA=B0=80=EC=A0=B8=EC=98=AC=20=EB=95=8C=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94,=20=EC=BB=AC=EB=9F=BC=20=EC=BD=94=EB=A9=98?= =?UTF-8?q?=ED=8A=B8=20=EA=B0=99=EC=9D=B4=20=EA=B0=80=EC=A0=B8=EC=98=A4?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/database/MariaDBConnector.ts | 37 +++-- .../widgets/yard-3d/DigitalTwinEditor.tsx | 18 +-- .../widgets/yard-3d/HierarchyConfigPanel.tsx | 133 ++++++++++++++---- 3 files changed, 137 insertions(+), 51 deletions(-) diff --git a/backend-node/src/database/MariaDBConnector.ts b/backend-node/src/database/MariaDBConnector.ts index 3f469330..d104b9e3 100644 --- a/backend-node/src/database/MariaDBConnector.ts +++ b/backend-node/src/database/MariaDBConnector.ts @@ -1,7 +1,11 @@ -import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector'; -import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes'; +import { + DatabaseConnector, + ConnectionConfig, + QueryResult, +} from "../interfaces/DatabaseConnector"; +import { ConnectionTestResult, TableInfo } from "../types/externalDbTypes"; // @ts-ignore -import * as mysql from 'mysql2/promise'; +import * as mysql from "mysql2/promise"; export class MariaDBConnector implements DatabaseConnector { private connection: mysql.Connection | null = null; @@ -20,7 +24,7 @@ export class MariaDBConnector implements DatabaseConnector { password: this.config.password, database: this.config.database, connectTimeout: this.config.connectionTimeoutMillis, - ssl: typeof this.config.ssl === 'boolean' ? undefined : this.config.ssl, + ssl: typeof this.config.ssl === "boolean" ? undefined : this.config.ssl, }); } } @@ -36,7 +40,9 @@ export class MariaDBConnector implements DatabaseConnector { const startTime = Date.now(); try { await this.connect(); - const [rows] = await this.connection!.query("SELECT VERSION() as version"); + const [rows] = await this.connection!.query( + "SELECT VERSION() as version" + ); const version = (rows as any[])[0]?.version || "Unknown"; const responseTime = Date.now() - startTime; await this.disconnect(); @@ -111,21 +117,28 @@ export class MariaDBConnector implements DatabaseConnector { console.log(`[MariaDBConnector] getColumns 호출: tableName=${tableName}`); await this.connect(); console.log(`[MariaDBConnector] 연결 완료, 쿼리 실행 시작`); - - const [rows] = await this.connection!.query(` + + const [rows] = await this.connection!.query( + ` SELECT COLUMN_NAME as column_name, DATA_TYPE as data_type, IS_NULLABLE as is_nullable, - COLUMN_DEFAULT as column_default + COLUMN_DEFAULT as column_default, + COLUMN_COMMENT as description FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION; - `, [tableName]); - + `, + [tableName] + ); + console.log(`[MariaDBConnector] 쿼리 결과:`, rows); - console.log(`[MariaDBConnector] 결과 개수:`, Array.isArray(rows) ? rows.length : 'not array'); - + console.log( + `[MariaDBConnector] 결과 개수:`, + Array.isArray(rows) ? rows.length : "not array" + ); + await this.disconnect(); return rows as any[]; } catch (error: any) { diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx index ac9aac19..5443d150 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx @@ -95,7 +95,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi // 동적 계층 구조 설정 const [hierarchyConfig, setHierarchyConfig] = useState(null); - const [availableTables, setAvailableTables] = useState([]); + const [availableTables, setAvailableTables] = useState>([]); const [loadingTables, setLoadingTables] = useState(false); // 레거시: 테이블 매핑 (구 Area/Location 방식 호환용) @@ -210,9 +210,9 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi const { getTables } = await import("@/lib/api/digitalTwin"); const response = await getTables(selectedDbConnection); if (response.success && response.data) { - const tableNames = response.data.map((t) => t.table_name); - setAvailableTables(tableNames); - console.log("📋 테이블 목록:", tableNames); + // 테이블 정보 전체 저장 (이름 + 설명) + setAvailableTables(response.data); + console.log("📋 테이블 목록:", response.data); } } catch (error) { console.error("테이블 목록 조회 실패:", error); @@ -1266,10 +1266,12 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi try { const response = await ExternalDbConnectionAPI.getTableColumns(selectedDbConnection, tableName); if (response.success && response.data) { - // 객체 배열을 문자열 배열로 변환 - return response.data.map((col: any) => - typeof col === "string" ? col : col.column_name || col.COLUMN_NAME || String(col), - ); + // 컬럼 정보 객체 배열로 반환 (이름 + 설명) + return response.data.map((col: any) => ({ + column_name: typeof col === "string" ? col : col.column_name || col.COLUMN_NAME || String(col), + data_type: col.data_type || col.DATA_TYPE, + description: col.description || col.COLUMN_COMMENT || undefined, + })); } return []; } catch (error) { diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx index 4def424c..00473450 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx @@ -40,13 +40,24 @@ export interface HierarchyConfig { }; } +interface TableInfo { + table_name: string; + description?: string; +} + +interface ColumnInfo { + column_name: string; + data_type?: string; + description?: string; +} + interface HierarchyConfigPanelProps { externalDbConnectionId: number | null; hierarchyConfig: HierarchyConfig | null; onHierarchyConfigChange: (config: HierarchyConfig) => void; - availableTables: string[]; + availableTables: TableInfo[]; onLoadTables: () => Promise; - onLoadColumns: (tableName: string) => Promise; + onLoadColumns: (tableName: string) => Promise; } export default function HierarchyConfigPanel({ @@ -65,7 +76,7 @@ export default function HierarchyConfigPanel({ ); const [loadingColumns, setLoadingColumns] = useState(false); - const [columnsCache, setColumnsCache] = useState<{ [tableName: string]: string[] }>({}); + const [columnsCache, setColumnsCache] = useState<{ [tableName: string]: ColumnInfo[] }>({}); // 외부에서 변경된 경우 동기화 useEffect(() => { @@ -187,8 +198,13 @@ export default function HierarchyConfigPanel({ {availableTables.map((table) => ( - - {table} + +
+ {table.table_name} + {table.description && ( + {table.description} + )} +
))}
@@ -209,8 +225,13 @@ export default function HierarchyConfigPanel({ {columnsCache[localConfig.warehouse.tableName].map((col) => ( - - {col} + +
+ {col.column_name} + {col.description && ( + {col.description} + )} +
))}
@@ -228,8 +249,13 @@ export default function HierarchyConfigPanel({ {columnsCache[localConfig.warehouse.tableName].map((col) => ( - - {col} + +
+ {col.column_name} + {col.description && ( + {col.description} + )} +
))}
@@ -287,8 +313,13 @@ export default function HierarchyConfigPanel({ {availableTables.map((table) => ( - - {table} + +
+ {table.table_name} + {table.description && ( + {table.description} + )} +
))}
@@ -308,8 +339,13 @@ export default function HierarchyConfigPanel({ {columnsCache[level.tableName].map((col) => ( - - {col} + +
+ {col.column_name} + {col.description && ( + {col.description} + )} +
))}
@@ -327,8 +363,13 @@ export default function HierarchyConfigPanel({ {columnsCache[level.tableName].map((col) => ( - - {col} + +
+ {col.column_name} + {col.description && ( + {col.description} + )} +
))}
@@ -409,8 +450,13 @@ export default function HierarchyConfigPanel({ {availableTables.map((table) => ( - - {table} + +
+ {table.table_name} + {table.description && ( + {table.description} + )} +
))}
@@ -430,8 +476,13 @@ export default function HierarchyConfigPanel({ {columnsCache[localConfig.material.tableName].map((col) => ( - - {col} + +
+ {col.column_name} + {col.description && ( + {col.description} + )} +
))}
@@ -449,8 +500,13 @@ export default function HierarchyConfigPanel({ {columnsCache[localConfig.material.tableName].map((col) => ( - - {col} + +
+ {col.column_name} + {col.description && ( + {col.description} + )} +
))}
@@ -469,8 +525,13 @@ export default function HierarchyConfigPanel({ 없음 {columnsCache[localConfig.material.tableName].map((col) => ( - - {col} + +
+ {col.column_name} + {col.description && ( + {col.description} + )} +
))}
@@ -489,8 +550,13 @@ export default function HierarchyConfigPanel({ 없음 {columnsCache[localConfig.material.tableName].map((col) => ( - - {col} + +
+ {col.column_name} + {col.description && ( + {col.description} + )} +
))}
@@ -507,30 +573,35 @@ export default function HierarchyConfigPanel({

{columnsCache[localConfig.material.tableName].map((col) => { - const displayItem = localConfig.material?.displayColumns?.find((d) => d.column === col); + const displayItem = localConfig.material?.displayColumns?.find((d) => d.column === col.column_name); const isSelected = !!displayItem; return ( -
+
{ const currentDisplay = localConfig.material?.displayColumns || []; const newDisplay = e.target.checked - ? [...currentDisplay, { column: col, label: col }] - : currentDisplay.filter((d) => d.column !== col); + ? [...currentDisplay, { column: col.column_name, label: col.column_name }] + : currentDisplay.filter((d) => d.column !== col.column_name); handleMaterialChange("displayColumns", newDisplay); }} className="h-3 w-3 shrink-0" /> - {col} +
+ {col.column_name} + {col.description && ( + {col.description} + )} +
{isSelected && ( { const currentDisplay = localConfig.material?.displayColumns || []; const newDisplay = currentDisplay.map((d) => - d.column === col ? { ...d, label: e.target.value } : d, + d.column === col.column_name ? { ...d, label: e.target.value } : d, ); handleMaterialChange("displayColumns", newDisplay); }} From 68e8e7b36bf38eb4e78732a625f31036c9059277 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Fri, 21 Nov 2025 16:13:50 +0900 Subject: [PATCH 04/29] =?UTF-8?q?=EC=B4=88=EA=B8=B0=EC=97=90=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=B6=88=EB=9F=AC?= =?UTF-8?q?=EC=99=80=EC=A7=80=EC=A7=80=20=EC=95=8A=EB=8A=94=20=ED=98=84?= =?UTF-8?q?=EC=83=81=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/yard-3d/HierarchyConfigPanel.tsx | 54 ++++++++++++++++--- 1 file changed, 48 insertions(+), 6 deletions(-) diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx index 00473450..31cca6f8 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx @@ -78,16 +78,43 @@ export default function HierarchyConfigPanel({ const [loadingColumns, setLoadingColumns] = useState(false); const [columnsCache, setColumnsCache] = useState<{ [tableName: string]: ColumnInfo[] }>({}); - // 외부에서 변경된 경우 동기화 + // 외부에서 변경된 경우 동기화 및 컬럼 자동 로드 useEffect(() => { if (hierarchyConfig) { setLocalConfig(hierarchyConfig); + + // 저장된 설정이 있으면 해당 테이블들의 컬럼을 자동 로드 + const loadSavedColumns = async () => { + // 창고 테이블 컬럼 로드 + if (hierarchyConfig.warehouse?.tableName) { + await handleTableChange(hierarchyConfig.warehouse.tableName, "warehouse"); + } + + // 레벨 테이블 컬럼 로드 + if (hierarchyConfig.levels) { + for (const level of hierarchyConfig.levels) { + if (level.tableName) { + await handleTableChange(level.tableName, level.level); + } + } + } + + // 자재 테이블 컬럼 로드 + if (hierarchyConfig.material?.tableName) { + await handleTableChange(hierarchyConfig.material.tableName, "material"); + } + }; + + loadSavedColumns(); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [hierarchyConfig]); // 테이블 선택 시 컬럼 로드 const handleTableChange = async (tableName: string, type: "warehouse" | "material" | number) => { - if (columnsCache[tableName]) return; // 이미 로드된 경우 스킵 + if (columnsCache[tableName]) { + return; // 이미 로드된 경우 스킵 + } setLoadingColumns(true); try { @@ -209,6 +236,11 @@ export default function HierarchyConfigPanel({ ))} + {!localConfig.warehouse?.tableName && ( +

+ ℹ️ 창고 테이블을 선택하고 "설정 적용"을 눌러주세요 +

+ )}
{/* 창고 컬럼 매핑 */} @@ -387,8 +419,13 @@ export default function HierarchyConfigPanel({ {columnsCache[level.tableName].map((col) => ( - - {col} + +
+ {col.column_name} + {col.description && ( + {col.description} + )} +
))}
@@ -409,8 +446,13 @@ export default function HierarchyConfigPanel({ 없음 {columnsCache[level.tableName].map((col) => ( - - {col} + +
+ {col.column_name} + {col.description && ( + {col.description} + )} +
))}
From 58427fb8e05332be384d523acf9dbf821c37e2db Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 21 Nov 2025 17:00:25 +0900 Subject: [PATCH 05/29] =?UTF-8?q?style(ui):=20=EB=B0=B0=EA=B2=BD=EC=83=89?= =?UTF-8?q?=20=ED=86=B5=EC=9D=BC=20=EB=B0=8F=20=EC=8A=A4=ED=81=AC=EB=A1=A4?= =?UTF-8?q?=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - autocomplete-search-input: !bg-background 강제 적용 - section-paper: 배경색 진하게(bg-muted/40), 기본 테두리 활성화, overflow-visible - modal-repeater-table: tbody 흰색 배경, 스크롤 높이 제한(240px), 헤더 고정 - autocomplete 드롭다운: z-index 100으로 상향 배경색 통일로 일관된 디자인, 스크롤로 공간 효율 개선 --- .../AutocompleteSearchInputComponent.tsx | 4 ++-- .../modal-repeater-table/RepeaterTable.tsx | 8 ++++---- .../section-paper/SectionPaperComponent.tsx | 12 ++++++------ 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx b/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx index 263cb294..e3572e33 100644 --- a/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx +++ b/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx @@ -175,7 +175,7 @@ export function AutocompleteSearchInputComponent({ onFocus={handleInputFocus} placeholder={placeholder} disabled={disabled} - className="h-8 pr-16 text-xs sm:h-10 sm:text-sm" + className="h-8 pr-16 text-xs sm:h-10 sm:text-sm !bg-background" />
{loading && ( @@ -198,7 +198,7 @@ export function AutocompleteSearchInputComponent({ {/* 드롭다운 결과 */} {isOpen && (results.length > 0 || loading) && ( -
+
{loading && results.length === 0 ? (
diff --git a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx index 879cbce5..c9aa2cfd 100644 --- a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx @@ -118,10 +118,10 @@ export function RepeaterTable({ }; return ( -
-
+
+
- + - + {data.length === 0 ? (
# @@ -141,7 +141,7 @@ export function RepeaterTable({
Date: Mon, 24 Nov 2025 10:02:56 +0900 Subject: [PATCH 06/29] =?UTF-8?q?=EB=A9=94=EB=89=B4=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20=EC=95=88=EB=90=98=EB=8A=94=20=ED=98=84=EC=83=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/controllers/adminController.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index 746bf931..a7e8404f 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -1324,7 +1324,7 @@ export async function updateMenu( if (!menuUrl) { await query( `UPDATE screen_menu_assignments - SET is_active = 'N', updated_date = NOW() + SET is_active = 'N' WHERE menu_objid = $1 AND company_code = $2`, [Number(menuId), companyCode] ); From ddb1d4cf60a9ba9e04120ca00e6b645230b5e13e Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 24 Nov 2025 12:02:23 +0900 Subject: [PATCH 07/29] =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=A2=8C=EC=9A=B0?= =?UTF-8?q?=20=EB=A7=9E=EC=B6=94=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app/(main)/main/page.tsx | 2 +- frontend/app/(main)/page.tsx | 2 +- .../app/(main)/screens/[screenId]/page.tsx | 968 +++++++++--------- frontend/components/layout/AppLayout.tsx | 2 +- .../screen/ResponsiveDesignerContainer.tsx | 8 +- 5 files changed, 504 insertions(+), 478 deletions(-) diff --git a/frontend/app/(main)/main/page.tsx b/frontend/app/(main)/main/page.tsx index 45f75f67..00ef509b 100644 --- a/frontend/app/(main)/main/page.tsx +++ b/frontend/app/(main)/main/page.tsx @@ -9,7 +9,7 @@ import { Badge } from "@/components/ui/badge"; */ export default function MainPage() { return ( -
+
{/* 메인 컨텐츠 */} {/* Welcome Message */} diff --git a/frontend/app/(main)/page.tsx b/frontend/app/(main)/page.tsx index 53c6dfb1..f5d7a153 100644 --- a/frontend/app/(main)/page.tsx +++ b/frontend/app/(main)/page.tsx @@ -1,6 +1,6 @@ export default function MainHomePage() { return ( -
+
{/* 대시보드 컨텐츠 */}

WACE 솔루션에 오신 것을 환영합니다!

diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 3b75f262..ce99a685 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -26,7 +26,7 @@ function ScreenViewPage() { const searchParams = useSearchParams(); const router = useRouter(); const screenId = parseInt(params.screenId as string); - + // URL 쿼리에서 menuObjid 가져오기 (메뉴 스코프) const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined; @@ -178,31 +178,26 @@ function ScreenViewPage() { for (const comp of layout.components) { // type: "component" 또는 type: "widget" 모두 처리 - if (comp.type === 'widget' || comp.type === 'component') { + if (comp.type === "widget" || comp.type === "component") { const widget = comp as any; const fieldName = widget.columnName || widget.id; - + // autoFill 처리 if (widget.autoFill?.enabled || (comp as any).autoFill?.enabled) { const autoFillConfig = widget.autoFill || (comp as any).autoFill; const currentValue = formData[fieldName]; - - if (currentValue === undefined || currentValue === '') { + + if (currentValue === undefined || currentValue === "") { const { sourceTable, filterColumn, userField, displayColumn } = autoFillConfig; - + // 사용자 정보에서 필터 값 가져오기 const userValue = user?.[userField as keyof typeof user]; - + if (userValue && sourceTable && filterColumn && displayColumn) { try { const { tableTypeApi } = await import("@/lib/api/screen"); - const result = await tableTypeApi.getTableRecord( - sourceTable, - filterColumn, - userValue, - displayColumn - ); - + const result = await tableTypeApi.getTableRecord(sourceTable, filterColumn, userValue, displayColumn); + setFormData((prev) => ({ ...prev, [fieldName]: result.value, @@ -233,24 +228,27 @@ function ScreenViewPage() { const designWidth = layout?.screenResolution?.width || 1200; const designHeight = layout?.screenResolution?.height || 800; - // containerRef는 이미 패딩이 적용된 영역 내부이므로 offsetWidth는 패딩을 제외한 크기입니다 + // 컨테이너의 실제 크기 const containerWidth = containerRef.current.offsetWidth; const containerHeight = containerRef.current.offsetHeight; - // 화면이 잘리지 않도록 가로/세로 중 작은 쪽 기준으로 스케일 조정 - const scaleX = containerWidth / designWidth; - const scaleY = containerHeight / designHeight; - // 전체 화면이 보이도록 작은 쪽 기준으로 스케일 설정 - const newScale = Math.min(scaleX, scaleY); - + // 여백 설정: 좌우 16px씩 (총 32px), 상단 패딩 32px (pt-8) + const MARGIN_X = 32; + const availableWidth = containerWidth - MARGIN_X; + + // 가로 기준 스케일 계산 (좌우 여백 16px씩 고정) + const newScale = availableWidth / designWidth; + console.log("📐 스케일 계산:", { containerWidth, containerHeight, + MARGIN_X, + availableWidth, designWidth, designHeight, - scaleX, - scaleY, finalScale: newScale, + "스케일된 화면 크기": `${designWidth * newScale}px × ${designHeight * newScale}px`, + "실제 좌우 여백": `${(containerWidth - designWidth * newScale) / 2}px씩`, }); setScale(newScale); @@ -307,503 +305,531 @@ function ScreenViewPage() { return ( -
- {/* 레이아웃 준비 중 로딩 표시 */} - {!layoutReady && ( -
-
- -

화면 준비 중...

-
+
+ {/* 레이아웃 준비 중 로딩 표시 */} + {!layoutReady && ( +
+
+ +

화면 준비 중...

- )} +
+ )} - {/* 절대 위치 기반 렌더링 (화면관리와 동일한 방식) */} - {layoutReady && layout && layout.components.length > 0 ? ( -
- {/* 최상위 컴포넌트들 렌더링 */} - {(() => { - // 🆕 플로우 버튼 그룹 감지 및 처리 - const topLevelComponents = layout.components.filter((component) => !component.parentId); + {/* 절대 위치 기반 렌더링 (화면관리와 동일한 방식) */} + {layoutReady && layout && layout.components.length > 0 ? ( +
+ {/* 최상위 컴포넌트들 렌더링 */} + {(() => { + // 🆕 플로우 버튼 그룹 감지 및 처리 + const topLevelComponents = layout.components.filter((component) => !component.parentId); - // 화면 관리에서 설정한 해상도를 사용하므로 widthOffset 계산 불필요 - // 모든 컴포넌트는 원본 위치 그대로 사용 - const widthOffset = 0; + // 화면 관리에서 설정한 해상도를 사용하므로 widthOffset 계산 불필요 + // 모든 컴포넌트는 원본 위치 그대로 사용 + const widthOffset = 0; - const buttonGroups: Record = {}; - const processedButtonIds = new Set(); - // 🔍 전체 버튼 목록 확인 - const allButtons = topLevelComponents.filter((component) => { - const isButton = - (component.type === "component" && - ["button-primary", "button-secondary"].includes((component as any).componentType)) || - (component.type === "widget" && (component as any).widgetType === "button"); - return isButton; - }); + const buttonGroups: Record = {}; + const processedButtonIds = new Set(); + // 🔍 전체 버튼 목록 확인 + const allButtons = topLevelComponents.filter((component) => { + const isButton = + (component.type === "component" && + ["button-primary", "button-secondary"].includes((component as any).componentType)) || + (component.type === "widget" && (component as any).widgetType === "button"); + return isButton; + }); - console.log( - "🔍 메뉴에서 발견된 전체 버튼:", - allButtons.map((b) => ({ - id: b.id, - label: b.label, - positionX: b.position.x, - positionY: b.position.y, - width: b.size?.width, - height: b.size?.height, - })), - ); + console.log( + "🔍 메뉴에서 발견된 전체 버튼:", + allButtons.map((b) => ({ + id: b.id, + label: b.label, + positionX: b.position.x, + positionY: b.position.y, + width: b.size?.width, + height: b.size?.height, + })), + ); - topLevelComponents.forEach((component) => { - const isButton = - (component.type === "component" && - ["button-primary", "button-secondary"].includes((component as any).componentType)) || - (component.type === "widget" && (component as any).widgetType === "button"); + topLevelComponents.forEach((component) => { + const isButton = + (component.type === "component" && + ["button-primary", "button-secondary"].includes((component as any).componentType)) || + (component.type === "widget" && (component as any).widgetType === "button"); - if (isButton) { - const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig as - | FlowVisibilityConfig - | undefined; + if (isButton) { + const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig as + | FlowVisibilityConfig + | undefined; - // 🔧 임시: 버튼 그룹 기능 완전 비활성화 - // TODO: 사용자가 명시적으로 그룹을 원하는 경우에만 활성화하도록 UI 개선 필요 - const DISABLE_BUTTON_GROUPS = false; + // 🔧 임시: 버튼 그룹 기능 완전 비활성화 + // TODO: 사용자가 명시적으로 그룹을 원하는 경우에만 활성화하도록 UI 개선 필요 + const DISABLE_BUTTON_GROUPS = false; - if ( - !DISABLE_BUTTON_GROUPS && - flowConfig?.enabled && - flowConfig.layoutBehavior === "auto-compact" && - flowConfig.groupId - ) { - if (!buttonGroups[flowConfig.groupId]) { - buttonGroups[flowConfig.groupId] = []; + if ( + !DISABLE_BUTTON_GROUPS && + flowConfig?.enabled && + flowConfig.layoutBehavior === "auto-compact" && + flowConfig.groupId + ) { + if (!buttonGroups[flowConfig.groupId]) { + buttonGroups[flowConfig.groupId] = []; + } + buttonGroups[flowConfig.groupId].push(component); + processedButtonIds.add(component.id); } - buttonGroups[flowConfig.groupId].push(component); - processedButtonIds.add(component.id); + // else: 모든 버튼을 개별 렌더링 } - // else: 모든 버튼을 개별 렌더링 - } - }); + }); - const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id)); + const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id)); - // TableSearchWidget들을 먼저 찾기 - const tableSearchWidgets = regularComponents.filter( - (c) => (c as any).componentId === "table-search-widget" - ); + // TableSearchWidget들을 먼저 찾기 + const tableSearchWidgets = regularComponents.filter( + (c) => (c as any).componentId === "table-search-widget", + ); - // 디버그: 모든 컴포넌트 타입 확인 - console.log("🔍 전체 컴포넌트 타입:", regularComponents.map(c => ({ - id: c.id, - type: c.type, - componentType: (c as any).componentType, - componentId: (c as any).componentId, - }))); - - // 🆕 조건부 컨테이너들을 찾기 - const conditionalContainers = regularComponents.filter( - (c) => (c as any).componentId === "conditional-container" || (c as any).componentType === "conditional-container" - ); - - console.log("🔍 조건부 컨테이너 발견:", conditionalContainers.map(c => ({ - id: c.id, - y: c.position.y, - size: c.size, - }))); + // 디버그: 모든 컴포넌트 타입 확인 + console.log( + "🔍 전체 컴포넌트 타입:", + regularComponents.map((c) => ({ + id: c.id, + type: c.type, + componentType: (c as any).componentType, + componentId: (c as any).componentId, + })), + ); - // TableSearchWidget 및 조건부 컨테이너 높이 차이를 계산하여 Y 위치 조정 - const adjustedComponents = regularComponents.map((component) => { - const isTableSearchWidget = (component as any).componentId === "table-search-widget"; - const isConditionalContainer = (component as any).componentId === "conditional-container"; - - if (isTableSearchWidget || isConditionalContainer) { - // 자기 자신은 조정하지 않음 - return component; - } - - let totalHeightAdjustment = 0; - - // TableSearchWidget 높이 조정 - for (const widget of tableSearchWidgets) { - const isBelow = component.position.y > widget.position.y; - const heightDiff = getHeightDiff(screenId, widget.id); - - if (isBelow && heightDiff > 0) { - totalHeightAdjustment += heightDiff; + // 🆕 조건부 컨테이너들을 찾기 + const conditionalContainers = regularComponents.filter( + (c) => + (c as any).componentId === "conditional-container" || + (c as any).componentType === "conditional-container", + ); + + console.log( + "🔍 조건부 컨테이너 발견:", + conditionalContainers.map((c) => ({ + id: c.id, + y: c.position.y, + size: c.size, + })), + ); + + // TableSearchWidget 및 조건부 컨테이너 높이 차이를 계산하여 Y 위치 조정 + const adjustedComponents = regularComponents.map((component) => { + const isTableSearchWidget = (component as any).componentId === "table-search-widget"; + const isConditionalContainer = (component as any).componentId === "conditional-container"; + + if (isTableSearchWidget || isConditionalContainer) { + // 자기 자신은 조정하지 않음 + return component; } - } - - // 🆕 조건부 컨테이너 높이 조정 - for (const container of conditionalContainers) { - const isBelow = component.position.y > container.position.y; - const actualHeight = conditionalContainerHeights[container.id]; - const originalHeight = container.size?.height || 200; - const heightDiff = actualHeight ? (actualHeight - originalHeight) : 0; - - console.log(`🔍 높이 조정 체크:`, { - componentId: component.id, - componentY: component.position.y, - containerY: container.position.y, - isBelow, - actualHeight, - originalHeight, - heightDiff, - containerId: container.id, - containerSize: container.size, - }); - - if (isBelow && heightDiff > 0) { - totalHeightAdjustment += heightDiff; - console.log(`📐 컴포넌트 ${component.id} 위치 조정: ${heightDiff}px (조건부 컨테이너 ${container.id})`); + + let totalHeightAdjustment = 0; + + // TableSearchWidget 높이 조정 + for (const widget of tableSearchWidgets) { + const isBelow = component.position.y > widget.position.y; + const heightDiff = getHeightDiff(screenId, widget.id); + + if (isBelow && heightDiff > 0) { + totalHeightAdjustment += heightDiff; + } } - } - - if (totalHeightAdjustment > 0) { - return { - ...component, - position: { - ...component.position, - y: component.position.y + totalHeightAdjustment, - }, - }; - } - - return component; - }); - return ( - <> - {/* 일반 컴포넌트들 */} - {adjustedComponents.map((component) => { - // 화면 관리 해상도를 사용하므로 위치 조정 불필요 - return ( - {}} - menuObjid={menuObjid} - screenId={screenId} - tableName={screen?.tableName} - userId={user?.userId} - userName={userName} - companyCode={companyCode} - menuObjid={menuObjid} - selectedRowsData={selectedRowsData} - sortBy={tableSortBy} - sortOrder={tableSortOrder} - columnOrder={tableColumnOrder} - tableDisplayData={tableDisplayData} - onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder, tableDisplayData) => { - console.log("🔍 화면에서 선택된 행 데이터:", selectedData); - console.log("📊 정렬 정보:", { sortBy, sortOrder, columnOrder }); - console.log("📊 화면 표시 데이터:", { count: tableDisplayData?.length, firstRow: tableDisplayData?.[0] }); - setSelectedRowsData(selectedData); - setTableSortBy(sortBy); - setTableSortOrder(sortOrder || "asc"); - setTableColumnOrder(columnOrder); - setTableDisplayData(tableDisplayData || []); - }} - flowSelectedData={flowSelectedData} - flowSelectedStepId={flowSelectedStepId} - onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => { - setFlowSelectedData(selectedData); - setFlowSelectedStepId(stepId); - }} - refreshKey={tableRefreshKey} - onRefresh={() => { - setTableRefreshKey((prev) => prev + 1); - setSelectedRowsData([]); // 선택 해제 - }} - flowRefreshKey={flowRefreshKey} - onFlowRefresh={() => { - setFlowRefreshKey((prev) => prev + 1); - setFlowSelectedData([]); // 선택 해제 - setFlowSelectedStepId(null); - }} - formData={formData} - onFormDataChange={(fieldName, value) => { - setFormData((prev) => ({ ...prev, [fieldName]: value })); - }} - onHeightChange={(componentId, newHeight) => { - setConditionalContainerHeights((prev) => ({ - ...prev, - [componentId]: newHeight, - })); - }} - > - {/* 자식 컴포넌트들 */} - {(component.type === "group" || component.type === "container" || component.type === "area") && - layout.components - .filter((child) => child.parentId === component.id) - .map((child) => { - // 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정 - const relativeChildComponent = { - ...child, - position: { - x: child.position.x - component.position.x, - y: child.position.y - component.position.y, - z: child.position.z || 1, - }, - }; + // 🆕 조건부 컨테이너 높이 조정 + for (const container of conditionalContainers) { + const isBelow = component.position.y > container.position.y; + const actualHeight = conditionalContainerHeights[container.id]; + const originalHeight = container.size?.height || 200; + const heightDiff = actualHeight ? actualHeight - originalHeight : 0; - return ( - {}} - menuObjid={menuObjid} - screenId={screenId} - tableName={screen?.tableName} - userId={user?.userId} - userName={userName} - companyCode={companyCode} - menuObjid={menuObjid} - selectedRowsData={selectedRowsData} - sortBy={tableSortBy} - sortOrder={tableSortOrder} - columnOrder={tableColumnOrder} - tableDisplayData={tableDisplayData} - onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder, tableDisplayData) => { - console.log("🔍 화면에서 선택된 행 데이터 (자식):", selectedData); - console.log("📊 정렬 정보 (자식):", { sortBy, sortOrder, columnOrder }); - console.log("📊 화면 표시 데이터 (자식):", { count: tableDisplayData?.length, firstRow: tableDisplayData?.[0] }); - setSelectedRowsData(selectedData); - setTableSortBy(sortBy); - setTableSortOrder(sortOrder || "asc"); - setTableColumnOrder(columnOrder); - setTableDisplayData(tableDisplayData || []); - }} - refreshKey={tableRefreshKey} - onRefresh={() => { - console.log("🔄 테이블 새로고침 요청됨 (자식)"); - setTableRefreshKey((prev) => prev + 1); - setSelectedRowsData([]); // 선택 해제 - }} - formData={formData} - onFormDataChange={(fieldName, value) => { - setFormData((prev) => ({ ...prev, [fieldName]: value })); - }} - /> - ); - })} - - ); - })} - - {/* 🆕 플로우 버튼 그룹들 */} - {Object.entries(buttonGroups).map(([groupId, buttons]) => { - if (buttons.length === 0) return null; - - const firstButton = buttons[0]; - const groupConfig = (firstButton as any).webTypeConfig - ?.flowVisibilityConfig as FlowVisibilityConfig; - - // 🔍 버튼 그룹 설정 확인 - console.log("🔍 버튼 그룹 설정:", { - groupId, - buttonCount: buttons.length, - buttons: buttons.map((b) => ({ - id: b.id, - label: b.label, - x: b.position.x, - y: b.position.y, - })), - groupConfig: { - layoutBehavior: groupConfig.layoutBehavior, - groupDirection: groupConfig.groupDirection, - groupAlign: groupConfig.groupAlign, - groupGap: groupConfig.groupGap, - }, + console.log(`🔍 높이 조정 체크:`, { + componentId: component.id, + componentY: component.position.y, + containerY: container.position.y, + isBelow, + actualHeight, + originalHeight, + heightDiff, + containerId: container.id, + containerSize: container.size, }); - // 🔧 수정: 그룹 컨테이너는 첫 번째 버튼 위치를 기준으로 하되, - // 각 버튼의 상대 위치는 원래 위치를 유지 - const firstButtonPosition = { - x: buttons[0].position.x, - y: buttons[0].position.y, - z: buttons[0].position.z || 2, - }; - - // 버튼 그룹 위치에도 widthOffset 적용 - const adjustedGroupPosition = { - ...firstButtonPosition, - x: firstButtonPosition.x + widthOffset, - }; - - // 그룹의 크기 계산: 버튼들의 실제 크기 + 간격을 기준으로 계산 - const direction = groupConfig.groupDirection || "horizontal"; - const gap = groupConfig.groupGap ?? 8; - - let groupWidth = 0; - let groupHeight = 0; - - if (direction === "horizontal") { - groupWidth = buttons.reduce((total, button, index) => { - const buttonWidth = button.size?.width || 100; - const gapWidth = index < buttons.length - 1 ? gap : 0; - return total + buttonWidth + gapWidth; - }, 0); - groupHeight = Math.max(...buttons.map((b) => b.size?.height || 40)); - } else { - groupWidth = Math.max(...buttons.map((b) => b.size?.width || 100)); - groupHeight = buttons.reduce((total, button, index) => { - const buttonHeight = button.size?.height || 40; - const gapHeight = index < buttons.length - 1 ? gap : 0; - return total + buttonHeight + gapHeight; - }, 0); + if (isBelow && heightDiff > 0) { + totalHeightAdjustment += heightDiff; + console.log( + `📐 컴포넌트 ${component.id} 위치 조정: ${heightDiff}px (조건부 컨테이너 ${container.id})`, + ); } + } - return ( -
- 0) { + return { + ...component, + position: { + ...component.position, + y: component.position.y + totalHeightAdjustment, + }, + }; + } + + return component; + }); + + return ( + <> + {/* 일반 컴포넌트들 */} + {adjustedComponents.map((component) => { + // 화면 관리 해상도를 사용하므로 위치 조정 불필요 + return ( + { - // 🔧 각 버튼의 상대 위치 = 버튼의 원래 위치 - 첫 번째 버튼 위치 - const relativeButton = { - ...button, - position: { - x: button.position.x - firstButtonPosition.x, - y: button.position.y - firstButtonPosition.y, - z: button.position.z || 1, - }, - }; + onClick={() => {}} + menuObjid={menuObjid} + screenId={screenId} + tableName={screen?.tableName} + userId={user?.userId} + userName={userName} + companyCode={companyCode} + menuObjid={menuObjid} + selectedRowsData={selectedRowsData} + sortBy={tableSortBy} + sortOrder={tableSortOrder} + columnOrder={tableColumnOrder} + tableDisplayData={tableDisplayData} + onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder, tableDisplayData) => { + console.log("🔍 화면에서 선택된 행 데이터:", selectedData); + console.log("📊 정렬 정보:", { sortBy, sortOrder, columnOrder }); + console.log("📊 화면 표시 데이터:", { + count: tableDisplayData?.length, + firstRow: tableDisplayData?.[0], + }); + setSelectedRowsData(selectedData); + setTableSortBy(sortBy); + setTableSortOrder(sortOrder || "asc"); + setTableColumnOrder(columnOrder); + setTableDisplayData(tableDisplayData || []); + }} + flowSelectedData={flowSelectedData} + flowSelectedStepId={flowSelectedStepId} + onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => { + setFlowSelectedData(selectedData); + setFlowSelectedStepId(stepId); + }} + refreshKey={tableRefreshKey} + onRefresh={() => { + setTableRefreshKey((prev) => prev + 1); + setSelectedRowsData([]); // 선택 해제 + }} + flowRefreshKey={flowRefreshKey} + onFlowRefresh={() => { + setFlowRefreshKey((prev) => prev + 1); + setFlowSelectedData([]); // 선택 해제 + setFlowSelectedStepId(null); + }} + formData={formData} + onFormDataChange={(fieldName, value) => { + setFormData((prev) => ({ ...prev, [fieldName]: value })); + }} + onHeightChange={(componentId, newHeight) => { + setConditionalContainerHeights((prev) => ({ + ...prev, + [componentId]: newHeight, + })); + }} + > + {/* 자식 컴포넌트들 */} + {(component.type === "group" || + component.type === "container" || + component.type === "area") && + layout.components + .filter((child) => child.parentId === component.id) + .map((child) => { + // 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정 + const relativeChildComponent = { + ...child, + position: { + x: child.position.x - component.position.x, + y: child.position.y - component.position.y, + z: child.position.z || 1, + }, + }; - return ( -
-
- {}} + onClick={() => {}} + menuObjid={menuObjid} screenId={screenId} tableName={screen?.tableName} userId={user?.userId} userName={userName} companyCode={companyCode} - tableDisplayData={tableDisplayData} + menuObjid={menuObjid} selectedRowsData={selectedRowsData} sortBy={tableSortBy} sortOrder={tableSortOrder} columnOrder={tableColumnOrder} - onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder) => { + tableDisplayData={tableDisplayData} + onSelectedRowsChange={( + _, + selectedData, + sortBy, + sortOrder, + columnOrder, + tableDisplayData, + ) => { + console.log("🔍 화면에서 선택된 행 데이터 (자식):", selectedData); + console.log("📊 정렬 정보 (자식):", { sortBy, sortOrder, columnOrder }); + console.log("📊 화면 표시 데이터 (자식):", { + count: tableDisplayData?.length, + firstRow: tableDisplayData?.[0], + }); setSelectedRowsData(selectedData); setTableSortBy(sortBy); setTableSortOrder(sortOrder || "asc"); setTableColumnOrder(columnOrder); - }} - flowSelectedData={flowSelectedData} - flowSelectedStepId={flowSelectedStepId} - onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => { - setFlowSelectedData(selectedData); - setFlowSelectedStepId(stepId); + setTableDisplayData(tableDisplayData || []); }} refreshKey={tableRefreshKey} onRefresh={() => { + console.log("🔄 테이블 새로고침 요청됨 (자식)"); setTableRefreshKey((prev) => prev + 1); - setSelectedRowsData([]); - }} - flowRefreshKey={flowRefreshKey} - onFlowRefresh={() => { - setFlowRefreshKey((prev) => prev + 1); - setFlowSelectedData([]); - setFlowSelectedStepId(null); + setSelectedRowsData([]); // 선택 해제 }} + formData={formData} onFormDataChange={(fieldName, value) => { setFormData((prev) => ({ ...prev, [fieldName]: value })); }} /> -
-
- ); - }} - /> -
- ); - })} - - ); - })()} -
- ) : ( - // 빈 화면일 때 -
-
-
- 📄 -
-

화면이 비어있습니다

-

이 화면에는 아직 설계된 컴포넌트가 없습니다.

-
-
- )} + ); + })} + + ); + })} - {/* 편집 모달 */} - { - setEditModalOpen(false); - setEditModalConfig({}); - }} - screenId={editModalConfig.screenId} - modalSize={editModalConfig.modalSize} - editData={editModalConfig.editData} - onSave={editModalConfig.onSave} - modalTitle={editModalConfig.modalTitle} - modalDescription={editModalConfig.modalDescription} - onDataChange={(changedFormData) => { - console.log("📝 EditModal에서 데이터 변경 수신:", changedFormData); - // 변경된 데이터를 메인 폼에 반영 - setFormData((prev) => { - const updatedFormData = { - ...prev, - ...changedFormData, // 변경된 필드들만 업데이트 - }; - console.log("📊 메인 폼 데이터 업데이트:", updatedFormData); - return updatedFormData; - }); - }} - /> + {/* 🆕 플로우 버튼 그룹들 */} + {Object.entries(buttonGroups).map(([groupId, buttons]) => { + if (buttons.length === 0) return null; + + const firstButton = buttons[0]; + const groupConfig = (firstButton as any).webTypeConfig + ?.flowVisibilityConfig as FlowVisibilityConfig; + + // 🔍 버튼 그룹 설정 확인 + console.log("🔍 버튼 그룹 설정:", { + groupId, + buttonCount: buttons.length, + buttons: buttons.map((b) => ({ + id: b.id, + label: b.label, + x: b.position.x, + y: b.position.y, + })), + groupConfig: { + layoutBehavior: groupConfig.layoutBehavior, + groupDirection: groupConfig.groupDirection, + groupAlign: groupConfig.groupAlign, + groupGap: groupConfig.groupGap, + }, + }); + + // 🔧 수정: 그룹 컨테이너는 첫 번째 버튼 위치를 기준으로 하되, + // 각 버튼의 상대 위치는 원래 위치를 유지 + const firstButtonPosition = { + x: buttons[0].position.x, + y: buttons[0].position.y, + z: buttons[0].position.z || 2, + }; + + // 버튼 그룹 위치에도 widthOffset 적용 + const adjustedGroupPosition = { + ...firstButtonPosition, + x: firstButtonPosition.x + widthOffset, + }; + + // 그룹의 크기 계산: 버튼들의 실제 크기 + 간격을 기준으로 계산 + const direction = groupConfig.groupDirection || "horizontal"; + const gap = groupConfig.groupGap ?? 8; + + let groupWidth = 0; + let groupHeight = 0; + + if (direction === "horizontal") { + groupWidth = buttons.reduce((total, button, index) => { + const buttonWidth = button.size?.width || 100; + const gapWidth = index < buttons.length - 1 ? gap : 0; + return total + buttonWidth + gapWidth; + }, 0); + groupHeight = Math.max(...buttons.map((b) => b.size?.height || 40)); + } else { + groupWidth = Math.max(...buttons.map((b) => b.size?.width || 100)); + groupHeight = buttons.reduce((total, button, index) => { + const buttonHeight = button.size?.height || 40; + const gapHeight = index < buttons.length - 1 ? gap : 0; + return total + buttonHeight + gapHeight; + }, 0); + } + + return ( +
+ { + // 🔧 각 버튼의 상대 위치 = 버튼의 원래 위치 - 첫 번째 버튼 위치 + const relativeButton = { + ...button, + position: { + x: button.position.x - firstButtonPosition.x, + y: button.position.y - firstButtonPosition.y, + z: button.position.z || 1, + }, + }; + + return ( +
+
+ {}} + screenId={screenId} + tableName={screen?.tableName} + userId={user?.userId} + userName={userName} + companyCode={companyCode} + tableDisplayData={tableDisplayData} + selectedRowsData={selectedRowsData} + sortBy={tableSortBy} + sortOrder={tableSortOrder} + columnOrder={tableColumnOrder} + onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder) => { + setSelectedRowsData(selectedData); + setTableSortBy(sortBy); + setTableSortOrder(sortOrder || "asc"); + setTableColumnOrder(columnOrder); + }} + flowSelectedData={flowSelectedData} + flowSelectedStepId={flowSelectedStepId} + onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => { + setFlowSelectedData(selectedData); + setFlowSelectedStepId(stepId); + }} + refreshKey={tableRefreshKey} + onRefresh={() => { + setTableRefreshKey((prev) => prev + 1); + setSelectedRowsData([]); + }} + flowRefreshKey={flowRefreshKey} + onFlowRefresh={() => { + setFlowRefreshKey((prev) => prev + 1); + setFlowSelectedData([]); + setFlowSelectedStepId(null); + }} + onFormDataChange={(fieldName, value) => { + setFormData((prev) => ({ ...prev, [fieldName]: value })); + }} + /> +
+
+ ); + }} + /> +
+ ); + })} + + ); + })()} +
+ ) : ( + // 빈 화면일 때 +
+
+
+ 📄 +
+

화면이 비어있습니다

+

이 화면에는 아직 설계된 컴포넌트가 없습니다.

+
+
+ )} + + {/* 편집 모달 */} + { + setEditModalOpen(false); + setEditModalConfig({}); + }} + screenId={editModalConfig.screenId} + modalSize={editModalConfig.modalSize} + editData={editModalConfig.editData} + onSave={editModalConfig.onSave} + modalTitle={editModalConfig.modalTitle} + modalDescription={editModalConfig.modalDescription} + onDataChange={(changedFormData) => { + console.log("📝 EditModal에서 데이터 변경 수신:", changedFormData); + // 변경된 데이터를 메인 폼에 반영 + setFormData((prev) => { + const updatedFormData = { + ...prev, + ...changedFormData, // 변경된 필드들만 업데이트 + }; + console.log("📊 메인 폼 데이터 업데이트:", updatedFormData); + return updatedFormData; + }); + }} + />
diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index e87dc73d..8394cd6d 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -470,7 +470,7 @@ function AppLayoutInner({ children }: AppLayoutProps) { {/* 가운데 컨텐츠 영역 - 스크롤 가능 */}
-
{children}
+ {children}
diff --git a/frontend/components/screen/ResponsiveDesignerContainer.tsx b/frontend/components/screen/ResponsiveDesignerContainer.tsx index 57f55b14..392e59ce 100644 --- a/frontend/components/screen/ResponsiveDesignerContainer.tsx +++ b/frontend/components/screen/ResponsiveDesignerContainer.tsx @@ -35,9 +35,9 @@ export const ResponsiveDesignerContainer: React.FC Date: Mon, 24 Nov 2025 15:24:31 +0900 Subject: [PATCH 08/29] =?UTF-8?q?feat:=20=EC=88=98=EC=A3=BC=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EA=B7=B8=EB=A3=B9=20=ED=8E=B8=EC=A7=91=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 같은 수주번호(order_no)를 가진 품목 일괄 수정 기능 추가 - groupByColumns 개념 도입 및 EditModal 그룹 데이터 처리 로직 구현 - ConditionalSectionViewer에서 DynamicComponentRenderer 사용으로 groupedData 전달 경로 확보 - ModalRepeaterTable onChange 에러 수정 및 sourceColumns 필터링 추가 - 조인된 컬럼 제외 로직 추가로 DB 저장 오류 해결 --- frontend/components/screen/EditModal.tsx | 229 +++++++++++++++++- .../screen/InteractiveScreenViewerDynamic.tsx | 5 + .../lib/registry/DynamicComponentRenderer.tsx | 16 +- .../ConditionalContainerComponent.tsx | 3 + .../ConditionalSectionViewer.tsx | 48 ++-- .../components/conditional-container/types.ts | 2 + .../ModalRepeaterTableComponent.tsx | 41 +++- frontend/lib/utils/buttonActions.ts | 53 ++-- 8 files changed, 340 insertions(+), 57 deletions(-) diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 0e7cc4ec..776eea83 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -24,6 +24,8 @@ interface EditModalState { modalSize: "sm" | "md" | "lg" | "xl"; editData: Record; onSave?: () => void; + groupByColumns?: string[]; // 🆕 그룹핑 컬럼 (예: ["order_no"]) + tableName?: string; // 🆕 테이블명 (그룹 조회용) } interface EditModalProps { @@ -40,6 +42,8 @@ export const EditModal: React.FC = ({ className }) => { modalSize: "md", editData: {}, onSave: undefined, + groupByColumns: undefined, + tableName: undefined, }); const [screenData, setScreenData] = useState<{ @@ -58,6 +62,10 @@ export const EditModal: React.FC = ({ className }) => { // 폼 데이터 상태 (편집 데이터로 초기화됨) const [formData, setFormData] = useState>({}); const [originalData, setOriginalData] = useState>({}); + + // 🆕 그룹 데이터 상태 (같은 order_no의 모든 품목) + const [groupData, setGroupData] = useState[]>([]); + const [originalGroupData, setOriginalGroupData] = useState[]>([]); // 화면의 실제 크기 계산 함수 (ScreenModal과 동일) const calculateScreenDimensions = (components: ComponentData[]) => { @@ -110,7 +118,7 @@ export const EditModal: React.FC = ({ className }) => { // 전역 모달 이벤트 리스너 useEffect(() => { const handleOpenEditModal = (event: CustomEvent) => { - const { screenId, title, description, modalSize, editData, onSave } = event.detail; + const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName } = event.detail; setModalState({ isOpen: true, @@ -120,6 +128,8 @@ export const EditModal: React.FC = ({ className }) => { modalSize: modalSize || "lg", editData: editData || {}, onSave, + groupByColumns, // 🆕 그룹핑 컬럼 + tableName, // 🆕 테이블명 }); // 편집 데이터로 폼 데이터 초기화 @@ -154,9 +164,78 @@ export const EditModal: React.FC = ({ className }) => { useEffect(() => { if (modalState.isOpen && modalState.screenId) { loadScreenData(modalState.screenId); + + // 🆕 그룹 데이터 조회 (groupByColumns가 있는 경우) + if (modalState.groupByColumns && modalState.groupByColumns.length > 0 && modalState.tableName) { + loadGroupData(); + } } }, [modalState.isOpen, modalState.screenId]); + // 🆕 그룹 데이터 조회 함수 + const loadGroupData = async () => { + if (!modalState.tableName || !modalState.groupByColumns || modalState.groupByColumns.length === 0) { + console.warn("테이블명 또는 그룹핑 컬럼이 없습니다."); + return; + } + + try { + console.log("🔍 그룹 데이터 조회 시작:", { + tableName: modalState.tableName, + groupByColumns: modalState.groupByColumns, + editData: modalState.editData, + }); + + // 그룹핑 컬럼 값 추출 (예: order_no = "ORD-20251124-001") + const groupValues: Record = {}; + modalState.groupByColumns.forEach((column) => { + if (modalState.editData[column]) { + groupValues[column] = modalState.editData[column]; + } + }); + + if (Object.keys(groupValues).length === 0) { + console.warn("그룹핑 컬럼 값이 없습니다:", modalState.groupByColumns); + return; + } + + console.log("🔍 그룹 조회 요청:", { + tableName: modalState.tableName, + groupValues, + }); + + // 같은 그룹의 모든 레코드 조회 (entityJoinApi 사용) + const { entityJoinApi } = await import("@/lib/api/entityJoin"); + const response = await entityJoinApi.getTableDataWithJoins(modalState.tableName, { + page: 1, + size: 1000, + search: groupValues, // search 파라미터로 전달 (백엔드에서 WHERE 조건으로 처리) + enableEntityJoin: true, + }); + + console.log("🔍 그룹 조회 응답:", response); + + // entityJoinApi는 배열 또는 { data: [] } 형식으로 반환 + const dataArray = Array.isArray(response) ? response : response?.data || []; + + if (dataArray.length > 0) { + console.log("✅ 그룹 데이터 조회 성공:", dataArray); + setGroupData(dataArray); + setOriginalGroupData(JSON.parse(JSON.stringify(dataArray))); // Deep copy + toast.info(`${dataArray.length}개의 관련 품목을 불러왔습니다.`); + } else { + console.warn("그룹 데이터가 없습니다:", response); + setGroupData([modalState.editData]); // 기본값: 선택된 행만 + setOriginalGroupData([JSON.parse(JSON.stringify(modalState.editData))]); + } + } catch (error: any) { + console.error("❌ 그룹 데이터 조회 오류:", error); + toast.error("관련 데이터를 불러오는 중 오류가 발생했습니다."); + setGroupData([modalState.editData]); // 기본값: 선택된 행만 + setOriginalGroupData([JSON.parse(JSON.stringify(modalState.editData))]); + } + }; + const loadScreenData = async (screenId: number) => { try { setLoading(true); @@ -208,10 +287,14 @@ export const EditModal: React.FC = ({ className }) => { modalSize: "md", editData: {}, onSave: undefined, + groupByColumns: undefined, + tableName: undefined, }); setScreenData(null); setFormData({}); setOriginalData({}); + setGroupData([]); // 🆕 + setOriginalGroupData([]); // 🆕 }; // 저장 버튼 클릭 시 - UPDATE 액션 실행 @@ -222,7 +305,104 @@ export const EditModal: React.FC = ({ className }) => { } try { - // 변경된 필드만 추출 + // 🆕 그룹 데이터가 있는 경우: 모든 품목 일괄 수정 + if (groupData.length > 0) { + console.log("🔄 그룹 데이터 일괄 수정 시작:", { + groupDataLength: groupData.length, + originalGroupDataLength: originalGroupData.length, + }); + + let updatedCount = 0; + + for (let i = 0; i < groupData.length; i++) { + const currentData = groupData[i]; + const originalItemData = originalGroupData[i]; + + if (!originalItemData) { + console.warn(`원본 데이터가 없습니다 (index: ${i})`); + continue; + } + + // 변경된 필드만 추출 + const changedData: Record = {}; + + // 🆕 sales_order_mng 테이블의 실제 컬럼만 포함 (조인된 컬럼 제외) + const salesOrderColumns = [ + "id", + "order_no", + "customer_code", + "customer_name", + "order_date", + "delivery_date", + "item_code", + "quantity", + "unit_price", + "amount", + "status", + "notes", + "created_at", + "updated_at", + "company_code", + ]; + + Object.keys(currentData).forEach((key) => { + // sales_order_mng 테이블의 컬럼만 처리 (조인 컬럼 제외) + if (!salesOrderColumns.includes(key)) { + return; + } + + if (currentData[key] !== originalItemData[key]) { + changedData[key] = currentData[key]; + } + }); + + // 변경사항이 없으면 스킵 + if (Object.keys(changedData).length === 0) { + console.log(`변경사항 없음 (index: ${i})`); + continue; + } + + // 기본키 확인 + const recordId = originalItemData.id || Object.values(originalItemData)[0]; + + // UPDATE 실행 + const response = await dynamicFormApi.updateFormDataPartial( + recordId, + originalItemData, + changedData, + screenData.screenInfo.tableName, + ); + + if (response.success) { + updatedCount++; + console.log(`✅ 품목 ${i + 1} 수정 성공 (id: ${recordId})`); + } else { + console.error(`❌ 품목 ${i + 1} 수정 실패 (id: ${recordId}):`, response.message); + } + } + + if (updatedCount > 0) { + toast.success(`${updatedCount}개의 품목이 수정되었습니다.`); + + // 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침) + if (modalState.onSave) { + try { + modalState.onSave(); + } catch (callbackError) { + console.error("⚠️ onSave 콜백 에러:", callbackError); + } + } + + handleClose(); + } else { + toast.info("변경된 내용이 없습니다."); + handleClose(); + } + + return; + } + + // 기존 로직: 단일 레코드 수정 const changedData: Record = {}; Object.keys(formData).forEach((key) => { if (formData[key] !== originalData[key]) { @@ -339,6 +519,13 @@ export const EditModal: React.FC = ({ className }) => { maxHeight: "100%", }} > + {/* 🆕 그룹 데이터가 있으면 안내 메시지 표시 */} + {groupData.length > 1 && ( +
+ {groupData.length}개의 관련 품목을 함께 수정합니다 +
+ )} + {screenData.components.map((component) => { // 컴포넌트 위치를 offset만큼 조정 const offsetX = screenDimensions?.offsetX || 0; @@ -353,23 +540,51 @@ export const EditModal: React.FC = ({ className }) => { }, }; + // 🔍 디버깅: 컴포넌트 렌더링 시점의 groupData 확인 + if (component.id === screenData.components[0]?.id) { + console.log("🔍 [EditModal] InteractiveScreenViewerDynamic props:", { + componentId: component.id, + groupDataLength: groupData.length, + groupData: groupData, + formData: groupData.length > 0 ? groupData[0] : formData, + }); + } + return ( 0 ? groupData[0] : formData} onFormDataChange={(fieldName, value) => { - setFormData((prev) => ({ - ...prev, - [fieldName]: value, - })); + // 🆕 그룹 데이터가 있으면 처리 + if (groupData.length > 0) { + // ModalRepeaterTable의 경우 배열 전체를 받음 + if (Array.isArray(value)) { + setGroupData(value); + } else { + // 일반 필드는 모든 항목에 동일하게 적용 + setGroupData((prev) => + prev.map((item) => ({ + ...item, + [fieldName]: value, + })) + ); + } + } else { + setFormData((prev) => ({ + ...prev, + [fieldName]: value, + })); + } }} screenInfo={{ id: modalState.screenId!, tableName: screenData.screenInfo?.tableName, }} onSave={handleSave} + // 🆕 그룹 데이터를 ModalRepeaterTable에 전달 + groupedData={groupData.length > 0 ? groupData : undefined} /> ); })} diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index df134685..d1cd2a5f 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -46,6 +46,8 @@ interface InteractiveScreenViewerProps { userId?: string; userName?: string; companyCode?: string; + // 🆕 그룹 데이터 (EditModal에서 전달) + groupedData?: Record[]; } export const InteractiveScreenViewerDynamic: React.FC = ({ @@ -61,6 +63,7 @@ export const InteractiveScreenViewerDynamic: React.FC { const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 const { userName: authUserName, user: authUser } = useAuth(); @@ -332,6 +335,8 @@ export const InteractiveScreenViewerDynamic: React.FC { diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 3792518e..92fd89e8 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -107,6 +107,8 @@ export interface DynamicComponentRendererProps { onClose?: () => void; // 테이블 선택된 행 정보 (다중 선택 액션용) selectedRows?: any[]; + // 🆕 그룹 데이터 (EditModal → ModalRepeaterTable) + groupedData?: Record[]; selectedRowsData?: any[]; onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[], tableDisplayData?: any[]) => void; // 테이블 정렬 정보 (엑셀 다운로드용) @@ -279,7 +281,17 @@ export const DynamicComponentRenderer: React.FC = // modal-repeater-table은 배열 데이터를 다루므로 빈 배열로 초기화 let currentValue; if (componentType === "modal-repeater-table") { - currentValue = formData?.[fieldName] || []; + // 🆕 EditModal에서 전달된 groupedData가 있으면 우선 사용 + currentValue = props.groupedData || formData?.[fieldName] || []; + + // 디버깅 로그 + console.log("🔍 [DynamicComponentRenderer] ModalRepeaterTable value 설정:", { + hasGroupedData: !!props.groupedData, + groupedDataLength: props.groupedData?.length || 0, + fieldName, + formDataValue: formData?.[fieldName], + finalValueLength: Array.isArray(currentValue) ? currentValue.length : 0, + }); } else { currentValue = formData?.[fieldName] || ""; } @@ -380,6 +392,8 @@ export const DynamicComponentRenderer: React.FC = isPreview, // 디자인 모드 플래그 전달 - isPreview와 명확히 구분 isDesignMode: props.isDesignMode !== undefined ? props.isDesignMode : false, + // 🆕 그룹 데이터 전달 (EditModal → ConditionalContainer → ModalRepeaterTable) + groupedData: props.groupedData, }; // 렌더러가 클래스인지 함수인지 확인 diff --git a/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx b/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx index d1aff6de..2589026f 100644 --- a/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx +++ b/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx @@ -40,6 +40,7 @@ export function ConditionalContainerComponent({ componentId, style, className, + groupedData, // 🆕 그룹 데이터 }: ConditionalContainerProps) { console.log("🎯 ConditionalContainerComponent 렌더링!", { isDesignMode, @@ -177,6 +178,7 @@ export function ConditionalContainerComponent({ showBorder={showBorder} formData={formData} onFormDataChange={onFormDataChange} + groupedData={groupedData} /> ))}
@@ -196,6 +198,7 @@ export function ConditionalContainerComponent({ showBorder={showBorder} formData={formData} onFormDataChange={onFormDataChange} + groupedData={groupedData} /> ) : null ) diff --git a/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx b/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx index c660a0a8..d402f4df 100644 --- a/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx +++ b/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx @@ -3,6 +3,7 @@ import React, { useState, useEffect } from "react"; import { ConditionalSectionViewerProps } from "./types"; import { RealtimePreview } from "@/components/screen/RealtimePreviewDynamic"; +import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; import { cn } from "@/lib/utils"; import { Loader2 } from "lucide-react"; import { screenApi } from "@/lib/api/screen"; @@ -24,6 +25,7 @@ export function ConditionalSectionViewer({ showBorder = true, formData, onFormDataChange, + groupedData, // 🆕 그룹 데이터 }: ConditionalSectionViewerProps) { const { userId, userName, user } = useAuth(); const [isLoading, setIsLoading] = useState(false); @@ -135,22 +137,36 @@ export function ConditionalSectionViewer({ minHeight: "200px", }} > - {components.map((component) => ( - {}} - screenId={screenInfo?.id} - tableName={screenInfo?.tableName} - userId={userId} - userName={userName} - companyCode={user?.companyCode} - formData={formData} - onFormDataChange={onFormDataChange} - /> - ))} + {components.map((component) => { + const { position = { x: 0, y: 0, z: 1 }, size = { width: 200, height: 40 } } = component; + + return ( +
+ +
+ ); + })}
)} diff --git a/frontend/lib/registry/components/conditional-container/types.ts b/frontend/lib/registry/components/conditional-container/types.ts index 6f1964e9..0cf741b2 100644 --- a/frontend/lib/registry/components/conditional-container/types.ts +++ b/frontend/lib/registry/components/conditional-container/types.ts @@ -45,6 +45,7 @@ export interface ConditionalContainerProps { onChange?: (value: string) => void; formData?: Record; onFormDataChange?: (fieldName: string, value: any) => void; + groupedData?: Record[]; // 🆕 그룹 데이터 (EditModal → ModalRepeaterTable) // 화면 편집기 관련 isDesignMode?: boolean; // 디자인 모드 여부 @@ -75,5 +76,6 @@ export interface ConditionalSectionViewerProps { // 폼 데이터 전달 formData?: Record; onFormDataChange?: (fieldName: string, value: any) => void; + groupedData?: Record[]; // 🆕 그룹 데이터 } diff --git a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx index d903cc9f..3941a89f 100644 --- a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx @@ -291,13 +291,47 @@ export function ModalRepeaterTableComponent({ return; } + // 🔥 sourceColumns에 포함된 컬럼 제외 (조인된 컬럼 제거) + console.log("🔍 [ModalRepeaterTable] 필터링 전 데이터:", { + sourceColumns, + sourceTable, + targetTable, + sampleItem: value[0], + itemKeys: value[0] ? Object.keys(value[0]) : [], + }); + + const filteredData = value.map((item: any) => { + const filtered: Record = {}; + + Object.keys(item).forEach((key) => { + // sourceColumns에 포함된 컬럼은 제외 (item_info 테이블의 컬럼) + if (sourceColumns.includes(key)) { + console.log(` ⛔ ${key} 제외 (sourceColumn)`); + return; + } + // 메타데이터 필드도 제외 + if (key.startsWith("_")) { + console.log(` ⛔ ${key} 제외 (메타데이터)`); + return; + } + filtered[key] = item[key]; + }); + + return filtered; + }); + + console.log("✅ [ModalRepeaterTable] 필터링 후 데이터:", { + filteredItemKeys: filteredData[0] ? Object.keys(filteredData[0]) : [], + sampleFilteredItem: filteredData[0], + }); + // 🔥 targetTable 메타데이터를 배열 항목에 추가 const dataWithTargetTable = targetTable - ? value.map(item => ({ + ? filteredData.map((item: any) => ({ ...item, _targetTable: targetTable, // 백엔드가 인식할 메타데이터 })) - : value; + : filteredData; // ✅ CustomEvent의 detail에 데이터 추가 if (event instanceof CustomEvent && event.detail) { @@ -333,9 +367,10 @@ export function ModalRepeaterTableComponent({ const calculated = calculateAll(value); // 값이 실제로 변경된 경우만 업데이트 if (JSON.stringify(calculated) !== JSON.stringify(value)) { - onChange(calculated); + handleChange(calculated); } } + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const handleAddItems = async (items: any[]) => { diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 5f825cdc..3b9b9d9e 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -41,7 +41,8 @@ export interface ButtonActionConfig { // 모달/팝업 관련 modalTitle?: string; - modalTitleBlocks?: Array<{ // 🆕 블록 기반 제목 (우선순위 높음) + modalTitleBlocks?: Array<{ + // 🆕 블록 기반 제목 (우선순위 높음) id: string; type: "text" | "field"; value: string; // type=text: 텍스트 내용, type=field: 컬럼명 @@ -88,6 +89,12 @@ export interface ButtonActionConfig { // 코드 병합 관련 mergeColumnName?: string; // 병합할 컬럼명 (예: "item_code") mergeShowPreview?: boolean; // 병합 전 미리보기 표시 여부 (기본: true) + + // 편집 관련 (수주관리 등 그룹별 다중 레코드 편집) + editMode?: "modal" | "navigate" | "inline"; // 편집 모드 + editModalTitle?: string; // 편집 모달 제목 + editModalDescription?: string; // 편집 모달 설명 + groupByColumns?: string[]; // 같은 그룹의 여러 행을 함께 편집 (예: ["order_no"]) } /** @@ -1256,14 +1263,6 @@ export class ButtonActionExecutor { // 플로우 선택 데이터 우선 사용 let dataToEdit = flowSelectedData && flowSelectedData.length > 0 ? flowSelectedData : selectedRowsData; - console.log("🔍 handleEdit - 데이터 소스 확인:", { - hasFlowSelectedData: !!(flowSelectedData && flowSelectedData.length > 0), - flowSelectedDataLength: flowSelectedData?.length || 0, - hasSelectedRowsData: !!(selectedRowsData && selectedRowsData.length > 0), - selectedRowsDataLength: selectedRowsData?.length || 0, - dataToEditLength: dataToEdit?.length || 0, - }); - // 선택된 데이터가 없는 경우 if (!dataToEdit || dataToEdit.length === 0) { toast.error("수정할 항목을 선택해주세요."); @@ -1276,26 +1275,15 @@ export class ButtonActionExecutor { return false; } - console.log(`📝 편집 액션 실행: ${dataToEdit.length}개 항목`, { - dataToEdit, - targetScreenId: config.targetScreenId, - editMode: config.editMode, - }); - if (dataToEdit.length === 1) { // 단일 항목 편집 const rowData = dataToEdit[0]; - console.log("📝 단일 항목 편집:", rowData); await this.openEditForm(config, rowData, context); } else { // 다중 항목 편집 - 현재는 단일 편집만 지원 toast.error("현재 단일 항목 편집만 지원됩니다. 하나의 항목만 선택해주세요."); return false; - - // TODO: 향후 다중 편집 지원 - // console.log("📝 다중 항목 편집:", selectedRowsData); - // this.openBulkEditForm(config, selectedRowsData, context); } return true; @@ -1329,7 +1317,7 @@ export class ButtonActionExecutor { default: // 기본값: 모달 - this.openEditModal(config, rowData, context); + await this.openEditModal(config, rowData, context); } } @@ -1341,11 +1329,17 @@ export class ButtonActionExecutor { rowData: any, context: ButtonActionContext, ): Promise { - console.log("🎭 편집 모달 열기:", { - targetScreenId: config.targetScreenId, - modalSize: config.modalSize, - rowData, - }); + const { groupByColumns = [] } = config; + + // PK 값 추출 (우선순위: id > ID > 첫 번째 필드) + let primaryKeyValue: any; + if (rowData.id !== undefined && rowData.id !== null) { + primaryKeyValue = rowData.id; + } else if (rowData.ID !== undefined && rowData.ID !== null) { + primaryKeyValue = rowData.ID; + } else { + primaryKeyValue = Object.values(rowData)[0]; + } // 1. config에 editModalDescription이 있으면 우선 사용 let description = config.editModalDescription || ""; @@ -1360,7 +1354,7 @@ export class ButtonActionExecutor { } } - // 모달 열기 이벤트 발생 + // 🔧 항상 EditModal 사용 (groupByColumns는 EditModal에서 처리) const modalEvent = new CustomEvent("openEditModal", { detail: { screenId: config.targetScreenId, @@ -1368,16 +1362,15 @@ export class ButtonActionExecutor { description: description, modalSize: config.modalSize || "lg", editData: rowData, + groupByColumns: groupByColumns.length > 0 ? groupByColumns : undefined, // 🆕 그룹핑 컬럼 전달 + tableName: context.tableName, // 🆕 테이블명 전달 onSave: () => { - // 저장 후 테이블 새로고침 - console.log("💾 편집 저장 완료 - 테이블 새로고침"); context.onRefresh?.(); }, }, }); window.dispatchEvent(modalEvent); - // 편집 모달 열기는 조용히 처리 (토스트 없음) } /** From 2f3d5f993ae3a9e673456c3fca3e05a6c6920101 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 24 Nov 2025 15:38:41 +0900 Subject: [PATCH 09/29] =?UTF-8?q?fix:=20=EC=88=98=EC=A3=BC=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EC=88=98=EC=A0=95=20=EC=A0=80=EC=9E=A5=20=EC=8B=9C?= =?UTF-8?q?=20=EC=A1=B0=EC=9D=B8=20=EC=BB=AC=EB=9F=BC=20=ED=95=84=ED=84=B0?= =?UTF-8?q?=EB=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - entityJoinApi 조회 데이터의 조인 컬럼(material_label 등) 필터링 - dynamicFormService.ts: 병합 모드에서 columnInfo 기반 유효 컬럼만 저장 - sales_order_mng 테이블에 존재하지 않는 컬럼 INSERT 방지 - "column does not exist" PostgreSQL 에러 해결 영향: 수주관리 그룹 편집 저장 정상 동작 --- .../src/services/dynamicFormService.ts | 29 +++++++++++++------ frontend/components/screen/EditModal.tsx | 10 +++---- .../ConditionalSectionViewer.tsx | 20 ++++++------- 3 files changed, 35 insertions(+), 24 deletions(-) diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index 965d2833..5ca787a4 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -496,16 +496,27 @@ export class DynamicFormService { for (const repeater of mergedRepeaterData) { for (const item of repeater.data) { // 헤더 + 품목을 병합 - const mergedData = { ...dataToInsert, ...item }; + const rawMergedData = { ...dataToInsert, ...item }; - // 타입 변환 - Object.keys(mergedData).forEach((columnName) => { - const column = columnInfo.find((col) => col.column_name === columnName); - if (column) { - mergedData[columnName] = this.convertValueForPostgreSQL( - mergedData[columnName], - column.data_type - ); + // 🆕 실제 테이블 컬럼만 필터링 (조인/계산 컬럼 제외) + const validColumnNames = columnInfo.map((col) => col.column_name); + const mergedData: Record = {}; + + Object.keys(rawMergedData).forEach((columnName) => { + // 실제 테이블 컬럼인지 확인 + if (validColumnNames.includes(columnName)) { + const column = columnInfo.find((col) => col.column_name === columnName); + if (column) { + // 타입 변환 + mergedData[columnName] = this.convertValueForPostgreSQL( + rawMergedData[columnName], + column.data_type + ); + } else { + mergedData[columnName] = rawMergedData[columnName]; + } + } else { + console.log(`⚠️ 조인/계산 컬럼 제외: ${columnName} (값: ${rawMergedData[columnName]})`); } }); diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 776eea83..5dec9266 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -323,7 +323,7 @@ export const EditModal: React.FC = ({ className }) => { continue; } - // 변경된 필드만 추출 + // 변경된 필드만 추출 const changedData: Record = {}; // 🆕 sales_order_mng 테이블의 실제 컬럼만 포함 (조인된 컬럼 제외) @@ -572,10 +572,10 @@ export const EditModal: React.FC = ({ className }) => { ); } } else { - setFormData((prev) => ({ - ...prev, - [fieldName]: value, - })); + setFormData((prev) => ({ + ...prev, + [fieldName]: value, + })); } }} screenInfo={{ diff --git a/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx b/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx index d402f4df..f77dbcdb 100644 --- a/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx +++ b/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx @@ -142,7 +142,7 @@ export function ConditionalSectionViewer({ return (
+ />
); })} From f286b6c695892914e5e5bd026d3cd6bfb35add9a Mon Sep 17 00:00:00 2001 From: leeheejin Date: Mon, 24 Nov 2025 15:52:45 +0900 Subject: [PATCH 10/29] =?UTF-8?q?=EB=A6=AC=EC=82=AC=EC=9D=B4=EC=A7=95=20?= =?UTF-8?q?=EC=9C=A0=EC=A7=80=EC=99=80=20=EC=97=B0=EC=86=8D=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20=EA=B5=AC=ED=98=84=20=EB=AA=A8=EB=8B=AC=20=EC=82=B4?= =?UTF-8?q?=EC=A7=9D=20=EB=8A=98=EC=96=B4=EB=82=98=EB=8A=94=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/common/ScreenModal.tsx | 68 +++-- frontend/components/screen/EditModal.tsx | 22 +- frontend/components/screen/SaveModal.tsx | 20 +- frontend/components/ui/resizable-dialog.tsx | 309 +++++++++++++------- 4 files changed, 275 insertions(+), 144 deletions(-) diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index f44e2227..65dbf84c 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -57,16 +57,18 @@ export const ScreenModal: React.FC = ({ className }) => { // 폼 데이터 상태 추가 const [formData, setFormData] = useState>({}); - // 연속 등록 모드 상태 (localStorage에 저장하여 리렌더링에 영향받지 않도록) - const continuousModeRef = useRef(false); - const [, setForceUpdate] = useState(0); // 강제 리렌더링용 (값은 사용하지 않음) + // 연속 등록 모드 상태 (state로 변경 - 체크박스 UI 업데이트를 위해) + const [continuousMode, setContinuousMode] = useState(false); + + // 화면 리셋 키 (컴포넌트 강제 리마운트용) + const [resetKey, setResetKey] = useState(0); // localStorage에서 연속 모드 상태 복원 useEffect(() => { const savedMode = localStorage.getItem("screenModal_continuousMode"); if (savedMode === "true") { - continuousModeRef.current = true; - // console.log("🔄 연속 모드 복원: true"); + setContinuousMode(true); + console.log("🔄 연속 모드 복원: true"); } }, []); @@ -162,29 +164,39 @@ export const ScreenModal: React.FC = ({ className }) => { }); setScreenData(null); setFormData({}); - continuousModeRef.current = false; + setContinuousMode(false); localStorage.setItem("screenModal_continuousMode", "false"); // localStorage에 저장 - // console.log("🔄 연속 모드 초기화: false"); + console.log("🔄 연속 모드 초기화: false"); }; // 저장 성공 이벤트 처리 (연속 등록 모드 지원) const handleSaveSuccess = () => { - const isContinuousMode = continuousModeRef.current; - // console.log("💾 저장 성공 이벤트 수신"); - // console.log("📌 현재 연속 모드 상태 (ref):", isContinuousMode); - // console.log("📌 localStorage:", localStorage.getItem("screenModal_continuousMode")); + const isContinuousMode = continuousMode; + console.log("💾 저장 성공 이벤트 수신"); + console.log("📌 현재 연속 모드 상태:", isContinuousMode); + console.log("📌 localStorage:", localStorage.getItem("screenModal_continuousMode")); if (isContinuousMode) { // 연속 모드: 폼만 초기화하고 모달은 유지 - // console.log("✅ 연속 모드 활성화 - 폼만 초기화"); + console.log("✅ 연속 모드 활성화 - 폼 초기화 및 화면 리셋"); - // 폼만 초기화 (연속 모드 상태는 localStorage에 저장되어 있으므로 유지됨) + // 1. 폼 데이터 초기화 setFormData({}); + + // 2. 리셋 키 변경 (컴포넌트 강제 리마운트) + setResetKey(prev => prev + 1); + console.log("🔄 resetKey 증가 - 컴포넌트 리마운트"); + + // 3. 화면 데이터 다시 로드 (채번 규칙 새로 생성) + if (modalState.screenId) { + console.log("🔄 화면 데이터 다시 로드:", modalState.screenId); + loadScreenData(modalState.screenId); + } toast.success("저장되었습니다. 계속 입력하세요."); } else { // 일반 모드: 모달 닫기 - // console.log("❌ 일반 모드 - 모달 닫기"); + console.log("❌ 일반 모드 - 모달 닫기"); handleCloseModal(); } }; @@ -198,7 +210,7 @@ export const ScreenModal: React.FC = ({ className }) => { window.removeEventListener("closeSaveModal", handleCloseModal); window.removeEventListener("saveSuccessInModal", handleSaveSuccess); }; - }, []); // 의존성 제거 (ref 사용으로 최신 상태 참조) + }, [continuousMode]); // continuousMode 의존성 추가 // 화면 데이터 로딩 useEffect(() => { @@ -415,18 +427,21 @@ export const ScreenModal: React.FC = ({ className }) => { setFormData({}); // 폼 데이터 초기화 }; - // 모달 크기 설정 - 화면 내용에 맞게 동적 조정 + // 모달 크기 설정 - 화면관리 설정 크기 + 헤더/푸터 const getModalStyle = () => { if (!screenDimensions) { return { className: "w-fit min-w-[400px] max-w-4xl max-h-[90vh] overflow-hidden p-0", - style: {}, + style: undefined, // undefined로 변경 - defaultWidth/defaultHeight 사용 }; } - // 헤더 높이를 최소화 (제목 영역만) - const headerHeight = 60; // DialogHeader 최소 높이 (타이틀 + 최소 패딩) - const totalHeight = screenDimensions.height + headerHeight; + // 화면관리에서 설정한 크기 = 컨텐츠 영역 크기 + // 실제 모달 크기 = 컨텐츠 + 헤더 + 연속등록 체크박스 + const headerHeight = 60; // DialogHeader (타이틀 + 패딩) + const footerHeight = 52; // 연속 등록 모드 체크박스 영역 + + const totalHeight = screenDimensions.height + headerHeight + footerHeight; return { className: "overflow-hidden p-0", @@ -504,7 +519,7 @@ export const ScreenModal: React.FC = ({ className }) => { = ({ className }) => {
-
+
{loading ? (
@@ -568,7 +583,7 @@ export const ScreenModal: React.FC = ({ className }) => { return ( = ({ className }) => {
{ const isChecked = checked === true; - continuousModeRef.current = isChecked; + setContinuousMode(isChecked); localStorage.setItem("screenModal_continuousMode", String(isChecked)); - setForceUpdate((prev) => prev + 1); // 체크박스 UI 업데이트를 위한 강제 리렌더링 - // console.log("🔄 연속 모드 변경:", isChecked); + console.log("🔄 연속 모드 변경:", isChecked); }} />
+ }> + +
); }; diff --git a/frontend/components/screen/widgets/TabsWidget.tsx b/frontend/components/screen/widgets/TabsWidget.tsx index 03dec3ba..90608a4b 100644 --- a/frontend/components/screen/widgets/TabsWidget.tsx +++ b/frontend/components/screen/widgets/TabsWidget.tsx @@ -1,210 +1,187 @@ "use client"; import React, { useState, useEffect } from "react"; -import { TabsComponent, TabItem, ScreenDefinition } from "@/types"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Card } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { Loader2, FileQuestion } from "lucide-react"; -import { screenApi } from "@/lib/api/screen"; +import { Button } from "@/components/ui/button"; +import { X, Loader2 } from "lucide-react"; +import type { TabsComponent, TabItem } from "@/types/screen-management"; import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic"; interface TabsWidgetProps { component: TabsComponent; - isPreview?: boolean; + className?: string; + style?: React.CSSProperties; } -/** - * 탭 위젯 컴포넌트 - * 각 탭에 다른 화면을 표시할 수 있습니다 - */ -export const TabsWidget: React.FC = ({ component, isPreview = false }) => { - // componentConfig에서 설정 읽기 (새 컴포넌트 시스템) - const config = (component as any).componentConfig || component; - const { tabs = [], defaultTab, orientation = "horizontal", variant = "default" } = config; - - // console.log("🔍 TabsWidget 렌더링:", { - // component, - // componentConfig: (component as any).componentConfig, - // tabs, - // tabsLength: tabs.length - // }); +export function TabsWidget({ component, className, style }: TabsWidgetProps) { + const { + tabs = [], + defaultTab, + orientation = "horizontal", + variant = "default", + allowCloseable = false, + persistSelection = false, + } = component; - const [activeTab, setActiveTab] = useState(defaultTab || tabs[0]?.id || ""); - const [loadedScreens, setLoadedScreens] = useState>({}); + console.log("🎨 TabsWidget 렌더링:", { + componentId: component.id, + tabs, + tabsLength: tabs.length, + component, + }); + + const storageKey = `tabs-${component.id}-selected`; + + // 초기 선택 탭 결정 + const getInitialTab = () => { + if (persistSelection && typeof window !== "undefined") { + const saved = localStorage.getItem(storageKey); + if (saved && tabs.some((t) => t.id === saved)) { + return saved; + } + } + return defaultTab || tabs[0]?.id || ""; + }; + + const [selectedTab, setSelectedTab] = useState(getInitialTab()); + const [visibleTabs, setVisibleTabs] = useState(tabs); const [loadingScreens, setLoadingScreens] = useState>({}); - const [screenErrors, setScreenErrors] = useState>({}); + const [screenLayouts, setScreenLayouts] = useState>({}); - // 탭 변경 시 화면 로드 + // 컴포넌트 탭 목록 변경 시 동기화 useEffect(() => { - if (!activeTab) return; + setVisibleTabs(tabs.filter((tab) => !tab.disabled)); + }, [tabs]); - const currentTab = tabs.find((tab) => tab.id === activeTab); - if (!currentTab || !currentTab.screenId) return; + // 선택된 탭 변경 시 localStorage에 저장 + useEffect(() => { + if (persistSelection && typeof window !== "undefined") { + localStorage.setItem(storageKey, selectedTab); + } + }, [selectedTab, persistSelection, storageKey]); - // 이미 로드된 화면이면 스킵 - if (loadedScreens[activeTab]) return; + // 화면 레이아웃 로드 + const loadScreenLayout = async (screenId: number) => { + if (screenLayouts[screenId]) { + return; // 이미 로드됨 + } - // 이미 로딩 중이면 스킵 - if (loadingScreens[activeTab]) return; - - // 화면 로드 시작 - loadScreen(activeTab, currentTab.screenId); - }, [activeTab, tabs]); - - const loadScreen = async (tabId: string, screenId: number) => { - setLoadingScreens((prev) => ({ ...prev, [tabId]: true })); - setScreenErrors((prev) => ({ ...prev, [tabId]: "" })); + setLoadingScreens((prev) => ({ ...prev, [screenId]: true })); try { - const layoutData = await screenApi.getLayout(screenId); - - if (layoutData) { - setLoadedScreens((prev) => ({ - ...prev, - [tabId]: { - screenId, - layout: layoutData, - }, - })); - } else { - setScreenErrors((prev) => ({ - ...prev, - [tabId]: "화면을 불러올 수 없습니다", - })); + const response = await fetch(`/api/screen-management/screens/${screenId}/layout`); + if (response.ok) { + const data = await response.json(); + if (data.success && data.data) { + setScreenLayouts((prev) => ({ ...prev, [screenId]: data.data })); + } } - } catch (error: any) { - setScreenErrors((prev) => ({ - ...prev, - [tabId]: error.message || "화면 로드 중 오류가 발생했습니다", - })); + } catch (error) { + console.error(`Failed to load screen layout ${screenId}:`, error); } finally { - setLoadingScreens((prev) => ({ ...prev, [tabId]: false })); + setLoadingScreens((prev) => ({ ...prev, [screenId]: false })); } }; - // 탭 콘텐츠 렌더링 - const renderTabContent = (tab: TabItem) => { - const isLoading = loadingScreens[tab.id]; - const error = screenErrors[tab.id]; - const screenData = loadedScreens[tab.id]; + // 탭 변경 핸들러 + const handleTabChange = (tabId: string) => { + setSelectedTab(tabId); - // 로딩 중 - if (isLoading) { - return ( -
- -

화면을 불러오는 중...

-
- ); + // 해당 탭의 화면 로드 + const tab = visibleTabs.find((t) => t.id === tabId); + if (tab && tab.screenId && !screenLayouts[tab.screenId]) { + loadScreenLayout(tab.screenId); } + }; - // 에러 발생 - if (error) { - return ( -
- -
-

화면 로드 실패

-

{error}

-
-
- ); + // 탭 닫기 핸들러 + const handleCloseTab = (tabId: string, e: React.MouseEvent) => { + e.stopPropagation(); + + const updatedTabs = visibleTabs.filter((tab) => tab.id !== tabId); + setVisibleTabs(updatedTabs); + + // 닫은 탭이 선택된 탭이었다면 다음 탭 선택 + if (selectedTab === tabId && updatedTabs.length > 0) { + setSelectedTab(updatedTabs[0].id); } + }; - // 화면 ID가 없는 경우 - if (!tab.screenId) { - return ( -
- -
-

화면이 할당되지 않았습니다

-

상세설정에서 화면을 선택하세요

-
-
- ); - } - - // 화면 렌더링 - 원본 화면의 모든 컴포넌트를 그대로 렌더링 - if (screenData && screenData.layout && screenData.layout.components) { - const components = screenData.layout.components; - const screenResolution = screenData.layout.screenResolution || { width: 1920, height: 1080 }; - - return ( -
-
- {components.map((comp) => ( - - ))} -
-
- ); - } + // 탭 스타일 클래스 + const getTabsListClass = () => { + const baseClass = orientation === "vertical" ? "flex-col" : ""; + const variantClass = + variant === "pills" + ? "bg-muted p-1 rounded-lg" + : variant === "underline" + ? "border-b" + : "bg-muted p-1"; + return `${baseClass} ${variantClass}`; + }; + if (visibleTabs.length === 0) { return ( -
- -
-

화면 데이터를 불러올 수 없습니다

-
+
+

탭이 없습니다

); - }; - - // 빈 탭 목록 - if (tabs.length === 0) { - return ( - -
-

탭이 없습니다

-

상세설정에서 탭을 추가하세요

-
-
- ); } return ( -
- - - {tabs.map((tab) => ( - - {tab.label} - {tab.screenName && ( - - {tab.screenName} - - )} + + + {visibleTabs.map((tab) => ( +
+ + {tab.label} - ))} - - - {tabs.map((tab) => ( - - {renderTabContent(tab)} - + {allowCloseable && ( + + )} +
))} -
-
- ); -}; + + {visibleTabs.map((tab) => ( + + {tab.screenId ? ( + loadingScreens[tab.screenId] ? ( +
+ + 화면 로딩 중... +
+ ) : screenLayouts[tab.screenId] ? ( +
+ +
+ ) : ( +
+

화면을 불러올 수 없습니다

+
+ ) + ) : ( +
+

연결된 화면이 없습니다

+
+ )} +
+ ))} + + ); +} diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 98e53425..12e6e944 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -59,6 +59,9 @@ import "./selected-items-detail-input/SelectedItemsDetailInputRenderer"; import "./section-paper/SectionPaperRenderer"; // Section Paper (색종이 - 배경색 기반 그룹화) - Renderer 방식 import "./section-card/SectionCardRenderer"; // Section Card (제목+테두리 기반 그룹화) - Renderer 방식 +// 🆕 탭 컴포넌트 +import "./tabs/tabs-component"; // 탭 기반 화면 전환 컴포넌트 + /** * 컴포넌트 초기화 함수 */ diff --git a/frontend/lib/registry/components/tabs/tabs-component.tsx b/frontend/lib/registry/components/tabs/tabs-component.tsx new file mode 100644 index 00000000..18fbf297 --- /dev/null +++ b/frontend/lib/registry/components/tabs/tabs-component.tsx @@ -0,0 +1,131 @@ +"use client"; + +import React from "react"; +import { ComponentRegistry } from "../../ComponentRegistry"; +import { ComponentCategory } from "@/types/component"; +import { Folder } from "lucide-react"; +import type { TabsComponent, TabItem } from "@/types/screen-management"; + +/** + * 탭 컴포넌트 정의 + * + * 여러 화면을 탭으로 구분하여 전환할 수 있는 컴포넌트 + */ +ComponentRegistry.registerComponent({ + id: "tabs-widget", + name: "탭 컴포넌트", + description: "화면을 탭으로 전환할 수 있는 컴포넌트입니다. 각 탭마다 다른 화면을 연결할 수 있습니다.", + category: ComponentCategory.LAYOUT, + webType: "text" as any, // 레이아웃 컴포넌트이므로 임시값 + component: () => null as any, // 레이아웃 컴포넌트이므로 임시값 + defaultConfig: {}, + tags: ["tabs", "navigation", "layout", "screen"], + icon: Folder, + version: "1.0.0", + + defaultSize: { + width: 800, + height: 600, + }, + + defaultProps: { + type: "tabs" as const, + tabs: [ + { + id: "tab-1", + label: "탭 1", + order: 0, + disabled: false, + }, + { + id: "tab-2", + label: "탭 2", + order: 1, + disabled: false, + }, + ] as TabItem[], + defaultTab: "tab-1", + orientation: "horizontal" as const, + variant: "default" as const, + allowCloseable: false, + persistSelection: false, + }, + + // 에디터 모드에서의 렌더링 + renderEditor: ({ component, isSelected, onClick, onDragStart, onDragEnd, children }) => { + const tabsComponent = component as TabsComponent; + const tabs = tabsComponent.tabs || []; + + return ( +
+
+
+ +
+

탭 컴포넌트

+

+ {tabs.length > 0 + ? `${tabs.length}개의 탭 (실제 화면에서 표시됩니다)` + : "탭이 없습니다. 설정 패널에서 탭을 추가하세요"} +

+ {tabs.length > 0 && ( +
+ {tabs.map((tab: TabItem, index: number) => ( + + {tab.label || `탭 ${index + 1}`} + + ))} +
+ )} +
+
+ ); + }, + + // 인터랙티브 모드에서의 렌더링 (실제 동작) + renderInteractive: ({ component }) => { + // InteractiveScreenViewer에서 TabsWidget을 사용하므로 여기서는 null 반환 + return null; + }, + + // 설정 패널 (동적 로딩) + configPanel: React.lazy(() => + import("@/components/screen/config-panels/TabsConfigPanel").then(module => ({ + default: module.TabsConfigPanel + })) + ), + + // 검증 함수 + validate: (component) => { + const tabsComponent = component as TabsComponent; + const errors: string[] = []; + + if (!tabsComponent.tabs || tabsComponent.tabs.length === 0) { + errors.push("최소 1개 이상의 탭이 필요합니다."); + } + + if (tabsComponent.tabs) { + const tabIds = tabsComponent.tabs.map((t) => t.id); + const uniqueIds = new Set(tabIds); + if (tabIds.length !== uniqueIds.size) { + errors.push("탭 ID가 중복되었습니다."); + } + } + + return { + isValid: errors.length === 0, + errors, + }; + }, +}); + +console.log("✅ 탭 컴포넌트 등록 완료"); + diff --git a/frontend/lib/utils/getComponentConfigPanel.tsx b/frontend/lib/utils/getComponentConfigPanel.tsx index 6bae6944..f921016c 100644 --- a/frontend/lib/utils/getComponentConfigPanel.tsx +++ b/frontend/lib/utils/getComponentConfigPanel.tsx @@ -42,6 +42,8 @@ const CONFIG_PANEL_MAP: Record Promise> = { // 🆕 섹션 그룹화 레이아웃 "section-card": () => import("@/lib/registry/components/section-card/SectionCardConfigPanel"), "section-paper": () => import("@/lib/registry/components/section-paper/SectionPaperConfigPanel"), + // 🆕 탭 컴포넌트 + "tabs-widget": () => import("@/components/screen/config-panels/TabsConfigPanel"), }; // ConfigPanel 컴포넌트 캐시 @@ -76,6 +78,7 @@ export async function getComponentConfigPanel(componentId: string): Promise; }; +// TabsConfigPanel 래퍼 +const TabsConfigPanelWrapper: ConfigPanelComponent = ({ config, onConfigChange }) => { + const mockComponent = { + id: "temp", + type: "tabs" as const, + tabs: config.tabs || [], + defaultTab: config.defaultTab, + orientation: config.orientation || "horizontal", + variant: config.variant || "default", + allowCloseable: config.allowCloseable || false, + persistSelection: config.persistSelection || false, + }; + + const handleUpdate = (updates: any) => { + onConfigChange({ ...config, ...updates }); + }; + + return ; +}; + // 설정 패널 이름으로 직접 매핑하는 함수 (DB의 config_panel 필드용) export const getConfigPanelComponent = (panelName: string): ConfigPanelComponent | null => { console.log(`🔧 getConfigPanelComponent 호출: panelName="${panelName}"`); @@ -128,6 +149,9 @@ export const getConfigPanelComponent = (panelName: string): ConfigPanelComponent case "DashboardConfigPanel": console.log(`🔧 DashboardConfigPanel 래퍼 컴포넌트 반환`); return DashboardConfigPanelWrapper; + case "TabsConfigPanel": + console.log(`🔧 TabsConfigPanel 래퍼 컴포넌트 반환`); + return TabsConfigPanelWrapper; default: console.warn(`🔧 알 수 없는 설정 패널: ${panelName}, 기본 설정 사용`); return null; // 기본 설정 (패널 없음) diff --git a/frontend/types/screen-management.ts b/frontend/types/screen-management.ts index 195b9b61..0320f303 100644 --- a/frontend/types/screen-management.ts +++ b/frontend/types/screen-management.ts @@ -190,6 +190,32 @@ export interface ComponentComponent extends BaseComponent { componentConfig: any; // 컴포넌트별 설정 } +/** + * 탭 아이템 인터페이스 + */ +export interface TabItem { + id: string; + label: string; + screenId?: number; // 연결된 화면 ID + screenName?: string; // 화면 이름 (표시용) + icon?: string; // 아이콘 (선택사항) + disabled?: boolean; // 비활성화 여부 + order: number; // 탭 순서 +} + +/** + * 탭 컴포넌트 + */ +export interface TabsComponent extends BaseComponent { + type: "tabs"; + tabs: TabItem[]; // 탭 목록 + defaultTab?: string; // 기본 선택 탭 ID + orientation?: "horizontal" | "vertical"; // 탭 방향 + variant?: "default" | "pills" | "underline"; // 탭 스타일 + allowCloseable?: boolean; // 탭 닫기 버튼 표시 여부 + persistSelection?: boolean; // 선택 상태 유지 (localStorage) +} + /** * 통합 컴포넌트 데이터 타입 */ @@ -200,7 +226,8 @@ export type ComponentData = | DataTableComponent | FileComponent | FlowComponent - | ComponentComponent; + | ComponentComponent + | TabsComponent; // ===== 웹타입별 설정 인터페이스 ===== @@ -791,6 +818,13 @@ export const isFlowComponent = (component: ComponentData): component is FlowComp return component.type === "flow"; }; +/** + * TabsComponent 타입 가드 + */ +export const isTabsComponent = (component: ComponentData): component is TabsComponent => { + return component.type === "tabs"; +}; + // ===== 안전한 타입 캐스팅 유틸리티 ===== /** @@ -852,3 +886,13 @@ export const asFlowComponent = (component: ComponentData): FlowComponent => { } return component; }; + +/** + * ComponentData를 TabsComponent로 안전하게 캐스팅 + */ +export const asTabsComponent = (component: ComponentData): TabsComponent => { + if (!isTabsComponent(component)) { + throw new Error(`Expected TabsComponent, got ${component.type}`); + } + return component; +}; diff --git a/frontend/types/unified-core.ts b/frontend/types/unified-core.ts index 4da2280a..f80c5c39 100644 --- a/frontend/types/unified-core.ts +++ b/frontend/types/unified-core.ts @@ -85,7 +85,8 @@ export type ComponentType = | "area" | "layout" | "flow" - | "component"; + | "component" + | "tabs"; /** * 기본 위치 정보 diff --git a/시연_시나리오.md b/시연_시나리오.md new file mode 100644 index 00000000..e67a7b09 --- /dev/null +++ b/시연_시나리오.md @@ -0,0 +1,542 @@ +# ERP-node 시스템 시연 시나리오 + +## 전체 개요 + +**주제**: 발주 → 입고 프로세스 자동화 +**목표**: 버튼 클릭 한 번으로 발주 데이터가 입고 테이블로 자동 이동하는 것을 보여주기 +**총 시간**: 10분 + +--- + +## Part 1: 테이블 2개 생성 (2분) + +### 1-1. 발주 테이블 생성 + +**화면 조작**: + +1. 테이블 관리 메뉴 접속 +2. "새 테이블" 버튼 클릭 +3. 테이블 정보 입력: + + - **테이블명(영문)**: `purchase_order` + - **테이블명(한글)**: `발주` + - **설명**: `발주 관리` + +4. 컬럼 추가 (4개): + +| 컬럼명(영문) | 컬럼명(한글) | 타입 | 필수 여부 | +| ------------ | ------------ | ------ | --------- | +| order_no | 발주번호 | text | ✓ | +| item_name | 품목명 | text | ✓ | +| quantity | 수량 | number | ✓ | +| unit_price | 단가 | number | ✓ | + +5. "테이블 생성" 버튼 클릭 +6. 성공 메시지 확인 + +--- + +### 1-2. 입고 테이블 생성 + +**화면 조작**: + +1. "새 테이블" 버튼 클릭 +2. 테이블 정보 입력: + + - **테이블명(영문)**: `receiving` + - **테이블명(한글)**: `입고` + - **설명**: `입고 관리` + +3. 컬럼 추가 (5개): + +| 컬럼명(영문) | 컬럼명(한글) | 타입 | 필수 여부 | 비고 | +| -------------- | ------------ | ------ | --------- | ------------------- | +| receiving_no | 입고번호 | text | ✓ | 자동 생성 | +| order_no | 발주번호 | text | ✓ | 발주 테이블 참조 | +| item_name | 품목명 | text | ✓ | | +| quantity | 수량 | number | ✓ | | +| receiving_date | 입고일자 | date | ✓ | 오늘 날짜 자동 입력 | + +4. "테이블 생성" 버튼 클릭 +5. 성공 메시지 확인 + +**포인트 강조**: + +- 클릭만으로 데이터베이스 테이블 자동 생성 +- Input Type에 따라 적절한 UI 자동 설정 + +--- + +## Part 2: 메뉴 2개 생성 (1분) + +### 2-1. 발주 관리 메뉴 생성 + +**화면 조작**: + +1. 관리자 메뉴 > 메뉴 관리 접속 +2. "새 메뉴 추가" 버튼 클릭 +3. 메뉴 정보 입력: + - **메뉴명**: `발주 관리` + - **순서**: 1 +4. "저장" 클릭 + +--- + +### 2-2. 입고 관리 메뉴 생성 + +**화면 조작**: + +1. "새 메뉴 추가" 버튼 클릭 +2. 메뉴 정보 입력: + - **메뉴명**: `입고 관리` + - **순서**: 2 +3. "저장" 클릭 +4. 좌측 메뉴바에서 새로 생성된 메뉴 2개 확인 + +**포인트 강조**: + +- URL 기반 자동 라우팅 +- 아이콘으로 직관적인 메뉴 구성 + +--- + +## Part 3: 플로우 생성 (2분) + +### 3-1. 플로우 생성 + +**화면 조작**: + +1. 제어 관리 메뉴 접속 +2. "새 플로우 생성" 버튼 클릭 +3. 플로우 생성 모달에서 입력: + - **플로우명**: `발주-입고 프로세스` + - **설명**: `발주에서 입고로 데이터 자동 이동` +4. "생성" 버튼 클릭 +5. 플로우 편집 화면(캔버스)으로 자동 이동 + +--- + +### 3-2. 노드 구성 + +**내레이션**: +"플로우는 소스 테이블과 액션 노드로 구성합니다. 발주 테이블에서 입고 테이블로 데이터를 INSERT하는 구조입니다." + +**노드 1: 발주 테이블 소스** + +**화면 조작**: + +1. 캔버스 좌측 팔레트에서 "테이블 소스" 에서 테이블 노드 드래그 +2. 캔버스에 드롭 +3. 생성된 노드 클릭 → 우측 속성 패널 표시 +4. 속성 패널에서 설정: + - **노드명**: `발주 테이블` + - **소스 테이블**: `purchase_order` 선택 + - **색상**: 파란색 (#3b82f6) +5. 데이터 소스 타입 컨텍스트 데이터 선택 + +--- + +**노드 2: 입고 INSERT 액션** + +**화면 조작**: + +1. 좌측 팔레트에서 "INSERT 액션" 노드 드래그 +2. 캔버스의 발주 테이블 오른쪽에 드롭 +3. 노드 클릭 → 우측 속성 패널 표시 +4. 속성 패널에서 설정: + - **노드명**: `입고 처리` + - **타겟 테이블**: `receiving`(입고) 선택 + - **액션 타입**: INSERT + - **색상**: 초록색 (#22c55e) + +--- + +### 3-3. 노드 연결 및 필드 매핑 + +**내레이션**: +"소스 테이블과 액션 노드를 연결하고 필드 매핑을 설정합니다." + +**화면 조작**: + +1. "발주 테이블" 노드의 오른쪽 연결점(핸들)에 마우스 올리기 +2. 연결점에서 드래그 시작 +3. "입고 처리" 노드의 왼쪽 연결점으로 드래그 +4. 연결선 자동 생성됨 + +5. "입고 처리" (INSERT 액션) 노드 클릭 +6. 우측 속성 패널에서 "필드 매핑" 탭 선택 +7. 필드 매핑 설정: + +| 소스 필드 (발주) | 타겟 필드 (입고) | 비고 | +| ---------------- | ---------------- | ------------- | +| order_no | order_no | 발주번호 복사 | +| item_name | item_name | 품목명 복사 | +| quantity | quantity | 수량 복사 | +| (자동 생성) | receiving_no | 입고번호 | +| (현재 날짜) | receiving_date | 입고일자 | + +8. 우측 상단 "저장" 버튼 클릭 +9. 성공 메시지: "플로우가 저장되었습니다" + +**포인트 강조**: + +- 테이블 소스 → 액션 노드 구조 +- 필드 매핑으로 데이터 자동 복사 설정 +- INSERT 액션으로 새 테이블에 데이터 생성 + +**참고**: + +- `receiving_no`와 `receiving_date`는 자동 생성 필드로 설정 +- 같은 이름의 필드는 자동 매핑됨 + +--- + +## Part 4: 화면 설계 (2분) + +### 4-1. 발주 관리 화면 설계 + +**화면 조작**: + +1. 화면 관리 > 화면 설계 메뉴 접속 +2. "발주 관리" 메뉴의 "화면 할당" 클릭 +3. "새 화면 생성" 선택 +4. 테이블 선택: `purchase_order` (발주) + +**화면 구성**: + +**전체: 테이블 리스트 컴포넌트 (CRUD 기능 포함)** + +1. 컴포넌트 팔레트에서 "테이블 리스트" 드래그 +2. 테이블 설정: + - **연결 테이블**: `purchase_order` + - **컬럼 표시**: + +| 컬럼 | 표시 | 정렬 가능 | 너비 | +| ---------- | ---- | --------- | ----- | +| order_no | ✓ | ✓ | 150px | +| item_name | ✓ | ✓ | 200px | +| quantity | ✓ | | 100px | +| unit_price | ✓ | | 120px | + +3. 기능 설정: + + - **조회**: 활성화 + - **등록**: 활성화 (신규 버튼) + - **수정**: 활성화 + - **삭제**: 활성화 + - **페이징**: 10개씩 + - **입고 처리 버튼**: 커스텀 액션 추가 + +4. 입고 처리 버튼 설정: + + - **버튼 라벨**: `입고 처리` + - **버튼 위치**: 행 액션 + - **연결 플로우**: `발주-입고 프로세스` 선택 + - **플로우 액션**: `입고 처리` (Connection에서 정의한 액션) + +5. "화면 저장" 버튼 클릭 + +--- + +### 4-2. 입고 관리 화면 설계 + +**화면 조작**: + +1. "입고 관리" 메뉴의 "화면 할당" 클릭 +2. "새 화면 생성" 선택 +3. 테이블 선택: `receiving` (입고) + +**화면 구성**: + +**전체: 테이블 리스트 컴포넌트 (조회 전용)** + +1. 컴포넌트 팔레트에서 "테이블 리스트" 드래그 +2. 테이블 설정: + - **연결 테이블**: `receiving` + - **컬럼 표시**: + +| 컬럼 | 표시 | 정렬 가능 | 너비 | +| -------------- | ---- | --------- | ----- | +| receiving_no | ✓ | ✓ | 150px | +| order_no | ✓ | ✓ | 150px | +| item_name | ✓ | ✓ | 200px | +| quantity | ✓ | | 100px | +| receiving_date | ✓ | ✓ | 120px | + +3. 기능 설정: + + - **조회**: 활성화 + - **등록**: 비활성화 (플로우로만 데이터 생성) + - **수정**: 비활성화 + - **삭제**: 비활성화 + - **페이징**: 20개씩 + - **정렬**: 입고일자 내림차순 + +4. "화면 저장" 버튼 클릭 + +**포인트 강조**: + +- 테이블 리스트 컴포넌트로 CRUD 자동 구성 +- 발주 화면에는 "입고 처리" 버튼으로 플로우 실행 +- 입고 화면은 조회 전용 (플로우로만 데이터 생성) + +--- + +## Part 5: 실행 및 동작 확인 (3분) + +### 5-1. 발주 등록 + +**화면 조작**: + +1. 좌측 메뉴에서 "발주 관리" 클릭 +2. 화면 구성 확인: + + - 테이블 리스트 컴포넌트 (빈 테이블) + - 상단에 "신규" 버튼 + +3. "신규" 버튼 클릭 +4. 입력 모달 창 표시 +5. 데이터 입력: + + - **발주번호**: PO-001 + - **품목명**: 노트북 (LG Gram 17) + - **수량**: 10 + - **단가**: 2,000,000 + +6. "저장" 버튼 클릭 +7. 성공 메시지 확인: "저장되었습니다" + +8. 결과 확인: + - 테이블에 새 행 추가됨 + - 행 우측에 "입고 처리" 버튼 표시됨 + +**추가 발주 등록 (옵션)**: + +9. "신규" 버튼 클릭 +10. 2번째 데이터 입력: + +- **발주번호**: PO-002 +- **품목명**: 모니터 (삼성 27인치) +- **수량**: 5 +- **단가**: 300,000 + +11. "저장" 클릭 +12. 테이블에 2개 행 확인 + +--- + +### 5-2. 입고 처리 실행 ⭐ (핵심 데모) + +**화면 조작**: + +1. 발주 테이블에서 첫 번째 행(PO-001 노트북) 확인 +2. 행 우측의 **"입고 처리"** 버튼 클릭 +3. 확인 대화상자: + + - "이 발주를 입고 처리하시겠습니까?" + - **"예"** 클릭 + +4. 성공 메시지: "입고 처리되었습니다" + +--- + +### 5-3. 자동 데이터 이동 확인 ⭐⭐⭐ + +**실시간 변화 확인**: + +**1) 발주 테이블 자동 업데이트** + +- PO-001 항목이 테이블에서 **즉시 사라짐** +- PO-002만 남아있음 (추가로 등록했다면) + +**2) 입고 관리 화면으로 이동** + +1. 좌측 메뉴에서 **"입고 관리"** 클릭 +2. 입고 테이블에 **자동으로 데이터 생성됨**: + +| 입고번호 | 발주번호 | 품목명 | 수량 | 입고일자 | +| ---------------- | -------- | ------------------- | ---- | ---------- | +| RCV-20250124-001 | PO-001 | 노트북 (LG Gram 17) | 10 | 2025-01-24 | + +3. **데이터 자동 생성 확인**: + - 입고번호: 자동 생성됨 (RCV-20250124-001) + - 발주번호: PO-001 복사됨 + - 품목명: 노트북 (LG Gram 17) 복사됨 + - 수량: 10 복사됨 + - 입고일자: 오늘 날짜 자동 입력 + +**3) 다시 발주 관리로 돌아가기** + +1. 좌측 메뉴 "발주 관리" 클릭 +2. PO-001은 여전히 사라진 상태 확인 +3. PO-002만 남아있음 + +**4) 제어 관리에서 확인** + +1. 제어 관리 > 플로우 목록 접속 +2. "발주-입고 프로세스" 클릭 +3. 플로우 현황 확인: + - **발주 완료**: 1건 (PO-002) + - **입고 완료**: 1건 (PO-001) + +--- + +### 5-4. 추가 입고 처리 (옵션) + +**화면 조작**: + +1. "발주 관리" 화면에서 PO-002 (모니터) 선택 +2. "입고 처리" 버튼 클릭 +3. 확인 후 입고 완료 + +4. 최종 확인: + - 발주 관리: 0건 (모두 입고 처리됨) + - 입고 관리: 2건 (PO-001, PO-002) + - 제어 관리 플로우: + - **발주 완료: 0건** + - **입고 완료: 2건** + +--- + +## 시연 마무리 (30초) + +**화면 정리 및 요약**: + +**보여준 핵심 기능**: + +- ✅ **코딩 없이 테이블 생성**: 클릭만으로 DB 테이블 자동 생성 +- ✅ **시각적 플로우 구성**: 드래그앤드롭으로 업무 흐름 설계 +- ✅ **자동 데이터 이동**: 버튼 클릭 한 번으로 테이블 간 데이터 자동 복사 및 이동 +- ✅ **실시간 상태 추적**: 제어 관리에서 플로우 현황 확인 +- ✅ **빠른 화면 구성**: 테이블 리스트 컴포넌트로 CRUD 자동 완성 + +**마지막 화면**: + +- 대시보드 또는 시스템 전체 구성도 +- 로고 및 연락처 정보 + +**자막**: +"개발자 없이도 비즈니스 담당자가 직접 업무 시스템을 구축할 수 있습니다." + +--- + +## 시간 배분 요약 + +| 파트 | 시간 | 주요 내용 | +| -------- | ---------- | ---------------------------- | +| Part 1 | 2분 | 테이블 2개 생성 (발주, 입고) | +| Part 2 | 1분 | 메뉴 2개 생성 | +| Part 3 | 2분 | 플로우 구성 및 연결 설정 | +| Part 4 | 2분 | 화면 2개 디자인 | +| Part 5 | 3분 | 발주 등록 → 입고 처리 실행 | +| 마무리 | 0.5분 | 요약 및 정리 | +| **합계** | **10.5분** | | + +--- + +## 시연 준비사항 + +### 사전 설정 + +1. 개발 서버 실행: `http://localhost:9771` +2. 로그인 정보: `wace / qlalfqjsgh11` +3. 데이터베이스 초기화 (테스트 데이터 제거) + +### 녹화 설정 + +- **해상도**: 1920x1080 (Full HD) +- **프레임**: 30fps +- **마우스 효과**: 클릭 하이라이트 활성화 +- **배경음악**: 부드러운 BGM (옵션) +- **자막**: 주요 포인트마다 표시 + +### 시연 팁 + +- 각 단계마다 2-3초 대기 (시청자 이해 시간) +- 중요한 버튼 클릭 시 화면 확대 효과 +- 플로우 위젯 카운트 변화는 빨간색 박스로 강조 +- 성공 메시지는 충분히 길게 보여주기 (최소 3초) +- 입고 테이블에 데이터 들어오는 순간 화면 확대 + +--- + +## 시연 스크립트 (참고용) + +### 오프닝 (10초) + +"안녕하세요. 오늘은 ERP-node 시스템의 핵심 기능을 시연하겠습니다. 발주에서 입고까지 데이터가 자동으로 이동하는 과정을 보여드립니다." + +### Part 1 (2분) + +"먼저 발주와 입고를 관리할 테이블을 생성합니다. 코딩 없이 클릭만으로 데이터베이스 테이블이 자동으로 만들어집니다." + +### Part 2 (1분) + +"이제 사용자가 접근할 메뉴를 추가합니다. URL만 지정하면 자동으로 라우팅이 연결됩니다." + +### Part 3 (2분) + +"발주에서 입고로 데이터가 이동하는 흐름을 제어 플로우로 정의합니다. 두 테이블을 연결하고 버튼을 누르면 자동으로 데이터가 복사 및 이동하도록 설정합니다." + +### Part 4 (2분) + +"실제 사용자가 볼 화면을 디자인합니다. 테이블 리스트 컴포넌트를 사용하면 CRUD 기능이 자동으로 구성되고, 각 행에 입고 처리 버튼을 추가하여 플로우를 실행할 수 있습니다." + +### Part 5 (3분) + +"이제 실제로 작동하는 모습을 보겠습니다. 발주를 등록하고... (데이터 입력) 저장하면 테이블에 추가됩니다. 입고 처리 버튼을 누르면... (클릭) 발주 테이블에서 데이터가 사라지고 입고 테이블에 자동으로 생성됩니다!" + +### 클로징 (10초) + +"이처럼 ERP-node는 코딩 없이 비즈니스 로직을 구현할 수 있는 노코드 플랫폼입니다. 감사합니다." + +--- + +## 체크리스트 + +### 시연 전 + +- [ ] 개발 서버 실행 확인 +- [ ] 로그인 테스트 +- [ ] 기존 테스트 데이터 삭제 +- [ ] 브라우저 창 크기 조정 (1920x1080) +- [ ] 녹화 프로그램 설정 +- [ ] 마이크 테스트 +- [ ] 시나리오 1회 이상 리허설 + +### 시연 중 + +- [ ] 천천히 명확하게 진행 +- [ ] 각 단계마다 결과 확인 +- [ ] 플로우 위젯 카운트 강조 +- [ ] 입고 테이블 데이터 자동 생성 강조 + +### 시연 후 + +- [ ] 녹화 파일 확인 +- [ ] 자막 추가 (필요 시) +- [ ] 배경음악 삽입 (옵션) +- [ ] 인트로/아웃트로 편집 +- [ ] 최종 영상 검수 + +--- + +## 추가 개선 아이디어 + +### 시연 버전 2 (고급) + +- 발주 승인 단계 추가 (발주 요청 → 승인 → 입고) +- 입고 수량 불일치 처리 (일부 입고) +- 대시보드에서 통계 차트 표시 + +### 시연 버전 3 (실전) + +- 실제 업무: 구매 요청 → 견적 → 발주 → 입고 → 검수 +- 권한 관리: 요청자, 승인자, 구매담당자 역할 분리 +- 알림: 각 단계 변경 시 담당자에게 알림 + +--- + +**작성일**: 2025-01-24 +**버전**: 1.0 +**작성자**: AI Assistant From 711f2670dea2b0b8c74614fc01fcc39de62f8a7a Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 24 Nov 2025 18:16:15 +0900 Subject: [PATCH 17/29] =?UTF-8?q?=EC=B4=88=EA=B8=B0=20=EB=B0=B0=EC=B9=98?= =?UTF-8?q?=20=EC=8B=9C=20=ED=94=84=EB=A6=AC=EB=B7=B0=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/yard-3d/DigitalTwinEditor.tsx | 111 +++++++++++------- .../widgets/yard-3d/Yard3DCanvas.tsx | 48 ++++++++ 2 files changed, 119 insertions(+), 40 deletions(-) diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx index 8b2d88f8..47902b42 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx @@ -73,6 +73,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi const [draggedTool, setDraggedTool] = useState(null); const [draggedAreaData, setDraggedAreaData] = useState(null); // 드래그 중인 Area 정보 const [draggedLocationData, setDraggedLocationData] = useState(null); // 드래그 중인 Location 정보 + const [previewPosition, setPreviewPosition] = useState<{ x: number; z: number } | null>(null); // 드래그 프리뷰 위치 const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); @@ -832,7 +833,10 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi // Location의 자재 목록 로드 const loadMaterialsForLocation = async (locaKey: string) => { + console.log("🔍 자재 조회 시작:", { locaKey, selectedDbConnection, material: hierarchyConfig?.material }); + if (!selectedDbConnection || !hierarchyConfig?.material) { + console.error("❌ 설정 누락:", { selectedDbConnection, material: hierarchyConfig?.material }); toast({ variant: "destructive", title: "자재 조회 실패", @@ -844,10 +848,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi try { setLoadingMaterials(true); setShowMaterialPanel(true); - const response = await getMaterials(selectedDbConnection, { + + const materialConfig = { ...hierarchyConfig.material, locaKey: locaKey, - }); + }; + console.log("📡 API 호출:", { externalDbConnectionId: selectedDbConnection, materialConfig }); + + const response = await getMaterials(selectedDbConnection, materialConfig); + console.log("📦 API 응답:", response); if (response.success && response.data) { // layerColumn이 있으면 정렬 const sortedMaterials = hierarchyConfig.material.layerColumn @@ -1597,48 +1606,70 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
- {/* 중앙: 3D 캔버스 */} -
e.preventDefault()} - onDrop={(e) => { - e.preventDefault(); - const rect = e.currentTarget.getBoundingClientRect(); - const rawX = ((e.clientX - rect.left) / rect.width - 0.5) * 100; - const rawZ = ((e.clientY - rect.top) / rect.height - 0.5) * 100; + {/* 중앙: 3D 캔버스 */} +
+ {isLoading ? ( +
+ +
+ ) : ( + <> + handleObjectClick(placement?.id || null)} + onPlacementDrag={(id, position) => handleObjectMove(id, position.x, position.z, position.y)} + focusOnPlacementId={null} + onCollisionDetected={() => {}} + previewTool={draggedTool} + previewPosition={previewPosition} + onPreviewPositionUpdate={setPreviewPosition} + /> + {/* 드래그 중일 때 Canvas 위에 투명한 오버레이 (프리뷰 및 드롭 이벤트 캐치용) */} + {draggedTool && ( +
{ + e.preventDefault(); + const rect = e.currentTarget.getBoundingClientRect(); + const rawX = ((e.clientX - rect.left) / rect.width - 0.5) * 100; + const rawZ = ((e.clientY - rect.top) / rect.height - 0.5) * 100; - // 그리드 크기 (5 단위) - const gridSize = 5; + // 그리드 크기 (5 단위) + const gridSize = 5; - // 그리드에 스냅 - // Area(20x20)는 그리드 교차점에, 다른 객체(5x5)는 타일 중앙에 - let snappedX = Math.round(rawX / gridSize) * gridSize; - let snappedZ = Math.round(rawZ / gridSize) * gridSize; + // 그리드에 스냅 + let snappedX = Math.round(rawX / gridSize) * gridSize; + let snappedZ = Math.round(rawZ / gridSize) * gridSize; - // 5x5 객체는 타일 중앙으로 오프셋 (Area는 제외) - if (draggedTool !== "area") { - snappedX += gridSize / 2; - snappedZ += gridSize / 2; - } + // 5x5 객체는 타일 중앙으로 오프셋 (Area는 제외) + if (draggedTool !== "area") { + snappedX += gridSize / 2; + snappedZ += gridSize / 2; + } - handleCanvasDrop(snappedX, snappedZ); - }} - > - {isLoading ? ( -
- -
- ) : ( - handleObjectClick(placement?.id || null)} - onPlacementDrag={(id, position) => handleObjectMove(id, position.x, position.z, position.y)} - focusOnPlacementId={null} - onCollisionDetected={() => {}} - /> - )} -
+ setPreviewPosition({ x: snappedX, z: snappedZ }); + }} + onDragLeave={() => { + setPreviewPosition(null); + }} + onDrop={(e) => { + e.preventDefault(); + e.stopPropagation(); + if (previewPosition) { + handleCanvasDrop(previewPosition.x, previewPosition.z); + setPreviewPosition(null); + } + setDraggedTool(null); + setDraggedAreaData(null); + setDraggedLocationData(null); + }} + /> + )} + + )} +
{/* 우측: 객체 속성 편집 or 자재 목록 */}
diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx index 3de44b02..cb70b75a 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx @@ -35,6 +35,9 @@ interface Yard3DCanvasProps { gridSize?: number; // 그리드 크기 (기본값: 5) onCollisionDetected?: () => void; // 충돌 감지 시 콜백 focusOnPlacementId?: number | null; // 카메라가 포커스할 요소 ID + previewTool?: string | null; // 드래그 중인 도구 타입 + previewPosition?: { x: number; z: number } | null; // 프리뷰 위치 + onPreviewPositionUpdate?: (position: { x: number; z: number } | null) => void; } // 좌표를 그리드 칸의 중심에 스냅 (마인크래프트 스타일) @@ -1007,10 +1010,26 @@ function Scene({ gridSize = 5, onCollisionDetected, focusOnPlacementId, + previewTool, + previewPosition, }: Yard3DCanvasProps) { const [isDraggingAny, setIsDraggingAny] = useState(false); const orbitControlsRef = useRef(null); + // 프리뷰 박스 크기 계산 + const getPreviewSize = (tool: string) => { + if (tool === "area") return { x: 20, y: 0.1, z: 20 }; + return { x: 5, y: 5, z: 5 }; + }; + + // 프리뷰 박스 색상 + const getPreviewColor = (tool: string) => { + if (tool === "area") return "#3b82f6"; + if (tool === "location-bed") return "#10b981"; + if (tool === "location-stp") return "#f59e0b"; + return "#9ca3af"; + }; + return ( <> {/* 카메라 포커스 컨트롤러 */} @@ -1069,6 +1088,30 @@ function Scene({ /> ))} + {/* 드래그 프리뷰 박스 */} + {previewTool && previewPosition && ( + + + + )} + {/* 카메라 컨트롤 */} { // Canvas의 빈 공간을 클릭했을 때만 선택 해제 @@ -1123,6 +1169,8 @@ export default function Yard3DCanvas({ gridSize={gridSize} onCollisionDetected={onCollisionDetected} focusOnPlacementId={focusOnPlacementId} + previewTool={previewTool} + previewPosition={previewPosition} /> From 216e1366efd36fbd691fad4b23b40c1735fc79fd Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 24 Nov 2025 18:23:00 +0900 Subject: [PATCH 18/29] =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=EC=8B=9C=20?= =?UTF-8?q?=EA=B8=B0=EC=A1=B4=20=EC=84=B8=ED=8C=85=20=EA=B0=80=EC=A0=B8?= =?UTF-8?q?=EC=98=A4=EB=8A=94=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/yard-3d/HierarchyConfigPanel.tsx | 43 ++++++++++++++++++- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx index 8a6f4bfd..9e9528cd 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx @@ -67,12 +67,51 @@ export default function HierarchyConfigPanel({ const [loadingColumns, setLoadingColumns] = useState(false); const [columnsCache, setColumnsCache] = useState<{ [tableName: string]: string[] }>({}); - // 외부에서 변경된 경우 동기화 + // 외부에서 변경된 경우 동기화 및 컬럼 자동 로드 useEffect(() => { if (hierarchyConfig) { setLocalConfig(hierarchyConfig); + + // 저장된 설정의 테이블들에 대한 컬럼 자동 로드 + const loadSavedColumns = async () => { + const tablesToLoad: string[] = []; + + // 창고 테이블 + if (hierarchyConfig.warehouse?.tableName) { + tablesToLoad.push(hierarchyConfig.warehouse.tableName); + } + + // 계층 레벨 테이블들 + hierarchyConfig.levels?.forEach((level) => { + if (level.tableName) { + tablesToLoad.push(level.tableName); + } + }); + + // 자재 테이블 + if (hierarchyConfig.material?.tableName) { + tablesToLoad.push(hierarchyConfig.material.tableName); + } + + // 중복 제거 후 로드 + const uniqueTables = [...new Set(tablesToLoad)]; + for (const tableName of uniqueTables) { + if (!columnsCache[tableName]) { + try { + const columns = await onLoadColumns(tableName); + setColumnsCache((prev) => ({ ...prev, [tableName]: columns })); + } catch (error) { + console.error(`컬럼 로드 실패 (${tableName}):`, error); + } + } + } + }; + + if (externalDbConnectionId) { + loadSavedColumns(); + } } - }, [hierarchyConfig]); + }, [hierarchyConfig, externalDbConnectionId]); // 테이블 선택 시 컬럼 로드 const handleTableChange = async (tableName: string, type: "warehouse" | "material" | number) => { From 3f60f9ca3e5bfc21644c592dcdd7a5d57e525b81 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 25 Nov 2025 09:33:36 +0900 Subject: [PATCH 19/29] =?UTF-8?q?fix(flow):=20=EC=A0=9C=EC=96=B4=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=20=EC=8B=9C=20writer=EC=99=80=20company=5Fco?= =?UTF-8?q?de=20=EC=9E=90=EB=8F=99=20=EC=9E=85=EB=A0=A5=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🐛 문제: - 제어(플로우) 실행으로 데이터 INSERT 시 writer, company_code 컬럼이 비어있는 문제 - 플로우 실행 API에 인증이 없어 사용자 정보를 사용할 수 없었음 ✅ 해결: 1. 플로우 실행 API에 authenticateToken 미들웨어 추가 2. 사용자 정보(userId, userName, companyCode)를 contextData에 포함 3. INSERT 노드 실행 시 writer와 company_code 자동 추가 - 필드 매핑에 없는 경우에만 자동 추가 - writer: 현재 로그인한 사용자 ID - company_code: 현재 사용자의 회사 코드 - 최고 관리자(companyCode = '*')는 제외 4. 플로우 제어 자동 감지 개선 - flowConfig가 있으면 controlMode 없이도 플로우 모드로 인식 - 데이터 미선택 시 명확한 오류 메시지 표시 🎯 영향: - 입고처리, 출고처리 등 제어 기반 데이터 생성 시 멀티테넌시 보장 - 데이터 추적성 향상 (누가 생성했는지 자동 기록) 📝 수정 파일: - frontend/lib/utils/buttonActions.ts - backend-node/src/routes/dataflow/node-flows.ts - backend-node/src/services/nodeFlowExecutionService.ts --- .../src/routes/dataflow/node-flows.ts | 15 ++++++++++-- .../src/services/nodeFlowExecutionService.ts | 24 +++++++++++++++++++ frontend/lib/utils/buttonActions.ts | 10 +++++++- 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/backend-node/src/routes/dataflow/node-flows.ts b/backend-node/src/routes/dataflow/node-flows.ts index 7ede970a..f13d65cf 100644 --- a/backend-node/src/routes/dataflow/node-flows.ts +++ b/backend-node/src/routes/dataflow/node-flows.ts @@ -7,6 +7,7 @@ import { query, queryOne } from "../../database/db"; import { logger } from "../../utils/logger"; import { NodeFlowExecutionService } from "../../services/nodeFlowExecutionService"; import { AuthenticatedRequest } from "../../types/auth"; +import { authenticateToken } from "../../middleware/authMiddleware"; const router = Router(); @@ -217,19 +218,29 @@ router.delete("/:flowId", async (req: Request, res: Response) => { * 플로우 실행 * POST /api/dataflow/node-flows/:flowId/execute */ -router.post("/:flowId/execute", async (req: Request, res: Response) => { +router.post("/:flowId/execute", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { try { const { flowId } = req.params; const contextData = req.body; logger.info(`🚀 플로우 실행 요청: flowId=${flowId}`, { contextDataKeys: Object.keys(contextData), + userId: req.user?.userId, + companyCode: req.user?.companyCode, }); + // 사용자 정보를 contextData에 추가 + const enrichedContextData = { + ...contextData, + userId: req.user?.userId, + userName: req.user?.userName, + companyCode: req.user?.companyCode, + }; + // 플로우 실행 const result = await NodeFlowExecutionService.executeFlow( parseInt(flowId, 10), - contextData + enrichedContextData ); return res.json({ diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index 546b215a..9cdd85f3 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -938,6 +938,30 @@ export class NodeFlowExecutionService { insertedData[mapping.targetField] = value; }); + // 🆕 writer와 company_code 자동 추가 (필드 매핑에 없는 경우) + const hasWriterMapping = fieldMappings.some((m: any) => m.targetField === "writer"); + const hasCompanyCodeMapping = fieldMappings.some((m: any) => m.targetField === "company_code"); + + // 컨텍스트에서 사용자 정보 추출 + const userId = context.buttonContext?.userId; + const companyCode = context.buttonContext?.companyCode; + + // writer 자동 추가 (매핑에 없고, 컨텍스트에 userId가 있는 경우) + if (!hasWriterMapping && userId) { + fields.push("writer"); + values.push(userId); + insertedData.writer = userId; + console.log(` 🔧 자동 추가: writer = ${userId}`); + } + + // company_code 자동 추가 (매핑에 없고, 컨텍스트에 companyCode가 있는 경우) + if (!hasCompanyCodeMapping && companyCode && companyCode !== "*") { + fields.push("company_code"); + values.push(companyCode); + insertedData.company_code = companyCode; + console.log(` 🔧 자동 추가: company_code = ${companyCode}`); + } + const sql = ` INSERT INTO ${targetTable} (${fields.join(", ")}) VALUES (${fields.map((_, i) => `$${i + 1}`).join(", ")}) diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 5f825cdc..0d69254b 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -1673,7 +1673,11 @@ export class ButtonActionExecutor { }); // 🔥 새로운 버튼 액션 실행 시스템 사용 - if (config.dataflowConfig?.controlMode === "flow" && config.dataflowConfig?.flowConfig) { + // flowConfig가 있으면 controlMode가 명시되지 않아도 플로우 모드로 간주 + const hasFlowConfig = config.dataflowConfig?.flowConfig && config.dataflowConfig.flowConfig.flowId; + const isFlowMode = config.dataflowConfig?.controlMode === "flow" || hasFlowConfig; + + if (isFlowMode && config.dataflowConfig?.flowConfig) { console.log("🎯 노드 플로우 실행:", config.dataflowConfig.flowConfig); const { flowId, executionTiming } = config.dataflowConfig.flowConfig; @@ -1711,6 +1715,8 @@ export class ButtonActionExecutor { }); } else { console.warn("⚠️ flow-selection 모드이지만 선택된 플로우 데이터가 없습니다."); + toast.error("플로우에서 데이터를 먼저 선택해주세요."); + return false; } break; @@ -1723,6 +1729,8 @@ export class ButtonActionExecutor { }); } else { console.warn("⚠️ table-selection 모드이지만 선택된 행이 없습니다."); + toast.error("테이블에서 처리할 항목을 먼저 선택해주세요."); + return false; } break; From 9fda390c55132d89024546256e0e4a832b5bbd92 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 25 Nov 2025 09:34:44 +0900 Subject: [PATCH 20/29] =?UTF-8?q?=EC=BB=A4=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/screenManagementController.ts | 4 +- .../src/services/screenManagementService.ts | 14 +-- frontend/components/screen/ScreenList.tsx | 95 +++++++++++++++---- 3 files changed, 84 insertions(+), 29 deletions(-) diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index be3a16a3..0ff80988 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -148,11 +148,11 @@ export const updateScreenInfo = async ( try { const { id } = req.params; const { companyCode } = req.user as any; - const { screenName, description, isActive } = req.body; + const { screenName, tableName, description, isActive } = req.body; await screenManagementService.updateScreenInfo( parseInt(id), - { screenName, description, isActive }, + { screenName, tableName, description, isActive }, companyCode ); res.json({ success: true, message: "화면 정보가 수정되었습니다." }); diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 6c3a3430..9dbe0270 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -321,7 +321,7 @@ export class ScreenManagementService { */ async updateScreenInfo( screenId: number, - updateData: { screenName: string; description?: string; isActive: string }, + updateData: { screenName: string; tableName?: string; description?: string; isActive: string }, userCompanyCode: string ): Promise { // 권한 확인 @@ -343,16 +343,18 @@ export class ScreenManagementService { throw new Error("이 화면을 수정할 권한이 없습니다."); } - // 화면 정보 업데이트 + // 화면 정보 업데이트 (tableName 포함) await query( `UPDATE screen_definitions SET screen_name = $1, - description = $2, - is_active = $3, - updated_date = $4 - WHERE screen_id = $5`, + table_name = $2, + description = $3, + is_active = $4, + updated_date = $5 + WHERE screen_id = $6`, [ updateData.screenName, + updateData.tableName || null, updateData.description || null, updateData.isActive, new Date(), diff --git a/frontend/components/screen/ScreenList.tsx b/frontend/components/screen/ScreenList.tsx index 51a09164..d2d3e367 100644 --- a/frontend/components/screen/ScreenList.tsx +++ b/frontend/components/screen/ScreenList.tsx @@ -35,7 +35,10 @@ import { DialogDescription, } from "@/components/ui/dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette, RotateCcw, Trash } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { cn } from "@/lib/utils"; +import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette, RotateCcw, Trash, Check, ChevronsUpDown } from "lucide-react"; import { ScreenDefinition } from "@/types/screen"; import { screenApi } from "@/lib/api/screen"; import CreateScreenModal from "./CreateScreenModal"; @@ -127,8 +130,9 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr isActive: "Y", tableName: "", }); - const [tables, setTables] = useState([]); + const [tables, setTables] = useState>([]); const [loadingTables, setLoadingTables] = useState(false); + const [tableComboboxOpen, setTableComboboxOpen] = useState(false); // 미리보기 관련 상태 const [previewDialogOpen, setPreviewDialogOpen] = useState(false); @@ -279,9 +283,12 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr const { tableManagementApi } = await import("@/lib/api/tableManagement"); const response = await tableManagementApi.getTableList(); if (response.success && response.data) { - // tableName만 추출 (camelCase) - const tableNames = response.data.map((table: any) => table.tableName); - setTables(tableNames); + // tableName과 displayName 매핑 (백엔드에서 displayName으로 라벨을 반환함) + const tableList = response.data.map((table: any) => ({ + tableName: table.tableName, + tableLabel: table.displayName || table.tableName, + })); + setTables(tableList); } } catch (error) { console.error("테이블 목록 조회 실패:", error); @@ -297,6 +304,10 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr // 화면 정보 업데이트 API 호출 await screenApi.updateScreenInfo(screenToEdit.screenId, editFormData); + // 선택된 테이블의 라벨 찾기 + const selectedTable = tables.find((t) => t.tableName === editFormData.tableName); + const tableLabel = selectedTable?.tableLabel || editFormData.tableName; + // 목록에서 해당 화면 정보 업데이트 setScreens((prev) => prev.map((s) => @@ -304,6 +315,8 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr ? { ...s, screenName: editFormData.screenName, + tableName: editFormData.tableName, + tableLabel: tableLabel, description: editFormData.description, isActive: editFormData.isActive, } @@ -1202,22 +1215,62 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
- + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {tables.map((table) => ( + { + setEditFormData({ ...editFormData, tableName: table.tableName }); + setTableComboboxOpen(false); + }} + className="text-xs sm:text-sm" + > + +
+ {table.tableLabel} + {table.tableName} +
+
+ ))} +
+
+
+
+
From 119afcaf42fffc520bfea4c8ab7555b805e65ea4 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 25 Nov 2025 09:35:47 +0900 Subject: [PATCH 21/29] =?UTF-8?q?=EB=B0=B0=EC=B9=98=EB=90=9C=20=EA=B0=9D?= =?UTF-8?q?=EC=B2=B4=20=EB=AA=A9=EB=A1=9D=20=EA=B3=84=EC=B8=B5=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EB=B0=8F=20=EC=95=84=EC=BD=94=EB=94=94=EC=96=B8=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/yard-3d/DigitalTwinEditor.tsx | 134 +++++++++++++++--- 1 file changed, 112 insertions(+), 22 deletions(-) diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx index 47902b42..b08d4fc1 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx @@ -1575,33 +1575,123 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi )}
- {/* 배치된 객체 목록 */} -
+ {/* 배치된 객체 목록 (계층 구조) */} +

배치된 객체 ({placedObjects.length})

{placedObjects.length === 0 ? (
상단 도구를 드래그하여 배치하세요
) : ( -
- {placedObjects.map((obj) => ( -
handleObjectClick(obj.id)} - className={`cursor-pointer rounded-lg border p-3 transition-all ${ - selectedObject?.id === obj.id ? "border-primary bg-primary/10" : "hover:border-primary/50" - }`} - > -
- {obj.name} -
-
-

- 위치: ({obj.position.x.toFixed(1)}, {obj.position.z.toFixed(1)}) -

- {obj.areaKey &&

Area: {obj.areaKey}

} -
- ))} -
+ + {/* Area별로 그룹핑 */} + {(() => { + // Area 객체들 + const areaObjects = placedObjects.filter((obj) => obj.type === "area"); + + // Area가 없으면 기존 방식으로 표시 + if (areaObjects.length === 0) { + return ( +
+ {placedObjects.map((obj) => ( +
handleObjectClick(obj.id)} + className={`cursor-pointer rounded-lg border p-3 transition-all ${ + selectedObject?.id === obj.id + ? "border-primary bg-primary/10" + : "hover:border-primary/50" + }`} + > +
+ {obj.name} +
+
+

+ 위치: ({obj.position.x.toFixed(1)}, {obj.position.z.toFixed(1)}) +

+
+ ))} +
+ ); + } + + // Area별로 Location들을 그룹핑 + return areaObjects.map((areaObj) => { + // 이 Area의 자식 Location들 찾기 + const childLocations = placedObjects.filter( + (obj) => + obj.type !== "area" && + obj.areaKey === areaObj.areaKey && + (obj.parentId === areaObj.id || obj.externalKey === areaObj.externalKey), + ); + + return ( + + +
{ + e.stopPropagation(); + handleObjectClick(areaObj.id); + }} + > +
+ + {areaObj.name} +
+
+ ({childLocations.length}) +
+
+
+ + + {childLocations.length === 0 ? ( +

+ Location이 없습니다 +

+ ) : ( +
+ {childLocations.map((locationObj) => ( +
handleObjectClick(locationObj.id)} + className={`cursor-pointer rounded-lg border p-2 transition-all ${ + selectedObject?.id === locationObj.id + ? "border-primary bg-primary/10" + : "hover:border-primary/50" + }`} + > +
+
+ + {locationObj.name} +
+
+
+

+ 위치: ({locationObj.position.x.toFixed(1)}, {locationObj.position.z.toFixed(1)}) +

+ {locationObj.locaKey && ( +

+ Key: {locationObj.locaKey} +

+ )} +
+ ))} +
+ )} + + + ); + }); + })()} + )}
From 5e2392c417937b6f4f3a49af4f86503d7bae8c7d Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 25 Nov 2025 10:06:56 +0900 Subject: [PATCH 22/29] =?UTF-8?q?=ED=83=AD=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screen/InteractiveScreenViewer.tsx | 12 +- .../components/screen/RealtimePreview.tsx | 91 ++++++--- .../components/screen/widgets/TabsWidget.tsx | 193 ++++++++++++------ .../components/tabs/tabs-component.tsx | 36 +++- 4 files changed, 238 insertions(+), 94 deletions(-) diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index 4b5d70e8..4c3e6506 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -346,6 +346,14 @@ export const InteractiveScreenViewer: React.FC = ( // 실제 사용 가능한 위젯 렌더링 const renderInteractiveWidget = (comp: ComponentData) => { + console.log("🎯 renderInteractiveWidget 호출:", { + type: comp.type, + id: comp.id, + componentId: (comp as any).componentId, + hasComponentConfig: !!(comp as any).componentConfig, + componentConfig: (comp as any).componentConfig, + }); + // 데이터 테이블 컴포넌트 처리 if (comp.type === "datatable") { return ( @@ -398,7 +406,8 @@ export const InteractiveScreenViewer: React.FC = ( } // 탭 컴포넌트 처리 - if (comp.type === "tabs" || (comp.type === "component" && comp.componentId === "tabs-widget")) { + const componentType = (comp as any).componentType || (comp as any).componentId; + if (comp.type === "tabs" || (comp.type === "component" && componentType === "tabs-widget")) { const TabsWidget = require("@/components/screen/widgets/TabsWidget").TabsWidget; // componentConfig에서 탭 정보 추출 @@ -416,6 +425,7 @@ export const InteractiveScreenViewer: React.FC = ( console.log("🔍 탭 컴포넌트 렌더링:", { originalType: comp.type, + componentType, componentId: (comp as any).componentId, tabs: tabsComponent.tabs, tabsConfig, diff --git a/frontend/components/screen/RealtimePreview.tsx b/frontend/components/screen/RealtimePreview.tsx index 7a0dbc34..0270ffa8 100644 --- a/frontend/components/screen/RealtimePreview.tsx +++ b/frontend/components/screen/RealtimePreview.tsx @@ -555,41 +555,70 @@ export const RealtimePreviewDynamic: React.FC = ({ })()} {/* 탭 컴포넌트 타입 */} - {(type === "tabs" || (type === "component" && (component as any).componentId === "tabs-widget")) && + {(type === "tabs" || (type === "component" && ((component as any).componentType === "tabs-widget" || (component as any).componentId === "tabs-widget"))) && (() => { - const tabsComponent = component as any; - // componentConfig에서 탭 정보 가져오기 - const tabs = tabsComponent.componentConfig?.tabs || tabsComponent.tabs || []; + console.log("🎯 탭 컴포넌트 조건 충족:", { + type, + componentType: (component as any).componentType, + componentId: (component as any).componentId, + isDesignMode, + }); - return ( -
-
-
- -
-

탭 컴포넌트

-

- {tabs.length > 0 - ? `${tabs.length}개의 탭 (실제 화면에서 표시됩니다)` - : "탭이 없습니다. 설정 패널에서 탭을 추가하세요"} -

- {tabs.length > 0 && ( -
- {tabs.map((tab: any, index: number) => ( - - {tab.label || `탭 ${index + 1}`} - {tab.screenName && ( - - ({tab.screenName}) - - )} - - ))} + if (isDesignMode) { + // 디자인 모드: 미리보기 표시 + const tabsComponent = component as any; + const tabs = tabsComponent.componentConfig?.tabs || tabsComponent.tabs || []; + + return ( +
+
+
+
- )} +

탭 컴포넌트

+

+ {tabs.length > 0 + ? `${tabs.length}개의 탭 (실제 화면에서 표시됩니다)` + : "탭이 없습니다. 설정 패널에서 탭을 추가하세요"} +

+ {tabs.length > 0 && ( +
+ {tabs.map((tab: any, index: number) => ( + + {tab.label || `탭 ${index + 1}`} + {tab.screenName && ( + + ({tab.screenName}) + + )} + + ))} +
+ )} +
-
- ); + ); + } else { + // 실제 화면: TabsWidget 렌더링 + const TabsWidget = require("@/components/screen/widgets/TabsWidget").TabsWidget; + const tabsConfig = (component as any).componentConfig || {}; + const tabsComponent = { + ...component, + type: "tabs" as const, + tabs: tabsConfig.tabs || [], + defaultTab: tabsConfig.defaultTab, + orientation: tabsConfig.orientation || "horizontal", + variant: tabsConfig.variant || "default", + allowCloseable: tabsConfig.allowCloseable || false, + persistSelection: tabsConfig.persistSelection || false, + }; + + return ( +
+ +
+ ); + } })()} {/* 그룹 타입 */} diff --git a/frontend/components/screen/widgets/TabsWidget.tsx b/frontend/components/screen/widgets/TabsWidget.tsx index 90608a4b..683017cf 100644 --- a/frontend/components/screen/widgets/TabsWidget.tsx +++ b/frontend/components/screen/widgets/TabsWidget.tsx @@ -60,24 +60,45 @@ export function TabsWidget({ component, className, style }: TabsWidgetProps) { } }, [selectedTab, persistSelection, storageKey]); + // 초기 로드 시 선택된 탭의 화면 불러오기 + useEffect(() => { + const currentTab = visibleTabs.find((t) => t.id === selectedTab); + console.log("🔄 초기 탭 로드:", { + selectedTab, + currentTab, + hasScreenId: !!currentTab?.screenId, + screenId: currentTab?.screenId, + }); + + if (currentTab && currentTab.screenId && !screenLayouts[currentTab.screenId]) { + console.log("📥 초기 화면 로딩 시작:", currentTab.screenId); + loadScreenLayout(currentTab.screenId); + } + }, [selectedTab, visibleTabs]); + // 화면 레이아웃 로드 const loadScreenLayout = async (screenId: number) => { if (screenLayouts[screenId]) { + console.log("✅ 이미 로드된 화면:", screenId); return; // 이미 로드됨 } + console.log("📥 화면 레이아웃 로딩 시작:", screenId); setLoadingScreens((prev) => ({ ...prev, [screenId]: true })); try { - const response = await fetch(`/api/screen-management/screens/${screenId}/layout`); - if (response.ok) { - const data = await response.json(); - if (data.success && data.data) { - setScreenLayouts((prev) => ({ ...prev, [screenId]: data.data })); - } + const { apiClient } = await import("@/lib/api/client"); + const response = await apiClient.get(`/screen-management/screens/${screenId}/layout`); + console.log("📦 API 응답:", { screenId, success: response.data.success, hasData: !!response.data.data }); + + if (response.data.success && response.data.data) { + console.log("✅ 화면 레이아웃 로드 완료:", screenId); + setScreenLayouts((prev) => ({ ...prev, [screenId]: response.data.data })); + } else { + console.error("❌ 화면 레이아웃 로드 실패 - success false"); } } catch (error) { - console.error(`Failed to load screen layout ${screenId}:`, error); + console.error(`❌ 화면 레이아웃 로드 실패 ${screenId}:`, error); } finally { setLoadingScreens((prev) => ({ ...prev, [screenId]: false })); } @@ -85,11 +106,15 @@ export function TabsWidget({ component, className, style }: TabsWidgetProps) { // 탭 변경 핸들러 const handleTabChange = (tabId: string) => { + console.log("🔄 탭 변경:", tabId); setSelectedTab(tabId); // 해당 탭의 화면 로드 const tab = visibleTabs.find((t) => t.id === tabId); + console.log("🔍 선택된 탭 정보:", { tab, hasScreenId: !!tab?.screenId, screenId: tab?.screenId }); + if (tab && tab.screenId && !screenLayouts[tab.screenId]) { + console.log("📥 탭 변경 시 화면 로딩:", tab.screenId); loadScreenLayout(tab.screenId); } }; @@ -120,6 +145,7 @@ export function TabsWidget({ component, className, style }: TabsWidgetProps) { }; if (visibleTabs.length === 0) { + console.log("⚠️ 보이는 탭이 없음"); return (

탭이 없습니다

@@ -127,61 +153,106 @@ export function TabsWidget({ component, className, style }: TabsWidgetProps) { ); } - return ( - - - {visibleTabs.map((tab) => ( -
- - {tab.label} - - {allowCloseable && ( - - )} -
- ))} -
+ console.log("🎨 TabsWidget 최종 렌더링:", { + visibleTabsCount: visibleTabs.length, + selectedTab, + screenLayoutsKeys: Object.keys(screenLayouts), + loadingScreensKeys: Object.keys(loadingScreens), + }); - {visibleTabs.map((tab) => ( - - {tab.screenId ? ( - loadingScreens[tab.screenId] ? ( -
- - 화면 로딩 중... + return ( +
+ +
+ + {visibleTabs.map((tab) => ( +
+ + {tab.label} + + {allowCloseable && ( + + )}
- ) : screenLayouts[tab.screenId] ? ( -
- -
- ) : ( -
-

화면을 불러올 수 없습니다

-
- ) - ) : ( -
-

연결된 화면이 없습니다

-
- )} - - ))} - + ))} +
+
+ +
+ {visibleTabs.map((tab) => ( + + {tab.screenId ? ( + loadingScreens[tab.screenId] ? ( +
+ + 화면 로딩 중... +
+ ) : screenLayouts[tab.screenId] ? ( + (() => { + const layoutData = screenLayouts[tab.screenId]; + const { components = [], screenResolution } = layoutData; + + console.log("🎯 렌더링할 화면 데이터:", { + screenId: tab.screenId, + componentsCount: components.length, + screenResolution, + }); + + const designWidth = screenResolution?.width || 1920; + const designHeight = screenResolution?.height || 1080; + + return ( +
+
+ {components.map((component: any) => ( + + ))} +
+
+ ); + })() + ) : ( +
+

화면을 불러올 수 없습니다

+
+ ) + ) : ( +
+

연결된 화면이 없습니다

+
+ )} +
+ ))} +
+
+
); } diff --git a/frontend/lib/registry/components/tabs/tabs-component.tsx b/frontend/lib/registry/components/tabs/tabs-component.tsx index 18fbf297..9006d78e 100644 --- a/frontend/lib/registry/components/tabs/tabs-component.tsx +++ b/frontend/lib/registry/components/tabs/tabs-component.tsx @@ -6,6 +6,40 @@ import { ComponentCategory } from "@/types/component"; import { Folder } from "lucide-react"; import type { TabsComponent, TabItem } from "@/types/screen-management"; +// TabsWidget 래퍼 컴포넌트 +const TabsWidgetWrapper: React.FC = (props) => { + const { component, ...restProps } = props; + + // componentConfig에서 탭 정보 추출 + const tabsConfig = component.componentConfig || {}; + const tabsComponent = { + ...component, + type: "tabs" as const, + tabs: tabsConfig.tabs || [], + defaultTab: tabsConfig.defaultTab, + orientation: tabsConfig.orientation || "horizontal", + variant: tabsConfig.variant || "default", + allowCloseable: tabsConfig.allowCloseable || false, + persistSelection: tabsConfig.persistSelection || false, + }; + + console.log("🎨 TabsWidget 렌더링:", { + componentId: component.id, + tabs: tabsComponent.tabs, + tabsLength: tabsComponent.tabs.length, + component, + }); + + // TabsWidget 동적 로드 + const TabsWidget = require("@/components/screen/widgets/TabsWidget").TabsWidget; + + return ( +
+ +
+ ); +}; + /** * 탭 컴포넌트 정의 * @@ -17,7 +51,7 @@ ComponentRegistry.registerComponent({ description: "화면을 탭으로 전환할 수 있는 컴포넌트입니다. 각 탭마다 다른 화면을 연결할 수 있습니다.", category: ComponentCategory.LAYOUT, webType: "text" as any, // 레이아웃 컴포넌트이므로 임시값 - component: () => null as any, // 레이아웃 컴포넌트이므로 임시값 + component: TabsWidgetWrapper, // ✅ 실제 TabsWidget 렌더러 defaultConfig: {}, tags: ["tabs", "navigation", "layout", "screen"], icon: Folder, From a9f57add62f2256f86a08ea21d12bdd8271e7d1d Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 25 Nov 2025 12:07:14 +0900 Subject: [PATCH 23/29] =?UTF-8?q?feat:=20=EC=88=98=EC=A3=BC=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=ED=92=88=EB=AA=A9=20=EC=B6=94=EA=B0=80/=EC=88=98?= =?UTF-8?q?=EC=A0=95/=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EditModal의 handleSave가 button-primary까지 전달되도록 수정 - ConditionalContainer/ConditionalSectionViewer에 onSave prop 추가 - DynamicComponentRenderer와 InteractiveScreenViewerDynamic에 onSave 전달 로직 추가 - ButtonActionExecutor에서 context.onSave 콜백 우선 실행 로직 구현 - 신규 품목 추가 시 groupByColumns 값 자동 포함 처리 기능: - 품목 추가: order_no 자동 설정 - 품목 수정: 변경 필드만 부분 업데이트 - 품목 삭제: originalGroupData 비교 후 제거 --- frontend/components/screen/EditModal.tsx | 220 ++++++++++++------ .../screen/InteractiveScreenViewerDynamic.tsx | 6 + .../lib/registry/DynamicComponentRenderer.tsx | 3 + .../button-primary/ButtonPrimaryComponent.tsx | 7 + .../ConditionalContainerComponent.tsx | 3 + .../ConditionalSectionViewer.tsx | 20 +- .../components/conditional-container/types.ts | 2 + frontend/lib/utils/buttonActions.ts | 19 +- 8 files changed, 204 insertions(+), 76 deletions(-) diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 4e756600..3280891f 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -305,84 +305,173 @@ export const EditModal: React.FC = ({ className }) => { } try { - // 🆕 그룹 데이터가 있는 경우: 모든 품목 일괄 수정 - if (groupData.length > 0) { - console.log("🔄 그룹 데이터 일괄 수정 시작:", { + // 🆕 그룹 데이터가 있는 경우: 모든 품목 일괄 처리 (추가/수정/삭제) + if (groupData.length > 0 || originalGroupData.length > 0) { + console.log("🔄 그룹 데이터 일괄 처리 시작:", { groupDataLength: groupData.length, originalGroupDataLength: originalGroupData.length, + groupData, + originalGroupData, + tableName: screenData.screenInfo.tableName, + screenId: modalState.screenId, }); + let insertedCount = 0; let updatedCount = 0; + let deletedCount = 0; - for (let i = 0; i < groupData.length; i++) { - const currentData = groupData[i]; - const originalItemData = originalGroupData[i]; + // 🆕 sales_order_mng 테이블의 실제 컬럼만 포함 (조인된 컬럼 제외) + const salesOrderColumns = [ + "id", + "order_no", + "customer_code", + "customer_name", + "order_date", + "delivery_date", + "item_code", + "quantity", + "unit_price", + "amount", + "status", + "notes", + "created_at", + "updated_at", + "company_code", + ]; - if (!originalItemData) { - console.warn(`원본 데이터가 없습니다 (index: ${i})`); - continue; - } + // 1️⃣ 신규 품목 추가 (id가 없는 항목) + for (const currentData of groupData) { + if (!currentData.id) { + console.log("➕ 신규 품목 추가:", currentData); - // 변경된 필드만 추출 - const changedData: Record = {}; - - // 🆕 sales_order_mng 테이블의 실제 컬럼만 포함 (조인된 컬럼 제외) - const salesOrderColumns = [ - "id", - "order_no", - "customer_code", - "customer_name", - "order_date", - "delivery_date", - "item_code", - "quantity", - "unit_price", - "amount", - "status", - "notes", - "created_at", - "updated_at", - "company_code", - ]; - - Object.keys(currentData).forEach((key) => { - // sales_order_mng 테이블의 컬럼만 처리 (조인 컬럼 제외) - if (!salesOrderColumns.includes(key)) { - return; + // 실제 테이블 컬럼만 추출 + const insertData: Record = {}; + Object.keys(currentData).forEach((key) => { + if (salesOrderColumns.includes(key) && key !== "id") { + insertData[key] = currentData[key]; + } + }); + + // 🆕 groupByColumns의 값을 강제로 포함 (order_no 등) + if (modalState.groupByColumns && modalState.groupByColumns.length > 0) { + modalState.groupByColumns.forEach((colName) => { + // 기존 품목(groupData[0])에서 groupByColumns 값 가져오기 + const referenceData = originalGroupData[0] || groupData.find(item => item.id); + if (referenceData && referenceData[colName]) { + insertData[colName] = referenceData[colName]; + console.log(`🔑 [신규 품목] ${colName} 값 추가:`, referenceData[colName]); + } + }); } - - if (currentData[key] !== originalItemData[key]) { - changedData[key] = currentData[key]; + + console.log("📦 [신규 품목] 최종 insertData:", insertData); + + try { + const response = await dynamicFormApi.saveFormData({ + screenId: modalState.screenId || 0, + tableName: screenData.screenInfo.tableName, + data: insertData, + }); + + if (response.success) { + insertedCount++; + console.log("✅ 신규 품목 추가 성공:", response.data); + } else { + console.error("❌ 신규 품목 추가 실패:", response.message); + } + } catch (error: any) { + console.error("❌ 신규 품목 추가 오류:", error); } - }); - - // 변경사항이 없으면 스킵 - if (Object.keys(changedData).length === 0) { - console.log(`변경사항 없음 (index: ${i})`); - continue; - } - - // 기본키 확인 - const recordId = originalItemData.id || Object.values(originalItemData)[0]; - - // UPDATE 실행 - const response = await dynamicFormApi.updateFormDataPartial( - recordId, - originalItemData, - changedData, - screenData.screenInfo.tableName, - ); - - if (response.success) { - updatedCount++; - console.log(`✅ 품목 ${i + 1} 수정 성공 (id: ${recordId})`); - } else { - console.error(`❌ 품목 ${i + 1} 수정 실패 (id: ${recordId}):`, response.message); } } - if (updatedCount > 0) { - toast.success(`${updatedCount}개의 품목이 수정되었습니다.`); + // 2️⃣ 기존 품목 수정 (id가 있는 항목) + for (const currentData of groupData) { + if (currentData.id) { + // id 기반 매칭 (인덱스 기반 X) + const originalItemData = originalGroupData.find( + (orig) => orig.id === currentData.id + ); + + if (!originalItemData) { + console.warn(`원본 데이터를 찾을 수 없습니다 (id: ${currentData.id})`); + continue; + } + + // 변경된 필드만 추출 + const changedData: Record = {}; + Object.keys(currentData).forEach((key) => { + // sales_order_mng 테이블의 컬럼만 처리 (조인 컬럼 제외) + if (!salesOrderColumns.includes(key)) { + return; + } + + if (currentData[key] !== originalItemData[key]) { + changedData[key] = currentData[key]; + } + }); + + // 변경사항이 없으면 스킵 + if (Object.keys(changedData).length === 0) { + console.log(`변경사항 없음 (id: ${currentData.id})`); + continue; + } + + // UPDATE 실행 + try { + const response = await dynamicFormApi.updateFormDataPartial( + currentData.id, + originalItemData, + changedData, + screenData.screenInfo.tableName, + ); + + if (response.success) { + updatedCount++; + console.log(`✅ 품목 수정 성공 (id: ${currentData.id})`); + } else { + console.error(`❌ 품목 수정 실패 (id: ${currentData.id}):`, response.message); + } + } catch (error: any) { + console.error(`❌ 품목 수정 오류 (id: ${currentData.id}):`, error); + } + } + } + + // 3️⃣ 삭제된 품목 제거 (원본에는 있지만 현재 데이터에는 없는 항목) + const currentIds = new Set(groupData.map((item) => item.id).filter(Boolean)); + const deletedItems = originalGroupData.filter( + (orig) => orig.id && !currentIds.has(orig.id) + ); + + for (const deletedItem of deletedItems) { + console.log("🗑️ 품목 삭제:", deletedItem); + + try { + const response = await dynamicFormApi.deleteFormDataFromTable( + deletedItem.id, + screenData.screenInfo.tableName + ); + + if (response.success) { + deletedCount++; + console.log(`✅ 품목 삭제 성공 (id: ${deletedItem.id})`); + } else { + console.error(`❌ 품목 삭제 실패 (id: ${deletedItem.id}):`, response.message); + } + } catch (error: any) { + console.error(`❌ 품목 삭제 오류 (id: ${deletedItem.id}):`, error); + } + } + + // 결과 메시지 + const messages: string[] = []; + if (insertedCount > 0) messages.push(`${insertedCount}개 추가`); + if (updatedCount > 0) messages.push(`${updatedCount}개 수정`); + if (deletedCount > 0) messages.push(`${deletedCount}개 삭제`); + + if (messages.length > 0) { + toast.success(`품목이 저장되었습니다 (${messages.join(", ")})`); // 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침) if (modalState.onSave) { @@ -585,6 +674,7 @@ export const EditModal: React.FC = ({ className }) => { tableName: screenData.screenInfo?.tableName, }} onSave={handleSave} + isInModal={true} // 🆕 그룹 데이터를 ModalRepeaterTable에 전달 groupedData={groupData.length > 0 ? groupData : undefined} /> diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index d1cd2a5f..fb5046c3 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -48,6 +48,8 @@ interface InteractiveScreenViewerProps { companyCode?: string; // 🆕 그룹 데이터 (EditModal에서 전달) groupedData?: Record[]; + // 🆕 EditModal 내부인지 여부 (button-primary가 EditModal의 handleSave 사용하도록) + isInModal?: boolean; } export const InteractiveScreenViewerDynamic: React.FC = ({ @@ -64,6 +66,7 @@ export const InteractiveScreenViewerDynamic: React.FC { const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 const { userName: authUserName, user: authUser } = useAuth(); @@ -329,6 +332,7 @@ export const InteractiveScreenViewerDynamic: React.FC { @@ -401,6 +405,8 @@ export const InteractiveScreenViewerDynamic: React.FC { diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 92fd89e8..bf2b6ecb 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -105,6 +105,7 @@ export interface DynamicComponentRendererProps { companyCode?: string; // 🆕 현재 사용자의 회사 코드 onRefresh?: () => void; onClose?: () => void; + onSave?: () => Promise; // 🆕 EditModal의 handleSave 콜백 // 테이블 선택된 행 정보 (다중 선택 액션용) selectedRows?: any[]; // 🆕 그룹 데이터 (EditModal → ModalRepeaterTable) @@ -244,6 +245,7 @@ export const DynamicComponentRenderer: React.FC = selectedScreen, // 🆕 화면 정보 onRefresh, onClose, + onSave, // 🆕 EditModal의 handleSave 콜백 screenId, userId, // 🆕 사용자 ID userName, // 🆕 사용자 이름 @@ -358,6 +360,7 @@ export const DynamicComponentRenderer: React.FC = selectedScreen, // 🆕 화면 정보 onRefresh, onClose, + onSave, // 🆕 EditModal의 handleSave 콜백 screenId, userId, // 🆕 사용자 ID userName, // 🆕 사용자 이름 diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 112a285c..d2b69074 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -35,6 +35,7 @@ export interface ButtonPrimaryComponentProps extends ComponentRendererProps { onRefresh?: () => void; onClose?: () => void; onFlowRefresh?: () => void; + onSave?: () => Promise; // 🆕 EditModal의 handleSave 콜백 // 폼 데이터 관련 originalData?: Record; // 부분 업데이트용 원본 데이터 @@ -83,6 +84,7 @@ export const ButtonPrimaryComponent: React.FC = ({ onRefresh, onClose, onFlowRefresh, + onSave, // 🆕 EditModal의 handleSave 콜백 sortBy, // 🆕 정렬 컬럼 sortOrder, // 🆕 정렬 방향 columnOrder, // 🆕 컬럼 순서 @@ -95,6 +97,10 @@ export const ButtonPrimaryComponent: React.FC = ({ ...props }) => { const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 + + // 🆕 props에서 onSave 추출 (명시적으로 선언되지 않은 경우 ...props에서 추출) + const propsOnSave = (props as any).onSave as (() => Promise) | undefined; + const finalOnSave = onSave || propsOnSave; // 🆕 플로우 단계별 표시 제어 const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig; @@ -415,6 +421,7 @@ export const ButtonPrimaryComponent: React.FC = ({ onRefresh, onClose, onFlowRefresh, // 플로우 새로고침 콜백 추가 + onSave: finalOnSave, // 🆕 EditModal의 handleSave 콜백 (props에서도 추출) // 테이블 선택된 행 정보 추가 selectedRows, selectedRowsData, diff --git a/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx b/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx index 2589026f..626ee137 100644 --- a/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx +++ b/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx @@ -41,6 +41,7 @@ export function ConditionalContainerComponent({ style, className, groupedData, // 🆕 그룹 데이터 + onSave, // 🆕 EditModal의 handleSave 콜백 }: ConditionalContainerProps) { console.log("🎯 ConditionalContainerComponent 렌더링!", { isDesignMode, @@ -179,6 +180,7 @@ export function ConditionalContainerComponent({ formData={formData} onFormDataChange={onFormDataChange} groupedData={groupedData} + onSave={onSave} /> ))}
@@ -199,6 +201,7 @@ export function ConditionalContainerComponent({ formData={formData} onFormDataChange={onFormDataChange} groupedData={groupedData} + onSave={onSave} /> ) : null ) diff --git a/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx b/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx index f77dbcdb..9709b620 100644 --- a/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx +++ b/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx @@ -26,6 +26,7 @@ export function ConditionalSectionViewer({ formData, onFormDataChange, groupedData, // 🆕 그룹 데이터 + onSave, // 🆕 EditModal의 handleSave 콜백 }: ConditionalSectionViewerProps) { const { userId, userName, user } = useAuth(); const [isLoading, setIsLoading] = useState(false); @@ -153,17 +154,18 @@ export function ConditionalSectionViewer({ }} > + onSave={onSave} + />
); })} diff --git a/frontend/lib/registry/components/conditional-container/types.ts b/frontend/lib/registry/components/conditional-container/types.ts index 0cf741b2..bcd701ef 100644 --- a/frontend/lib/registry/components/conditional-container/types.ts +++ b/frontend/lib/registry/components/conditional-container/types.ts @@ -46,6 +46,7 @@ export interface ConditionalContainerProps { formData?: Record; onFormDataChange?: (fieldName: string, value: any) => void; groupedData?: Record[]; // 🆕 그룹 데이터 (EditModal → ModalRepeaterTable) + onSave?: () => Promise; // 🆕 EditModal의 handleSave 콜백 // 화면 편집기 관련 isDesignMode?: boolean; // 디자인 모드 여부 @@ -77,5 +78,6 @@ export interface ConditionalSectionViewerProps { formData?: Record; onFormDataChange?: (fieldName: string, value: any) => void; groupedData?: Record[]; // 🆕 그룹 데이터 + onSave?: () => Promise; // 🆕 EditModal의 handleSave 콜백 } diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 3b9b9d9e..ddcf0f18 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -112,6 +112,7 @@ export interface ButtonActionContext { onClose?: () => void; onRefresh?: () => void; onFlowRefresh?: () => void; // 플로우 새로고침 콜백 + onSave?: () => Promise; // 🆕 EditModal의 handleSave 콜백 // 테이블 선택된 행 정보 (다중 선택 액션용) selectedRows?: any[]; @@ -213,9 +214,23 @@ export class ButtonActionExecutor { * 저장 액션 처리 (INSERT/UPDATE 자동 판단 - DB 기반) */ private static async handleSave(config: ButtonActionConfig, context: ButtonActionContext): Promise { - const { formData, originalData, tableName, screenId } = context; + const { formData, originalData, tableName, screenId, onSave } = context; - console.log("💾 [handleSave] 저장 시작:", { formData, tableName, screenId }); + console.log("💾 [handleSave] 저장 시작:", { formData, tableName, screenId, hasOnSave: !!onSave }); + + // 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용 + if (onSave) { + console.log("✅ [handleSave] onSave 콜백 발견 - 콜백 실행"); + try { + await onSave(); + return true; + } catch (error) { + console.error("❌ [handleSave] onSave 콜백 실행 오류:", error); + throw error; + } + } + + console.log("⚠️ [handleSave] onSave 콜백 없음 - 기본 저장 로직 실행"); // 🆕 저장 전 이벤트 발생 (SelectedItemsDetailInput 등에서 최신 데이터 수집) // context.formData를 이벤트 detail에 포함하여 직접 수정 가능하게 함 From a0180d66a26fe0f1c1f5e702bba4bb331f0e9bae Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 25 Nov 2025 13:04:58 +0900 Subject: [PATCH 24/29] =?UTF-8?q?=ED=8E=B8=EC=A7=91=EA=B8=B0=20=EC=9D=B8?= =?UTF-8?q?=ED=92=8B=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95=20=EB=B0=8F?= =?UTF-8?q?=20=ED=83=AD=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=99=84?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/screen/ScreenDesigner.tsx | 24 +++- frontend/components/screen/StyleEditor.tsx | 49 +++---- .../config-panels/ButtonConfigPanel.tsx | 17 +-- .../config-panels/CheckboxConfigPanel.tsx | 24 ++-- .../screen/config-panels/CodeConfigPanel.tsx | 12 +- .../screen/config-panels/DateConfigPanel.tsx | 12 +- .../config-panels/EntityConfigPanel.tsx | 131 +++++++++++------- .../screen/config-panels/FileConfigPanel.tsx | 12 +- .../FlowVisibilityConfigPanel.tsx | 34 ++--- .../config-panels/FlowWidgetConfigPanel.tsx | 2 +- .../config-panels/NumberConfigPanel.tsx | 14 +- .../screen/config-panels/RadioConfigPanel.tsx | 22 +-- .../config-panels/SelectConfigPanel.tsx | 22 +-- .../screen/config-panels/TabsConfigPanel.tsx | 23 ++- .../screen/config-panels/TextConfigPanel.tsx | 12 +- .../config-panels/TextareaConfigPanel.tsx | 14 +- .../screen/dialogs/FlowButtonGroupDialog.tsx | 4 +- .../screen/panels/ComponentsPanel.tsx | 2 +- .../screen/panels/DataTableConfigPanel.tsx | 100 ++++++------- .../screen/panels/DetailSettingsPanel.tsx | 33 +---- .../screen/panels/FlowButtonGroupPanel.tsx | 4 +- .../components/screen/panels/LayoutsPanel.tsx | 2 +- .../screen/panels/PropertiesPanel.tsx | 6 +- .../screen/panels/ResolutionPanel.tsx | 11 +- .../screen/panels/RowSettingsPanel.tsx | 4 +- .../screen/panels/TemplatesPanel.tsx | 2 +- .../screen/panels/UnifiedPropertiesPanel.tsx | 29 +--- .../screen/panels/WebTypeConfigPanel.tsx | 8 +- .../CheckboxTypeConfigPanel.tsx | 8 +- .../webtype-configs/CodeTypeConfigPanel.tsx | 4 +- .../webtype-configs/DateTypeConfigPanel.tsx | 2 +- .../webtype-configs/EntityTypeConfigPanel.tsx | 6 +- .../webtype-configs/NumberTypeConfigPanel.tsx | 2 +- .../webtype-configs/RadioTypeConfigPanel.tsx | 2 +- .../webtype-configs/SelectTypeConfigPanel.tsx | 2 +- .../webtype-configs/TextTypeConfigPanel.tsx | 4 +- .../TextareaTypeConfigPanel.tsx | 2 +- .../components/screen/widgets/FlowWidget.tsx | 2 +- .../components/screen/widgets/InputWidget.tsx | 2 +- .../screen/widgets/SelectWidget.tsx | 2 +- 40 files changed, 342 insertions(+), 325 deletions(-) diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index dcd80a62..0127c9d1 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -981,7 +981,18 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD // 스페이스바 키 이벤트 처리 (Pan 모드) + 전역 마우스 이벤트 useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - // 입력 필드에서는 스페이스바 무시 + // 입력 필드에서는 스페이스바 무시 (activeElement로 정확하게 체크) + const activeElement = document.activeElement; + if ( + activeElement instanceof HTMLInputElement || + activeElement instanceof HTMLTextAreaElement || + activeElement?.getAttribute('contenteditable') === 'true' || + activeElement?.getAttribute('role') === 'textbox' + ) { + return; + } + + // e.target도 함께 체크 (이중 방어) if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { return; } @@ -997,6 +1008,17 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD }; const handleKeyUp = (e: KeyboardEvent) => { + // 입력 필드에서는 스페이스바 무시 + const activeElement = document.activeElement; + if ( + activeElement instanceof HTMLInputElement || + activeElement instanceof HTMLTextAreaElement || + activeElement?.getAttribute('contenteditable') === 'true' || + activeElement?.getAttribute('role') === 'textbox' + ) { + return; + } + if (e.code === "Space") { e.preventDefault(); // 스페이스바 기본 스크롤 동작 차단 setIsPanMode(false); diff --git a/frontend/components/screen/StyleEditor.tsx b/frontend/components/screen/StyleEditor.tsx index 054875f7..2e5dec7e 100644 --- a/frontend/components/screen/StyleEditor.tsx +++ b/frontend/components/screen/StyleEditor.tsx @@ -49,7 +49,6 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd value={localStyle.borderWidth || ""} onChange={(e) => handleStyleChange("borderWidth", e.target.value)} className="h-6 w-full px-2 py-0 text-xs" - style={{ fontSize: "12px" }} />
@@ -60,20 +59,20 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd value={localStyle.borderStyle || "solid"} onValueChange={(value) => handleStyleChange("borderStyle", value)} > - + - + 실선 - + 파선 - + 점선 - + 없음 @@ -93,7 +92,7 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd value={localStyle.borderColor || "#000000"} onChange={(e) => handleStyleChange("borderColor", e.target.value)} className="h-6 w-12 p-1" - style={{ fontSize: "12px" }} + className="text-xs" /> handleStyleChange("borderColor", e.target.value)} placeholder="#000000" className="h-6 flex-1 text-xs" - style={{ fontSize: "12px" }} />
@@ -116,7 +114,6 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd value={localStyle.borderRadius || ""} onChange={(e) => handleStyleChange("borderRadius", e.target.value)} className="h-6 w-full px-2 py-0 text-xs" - style={{ fontSize: "12px" }} />
@@ -142,7 +139,7 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd value={localStyle.backgroundColor || "#ffffff"} onChange={(e) => handleStyleChange("backgroundColor", e.target.value)} className="h-6 w-12 p-1" - style={{ fontSize: "12px" }} + className="text-xs" /> handleStyleChange("backgroundColor", e.target.value)} placeholder="#ffffff" className="h-6 flex-1 text-xs" - style={{ fontSize: "12px" }} />
@@ -166,7 +162,6 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd value={localStyle.backgroundImage || ""} onChange={(e) => handleStyleChange("backgroundImage", e.target.value)} className="h-6 w-full px-2 py-0 text-xs" - style={{ fontSize: "12px" }} />

위젯 배경 꾸미기용 (고급 사용자 전용) @@ -195,7 +190,7 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd value={localStyle.color || "#000000"} onChange={(e) => handleStyleChange("color", e.target.value)} className="h-6 w-12 p-1" - style={{ fontSize: "12px" }} + className="text-xs" /> handleStyleChange("color", e.target.value)} placeholder="#000000" className="h-6 flex-1 text-xs" - style={{ fontSize: "12px" }} />

@@ -218,7 +212,6 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd value={localStyle.fontSize || ""} onChange={(e) => handleStyleChange("fontSize", e.target.value)} className="h-6 w-full px-2 py-0 text-xs" - style={{ fontSize: "12px" }} />
@@ -232,29 +225,29 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd value={localStyle.fontWeight || "normal"} onValueChange={(value) => handleStyleChange("fontWeight", value)} > - + - + 보통 - + 굵게 - + 100 - + 400 - + 500 - + 600 - + 700 @@ -268,20 +261,20 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd value={localStyle.textAlign || "left"} onValueChange={(value) => handleStyleChange("textAlign", value)} > - + - + 왼쪽 - + 가운데 - + 오른쪽 - + 양쪽 diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index 1ef8fee4..7af50458 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -509,7 +509,7 @@ export const ButtonConfigPanel: React.FC = ({ role="combobox" aria-expanded={modalScreenOpen} className="h-6 w-full justify-between px-2 py-0" - style={{ fontSize: "12px" }} + className="text-xs" disabled={screensLoading} > {config.action?.targetScreenId @@ -900,7 +900,7 @@ export const ButtonConfigPanel: React.FC = ({ role="combobox" aria-expanded={modalScreenOpen} className="h-6 w-full justify-between px-2 py-0" - style={{ fontSize: "12px" }} + className="text-xs" disabled={screensLoading} > {config.action?.targetScreenId @@ -978,7 +978,7 @@ export const ButtonConfigPanel: React.FC = ({ role="combobox" aria-expanded={modalScreenOpen} className="h-6 w-full justify-between px-2 py-0" - style={{ fontSize: "12px" }} + className="text-xs" disabled={screensLoading} > {config.action?.targetScreenId @@ -1132,7 +1132,7 @@ export const ButtonConfigPanel: React.FC = ({ role="combobox" aria-expanded={modalScreenOpen} className="h-6 w-full justify-between px-2 py-0" - style={{ fontSize: "12px" }} + className="text-xs" disabled={screensLoading} > {config.action?.targetScreenId @@ -1286,7 +1286,6 @@ export const ButtonConfigPanel: React.FC = ({ role="combobox" aria-expanded={displayColumnOpen} className="mt-2 h-8 w-full justify-between text-xs" - style={{ fontSize: "12px" }} disabled={columnsLoading || tableColumns.length === 0} > {columnsLoading @@ -1301,9 +1300,9 @@ export const ButtonConfigPanel: React.FC = ({ - + - + 컬럼을 찾을 수 없습니다. @@ -1316,7 +1315,6 @@ export const ButtonConfigPanel: React.FC = ({ setDisplayColumnOpen(false); }} className="text-xs" - style={{ fontSize: "12px" }} > = ({ role="combobox" aria-expanded={navScreenOpen} className="h-6 w-full justify-between px-2 py-0" - style={{ fontSize: "12px" }} + className="text-xs" disabled={screensLoading} > {config.action?.targetScreenId @@ -1424,7 +1422,6 @@ export const ButtonConfigPanel: React.FC = ({ onUpdateProperty("componentConfig.action.targetUrl", newValue); }} className="h-6 w-full px-2 py-0 text-xs" - style={{ fontSize: "12px" }} />

URL을 입력하면 화면 선택보다 우선 적용됩니다

diff --git a/frontend/components/screen/config-panels/CheckboxConfigPanel.tsx b/frontend/components/screen/config-panels/CheckboxConfigPanel.tsx index 85a78b7a..bff983dc 100644 --- a/frontend/components/screen/config-panels/CheckboxConfigPanel.tsx +++ b/frontend/components/screen/config-panels/CheckboxConfigPanel.tsx @@ -117,7 +117,7 @@ export const CheckboxConfigPanel: React.FC = ({ return ( - + 체크박스 설정 @@ -173,7 +173,7 @@ export const CheckboxConfigPanel: React.FC = ({ value={localConfig.label || ""} onChange={(e) => updateConfig("label", e.target.value)} placeholder="체크박스 라벨" - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" /> @@ -187,7 +187,7 @@ export const CheckboxConfigPanel: React.FC = ({ value={localConfig.checkedValue || ""} onChange={(e) => updateConfig("checkedValue", e.target.value)} placeholder="Y" - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" />
@@ -199,7 +199,7 @@ export const CheckboxConfigPanel: React.FC = ({ value={localConfig.uncheckedValue || ""} onChange={(e) => updateConfig("uncheckedValue", e.target.value)} placeholder="N" - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" />
@@ -232,7 +232,7 @@ export const CheckboxConfigPanel: React.FC = ({ value={localConfig.groupLabel || ""} onChange={(e) => updateConfig("groupLabel", e.target.value)} placeholder="체크박스 그룹 제목" - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" /> @@ -244,19 +244,19 @@ export const CheckboxConfigPanel: React.FC = ({ value={newOptionLabel} onChange={(e) => setNewOptionLabel(e.target.value)} placeholder="라벨" - className="flex-1 text-xs" style={{ fontSize: "12px" }} + className="flex-1 text-xs" /> setNewOptionValue(e.target.value)} placeholder="값" - className="flex-1 text-xs" style={{ fontSize: "12px" }} + className="flex-1 text-xs" /> @@ -277,13 +277,13 @@ export const CheckboxConfigPanel: React.FC = ({ value={option.label} onChange={(e) => updateOption(index, "label", e.target.value)} placeholder="라벨" - className="flex-1 text-xs" style={{ fontSize: "12px" }} + className="flex-1 text-xs" /> updateOption(index, "value", e.target.value)} placeholder="값" - className="flex-1 text-xs" style={{ fontSize: "12px" }} + className="flex-1 text-xs" /> = ({ disabled={localConfig.readonly} required={localConfig.required} defaultChecked={localConfig.defaultChecked} - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" />