N-Level 계층 구조 및 공간 종속성 시스템 구현
This commit is contained in:
parent
6fe708505a
commit
ace80be8e1
1
PLAN.MD
1
PLAN.MD
|
|
@ -25,3 +25,4 @@ Digital Twin 에디터(`DigitalTwinEditor.tsx`)에서 발생한 런타임 에러
|
|||
## 진행 상태
|
||||
|
||||
- [진행중] 1단계 긴급 버그 수정 완료 후 사용자 피드백 대기 중
|
||||
|
||||
|
|
|
|||
|
|
@ -55,3 +55,4 @@
|
|||
- `backend-node/src/routes/digitalTwinRoutes.ts`
|
||||
- `db/migrations/042_refactor_digital_twin_hierarchy.sql`
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
|
@ -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]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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 ==========
|
||||
|
||||
|
|
|
|||
|
|
@ -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: "매핑 템플릿 생성 중 오류가 발생했습니다.",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -145,11 +145,17 @@ export default function YardManagement3DWidget({
|
|||
// 편집 모드: 편집 중인 경우 DigitalTwinEditor 표시
|
||||
if (isEditMode && editingLayout) {
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<DigitalTwinEditor
|
||||
layoutId={editingLayout.id}
|
||||
// 대시보드 위젯 선택/사이드바 오픈과 독립적으로 동작해야 하므로
|
||||
// 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}
|
||||
onBack={handleEditComplete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<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<Array<{ table_name: string; description?: string }>>([]);
|
||||
|
|
@ -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
|
|||
</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>
|
||||
{[
|
||||
|
|
@ -1314,6 +1479,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
*/}
|
||||
|
||||
{/* 메인 영역 */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
|
|
@ -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
|
|||
))}
|
||||
</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 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>
|
||||
|
||||
{/* 계층 설정 패널 (신규) */}
|
||||
|
|
@ -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
|
|||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ? "검색 결과가 없습니다" : "객체가 없습니다"}
|
||||
</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: obj.color }}
|
||||
/>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -408,3 +408,4 @@ const handleObjectMove = (
|
|||
**작성일**: 2025-11-20
|
||||
**작성자**: AI Assistant
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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(() => {
|
||||
|
|
@ -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<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;
|
||||
});
|
||||
};
|
||||
|
||||
// 창고 키 변경 (제거됨 - 상위 컴포넌트에서 처리)
|
||||
|
|
@ -271,16 +348,22 @@ export default function HierarchyConfigPanel({
|
|||
<SelectValue placeholder="선택..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columnsCache[localConfig.warehouse.tableName].map((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>
|
||||
))}
|
||||
{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>
|
||||
|
|
@ -310,6 +393,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>
|
||||
|
||||
|
|
@ -385,16 +477,22 @@ export default function HierarchyConfigPanel({
|
|||
<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>
|
||||
))}
|
||||
{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>
|
||||
|
|
@ -475,6 +573,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>
|
||||
))}
|
||||
|
|
@ -528,21 +633,27 @@ export default function HierarchyConfigPanel({
|
|||
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.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>
|
||||
<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>
|
||||
|
||||
|
|
@ -673,6 +784,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>
|
||||
|
||||
|
|
|
|||
|
|
@ -163,3 +163,4 @@ export function getAllDescendants(
|
|||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue