diff --git a/PLAN.MD b/PLAN.MD index 7c3b1007..787bef69 100644 --- a/PLAN.MD +++ b/PLAN.MD @@ -25,3 +25,4 @@ Digital Twin 에디터(`DigitalTwinEditor.tsx`)에서 발생한 런타임 에러 ## 진행 상태 - [진행중] 1단계 긴급 버그 수정 완료 후 사용자 피드백 대기 중 + diff --git a/PROJECT_STATUS_2025_11_20.md b/PROJECT_STATUS_2025_11_20.md index 570dd789..1fe76c86 100644 --- a/PROJECT_STATUS_2025_11_20.md +++ b/PROJECT_STATUS_2025_11_20.md @@ -55,3 +55,4 @@ - `backend-node/src/routes/digitalTwinRoutes.ts` - `db/migrations/042_refactor_digital_twin_hierarchy.sql` + diff --git a/backend-node/src/controllers/digitalTwinTemplateController.ts b/backend-node/src/controllers/digitalTwinTemplateController.ts new file mode 100644 index 00000000..882d8e62 --- /dev/null +++ b/backend-node/src/controllers/digitalTwinTemplateController.ts @@ -0,0 +1,163 @@ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { + DigitalTwinTemplateService, + DigitalTwinLayoutTemplate, +} from "../services/DigitalTwinTemplateService"; + +export const listMappingTemplates = async ( + req: AuthenticatedRequest, + res: Response, +): Promise => { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ + success: false, + message: "인증 정보가 없습니다.", + }); + } + + const externalDbConnectionId = req.query.externalDbConnectionId + ? Number(req.query.externalDbConnectionId) + : undefined; + const layoutType = + typeof req.query.layoutType === "string" + ? req.query.layoutType + : undefined; + + const result = await DigitalTwinTemplateService.listTemplates( + companyCode, + { + externalDbConnectionId, + layoutType, + }, + ); + + if (!result.success) { + return res.status(500).json({ + success: false, + message: result.message, + error: result.error, + }); + } + + return res.json({ + success: true, + data: result.data as DigitalTwinLayoutTemplate[], + }); + } catch (error: any) { + return res.status(500).json({ + success: false, + message: "매핑 템플릿 목록 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; + +export const getMappingTemplateById = async ( + req: AuthenticatedRequest, + res: Response, +): Promise => { + try { + const companyCode = req.user?.companyCode; + const { id } = req.params; + + if (!companyCode) { + return res.status(401).json({ + success: false, + message: "인증 정보가 없습니다.", + }); + } + + const result = await DigitalTwinTemplateService.getTemplateById( + companyCode, + id, + ); + + if (!result.success) { + return res.status(404).json({ + success: false, + message: result.message || "매핑 템플릿을 찾을 수 없습니다.", + error: result.error, + }); + } + + return res.json({ + success: true, + data: result.data, + }); + } catch (error: any) { + return res.status(500).json({ + success: false, + message: "매핑 템플릿 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; + +export const createMappingTemplate = async ( + req: AuthenticatedRequest, + res: Response, +): Promise => { + try { + const companyCode = req.user?.companyCode; + const userId = req.user?.userId; + + if (!companyCode || !userId) { + return res.status(401).json({ + success: false, + message: "인증 정보가 없습니다.", + }); + } + + const { + name, + description, + externalDbConnectionId, + layoutType, + config, + } = req.body; + + if (!name || !externalDbConnectionId || !config) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다.", + }); + } + + const result = await DigitalTwinTemplateService.createTemplate( + companyCode, + userId, + { + name, + description, + externalDbConnectionId, + layoutType, + config, + }, + ); + + if (!result.success || !result.data) { + return res.status(500).json({ + success: false, + message: result.message || "매핑 템플릿 생성 중 오류가 발생했습니다.", + error: result.error, + }); + } + + return res.status(201).json({ + success: true, + data: result.data, + }); + } catch (error: any) { + return res.status(500).json({ + success: false, + message: "매핑 템플릿 생성 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; + + + diff --git a/backend-node/src/database/MariaDBConnector.ts b/backend-node/src/database/MariaDBConnector.ts index d104b9e3..cde351dc 100644 --- a/backend-node/src/database/MariaDBConnector.ts +++ b/backend-node/src/database/MariaDBConnector.ts @@ -95,15 +95,13 @@ export class MariaDBConnector implements DatabaseConnector { ORDER BY TABLE_NAME; `); - const tables: TableInfo[] = []; - for (const row of rows as any[]) { - const columns = await this.getColumns(row.table_name); - tables.push({ - table_name: row.table_name, - description: row.description || null, - columns: columns, - }); - } + // 테이블 목록만 반환 (컬럼 정보는 getColumns에서 개별 조회) + const tables: TableInfo[] = (rows as any[]).map((row) => ({ + table_name: row.table_name, + description: row.description || null, + columns: [], + })); + await this.disconnect(); return tables; } catch (error: any) { @@ -121,14 +119,29 @@ export class MariaDBConnector implements DatabaseConnector { 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_COMMENT as description - FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? - ORDER BY ORDINAL_POSITION; + c.COLUMN_NAME AS column_name, + c.DATA_TYPE AS data_type, + c.IS_NULLABLE AS is_nullable, + c.COLUMN_DEFAULT AS column_default, + c.COLUMN_COMMENT AS description, + CASE + WHEN tc.CONSTRAINT_TYPE = 'PRIMARY KEY' THEN 'YES' + ELSE 'NO' + END AS is_primary_key + FROM information_schema.COLUMNS c + LEFT JOIN information_schema.KEY_COLUMN_USAGE k + ON c.TABLE_SCHEMA = k.TABLE_SCHEMA + AND c.TABLE_NAME = k.TABLE_NAME + AND c.COLUMN_NAME = k.COLUMN_NAME + LEFT JOIN information_schema.TABLE_CONSTRAINTS tc + ON k.CONSTRAINT_SCHEMA = tc.CONSTRAINT_SCHEMA + AND k.CONSTRAINT_NAME = tc.CONSTRAINT_NAME + AND k.TABLE_SCHEMA = tc.TABLE_SCHEMA + AND k.TABLE_NAME = tc.TABLE_NAME + AND tc.CONSTRAINT_TYPE = 'PRIMARY KEY' + WHERE c.TABLE_SCHEMA = DATABASE() + AND c.TABLE_NAME = ? + ORDER BY c.ORDINAL_POSITION; `, [tableName] ); diff --git a/backend-node/src/database/PostgreSQLConnector.ts b/backend-node/src/database/PostgreSQLConnector.ts index 52f9ce19..2461cc53 100644 --- a/backend-node/src/database/PostgreSQLConnector.ts +++ b/backend-node/src/database/PostgreSQLConnector.ts @@ -210,15 +210,33 @@ export class PostgreSQLConnector implements DatabaseConnector { const result = await tempClient.query( ` SELECT - column_name, - data_type, - is_nullable, - column_default, - col_description(c.oid, a.attnum) as column_comment + isc.column_name, + isc.data_type, + isc.is_nullable, + isc.column_default, + col_description(c.oid, a.attnum) as column_comment, + CASE + WHEN tc.constraint_type = 'PRIMARY KEY' THEN 'YES' + ELSE 'NO' + END AS is_primary_key FROM information_schema.columns isc - LEFT JOIN pg_class c ON c.relname = isc.table_name - LEFT JOIN pg_attribute a ON a.attrelid = c.oid AND a.attname = isc.column_name - WHERE isc.table_schema = 'public' AND isc.table_name = $1 + LEFT JOIN pg_class c + ON c.relname = isc.table_name + AND c.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = isc.table_schema) + LEFT JOIN pg_attribute a + ON a.attrelid = c.oid + AND a.attname = isc.column_name + LEFT JOIN information_schema.key_column_usage k + ON k.table_name = isc.table_name + AND k.table_schema = isc.table_schema + AND k.column_name = isc.column_name + LEFT JOIN information_schema.table_constraints tc + ON tc.constraint_name = k.constraint_name + AND tc.table_schema = k.table_schema + AND tc.table_name = k.table_name + AND tc.constraint_type = 'PRIMARY KEY' + WHERE isc.table_schema = 'public' + AND isc.table_name = $1 ORDER BY isc.ordinal_position; `, [tableName] diff --git a/backend-node/src/routes/digitalTwinRoutes.ts b/backend-node/src/routes/digitalTwinRoutes.ts index 904096f7..467813f0 100644 --- a/backend-node/src/routes/digitalTwinRoutes.ts +++ b/backend-node/src/routes/digitalTwinRoutes.ts @@ -9,6 +9,11 @@ import { updateLayout, deleteLayout, } from "../controllers/digitalTwinLayoutController"; +import { + listMappingTemplates, + getMappingTemplateById, + createMappingTemplate, +} from "../controllers/digitalTwinTemplateController"; // 외부 DB 데이터 조회 import { @@ -27,11 +32,16 @@ const router = express.Router(); router.use(authenticateToken); // ========== 레이아웃 관리 API ========== -router.get("/layouts", getLayouts); // 레이아웃 목록 -router.get("/layouts/:id", getLayoutById); // 레이아웃 상세 -router.post("/layouts", createLayout); // 레이아웃 생성 -router.put("/layouts/:id", updateLayout); // 레이아웃 수정 -router.delete("/layouts/:id", deleteLayout); // 레이아웃 삭제 +router.get("/layouts", getLayouts); // 레이아웃 목록 +router.get("/layouts/:id", getLayoutById); // 레이아웃 상세 +router.post("/layouts", createLayout); // 레이아웃 생성 +router.put("/layouts/:id", updateLayout); // 레이아웃 수정 +router.delete("/layouts/:id", deleteLayout); // 레이아웃 삭제 + +// ========== 매핑 템플릿 API ========== +router.get("/mapping-templates", listMappingTemplates); +router.get("/mapping-templates/:id", getMappingTemplateById); +router.post("/mapping-templates", createMappingTemplate); // ========== 외부 DB 데이터 조회 API ========== diff --git a/backend-node/src/services/DigitalTwinTemplateService.ts b/backend-node/src/services/DigitalTwinTemplateService.ts new file mode 100644 index 00000000..d4818b3a --- /dev/null +++ b/backend-node/src/services/DigitalTwinTemplateService.ts @@ -0,0 +1,172 @@ +import { pool } from "../database/db"; +import logger from "../utils/logger"; + +export interface DigitalTwinLayoutTemplate { + id: string; + company_code: string; + name: string; + description?: string | null; + external_db_connection_id: number; + layout_type: string; + config: any; + created_by: string; + created_at: Date; + updated_by: string; + updated_at: Date; +} + +interface ServiceResponse { + success: boolean; + data?: T; + message?: string; + error?: string; +} + +export class DigitalTwinTemplateService { + static async listTemplates( + companyCode: string, + options: { externalDbConnectionId?: number; layoutType?: string } = {}, + ): Promise> { + try { + const params: any[] = [companyCode]; + let paramIndex = 2; + + let query = ` + SELECT * + FROM digital_twin_layout_template + WHERE company_code = $1 + `; + + if (options.layoutType) { + query += ` AND layout_type = $${paramIndex++}`; + params.push(options.layoutType); + } + + if (options.externalDbConnectionId) { + query += ` AND external_db_connection_id = $${paramIndex++}`; + params.push(options.externalDbConnectionId); + } + + query += ` + ORDER BY updated_at DESC, name ASC + `; + + const result = await pool.query(query, params); + + logger.info("디지털 트윈 매핑 템플릿 목록 조회", { + companyCode, + count: result.rowCount, + }); + + return { + success: true, + data: result.rows as DigitalTwinLayoutTemplate[], + }; + } catch (error: any) { + logger.error("디지털 트윈 매핑 템플릿 목록 조회 실패", error); + return { + success: false, + error: error.message, + message: "매핑 템플릿 목록 조회 중 오류가 발생했습니다.", + }; + } + } + + static async getTemplateById( + companyCode: string, + id: string, + ): Promise> { + try { + const query = ` + SELECT * + FROM digital_twin_layout_template + WHERE id = $1 AND company_code = $2 + `; + + const result = await pool.query(query, [id, companyCode]); + + if (result.rowCount === 0) { + return { + success: false, + message: "매핑 템플릿을 찾을 수 없습니다.", + }; + } + + return { + success: true, + data: result.rows[0] as DigitalTwinLayoutTemplate, + }; + } catch (error: any) { + logger.error("디지털 트윈 매핑 템플릿 조회 실패", error); + return { + success: false, + error: error.message, + message: "매핑 템플릿 조회 중 오류가 발생했습니다.", + }; + } + } + + static async createTemplate( + companyCode: string, + userId: string, + payload: { + name: string; + description?: string; + externalDbConnectionId: number; + layoutType?: string; + config: any; + }, + ): Promise> { + try { + const query = ` + INSERT INTO digital_twin_layout_template ( + company_code, + name, + description, + external_db_connection_id, + layout_type, + config, + created_by, + created_at, + updated_by, + updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $7, NOW()) + RETURNING * + `; + + const values = [ + companyCode, + payload.name, + payload.description || null, + payload.externalDbConnectionId, + payload.layoutType || "yard-3d", + JSON.stringify(payload.config), + userId, + ]; + + const result = await pool.query(query, values); + + logger.info("디지털 트윈 매핑 템플릿 생성", { + companyCode, + templateId: result.rows[0].id, + externalDbConnectionId: payload.externalDbConnectionId, + }); + + return { + success: true, + data: result.rows[0] as DigitalTwinLayoutTemplate, + }; + } catch (error: any) { + logger.error("디지털 트윈 매핑 템플릿 생성 실패", error); + return { + success: false, + error: error.message, + message: "매핑 템플릿 생성 중 오류가 발생했습니다.", + }; + } + } +} + + + diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 2bb85051..beb1e483 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -312,6 +312,24 @@ export function CanvasElement({ return; } + // 위젯 테두리(바깥쪽 영역)를 클릭한 경우에만 선택/드래그 허용 + // - 내용 영역을 클릭해도 대시보드 설정 사이드바가 튀어나오지 않도록 하기 위함 + const container = elementRef.current; + if (container) { + const rect = container.getBoundingClientRect(); + const BORDER_HIT_WIDTH = 8; // px, 테두리로 인식할 범위 + const isOnBorder = + e.clientX <= rect.left + BORDER_HIT_WIDTH || + e.clientX >= rect.right - BORDER_HIT_WIDTH || + e.clientY <= rect.top + BORDER_HIT_WIDTH || + e.clientY >= rect.bottom - BORDER_HIT_WIDTH; + + if (!isOnBorder) { + // 테두리가 아닌 내부 클릭은 선택/드래그 처리하지 않음 + return; + } + } + // 선택되지 않은 경우에만 선택 처리 if (!isSelected) { onSelect(element.id); diff --git a/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx b/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx index 91f58650..815ef07c 100644 --- a/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx +++ b/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx @@ -145,11 +145,17 @@ export default function YardManagement3DWidget({ // 편집 모드: 편집 중인 경우 DigitalTwinEditor 표시 if (isEditMode && editingLayout) { return ( -
- e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > +
); diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx index a81e75dc..9a71c338 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx @@ -20,13 +20,24 @@ import { getMaterials, getHierarchyData, getChildrenData, + getMappingTemplates, + createMappingTemplate, type HierarchyData, + type DigitalTwinMappingTemplate, } from "@/lib/api/digitalTwin"; 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"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; // 백엔드 DB 객체 타입 (snake_case) interface DbObject { @@ -94,6 +105,14 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi const [loadingMaterials, setLoadingMaterials] = useState(false); const [showMaterialPanel, setShowMaterialPanel] = useState(false); + // 매핑 템플릿 + const [mappingTemplates, setMappingTemplates] = useState([]); + const [selectedTemplateId, setSelectedTemplateId] = useState(""); + const [loadingTemplates, setLoadingTemplates] = useState(false); + const [isSaveTemplateDialogOpen, setIsSaveTemplateDialogOpen] = useState(false); + const [newTemplateName, setNewTemplateName] = useState(""); + const [newTemplateDescription, setNewTemplateDescription] = useState(""); + // 동적 계층 구조 설정 const [hierarchyConfig, setHierarchyConfig] = useState(null); const [availableTables, setAvailableTables] = useState>([]); @@ -166,6 +185,36 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi })); }, [placedObjects, layoutId]); + // 외부 DB 또는 레이아웃 타입이 변경될 때 템플릿 목록 로드 + useEffect(() => { + const loadTemplates = async () => { + if (!selectedDbConnection) { + setMappingTemplates([]); + setSelectedTemplateId(""); + return; + } + + try { + setLoadingTemplates(true); + const response = await getMappingTemplates({ + externalDbConnectionId: selectedDbConnection, + layoutType: "yard-3d", + }); + if (response.success && response.data) { + setMappingTemplates(response.data); + } else { + setMappingTemplates([]); + } + } catch (error) { + console.error("매핑 템플릿 목록 조회 실패:", error); + } finally { + setLoadingTemplates(false); + } + }; + + loadTemplates(); + }, [selectedDbConnection]); + // 외부 DB 연결 목록 로드 useEffect(() => { const loadExternalDbConnections = async () => { @@ -208,12 +257,23 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi const loadTables = async () => { try { setLoadingTables(true); - const { getTables } = await import("@/lib/api/digitalTwin"); - const response = await getTables(selectedDbConnection); + // 외부 DB 메타데이터 API 사용 (테이블 + 설명) + const response = await ExternalDbConnectionAPI.getTables(selectedDbConnection); if (response.success && response.data) { - // 테이블 정보 전체 저장 (이름 + 설명) - setAvailableTables(response.data); - console.log("📋 테이블 목록:", response.data); + const rawTables = response.data as any[]; + const normalized = rawTables.map((t: any) => + typeof t === "string" + ? { table_name: t } + : { + table_name: t.table_name || t.TABLE_NAME || String(t), + description: t.description || t.table_description || undefined, + }, + ); + setAvailableTables(normalized); + console.log("📋 테이블 목록:", normalized); + } else { + setAvailableTables([]); + console.warn("테이블 목록 조회 실패:", response.message || response.error); } } catch (error) { console.error("테이블 목록 조회 실패:", error); @@ -976,6 +1036,110 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi } }; + // 매핑 템플릿 적용 + const handleApplyTemplate = (templateId: string) => { + if (!templateId) return; + const template = mappingTemplates.find((t) => t.id === templateId); + if (!template) { + toast({ + variant: "destructive", + title: "템플릿 적용 실패", + description: "선택한 템플릿을 찾을 수 없습니다.", + }); + return; + } + + const config = template.config as HierarchyConfig; + setHierarchyConfig(config); + + // 선택된 테이블 정보 동기화 + const newSelectedTables: any = { + warehouse: config.warehouse?.tableName || "", + area: "", + location: "", + material: "", + }; + + if (config.levels && config.levels.length > 0) { + // 레벨 1 = Area + if (config.levels[0]?.tableName) { + newSelectedTables.area = config.levels[0].tableName; + } + // 레벨 2 = Location + if (config.levels[1]?.tableName) { + newSelectedTables.location = config.levels[1].tableName; + } + } + + if (config.material?.tableName) { + newSelectedTables.material = config.material.tableName; + } + + setSelectedTables(newSelectedTables); + setSelectedWarehouse(config.warehouseKey || null); + setHasUnsavedChanges(true); + + toast({ + title: "템플릿 적용 완료", + description: `"${template.name}" 템플릿이 적용되었습니다.`, + }); + }; + + // 매핑 템플릿 저장 + const handleSaveTemplate = async () => { + if (!selectedDbConnection || !hierarchyConfig) { + toast({ + variant: "destructive", + title: "템플릿 저장 불가", + description: "외부 DB와 계층 설정을 먼저 완료해주세요.", + }); + return; + } + + if (!newTemplateName.trim()) { + toast({ + variant: "destructive", + title: "템플릿 이름 필요", + description: "템플릿 이름을 입력해주세요.", + }); + return; + } + + try { + const response = await createMappingTemplate({ + name: newTemplateName.trim(), + description: newTemplateDescription.trim() || undefined, + externalDbConnectionId: selectedDbConnection, + layoutType: "yard-3d", + config: hierarchyConfig, + }); + + if (response.success && response.data) { + setMappingTemplates((prev) => [response.data!, ...prev]); + setIsSaveTemplateDialogOpen(false); + setNewTemplateName(""); + setNewTemplateDescription(""); + toast({ + title: "템플릿 저장 완료", + description: `"${response.data.name}" 템플릿이 저장되었습니다.`, + }); + } else { + toast({ + variant: "destructive", + title: "템플릿 저장 실패", + description: response.error || "템플릿을 저장하지 못했습니다.", + }); + } + } catch (error) { + console.error("매핑 템플릿 저장 실패:", error); + toast({ + variant: "destructive", + title: "템플릿 저장 실패", + description: "템플릿을 저장하는 중 오류가 발생했습니다.", + }); + } + }; + // 객체 이동 const handleObjectMove = (objectId: number, newX: number, newZ: number, newY?: number) => { setPlacedObjects((prev) => { @@ -1288,7 +1452,8 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi - {/* 도구 팔레트 */} + {/* 도구 팔레트 (현재 숨김 처리 - 나중에 재사용 가능) */} + {/*
도구: {[ @@ -1314,6 +1479,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi ); })}
+ */} {/* 메인 영역 */}
@@ -1329,6 +1495,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi onValueChange={(value) => { setSelectedDbConnection(parseInt(value)); setSelectedWarehouse(null); + setSelectedTemplateId(""); setHasUnsavedChanges(true); }} > @@ -1343,6 +1510,65 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi ))} + + {/* 매핑 템플릿 선택/저장 */} + {selectedDbConnection && ( +
+
+ + +
+
+ + +
+
+ )}
{/* 계층 설정 패널 (신규) */} @@ -1387,13 +1613,20 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi }} onLoadColumns={async (tableName: string) => { try { - const response = await ExternalDbConnectionAPI.getTableColumns(selectedDbConnection, tableName); + const response = await ExternalDbConnectionAPI.getTableColumns( + selectedDbConnection, + tableName, + ); if (response.success && response.data) { - // 컬럼 정보 객체 배열로 반환 (이름 + 설명) + // 컬럼 정보 객체 배열로 반환 (이름 + 설명 + PK 플래그) return response.data.map((col: any) => ({ - column_name: typeof col === "string" ? col : col.column_name || col.COLUMN_NAME || String(col), + 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, + is_primary_key: col.is_primary_key ?? col.IS_PRIMARY_KEY, })); } return []; @@ -1984,6 +2217,58 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi )} + {/* 매핑 템플릿 저장 다이얼로그 */} + + + + 매핑 템플릿 저장 + + 현재 창고/계층/자재 설정을 템플릿으로 저장하여 다른 레이아웃에서 재사용할 수 있습니다. + + +
+
+ + setNewTemplateName(e.target.value)} + placeholder="예: 동연 야드 표준 매핑" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + setNewTemplateDescription(e.target.value)} + placeholder="이 템플릿에 대한 설명을 입력하세요" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + + + +
+
); } diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx index 94ef98fb..cc34fb19 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useMemo } from "react"; -import { Loader2, Search, Filter, X } from "lucide-react"; +import { Loader2, Search, X, Grid3x3, Package } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; @@ -11,6 +11,7 @@ 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"; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), { ssr: false, @@ -94,6 +95,9 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) displayOrder: obj.display_order, locked: obj.locked, visible: obj.visible !== false, + hierarchyLevel: obj.hierarchy_level, + parentKey: obj.parent_key, + externalKey: obj.external_key, }; }); @@ -352,61 +356,154 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) {searchQuery ? "검색 결과가 없습니다" : "객체가 없습니다"} ) : ( -
- {filteredObjects.map((obj) => { - // 타입별 레이블 - let typeLabel = obj.type; - if (obj.type === "location-bed") typeLabel = "베드(BED)"; - else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)"; - else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)"; - else if (obj.type === "location-dest") typeLabel = "지정착지(DES)"; - else if (obj.type === "crane-mobile") typeLabel = "크레인"; - else if (obj.type === "area") typeLabel = "Area"; - else if (obj.type === "rack") typeLabel = "랙"; + (() => { + // Area 객체가 있는 경우 계층 트리 아코디언 적용 + const areaObjects = filteredObjects.filter((obj) => obj.type === "area"); + // Area가 없으면 기존 평면 리스트 유지 + if (areaObjects.length === 0) { return ( -
handleObjectClick(obj.id)} - className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${ - selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm" - }`} - > -
-
-

{obj.name}

-
- - {typeLabel} -
-
-
+
+ {filteredObjects.map((obj) => { + let typeLabel = obj.type; + if (obj.type === "location-bed") typeLabel = "베드(BED)"; + else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)"; + else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)"; + else if (obj.type === "location-dest") typeLabel = "지정착지(DES)"; + else if (obj.type === "crane-mobile") typeLabel = "크레인"; + else if (obj.type === "area") typeLabel = "Area"; + else if (obj.type === "rack") typeLabel = "랙"; - {/* 추가 정보 */} -
- {obj.areaKey && ( -

- Area: {obj.areaKey} -

- )} - {obj.locaKey && ( -

- Location: {obj.locaKey} -

- )} - {obj.materialCount !== undefined && obj.materialCount > 0 && ( -

- 자재: {obj.materialCount}개 -

- )} -
+ return ( +
handleObjectClick(obj.id)} + className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${ + selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm" + }`} + > +
+
+

{obj.name}

+
+ + {typeLabel} +
+
+
+
+ {obj.areaKey && ( +

+ Area: {obj.areaKey} +

+ )} + {obj.locaKey && ( +

+ Location: {obj.locaKey} +

+ )} + {obj.materialCount !== undefined && obj.materialCount > 0 && ( +

+ 자재: {obj.materialCount}개 +

+ )} +
+
+ ); + })}
); - })} -
+ } + + // Area가 있는 경우: Area → Location 계층 아코디언 + return ( + + {areaObjects.map((areaObj) => { + const childLocations = filteredObjects.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 && ( +

+ Location: {locationObj.locaKey} +

+ )} + {locationObj.materialCount !== undefined && locationObj.materialCount > 0 && ( +

+ 자재: {locationObj.materialCount}개 +

+ )} +
+ ))} +
+ )} +
+
+ ); + })} +
+ ); + })() )}
diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/HIERARCHY_MIGRATION_GUIDE.md b/frontend/components/admin/dashboard/widgets/yard-3d/HIERARCHY_MIGRATION_GUIDE.md index 5a7e01fb..915722b4 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/HIERARCHY_MIGRATION_GUIDE.md +++ b/frontend/components/admin/dashboard/widgets/yard-3d/HIERARCHY_MIGRATION_GUIDE.md @@ -408,3 +408,4 @@ const handleObjectMove = ( **작성일**: 2025-11-20 **작성자**: AI Assistant + diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx index 770a2bad..186ac63f 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx @@ -49,6 +49,8 @@ interface ColumnInfo { column_name: string; data_type?: string; description?: string; + // 백엔드에서 내려주는 Primary Key 플래그 ("YES"/"NO" 또는 boolean) + is_primary_key?: string | boolean; } interface HierarchyConfigPanelProps { @@ -78,6 +80,18 @@ export default function HierarchyConfigPanel({ const [loadingColumns, setLoadingColumns] = useState(false); const [columnsCache, setColumnsCache] = useState<{ [tableName: string]: ColumnInfo[] }>({}); + // 동일한 column_name 이 여러 번 내려오는 경우(조인 중복 등) 제거 + const normalizeColumns = (columns: ColumnInfo[]): ColumnInfo[] => { + const map = new Map(); + for (const col of columns) { + const key = col.column_name; + if (!map.has(key)) { + map.set(key, col); + } + } + return Array.from(map.values()); + }; + // 외부에서 변경된 경우 동기화 및 컬럼 자동 로드 // 외부에서 변경된 경우 동기화 및 컬럼 자동 로드 useEffect(() => { @@ -111,7 +125,8 @@ export default function HierarchyConfigPanel({ if (!columnsCache[tableName]) { try { const columns = await onLoadColumns(tableName); - setColumnsCache((prev) => ({ ...prev, [tableName]: columns })); + const normalized = normalizeColumns(columns); + setColumnsCache((prev) => ({ ...prev, [tableName]: normalized })); } catch (error) { console.error(`컬럼 로드 실패 (${tableName}):`, error); } @@ -125,21 +140,83 @@ export default function HierarchyConfigPanel({ } }, [hierarchyConfig, externalDbConnectionId]); - // 테이블 선택 시 컬럼 로드 + // 지정된 컬럼이 Primary Key 인지 여부 + const isPrimaryKey = (col: ColumnInfo): boolean => { + if (col.is_primary_key === true) return true; + if (typeof col.is_primary_key === "string") { + const v = col.is_primary_key.toUpperCase(); + return v === "YES" || v === "Y" || v === "TRUE" || v === "PK"; + } + return false; + }; + + // 테이블 선택 시 컬럼 로드 + PK 기반 기본값 설정 const handleTableChange = async (tableName: string, type: "warehouse" | "material" | number) => { - if (columnsCache[tableName]) { - return; // 이미 로드된 경우 스킵 + let loadedColumns = columnsCache[tableName]; + + // 아직 캐시에 없으면 먼저 컬럼 조회 + if (!loadedColumns) { + setLoadingColumns(true); + try { + const fetched = await onLoadColumns(tableName); + loadedColumns = normalizeColumns(fetched); + setColumnsCache((prev) => ({ ...prev, [tableName]: loadedColumns! })); + } catch (error) { + console.error("컬럼 로드 실패:", error); + loadedColumns = []; + } finally { + setLoadingColumns(false); + } } - setLoadingColumns(true); - try { - const columns = await onLoadColumns(tableName); - setColumnsCache((prev) => ({ ...prev, [tableName]: columns })); - } catch (error) { - console.error("컬럼 로드 실패:", error); - } finally { - setLoadingColumns(false); - } + const columns = loadedColumns || []; + + // PK 기반으로 keyColumn 기본값 자동 설정 (이미 값이 있으면 건드리지 않음) + // PK 정보가 없으면 첫 번째 컬럼을 기본값으로 사용 + setLocalConfig((prev) => { + const next = { ...prev }; + const primaryColumns = columns.filter((col) => isPrimaryKey(col)); + const pkName = (primaryColumns[0] || columns[0])?.column_name; + + if (!pkName) { + return next; + } + + if (type === "warehouse") { + const wh = { + ...(next.warehouse || { tableName }), + tableName: next.warehouse?.tableName || tableName, + }; + if (!wh.keyColumn) { + wh.keyColumn = pkName; + } + next.warehouse = wh; + } else if (type === "material") { + const material = { + ...(next.material || { tableName }), + tableName: next.material?.tableName || tableName, + }; + if (!material.keyColumn) { + material.keyColumn = pkName; + } + next.material = material as NonNullable; + } else if (typeof type === "number") { + // 계층 레벨 + next.levels = next.levels.map((lvl) => { + if (lvl.level !== type) return lvl; + const updated: HierarchyLevel = { + ...lvl, + tableName: lvl.tableName || tableName, + }; + if (!updated.keyColumn) { + updated.keyColumn = pkName; + } + return updated; + }); + } + + return next; + }); }; // 창고 키 변경 (제거됨 - 상위 컴포넌트에서 처리) @@ -271,16 +348,22 @@ export default function HierarchyConfigPanel({ - {columnsCache[localConfig.warehouse.tableName].map((col) => ( - -
- {col.column_name} - {col.description && ( - {col.description} - )} -
-
- ))} + {columnsCache[localConfig.warehouse.tableName].map((col) => { + const pk = isPrimaryKey(col); + return ( + +
+ + {col.column_name} + {pk && PK} + + {col.description && ( + {col.description} + )} +
+
+ ); + })}
@@ -310,6 +393,15 @@ export default function HierarchyConfigPanel({ )} + + {localConfig.warehouse?.tableName && + !columnsCache[localConfig.warehouse.tableName] && + loadingColumns && ( +
+ + 컬럼 정보를 불러오는 중입니다... +
+ )} @@ -385,16 +477,22 @@ export default function HierarchyConfigPanel({ - {columnsCache[level.tableName].map((col) => ( - -
- {col.column_name} - {col.description && ( - {col.description} - )} -
-
- ))} + {columnsCache[level.tableName].map((col) => { + const pk = isPrimaryKey(col); + return ( + +
+ + {col.column_name} + {pk && PK} + + {col.description && ( + {col.description} + )} +
+
+ ); + })}
@@ -475,6 +573,13 @@ export default function HierarchyConfigPanel({ )} + + {level.tableName && !columnsCache[level.tableName] && loadingColumns && ( +
+ + 컬럼 정보를 불러오는 중입니다... +
+ )} ))} @@ -528,21 +633,27 @@ export default function HierarchyConfigPanel({ value={localConfig.material.keyColumn || ""} onValueChange={(val) => handleMaterialChange("keyColumn", val)} > - - - - - {columnsCache[localConfig.material.tableName].map((col) => ( - -
- {col.column_name} - {col.description && ( - {col.description} - )} -
-
- ))} -
+ + + + + {columnsCache[localConfig.material.tableName].map((col) => { + const pk = isPrimaryKey(col); + return ( + +
+ + {col.column_name} + {pk && PK} + + {col.description && ( + {col.description} + )} +
+
+ ); + })} +
@@ -673,6 +784,15 @@ export default function HierarchyConfigPanel({ )} + + {localConfig.material?.tableName && + !columnsCache[localConfig.material.tableName] && + loadingColumns && ( +
+ + 컬럼 정보를 불러오는 중입니다... +
+ )} diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/spatialContainment.ts b/frontend/components/admin/dashboard/widgets/yard-3d/spatialContainment.ts index ebedb9f2..f2df7e70 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/spatialContainment.ts +++ b/frontend/components/admin/dashboard/widgets/yard-3d/spatialContainment.ts @@ -163,3 +163,4 @@ export function getAllDescendants( } + diff --git a/frontend/lib/api/digitalTwin.ts b/frontend/lib/api/digitalTwin.ts index c20525b4..7a67ff39 100644 --- a/frontend/lib/api/digitalTwin.ts +++ b/frontend/lib/api/digitalTwin.ts @@ -19,6 +19,21 @@ interface ApiResponse { error?: string; } +// 매핑 템플릿 타입 +export interface DigitalTwinMappingTemplate { + id: string; + company_code: string; + name: string; + description?: string; + external_db_connection_id: number; + layout_type: string; + config: any; + created_by: string; + created_at: string; + updated_by: string; + updated_at: string; +} + // ========== 레이아웃 관리 API ========== // 레이아웃 목록 조회 @@ -281,3 +296,60 @@ export const getChildrenData = async ( }; } }; + +// ========== 매핑 템플릿 API ========== + +// 템플릿 목록 조회 (회사 단위, 현재 사용자 기준) +export const getMappingTemplates = async (params?: { + externalDbConnectionId?: number; + layoutType?: string; +}): Promise> => { + try { + const response = await apiClient.get("/digital-twin/mapping-templates", { + params: { + externalDbConnectionId: params?.externalDbConnectionId, + layoutType: params?.layoutType, + }, + }); + return response.data; + } catch (error: any) { + return { + success: false, + error: error.response?.data?.message || error.message, + }; + } +}; + +// 템플릿 생성 +export const createMappingTemplate = async (data: { + name: string; + description?: string; + externalDbConnectionId: number; + layoutType?: string; + config: any; +}): Promise> => { + try { + const response = await apiClient.post("/digital-twin/mapping-templates", data); + return response.data; + } catch (error: any) { + return { + success: false, + error: error.response?.data?.message || error.message, + }; + } +}; + +// 템플릿 단건 조회 +export const getMappingTemplateById = async ( + id: string, +): Promise> => { + try { + const response = await apiClient.get(`/digital-twin/mapping-templates/${id}`); + return response.data; + } catch (error: any) { + return { + success: false, + error: error.response?.data?.message || error.message, + }; + } +};