From 5533a134c60de4bd73c55627f05368d7204099f9 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Fri, 14 Nov 2025 13:47:55 +0900 Subject: [PATCH 01/15] =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=81=AC=20=EC=9C=84?= =?UTF-8?q?=EC=A0=AF=20=EB=82=A0=EC=A7=9C=20=ED=98=95=EC=8B=9D=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/widgets/RiskAlertTestWidget.tsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/frontend/components/dashboard/widgets/RiskAlertTestWidget.tsx b/frontend/components/dashboard/widgets/RiskAlertTestWidget.tsx index 8aa2e3e2..df8bf098 100644 --- a/frontend/components/dashboard/widgets/RiskAlertTestWidget.tsx +++ b/frontend/components/dashboard/widgets/RiskAlertTestWidget.tsx @@ -636,9 +636,10 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
{(() => { - const ts = String(alert.timestamp); + const original = String(alert.timestamp); + const ts = original.replace(/\s+/g, ""); // 공백 제거 - // yyyyMMddHHmm 형식 감지 (예: 20251114 1000) + // yyyyMMddHHmm 형식 감지 (12자리 숫자) if (/^\d{12}$/.test(ts)) { const year = ts.substring(0, 4); const month = ts.substring(4, 6); @@ -646,12 +647,20 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp const hour = ts.substring(8, 10); const minute = ts.substring(10, 12); const date = new Date(`${year}-${month}-${day}T${hour}:${minute}:00`); - return isNaN(date.getTime()) ? ts : date.toLocaleString("ko-KR"); + return isNaN(date.getTime()) ? original : date.toLocaleString("ko-KR"); + } + + // "2025년 11월 14일 13시 20분" 형식 + const koreanMatch = original.match(/(\d{4})년\s*(\d{1,2})월\s*(\d{1,2})일\s*(\d{1,2})시\s*(\d{1,2})분/); + if (koreanMatch) { + const [, year, month, day, hour, minute] = koreanMatch; + const date = new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}T${hour.padStart(2, '0')}:${minute.padStart(2, '0')}:00`); + return isNaN(date.getTime()) ? original : date.toLocaleString("ko-KR"); } // ISO 형식 또는 일반 날짜 형식 - const date = new Date(ts); - return isNaN(date.getTime()) ? ts : date.toLocaleString("ko-KR"); + const date = new Date(original); + return isNaN(date.getTime()) ? original : date.toLocaleString("ko-KR"); })()} {alert.source && · {alert.source}} -- 2.43.0 From 385ecdc46a0ab9a9133d8cf94dff0494c34afae2 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Fri, 14 Nov 2025 16:55:52 +0900 Subject: [PATCH 02/15] =?UTF-8?q?=ED=86=B5=EA=B3=84=EC=B9=B4=EB=93=9C?= =?UTF-8?q?=EC=97=90=20=EC=8B=9C=EA=B0=84=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/QueryEditor.tsx | 237 ++---------------- frontend/components/admin/dashboard/types.ts | 1 + .../widget-sections/CustomMetricSection.tsx | 23 ++ .../widgets/CustomMetricTestWidget.tsx | 25 +- .../dashboard/widgets/CustomMetricWidget.tsx | 44 ++-- 5 files changed, 89 insertions(+), 241 deletions(-) diff --git a/frontend/components/admin/dashboard/QueryEditor.tsx b/frontend/components/admin/dashboard/QueryEditor.tsx index 8f0b65e0..50e57689 100644 --- a/frontend/components/admin/dashboard/QueryEditor.tsx +++ b/frontend/components/admin/dashboard/QueryEditor.tsx @@ -1,12 +1,11 @@ "use client"; import React, { useState, useCallback } from "react"; -import { ChartDataSource, QueryResult, ChartConfig } from "./types"; +import { ChartDataSource, QueryResult } from "./types"; import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection"; import { dashboardApi } from "@/lib/api/dashboard"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Label } from "@/components/ui/label"; import { Card } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; @@ -14,7 +13,6 @@ import { Alert, AlertDescription } from "@/components/ui/alert"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Play, Loader2, Database, Code, ChevronDown, ChevronRight } from "lucide-react"; -import { applyQueryFilters } from "./utils/queryHelpers"; interface QueryEditorProps { dataSource?: ChartDataSource; @@ -106,7 +104,6 @@ export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: Que ...dataSource, type: "database", query: query.trim(), - refreshInterval: dataSource?.refreshInterval ?? 0, lastExecuted: new Date().toISOString(), }); } catch (err) { @@ -168,8 +165,8 @@ ORDER BY 하위부서수 DESC`, {/* 쿼리 에디터 헤더 */}
- -

SQL 쿼리 에디터

+ +

SQL 쿼리 에디터

@@ -247,46 +244,6 @@ ORDER BY 하위부서수 DESC`,
- {/* 새로고침 간격 설정 */} -
- - -
- {/* 오류 메시지 */} {error && ( @@ -300,15 +257,15 @@ ORDER BY 하위부서수 DESC`, {/* 쿼리 결과 미리보기 */} {queryResult && ( -
+
- 쿼리 결과 + 쿼리 결과 {queryResult.rows.length}행
- 실행 시간: {queryResult.executionTime}ms + 실행 시간: {queryResult.executionTime}ms
@@ -339,13 +296,13 @@ ORDER BY 하위부서수 DESC`, {queryResult.rows.length > 10 && ( -
+
... 및 {queryResult.rows.length - 10}개 더 (미리보기는 10행까지만 표시)
)}
) : ( -
결과가 없습니다.
+
결과가 없습니다.
)}
@@ -353,169 +310,3 @@ ORDER BY 하위부서수 DESC`, ); } - -/** - * 샘플 쿼리 결과 생성 함수 - */ -function generateSampleQueryResult(query: string): QueryResult { - // 쿼리에서 키워드 추출하여 적절한 샘플 데이터 생성 - const queryLower = query.toLowerCase(); - - // 디버깅용 로그 - // console.log('generateSampleQueryResult called with query:', query.substring(0, 100)); - - // 가장 구체적인 조건부터 먼저 체크 (순서 중요!) - const isComparison = - queryLower.includes("galaxy") || - queryLower.includes("갤럭시") || - queryLower.includes("아이폰") || - queryLower.includes("iphone"); - const isRegional = queryLower.includes("region") || queryLower.includes("지역"); - const isMonthly = queryLower.includes("month"); - const isSales = queryLower.includes("sales") || queryLower.includes("매출"); - const isUsers = queryLower.includes("users") || queryLower.includes("사용자"); - const isProducts = queryLower.includes("product") || queryLower.includes("상품"); - const isWeekly = queryLower.includes("week"); - - // console.log('Sample data type detection:', { - // isComparison, - // isRegional, - // isWeekly, - // isProducts, - // isMonthly, - // isSales, - // isUsers, - // querySnippet: query.substring(0, 200) - // }); - - let columns: string[]; - let rows: Record[]; - - // 더 구체적인 조건부터 먼저 체크 (순서 중요!) - if (isComparison) { - // console.log('✅ Using COMPARISON data'); - // 제품 비교 데이터 (다중 시리즈) - columns = ["month", "galaxy_sales", "iphone_sales", "other_sales"]; - rows = [ - { month: "2024-01", galaxy_sales: 450000, iphone_sales: 620000, other_sales: 130000 }, - { month: "2024-02", galaxy_sales: 520000, iphone_sales: 680000, other_sales: 150000 }, - { month: "2024-03", galaxy_sales: 480000, iphone_sales: 590000, other_sales: 110000 }, - { month: "2024-04", galaxy_sales: 610000, iphone_sales: 650000, other_sales: 160000 }, - { month: "2024-05", galaxy_sales: 720000, iphone_sales: 780000, other_sales: 180000 }, - { month: "2024-06", galaxy_sales: 680000, iphone_sales: 690000, other_sales: 170000 }, - { month: "2024-07", galaxy_sales: 750000, iphone_sales: 800000, other_sales: 170000 }, - { month: "2024-08", galaxy_sales: 690000, iphone_sales: 720000, other_sales: 170000 }, - { month: "2024-09", galaxy_sales: 730000, iphone_sales: 750000, other_sales: 170000 }, - { month: "2024-10", galaxy_sales: 800000, iphone_sales: 810000, other_sales: 170000 }, - { month: "2024-11", galaxy_sales: 870000, iphone_sales: 880000, other_sales: 170000 }, - { month: "2024-12", galaxy_sales: 950000, iphone_sales: 990000, other_sales: 160000 }, - ]; - // COMPARISON 데이터를 반환하고 함수 종료 - // console.log('COMPARISON data generated:', { - // columns, - // rowCount: rows.length, - // sampleRow: rows[0], - // allRows: rows, - // fieldTypes: { - // month: typeof rows[0].month, - // galaxy_sales: typeof rows[0].galaxy_sales, - // iphone_sales: typeof rows[0].iphone_sales, - // other_sales: typeof rows[0].other_sales - // }, - // firstFewRows: rows.slice(0, 3), - // lastFewRows: rows.slice(-3) - // }); - return { - columns, - rows, - totalRows: rows.length, - executionTime: Math.floor(Math.random() * 200) + 100, - }; - } else if (isRegional) { - // console.log('✅ Using REGIONAL data'); - // 지역별 분기별 매출 - columns = ["지역", "Q1", "Q2", "Q3", "Q4"]; - rows = [ - { 지역: "서울", Q1: 1200000, Q2: 1350000, Q3: 1420000, Q4: 1580000 }, - { 지역: "경기", Q1: 980000, Q2: 1120000, Q3: 1180000, Q4: 1290000 }, - { 지역: "부산", Q1: 650000, Q2: 720000, Q3: 780000, Q4: 850000 }, - { 지역: "대구", Q1: 450000, Q2: 490000, Q3: 520000, Q4: 580000 }, - { 지역: "인천", Q1: 520000, Q2: 580000, Q3: 620000, Q4: 690000 }, - { 지역: "광주", Q1: 380000, Q2: 420000, Q3: 450000, Q4: 490000 }, - { 지역: "대전", Q1: 410000, Q2: 460000, Q3: 490000, Q4: 530000 }, - ]; - } else if (isWeekly && isUsers) { - // console.log('✅ Using USERS data'); - // 사용자 가입 추이 - columns = ["week", "new_users"]; - rows = [ - { week: "2024-W10", new_users: 23 }, - { week: "2024-W11", new_users: 31 }, - { week: "2024-W12", new_users: 28 }, - { week: "2024-W13", new_users: 35 }, - { week: "2024-W14", new_users: 42 }, - { week: "2024-W15", new_users: 38 }, - { week: "2024-W16", new_users: 45 }, - { week: "2024-W17", new_users: 52 }, - { week: "2024-W18", new_users: 48 }, - { week: "2024-W19", new_users: 55 }, - { week: "2024-W20", new_users: 61 }, - { week: "2024-W21", new_users: 58 }, - ]; - } else if (isProducts && !isComparison) { - // console.log('✅ Using PRODUCTS data'); - // 상품별 판매량 - columns = ["product_name", "total_sold", "revenue"]; - rows = [ - { product_name: "스마트폰", total_sold: 156, revenue: 234000000 }, - { product_name: "노트북", total_sold: 89, revenue: 178000000 }, - { product_name: "태블릿", total_sold: 134, revenue: 67000000 }, - { product_name: "이어폰", total_sold: 267, revenue: 26700000 }, - { product_name: "스마트워치", total_sold: 98, revenue: 49000000 }, - { product_name: "키보드", total_sold: 78, revenue: 15600000 }, - { product_name: "마우스", total_sold: 145, revenue: 8700000 }, - { product_name: "모니터", total_sold: 67, revenue: 134000000 }, - { product_name: "프린터", total_sold: 34, revenue: 17000000 }, - { product_name: "웹캠", total_sold: 89, revenue: 8900000 }, - ]; - } else if (isMonthly && isSales && !isComparison) { - // console.log('✅ Using MONTHLY SALES data'); - // 월별 매출 데이터 - columns = ["month", "sales", "order_count"]; - rows = [ - { month: "2024-01", sales: 1200000, order_count: 45 }, - { month: "2024-02", sales: 1350000, order_count: 52 }, - { month: "2024-03", sales: 1180000, order_count: 41 }, - { month: "2024-04", sales: 1420000, order_count: 58 }, - { month: "2024-05", sales: 1680000, order_count: 67 }, - { month: "2024-06", sales: 1540000, order_count: 61 }, - { month: "2024-07", sales: 1720000, order_count: 71 }, - { month: "2024-08", sales: 1580000, order_count: 63 }, - { month: "2024-09", sales: 1650000, order_count: 68 }, - { month: "2024-10", sales: 1780000, order_count: 75 }, - { month: "2024-11", sales: 1920000, order_count: 82 }, - { month: "2024-12", sales: 2100000, order_count: 89 }, - ]; - } else { - // console.log('⚠️ Using DEFAULT data'); - // 기본 샘플 데이터 - columns = ["category", "value", "count"]; - rows = [ - { category: "A", value: 100, count: 10 }, - { category: "B", value: 150, count: 15 }, - { category: "C", value: 120, count: 12 }, - { category: "D", value: 180, count: 18 }, - { category: "E", value: 90, count: 9 }, - { category: "F", value: 200, count: 20 }, - { category: "G", value: 110, count: 11 }, - { category: "H", value: 160, count: 16 }, - ]; - } - - return { - columns, - rows, - totalRows: rows.length, - executionTime: Math.floor(Math.random() * 200) + 100, // 100-300ms - }; -} diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts index e0fdb3a1..982fe770 100644 --- a/frontend/components/admin/dashboard/types.ts +++ b/frontend/components/admin/dashboard/types.ts @@ -397,6 +397,7 @@ export interface CustomMetricConfig { unit?: string; // 표시 단위 (원, 건, % 등) color?: "indigo" | "green" | "blue" | "purple" | "orange" | "gray"; // 카드 색상 decimals?: number; // 소수점 자릿수 (기본: 0) + refreshInterval?: number; // 자동 새로고침 간격 (초, 0이면 비활성) // 필터 조건 filters?: Array<{ diff --git a/frontend/components/admin/dashboard/widget-sections/CustomMetricSection.tsx b/frontend/components/admin/dashboard/widget-sections/CustomMetricSection.tsx index f7f89e96..6dbbb60c 100644 --- a/frontend/components/admin/dashboard/widget-sections/CustomMetricSection.tsx +++ b/frontend/components/admin/dashboard/widget-sections/CustomMetricSection.tsx @@ -231,6 +231,29 @@ export function CustomMetricSection({ queryResult, config, onConfigChange }: Cus /> + {/* 6. 자동 새로고침 간격 */} +
+ + +

+ 통계 데이터를 자동으로 갱신하는 주기 +

+
+ {/* 미리보기 */} {config.valueColumn && config.aggregation && (
diff --git a/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx b/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx index 1b78801e..1aa36559 100644 --- a/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx +++ b/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx @@ -70,6 +70,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg const [value, setValue] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [lastUpdateTime, setLastUpdateTime] = useState(null); const config = element?.customMetricConfig; @@ -82,11 +83,15 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg useEffect(() => { loadData(); - // 자동 새로고침 (30초마다) - const interval = setInterval(loadData, 30000); - return () => clearInterval(interval); + // 자동 새로고침 (설정된 간격마다, 0이면 비활성) + const refreshInterval = config?.refreshInterval ?? 30; // 기본값: 30초 + + if (refreshInterval > 0) { + const interval = setInterval(loadData, refreshInterval * 1000); + return () => clearInterval(interval); + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [element]); + }, [element, config?.refreshInterval]); const loadData = async () => { try { @@ -132,6 +137,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg if (config?.valueColumn && config?.aggregation) { const calculatedValue = calculateMetric(rows, config.valueColumn, config.aggregation); setValue(calculatedValue); + setLastUpdateTime(new Date()); // 업데이트 시간 기록 } else { setValue(0); } @@ -192,6 +198,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg if (config?.valueColumn && config?.aggregation) { const calculatedValue = calculateMetric(rows, config.valueColumn, config.aggregation); setValue(calculatedValue); + setLastUpdateTime(new Date()); // 업데이트 시간 기록 } else { setValue(0); } @@ -283,6 +290,16 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg {formattedValue} {config?.unit && {config.unit}}
+ + {/* 마지막 업데이트 시간 */} + {lastUpdateTime && ( +
+ {lastUpdateTime.toLocaleTimeString("ko-KR")} + {config?.refreshInterval && config.refreshInterval > 0 && ( + • {config.refreshInterval}초마다 갱신 + )} +
+ )} ); } diff --git a/frontend/components/dashboard/widgets/CustomMetricWidget.tsx b/frontend/components/dashboard/widgets/CustomMetricWidget.tsx index fcd5593f..7c39c731 100644 --- a/frontend/components/dashboard/widgets/CustomMetricWidget.tsx +++ b/frontend/components/dashboard/widgets/CustomMetricWidget.tsx @@ -70,17 +70,22 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) const [value, setValue] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [lastUpdateTime, setLastUpdateTime] = useState(null); const config = element?.customMetricConfig; useEffect(() => { loadData(); - // 자동 새로고침 (30초마다) - const interval = setInterval(loadData, 30000); - return () => clearInterval(interval); + // 자동 새로고침 (설정된 간격마다, 0이면 비활성) + const refreshInterval = config?.refreshInterval ?? 30; // 기본값: 30초 + + if (refreshInterval > 0) { + const interval = setInterval(loadData, refreshInterval * 1000); + return () => clearInterval(interval); + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [element]); + }, [element, config?.refreshInterval]); const loadData = async () => { try { @@ -198,15 +203,16 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) setError(err instanceof Error ? err.message : "데이터를 불러올 수 없습니다"); } finally { setLoading(false); + setLastUpdateTime(new Date()); } }; if (loading) { return ( -
+
-

데이터 로딩 중...

+

데이터 로딩 중...

); @@ -214,12 +220,12 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) if (error) { return ( -
+
-

⚠️ {error}

+

⚠️ {error}

@@ -238,10 +244,10 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) // 설정이 없으면 안내 화면 if (!hasDataSource || !hasConfig) { return ( -
+
-

통계 카드

-
+

통계 카드

+

📊 단일 통계 위젯

  • • 데이터 소스에서 쿼리를 실행합니다
  • @@ -250,7 +256,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
  • • COUNT, SUM, AVG, MIN, MAX 지원
-
+

⚙️ 설정 방법

1. 데이터 탭에서 쿼리 실행

2. 필터 조건 추가 (선택사항)

@@ -268,7 +274,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) // 통계 카드 렌더링 return ( -
+
{/* 제목 */}
{config?.title || "통계"}
@@ -277,6 +283,16 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) {formattedValue} {config?.unit && {config.unit}}
+ + {/* 마지막 업데이트 시간 */} + {lastUpdateTime && ( +
+ {lastUpdateTime.toLocaleTimeString("ko-KR")} + {config?.refreshInterval && config.refreshInterval > 0 && ( + • {config.refreshInterval}초마다 갱신 + )} +
+ )}
); } -- 2.43.0 From ff23aa7d1d050573ab3c8717f195b3c07c5b4ebf Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 17 Nov 2025 09:49:13 +0900 Subject: [PATCH 03/15] =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EC=A0=9C=EB=AA=A9=20=EB=88=84=EB=A5=B4=EB=A9=B4=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=ED=99=94=EB=A9=B4=EC=9C=BC=EB=A1=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/DashboardListClient.tsx | 16 ++++- .../widget-sections/CustomMetricSection.tsx | 72 ++++++++++--------- 2 files changed, 51 insertions(+), 37 deletions(-) diff --git a/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx b/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx index d8eeae61..082e8661 100644 --- a/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx +++ b/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx @@ -300,7 +300,14 @@ export default function DashboardListClient({ initialDashboards, initialPaginati {dashboards.map((dashboard) => ( - {dashboard.title} + + + {dashboard.description || "-"} @@ -355,7 +362,12 @@ export default function DashboardListClient({ initialDashboards, initialPaginati {/* 헤더 */}
-

{dashboard.title}

+

{dashboard.id}

diff --git a/frontend/components/admin/dashboard/widget-sections/CustomMetricSection.tsx b/frontend/components/admin/dashboard/widget-sections/CustomMetricSection.tsx index 6dbbb60c..97c0f395 100644 --- a/frontend/components/admin/dashboard/widget-sections/CustomMetricSection.tsx +++ b/frontend/components/admin/dashboard/widget-sections/CustomMetricSection.tsx @@ -89,68 +89,70 @@ export function CustomMetricSection({ queryResult, config, onConfigChange }: Cus
{config.filters && config.filters.length > 0 ? ( -
+
{config.filters.map((filter, index) => ( -
- {/* 컬럼 선택 */} - +
+ {/* 첫 번째 줄: 컬럼 선택 */} +
+ + + {/* 삭제 버튼 */} + +
- {/* 연산자 선택 */} + {/* 두 번째 줄: 연산자 선택 */} - {/* 값 입력 */} + {/* 세 번째 줄: 값 입력 */} updateFilter(index, "value", e.target.value)} - placeholder="값" - className="h-8 flex-1 text-xs" + placeholder="값을 입력하세요" + className="h-9 w-full text-sm" /> - - {/* 삭제 버튼 */} -
))}
@@ -264,7 +266,7 @@ export function CustomMetricSection({ queryResult, config, onConfigChange }: Cus

필터:

{config.filters.map((filter, idx) => ( -

+

· {filter.column} {filter.operator} "{filter.value}"

))} -- 2.43.0 From 3e9c5668343398cd9ed1482fff12c297137da342 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 17 Nov 2025 12:10:29 +0900 Subject: [PATCH 04/15] =?UTF-8?q?=EC=A7=80=EB=8F=84=20=EC=9C=84=EC=A0=AF?= =?UTF-8?q?=20=ED=8C=9D=EC=97=85=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20?= =?UTF-8?q?=ED=8F=B4=EB=A6=AC=EA=B3=A4=20=EB=A7=A4=ED=95=91=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/data-sources/MultiApiConfig.tsx | 26 +- .../dashboard/widgets/MapTestWidgetV2.tsx | 337 +++++++++++++----- 2 files changed, 250 insertions(+), 113 deletions(-) diff --git a/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx b/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx index 2aba31f8..feb983cd 100644 --- a/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx +++ b/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx @@ -26,8 +26,6 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M const [sampleData, setSampleData] = useState([]); // 샘플 데이터 (최대 3개) const [columnSearchTerm, setColumnSearchTerm] = useState(""); // 컬럼 검색어 - console.log("🔧 MultiApiConfig - dataSource:", dataSource); - // 외부 API 커넥션 목록 로드 useEffect(() => { const loadApiConnections = async () => { @@ -51,14 +49,12 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M return; } - console.log("불러온 커넥션:", connection); // base_url과 endpoint_path를 조합하여 전체 URL 생성 const fullEndpoint = connection.endpoint_path ? `${connection.base_url}${connection.endpoint_path}` : connection.base_url; - console.log("전체 엔드포인트:", fullEndpoint); const updates: Partial = { endpoint: fullEndpoint, @@ -76,7 +72,6 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M value, }); }); - console.log("기본 헤더 적용:", headers); } // 인증 설정이 있으면 헤더 또는 쿼리 파라미터에 추가 @@ -91,7 +86,6 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M key: authConfig.keyName, value: authConfig.keyValue, }); - console.log("API Key 헤더 추가:", authConfig.keyName); } else if (authConfig.keyLocation === "query" && authConfig.keyName && authConfig.keyValue) { // UTIC API는 'key'를 사용하므로, 'apiKey'를 'key'로 변환 const actualKeyName = authConfig.keyName === "apiKey" ? "key" : authConfig.keyName; @@ -100,7 +94,6 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M key: actualKeyName, value: authConfig.keyValue, }); - console.log("API Key 쿼리 파라미터 추가:", actualKeyName, "(원본:", authConfig.keyName, ")"); } break; @@ -111,7 +104,6 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M key: "Authorization", value: `Bearer ${authConfig.token}`, }); - console.log("Bearer Token 헤더 추가"); } break; @@ -123,7 +115,6 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M key: "Authorization", value: `Basic ${credentials}`, }); - console.log("Basic Auth 헤더 추가"); } break; @@ -134,7 +125,6 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M key: "Authorization", value: `Bearer ${authConfig.accessToken}`, }); - console.log("OAuth2 Token 헤더 추가"); } break; } @@ -148,7 +138,6 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M updates.queryParams = queryParams; } - console.log("최종 업데이트:", updates); onChange(updates); }; @@ -235,12 +224,12 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M const result = await response.json(); + console.log("🌐 [API 테스트 결과]", result.data); + if (result.success) { // 텍스트 데이터 파싱 함수 (MapTestWidgetV2와 동일) const parseTextData = (text: string): any[] => { try { - console.log("🔍 텍스트 파싱 시작 (처음 500자):", text.substring(0, 500)); - const lines = text.split('\n').filter(line => { const trimmed = line.trim(); return trimmed && @@ -249,8 +238,6 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M !trimmed.startsWith('---'); }); - console.log(`📝 유효한 라인: ${lines.length}개`); - if (lines.length === 0) return []; const result: any[] = []; @@ -278,7 +265,6 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M } } - console.log("📊 파싱 결과:", result.length, "개"); return result; } catch (error) { console.error("❌ 텍스트 파싱 오류:", error); @@ -291,10 +277,8 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M // 텍스트 데이터 체크 (기상청 API 등) if (data && typeof data === 'object' && data.text && typeof data.text === 'string') { - console.log("📄 텍스트 형식 데이터 감지, CSV 파싱 시도"); const parsedData = parseTextData(data.text); if (parsedData.length > 0) { - console.log(`✅ CSV 파싱 성공: ${parsedData.length}개 행`); data = parsedData; } } else if (dataSource.jsonPath) { @@ -306,6 +290,8 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M const rows = Array.isArray(data) ? data : [data]; + console.log("📊 [최종 파싱된 데이터]", rows); + // 컬럼 목록 및 타입 추출 if (rows.length > 0) { const columns = Object.keys(rows[0]); @@ -336,9 +322,6 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M // 샘플 데이터 저장 (최대 3개) setSampleData(rows.slice(0, 3)); - - console.log("📊 발견된 컬럼:", columns); - console.log("📊 컬럼 타입:", types); } // 위도/경도 또는 coordinates 필드 또는 지역 코드 체크 @@ -422,7 +405,6 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M id={`endpoint-${dataSource.id}`} value={dataSource.endpoint || ""} onChange={(e) => { - console.log("📝 API URL 변경:", e.target.value); onChange({ endpoint: e.target.value }); }} placeholder="https://api.example.com/data" diff --git a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx index 5df1663a..56ef2089 100644 --- a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx +++ b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx @@ -96,11 +96,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // 다중 데이터 소스 로딩 const loadMultipleDataSources = useCallback(async () => { if (!dataSources || dataSources.length === 0) { - // // console.log("⚠️ 데이터 소스가 없습니다."); return; } - - // // console.log(`🔄 ${dataSources.length}개의 데이터 소스 로딩 시작...`); setLoading(true); setError(null); @@ -109,8 +106,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const results = await Promise.allSettled( dataSources.map(async (source) => { try { - // // console.log(`📡 데이터 소스 "${source.name || source.id}" 로딩 중...`); - if (source.type === "api") { return await loadRestApiData(source); } else if (source.type === "database") { @@ -130,21 +125,16 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const allPolygons: PolygonData[] = []; results.forEach((result, index) => { - // // console.log(`🔍 결과 ${index}:`, result); - if (result.status === "fulfilled" && result.value) { const value = result.value as { markers: MarkerData[]; polygons: PolygonData[] }; - // // console.log(`✅ 데이터 소스 ${index} 성공:`, value); // 마커 병합 if (value.markers && Array.isArray(value.markers)) { - // // console.log(` → 마커 ${value.markers.length}개 추가`); allMarkers.push(...value.markers); } // 폴리곤 병합 if (value.polygons && Array.isArray(value.polygons)) { - // // console.log(` → 폴리곤 ${value.polygons.length}개 추가`); allPolygons.push(...value.polygons); } } else if (result.status === "rejected") { @@ -152,10 +142,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { } }); - // // console.log(`✅ 총 ${allMarkers.length}개의 마커, ${allPolygons.length}개의 폴리곤 로딩 완료`); - // // console.log("📍 최종 마커 데이터:", allMarkers); - // // console.log("🔷 최종 폴리곤 데이터:", allPolygons); - // 이전 마커 위치와 비교하여 진행 방향 계산 const markersWithHeading = allMarkers.map((marker) => { const prevMarker = prevMarkers.find((pm) => pm.id === marker.id); @@ -192,7 +178,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // 수동 새로고침 핸들러 const handleManualRefresh = useCallback(() => { - // // console.log("🔄 수동 새로고침 버튼 클릭"); loadMultipleDataSources(); }, [loadMultipleDataSources]); @@ -200,8 +185,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const loadRestApiData = async ( source: ChartDataSource, ): Promise<{ markers: MarkerData[]; polygons: PolygonData[] }> => { - // // console.log(`🌐 REST API 데이터 로딩 시작:`, source.name, `mapDisplayType:`, source.mapDisplayType); - if (!source.endpoint) { throw new Error("API endpoint가 없습니다."); } @@ -256,13 +239,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // 텍스트 형식 데이터 체크 (기상청 API 등) if (data && typeof data === "object" && data.text && typeof data.text === "string") { - // // console.log("📄 텍스트 형식 데이터 감지, CSV 파싱 시도"); const parsedData = parseTextData(data.text); if (parsedData.length > 0) { - // // console.log(`✅ CSV 파싱 성공: ${parsedData.length}개 행`); // 컬럼 매핑 적용 const mappedData = applyColumnMapping(parsedData, source.columnMapping); - return convertToMapData(mappedData, source.name || source.id || "API", source.mapDisplayType, source); + const result = convertToMapData(mappedData, source.name || source.id || "API", source.mapDisplayType, source); + return result; } } @@ -280,15 +262,14 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const mappedRows = applyColumnMapping(rows, source.columnMapping); // 마커와 폴리곤으로 변환 (mapDisplayType + dataSource 전달) - return convertToMapData(mappedRows, source.name || source.id || "API", source.mapDisplayType, source); + const finalResult = convertToMapData(mappedRows, source.name || source.id || "API", source.mapDisplayType, source); + return finalResult; }; // Database 데이터 로딩 const loadDatabaseData = async ( source: ChartDataSource, ): Promise<{ markers: MarkerData[]; polygons: PolygonData[] }> => { - // // console.log(`💾 Database 데이터 로딩 시작:`, source.name, `mapDisplayType:`, source.mapDisplayType); - if (!source.query) { throw new Error("SQL 쿼리가 없습니다."); } @@ -330,7 +311,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // XML 데이터 파싱 (UTIC API 등) const parseXmlData = (xmlText: string): any[] => { try { - // // console.log(" 📄 XML 파싱 시작"); const parser = new DOMParser(); const xmlDoc = parser.parseFromString(xmlText, "text/xml"); @@ -349,7 +329,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { results.push(obj); } - // // console.log(` ✅ XML 파싱 완료: ${results.length}개 레코드`); return results; } catch (error) { console.error(" ❌ XML 파싱 실패:", error); @@ -360,11 +339,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // 텍스트 데이터 파싱 (CSV, 기상청 형식 등) const parseTextData = (text: string): any[] => { try { - // // console.log(" 🔍 원본 텍스트 (처음 500자):", text.substring(0, 500)); - // XML 형식 감지 if (text.trim().startsWith("")) { - // // console.log(" 📄 XML 형식 데이터 감지"); return parseXmlData(text); } @@ -373,8 +349,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { return trimmed && !trimmed.startsWith("#") && !trimmed.startsWith("=") && !trimmed.startsWith("---"); }); - // // console.log(` 📝 유효한 라인: ${lines.length}개`); - if (lines.length === 0) return []; // CSV 형식으로 파싱 @@ -384,8 +358,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const line = lines[i]; const values = line.split(",").map((v) => v.trim().replace(/,=$/g, "")); - // // console.log(` 라인 ${i}:`, values); - // 기상특보 형식: 지역코드, 지역명, 하위코드, 하위지역명, 발표시각, 특보종류, 등급, 발표상태, 설명 if (values.length >= 4) { const obj: any = { @@ -408,7 +380,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { } } - // // console.log(" 📊 최종 파싱 결과:", result.length, "개"); return result; } catch (error) { console.error(" ❌ 텍스트 파싱 오류:", error); @@ -423,23 +394,15 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { mapDisplayType?: "auto" | "marker" | "polygon", dataSource?: ChartDataSource, ): { markers: MarkerData[]; polygons: PolygonData[] } => { - // // console.log(`🔄 ${sourceName} 데이터 변환 시작:`, rows.length, "개 행"); - // // console.log(` 📌 mapDisplayType:`, mapDisplayType, `(타입: ${typeof mapDisplayType})`); - // // console.log(` 🎨 마커 색상:`, dataSource?.markerColor, `폴리곤 색상:`, dataSource?.polygonColor); - if (rows.length === 0) return { markers: [], polygons: [] }; const markers: MarkerData[] = []; const polygons: PolygonData[] = []; rows.forEach((row, index) => { - // // console.log(` 행 ${index}:`, row); - // 텍스트 데이터 체크 (기상청 API 등) if (row && typeof row === "object" && row.text && typeof row.text === "string") { - // // console.log(" 📄 텍스트 형식 데이터 감지, CSV 파싱 시도"); const parsedData = parseTextData(row.text); - // // console.log(` ✅ CSV 파싱 결과: ${parsedData.length}개 행`); // 파싱된 데이터를 재귀적으로 변환 (색상 정보 전달) const result = convertToMapData(parsedData, sourceName, mapDisplayType, dataSource); @@ -450,7 +413,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // 폴리곤 데이터 체크 (coordinates 필드가 배열인 경우 또는 강제 polygon 모드) if (row.coordinates && Array.isArray(row.coordinates) && row.coordinates.length > 0) { - // // console.log(` → coordinates 발견:`, row.coordinates.length, "개"); // coordinates가 [lat, lng] 배열의 배열인지 확인 const firstCoord = row.coordinates[0]; if (Array.isArray(firstCoord) && firstCoord.length === 2) { @@ -460,7 +422,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { name: row.name || row.title || `영역 ${index + 1}`, coordinates: row.coordinates as [number, number][], status: row.status || row.level, - description: row.description || JSON.stringify(row, null, 2), + description: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장 source: sourceName, color: dataSource?.polygonColor || getColorByStatus(row.status || row.level), }); @@ -471,13 +433,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // 지역명으로 해상 구역 확인 (auto 또는 polygon 모드일 때만) const regionName = row.name || row.area || row.region || row.location || row.subRegion; if (regionName && MARITIME_ZONES[regionName] && mapDisplayType !== "marker") { - // // console.log(` → 해상 구역 발견: ${regionName}, 폴리곤으로 처리`); polygons.push({ id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성 name: regionName, coordinates: MARITIME_ZONES[regionName] as [number, number][], status: row.status || row.level, - description: row.description || `${row.type || ""} ${row.level || ""}`.trim() || JSON.stringify(row, null, 2), + description: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장 source: sourceName, color: dataSource?.polygonColor || getColorByStatus(row.status || row.level), }); @@ -494,7 +455,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { (row.code || row.areaCode || row.regionCode || row.tmFc || row.stnId) ) { const regionCode = row.code || row.areaCode || row.regionCode || row.tmFc || row.stnId; - // // console.log(` → 지역 코드 발견: ${regionCode}, 위도/경도 변환 시도`); const coords = getCoordinatesByRegionCode(regionCode); if (coords) { lat = coords.lat; @@ -506,7 +466,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // 지역명으로도 시도 if ((lat === undefined || lng === undefined) && (row.name || row.area || row.region || row.location)) { const regionName = row.name || row.area || row.region || row.location; - // // console.log(` → 지역명 발견: ${regionName}, 위도/경도 변환 시도`); const coords = getCoordinatesByRegionName(regionName); if (coords) { lat = coords.lat; @@ -525,7 +484,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { name: regionName, coordinates: [], // GeoJSON에서 좌표를 가져올 것 status: row.status || row.level, - description: row.description || JSON.stringify(row, null, 2), + description: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장 source: sourceName, color: dataSource?.polygonColor || getColorByStatus(row.status || row.level), }); @@ -537,7 +496,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // 위도/경도가 있고 polygon 모드가 아니면 마커로 처리 if (lat !== undefined && lng !== undefined && (mapDisplayType as string) !== "polygon") { - // // console.log(` → 마커로 처리: (${lat}, ${lng})`); markers.push({ id: `${sourceName}-marker-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성 lat: Number(lat), @@ -546,7 +504,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { longitude: Number(lng), name: row.name || row.title || row.area || row.region || `위치 ${index + 1}`, status: row.status || row.level, - description: row.description || JSON.stringify(row, null, 2), + description: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장 source: sourceName, color: dataSource?.markerColor || "#3b82f6", // 사용자 지정 색상 또는 기본 파랑 }); @@ -560,7 +518,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { name: regionName, coordinates: [], // GeoJSON에서 좌표를 가져올 것 status: row.status || row.level, - description: row.description || JSON.stringify(row, null, 2), + description: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장 source: sourceName, color: dataSource?.polygonColor || getColorByStatus(row.status || row.level), }); @@ -571,7 +529,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { } }); - // // console.log(`✅ ${sourceName}: 마커 ${markers.length}개, 폴리곤 ${polygons.length}개 변환 완료`); return { markers, polygons }; }; @@ -627,6 +584,97 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // 해상 구역 폴리곤 좌표 (기상청 특보 구역 기준) const MARITIME_ZONES: Record> = { + // 서해 해역 + "인천·경기북부앞바다": [ + [37.8, 125.8], + [37.8, 126.5], + [37.3, 126.5], + [37.3, 125.8], + ], + "인천·경기남부앞바다": [ + [37.3, 125.7], + [37.3, 126.4], + [36.8, 126.4], + [36.8, 125.7], + ], + 충남북부앞바다: [ + [36.8, 125.6], + [36.8, 126.3], + [36.3, 126.3], + [36.3, 125.6], + ], + 충남남부앞바다: [ + [36.3, 125.5], + [36.3, 126.2], + [35.8, 126.2], + [35.8, 125.5], + ], + 전북북부앞바다: [ + [35.8, 125.4], + [35.8, 126.1], + [35.3, 126.1], + [35.3, 125.4], + ], + 전북남부앞바다: [ + [35.3, 125.3], + [35.3, 126.0], + [34.8, 126.0], + [34.8, 125.3], + ], + 전남북부서해앞바다: [ + [35.5, 125.2], + [35.5, 125.9], + [35.0, 125.9], + [35.0, 125.2], + ], + 전남중부서해앞바다: [ + [35.0, 125.1], + [35.0, 125.8], + [34.5, 125.8], + [34.5, 125.1], + ], + 전남남부서해앞바다: [ + [34.5, 125.0], + [34.5, 125.7], + [34.0, 125.7], + [34.0, 125.0], + ], + 서해중부안쪽먼바다: [ + [37.5, 124.5], + [37.5, 126.0], + [36.0, 126.0], + [36.0, 124.5], + ], + 서해중부바깥먼바다: [ + [37.5, 123.5], + [37.5, 125.0], + [36.0, 125.0], + [36.0, 123.5], + ], + 서해남부북쪽안쪽먼바다: [ + [36.0, 124.5], + [36.0, 126.0], + [35.0, 126.0], + [35.0, 124.5], + ], + 서해남부북쪽바깥먼바다: [ + [36.0, 123.5], + [36.0, 125.0], + [35.0, 125.0], + [35.0, 123.5], + ], + 서해남부남쪽안쪽먼바다: [ + [35.0, 124.0], + [35.0, 125.5], + [34.0, 125.5], + [34.0, 124.0], + ], + 서해남부남쪽바깥먼바다: [ + [35.0, 123.0], + [35.0, 124.5], + [33.5, 124.5], + [33.5, 123.0], + ], // 제주도 해역 제주도남부앞바다: [ [33.25, 126.0], @@ -896,7 +944,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { try { const response = await fetch("/geojson/korea-municipalities.json"); const data = await response.json(); - // // console.log("🗺️ GeoJSON 로드 완료:", data.features?.length, "개 시/군/구"); setGeoJsonData(data); } catch (err) { console.error("❌ GeoJSON 로드 실패:", err); @@ -982,7 +1029,13 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {

{error}

) : ( - + {/* 폴리곤 렌더링 */} @@ -1069,14 +1122,57 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { }); if (matchingPolygon) { - layer.bindPopup(` -
-
${matchingPolygon.name}
- ${matchingPolygon.source ? `
출처: ${matchingPolygon.source}
` : ""} - ${matchingPolygon.status ? `
상태: ${matchingPolygon.status}
` : ""} - ${matchingPolygon.description ? `
${matchingPolygon.description}
` : ""} -
- `); + // 폴리곤의 데이터 소스 찾기 + const polygonDataSource = dataSources?.find((ds) => ds.name === matchingPolygon.source); + const popupFields = polygonDataSource?.popupFields; + + let popupContent = ""; + + // popupFields가 설정되어 있으면 설정된 필드만 표시 + if (popupFields && popupFields.length > 0 && matchingPolygon.description) { + try { + const parsed = JSON.parse(matchingPolygon.description); + popupContent = ` +
+ ${matchingPolygon.source ? `
📡 ${matchingPolygon.source}
` : ""} +
+
상세 정보
+
+ ${popupFields + .map((field) => { + const value = parsed[field.fieldName]; + if (value === undefined || value === null) return ""; + return `
${field.label}: ${value}
`; + }) + .join("")} +
+
+
+ `; + } catch (error) { + // JSON 파싱 실패 시 기본 표시 + popupContent = ` +
+
${matchingPolygon.name}
+ ${matchingPolygon.source ? `
출처: ${matchingPolygon.source}
` : ""} + ${matchingPolygon.status ? `
상태: ${matchingPolygon.status}
` : ""} + ${matchingPolygon.description ? `
${matchingPolygon.description}
` : ""} +
+ `; + } + } else { + // popupFields가 없으면 전체 데이터 표시 + popupContent = ` +
+
${matchingPolygon.name}
+ ${matchingPolygon.source ? `
출처: ${matchingPolygon.source}
` : ""} + ${matchingPolygon.status ? `
상태: ${matchingPolygon.status}
` : ""} + ${matchingPolygon.description ? `
${matchingPolygon.description}
` : ""} +
+ `; + } + + layer.bindPopup(popupContent); } }} /> @@ -1089,33 +1185,91 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { {/* 폴리곤 렌더링 (해상 구역만) */} {polygons .filter((p) => MARITIME_ZONES[p.name]) - .map((polygon) => ( - - -
-
{polygon.name}
- {polygon.source && ( -
출처: {polygon.source}
- )} - {polygon.status &&
상태: {polygon.status}
} - {polygon.description && ( -
-
{polygon.description}
-
- )} -
-
-
- ))} + .map((polygon) => { + // 폴리곤의 데이터 소스 찾기 + const polygonDataSource = dataSources?.find((ds) => ds.name === polygon.source); + const popupFields = polygonDataSource?.popupFields; + + return ( + + +
+ {/* popupFields가 설정되어 있으면 설정된 필드만 표시 */} + {popupFields && popupFields.length > 0 && polygon.description ? ( + (() => { + try { + const parsed = JSON.parse(polygon.description); + return ( + <> + {polygon.source && ( +
+
📡 {polygon.source}
+
+ )} +
+
상세 정보
+
+ {popupFields.map((field, idx) => { + const value = parsed[field.fieldName]; + if (value === undefined || value === null) return null; + return ( +
+ {field.label}:{" "} + {String(value)} +
+ ); + })} +
+
+ + ); + } catch (error) { + // JSON 파싱 실패 시 기본 표시 + return ( + <> +
{polygon.name}
+ {polygon.source && ( +
출처: {polygon.source}
+ )} + {polygon.status &&
상태: {polygon.status}
} + {polygon.description && ( +
+
{polygon.description}
+
+ )} + + ); + } + })() + ) : ( + // popupFields가 없으면 전체 데이터 표시 + <> +
{polygon.name}
+ {polygon.source && ( +
출처: {polygon.source}
+ )} + {polygon.status &&
상태: {polygon.status}
} + {polygon.description && ( +
+
{polygon.description}
+
+ )} + + )} +
+
+
+ ); + })} {/* 마커 렌더링 */} {markers.map((marker) => { @@ -1227,8 +1381,9 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
{marker.description && (() => { - const firstDataSource = dataSources?.[0]; - const popupFields = firstDataSource?.popupFields; + // 마커의 소스에 해당하는 데이터 소스 찾기 + const sourceDataSource = dataSources?.find((ds) => ds.name === marker.source); + const popupFields = sourceDataSource?.popupFields; // popupFields가 설정되어 있으면 설정된 필드만 표시 if (popupFields && popupFields.length > 0) { -- 2.43.0 From 251c4e3a66a97d10269f5e9039a6050090ef28ef Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 17 Nov 2025 12:12:02 +0900 Subject: [PATCH 05/15] =?UTF-8?q?=ED=8C=9D=EC=97=85=20=EA=BC=AC=EB=A6=AC?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/widgets/MapTestWidgetV2.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx index 56ef2089..95406e07 100644 --- a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx +++ b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx @@ -10,6 +10,20 @@ import { applyColumnMapping } from "@/lib/utils/columnMapping"; import { getApiUrl } from "@/lib/utils/apiUrl"; import "leaflet/dist/leaflet.css"; +// Popup 말풍선 꼬리 제거 스타일 +if (typeof document !== "undefined") { + const style = document.createElement("style"); + style.textContent = ` + .leaflet-popup-tip-container { + display: none !important; + } + .leaflet-popup-content-wrapper { + border-radius: 8px !important; + } + `; + document.head.appendChild(style); +} + // Leaflet 아이콘 경로 설정 (엑박 방지) if (typeof window !== "undefined") { import("leaflet").then((L) => { -- 2.43.0 From 800da3bf21ff373680c928c695b43c472e705c22 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 17 Nov 2025 12:16:55 +0900 Subject: [PATCH 06/15] =?UTF-8?q?list=EC=9C=84=EC=A0=AF=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=EA=B8=B0=EB=8A=A5=20=EC=9E=AC=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/WidgetConfigSidebar.tsx | 30 +++++-------------- .../widget-sections/ListWidgetSection.tsx | 2 +- 2 files changed, 9 insertions(+), 23 deletions(-) diff --git a/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx b/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx index a18abf9a..994bc07d 100644 --- a/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx +++ b/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx @@ -579,30 +579,16 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge refreshInterval={element.chartConfig?.refreshInterval || 5} markerType={element.chartConfig?.markerType || "circle"} onRefreshIntervalChange={(interval) => { - setElement((prev) => - prev - ? { - ...prev, - chartConfig: { - ...prev.chartConfig, - refreshInterval: interval, - }, - } - : prev - ); + setChartConfig((prev) => ({ + ...prev, + refreshInterval: interval, + })); }} onMarkerTypeChange={(type) => { - setElement((prev) => - prev - ? { - ...prev, - chartConfig: { - ...prev.chartConfig, - markerType: type, - }, - } - : prev - ); + setChartConfig((prev) => ({ + ...prev, + markerType: type, + })); }} /> )} diff --git a/frontend/components/admin/dashboard/widget-sections/ListWidgetSection.tsx b/frontend/components/admin/dashboard/widget-sections/ListWidgetSection.tsx index 8459716e..2e84f123 100644 --- a/frontend/components/admin/dashboard/widget-sections/ListWidgetSection.tsx +++ b/frontend/components/admin/dashboard/widget-sections/ListWidgetSection.tsx @@ -32,7 +32,7 @@ export function ListWidgetSection({ queryResult, config, onConfigChange }: ListW {config.columns.length > 0 && (
- +
)}
-- 2.43.0 From 227ab1904c2b95575a088c7aecc50b6dd1759c80 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 17 Nov 2025 12:22:13 +0900 Subject: [PATCH 07/15] =?UTF-8?q?=EC=9C=84=EC=A0=AF=20=ED=97=A4=EB=8D=94?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20=EB=94=94=EC=9E=90=EC=9D=B4=EB=84=88=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=97=90=EC=84=9C=EB=8F=84=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=EB=90=98=EB=8F=84=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/admin/dashboard/CanvasElement.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index ce08c522..2264e699 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -812,11 +812,12 @@ export function CanvasElement({ }} onMouseDown={handleMouseDown} > - {/* 헤더 */} -
-
- {/* 차트 타입 전환 드롭다운 (차트일 경우만) */} - {element.type === "chart" && ( + {/* 헤더 - showHeader가 false이면 숨김 */} + {element.showHeader !== false && ( +
+
+ {/* 차트 타입 전환 드롭다운 (차트일 경우만) */} + {element.type === "chart" && ( setRefreshInterval(parseInt(value))}> +

diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts index 982fe770..b8026c02 100644 --- a/frontend/components/admin/dashboard/types.ts +++ b/frontend/components/admin/dashboard/types.ts @@ -163,7 +163,10 @@ export interface ChartDataSource { markerColor?: string; // 마커 색상 (예: "#ff0000") polygonColor?: string; // 폴리곤 색상 (예: "#0000ff") polygonOpacity?: number; // 폴리곤 투명도 (0.0 ~ 1.0, 기본값: 0.5) - markerType?: string; // 마커 종류 (circle, arrow) + markerType?: string; // 마커 종류 (circle, arrow, truck) + minZoom?: number; // 최소 줌 레벨 (최대로 멀리 보기, 기본값: 2) + maxZoom?: number; // 최대 줌 레벨 (최대로 가까이 보기, 기본값: 18) + initialZoom?: number; // 초기 줌 레벨 (기본값: 13) // 컬럼 매핑 (다중 데이터 소스 통합용) columnMapping?: Record; // { 원본컬럼: 표시이름 } (예: { "name": "product" }) diff --git a/frontend/components/admin/dashboard/widget-sections/MapConfigSection.tsx b/frontend/components/admin/dashboard/widget-sections/MapConfigSection.tsx index 3ed5fe24..b47bc0a2 100644 --- a/frontend/components/admin/dashboard/widget-sections/MapConfigSection.tsx +++ b/frontend/components/admin/dashboard/widget-sections/MapConfigSection.tsx @@ -20,32 +20,30 @@ interface MapConfigSectionProps { * - 자동 새로고침 간격 설정 * - 마커 종류 선택 */ -export function MapConfigSection({ - queryResult, +export function MapConfigSection({ + queryResult, refreshInterval = 5, markerType = "circle", onRefreshIntervalChange, - onMarkerTypeChange + onMarkerTypeChange, }: MapConfigSectionProps) { // 쿼리 결과가 없으면 안내 메시지 표시 if (!queryResult || !queryResult.columns || queryResult.columns.length === 0) { return ( -

+
- - 먼저 데이터 소스를 설정하고 쿼리를 테스트해주세요. - + 먼저 데이터 소스를 설정하고 쿼리를 테스트해주세요.
); } return ( -
+
- +
{/* 자동 새로고침 간격 */}
@@ -60,16 +58,24 @@ export function MapConfigSection({ - 없음 - 5초 - 10초 - 30초 - 1분 + + 없음 + + + 5초 + + + 10초 + + + 30초 + + + 1분 + -

- 마커 데이터를 자동으로 갱신하는 주기를 설정합니다 -

+

마커 데이터를 자동으로 갱신하는 주기를 설정합니다

{/* 마커 종류 선택 */} @@ -77,24 +83,25 @@ export function MapConfigSection({ - onMarkerTypeChange?.(value)}> - 동그라미 - 화살표 + + 동그라미 + + + 화살표 + + + 트럭 + -

- 지도에 표시할 마커의 모양을 선택합니다 -

+

지도에 표시할 마커의 모양을 선택합니다

); } - diff --git a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx index 95406e07..afe213e1 100644 --- a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx +++ b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-require-imports */ "use client"; -import React, { useEffect, useState, useCallback, useMemo } from "react"; +import React, { useEffect, useState, useCallback, useMemo, useRef } from "react"; import dynamic from "next/dynamic"; import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types"; import { Button } from "@/components/ui/button"; @@ -80,7 +80,7 @@ interface PolygonData { export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const [markers, setMarkers] = useState([]); - const [prevMarkers, setPrevMarkers] = useState([]); // 이전 마커 위치 저장 + const prevMarkersRef = useRef([]); // 이전 마커 위치 저장 (useRef 사용) const [polygons, setPolygons] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -158,11 +158,24 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // 이전 마커 위치와 비교하여 진행 방향 계산 const markersWithHeading = allMarkers.map((marker) => { - const prevMarker = prevMarkers.find((pm) => pm.id === marker.id); + const prevMarker = prevMarkersRef.current.find((pm) => pm.id === marker.id); + + console.log("🔍 마커 비교:", { + id: marker.id, + 현재위치: `[${marker.lat}, ${marker.lng}]`, + 이전위치: prevMarker ? `[${prevMarker.lat}, ${prevMarker.lng}]` : "없음", + 같은지: prevMarker ? prevMarker.lat === marker.lat && prevMarker.lng === marker.lng : "N/A", + }); if (prevMarker && (prevMarker.lat !== marker.lat || prevMarker.lng !== marker.lng)) { // 이동했으면 방향 계산 const heading = calculateHeading(prevMarker.lat, prevMarker.lng, marker.lat, marker.lng); + console.log("🧭 방향 계산:", { + id: marker.id, + from: `[${prevMarker.lat.toFixed(4)}, ${prevMarker.lng.toFixed(4)}]`, + to: `[${marker.lat.toFixed(4)}, ${marker.lng.toFixed(4)}]`, + heading: `${heading.toFixed(1)}°`, + }); return { ...marker, heading, @@ -172,13 +185,14 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { } // 이동하지 않았거나 이전 데이터가 없으면 기존 heading 유지 (또는 0) + console.log("⏸️ 이동 없음:", { id: marker.id, heading: marker.heading || prevMarker?.heading || 0 }); return { ...marker, heading: marker.heading || prevMarker?.heading || 0, }; }); - setPrevMarkers(markersWithHeading); // 다음 비교를 위해 현재 위치 저장 + prevMarkersRef.current = markersWithHeading; // 다음 비교를 위해 현재 위치 저장 (useRef 사용) setMarkers(markersWithHeading); setPolygons(allPolygons); setLastRefreshTime(new Date()); @@ -188,7 +202,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { } finally { setLoading(false); } - }, [dataSources, prevMarkers, calculateHeading]); + }, [dataSources, calculateHeading]); // prevMarkersRef는 의존성에 포함하지 않음 (useRef이므로) // 수동 새로고침 핸들러 const handleManualRefresh = useCallback(() => { @@ -448,7 +462,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const regionName = row.name || row.area || row.region || row.location || row.subRegion; if (regionName && MARITIME_ZONES[regionName] && mapDisplayType !== "marker") { polygons.push({ - id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성 + id: `${sourceName}-polygon-${index}`, // 고유 ID 생성 (위치 추적을 위해 고정) name: regionName, coordinates: MARITIME_ZONES[regionName] as [number, number][], status: row.status || row.level, @@ -494,7 +508,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { if (regionName) { // console.log(` 🔷 강제 폴리곤 모드: ${regionName} → 폴리곤으로 추가 (GeoJSON 매칭)`); polygons.push({ - id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`, + id: `${sourceName}-polygon-${index}`, // 고유 ID 생성 (위치 추적을 위해 고정) name: regionName, coordinates: [], // GeoJSON에서 좌표를 가져올 것 status: row.status || row.level, @@ -511,7 +525,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // 위도/경도가 있고 polygon 모드가 아니면 마커로 처리 if (lat !== undefined && lng !== undefined && (mapDisplayType as string) !== "polygon") { markers.push({ - id: `${sourceName}-marker-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성 + id: `${sourceName}-marker-${index}`, // 고유 ID 생성 (위치 추적을 위해 고정) lat: Number(lat), lng: Number(lng), latitude: Number(lat), @@ -528,7 +542,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { if (regionName) { // console.log(` 📍 위도/경도 없지만 지역명 있음: ${regionName} → 폴리곤으로 추가 (GeoJSON 매칭)`); polygons.push({ - id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`, + id: `${sourceName}-polygon-${index}`, // 고유 ID 생성 (위치 추적을 위해 고정) name: regionName, coordinates: [], // GeoJSON에서 좌표를 가져올 것 status: row.status || row.level, @@ -995,9 +1009,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [dataSources, element?.chartConfig?.refreshInterval]); - // 타일맵 URL (chartConfig에서 가져오기) - const tileMapUrl = - element?.chartConfig?.tileMapUrl || `https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`; + // 타일맵 URL (VWorld 한국 지도) + const tileMapUrl = `https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`; // 지도 중심점 계산 const center: [number, number] = @@ -1006,7 +1019,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { markers.reduce((sum, m) => sum + m.lat, 0) / markers.length, markers.reduce((sum, m) => sum + m.lng, 0) / markers.length, ] - : [37.5665, 126.978]; // 기본: 서울 + : [20, 0]; // 🌍 세계 지도 중심 (ISS 테스트용) return (
@@ -1046,11 +1059,17 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { - + {/* 폴리곤 렌더링 */} {/* GeoJSON 렌더링 (육지 지역 경계선) */} @@ -1296,6 +1315,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const L = require("leaflet"); const heading = marker.heading || 0; + console.log("🎨 마커 렌더링:", { id: marker.id, heading: `${heading.toFixed(1)}°`, type: markerType }); + if (markerType === "arrow") { // 화살표 마커 markerIcon = L.divIcon({ @@ -1311,28 +1332,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3)); "> - + - - - -
@@ -1340,6 +1345,74 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { iconSize: [40, 40], iconAnchor: [20, 20], }); + } else if (markerType === "truck") { + // 트럭 마커 + markerIcon = L.divIcon({ + className: "custom-truck-marker", + html: ` +
+ + + + + + + + + + + + + + +
+ `, + iconSize: [48, 48], + iconAnchor: [24, 24], + }); } else { // 동그라미 마커 (기본) markerIcon = L.divIcon({ -- 2.43.0 From fb9bc268a0ecfc51a1f977c5a3254601c611bf4b Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 17 Nov 2025 15:09:50 +0900 Subject: [PATCH 09/15] =?UTF-8?q?=EB=A7=B5=20=EC=9C=84=EC=A0=AF=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/dashboard/widgets/MapTestWidgetV2.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx index afe213e1..50f203d5 100644 --- a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx +++ b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx @@ -462,7 +462,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const regionName = row.name || row.area || row.region || row.location || row.subRegion; if (regionName && MARITIME_ZONES[regionName] && mapDisplayType !== "marker") { polygons.push({ - id: `${sourceName}-polygon-${index}`, // 고유 ID 생성 (위치 추적을 위해 고정) + id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성 name: regionName, coordinates: MARITIME_ZONES[regionName] as [number, number][], status: row.status || row.level, @@ -508,7 +508,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { if (regionName) { // console.log(` 🔷 강제 폴리곤 모드: ${regionName} → 폴리곤으로 추가 (GeoJSON 매칭)`); polygons.push({ - id: `${sourceName}-polygon-${index}`, // 고유 ID 생성 (위치 추적을 위해 고정) + id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성 name: regionName, coordinates: [], // GeoJSON에서 좌표를 가져올 것 status: row.status || row.level, @@ -525,7 +525,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // 위도/경도가 있고 polygon 모드가 아니면 마커로 처리 if (lat !== undefined && lng !== undefined && (mapDisplayType as string) !== "polygon") { markers.push({ - id: `${sourceName}-marker-${index}`, // 고유 ID 생성 (위치 추적을 위해 고정) + id: `${sourceName}-marker-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성 lat: Number(lat), lng: Number(lng), latitude: Number(lat), @@ -542,7 +542,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { if (regionName) { // console.log(` 📍 위도/경도 없지만 지역명 있음: ${regionName} → 폴리곤으로 추가 (GeoJSON 매칭)`); polygons.push({ - id: `${sourceName}-polygon-${index}`, // 고유 ID 생성 (위치 추적을 위해 고정) + id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성 name: regionName, coordinates: [], // GeoJSON에서 좌표를 가져올 것 status: row.status || row.level, -- 2.43.0 From 8006255bbfb5daca9717d4766c7ab11d9aef802f Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 17 Nov 2025 16:32:50 +0900 Subject: [PATCH 10/15] =?UTF-8?q?=EB=94=94=EB=B2=84=EA=B9=85=EC=9A=A9=20co?= =?UTF-8?q?nsole.log=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/CustomMetricTestWidget.tsx | 7 --- .../dashboard/widgets/MapTestWidgetV2.tsx | 56 +------------------ 2 files changed, 2 insertions(+), 61 deletions(-) diff --git a/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx b/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx index 1aa36559..6a5f235c 100644 --- a/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx +++ b/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx @@ -74,12 +74,6 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg const config = element?.customMetricConfig; - console.log("📊 [CustomMetricTestWidget] 렌더링:", { - element, - config, - dataSource: element?.dataSource, - }); - useEffect(() => { loadData(); @@ -207,7 +201,6 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg } } } catch (err) { - console.error("데이터 로드 실패:", err); setError(err instanceof Error ? err.message : "데이터를 불러올 수 없습니다"); } finally { setLoading(false); diff --git a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx index 50f203d5..e23d32c1 100644 --- a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx +++ b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx @@ -128,7 +128,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { return { markers: [], polygons: [] }; } catch (err: any) { - console.error(`❌ 데이터 소스 "${source.name || source.id}" 로딩 실패:`, err); return { markers: [], polygons: [] }; } }), @@ -151,8 +150,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { if (value.polygons && Array.isArray(value.polygons)) { allPolygons.push(...value.polygons); } - } else if (result.status === "rejected") { - console.error(`❌ 데이터 소스 ${index} 실패:`, result.reason); } }); @@ -160,22 +157,9 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const markersWithHeading = allMarkers.map((marker) => { const prevMarker = prevMarkersRef.current.find((pm) => pm.id === marker.id); - console.log("🔍 마커 비교:", { - id: marker.id, - 현재위치: `[${marker.lat}, ${marker.lng}]`, - 이전위치: prevMarker ? `[${prevMarker.lat}, ${prevMarker.lng}]` : "없음", - 같은지: prevMarker ? prevMarker.lat === marker.lat && prevMarker.lng === marker.lng : "N/A", - }); - if (prevMarker && (prevMarker.lat !== marker.lat || prevMarker.lng !== marker.lng)) { // 이동했으면 방향 계산 const heading = calculateHeading(prevMarker.lat, prevMarker.lng, marker.lat, marker.lng); - console.log("🧭 방향 계산:", { - id: marker.id, - from: `[${prevMarker.lat.toFixed(4)}, ${prevMarker.lng.toFixed(4)}]`, - to: `[${marker.lat.toFixed(4)}, ${marker.lng.toFixed(4)}]`, - heading: `${heading.toFixed(1)}°`, - }); return { ...marker, heading, @@ -185,7 +169,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { } // 이동하지 않았거나 이전 데이터가 없으면 기존 heading 유지 (또는 0) - console.log("⏸️ 이동 없음:", { id: marker.id, heading: marker.heading || prevMarker?.heading || 0 }); return { ...marker, heading: marker.heading || prevMarker?.heading || 0, @@ -197,7 +180,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { setPolygons(allPolygons); setLastRefreshTime(new Date()); } catch (err: any) { - console.error("❌ 데이터 로딩 중 오류:", err); setError(err.message); } finally { setLoading(false); @@ -359,7 +341,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { return results; } catch (error) { - console.error(" ❌ XML 파싱 실패:", error); return []; } }; @@ -404,13 +385,11 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { obj.name = obj.subRegion || obj.region || obj.code; result.push(obj); - // console.log(` ✅ 파싱 성공:`, obj); } } return result; } catch (error) { - console.error(" ❌ 텍스트 파싱 오류:", error); return []; } }; @@ -444,7 +423,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // coordinates가 [lat, lng] 배열의 배열인지 확인 const firstCoord = row.coordinates[0]; if (Array.isArray(firstCoord) && firstCoord.length === 2) { - // console.log(` → 폴리곤으로 처리:`, row.name); polygons.push({ id: row.id || row.code || `polygon-${index}`, name: row.name || row.title || `영역 ${index + 1}`, @@ -487,7 +465,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { if (coords) { lat = coords.lat; lng = coords.lng; - // console.log(` → 변환 성공: (${lat}, ${lng})`); } } @@ -498,7 +475,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { if (coords) { lat = coords.lat; lng = coords.lng; - // console.log(` → 변환 성공: (${lat}, ${lng})`); } } @@ -506,7 +482,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { if (mapDisplayType === "polygon") { const regionName = row.name || row.subRegion || row.region || row.area; if (regionName) { - // console.log(` 🔷 강제 폴리곤 모드: ${regionName} → 폴리곤으로 추가 (GeoJSON 매칭)`); polygons.push({ id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성 name: regionName, @@ -516,8 +491,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { source: sourceName, color: dataSource?.polygonColor || getColorByStatus(row.status || row.level), }); - } else { - // console.log(` ⚠️ 강제 폴리곤 모드지만 지역명 없음 - 스킵`); } return; // 폴리곤으로 처리했으므로 마커로는 추가하지 않음 } @@ -540,7 +513,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // 위도/경도가 없는 육지 지역 → 폴리곤으로 추가 (GeoJSON 매칭용) const regionName = row.name || row.subRegion || row.region || row.area; if (regionName) { - // console.log(` 📍 위도/경도 없지만 지역명 있음: ${regionName} → 폴리곤으로 추가 (GeoJSON 매칭)`); polygons.push({ id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성 name: regionName, @@ -550,9 +522,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { source: sourceName, color: dataSource?.polygonColor || getColorByStatus(row.status || row.level), }); - } else { - // console.log(` ⚠️ 위도/경도 없고 지역명도 없음 - 스킵`); - // console.log(` 데이터:`, row); } } }); @@ -938,7 +907,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const nameColumn = columns.find((col) => /^(name|title|이름|명칭|location)$/i.test(col)); if (!latColumn || !lngColumn) { - console.warn("⚠️ 위도/경도 컬럼을 찾을 수 없습니다."); return []; } @@ -974,7 +942,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const data = await response.json(); setGeoJsonData(data); } catch (err) { - console.error("❌ GeoJSON 로드 실패:", err); + // GeoJSON 로드 실패 처리 } }; loadGeoJsonData(); @@ -1073,14 +1041,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { {/* 폴리곤 렌더링 */} {/* GeoJSON 렌더링 (육지 지역 경계선) */} - {(() => { - // console.log(`🗺️ GeoJSON 렌더링 조건 체크:`, { - // geoJsonData: !!geoJsonData, - // polygonsLength: polygons.length, - // polygonNames: polygons.map(p => p.name), - // }); - return null; - })()} {geoJsonData && polygons.length > 0 ? ( p.id))} // 폴리곤 변경 시 재렌더링 @@ -1095,31 +1055,25 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // 정확한 매칭 if (p.name === sigName) { - // console.log(`✅ 정확 매칭: ${p.name} === ${sigName}`); return true; } if (p.name === ctpName) { - // console.log(`✅ 정확 매칭: ${p.name} === ${ctpName}`); return true; } // 부분 매칭 (GeoJSON 지역명에 폴리곤 이름이 포함되는지) if (sigName && sigName.includes(p.name)) { - // console.log(`✅ 부분 매칭: ${sigName} includes ${p.name}`); return true; } if (ctpName && ctpName.includes(p.name)) { - // console.log(`✅ 부분 매칭: ${ctpName} includes ${p.name}`); return true; } // 역방향 매칭 (폴리곤 이름에 GeoJSON 지역명이 포함되는지) if (sigName && p.name.includes(sigName)) { - // console.log(`✅ 역방향 매칭: ${p.name} includes ${sigName}`); return true; } if (ctpName && p.name.includes(ctpName)) { - // console.log(`✅ 역방향 매칭: ${p.name} includes ${ctpName}`); return true; } @@ -1209,11 +1163,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { } }} /> - ) : ( - <> - {/* console.log(`⚠️ GeoJSON 렌더링 안 됨: geoJsonData=${!!geoJsonData}, polygons=${polygons.length}`) */} - - )} + ) : null} {/* 폴리곤 렌더링 (해상 구역만) */} {polygons @@ -1315,8 +1265,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const L = require("leaflet"); const heading = marker.heading || 0; - console.log("🎨 마커 렌더링:", { id: marker.id, heading: `${heading.toFixed(1)}°`, type: markerType }); - if (markerType === "arrow") { // 화살표 마커 markerIcon = L.divIcon({ -- 2.43.0 From 5142a1254ede2b42092367ed2561a5ca8f7c5be5 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 17 Nov 2025 16:37:22 +0900 Subject: [PATCH 11/15] =?UTF-8?q?=ED=86=B5=EA=B3=84=20=EC=B9=B4=EB=93=9C?= =?UTF-8?q?=20=ED=97=A4=EB=8D=94=20=EC=A0=9C=EA=B1=B0=ED=95=B4=EB=8F=84=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EB=B2=84=ED=8A=BC=EC=9D=80=20=EB=82=A8?= =?UTF-8?q?=EA=B8=B0=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/CanvasElement.tsx | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 2264e699..29825952 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -859,22 +859,21 @@ export function CanvasElement({ ) ) : null}
-
- {/* 삭제 버튼 */} - -
)} + {/* 삭제 버튼 - 항상 표시 (우측 상단 절대 위치) */} + + {/* 내용 */}
{element.type === "chart" ? ( -- 2.43.0 From 1acbd76eb8dacb8e1519cf2a0ca48be8cfaacd00 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 18 Nov 2025 17:46:32 +0900 Subject: [PATCH 12/15] =?UTF-8?q?=EC=95=84=EC=9D=B4=EC=BD=98=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=EB=B0=A9=ED=96=A5=EC=97=90=20=EB=94=B0=EB=A5=B8=20?= =?UTF-8?q?=ED=9A=8C=EC=A0=84=20=EC=9E=AC=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/widgets/MapTestWidgetV2.tsx | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx index e23d32c1..694cba79 100644 --- a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx +++ b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx @@ -498,7 +498,10 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // 위도/경도가 있고 polygon 모드가 아니면 마커로 처리 if (lat !== undefined && lng !== undefined && (mapDisplayType as string) !== "polygon") { markers.push({ - id: `${sourceName}-marker-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성 + // 진행 방향(heading) 계산을 위해 ID는 새로고침마다 바뀌지 않도록 고정값 사용 + // - row.id / row.code가 있으면 그 값을 사용 + // - 없으면 sourceName과 index 조합으로 고정 ID 생성 + id: row.id || row.code || `${sourceName}-marker-${index}`, lat: Number(lat), lng: Number(lng), latitude: Number(lat), @@ -987,7 +990,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { markers.reduce((sum, m) => sum + m.lat, 0) / markers.length, markers.reduce((sum, m) => sum + m.lng, 0) / markers.length, ] - : [20, 0]; // 🌍 세계 지도 중심 (ISS 테스트용) + : [36.5, 127.5]; // 한국 중심 return (
@@ -1027,17 +1030,22 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { - + {/* 폴리곤 렌더링 */} {/* GeoJSON 렌더링 (육지 지역 경계선) */} -- 2.43.0 From cec631d0f76d179507983aca2291ebe87ff3e77c Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 18 Nov 2025 17:57:19 +0900 Subject: [PATCH 13/15] =?UTF-8?q?=EC=95=BC=EB=93=9C=EA=B4=80=EB=A6=AC=203d?= =?UTF-8?q?=20=EC=97=90=EB=9F=AC=20=EB=B0=8F=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/admin/dashboard/WidgetConfigSidebar.tsx | 2 +- .../admin/dashboard/widgets/YardManagement3DWidget.tsx | 2 +- .../components/admin/dashboard/widgets/yard-3d/YardEditor.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx b/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx index 9fb2cbde..4ac370bf 100644 --- a/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx +++ b/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx @@ -46,7 +46,7 @@ const needsDataSource = (subtype: ElementSubtype): boolean => { "chart", "map-summary-v2", "risk-alert-v2", - "yard-management-3d", + // "yard-management-3d", // 데이터 탭 불필요 (레이아웃 선택만 사용) "todo", "document", "work-history", diff --git a/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx b/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx index 41e30b96..fada50de 100644 --- a/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx +++ b/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; -import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Plus, Check, Trash2 } from "lucide-react"; import YardLayoutCreateModal from "./yard-3d/YardLayoutCreateModal"; import YardEditor from "./yard-3d/YardEditor"; diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx index 555820b6..5a0c5871 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx @@ -8,7 +8,7 @@ import dynamic from "next/dynamic"; import { YardLayout, YardPlacement } from "./types"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { AlertCircle, CheckCircle, XCircle } from "lucide-react"; -import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle, ResizableDialogDescription } from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useToast } from "@/hooks/use-toast"; -- 2.43.0 From eeed6714360ee3168e3b833decec7a63d6a505fd Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 19 Nov 2025 12:00:55 +0900 Subject: [PATCH 14/15] =?UTF-8?q?3d=20-=20=EB=B0=B0=EC=B9=98=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/CanvasElement.tsx | 96 +-- .../widgets/YardManagement3DWidget.tsx | 16 +- .../widgets/yard-3d/DigitalTwinEditor.tsx | 641 +++++++++++++++ .../widgets/yard-3d/DigitalTwinViewer.tsx | 310 +++++++ .../widgets/yard-3d/Yard3DCanvas.tsx | 777 +++++++++++++----- .../widgets/yard-3d/Yard3DCanvas_NEW.tsx | 290 +++++++ 6 files changed, 1860 insertions(+), 270 deletions(-) create mode 100644 frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx create mode 100644 frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx create mode 100644 frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas_NEW.tsx diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 29825952..c6429121 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -277,7 +277,7 @@ export function CanvasElement({ const autoScrollDirectionRef = useRef<"up" | "down" | null>(null); // 🔥 자동 스크롤 방향 const autoScrollFrameRef = useRef(null); // 🔥 requestAnimationFrame ID const lastMouseYRef = useRef(window.innerHeight / 2); // 🔥 마지막 마우스 Y 위치 (초기값: 화면 중간) - const [resizeStart, setResizeStart] = useState({ + const [resizeStart, setResizeStart] = useState({ x: 0, y: 0, width: 0, @@ -302,26 +302,26 @@ export function CanvasElement({ return; } - // 닫기 버튼이나 리사이즈 핸들 클릭 시 무시 + // 닫기 버튼이나 리사이즈 핸들 클릭 시 무시 if ((e.target as HTMLElement).closest(".element-close, .resize-handle")) { return; } // 위젯 내부 (헤더 제외) 클릭 시 드래그 무시 - 인터랙티브 사용 가능 if ((e.target as HTMLElement).closest(".widget-interactive-area")) { - return; - } + return; + } // 선택되지 않은 경우에만 선택 처리 if (!isSelected) { - onSelect(element.id); + onSelect(element.id); } - setIsDragging(true); + setIsDragging(true); const startPos = { - x: e.clientX, - y: e.clientY, - elementX: element.position.x, + x: e.clientX, + y: e.clientY, + elementX: element.position.x, elementY: element.position.y, initialScrollY: window.pageYOffset, // 🔥 드래그 시작 시점의 스크롤 위치 }; @@ -348,7 +348,7 @@ export function CanvasElement({ onMultiDragStart(element.id, offsets); } - e.preventDefault(); + e.preventDefault(); }, [ element.id, @@ -370,17 +370,17 @@ export function CanvasElement({ return; } - e.stopPropagation(); - setIsResizing(true); - setResizeStart({ - x: e.clientX, - y: e.clientY, - width: element.size.width, - height: element.size.height, - elementX: element.position.x, - elementY: element.position.y, + e.stopPropagation(); + setIsResizing(true); + setResizeStart({ + x: e.clientX, + y: e.clientY, + width: element.size.width, + height: element.size.height, + elementX: element.position.x, + elementY: element.position.y, handle, - }); + }); }, [element.size.width, element.size.height, element.position.x, element.position.y], ); @@ -388,7 +388,7 @@ export function CanvasElement({ // 마우스 이동 처리 (그리드 스냅 적용) const handleMouseMove = useCallback( (e: MouseEvent) => { - if (isDragging) { + if (isDragging) { // 🔥 자동 스크롤: 다중 선택 시 첫 번째 위젯에서만 처리 const isFirstSelectedElement = !selectedElements || selectedElements.length === 0 || selectedElements[0] === element.id; @@ -425,14 +425,14 @@ export function CanvasElement({ if (selectedElements.length > 1 && selectedElements.includes(element.id) && onMultiDragMove) { onMultiDragMove(element, { x: rawX, y: rawY }); } - } else if (isResizing) { - const deltaX = e.clientX - resizeStart.x; - const deltaY = e.clientY - resizeStart.y; - - let newWidth = resizeStart.width; - let newHeight = resizeStart.height; - let newX = resizeStart.elementX; - let newY = resizeStart.elementY; + } else if (isResizing) { + const deltaX = e.clientX - resizeStart.x; + const deltaY = e.clientY - resizeStart.y; + + let newWidth = resizeStart.width; + let newHeight = resizeStart.height; + let newX = resizeStart.elementX; + let newY = resizeStart.elementY; // 최소 크기 설정: 모든 위젯 1x1 const minWidthCells = 1; @@ -440,28 +440,28 @@ export function CanvasElement({ const minWidth = cellSize * minWidthCells; const minHeight = cellSize * minHeightCells; - switch (resizeStart.handle) { + switch (resizeStart.handle) { case "se": // 오른쪽 아래 newWidth = Math.max(minWidth, resizeStart.width + deltaX); newHeight = Math.max(minHeight, resizeStart.height + deltaY); - break; + break; case "sw": // 왼쪽 아래 newWidth = Math.max(minWidth, resizeStart.width - deltaX); newHeight = Math.max(minHeight, resizeStart.height + deltaY); - newX = resizeStart.elementX + deltaX; - break; + newX = resizeStart.elementX + deltaX; + break; case "ne": // 오른쪽 위 newWidth = Math.max(minWidth, resizeStart.width + deltaX); newHeight = Math.max(minHeight, resizeStart.height - deltaY); - newY = resizeStart.elementY + deltaY; - break; + newY = resizeStart.elementY + deltaY; + break; case "nw": // 왼쪽 위 newWidth = Math.max(minWidth, resizeStart.width - deltaX); newHeight = Math.max(minHeight, resizeStart.height - deltaY); - newX = resizeStart.elementX + deltaX; - newY = resizeStart.elementY + deltaY; - break; - } + newX = resizeStart.elementX + deltaX; + newY = resizeStart.elementY + deltaY; + break; + } // 가로 너비가 캔버스를 벗어나지 않도록 제한 const maxWidth = canvasWidth - newX; @@ -664,7 +664,7 @@ export function CanvasElement({ if (isDragging || isResizing) { document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); - + return () => { document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); @@ -685,7 +685,7 @@ export function CanvasElement({ // 필터 적용 (날짜 필터 등) const { applyQueryFilters } = await import("./utils/queryHelpers"); const filteredQuery = applyQueryFilters(element.dataSource.query, element.chartConfig); - + // 외부 DB vs 현재 DB 분기 if (element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId) { // 외부 DB @@ -709,13 +709,13 @@ export function CanvasElement({ // 현재 DB const { dashboardApi } = await import("@/lib/api/dashboard"); result = await dashboardApi.executeQuery(filteredQuery); - - setChartData({ - columns: result.columns || [], - rows: result.rows || [], - totalRows: result.rowCount || 0, + + setChartData({ + columns: result.columns || [], + rows: result.rows || [], + totalRows: result.rowCount || 0, executionTime: 0, - }); + }); } } catch (error) { // console.error("Chart data loading error:", error); @@ -859,7 +859,7 @@ export function CanvasElement({ ) ) : null}
-
+
)} {/* 삭제 버튼 - 항상 표시 (우측 상단 절대 위치) */} diff --git a/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx b/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx index fada50de..c312b9f4 100644 --- a/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx +++ b/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx @@ -5,8 +5,8 @@ import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Plus, Check, Trash2 } from "lucide-react"; import YardLayoutCreateModal from "./yard-3d/YardLayoutCreateModal"; -import YardEditor from "./yard-3d/YardEditor"; -import Yard3DViewer from "./yard-3d/Yard3DViewer"; +import DigitalTwinEditor from "./yard-3d/DigitalTwinEditor"; +import DigitalTwinViewer from "./yard-3d/DigitalTwinViewer"; import { yardLayoutApi } from "@/lib/api/yardLayoutApi"; import type { YardManagementConfig } from "../types"; @@ -125,11 +125,15 @@ export default function YardManagement3DWidget({ } }; - // 편집 모드: 편집 중인 경우 YardEditor 표시 + // 편집 모드: 편집 중인 경우 DigitalTwinEditor 표시 if (isEditMode && editingLayout) { return (
- +
); } @@ -269,10 +273,10 @@ export default function YardManagement3DWidget({ ); } - // 선택된 레이아웃의 3D 뷰어 표시 + // 선택된 레이아웃의 디지털 트윈 뷰어 표시 return (
- +
); } diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx new file mode 100644 index 00000000..e51fe131 --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx @@ -0,0 +1,641 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { ArrowLeft, Save, Loader2, Grid3x3, Combine, Move, Box, Package } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import dynamic from "next/dynamic"; +import { useToast } from "@/hooks/use-toast"; + +const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), { + ssr: false, + loading: () => ( +
+ +
+ ), +}); + +interface DigitalTwinEditorProps { + layoutId: number; + layoutName: string; + onBack: () => void; +} + +type ToolType = "yard" | "gantry-crane" | "mobile-crane" | "rack" | "plate-stack"; + +interface PlacedObject { + id: number; + type: ToolType; + name: string; + position: { x: number; y: number; z: number }; + size: { x: number; y: number; z: number }; + color: string; + // 데이터 바인딩 정보 + externalDbConnectionId?: number; + dataBindingConfig?: any; +} + +export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: DigitalTwinEditorProps) { + const { toast } = useToast(); + const [placedObjects, setPlacedObjects] = useState([]); + const [selectedObject, setSelectedObject] = useState(null); + const [draggedTool, setDraggedTool] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [externalDbConnections, setExternalDbConnections] = useState([]); + const [selectedDbConnection, setSelectedDbConnection] = useState(null); + const [nextObjectId, setNextObjectId] = useState(-1); + + // 외부 DB 연결 목록 로드 + useEffect(() => { + const loadExternalDbConnections = async () => { + try { + // TODO: 실제 API 호출 + // const response = await externalDbConnectionApi.getConnections({ is_active: 'Y' }); + + // 임시 데이터 + setExternalDbConnections([ + { id: 1, name: "DO_DY (동연 야드)", db_type: "mariadb" }, + { id: 2, name: "GY_YARD (광양 야드)", db_type: "mariadb" }, + ]); + } catch (error) { + console.error("외부 DB 연결 목록 조회 실패:", error); + } + }; + + loadExternalDbConnections(); + }, []); + + // 레이아웃 데이터 로드 + useEffect(() => { + const loadLayout = async () => { + try { + setIsLoading(true); + // TODO: 실제 API 호출 + // const response = await digitalTwinApi.getLayout(layoutId); + + // 임시 데이터 + setPlacedObjects([]); + } catch (error) { + console.error("레이아웃 로드 실패:", error); + } finally { + setIsLoading(false); + } + }; + + loadLayout(); + }, [layoutId]); + + // 도구 타입별 기본 설정 + const getToolDefaults = (type: ToolType): Partial => { + switch (type) { + case "yard": + return { + name: "영역", + size: { x: 20, y: 0.1, z: 20 }, // 4x4 칸 + color: "#3b82f6", // 파란색 + }; + case "gantry-crane": + return { + name: "겐트리 크레인", + size: { x: 5, y: 8, z: 5 }, // 1x1 칸 + color: "#22c55e", // 녹색 + }; + case "mobile-crane": + return { + name: "크레인", + size: { x: 5, y: 6, z: 5 }, // 1x1 칸 + color: "#eab308", // 노란색 + }; + case "rack": + return { + name: "랙", + size: { x: 5, y: 3, z: 5 }, // 1x1 칸 + color: "#a855f7", // 보라색 + }; + case "plate-stack": + return { + name: "후판 스택", + size: { x: 5, y: 2, z: 5 }, // 1x1 칸 + color: "#ef4444", // 빨간색 + }; + } + }; + + // 도구 드래그 시작 + const handleToolDragStart = (toolType: ToolType) => { + setDraggedTool(toolType); + }; + + // 캔버스에 드롭 + const handleCanvasDrop = (x: number, z: number) => { + if (!draggedTool) return; + + const defaults = getToolDefaults(draggedTool); + + // 야드는 바닥(y=0.05)에, 다른 객체는 중앙 정렬 + const yPosition = draggedTool === "yard" ? 0.05 : (defaults.size?.y || 1) / 2; + + const newObject: PlacedObject = { + id: nextObjectId, + type: draggedTool, + name: defaults.name || "새 객체", + position: { x, y: yPosition, z }, + size: defaults.size || { x: 5, y: 5, z: 5 }, + color: defaults.color || "#9ca3af", + }; + + setPlacedObjects((prev) => [...prev, newObject]); + setSelectedObject(newObject); + setNextObjectId((prev) => prev - 1); + setHasUnsavedChanges(true); + setDraggedTool(null); + }; + + // 객체 클릭 + const handleObjectClick = (objectId: number | null) => { + if (objectId === null) { + setSelectedObject(null); + return; + } + + const obj = placedObjects.find((o) => o.id === objectId); + setSelectedObject(obj || null); + }; + + // 객체 이동 + const handleObjectMove = (objectId: number, newX: number, newZ: number, newY?: number) => { + // Yard3DCanvas에서 이미 스냅+오프셋이 완료된 좌표를 받음 + // 그대로 저장하면 됨 + setPlacedObjects((prev) => + prev.map((obj) => { + if (obj.id === objectId) { + const newPosition = { ...obj.position, x: newX, z: newZ }; + if (newY !== undefined) { + newPosition.y = newY; + } + return { ...obj, position: newPosition }; + } + return obj; + }), + ); + + if (selectedObject?.id === objectId) { + setSelectedObject((prev) => { + if (!prev) return null; + const newPosition = { ...prev.position, x: newX, z: newZ }; + if (newY !== undefined) { + newPosition.y = newY; + } + return { ...prev, position: newPosition }; + }); + } + + setHasUnsavedChanges(true); + }; + + // 객체 속성 업데이트 + const handleObjectUpdate = (updates: Partial) => { + if (!selectedObject) return; + + let finalUpdates = { ...updates }; + + // 크기 변경 시에만 5 단위로 스냅하고 위치 조정 (position 변경은 제외) + if (updates.size && !updates.position) { + // placedObjects 배열에서 실제 저장된 객체를 가져옴 (selectedObject 상태가 아닌) + const actualObject = placedObjects.find((obj) => obj.id === selectedObject.id); + if (!actualObject) return; + + const oldSize = actualObject.size; + const newSize = { ...oldSize, ...updates.size }; + + // W, D를 5 단위로 스냅 + newSize.x = Math.max(5, Math.round(newSize.x / 5) * 5); + newSize.z = Math.max(5, Math.round(newSize.z / 5) * 5); + + // H는 자유롭게 (야드 제외) + if (actualObject.type !== "yard") { + newSize.y = Math.max(0.1, newSize.y); + } + + // 크기 차이 계산 + const deltaX = newSize.x - oldSize.x; + const deltaZ = newSize.z - oldSize.z; + const deltaY = newSize.y - oldSize.y; + + // 위치 조정: 왼쪽/뒤쪽/바닥 모서리 고정, 오른쪽/앞쪽/위쪽으로만 늘어남 + // Three.js는 중심점 기준이므로 크기 차이의 절반만큼 위치 이동 + // actualObject.position (실제 배열의 position)을 기준으로 계산 + const newPosition = { + ...actualObject.position, + x: actualObject.position.x + deltaX / 2, // 오른쪽으로 늘어남 + y: actualObject.position.y + deltaY / 2, // 위쪽으로 늘어남 (바닥 고정) + z: actualObject.position.z + deltaZ / 2, // 앞쪽으로 늘어남 + }; + + finalUpdates = { + ...finalUpdates, + size: newSize, + position: newPosition, + }; + } + + setPlacedObjects((prev) => prev.map((obj) => (obj.id === selectedObject.id ? { ...obj, ...finalUpdates } : obj))); + + setSelectedObject((prev) => (prev ? { ...prev, ...finalUpdates } : null)); + setHasUnsavedChanges(true); + }; + + // 객체 삭제 + const handleObjectDelete = () => { + if (!selectedObject) return; + + setPlacedObjects((prev) => prev.filter((obj) => obj.id !== selectedObject.id)); + setSelectedObject(null); + setHasUnsavedChanges(true); + }; + + // 저장 + const handleSave = async () => { + if (!selectedDbConnection) { + toast({ + title: "외부 DB 선택 필요", + description: "외부 데이터베이스 연결을 선택하세요.", + variant: "destructive", + }); + return; + } + + setIsSaving(true); + try { + // TODO: 실제 API 호출 + // await digitalTwinApi.saveLayout(layoutId, { + // externalDbConnectionId: selectedDbConnection, + // objects: placedObjects, + // }); + + toast({ + title: "저장 완료", + description: "레이아웃이 성공적으로 저장되었습니다.", + }); + + setHasUnsavedChanges(false); + } catch (error) { + console.error("저장 실패:", error); + toast({ + title: "저장 실패", + description: "레이아웃 저장에 실패했습니다.", + variant: "destructive", + }); + } finally { + setIsSaving(false); + } + }; + + return ( +
+ {/* 상단 툴바 */} +
+
+ +
+

{layoutName}

+

디지털 트윈 야드 편집

+
+
+ +
+ {hasUnsavedChanges && 미저장 변경사항 있음} + +
+
+ + {/* 도구 팔레트 */} +
+ 도구: + {[ + { type: "yard" as ToolType, label: "영역", icon: Grid3x3, color: "text-blue-500" }, + // { type: "gantry-crane" as ToolType, label: "겐트리", icon: Combine, color: "text-green-500" }, + { type: "mobile-crane" as ToolType, label: "크레인", icon: Move, color: "text-yellow-500" }, + { type: "rack" as ToolType, label: "랙", icon: Box, color: "text-purple-500" }, + { type: "plate-stack" as ToolType, label: "후판", icon: Package, color: "text-red-500" }, + ].map((tool) => { + const Icon = tool.icon; + return ( +
handleToolDragStart(tool.type)} + className="bg-background hover:bg-accent flex cursor-grab items-center gap-1 rounded-md border px-3 py-2 transition-colors active:cursor-grabbing" + title={`${tool.label} 드래그하여 배치`} + > + + {tool.label} +
+ ); + })} +
+ + {/* 메인 영역 */} +
+ {/* 좌측: 외부 DB 선택 + 객체 목록 */} +
+ {/* 외부 DB 선택 */} +
+ + +

실시간 데이터를 가져올 데이터베이스를 선택하세요

+
+ + {/* 배치된 객체 목록 */} +
+

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

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

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

+
+ ))} +
+ )} +
+
+ + {/* 중앙: 3D 캔버스 */} +
e.preventDefault()} + onDrop={(e) => { + e.preventDefault(); + const rect = e.currentTarget.getBoundingClientRect(); + const rawX = ((e.clientX - rect.left) / rect.width - 0.5) * 100; + const rawZ = ((e.clientY - rect.top) / rect.height - 0.5) * 100; + + // 그리드 크기 (5 단위) + const gridSize = 5; + + // 그리드에 스냅 + // 야드(20x20)는 그리드 교차점에, 다른 객체(5x5)는 타일 중앙에 + let snappedX = Math.round(rawX / gridSize) * gridSize; + let snappedZ = Math.round(rawZ / gridSize) * gridSize; + + // 5x5 객체는 타일 중앙으로 오프셋 (야드는 제외) + if (draggedTool !== "yard") { + snappedX += gridSize / 2; + snappedZ += gridSize / 2; + } + + handleCanvasDrop(snappedX, snappedZ); + }} + > + {isLoading ? ( +
+ +
+ ) : ( + ({ + id: obj.id, + yard_layout_id: layoutId, + material_code: null, + material_name: obj.name, + name: obj.name, // 객체 이름 (야드 이름 표시용) + quantity: null, + unit: null, + 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, + data_source_config: null, + data_binding: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }))} + selectedPlacementId={selectedObject?.id || null} + onPlacementClick={(placement) => handleObjectClick(placement?.id || null)} + onPlacementDrag={(id, position) => handleObjectMove(id, position.x, position.z, position.y)} + focusOnPlacementId={null} + onCollisionDetected={() => {}} + /> + )} +
+ + {/* 우측: 객체 속성 편집 */} +
+ {selectedObject ? ( +
+

객체 속성

+ +
+ {/* 이름 */} +
+ + handleObjectUpdate({ name: e.target.value })} + className="mt-1.5 h-9 text-sm" + /> +
+ + {/* 위치 */} +
+ +
+
+ + + handleObjectUpdate({ + position: { + ...selectedObject.position, + x: parseFloat(e.target.value), + }, + }) + } + className="h-9 text-sm" + /> +
+
+ + + handleObjectUpdate({ + position: { + ...selectedObject.position, + z: parseFloat(e.target.value), + }, + }) + } + className="h-9 text-sm" + /> +
+
+
+ + {/* 크기 */} +
+ +
+
+ + + handleObjectUpdate({ + size: { + ...selectedObject.size, + x: parseFloat(e.target.value), + }, + }) + } + className="h-9 text-sm" + /> +
+
+ + + handleObjectUpdate({ + size: { + ...selectedObject.size, + y: parseFloat(e.target.value), + }, + }) + } + className="h-9 text-sm" + /> +
+
+ + + handleObjectUpdate({ + size: { + ...selectedObject.size, + z: parseFloat(e.target.value), + }, + }) + } + className="h-9 text-sm" + /> +
+
+
+ + {/* 색상 */} +
+ + handleObjectUpdate({ color: e.target.value })} + className="mt-1.5 h-9" + /> +
+ + {/* 삭제 버튼 */} + +
+
+ ) : ( +
+

객체를 선택하면 속성을 편집할 수 있습니다

+
+ )} +
+
+
+ ); +} diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx new file mode 100644 index 00000000..6520ea29 --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx @@ -0,0 +1,310 @@ +"use client"; + +import { useState, useEffect, useMemo } from "react"; +import { Search } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import dynamic from "next/dynamic"; +import { Loader2 } from "lucide-react"; + +const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), { + ssr: false, + loading: () => ( +
+ +
+ ), +}); + +interface DigitalTwinViewerProps { + layoutId: number; +} + +// 임시 타입 정의 +interface Material { + id: number; + plate_no: string; // 후판번호 + steel_grade: string; // 강종 + thickness: number; // 두께 + width: number; // 폭 + length: number; // 길이 + weight: number; // 중량 + location: string; // 위치 + status: string; // 상태 + arrival_date: string; // 입고일자 +} + +export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) { + const [searchTerm, setSearchTerm] = useState(""); + const [selectedYard, setSelectedYard] = useState("all"); + const [selectedStatus, setSelectedStatus] = useState("all"); + const [dateRange, setDateRange] = useState({ from: "", to: "" }); + const [selectedMaterial, setSelectedMaterial] = useState(null); + const [materials, setMaterials] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + // 레이아웃 데이터 로드 + useEffect(() => { + const loadData = async () => { + try { + setIsLoading(true); + // TODO: 실제 API 호출 + // const response = await digitalTwinApi.getLayoutData(layoutId); + + // 임시 데이터 + setMaterials([ + { + id: 1, + plate_no: "P-2024-001", + steel_grade: "SM490A", + thickness: 25, + width: 2000, + length: 6000, + weight: 2355, + location: "A동-101", + status: "입고", + arrival_date: "2024-11-15", + }, + { + id: 2, + plate_no: "P-2024-002", + steel_grade: "SS400", + thickness: 30, + width: 2500, + length: 8000, + weight: 4710, + location: "B동-205", + status: "가공중", + arrival_date: "2024-11-16", + }, + ]); + } catch (error) { + console.error("디지털 트윈 데이터 로드 실패:", error); + } finally { + setIsLoading(false); + } + }; + + loadData(); + }, [layoutId]); + + // 필터링된 자재 목록 + const filteredMaterials = useMemo(() => { + return materials.filter((material) => { + // 검색어 필터 + if (searchTerm) { + const searchLower = searchTerm.toLowerCase(); + const matchSearch = + material.plate_no.toLowerCase().includes(searchLower) || + material.steel_grade.toLowerCase().includes(searchLower) || + material.location.toLowerCase().includes(searchLower); + if (!matchSearch) return false; + } + + // 야드 필터 + if (selectedYard !== "all" && !material.location.startsWith(selectedYard)) { + return false; + } + + // 상태 필터 + if (selectedStatus !== "all" && material.status !== selectedStatus) { + return false; + } + + // 날짜 필터 + if (dateRange.from && material.arrival_date < dateRange.from) { + return false; + } + if (dateRange.to && material.arrival_date > dateRange.to) { + return false; + } + + return true; + }); + }, [materials, searchTerm, selectedYard, selectedStatus, dateRange]); + + // 3D 객체 클릭 핸들러 + const handleObjectClick = (objectId: number) => { + const material = materials.find((m) => m.id === objectId); + setSelectedMaterial(material || null); + }; + + return ( +
+ {/* 좌측: 필터 패널 */} +
+ {/* 검색바 */} +
+
+ + setSearchTerm(e.target.value)} + placeholder="후판번호, 강종, 위치 검색..." + className="h-10 pl-10 text-sm" + /> +
+
+ + {/* 필터 옵션 */} +
+
+ {/* 야드 선택 */} +
+

야드

+
+ {["all", "A동", "B동", "C동", "겐트리"].map((yard) => ( + + ))} +
+
+ + {/* 상태 필터 */} +
+

상태

+
+ {["all", "입고", "가공중", "출고대기", "출고완료"].map((status) => ( + + ))} +
+
+ + {/* 기간 필터 */} +
+

입고 기간

+
+ setDateRange((prev) => ({ ...prev, from: e.target.value }))} + className="h-9 text-sm" + placeholder="시작일" + /> + setDateRange((prev) => ({ ...prev, to: e.target.value }))} + className="h-9 text-sm" + placeholder="종료일" + /> +
+
+
+
+
+ + {/* 중앙: 3D 캔버스 */} +
+ {isLoading ? ( +
+ +
+ ) : ( + { + if (placement) { + handleObjectClick(placement.id); + } else { + setSelectedMaterial(null); + } + }} + onPlacementDrag={() => {}} // 뷰어 모드에서는 드래그 비활성화 + focusOnPlacementId={null} + onCollisionDetected={() => {}} + /> + )} +
+ + {/* 우측: 상세정보 패널 (후판 목록 테이블) */} +
+
+

후판 목록

+ + {filteredMaterials.length === 0 ? ( +
+

조건에 맞는 후판이 없습니다.

+
+ ) : ( +
+ {filteredMaterials.map((material) => ( +
setSelectedMaterial(material)} + className={`cursor-pointer rounded-lg border p-3 transition-all ${ + selectedMaterial?.id === material.id + ? "border-primary bg-primary/10" + : "border-border hover:border-primary/50" + }`} + > +
+ {material.plate_no} + + {material.status} + +
+ +
+
+ 강종: + {material.steel_grade} +
+
+ 규격: + + {material.thickness}×{material.width}×{material.length} + +
+
+ 중량: + {material.weight.toLocaleString()} kg +
+
+ 위치: + {material.location} +
+
+ 입고일: + {material.arrival_date} +
+
+
+ ))} +
+ )} +
+
+
+ ); +} + diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx index eba640cf..911afcb9 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx @@ -10,6 +10,7 @@ interface YardPlacement { yard_layout_id?: number; material_code?: string | null; material_name?: string | null; + name?: string | null; // 객체 이름 (야드 이름 등) quantity?: number | null; unit?: string | null; position_x: number; @@ -37,12 +38,9 @@ interface Yard3DCanvasProps { // 좌표를 그리드 칸의 중심에 스냅 (마인크래프트 스타일) // Three.js Box의 position은 중심점이므로, 그리드 칸의 중심에 배치해야 칸에 딱 맞음 function snapToGrid(value: number, gridSize: number): number { - // 가장 가까운 그리드 칸 찾기 - const gridIndex = Math.round(value / gridSize); - // 그리드 칸의 중심점 반환 - // gridSize=5일 때: ..., -7.5, -2.5, 2.5, 7.5, 12.5, 17.5... - // 이렇게 하면 Box가 칸 안에 정확히 들어감 - return gridIndex * gridSize + gridSize / 2; + // 가장 가까운 그리드 교차점으로 스냅 (오프셋 없음) + // DigitalTwinEditor에서 오프셋 처리하므로 여기서는 순수 스냅만 + return Math.round(value / gridSize) * gridSize; } // 자재 박스 컴포넌트 (드래그 가능) @@ -55,7 +53,6 @@ function MaterialBox({ onDragEnd, gridSize = 5, allPlacements = [], - onCollisionDetected, }: { placement: YardPlacement; isSelected: boolean; @@ -71,19 +68,70 @@ function MaterialBox({ const [isDragging, setIsDragging] = useState(false); const dragStartPos = useRef<{ x: number; y: number; z: number }>({ x: 0, y: 0, z: 0 }); const mouseStartPos = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); + const dragOffset = useRef<{ x: number; z: number }>({ x: 0, z: 0 }); // 마우스와 객체 중심 간 오프셋 const { camera, gl } = useThree(); + const [glowIntensity, setGlowIntensity] = useState(1); + + // 선택 시 빛나는 애니메이션 + useEffect(() => { + if (!isSelected) { + setGlowIntensity(1); + return; + } + + let animationId: number; + const startTime = Date.now(); + + const animate = () => { + const elapsed = Date.now() - startTime; + const intensity = 1 + Math.sin(elapsed * 0.003) * 0.5; // 0.5 ~ 1.5 사이 진동 + setGlowIntensity(intensity); + animationId = requestAnimationFrame(animate); + }; + + animate(); + + return () => { + if (animationId) { + cancelAnimationFrame(animationId); + } + }; + }, [isSelected]); // 특정 좌표에 요소를 배치할 수 있는지 확인하고, 필요하면 Y 위치를 조정 const checkCollisionAndAdjustY = (x: number, y: number, z: number): { hasCollision: boolean; adjustedY: number } => { - const palletHeight = 0.3; // 팔레트 높이 - const palletGap = 0.05; // 팔레트와 박스 사이 간격 + if (!allPlacements || allPlacements.length === 0) { + // 다른 객체가 없으면 기본 높이 + const objectType = placement.data_source_type as string | null; + const defaultY = objectType === "yard" ? 0.05 : (placement.size_y || gridSize) / 2; + return { + hasCollision: false, + adjustedY: defaultY, + }; + } - const mySize = placement.size_x || gridSize; // 내 크기 (5) - const myHalfSize = mySize / 2; // 2.5 - const mySizeY = placement.size_y || gridSize; // 박스 높이 (5) - const myTotalHeight = mySizeY + palletHeight + palletGap; // 팔레트 포함한 전체 높이 + // 내 크기 정보 + const mySizeX = placement.size_x || gridSize; + const mySizeZ = placement.size_z || gridSize; + const mySizeY = placement.size_y || gridSize; - let maxYBelow = gridSize / 2; // 기본 바닥 높이 (2.5) + // 내 바운딩 박스 (좌측 하단 모서리 기준) + const myMinX = x - mySizeX / 2; + const myMaxX = x + mySizeX / 2; + const myMinZ = z - mySizeZ / 2; + const myMaxZ = z + mySizeZ / 2; + + const objectType = placement.data_source_type as string | null; + const defaultY = objectType === "yard" ? 0.05 : mySizeY / 2; + let maxYBelow = defaultY; + + // 야드는 스택되지 않음 (항상 바닥에 배치) + if (objectType === "yard") { + return { + hasCollision: false, + adjustedY: defaultY, + }; + } for (const p of allPlacements) { // 자기 자신은 제외 @@ -91,39 +139,31 @@ function MaterialBox({ continue; } - const pSize = p.size_x || gridSize; // 상대방 크기 (5) - const pHalfSize = pSize / 2; // 2.5 - const pSizeY = p.size_y || gridSize; // 상대방 박스 높이 (5) - const pTotalHeight = pSizeY + palletHeight + palletGap; // 상대방 팔레트 포함 전체 높이 + // 상대방 크기 정보 + const pSizeX = p.size_x || gridSize; + const pSizeZ = p.size_z || gridSize; + const pSizeY = p.size_y || gridSize; - // 1단계: 넓은 범위로 겹침 감지 (살짝만 가까이 가도 감지) - const detectionMargin = 0.5; // 감지 범위 확장 (0.5 유닛) - const isNearby = - Math.abs(x - p.position_x) < myHalfSize + pHalfSize + detectionMargin && // X축 근접 - Math.abs(z - p.position_z) < myHalfSize + pHalfSize + detectionMargin; // Z축 근접 + // 상대방 바운딩 박스 + const pMinX = p.position_x - pSizeX / 2; + const pMaxX = p.position_x + pSizeX / 2; + const pMinZ = p.position_z - pSizeZ / 2; + const pMaxZ = p.position_z + pSizeZ / 2; - if (isNearby) { - // 2단계: 실제로 겹치는지 정확히 판단 (바닥에 둘지, 위에 둘지 결정) - const isActuallyOverlapping = - Math.abs(x - p.position_x) < myHalfSize + pHalfSize && // X축 실제 겹침 - Math.abs(z - p.position_z) < myHalfSize + pHalfSize; // Z축 실제 겹침 + // AABB 충돌 감지 (2D 평면에서) + const isOverlapping = myMinX < pMaxX && myMaxX > pMinX && myMinZ < pMaxZ && myMaxZ > pMinZ; - if (isActuallyOverlapping) { - // 실제로 겹침: 위에 배치 - // 상대방 전체 높이 (박스 + 팔레트)의 윗면 계산 - const topOfOtherElement = p.position_y + pTotalHeight / 2; - // 내 전체 높이의 절반을 더해서 내가 올라갈 Y 위치 계산 - const myYOnTop = topOfOtherElement + myTotalHeight / 2; + if (isOverlapping) { + // 겹침: 상대방 위에 배치 + const topOfOtherElement = p.position_y + pSizeY / 2; + const myYOnTop = topOfOtherElement + mySizeY / 2; - if (myYOnTop > maxYBelow) { - maxYBelow = myYOnTop; - } + if (myYOnTop > maxYBelow) { + maxYBelow = myYOnTop; } - // 근처에만 있고 실제로 안 겹침: 바닥에 배치 (maxYBelow 유지) } } - // 요청한 Y와 조정된 Y가 다르면 충돌로 간주 (위로 올려야 함) const needsAdjustment = Math.abs(y - maxYBelow) > 0.1; return { @@ -160,46 +200,60 @@ function MaterialBox({ e.preventDefault(); e.stopPropagation(); - // 마우스 이동 거리 계산 (픽셀) - const deltaX = e.clientX - mouseStartPos.current.x; - const deltaY = e.clientY - mouseStartPos.current.y; + // 마우스 좌표를 정규화 (-1 ~ 1) + const rect = gl.domElement.getBoundingClientRect(); + const mouseX = ((e.clientX - rect.left) / rect.width) * 2 - 1; + const mouseY = -((e.clientY - rect.top) / rect.height) * 2 + 1; - // 카메라 거리를 고려한 스케일 팩터 - const distance = camera.position.distanceTo(meshRef.current.position); - const scaleFactor = distance / 500; // 조정 가능한 값 + // Raycaster로 바닥 평면과의 교차점 계산 + const raycaster = new THREE.Raycaster(); + raycaster.setFromCamera(new THREE.Vector2(mouseX, mouseY), camera); - // 카메라 방향 벡터 - const cameraDirection = new THREE.Vector3(); - camera.getWorldDirection(cameraDirection); + // 바닥 평면 (y = 0) + const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); + const intersectPoint = new THREE.Vector3(); + const hasIntersection = raycaster.ray.intersectPlane(plane, intersectPoint); - // 카메라의 우측 벡터 (X축 이동용) - const right = new THREE.Vector3(); - right.crossVectors(camera.up, cameraDirection).normalize(); + if (!hasIntersection) { + return; + } - // 실제 3D 공간에서의 이동량 계산 - const moveRight = right.multiplyScalar(-deltaX * scaleFactor); - const moveForward = new THREE.Vector3(-cameraDirection.x, 0, -cameraDirection.z) - .normalize() - .multiplyScalar(deltaY * scaleFactor); - - // 최종 위치 계산 - const finalX = dragStartPos.current.x + moveRight.x + moveForward.x; - const finalZ = dragStartPos.current.z + moveRight.z + moveForward.z; + // 마우스 위치에 드래그 시작 시 저장한 오프셋 적용 + const finalX = intersectPoint.x + dragOffset.current.x; + const finalZ = intersectPoint.z + dragOffset.current.z; // NaN 검증 if (isNaN(finalX) || isNaN(finalZ)) { return; } - // 그리드에 스냅 - const snappedX = snapToGrid(finalX, gridSize); - const snappedZ = snapToGrid(finalZ, gridSize); + // 객체의 좌측 하단 모서리 좌표 계산 (크기 / 2를 빼서) + const sizeX = placement.size_x || 5; + const sizeZ = placement.size_z || 5; + + const cornerX = finalX - sizeX / 2; + const cornerZ = finalZ - sizeZ / 2; + + // 좌측 하단 모서리를 그리드에 스냅 + const snappedCornerX = snapToGrid(cornerX, gridSize); + const snappedCornerZ = snapToGrid(cornerZ, gridSize); + + // 스냅된 모서리로부터 중심 위치 계산 + const finalSnappedX = snappedCornerX + sizeX / 2; + const finalSnappedZ = snappedCornerZ + sizeZ / 2; + + console.log("🐛 드래그 중:", { + 마우스_화면: { x: e.clientX, y: e.clientY }, + 정규화_마우스: { x: mouseX, y: mouseY }, + 교차점: { x: finalX, z: finalZ }, + 스냅후: { x: finalSnappedX, z: finalSnappedZ }, + }); // 충돌 체크 및 Y 위치 조정 - const { adjustedY } = checkCollisionAndAdjustY(snappedX, dragStartPos.current.y, snappedZ); + const { adjustedY } = checkCollisionAndAdjustY(finalSnappedX, dragStartPos.current.y, finalSnappedZ); - // 즉시 mesh 위치 업데이트 (조정된 Y 위치로) - meshRef.current.position.set(finalX, adjustedY, finalZ); + // 즉시 mesh 위치 업데이트 (스냅된 위치로) + meshRef.current.position.set(finalSnappedX, adjustedY, finalSnappedZ); // ⚠️ 드래그 중에는 상태 업데이트 안 함 (미리보기만) // 실제 저장은 handleGlobalMouseUp에서만 수행 @@ -217,23 +271,21 @@ function MaterialBox({ const hasMoved = deltaX > minMovement || deltaZ > minMovement; if (hasMoved) { - // 실제로 드래그한 경우: 그리드에 스냅 - const snappedX = snapToGrid(currentPos.x, gridSize); - const snappedZ = snapToGrid(currentPos.z, gridSize); - - // Y 위치 조정 (마인크래프트처럼 쌓기) - const { adjustedY } = checkCollisionAndAdjustY(snappedX, currentPos.y, snappedZ); + // 실제로 드래그한 경우: 이미 handleGlobalMouseMove에서 스냅됨 + // currentPos는 이미 스냅+오프셋이 적용된 값이므로 그대로 사용 + const finalX = currentPos.x; + const finalY = currentPos.y; + const finalZ = currentPos.z; // ✅ 항상 배치 가능 (위로 올라가므로) - console.log("✅ 배치 완료! 저장:", { x: snappedX, y: adjustedY, z: snappedZ }); - meshRef.current.position.set(snappedX, adjustedY, snappedZ); + console.log("✅ 배치 완료! 저장:", { x: finalX, y: finalY, z: finalZ }); - // 최종 위치 저장 (조정된 Y 위치로) + // 최종 위치 저장 if (onDrag) { onDrag({ - x: snappedX, - y: adjustedY, - z: snappedZ, + x: finalX, + y: finalY, + z: finalZ, }); } } else { @@ -284,6 +336,29 @@ function MaterialBox({ y: e.clientY, }; + // 마우스 클릭 위치를 3D 좌표로 변환 + const rect = gl.domElement.getBoundingClientRect(); + const mouseX = ((e.clientX - rect.left) / rect.width) * 2 - 1; + const mouseY = -((e.clientY - rect.top) / rect.height) * 2 + 1; + + const raycaster = new THREE.Raycaster(); + raycaster.setFromCamera(new THREE.Vector2(mouseX, mouseY), camera); + + // 바닥 평면과의 교차점 계산 + const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); + const intersectPoint = new THREE.Vector3(); + const hasIntersection = raycaster.ray.intersectPlane(plane, intersectPoint); + + if (hasIntersection) { + // 마우스 클릭 위치와 객체 중심 간의 오프셋 저장 + dragOffset.current = { + x: currentPos.x - intersectPoint.x, + z: currentPos.z - intersectPoint.z, + }; + } else { + dragOffset.current = { x: 0, z: 0 }; + } + setIsDragging(true); gl.domElement.style.cursor = "grabbing"; if (onDragStart) { @@ -304,6 +379,407 @@ function MaterialBox({ // 팔레트 위치 계산: 박스 하단부터 시작 const palletYOffset = -(boxHeight / 2) - palletHeight / 2 - palletGap; + // 객체 타입 (data_source_type에 저장됨) + const objectType = placement.data_source_type as string | null; + + // 타입별 렌더링 + const renderObjectByType = () => { + switch (objectType) { + case "yard": + // 야드: 투명한 바닥 + 두꺼운 외곽선 (mesh) + 이름 텍스트 + const borderThickness = 0.3; // 외곽선 두께 + return ( + <> + {/* 투명한 메쉬 (클릭 영역) */} + + + + + + {/* 두꺼운 외곽선 - 4개의 막대로 구현 */} + {/* 상단 */} + + + + + {/* 하단 */} + + + + + {/* 좌측 */} + + + + + {/* 우측 */} + + + + + + {/* 선택 시 빛나는 효과 */} + {isSelected && ( + <> + + + + + + + + + + + + + + + + + + )} + + {/* 야드 이름 텍스트 */} + {placement.name && ( + + {placement.name} + + )} + + ); + + // case "gantry-crane": + // // 겐트리 크레인: 기둥 2개 + 상단 빔 + // return ( + // + // {/* 왼쪽 기둥 */} + // + // + // + // {/* 오른쪽 기둥 */} + // + // + // + // {/* 상단 빔 */} + // + // + // + // {/* 호이스트 (크레인 훅) */} + // + // + // + // + // ); + + case "mobile-crane": + // 이동식 크레인: 하부(트랙) + 회전대 + 캐빈 + 붐대 + 카운터웨이트 + 후크 + return ( + + {/* 하부 - 크롤러 트랙 (좌측) */} + + + + {/* 하부 - 크롤러 트랙 (우측) */} + + + + + {/* 회전 플랫폼 */} + + + + + {/* 엔진룸 (뒤쪽) */} + + + + + {/* 캐빈 (운전실) - 앞쪽 */} + + + + + {/* 붐대 베이스 (회전 지점) */} + + + + + {/* 메인 붐대 (하단 섹션) */} + + + + + {/* 메인 붐대 (상단 섹션 - 연장) */} + + + + + {/* 카운터웨이트 (뒤쪽 균형추) */} + + + + + {/* 후크 케이블 */} + + + + + {/* 후크 */} + + + + + {/* 지브 와이어 (지지 케이블) */} + + + + + ); + + case "rack": + // 랙: 프레임 구조 + return ( + + {/* 4개 기둥 */} + {[ + [-boxWidth * 0.4, -boxDepth * 0.4], + [boxWidth * 0.4, -boxDepth * 0.4], + [-boxWidth * 0.4, boxDepth * 0.4], + [boxWidth * 0.4, boxDepth * 0.4], + ].map(([x, z], idx) => ( + + + + ))} + {/* 선반 (3단) */} + {[-boxHeight * 0.3, 0, boxHeight * 0.3].map((y, idx) => ( + + + + ))} + + ); + + case "plate-stack": + default: + // 후판 스택: 팔레트 + 박스 (기존 렌더링) + return ( + <> + {/* 팔레트 그룹 - 박스 하단에 붙어있도록 */} + + {/* 상단 가로 판자들 (5개) */} + {[-boxDepth * 0.4, -boxDepth * 0.2, 0, boxDepth * 0.2, boxDepth * 0.4].map((zOffset, idx) => ( + + + + + + + + ))} + + {/* 중간 세로 받침대 (3개) */} + {[-boxWidth * 0.35, 0, boxWidth * 0.35].map((xOffset, idx) => ( + + + + + + + + ))} + + {/* 하단 가로 판자들 (3개) */} + {[-boxDepth * 0.3, 0, boxDepth * 0.3].map((zOffset, idx) => ( + + + + + + + + ))} + + + {/* 메인 박스 */} + + {/* 메인 재질 - 골판지 느낌 */} + + + {/* 외곽선 - 더 진하게 */} + + + + + + + ); + } + }; + return ( - {/* 팔레트 그룹 - 박스 하단에 붙어있도록 */} - - {/* 상단 가로 판자들 (5개) */} - {[-boxDepth * 0.4, -boxDepth * 0.2, 0, boxDepth * 0.2, boxDepth * 0.4].map((zOffset, idx) => ( - - - - - - - - ))} - - {/* 중간 세로 받침대 (3개) */} - {[-boxWidth * 0.35, 0, boxWidth * 0.35].map((xOffset, idx) => ( - - - - - - - - ))} - - {/* 하단 가로 판자들 (3개) */} - {[-boxDepth * 0.3, 0, boxDepth * 0.3].map((zOffset, idx) => ( - - - - - - - - ))} - - - {/* 메인 박스 */} - - {/* 메인 재질 - 골판지 느낌 */} - - - {/* 외곽선 - 더 진하게 */} - - - - - - - {/* 포장 테이프 (가로) - 윗면 */} - {isConfigured && ( - <> - {/* 테이프 세로 */} - - - - - )} - - {/* 자재명 라벨 스티커 (앞면) - 흰색 배경 */} - {isConfigured && placement.material_name && ( - - {/* 라벨 배경 (흰색 스티커) */} - - - - - - - - {/* 라벨 텍스트 */} - - {placement.material_name} - - - )} - - {/* 수량 라벨 (윗면) - 큰 글씨 */} - {isConfigured && placement.quantity && ( - - {placement.quantity} {placement.unit || ""} - - )} - - {/* 디테일 표시 */} - {isConfigured && ( - <> - {/* 화살표 표시 (이 쪽이 위) */} - - - ▲ - - - UP - - - - )} + {renderObjectByType()} ); } @@ -563,15 +905,18 @@ function Scene({ + {/* 배경색 */} + + {/* 바닥 그리드 (타일을 4등분) */} +
+ {/* ========== 하부 크롤러 트랙 시스템 ========== */} + {/* 좌측 트랙 메인 */} + + + + {/* 좌측 트랙 상부 롤러 */} + + + + + + + + + + + {/* 우측 트랙 메인 */} + + + + {/* 우측 트랙 상부 롤러 */} + + + + + + + + + + + {/* 트랙 연결 프레임 */} + + + + + {/* ========== 회전 상부 구조 ========== */} + {/* 메인 회전 플랫폼 */} + + + + {/* 회전 베어링 하우징 */} + + + + + {/* ========== 엔진 및 유압 시스템 ========== */} + {/* 엔진룸 메인 */} + + + + {/* 유압 펌프 하우징 */} + + + + + + + {/* 배기 파이프 */} + + + + + {/* ========== 운전실 (캐빈) ========== */} + {/* 캐빈 메인 바디 */} + + + + {/* 캐빈 창문 */} + + + + {/* 캐빈 지붕 */} + + + + + {/* ========== 붐대 시스템 ========== */} + {/* 붐대 마운트 베이스 */} + + + + {/* 붐대 힌지 실린더 (유압) */} + + + + + {/* 메인 붐대 하단 섹션 */} + + + + {/* 붐대 상단 섹션 (텔레스코픽) */} + + + + {/* 붐대 최상단 섹션 */} + + + + + {/* 붐대 트러스 구조 (디테일) */} + {[-0.15, -0.05, 0.05, 0.15].map((offset, idx) => ( + + + + ))} + + {/* ========== 카운터웨이트 시스템 ========== */} + {/* 카운터웨이트 메인 블록 */} + + + + {/* 카운터웨이트 추가 블록 (상단) */} + + + + {/* 카운터웨이트 프레임 */} + + + + + {/* ========== 후크 및 케이블 시스템 ========== */} + {/* 붐대 끝단 풀리 */} + + + + + {/* 메인 호이스트 케이블 */} + + + + + {/* 후크 블록 상단 */} + + + + {/* 후크 메인 (빨간색 안전색) */} + + + + + {/* 지브 지지 케이블 (좌측) */} + + + + {/* 지브 지지 케이블 (우측) */} + + + + + {/* ========== 조명 및 안전 장치 ========== */} + {/* 작업등 (전방) */} + + + + {/* 경고등 (붐대 상단) */} + + + + + ); + -- 2.43.0 From 33350a4d46e559bdb44eb954d089843033d5db69 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 20 Nov 2025 10:15:58 +0900 Subject: [PATCH 15/15] =?UTF-8?q?feat:=20Digital=20Twin=20Editor=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EB=A7=A4=ED=95=91=20UI=20?= =?UTF-8?q?=EB=B0=8F=20=EB=B0=B1=EC=97=94=EB=93=9C=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/scripts/test-digital-twin-db.ts | 209 +++ backend-node/src/app.ts | 2 + .../controllers/digitalTwinDataController.ts | 273 ++++ .../digitalTwinLayoutController.ts | 386 ++++++ backend-node/src/routes/digitalTwinRoutes.ts | 66 + .../admin/dashboard/CanvasElement.tsx | 176 +-- .../admin/dashboard/WidgetConfigSidebar.tsx | 14 + .../widgets/YardManagement3DWidget.tsx | 33 +- .../widgets/yard-3d/DigitalTwinEditor.tsx | 1119 +++++++++++++++-- .../widgets/yard-3d/DigitalTwinViewer.tsx | 683 ++++++---- .../widgets/yard-3d/Yard3DCanvas.tsx | 138 +- frontend/lib/api/digitalTwin.ts | 215 ++++ frontend/types/digitalTwin.ts | 155 +++ 13 files changed, 3007 insertions(+), 462 deletions(-) create mode 100644 backend-node/scripts/test-digital-twin-db.ts create mode 100644 backend-node/src/controllers/digitalTwinDataController.ts create mode 100644 backend-node/src/controllers/digitalTwinLayoutController.ts create mode 100644 backend-node/src/routes/digitalTwinRoutes.ts create mode 100644 frontend/lib/api/digitalTwin.ts create mode 100644 frontend/types/digitalTwin.ts diff --git a/backend-node/scripts/test-digital-twin-db.ts b/backend-node/scripts/test-digital-twin-db.ts new file mode 100644 index 00000000..7d0efce7 --- /dev/null +++ b/backend-node/scripts/test-digital-twin-db.ts @@ -0,0 +1,209 @@ +/** + * 디지털 트윈 외부 DB (DO_DY) 연결 및 쿼리 테스트 스크립트 + * READ-ONLY: SELECT 쿼리만 실행 + */ + +import { Pool } from "pg"; +import mysql from "mysql2/promise"; +import { CredentialEncryption } from "../src/utils/credentialEncryption"; + +async function testDigitalTwinDb() { + // 내부 DB 연결 (연결 정보 저장용) + const internalPool = new Pool({ + host: process.env.DB_HOST || "localhost", + port: parseInt(process.env.DB_PORT || "5432"), + database: process.env.DB_NAME || "plm", + user: process.env.DB_USER || "postgres", + password: process.env.DB_PASSWORD || "ph0909!!", + }); + + const encryptionKey = + process.env.ENCRYPTION_SECRET_KEY || "default-secret-key-for-development"; + const encryption = new CredentialEncryption(encryptionKey); + + try { + console.log("🚀 디지털 트윈 외부 DB 연결 테스트 시작\n"); + + // 디지털 트윈 외부 DB 연결 정보 + const digitalTwinConnection = { + name: "디지털트윈_DO_DY", + description: "디지털 트윈 후판(자재) 재고 정보 데이터베이스 (MariaDB)", + dbType: "mysql", // MariaDB는 MySQL 프로토콜 사용 + host: "1.240.13.83", + port: 4307, + databaseName: "DO_DY", + username: "root", + password: "pohangms619!#", + sslEnabled: false, + isActive: true, + }; + + console.log("📝 연결 정보:"); + console.log(` - 이름: ${digitalTwinConnection.name}`); + console.log(` - DB 타입: ${digitalTwinConnection.dbType}`); + console.log(` - 호스트: ${digitalTwinConnection.host}:${digitalTwinConnection.port}`); + console.log(` - 데이터베이스: ${digitalTwinConnection.databaseName}\n`); + + // 1. 외부 DB 직접 연결 테스트 + console.log("🔍 외부 DB 직접 연결 테스트 중..."); + + const externalConnection = await mysql.createConnection({ + host: digitalTwinConnection.host, + port: digitalTwinConnection.port, + database: digitalTwinConnection.databaseName, + user: digitalTwinConnection.username, + password: digitalTwinConnection.password, + connectTimeout: 10000, + }); + + console.log("✅ 외부 DB 연결 성공!\n"); + + // 2. SELECT 쿼리 실행 + console.log("📊 WSTKKY 테이블 쿼리 실행 중...\n"); + + const query = ` + SELECT + SKUMKEY -- 제품번호 + , SKUDESC -- 자재명 + , SKUTHIC -- 두께 + , SKUWIDT -- 폭 + , SKULENG -- 길이 + , SKUWEIG -- 중량 + , STOTQTY -- 수량 + , SUOMKEY -- 단위 + FROM DO_DY.WSTKKY + LIMIT 10 + `; + + const [rows] = await externalConnection.execute(query); + + console.log("✅ 쿼리 실행 성공!\n"); + console.log(`📦 조회된 데이터: ${Array.isArray(rows) ? rows.length : 0}건\n`); + + if (Array.isArray(rows) && rows.length > 0) { + console.log("🔍 샘플 데이터 (첫 3건):\n"); + rows.slice(0, 3).forEach((row: any, index: number) => { + console.log(`[${index + 1}]`); + console.log(` 제품번호(SKUMKEY): ${row.SKUMKEY}`); + console.log(` 자재명(SKUDESC): ${row.SKUDESC}`); + console.log(` 두께(SKUTHIC): ${row.SKUTHIC}`); + console.log(` 폭(SKUWIDT): ${row.SKUWIDT}`); + console.log(` 길이(SKULENG): ${row.SKULENG}`); + console.log(` 중량(SKUWEIG): ${row.SKUWEIG}`); + console.log(` 수량(STOTQTY): ${row.STOTQTY}`); + console.log(` 단위(SUOMKEY): ${row.SUOMKEY}\n`); + }); + + // 전체 데이터 JSON 출력 + console.log("📄 전체 데이터 (JSON):"); + console.log(JSON.stringify(rows, null, 2)); + console.log("\n"); + } + + await externalConnection.end(); + + // 3. 내부 DB에 연결 정보 저장 + console.log("💾 내부 DB에 연결 정보 저장 중..."); + + const encryptedPassword = encryption.encrypt(digitalTwinConnection.password); + + // 중복 체크 + const existingResult = await internalPool.query( + "SELECT id FROM flow_external_db_connection WHERE name = $1", + [digitalTwinConnection.name] + ); + + let connectionId: number; + + if (existingResult.rows.length > 0) { + connectionId = existingResult.rows[0].id; + console.log(`⚠️ 이미 존재하는 연결 (ID: ${connectionId})`); + + // 기존 연결 업데이트 + await internalPool.query( + `UPDATE flow_external_db_connection + SET description = $1, + db_type = $2, + host = $3, + port = $4, + database_name = $5, + username = $6, + password_encrypted = $7, + ssl_enabled = $8, + is_active = $9, + updated_at = NOW(), + updated_by = 'system' + WHERE name = $10`, + [ + digitalTwinConnection.description, + digitalTwinConnection.dbType, + digitalTwinConnection.host, + digitalTwinConnection.port, + digitalTwinConnection.databaseName, + digitalTwinConnection.username, + encryptedPassword, + digitalTwinConnection.sslEnabled, + digitalTwinConnection.isActive, + digitalTwinConnection.name, + ] + ); + console.log(`✅ 연결 정보 업데이트 완료`); + } else { + // 새 연결 추가 + const result = await internalPool.query( + `INSERT INTO flow_external_db_connection ( + name, + description, + db_type, + host, + port, + database_name, + username, + password_encrypted, + ssl_enabled, + is_active, + created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'system') + RETURNING id`, + [ + digitalTwinConnection.name, + digitalTwinConnection.description, + digitalTwinConnection.dbType, + digitalTwinConnection.host, + digitalTwinConnection.port, + digitalTwinConnection.databaseName, + digitalTwinConnection.username, + encryptedPassword, + digitalTwinConnection.sslEnabled, + digitalTwinConnection.isActive, + ] + ); + connectionId = result.rows[0].id; + console.log(`✅ 새 연결 추가 완료 (ID: ${connectionId})`); + } + + console.log("\n✅ 모든 테스트 완료!"); + console.log(`\n📌 연결 ID: ${connectionId}`); + console.log(" 이 ID를 사용하여 플로우 관리나 제어 관리에서 외부 DB를 연동할 수 있습니다."); + + } catch (error: any) { + console.error("\n❌ 오류 발생:", error.message); + console.error("상세 정보:", error); + throw error; + } finally { + await internalPool.end(); + } +} + +// 스크립트 실행 +testDigitalTwinDb() + .then(() => { + console.log("\n🎉 스크립트 완료"); + process.exit(0); + }) + .catch((error) => { + console.error("\n💥 스크립트 실패:", error); + process.exit(1); + }); + + diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 37936f36..9ffaaa8a 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -59,6 +59,7 @@ import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리 import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리 import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 야드 관리 3D //import materialRoutes from "./routes/materialRoutes"; // 자재 관리 +import digitalTwinRoutes from "./routes/digitalTwinRoutes"; // 디지털 트윈 (야드 관제) import flowRoutes from "./routes/flowRoutes"; // 플로우 관리 import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRoutes"; // 플로우 전용 외부 DB 연결 import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리 @@ -221,6 +222,7 @@ app.use("/api/bookings", bookingRoutes); // 예약 요청 관리 app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회 app.use("/api/yard-layouts", yardLayoutRoutes); // 야드 관리 3D // app.use("/api/materials", materialRoutes); // 자재 관리 (임시 주석) +app.use("/api/digital-twin", digitalTwinRoutes); // 디지털 트윈 (야드 관제) app.use("/api/flow-external-db", flowExternalDbConnectionRoutes); // 플로우 전용 외부 DB 연결 app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여 다른 라우트와 충돌 방지) app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리 diff --git a/backend-node/src/controllers/digitalTwinDataController.ts b/backend-node/src/controllers/digitalTwinDataController.ts new file mode 100644 index 00000000..51dd85d8 --- /dev/null +++ b/backend-node/src/controllers/digitalTwinDataController.ts @@ -0,0 +1,273 @@ +import { Request, Response } from "express"; +import { pool, queryOne } from "../database/db"; +import logger from "../utils/logger"; +import { PasswordEncryption } from "../utils/passwordEncryption"; +import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory"; + +// 외부 DB 커넥터를 가져오는 헬퍼 함수 +export async function getExternalDbConnector(connectionId: number) { + // 외부 DB 연결 정보 조회 + const connection = await queryOne( + `SELECT * FROM external_db_connections WHERE id = $1`, + [connectionId] + ); + + if (!connection) { + throw new Error(`외부 DB 연결 정보를 찾을 수 없습니다. ID: ${connectionId}`); + } + + // 패스워드 복호화 + const decryptedPassword = PasswordEncryption.decrypt(connection.password); + + // DB 연결 설정 + const config = { + host: connection.host, + port: connection.port, + user: connection.username, + password: decryptedPassword, + database: connection.database_name, + }; + + // DB 커넥터 생성 + return await DatabaseConnectorFactory.createConnector( + connection.db_type || "mariadb", + config, + connectionId + ); +} + +// 창고 목록 조회 (사용자 지정 테이블) +export const getWarehouses = async (req: Request, res: Response): Promise => { + try { + const { externalDbConnectionId, tableName } = req.query; + + if (!externalDbConnectionId) { + return res.status(400).json({ + success: false, + message: "외부 DB 연결 ID가 필요합니다.", + }); + } + + if (!tableName) { + return res.status(400).json({ + success: false, + message: "테이블명이 필요합니다.", + }); + } + + const connector = await getExternalDbConnector(Number(externalDbConnectionId)); + + // 테이블명을 사용하여 모든 컬럼 조회 + const query = `SELECT * FROM ${tableName} LIMIT 100`; + + const result = await connector.executeQuery(query); + + logger.info("창고 목록 조회", { + externalDbConnectionId, + tableName, + count: result.rows.length, + data: result.rows, // 실제 데이터 확인 + }); + + 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, + }); + } +}; + +// Area 목록 조회 (사용자 지정 테이블) +export const getAreas = async (req: Request, res: Response): Promise => { + try { + const { externalDbConnectionId, tableName, warehouseKey } = req.query; + + if (!externalDbConnectionId || !tableName) { + return res.status(400).json({ + success: false, + message: "외부 DB 연결 ID와 테이블명이 필요합니다.", + }); + } + + const connector = await getExternalDbConnector(Number(externalDbConnectionId)); + + // 테이블명을 사용하여 모든 컬럼 조회 + let query = `SELECT * FROM ${tableName}`; + + if (warehouseKey) { + query += ` WHERE WAREKEY = '${warehouseKey}'`; + } + + query += ` LIMIT 1000`; + + const result = await connector.executeQuery(query); + + logger.info("Area 목록 조회", { + externalDbConnectionId, + tableName, + warehouseKey, + count: result.rows.length, + }); + + return res.json({ + success: true, + data: result.rows, + }); + } catch (error: any) { + logger.error("Area 목록 조회 실패", error); + return res.status(500).json({ + success: false, + message: "Area 목록 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; + +// Location 목록 조회 (사용자 지정 테이블) +export const getLocations = async (req: Request, res: Response): Promise => { + try { + const { externalDbConnectionId, tableName, areaKey } = req.query; + + if (!externalDbConnectionId || !tableName) { + return res.status(400).json({ + success: false, + message: "외부 DB 연결 ID와 테이블명이 필요합니다.", + }); + } + + const connector = await getExternalDbConnector(Number(externalDbConnectionId)); + + // 테이블명을 사용하여 모든 컬럼 조회 + let query = `SELECT * FROM ${tableName}`; + + if (areaKey) { + query += ` WHERE AREAKEY = '${areaKey}'`; + } + + query += ` LIMIT 1000`; + + const result = await connector.executeQuery(query); + + logger.info("Location 목록 조회", { + externalDbConnectionId, + tableName, + areaKey, + count: result.rows.length, + }); + + return res.json({ + success: true, + data: result.rows, + }); + } catch (error: any) { + logger.error("Location 목록 조회 실패", error); + return res.status(500).json({ + success: false, + message: "Location 목록 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; + +// 자재 목록 조회 (사용자 지정 테이블) +export const getMaterials = async (req: Request, res: Response): Promise => { + try { + const { externalDbConnectionId, tableName, locaKey } = req.query; + + if (!externalDbConnectionId || !tableName) { + return res.status(400).json({ + success: false, + message: "외부 DB 연결 ID와 테이블명이 필요합니다.", + }); + } + + const connector = await getExternalDbConnector(Number(externalDbConnectionId)); + + // 테이블명을 사용하여 모든 컬럼 조회 + let query = `SELECT * FROM ${tableName}`; + + if (locaKey) { + query += ` WHERE LOCAKEY = '${locaKey}'`; + } + + query += ` LIMIT 1000`; + + const result = await connector.executeQuery(query); + + logger.info("자재 목록 조회", { + externalDbConnectionId, + tableName, + locaKey, + 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, + }); + } +}; + +// Location별 자재 개수 조회 (배치 시 사용 - 사용자 지정 테이블) +export const getMaterialCounts = async (req: Request, res: Response): Promise => { + try { + const { externalDbConnectionId, tableName, locaKeys } = req.query; + + if (!externalDbConnectionId || !tableName || !locaKeys) { + return res.status(400).json({ + success: false, + message: "외부 DB 연결 ID, 테이블명, Location 키 목록이 필요합니다.", + }); + } + + const connector = await getExternalDbConnector(Number(externalDbConnectionId)); + + // locaKeys는 쉼표로 구분된 문자열 + const locaKeyArray = (locaKeys as string).split(","); + const quotedKeys = locaKeyArray.map((key) => `'${key}'`).join(","); + + const query = ` + SELECT + LOCAKEY, + COUNT(*) as material_count, + MAX(LOLAYER) as max_layer + FROM ${tableName} + WHERE LOCAKEY IN (${quotedKeys}) + GROUP BY LOCAKEY + `; + + const result = await connector.executeQuery(query); + + logger.info("자재 개수 조회", { + externalDbConnectionId, + tableName, + locaKeyCount: locaKeyArray.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, + }); + } +}; + diff --git a/backend-node/src/controllers/digitalTwinLayoutController.ts b/backend-node/src/controllers/digitalTwinLayoutController.ts new file mode 100644 index 00000000..9e66ecf2 --- /dev/null +++ b/backend-node/src/controllers/digitalTwinLayoutController.ts @@ -0,0 +1,386 @@ +import { Request, Response } from "express"; +import { pool } from "../database/db"; +import logger from "../utils/logger"; + +// 레이아웃 목록 조회 +export const getLayouts = async ( + req: Request, + res: Response +): Promise => { + try { + const companyCode = req.user?.companyCode; + const { externalDbConnectionId, warehouseKey } = req.query; + + let query = ` + SELECT + l.*, + u1.user_name as created_by_name, + u2.user_name as updated_by_name, + COUNT(o.id) as object_count + FROM digital_twin_layout l + LEFT JOIN user_info u1 ON l.created_by = u1.user_id + LEFT JOIN user_info u2 ON l.updated_by = u2.user_id + LEFT JOIN digital_twin_objects o ON l.id = o.layout_id + WHERE l.company_code = $1 + `; + + const params: any[] = [companyCode]; + let paramIndex = 2; + + if (externalDbConnectionId) { + query += ` AND l.external_db_connection_id = $${paramIndex}`; + params.push(externalDbConnectionId); + paramIndex++; + } + + if (warehouseKey) { + query += ` AND l.warehouse_key = $${paramIndex}`; + params.push(warehouseKey); + paramIndex++; + } + + query += ` + GROUP BY l.id, u1.user_name, u2.user_name + ORDER BY l.updated_at DESC + `; + + const result = await pool.query(query, params); + + logger.info("레이아웃 목록 조회", { + companyCode, + count: result.rowCount, + }); + + 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 getLayoutById = async ( + req: Request, + res: Response +): Promise => { + try { + const companyCode = req.user?.companyCode; + const { id } = req.params; + + // 레이아웃 기본 정보 + const layoutQuery = ` + SELECT l.* + FROM digital_twin_layout l + WHERE l.id = $1 AND l.company_code = $2 + `; + + const layoutResult = await pool.query(layoutQuery, [id, companyCode]); + + if (layoutResult.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "레이아웃을 찾을 수 없습니다.", + }); + } + + // 배치된 객체들 조회 + const objectsQuery = ` + SELECT * + FROM digital_twin_objects + WHERE layout_id = $1 + ORDER BY display_order, created_at + `; + + const objectsResult = await pool.query(objectsQuery, [id]); + + logger.info("레이아웃 상세 조회", { + companyCode, + layoutId: id, + objectCount: objectsResult.rowCount, + }); + + return res.json({ + success: true, + data: { + layout: layoutResult.rows[0], + objects: objectsResult.rows, + }, + }); + } catch (error: any) { + logger.error("레이아웃 상세 조회 실패", error); + return res.status(500).json({ + success: false, + message: "레이아웃 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; + +// 레이아웃 생성 +export const createLayout = async ( + req: Request, + res: Response +): Promise => { + const client = await pool.connect(); + + try { + const companyCode = req.user?.companyCode; + const userId = req.user?.userId; + const { + externalDbConnectionId, + warehouseKey, + layoutName, + description, + objects, + } = req.body; + + await client.query("BEGIN"); + + // 레이아웃 생성 + const layoutQuery = ` + INSERT INTO digital_twin_layout ( + company_code, external_db_connection_id, warehouse_key, + layout_name, description, created_by, updated_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $6) + RETURNING * + `; + + const layoutResult = await client.query(layoutQuery, [ + companyCode, + externalDbConnectionId, + warehouseKey, + layoutName, + description, + userId, + ]); + + const layoutId = layoutResult.rows[0].id; + + // 객체들 저장 + if (objects && objects.length > 0) { + const objectQuery = ` + INSERT INTO digital_twin_objects ( + layout_id, object_type, object_name, + position_x, position_y, position_z, + size_x, size_y, size_z, + rotation, color, + area_key, loca_key, loc_type, + material_count, material_preview_height, + parent_id, display_order, locked + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) + `; + + for (const obj of objects) { + await client.query(objectQuery, [ + layoutId, + 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, + obj.parentId || null, + obj.displayOrder || 0, + obj.locked || false, + ]); + } + } + + await client.query("COMMIT"); + + logger.info("레이아웃 생성", { + companyCode, + layoutId, + objectCount: objects?.length || 0, + }); + + return res.status(201).json({ + success: true, + data: layoutResult.rows[0], + }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("레이아웃 생성 실패", error); + return res.status(500).json({ + success: false, + message: "레이아웃 생성 중 오류가 발생했습니다.", + error: error.message, + }); + } finally { + client.release(); + } +}; + +// 레이아웃 수정 +export const updateLayout = async ( + req: Request, + res: Response +): Promise => { + const client = await pool.connect(); + + try { + const companyCode = req.user?.companyCode; + const userId = req.user?.userId; + const { id } = req.params; + const { layoutName, description, objects } = req.body; + + await client.query("BEGIN"); + + // 레이아웃 기본 정보 수정 + const updateLayoutQuery = ` + UPDATE digital_twin_layout + SET layout_name = $1, + description = $2, + updated_by = $3, + updated_at = NOW() + WHERE id = $4 AND company_code = $5 + RETURNING * + `; + + const layoutResult = await client.query(updateLayoutQuery, [ + layoutName, + description, + userId, + id, + companyCode, + ]); + + if (layoutResult.rowCount === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ + success: false, + message: "레이아웃을 찾을 수 없습니다.", + }); + } + + // 기존 객체 삭제 + await client.query( + "DELETE FROM digital_twin_objects WHERE layout_id = $1", + [id] + ); + + // 새 객체 저장 + if (objects && objects.length > 0) { + const objectQuery = ` + INSERT INTO digital_twin_objects ( + layout_id, object_type, object_name, + position_x, position_y, position_z, + size_x, size_y, size_z, + rotation, color, + area_key, loca_key, loc_type, + material_count, material_preview_height, + parent_id, display_order, locked + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) + `; + + for (const obj of objects) { + 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, + obj.parentId || null, + obj.displayOrder || 0, + obj.locked || false, + ]); + } + } + + await client.query("COMMIT"); + + logger.info("레이아웃 수정", { + companyCode, + layoutId: id, + objectCount: objects?.length || 0, + }); + + return res.json({ + success: true, + data: layoutResult.rows[0], + }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("레이아웃 수정 실패", error); + return res.status(500).json({ + success: false, + message: "레이아웃 수정 중 오류가 발생했습니다.", + error: error.message, + }); + } finally { + client.release(); + } +}; + +// 레이아웃 삭제 +export const deleteLayout = async ( + req: Request, + res: Response +): Promise => { + try { + const companyCode = req.user?.companyCode; + const { id } = req.params; + + const query = ` + DELETE FROM digital_twin_layout + WHERE id = $1 AND company_code = $2 + RETURNING id + `; + + const result = await pool.query(query, [id, companyCode]); + + if (result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "레이아웃을 찾을 수 없습니다.", + }); + } + + logger.info("레이아웃 삭제", { + companyCode, + layoutId: id, + }); + + return res.json({ + success: true, + message: "레이아웃이 삭제되었습니다.", + }); + } catch (error: any) { + logger.error("레이아웃 삭제 실패", error); + return res.status(500).json({ + success: false, + message: "레이아웃 삭제 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; diff --git a/backend-node/src/routes/digitalTwinRoutes.ts b/backend-node/src/routes/digitalTwinRoutes.ts new file mode 100644 index 00000000..3130b470 --- /dev/null +++ b/backend-node/src/routes/digitalTwinRoutes.ts @@ -0,0 +1,66 @@ +import express from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; + +// 레이아웃 관리 +import { + getLayouts, + getLayoutById, + createLayout, + updateLayout, + deleteLayout, +} from "../controllers/digitalTwinLayoutController"; + +// 외부 DB 데이터 조회 +import { + getWarehouses, + getAreas, + getLocations, + getMaterials, + getMaterialCounts, +} from "../controllers/digitalTwinDataController"; + +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); // 레이아웃 삭제 + +// ========== 외부 DB 데이터 조회 API ========== +router.get("/data/tables/:connectionId", async (req, res) => { + // 테이블 목록 조회 + try { + const { ExternalDbConnectionService } = await import("../services/externalDbConnectionService"); + const result = await ExternalDbConnectionService.getTablesFromConnection(Number(req.params.connectionId)); + return res.json(result); + } catch (error: any) { + return res.status(500).json({ success: false, error: error.message }); + } +}); + +router.get("/data/table-preview/:connectionId/:tableName", async (req, res) => { + // 테이블 미리보기 (10개 레코드) + try { + const { connectionId, tableName } = req.params; + const { getExternalDbConnector } = await import("../controllers/digitalTwinDataController"); + const connector = await getExternalDbConnector(Number(connectionId)); + const result = await connector.executeQuery(`SELECT * FROM ${tableName} LIMIT 10`); + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + return res.status(500).json({ success: false, error: error.message }); + } +}); + +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) + +export default router; + diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index c6429121..2bb85051 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -277,7 +277,7 @@ export function CanvasElement({ const autoScrollDirectionRef = useRef<"up" | "down" | null>(null); // 🔥 자동 스크롤 방향 const autoScrollFrameRef = useRef(null); // 🔥 requestAnimationFrame ID const lastMouseYRef = useRef(window.innerHeight / 2); // 🔥 마지막 마우스 Y 위치 (초기값: 화면 중간) - const [resizeStart, setResizeStart] = useState({ + const [resizeStart, setResizeStart] = useState({ x: 0, y: 0, width: 0, @@ -302,26 +302,26 @@ export function CanvasElement({ return; } - // 닫기 버튼이나 리사이즈 핸들 클릭 시 무시 + // 닫기 버튼이나 리사이즈 핸들 클릭 시 무시 if ((e.target as HTMLElement).closest(".element-close, .resize-handle")) { return; } // 위젯 내부 (헤더 제외) 클릭 시 드래그 무시 - 인터랙티브 사용 가능 if ((e.target as HTMLElement).closest(".widget-interactive-area")) { - return; - } + return; + } // 선택되지 않은 경우에만 선택 처리 if (!isSelected) { - onSelect(element.id); + onSelect(element.id); } - setIsDragging(true); + setIsDragging(true); const startPos = { - x: e.clientX, - y: e.clientY, - elementX: element.position.x, + x: e.clientX, + y: e.clientY, + elementX: element.position.x, elementY: element.position.y, initialScrollY: window.pageYOffset, // 🔥 드래그 시작 시점의 스크롤 위치 }; @@ -348,7 +348,7 @@ export function CanvasElement({ onMultiDragStart(element.id, offsets); } - e.preventDefault(); + e.preventDefault(); }, [ element.id, @@ -370,17 +370,17 @@ export function CanvasElement({ return; } - e.stopPropagation(); - setIsResizing(true); - setResizeStart({ - x: e.clientX, - y: e.clientY, - width: element.size.width, - height: element.size.height, - elementX: element.position.x, - elementY: element.position.y, + e.stopPropagation(); + setIsResizing(true); + setResizeStart({ + x: e.clientX, + y: e.clientY, + width: element.size.width, + height: element.size.height, + elementX: element.position.x, + elementY: element.position.y, handle, - }); + }); }, [element.size.width, element.size.height, element.position.x, element.position.y], ); @@ -388,7 +388,7 @@ export function CanvasElement({ // 마우스 이동 처리 (그리드 스냅 적용) const handleMouseMove = useCallback( (e: MouseEvent) => { - if (isDragging) { + if (isDragging) { // 🔥 자동 스크롤: 다중 선택 시 첫 번째 위젯에서만 처리 const isFirstSelectedElement = !selectedElements || selectedElements.length === 0 || selectedElements[0] === element.id; @@ -425,14 +425,14 @@ export function CanvasElement({ if (selectedElements.length > 1 && selectedElements.includes(element.id) && onMultiDragMove) { onMultiDragMove(element, { x: rawX, y: rawY }); } - } else if (isResizing) { - const deltaX = e.clientX - resizeStart.x; - const deltaY = e.clientY - resizeStart.y; - - let newWidth = resizeStart.width; - let newHeight = resizeStart.height; - let newX = resizeStart.elementX; - let newY = resizeStart.elementY; + } else if (isResizing) { + const deltaX = e.clientX - resizeStart.x; + const deltaY = e.clientY - resizeStart.y; + + let newWidth = resizeStart.width; + let newHeight = resizeStart.height; + let newX = resizeStart.elementX; + let newY = resizeStart.elementY; // 최소 크기 설정: 모든 위젯 1x1 const minWidthCells = 1; @@ -440,28 +440,28 @@ export function CanvasElement({ const minWidth = cellSize * minWidthCells; const minHeight = cellSize * minHeightCells; - switch (resizeStart.handle) { + switch (resizeStart.handle) { case "se": // 오른쪽 아래 newWidth = Math.max(minWidth, resizeStart.width + deltaX); newHeight = Math.max(minHeight, resizeStart.height + deltaY); - break; + break; case "sw": // 왼쪽 아래 newWidth = Math.max(minWidth, resizeStart.width - deltaX); newHeight = Math.max(minHeight, resizeStart.height + deltaY); - newX = resizeStart.elementX + deltaX; - break; + newX = resizeStart.elementX + deltaX; + break; case "ne": // 오른쪽 위 newWidth = Math.max(minWidth, resizeStart.width + deltaX); newHeight = Math.max(minHeight, resizeStart.height - deltaY); - newY = resizeStart.elementY + deltaY; - break; + newY = resizeStart.elementY + deltaY; + break; case "nw": // 왼쪽 위 newWidth = Math.max(minWidth, resizeStart.width - deltaX); newHeight = Math.max(minHeight, resizeStart.height - deltaY); - newX = resizeStart.elementX + deltaX; - newY = resizeStart.elementY + deltaY; - break; - } + newX = resizeStart.elementX + deltaX; + newY = resizeStart.elementY + deltaY; + break; + } // 가로 너비가 캔버스를 벗어나지 않도록 제한 const maxWidth = canvasWidth - newX; @@ -664,7 +664,7 @@ export function CanvasElement({ if (isDragging || isResizing) { document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); - + return () => { document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); @@ -685,7 +685,7 @@ export function CanvasElement({ // 필터 적용 (날짜 필터 등) const { applyQueryFilters } = await import("./utils/queryHelpers"); const filteredQuery = applyQueryFilters(element.dataSource.query, element.chartConfig); - + // 외부 DB vs 현재 DB 분기 if (element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId) { // 외부 DB @@ -709,13 +709,13 @@ export function CanvasElement({ // 현재 DB const { dashboardApi } = await import("@/lib/api/dashboard"); result = await dashboardApi.executeQuery(filteredQuery); - - setChartData({ - columns: result.columns || [], - rows: result.rows || [], - totalRows: result.rowCount || 0, + + setChartData({ + columns: result.columns || [], + rows: result.rows || [], + totalRows: result.rowCount || 0, executionTime: 0, - }); + }); } } catch (error) { // console.error("Chart data loading error:", error); @@ -818,55 +818,55 @@ export function CanvasElement({
{/* 차트 타입 전환 드롭다운 (차트일 경우만) */} {element.type === "chart" && ( - { + onUpdate(element.id, { subtype: newSubtype as ElementSubtype }); + }} > - - - e.stopPropagation()}> - {getChartCategory(element.subtype) === "axis-based" ? ( - - 축 기반 차트 - 바 차트 - 수평 바 차트 - 누적 바 차트 - 꺾은선 차트 - 영역 차트 - 콤보 차트 - - ) : ( - - 원형 차트 - 원형 차트 - 도넛 차트 - - )} - - - )} - {/* 제목 */} - {!element.type || element.type !== "chart" ? ( - element.subtype === "map-summary-v2" && !element.customTitle ? null : ( - {element.customTitle || element.title} - ) - ) : null} + e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + > + + + e.stopPropagation()}> + {getChartCategory(element.subtype) === "axis-based" ? ( + + 축 기반 차트 + 바 차트 + 수평 바 차트 + 누적 바 차트 + 꺾은선 차트 + 영역 차트 + 콤보 차트 + + ) : ( + + 원형 차트 + 원형 차트 + 도넛 차트 + + )} + + + )} + {/* 제목 */} + {!element.type || element.type !== "chart" ? ( + element.subtype === "map-summary-v2" && !element.customTitle ? null : ( + {element.customTitle || element.title} + ) + ) : null} +
-
)} {/* 삭제 버튼 - 항상 표시 (우측 상단 절대 위치) */}
+ {/* 레이아웃 선택 (야드 관리 3D 위젯 전용) */} + {element.subtype === "yard-management-3d" && ( +
+ +

표시할 디지털 트윈 레이아웃을 선택하세요

+
+

위젯 내부에서 레이아웃을 선택할 수 있습니다.

+

편집 모드에서 레이아웃 목록을 확인하고 선택하세요.

+
+
+ )} + {/* 자동 새로고침 설정 (지도 위젯 전용) */} {element.subtype === "map-summary-v2" && (
diff --git a/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx b/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx index c312b9f4..91f58650 100644 --- a/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx +++ b/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx @@ -7,7 +7,7 @@ import { Plus, Check, Trash2 } from "lucide-react"; import YardLayoutCreateModal from "./yard-3d/YardLayoutCreateModal"; import DigitalTwinEditor from "./yard-3d/DigitalTwinEditor"; import DigitalTwinViewer from "./yard-3d/DigitalTwinViewer"; -import { yardLayoutApi } from "@/lib/api/yardLayoutApi"; +import { getLayouts, createLayout, deleteLayout } from "@/lib/api/digitalTwin"; import type { YardManagementConfig } from "../types"; interface YardLayout { @@ -40,9 +40,16 @@ export default function YardManagement3DWidget({ const loadLayouts = async () => { try { setIsLoading(true); - const response = await yardLayoutApi.getAllLayouts(); - if (response.success) { - setLayouts(response.data as YardLayout[]); + const response = await getLayouts(); + if (response.success && response.data) { + setLayouts(response.data.map((layout: any) => ({ + id: layout.id, + name: layout.layout_name, + description: layout.description || "", + placement_count: layout.object_count || 0, + created_at: layout.created_at, + updated_at: layout.updated_at, + }))); } } catch (error) { console.error("야드 레이아웃 목록 조회 실패:", error); @@ -81,11 +88,21 @@ export default function YardManagement3DWidget({ // 새 레이아웃 생성 const handleCreateLayout = async (name: string) => { try { - const response = await yardLayoutApi.createLayout({ name }); - if (response.success) { + const response = await createLayout({ + layoutName: name, + description: "", + }); + if (response.success && response.data) { await loadLayouts(); setIsCreateModalOpen(false); - setEditingLayout(response.data as YardLayout); + setEditingLayout({ + id: response.data.id, + name: response.data.layout_name, + description: response.data.description || "", + placement_count: 0, + created_at: response.data.created_at, + updated_at: response.data.updated_at, + }); } } catch (error) { console.error("야드 레이아웃 생성 실패:", error); @@ -110,7 +127,7 @@ export default function YardManagement3DWidget({ if (!deleteLayoutId) return; try { - const response = await yardLayoutApi.deleteLayout(deleteLayoutId); + const response = await deleteLayout(deleteLayoutId); if (response.success) { // 삭제된 레이아웃이 현재 선택된 레이아웃이면 설정 초기화 if (config?.layoutId === deleteLayoutId && onConfigChange) { diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx index e51fe131..83c90e70 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx @@ -1,13 +1,49 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { Button } from "@/components/ui/button"; -import { ArrowLeft, Save, Loader2, Grid3x3, Combine, Move, Box, Package } from "lucide-react"; +import { ArrowLeft, Save, Loader2, Grid3x3, Move, Box, Package, Truck } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import dynamic from "next/dynamic"; import { useToast } from "@/hooks/use-toast"; +import type { PlacedObject, ToolType, Warehouse, Area, Location, ObjectType } from "@/types/digitalTwin"; +import { + getWarehouses, + getAreas, + getLocations, + getLayoutById, + updateLayout, + getMaterialCounts, + getMaterials, +} from "@/lib/api/digitalTwin"; +import type { MaterialData } from "@/types/digitalTwin"; +import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection"; + +// 백엔드 DB 객체 타입 (snake_case) +interface DbObject { + id: number; + object_type: ObjectType; + object_name: string; + position_x: string; + position_y: string; + position_z: string; + size_x: string; + size_y: string; + size_z: string; + rotation?: string; + color: string; + area_key?: string; + loca_key?: string; + loc_type?: string; + material_count?: number; + material_preview_height?: string; + parent_id?: number; + display_order?: number; + locked?: boolean; + visible?: boolean; +} const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), { ssr: false, @@ -24,88 +60,421 @@ interface DigitalTwinEditorProps { onBack: () => void; } -type ToolType = "yard" | "gantry-crane" | "mobile-crane" | "rack" | "plate-stack"; - -interface PlacedObject { - id: number; - type: ToolType; - name: string; - position: { x: number; y: number; z: number }; - size: { x: number; y: number; z: number }; - color: string; - // 데이터 바인딩 정보 - externalDbConnectionId?: number; - dataBindingConfig?: any; -} - export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: DigitalTwinEditorProps) { const { toast } = useToast(); const [placedObjects, setPlacedObjects] = useState([]); const [selectedObject, setSelectedObject] = useState(null); const [draggedTool, setDraggedTool] = useState(null); + const [draggedAreaData, setDraggedAreaData] = useState(null); // 드래그 중인 Area 정보 + const [draggedLocationData, setDraggedLocationData] = useState(null); // 드래그 중인 Location 정보 const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); - const [externalDbConnections, setExternalDbConnections] = useState([]); + const [externalDbConnections, setExternalDbConnections] = useState<{ id: number; name: string; db_type: string }[]>( + [], + ); const [selectedDbConnection, setSelectedDbConnection] = useState(null); + const [selectedWarehouse, setSelectedWarehouse] = useState(null); + const [warehouses, setWarehouses] = useState([]); + const [availableAreas, setAvailableAreas] = useState([]); + const [availableLocations, setAvailableLocations] = useState([]); const [nextObjectId, setNextObjectId] = useState(-1); + const [loadingWarehouses, setLoadingWarehouses] = useState(false); + const [loadingAreas, setLoadingAreas] = useState(false); + const [loadingLocations, setLoadingLocations] = useState(false); + const [materials, setMaterials] = useState([]); + const [loadingMaterials, setLoadingMaterials] = useState(false); + const [showMaterialPanel, setShowMaterialPanel] = useState(false); + + // 테이블 매핑 관련 상태 + const [availableTables, setAvailableTables] = useState([]); + const [loadingTables, setLoadingTables] = useState(false); + const [selectedTables, setSelectedTables] = useState({ + warehouse: "", + area: "", + location: "", + material: "", + }); + const [tableColumns, setTableColumns] = useState<{ [key: string]: string[] }>({}); + const [selectedColumns, setSelectedColumns] = useState({ + warehouseKey: "WAREKEY", + warehouseName: "WARENAME", + areaKey: "AREAKEY", + areaName: "AREANAME", + locationKey: "LOCAKEY", + locationName: "LOCANAME", + materialKey: "STKKEY", + }); + + // placedObjects를 YardPlacement 형식으로 변환 (useMemo로 최적화) + const placements = useMemo(() => { + const now = new Date().toISOString(); // 한 번만 생성 + return placedObjects.map((obj) => ({ + id: obj.id, + yard_layout_id: layoutId, + material_code: null, + material_name: obj.name, + name: obj.name, // 객체 이름 (야드 이름 표시용) + quantity: null, + unit: null, + 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, + data_source_config: null, + data_binding: null, + created_at: now, // 고정된 값 사용 + updated_at: now, // 고정된 값 사용 + material_count: obj.materialCount, + material_preview_height: obj.materialPreview?.height, + })); + }, [placedObjects, layoutId]); // 외부 DB 연결 목록 로드 useEffect(() => { const loadExternalDbConnections = async () => { try { - // TODO: 실제 API 호출 - // const response = await externalDbConnectionApi.getConnections({ is_active: 'Y' }); - - // 임시 데이터 - setExternalDbConnections([ - { id: 1, name: "DO_DY (동연 야드)", db_type: "mariadb" }, - { id: 2, name: "GY_YARD (광양 야드)", db_type: "mariadb" }, - ]); + const connections = await ExternalDbConnectionAPI.getConnections({ is_active: "Y" }); + console.log("🔍 외부 DB 연결 목록 (is_active=Y):", connections); + console.log("🔍 연결 ID들:", connections.map(c => c.id)); + setExternalDbConnections( + connections.map((conn) => ({ + id: conn.id!, + name: conn.connection_name, + db_type: conn.db_type, + })), + ); } catch (error) { console.error("외부 DB 연결 목록 조회 실패:", error); + toast({ + variant: "destructive", + title: "오류", + description: "외부 DB 연결 목록을 불러오는데 실패했습니다.", + }); } }; loadExternalDbConnections(); - }, []); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // 컴포넌트 마운트 시 한 번만 실행 + + // 외부 DB 선택 시 테이블 목록 로드 + useEffect(() => { + if (!selectedDbConnection) { + setAvailableTables([]); + setSelectedTables({ warehouse: "", area: "", location: "", material: "" }); + return; + } + + const loadTables = async () => { + try { + setLoadingTables(true); + const { getTables } = await import("@/lib/api/digitalTwin"); + const response = await getTables(selectedDbConnection); + if (response.success && response.data) { + const tableNames = response.data.map((t) => t.table_name); + setAvailableTables(tableNames); + console.log("📋 테이블 목록:", tableNames); + } + } catch (error) { + console.error("테이블 목록 조회 실패:", error); + toast({ + variant: "destructive", + title: "오류", + description: "테이블 목록을 불러오는데 실패했습니다.", + }); + } finally { + setLoadingTables(false); + } + }; + + loadTables(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedDbConnection]); + + // 테이블 컬럼 로드 + const loadColumnsForTable = async (tableName: string, type: "warehouse" | "area" | "location" | "material") => { + if (!selectedDbConnection || !tableName) return; + + try { + const { getTablePreview } = await import("@/lib/api/digitalTwin"); + const response = await getTablePreview(selectedDbConnection, tableName); + + console.log(`📊 ${type} 테이블 미리보기:`, response); + + if (response.success && response.data && response.data.length > 0) { + const columns = Object.keys(response.data[0]); + setTableColumns(prev => ({ ...prev, [type]: columns })); + + // 자동 매핑 시도 (기본값 설정) + if (type === "warehouse") { + const keyCol = columns.find(c => c.includes("KEY") || c.includes("ID")) || columns[0]; + const nameCol = columns.find(c => c.includes("NAME") || c.includes("NAM")) || columns[1] || columns[0]; + setSelectedColumns(prev => ({ ...prev, warehouseKey: keyCol, warehouseName: nameCol })); + } + } else { + console.warn(`⚠️ ${tableName} 테이블에 데이터가 없습니다.`); + toast({ + variant: "default", // destructive 대신 default로 변경 (단순 알림) + title: "데이터 없음", + description: `${tableName} 테이블에 데이터가 없습니다.`, + }); + } + } catch (error) { + console.error(`컬럼 로드 실패 (${tableName}):`, error); + } + }; + + // 외부 DB 선택 시 창고 목록 로드 (테이블이 선택되어 있을 때만) + useEffect(() => { + if (!selectedDbConnection || !selectedTables.warehouse) { + setWarehouses([]); + setSelectedWarehouse(null); + return; + } + + const loadWarehouses = async () => { + try { + setLoadingWarehouses(true); + const response = await getWarehouses(selectedDbConnection, selectedTables.warehouse); + console.log("📦 창고 API 응답:", response); + if (response.success && response.data) { + console.log("📦 창고 데이터:", response.data); + setWarehouses(response.data); + } else { + // 외부 DB 연결이 유효하지 않으면 선택 초기화 + console.warn("외부 DB 연결이 유효하지 않습니다:", selectedDbConnection); + setSelectedDbConnection(null); + toast({ + variant: "destructive", + title: "외부 DB 연결 오류", + description: "저장된 외부 DB 연결을 찾을 수 없습니다. 다시 선택해주세요.", + }); + } + } catch (error: any) { + console.error("창고 목록 조회 실패:", error); + // 외부 DB 연결이 존재하지 않으면 선택 초기화 + if (error.response?.status === 500 && error.response?.data?.error?.includes("연결 정보를 찾을 수 없습니다")) { + setSelectedDbConnection(null); + toast({ + variant: "destructive", + title: "외부 DB 연결 오류", + description: "저장된 외부 DB 연결을 찾을 수 없습니다. 다시 선택해주세요.", + }); + } else { + toast({ + variant: "destructive", + title: "오류", + description: "창고 목록을 불러오는데 실패했습니다.", + }); + } + } finally { + setLoadingWarehouses(false); + } + }; + + loadWarehouses(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedDbConnection, selectedTables.warehouse]); // toast 제거, warehouse 테이블 추가 + + // 창고 선택 시 Area 목록 로드 + useEffect(() => { + if (!selectedDbConnection || !selectedWarehouse) { + setAvailableAreas([]); + return; + } + + const loadAreas = async () => { + try { + setLoadingAreas(true); + const response = await getAreas(selectedDbConnection, selectedTables.area, selectedWarehouse); + if (response.success && response.data) { + setAvailableAreas(response.data); + } + } catch (error) { + console.error("Area 목록 조회 실패:", error); + toast({ + variant: "destructive", + title: "오류", + description: "Area 목록을 불러오는데 실패했습니다.", + }); + } finally { + setLoadingAreas(false); + } + }; + + loadAreas(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedDbConnection, selectedWarehouse, selectedTables.area]); // toast 제거, area 테이블 추가 // 레이아웃 데이터 로드 + const [layoutData, setLayoutData] = useState<{ layout: any; objects: any[] } | null>(null); + useEffect(() => { const loadLayout = async () => { try { setIsLoading(true); - // TODO: 실제 API 호출 - // const response = await digitalTwinApi.getLayout(layoutId); + const response = await getLayoutById(layoutId); - // 임시 데이터 - setPlacedObjects([]); + if (response.success && response.data) { + const { layout, objects } = response.data; + setLayoutData({ layout, objects }); // 레이아웃 데이터 저장 + + // 객체 데이터 변환 (DB -> PlacedObject) + const loadedObjects: PlacedObject[] = (objects as unknown as DbObject[]).map((obj) => ({ + 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, + })); + + setPlacedObjects(loadedObjects); + + // 다음 임시 ID 설정 (기존 ID 중 최소값 - 1) + const minId = Math.min(...loadedObjects.map((o) => o.id), 0); + setNextObjectId(minId - 1); + + setHasUnsavedChanges(false); + + toast({ + title: "레이아웃 불러오기 완료", + description: `${loadedObjects.length}개의 객체를 불러왔습니다.`, + }); + + // Location 객체들의 자재 개수 로드 + const locationObjects = loadedObjects.filter( + (obj) => + (obj.type === "location-bed" || + obj.type === "location-stp" || + obj.type === "location-temp" || + obj.type === "location-dest") && + obj.locaKey, + ); + if (locationObjects.length > 0) { + const locaKeys = locationObjects.map((obj) => obj.locaKey!); + setTimeout(() => { + loadMaterialCountsForLocations(locaKeys); + }, 100); + } + } else { + throw new Error(response.error || "레이아웃 조회 실패"); + } } catch (error) { console.error("레이아웃 로드 실패:", error); + const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다."; + toast({ + variant: "destructive", + title: "오류", + description: errorMessage, + }); } finally { setIsLoading(false); } }; loadLayout(); - }, [layoutId]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [layoutId]); // toast 제거 + + // 외부 DB 연결 자동 선택 (externalDbConnections 로드 완료 시) + useEffect(() => { + if (!layoutData || !layoutData.layout.externalDbConnectionId || externalDbConnections.length === 0) { + return; + } + + const layout = layoutData.layout; + console.log("🔍 외부 DB 연결 자동 선택 시도"); + console.log("🔍 레이아웃의 externalDbConnectionId:", layout.externalDbConnectionId); + console.log("🔍 사용 가능한 연결 목록:", externalDbConnections); + + const connectionExists = externalDbConnections.some( + (conn) => conn.id === layout.externalDbConnectionId, + ); + console.log("🔍 연결 존재 여부:", connectionExists); + + if (connectionExists) { + setSelectedDbConnection(layout.externalDbConnectionId); + if (layout.warehouseKey) { + setSelectedWarehouse(layout.warehouseKey); + } + console.log("✅ 외부 DB 연결 자동 선택 완료:", layout.externalDbConnectionId); + } else { + console.warn("⚠️ 저장된 외부 DB 연결을 찾을 수 없습니다:", layout.externalDbConnectionId); + console.warn("⚠️ 사용 가능한 연결 ID들:", externalDbConnections.map(c => c.id)); + toast({ + variant: "destructive", + title: "외부 DB 연결 오류", + description: "저장된 외부 DB 연결을 찾을 수 없습니다. 다시 선택해주세요.", + }); + } + }, [layoutData, externalDbConnections]); // layoutData와 externalDbConnections가 모두 준비되면 실행 // 도구 타입별 기본 설정 const getToolDefaults = (type: ToolType): Partial => { switch (type) { - case "yard": + case "area": return { name: "영역", size: { x: 20, y: 0.1, z: 20 }, // 4x4 칸 color: "#3b82f6", // 파란색 }; - case "gantry-crane": + case "location-bed": return { - name: "겐트리 크레인", - size: { x: 5, y: 8, z: 5 }, // 1x1 칸 - color: "#22c55e", // 녹색 + name: "베드(BED)", + size: { x: 5, y: 2, z: 5 }, // 1x1 칸 + color: "#10b981", // 에메랄드 }; - case "mobile-crane": + case "location-stp": + return { + name: "정차포인트(STP)", + size: { x: 5, y: 0.5, z: 5 }, // 1x1 칸, 낮은 높이 + color: "#f59e0b", // 주황색 + }; + case "location-temp": + return { + name: "임시베드(TMP)", + size: { x: 5, y: 2, z: 5 }, // 베드와 동일 + color: "#10b981", // 베드와 동일 + }; + case "location-dest": + return { + name: "지정착지(DES)", + size: { x: 5, y: 2, z: 5 }, // 베드와 동일 + color: "#10b981", // 베드와 동일 + }; + // case "crane-gantry": + // return { + // name: "겐트리 크레인", + // size: { x: 5, y: 8, z: 5 }, // 1x1 칸 + // color: "#22c55e", // 녹색 + // }; + case "crane-mobile": return { name: "크레인", size: { x: 5, y: 6, z: 5 }, // 1x1 칸 @@ -117,12 +486,12 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi size: { x: 5, y: 3, z: 5 }, // 1x1 칸 color: "#a855f7", // 보라색 }; - case "plate-stack": - return { - name: "후판 스택", - size: { x: 5, y: 2, z: 5 }, // 1x1 칸 - color: "#ef4444", // 빨간색 - }; + // case "material": + // return { + // name: "자재", + // size: { x: 5, y: 2, z: 5 }, // 1x1 칸 + // color: "#ef4444", // 빨간색 + // }; } }; @@ -137,16 +506,41 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi const defaults = getToolDefaults(draggedTool); - // 야드는 바닥(y=0.05)에, 다른 객체는 중앙 정렬 - const yPosition = draggedTool === "yard" ? 0.05 : (defaults.size?.y || 1) / 2; + // Area는 바닥(y=0.05)에, 다른 객체는 중앙 정렬 + const yPosition = draggedTool === "area" ? 0.05 : (defaults.size?.y || 1) / 2; + + // 외부 DB 데이터에서 드래그한 경우 해당 정보 사용 + let objectName = defaults.name || "새 객체"; + let areaKey: string | undefined = undefined; + let locaKey: string | undefined = undefined; + let locType: string | undefined = undefined; + + if (draggedTool === "area" && draggedAreaData) { + objectName = draggedAreaData.AREANAME; + areaKey = draggedAreaData.AREAKEY; + } else if ( + (draggedTool === "location-bed" || + draggedTool === "location-stp" || + draggedTool === "location-temp" || + draggedTool === "location-dest") && + draggedLocationData + ) { + objectName = draggedLocationData.LOCANAME || draggedLocationData.LOCAKEY; + areaKey = draggedLocationData.AREAKEY; + locaKey = draggedLocationData.LOCAKEY; + locType = draggedLocationData.LOCTYPE; + } const newObject: PlacedObject = { id: nextObjectId, type: draggedTool, - name: defaults.name || "새 객체", + name: objectName, position: { x, y: yPosition, z }, size: defaults.size || { x: 5, y: 5, z: 5 }, color: defaults.color || "#9ca3af", + areaKey, + locaKey, + locType, }; setPlacedObjects((prev) => [...prev, newObject]); @@ -154,17 +548,142 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi setNextObjectId((prev) => prev - 1); setHasUnsavedChanges(true); setDraggedTool(null); + setDraggedAreaData(null); + setDraggedLocationData(null); + + // Location 배치 시 자재 개수 로드 + if ( + (draggedTool === "location-bed" || + draggedTool === "location-stp" || + draggedTool === "location-temp" || + draggedTool === "location-dest") && + locaKey + ) { + // 새 객체 추가 후 자재 개수 로드 (약간의 딜레이를 두어 state 업데이트 완료 후 실행) + setTimeout(() => { + loadMaterialCountsForLocations(); + }, 100); + } + }; + + // Location의 자재 목록 로드 + const loadMaterialsForLocation = async (locaKey: string) => { + if (!selectedDbConnection) return; + + try { + setLoadingMaterials(true); + setShowMaterialPanel(true); + const response = await getMaterials(selectedDbConnection, selectedTables.material, locaKey); + if (response.success && response.data) { + // LOLAYER 순으로 정렬 + const sortedMaterials = response.data.sort((a, b) => a.LOLAYER - b.LOLAYER); + setMaterials(sortedMaterials); + } else { + setMaterials([]); + toast({ + variant: "destructive", + title: "자재 조회 실패", + description: response.error || "자재 목록을 불러올 수 없습니다.", + }); + } + } catch (error) { + console.error("자재 로드 실패:", error); + setMaterials([]); + toast({ + variant: "destructive", + title: "오류", + description: "자재 목록을 불러오는데 실패했습니다.", + }); + } finally { + setLoadingMaterials(false); + } }; // 객체 클릭 const handleObjectClick = (objectId: number | null) => { if (objectId === null) { setSelectedObject(null); + setShowMaterialPanel(false); return; } const obj = placedObjects.find((o) => o.id === objectId); setSelectedObject(obj || null); + + // Area를 클릭한 경우, 해당 Area의 Location 목록 로드 + if (obj && obj.type === "area" && obj.areaKey && selectedDbConnection) { + loadLocationsForArea(obj.areaKey); + setShowMaterialPanel(false); + } + // Location을 클릭한 경우, 해당 Location의 자재 목록 로드 + else if ( + obj && + (obj.type === "location-bed" || + obj.type === "location-stp" || + obj.type === "location-temp" || + obj.type === "location-dest") && + obj.locaKey && + selectedDbConnection + ) { + loadMaterialsForLocation(obj.locaKey); + } else { + setShowMaterialPanel(false); + } + }; + + // Location별 자재 개수 로드 (locaKeys를 직접 받음) + const loadMaterialCountsForLocations = async (locaKeys: string[]) => { + if (!selectedDbConnection || locaKeys.length === 0) return; + + try { + const response = await getMaterialCounts(selectedDbConnection, selectedTables.material, locaKeys); + if (response.success && response.data) { + // 각 Location 객체에 자재 개수 업데이트 + setPlacedObjects((prev) => + prev.map((obj) => { + const materialCount = response.data?.find((mc) => mc.LOCAKEY === obj.locaKey); + if (materialCount) { + return { + ...obj, + materialCount: materialCount.material_count, + materialPreview: { + height: materialCount.max_layer * 1.5, // 층당 1.5 높이 (시각적) + }, + }; + } + return obj; + }), + ); + } + } catch (error) { + console.error("자재 개수 로드 실패:", error); + } + }; + + // 특정 Area의 Location 목록 로드 + const loadLocationsForArea = async (areaKey: string) => { + if (!selectedDbConnection) return; + + try { + setLoadingLocations(true); + const response = await getLocations(selectedDbConnection, selectedTables.location, areaKey); + if (response.success && response.data) { + setAvailableLocations(response.data); + toast({ + title: "Location 로드 완료", + description: `${response.data.length}개 Location을 불러왔습니다.`, + }); + } + } catch (error) { + console.error("Location 목록 조회 실패:", error); + toast({ + variant: "destructive", + title: "오류", + description: "Location 목록을 불러오는데 실패했습니다.", + }); + } finally { + setLoadingLocations(false); + } }; // 객체 이동 @@ -217,8 +736,8 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi newSize.x = Math.max(5, Math.round(newSize.x / 5) * 5); newSize.z = Math.max(5, Math.round(newSize.z / 5) * 5); - // H는 자유롭게 (야드 제외) - if (actualObject.type !== "yard") { + // H는 자유롭게 (Area 제외) + if (actualObject.type !== "area") { newSize.y = Math.max(0.1, newSize.y); } @@ -270,25 +789,80 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi return; } + if (!selectedWarehouse) { + toast({ + title: "창고 선택 필요", + description: "창고를 선택하세요.", + variant: "destructive", + }); + return; + } + setIsSaving(true); try { - // TODO: 실제 API 호출 - // await digitalTwinApi.saveLayout(layoutId, { - // externalDbConnectionId: selectedDbConnection, - // objects: placedObjects, - // }); - - toast({ - title: "저장 완료", - description: "레이아웃이 성공적으로 저장되었습니다.", + const response = await updateLayout(layoutId, { + layoutName: layoutName, + description: undefined, + objects: placedObjects.map((obj, index) => ({ + ...obj, + displayOrder: index, // 현재 순서대로 저장 + })), }); - setHasUnsavedChanges(false); + if (response.success) { + toast({ + title: "저장 완료", + description: `${placedObjects.length}개의 객체가 저장되었습니다.`, + }); + + setHasUnsavedChanges(false); + + // 저장 후 DB에서 할당된 ID로 객체 업데이트 (필요 시) + // 현재는 updateLayout이 기존 객체 삭제 후 재생성하므로 + // 레이아웃 다시 불러오기 + const reloadResponse = await getLayoutById(layoutId); + if (reloadResponse.success && reloadResponse.data) { + const { objects } = reloadResponse.data; + const reloadedObjects: PlacedObject[] = (objects as unknown as DbObject[]).map((obj) => ({ + 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, + })); + + setPlacedObjects(reloadedObjects); + } + } else { + throw new Error(response.error || "레이아웃 저장 실패"); + } } catch (error) { console.error("저장 실패:", error); + const errorMessage = error instanceof Error ? error.message : "레이아웃 저장에 실패했습니다."; toast({ title: "저장 실패", - description: "레이아웃 저장에 실패했습니다.", + description: errorMessage, variant: "destructive", }); } finally { @@ -333,11 +907,12 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
도구: {[ - { type: "yard" as ToolType, label: "영역", icon: Grid3x3, color: "text-blue-500" }, - // { type: "gantry-crane" as ToolType, label: "겐트리", icon: Combine, color: "text-green-500" }, - { type: "mobile-crane" as ToolType, label: "크레인", icon: Move, color: "text-yellow-500" }, + { type: "area" as ToolType, label: "영역", icon: Grid3x3, color: "text-blue-500" }, + { type: "location-bed" as ToolType, label: "베드", icon: Package, color: "text-emerald-500" }, + { type: "location-stp" as ToolType, label: "정차", icon: Move, color: "text-orange-500" }, + // { type: "crane-gantry" as ToolType, label: "겐트리", icon: Combine, color: "text-green-500" }, + { type: "crane-mobile" as ToolType, label: "크레인", icon: Truck, color: "text-yellow-500" }, { type: "rack" as ToolType, label: "랙", icon: Box, color: "text-purple-500" }, - { type: "plate-stack" as ToolType, label: "후판", icon: Package, color: "text-red-500" }, ].map((tool) => { const Icon = tool.icon; return ( @@ -359,28 +934,301 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
{/* 좌측: 외부 DB 선택 + 객체 목록 */}
- {/* 외부 DB 선택 */} -
- - -

실시간 데이터를 가져올 데이터베이스를 선택하세요

+ {/* 스크롤 영역 */} +
+ {/* 외부 DB 선택 */} +
+ + +
+ + {/* 테이블 매핑 선택 */} + {selectedDbConnection && ( +
+ + {loadingTables ? ( +
+ +
+ ) : ( + <> +
+ + +
+ + {/* 창고 컬럼 매핑 */} + {selectedTables.warehouse && tableColumns.warehouse && ( +
+
+ + +
+
+ + +
+
+ )} + +
+ + +
+ +
+ + +
+ +
+ + +
+ + )} +
+ )} + + {/* 창고 선택 */} + {selectedDbConnection && selectedTables.warehouse && ( +
+ + {loadingWarehouses ? ( +
+ +
+ ) : ( + + )} +
+ )} + + {/* Area 목록 */} + {selectedDbConnection && selectedWarehouse && ( +
+
+

사용 가능한 Area

+ {loadingAreas && } +
+ + {availableAreas.length === 0 ? ( +

Area가 없습니다

+ ) : ( +
+ {availableAreas.map((area) => ( +
{ + // Area 정보를 임시 저장 + setDraggedTool("area"); + setDraggedAreaData(area); + }} + onDragEnd={() => { + setDraggedAreaData(null); + }} + className="bg-background hover:bg-accent cursor-grab rounded-lg border p-3 transition-all active:cursor-grabbing" + > +
+
+

{area.AREANAME}

+

{area.AREAKEY}

+
+ +
+
+ ))} +
+ )} +
+ )} + + {/* Location 목록 (Area 클릭 시 표시) */} + {availableLocations.length > 0 && ( +
+
+

사용 가능한 Location

+ {loadingLocations && } +
+ +
+ {availableLocations.map((location) => { + // Location 타입에 따라 ObjectType 결정 + let locationType: ToolType = "location-bed"; + if (location.LOCTYPE === "STP") { + locationType = "location-stp"; + } else if (location.LOCTYPE === "TMP") { + locationType = "location-temp"; + } else if (location.LOCTYPE === "DES") { + locationType = "location-dest"; + } + + return ( +
{ + // Location 정보를 임시 저장 + setDraggedTool(locationType); + setDraggedLocationData(location); + }} + onDragEnd={() => { + setDraggedLocationData(null); + }} + className="bg-background hover:bg-accent cursor-grab rounded-lg border p-3 transition-all active:cursor-grabbing" + > +
+
+

{location.LOCANAME || location.LOCAKEY}

+
+ {location.LOCAKEY} + {location.LOCTYPE} +
+
+ +
+
+ ); + })} +
+
+ )}
{/* 배치된 객체 목록 */} @@ -406,6 +1254,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi

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

+ {obj.areaKey &&

Area: {obj.areaKey}

}
))}
@@ -427,12 +1276,12 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi const gridSize = 5; // 그리드에 스냅 - // 야드(20x20)는 그리드 교차점에, 다른 객체(5x5)는 타일 중앙에 + // Area(20x20)는 그리드 교차점에, 다른 객체(5x5)는 타일 중앙에 let snappedX = Math.round(rawX / gridSize) * gridSize; let snappedZ = Math.round(rawZ / gridSize) * gridSize; - // 5x5 객체는 타일 중앙으로 오프셋 (야드는 제외) - if (draggedTool !== "yard") { + // 5x5 객체는 타일 중앙으로 오프셋 (Area는 제외) + if (draggedTool !== "area") { snappedX += gridSize / 2; snappedZ += gridSize / 2; } @@ -446,27 +1295,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
) : ( ({ - id: obj.id, - yard_layout_id: layoutId, - material_code: null, - material_name: obj.name, - name: obj.name, // 객체 이름 (야드 이름 표시용) - quantity: null, - unit: null, - 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, - data_source_config: null, - data_binding: null, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }))} + placements={placements} selectedPlacementId={selectedObject?.id || null} onPlacementClick={(placement) => handleObjectClick(placement?.id || null)} onPlacementDrag={(id, position) => handleObjectMove(id, position.x, position.z, position.y)} @@ -476,9 +1305,77 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi )}
- {/* 우측: 객체 속성 편집 */} + {/* 우측: 객체 속성 편집 or 자재 목록 */}
- {selectedObject ? ( + {showMaterialPanel && selectedObject ? ( + /* 자재 목록 패널 */ +
+
+
+

자재 목록

+

+ {selectedObject.name} ({selectedObject.locaKey}) +

+
+ +
+ + {loadingMaterials ? ( +
+ +
+ ) : materials.length === 0 ? ( +
+ 자재가 없습니다 +
+ ) : ( +
+ {materials.map((material, index) => ( +
+
+
+

{material.STKKEY}

+

+ 층: {material.LOLAYER} | Area: {material.AREAKEY} +

+
+
+
+ {material.STKWIDT && ( +
+ 폭: {material.STKWIDT} +
+ )} + {material.STKLENG && ( +
+ 길이: {material.STKLENG} +
+ )} + {material.STKHEIG && ( +
+ 높이: {material.STKHEIG} +
+ )} + {material.STKWEIG && ( +
+ 무게: {material.STKWEIG} +
+ )} +
+ {material.STKRMKS && ( +

{material.STKRMKS}

+ )} +
+ ))} +
+ )} +
+ ) : selectedObject ? (

객체 속성

diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx index 6520ea29..d8162e31 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx @@ -1,16 +1,21 @@ "use client"; import { useState, useEffect, useMemo } from "react"; -import { Search } from "lucide-react"; +import { Loader2, Search, Filter, X } from "lucide-react"; import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import dynamic from "next/dynamic"; -import { Loader2 } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; +import type { PlacedObject, MaterialData } from "@/types/digitalTwin"; +import { getLayoutById, getMaterials } from "@/lib/api/digitalTwin"; const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), { ssr: false, loading: () => ( -
- +
+
), }); @@ -19,292 +24,478 @@ interface DigitalTwinViewerProps { layoutId: number; } -// 임시 타입 정의 -interface Material { - id: number; - plate_no: string; // 후판번호 - steel_grade: string; // 강종 - thickness: number; // 두께 - width: number; // 폭 - length: number; // 길이 - weight: number; // 중량 - location: string; // 위치 - status: string; // 상태 - arrival_date: string; // 입고일자 -} - export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) { - const [searchTerm, setSearchTerm] = useState(""); - const [selectedYard, setSelectedYard] = useState("all"); - const [selectedStatus, setSelectedStatus] = useState("all"); - const [dateRange, setDateRange] = useState({ from: "", to: "" }); - const [selectedMaterial, setSelectedMaterial] = useState(null); - const [materials, setMaterials] = useState([]); + const { toast } = useToast(); + const [placedObjects, setPlacedObjects] = useState([]); + const [selectedObject, setSelectedObject] = useState(null); const [isLoading, setIsLoading] = useState(true); + const [materials, setMaterials] = useState([]); + const [loadingMaterials, setLoadingMaterials] = useState(false); + const [showInfoPanel, setShowInfoPanel] = useState(false); + const [externalDbConnectionId, setExternalDbConnectionId] = useState(null); + const [layoutName, setLayoutName] = useState(""); + + // 검색 및 필터 + const [searchQuery, setSearchQuery] = useState(""); + const [filterType, setFilterType] = useState("all"); // 레이아웃 데이터 로드 useEffect(() => { - const loadData = async () => { + const loadLayout = async () => { try { setIsLoading(true); - // TODO: 실제 API 호출 - // const response = await digitalTwinApi.getLayoutData(layoutId); - - // 임시 데이터 - setMaterials([ - { - id: 1, - plate_no: "P-2024-001", - steel_grade: "SM490A", - thickness: 25, - width: 2000, - length: 6000, - weight: 2355, - location: "A동-101", - status: "입고", - arrival_date: "2024-11-15", - }, - { - id: 2, - plate_no: "P-2024-002", - steel_grade: "SS400", - thickness: 30, - width: 2500, - length: 8000, - weight: 4710, - location: "B동-205", - status: "가공중", - arrival_date: "2024-11-16", - }, - ]); + const response = await getLayoutById(layoutId); + + if (response.success && response.data) { + const { layout, objects } = response.data; + + // 레이아웃 정보 저장 + setLayoutName(layout.layoutName); + setExternalDbConnectionId(layout.externalDbConnectionId); + + // 객체 데이터 변환 + 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, + })); + + setPlacedObjects(loadedObjects); + } else { + throw new Error(response.error || "레이아웃 조회 실패"); + } } catch (error) { - console.error("디지털 트윈 데이터 로드 실패:", error); + console.error("레이아웃 로드 실패:", error); + const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다."; + toast({ + variant: "destructive", + title: "오류", + description: errorMessage, + }); } finally { setIsLoading(false); } }; - loadData(); - }, [layoutId]); + loadLayout(); + }, [layoutId, toast]); - // 필터링된 자재 목록 - const filteredMaterials = useMemo(() => { - return materials.filter((material) => { - // 검색어 필터 - if (searchTerm) { - const searchLower = searchTerm.toLowerCase(); - const matchSearch = - material.plate_no.toLowerCase().includes(searchLower) || - material.steel_grade.toLowerCase().includes(searchLower) || - material.location.toLowerCase().includes(searchLower); - if (!matchSearch) return false; + // Location의 자재 목록 로드 + const loadMaterialsForLocation = async (locaKey: string, externalDbConnectionId: number) => { + try { + setLoadingMaterials(true); + setShowInfoPanel(true); + const response = await getMaterials(externalDbConnectionId, locaKey); + if (response.success && response.data) { + const sortedMaterials = response.data.sort((a, b) => a.LOLAYER - b.LOLAYER); + setMaterials(sortedMaterials); + } else { + setMaterials([]); } + } catch (error) { + console.error("자재 로드 실패:", error); + setMaterials([]); + } finally { + setLoadingMaterials(false); + } + }; - // 야드 필터 - if (selectedYard !== "all" && !material.location.startsWith(selectedYard)) { + // 객체 클릭 + const handleObjectClick = (objectId: number | null) => { + if (objectId === null) { + setSelectedObject(null); + setShowInfoPanel(false); + return; + } + + const obj = placedObjects.find((o) => o.id === objectId); + setSelectedObject(obj || null); + + // Location을 클릭한 경우, 자재 정보 표시 + if ( + obj && + (obj.type === "location-bed" || + obj.type === "location-stp" || + obj.type === "location-temp" || + obj.type === "location-dest") && + obj.locaKey && + externalDbConnectionId + ) { + setShowInfoPanel(true); + loadMaterialsForLocation(obj.locaKey, externalDbConnectionId); + } else { + setShowInfoPanel(true); + setMaterials([]); + } + }; + + // 타입별 개수 계산 (useMemo로 최적화) + const typeCounts = useMemo(() => { + const counts: Record = { + all: placedObjects.length, + area: 0, + "location-bed": 0, + "location-stp": 0, + "location-temp": 0, + "location-dest": 0, + "crane-mobile": 0, + rack: 0, + }; + + placedObjects.forEach((obj) => { + if (counts[obj.type] !== undefined) { + counts[obj.type]++; + } + }); + + return counts; + }, [placedObjects]); + + // 필터링된 객체 목록 (useMemo로 최적화) + const filteredObjects = useMemo(() => { + return placedObjects.filter((obj) => { + // 타입 필터 + if (filterType !== "all" && obj.type !== filterType) { return false; } - // 상태 필터 - if (selectedStatus !== "all" && material.status !== selectedStatus) { - return false; - } - - // 날짜 필터 - if (dateRange.from && material.arrival_date < dateRange.from) { - return false; - } - if (dateRange.to && material.arrival_date > dateRange.to) { - return false; + // 검색 쿼리 + if (searchQuery) { + const query = searchQuery.toLowerCase(); + return ( + obj.name.toLowerCase().includes(query) || + obj.areaKey?.toLowerCase().includes(query) || + obj.locaKey?.toLowerCase().includes(query) + ); } return true; }); - }, [materials, searchTerm, selectedYard, selectedStatus, dateRange]); + }, [placedObjects, filterType, searchQuery]); - // 3D 객체 클릭 핸들러 - const handleObjectClick = (objectId: number) => { - const material = materials.find((m) => m.id === objectId); - setSelectedMaterial(material || null); - }; + if (isLoading) { + return ( +
+ +
+ ); + } return ( -
- {/* 좌측: 필터 패널 */} -
- {/* 검색바 */} -
-
- - setSearchTerm(e.target.value)} - placeholder="후판번호, 강종, 위치 검색..." - className="h-10 pl-10 text-sm" - /> -
+
+ {/* 상단 헤더 */} +
+
+

{layoutName || "디지털 트윈 야드"}

+

읽기 전용 뷰

+
- {/* 필터 옵션 */} -
-
- {/* 야드 선택 */} + {/* 메인 영역 */} +
+ {/* 좌측: 검색/필터 */} +
+
+ {/* 검색 */}
-

야드

-
- {["all", "A동", "B동", "C동", "겐트리"].map((yard) => ( - - ))} + + + )}
- {/* 상태 필터 */} + {/* 타입 필터 */}
-

상태

-
- {["all", "입고", "가공중", "출고대기", "출고완료"].map((status) => ( - - ))} -
+ +
- {/* 기간 필터 */} -
-

입고 기간

+ {/* 필터 초기화 */} + {(searchQuery || filterType !== "all") && ( + + )} +
+ + {/* 객체 목록 */} +
+ + {filteredObjects.length === 0 ? ( +
+ {searchQuery ? "검색 결과가 없습니다" : "객체가 없습니다"} +
+ ) : (
- setDateRange((prev) => ({ ...prev, from: e.target.value }))} - className="h-9 text-sm" - placeholder="시작일" - /> - setDateRange((prev) => ({ ...prev, to: e.target.value }))} - className="h-9 text-sm" - placeholder="종료일" - /> -
-
-
-
-
+ {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 = "랙"; - {/* 중앙: 3D 캔버스 */} -
- {isLoading ? ( -
- -
- ) : ( - { - if (placement) { - handleObjectClick(placement.id); - } else { - setSelectedMaterial(null); - } - }} - onPlacementDrag={() => {}} // 뷰어 모드에서는 드래그 비활성화 - focusOnPlacementId={null} - onCollisionDetected={() => {}} - /> - )} -
- - {/* 우측: 상세정보 패널 (후판 목록 테이블) */} -
-
-

후판 목록

- - {filteredMaterials.length === 0 ? ( -
-

조건에 맞는 후판이 없습니다.

-
- ) : ( -
- {filteredMaterials.map((material) => ( -
setSelectedMaterial(material)} - className={`cursor-pointer rounded-lg border p-3 transition-all ${ - selectedMaterial?.id === material.id - ? "border-primary bg-primary/10" - : "border-border hover:border-primary/50" - }`} - > -
- {material.plate_no} - 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" }`} > - {material.status} - -
+
+
+

{obj.name}

+
+ + {typeLabel} +
+
+
+ + {/* 추가 정보 */} +
+ {obj.areaKey && ( +

+ Area: {obj.areaKey} +

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

+ Location: {obj.locaKey} +

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

+ 자재: {obj.materialCount}개 +

+ )} +
+
+ ); + })} +
+ )} +
+
-
-
- 강종: - {material.steel_grade} -
-
- 규격: - - {material.thickness}×{material.width}×{material.length} - -
-
- 중량: - {material.weight.toLocaleString()} kg -
-
- 위치: - {material.location} -
-
- 입고일: - {material.arrival_date} -
-
-
- ))} -
+ {/* 중앙: 3D 캔버스 */} +
+ {!isLoading && ( + + 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], + )} + selectedPlacementId={selectedObject?.id || null} + onPlacementClick={(placement) => handleObjectClick(placement?.id || null)} + focusOnPlacementId={null} + onCollisionDetected={() => {}} + /> )}
+ + {/* 우측: 정보 패널 */} + {showInfoPanel && selectedObject && ( +
+
+
+
+

상세 정보

+

{selectedObject.name}

+
+ +
+ + {/* 기본 정보 */} +
+
+ +

{selectedObject.type}

+
+ {selectedObject.areaKey && ( +
+ +

{selectedObject.areaKey}

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

{selectedObject.locaKey}

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

{selectedObject.materialCount}개

+
+ )} +
+ + {/* 자재 목록 (Location인 경우) */} + {(selectedObject.type === "location-bed" || + selectedObject.type === "location-stp" || + selectedObject.type === "location-temp" || + selectedObject.type === "location-dest") && ( +
+ + {loadingMaterials ? ( +
+ +
+ ) : materials.length === 0 ? ( +
+ {externalDbConnectionId + ? "자재가 없습니다" + : "외부 DB 연결이 설정되지 않았습니다"} +
+ ) : ( +
+ {materials.map((material, index) => ( +
+
+
+

{material.STKKEY}

+

+ 층: {material.LOLAYER} | Area: {material.AREAKEY} +

+
+
+
+ {material.STKWIDT && ( +
+ 폭: {material.STKWIDT} +
+ )} + {material.STKLENG && ( +
+ 길이: {material.STKLENG} +
+ )} + {material.STKHEIG && ( +
+ 높이: {material.STKHEIG} +
+ )} + {material.STKWEIG && ( +
+ 무게: {material.STKWEIG} +
+ )} +
+ {material.STKRMKS && ( +

{material.STKRMKS}

+ )} +
+ ))} +
+ )} +
+ )} +
+
+ )}
); } - diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx index 911afcb9..3de44b02 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx @@ -2,7 +2,7 @@ import { Canvas, useThree } from "@react-three/fiber"; import { OrbitControls, Grid, Box, Text } from "@react-three/drei"; -import { Suspense, useRef, useState, useEffect } from "react"; +import { Suspense, useRef, useState, useEffect, useMemo } from "react"; import * as THREE from "three"; interface YardPlacement { @@ -23,6 +23,8 @@ interface YardPlacement { data_source_type?: string | null; data_source_config?: any; data_binding?: any; + material_count?: number; // Location의 자재 개수 + material_preview_height?: number; // 자재 스택 높이 (시각적) } interface Yard3DCanvasProps { @@ -103,7 +105,7 @@ function MaterialBox({ if (!allPlacements || allPlacements.length === 0) { // 다른 객체가 없으면 기본 높이 const objectType = placement.data_source_type as string | null; - const defaultY = objectType === "yard" ? 0.05 : (placement.size_y || gridSize) / 2; + const defaultY = objectType === "area" ? 0.05 : (placement.size_y || gridSize) / 2; return { hasCollision: false, adjustedY: defaultY, @@ -122,11 +124,11 @@ function MaterialBox({ const myMaxZ = z + mySizeZ / 2; const objectType = placement.data_source_type as string | null; - const defaultY = objectType === "yard" ? 0.05 : mySizeY / 2; + const defaultY = objectType === "area" ? 0.05 : mySizeY / 2; let maxYBelow = defaultY; - // 야드는 스택되지 않음 (항상 바닥에 배치) - if (objectType === "yard") { + // Area는 스택되지 않음 (항상 바닥에 배치) + if (objectType === "area") { return { hasCollision: false, adjustedY: defaultY, @@ -385,8 +387,8 @@ function MaterialBox({ // 타입별 렌더링 const renderObjectByType = () => { switch (objectType) { - case "yard": - // 야드: 투명한 바닥 + 두꺼운 외곽선 (mesh) + 이름 텍스트 + case "area": + // Area: 투명한 바닥 + 두꺼운 외곽선 (mesh) + 이름 텍스트 const borderThickness = 0.3; // 외곽선 두께 return ( <> @@ -440,7 +442,7 @@ function MaterialBox({ )} - {/* 야드 이름 텍스트 */} + {/* Area 이름 텍스트 */} {placement.name && ( ); + case "location-bed": + case "location-temp": + case "location-dest": + // 베드 타입 Location: 초록색 상자 + return ( + <> + + + + + {/* 대표 자재 스택 (자재가 있을 때만) */} + {placement.material_count !== undefined && + placement.material_count > 0 && + placement.material_preview_height && ( + + + + )} + + {/* Location 이름 */} + {placement.name && ( + + {placement.name} + + )} + + {/* 자재 개수 */} + {placement.material_count !== undefined && placement.material_count > 0 && ( + + {`자재: ${placement.material_count}개`} + + )} + + ); + + case "location-stp": + // 정차포인트(STP): 주황색 낮은 플랫폼 + return ( + <> + + + + + {/* Location 이름 */} + {placement.name && ( + + {placement.name} + + )} + + {/* 자재 개수 (STP는 정차포인트라 자재가 없을 수 있음) */} + {placement.material_count !== undefined && placement.material_count > 0 && ( + + {`자재: ${placement.material_count}개`} + + )} + + ); + // case "gantry-crane": // // 겐트리 크레인: 기둥 2개 + 상단 빔 // return ( @@ -505,7 +625,7 @@ function MaterialBox({ // // ); - case "mobile-crane": + case "crane-mobile": // 이동식 크레인: 하부(트랙) + 회전대 + 캐빈 + 붐대 + 카운터웨이트 + 후크 return ( diff --git a/frontend/lib/api/digitalTwin.ts b/frontend/lib/api/digitalTwin.ts new file mode 100644 index 00000000..b58b0206 --- /dev/null +++ b/frontend/lib/api/digitalTwin.ts @@ -0,0 +1,215 @@ +import { apiClient } from "./client"; +import type { + DigitalTwinLayout, + DigitalTwinLayoutDetail, + CreateLayoutRequest, + UpdateLayoutRequest, + Warehouse, + Area, + Location, + MaterialData, + MaterialCount, +} from "@/types/digitalTwin"; + +// API 응답 타입 +interface ApiResponse { + success: boolean; + data?: T; + message?: string; + error?: string; +} + +// ========== 레이아웃 관리 API ========== + +// 레이아웃 목록 조회 +export const getLayouts = async (params?: { + externalDbConnectionId?: number; + warehouseKey?: string; +}): Promise> => { + try { + const response = await apiClient.get("/digital-twin/layouts", { params }); + return response.data; + } catch (error: any) { + return { + success: false, + error: error.response?.data?.message || error.message, + }; + } +}; + +// 레이아웃 상세 조회 +export const getLayoutById = async (id: number): Promise> => { + try { + const response = await apiClient.get(`/digital-twin/layouts/${id}`); + return response.data; + } catch (error: any) { + return { + success: false, + error: error.response?.data?.message || error.message, + }; + } +}; + +// 레이아웃 생성 +export const createLayout = async (data: CreateLayoutRequest): Promise> => { + try { + const response = await apiClient.post("/digital-twin/layouts", data); + return response.data; + } catch (error: any) { + return { + success: false, + error: error.response?.data?.message || error.message, + }; + } +}; + +// 레이아웃 수정 +export const updateLayout = async (id: number, data: UpdateLayoutRequest): Promise> => { + try { + const response = await apiClient.put(`/digital-twin/layouts/${id}`, data); + return response.data; + } catch (error: any) { + return { + success: false, + error: error.response?.data?.message || error.message, + }; + } +}; + +// 레이아웃 삭제 +export const deleteLayout = async (id: number): Promise> => { + try { + const response = await apiClient.delete(`/digital-twin/layouts/${id}`); + return response.data; + } catch (error: any) { + return { + success: false, + error: error.response?.data?.message || error.message, + }; + } +}; + +// ========== 외부 DB 테이블 조회 API ========== + +export const getTables = async ( + connectionId: number +): Promise>> => { + try { + const response = await apiClient.get(`/digital-twin/data/tables/${connectionId}`); + return response.data; + } catch (error: any) { + return { + success: false, + error: error.response?.data?.message || error.message, + }; + } +}; + +export const getTablePreview = async ( + connectionId: number, + tableName: string +): Promise> => { + try { + const response = await apiClient.get(`/digital-twin/data/table-preview/${connectionId}/${tableName}`); + return response.data; + } catch (error: any) { + return { + success: false, + error: error.response?.data?.message || error.message, + }; + } +}; + +// ========== 외부 DB 데이터 조회 API ========== + +// 창고 목록 조회 +export const getWarehouses = async (externalDbConnectionId: number, tableName: string): Promise> => { + try { + const response = await apiClient.get("/digital-twin/data/warehouses", { + params: { externalDbConnectionId, tableName }, + }); + return response.data; + } catch (error: any) { + return { + success: false, + error: error.response?.data?.message || error.message, + }; + } +}; + +// Area 목록 조회 +export const getAreas = async (externalDbConnectionId: number, tableName: string, warehouseKey: string): Promise> => { + try { + const response = await apiClient.get("/digital-twin/data/areas", { + params: { externalDbConnectionId, tableName, warehouseKey }, + }); + return response.data; + } catch (error: any) { + return { + success: false, + error: error.response?.data?.message || error.message, + }; + } +}; + +// Location 목록 조회 +export const getLocations = async ( + externalDbConnectionId: number, + tableName: string, + areaKey: string, +): Promise> => { + try { + const response = await apiClient.get("/digital-twin/data/locations", { + params: { externalDbConnectionId, tableName, areaKey }, + }); + return response.data; + } catch (error: any) { + return { + success: false, + error: error.response?.data?.message || error.message, + }; + } +}; + +// 자재 목록 조회 (특정 Location) +export const getMaterials = async ( + externalDbConnectionId: number, + tableName: string, + locaKey: string, +): Promise> => { + try { + const response = await apiClient.get("/digital-twin/data/materials", { + params: { externalDbConnectionId, tableName, locaKey }, + }); + return response.data; + } catch (error: any) { + return { + success: false, + error: error.response?.data?.message || error.message, + }; + } +}; + +// 자재 개수 조회 (여러 Location) +export const getMaterialCounts = async ( + externalDbConnectionId: number, + tableName: string, + locaKeys: string[], +): Promise> => { + try { + const response = await apiClient.get("/digital-twin/data/material-counts", { + params: { + externalDbConnectionId, + tableName, + locaKeys: locaKeys.join(","), + }, + }); + return response.data; + } catch (error: any) { + return { + success: false, + error: error.response?.data?.message || error.message, + }; + } +}; + diff --git a/frontend/types/digitalTwin.ts b/frontend/types/digitalTwin.ts new file mode 100644 index 00000000..b8a3bc1e --- /dev/null +++ b/frontend/types/digitalTwin.ts @@ -0,0 +1,155 @@ +// Digital Twin 관련 타입 정의 + +// 객체 타입 +export type ObjectType = + | "area" // Area (A동, B동, C동, 겐트리) + | "location-bed" // BED (사각형, 파란색, 상자) + | "location-stp" // STP (원형, 회색, 주차) + | "location-temp" // 임시베드 (BED와 동일) + | "location-dest" // 지정착지 (BED와 동일) + | "crane-mobile" // 모바일 크레인 (참고용) + | "rack"; // 랙 (참고용) + +// 3D 위치 +export interface Position3D { + x: number; + y: number; + z: number; +} + +// 3D 크기 +export interface Size3D { + x: number; + y: number; + z: number; +} + +// 자재 미리보기 정보 +export interface MaterialPreview { + height: number; // 스택 높이 (LOLAYER 기반) + topMaterial?: MaterialData; // 최상단 자재 정보 (선택사항) +} + +// 자재 데이터 (WSTKKY) +export interface MaterialData { + STKKEY: string; // 자재 키 + LOCAKEY: string; // Location 키 + AREAKEY: string; // Area 키 + LOLAYER: number; // 층 (스택 순서) + STKWIDT?: number; // 폭 + STKLENG?: number; // 길이 + STKHEIG?: number; // 높이 + STKWEIG?: number; // 무게 + STKQTY?: number; // 수량 + STKSTAT?: string; // 상태 + STKREDT?: string; // 등록일 + STKRMKS?: string; // 비고 +} + +// 배치된 객체 +export interface PlacedObject { + id: number; // 로컬 ID (음수: 임시, 양수: DB 저장됨) + type: ObjectType; + name: string; + position: Position3D; + size: Size3D; + rotation?: number; // 회전 각도 (라디안) + color: string; + + // 외부 DB 연동 + areaKey?: string; // MAREMA.AREAKEY + locaKey?: string; // MLOCMA.LOCAKEY + locType?: string; // MLOCMA.LOCTYPE (BED, STP, TMP, DES) + + // 자재 정보 + materialCount?: number; // 자재 개수 + materialPreview?: MaterialPreview; // 자재 미리보기 + + // 계층 구조 + parentId?: number; + displayOrder?: number; + + // 편집 제한 + locked?: boolean; // true면 이동/크기조절 불가 + visible?: boolean; +} + +// 레이아웃 +export interface DigitalTwinLayout { + id: number; + companyCode: string; + externalDbConnectionId: number; + warehouseKey: string; // WAREKEY (예: DY99) + layoutName: string; + description?: string; + isActive: boolean; + createdBy?: number; + updatedBy?: number; + createdAt: string; + updatedAt: string; + + // 통계 (조회 시만) + objectCount?: number; +} + +// 레이아웃 상세 (객체 포함) +export interface DigitalTwinLayoutDetail { + layout: DigitalTwinLayout; + objects: PlacedObject[]; +} + +// 창고 (MWAREMA) +export interface Warehouse { + WAREKEY: string; + WARENAME: string; + WARETYPE?: string; + WARESTAT?: string; +} + +// Area (MAREMA) +export interface Area { + AREAKEY: string; + AREANAME: string; + AREATYP?: string; // 내부/외부 + WAREKEY: string; + AREASTAT?: string; +} + +// Location (MLOCMA) +export interface Location { + LOCAKEY: string; + LOCANAME: string; + LOCTYPE: string; // BED, STP, TMP, DES + AREAKEY: string; + LOCWIDT?: number; // 폭 (현재 데이터는 0) + LOCLENG?: number; // 길이 (현재 데이터는 0) + LOCHEIG?: number; // 높이 (현재 데이터는 0) + LOCCUBI?: number; // 용적 (현재 데이터는 0) + LOCSTAT?: string; +} + +// 자재 개수 (배치 시 사용) +export interface MaterialCount { + LOCAKEY: string; + material_count: number; + max_layer: number; +} + +// API 요청/응답 타입 +export interface CreateLayoutRequest { + externalDbConnectionId: number; + warehouseKey: string; + layoutName: string; + description?: string; + objects: PlacedObject[]; +} + +export interface UpdateLayoutRequest { + layoutName: string; + description?: string; + objects: PlacedObject[]; +} + +// 도구 타입 (UI용) +export type ToolType = ObjectType; + -- 2.43.0