From 0b5b14062533699c6c79074dfbc0aa244bb0de5d Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 15 Oct 2025 17:02:06 +0900 Subject: [PATCH 01/28] =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EC=9C=84=EC=A0=AF=20=EB=A0=8C=EB=8D=94=EB=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20=EC=99=B8=EB=B6=80=20API=20=ED=82=A4=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DashboardViewer에 ListSummaryWidget 연결 - list 위젯이 실제 DB 데이터 표시하도록 수정 - ITS_API_KEY (국토교통부 교통사고 API) 추가 - KMA_API_KEY (기상청 특보 API) 재적용 - dashboard.ts API URL 수정 (/api로 통일) --- backend-node/src/services/DashboardService.ts | 7 +++++++ docker/dev/docker-compose.backend.mac.yml | 2 +- frontend/components/dashboard/DashboardViewer.tsx | 9 ++++++++- frontend/lib/api/dashboard.ts | 2 +- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/backend-node/src/services/DashboardService.ts b/backend-node/src/services/DashboardService.ts index fa0ce775..6b709235 100644 --- a/backend-node/src/services/DashboardService.ts +++ b/backend-node/src/services/DashboardService.ts @@ -300,6 +300,11 @@ export class DashboardService { const elementsResult = await PostgreSQLService.query(elementsQuery, [dashboardId]); // 3. 요소 데이터 변환 + console.log('📊 대시보드 요소 개수:', elementsResult.rows.length); + if (elementsResult.rows.length > 0) { + console.log('📊 첫 번째 요소 raw data:', elementsResult.rows[0]); + } + const elements: DashboardElement[] = elementsResult.rows.map((row: any) => ({ id: row.id, type: row.element_type, @@ -318,6 +323,8 @@ export class DashboardService { chartConfig: JSON.parse(row.chart_config || '{}') })); + console.log('📊 변환된 첫 번째 요소:', elements[0]); + return { id: dashboard.id, title: dashboard.title, diff --git a/docker/dev/docker-compose.backend.mac.yml b/docker/dev/docker-compose.backend.mac.yml index b9675147..4d862d9e 100644 --- a/docker/dev/docker-compose.backend.mac.yml +++ b/docker/dev/docker-compose.backend.mac.yml @@ -20,7 +20,7 @@ services: - LOG_LEVEL=debug - ENCRYPTION_KEY=ilshin-plm-mail-encryption-key-32characters-2024-secure - KMA_API_KEY=ogdXr2e9T4iHV69nvV-IwA - - ITS_API_KEY=${ITS_API_KEY:-} + - ITS_API_KEY=d6b9befec3114d648284674b8fddcc32 - EXPRESSWAY_API_KEY=${EXPRESSWAY_API_KEY:-} volumes: - ../../backend-node:/app # 개발 모드: 코드 변경 시 자동 반영 diff --git a/frontend/components/dashboard/DashboardViewer.tsx b/frontend/components/dashboard/DashboardViewer.tsx index b8517d73..e94d6fe5 100644 --- a/frontend/components/dashboard/DashboardViewer.tsx +++ b/frontend/components/dashboard/DashboardViewer.tsx @@ -3,6 +3,10 @@ import React, { useState, useEffect, useCallback } from "react"; import { DashboardElement, QueryResult } from "@/components/admin/dashboard/types"; import { ChartRenderer } from "@/components/admin/dashboard/charts/ChartRenderer"; +import dynamic from "next/dynamic"; + +// 위젯 동적 import +const ListSummaryWidget = dynamic(() => import("./widgets/ListSummaryWidget"), { ssr: false }); interface DashboardViewerProps { elements: DashboardElement[]; @@ -198,8 +202,11 @@ function ViewerElement({ element, data, isLoading, onRefresh }: ViewerElementPro
{element.type === "chart" ? ( + ) : element.subtype === "list" ? ( + // 리스트 위젯 + ) : ( - // 위젯 렌더링 + // 기타 위젯 렌더링
diff --git a/frontend/lib/api/dashboard.ts b/frontend/lib/api/dashboard.ts index f6365854..d5a53136 100644 --- a/frontend/lib/api/dashboard.ts +++ b/frontend/lib/api/dashboard.ts @@ -5,7 +5,7 @@ import { DashboardElement } from "@/components/admin/dashboard/types"; // API 기본 설정 -const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/api"; +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "/api"; // 토큰 가져오기 (실제 인증 시스템에 맞게 수정) function getAuthToken(): string | null { From 1995adf2457f9f2820738e40860dbd91724b71e5 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 15 Oct 2025 17:14:42 +0900 Subject: [PATCH 02/28] =?UTF-8?q?=EC=A4=91=EA=B0=84=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/services/DashboardService.ts | 45 +++---- .../components/dashboard/DashboardViewer.tsx | 123 +++++++++++++++--- 2 files changed, 130 insertions(+), 38 deletions(-) diff --git a/backend-node/src/services/DashboardService.ts b/backend-node/src/services/DashboardService.ts index 6b709235..c25efe4f 100644 --- a/backend-node/src/services/DashboardService.ts +++ b/backend-node/src/services/DashboardService.ts @@ -301,29 +301,30 @@ export class DashboardService { // 3. 요소 데이터 변환 console.log('📊 대시보드 요소 개수:', elementsResult.rows.length); - if (elementsResult.rows.length > 0) { - console.log('📊 첫 번째 요소 raw data:', elementsResult.rows[0]); - } - const elements: DashboardElement[] = elementsResult.rows.map((row: any) => ({ - id: row.id, - type: row.element_type, - subtype: row.element_subtype, - position: { - x: row.position_x, - y: row.position_y - }, - size: { - width: row.width, - height: row.height - }, - title: row.title, - content: row.content, - dataSource: JSON.parse(row.data_source_config || '{}'), - chartConfig: JSON.parse(row.chart_config || '{}') - })); - - console.log('📊 변환된 첫 번째 요소:', elements[0]); + const elements: DashboardElement[] = elementsResult.rows.map((row: any, index: number) => { + const element = { + id: row.id, + type: row.element_type, + subtype: row.element_subtype, + position: { + x: row.position_x, + y: row.position_y + }, + size: { + width: row.width, + height: row.height + }, + title: row.title, + content: row.content, + dataSource: JSON.parse(row.data_source_config || '{}'), + chartConfig: JSON.parse(row.chart_config || '{}') + }; + + console.log(`📊 위젯 #${index + 1}: type="${element.type}", subtype="${element.subtype}", title="${element.title}"`); + + return element; + }); return { id: dashboard.id, diff --git a/frontend/components/dashboard/DashboardViewer.tsx b/frontend/components/dashboard/DashboardViewer.tsx index e94d6fe5..036a18ac 100644 --- a/frontend/components/dashboard/DashboardViewer.tsx +++ b/frontend/components/dashboard/DashboardViewer.tsx @@ -5,8 +5,113 @@ import { DashboardElement, QueryResult } from "@/components/admin/dashboard/type import { ChartRenderer } from "@/components/admin/dashboard/charts/ChartRenderer"; import dynamic from "next/dynamic"; -// 위젯 동적 import +// 위젯 동적 import - 모든 위젯 const ListSummaryWidget = dynamic(() => import("./widgets/ListSummaryWidget"), { ssr: false }); +const MapSummaryWidget = dynamic(() => import("./widgets/MapSummaryWidget"), { ssr: false }); +const StatusSummaryWidget = dynamic(() => import("./widgets/StatusSummaryWidget"), { ssr: false }); +const RiskAlertWidget = dynamic(() => import("./widgets/RiskAlertWidget"), { ssr: false }); +const WeatherWidget = dynamic(() => import("./widgets/WeatherWidget"), { ssr: false }); +const ExchangeWidget = dynamic(() => import("./widgets/ExchangeWidget"), { ssr: false }); +const VehicleStatusWidget = dynamic(() => import("./widgets/VehicleStatusWidget"), { ssr: false }); +const VehicleListWidget = dynamic(() => import("./widgets/VehicleListWidget"), { ssr: false }); +const VehicleMapOnlyWidget = dynamic(() => import("./widgets/VehicleMapOnlyWidget"), { ssr: false }); +const CargoListWidget = dynamic(() => import("./widgets/CargoListWidget"), { ssr: false }); +const CustomerIssuesWidget = dynamic(() => import("./widgets/CustomerIssuesWidget"), { ssr: false }); +const DeliveryStatusWidget = dynamic(() => import("./widgets/DeliveryStatusWidget"), { ssr: false }); +const DeliveryStatusSummaryWidget = dynamic(() => import("./widgets/DeliveryStatusSummaryWidget"), { ssr: false }); +const DeliveryTodayStatsWidget = dynamic(() => import("./widgets/DeliveryTodayStatsWidget"), { ssr: false }); +const TodoWidget = dynamic(() => import("./widgets/TodoWidget"), { ssr: false }); +const DocumentWidget = dynamic(() => import("./widgets/DocumentWidget"), { ssr: false }); +const BookingAlertWidget = dynamic(() => import("./widgets/BookingAlertWidget"), { ssr: false }); +const MaintenanceWidget = dynamic(() => import("./widgets/MaintenanceWidget"), { ssr: false }); +const CalculatorWidget = dynamic(() => import("./widgets/CalculatorWidget"), { ssr: false }); + +/** + * 위젯 렌더링 함수 - DashboardSidebar의 모든 subtype 처리 + * ViewerElement에서 사용하기 위해 컴포넌트 외부에 정의 + */ +function renderWidget(element: DashboardElement) { + switch (element.subtype) { + // 차트는 ChartRenderer에서 처리됨 (이 함수 호출 안됨) + + // === 위젯 종류 === + case "exchange": + return ; + case "weather": + return ; + case "calculator": + return ; + case "clock": + return ( +
+
+
+
시계 위젯 (개발 예정)
+
+
+ ); + case "map-summary": + return ; + case "list-summary": + return ; + case "risk-alert": + return ; + case "calendar": + return ( +
+
+
📅
+
달력 위젯 (개발 예정)
+
+
+ ); + case "status-summary": + return ; + + // === 운영/작업 지원 === + case "todo": + return ; + case "booking-alert": + return ; + case "maintenance": + return ; + case "document": + return ; + case "list": + return ; + + // === 차량 관련 (추가 위젯) === + case "vehicle-status": + return ; + case "vehicle-list": + return ; + case "vehicle-map": + return ; + + // === 배송 관련 (추가 위젯) === + case "delivery-status": + return ; + case "delivery-status-summary": + return ; + case "delivery-today-stats": + return ; + case "cargo-list": + return ; + case "customer-issues": + return ; + + // === 기본 fallback === + default: + return ( +
+
+
+
알 수 없는 위젯 타입: {element.subtype}
+
+
+ ); + } +} interface DashboardViewerProps { elements: DashboardElement[]; @@ -202,21 +307,7 @@ function ViewerElement({ element, data, isLoading, onRefresh }: ViewerElementPro
{element.type === "chart" ? ( - ) : element.subtype === "list" ? ( - // 리스트 위젯 - - ) : ( - // 기타 위젯 렌더링 -
-
-
- {element.subtype === "exchange" && "💱"} - {element.subtype === "weather" && "☁️"} -
-
{element.content}
-
-
- )} + ) : renderWidget(element)}
{/* 로딩 오버레이 */} From 4addf8dccf551c610f525ef9663c126a5b78c395 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 15 Oct 2025 17:32:09 +0900 Subject: [PATCH 03/28] =?UTF-8?q?=EC=9D=BC=EB=8B=A8=20=EC=9C=84=EC=A0=AF?= =?UTF-8?q?=EB=93=A4=EC=9D=80=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=EC=97=90=EC=84=9C=20=EB=B3=B4=EC=9E=85?= =?UTF-8?q?=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/dashboard/DashboardViewer.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/frontend/components/dashboard/DashboardViewer.tsx b/frontend/components/dashboard/DashboardViewer.tsx index 036a18ac..5598d943 100644 --- a/frontend/components/dashboard/DashboardViewer.tsx +++ b/frontend/components/dashboard/DashboardViewer.tsx @@ -25,6 +25,7 @@ const DocumentWidget = dynamic(() => import("./widgets/DocumentWidget"), { ssr: const BookingAlertWidget = dynamic(() => import("./widgets/BookingAlertWidget"), { ssr: false }); const MaintenanceWidget = dynamic(() => import("./widgets/MaintenanceWidget"), { ssr: false }); const CalculatorWidget = dynamic(() => import("./widgets/CalculatorWidget"), { ssr: false }); +const CalendarWidget = dynamic(() => import("@/components/admin/dashboard/widgets/CalendarWidget").then(mod => ({ default: mod.CalendarWidget })), { ssr: false }); /** * 위젯 렌더링 함수 - DashboardSidebar의 모든 subtype 처리 @@ -57,14 +58,7 @@ function renderWidget(element: DashboardElement) { case "risk-alert": return ; case "calendar": - return ( -
-
-
📅
-
달력 위젯 (개발 예정)
-
-
- ); + return ; case "status-summary": return ; From 7e38f82d0cd3eac07ec1e470bc46fcef4d52b00b Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 15 Oct 2025 18:25:16 +0900 Subject: [PATCH 04/28] =?UTF-8?q?=ED=95=84=EC=9A=94=EC=97=86=EB=8A=94=20?= =?UTF-8?q?=EC=A2=85=EB=A5=98=20=EC=A0=95=EB=A6=AC=20=EB=B0=8F=20=EB=8C=80?= =?UTF-8?q?=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EC=9D=B4=EC=A0=A0=20=EB=A0=8C?= =?UTF-8?q?=EB=8D=94=EB=A7=81=20=EB=90=98=EA=B3=A0=20=EB=AA=A8=EB=93=A0=20?= =?UTF-8?q?=EC=9C=84=EC=A0=AF=EB=93=A4=20=EC=9D=B4=EB=A6=84=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EA=B0=80=EB=8A=A5=ED=95=98=EA=B2=8C=20=ED=95=B4?= =?UTF-8?q?=EB=8B=AC=EB=9D=BC=EA=B3=A0=20=ED=96=88=EB=8A=94=EB=8D=B0=20?= =?UTF-8?q?=EC=A7=80=EA=B8=88=EC=9D=80=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=EB=B2=A0=EC=9D=B4=EC=8A=A4=20=EC=97=B0=EA=B2=B0=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EA=B2=83=EB=A7=8C=20=EC=9D=B4=EB=A6=84=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EC=9D=B4=20=EB=90=A9=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/DashboardSidebar.tsx | 8 +- .../admin/dashboard/ElementConfigModal.tsx | 96 +++++++++----- frontend/components/admin/dashboard/types.ts | 1 + .../components/dashboard/DashboardViewer.tsx | 16 +-- .../dashboard/widgets/BookingAlertWidget.tsx | 9 +- .../dashboard/widgets/CalculatorWidget.tsx | 9 +- .../dashboard/widgets/DocumentWidget.tsx | 122 +++++++++--------- .../dashboard/widgets/ExchangeWidget.tsx | 5 +- .../dashboard/widgets/MaintenanceWidget.tsx | 87 +++++++------ .../dashboard/widgets/MapSummaryWidget.tsx | 9 +- .../dashboard/widgets/RiskAlertWidget.tsx | 9 +- .../dashboard/widgets/StatusSummaryWidget.tsx | 3 +- .../dashboard/widgets/TodoWidget.tsx | 9 +- .../widgets/VehicleMapOnlyWidget.tsx | 6 +- .../dashboard/widgets/WeatherWidget.tsx | 8 +- 15 files changed, 229 insertions(+), 168 deletions(-) diff --git a/frontend/components/admin/dashboard/DashboardSidebar.tsx b/frontend/components/admin/dashboard/DashboardSidebar.tsx index cc34feb4..6c03b398 100644 --- a/frontend/components/admin/dashboard/DashboardSidebar.tsx +++ b/frontend/components/admin/dashboard/DashboardSidebar.tsx @@ -220,13 +220,7 @@ export function DashboardSidebar() { subtype="booking-alert" onDragStart={handleDragStart} /> - + {/* 정비 일정 관리 위젯 제거 - 커스텀 목록 카드로 대체 가능 */} (element.chartConfig || {}); const [queryResult, setQueryResult] = useState(null); const [currentStep, setCurrentStep] = useState<1 | 2>(1); + const [customTitle, setCustomTitle] = useState(element.customTitle || ""); // 차트 설정이 필요 없는 위젯 (쿼리/API만 필요) const isSimpleWidget = @@ -56,6 +57,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element setChartConfig(element.chartConfig || {}); setQueryResult(null); setCurrentStep(1); + setCustomTitle(element.customTitle || ""); } }, [isOpen, element]); @@ -119,13 +121,14 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element ...element, dataSource, chartConfig, + customTitle: customTitle.trim() || undefined, // 빈 문자열이면 undefined }; console.log(" 저장할 element:", updatedElement); onSave(updatedElement); onClose(); - }, [element, dataSource, chartConfig, onSave, onClose]); + }, [element, dataSource, chartConfig, customTitle, onSave, onClose]); // 모달이 열려있지 않으면 렌더링하지 않음 if (!isOpen) return null; @@ -147,28 +150,32 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element chartConfig.yAxis && (typeof chartConfig.yAxis === "string" || (Array.isArray(chartConfig.yAxis) && chartConfig.yAxis.length > 0)); - const canSave = isSimpleWidget - ? // 간단한 위젯: 2단계에서 쿼리 테스트 후 저장 가능 - currentStep === 2 && queryResult && queryResult.rows.length > 0 - : isMapWidget - ? // 지도 위젯: 위도/경도 매핑 필요 - currentStep === 2 && - queryResult && - queryResult.rows.length > 0 && - chartConfig.latitudeColumn && - chartConfig.longitudeColumn - : // 차트: 기존 로직 (2단계에서 차트 설정 필요) - currentStep === 2 && - queryResult && - queryResult.rows.length > 0 && - chartConfig.xAxis && - (isPieChart || isApiSource - ? // 파이/도넛 차트 또는 REST API - chartConfig.aggregation === "count" - ? true // count는 Y축 없어도 됨 - : hasYAxis // 다른 집계(sum, avg, max, min) 또는 집계 없음 → Y축 필수 - : // 일반 차트 (DB): Y축 필수 - hasYAxis); + // customTitle이 변경되었는지 확인 + const isTitleChanged = customTitle.trim() !== (element.customTitle || ""); + + const canSave = isTitleChanged || // 제목만 변경해도 저장 가능 + (isSimpleWidget + ? // 간단한 위젯: 2단계에서 쿼리 테스트 후 저장 가능 + currentStep === 2 && queryResult && queryResult.rows.length > 0 + : isMapWidget + ? // 지도 위젯: 위도/경도 매핑 필요 + currentStep === 2 && + queryResult && + queryResult.rows.length > 0 && + chartConfig.latitudeColumn && + chartConfig.longitudeColumn + : // 차트: 기존 로직 (2단계에서 차트 설정 필요) + currentStep === 2 && + queryResult && + queryResult.rows.length > 0 && + chartConfig.xAxis && + (isPieChart || isApiSource + ? // 파이/도넛 차트 또는 REST API + chartConfig.aggregation === "count" + ? true // count는 Y축 없어도 됨 + : hasYAxis // 다른 집계(sum, avg, max, min) 또는 집계 없음 → Y축 필수 + : // 일반 차트 (DB): Y축 필수 + hasYAxis)); return (
@@ -178,20 +185,39 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element }`} > {/* 모달 헤더 */} -
-
-

{element.title} 설정

-

- {isSimpleWidget - ? "데이터 소스를 설정하세요" - : currentStep === 1 - ? "데이터 소스를 선택하세요" - : "쿼리를 실행하고 차트를 설정하세요"} +

+
+
+

{element.title} 설정

+

+ {isSimpleWidget + ? "데이터 소스를 설정하세요" + : currentStep === 1 + ? "데이터 소스를 선택하세요" + : "쿼리를 실행하고 차트를 설정하세요"} +

+
+ +
+ + {/* 커스텀 제목 입력 */} +
+ + setCustomTitle(e.target.value)} + placeholder={`예: 정비 일정 목록, 창고 위치 현황 등 (비워두면 자동 생성)`} + className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" + /> +

+ 💡 비워두면 테이블명으로 자동 생성됩니다 (예: "maintenance_schedules 목록")

-
{/* 진행 상황 표시 - 간단한 위젯은 표시 안 함 */} diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts index 833c033a..cdf70550 100644 --- a/frontend/components/admin/dashboard/types.ts +++ b/frontend/components/admin/dashboard/types.ts @@ -54,6 +54,7 @@ export interface DashboardElement { position: Position; size: Size; title: string; + customTitle?: string; // 사용자 정의 제목 (옵션) content: string; dataSource?: ChartDataSource; // 데이터 소스 설정 chartConfig?: ChartConfig; // 차트 설정 diff --git a/frontend/components/dashboard/DashboardViewer.tsx b/frontend/components/dashboard/DashboardViewer.tsx index 5598d943..c6e941e3 100644 --- a/frontend/components/dashboard/DashboardViewer.tsx +++ b/frontend/components/dashboard/DashboardViewer.tsx @@ -37,11 +37,11 @@ function renderWidget(element: DashboardElement) { // === 위젯 종류 === case "exchange": - return ; + return ; case "weather": - return ; + return ; case "calculator": - return ; + return ; case "clock": return (
@@ -56,7 +56,7 @@ function renderWidget(element: DashboardElement) { case "list-summary": return ; case "risk-alert": - return ; + return ; case "calendar": return ; case "status-summary": @@ -64,13 +64,13 @@ function renderWidget(element: DashboardElement) { // === 운영/작업 지원 === case "todo": - return ; + return ; case "booking-alert": - return ; + return ; case "maintenance": - return ; + return ; case "document": - return ; + return ; case "list": return ; diff --git a/frontend/components/dashboard/widgets/BookingAlertWidget.tsx b/frontend/components/dashboard/widgets/BookingAlertWidget.tsx index 4c600079..b47f0fb4 100644 --- a/frontend/components/dashboard/widgets/BookingAlertWidget.tsx +++ b/frontend/components/dashboard/widgets/BookingAlertWidget.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from "react"; import { Check, X, Phone, MapPin, Package, Clock, AlertCircle } from "lucide-react"; +import { DashboardElement } from "@/components/admin/dashboard/types"; interface BookingRequest { id: string; @@ -19,7 +20,11 @@ interface BookingRequest { estimatedCost?: number; } -export default function BookingAlertWidget() { +interface BookingAlertWidgetProps { + element?: DashboardElement; +} + +export default function BookingAlertWidget({ element }: BookingAlertWidgetProps) { const [bookings, setBookings] = useState([]); const [newCount, setNewCount] = useState(0); const [loading, setLoading] = useState(true); @@ -156,7 +161,7 @@ export default function BookingAlertWidget() {
-

🔔 예약 요청 알림

+

🔔 {element?.customTitle || "예약 요청 알림"}

{newCount > 0 && ( {newCount} diff --git a/frontend/components/dashboard/widgets/CalculatorWidget.tsx b/frontend/components/dashboard/widgets/CalculatorWidget.tsx index 6e7aad4d..b8816bbc 100644 --- a/frontend/components/dashboard/widgets/CalculatorWidget.tsx +++ b/frontend/components/dashboard/widgets/CalculatorWidget.tsx @@ -9,12 +9,14 @@ import React, { useState } from 'react'; import { Button } from '@/components/ui/button'; +import { DashboardElement } from '@/components/admin/dashboard/types'; interface CalculatorWidgetProps { + element?: DashboardElement; className?: string; } -export default function CalculatorWidget({ className = '' }: CalculatorWidgetProps) { +export default function CalculatorWidget({ element, className = '' }: CalculatorWidgetProps) { const [display, setDisplay] = useState('0'); const [previousValue, setPreviousValue] = useState(null); const [operation, setOperation] = useState(null); @@ -117,7 +119,10 @@ export default function CalculatorWidget({ className = '' }: CalculatorWidgetPro return (
-
+
+ {/* 제목 */} +

🧮 {element?.customTitle || "계산기"}

+ {/* 디스플레이 */}
diff --git a/frontend/components/dashboard/widgets/DocumentWidget.tsx b/frontend/components/dashboard/widgets/DocumentWidget.tsx index 7a85a556..6a15cce1 100644 --- a/frontend/components/dashboard/widgets/DocumentWidget.tsx +++ b/frontend/components/dashboard/widgets/DocumentWidget.tsx @@ -2,6 +2,7 @@ import React, { useState } from "react"; import { FileText, Download, Calendar, Folder, Search } from "lucide-react"; +import { DashboardElement } from "@/components/admin/dashboard/types"; interface Document { id: string; @@ -13,64 +14,69 @@ interface Document { description?: string; } -// 목 데이터 -const mockDocuments: Document[] = [ - { - id: "1", - name: "2025년 1월 세금계산서.pdf", - category: "세금계산서", - size: "1.2 MB", - uploadDate: "2025-01-05", - url: "/documents/tax-invoice-202501.pdf", - description: "1월 매출 세금계산서", - }, - { - id: "2", - name: "차량보험증권_서울12가3456.pdf", - category: "보험", - size: "856 KB", - uploadDate: "2024-12-20", - url: "/documents/insurance-vehicle-1.pdf", - description: "1톤 트럭 종합보험", - }, - { - id: "3", - name: "운송계약서_ABC물류.pdf", - category: "계약서", - size: "2.4 MB", - uploadDate: "2024-12-15", - url: "/documents/contract-abc-logistics.pdf", - description: "ABC물류 연간 운송 계약", - }, - { - id: "4", - name: "2024년 12월 세금계산서.pdf", - category: "세금계산서", - size: "1.1 MB", - uploadDate: "2024-12-05", - url: "/documents/tax-invoice-202412.pdf", - }, - { - id: "5", - name: "화물배상책임보험증권.pdf", - category: "보험", - size: "720 KB", - uploadDate: "2024-11-30", - url: "/documents/cargo-insurance.pdf", - description: "화물 배상책임보험", - }, - { - id: "6", - name: "차고지 임대계약서.pdf", - category: "계약서", - size: "1.8 MB", - uploadDate: "2024-11-15", - url: "/documents/garage-lease-contract.pdf", - }, -]; +// 목 데이터 (하드코딩 - 주석처리됨) +// const mockDocuments: Document[] = [ +// { +// id: "1", +// name: "2025년 1월 세금계산서.pdf", +// category: "세금계산서", +// size: "1.2 MB", +// uploadDate: "2025-01-05", +// url: "/documents/tax-invoice-202501.pdf", +// description: "1월 매출 세금계산서", +// }, +// { +// id: "2", +// name: "차량보험증권_서울12가3456.pdf", +// category: "보험", +// size: "856 KB", +// uploadDate: "2024-12-20", +// url: "/documents/insurance-vehicle-1.pdf", +// description: "1톤 트럭 종합보험", +// }, +// { +// id: "3", +// name: "운송계약서_ABC물류.pdf", +// category: "계약서", +// size: "2.4 MB", +// uploadDate: "2024-12-15", +// url: "/documents/contract-abc-logistics.pdf", +// description: "ABC물류 연간 운송 계약", +// }, +// { +// id: "4", +// name: "2024년 12월 세금계산서.pdf", +// category: "세금계산서", +// size: "1.1 MB", +// uploadDate: "2024-12-05", +// url: "/documents/tax-invoice-202412.pdf", +// }, +// { +// id: "5", +// name: "화물배상책임보험증권.pdf", +// category: "보험", +// size: "720 KB", +// uploadDate: "2024-11-30", +// url: "/documents/cargo-insurance.pdf", +// description: "화물 배상책임보험", +// }, +// { +// id: "6", +// name: "차고지 임대계약서.pdf", +// category: "계약서", +// size: "1.8 MB", +// uploadDate: "2024-11-15", +// url: "/documents/garage-lease-contract.pdf", +// }, +// ]; -export default function DocumentWidget() { - const [documents] = useState(mockDocuments); +interface DocumentWidgetProps { + element?: DashboardElement; +} + +export default function DocumentWidget({ element }: DocumentWidgetProps) { + // TODO: 실제 API 연동 필요 + const [documents] = useState([]); const [filter, setFilter] = useState<"all" | Document["category"]>("all"); const [searchTerm, setSearchTerm] = useState(""); @@ -126,7 +132,7 @@ export default function DocumentWidget() { {/* 헤더 */}
-

📂 문서 관리

+

📂 {element?.customTitle || "문서 관리"}

diff --git a/frontend/components/dashboard/widgets/ExchangeWidget.tsx b/frontend/components/dashboard/widgets/ExchangeWidget.tsx index 946363d4..86743326 100644 --- a/frontend/components/dashboard/widgets/ExchangeWidget.tsx +++ b/frontend/components/dashboard/widgets/ExchangeWidget.tsx @@ -12,14 +12,17 @@ import { TrendingUp, TrendingDown, RefreshCw, ArrowRightLeft } from 'lucide-reac import { Button } from '@/components/ui/button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Input } from '@/components/ui/input'; +import { DashboardElement } from '@/components/admin/dashboard/types'; interface ExchangeWidgetProps { + element?: DashboardElement; baseCurrency?: string; targetCurrency?: string; refreshInterval?: number; // 새로고침 간격 (ms), 기본값: 600000 (10분) } export default function ExchangeWidget({ + element, baseCurrency = 'KRW', targetCurrency = 'USD', refreshInterval = 600000, @@ -136,7 +139,7 @@ export default function ExchangeWidget({ {/* 헤더 */}
-

💱 환율

+

💱 {element?.customTitle || "환율"}

{lastUpdated ? `업데이트: ${lastUpdated.toLocaleTimeString('ko-KR', { diff --git a/frontend/components/dashboard/widgets/MaintenanceWidget.tsx b/frontend/components/dashboard/widgets/MaintenanceWidget.tsx index 634b8df8..361b7710 100644 --- a/frontend/components/dashboard/widgets/MaintenanceWidget.tsx +++ b/frontend/components/dashboard/widgets/MaintenanceWidget.tsx @@ -14,51 +14,52 @@ interface MaintenanceSchedule { estimatedCost?: number; } -// 목 데이터 -const mockSchedules: MaintenanceSchedule[] = [ - { - id: "1", - vehicleNumber: "서울12가3456", - vehicleType: "1톤 트럭", - maintenanceType: "정기점검", - scheduledDate: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString(), - status: "scheduled", - notes: "6개월 정기점검", - estimatedCost: 300000, - }, - { - id: "2", - vehicleNumber: "경기34나5678", - vehicleType: "2.5톤 트럭", - maintenanceType: "오일교환", - scheduledDate: new Date(Date.now() + 1 * 24 * 60 * 60 * 1000).toISOString(), - status: "scheduled", - estimatedCost: 150000, - }, - { - id: "3", - vehicleNumber: "인천56다7890", - vehicleType: "라보", - maintenanceType: "타이어교체", - scheduledDate: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), - status: "overdue", - notes: "긴급", - estimatedCost: 400000, - }, - { - id: "4", - vehicleNumber: "부산78라1234", - vehicleType: "1톤 트럭", - maintenanceType: "수리", - scheduledDate: new Date().toISOString(), - status: "in_progress", - notes: "엔진 점검 중", - estimatedCost: 800000, - }, -]; +// 목 데이터 (하드코딩 - 주석처리됨) +// const mockSchedules: MaintenanceSchedule[] = [ +// { +// id: "1", +// vehicleNumber: "서울12가3456", +// vehicleType: "1톤 트럭", +// maintenanceType: "정기점검", +// scheduledDate: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString(), +// status: "scheduled", +// notes: "6개월 정기점검", +// estimatedCost: 300000, +// }, +// { +// id: "2", +// vehicleNumber: "경기34나5678", +// vehicleType: "2.5톤 트럭", +// maintenanceType: "오일교환", +// scheduledDate: new Date(Date.now() + 1 * 24 * 60 * 60 * 1000).toISOString(), +// status: "scheduled", +// estimatedCost: 150000, +// }, +// { +// id: "3", +// vehicleNumber: "인천56다7890", +// vehicleType: "라보", +// maintenanceType: "타이어교체", +// scheduledDate: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), +// status: "overdue", +// notes: "긴급", +// estimatedCost: 400000, +// }, +// { +// id: "4", +// vehicleNumber: "부산78라1234", +// vehicleType: "1톤 트럭", +// maintenanceType: "수리", +// scheduledDate: new Date().toISOString(), +// status: "in_progress", +// notes: "엔진 점검 중", +// estimatedCost: 800000, +// }, +// ]; export default function MaintenanceWidget() { - const [schedules] = useState(mockSchedules); + // TODO: 실제 API 연동 필요 + const [schedules] = useState([]); const [filter, setFilter] = useState<"all" | MaintenanceSchedule["status"]>("all"); const [selectedDate, setSelectedDate] = useState(new Date()); diff --git a/frontend/components/dashboard/widgets/MapSummaryWidget.tsx b/frontend/components/dashboard/widgets/MapSummaryWidget.tsx index 53db0b8e..e91746a9 100644 --- a/frontend/components/dashboard/widgets/MapSummaryWidget.tsx +++ b/frontend/components/dashboard/widgets/MapSummaryWidget.tsx @@ -150,7 +150,8 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) { } }; - const displayTitle = tableName ? `${translateTableName(tableName)} 위치` : "위치 지도"; + // customTitle이 있으면 사용, 없으면 테이블명으로 자동 생성 + const displayTitle = element.customTitle || (tableName ? `${translateTableName(tableName)} 위치` : "위치 지도"); return (

@@ -181,13 +182,15 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) { )} {/* 지도 (항상 표시) */} -
+
{/* 브이월드 타일맵 */} ([]); const [isRefreshing, setIsRefreshing] = useState(false); const [filter, setFilter] = useState("all"); @@ -163,7 +168,7 @@ export default function RiskAlertWidget() {
-

리스크 / 알림

+

{element?.customTitle || "리스크 / 알림"}

{stats.high > 0 && ( 긴급 {stats.high}건 )} diff --git a/frontend/components/dashboard/widgets/StatusSummaryWidget.tsx b/frontend/components/dashboard/widgets/StatusSummaryWidget.tsx index e9641eee..e5478cdb 100644 --- a/frontend/components/dashboard/widgets/StatusSummaryWidget.tsx +++ b/frontend/components/dashboard/widgets/StatusSummaryWidget.tsx @@ -349,7 +349,8 @@ export default function StatusSummaryWidget({ return name; }; - const displayTitle = tableName ? `${translateTableName(tableName)} 현황` : title; + // customTitle이 있으면 사용, 없으면 테이블명으로 자동 생성 + const displayTitle = element.customTitle || (tableName ? `${translateTableName(tableName)} 현황` : title); return (
diff --git a/frontend/components/dashboard/widgets/TodoWidget.tsx b/frontend/components/dashboard/widgets/TodoWidget.tsx index f2cf3625..f43ba325 100644 --- a/frontend/components/dashboard/widgets/TodoWidget.tsx +++ b/frontend/components/dashboard/widgets/TodoWidget.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from "react"; import { Plus, Check, X, Clock, AlertCircle, GripVertical, ChevronDown } from "lucide-react"; +import { DashboardElement } from "@/components/admin/dashboard/types"; interface TodoItem { id: string; @@ -27,7 +28,11 @@ interface TodoStats { overdue: number; } -export default function TodoWidget() { +interface TodoWidgetProps { + element?: DashboardElement; +} + +export default function TodoWidget({ element }: TodoWidgetProps) { const [todos, setTodos] = useState([]); const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); @@ -193,7 +198,7 @@ export default function TodoWidget() { {/* 헤더 */}
-

✅ To-Do / 긴급 지시

+

✅ {element?.customTitle || "To-Do / 긴급 지시"}

From 18e2280623513900bf7a3cd2dc15d953dd687ba2 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 16 Oct 2025 09:55:14 +0900 Subject: [PATCH 05/28] =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=20=EC=9D=B4=EC=A0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app/(main)/admin/dashboard/page.tsx | 23 +- .../admin/dashboard/CanvasElement.tsx | 39 ++- .../admin/dashboard/DashboardCanvas.tsx | 69 ++++-- .../admin/dashboard/DashboardDesigner.tsx | 89 +++++-- .../admin/dashboard/DashboardTopMenu.tsx | 223 ++++++++++++++++++ .../admin/dashboard/ResolutionSelector.tsx | 122 ++++++++++ .../components/admin/dashboard/gridUtils.ts | 32 ++- 7 files changed, 498 insertions(+), 99 deletions(-) create mode 100644 frontend/components/admin/dashboard/DashboardTopMenu.tsx create mode 100644 frontend/components/admin/dashboard/ResolutionSelector.tsx diff --git a/frontend/app/(main)/admin/dashboard/page.tsx b/frontend/app/(main)/admin/dashboard/page.tsx index dcf81963..cde559ee 100644 --- a/frontend/app/(main)/admin/dashboard/page.tsx +++ b/frontend/app/(main)/admin/dashboard/page.tsx @@ -156,8 +156,6 @@ export default function DashboardListPage() { 제목 설명 - 요소 수 - 상태 생성일 수정일 작업 @@ -166,29 +164,10 @@ export default function DashboardListPage() { {dashboards.map((dashboard) => ( - -
- {dashboard.title} - {dashboard.isPublic && ( - - 공개 - - )} -
-
+ {dashboard.title} {dashboard.description || "-"} - - {dashboard.elementsCount || 0}개 - - - {dashboard.isPublic ? ( - 공개 - ) : ( - 비공개 - )} - {formatDate(dashboard.createdAt)} {formatDate(dashboard.updatedAt)} diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 5c75acb7..2340007c 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -110,6 +110,7 @@ interface CanvasElementProps { element: DashboardElement; isSelected: boolean; cellSize: number; + canvasWidth?: number; onUpdate: (id: string, updates: Partial) => void; onRemove: (id: string) => void; onSelect: (id: string | null) => void; @@ -126,6 +127,7 @@ export function CanvasElement({ element, isSelected, cellSize, + canvasWidth = 1560, onUpdate, onRemove, onSelect, @@ -207,7 +209,7 @@ export function CanvasElement({ const rawY = Math.max(0, dragStart.elementY + deltaY); // X 좌표가 캔버스 너비를 벗어나지 않도록 제한 - const maxX = GRID_CONFIG.CANVAS_WIDTH - element.size.width; + const maxX = canvasWidth - element.size.width; rawX = Math.min(rawX, maxX); setTempPosition({ x: rawX, y: rawY }); @@ -250,7 +252,7 @@ export function CanvasElement({ } // 가로 너비가 캔버스를 벗어나지 않도록 제한 - const maxWidth = GRID_CONFIG.CANVAS_WIDTH - newX; + const maxWidth = canvasWidth - newX; newWidth = Math.min(newWidth, maxWidth); // 임시 크기/위치 저장 (스냅 안 됨) @@ -258,7 +260,7 @@ export function CanvasElement({ setTempSize({ width: newWidth, height: newHeight }); } }, - [isDragging, isResizing, dragStart, resizeStart, element.size.width, element.type, element.subtype], + [isDragging, isResizing, dragStart, resizeStart, element.size.width, element.type, element.subtype, canvasWidth], ); // 마우스 업 처리 (그리드 스냅 적용) @@ -269,7 +271,7 @@ export function CanvasElement({ const snappedY = snapToGrid(tempPosition.y, cellSize); // X 좌표가 캔버스 너비를 벗어나지 않도록 최종 제한 - const maxX = GRID_CONFIG.CANVAS_WIDTH - element.size.width; + const maxX = canvasWidth - element.size.width; snappedX = Math.min(snappedX, maxX); onUpdate(element.id, { @@ -287,7 +289,7 @@ export function CanvasElement({ const snappedHeight = snapSizeToGrid(tempSize.height, 2, cellSize); // 가로 너비가 캔버스를 벗어나지 않도록 최종 제한 - const maxWidth = GRID_CONFIG.CANVAS_WIDTH - snappedX; + const maxWidth = canvasWidth - snappedX; snappedWidth = Math.min(snappedWidth, maxWidth); onUpdate(element.id, { @@ -301,7 +303,7 @@ export function CanvasElement({ setIsDragging(false); setIsResizing(false); - }, [isDragging, isResizing, tempPosition, tempSize, element.id, element.size.width, onUpdate, cellSize]); + }, [isDragging, isResizing, tempPosition, tempSize, element.id, element.size.width, onUpdate, cellSize, canvasWidth]); // 전역 마우스 이벤트 등록 React.useEffect(() => { @@ -545,12 +547,7 @@ export function CanvasElement({ ) : element.type === "widget" && element.subtype === "status-summary" ? ( // 커스텀 상태 카드 - 범용 위젯
- +
) : /* element.type === "widget" && element.subtype === "list-summary" ? ( // 커스텀 목록 카드 - 범용 위젯 (다른 분 작업 중 - 임시 주석) @@ -560,7 +557,7 @@ export function CanvasElement({ ) : */ element.type === "widget" && element.subtype === "delivery-status" ? ( // 배송/화물 현황 위젯 - 범용 위젯 사용 (구버전 호환)
- -
) : element.type === "widget" && element.subtype === "delivery-today-stats" ? ( // 오늘 처리 현황 - 범용 위젯 사용
- - - void; onConfigureElement?: (element: DashboardElement) => void; backgroundColor?: string; + canvasWidth?: number; + canvasHeight?: number; } /** @@ -34,11 +36,17 @@ export const DashboardCanvas = forwardRef( onSelectElement, onConfigureElement, backgroundColor = "#f9fafb", + canvasWidth = 1560, + canvasHeight = 768, }, ref, ) => { const [isDragOver, setIsDragOver] = useState(false); + // 현재 캔버스 크기에 맞는 그리드 설정 계산 + const gridConfig = useMemo(() => calculateGridConfig(canvasWidth), [canvasWidth]); + const cellSize = gridConfig.CELL_SIZE; + // 드래그 오버 처리 const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); @@ -71,20 +79,20 @@ export const DashboardCanvas = forwardRef( const rawX = e.clientX - rect.left + (ref.current?.scrollLeft || 0); const rawY = e.clientY - rect.top + (ref.current?.scrollTop || 0); - // 그리드에 스냅 (고정 셀 크기 사용) - let snappedX = snapToGrid(rawX, GRID_CONFIG.CELL_SIZE); - const snappedY = snapToGrid(rawY, GRID_CONFIG.CELL_SIZE); + // 그리드에 스냅 (동적 셀 크기 사용) + let snappedX = snapToGrid(rawX, cellSize); + const snappedY = snapToGrid(rawY, cellSize); // X 좌표가 캔버스 너비를 벗어나지 않도록 제한 - const maxX = GRID_CONFIG.CANVAS_WIDTH - GRID_CONFIG.CELL_SIZE * 2; // 최소 2칸 너비 보장 + const maxX = canvasWidth - cellSize * 2; // 최소 2칸 너비 보장 snappedX = Math.max(0, Math.min(snappedX, maxX)); onCreateElement(dragData.type, dragData.subtype, snappedX, snappedY); - } catch (error) { - // console.error('드롭 데이터 파싱 오류:', error); + } catch { + // 드롭 데이터 파싱 오류 무시 } }, - [ref, onCreateElement], + [ref, onCreateElement, canvasWidth, cellSize], ); // 캔버스 클릭 시 선택 해제 @@ -97,28 +105,23 @@ export const DashboardCanvas = forwardRef( [onSelectElement], ); - // 고정 그리드 크기 - const cellWithGap = GRID_CONFIG.CELL_SIZE + GRID_CONFIG.GAP; + // 동적 그리드 크기 계산 + const cellWithGap = cellSize + GRID_CONFIG.GAP; const gridSize = `${cellWithGap}px ${cellWithGap}px`; - // 캔버스 높이를 요소들의 최대 y + height 기준으로 계산 (최소 화면 높이 보장) - const minCanvasHeight = Math.max( - typeof window !== "undefined" ? window.innerHeight : 800, - ...elements.map((el) => el.position.y + el.size.height + 100), // 하단 여백 100px - ); + // 12개 컬럼 구분선 위치 계산 + const columnLines = Array.from({ length: GRID_CONFIG.COLUMNS + 1 }, (_, i) => i * cellWithGap); return (
( onDrop={handleDrop} onClick={handleCanvasClick} > + {/* 12개 컬럼 메인 구분선 */} + {columnLines.map((x, i) => ( +
+ ))} {/* 배치된 요소들 렌더링 */} + {elements.length === 0 && ( +
+
+
상단 메뉴에서 차트나 위젯을 선택하세요
+
+
+ )} {elements.map((element) => ( ("#f9fafb"); const canvasRef = useRef(null); + // 화면 해상도 자동 감지 및 기본 해상도 설정 + const [screenResolution] = useState(() => detectScreenResolution()); + const [resolution, setResolution] = useState(screenResolution); + + // 현재 해상도 설정 + const canvasConfig = RESOLUTIONS[resolution]; + // 대시보드 ID가 props로 전달되면 로드 React.useEffect(() => { if (initialDashboardId) { @@ -81,9 +88,15 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D } }; - // 새로운 요소 생성 (고정 그리드 기반 기본 크기) + // 새로운 요소 생성 (동적 그리드 기반 기본 크기) const createElement = useCallback( (type: ElementType, subtype: ElementSubtype, x: number, y: number) => { + // 좌표 유효성 검사 + if (isNaN(x) || isNaN(y)) { + console.error("Invalid coordinates:", { x, y }); + return; + } + // 기본 크기 설정 let defaultCells = { width: 2, height: 2 }; // 기본 위젯 크기 @@ -93,7 +106,9 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D defaultCells = { width: 2, height: 3 }; // 달력 최소 크기 } - const cellWithGap = GRID_CONFIG.CELL_SIZE + GRID_CONFIG.GAP; + // 현재 해상도에 맞는 셀 크기 계산 + const cellSize = Math.floor((canvasConfig.width + GRID_CONFIG.GAP) / GRID_CONFIG.COLUMNS) - GRID_CONFIG.GAP; + const cellWithGap = cellSize + GRID_CONFIG.GAP; const defaultWidth = defaultCells.width * cellWithGap - GRID_CONFIG.GAP; const defaultHeight = defaultCells.height * cellWithGap - GRID_CONFIG.GAP; @@ -112,7 +127,25 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D setElementCounter((prev) => prev + 1); setSelectedElement(newElement.id); }, - [elementCounter], + [elementCounter, canvasConfig.width], + ); + + // 메뉴에서 요소 추가 시 (캔버스 중앙에 배치) + const addElementFromMenu = useCallback( + (type: ElementType, subtype: ElementSubtype) => { + // 캔버스 중앙 좌표 계산 + const centerX = Math.floor(canvasConfig.width / 2); + const centerY = Math.floor(canvasConfig.height / 2); + + // 좌표 유효성 확인 + if (isNaN(centerX) || isNaN(centerY)) { + console.error("Invalid canvas config:", canvasConfig); + return; + } + + createElement(type, subtype, centerX, centerY); + }, + [canvasConfig.width, canvasConfig.height, createElement], ); // 요소 업데이트 @@ -245,25 +278,30 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D } return ( -
- {/* 캔버스 영역 */} -
- {/* 편집 중인 대시보드 표시 */} - {dashboardTitle && ( -
- 📝 편집 중: {dashboardTitle} -
- )} +
+ {/* 상단 메뉴바 */} + router.push(`/dashboard/${dashboardId}`) : undefined} + dashboardTitle={dashboardTitle} + onAddElement={addElementFromMenu} + resolution={resolution} + onResolutionChange={setResolution} + currentScreenResolution={screenResolution} + backgroundColor={canvasBackgroundColor} + onBackgroundColorChange={setCanvasBackgroundColor} + /> - - - {/* 캔버스 중앙 정렬 컨테이너 */} -
+ {/* 캔버스 영역 - 해상도에 따른 크기, 중앙 정렬 */} +
+
- {/* 사이드바 */} - - {/* 요소 설정 모달 */} {configModalElement && ( <> diff --git a/frontend/components/admin/dashboard/DashboardTopMenu.tsx b/frontend/components/admin/dashboard/DashboardTopMenu.tsx new file mode 100644 index 00000000..cc56265a --- /dev/null +++ b/frontend/components/admin/dashboard/DashboardTopMenu.tsx @@ -0,0 +1,223 @@ +"use client"; + +import React from "react"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Save, Trash2, Eye, Palette } from "lucide-react"; +import { ElementType, ElementSubtype } from "./types"; +import { ResolutionSelector, Resolution } from "./ResolutionSelector"; +import { Input } from "@/components/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; + +interface DashboardTopMenuProps { + onSaveLayout: () => void; + onClearCanvas: () => void; + onViewDashboard?: () => void; + dashboardTitle?: string; + onAddElement?: (type: ElementType, subtype: ElementSubtype) => void; + resolution?: Resolution; + onResolutionChange?: (resolution: Resolution) => void; + currentScreenResolution?: Resolution; + backgroundColor?: string; + onBackgroundColorChange?: (color: string) => void; +} + +/** + * 대시보드 편집 화면 상단 메뉴바 + * - 차트/위젯 선택 (셀렉트박스) + * - 저장/초기화/보기 버튼 + */ +export function DashboardTopMenu({ + onSaveLayout, + onClearCanvas, + onViewDashboard, + dashboardTitle, + onAddElement, + resolution = "fhd", + onResolutionChange, + currentScreenResolution, + backgroundColor = "#f9fafb", + onBackgroundColorChange, +}: DashboardTopMenuProps) { + // 차트 선택 시 캔버스 중앙에 추가 + const handleChartSelect = (value: string) => { + if (onAddElement) { + onAddElement("chart", value as ElementSubtype); + } + }; + + // 위젯 선택 시 캔버스 중앙에 추가 + const handleWidgetSelect = (value: string) => { + if (onAddElement) { + onAddElement("widget", value as ElementSubtype); + } + }; + + return ( +
+ {/* 좌측: 대시보드 제목 */} +
+ {dashboardTitle && ( +
+ {dashboardTitle} + 편집 중 +
+ )} +
+ + {/* 중앙: 해상도 선택 & 요소 추가 */} +
+ {/* 해상도 선택 */} + {onResolutionChange && ( + + )} + +
+ + {/* 배경색 선택 */} + {onBackgroundColorChange && ( + + + + + +
+
+ +
+
+ onBackgroundColorChange(e.target.value)} + className="h-10 w-20 cursor-pointer" + /> + onBackgroundColorChange(e.target.value)} + placeholder="#f9fafb" + className="flex-1" + /> +
+
+ {[ + "#ffffff", + "#f9fafb", + "#f3f4f6", + "#e5e7eb", + "#1f2937", + "#111827", + "#fef3c7", + "#fde68a", + "#dbeafe", + "#bfdbfe", + "#fecaca", + "#fca5a5", + ].map((color) => ( +
+
+
+
+ )} + +
+ {/* 차트 선택 */} + + + {/* 위젯 선택 */} + +
+ + {/* 우측: 액션 버튼 */} +
+ {onViewDashboard && ( + + )} + + +
+
+ ); +} diff --git a/frontend/components/admin/dashboard/ResolutionSelector.tsx b/frontend/components/admin/dashboard/ResolutionSelector.tsx new file mode 100644 index 00000000..5f5bda53 --- /dev/null +++ b/frontend/components/admin/dashboard/ResolutionSelector.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Monitor } from "lucide-react"; + +export type Resolution = "hd" | "fhd" | "qhd" | "uhd"; + +export interface ResolutionConfig { + width: number; + height: number; + label: string; +} + +export const RESOLUTIONS: Record = { + hd: { + width: 1280 - 360, + height: 720 - 312, + label: "HD (1280x720)", + }, + fhd: { + width: 1920 - 360, + height: 1080 - 312, + label: "Full HD (1920x1080)", + }, + qhd: { + width: 2560 - 360, + height: 1440 - 312, + label: "QHD (2560x1440)", + }, + uhd: { + width: 3840 - 360, + height: 2160 - 312, + label: "4K UHD (3840x2160)", + }, +}; + +interface ResolutionSelectorProps { + value: Resolution; + onChange: (resolution: Resolution) => void; + currentScreenResolution?: Resolution; +} + +/** + * 현재 화면 해상도 감지 + */ +export function detectScreenResolution(): Resolution { + if (typeof window === "undefined") return "fhd"; + + const width = window.screen.width; + const height = window.screen.height; + + // 화면 해상도에 따라 적절한 캔버스 해상도 반환 + if (width >= 3840 || height >= 2160) return "uhd"; + if (width >= 2560 || height >= 1440) return "qhd"; + if (width >= 1920 || height >= 1080) return "fhd"; + return "hd"; +} + +/** + * 해상도 선택 컴포넌트 + * - HD, Full HD, QHD, 4K UHD 지원 + * - 12칸 그리드 유지, 셀 크기만 변경 + * - 현재 화면 해상도 감지 및 경고 표시 + */ +export function ResolutionSelector({ value, onChange, currentScreenResolution }: ResolutionSelectorProps) { + const currentConfig = RESOLUTIONS[value]; + const screenConfig = currentScreenResolution ? RESOLUTIONS[currentScreenResolution] : null; + + // 현재 선택된 해상도가 화면보다 큰지 확인 + const isTooLarge = + screenConfig && + (currentConfig.width > screenConfig.width + 360 || currentConfig.height > screenConfig.height + 312); + + return ( +
+ + + {isTooLarge && ⚠️ 현재 화면보다 큽니다} +
+ ); +} diff --git a/frontend/components/admin/dashboard/gridUtils.ts b/frontend/components/admin/dashboard/gridUtils.ts index f5ec9d7c..54149222 100644 --- a/frontend/components/admin/dashboard/gridUtils.ts +++ b/frontend/components/admin/dashboard/gridUtils.ts @@ -5,18 +5,36 @@ * - 스냅 기능 */ -// 그리드 설정 (고정 크기) +// 기본 그리드 설정 (FHD 기준) export const GRID_CONFIG = { - COLUMNS: 12, - CELL_SIZE: 132, // 고정 셀 크기 - GAP: 8, // 셀 간격 + COLUMNS: 12, // 모든 해상도에서 12칸 고정 + GAP: 8, // 셀 간격 고정 SNAP_THRESHOLD: 15, // 스냅 임계값 (px) ELEMENT_PADDING: 4, // 요소 주위 여백 (px) - CANVAS_WIDTH: 1682, // 고정 캔버스 너비 (실제 측정값) - // 계산식: (132 + 8) × 12 - 8 = 1672px (그리드) - // 추가 여백 10px 포함 = 1682px + // CELL_SIZE와 CANVAS_WIDTH는 해상도에 따라 동적 계산 } as const; +/** + * 캔버스 너비에 맞춰 셀 크기 계산 + * 공식: (CELL_SIZE + GAP) * 12 - GAP = canvasWidth + * CELL_SIZE = (canvasWidth + GAP) / 12 - GAP + */ +export function calculateCellSize(canvasWidth: number): number { + return Math.floor((canvasWidth + GRID_CONFIG.GAP) / GRID_CONFIG.COLUMNS) - GRID_CONFIG.GAP; +} + +/** + * 해상도별 그리드 설정 계산 + */ +export function calculateGridConfig(canvasWidth: number) { + const cellSize = calculateCellSize(canvasWidth); + return { + ...GRID_CONFIG, + CELL_SIZE: cellSize, + CANVAS_WIDTH: canvasWidth, + }; +} + /** * 실제 그리드 셀 크기 계산 (gap 포함) */ From 3afcd3d9fb3d21b3b4ec55f69490bdeed821c226 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 16 Oct 2025 10:02:47 +0900 Subject: [PATCH 06/28] =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EC=9A=94=EC=86=8C?= =?UTF-8?q?=EB=A5=BC=20=EC=97=B0=EC=86=8D=EC=9C=BC=EB=A1=9C=20=EA=BA=BC?= =?UTF-8?q?=EB=82=BC=20=EC=88=98=20=EC=9E=88=EA=B2=8C=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/CanvasElement.tsx | 8 ++++++-- .../components/admin/dashboard/DashboardTopMenu.tsx | 11 +++++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 2340007c..fb8d9847 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -166,7 +166,11 @@ export function CanvasElement({ return; } - onSelect(element.id); + // 선택되지 않은 경우에만 선택 처리 + if (!isSelected) { + onSelect(element.id); + } + setIsDragging(true); setDragStart({ x: e.clientX, @@ -176,7 +180,7 @@ export function CanvasElement({ }); e.preventDefault(); }, - [element.id, element.position.x, element.position.y, onSelect], + [element.id, element.position.x, element.position.y, onSelect, isSelected], ); // 리사이즈 핸들 마우스다운 diff --git a/frontend/components/admin/dashboard/DashboardTopMenu.tsx b/frontend/components/admin/dashboard/DashboardTopMenu.tsx index cc56265a..1d58988f 100644 --- a/frontend/components/admin/dashboard/DashboardTopMenu.tsx +++ b/frontend/components/admin/dashboard/DashboardTopMenu.tsx @@ -47,10 +47,15 @@ export function DashboardTopMenu({ backgroundColor = "#f9fafb", onBackgroundColorChange, }: DashboardTopMenuProps) { + const [chartValue, setChartValue] = React.useState(""); + const [widgetValue, setWidgetValue] = React.useState(""); + // 차트 선택 시 캔버스 중앙에 추가 const handleChartSelect = (value: string) => { if (onAddElement) { onAddElement("chart", value as ElementSubtype); + // 선택 후 즉시 리셋하여 같은 항목을 연속으로 선택 가능하게 + setTimeout(() => setChartValue(""), 0); } }; @@ -58,6 +63,8 @@ export function DashboardTopMenu({ const handleWidgetSelect = (value: string) => { if (onAddElement) { onAddElement("widget", value as ElementSubtype); + // 선택 후 즉시 리셋하여 같은 항목을 연속으로 선택 가능하게 + setTimeout(() => setWidgetValue(""), 0); } }; @@ -148,7 +155,7 @@ export function DashboardTopMenu({
{/* 차트 선택 */} - @@ -168,7 +175,7 @@ export function DashboardTopMenu({ {/* 위젯 선택 */} - From ed9da3962a81a6ddd202a23819053049115b6d32 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 16 Oct 2025 10:05:43 +0900 Subject: [PATCH 07/28] =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/DashboardDesigner.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 340d9f41..4683ee65 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -37,8 +37,8 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D const [screenResolution] = useState(() => detectScreenResolution()); const [resolution, setResolution] = useState(screenResolution); - // 현재 해상도 설정 - const canvasConfig = RESOLUTIONS[resolution]; + // 현재 해상도 설정 (안전하게 기본값 제공) + const canvasConfig = RESOLUTIONS[resolution] || RESOLUTIONS.fhd; // 대시보드 ID가 props로 전달되면 로드 React.useEffect(() => { @@ -113,6 +113,19 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D const defaultWidth = defaultCells.width * cellWithGap - GRID_CONFIG.GAP; const defaultHeight = defaultCells.height * cellWithGap - GRID_CONFIG.GAP; + // 크기 유효성 검사 + if (isNaN(defaultWidth) || isNaN(defaultHeight) || defaultWidth <= 0 || defaultHeight <= 0) { + console.error("Invalid size calculated:", { + canvasConfig, + cellSize, + cellWithGap, + defaultCells, + defaultWidth, + defaultHeight, + }); + return; + } + const newElement: DashboardElement = { id: `element-${elementCounter + 1}`, type, From 337cc448d0ec55b0edbb1b93f9dbe2e44ba1349d Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 16 Oct 2025 10:09:10 +0900 Subject: [PATCH 08/28] =?UTF-8?q?=EB=A6=AC=EC=82=AC=EC=9D=B4=EC=A6=88=20?= =?UTF-8?q?=ED=95=B8=EB=93=A4=EB=9F=AC=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/admin/dashboard/CanvasElement.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index fb8d9847..c1a682d3 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -229,8 +229,8 @@ export function CanvasElement({ // 최소 크기 설정: 달력은 2x3, 나머지는 2x2 const minWidthCells = 2; const minHeightCells = element.type === "widget" && element.subtype === "calendar" ? 3 : 2; - const minWidth = GRID_CONFIG.CELL_SIZE * minWidthCells; - const minHeight = GRID_CONFIG.CELL_SIZE * minHeightCells; + const minWidth = cellSize * minWidthCells; + const minHeight = cellSize * minHeightCells; switch (resizeStart.handle) { case "se": // 오른쪽 아래 From 9e1a7c53e16b1766556f91379fdbde172a99e6b2 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 16 Oct 2025 10:27:43 +0900 Subject: [PATCH 09/28] =?UTF-8?q?settings=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/services/DashboardService.ts | 438 ++++++++++-------- .../admin/dashboard/DashboardDesigner.tsx | 34 +- frontend/lib/api/dashboard.ts | 8 + 3 files changed, 283 insertions(+), 197 deletions(-) diff --git a/backend-node/src/services/DashboardService.ts b/backend-node/src/services/DashboardService.ts index fa0ce775..9829c49d 100644 --- a/backend-node/src/services/DashboardService.ts +++ b/backend-node/src/services/DashboardService.ts @@ -1,89 +1,98 @@ -import { v4 as uuidv4 } from 'uuid'; -import { PostgreSQLService } from '../database/PostgreSQLService'; -import { - Dashboard, - DashboardElement, - CreateDashboardRequest, +import { v4 as uuidv4 } from "uuid"; +import { PostgreSQLService } from "../database/PostgreSQLService"; +import { + Dashboard, + DashboardElement, + CreateDashboardRequest, UpdateDashboardRequest, - DashboardListQuery -} from '../types/dashboard'; + DashboardListQuery, +} from "../types/dashboard"; /** * 대시보드 서비스 - Raw Query 방식 * PostgreSQL 직접 연결을 통한 CRUD 작업 */ export class DashboardService { - /** * 대시보드 생성 */ - static async createDashboard(data: CreateDashboardRequest, userId: string): Promise { + static async createDashboard( + data: CreateDashboardRequest, + userId: string + ): Promise { const dashboardId = uuidv4(); const now = new Date(); - + try { // 트랜잭션으로 대시보드와 요소들을 함께 생성 const result = await PostgreSQLService.transaction(async (client) => { // 1. 대시보드 메인 정보 저장 - await client.query(` + await client.query( + ` INSERT INTO dashboards ( id, title, description, is_public, created_by, - created_at, updated_at, tags, category, view_count - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - `, [ - dashboardId, - data.title, - data.description || null, - data.isPublic || false, - userId, - now, - now, - JSON.stringify(data.tags || []), - data.category || null, - 0 - ]); - + created_at, updated_at, tags, category, view_count, settings + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + `, + [ + dashboardId, + data.title, + data.description || null, + data.isPublic || false, + userId, + now, + now, + JSON.stringify(data.tags || []), + data.category || null, + 0, + JSON.stringify(data.settings || {}), + ] + ); + // 2. 대시보드 요소들 저장 if (data.elements && data.elements.length > 0) { for (let i = 0; i < data.elements.length; i++) { const element = data.elements[i]; const elementId = uuidv4(); // 항상 새로운 UUID 생성 - - await client.query(` + + await client.query( + ` INSERT INTO dashboard_elements ( id, dashboard_id, element_type, element_subtype, position_x, position_y, width, height, title, content, data_source_config, chart_config, display_order, created_at, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) - `, [ - elementId, - dashboardId, - element.type, - element.subtype, - element.position.x, - element.position.y, - element.size.width, - element.size.height, - element.title, - element.content || null, - JSON.stringify(element.dataSource || {}), - JSON.stringify(element.chartConfig || {}), - i, - now, - now - ]); + `, + [ + elementId, + dashboardId, + element.type, + element.subtype, + element.position.x, + element.position.y, + element.size.width, + element.size.height, + element.title, + element.content || null, + JSON.stringify(element.dataSource || {}), + JSON.stringify(element.chartConfig || {}), + i, + now, + now, + ] + ); } } - + return dashboardId; }); - + // 생성된 대시보드 반환 try { const dashboard = await this.getDashboardById(dashboardId, userId); if (!dashboard) { - console.error('대시보드 생성은 성공했으나 조회에 실패:', dashboardId); + console.error("대시보드 생성은 성공했으나 조회에 실패:", dashboardId); // 생성은 성공했으므로 기본 정보만이라도 반환 return { id: dashboardId, @@ -97,13 +106,13 @@ export class DashboardService { tags: data.tags || [], category: data.category, viewCount: 0, - elements: data.elements || [] + elements: data.elements || [], }; } - + return dashboard; } catch (fetchError) { - console.error('생성된 대시보드 조회 중 오류:', fetchError); + console.error("생성된 대시보드 조회 중 오류:", fetchError); // 생성은 성공했으므로 기본 정보 반환 return { id: dashboardId, @@ -117,76 +126,79 @@ export class DashboardService { tags: data.tags || [], category: data.category, viewCount: 0, - elements: data.elements || [] + elements: data.elements || [], }; } - } catch (error) { - console.error('Dashboard creation error:', error); + console.error("Dashboard creation error:", error); throw error; } } - + /** * 대시보드 목록 조회 */ static async getDashboards(query: DashboardListQuery, userId?: string) { - const { - page = 1, - limit = 20, - search, - category, - isPublic, - createdBy + const { + page = 1, + limit = 20, + search, + category, + isPublic, + createdBy, } = query; - + const offset = (page - 1) * limit; - + try { // 기본 WHERE 조건 - let whereConditions = ['d.deleted_at IS NULL']; + let whereConditions = ["d.deleted_at IS NULL"]; let params: any[] = []; let paramIndex = 1; - + // 권한 필터링 if (userId) { - whereConditions.push(`(d.created_by = $${paramIndex} OR d.is_public = true)`); + whereConditions.push( + `(d.created_by = $${paramIndex} OR d.is_public = true)` + ); params.push(userId); paramIndex++; } else { - whereConditions.push('d.is_public = true'); + whereConditions.push("d.is_public = true"); } - + // 검색 조건 if (search) { - whereConditions.push(`(d.title ILIKE $${paramIndex} OR d.description ILIKE $${paramIndex + 1})`); + whereConditions.push( + `(d.title ILIKE $${paramIndex} OR d.description ILIKE $${paramIndex + 1})` + ); params.push(`%${search}%`, `%${search}%`); paramIndex += 2; } - + // 카테고리 필터 if (category) { whereConditions.push(`d.category = $${paramIndex}`); params.push(category); paramIndex++; } - + // 공개/비공개 필터 - if (typeof isPublic === 'boolean') { + if (typeof isPublic === "boolean") { whereConditions.push(`d.is_public = $${paramIndex}`); params.push(isPublic); paramIndex++; } - + // 작성자 필터 if (createdBy) { whereConditions.push(`d.created_by = $${paramIndex}`); params.push(createdBy); paramIndex++; } - - const whereClause = whereConditions.join(' AND '); - + + const whereClause = whereConditions.join(" AND "); + // 대시보드 목록 조회 (users 테이블 조인 제거) const dashboardQuery = ` SELECT @@ -211,22 +223,23 @@ export class DashboardService { ORDER BY d.updated_at DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1} `; - - const dashboardResult = await PostgreSQLService.query( - dashboardQuery, - [...params, limit, offset] - ); - + + const dashboardResult = await PostgreSQLService.query(dashboardQuery, [ + ...params, + limit, + offset, + ]); + // 전체 개수 조회 const countQuery = ` SELECT COUNT(DISTINCT d.id) as total FROM dashboards d WHERE ${whereClause} `; - + const countResult = await PostgreSQLService.query(countQuery, params); - const total = parseInt(countResult.rows[0]?.total || '0'); - + const total = parseInt(countResult.rows[0]?.total || "0"); + return { dashboards: dashboardResult.rows.map((row: any) => ({ id: row.id, @@ -237,33 +250,36 @@ export class DashboardService { createdBy: row.created_by, createdAt: row.created_at, updatedAt: row.updated_at, - tags: JSON.parse(row.tags || '[]'), + tags: JSON.parse(row.tags || "[]"), category: row.category, - viewCount: parseInt(row.view_count || '0'), - elementsCount: parseInt(row.elements_count || '0') + viewCount: parseInt(row.view_count || "0"), + elementsCount: parseInt(row.elements_count || "0"), })), pagination: { page, limit, total, - totalPages: Math.ceil(total / limit) - } + totalPages: Math.ceil(total / limit), + }, }; } catch (error) { - console.error('Dashboard list error:', error); + console.error("Dashboard list error:", error); throw error; } } - + /** * 대시보드 상세 조회 */ - static async getDashboardById(dashboardId: string, userId?: string): Promise { + static async getDashboardById( + dashboardId: string, + userId?: string + ): Promise { try { // 1. 대시보드 기본 정보 조회 (권한 체크 포함) let dashboardQuery: string; let dashboardParams: any[]; - + if (userId) { dashboardQuery = ` SELECT d.* @@ -281,43 +297,50 @@ export class DashboardService { `; dashboardParams = [dashboardId]; } - - const dashboardResult = await PostgreSQLService.query(dashboardQuery, dashboardParams); - + + const dashboardResult = await PostgreSQLService.query( + dashboardQuery, + dashboardParams + ); + if (dashboardResult.rows.length === 0) { return null; } - + const dashboard = dashboardResult.rows[0]; - + // 2. 대시보드 요소들 조회 const elementsQuery = ` SELECT * FROM dashboard_elements WHERE dashboard_id = $1 ORDER BY display_order ASC `; - - const elementsResult = await PostgreSQLService.query(elementsQuery, [dashboardId]); - + + const elementsResult = await PostgreSQLService.query(elementsQuery, [ + dashboardId, + ]); + // 3. 요소 데이터 변환 - const elements: DashboardElement[] = elementsResult.rows.map((row: any) => ({ - id: row.id, - type: row.element_type, - subtype: row.element_subtype, - position: { - x: row.position_x, - y: row.position_y - }, - size: { - width: row.width, - height: row.height - }, - title: row.title, - content: row.content, - dataSource: JSON.parse(row.data_source_config || '{}'), - chartConfig: JSON.parse(row.chart_config || '{}') - })); - + const elements: DashboardElement[] = elementsResult.rows.map( + (row: any) => ({ + id: row.id, + type: row.element_type, + subtype: row.element_subtype, + position: { + x: row.position_x, + y: row.position_y, + }, + size: { + width: row.width, + height: row.height, + }, + title: row.title, + content: row.content, + dataSource: JSON.parse(row.data_source_config || "{}"), + chartConfig: JSON.parse(row.chart_config || "{}"), + }) + ); + return { id: dashboard.id, title: dashboard.title, @@ -327,44 +350,47 @@ export class DashboardService { createdBy: dashboard.created_by, createdAt: dashboard.created_at, updatedAt: dashboard.updated_at, - tags: JSON.parse(dashboard.tags || '[]'), + tags: JSON.parse(dashboard.tags || "[]"), category: dashboard.category, - viewCount: parseInt(dashboard.view_count || '0'), - elements + viewCount: parseInt(dashboard.view_count || "0"), + elements, }; } catch (error) { - console.error('Dashboard get error:', error); + console.error("Dashboard get error:", error); throw error; } } - + /** * 대시보드 업데이트 */ static async updateDashboard( - dashboardId: string, - data: UpdateDashboardRequest, + dashboardId: string, + data: UpdateDashboardRequest, userId: string ): Promise { try { const result = await PostgreSQLService.transaction(async (client) => { // 권한 체크 - const authCheckResult = await client.query(` + const authCheckResult = await client.query( + ` SELECT id FROM dashboards WHERE id = $1 AND created_by = $2 AND deleted_at IS NULL - `, [dashboardId, userId]); - + `, + [dashboardId, userId] + ); + if (authCheckResult.rows.length === 0) { - throw new Error('대시보드를 찾을 수 없거나 수정 권한이 없습니다.'); + throw new Error("대시보드를 찾을 수 없거나 수정 권한이 없습니다."); } - + const now = new Date(); - + // 1. 대시보드 메인 정보 업데이트 const updateFields: string[] = []; const updateParams: any[] = []; let paramIndex = 1; - + if (data.title !== undefined) { updateFields.push(`title = $${paramIndex}`); updateParams.push(data.title); @@ -390,120 +416,141 @@ export class DashboardService { updateParams.push(data.category); paramIndex++; } - + if (data.settings !== undefined) { + updateFields.push(`settings = $${paramIndex}`); + updateParams.push(JSON.stringify(data.settings)); + paramIndex++; + } + updateFields.push(`updated_at = $${paramIndex}`); updateParams.push(now); paramIndex++; - + updateParams.push(dashboardId); - - if (updateFields.length > 1) { // updated_at 외에 다른 필드가 있는 경우 + + if (updateFields.length > 1) { + // updated_at 외에 다른 필드가 있는 경우 const updateQuery = ` UPDATE dashboards - SET ${updateFields.join(', ')} + SET ${updateFields.join(", ")} WHERE id = $${paramIndex} `; - + await client.query(updateQuery, updateParams); } - + // 2. 요소 업데이트 (있는 경우) if (data.elements) { // 기존 요소들 삭제 - await client.query(` + await client.query( + ` DELETE FROM dashboard_elements WHERE dashboard_id = $1 - `, [dashboardId]); - + `, + [dashboardId] + ); + // 새 요소들 추가 for (let i = 0; i < data.elements.length; i++) { const element = data.elements[i]; const elementId = uuidv4(); - - await client.query(` + + await client.query( + ` INSERT INTO dashboard_elements ( id, dashboard_id, element_type, element_subtype, position_x, position_y, width, height, title, content, data_source_config, chart_config, display_order, created_at, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) - `, [ - elementId, - dashboardId, - element.type, - element.subtype, - element.position.x, - element.position.y, - element.size.width, - element.size.height, - element.title, - element.content || null, - JSON.stringify(element.dataSource || {}), - JSON.stringify(element.chartConfig || {}), - i, - now, - now - ]); + `, + [ + elementId, + dashboardId, + element.type, + element.subtype, + element.position.x, + element.position.y, + element.size.width, + element.size.height, + element.title, + element.content || null, + JSON.stringify(element.dataSource || {}), + JSON.stringify(element.chartConfig || {}), + i, + now, + now, + ] + ); } } - + return dashboardId; }); - + // 업데이트된 대시보드 반환 return await this.getDashboardById(dashboardId, userId); - } catch (error) { - console.error('Dashboard update error:', error); + console.error("Dashboard update error:", error); throw error; } } - + /** * 대시보드 삭제 (소프트 삭제) */ - static async deleteDashboard(dashboardId: string, userId: string): Promise { + static async deleteDashboard( + dashboardId: string, + userId: string + ): Promise { try { const now = new Date(); - - const result = await PostgreSQLService.query(` + + const result = await PostgreSQLService.query( + ` UPDATE dashboards SET deleted_at = $1, updated_at = $2 WHERE id = $3 AND created_by = $4 AND deleted_at IS NULL - `, [now, now, dashboardId, userId]); - + `, + [now, now, dashboardId, userId] + ); + return (result.rowCount || 0) > 0; } catch (error) { - console.error('Dashboard delete error:', error); + console.error("Dashboard delete error:", error); throw error; } } - + /** * 조회수 증가 */ static async incrementViewCount(dashboardId: string): Promise { try { - await PostgreSQLService.query(` + await PostgreSQLService.query( + ` UPDATE dashboards SET view_count = view_count + 1 WHERE id = $1 AND deleted_at IS NULL - `, [dashboardId]); + `, + [dashboardId] + ); } catch (error) { - console.error('View count increment error:', error); + console.error("View count increment error:", error); // 조회수 증가 실패는 치명적이지 않으므로 에러를 던지지 않음 } } - + /** * 사용자 권한 체크 */ static async checkUserPermission( - dashboardId: string, - userId: string, - requiredPermission: 'view' | 'edit' | 'admin' = 'view' + dashboardId: string, + userId: string, + requiredPermission: "view" | "edit" | "admin" = "view" ): Promise { try { - const result = await PostgreSQLService.query(` + const result = await PostgreSQLService.query( + ` SELECT CASE WHEN d.created_by = $2 THEN 'admin' @@ -512,23 +559,26 @@ export class DashboardService { END as permission FROM dashboards d WHERE d.id = $1 AND d.deleted_at IS NULL - `, [dashboardId, userId]); - + `, + [dashboardId, userId] + ); + if (result.rows.length === 0) { return false; } - + const userPermission = result.rows[0].permission; - + // 권한 레벨 체크 - const permissionLevels = { 'view': 1, 'edit': 2, 'admin': 3 }; - const userLevel = permissionLevels[userPermission as keyof typeof permissionLevels] || 0; + const permissionLevels = { view: 1, edit: 2, admin: 3 }; + const userLevel = + permissionLevels[userPermission as keyof typeof permissionLevels] || 0; const requiredLevel = permissionLevels[requiredPermission]; - + return userLevel >= requiredLevel; } catch (error) { - console.error('Permission check error:', error); + console.error("Permission check error:", error); return false; } } -} \ No newline at end of file +} diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 4683ee65..63451284 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -33,7 +33,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D const [canvasBackgroundColor, setCanvasBackgroundColor] = useState("#f9fafb"); const canvasRef = useRef(null); - // 화면 해상도 자동 감지 및 기본 해상도 설정 + // 화면 해상도 자동 감지 const [screenResolution] = useState(() => detectScreenResolution()); const [resolution, setResolution] = useState(screenResolution); @@ -62,6 +62,22 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D setDashboardId(dashboard.id); setDashboardTitle(dashboard.title); + // 저장된 설정 복원 + console.log("🔍 로드된 대시보드:", dashboard); + console.log("📦 저장된 settings:", (dashboard as any).settings); + + if ((dashboard as any).settings?.resolution) { + const savedResolution = (dashboard as any).settings.resolution as Resolution; + console.log("✅ 저장된 해상도 복원:", savedResolution); + setResolution(savedResolution); + } else { + console.log("⚠️ 저장된 해상도 없음"); + } + + if ((dashboard as any).settings?.backgroundColor) { + setCanvasBackgroundColor((dashboard as any).settings.backgroundColor); + } + // 요소들 설정 if (dashboard.elements && dashboard.elements.length > 0) { setElements(dashboard.elements); @@ -241,9 +257,17 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D if (dashboardId) { // 기존 대시보드 업데이트 - savedDashboard = await dashboardApi.updateDashboard(dashboardId, { + const updateData = { elements: elementsData, - }); + settings: { + resolution, + backgroundColor: canvasBackgroundColor, + }, + }; + console.log("💾 저장할 데이터:", updateData); + console.log("💾 저장할 해상도:", resolution); + + savedDashboard = await dashboardApi.updateDashboard(dashboardId, updateData); alert(`대시보드 "${savedDashboard.title}"이 업데이트되었습니다!`); @@ -261,6 +285,10 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D description: description || undefined, isPublic: false, elements: elementsData, + settings: { + resolution, + backgroundColor: canvasBackgroundColor, + }, }; savedDashboard = await dashboardApi.createDashboard(dashboardData); diff --git a/frontend/lib/api/dashboard.ts b/frontend/lib/api/dashboard.ts index f6365854..7767d89b 100644 --- a/frontend/lib/api/dashboard.ts +++ b/frontend/lib/api/dashboard.ts @@ -78,6 +78,10 @@ export interface Dashboard { elementsCount?: number; creatorName?: string; elements?: DashboardElement[]; + settings?: { + resolution?: string; + backgroundColor?: string; + }; } export interface CreateDashboardRequest { @@ -87,6 +91,10 @@ export interface CreateDashboardRequest { elements: DashboardElement[]; tags?: string[]; category?: string; + settings?: { + resolution?: string; + backgroundColor?: string; + }; } export interface DashboardListQuery { From 8f676c0a6dbda244092e736f2786058025cea47c Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 16 Oct 2025 10:33:21 +0900 Subject: [PATCH 10/28] =?UTF-8?q?=EB=B0=B0=ED=8F=AC=EC=9A=A9=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/services/bookingService.ts | 54 +++++++++++----- .../src/services/mailAccountFileService.ts | 6 +- .../src/services/mailReceiveBasicService.ts | 6 +- .../src/services/mailSentHistoryService.ts | 14 ++--- .../src/services/mailTemplateFileService.ts | 8 +-- backend-node/src/services/todoService.ts | 62 +++++++++++++------ docker/deploy/backend.Dockerfile | 13 ++-- docker/prod/backend.Dockerfile | 7 ++- scripts/prod/deploy.sh | 10 ++- 9 files changed, 107 insertions(+), 73 deletions(-) diff --git a/backend-node/src/services/bookingService.ts b/backend-node/src/services/bookingService.ts index 79935414..b27544e1 100644 --- a/backend-node/src/services/bookingService.ts +++ b/backend-node/src/services/bookingService.ts @@ -53,13 +53,20 @@ export class BookingService { } private ensureDataDirectory(): void { - if (!fs.existsSync(BOOKING_DIR)) { - fs.mkdirSync(BOOKING_DIR, { recursive: true }); - logger.info(`📁 예약 데이터 디렉토리 생성: ${BOOKING_DIR}`); - } - if (!fs.existsSync(BOOKING_FILE)) { - fs.writeFileSync(BOOKING_FILE, JSON.stringify([], null, 2)); - logger.info(`📄 예약 파일 생성: ${BOOKING_FILE}`); + try { + if (!fs.existsSync(BOOKING_DIR)) { + fs.mkdirSync(BOOKING_DIR, { recursive: true, mode: 0o755 }); + logger.info(`📁 예약 데이터 디렉토리 생성: ${BOOKING_DIR}`); + } + if (!fs.existsSync(BOOKING_FILE)) { + fs.writeFileSync(BOOKING_FILE, JSON.stringify([], null, 2), { + mode: 0o644, + }); + logger.info(`📄 예약 파일 생성: ${BOOKING_FILE}`); + } + } catch (error) { + logger.error(`❌ 예약 디렉토리 생성 실패: ${BOOKING_DIR}`, error); + throw error; } } @@ -111,13 +118,16 @@ export class BookingService { priority?: string; }): Promise<{ bookings: BookingRequest[]; newCount: number }> { try { - const bookings = DATA_SOURCE === "database" - ? await this.loadBookingsFromDB(filter) - : this.loadBookingsFromFile(filter); + const bookings = + DATA_SOURCE === "database" + ? await this.loadBookingsFromDB(filter) + : this.loadBookingsFromFile(filter); bookings.sort((a, b) => { if (a.priority !== b.priority) return a.priority === "urgent" ? -1 : 1; - return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + return ( + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); }); const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); @@ -145,7 +155,10 @@ export class BookingService { } } - public async rejectBooking(id: string, reason?: string): Promise { + public async rejectBooking( + id: string, + reason?: string + ): Promise { try { if (DATA_SOURCE === "database") { return await this.rejectBookingDB(id, reason); @@ -194,9 +207,15 @@ export class BookingService { scheduledTime: new Date(row.scheduledTime).toISOString(), createdAt: new Date(row.createdAt).toISOString(), updatedAt: new Date(row.updatedAt).toISOString(), - acceptedAt: row.acceptedAt ? new Date(row.acceptedAt).toISOString() : undefined, - rejectedAt: row.rejectedAt ? new Date(row.rejectedAt).toISOString() : undefined, - completedAt: row.completedAt ? new Date(row.completedAt).toISOString() : undefined, + acceptedAt: row.acceptedAt + ? new Date(row.acceptedAt).toISOString() + : undefined, + rejectedAt: row.rejectedAt + ? new Date(row.rejectedAt).toISOString() + : undefined, + completedAt: row.completedAt + ? new Date(row.completedAt).toISOString() + : undefined, })); } @@ -230,7 +249,10 @@ export class BookingService { }; } - private async rejectBookingDB(id: string, reason?: string): Promise { + private async rejectBookingDB( + id: string, + reason?: string + ): Promise { const rows = await query( `UPDATE booking_requests SET status = 'rejected', rejected_at = NOW(), updated_at = NOW(), rejection_reason = $2 diff --git a/backend-node/src/services/mailAccountFileService.ts b/backend-node/src/services/mailAccountFileService.ts index 7b07b531..e547171a 100644 --- a/backend-node/src/services/mailAccountFileService.ts +++ b/backend-node/src/services/mailAccountFileService.ts @@ -33,11 +33,7 @@ class MailAccountFileService { try { await fs.access(this.accountsDir); } catch { - try { - await fs.mkdir(this.accountsDir, { recursive: true }); - } catch (error) { - console.error("메일 계정 디렉토리 생성 실패:", error); - } + await fs.mkdir(this.accountsDir, { recursive: true, mode: 0o755 }); } } diff --git a/backend-node/src/services/mailReceiveBasicService.ts b/backend-node/src/services/mailReceiveBasicService.ts index 741353fa..d5e3a78f 100644 --- a/backend-node/src/services/mailReceiveBasicService.ts +++ b/backend-node/src/services/mailReceiveBasicService.ts @@ -59,11 +59,7 @@ export class MailReceiveBasicService { try { await fs.access(this.attachmentsDir); } catch { - try { - await fs.mkdir(this.attachmentsDir, { recursive: true }); - } catch (error) { - console.error("메일 첨부파일 디렉토리 생성 실패:", error); - } + await fs.mkdir(this.attachmentsDir, { recursive: true, mode: 0o755 }); } } diff --git a/backend-node/src/services/mailSentHistoryService.ts b/backend-node/src/services/mailSentHistoryService.ts index 61fd6f89..c7828888 100644 --- a/backend-node/src/services/mailSentHistoryService.ts +++ b/backend-node/src/services/mailSentHistoryService.ts @@ -20,15 +20,13 @@ const SENT_MAIL_DIR = class MailSentHistoryService { constructor() { - // 디렉토리 생성 (없으면) - try-catch로 권한 에러 방지 try { if (!fs.existsSync(SENT_MAIL_DIR)) { - fs.mkdirSync(SENT_MAIL_DIR, { recursive: true }); + fs.mkdirSync(SENT_MAIL_DIR, { recursive: true, mode: 0o755 }); } } catch (error) { console.error("메일 발송 이력 디렉토리 생성 실패:", error); - // 디렉토리가 이미 존재하거나 권한이 없어도 서비스는 계속 실행 - // 실제 파일 쓰기 시점에 에러 처리 + throw error; } } @@ -45,13 +43,15 @@ class MailSentHistoryService { }; try { - // 디렉토리가 없으면 다시 시도 if (!fs.existsSync(SENT_MAIL_DIR)) { - fs.mkdirSync(SENT_MAIL_DIR, { recursive: true }); + fs.mkdirSync(SENT_MAIL_DIR, { recursive: true, mode: 0o755 }); } const filePath = path.join(SENT_MAIL_DIR, `${history.id}.json`); - fs.writeFileSync(filePath, JSON.stringify(history, null, 2), "utf-8"); + fs.writeFileSync(filePath, JSON.stringify(history, null, 2), { + encoding: "utf-8", + mode: 0o644, + }); console.log("발송 이력 저장:", history.id); } catch (error) { diff --git a/backend-node/src/services/mailTemplateFileService.ts b/backend-node/src/services/mailTemplateFileService.ts index 7a8d4300..e1a878b9 100644 --- a/backend-node/src/services/mailTemplateFileService.ts +++ b/backend-node/src/services/mailTemplateFileService.ts @@ -54,17 +54,13 @@ class MailTemplateFileService { } /** - * 템플릿 디렉토리 생성 (없으면) - try-catch로 권한 에러 방지 + * 템플릿 디렉토리 생성 */ private async ensureDirectoryExists() { try { await fs.access(this.templatesDir); } catch { - try { - await fs.mkdir(this.templatesDir, { recursive: true }); - } catch (error) { - console.error("메일 템플릿 디렉토리 생성 실패:", error); - } + await fs.mkdir(this.templatesDir, { recursive: true, mode: 0o755 }); } } diff --git a/backend-node/src/services/todoService.ts b/backend-node/src/services/todoService.ts index 1347c665..33becbb9 100644 --- a/backend-node/src/services/todoService.ts +++ b/backend-node/src/services/todoService.ts @@ -61,13 +61,20 @@ export class TodoService { * 데이터 디렉토리 생성 (파일 모드) */ private ensureDataDirectory(): void { - if (!fs.existsSync(TODO_DIR)) { - fs.mkdirSync(TODO_DIR, { recursive: true }); - logger.info(`📁 To-Do 데이터 디렉토리 생성: ${TODO_DIR}`); - } - if (!fs.existsSync(TODO_FILE)) { - fs.writeFileSync(TODO_FILE, JSON.stringify([], null, 2)); - logger.info(`📄 To-Do 파일 생성: ${TODO_FILE}`); + try { + if (!fs.existsSync(TODO_DIR)) { + fs.mkdirSync(TODO_DIR, { recursive: true, mode: 0o755 }); + logger.info(`📁 To-Do 데이터 디렉토리 생성: ${TODO_DIR}`); + } + if (!fs.existsSync(TODO_FILE)) { + fs.writeFileSync(TODO_FILE, JSON.stringify([], null, 2), { + mode: 0o644, + }); + logger.info(`📄 To-Do 파일 생성: ${TODO_FILE}`); + } + } catch (error) { + logger.error(`❌ To-Do 디렉토리 생성 실패: ${TODO_DIR}`, error); + throw error; } } @@ -80,15 +87,17 @@ export class TodoService { assignedTo?: string; }): Promise { try { - const todos = DATA_SOURCE === "database" - ? await this.loadTodosFromDB(filter) - : this.loadTodosFromFile(filter); + const todos = + DATA_SOURCE === "database" + ? await this.loadTodosFromDB(filter) + : this.loadTodosFromFile(filter); // 정렬: 긴급 > 우선순위 > 순서 todos.sort((a, b) => { if (a.isUrgent !== b.isUrgent) return a.isUrgent ? -1 : 1; const priorityOrder = { urgent: 0, high: 1, normal: 2, low: 3 }; - if (a.priority !== b.priority) return priorityOrder[a.priority] - priorityOrder[b.priority]; + if (a.priority !== b.priority) + return priorityOrder[a.priority] - priorityOrder[b.priority]; return a.order - b.order; }); @@ -124,7 +133,8 @@ export class TodoService { await this.createTodoDB(newTodo); } else { const todos = this.loadTodosFromFile(); - newTodo.order = todos.length > 0 ? Math.max(...todos.map((t) => t.order)) + 1 : 0; + newTodo.order = + todos.length > 0 ? Math.max(...todos.map((t) => t.order)) + 1 : 0; todos.push(newTodo); this.saveTodosToFile(todos); } @@ -140,7 +150,10 @@ export class TodoService { /** * To-Do 항목 수정 */ - public async updateTodo(id: string, updates: Partial): Promise { + public async updateTodo( + id: string, + updates: Partial + ): Promise { try { if (DATA_SOURCE === "database") { return await this.updateTodoDB(id, updates); @@ -231,7 +244,9 @@ export class TodoService { dueDate: row.dueDate ? new Date(row.dueDate).toISOString() : undefined, createdAt: new Date(row.createdAt).toISOString(), updatedAt: new Date(row.updatedAt).toISOString(), - completedAt: row.completedAt ? new Date(row.completedAt).toISOString() : undefined, + completedAt: row.completedAt + ? new Date(row.completedAt).toISOString() + : undefined, })); } @@ -263,7 +278,10 @@ export class TodoService { ); } - private async updateTodoDB(id: string, updates: Partial): Promise { + private async updateTodoDB( + id: string, + updates: Partial + ): Promise { const setClauses: string[] = ["updated_at = NOW()"]; const params: any[] = []; let paramIndex = 1; @@ -327,12 +345,17 @@ export class TodoService { dueDate: row.dueDate ? new Date(row.dueDate).toISOString() : undefined, createdAt: new Date(row.createdAt).toISOString(), updatedAt: new Date(row.updatedAt).toISOString(), - completedAt: row.completedAt ? new Date(row.completedAt).toISOString() : undefined, + completedAt: row.completedAt + ? new Date(row.completedAt).toISOString() + : undefined, }; } private async deleteTodoDB(id: string): Promise { - const rows = await query("DELETE FROM todo_items WHERE id = $1 RETURNING id", [id]); + const rows = await query( + "DELETE FROM todo_items WHERE id = $1 RETURNING id", + [id] + ); if (rows.length === 0) { throw new Error(`To-Do 항목을 찾을 수 없습니다: ${id}`); } @@ -443,7 +466,10 @@ export class TodoService { inProgress: todos.filter((t) => t.status === "in_progress").length, completed: todos.filter((t) => t.status === "completed").length, urgent: todos.filter((t) => t.isUrgent).length, - overdue: todos.filter((t) => t.dueDate && new Date(t.dueDate) < now && t.status !== "completed").length, + overdue: todos.filter( + (t) => + t.dueDate && new Date(t.dueDate) < now && t.status !== "completed" + ).length, }; } } diff --git a/docker/deploy/backend.Dockerfile b/docker/deploy/backend.Dockerfile index bbfd3438..a5dd1aeb 100644 --- a/docker/deploy/backend.Dockerfile +++ b/docker/deploy/backend.Dockerfile @@ -34,14 +34,11 @@ COPY --from=build /app/dist ./dist # Copy package files COPY package*.json ./ -# Create logs, uploads, and data directories and set permissions (use existing node user with UID 1000) -RUN mkdir -p logs \ - uploads/mail-attachments \ - uploads/mail-templates \ - uploads/mail-accounts \ - data/mail-sent && \ - chown -R node:node logs uploads data && \ - chmod -R 755 logs uploads data +# 루트 디렉토리만 생성하고 node 유저에게 쓰기 권한 부여 +# 하위 디렉토리는 애플리케이션이 런타임에 자동 생성 +RUN mkdir -p logs uploads data && \ + chown -R node:node /app && \ + chmod -R 755 /app EXPOSE 3001 USER node diff --git a/docker/prod/backend.Dockerfile b/docker/prod/backend.Dockerfile index 8ef8a372..7944bc67 100644 --- a/docker/prod/backend.Dockerfile +++ b/docker/prod/backend.Dockerfile @@ -37,8 +37,11 @@ COPY --from=build /app/dist ./dist # Copy package files COPY package*.json ./ -# Create logs and uploads directories and set permissions -RUN mkdir -p logs uploads && chown -R appuser:appgroup logs uploads && chmod -R 755 logs uploads +# 루트 디렉토리만 생성하고 appuser에게 쓰기 권한 부여 +# 하위 디렉토리는 애플리케이션이 런타임에 자동 생성 +RUN mkdir -p logs uploads data && \ + chown -R appuser:appgroup /app && \ + chmod -R 755 /app EXPOSE 8080 USER appuser diff --git a/scripts/prod/deploy.sh b/scripts/prod/deploy.sh index b5388d54..cad282cd 100755 --- a/scripts/prod/deploy.sh +++ b/scripts/prod/deploy.sh @@ -20,17 +20,15 @@ echo "" echo "[1/6] Git 최신 코드 가져오기..." git pull origin main -# 호스트 디렉토리 준비 +# 호스트 디렉토리 준비 (볼륨 마운트용 루트 디렉토리만 생성) echo "" echo "[2/6] 호스트 디렉토리 준비..." -mkdir -p /home/vexplor/backend_data/data/mail-sent -mkdir -p /home/vexplor/backend_data/uploads/mail-attachments -mkdir -p /home/vexplor/backend_data/uploads/mail-templates -mkdir -p /home/vexplor/backend_data/uploads/mail-accounts +mkdir -p /home/vexplor/backend_data/uploads +mkdir -p /home/vexplor/backend_data/data mkdir -p /home/vexplor/frontend_data chmod -R 755 /home/vexplor/backend_data chmod -R 755 /home/vexplor/frontend_data -echo "디렉토리 생성 완료 (mail-sent, mail-attachments, mail-templates, mail-accounts, frontend)" +echo "볼륨 마운트 디렉토리 생성 완료 (하위 디렉토리는 컨테이너가 자동 생성)" # 기존 컨테이너 중지 및 제거 echo "" From 93498674769952915e7d9da4ee61fe512591017d Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 16 Oct 2025 10:39:48 +0900 Subject: [PATCH 11/28] =?UTF-8?q?=EB=B0=B0=ED=8F=AC=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A6=BD=ED=8A=B8=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/prod/deploy.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/prod/deploy.sh b/scripts/prod/deploy.sh index cad282cd..8535f372 100755 --- a/scripts/prod/deploy.sh +++ b/scripts/prod/deploy.sh @@ -26,9 +26,13 @@ echo "[2/6] 호스트 디렉토리 준비..." mkdir -p /home/vexplor/backend_data/uploads mkdir -p /home/vexplor/backend_data/data mkdir -p /home/vexplor/frontend_data + +# node 유저(UID 1000)와 동일한 소유권 설정 +chown -R 1000:1000 /home/vexplor/backend_data +chown -R 1001:1001 /home/vexplor/frontend_data chmod -R 755 /home/vexplor/backend_data chmod -R 755 /home/vexplor/frontend_data -echo "볼륨 마운트 디렉토리 생성 완료 (하위 디렉토리는 컨테이너가 자동 생성)" +echo "볼륨 마운트 디렉토리 생성 완료 (node 유저 권한 부여)" # 기존 컨테이너 중지 및 제거 echo "" From 02f67c23726175e84f1d8ba6bb91a5c1bde78b11 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 16 Oct 2025 10:47:24 +0900 Subject: [PATCH 12/28] =?UTF-8?q?=EC=A7=84=EC=A7=9C=20=EB=A7=88=EC=A7=80?= =?UTF-8?q?=EB=A7=89=20=EC=88=98=EC=A0=95=EC=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/deploy/docker-compose.yml | 14 +++++++++++--- scripts/prod/deploy.sh | 15 +++------------ 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/docker/deploy/docker-compose.yml b/docker/deploy/docker-compose.yml index d3934c00..e1c76ad9 100644 --- a/docker/deploy/docker-compose.yml +++ b/docker/deploy/docker-compose.yml @@ -20,8 +20,8 @@ services: LOG_LEVEL: info ENCRYPTION_KEY: ilshin-plm-mail-encryption-key-32characters-2024-secure volumes: - - /home/vexplor/backend_data/uploads:/app/uploads - - /home/vexplor/backend_data/data:/app/data + - backend_uploads:/app/uploads + - backend_data:/app/data labels: - traefik.enable=true - traefik.http.routers.backend.rule=Host(`api.vexplor.com`) @@ -46,7 +46,7 @@ services: PORT: "3000" HOSTNAME: 0.0.0.0 volumes: - - /home/vexplor/frontend_data:/app/data + - frontend_data:/app/data labels: - traefik.enable=true - traefik.http.routers.frontend.rule=Host(`v1.vexplor.com`) @@ -55,6 +55,14 @@ services: - traefik.http.routers.frontend.tls.certresolver=le - traefik.http.services.frontend.loadbalancer.server.port=3000 +volumes: + backend_uploads: + driver: local + backend_data: + driver: local + frontend_data: + driver: local + networks: default: name: toktork_server_default diff --git a/scripts/prod/deploy.sh b/scripts/prod/deploy.sh index 8535f372..d8430e3e 100755 --- a/scripts/prod/deploy.sh +++ b/scripts/prod/deploy.sh @@ -20,19 +20,10 @@ echo "" echo "[1/6] Git 최신 코드 가져오기..." git pull origin main -# 호스트 디렉토리 준비 (볼륨 마운트용 루트 디렉토리만 생성) +# Docker 볼륨 사용으로 호스트 디렉토리 준비 불필요 echo "" -echo "[2/6] 호스트 디렉토리 준비..." -mkdir -p /home/vexplor/backend_data/uploads -mkdir -p /home/vexplor/backend_data/data -mkdir -p /home/vexplor/frontend_data - -# node 유저(UID 1000)와 동일한 소유권 설정 -chown -R 1000:1000 /home/vexplor/backend_data -chown -R 1001:1001 /home/vexplor/frontend_data -chmod -R 755 /home/vexplor/backend_data -chmod -R 755 /home/vexplor/frontend_data -echo "볼륨 마운트 디렉토리 생성 완료 (node 유저 권한 부여)" +echo "[2/6] Docker 볼륨 확인..." +echo "Docker named volumes 사용 (권한 문제 없음)" # 기존 컨테이너 중지 및 제거 echo "" From a7123216f23e1a3ce55b7a0dd56f84d38fcf7cee Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 16 Oct 2025 11:03:57 +0900 Subject: [PATCH 13/28] =?UTF-8?q?=EB=B0=B1=EC=97=94=EB=93=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/types/dashboard.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/backend-node/src/types/dashboard.ts b/backend-node/src/types/dashboard.ts index c37beae8..7d5bfab6 100644 --- a/backend-node/src/types/dashboard.ts +++ b/backend-node/src/types/dashboard.ts @@ -4,8 +4,8 @@ export interface DashboardElement { id: string; - type: 'chart' | 'widget'; - subtype: 'bar' | 'pie' | 'line' | 'exchange' | 'weather'; + type: "chart" | "widget"; + subtype: "bar" | "pie" | "line" | "exchange" | "weather"; position: { x: number; y: number; @@ -17,7 +17,7 @@ export interface DashboardElement { title: string; content?: string; dataSource?: { - type: 'api' | 'database' | 'static'; + type: "api" | "database" | "static"; endpoint?: string; query?: string; refreshInterval?: number; @@ -28,7 +28,7 @@ export interface DashboardElement { xAxis?: string; yAxis?: string; groupBy?: string; - aggregation?: 'sum' | 'avg' | 'count' | 'max' | 'min'; + aggregation?: "sum" | "avg" | "count" | "max" | "min"; colors?: string[]; title?: string; showLegend?: boolean; @@ -58,6 +58,10 @@ export interface CreateDashboardRequest { elements: DashboardElement[]; tags?: string[]; category?: string; + settings?: { + resolution?: string; + backgroundColor?: string; + }; } export interface UpdateDashboardRequest { @@ -67,6 +71,10 @@ export interface UpdateDashboardRequest { elements?: DashboardElement[]; tags?: string[]; category?: string; + settings?: { + resolution?: string; + backgroundColor?: string; + }; } export interface DashboardListQuery { @@ -83,7 +91,7 @@ export interface DashboardShare { dashboardId: string; sharedWithUser?: string; sharedWithRole?: string; - permissionLevel: 'view' | 'edit' | 'admin'; + permissionLevel: "view" | "edit" | "admin"; createdBy: string; createdAt: string; expiresAt?: string; From 3bda194bf204e2f20d1eae9a334fa0204ff453cc Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 16 Oct 2025 11:09:11 +0900 Subject: [PATCH 14/28] =?UTF-8?q?=EC=BA=94=EB=B2=84=EC=8A=A4=20=EB=8F=99?= =?UTF-8?q?=EC=A0=81=20=EB=86=92=EC=9D=B4=20=EA=B8=B0=EB=8A=A5=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/DashboardCanvas.tsx | 4 +++- .../admin/dashboard/DashboardDesigner.tsx | 22 ++++++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/frontend/components/admin/dashboard/DashboardCanvas.tsx b/frontend/components/admin/dashboard/DashboardCanvas.tsx index f74af42d..d05f73cf 100644 --- a/frontend/components/admin/dashboard/DashboardCanvas.tsx +++ b/frontend/components/admin/dashboard/DashboardCanvas.tsx @@ -115,9 +115,11 @@ export const DashboardCanvas = forwardRef( return (
{ + if (elements.length === 0) { + return canvasConfig.height; // 기본 높이 + } + + // 모든 요소의 최하단 y 좌표 계산 + const maxBottomY = Math.max(...elements.map((el) => el.position.y + el.size.height)); + + // 최소 높이는 기본 높이, 요소가 아래로 내려가면 자동으로 늘어남 + // 패딩 추가 (100px 여유) + return Math.max(canvasConfig.height, maxBottomY + 100); + }, [elements, canvasConfig.height]); + + const dynamicCanvasHeight = calculateCanvasHeight(); + // 대시보드 ID가 props로 전달되면 로드 React.useEffect(() => { if (initialDashboardId) { @@ -335,12 +351,12 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D /> {/* 캔버스 영역 - 해상도에 따른 크기, 중앙 정렬 */} -
+
From 8e0ef82de76a5069eb826147fee8cff22f63efe0 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 16 Oct 2025 11:29:45 +0900 Subject: [PATCH 15/28] =?UTF-8?q?=ED=95=B4=EC=83=81=EB=8F=84=20=EB=B3=B5?= =?UTF-8?q?=EC=9B=90=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/services/DashboardService.ts | 45 ++++++++----------- backend-node/src/types/dashboard.ts | 4 ++ .../admin/dashboard/DashboardDesigner.tsx | 25 +++++++++-- 3 files changed, 44 insertions(+), 30 deletions(-) diff --git a/backend-node/src/services/DashboardService.ts b/backend-node/src/services/DashboardService.ts index 82ef499e..d7245ce0 100644 --- a/backend-node/src/services/DashboardService.ts +++ b/backend-node/src/services/DashboardService.ts @@ -321,34 +321,24 @@ export class DashboardService { ]); // 3. 요소 데이터 변환 - console.log("📊 대시보드 요소 개수:", elementsResult.rows.length); - const elements: DashboardElement[] = elementsResult.rows.map( - (row: any, index: number) => { - const element = { - id: row.id, - type: row.element_type, - subtype: row.element_subtype, - position: { - x: row.position_x, - y: row.position_y, - }, - size: { - width: row.width, - height: row.height, - }, - title: row.title, - content: row.content, - dataSource: JSON.parse(row.data_source_config || "{}"), - chartConfig: JSON.parse(row.chart_config || "{}"), - }; - - console.log( - `📊 위젯 #${index + 1}: type="${element.type}", subtype="${element.subtype}", title="${element.title}"` - ); - - return element; - } + (row: any) => ({ + id: row.id, + type: row.element_type, + subtype: row.element_subtype, + position: { + x: row.position_x, + y: row.position_y, + }, + size: { + width: row.width, + height: row.height, + }, + title: row.title, + content: row.content, + dataSource: JSON.parse(row.data_source_config || "{}"), + chartConfig: JSON.parse(row.chart_config || "{}"), + }) ); return { @@ -363,6 +353,7 @@ export class DashboardService { tags: JSON.parse(dashboard.tags || "[]"), category: dashboard.category, viewCount: parseInt(dashboard.view_count || "0"), + settings: dashboard.settings || undefined, elements, }; } catch (error) { diff --git a/backend-node/src/types/dashboard.ts b/backend-node/src/types/dashboard.ts index 7d5bfab6..f40ee768 100644 --- a/backend-node/src/types/dashboard.ts +++ b/backend-node/src/types/dashboard.ts @@ -48,6 +48,10 @@ export interface Dashboard { tags?: string[]; category?: string; viewCount: number; + settings?: { + resolution?: string; + backgroundColor?: string; + }; elements: DashboardElement[]; } diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 86d5fa40..60983eff 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -37,6 +37,15 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D const [screenResolution] = useState(() => detectScreenResolution()); const [resolution, setResolution] = useState(screenResolution); + // resolution 변경 감지 + const handleResolutionChange = useCallback((newResolution: Resolution) => { + console.log("🎯 해상도 변경 요청:", newResolution); + setResolution((prev) => { + console.log("🎯 이전 해상도:", prev); + return newResolution; + }); + }, []); + // 현재 해상도 설정 (안전하게 기본값 제공) const canvasConfig = RESOLUTIONS[resolution] || RESOLUTIONS.fhd; @@ -81,6 +90,8 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D // 저장된 설정 복원 console.log("🔍 로드된 대시보드:", dashboard); console.log("📦 저장된 settings:", (dashboard as any).settings); + console.log("🎯 settings 타입:", typeof (dashboard as any).settings); + console.log("🔍 resolution 값:", (dashboard as any).settings?.resolution); if ((dashboard as any).settings?.resolution) { const savedResolution = (dashboard as any).settings.resolution as Resolution; @@ -91,6 +102,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D } if ((dashboard as any).settings?.backgroundColor) { + console.log("✅ 저장된 배경색 복원:", (dashboard as any).settings.backgroundColor); setCanvasBackgroundColor((dashboard as any).settings.backgroundColor); } @@ -273,6 +285,9 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D if (dashboardId) { // 기존 대시보드 업데이트 + console.log("💾 저장 시작 - 현재 resolution 상태:", resolution); + console.log("💾 저장 시작 - 현재 배경색 상태:", canvasBackgroundColor); + const updateData = { elements: elementsData, settings: { @@ -280,11 +295,15 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D backgroundColor: canvasBackgroundColor, }, }; + console.log("💾 저장할 데이터:", updateData); - console.log("💾 저장할 해상도:", resolution); + console.log("💾 저장할 settings:", updateData.settings); savedDashboard = await dashboardApi.updateDashboard(dashboardId, updateData); + console.log("✅ 저장된 대시보드:", savedDashboard); + console.log("✅ 저장된 settings:", (savedDashboard as any).settings); + alert(`대시보드 "${savedDashboard.title}"이 업데이트되었습니다!`); // Next.js 라우터로 뷰어 페이지 이동 @@ -319,7 +338,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류"; alert(`대시보드 저장 중 오류가 발생했습니다.\n\n오류: ${errorMessage}\n\n관리자에게 문의하세요.`); } - }, [elements, dashboardId, router]); + }, [elements, dashboardId, router, resolution, canvasBackgroundColor]); // 로딩 중이면 로딩 화면 표시 if (isLoading) { @@ -344,7 +363,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D dashboardTitle={dashboardTitle} onAddElement={addElementFromMenu} resolution={resolution} - onResolutionChange={setResolution} + onResolutionChange={handleResolutionChange} currentScreenResolution={screenResolution} backgroundColor={canvasBackgroundColor} onBackgroundColorChange={setCanvasBackgroundColor} From a8c1b4b5e5110b86e078be29f6676e2e7aa65eec Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 16 Oct 2025 11:49:06 +0900 Subject: [PATCH 16/28] =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EB=B7=B0/?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=EB=AA=A8=EB=93=9C=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=8F=99=EC=9D=BC=ED=95=9C=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=A0=9C=EA=B3=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/dashboard/DashboardViewer.tsx | 38 +++++++++++-------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/frontend/components/dashboard/DashboardViewer.tsx b/frontend/components/dashboard/DashboardViewer.tsx index c6e941e3..f4f8caea 100644 --- a/frontend/components/dashboard/DashboardViewer.tsx +++ b/frontend/components/dashboard/DashboardViewer.tsx @@ -25,7 +25,18 @@ const DocumentWidget = dynamic(() => import("./widgets/DocumentWidget"), { ssr: const BookingAlertWidget = dynamic(() => import("./widgets/BookingAlertWidget"), { ssr: false }); const MaintenanceWidget = dynamic(() => import("./widgets/MaintenanceWidget"), { ssr: false }); const CalculatorWidget = dynamic(() => import("./widgets/CalculatorWidget"), { ssr: false }); -const CalendarWidget = dynamic(() => import("@/components/admin/dashboard/widgets/CalendarWidget").then(mod => ({ default: mod.CalendarWidget })), { ssr: false }); +const CalendarWidget = dynamic( + () => import("@/components/admin/dashboard/widgets/CalendarWidget").then((mod) => ({ default: mod.CalendarWidget })), + { ssr: false }, +); +const ClockWidget = dynamic( + () => import("@/components/admin/dashboard/widgets/ClockWidget").then((mod) => ({ default: mod.ClockWidget })), + { ssr: false }, +); +const ListWidget = dynamic( + () => import("@/components/admin/dashboard/widgets/ListWidget").then((mod) => ({ default: mod.ListWidget })), + { ssr: false }, +); /** * 위젯 렌더링 함수 - DashboardSidebar의 모든 subtype 처리 @@ -34,7 +45,7 @@ const CalendarWidget = dynamic(() => import("@/components/admin/dashboard/widget function renderWidget(element: DashboardElement) { switch (element.subtype) { // 차트는 ChartRenderer에서 처리됨 (이 함수 호출 안됨) - + // === 위젯 종류 === case "exchange": return ; @@ -43,14 +54,7 @@ function renderWidget(element: DashboardElement) { case "calculator": return ; case "clock": - return ( -
-
-
-
시계 위젯 (개발 예정)
-
-
- ); + return ; case "map-summary": return ; case "list-summary": @@ -61,7 +65,7 @@ function renderWidget(element: DashboardElement) { return ; case "status-summary": return ; - + // === 운영/작업 지원 === case "todo": return ; @@ -72,8 +76,8 @@ function renderWidget(element: DashboardElement) { case "document": return ; case "list": - return ; - + return ; + // === 차량 관련 (추가 위젯) === case "vehicle-status": return ; @@ -81,7 +85,7 @@ function renderWidget(element: DashboardElement) { return ; case "vehicle-map": return ; - + // === 배송 관련 (추가 위젯) === case "delivery-status": return ; @@ -93,7 +97,7 @@ function renderWidget(element: DashboardElement) { return ; case "customer-issues": return ; - + // === 기본 fallback === default: return ( @@ -301,7 +305,9 @@ function ViewerElement({ element, data, isLoading, onRefresh }: ViewerElementPro
{element.type === "chart" ? ( - ) : renderWidget(element)} + ) : ( + renderWidget(element) + )}
{/* 로딩 오버레이 */} From c5499d2e18a59ebce88edcdf4a7ea47dbb8555fc Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 16 Oct 2025 11:55:14 +0900 Subject: [PATCH 17/28] =?UTF-8?q?=ED=95=B4=EC=83=81=EB=8F=84=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EC=8B=9C=20=EC=9A=94=EC=86=8C=EB=93=A4=20=EA=B0=84?= =?UTF-8?q?=EA=B2=A9=20=EC=A1=B0=EC=A0=88=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/DashboardDesigner.tsx | 62 ++++++++++++++++--- 1 file changed, 53 insertions(+), 9 deletions(-) diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 60983eff..048e5172 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -7,7 +7,7 @@ import { DashboardTopMenu } from "./DashboardTopMenu"; import { ElementConfigModal } from "./ElementConfigModal"; import { ListWidgetConfigModal } from "./widgets/ListWidgetConfigModal"; import { DashboardElement, ElementType, ElementSubtype } from "./types"; -import { GRID_CONFIG } from "./gridUtils"; +import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize } from "./gridUtils"; import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector"; interface DashboardDesignerProps { @@ -37,14 +37,58 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D const [screenResolution] = useState(() => detectScreenResolution()); const [resolution, setResolution] = useState(screenResolution); - // resolution 변경 감지 - const handleResolutionChange = useCallback((newResolution: Resolution) => { - console.log("🎯 해상도 변경 요청:", newResolution); - setResolution((prev) => { - console.log("🎯 이전 해상도:", prev); - return newResolution; - }); - }, []); + // resolution 변경 감지 및 요소 자동 조정 + const handleResolutionChange = useCallback( + (newResolution: Resolution) => { + console.log("🎯 해상도 변경 요청:", newResolution); + setResolution((prev) => { + console.log("🎯 이전 해상도:", prev); + + // 이전 해상도와 새 해상도의 캔버스 너비 비율 계산 + const oldConfig = RESOLUTIONS[prev]; + const newConfig = RESOLUTIONS[newResolution]; + const widthRatio = newConfig.width / oldConfig.width; + + console.log("📐 너비 비율:", widthRatio, `(${oldConfig.width}px → ${newConfig.width}px)`); + + // 요소들의 위치와 크기를 비율에 맞춰 조정 + if (widthRatio !== 1 && elements.length > 0) { + // 새 해상도의 셀 크기 계산 + const newCellSize = calculateCellSize(newConfig.width); + + const adjustedElements = elements.map((el) => { + // 비율에 맞춰 조정 (X와 너비만) + const scaledX = el.position.x * widthRatio; + const scaledWidth = el.size.width * widthRatio; + + // 그리드에 스냅 (X, Y, 너비, 높이 모두) + const snappedX = snapToGrid(scaledX, newCellSize); + const snappedY = snapToGrid(el.position.y, newCellSize); + const snappedWidth = snapSizeToGrid(scaledWidth, 2, newCellSize); + const snappedHeight = snapSizeToGrid(el.size.height, 2, newCellSize); + + return { + ...el, + position: { + x: snappedX, + y: snappedY, + }, + size: { + width: snappedWidth, + height: snappedHeight, + }, + }; + }); + + console.log("✨ 요소 위치/크기 자동 조정 (그리드 스냅 적용):", adjustedElements.length, "개"); + setElements(adjustedElements); + } + + return newResolution; + }); + }, + [elements], + ); // 현재 해상도 설정 (안전하게 기본값 제공) const canvasConfig = RESOLUTIONS[resolution] || RESOLUTIONS.fhd; From 71aaef7acb1ff41f36570795cc9d7cc4107cf1f9 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 16 Oct 2025 14:19:08 +0900 Subject: [PATCH 18/28] =?UTF-8?q?=EB=A9=94=EB=89=B4=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=97=90=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20=ED=95=A0?= =?UTF-8?q?=EB=8B=B9=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/admin/MenuFormModal.tsx | 287 ++++++++++++++------ 1 file changed, 211 insertions(+), 76 deletions(-) diff --git a/frontend/components/admin/MenuFormModal.tsx b/frontend/components/admin/MenuFormModal.tsx index f8d80592..ad9edfc4 100644 --- a/frontend/components/admin/MenuFormModal.tsx +++ b/frontend/components/admin/MenuFormModal.tsx @@ -47,12 +47,12 @@ export const MenuFormModal: React.FC = ({ uiTexts, }) => { // console.log("🎯 MenuFormModal 렌더링 - Props:", { - // isOpen, - // menuId, - // parentId, - // menuType, - // level, - // parentCompanyCode, + // isOpen, + // menuId, + // parentId, + // menuType, + // level, + // parentCompanyCode, // }); // 다국어 텍스트 가져오기 함수 @@ -75,12 +75,18 @@ export const MenuFormModal: React.FC = ({ }); // 화면 할당 관련 상태 - const [urlType, setUrlType] = useState<"direct" | "screen">("screen"); // URL 직접 입력 or 화면 할당 (기본값: 화면 할당) + const [urlType, setUrlType] = useState<"direct" | "screen" | "dashboard">("screen"); // URL 직접 입력 or 화면 할당 or 대시보드 할당 (기본값: 화면 할당) const [selectedScreen, setSelectedScreen] = useState(null); const [screens, setScreens] = useState([]); const [screenSearchText, setScreenSearchText] = useState(""); const [isScreenDropdownOpen, setIsScreenDropdownOpen] = useState(false); + // 대시보드 할당 관련 상태 + const [selectedDashboard, setSelectedDashboard] = useState(null); + const [dashboards, setDashboards] = useState([]); + const [dashboardSearchText, setDashboardSearchText] = useState(""); + const [isDashboardDropdownOpen, setIsDashboardDropdownOpen] = useState(false); + const [loading, setLoading] = useState(false); const [isEdit, setIsEdit] = useState(false); const [companies, setCompanies] = useState([]); @@ -93,21 +99,6 @@ export const MenuFormModal: React.FC = ({ try { const response = await screenApi.getScreens({ size: 1000 }); // 모든 화면 가져오기 - // console.log("🔍 화면 목록 로드 디버깅:", { - // totalScreens: response.data.length, - // firstScreen: response.data[0], - // firstScreenFields: response.data[0] ? Object.keys(response.data[0]) : [], - // firstScreenValues: response.data[0] ? Object.values(response.data[0]) : [], - // allScreenIds: response.data - // .map((s) => ({ - // screenId: s.screenId, - // legacyId: s.id, - // name: s.screenName, - // code: s.screenCode, - // })) - // .slice(0, 5), // 처음 5개만 출력 - // }); - setScreens(response.data); console.log("✅ 화면 목록 로드 완료:", response.data.length); } catch (error) { @@ -116,15 +107,28 @@ export const MenuFormModal: React.FC = ({ } }; + // 대시보드 목록 로드 + const loadDashboards = async () => { + try { + const { dashboardApi } = await import("@/lib/api/dashboard"); + const response = await dashboardApi.getMyDashboards(); + setDashboards(response.dashboards || []); + console.log("✅ 대시보드 목록 로드 완료:", response.dashboards?.length || 0); + } catch (error) { + console.error("❌ 대시보드 목록 로드 실패:", error); + toast.error("대시보드 목록을 불러오는데 실패했습니다."); + } + }; + // 화면 선택 시 URL 자동 설정 const handleScreenSelect = (screen: ScreenDefinition) => { // console.log("🖥️ 화면 선택 디버깅:", { - // screen, - // screenId: screen.screenId, - // screenIdType: typeof screen.screenId, - // legacyId: screen.id, - // allFields: Object.keys(screen), - // screenValues: Object.values(screen), + // screen, + // screenId: screen.screenId, + // screenIdType: typeof screen.screenId, + // legacyId: screen.id, + // allFields: Object.keys(screen), + // screenValues: Object.values(screen), // }); // ScreenDefinition에서는 screenId 필드를 사용 @@ -155,24 +159,42 @@ export const MenuFormModal: React.FC = ({ })); // console.log("🖥️ 화면 선택 완료:", { - // screenId: screen.screenId, - // legacyId: screen.id, - // actualScreenId, - // screenName: screen.screenName, - // menuType: menuType, - // formDataMenuType: formData.menuType, - // isAdminMenu, - // generatedUrl: screenUrl, + // screenId: screen.screenId, + // legacyId: screen.id, + // actualScreenId, + // screenName: screen.screenName, + // menuType: menuType, + // formDataMenuType: formData.menuType, + // isAdminMenu, + // generatedUrl: screenUrl, // }); }; + // 대시보드 선택 시 URL 자동 설정 + const handleDashboardSelect = (dashboard: any) => { + setSelectedDashboard(dashboard); + setIsDashboardDropdownOpen(false); + + // 대시보드 URL 생성 + let dashboardUrl = `/dashboard/${dashboard.id}`; + + // 현재 메뉴 타입이 관리자인지 확인 (0 또는 "admin") + const isAdminMenu = menuType === "0" || menuType === "admin" || formData.menuType === "0"; + if (isAdminMenu) { + dashboardUrl += "?mode=admin"; + } + + setFormData((prev) => ({ ...prev, menuUrl: dashboardUrl })); + toast.success(`대시보드가 선택되었습니다: ${dashboard.title}`); + }; + // URL 타입 변경 시 처리 - const handleUrlTypeChange = (type: "direct" | "screen") => { + const handleUrlTypeChange = (type: "direct" | "screen" | "dashboard") => { // console.log("🔄 URL 타입 변경:", { - // from: urlType, - // to: type, - // currentSelectedScreen: selectedScreen?.screenName, - // currentUrl: formData.menuUrl, + // from: urlType, + // to: type, + // currentSelectedScreen: selectedScreen?.screenName, + // currentUrl: formData.menuUrl, // }); setUrlType(type); @@ -286,10 +308,10 @@ export const MenuFormModal: React.FC = ({ const screenId = menuUrl.match(/\/screens\/(\d+)/)?.[1]; if (screenId) { // console.log("🔍 기존 메뉴에서 화면 ID 추출:", { - // menuUrl, - // screenId, - // hasAdminParam: menuUrl.includes("mode=admin"), - // currentScreensCount: screens.length, + // menuUrl, + // screenId, + // hasAdminParam: menuUrl.includes("mode=admin"), + // currentScreensCount: screens.length, // }); // 화면 설정 함수 @@ -298,15 +320,15 @@ export const MenuFormModal: React.FC = ({ if (screen) { setSelectedScreen(screen); // console.log("🖥️ 기존 메뉴의 할당된 화면 설정:", { - // screen, - // originalUrl: menuUrl, - // hasAdminParam: menuUrl.includes("mode=admin"), + // screen, + // originalUrl: menuUrl, + // hasAdminParam: menuUrl.includes("mode=admin"), // }); return true; } else { // console.warn("⚠️ 해당 ID의 화면을 찾을 수 없음:", { - // screenId, - // availableScreens: screens.map((s) => ({ screenId: s.screenId, id: s.id, name: s.screenName })), + // screenId, + // availableScreens: screens.map((s) => ({ screenId: s.screenId, id: s.id, name: s.screenName })), // }); return false; } @@ -325,30 +347,34 @@ export const MenuFormModal: React.FC = ({ }, 500); } } + } else if (menuUrl.startsWith("/dashboard/")) { + setUrlType("dashboard"); + setSelectedScreen(null); + // 대시보드 ID 추출 및 선택은 useEffect에서 처리됨 } else { setUrlType("direct"); setSelectedScreen(null); } // console.log("설정된 폼 데이터:", { - // objid: menu.objid || menu.OBJID, - // parentObjId: menu.parent_obj_id || menu.PARENT_OBJ_ID || "0", - // menuNameKor: menu.menu_name_kor || menu.MENU_NAME_KOR || "", - // menuUrl: menu.menu_url || menu.MENU_URL || "", - // menuDesc: menu.menu_desc || menu.MENU_DESC || "", - // seq: menu.seq || menu.SEQ || 1, - // menuType: convertedMenuType, - // status: convertedStatus, - // companyCode: companyCode, - // langKey: langKey, + // objid: menu.objid || menu.OBJID, + // parentObjId: menu.parent_obj_id || menu.PARENT_OBJ_ID || "0", + // menuNameKor: menu.menu_name_kor || menu.MENU_NAME_KOR || "", + // menuUrl: menu.menu_url || menu.MENU_URL || "", + // menuDesc: menu.menu_desc || menu.MENU_DESC || "", + // seq: menu.seq || menu.SEQ || 1, + // menuType: convertedMenuType, + // status: convertedStatus, + // companyCode: companyCode, + // langKey: langKey, // }); } } catch (error: any) { console.error("메뉴 정보 로딩 오류:", error); // console.error("오류 상세 정보:", { - // message: error?.message, - // stack: error?.stack, - // response: error?.response, + // message: error?.message, + // stack: error?.stack, + // response: error?.response, // }); toast.error(getText(MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_MENU_INFO)); } finally { @@ -391,11 +417,11 @@ export const MenuFormModal: React.FC = ({ }); // console.log("메뉴 등록 기본값 설정:", { - // parentObjId: parentId || "0", - // menuType: defaultMenuType, - // status: "ACTIVE", - // companyCode: "", - // langKey: "", + // parentObjId: parentId || "0", + // menuType: defaultMenuType, + // status: "ACTIVE", + // companyCode: "", + // langKey: "", // }); } }, [menuId, parentId, menuType]); @@ -430,10 +456,11 @@ export const MenuFormModal: React.FC = ({ } }, [isOpen, formData.companyCode]); - // 화면 목록 로드 + // 화면 목록 및 대시보드 목록 로드 useEffect(() => { if (isOpen) { loadScreens(); + loadDashboards(); } }, [isOpen]); @@ -449,9 +476,9 @@ export const MenuFormModal: React.FC = ({ if (screen) { setSelectedScreen(screen); // console.log("✅ 기존 메뉴의 할당된 화면 자동 설정 완료:", { - // screenId, - // screenName: screen.screenName, - // menuUrl, + // screenId, + // screenName: screen.screenName, + // menuUrl, // }); } } @@ -459,6 +486,23 @@ export const MenuFormModal: React.FC = ({ } }, [screens, isEdit, formData.menuUrl, urlType, selectedScreen]); + // 대시보드 목록 로드 완료 후 기존 메뉴의 할당된 대시보드 설정 + useEffect(() => { + if (dashboards.length > 0 && isEdit && formData.menuUrl && urlType === "dashboard") { + const menuUrl = formData.menuUrl; + if (menuUrl.startsWith("/dashboard/")) { + const dashboardId = menuUrl.replace("/dashboard/", ""); + if (dashboardId && !selectedDashboard) { + console.log("🔄 대시보드 목록 로드 완료 - 기존 할당 대시보드 자동 설정"); + const dashboard = dashboards.find((d) => d.id === dashboardId); + if (dashboard) { + setSelectedDashboard(dashboard); + } + } + } + } + }, [dashboards, isEdit, formData.menuUrl, urlType, selectedDashboard]); + // 드롭다운 외부 클릭 시 닫기 useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -471,9 +515,13 @@ export const MenuFormModal: React.FC = ({ setIsScreenDropdownOpen(false); setScreenSearchText(""); } + if (!target.closest(".dashboard-dropdown")) { + setIsDashboardDropdownOpen(false); + setDashboardSearchText(""); + } }; - if (isLangKeyDropdownOpen || isScreenDropdownOpen) { + if (isLangKeyDropdownOpen || isScreenDropdownOpen || isDashboardDropdownOpen) { document.addEventListener("mousedown", handleClickOutside); } @@ -751,6 +799,12 @@ export const MenuFormModal: React.FC = ({ 화면 할당
+
+ + +
); } diff --git a/frontend/components/admin/dashboard/MenuAssignmentModal.tsx b/frontend/components/admin/dashboard/MenuAssignmentModal.tsx new file mode 100644 index 00000000..9220a0c8 --- /dev/null +++ b/frontend/components/admin/dashboard/MenuAssignmentModal.tsx @@ -0,0 +1,210 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { toast } from "sonner"; +import { menuApi, MenuItem } from "@/lib/api/menu"; +import { Loader2 } from "lucide-react"; + +interface MenuAssignmentModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: (assignToMenu: boolean, menuId?: string, menuType?: "0" | "1") => void; + dashboardId: string; + dashboardTitle: string; +} + +export const MenuAssignmentModal: React.FC = ({ + isOpen, + onClose, + onConfirm, + dashboardId, + dashboardTitle, +}) => { + const [assignToMenu, setAssignToMenu] = useState(false); + const [selectedMenuId, setSelectedMenuId] = useState(""); + const [selectedMenuType, setSelectedMenuType] = useState<"0" | "1">("0"); + const [adminMenus, setAdminMenus] = useState([]); + const [userMenus, setUserMenus] = useState([]); + const [loading, setLoading] = useState(false); + + // 메뉴 목록 로드 + useEffect(() => { + if (isOpen && assignToMenu) { + loadMenus(); + } + }, [isOpen, assignToMenu]); + + const loadMenus = async () => { + try { + setLoading(true); + const [adminResponse, userResponse] = await Promise.all([ + menuApi.getAdminMenus(), // 관리자 메뉴 + menuApi.getUserMenus(), // 사용자 메뉴 + ]); + + if (adminResponse.success) { + setAdminMenus(adminResponse.data || []); + } + if (userResponse.success) { + setUserMenus(userResponse.data || []); + } + } catch (error) { + console.error("메뉴 목록 로드 실패:", error); + toast.error("메뉴 목록을 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }; + + // 메뉴 트리를 평탄화하여 Select 옵션으로 변환 + const flattenMenus = (menus: MenuItem[], level: number = 0): Array<{ id: string; name: string; level: number }> => { + const result: Array<{ id: string; name: string; level: number }> = []; + + menus.forEach((menu) => { + const menuId = menu.objid || menu.OBJID || ""; + const menuName = menu.menu_name_kor || menu.MENU_NAME_KOR || ""; + const parentId = menu.parent_obj_id || menu.PARENT_OBJ_ID || "0"; + + // 메뉴 이름이 있고, 최상위가 아닌 경우에만 추가 + if (menuName && parentId !== "0") { + result.push({ + id: menuId, + name: " ".repeat(level) + menuName, + level, + }); + + // 하위 메뉴가 있으면 재귀 호출 + const children = menus.filter((m) => (m.parent_obj_id || m.PARENT_OBJ_ID) === menuId); + if (children.length > 0) { + result.push(...flattenMenus(children, level + 1)); + } + } + }); + + return result; + }; + + const currentMenus = selectedMenuType === "0" ? adminMenus : userMenus; + const flatMenus = flattenMenus(currentMenus); + + const handleConfirm = () => { + if (assignToMenu && !selectedMenuId) { + toast.error("메뉴를 선택해주세요."); + return; + } + + onConfirm(assignToMenu, selectedMenuId, selectedMenuType); + }; + + const handleClose = () => { + setAssignToMenu(false); + setSelectedMenuId(""); + setSelectedMenuType("0"); + onClose(); + }; + + return ( + + + + 대시보드 저장 완료 + '{dashboardTitle}' 대시보드가 저장되었습니다. + + +
+
+ + setAssignToMenu(value === "yes")} + className="flex space-x-4" + > +
+ + +
+
+ + +
+
+
+ + {assignToMenu && ( + <> +
+ + { + setSelectedMenuType(value as "0" | "1"); + setSelectedMenuId(""); // 메뉴 타입 변경 시 선택 초기화 + }} + className="flex space-x-4" + > +
+ + +
+
+ + +
+
+
+ +
+ + {loading ? ( +
+ +
+ ) : ( + + )} +
+ + )} +
+ + + + + +
+
+ ); +}; From 6d51aced2c16994331f676b3eade38bd1600bd37 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Thu, 16 Oct 2025 14:59:07 +0900 Subject: [PATCH 20/28] =?UTF-8?q?=EC=9C=84=EC=A0=AF=20=EC=BB=A4=EC=8A=A4?= =?UTF-8?q?=ED=85=80=20=EC=A0=9C=EB=AA=A9=20=EB=B0=8F=20=ED=97=A4=EB=8D=94?= =?UTF-8?q?=20=ED=91=9C=EC=8B=9C/=EC=88=A8=EA=B9=80=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 위젯 설정에서 제목 변경 가능 - 위젯 헤더 표시/숨김 토글 추가 - DB 마이그레이션 자동 실행 (custom_title, show_header 컬럼) - 편집 모드/보기 모드 모두 지원 - DashboardTopMenu 레이아웃 유지 --- backend-node/src/app.ts | 8 ++++ backend-node/src/database/runMigration.ts | 42 +++++++++++++++++++ backend-node/src/services/DashboardService.ts | 14 +++++-- backend-node/src/types/dashboard.ts | 2 + .../admin/dashboard/CanvasElement.tsx | 2 +- .../admin/dashboard/DashboardDesigner.tsx | 2 + .../admin/dashboard/ElementConfigModal.tsx | 18 +++++++- frontend/components/admin/dashboard/types.ts | 1 + .../components/dashboard/DashboardViewer.tsx | 42 ++++++++++--------- 9 files changed, 105 insertions(+), 26 deletions(-) create mode 100644 backend-node/src/database/runMigration.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index ae10a6fe..0d7e0e15 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -231,6 +231,14 @@ app.listen(PORT, HOST, async () => { logger.info(`🔗 Health check: http://${HOST}:${PORT}/health`); logger.info(`🌐 External access: http://39.117.244.52:${PORT}/health`); + // 대시보드 마이그레이션 실행 + try { + const { runDashboardMigration } = await import('./database/runMigration'); + await runDashboardMigration(); + } catch (error) { + logger.error(`❌ 대시보드 마이그레이션 실패:`, error); + } + // 배치 스케줄러 초기화 try { await BatchSchedulerService.initialize(); diff --git a/backend-node/src/database/runMigration.ts b/backend-node/src/database/runMigration.ts new file mode 100644 index 00000000..61b98241 --- /dev/null +++ b/backend-node/src/database/runMigration.ts @@ -0,0 +1,42 @@ +import { PostgreSQLService } from './PostgreSQLService'; + +/** + * 데이터베이스 마이그레이션 실행 + * dashboard_elements 테이블에 custom_title, show_header 컬럼 추가 + */ +export async function runDashboardMigration() { + try { + console.log('🔄 대시보드 마이그레이션 시작...'); + + // custom_title 컬럼 추가 + await PostgreSQLService.query(` + ALTER TABLE dashboard_elements + ADD COLUMN IF NOT EXISTS custom_title VARCHAR(255) + `); + console.log('✅ custom_title 컬럼 추가 완료'); + + // show_header 컬럼 추가 + await PostgreSQLService.query(` + ALTER TABLE dashboard_elements + ADD COLUMN IF NOT EXISTS show_header BOOLEAN DEFAULT true + `); + console.log('✅ show_header 컬럼 추가 완료'); + + // 기존 데이터 업데이트 + await PostgreSQLService.query(` + UPDATE dashboard_elements + SET show_header = true + WHERE show_header IS NULL + `); + console.log('✅ 기존 데이터 업데이트 완료'); + + console.log('✅ 대시보드 마이그레이션 완료!'); + } catch (error) { + console.error('❌ 대시보드 마이그레이션 실패:', error); + // 이미 컬럼이 있는 경우는 무시 + if (error instanceof Error && error.message.includes('already exists')) { + console.log('ℹ️ 컬럼이 이미 존재합니다.'); + } + } +} + diff --git a/backend-node/src/services/DashboardService.ts b/backend-node/src/services/DashboardService.ts index d7245ce0..f8816555 100644 --- a/backend-node/src/services/DashboardService.ts +++ b/backend-node/src/services/DashboardService.ts @@ -60,9 +60,9 @@ export class DashboardService { INSERT INTO dashboard_elements ( id, dashboard_id, element_type, element_subtype, position_x, position_y, width, height, - title, content, data_source_config, chart_config, + title, custom_title, show_header, content, data_source_config, chart_config, display_order, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) `, [ elementId, @@ -74,6 +74,8 @@ export class DashboardService { element.size.width, element.size.height, element.title, + element.customTitle || null, + element.showHeader !== false, // 기본값 true element.content || null, JSON.stringify(element.dataSource || {}), JSON.stringify(element.chartConfig || {}), @@ -335,6 +337,8 @@ export class DashboardService { height: row.height, }, title: row.title, + customTitle: row.custom_title || undefined, + showHeader: row.show_header !== false, // 기본값 true content: row.content, dataSource: JSON.parse(row.data_source_config || "{}"), chartConfig: JSON.parse(row.chart_config || "{}"), @@ -460,9 +464,9 @@ export class DashboardService { INSERT INTO dashboard_elements ( id, dashboard_id, element_type, element_subtype, position_x, position_y, width, height, - title, content, data_source_config, chart_config, + title, custom_title, show_header, content, data_source_config, chart_config, display_order, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) `, [ elementId, @@ -474,6 +478,8 @@ export class DashboardService { element.size.width, element.size.height, element.title, + element.customTitle || null, + element.showHeader !== false, // 기본값 true element.content || null, JSON.stringify(element.dataSource || {}), JSON.stringify(element.chartConfig || {}), diff --git a/backend-node/src/types/dashboard.ts b/backend-node/src/types/dashboard.ts index f40ee768..789adda3 100644 --- a/backend-node/src/types/dashboard.ts +++ b/backend-node/src/types/dashboard.ts @@ -15,6 +15,8 @@ export interface DashboardElement { height: number; }; title: string; + customTitle?: string; // 사용자 정의 제목 (옵션) + showHeader?: boolean; // 헤더 표시 여부 (기본값: true) content?: string; dataSource?: { type: "api" | "database" | "static"; diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index c1a682d3..7050b1f2 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -455,7 +455,7 @@ export function CanvasElement({ > {/* 헤더 */}
- {element.title} + {element.customTitle || element.title}
{/* 설정 버튼 (시계, 달력, 기사관리 위젯은 자체 설정 UI 사용) */} {onConfigure && diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 048e5172..9cbff1b8 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -320,6 +320,8 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D position: el.position, size: el.size, title: el.title, + customTitle: el.customTitle, + showHeader: el.showHeader, content: el.content, dataSource: el.dataSource, chartConfig: el.chartConfig, diff --git a/frontend/components/admin/dashboard/ElementConfigModal.tsx b/frontend/components/admin/dashboard/ElementConfigModal.tsx index aa074317..5e2df2df 100644 --- a/frontend/components/admin/dashboard/ElementConfigModal.tsx +++ b/frontend/components/admin/dashboard/ElementConfigModal.tsx @@ -32,6 +32,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element const [queryResult, setQueryResult] = useState(null); const [currentStep, setCurrentStep] = useState<1 | 2>(1); const [customTitle, setCustomTitle] = useState(element.customTitle || ""); + const [showHeader, setShowHeader] = useState(element.showHeader !== false); // 차트 설정이 필요 없는 위젯 (쿼리/API만 필요) const isSimpleWidget = @@ -122,13 +123,14 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element dataSource, chartConfig, customTitle: customTitle.trim() || undefined, // 빈 문자열이면 undefined + showHeader, // 헤더 표시 여부 }; console.log(" 저장할 element:", updatedElement); onSave(updatedElement); onClose(); - }, [element, dataSource, chartConfig, customTitle, onSave, onClose]); + }, [element, dataSource, chartConfig, customTitle, showHeader, onSave, onClose]); // 모달이 열려있지 않으면 렌더링하지 않음 if (!isOpen) return null; @@ -218,6 +220,20 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element 💡 비워두면 테이블명으로 자동 생성됩니다 (예: "maintenance_schedules 목록")

+ + {/* 헤더 표시 여부 */} +
+ setShowHeader(e.target.checked)} + className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" + /> + +
{/* 진행 상황 표시 - 간단한 위젯은 표시 안 함 */} diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts index cdf70550..1cc4a649 100644 --- a/frontend/components/admin/dashboard/types.ts +++ b/frontend/components/admin/dashboard/types.ts @@ -55,6 +55,7 @@ export interface DashboardElement { size: Size; title: string; customTitle?: string; // 사용자 정의 제목 (옵션) + showHeader?: boolean; // 헤더 표시 여부 (기본값: true) content: string; dataSource?: ChartDataSource; // 데이터 소스 설정 chartConfig?: ChartConfig; // 차트 설정 diff --git a/frontend/components/dashboard/DashboardViewer.tsx b/frontend/components/dashboard/DashboardViewer.tsx index f4f8caea..a363de36 100644 --- a/frontend/components/dashboard/DashboardViewer.tsx +++ b/frontend/components/dashboard/DashboardViewer.tsx @@ -280,29 +280,31 @@ function ViewerElement({ element, data, isLoading, onRefresh }: ViewerElementPro onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > - {/* 헤더 */} -
-

{element.title}

+ {/* 헤더 (showHeader가 false가 아닐 때만 표시) */} + {element.showHeader !== false && ( +
+

{element.customTitle || element.title}

- {/* 새로고침 버튼 (호버 시에만 표시) */} - {isHovered && ( - - )} -
+ {/* 새로고침 버튼 (호버 시에만 표시) */} + {isHovered && ( + + )} +
+ )} {/* 내용 */} -
+
{element.type === "chart" ? ( ) : ( From 8e2c66e2a46c94f945e9d867251501965bbdb156 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 16 Oct 2025 15:39:54 +0900 Subject: [PATCH 21/28] =?UTF-8?q?3d=20=EC=A4=91=EA=B0=84=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/app.ts | 6 +- .../src/controllers/WarehouseController.ts | 97 +++ backend-node/src/routes/warehouseRoutes.ts | 22 + backend-node/src/services/WarehouseService.ts | 170 +++++ .../admin/dashboard/DashboardDesigner.tsx | 4 + .../admin/dashboard/DashboardTopMenu.tsx | 1 + frontend/components/admin/dashboard/types.ts | 5 +- .../dashboard/widgets/Warehouse3DWidget.tsx | 418 ++++++++++++ .../components/dashboard/DashboardViewer.tsx | 11 + frontend/package-lock.json | 610 +++++++++++++++++- frontend/package.json | 4 + 11 files changed, 1339 insertions(+), 9 deletions(-) create mode 100644 backend-node/src/controllers/WarehouseController.ts create mode 100644 backend-node/src/routes/warehouseRoutes.ts create mode 100644 backend-node/src/services/WarehouseService.ts create mode 100644 frontend/components/admin/dashboard/widgets/Warehouse3DWidget.tsx diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index ae10a6fe..9483726a 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -55,6 +55,7 @@ import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관 import todoRoutes from "./routes/todoRoutes"; // To-Do 관리 import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리 import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리 +import warehouseRoutes from "./routes/warehouseRoutes"; // 창고 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -204,6 +205,7 @@ app.use("/api/risk-alerts", riskAlertRoutes); // 리스크/알림 관리 app.use("/api/todos", todoRoutes); // To-Do 관리 app.use("/api/bookings", bookingRoutes); // 예약 요청 관리 app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회 +app.use("/api/warehouse", warehouseRoutes); // 창고 관리 // app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석 // app.use('/api/users', userRoutes); @@ -241,7 +243,9 @@ app.listen(PORT, HOST, async () => { // 리스크/알림 자동 갱신 시작 try { - const { RiskAlertCacheService } = await import('./services/riskAlertCacheService'); + const { RiskAlertCacheService } = await import( + "./services/riskAlertCacheService" + ); const cacheService = RiskAlertCacheService.getInstance(); cacheService.startAutoRefresh(); logger.info(`⏰ 리스크/알림 자동 갱신이 시작되었습니다. (10분 간격)`); diff --git a/backend-node/src/controllers/WarehouseController.ts b/backend-node/src/controllers/WarehouseController.ts new file mode 100644 index 00000000..1fe140e8 --- /dev/null +++ b/backend-node/src/controllers/WarehouseController.ts @@ -0,0 +1,97 @@ +import { Request, Response } from "express"; +import { WarehouseService } from "../services/WarehouseService"; + +export class WarehouseController { + private warehouseService: WarehouseService; + + constructor() { + this.warehouseService = new WarehouseService(); + } + + // 창고 및 자재 데이터 조회 + getWarehouseData = async (req: Request, res: Response) => { + try { + const data = await this.warehouseService.getWarehouseData(); + + return res.json({ + success: true, + warehouses: data.warehouses, + materials: data.materials, + }); + } catch (error: any) { + console.error("창고 데이터 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "창고 데이터를 불러오는데 실패했습니다.", + error: error.message, + }); + } + }; + + // 특정 창고 정보 조회 + getWarehouseById = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const warehouse = await this.warehouseService.getWarehouseById(id); + + if (!warehouse) { + return res.status(404).json({ + success: false, + message: "창고를 찾을 수 없습니다.", + }); + } + + return res.json({ + success: true, + data: warehouse, + }); + } catch (error: any) { + console.error("창고 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "창고 정보를 불러오는데 실패했습니다.", + error: error.message, + }); + } + }; + + // 창고별 자재 목록 조회 + getMaterialsByWarehouse = async (req: Request, res: Response) => { + try { + const { warehouseId } = req.params; + const materials = + await this.warehouseService.getMaterialsByWarehouse(warehouseId); + + return res.json({ + success: true, + data: materials, + }); + } catch (error: any) { + console.error("자재 목록 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "자재 목록을 불러오는데 실패했습니다.", + error: error.message, + }); + } + }; + + // 창고 통계 조회 + getWarehouseStats = async (req: Request, res: Response) => { + try { + const stats = await this.warehouseService.getWarehouseStats(); + + return res.json({ + success: true, + data: stats, + }); + } catch (error: any) { + console.error("창고 통계 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "창고 통계를 불러오는데 실패했습니다.", + error: error.message, + }); + } + }; +} diff --git a/backend-node/src/routes/warehouseRoutes.ts b/backend-node/src/routes/warehouseRoutes.ts new file mode 100644 index 00000000..15625a35 --- /dev/null +++ b/backend-node/src/routes/warehouseRoutes.ts @@ -0,0 +1,22 @@ +import { Router } from "express"; +import { WarehouseController } from "../controllers/WarehouseController"; + +const router = Router(); +const warehouseController = new WarehouseController(); + +// 창고 및 자재 데이터 조회 +router.get("/data", warehouseController.getWarehouseData); + +// 특정 창고 정보 조회 +router.get("/:id", warehouseController.getWarehouseById); + +// 창고별 자재 목록 조회 +router.get( + "/:warehouseId/materials", + warehouseController.getMaterialsByWarehouse +); + +// 창고 통계 조회 +router.get("/stats/summary", warehouseController.getWarehouseStats); + +export default router; diff --git a/backend-node/src/services/WarehouseService.ts b/backend-node/src/services/WarehouseService.ts new file mode 100644 index 00000000..fe0433c7 --- /dev/null +++ b/backend-node/src/services/WarehouseService.ts @@ -0,0 +1,170 @@ +import pool from "../database/db"; + +export class WarehouseService { + // 창고 및 자재 데이터 조회 + async getWarehouseData() { + try { + // 창고 목록 조회 + const warehousesResult = await pool.query(` + SELECT + id, + name, + position_x, + position_y, + position_z, + size_x, + size_y, + size_z, + color, + capacity, + current_usage, + status, + description, + created_at, + updated_at + FROM warehouse + WHERE status = 'active' + ORDER BY id + `); + + // 자재 목록 조회 + const materialsResult = await pool.query(` + SELECT + id, + warehouse_id, + name, + material_code, + quantity, + unit, + position_x, + position_y, + position_z, + size_x, + size_y, + size_z, + color, + status, + last_updated, + created_at + FROM warehouse_material + ORDER BY warehouse_id, id + `); + + return { + warehouses: warehousesResult, + materials: materialsResult, + }; + } catch (error) { + throw error; + } + } + + // 특정 창고 정보 조회 + async getWarehouseById(id: string) { + try { + const result = await pool.query( + ` + SELECT + id, + name, + position_x, + position_y, + position_z, + size_x, + size_y, + size_z, + color, + capacity, + current_usage, + status, + description, + created_at, + updated_at + FROM warehouse + WHERE id = $1 + `, + [id] + ); + + return result[0] || null; + } catch (error) { + throw error; + } + } + + // 창고별 자재 목록 조회 + async getMaterialsByWarehouse(warehouseId: string) { + try { + const result = await pool.query( + ` + SELECT + id, + warehouse_id, + name, + material_code, + quantity, + unit, + position_x, + position_y, + position_z, + size_x, + size_y, + size_z, + color, + status, + last_updated, + created_at + FROM warehouse_material + WHERE warehouse_id = $1 + ORDER BY id + `, + [warehouseId] + ); + + return result; + } catch (error) { + throw error; + } + } + + // 창고 통계 조회 + async getWarehouseStats() { + try { + const result = await pool.query(` + SELECT + COUNT(DISTINCT w.id) as total_warehouses, + COUNT(m.id) as total_materials, + SUM(w.capacity) as total_capacity, + SUM(w.current_usage) as total_usage, + ROUND(AVG((w.current_usage::numeric / NULLIF(w.capacity, 0)) * 100), 2) as avg_usage_percent + FROM warehouse w + LEFT JOIN warehouse_material m ON w.id = m.warehouse_id + WHERE w.status = 'active' + `); + + // 상태별 자재 수 + const statusResult = await pool.query(` + SELECT + status, + COUNT(*) as count + FROM warehouse_material + GROUP BY status + `); + + const statusCounts = statusResult.reduce( + (acc: Record, row: any) => { + acc[row.status] = parseInt(row.count); + return acc; + }, + {} as Record + ); + + return { + ...result[0], + materialsByStatus: statusCounts, + }; + } catch (error) { + throw error; + } + } +} diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 0c41ff7c..95eea2f2 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -575,6 +575,8 @@ function getElementTitle(type: ElementType, subtype: ElementSubtype): string { return "🚚 기사 관리 위젯"; case "list": return "📋 리스트 위젯"; + case "warehouse-3d": + return "🏭 창고 현황 (3D)"; default: return "🔧 위젯"; } @@ -615,6 +617,8 @@ function getElementContent(type: ElementType, subtype: ElementSubtype): string { return "driver-management"; case "list": return "list-widget"; + case "warehouse-3d": + return "warehouse-3d"; default: return "위젯 내용이 여기에 표시됩니다"; } diff --git a/frontend/components/admin/dashboard/DashboardTopMenu.tsx b/frontend/components/admin/dashboard/DashboardTopMenu.tsx index 1d58988f..446830a8 100644 --- a/frontend/components/admin/dashboard/DashboardTopMenu.tsx +++ b/frontend/components/admin/dashboard/DashboardTopMenu.tsx @@ -184,6 +184,7 @@ export function DashboardTopMenu({ 데이터 위젯 리스트 위젯 지도 + 창고 현황 (3D) 일반 위젯 diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts index cdf70550..90668874 100644 --- a/frontend/components/admin/dashboard/types.ts +++ b/frontend/components/admin/dashboard/types.ts @@ -35,7 +35,8 @@ export type ElementSubtype = | "booking-alert" | "maintenance" | "document" - | "list"; // 위젯 타입 + | "list" + | "warehouse-3d"; // 위젯 타입 export interface Position { x: number; @@ -139,7 +140,7 @@ export interface ChartConfig { // 애니메이션 enableAnimation?: boolean; // 애니메이션 활성화 - + // 상태 필터링 (커스텀 상태 카드용) statusFilter?: string[]; // 표시할 상태 목록 (예: ["driving", "parked"]) animationDuration?: number; // 애니메이션 시간 (ms) diff --git a/frontend/components/admin/dashboard/widgets/Warehouse3DWidget.tsx b/frontend/components/admin/dashboard/widgets/Warehouse3DWidget.tsx new file mode 100644 index 00000000..71d2df0e --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/Warehouse3DWidget.tsx @@ -0,0 +1,418 @@ +"use client"; + +import React, { useRef, useState, useEffect, Suspense } from "react"; +import { Canvas, useFrame } from "@react-three/fiber"; +import { OrbitControls, Text, Box, Html } from "@react-three/drei"; +import * as THREE from "three"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Loader2, Maximize2, Info } from "lucide-react"; + +interface WarehouseData { + id: string; + name: string; + position_x: number; + position_y: number; + position_z: number; + size_x: number; + size_y: number; + size_z: number; + color: string; + capacity: number; + current_usage: number; + status: string; + description?: string; +} + +interface MaterialData { + id: string; + warehouse_id: string; + name: string; + material_code: string; + quantity: number; + unit: string; + position_x: number; + position_y: number; + position_z: number; + size_x: number; + size_y: number; + size_z: number; + color: string; + status: string; +} + +interface Warehouse3DWidgetProps { + element?: any; +} + +// 창고 3D 박스 컴포넌트 +function WarehouseBox({ + warehouse, + onClick, + isSelected, +}: { + warehouse: WarehouseData; + onClick: () => void; + isSelected: boolean; +}) { + const meshRef = useRef(null); + const [hovered, setHovered] = useState(false); + + useFrame(() => { + if (meshRef.current) { + if (isSelected) { + meshRef.current.scale.lerp(new THREE.Vector3(1.05, 1.05, 1.05), 0.1); + } else if (hovered) { + meshRef.current.scale.lerp(new THREE.Vector3(1.02, 1.02, 1.02), 0.1); + } else { + meshRef.current.scale.lerp(new THREE.Vector3(1, 1, 1), 0.1); + } + } + }); + + const usagePercentage = (warehouse.current_usage / warehouse.capacity) * 100; + + return ( + + { + e.stopPropagation(); + onClick(); + }} + onPointerOver={() => setHovered(true)} + onPointerOut={() => setHovered(false)} + > + + + + + {/* 창고 테두리 */} + + + + + + {/* 창고 이름 라벨 */} + + {warehouse.name} + + + {/* 사용률 표시 */} + +
+ {usagePercentage.toFixed(0)}% 사용중 +
+ +
+ ); +} + +// 자재 3D 박스 컴포넌트 +function MaterialBox({ + material, + onClick, + isSelected, +}: { + material: MaterialData; + onClick: () => void; + isSelected: boolean; +}) { + const meshRef = useRef(null); + const [hovered, setHovered] = useState(false); + + useFrame(() => { + if (meshRef.current && (isSelected || hovered)) { + meshRef.current.rotation.y += 0.01; + } + }); + + const statusColor = + { + stocked: material.color, + reserved: "#FFA500", + urgent: "#FF0000", + out_of_stock: "#808080", + }[material.status] || material.color; + + return ( + + { + e.stopPropagation(); + onClick(); + }} + onPointerOver={() => setHovered(true)} + onPointerOut={() => setHovered(false)} + > + + + + + {(hovered || isSelected) && ( + +
+
{material.name}
+
+ {material.quantity} {material.unit} +
+
+ + )} +
+ ); +} + +// 3D 씬 컴포넌트 +function Scene({ + warehouses, + materials, + onSelectWarehouse, + onSelectMaterial, + selectedWarehouse, + selectedMaterial, +}: { + warehouses: WarehouseData[]; + materials: MaterialData[]; + onSelectWarehouse: (warehouse: WarehouseData | null) => void; + onSelectMaterial: (material: MaterialData | null) => void; + selectedWarehouse: WarehouseData | null; + selectedMaterial: MaterialData | null; +}) { + return ( + <> + {/* 조명 */} + + + + + {/* 바닥 그리드 */} + + + {/* 창고들 */} + {warehouses.map((warehouse) => ( + { + if (selectedWarehouse?.id === warehouse.id) { + onSelectWarehouse(null); + } else { + onSelectWarehouse(warehouse); + onSelectMaterial(null); + } + }} + isSelected={selectedWarehouse?.id === warehouse.id} + /> + ))} + + {/* 자재들 */} + {materials.map((material) => ( + { + if (selectedMaterial?.id === material.id) { + onSelectMaterial(null); + } else { + onSelectMaterial(material); + } + }} + isSelected={selectedMaterial?.id === material.id} + /> + ))} + + {/* 카메라 컨트롤 */} + + + ); +} + +export function Warehouse3DWidget({ element }: Warehouse3DWidgetProps) { + const [warehouses, setWarehouses] = useState([]); + const [materials, setMaterials] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedWarehouse, setSelectedWarehouse] = useState(null); + const [selectedMaterial, setSelectedMaterial] = useState(null); + const [isFullscreen, setIsFullscreen] = useState(false); + + useEffect(() => { + loadData(); + }, []); + + const loadData = async () => { + try { + setLoading(true); + // API 호출 (백엔드 API 구현 필요) + const response = await fetch("/api/warehouse/data"); + if (response.ok) { + const data = await response.json(); + setWarehouses(data.warehouses || []); + setMaterials(data.materials || []); + } else { + // 임시 더미 데이터 (개발용) + console.log("API 실패, 더미 데이터 사용"); + } + } catch (error) { + console.error("창고 데이터 로드 실패:", error); + } finally { + setLoading(false); + } + }; + + if (loading) { + return ( + + + + + + ); + } + + return ( + + + 🏭 창고 현황 (3D) +
+ + {warehouses.length}개 창고 | {materials.length}개 자재 + + +
+
+ + {/* 3D 뷰 */} +
+ + + + + +
+ + {/* 정보 패널 */} +
+ {/* 선택된 창고 정보 */} + {selectedWarehouse && ( + + + + + 창고 정보 + + + +
+ 이름: {selectedWarehouse.name} +
+
+ ID: {selectedWarehouse.id} +
+
+ 용량: {selectedWarehouse.current_usage} /{" "} + {selectedWarehouse.capacity} +
+
+ 사용률:{" "} + {((selectedWarehouse.current_usage / selectedWarehouse.capacity) * 100).toFixed(1)}% +
+
+ 상태:{" "} + + {selectedWarehouse.status} + +
+ {selectedWarehouse.description && ( +
+ 설명: {selectedWarehouse.description} +
+ )} +
+
+ )} + + {/* 선택된 자재 정보 */} + {selectedMaterial && ( + + + + + 자재 정보 + + + +
+ 이름: {selectedMaterial.name} +
+
+ 코드: {selectedMaterial.material_code} +
+
+ 수량: {selectedMaterial.quantity} {selectedMaterial.unit} +
+
+ 위치:{" "} + {warehouses.find((w) => w.id === selectedMaterial.warehouse_id)?.name} +
+
+ 상태:{" "} + + {selectedMaterial.status} + +
+
+
+ )} + + {/* 창고 목록 */} + {!selectedWarehouse && !selectedMaterial && ( + + + 창고 목록 + + + {warehouses.map((warehouse) => { + const warehouseMaterials = materials.filter((m) => m.warehouse_id === warehouse.id); + return ( + + ); + })} + + + )} +
+
+
+ ); +} diff --git a/frontend/components/dashboard/DashboardViewer.tsx b/frontend/components/dashboard/DashboardViewer.tsx index f4f8caea..d0c12ccf 100644 --- a/frontend/components/dashboard/DashboardViewer.tsx +++ b/frontend/components/dashboard/DashboardViewer.tsx @@ -38,6 +38,14 @@ const ListWidget = dynamic( { ssr: false }, ); +const Warehouse3DWidget = dynamic( + () => + import("@/components/admin/dashboard/widgets/Warehouse3DWidget").then((mod) => ({ + default: mod.Warehouse3DWidget, + })), + { ssr: false }, +); + /** * 위젯 렌더링 함수 - DashboardSidebar의 모든 subtype 처리 * ViewerElement에서 사용하기 위해 컴포넌트 외부에 정의 @@ -78,6 +86,9 @@ function renderWidget(element: DashboardElement) { case "list": return ; + case "warehouse-3d": + return ; + // === 차량 관련 (추가 위젯) === case "vehicle-status": return ; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 99c48de4..9dbf4f10 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -30,11 +30,14 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", + "@react-three/drei": "^10.7.6", + "@react-three/fiber": "^9.4.0", "@tanstack/react-query": "^5.86.0", "@tanstack/react-table": "^8.21.3", "@types/d3": "^7.4.3", "@types/leaflet": "^1.9.21", "@types/react-window": "^1.8.8", + "@types/three": "^0.180.0", "@xyflow/react": "^12.8.4", "axios": "^1.11.0", "class-variance-authority": "^0.7.1", @@ -64,6 +67,7 @@ "sheetjs-style": "^0.15.8", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", + "three": "^0.180.0", "uuid": "^13.0.0", "xlsx": "^0.18.5", "zod": "^4.1.5" @@ -280,6 +284,12 @@ "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", "license": "MIT" }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "license": "Apache-2.0" + }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", @@ -1100,6 +1110,24 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mediapipe/tasks-vision": { + "version": "0.10.17", + "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz", + "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==", + "license": "Apache-2.0" + }, + "node_modules/@monogrid/gainmap-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.1.0.tgz", + "integrity": "sha512-Obb0/gEd/HReTlg8ttaYk+0m62gQJmCblMOjHSMHRrBP2zdfKMHLCRbh/6ex9fSUJMKdjjIEiohwkbGD3wj2Nw==", + "license": "MIT", + "dependencies": { + "promise-worker-transferable": "^1.0.4" + }, + "peerDependencies": { + "three": ">= 0.159.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -2495,6 +2523,160 @@ "react-dom": "^19.0.0" } }, + "node_modules/@react-three/drei": { + "version": "10.7.6", + "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.6.tgz", + "integrity": "sha512-ZSFwRlRaa4zjtB7yHO6Q9xQGuyDCzE7whXBhum92JslcMRC3aouivp0rAzszcVymIoJx6PXmibyP+xr+zKdwLg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mediapipe/tasks-vision": "0.10.17", + "@monogrid/gainmap-js": "^3.0.6", + "@use-gesture/react": "^10.3.1", + "camera-controls": "^3.1.0", + "cross-env": "^7.0.3", + "detect-gpu": "^5.0.56", + "glsl-noise": "^0.0.0", + "hls.js": "^1.5.17", + "maath": "^0.10.8", + "meshline": "^3.3.1", + "stats-gl": "^2.2.8", + "stats.js": "^0.17.0", + "suspend-react": "^0.1.3", + "three-mesh-bvh": "^0.8.3", + "three-stdlib": "^2.35.6", + "troika-three-text": "^0.52.4", + "tunnel-rat": "^0.1.2", + "use-sync-external-store": "^1.4.0", + "utility-types": "^3.11.0", + "zustand": "^5.0.1" + }, + "peerDependencies": { + "@react-three/fiber": "^9.0.0", + "react": "^19", + "react-dom": "^19", + "three": ">=0.159" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/@react-three/drei/node_modules/zustand": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", + "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, + "node_modules/@react-three/fiber": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz", + "integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8", + "@types/react-reconciler": "^0.32.0", + "@types/webxr": "*", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "its-fine": "^2.0.0", + "react-reconciler": "^0.31.0", + "react-use-measure": "^2.1.7", + "scheduler": "^0.25.0", + "suspend-react": "^0.1.3", + "use-sync-external-store": "^1.4.0", + "zustand": "^5.0.3" + }, + "peerDependencies": { + "expo": ">=43.0", + "expo-asset": ">=8.4", + "expo-file-system": ">=11.0", + "expo-gl": ">=11.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-native": ">=0.78", + "three": ">=0.156" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "expo-asset": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "expo-gl": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@react-three/fiber/node_modules/scheduler": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", + "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", + "license": "MIT" + }, + "node_modules/@react-three/fiber/node_modules/zustand": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", + "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, "node_modules/@reactflow/background": { "version": "11.3.9", "resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.9.tgz", @@ -3022,6 +3204,12 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -3286,6 +3474,12 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/draco3d": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", + "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -3332,6 +3526,12 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.1.13", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz", @@ -3351,6 +3551,15 @@ "@types/react": "^19.0.0" } }, + "node_modules/@types/react-reconciler": { + "version": "0.32.2", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.32.2.tgz", + "integrity": "sha512-gjcm6O0aUknhYaogEl8t5pecPfiOTD8VQkbjOhgbZas/E6qGY+veW9iuJU/7p4Y1E0EuQ0mArga7VEOUWSlVRA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-window": { "version": "1.8.8", "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz", @@ -3360,6 +3569,27 @@ "@types/react": "*" } }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.180.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz", + "integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==", + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": "*", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~0.22.0" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -3380,6 +3610,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.44.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.1.tgz", @@ -3937,6 +4173,30 @@ "win32" ] }, + "node_modules/@use-gesture/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", + "license": "MIT" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "license": "MIT", + "dependencies": { + "@use-gesture/core": "10.3.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, + "node_modules/@webgpu/types": { + "version": "0.1.66", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.66.tgz", + "integrity": "sha512-YA2hLrwLpDsRueNDXIMqN9NTzD6bCDkuXbOSe0heS+f8YE8usA6Gbv1prj81pzVHrbaAma7zObnIC+I6/sXJgA==", + "license": "BSD-3-Clause" + }, "node_modules/@xmldom/xmldom": { "version": "0.8.11", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", @@ -4384,6 +4644,30 @@ "node": ">=8" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/c12": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", @@ -4472,6 +4756,19 @@ "node": ">=6" } }, + "node_modules/camera-controls": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.0.tgz", + "integrity": "sha512-w5oULNpijgTRH0ARFJJ0R5ct1nUM3R3WP7/b8A6j9uTGpRfnsypc/RBMPQV8JQDPayUe37p/TZZY1PcUr4czOQ==", + "license": "MIT", + "engines": { + "node": ">=20.11.0", + "npm": ">=10.8.2" + }, + "peerDependencies": { + "three": ">=0.126.1" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001745", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001745.tgz", @@ -4718,11 +5015,28 @@ "node": ">=0.8" } }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -5380,6 +5694,15 @@ "dev": true, "license": "MIT" }, + "node_modules/detect-gpu": { + "version": "5.0.70", + "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz", + "integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==", + "license": "MIT", + "dependencies": { + "webgl-constants": "^1.1.1" + } + }, "node_modules/detect-libc": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.1.tgz", @@ -5516,6 +5839,12 @@ "url": "https://dotenvx.com" } }, + "node_modules/draco3d": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", + "license": "Apache-2.0" + }, "node_modules/duck": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz", @@ -6374,6 +6703,12 @@ "reusify": "^1.0.4" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -6677,6 +7012,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/glsl-noise": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz", + "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==", + "license": "MIT" + }, "node_modules/goober": { "version": "2.1.16", "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz", @@ -6813,6 +7154,12 @@ "node": ">= 0.4" } }, + "node_modules/hls.js": { + "version": "1.6.13", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.13.tgz", + "integrity": "sha512-hNEzjZNHf5bFrUNvdS4/1RjIanuJ6szpWNfTaX5I6WfGynWXGT7K/YQLYtemSvFExzeMdgdE4SsyVLJbd5PcZA==", + "license": "Apache-2.0" + }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -6872,6 +7219,26 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -7217,6 +7584,12 @@ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "license": "MIT" }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -7373,7 +7746,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/isomorphic-dompurify": { @@ -7407,6 +7779,27 @@ "node": ">= 0.4" } }, + "node_modules/its-fine": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz", + "integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.9" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, + "node_modules/its-fine/node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/jiti": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.0.tgz", @@ -7901,6 +8294,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/maath": { + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz", + "integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==", + "license": "MIT", + "peerDependencies": { + "@types/three": ">=0.134.0", + "three": ">=0.134.0" + } + }, "node_modules/magic-string": { "version": "0.30.19", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", @@ -7969,6 +8372,21 @@ "node": ">= 8" } }, + "node_modules/meshline": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz", + "integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/meshoptimizer": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz", + "integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==", + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -8468,7 +8886,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8564,6 +8981,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -8734,6 +9157,16 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/promise-worker-transferable": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz", + "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==", + "license": "Apache-2.0", + "dependencies": { + "is-promise": "^2.1.0", + "lie": "^3.0.2" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -8944,6 +9377,27 @@ "react-dom": "^19.0.0" } }, + "node_modules/react-reconciler": { + "version": "0.31.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.31.0.tgz", + "integrity": "sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.25.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, + "node_modules/react-reconciler/node_modules/scheduler": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", + "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", + "license": "MIT" + }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", @@ -9046,6 +9500,21 @@ } } }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/react-window": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/react-window/-/react-window-2.1.1.tgz", @@ -9510,7 +9979,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -9523,7 +9991,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -9674,6 +10141,32 @@ "dev": true, "license": "MIT" }, + "node_modules/stats-gl": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", + "integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==", + "license": "MIT", + "dependencies": { + "@types/three": "*", + "three": "^0.170.0" + }, + "peerDependencies": { + "@types/three": "*", + "three": "*" + } + }, + "node_modules/stats-gl/node_modules/three": { + "version": "0.170.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", + "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==", + "license": "MIT" + }, + "node_modules/stats.js": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz", + "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -9882,6 +10375,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/suspend-react": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", + "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=17.0" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -9952,6 +10454,44 @@ "node": ">=18" } }, + "node_modules/three": { + "version": "0.180.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz", + "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==", + "license": "MIT" + }, + "node_modules/three-mesh-bvh": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.8.3.tgz", + "integrity": "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==", + "license": "MIT", + "peerDependencies": { + "three": ">= 0.159.0" + } + }, + "node_modules/three-stdlib": { + "version": "2.36.0", + "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.0.tgz", + "integrity": "sha512-kv0Byb++AXztEGsULgMAs8U2jgUdz6HPpAB/wDJnLiLlaWQX2APHhiTJIN7rqW+Of0eRgcp7jn05U1BsCP3xBA==", + "license": "MIT", + "dependencies": { + "@types/draco3d": "^1.4.0", + "@types/offscreencanvas": "^2019.6.4", + "@types/webxr": "^0.5.2", + "draco3d": "^1.4.1", + "fflate": "^0.6.9", + "potpack": "^1.0.1" + }, + "peerDependencies": { + "three": ">=0.128.0" + } + }, + "node_modules/three-stdlib/node_modules/fflate": { + "version": "0.6.10", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz", + "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==", + "license": "MIT" + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -10068,6 +10608,36 @@ "node": ">=20" } }, + "node_modules/troika-three-text": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz", + "integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==", + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.2", + "troika-three-utils": "^0.52.4", + "troika-worker-utils": "^0.52.0", + "webgl-sdf-generator": "1.1.1" + }, + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-three-utils": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz", + "integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-worker-utils": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz", + "integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -10100,6 +10670,15 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tunnel-rat": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", + "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", + "license": "MIT", + "dependencies": { + "zustand": "^4.3.2" + } + }, "node_modules/tw-animate-css": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", @@ -10350,6 +10929,15 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/uuid": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", @@ -10397,6 +10985,17 @@ "node": ">=18" } }, + "node_modules/webgl-constants": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz", + "integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==" + }, + "node_modules/webgl-sdf-generator": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz", + "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==", + "license": "MIT" + }, "node_modules/webidl-conversions": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", @@ -10444,7 +11043,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" diff --git a/frontend/package.json b/frontend/package.json index bc9e8d1d..522ccd29 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -38,11 +38,14 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", + "@react-three/drei": "^10.7.6", + "@react-three/fiber": "^9.4.0", "@tanstack/react-query": "^5.86.0", "@tanstack/react-table": "^8.21.3", "@types/d3": "^7.4.3", "@types/leaflet": "^1.9.21", "@types/react-window": "^1.8.8", + "@types/three": "^0.180.0", "@xyflow/react": "^12.8.4", "axios": "^1.11.0", "class-variance-authority": "^0.7.1", @@ -72,6 +75,7 @@ "sheetjs-style": "^0.15.8", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", + "three": "^0.180.0", "uuid": "^13.0.0", "xlsx": "^0.18.5", "zod": "^4.1.5" From 5093d336c0f54b7b6f68b33f1d60d30d129166a9 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 16 Oct 2025 16:43:04 +0900 Subject: [PATCH 22/28] =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C?= =?UTF-8?q?=EC=97=90=20=ED=99=94=EB=A9=B4=20=ED=95=A0=EB=8B=B9=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/DashboardDesigner.tsx | 254 +++++++------- .../admin/dashboard/DashboardSaveModal.tsx | 321 ++++++++++++++++++ .../admin/dashboard/ElementConfigModal.tsx | 13 +- 3 files changed, 457 insertions(+), 131 deletions(-) create mode 100644 frontend/components/admin/dashboard/DashboardSaveModal.tsx diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 95eea2f2..7f19bbc4 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -6,10 +6,14 @@ import { DashboardCanvas } from "./DashboardCanvas"; import { DashboardTopMenu } from "./DashboardTopMenu"; import { ElementConfigModal } from "./ElementConfigModal"; import { ListWidgetConfigModal } from "./widgets/ListWidgetConfigModal"; -import { MenuAssignmentModal } from "./MenuAssignmentModal"; +import { DashboardSaveModal } from "./DashboardSaveModal"; import { DashboardElement, ElementType, ElementSubtype } from "./types"; import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize } from "./gridUtils"; import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector"; +import { useMenu } from "@/contexts/MenuContext"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { CheckCircle2 } from "lucide-react"; interface DashboardDesignerProps { dashboardId?: string; @@ -24,6 +28,7 @@ interface DashboardDesignerProps { */ export default function DashboardDesigner({ dashboardId: initialDashboardId }: DashboardDesignerProps = {}) { const router = useRouter(); + const { refreshMenus } = useMenu(); const [elements, setElements] = useState([]); const [selectedElement, setSelectedElement] = useState(null); const [elementCounter, setElementCounter] = useState(0); @@ -34,9 +39,10 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D const [canvasBackgroundColor, setCanvasBackgroundColor] = useState("#f9fafb"); const canvasRef = useRef(null); - // 메뉴 할당 모달 상태 - const [menuAssignmentModalOpen, setMenuAssignmentModalOpen] = useState(false); - const [savedDashboardInfo, setSavedDashboardInfo] = useState<{ id: string; title: string } | null>(null); + // 저장 모달 상태 + const [saveModalOpen, setSaveModalOpen] = useState(false); + const [dashboardDescription, setDashboardDescription] = useState(""); + const [successModalOpen, setSuccessModalOpen] = useState(false); // 화면 해상도 자동 감지 const [screenResolution] = useState(() => detectScreenResolution()); @@ -308,117 +314,93 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D ); // 레이아웃 저장 - const saveLayout = useCallback(async () => { + const saveLayout = useCallback(() => { if (elements.length === 0) { alert("저장할 요소가 없습니다. 차트나 위젯을 추가해주세요."); return; } - try { - // 실제 API 호출 - const { dashboardApi } = await import("@/lib/api/dashboard"); - - const elementsData = elements.map((el) => ({ - id: el.id, - type: el.type, - subtype: el.subtype, - position: el.position, - size: el.size, - title: el.title, - content: el.content, - dataSource: el.dataSource, - chartConfig: el.chartConfig, - })); - - let savedDashboard; - - if (dashboardId) { - // 기존 대시보드 업데이트 - console.log("💾 저장 시작 - 현재 resolution 상태:", resolution); - console.log("💾 저장 시작 - 현재 배경색 상태:", canvasBackgroundColor); - - const updateData = { - elements: elementsData, - settings: { - resolution, - backgroundColor: canvasBackgroundColor, - }, - }; - - console.log("💾 저장할 데이터:", updateData); - console.log("💾 저장할 settings:", updateData.settings); - - savedDashboard = await dashboardApi.updateDashboard(dashboardId, updateData); - - console.log("✅ 저장된 대시보드:", savedDashboard); - console.log("✅ 저장된 settings:", (savedDashboard as any).settings); - - // 메뉴 할당 모달 띄우기 (기존 대시보드 업데이트 시에도) - setSavedDashboardInfo({ - id: savedDashboard.id, - title: savedDashboard.title, - }); - setMenuAssignmentModalOpen(true); - } else { - // 새 대시보드 생성 - const title = prompt("대시보드 제목을 입력하세요:", "새 대시보드"); - if (!title) return; - - const description = prompt("대시보드 설명을 입력하세요 (선택사항):", ""); - - const dashboardData = { - title, - description: description || undefined, - isPublic: false, - elements: elementsData, - settings: { - resolution, - backgroundColor: canvasBackgroundColor, - }, - }; - - savedDashboard = await dashboardApi.createDashboard(dashboardData); - - // 대시보드 ID 설정 (다음 저장 시 업데이트 모드로 전환) - setDashboardId(savedDashboard.id); - setDashboardTitle(savedDashboard.title); - - // 메뉴 할당 모달 띄우기 - setSavedDashboardInfo({ - id: savedDashboard.id, - title: savedDashboard.title, - }); - setMenuAssignmentModalOpen(true); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류"; - alert(`대시보드 저장 중 오류가 발생했습니다.\n\n오류: ${errorMessage}\n\n관리자에게 문의하세요.`); - } - }, [elements, dashboardId, router, resolution, canvasBackgroundColor]); - - // 메뉴 할당 처리 - const handleMenuAssignment = useCallback( - async (assignToMenu: boolean, menuId?: string, menuType?: "0" | "1") => { - if (!savedDashboardInfo) return; + // 저장 모달 열기 + setSaveModalOpen(true); + }, [elements]); + // 저장 처리 + const handleSave = useCallback( + async (data: { + title: string; + description: string; + assignToMenu: boolean; + menuType?: "admin" | "user"; + menuId?: string; + }) => { try { - if (assignToMenu && menuId) { - // 메뉴 API를 통해 대시보드 URL 할당 + const { dashboardApi } = await import("@/lib/api/dashboard"); + + const elementsData = elements.map((el) => ({ + id: el.id, + type: el.type, + subtype: el.subtype, + position: el.position, + size: el.size, + title: el.title, + content: el.content, + dataSource: el.dataSource, + chartConfig: el.chartConfig, + listConfig: el.listConfig, + })); + + let savedDashboard; + + if (dashboardId) { + // 기존 대시보드 업데이트 + const updateData = { + title: data.title, + description: data.description || undefined, + elements: elementsData, + settings: { + resolution, + backgroundColor: canvasBackgroundColor, + }, + }; + + savedDashboard = await dashboardApi.updateDashboard(dashboardId, updateData); + } else { + // 새 대시보드 생성 + const dashboardData = { + title: data.title, + description: data.description || undefined, + isPublic: false, + elements: elementsData, + settings: { + resolution, + backgroundColor: canvasBackgroundColor, + }, + }; + + savedDashboard = await dashboardApi.createDashboard(dashboardData); + setDashboardId(savedDashboard.id); + } + + setDashboardTitle(savedDashboard.title); + setDashboardDescription(data.description); + + // 메뉴 할당 처리 + if (data.assignToMenu && data.menuId) { const { menuApi } = await import("@/lib/api/menu"); // 대시보드 URL 생성 (관리자 메뉴면 mode=admin 추가) - let dashboardUrl = `/dashboard/${savedDashboardInfo.id}`; - if (menuType === "0") { + let dashboardUrl = `/dashboard/${savedDashboard.id}`; + if (data.menuType === "admin") { dashboardUrl += "?mode=admin"; } // 메뉴 정보 가져오기 - const menuResponse = await menuApi.getMenuInfo(menuId); + const menuResponse = await menuApi.getMenuInfo(data.menuId); + if (menuResponse.success && menuResponse.data) { const menu = menuResponse.data; - // 메뉴 URL 업데이트 - await menuApi.updateMenu(menuId, { + const updateData = { menuUrl: dashboardUrl, parentObjId: menu.parent_obj_id || menu.PARENT_OBJ_ID || "0", menuNameKor: menu.menu_name_kor || menu.MENU_NAME_KOR || "", @@ -428,24 +410,25 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D status: menu.status || menu.STATUS || "active", companyCode: menu.company_code || menu.COMPANY_CODE || "", langKey: menu.lang_key || menu.LANG_KEY || "", - }); + }; - alert(`메뉴 "${menu.menu_name_kor || menu.MENU_NAME_KOR}"에 대시보드가 할당되었습니다!`); + // 메뉴 URL 업데이트 + await menuApi.updateMenu(data.menuId, updateData); + + // 메뉴 목록 새로고침 + await refreshMenus(); } } - // 모달 닫기 - setMenuAssignmentModalOpen(false); - setSavedDashboardInfo(null); - - // 대시보드 뷰어로 이동 - router.push(`/dashboard/${savedDashboardInfo.id}`); + // 성공 모달 표시 + setSuccessModalOpen(true); } catch (error) { - console.error("메뉴 할당 오류:", error); - alert("메뉴 할당 중 오류가 발생했습니다."); + const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류"; + alert(`대시보드 저장 중 오류가 발생했습니다.\n\n오류: ${errorMessage}`); + throw error; } }, - [savedDashboardInfo, router], + [elements, dashboardId, resolution, canvasBackgroundColor, router], ); // 로딩 중이면 로딩 화면 표시 @@ -523,21 +506,44 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D )} - {/* 메뉴 할당 모달 */} - {savedDashboardInfo && ( - { - setMenuAssignmentModalOpen(false); - setSavedDashboardInfo(null); - // 모달을 그냥 닫으면 대시보드 뷰어로 이동 - router.push(`/dashboard/${savedDashboardInfo.id}`); - }} - onConfirm={handleMenuAssignment} - dashboardId={savedDashboardInfo.id} - dashboardTitle={savedDashboardInfo.title} - /> - )} + {/* 저장 모달 */} + setSaveModalOpen(false)} + onSave={handleSave} + initialTitle={dashboardTitle} + initialDescription={dashboardDescription} + isEditing={!!dashboardId} + /> + + {/* 저장 성공 모달 */} + { + setSuccessModalOpen(false); + router.push("/admin/dashboard"); + }} + > + + +
+ +
+ 저장 완료 + 대시보드가 성공적으로 저장되었습니다. +
+
+ +
+
+
); } diff --git a/frontend/components/admin/dashboard/DashboardSaveModal.tsx b/frontend/components/admin/dashboard/DashboardSaveModal.tsx new file mode 100644 index 00000000..34837384 --- /dev/null +++ b/frontend/components/admin/dashboard/DashboardSaveModal.tsx @@ -0,0 +1,321 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + SelectGroup, + SelectLabel, +} from "@/components/ui/select"; +import { Loader2, Save } from "lucide-react"; +import { menuApi } from "@/lib/api/menu"; + +interface MenuItem { + id: string; + name: string; + url?: string; + parent_id?: string; + children?: MenuItem[]; +} + +interface DashboardSaveModalProps { + isOpen: boolean; + onClose: () => void; + onSave: (data: { + title: string; + description: string; + assignToMenu: boolean; + menuType?: "admin" | "user"; + menuId?: string; + }) => Promise; + initialTitle?: string; + initialDescription?: string; + isEditing?: boolean; +} + +export function DashboardSaveModal({ + isOpen, + onClose, + onSave, + initialTitle = "", + initialDescription = "", + isEditing = false, +}: DashboardSaveModalProps) { + const [title, setTitle] = useState(initialTitle); + const [description, setDescription] = useState(initialDescription); + const [assignToMenu, setAssignToMenu] = useState(false); + const [menuType, setMenuType] = useState<"admin" | "user">("admin"); + const [selectedMenuId, setSelectedMenuId] = useState(""); + const [adminMenus, setAdminMenus] = useState([]); + const [userMenus, setUserMenus] = useState([]); + const [loading, setLoading] = useState(false); + const [loadingMenus, setLoadingMenus] = useState(false); + + useEffect(() => { + if (isOpen) { + setTitle(initialTitle); + setDescription(initialDescription); + setAssignToMenu(false); + setMenuType("admin"); + setSelectedMenuId(""); + loadMenus(); + } + }, [isOpen, initialTitle, initialDescription]); + + const loadMenus = async () => { + setLoadingMenus(true); + try { + const [adminData, userData] = await Promise.all([menuApi.getAdminMenus(), menuApi.getUserMenus()]); + + // API 응답이 배열인지 확인하고 처리 + const adminMenuList = Array.isArray(adminData) ? adminData : adminData?.data || []; + const userMenuList = Array.isArray(userData) ? userData : userData?.data || []; + + setAdminMenus(adminMenuList); + setUserMenus(userMenuList); + } catch (error) { + console.error("메뉴 목록 로드 실패:", error); + setAdminMenus([]); + setUserMenus([]); + } finally { + setLoadingMenus(false); + } + }; + + const flattenMenus = ( + menus: MenuItem[], + prefix = "", + parentPath = "", + ): { id: string; label: string; uniqueKey: string }[] => { + if (!Array.isArray(menus)) { + return []; + } + + const result: { id: string; label: string; uniqueKey: string }[] = []; + menus.forEach((menu, index) => { + // 메뉴 ID 추출 (objid 또는 id) + const menuId = (menu as any).objid || menu.id || ""; + const uniqueKey = `${parentPath}-${menuId}-${index}`; + + // 메뉴 이름 추출 + const menuName = + menu.name || + (menu as any).menu_name_kor || + (menu as any).MENU_NAME_KOR || + (menu as any).menuNameKor || + (menu as any).title || + "이름없음"; + + // lev 필드로 레벨 확인 (lev > 1인 메뉴만 추가) + const menuLevel = (menu as any).lev || 0; + + if (menuLevel > 1) { + result.push({ + id: menuId, + label: prefix + menuName, + uniqueKey, + }); + } + + // 하위 메뉴가 있으면 재귀 호출 + if (menu.children && Array.isArray(menu.children) && menu.children.length > 0) { + result.push(...flattenMenus(menu.children, prefix + menuName + " > ", uniqueKey)); + } + }); + + return result; + }; + + const handleSave = async () => { + if (!title.trim()) { + alert("대시보드 이름을 입력해주세요."); + return; + } + + if (assignToMenu && !selectedMenuId) { + alert("메뉴를 선택해주세요."); + return; + } + + setLoading(true); + try { + await onSave({ + title: title.trim(), + description: description.trim(), + assignToMenu, + menuType: assignToMenu ? menuType : undefined, + menuId: assignToMenu ? selectedMenuId : undefined, + }); + onClose(); + } catch (error) { + console.error("저장 실패:", error); + } finally { + setLoading(false); + } + }; + + const currentMenus = menuType === "admin" ? adminMenus : userMenus; + const flatMenus = flattenMenus(currentMenus); + + return ( + + + + {isEditing ? "대시보드 수정" : "대시보드 저장"} + + +
+ {/* 대시보드 이름 */} +
+ + setTitle(e.target.value)} + placeholder="예: 생산 현황 대시보드" + className="w-full" + /> +
+ + {/* 대시보드 설명 */} +
+ +