Merge pull request '대시보드 수정' (#216) from common/feat/dashboard-map into main

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/216
This commit is contained in:
hyeonsu 2025-11-21 03:34:27 +09:00
commit 25c36167c0
17 changed files with 2290 additions and 601 deletions

27
PLAN.MD Normal file
View File

@ -0,0 +1,27 @@
# 프로젝트: Digital Twin 에디터 안정화
## 개요
Digital Twin 에디터(`DigitalTwinEditor.tsx`)에서 발생한 런타임 에러(`TypeError: Cannot read properties of undefined`)를 수정하고, 전반적인 안정성을 확보합니다.
## 핵심 기능
1. `DigitalTwinEditor` 버그 수정
2. 비동기 함수 입력값 유효성 검증 강화
3. 외부 DB 연결 상태에 따른 방어 코드 추가
## 테스트 계획
### 1단계: 긴급 버그 수정
- [x] `loadMaterialCountsForLocations` 함수에서 `locaKeys` undefined 체크 추가 (완료)
- [ ] 에디터 로드 및 객체 조작 시 에러 발생 여부 확인
### 2단계: 잠재적 문제 점검
- [ ] `loadLayout` 등 주요 로딩 함수의 데이터 유효성 검사
- [ ] `handleToolDragStart`, `handleCanvasDrop` 등 인터랙션 함수의 예외 처리
## 진행 상태
- [진행중] 1단계 긴급 버그 수정 완료 후 사용자 피드백 대기 중

View File

@ -0,0 +1,57 @@
# 프로젝트 진행 상황 (2025-11-20)
## 작업 개요: 디지털 트윈 3D 야드 고도화 (동적 계층 구조)
### 1. 핵심 변경 사항
기존의 고정된 `Area` -> `Location` 2단계 구조를 유연한 **N-Level 동적 계층 구조**로 변경하고, 공간적 제약을 강화했습니다.
### 2. 완료된 작업
#### 데이터베이스
- **마이그레이션 실행**: `db/migrations/042_refactor_digital_twin_hierarchy.sql`
- **스키마 변경**:
- `digital_twin_layout` 테이블에 `hierarchy_config` (JSONB) 컬럼 추가
- `digital_twin_objects` 테이블에 `hierarchy_level`, `parent_key`, `external_key` 컬럼 추가
- 기존 하드코딩된 테이블 매핑 컬럼 제거
#### 백엔드 (Node.js)
- **API 추가/수정**:
- `POST /api/digital-twin/data/hierarchy`: 계층 설정에 따른 전체 데이터 조회
- `POST /api/digital-twin/data/children`: 특정 부모의 하위 데이터 조회
- 기존 레거시 API (`getWarehouses` 등) 호환성 유지
- **컨트롤러 수정**:
- `digitalTwinDataController.ts`: 동적 쿼리 생성 로직 구현
- `digitalTwinLayoutController.ts`: 레이아웃 저장/수정 시 `hierarchy_config` 및 객체 계층 정보 처리
#### 프론트엔드 (React)
- **신규 컴포넌트**: `HierarchyConfigPanel.tsx`
- 레벨 추가/삭제, 테이블 및 컬럼 매핑 설정 UI
- **유틸리티**: `spatialContainment.ts`
- `validateSpatialContainment`: 자식 객체가 부모 객체 내부에 있는지 검증 (AABB)
- `updateChildrenPositions`: 부모 이동 시 자식 객체 자동 이동 (그룹 이동)
- **에디터 통합 (`DigitalTwinEditor.tsx`)**:
- `HierarchyConfigPanel` 적용
- 동적 데이터 로드 로직 구현
- 3D 캔버스 드래그앤드롭 시 공간적 종속성 검증 적용
- 객체 이동 시 그룹 이동 적용
### 3. 현재 상태
- **백엔드 서버**: 재시작 완료, 정상 동작 중 (PostgreSQL 연결 이슈 해결됨)
- **DB**: 마이그레이션 스크립트 실행 완료
### 4. 다음 단계 (테스트 필요)
새로운 세션에서 다음 시나리오를 테스트해야 합니다:
1. **계층 설정**: 에디터에서 창고 -> 구역(Lv1) -> 위치(Lv2) 설정 및 매핑 저장
2. **배치 검증**:
- 구역 배치 후, 위치를 구역 **내부**에 배치 (성공해야 함)
- 위치를 구역 **외부**에 배치 (실패해야 함)
3. **이동 검증**: 구역 이동 시 내부의 위치들도 같이 따라오는지 확인
### 5. 관련 파일
- `frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx`
- `frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx`
- `frontend/components/admin/dashboard/widgets/yard-3d/spatialContainment.ts`
- `backend-node/src/controllers/digitalTwinDataController.ts`
- `backend-node/src/routes/digitalTwinRoutes.ts`
- `db/migrations/042_refactor_digital_twin_hierarchy.sql`

View File

@ -36,7 +36,138 @@ export async function getExternalDbConnector(connectionId: number) {
);
}
// 창고 목록 조회 (사용자 지정 테이블)
// 동적 계층 구조 데이터 조회 (범용)
export const getHierarchyData = async (req: Request, res: Response): Promise<Response> => {
try {
const { externalDbConnectionId, hierarchyConfig } = req.body;
if (!externalDbConnectionId || !hierarchyConfig) {
return res.status(400).json({
success: false,
message: "외부 DB 연결 ID와 계층 구조 설정이 필요합니다.",
});
}
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
const config = JSON.parse(hierarchyConfig);
const result: any = {
warehouse: null,
levels: [],
materials: [],
};
// 창고 데이터 조회
if (config.warehouse) {
const warehouseQuery = `SELECT * FROM ${config.warehouse.tableName} LIMIT 100`;
const warehouseResult = await connector.executeQuery(warehouseQuery);
result.warehouse = warehouseResult.rows;
}
// 각 레벨 데이터 조회
if (config.levels && Array.isArray(config.levels)) {
for (const level of config.levels) {
const levelQuery = `SELECT * FROM ${level.tableName} LIMIT 1000`;
const levelResult = await connector.executeQuery(levelQuery);
result.levels.push({
level: level.level,
name: level.name,
data: levelResult.rows,
});
}
}
// 자재 데이터 조회 (개수만)
if (config.material) {
const materialQuery = `
SELECT
${config.material.locationKeyColumn} as location_key,
COUNT(*) as count
FROM ${config.material.tableName}
GROUP BY ${config.material.locationKeyColumn}
`;
const materialResult = await connector.executeQuery(materialQuery);
result.materials = materialResult.rows;
}
logger.info("동적 계층 구조 데이터 조회", {
externalDbConnectionId,
warehouseCount: result.warehouse?.length || 0,
levelCounts: result.levels.map((l: any) => ({ level: l.level, count: l.data.length })),
});
return res.json({
success: true,
data: result,
});
} catch (error: any) {
logger.error("동적 계층 구조 데이터 조회 실패", error);
return res.status(500).json({
success: false,
message: "데이터 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
};
// 특정 레벨의 하위 데이터 조회
export const getChildrenData = async (req: Request, res: Response): Promise<Response> => {
try {
const { externalDbConnectionId, hierarchyConfig, parentLevel, parentKey } = req.body;
if (!externalDbConnectionId || !hierarchyConfig || !parentLevel || !parentKey) {
return res.status(400).json({
success: false,
message: "필수 파라미터가 누락되었습니다.",
});
}
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
const config = JSON.parse(hierarchyConfig);
// 다음 레벨 찾기
const nextLevel = config.levels?.find((l: any) => l.level === parentLevel + 1);
if (!nextLevel) {
return res.json({
success: true,
data: [],
message: "하위 레벨이 없습니다.",
});
}
// 하위 데이터 조회
const query = `
SELECT * FROM ${nextLevel.tableName}
WHERE ${nextLevel.parentKeyColumn} = '${parentKey}'
LIMIT 1000
`;
const result = await connector.executeQuery(query);
logger.info("하위 데이터 조회", {
externalDbConnectionId,
parentLevel,
parentKey,
count: result.rows.length,
});
return res.json({
success: true,
data: result.rows,
});
} catch (error: any) {
logger.error("하위 데이터 조회 실패", error);
return res.status(500).json({
success: false,
message: "하위 데이터 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
};
// 창고 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
export const getWarehouses = async (req: Request, res: Response): Promise<Response> => {
try {
const { externalDbConnectionId, tableName } = req.query;
@ -83,32 +214,29 @@ export const getWarehouses = async (req: Request, res: Response): Promise<Respon
}
};
// Area 목록 조회 (사용자 지정 테이블)
// 구역 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
export const getAreas = async (req: Request, res: Response): Promise<Response> => {
try {
const { externalDbConnectionId, tableName, warehouseKey } = req.query;
const { externalDbConnectionId, warehouseKey, tableName } = req.query;
if (!externalDbConnectionId || !tableName) {
if (!externalDbConnectionId || !warehouseKey || !tableName) {
return res.status(400).json({
success: false,
message: "외부 DB 연결 ID와 테이블명이 필요합니다.",
message: "필수 파라미터가 누락되었습니다.",
});
}
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
// 테이블명을 사용하여 모든 컬럼 조회
let query = `SELECT * FROM ${tableName}`;
if (warehouseKey) {
query += ` WHERE WAREKEY = '${warehouseKey}'`;
}
query += ` LIMIT 1000`;
const query = `
SELECT * FROM ${tableName}
WHERE WAREKEY = '${warehouseKey}'
LIMIT 1000
`;
const result = await connector.executeQuery(query);
logger.info("Area 목록 조회", {
logger.info("구역 목록 조회", {
externalDbConnectionId,
tableName,
warehouseKey,
@ -120,41 +248,38 @@ export const getAreas = async (req: Request, res: Response): Promise<Response> =
data: result.rows,
});
} catch (error: any) {
logger.error("Area 목록 조회 실패", error);
logger.error("구역 목록 조회 실패", error);
return res.status(500).json({
success: false,
message: "Area 목록 조회 중 오류가 발생했습니다.",
message: "구역 목록 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
};
// Location 목록 조회 (사용자 지정 테이블)
// 위치 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
export const getLocations = async (req: Request, res: Response): Promise<Response> => {
try {
const { externalDbConnectionId, tableName, areaKey } = req.query;
const { externalDbConnectionId, areaKey, tableName } = req.query;
if (!externalDbConnectionId || !tableName) {
if (!externalDbConnectionId || !areaKey || !tableName) {
return res.status(400).json({
success: false,
message: "외부 DB 연결 ID와 테이블명이 필요합니다.",
message: "필수 파라미터가 누락되었습니다.",
});
}
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
// 테이블명을 사용하여 모든 컬럼 조회
let query = `SELECT * FROM ${tableName}`;
if (areaKey) {
query += ` WHERE AREAKEY = '${areaKey}'`;
}
query += ` LIMIT 1000`;
const query = `
SELECT * FROM ${tableName}
WHERE AREAKEY = '${areaKey}'
LIMIT 1000
`;
const result = await connector.executeQuery(query);
logger.info("Location 목록 조회", {
logger.info("위치 목록 조회", {
externalDbConnectionId,
tableName,
areaKey,
@ -166,37 +291,46 @@ export const getLocations = async (req: Request, res: Response): Promise<Respons
data: result.rows,
});
} catch (error: any) {
logger.error("Location 목록 조회 실패", error);
logger.error("위치 목록 조회 실패", error);
return res.status(500).json({
success: false,
message: "Location 목록 조회 중 오류가 발생했습니다.",
message: "위치 목록 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
};
// 자재 목록 조회 (사용자 지정 테이블)
// 자재 목록 조회 (동적 컬럼 매핑 지원)
export const getMaterials = async (req: Request, res: Response): Promise<Response> => {
try {
const { externalDbConnectionId, tableName, locaKey } = req.query;
const {
externalDbConnectionId,
locaKey,
tableName,
keyColumn,
locationKeyColumn,
layerColumn
} = req.query;
if (!externalDbConnectionId || !tableName) {
if (!externalDbConnectionId || !locaKey || !tableName || !locationKeyColumn) {
return res.status(400).json({
success: false,
message: "외부 DB 연결 ID와 테이블명이 필요합니다.",
message: "필수 파라미터가 누락되었습니다.",
});
}
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
// 테이블명을 사용하여 모든 컬럼 조회
let query = `SELECT * FROM ${tableName}`;
if (locaKey) {
query += ` WHERE LOCAKEY = '${locaKey}'`;
}
query += ` LIMIT 1000`;
// 동적 쿼리 생성
const orderByClause = layerColumn ? `ORDER BY ${layerColumn}` : '';
const query = `
SELECT * FROM ${tableName}
WHERE ${locationKeyColumn} = '${locaKey}'
${orderByClause}
LIMIT 1000
`;
logger.info(`자재 조회 쿼리: ${query}`);
const result = await connector.executeQuery(query);
@ -221,31 +355,28 @@ export const getMaterials = async (req: Request, res: Response): Promise<Respons
}
};
// Location별 자재 개수 조회 (배치 시 사용 - 사용자 지정 테이블)
// 자재 개수 조회 (여러 Location 일괄) - 레거시, 호환성 유지
export const getMaterialCounts = async (req: Request, res: Response): Promise<Response> => {
try {
const { externalDbConnectionId, tableName, locaKeys } = req.query;
const { externalDbConnectionId, locationKeys, tableName } = req.body;
if (!externalDbConnectionId || !tableName || !locaKeys) {
if (!externalDbConnectionId || !locationKeys || !tableName) {
return res.status(400).json({
success: false,
message: "외부 DB 연결 ID, 테이블명, Location 키 목록이 필요합니다.",
message: "필수 파라미터가 누락되었습니다.",
});
}
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
// locaKeys는 쉼표로 구분된 문자열
const locaKeyArray = (locaKeys as string).split(",");
const quotedKeys = locaKeyArray.map((key) => `'${key}'`).join(",");
const keysString = locationKeys.map((key: string) => `'${key}'`).join(",");
const query = `
SELECT
LOCAKEY,
COUNT(*) as material_count,
MAX(LOLAYER) as max_layer
LOCAKEY as location_key,
COUNT(*) as count
FROM ${tableName}
WHERE LOCAKEY IN (${quotedKeys})
WHERE LOCAKEY IN (${keysString})
GROUP BY LOCAKEY
`;
@ -254,7 +385,7 @@ export const getMaterialCounts = async (req: Request, res: Response): Promise<Re
logger.info("자재 개수 조회", {
externalDbConnectionId,
tableName,
locaKeyCount: locaKeyArray.length,
locationCount: locationKeys.length,
});
return res.json({
@ -270,4 +401,3 @@ export const getMaterialCounts = async (req: Request, res: Response): Promise<Re
});
}
};

View File

@ -138,6 +138,7 @@ export const createLayout = async (
warehouseKey,
layoutName,
description,
hierarchyConfig,
objects,
} = req.body;
@ -147,9 +148,9 @@ export const createLayout = async (
const layoutQuery = `
INSERT INTO digital_twin_layout (
company_code, external_db_connection_id, warehouse_key,
layout_name, description, created_by, updated_by
layout_name, description, hierarchy_config, created_by, updated_by
)
VALUES ($1, $2, $3, $4, $5, $6, $6)
VALUES ($1, $2, $3, $4, $5, $6, $7, $7)
RETURNING *
`;
@ -159,6 +160,7 @@ export const createLayout = async (
warehouseKey,
layoutName,
description,
hierarchyConfig ? JSON.stringify(hierarchyConfig) : null,
userId,
]);
@ -174,9 +176,10 @@ export const createLayout = async (
rotation, color,
area_key, loca_key, loc_type,
material_count, material_preview_height,
parent_id, display_order, locked
parent_id, display_order, locked,
hierarchy_level, parent_key, external_key
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)
`;
for (const obj of objects) {
@ -200,6 +203,9 @@ export const createLayout = async (
obj.parentId || null,
obj.displayOrder || 0,
obj.locked || false,
obj.hierarchyLevel || 1,
obj.parentKey || null,
obj.externalKey || null,
]);
}
}
@ -240,7 +246,14 @@ export const updateLayout = async (
const companyCode = req.user?.companyCode;
const userId = req.user?.userId;
const { id } = req.params;
const { layoutName, description, objects } = req.body;
const {
layoutName,
description,
hierarchyConfig,
externalDbConnectionId,
warehouseKey,
objects,
} = req.body;
await client.query("BEGIN");
@ -249,15 +262,21 @@ export const updateLayout = async (
UPDATE digital_twin_layout
SET layout_name = $1,
description = $2,
updated_by = $3,
hierarchy_config = $3,
external_db_connection_id = $4,
warehouse_key = $5,
updated_by = $6,
updated_at = NOW()
WHERE id = $4 AND company_code = $5
WHERE id = $7 AND company_code = $8
RETURNING *
`;
const layoutResult = await client.query(updateLayoutQuery, [
layoutName,
description,
hierarchyConfig ? JSON.stringify(hierarchyConfig) : null,
externalDbConnectionId || null,
warehouseKey || null,
userId,
id,
companyCode,
@ -277,7 +296,7 @@ export const updateLayout = async (
[id]
);
// 새 객체 저장
// 새 객체 저장 (부모-자식 관계 처리)
if (objects && objects.length > 0) {
const objectQuery = `
INSERT INTO digital_twin_objects (
@ -287,12 +306,53 @@ export const updateLayout = async (
rotation, color,
area_key, loca_key, loc_type,
material_count, material_preview_height,
parent_id, display_order, locked
parent_id, display_order, locked,
hierarchy_level, parent_key, external_key
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)
RETURNING id
`;
for (const obj of objects) {
// 임시 ID (음수) → 실제 DB ID 매핑
const idMapping: { [tempId: number]: number } = {};
// 1단계: 부모 객체 먼저 저장 (parentId가 없는 것들)
for (const obj of objects.filter((o) => !o.parentId)) {
const result = await client.query(objectQuery, [
id,
obj.type,
obj.name,
obj.position.x,
obj.position.y,
obj.position.z,
obj.size.x,
obj.size.y,
obj.size.z,
obj.rotation || 0,
obj.color,
obj.areaKey || null,
obj.locaKey || null,
obj.locType || null,
obj.materialCount || 0,
obj.materialPreview?.height || null,
null, // parent_id
obj.displayOrder || 0,
obj.locked || false,
obj.hierarchyLevel || 1,
obj.parentKey || null,
obj.externalKey || null,
]);
// 임시 ID와 실제 DB ID 매핑
if (obj.id) {
idMapping[obj.id] = result.rows[0].id;
}
}
// 2단계: 자식 객체 저장 (parentId가 있는 것들)
for (const obj of objects.filter((o) => o.parentId)) {
const realParentId = idMapping[obj.parentId!] || null;
await client.query(objectQuery, [
id,
obj.type,
@ -310,9 +370,12 @@ export const updateLayout = async (
obj.locType || null,
obj.materialCount || 0,
obj.materialPreview?.height || null,
obj.parentId || null,
realParentId, // 실제 DB ID 사용
obj.displayOrder || 0,
obj.locked || false,
obj.hierarchyLevel || 1,
obj.parentKey || null,
obj.externalKey || null,
]);
}
}

View File

@ -12,6 +12,8 @@ import {
// 외부 DB 데이터 조회
import {
getHierarchyData,
getChildrenData,
getWarehouses,
getAreas,
getLocations,
@ -32,6 +34,12 @@ router.put("/layouts/:id", updateLayout); // 레이아웃 수정
router.delete("/layouts/:id", deleteLayout); // 레이아웃 삭제
// ========== 외부 DB 데이터 조회 API ==========
// 동적 계층 구조 API
router.post("/data/hierarchy", getHierarchyData); // 전체 계층 데이터 조회
router.post("/data/children", getChildrenData); // 특정 부모의 하위 데이터 조회
// 테이블 메타데이터 API
router.get("/data/tables/:connectionId", async (req, res) => {
// 테이블 목록 조회
try {
@ -56,11 +64,12 @@ router.get("/data/table-preview/:connectionId/:tableName", async (req, res) => {
}
});
// 레거시 API (호환성 유지)
router.get("/data/warehouses", getWarehouses); // 창고 목록
router.get("/data/areas", getAreas); // Area 목록
router.get("/data/locations", getLocations); // Location 목록
router.get("/data/materials", getMaterials); // 자재 목록 (특정 Location)
router.get("/data/material-counts", getMaterialCounts); // 자재 개수 (여러 Location)
router.post("/data/material-counts", getMaterialCounts); // 자재 개수 (여러 Location) - POST로 변경
export default router;

View File

@ -18,32 +18,26 @@ import { Pagination, PaginationInfo } from "@/components/common/Pagination";
import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal";
import { Plus, Search, Edit, Trash2, Copy, MoreVertical, AlertCircle, RefreshCw } from "lucide-react";
interface DashboardListClientProps {
initialDashboards: Dashboard[];
initialPagination: {
total: number;
page: number;
limit: number;
};
}
/**
*
* - CSR
* -
* - ///
*/
export default function DashboardListClient({ initialDashboards, initialPagination }: DashboardListClientProps) {
export default function DashboardListClient() {
const router = useRouter();
const { toast } = useToast();
const [dashboards, setDashboards] = useState<Dashboard[]>(initialDashboards);
const [loading, setLoading] = useState(false); // 초기 로딩은 서버에서 완료
// 상태 관리
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
const [loading, setLoading] = useState(true); // CSR이므로 초기 로딩 true
const [error, setError] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState("");
// 페이지네이션 상태
const [currentPage, setCurrentPage] = useState(initialPagination.page);
const [pageSize, setPageSize] = useState(initialPagination.limit);
const [totalCount, setTotalCount] = useState(initialPagination.total);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [totalCount, setTotalCount] = useState(0);
// 모달 상태
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
@ -73,17 +67,8 @@ export default function DashboardListClient({ initialDashboards, initialPaginati
}
};
// 초기 로드 여부 추적
const [isInitialLoad, setIsInitialLoad] = useState(true);
// 검색어/페이지 변경 시 fetch (초기 로딩 포함)
useEffect(() => {
// 초기 로드는 건너뛰기 (서버에서 이미 데이터를 가져왔음)
if (isInitialLoad) {
setIsInitialLoad(false);
return;
}
// 이후 검색어/페이지 변경 시에만 fetch
loadDashboards();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchTerm, currentPage, pageSize]);
@ -91,7 +76,7 @@ export default function DashboardListClient({ initialDashboards, initialPaginati
// 페이지네이션 정보 계산
const paginationInfo: PaginationInfo = {
currentPage,
totalPages: Math.ceil(totalCount / pageSize),
totalPages: Math.ceil(totalCount / pageSize) || 1,
totalItems: totalCount,
itemsPerPage: pageSize,
startItem: totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1,

View File

@ -1,73 +1,22 @@
import DashboardListClient from "@/app/(main)/admin/dashboard/DashboardListClient";
import { cookies } from "next/headers";
/**
* fetch
*
* -
* - CSR로
*/
async function getInitialDashboards() {
try {
// 서버 사이드 전용: 백엔드 API 직접 호출
// 도커 네트워크 내부에서는 서비스 이름 사용, 로컬에서는 127.0.0.1
const backendUrl = process.env.SERVER_API_URL || "http://backend:8080";
// 쿠키에서 authToken 추출
const cookieStore = await cookies();
const authToken = cookieStore.get("authToken")?.value;
if (!authToken) {
// 토큰이 없으면 빈 데이터 반환 (클라이언트에서 로드)
return {
dashboards: [],
pagination: { total: 0, page: 1, limit: 10 },
};
}
const response = await fetch(`${backendUrl}/api/dashboards/my?page=1&limit=10`, {
cache: "no-store", // 항상 최신 데이터
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${authToken}`, // Authorization 헤더로 전달
},
});
if (!response.ok) {
throw new Error(`Failed to fetch dashboards: ${response.status}`);
}
const data = await response.json();
return {
dashboards: data.data || [],
pagination: data.pagination || { total: 0, page: 1, limit: 10 },
};
} catch (error) {
console.error("Server-side fetch error:", error);
// 에러 발생 시 빈 데이터 반환 (클라이언트에서 재시도 가능)
return {
dashboards: [],
pagination: { total: 0, page: 1, limit: 10 },
};
}
}
/**
* ( )
* - +
* -
*/
export default async function DashboardListPage() {
const initialData = await getInitialDashboards();
export default function DashboardListPage() {
return (
<div className="bg-background flex min-h-screen flex-col">
<div className="space-y-6 p-6">
{/* 페이지 헤더 (서버에서 렌더링) */}
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-muted-foreground text-sm"> </p>
</div>
{/* 나머지 컨텐츠 (클라이언트 컴포넌트 + 서버 데이터) */}
<DashboardListClient initialDashboards={initialData.dashboards} initialPagination={initialData.pagination} />
{/* 클라이언트 컴포넌트 */}
<DashboardListClient />
</div>
</div>
);

View File

@ -199,14 +199,14 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult
onValueChange={(value: "current" | "external") => onChange({ connectionType: value })}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="current" id={"current-${dataSource.id}"} />
<Label htmlFor={"current-${dataSource.id}"} className="text-xs font-normal">
<RadioGroupItem value="current" id={`current-${dataSource.id}`} />
<Label htmlFor={`current-${dataSource.id}`} className="text-xs font-normal">
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="external" id={"external-${dataSource.id}"} />
<Label htmlFor={"external-${dataSource.id}"} className="text-xs font-normal">
<RadioGroupItem value="external" id={`external-${dataSource.id}`} />
<Label htmlFor={`external-${dataSource.id}`} className="text-xs font-normal">
</Label>
</div>
@ -216,7 +216,7 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult
{/* 외부 DB 선택 */}
{dataSource.connectionType === "external" && (
<div className="space-y-2">
<Label htmlFor={"external-conn-${dataSource.id}"} className="text-xs">
<Label htmlFor={`external-conn-${dataSource.id}`} className="text-xs">
*
</Label>
{loadingConnections ? (
@ -246,7 +246,7 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult
{/* SQL 쿼리 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor={"query-${dataSource.id}"} className="text-xs">
<Label htmlFor={`query-${dataSource.id}`} className="text-xs">
SQL *
</Label>
<Select
@ -313,7 +313,7 @@ ORDER BY 하위부서수 DESC`,
</Select>
</div>
<Textarea
id={"query-${dataSource.id}"}
id={`query-${dataSource.id}`}
value={dataSource.query || ""}
onChange={(e) => onChange({ query: e.target.value })}
placeholder="SELECT * FROM table_name WHERE ..."
@ -340,6 +340,9 @@ ORDER BY 하위부서수 DESC`,
<SelectItem value="arrow" className="text-xs">
</SelectItem>
<SelectItem value="truck" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
<p className="text-muted-foreground text-[10px]"> </p>

View File

@ -34,7 +34,8 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
const [showInfoPanel, setShowInfoPanel] = useState(false);
const [externalDbConnectionId, setExternalDbConnectionId] = useState<number | null>(null);
const [layoutName, setLayoutName] = useState<string>("");
const [hierarchyConfig, setHierarchyConfig] = useState<any>(null);
// 검색 및 필터
const [searchQuery, setSearchQuery] = useState("");
const [filterType, setFilterType] = useState<string>("all");
@ -49,39 +50,51 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
if (response.success && response.data) {
const { layout, objects } = response.data;
// 레이아웃 정보 저장
setLayoutName(layout.layoutName);
setExternalDbConnectionId(layout.externalDbConnectionId);
// 레이아웃 정보 저장 (snake_case와 camelCase 모두 지원)
setLayoutName(layout.layout_name || layout.layoutName);
setExternalDbConnectionId(layout.external_db_connection_id || layout.externalDbConnectionId);
// hierarchy_config 저장
if (layout.hierarchy_config) {
const config =
typeof layout.hierarchy_config === "string"
? JSON.parse(layout.hierarchy_config)
: layout.hierarchy_config;
setHierarchyConfig(config);
}
// 객체 데이터 변환
const loadedObjects: PlacedObject[] = objects.map((obj: any) => ({
id: obj.id,
type: obj.object_type,
name: obj.object_name,
position: {
x: parseFloat(obj.position_x),
y: parseFloat(obj.position_y),
z: parseFloat(obj.position_z),
},
size: {
x: parseFloat(obj.size_x),
y: parseFloat(obj.size_y),
z: parseFloat(obj.size_z),
},
rotation: obj.rotation ? parseFloat(obj.rotation) : 0,
color: obj.color,
areaKey: obj.area_key,
locaKey: obj.loca_key,
locType: obj.loc_type,
materialCount: obj.material_count,
materialPreview: obj.material_preview_height
? { height: parseFloat(obj.material_preview_height) }
: undefined,
parentId: obj.parent_id,
displayOrder: obj.display_order,
locked: obj.locked,
visible: obj.visible !== false,
}));
const loadedObjects: PlacedObject[] = objects.map((obj: any) => {
const objectType = obj.object_type;
return {
id: obj.id,
type: objectType,
name: obj.object_name,
position: {
x: parseFloat(obj.position_x),
y: parseFloat(obj.position_y),
z: parseFloat(obj.position_z),
},
size: {
x: parseFloat(obj.size_x),
y: parseFloat(obj.size_y),
z: parseFloat(obj.size_z),
},
rotation: obj.rotation ? parseFloat(obj.rotation) : 0,
color: getObjectColor(objectType), // 타입별 기본 색상 사용
areaKey: obj.area_key,
locaKey: obj.loca_key,
locType: obj.loc_type,
materialCount: obj.material_count,
materialPreview: obj.material_preview_height
? { height: parseFloat(obj.material_preview_height) }
: undefined,
parentId: obj.parent_id,
displayOrder: obj.display_order,
locked: obj.locked,
visible: obj.visible !== false,
};
});
setPlacedObjects(loadedObjects);
} else {
@ -101,16 +114,30 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
};
loadLayout();
}, [layoutId, toast]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layoutId]); // toast 제거 - 무한 루프 방지
// Location의 자재 목록 로드
const loadMaterialsForLocation = async (locaKey: string, externalDbConnectionId: number) => {
if (!hierarchyConfig?.material) {
console.warn("hierarchyConfig.material이 없습니다. 자재 로드를 건너뜁니다.");
return;
}
try {
setLoadingMaterials(true);
setShowInfoPanel(true);
const response = await getMaterials(externalDbConnectionId, locaKey);
const response = await getMaterials(externalDbConnectionId, {
tableName: hierarchyConfig.material.tableName,
keyColumn: hierarchyConfig.material.keyColumn,
locationKeyColumn: hierarchyConfig.material.locationKeyColumn,
layerColumn: hierarchyConfig.material.layerColumn,
locaKey: locaKey,
});
if (response.success && response.data) {
const sortedMaterials = response.data.sort((a, b) => a.LOLAYER - b.LOLAYER);
const layerColumn = hierarchyConfig.material.layerColumn || "LOLAYER";
const sortedMaterials = response.data.sort((a: any, b: any) => (a[layerColumn] || 0) - (b[layerColumn] || 0));
setMaterials(sortedMaterials);
} else {
setMaterials([]);
@ -196,6 +223,49 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
});
}, [placedObjects, filterType, searchQuery]);
// 객체 타입별 기본 색상 (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";
};
}, []);
// 3D 캔버스용 placements 변환 (useMemo로 최적화)
const canvasPlacements = useMemo(() => {
return placedObjects.map((obj) => ({
id: obj.id,
name: obj.name,
position_x: obj.position.x,
position_y: obj.position.y,
position_z: obj.position.z,
size_x: obj.size.x,
size_y: obj.size.y,
size_z: obj.size.z,
color: obj.color,
data_source_type: obj.type,
material_count: obj.materialCount,
material_preview_height: obj.materialPreview?.height,
yard_layout_id: undefined,
material_code: null,
material_name: null,
quantity: null,
unit: null,
data_source_config: undefined,
data_binding: undefined,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}));
}, [placedObjects]);
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
@ -217,13 +287,13 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
{/* 메인 영역 */}
<div className="flex flex-1 overflow-hidden">
{/* 좌측: 검색/필터 */}
<div className="flex h-full w-[20%] flex-col border-r">
<div className="flex h-full w-64 flex-shrink-0 flex-col border-r">
<div className="space-y-4 p-4">
{/* 검색 */}
<div>
<Label className="mb-2 block text-sm font-semibold"></Label>
<div className="relative">
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
@ -234,7 +304,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
<Button
variant="ghost"
size="sm"
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 p-0"
className="absolute top-1/2 right-1 h-7 w-7 -translate-y-1/2 p-0"
onClick={() => setSearchQuery("")}
>
<X className="h-3 w-3" />
@ -281,9 +351,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
{/* 객체 목록 */}
<div className="flex-1 overflow-y-auto border-t p-4">
<Label className="mb-2 block text-sm font-semibold">
({filteredObjects.length})
</Label>
<Label className="mb-2 block text-sm font-semibold"> ({filteredObjects.length})</Label>
{filteredObjects.length === 0 ? (
<div className="text-muted-foreground flex h-32 items-center justify-center text-center text-sm">
{searchQuery ? "검색 결과가 없습니다" : "객체가 없습니다"}
@ -306,9 +374,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
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"
selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm"
}`}
>
<div className="flex items-start justify-between">
@ -317,13 +383,13 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
<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 }}
style={{ backgroundColor: getObjectColor(obj.type) }}
/>
<span>{typeLabel}</span>
</div>
</div>
</div>
{/* 추가 정보 */}
<div className="mt-2 space-y-1">
{obj.areaKey && (
@ -354,33 +420,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
<div className="relative flex-1">
{!isLoading && (
<Yard3DCanvas
placements={useMemo(
() =>
placedObjects.map((obj) => ({
id: obj.id,
name: obj.name,
position_x: obj.position.x,
position_y: obj.position.y,
position_z: obj.position.z,
size_x: obj.size.x,
size_y: obj.size.y,
size_z: obj.size.z,
color: obj.color,
data_source_type: obj.type,
material_count: obj.materialCount,
material_preview_height: obj.materialPreview?.height,
yard_layout_id: undefined,
material_code: null,
material_name: null,
quantity: null,
unit: null,
data_source_config: undefined,
data_binding: undefined,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})),
[placedObjects],
)}
placements={canvasPlacements}
selectedPlacementId={selectedObject?.id || null}
onPlacementClick={(placement) => handleObjectClick(placement?.id || null)}
focusOnPlacementId={null}
@ -390,17 +430,12 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
</div>
{/* 우측: 정보 패널 */}
{showInfoPanel && selectedObject && (
<div className="h-full w-[25%] overflow-y-auto border-l">
<div className="h-full w-[480px] flex-shrink-0 overflow-y-auto border-l">
{selectedObject ? (
<div className="p-4">
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold"> </h3>
<p className="text-muted-foreground text-xs">{selectedObject.name}</p>
</div>
<Button variant="ghost" size="sm" onClick={() => setShowInfoPanel(false)}>
<X className="h-4 w-4" />
</Button>
<div className="mb-4">
<h3 className="text-lg font-semibold"> </h3>
<p className="text-muted-foreground text-xs">{selectedObject.name}</p>
</div>
{/* 기본 정보 */}
@ -429,72 +464,74 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
)}
</div>
{/* 자재 목록 (Location인 경우) */}
{/* 자재 목록 (Location인 경우) - 아코디언 */}
{(selectedObject.type === "location-bed" ||
selectedObject.type === "location-stp" ||
selectedObject.type === "location-temp" ||
selectedObject.type === "location-dest") && (
<div className="mt-4">
<Label className="mb-2 block text-sm font-semibold"> </Label>
{loadingMaterials ? (
<div className="flex h-32 items-center justify-center">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
) : materials.length === 0 ? (
<div className="text-muted-foreground flex h-32 items-center justify-center text-center text-sm">
{externalDbConnectionId
? "자재가 없습니다"
: "외부 DB 연결이 설정되지 않았습니다"}
{externalDbConnectionId ? "자재가 없습니다" : "외부 DB 연결이 설정되지 않았습니다"}
</div>
) : (
<div className="space-y-2">
{materials.map((material, index) => (
<div
key={`${material.STKKEY}-${index}`}
className="bg-muted hover:bg-accent rounded-lg border p-3 transition-colors"
>
<div className="mb-2 flex items-start justify-between">
<div className="flex-1">
<p className="text-sm font-medium">{material.STKKEY}</p>
<p className="text-muted-foreground mt-0.5 text-xs">
: {material.LOLAYER} | Area: {material.AREAKEY}
</p>
<Label className="mb-2 block text-sm font-semibold"> ({materials.length})</Label>
{materials.map((material, index) => {
const displayColumns = hierarchyConfig?.material?.displayColumns || [];
return (
<details
key={`${material.STKKEY}-${index}`}
className="bg-muted group hover:bg-accent rounded-lg border transition-colors"
>
<summary className="flex cursor-pointer items-center justify-between p-3 text-sm font-medium">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold">
{material[hierarchyConfig?.material?.layerColumn || "LOLAYER"]}
</span>
{displayColumns[0] && (
<span className="text-muted-foreground text-xs">
{material[displayColumns[0].column]}
</span>
)}
</div>
</div>
<svg
className="text-muted-foreground h-4 w-4 transition-transform group-open:rotate-180"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div className="space-y-2 border-t p-3 pt-3">
{displayColumns.map((colConfig: any) => (
<div key={colConfig.column} className="flex justify-between text-xs">
<span className="text-muted-foreground">{colConfig.label}:</span>
<span className="font-medium">{material[colConfig.column] || "-"}</span>
</div>
))}
</div>
</div>
<div className="text-muted-foreground grid grid-cols-2 gap-2 text-xs">
{material.STKWIDT && (
<div>
: <span className="font-medium">{material.STKWIDT}</span>
</div>
)}
{material.STKLENG && (
<div>
: <span className="font-medium">{material.STKLENG}</span>
</div>
)}
{material.STKHEIG && (
<div>
: <span className="font-medium">{material.STKHEIG}</span>
</div>
)}
{material.STKWEIG && (
<div>
: <span className="font-medium">{material.STKWEIG}</span>
</div>
)}
</div>
{material.STKRMKS && (
<p className="text-muted-foreground mt-2 text-xs italic">{material.STKRMKS}</p>
)}
</div>
))}
</details>
);
})}
</div>
)}
</div>
)}
</div>
</div>
)}
) : (
<div className="flex h-full items-center justify-center p-4">
<p className="text-muted-foreground text-sm"> </p>
</div>
)}
</div>
</div>
</div>
);

View File

@ -0,0 +1,410 @@
# 디지털 트윈 동적 계층 구조 마이그레이션 가이드
## 개요
**기존 구조**: Area(구역) → Location(위치) 고정 2단계
**신규 구조**: 동적 N-Level 계층 (영역 → 하위 영역 → ... → 자재)
---
## 1. 데이터베이스 마이그레이션
### 실행 방법
```bash
# PostgreSQL 컨테이너 접속
docker exec -it pms-db psql -U postgres -d erp
# 마이그레이션 실행
\i db/migrations/042_refactor_digital_twin_hierarchy.sql
```
### 변경 사항
- `digital_twin_layout` 테이블에 `hierarchy_config` JSONB 컬럼 추가
- 기존 테이블 매핑 컬럼들 제거 (warehouse_table_name, area_table_name 등)
- `digital_twin_objects` 테이블에 계층 관련 컬럼 추가:
- `hierarchy_level`: 계층 레벨 (1, 2, 3, ...)
- `parent_key`: 부모 객체의 외부 DB 키
- `external_key`: 자신의 외부 DB 키
---
## 2. 백엔드 API 변경 사항
### 신규 API 엔드포인트
#### 전체 계층 데이터 조회
```
POST /api/digital-twin/data/hierarchy
Request Body:
{
"externalDbConnectionId": 15,
"hierarchyConfig": "{...}" // JSON 문자열
}
Response:
{
"success": true,
"data": {
"warehouse": [...],
"levels": [
{ "level": 1, "name": "구역", "data": [...] },
{ "level": 2, "name": "위치", "data": [...] }
],
"materials": [
{ "location_key": "LOC001", "count": 150 }
]
}
}
```
#### 특정 부모의 하위 데이터 조회
```
POST /api/digital-twin/data/children
Request Body:
{
"externalDbConnectionId": 15,
"hierarchyConfig": "{...}",
"parentLevel": 1,
"parentKey": "AREA001"
}
Response:
{
"success": true,
"data": [...] // 다음 레벨 데이터
}
```
### 레거시 API (호환성 유지)
- `/api/digital-twin/data/warehouses` (GET)
- `/api/digital-twin/data/areas` (GET)
- `/api/digital-twin/data/locations` (GET)
- `/api/digital-twin/data/materials` (GET)
- `/api/digital-twin/data/material-counts` (POST로 변경)
---
## 3. 프론트엔드 변경 사항
### 새로운 컴포넌트
#### `HierarchyConfigPanel.tsx`
동적 계층 구조 설정 UI
**사용 방법:**
```tsx
import HierarchyConfigPanel from "./HierarchyConfigPanel";
<HierarchyConfigPanel
externalDbConnectionId={selectedDbConnection}
hierarchyConfig={hierarchyConfig}
onHierarchyConfigChange={setHierarchyConfig}
availableTables={availableTables}
onLoadTables={loadTablesFromDb}
onLoadColumns={loadColumnsFromTable}
/>
```
### 계층 구조 설정 예시
```json
{
"warehouse": {
"tableName": "MWARMA",
"keyColumn": "WAREKEY",
"nameColumn": "WARENAME"
},
"levels": [
{
"level": 1,
"name": "구역",
"tableName": "MAREMA",
"keyColumn": "AREAKEY",
"nameColumn": "AREANAME",
"parentKeyColumn": "WAREKEY",
"objectTypes": ["area"]
},
{
"level": 2,
"name": "위치",
"tableName": "MLOCMA",
"keyColumn": "LOCAKEY",
"nameColumn": "LOCANAME",
"parentKeyColumn": "AREAKEY",
"typeColumn": "LOCTYPE",
"objectTypes": ["location-bed", "location-stp"]
}
],
"material": {
"tableName": "WSTKKY",
"keyColumn": "STKKEY",
"locationKeyColumn": "LOCAKEY",
"layerColumn": "LOLAYER",
"quantityColumn": "STKQUAN"
}
}
```
---
## 4. 공간적 종속성 (Spatial Containment)
### 새로운 유틸리티: `spatialContainment.ts`
#### 주요 함수
**1. 포함 여부 확인**
```typescript
import { isContainedIn } from "./spatialContainment";
const isValid = isContainedIn(childObject, parentObject);
// 자식 객체가 부모 객체 내부에 있는지 AABB로 검증
```
**2. 유효한 부모 찾기**
```typescript
import { findValidParent } from "./spatialContainment";
const parent = findValidParent(draggedChild, allObjects, hierarchyLevels);
// 드래그 중인 자식 객체를 포함하는 부모 객체 자동 감지
```
**3. 검증**
```typescript
import { validateSpatialContainment } from "./spatialContainment";
const result = validateSpatialContainment(child, allObjects);
if (!result.valid) {
alert("하위 영역은 반드시 상위 영역 내부에 배치되어야 합니다!");
}
```
**4. 그룹 이동 (부모 이동 시 자식도 함께)**
```typescript
import { updateChildrenPositions, getAllDescendants } from "./spatialContainment";
// 부모 객체 이동 시
const updatedChildren = updateChildrenPositions(
parentObject,
oldPosition,
newPosition,
allObjects
);
// 모든 하위 자손(재귀) 가져오기
const descendants = getAllDescendants(parentId, allObjects);
```
---
## 5. DigitalTwinEditor 통합 방법
### Step 1: HierarchyConfigPanel 추가
```tsx
// DigitalTwinEditor.tsx
import HierarchyConfigPanel, { HierarchyConfig } from "./HierarchyConfigPanel";
const [hierarchyConfig, setHierarchyConfig] = useState<HierarchyConfig | null>(null);
// 좌측 사이드바에 추가
<HierarchyConfigPanel
externalDbConnectionId={selectedDbConnection}
hierarchyConfig={hierarchyConfig}
onHierarchyConfigChange={setHierarchyConfig}
availableTables={availableTables}
onLoadTables={loadTables}
onLoadColumns={loadColumns}
/>
```
### Step 2: 계층 데이터 로드
```tsx
import { getHierarchyData, getChildrenData } from "@/lib/api/digitalTwin";
const loadHierarchyData = async () => {
if (!selectedDbConnection || !hierarchyConfig) return;
const response = await getHierarchyData(selectedDbConnection, hierarchyConfig);
if (response.success && response.data) {
// 창고 데이터
setWarehouses(response.data.warehouse);
// 각 레벨 데이터
response.data.levels.forEach((level) => {
if (level.level === 1) {
setAvailableAreas(level.data);
} else if (level.level === 2) {
setAvailableLocations(level.data);
}
// ... 추가 레벨
});
// 자재 개수
setMaterialCounts(response.data.materials);
}
};
```
### Step 3: Yard3DCanvas에서 검증
```tsx
// Yard3DCanvas.tsx 또는 DigitalTwinEditor.tsx
import { validateSpatialContainment } from "./spatialContainment";
const handleObjectDrop = (droppedObject: PlacedObject) => {
const result = validateSpatialContainment(
{
id: droppedObject.id,
position: droppedObject.position,
size: droppedObject.size,
hierarchyLevel: droppedObject.hierarchyLevel || 1,
parentId: droppedObject.parentId,
},
placedObjects.map((obj) => ({
id: obj.id,
position: obj.position,
size: obj.size,
hierarchyLevel: obj.hierarchyLevel || 1,
parentId: obj.parentId,
}))
);
if (!result.valid) {
toast({
variant: "destructive",
title: "배치 오류",
description: "하위 영역은 반드시 상위 영역 내부에 배치되어야 합니다.",
});
return; // 배치 취소
}
// 유효하면 부모 ID 업데이트
droppedObject.parentId = result.parent?.id;
// 상태 업데이트
setPlacedObjects([...placedObjects, droppedObject]);
};
```
### Step 4: 그룹 이동 구현
```tsx
import { updateChildrenPositions, getAllDescendants } from "./spatialContainment";
const handleObjectMove = (
movedObject: PlacedObject,
oldPosition: { x: number; y: number; z: number },
newPosition: { x: number; y: number; z: number }
) => {
// 이동한 객체 업데이트
const updatedObjects = placedObjects.map((obj) =>
obj.id === movedObject.id
? { ...obj, position: newPosition }
: obj
);
// 모든 하위 자손 가져오기
const descendants = getAllDescendants(
movedObject.id,
placedObjects.map((obj) => ({
id: obj.id,
position: obj.position,
size: obj.size,
hierarchyLevel: obj.hierarchyLevel || 1,
parentId: obj.parentId,
}))
);
// 하위 자손들도 같이 이동
const delta = {
x: newPosition.x - oldPosition.x,
y: newPosition.y - oldPosition.y,
z: newPosition.z - oldPosition.z,
};
descendants.forEach((descendant) => {
const index = updatedObjects.findIndex((obj) => obj.id === descendant.id);
if (index !== -1) {
updatedObjects[index].position = {
x: updatedObjects[index].position.x + delta.x,
y: updatedObjects[index].position.y + delta.y,
z: updatedObjects[index].position.z + delta.z,
};
}
});
setPlacedObjects(updatedObjects);
};
```
---
## 6. 테스트 시나리오
### 테스트 1: 계층 구조 설정
1. 외부 DB 선택
2. 창고 테이블 선택 및 컬럼 매핑
3. 레벨 추가 (레벨 1: 구역, 레벨 2: 위치)
4. 각 레벨의 테이블 및 컬럼 매핑
5. 자재 테이블 설정
6. "저장" 클릭하여 `hierarchy_config` 저장
### 테스트 2: 데이터 로드
1. 계층 구조 설정 완료 후
2. 창고 선택
3. 각 레벨 데이터가 좌측 패널에 표시되는지 확인
4. 자재 개수가 올바르게 표시되는지 확인
### 테스트 3: 3D 배치 및 공간적 종속성
1. 레벨 1 (구역) 객체를 3D 캔버스에 드래그앤드롭
2. 레벨 2 (위치) 객체를 레벨 1 객체 **내부**에 드래그앤드롭 → 성공
3. 레벨 2 객체를 레벨 1 객체 **외부**에 드롭 → 오류 메시지 표시
### 테스트 4: 그룹 이동
1. 레벨 1 객체를 이동
2. 해당 레벨 1 객체의 모든 하위 객체(레벨 2, 3, ...)도 같이 이동하는지 확인
3. 부모-자식 관계가 유지되는지 확인
### 테스트 5: 레이아웃 저장/로드
1. 위 단계를 완료한 후 "저장" 클릭
2. 페이지 새로고침
3. 레이아웃을 다시 로드하여 계층 구조 및 객체 위치가 복원되는지 확인
---
## 7. 마이그레이션 체크리스트
- [ ] DB 마이그레이션 실행 (042_refactor_digital_twin_hierarchy.sql)
- [ ] 백엔드 API 테스트 (Postman/cURL)
- [ ] `HierarchyConfigPanel` 컴포넌트 통합
- [ ] `spatialContainment.ts` 유틸리티 통합
- [ ] `DigitalTwinEditor`에서 계층 데이터 로드 구현
- [ ] `Yard3DCanvas`에서 공간적 종속성 검증 구현
- [ ] 그룹 이동 기능 구현
- [ ] 모든 테스트 시나리오 통과
- [ ] 레거시 API와의 호환성 확인
---
## 8. 주의사항
1. **기존 레이아웃 데이터**: 마이그레이션 전 기존 레이아웃이 있다면 백업 필요
2. **컬럼 매핑 검증**: 외부 DB 테이블의 컬럼명이 변경될 수 있으므로 auto-mapping 로직 필수
3. **성능**: N-Level이 3단계 이상 깊어지면 재귀 쿼리 성능 모니터링 필요
4. **권한**: 외부 DB에 대한 읽기 권한 확인
---
## 9. 향후 개선 사항
1. **드래그 중 실시간 검증**: 드래그하는 동안 부모 영역 하이라이트
2. **시각적 피드백**: 유효한 배치 위치를 그리드에 색상으로 표시
3. **계층 구조 시각화**: 좌측 패널에 트리 구조로 표시
4. **Undo/Redo**: 객체 배치 실행 취소 기능
5. **스냅 가이드**: 부모 영역 테두리에 스냅 가이드라인 표시
---
**작성일**: 2025-11-20
**작성자**: AI Assistant

View File

@ -0,0 +1,559 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Loader2, Plus, Trash2, GripVertical } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
// 계층 레벨 설정 인터페이스
export interface HierarchyLevel {
level: number;
name: string;
tableName: string;
keyColumn: string;
nameColumn: string;
parentKeyColumn: string;
typeColumn?: string;
objectTypes: string[];
}
// 전체 계층 구조 설정
export interface HierarchyConfig {
warehouseKey: string; // 이 레이아웃이 속한 창고 키 (예: "DY99")
warehouse?: {
tableName: string; // 창고 테이블명 (예: "MWARMA")
keyColumn: string;
nameColumn: string;
};
levels: HierarchyLevel[];
material?: {
tableName: string;
keyColumn: string;
locationKeyColumn: string;
layerColumn?: string;
quantityColumn?: string;
displayColumns?: Array<{ column: string; label: string }>; // 우측 패널에 표시할 컬럼들 (컬럼명 + 표시명)
};
}
interface HierarchyConfigPanelProps {
externalDbConnectionId: number | null;
hierarchyConfig: HierarchyConfig | null;
onHierarchyConfigChange: (config: HierarchyConfig) => void;
availableTables: string[];
onLoadTables: () => Promise<void>;
onLoadColumns: (tableName: string) => Promise<string[]>;
}
export default function HierarchyConfigPanel({
externalDbConnectionId,
hierarchyConfig,
onHierarchyConfigChange,
availableTables,
onLoadTables,
onLoadColumns,
}: HierarchyConfigPanelProps) {
const [localConfig, setLocalConfig] = useState<HierarchyConfig>(
hierarchyConfig || {
warehouseKey: "",
levels: [],
},
);
const [loadingColumns, setLoadingColumns] = useState(false);
const [columnsCache, setColumnsCache] = useState<{ [tableName: string]: string[] }>({});
// 외부에서 변경된 경우 동기화
useEffect(() => {
if (hierarchyConfig) {
setLocalConfig(hierarchyConfig);
}
}, [hierarchyConfig]);
// 테이블 선택 시 컬럼 로드
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);
}
};
// 창고 키 변경 (제거됨 - 상위 컴포넌트에서 처리)
// 레벨 추가
const handleAddLevel = () => {
const maxLevel = localConfig.levels.length > 0 ? Math.max(...localConfig.levels.map((l) => l.level)) : 0;
const newLevel: HierarchyLevel = {
level: maxLevel + 1,
name: `레벨 ${maxLevel + 1}`,
tableName: "",
keyColumn: "",
nameColumn: "",
parentKeyColumn: "",
objectTypes: [],
};
const newConfig = {
...localConfig,
levels: [...localConfig.levels, newLevel],
};
setLocalConfig(newConfig);
// onHierarchyConfigChange(newConfig); // 즉시 전달하지 않음
};
// 레벨 삭제
const handleRemoveLevel = (level: number) => {
const newConfig = {
...localConfig,
levels: localConfig.levels.filter((l) => l.level !== level),
};
setLocalConfig(newConfig);
// onHierarchyConfigChange(newConfig); // 즉시 전달하지 않음
};
// 레벨 설정 변경
const handleLevelChange = (level: number, field: keyof HierarchyLevel, value: any) => {
const newConfig = {
...localConfig,
levels: localConfig.levels.map((l) => (l.level === level ? { ...l, [field]: value } : l)),
};
setLocalConfig(newConfig);
// onHierarchyConfigChange(newConfig); // 즉시 전달하지 않음
};
// 자재 설정 변경
const handleMaterialChange = (field: keyof NonNullable<HierarchyConfig["material"]>, value: string) => {
const newConfig = {
...localConfig,
material: {
...localConfig.material,
[field]: value,
} as NonNullable<HierarchyConfig["material"]>,
};
setLocalConfig(newConfig);
// onHierarchyConfigChange(newConfig); // 즉시 전달하지 않음
};
// 창고 설정 변경
const handleWarehouseChange = (field: keyof NonNullable<HierarchyConfig["warehouse"]>, value: string) => {
const newWarehouse = {
...localConfig.warehouse,
[field]: value,
} as NonNullable<HierarchyConfig["warehouse"]>;
setLocalConfig({ ...localConfig, warehouse: newWarehouse });
};
// 설정 적용
const handleApplyConfig = () => {
onHierarchyConfigChange(localConfig);
};
if (!externalDbConnectionId) {
return <div className="text-muted-foreground p-4 text-center text-sm"> DB를 </div>;
}
return (
<div className="space-y-4">
{/* 창고 설정 */}
<Card>
<CardHeader className="p-4 pb-3">
<CardTitle className="text-sm"> </CardTitle>
<CardDescription className="text-[10px]"> </CardDescription>
</CardHeader>
<CardContent className="space-y-2 p-4 pt-0">
{/* 창고 테이블 선택 */}
<div>
<Label className="text-[10px]"></Label>
<Select
value={localConfig.warehouse?.tableName || ""}
onValueChange={async (value) => {
handleWarehouseChange("tableName", value);
await handleTableChange(value, "warehouse");
}}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue placeholder="테이블 선택..." />
</SelectTrigger>
<SelectContent>
{availableTables.map((table) => (
<SelectItem key={table} value={table} className="text-[10px]">
{table}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 창고 컬럼 매핑 */}
{localConfig.warehouse?.tableName && columnsCache[localConfig.warehouse.tableName] && (
<div className="space-y-2">
<div>
<Label className="text-[10px]">ID </Label>
<Select
value={localConfig.warehouse.keyColumn || ""}
onValueChange={(value) => handleWarehouseChange("keyColumn", value)}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue placeholder="선택..." />
</SelectTrigger>
<SelectContent>
{columnsCache[localConfig.warehouse.tableName].map((col) => (
<SelectItem key={col} value={col} className="text-[10px]">
{col}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Select
value={localConfig.warehouse.nameColumn || ""}
onValueChange={(value) => handleWarehouseChange("nameColumn", value)}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue placeholder="선택..." />
</SelectTrigger>
<SelectContent>
{columnsCache[localConfig.warehouse.tableName].map((col) => (
<SelectItem key={col} value={col} className="text-[10px]">
{col}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
</CardContent>
</Card>
{/* 계층 레벨 목록 */}
<Card>
<CardHeader className="p-4 pb-3">
<CardTitle className="text-sm"> </CardTitle>
<CardDescription className="text-[10px]">, </CardDescription>
</CardHeader>
<CardContent className="space-y-3 p-4 pt-0">
{localConfig.levels.length === 0 && (
<div className="text-muted-foreground py-6 text-center text-xs"> </div>
)}
{localConfig.levels.map((level) => (
<Card key={level.level} className="border-muted">
<CardHeader className="flex flex-row items-center justify-between p-3">
<div className="flex items-center gap-2">
<GripVertical className="text-muted-foreground h-4 w-4" />
<Input
value={level.name}
onChange={(e) => handleLevelChange(level.level, "name", e.target.value)}
className="h-7 w-32 text-xs"
placeholder="레벨명"
/>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveLevel(level.level)}
className="h-7 w-7 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</CardHeader>
<CardContent className="space-y-2 p-3 pt-0">
<div>
<Label className="text-[10px]"></Label>
<Select
value={level.tableName}
onValueChange={(val) => {
handleLevelChange(level.level, "tableName", val);
handleTableChange(val, level.level);
}}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{availableTables.map((table) => (
<SelectItem key={table} value={table} className="text-xs">
{table}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{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}
</SelectItem>
))}
</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>
<div>
<Label className="text-[10px]"> </Label>
<Select
value={level.parentKeyColumn}
onValueChange={(val) => handleLevelChange(level.level, "parentKeyColumn", val)}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue placeholder="부모 키 컬럼" />
</SelectTrigger>
<SelectContent>
{columnsCache[level.tableName].map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[10px]"> ()</Label>
<Select
value={level.typeColumn || "__none__"}
onValueChange={(val) =>
handleLevelChange(level.level, "typeColumn", val === "__none__" ? undefined : val)
}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue placeholder="타입 컬럼 (선택)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"></SelectItem>
{columnsCache[level.tableName].map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
</CardContent>
</Card>
))}
<Button variant="outline" size="sm" onClick={handleAddLevel} className="h-8 w-full text-xs">
<Plus className="mr-1 h-3 w-3" />
</Button>
</CardContent>
</Card>
{/* 자재 설정 */}
<Card>
<CardHeader className="p-4 pb-3">
<CardTitle className="text-sm"> </CardTitle>
<CardDescription className="text-[10px]"> </CardDescription>
</CardHeader>
<CardContent className="space-y-3 p-4 pt-0">
<div>
<Label className="text-[10px]"></Label>
<Select
value={localConfig.material?.tableName || ""}
onValueChange={(val) => {
handleMaterialChange("tableName", val);
handleTableChange(val, "material");
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{availableTables.map((table) => (
<SelectItem key={table} value={table} className="text-xs">
{table}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{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>
<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>
<SelectItem value="__none__"></SelectItem>
{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.quantityColumn || "__none__"}
onValueChange={(val) => handleMaterialChange("quantityColumn", val === "__none__" ? undefined : val)}
>
<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>
<Separator className="my-3" />
{/* 표시 컬럼 선택 */}
<div>
<Label className="text-[10px]"> </Label>
<p className="text-muted-foreground mb-2 text-[9px]">
</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 isSelected = !!displayItem;
return (
<div key={col} 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);
handleMaterialChange("displayColumns", newDisplay);
}}
className="h-3 w-3 shrink-0"
/>
<span className="w-20 shrink-0 text-[10px]">{col}</span>
{isSelected && (
<Input
value={displayItem?.label || col}
onChange={(e) => {
const currentDisplay = localConfig.material?.displayColumns || [];
const newDisplay = currentDisplay.map((d) =>
d.column === col ? { ...d, label: e.target.value } : d,
);
handleMaterialChange("displayColumns", newDisplay);
}}
placeholder="표시명 입력..."
className="h-6 flex-1 text-[10px]"
/>
)}
</div>
);
})}
</div>
</div>
</>
)}
</CardContent>
</Card>
{/* 적용 버튼 */}
<div className="flex justify-end">
<Button onClick={handleApplyConfig} className="h-10 gap-2 text-sm font-medium">
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,164 @@
/**
*
*
*
*/
export interface SpatialObject {
id: number;
position: { x: number; y: number; z: number };
size: { x: number; y: number; z: number };
hierarchyLevel: number;
parentId?: number;
parentKey?: string; // 외부 DB 키 (데이터 바인딩용)
}
/**
* A가 B (AABB)
*/
export function isContainedIn(child: SpatialObject, parent: SpatialObject): boolean {
// AABB (Axis-Aligned Bounding Box) 계산
const childMin = {
x: child.position.x - child.size.x / 2,
z: child.position.z - child.size.z / 2,
};
const childMax = {
x: child.position.x + child.size.x / 2,
z: child.position.z + child.size.z / 2,
};
const parentMin = {
x: parent.position.x - parent.size.x / 2,
z: parent.position.z - parent.size.z / 2,
};
const parentMax = {
x: parent.position.x + parent.size.x / 2,
z: parent.position.z + parent.size.z / 2,
};
// 자식 객체의 모든 모서리가 부모 객체 내부에 있어야 함 (XZ 평면에서)
return (
childMin.x >= parentMin.x &&
childMax.x <= parentMax.x &&
childMin.z >= parentMin.z &&
childMax.z <= parentMax.z
);
}
/**
*
* @param child
* @param allObjects
* @param hierarchyLevels (1, 2, 3, ...)
* @returns null
*/
export function findValidParent(
child: SpatialObject,
allObjects: SpatialObject[],
hierarchyLevels: number
): SpatialObject | null {
// 최상위 레벨(레벨 1)은 부모가 없음
if (child.hierarchyLevel === 1) {
return null;
}
// 부모 레벨 (자신보다 1단계 위)
const parentLevel = child.hierarchyLevel - 1;
// 부모 레벨의 모든 객체 중에서 포함하는 객체 찾기
const possibleParents = allObjects.filter(
(obj) => obj.hierarchyLevel === parentLevel
);
for (const parent of possibleParents) {
if (isContainedIn(child, parent)) {
return parent;
}
}
// 포함하는 부모가 없으면 null
return null;
}
/**
*
* @param child
* @param allObjects
* @returns { valid: boolean, parent: SpatialObject | null }
*/
export function validateSpatialContainment(
child: SpatialObject,
allObjects: SpatialObject[]
): { valid: boolean; parent: SpatialObject | null } {
// 최상위 레벨은 항상 유효
if (child.hierarchyLevel === 1) {
return { valid: true, parent: null };
}
const parent = findValidParent(child, allObjects, child.hierarchyLevel);
return {
valid: parent !== null,
parent: parent,
};
}
/**
*
* @param parent
* @param oldPosition
* @param newPosition
* @param allObjects
* @returns
*/
export function updateChildrenPositions(
parent: SpatialObject,
oldPosition: { x: number; y: number; z: number },
newPosition: { x: number; y: number; z: number },
allObjects: SpatialObject[]
): SpatialObject[] {
const delta = {
x: newPosition.x - oldPosition.x,
y: newPosition.y - oldPosition.y,
z: newPosition.z - oldPosition.z,
};
// 직계 자식 (부모 ID가 일치하는 객체)
const directChildren = allObjects.filter(
(obj) => obj.parentId === parent.id
);
// 자식들의 위치 업데이트
return directChildren.map((child) => ({
...child,
position: {
x: child.position.x + delta.x,
y: child.position.y + delta.y,
z: child.position.z + delta.z,
},
}));
}
/**
* ()
* @param parentId ID
* @param allObjects
* @returns
*/
export function getAllDescendants(
parentId: number,
allObjects: SpatialObject[]
): SpatialObject[] {
const directChildren = allObjects.filter((obj) => obj.parentId === parentId);
let descendants = [...directChildren];
// 재귀적으로 손자, 증손자... 찾기
for (const child of directChildren) {
const grandChildren = getAllDescendants(child.id, allObjects);
descendants = [...descendants, ...grandChildren];
}
return descendants;
}

View File

@ -424,7 +424,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
const firstCoord = row.coordinates[0];
if (Array.isArray(firstCoord) && firstCoord.length === 2) {
polygons.push({
id: row.id || row.code || `polygon-${index}`,
id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성
name: row.name || row.title || `영역 ${index + 1}`,
coordinates: row.coordinates as [number, number][],
status: row.status || row.level,
@ -499,9 +499,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
if (lat !== undefined && lng !== undefined && (mapDisplayType as string) !== "polygon") {
markers.push({
// 진행 방향(heading) 계산을 위해 ID는 새로고침마다 바뀌지 않도록 고정값 사용
// - row.id / row.code가 있으면 그 값을 사용
// - 없으면 sourceName과 index 조합으로 고정 ID 생성
id: row.id || row.code || `${sourceName}-marker-${index}`,
// 중복 방지를 위해 sourceName과 index를 조합하여 고유 ID 생성
id: `${sourceName}-${row.id || row.code || "marker"}-${index}`,
lat: Number(lat),
lng: Number(lng),
latitude: Number(lat),
@ -1264,14 +1263,15 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
{/* 마커 렌더링 */}
{markers.map((marker) => {
// 첫 번째 데이터 소스의 마커 종류 가져오
const firstDataSource = dataSources?.[0];
const markerType = firstDataSource?.markerType || "circle";
// 마커의 소스에 해당하는 데이터 소스 찾
const sourceDataSource = dataSources?.find((ds) => ds.name === marker.source) || dataSources?.[0];
const markerType = sourceDataSource?.markerType || "circle";
let markerIcon: any;
if (typeof window !== "undefined") {
const L = require("leaflet");
const heading = marker.heading || 0;
// heading이 없거나 0일 때 기본값 90(동쪽/오른쪽)으로 설정하여 처음에 오른쪽을 보게 함
const heading = marker.heading || 90;
if (markerType === "arrow") {
// 화살표 마커
@ -1303,6 +1303,9 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
});
} else if (markerType === "truck") {
// 트럭 마커
// 트럭 아이콘이 오른쪽(90도)을 보고 있으므로, 북쪽(0도)으로 가려면 -90도 회전 필요
const rotation = heading - 90;
markerIcon = L.divIcon({
className: "custom-truck-marker",
html: `
@ -1312,11 +1315,11 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
display: flex;
align-items: center;
justify-content: center;
transform: translate(-50%, -50%) rotate(${heading}deg);
transform: translate(-50%, -50%) rotate(${rotation}deg);
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
">
<svg width="48" height="48" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
<g transform="rotate(-90 20 20)">
<g>
<!-- -->
<rect
x="10"

View File

@ -377,8 +377,8 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
new Date().toISOString();
const alert: Alert = {
id: row.id || row.alert_id || row.incidentId || row.eventId ||
row.code || row.subCode || `${sourceName}-${index}-${Date.now()}`,
// 중복 방지를 위해 소스명과 인덱스를 포함하여 고유 ID 생성
id: `${sourceName}-${index}-${row.id || row.alert_id || row.incidentId || row.eventId || row.code || row.subCode || Date.now()}`,
type,
severity,
title,
@ -614,8 +614,9 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
<p className="text-sm"> </p>
</div>
) : (
filteredAlerts.map((alert) => (
<Card key={alert.id} className="p-2">
filteredAlerts.map((alert, idx) => (
// key 중복 방지를 위해 인덱스 추가
<Card key={`${alert.id}-${idx}`} className="p-2">
<div className="flex items-start gap-2">
<div className={`mt-0.5 rounded-full p-1 ${alert.severity === "high" ? "bg-destructive/10 text-destructive" : alert.severity === "medium" ? "bg-warning/10 text-warning" : "bg-primary/10 text-primary"}`}>
{getTypeIcon(alert.type)}

View File

@ -91,9 +91,7 @@ export const deleteLayout = async (id: number): Promise<ApiResponse<void>> => {
// ========== 외부 DB 테이블 조회 API ==========
export const getTables = async (
connectionId: number
): Promise<ApiResponse<Array<{ table_name: string }>>> => {
export const getTables = async (connectionId: number): Promise<ApiResponse<Array<{ table_name: string }>>> => {
try {
const response = await apiClient.get(`/digital-twin/data/tables/${connectionId}`);
return response.data;
@ -105,10 +103,7 @@ export const getTables = async (
}
};
export const getTablePreview = async (
connectionId: number,
tableName: string
): Promise<ApiResponse<any[]>> => {
export const getTablePreview = async (connectionId: number, tableName: string): Promise<ApiResponse<any[]>> => {
try {
const response = await apiClient.get(`/digital-twin/data/table-preview/${connectionId}/${tableName}`);
return response.data;
@ -123,7 +118,10 @@ export const getTablePreview = async (
// ========== 외부 DB 데이터 조회 API ==========
// 창고 목록 조회
export const getWarehouses = async (externalDbConnectionId: number, tableName: string): Promise<ApiResponse<Warehouse[]>> => {
export const getWarehouses = async (
externalDbConnectionId: number,
tableName: string,
): Promise<ApiResponse<Warehouse[]>> => {
try {
const response = await apiClient.get("/digital-twin/data/warehouses", {
params: { externalDbConnectionId, tableName },
@ -138,7 +136,11 @@ export const getWarehouses = async (externalDbConnectionId: number, tableName: s
};
// Area 목록 조회
export const getAreas = async (externalDbConnectionId: number, tableName: string, warehouseKey: string): Promise<ApiResponse<Area[]>> => {
export const getAreas = async (
externalDbConnectionId: number,
tableName: string,
warehouseKey: string,
): Promise<ApiResponse<Area[]>> => {
try {
const response = await apiClient.get("/digital-twin/data/areas", {
params: { externalDbConnectionId, tableName, warehouseKey },
@ -174,12 +176,24 @@ export const getLocations = async (
// 자재 목록 조회 (특정 Location)
export const getMaterials = async (
externalDbConnectionId: number,
tableName: string,
locaKey: string,
materialConfig: {
tableName: string;
keyColumn: string;
locationKeyColumn: string;
layerColumn?: string;
locaKey: string;
},
): Promise<ApiResponse<MaterialData[]>> => {
try {
const response = await apiClient.get("/digital-twin/data/materials", {
params: { externalDbConnectionId, tableName, locaKey },
params: {
externalDbConnectionId,
tableName: materialConfig.tableName,
keyColumn: materialConfig.keyColumn,
locationKeyColumn: materialConfig.locationKeyColumn,
layerColumn: materialConfig.layerColumn,
locaKey: materialConfig.locaKey,
},
});
return response.data;
} catch (error: any) {
@ -197,12 +211,10 @@ export const getMaterialCounts = async (
locaKeys: string[],
): Promise<ApiResponse<MaterialCount[]>> => {
try {
const response = await apiClient.get("/digital-twin/data/material-counts", {
params: {
externalDbConnectionId,
tableName,
locaKeys: locaKeys.join(","),
},
const response = await apiClient.post("/digital-twin/data/material-counts", {
externalDbConnectionId,
tableName,
locationKeys: locaKeys,
});
return response.data;
} catch (error: any) {
@ -213,3 +225,59 @@ export const getMaterialCounts = async (
}
};
// ========== 동적 계층 구조 API ==========
export interface HierarchyData {
warehouse: any[];
levels: Array<{
level: number;
name: string;
data: any[];
}>;
materials: Array<{
location_key: string;
count: number;
}>;
}
// 전체 계층 데이터 조회
export const getHierarchyData = async (
externalDbConnectionId: number,
hierarchyConfig: any,
): Promise<ApiResponse<HierarchyData>> => {
try {
const response = await apiClient.post("/digital-twin/data/hierarchy", {
externalDbConnectionId,
hierarchyConfig: JSON.stringify(hierarchyConfig),
});
return response.data;
} catch (error: any) {
return {
success: false,
error: error.response?.data?.message || error.message,
};
}
};
// 특정 부모의 하위 데이터 조회
export const getChildrenData = async (
externalDbConnectionId: number,
hierarchyConfig: any,
parentLevel: number,
parentKey: string,
): Promise<ApiResponse<any[]>> => {
try {
const response = await apiClient.post("/digital-twin/data/children", {
externalDbConnectionId,
hierarchyConfig: JSON.stringify(hierarchyConfig),
parentLevel,
parentKey,
});
return response.data;
} catch (error: any) {
return {
success: false,
error: error.response?.data?.message || error.message,
};
}
};

View File

@ -72,6 +72,11 @@ export interface PlacedObject {
// 편집 제한
locked?: boolean; // true면 이동/크기조절 불가
visible?: boolean;
// 동적 계층 구조
hierarchyLevel?: number; // 1, 2, 3...
parentKey?: string; // 부모 객체의 외부 DB 키
externalKey?: string; // 자신의 외부 DB 키
}
// 레이아웃
@ -82,6 +87,7 @@ export interface DigitalTwinLayout {
warehouseKey: string; // WAREKEY (예: DY99)
layoutName: string;
description?: string;
hierarchyConfig?: any; // JSON 설정
isActive: boolean;
createdBy?: number;
updatedBy?: number;