3d 변경사항 #221

Merged
hyeonsu merged 11 commits from common/feat/dashboard-map into main 2025-11-25 15:07:38 +09:00
25 changed files with 1630 additions and 455 deletions

View File

@ -25,3 +25,4 @@ Digital Twin 에디터(`DigitalTwinEditor.tsx`)에서 발생한 런타임 에러
## 진행 상태
- [진행중] 1단계 긴급 버그 수정 완료 후 사용자 피드백 대기 중

View File

@ -55,3 +55,4 @@
- `backend-node/src/routes/digitalTwinRoutes.ts`
- `db/migrations/042_refactor_digital_twin_hierarchy.sql`

View File

@ -57,7 +57,7 @@ import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관
import todoRoutes from "./routes/todoRoutes"; // To-Do 관리
import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리
import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리
import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 야드 관리 3D
import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 3D 필드
//import materialRoutes from "./routes/materialRoutes"; // 자재 관리
import digitalTwinRoutes from "./routes/digitalTwinRoutes"; // 디지털 트윈 (야드 관제)
import flowRoutes from "./routes/flowRoutes"; // 플로우 관리
@ -222,7 +222,7 @@ app.use("/api/risk-alerts", riskAlertRoutes); // 리스크/알림 관리
app.use("/api/todos", todoRoutes); // To-Do 관리
app.use("/api/bookings", bookingRoutes); // 예약 요청 관리
app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회
app.use("/api/yard-layouts", yardLayoutRoutes); // 야드 관리 3D
app.use("/api/yard-layouts", yardLayoutRoutes); // 3D 필드
// app.use("/api/materials", materialRoutes); // 자재 관리 (임시 주석)
app.use("/api/digital-twin", digitalTwinRoutes); // 디지털 트윈 (야드 관제)
app.use("/api/flow-external-db", flowExternalDbConnectionRoutes); // 플로우 전용 외부 DB 연결

View File

@ -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<Response> => {
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<Response> => {
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<Response> => {
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,
});
}
};

View File

@ -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();
@ -89,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) {
@ -111,21 +115,43 @@ 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
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?
ORDER BY ORDINAL_POSITION;
`, [tableName]);
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]
);
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) {

View File

@ -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]

View File

@ -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 ==========

View File

@ -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<T> {
success: boolean;
data?: T;
message?: string;
error?: string;
}
export class DigitalTwinTemplateService {
static async listTemplates(
companyCode: string,
options: { externalDbConnectionId?: number; layoutType?: string } = {},
): Promise<ServiceResponse<DigitalTwinLayoutTemplate[]>> {
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<ServiceResponse<DigitalTwinLayoutTemplate>> {
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<ServiceResponse<DigitalTwinLayoutTemplate>> {
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: "매핑 템플릿 생성 중 오류가 발생했습니다.",
};
}
}
}

View File

@ -193,7 +193,7 @@ import { ListWidget } from "./widgets/ListWidget";
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
// 야드 관리 3D 위젯
// 3D 필드 위젯
const YardManagement3DWidget = dynamic(() => import("./widgets/YardManagement3DWidget"), {
ssr: false,
loading: () => (
@ -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);
@ -1067,7 +1085,7 @@ export function CanvasElement({
<ListWidget element={element} />
</div>
) : element.type === "widget" && element.subtype === "yard-management-3d" ? (
// 야드 관리 3D 위젯 렌더링
// 3D 필드 위젯 렌더링
<div className="widget-interactive-area h-full w-full">
<YardManagement3DWidget
isEditMode={true}

View File

@ -749,7 +749,7 @@ function getElementTitle(type: ElementType, subtype: ElementSubtype): string {
case "document":
return "문서 위젯";
case "yard-management-3d":
return "야드 관리 3D";
return "3D 필드";
case "work-history":
return "작업 이력";
case "transport-stats":

View File

@ -362,7 +362,7 @@ export function DashboardTopMenu({
<SelectItem value="list-v2"></SelectItem>
<SelectItem value="custom-metric-v2"> </SelectItem>
<SelectItem value="risk-alert-v2">/</SelectItem>
<SelectItem value="yard-management-3d"> 3D</SelectItem>
<SelectItem value="yard-management-3d">3D </SelectItem>
{/* <SelectItem value="transport-stats">커스텀 통계 카드</SelectItem> */}
{/* <SelectItem value="status-summary">커스텀 상태 카드</SelectItem> */}
</SelectGroup>

View File

@ -93,7 +93,7 @@ const getWidgetTitle = (subtype: ElementSubtype): string => {
chart: "차트",
"map-summary-v2": "지도",
"risk-alert-v2": "리스크 알림",
"yard-management-3d": "야드 관리 3D",
"yard-management-3d": "3D 필드",
weather: "날씨 위젯",
exchange: "환율 위젯",
calculator: "계산기",
@ -449,7 +449,7 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
</div>
</div>
{/* 레이아웃 선택 (야드 관리 3D 위젯 전용) */}
{/* 레이아웃 선택 (3D 필드 위젯 전용) */}
{element.subtype === "yard-management-3d" && (
<div className="bg-background rounded-lg p-3 shadow-sm">
<Label htmlFor="layout-id" className="mb-2 block text-xs font-semibold">

View File

@ -49,7 +49,7 @@ export type ElementSubtype =
| "maintenance"
| "document"
// | "list" // (구버전 - 주석 처리: 2025-10-28, list-v2로 대체)
| "yard-management-3d" // 야드 관리 3D 위젯
| "yard-management-3d" // 3D 필드 위젯
| "work-history" // 작업 이력 위젯
| "transport-stats"; // 커스텀 통계 카드 위젯
// | "custom-metric"; // (구버전 - 주석 처리: 2025-10-28, custom-metric-v2로 대체)
@ -116,7 +116,7 @@ export interface DashboardElement {
calendarConfig?: CalendarConfig; // 달력 설정
driverManagementConfig?: DriverManagementConfig; // 기사 관리 설정
listConfig?: ListWidgetConfig; // 리스트 위젯 설정
yardConfig?: YardManagementConfig; // 야드 관리 3D 설정
yardConfig?: YardManagementConfig; // 3D 필드 설정
customMetricConfig?: CustomMetricConfig; // 사용자 커스텀 카드 설정
}
@ -385,7 +385,7 @@ export interface ListColumn {
visible?: boolean; // 표시 여부 (기본: true)
}
// 야드 관리 3D 설정
// 3D 필드 설정
export interface YardManagementConfig {
layoutId: number; // 선택된 야드 레이아웃 ID
layoutName?: string; // 레이아웃 이름 (표시용)

View File

@ -42,14 +42,16 @@ export default function YardManagement3DWidget({
setIsLoading(true);
const response = await getLayouts();
if (response.success && response.data) {
setLayouts(response.data.map((layout: any) => ({
id: layout.id,
name: layout.layout_name,
description: layout.description || "",
placement_count: layout.object_count || 0,
created_at: layout.created_at,
updated_at: layout.updated_at,
})));
setLayouts(
response.data.map((layout: any) => ({
id: layout.id,
name: layout.layout_name,
description: layout.description || "",
placement_count: layout.object_count || 0,
created_at: layout.created_at,
updated_at: layout.updated_at,
})),
);
}
} catch (error) {
console.error("야드 레이아웃 목록 조회 실패:", error);
@ -145,12 +147,14 @@ export default function YardManagement3DWidget({
// 편집 모드: 편집 중인 경우 DigitalTwinEditor 표시
if (isEditMode && editingLayout) {
return (
<div className="h-full w-full">
<DigitalTwinEditor
layoutId={editingLayout.id}
layoutName={editingLayout.name}
onBack={handleEditComplete}
/>
// 대시보드 위젯 선택/사이드바 오픈과 독립적으로 동작해야 하므로
// widget-interactive-area 클래스를 부여하고, 마우스 이벤트 전파를 막아준다.
<div
className="widget-interactive-area h-full w-full"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<DigitalTwinEditor layoutId={editingLayout.id} layoutName={editingLayout.name} onBack={handleEditComplete} />
</div>
);
}
@ -158,30 +162,31 @@ export default function YardManagement3DWidget({
// 편집 모드: 레이아웃 선택 UI
if (isEditMode) {
return (
<div className="widget-interactive-area flex h-full w-full flex-col bg-background">
<div className="widget-interactive-area bg-background flex h-full w-full flex-col">
<div className="flex items-center justify-between border-b p-4">
<div>
<h3 className="text-sm font-semibold text-foreground"> </h3>
<p className="mt-1 text-xs text-muted-foreground">
{config?.layoutName ? `선택됨: ${config.layoutName}` : "표시할 야드 레이아웃을 선택하세요"}
<h3 className="text-foreground text-sm font-semibold">3D </h3>
<p className="text-muted-foreground mt-1 text-xs">
{config?.layoutName ? `선택됨: ${config.layoutName}` : "표시할 3D필드를 선택하세요"}
</p>
</div>
<Button onClick={() => setIsCreateModalOpen(true)} size="sm">
<Plus className="mr-1 h-4 w-4" />
<Plus className="mr-1 h-4 w-4" />
3D필드
</Button>
</div>
<div className="flex-1 overflow-auto p-4">
{isLoading ? (
<div className="flex h-full items-center justify-center">
<div className="text-sm text-muted-foreground"> ...</div>
<div className="text-muted-foreground text-sm"> ...</div>
</div>
) : layouts.length === 0 ? (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<div className="mb-2 text-4xl">🏗</div>
<div className="text-sm text-foreground"> </div>
<div className="mt-1 text-xs text-muted-foreground"> </div>
<div className="text-foreground text-sm"> 3D필드가 </div>
<div className="text-muted-foreground mt-1 text-xs"> 3D필드가 </div>
</div>
</div>
) : (
@ -196,11 +201,11 @@ export default function YardManagement3DWidget({
<div className="flex items-start justify-between gap-3">
<button onClick={() => handleSelectLayout(layout)} className="flex-1 text-left hover:opacity-80">
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">{layout.name}</span>
{config?.layoutId === layout.id && <Check className="h-4 w-4 text-primary" />}
<span className="text-foreground font-medium">{layout.name}</span>
{config?.layoutId === layout.id && <Check className="text-primary h-4 w-4" />}
</div>
{layout.description && <p className="mt-1 text-xs text-muted-foreground">{layout.description}</p>}
<div className="mt-2 text-xs text-muted-foreground"> : {layout.placement_count}</div>
{layout.description && <p className="text-muted-foreground mt-1 text-xs">{layout.description}</p>}
<div className="text-muted-foreground mt-2 text-xs"> : {layout.placement_count}</div>
</button>
<div className="flex gap-1">
<Button
@ -251,12 +256,12 @@ export default function YardManagement3DWidget({
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-foreground">
<p className="text-foreground text-sm">
?
<br />
.
<br />
<span className="font-semibold text-destructive"> .</span>
<span className="text-destructive font-semibold"> .</span>
</p>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setDeleteLayoutId(null)}>
@ -277,14 +282,12 @@ export default function YardManagement3DWidget({
if (!config?.layoutId) {
console.warn("⚠️ 야드관리 위젯: layoutId가 설정되지 않음", { config, isEditMode });
return (
<div className="flex h-full w-full items-center justify-center bg-muted">
<div className="bg-muted flex h-full w-full items-center justify-center">
<div className="text-center">
<div className="mb-2 text-4xl">🏗</div>
<div className="text-sm font-medium text-foreground"> </div>
<div className="mt-1 text-xs text-muted-foreground"> </div>
<div className="mt-2 text-xs text-destructive">
디버그: config={JSON.stringify(config)}
</div>
<div className="text-foreground text-sm font-medium">3D필드가 </div>
<div className="text-muted-foreground mt-1 text-xs"> 3D필드를 </div>
<div className="text-destructive mt-2 text-xs">디버그: config={JSON.stringify(config)}</div>
</div>
</div>
);

View File

@ -20,12 +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 {
@ -93,9 +105,17 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
const [loadingMaterials, setLoadingMaterials] = useState(false);
const [showMaterialPanel, setShowMaterialPanel] = useState(false);
// 매핑 템플릿
const [mappingTemplates, setMappingTemplates] = useState<DigitalTwinMappingTemplate[]>([]);
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
const [loadingTemplates, setLoadingTemplates] = useState(false);
const [isSaveTemplateDialogOpen, setIsSaveTemplateDialogOpen] = useState(false);
const [newTemplateName, setNewTemplateName] = useState("");
const [newTemplateDescription, setNewTemplateDescription] = useState("");
// 동적 계층 구조 설정
const [hierarchyConfig, setHierarchyConfig] = useState<HierarchyConfig | null>(null);
const [availableTables, setAvailableTables] = useState<string[]>([]);
const [availableTables, setAvailableTables] = useState<Array<{ table_name: string; description?: string }>>([]);
const [loadingTables, setLoadingTables] = useState(false);
// 레거시: 테이블 매핑 (구 Area/Location 방식 호환용)
@ -165,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 () => {
@ -207,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) {
const tableNames = response.data.map((t) => t.table_name);
setAvailableTables(tableNames);
console.log("📋 테이블 목록:", tableNames);
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);
@ -742,7 +803,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
name: objectName,
position: { x, y: yPosition, z },
size: objectSize,
color: defaults.color || "#9ca3af",
color: OBJECT_COLORS[draggedTool] || DEFAULT_COLOR, // 타입별 기본 색상
areaKey,
locaKey,
locType,
@ -848,13 +909,13 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
try {
setLoadingMaterials(true);
setShowMaterialPanel(true);
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) {
@ -975,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) => {
@ -1287,13 +1452,14 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
</div>
</div>
{/* 도구 팔레트 */}
{/* 도구 팔레트 (현재 숨김 처리 - 나중에 재사용 가능) */}
{/*
<div className="bg-muted flex items-center justify-center gap-2 border-b p-4">
<span className="text-muted-foreground text-sm font-medium">:</span>
{[
{ 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" },
@ -1313,6 +1479,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
);
})}
</div>
*/}
{/* 메인 영역 */}
<div className="flex flex-1 overflow-hidden">
@ -1328,6 +1495,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
onValueChange={(value) => {
setSelectedDbConnection(parseInt(value));
setSelectedWarehouse(null);
setSelectedTemplateId("");
setHasUnsavedChanges(true);
}}
>
@ -1342,55 +1510,66 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
))}
</SelectContent>
</Select>
</div>
{/* 창고 테이블 및 컬럼 매핑 */}
{selectedDbConnection && (
<div className="space-y-3">
<Label className="text-sm font-semibold"> </Label>
{/* 이 레이아웃의 창고 선택 */}
{hierarchyConfig?.warehouse?.tableName && hierarchyConfig?.warehouse?.keyColumn && (
<div>
<Label className="text-muted-foreground mb-1 block text-xs"> </Label>
{loadingWarehouses ? (
<div className="flex h-9 items-center justify-center rounded-md border">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
</div>
) : (
<Select
value={selectedWarehouse || ""}
onValueChange={(value) => {
setSelectedWarehouse(value);
// hierarchyConfig 업데이트 (없으면 새로 생성)
setHierarchyConfig((prev) => ({
warehouseKey: value,
levels: prev?.levels || [],
material: prev?.material,
}));
setHasUnsavedChanges(true);
}}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue placeholder="창고 선택..." />
</SelectTrigger>
<SelectContent>
{warehouses.map((wh: any) => {
const keyCol = hierarchyConfig.warehouse!.keyColumn;
const nameCol = hierarchyConfig.warehouse!.nameColumn;
return (
<SelectItem key={wh[keyCol]} value={wh[keyCol]} className="text-xs">
{wh[nameCol] || wh[keyCol]}
</SelectItem>
);
})}
</SelectContent>
</Select>
)}
{/* 매핑 템플릿 선택/저장 */}
{selectedDbConnection && (
<div className="mt-4 space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-semibold"> 릿</Label>
<Button
variant="outline"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => {
setNewTemplateName(layoutName || "");
setIsSaveTemplateDialogOpen(true);
}}
>
릿
</Button>
</div>
)}
</div>
)}
<div className="flex gap-2">
<Select
value={selectedTemplateId}
onValueChange={(val) => setSelectedTemplateId(val)}
>
<SelectTrigger className="h-8 flex-1 text-xs">
<SelectValue placeholder={loadingTemplates ? "로딩 중..." : "템플릿 선택..."} />
</SelectTrigger>
<SelectContent>
{mappingTemplates.length === 0 ? (
<div className="text-muted-foreground px-2 py-1 text-xs">
릿
</div>
) : (
mappingTemplates.map((tpl) => (
<SelectItem key={tpl.id} value={tpl.id} className="text-xs">
<div className="flex flex-col">
<span>{tpl.name}</span>
{tpl.description && (
<span className="text-muted-foreground text-[10px]">
{tpl.description}
</span>
)}
</div>
</SelectItem>
))
)}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
className="h-8 px-3 text-xs"
disabled={!selectedTemplateId}
onClick={() => handleApplyTemplate(selectedTemplateId)}
>
</Button>
</div>
</div>
)}
</div>
{/* 계층 설정 패널 (신규) */}
{selectedDbConnection && (
@ -1434,12 +1613,21 @@ 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) {
// 객체 배열을 문자열 배열로 변환
return response.data.map((col: any) =>
typeof col === "string" ? col : col.column_name || col.COLUMN_NAME || String(col),
);
// 컬럼 정보 객체 배열로 반환 (이름 + 설명 + PK 플래그)
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,
is_primary_key: col.is_primary_key ?? col.IS_PRIMARY_KEY,
}));
}
return [];
} catch (error) {
@ -1450,6 +1638,53 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
/>
)}
{/* 창고 선택 (HierarchyConfigPanel 아래로 이동) */}
{selectedDbConnection && hierarchyConfig?.warehouse?.tableName && hierarchyConfig?.warehouse?.keyColumn && (
<div className="space-y-3">
<Label className="text-sm font-semibold"> </Label>
<div>
<Label className="text-muted-foreground mb-1 block text-xs"> </Label>
{loadingWarehouses ? (
<div className="flex h-9 items-center justify-center rounded-md border">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
</div>
) : (
<Select
value={selectedWarehouse || ""}
onValueChange={(value) => {
setSelectedWarehouse(value);
// hierarchyConfig 업데이트
setHierarchyConfig((prev) => ({
...prev,
warehouseKey: value,
levels: prev?.levels || [],
material: prev?.material,
warehouse: prev?.warehouse,
}));
setHasUnsavedChanges(true);
}}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue placeholder="창고 선택..." />
</SelectTrigger>
<SelectContent>
{warehouses.map((wh: any) => {
const keyCol = hierarchyConfig.warehouse!.keyColumn;
const nameCol = hierarchyConfig.warehouse!.nameColumn;
return (
<SelectItem key={wh[keyCol]} value={wh[keyCol]} className="text-xs">
{wh[nameCol] || wh[keyCol]}
</SelectItem>
);
})}
</SelectContent>
</Select>
)}
</div>
</div>
)}
{/* Area 목록 */}
{selectedDbConnection && selectedWarehouse && (
<div className="space-y-3">
@ -1597,9 +1832,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
key={obj.id}
onClick={() => 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"
selectedObject?.id === obj.id ? "border-primary bg-primary/10" : "hover:border-primary/50"
}`}
>
<div className="flex items-center justify-between">
@ -1649,9 +1882,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
</AccordionTrigger>
<AccordionContent className="px-2 pb-3">
{childLocations.length === 0 ? (
<p className="text-muted-foreground py-2 text-center text-xs">
Location이
</p>
<p className="text-muted-foreground py-2 text-center text-xs">Location이 </p>
) : (
<div className="space-y-2">
{childLocations.map((locationObj) => (
@ -1696,70 +1927,70 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
</div>
</div>
{/* 중앙: 3D 캔버스 */}
<div className="relative h-full flex-1 bg-gray-100">
{isLoading ? (
<div className="flex h-full items-center justify-center">
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
</div>
) : (
<>
<Yard3DCanvas
placements={placements}
selectedPlacementId={selectedObject?.id || null}
onPlacementClick={(placement) => 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 && (
<div
className="pointer-events-auto absolute inset-0"
style={{ zIndex: 10 }}
onDragOver={(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 캔버스 */}
<div className="relative h-full flex-1 bg-gray-100">
{isLoading ? (
<div className="flex h-full items-center justify-center">
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
</div>
) : (
<>
<Yard3DCanvas
placements={placements}
selectedPlacementId={selectedObject?.id || null}
onPlacementClick={(placement) => 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 && (
<div
className="pointer-events-auto absolute inset-0"
style={{ zIndex: 10 }}
onDragOver={(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;
// 그리드 크기 (5 단위)
const gridSize = 5;
// 그리드 크기 (5 단위)
const gridSize = 5;
// 그리드에 스냅
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;
}
setPreviewPosition({ x: snappedX, z: snappedZ });
}}
onDragLeave={() => {
setPreviewPosition({ x: snappedX, z: snappedZ });
}}
onDragLeave={() => {
setPreviewPosition(null);
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
if (previewPosition) {
handleCanvasDrop(previewPosition.x, previewPosition.z);
setPreviewPosition(null);
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
if (previewPosition) {
handleCanvasDrop(previewPosition.x, previewPosition.z);
setPreviewPosition(null);
}
setDraggedTool(null);
setDraggedAreaData(null);
setDraggedLocationData(null);
}}
/>
)}
</>
)}
</div>
}
setDraggedTool(null);
setDraggedAreaData(null);
setDraggedLocationData(null);
}}
/>
)}
</>
)}
</div>
{/* 우측: 객체 속성 편집 or 자재 목록 */}
<div className="h-full w-[25%] overflow-y-auto border-l">
@ -1840,7 +2071,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
</Label>
<Input
id="object-name"
value={selectedObject.name}
value={selectedObject.name || ""}
onChange={(e) => handleObjectUpdate({ name: e.target.value })}
className="mt-1.5 h-9 text-sm"
/>
@ -1857,7 +2088,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
<Input
id="pos-x"
type="number"
value={selectedObject.position.x.toFixed(1)}
value={(selectedObject.position?.x || 0).toFixed(1)}
onChange={(e) =>
handleObjectUpdate({
position: {
@ -1876,7 +2107,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
<Input
id="pos-z"
type="number"
value={selectedObject.position.z.toFixed(1)}
value={(selectedObject.position?.z || 0).toFixed(1)}
onChange={(e) =>
handleObjectUpdate({
position: {
@ -1904,7 +2135,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: {
@ -1923,7 +2154,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
<Input
id="size-y"
type="number"
value={selectedObject.size.y}
value={selectedObject.size?.y || 5}
onChange={(e) =>
handleObjectUpdate({
size: {
@ -1944,7 +2175,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: {
@ -1967,7 +2198,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
<Input
id="object-color"
type="color"
value={selectedObject.color}
value={selectedObject.color || "#3b82f6"}
onChange={(e) => handleObjectUpdate({ color: e.target.value })}
className="mt-1.5 h-9"
/>
@ -1986,6 +2217,58 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
)}
</div>
</div>
{/* 매핑 템플릿 저장 다이얼로그 */}
<Dialog open={isSaveTemplateDialogOpen} onOpenChange={setIsSaveTemplateDialogOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> 릿 </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
// 릿 .
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
<div>
<Label htmlFor="template-name" className="text-xs sm:text-sm">
릿 *
</Label>
<Input
id="template-name"
value={newTemplateName}
onChange={(e) => setNewTemplateName(e.target.value)}
placeholder="예: 동연 야드 표준 매핑"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label htmlFor="template-desc" className="text-xs sm:text-sm">
()
</Label>
<Input
id="template-desc"
value={newTemplateDescription}
onChange={(e) => setNewTemplateDescription(e.target.value)}
placeholder="이 템플릿에 대한 설명을 입력하세요"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setIsSaveTemplateDialogOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleSaveTemplate}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -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";
@ -10,6 +10,8 @@ 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";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), {
ssr: false,
@ -81,7 +83,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,
@ -93,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,
};
});
@ -225,17 +230,11 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
// 객체 타입별 기본 색상 (useMemo로 최적화)
const getObjectColor = useMemo(() => {
return (type: string): string => {
const colorMap: Record<string, string> = {
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;
};
}, []);
@ -357,61 +356,154 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
{searchQuery ? "검색 결과가 없습니다" : "객체가 없습니다"}
</div>
) : (
<div className="space-y-2">
{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 (
<div
key={obj.id}
onClick={() => 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"
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm font-medium">{obj.name}</p>
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: getObjectColor(obj.type) }}
/>
<span>{typeLabel}</span>
</div>
</div>
</div>
<div className="space-y-2">
{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 = "랙";
{/* 추가 정보 */}
<div className="mt-2 space-y-1">
{obj.areaKey && (
<p className="text-muted-foreground text-xs">
Area: <span className="font-medium">{obj.areaKey}</span>
</p>
)}
{obj.locaKey && (
<p className="text-muted-foreground text-xs">
Location: <span className="font-medium">{obj.locaKey}</span>
</p>
)}
{obj.materialCount !== undefined && obj.materialCount > 0 && (
<p className="text-xs text-yellow-600">
: <span className="font-semibold">{obj.materialCount}</span>
</p>
)}
</div>
return (
<div
key={obj.id}
onClick={() => 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"
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm font-medium">{obj.name}</p>
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: obj.color }}
/>
<span>{typeLabel}</span>
</div>
</div>
</div>
<div className="mt-2 space-y-1">
{obj.areaKey && (
<p className="text-muted-foreground text-xs">
Area: <span className="font-medium">{obj.areaKey}</span>
</p>
)}
{obj.locaKey && (
<p className="text-muted-foreground text-xs">
Location: <span className="font-medium">{obj.locaKey}</span>
</p>
)}
{obj.materialCount !== undefined && obj.materialCount > 0 && (
<p className="text-xs text-yellow-600">
: <span className="font-semibold">{obj.materialCount}</span>
</p>
)}
</div>
</div>
);
})}
</div>
);
})}
</div>
}
// Area가 있는 경우: Area → Location 계층 아코디언
return (
<Accordion type="multiple" className="w-full">
{areaObjects.map((areaObj) => {
const childLocations = filteredObjects.filter(
(obj) =>
obj.type !== "area" &&
obj.areaKey === areaObj.areaKey &&
(obj.parentId === areaObj.id || obj.externalKey === areaObj.externalKey),
);
return (
<AccordionItem key={areaObj.id} value={`area-${areaObj.id}`} className="border-b">
<AccordionTrigger className="px-2 py-3 hover:no-underline">
<div
className={`flex w-full items-center justify-between pr-2 ${
selectedObject?.id === areaObj.id ? "text-primary font-semibold" : ""
}`}
onClick={(e) => {
e.stopPropagation();
handleObjectClick(areaObj.id);
}}
>
<div className="flex items-center gap-2">
<Grid3x3 className="h-4 w-4" />
<span className="text-sm font-medium">{areaObj.name}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">({childLocations.length})</span>
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: areaObj.color }}
/>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-2 pb-3">
{childLocations.length === 0 ? (
<p className="text-muted-foreground py-2 text-center text-xs">Location이 </p>
) : (
<div className="space-y-2">
{childLocations.map((locationObj) => (
<div
key={locationObj.id}
onClick={() => 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"
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Package className="h-3 w-3" />
<span className="text-xs font-medium">{locationObj.name}</span>
</div>
<span
className="inline-block h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: locationObj.color }}
/>
</div>
<p className="text-muted-foreground mt-1 text-[10px]">
: ({locationObj.position.x.toFixed(1)},{" "}
{locationObj.position.z.toFixed(1)})
</p>
{locationObj.locaKey && (
<p className="text-muted-foreground mt-0.5 text-[10px]">
Location: <span className="font-medium">{locationObj.locaKey}</span>
</p>
)}
{locationObj.materialCount !== undefined && locationObj.materialCount > 0 && (
<p className="mt-0.5 text-[10px] text-yellow-600">
: <span className="font-semibold">{locationObj.materialCount}</span>
</p>
)}
</div>
))}
</div>
)}
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
);
})()
)}
</div>
</div>

View File

@ -408,3 +408,4 @@ const handleObjectMove = (
**작성일**: 2025-11-20
**작성자**: AI Assistant

View File

@ -40,13 +40,26 @@ export interface HierarchyConfig {
};
}
interface TableInfo {
table_name: string;
description?: string;
}
interface ColumnInfo {
column_name: string;
data_type?: string;
description?: string;
// 백엔드에서 내려주는 Primary Key 플래그 ("YES"/"NO" 또는 boolean)
is_primary_key?: string | boolean;
}
interface HierarchyConfigPanelProps {
externalDbConnectionId: number | null;
hierarchyConfig: HierarchyConfig | null;
onHierarchyConfigChange: (config: HierarchyConfig) => void;
availableTables: string[];
availableTables: TableInfo[];
onLoadTables: () => Promise<void>;
onLoadColumns: (tableName: string) => Promise<string[]>;
onLoadColumns: (tableName: string) => Promise<ColumnInfo[]>;
}
export default function HierarchyConfigPanel({
@ -65,8 +78,21 @@ export default function HierarchyConfigPanel({
);
const [loadingColumns, setLoadingColumns] = useState(false);
const [columnsCache, setColumnsCache] = useState<{ [tableName: string]: string[] }>({});
const [columnsCache, setColumnsCache] = useState<{ [tableName: string]: ColumnInfo[] }>({});
// 동일한 column_name 이 여러 번 내려오는 경우(조인 중복 등) 제거
const normalizeColumns = (columns: ColumnInfo[]): ColumnInfo[] => {
const map = new Map<string, ColumnInfo>();
for (const col of columns) {
const key = col.column_name;
if (!map.has(key)) {
map.set(key, col);
}
}
return Array.from(map.values());
};
// 외부에서 변경된 경우 동기화 및 컬럼 자동 로드
// 외부에서 변경된 경우 동기화 및 컬럼 자동 로드
useEffect(() => {
if (hierarchyConfig) {
@ -93,17 +119,29 @@ export default function HierarchyConfigPanel({
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);
}
}
const tablesToFetch = uniqueTables.filter((tableName) => !columnsCache[tableName]);
if (tablesToFetch.length === 0) {
return;
}
setLoadingColumns(true);
try {
await Promise.all(
tablesToFetch.map(async (tableName) => {
try {
const columns = await onLoadColumns(tableName);
const normalized = normalizeColumns(columns);
setColumnsCache((prev) => ({ ...prev, [tableName]: normalized }));
} catch (error) {
console.error(`컬럼 로드 실패 (${tableName}):`, error);
}
}),
);
} finally {
setLoadingColumns(false);
}
};
@ -113,19 +151,83 @@ export default function HierarchyConfigPanel({
}
}, [hierarchyConfig, externalDbConnectionId]);
// 테이블 선택 시 컬럼 로드
const handleTableChange = async (tableName: string, type: "warehouse" | "material" | number) => {
if (columnsCache[tableName]) return; // 이미 로드된 경우 스킵
setLoadingColumns(true);
try {
const columns = await onLoadColumns(tableName);
setColumnsCache((prev) => ({ ...prev, [tableName]: columns }));
} catch (error) {
console.error("컬럼 로드 실패:", error);
} finally {
setLoadingColumns(false);
// 지정된 컬럼이 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) => {
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);
}
}
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<HierarchyConfig["material"]>;
} 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;
});
};
// 창고 키 변경 (제거됨 - 상위 컴포넌트에서 처리)
@ -226,12 +328,22 @@ export default function HierarchyConfigPanel({
</SelectTrigger>
<SelectContent>
{availableTables.map((table) => (
<SelectItem key={table} value={table} className="text-[10px]">
{table}
<SelectItem key={table.table_name} value={table.table_name} className="text-[10px]">
<div className="flex flex-col">
<span>{table.table_name}</span>
{table.description && (
<span className="text-muted-foreground text-[9px]">{table.description}</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{!localConfig.warehouse?.tableName && (
<p className="text-muted-foreground mt-1 text-[9px]">
"설정 적용"
</p>
)}
</div>
{/* 창고 컬럼 매핑 */}
@ -247,11 +359,22 @@ export default function HierarchyConfigPanel({
<SelectValue placeholder="선택..." />
</SelectTrigger>
<SelectContent>
{columnsCache[localConfig.warehouse.tableName].map((col) => (
<SelectItem key={col} value={col} className="text-[10px]">
{col}
</SelectItem>
))}
{columnsCache[localConfig.warehouse.tableName].map((col) => {
const pk = isPrimaryKey(col);
return (
<SelectItem key={col.column_name} value={col.column_name} className="text-[10px]">
<div className="flex flex-col">
<span>
{col.column_name}
{pk && <span className="text-amber-500 ml-1 text-[8px]">PK</span>}
</span>
{col.description && (
<span className="text-muted-foreground text-[8px]">{col.description}</span>
)}
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
@ -267,8 +390,13 @@ export default function HierarchyConfigPanel({
</SelectTrigger>
<SelectContent>
{columnsCache[localConfig.warehouse.tableName].map((col) => (
<SelectItem key={col} value={col} className="text-[10px]">
{col}
<SelectItem key={col.column_name} value={col.column_name} className="text-[10px]">
<div className="flex flex-col">
<span>{col.column_name}</span>
{col.description && (
<span className="text-muted-foreground text-[8px]">{col.description}</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
@ -276,6 +404,15 @@ export default function HierarchyConfigPanel({
</div>
</div>
)}
{localConfig.warehouse?.tableName &&
!columnsCache[localConfig.warehouse.tableName] &&
loadingColumns && (
<div className="flex items-center gap-2 pt-2 text-[10px] text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
<span> ...</span>
</div>
)}
</CardContent>
</Card>
@ -296,7 +433,7 @@ export default function HierarchyConfigPanel({
<div className="flex items-center gap-2">
<GripVertical className="text-muted-foreground h-4 w-4" />
<Input
value={level.name}
value={level.name || ""}
onChange={(e) => handleLevelChange(level.level, "name", e.target.value)}
className="h-7 w-32 text-xs"
placeholder="레벨명"
@ -315,7 +452,7 @@ export default function HierarchyConfigPanel({
<div>
<Label className="text-[10px]"></Label>
<Select
value={level.tableName}
value={level.tableName || ""}
onValueChange={(val) => {
handleLevelChange(level.level, "tableName", val);
handleTableChange(val, level.level);
@ -326,8 +463,13 @@ export default function HierarchyConfigPanel({
</SelectTrigger>
<SelectContent>
{availableTables.map((table) => (
<SelectItem key={table} value={table} className="text-xs">
{table}
<SelectItem key={table.table_name} value={table.table_name} className="text-xs">
<div className="flex flex-col">
<span>{table.table_name}</span>
{table.description && (
<span className="text-muted-foreground text-[10px]">{table.description}</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
@ -336,48 +478,64 @@ export default function HierarchyConfigPanel({
{level.tableName && columnsCache[level.tableName] && (
<>
<div>
<Label className="text-[10px]">ID </Label>
<Select
value={level.keyColumn}
onValueChange={(val) => handleLevelChange(level.level, "keyColumn", val)}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{columnsCache[level.tableName].map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
<div>
<Label className="text-[10px]">ID </Label>
<Select
value={level.keyColumn || ""}
onValueChange={(val) => handleLevelChange(level.level, "keyColumn", val)}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{columnsCache[level.tableName].map((col) => {
const pk = isPrimaryKey(col);
return (
<SelectItem key={col.column_name} value={col.column_name} className="text-xs">
<div className="flex flex-col">
<span>
{col.column_name}
{pk && <span className="text-amber-500 ml-1 text-[9px]">PK</span>}
</span>
{col.description && (
<span className="text-muted-foreground text-[9px]">{col.description}</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
})}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Select
value={level.nameColumn}
onValueChange={(val) => handleLevelChange(level.level, "nameColumn", val)}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{columnsCache[level.tableName].map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))}
</SelectContent>
</Select>
<div>
<Label className="text-[10px]"> </Label>
<Select
value={level.nameColumn || ""}
onValueChange={(val) => handleLevelChange(level.level, "nameColumn", val)}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{columnsCache[level.tableName].map((col) => (
<SelectItem key={col.column_name} value={col.column_name} className="text-xs">
<div className="flex flex-col">
<span>{col.column_name}</span>
{col.description && (
<span className="text-muted-foreground text-[9px]">{col.description}</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Select
value={level.parentKeyColumn}
value={level.parentKeyColumn || ""}
onValueChange={(val) => handleLevelChange(level.level, "parentKeyColumn", val)}
>
<SelectTrigger className="h-7 text-[10px]">
@ -385,8 +543,13 @@ export default function HierarchyConfigPanel({
</SelectTrigger>
<SelectContent>
{columnsCache[level.tableName].map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
<SelectItem key={col.column_name} value={col.column_name} className="text-xs">
<div className="flex flex-col">
<span>{col.column_name}</span>
{col.description && (
<span className="text-muted-foreground text-[9px]">{col.description}</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
@ -407,8 +570,13 @@ export default function HierarchyConfigPanel({
<SelectContent>
<SelectItem value="__none__"></SelectItem>
{columnsCache[level.tableName].map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
<SelectItem key={col.column_name} value={col.column_name} className="text-xs">
<div className="flex flex-col">
<span>{col.column_name}</span>
{col.description && (
<span className="text-muted-foreground text-[9px]">{col.description}</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
@ -416,6 +584,13 @@ export default function HierarchyConfigPanel({
</div>
</>
)}
{level.tableName && !columnsCache[level.tableName] && loadingColumns && (
<div className="flex items-center gap-2 pt-2 text-[10px] text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
<span> ...</span>
</div>
)}
</CardContent>
</Card>
))}
@ -448,8 +623,13 @@ export default function HierarchyConfigPanel({
</SelectTrigger>
<SelectContent>
{availableTables.map((table) => (
<SelectItem key={table} value={table} className="text-xs">
{table}
<SelectItem key={table.table_name} value={table.table_name} className="text-xs">
<div className="flex flex-col">
<span>{table.table_name}</span>
{table.description && (
<span className="text-muted-foreground text-[10px]">{table.description}</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
@ -458,82 +638,108 @@ export default function HierarchyConfigPanel({
{localConfig.material?.tableName && columnsCache[localConfig.material.tableName] && (
<>
<div>
<Label className="text-[10px]">ID </Label>
<Select
value={localConfig.material.keyColumn}
onValueChange={(val) => handleMaterialChange("keyColumn", val)}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{columnsCache[localConfig.material.tableName].map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Select
value={localConfig.material.locationKeyColumn}
onValueChange={(val) => handleMaterialChange("locationKeyColumn", val)}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{columnsCache[localConfig.material.tableName].map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))}
</SelectContent>
</Select>
<div>
<Label className="text-[10px]">ID </Label>
<Select
value={localConfig.material.keyColumn || ""}
onValueChange={(val) => handleMaterialChange("keyColumn", val)}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{columnsCache[localConfig.material.tableName].map((col) => {
const pk = isPrimaryKey(col);
return (
<SelectItem key={col.column_name} value={col.column_name} className="text-xs">
<div className="flex flex-col">
<span>
{col.column_name}
{pk && <span className="text-amber-500 ml-1 text-[9px]">PK</span>}
</span>
{col.description && (
<span className="text-muted-foreground text-[9px]">{col.description}</span>
)}
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[10px]"> ()</Label>
<Select
<div>
<Label className="text-[10px]"> </Label>
<Select
value={localConfig.material.locationKeyColumn || ""}
onValueChange={(val) => handleMaterialChange("locationKeyColumn", val)}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{columnsCache[localConfig.material.tableName].map((col) => (
<SelectItem key={col.column_name} value={col.column_name} className="text-xs">
<div className="flex flex-col">
<span>{col.column_name}</span>
{col.description && (
<span className="text-muted-foreground text-[9px]">{col.description}</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[10px]"> ()</Label>
<Select
value={localConfig.material.layerColumn || "__none__"}
onValueChange={(val) => handleMaterialChange("layerColumn", val === "__none__" ? undefined : val)}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue placeholder="레이어 컬럼" />
</SelectTrigger>
<SelectContent>
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue placeholder="레이어 컬럼" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"></SelectItem>
{columnsCache[localConfig.material.tableName].map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{columnsCache[localConfig.material.tableName].map((col) => (
<SelectItem key={col.column_name} value={col.column_name} className="text-xs">
<div className="flex flex-col">
<span>{col.column_name}</span>
{col.description && (
<span className="text-muted-foreground text-[9px]">{col.description}</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[10px]"> ()</Label>
<Select
<div>
<Label className="text-[10px]"> ()</Label>
<Select
value={localConfig.material.quantityColumn || "__none__"}
onValueChange={(val) => handleMaterialChange("quantityColumn", val === "__none__" ? undefined : val)}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue placeholder="수량 컬럼" />
</SelectTrigger>
<SelectContent>
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue placeholder="수량 컬럼" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"></SelectItem>
{columnsCache[localConfig.material.tableName].map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))}
</SelectContent>
</Select>
{columnsCache[localConfig.material.tableName].map((col) => (
<SelectItem key={col.column_name} value={col.column_name} className="text-xs">
<div className="flex flex-col">
<span>{col.column_name}</span>
{col.description && (
<span className="text-muted-foreground text-[9px]">{col.description}</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Separator className="my-3" />
@ -546,30 +752,35 @@ export default function HierarchyConfigPanel({
</p>
<div className="max-h-60 space-y-2 overflow-y-auto rounded border p-2">
{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 (
<div key={col} className="flex items-center gap-2">
<div key={col.column_name} className="flex items-center gap-2">
<input
type="checkbox"
checked={isSelected}
onChange={(e) => {
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"
/>
<span className="w-20 shrink-0 text-[10px]">{col}</span>
<div className="flex w-24 shrink-0 flex-col">
<span className="text-[10px]">{col.column_name}</span>
{col.description && (
<span className="text-muted-foreground text-[8px]">{col.description}</span>
)}
</div>
{isSelected && (
<Input
value={displayItem?.label || col}
value={displayItem?.label ?? ""}
onChange={(e) => {
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);
}}
@ -584,6 +795,15 @@ export default function HierarchyConfigPanel({
</div>
</>
)}
{localConfig.material?.tableName &&
!columnsCache[localConfig.material.tableName] &&
loadingColumns && (
<div className="flex items-center gap-2 pt-2 text-[10px] text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
<span> ...</span>
</div>
)}
</CardContent>
</Card>

View File

@ -445,20 +445,79 @@ function MaterialBox({
</>
)}
{/* Area 이름 텍스트 */}
{/* Area 이름 텍스트 - 위쪽 (바닥) */}
{placement.name && (
<Text
position={[0, 0.15, 0]}
rotation={[-Math.PI / 2, 0, 0]}
fontSize={Math.min(boxWidth, boxDepth) * 0.2}
color={placement.color}
anchorX="center"
anchorY="middle"
outlineWidth={0.05}
outlineColor="#000000"
>
{placement.name}
</Text>
<>
<Text
position={[0, 0.15, 0]}
rotation={[-Math.PI / 2, 0, 0]}
fontSize={Math.min(boxWidth, boxDepth) * 0.2}
color={placement.color}
anchorX="center"
anchorY="middle"
outlineWidth={0.05}
outlineColor="#000000"
>
{placement.name}
</Text>
{/* 4면에 텍스트 표시 */}
{/* 앞면 (+Z) */}
<Text
position={[0, boxHeight / 2, boxDepth / 2 + 0.01]}
rotation={[0, 0, 0]}
fontSize={Math.min(boxWidth, boxHeight) * 0.3}
color="#ffffff"
anchorX="center"
anchorY="middle"
outlineWidth={0.08}
outlineColor="#000000"
>
{placement.name}
</Text>
{/* 뒷면 (-Z) */}
<Text
position={[0, boxHeight / 2, -boxDepth / 2 - 0.01]}
rotation={[0, Math.PI, 0]}
fontSize={Math.min(boxWidth, boxHeight) * 0.3}
color="#ffffff"
anchorX="center"
anchorY="middle"
outlineWidth={0.08}
outlineColor="#000000"
>
{placement.name}
</Text>
{/* 왼쪽면 (-X) */}
<Text
position={[-boxWidth / 2 - 0.01, boxHeight / 2, 0]}
rotation={[0, -Math.PI / 2, 0]}
fontSize={Math.min(boxDepth, boxHeight) * 0.3}
color="#ffffff"
anchorX="center"
anchorY="middle"
outlineWidth={0.08}
outlineColor="#000000"
>
{placement.name}
</Text>
{/* 오른쪽면 (+X) */}
<Text
position={[boxWidth / 2 + 0.01, boxHeight / 2, 0]}
rotation={[0, Math.PI / 2, 0]}
fontSize={Math.min(boxDepth, boxHeight) * 0.3}
color="#ffffff"
anchorX="center"
anchorY="middle"
outlineWidth={0.08}
outlineColor="#000000"
>
{placement.name}
</Text>
</>
)}
</>
);

View File

@ -68,15 +68,15 @@ export default function YardLayoutCreateModal({ isOpen, onClose, onCreate }: Yar
<ResizableDialogContent className="sm:max-w-[500px]" onPointerDown={(e) => e.stopPropagation()}>
<ResizableDialogHeader>
<div className="flex items-center gap-2">
<ResizableDialogTitle> </ResizableDialogTitle>
<ResizableDialogDescription> </ResizableDialogDescription>
<ResizableDialogTitle> 3D필</ResizableDialogTitle>
<ResizableDialogDescription> </ResizableDialogDescription>
</div>
</ResizableDialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="yard-name">
<span className="text-destructive">*</span>
<span className="text-destructive">*</span>
</Label>
<Input
id="yard-name"
@ -86,7 +86,7 @@ export default function YardLayoutCreateModal({ isOpen, onClose, onCreate }: Yar
setError("");
}}
onKeyDown={handleKeyDown}
placeholder="예: A구역, 1번 야드"
placeholder="예: A 공장"
disabled={isCreating}
autoFocus
/>

View File

@ -0,0 +1,30 @@
/**
* 3D -
*/
// 객체 타입별 색상 매핑 (HEX 코드)
export const OBJECT_COLORS: Record<string, string> = {
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<string, string> = {
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";

View File

@ -163,3 +163,4 @@ export function getAllDescendants(
}

View File

@ -1,4 +1,4 @@
// 야드 관리 3D - 타입 정의
// 3D 필드 - 타입 정의
import { ChartDataSource } from "../../types";

View File

@ -19,6 +19,21 @@ interface ApiResponse<T> {
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<ApiResponse<DigitalTwinMappingTemplate[]>> => {
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<ApiResponse<DigitalTwinMappingTemplate>> => {
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<ApiResponse<DigitalTwinMappingTemplate>> => {
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,
};
}
};

View File

@ -290,8 +290,13 @@ export class ExternalDbConnectionAPI {
static async getTableColumns(connectionId: number, tableName: string): Promise<ApiResponse<any[]>> {
try {
console.log("컬럼 정보 API 요청:", `${this.BASE_PATH}/${connectionId}/tables/${tableName}/columns`);
// 컬럼 메타데이터 조회는 외부 DB 성능/네트워크 영향으로 오래 걸릴 수 있으므로
// 기본 30초보다 넉넉한 타임아웃을 사용
const response = await apiClient.get<ApiResponse<any[]>>(
`${this.BASE_PATH}/${connectionId}/tables/${tableName}/columns`,
{
timeout: 120000, // 120초
},
);
console.log("컬럼 정보 API 응답:", response.data);
return response.data;