From 7097775343828eb447cd8ab431b86f8624bc9406 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Thu, 16 Oct 2025 16:34:59 +0900 Subject: [PATCH 01/13] =?UTF-8?q?=EC=95=8C=EC=95=84=EC=84=9C=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=98=EB=90=98=EB=8A=94=EA=B1=B0=20=ED=95=98=EA=B8=B0=20?= =?UTF-8?q?=EC=A0=84=20=EC=84=B8=EC=9D=B4=EB=B8=8C=20=EB=94=94=EB=B2=A8?= =?UTF-8?q?=EB=A1=AD=EB=A7=8C=20=EB=90=9C=20=EC=83=81=ED=83=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/services/riskAlertService.ts | 99 +++---------------- .../(main)/dashboard/[dashboardId]/page.tsx | 10 +- .../components/dashboard/DashboardViewer.tsx | 5 +- .../dashboard/widgets/ExchangeWidget.tsx | 12 +-- 4 files changed, 29 insertions(+), 97 deletions(-) diff --git a/backend-node/src/services/riskAlertService.ts b/backend-node/src/services/riskAlertService.ts index d911de94..514d3e95 100644 --- a/backend-node/src/services/riskAlertService.ts +++ b/backend-node/src/services/riskAlertService.ts @@ -25,8 +25,8 @@ export class RiskAlertService { const apiKey = process.env.KMA_API_KEY; if (!apiKey) { - console.log('⚠️ 기상청 API 키가 없습니다. 테스트 데이터를 반환합니다.'); - return this.generateDummyWeatherAlerts(); + console.log('⚠️ 기상청 API 키가 없습니다. 빈 데이터를 반환합니다.'); + return []; } const alerts: Alert[] = []; @@ -109,7 +109,7 @@ export class RiskAlertService { console.log(`✅ 총 ${alerts.length}건의 기상특보 감지`); } catch (warningError: any) { console.error('❌ 기상청 특보 API 오류:', warningError.message); - return this.generateDummyWeatherAlerts(); + return []; } // 특보가 없으면 빈 배열 반환 (0건) @@ -120,8 +120,8 @@ export class RiskAlertService { return alerts; } catch (error: any) { console.error('❌ 기상청 특보 API 오류:', error.message); - // API 오류 시 더미 데이터 반환 - return this.generateDummyWeatherAlerts(); + // API 오류 시 빈 배열 반환 + return []; } } @@ -237,9 +237,9 @@ export class RiskAlertService { console.error('❌ 한국도로공사 API 오류:', error.message); } - // 모든 API 실패 시 더미 데이터 - console.log('ℹ️ 모든 교통사고 API 실패. 더미 데이터를 반환합니다.'); - return this.generateDummyAccidentAlerts(); + // 모든 API 실패 시 빈 배열 + console.log('ℹ️ 모든 교통사고 API 실패. 빈 배열을 반환합니다.'); + return []; } /** @@ -356,9 +356,9 @@ export class RiskAlertService { console.error('❌ 한국도로공사 API 오류:', error.message); } - // 모든 API 실패 시 더미 데이터 - console.log('ℹ️ 모든 도로공사 API 실패. 더미 데이터를 반환합니다.'); - return this.generateDummyRoadworkAlerts(); + // 모든 API 실패 시 빈 배열 + console.log('ℹ️ 모든 도로공사 API 실패. 빈 배열을 반환합니다.'); + return []; } /** @@ -467,82 +467,5 @@ export class RiskAlertService { return 'low'; } - /** - * 테스트용 날씨 특보 더미 데이터 - */ - private generateDummyWeatherAlerts(): Alert[] { - return [ - { - id: `weather-${Date.now()}-1`, - type: 'weather', - severity: 'high', - title: '대설특보', - location: '강원 영동지역', - description: '시간당 2cm 이상 폭설. 차량 운행 주의', - timestamp: new Date(Date.now() - 30 * 60000).toISOString(), - }, - { - id: `weather-${Date.now()}-2`, - type: 'weather', - severity: 'medium', - title: '강풍특보', - location: '남해안 전 지역', - description: '순간 풍속 20m/s 이상. 고속도로 주행 주의', - timestamp: new Date(Date.now() - 90 * 60000).toISOString(), - }, - ]; - } - - /** - * 테스트용 교통사고 더미 데이터 - */ - private generateDummyAccidentAlerts(): Alert[] { - return [ - { - id: `accident-${Date.now()}-1`, - type: 'accident', - severity: 'high', - title: '교통사고 발생', - location: '경부고속도로 서울방향 189km', - description: '3중 추돌사고로 2차로 통제 중. 우회 권장', - timestamp: new Date(Date.now() - 10 * 60000).toISOString(), - }, - { - id: `accident-${Date.now()}-2`, - type: 'accident', - severity: 'medium', - title: '사고 다발 지역', - location: '영동고속도로 강릉방향 160km', - description: '안개로 인한 가시거리 50m 이하. 서행 운전', - timestamp: new Date(Date.now() - 60 * 60000).toISOString(), - }, - ]; - } - - /** - * 테스트용 도로공사 더미 데이터 - */ - private generateDummyRoadworkAlerts(): Alert[] { - return [ - { - id: `construction-${Date.now()}-1`, - type: 'construction', - severity: 'medium', - title: '도로 공사', - location: '서울외곽순환 목동IC~화곡IC', - description: '야간 공사로 1차로 통제 (22:00~06:00)', - timestamp: new Date(Date.now() - 45 * 60000).toISOString(), - }, - { - id: `construction-${Date.now()}-2`, - type: 'construction', - severity: 'low', - title: '도로 통제', - location: '중부내륙고속도로 김천JC~현풍IC', - description: '도로 유지보수 작업. 차량 속도 제한 60km/h', - timestamp: new Date(Date.now() - 120 * 60000).toISOString(), - }, - ]; - } } diff --git a/frontend/app/(main)/dashboard/[dashboardId]/page.tsx b/frontend/app/(main)/dashboard/[dashboardId]/page.tsx index 0705d77b..6f5af7ba 100644 --- a/frontend/app/(main)/dashboard/[dashboardId]/page.tsx +++ b/frontend/app/(main)/dashboard/[dashboardId]/page.tsx @@ -25,6 +25,10 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) { title: string; description?: string; elements: DashboardElement[]; + settings?: { + backgroundColor?: string; + resolution?: string; + }; createdAt: string; updatedAt: string; } | null>(null); @@ -156,7 +160,11 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) { {/* 대시보드 뷰어 */}
- +
); diff --git a/frontend/components/dashboard/DashboardViewer.tsx b/frontend/components/dashboard/DashboardViewer.tsx index a363de36..1c9fd7ed 100644 --- a/frontend/components/dashboard/DashboardViewer.tsx +++ b/frontend/components/dashboard/DashboardViewer.tsx @@ -115,6 +115,7 @@ interface DashboardViewerProps { elements: DashboardElement[]; dashboardId: string; refreshInterval?: number; // 전체 대시보드 새로고침 간격 (ms) + backgroundColor?: string; // 배경색 } /** @@ -123,7 +124,7 @@ interface DashboardViewerProps { * - 실시간 데이터 업데이트 * - 반응형 레이아웃 */ -export function DashboardViewer({ elements, dashboardId, refreshInterval }: DashboardViewerProps) { +export function DashboardViewer({ elements, dashboardId, refreshInterval, backgroundColor = "#f9fafb" }: DashboardViewerProps) { const [elementData, setElementData] = useState>({}); const [loadingElements, setLoadingElements] = useState>(new Set()); const [lastRefresh, setLastRefresh] = useState(new Date()); @@ -230,7 +231,7 @@ export function DashboardViewer({ elements, dashboardId, refreshInterval }: Dash } return ( -
+
{/* 새로고침 상태 표시 */}
마지막 업데이트: {lastRefresh.toLocaleTimeString()} diff --git a/frontend/components/dashboard/widgets/ExchangeWidget.tsx b/frontend/components/dashboard/widgets/ExchangeWidget.tsx index 86743326..d7fb2128 100644 --- a/frontend/components/dashboard/widgets/ExchangeWidget.tsx +++ b/frontend/components/dashboard/widgets/ExchangeWidget.tsx @@ -135,7 +135,7 @@ export default function ExchangeWidget({ const hasError = error || !exchangeRate; return ( -
+
{/* 헤더 */}
@@ -160,10 +160,10 @@ export default function ExchangeWidget({
- {/* 통화 선택 */} -
+ {/* 통화 선택 - 반응형 (좁을 때 세로 배치) */} +
- + From fa08a26079c6779bef1de25be1663c52ca3ae261 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Fri, 17 Oct 2025 09:49:02 +0900 Subject: [PATCH 02/13] =?UTF-8?q?=EB=8B=AC=EB=A0=A5=EA=B3=BC=20=ED=88=AC?= =?UTF-8?q?=EB=91=90=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=ED=95=A9=EC=B9=A8,=20?= =?UTF-8?q?=EB=B0=B0=EA=B2=BD=EC=83=89=EB=B3=80=EA=B2=BD=EA=B0=80=EB=8A=A5?= =?UTF-8?q?,=20=EC=9C=84=EC=A0=AF=EB=81=BC=EB=A6=AC=20=EB=B0=80=EC=96=B4?= =?UTF-8?q?=EB=82=B4=EB=8A=94=20=EA=B8=B0=EB=8A=A5=EA=B3=BC=20=EC=84=B8?= =?UTF-8?q?=EB=B0=80=ED=95=9C=20=EA=B7=B8=EB=A6=AC=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80,=20=EB=B2=94=EC=9A=A9=EC=9C=84=EC=A0=AF=20=EB=B3=B5?= =?UTF-8?q?=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/CanvasElement.tsx | 102 ++++-- .../admin/dashboard/DashboardCanvas.tsx | 64 +++- .../admin/dashboard/DashboardDesigner.tsx | 50 ++- .../admin/dashboard/DashboardSidebar.tsx | 12 +- .../admin/dashboard/DashboardTopMenu.tsx | 10 +- .../admin/dashboard/collisionUtils.ts | 162 +++++++++ .../components/admin/dashboard/gridUtils.ts | 31 +- .../dashboard/widgets/CalendarWidget.tsx | 19 +- .../admin/dashboard/widgets/MonthView.tsx | 44 ++- .../widgets/TodoWidgetConfigModal.tsx | 336 ++++++++++++++++++ .../components/dashboard/DashboardViewer.tsx | 73 ++-- .../dashboard/widgets/TodoWidget.tsx | 168 ++++++++- frontend/contexts/DashboardContext.tsx | 34 ++ 13 files changed, 992 insertions(+), 113 deletions(-) create mode 100644 frontend/components/admin/dashboard/collisionUtils.ts create mode 100644 frontend/components/admin/dashboard/widgets/TodoWidgetConfigModal.tsx create mode 100644 frontend/contexts/DashboardContext.tsx diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 7050b1f2..ccf8ff13 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -208,7 +208,7 @@ export function CanvasElement({ const deltaX = e.clientX - dragStart.x; const deltaY = e.clientY - dragStart.y; - // 임시 위치 계산 (스냅 안 됨) + // 임시 위치 계산 let rawX = Math.max(0, dragStart.elementX + deltaX); const rawY = Math.max(0, dragStart.elementY + deltaY); @@ -216,7 +216,26 @@ export function CanvasElement({ const maxX = canvasWidth - element.size.width; rawX = Math.min(rawX, maxX); - setTempPosition({ x: rawX, y: rawY }); + // 드래그 중 실시간 스냅 (마그네틱 스냅) + const subGridSize = Math.floor(cellSize / 3); + const gridSize = cellSize + 5; // GAP 포함한 실제 그리드 크기 + const magneticThreshold = 15; // 큰 그리드에 끌리는 거리 (px) + + // X 좌표 스냅 (큰 그리드 우선, 없으면 서브그리드) + const nearestGridX = Math.round(rawX / gridSize) * gridSize; + const distToGridX = Math.abs(rawX - nearestGridX); + const snappedX = distToGridX <= magneticThreshold + ? nearestGridX + : Math.round(rawX / subGridSize) * subGridSize; + + // Y 좌표 스냅 (큰 그리드 우선, 없으면 서브그리드) + const nearestGridY = Math.round(rawY / gridSize) * gridSize; + const distToGridY = Math.abs(rawY - nearestGridY); + const snappedY = distToGridY <= magneticThreshold + ? nearestGridY + : Math.round(rawY / subGridSize) * subGridSize; + + setTempPosition({ x: snappedX, y: snappedY }); } else if (isResizing) { const deltaX = e.clientX - resizeStart.x; const deltaY = e.clientY - resizeStart.y; @@ -259,46 +278,85 @@ export function CanvasElement({ const maxWidth = canvasWidth - newX; newWidth = Math.min(newWidth, maxWidth); - // 임시 크기/위치 저장 (스냅 안 됨) - setTempPosition({ x: Math.max(0, newX), y: Math.max(0, newY) }); - setTempSize({ width: newWidth, height: newHeight }); + // 리사이즈 중 실시간 스냅 (마그네틱 스냅) + const subGridSize = Math.floor(cellSize / 3); + const gridSize = cellSize + 5; // GAP 포함한 실제 그리드 크기 + const magneticThreshold = 15; + + // 위치 스냅 + const nearestGridX = Math.round(newX / gridSize) * gridSize; + const distToGridX = Math.abs(newX - nearestGridX); + const snappedX = distToGridX <= magneticThreshold + ? nearestGridX + : Math.round(newX / subGridSize) * subGridSize; + + const nearestGridY = Math.round(newY / gridSize) * gridSize; + const distToGridY = Math.abs(newY - nearestGridY); + const snappedY = distToGridY <= magneticThreshold + ? nearestGridY + : Math.round(newY / subGridSize) * subGridSize; + + // 크기 스냅 (그리드 칸 단위로 스냅하되, 마지막 GAP은 제외) + // 예: 1칸 = cellSize, 2칸 = cellSize*2 + GAP, 3칸 = cellSize*3 + GAP*2 + const calculateGridWidth = (cells: number) => cells * cellSize + Math.max(0, cells - 1) * 5; + + // 가장 가까운 그리드 칸 수 계산 + const nearestWidthCells = Math.round(newWidth / gridSize); + const nearestGridWidth = calculateGridWidth(nearestWidthCells); + const distToGridWidth = Math.abs(newWidth - nearestGridWidth); + const snappedWidth = distToGridWidth <= magneticThreshold + ? nearestGridWidth + : Math.round(newWidth / subGridSize) * subGridSize; + + const nearestHeightCells = Math.round(newHeight / gridSize); + const nearestGridHeight = calculateGridWidth(nearestHeightCells); + const distToGridHeight = Math.abs(newHeight - nearestGridHeight); + const snappedHeight = distToGridHeight <= magneticThreshold + ? nearestGridHeight + : Math.round(newHeight / subGridSize) * subGridSize; + + // 임시 크기/위치 저장 (스냅됨) + setTempPosition({ x: Math.max(0, snappedX), y: Math.max(0, snappedY) }); + setTempSize({ width: snappedWidth, height: snappedHeight }); } }, - [isDragging, isResizing, dragStart, resizeStart, element.size.width, element.type, element.subtype, canvasWidth], + [isDragging, isResizing, dragStart, resizeStart, element.size.width, element.type, element.subtype, canvasWidth, cellSize], ); - // 마우스 업 처리 (그리드 스냅 적용) + // 마우스 업 처리 (이미 스냅된 위치 사용) const handleMouseUp = useCallback(() => { if (isDragging && tempPosition) { - // 드래그 종료 시 그리드에 스냅 (동적 셀 크기 사용) - let snappedX = snapToGrid(tempPosition.x, cellSize); - const snappedY = snapToGrid(tempPosition.y, cellSize); + // tempPosition은 이미 드래그 중에 마그네틱 스냅 적용됨 + // 다시 스냅하지 않고 그대로 사용! + let finalX = tempPosition.x; + const finalY = tempPosition.y; // X 좌표가 캔버스 너비를 벗어나지 않도록 최종 제한 const maxX = canvasWidth - element.size.width; - snappedX = Math.min(snappedX, maxX); + finalX = Math.min(finalX, maxX); onUpdate(element.id, { - position: { x: snappedX, y: snappedY }, + position: { x: finalX, y: finalY }, }); setTempPosition(null); } if (isResizing && tempPosition && tempSize) { - // 리사이즈 종료 시 그리드에 스냅 (동적 셀 크기 사용) - const snappedX = snapToGrid(tempPosition.x, cellSize); - const snappedY = snapToGrid(tempPosition.y, cellSize); - let snappedWidth = snapSizeToGrid(tempSize.width, 2, cellSize); - const snappedHeight = snapSizeToGrid(tempSize.height, 2, cellSize); + // tempPosition과 tempSize는 이미 리사이즈 중에 마그네틱 스냅 적용됨 + // 다시 스냅하지 않고 그대로 사용! + let finalX = tempPosition.x; + const finalY = tempPosition.y; + let finalWidth = tempSize.width; + const finalHeight = tempSize.height; // 가로 너비가 캔버스를 벗어나지 않도록 최종 제한 - const maxWidth = canvasWidth - snappedX; - snappedWidth = Math.min(snappedWidth, maxWidth); + const maxWidth = canvasWidth - finalX; + finalWidth = Math.min(finalWidth, maxWidth); onUpdate(element.id, { - position: { x: snappedX, y: snappedY }, - size: { width: snappedWidth, height: snappedHeight }, + position: { x: finalX, y: finalY }, + size: { width: finalWidth, height: finalHeight }, }); setTempPosition(null); @@ -652,7 +710,7 @@ export function CanvasElement({ ) : element.type === "widget" && element.subtype === "todo" ? ( // To-Do 위젯 렌더링
- +
) : element.type === "widget" && element.subtype === "booking-alert" ? ( // 예약 요청 알림 위젯 렌더링 diff --git a/frontend/components/admin/dashboard/DashboardCanvas.tsx b/frontend/components/admin/dashboard/DashboardCanvas.tsx index d05f73cf..eecf12ec 100644 --- a/frontend/components/admin/dashboard/DashboardCanvas.tsx +++ b/frontend/components/admin/dashboard/DashboardCanvas.tsx @@ -4,6 +4,7 @@ import React, { forwardRef, useState, useCallback, useMemo } from "react"; import { DashboardElement, ElementType, ElementSubtype, DragData } from "./types"; import { CanvasElement } from "./CanvasElement"; import { GRID_CONFIG, snapToGrid, calculateGridConfig } from "./gridUtils"; +import { resolveAllCollisions } from "./collisionUtils"; interface DashboardCanvasProps { elements: DashboardElement[]; @@ -47,6 +48,46 @@ export const DashboardCanvas = forwardRef( const gridConfig = useMemo(() => calculateGridConfig(canvasWidth), [canvasWidth]); const cellSize = gridConfig.CELL_SIZE; + // 충돌 방지 기능이 포함된 업데이트 핸들러 + const handleUpdateWithCollisionDetection = useCallback( + (id: string, updates: Partial) => { + // 업데이트할 요소 찾기 + const elementIndex = elements.findIndex((el) => el.id === id); + if (elementIndex === -1) { + onUpdateElement(id, updates); + return; + } + + // 임시로 업데이트된 요소 배열 생성 + const updatedElements = elements.map((el) => + el.id === id ? { ...el, ...updates, position: updates.position || el.position, size: updates.size || el.size } : el + ); + + // 서브 그리드 크기 계산 (cellSize / 3) + const subGridSize = Math.floor(cellSize / 3); + + // 충돌 해결 (서브 그리드 단위로 스냅 및 충돌 감지) + const resolvedElements = resolveAllCollisions(updatedElements, id, subGridSize, canvasWidth, cellSize); + + // 변경된 요소들만 업데이트 + resolvedElements.forEach((resolvedEl, idx) => { + const originalEl = elements[idx]; + if ( + resolvedEl.position.x !== originalEl.position.x || + resolvedEl.position.y !== originalEl.position.y || + resolvedEl.size.width !== originalEl.size.width || + resolvedEl.size.height !== originalEl.size.height + ) { + onUpdateElement(resolvedEl.id, { + position: resolvedEl.position, + size: resolvedEl.size, + }); + } + }); + }, + [elements, onUpdateElement, cellSize, canvasWidth] + ); + // 드래그 오버 처리 const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); @@ -79,9 +120,24 @@ 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, cellSize); - const snappedY = snapToGrid(rawY, cellSize); + // 마그네틱 스냅 (큰 그리드 우선, 없으면 서브그리드) + const subGridSize = Math.floor(cellSize / 3); + const gridSize = cellSize + 5; // GAP 포함한 실제 그리드 크기 + const magneticThreshold = 15; + + // X 좌표 스냅 + const nearestGridX = Math.round(rawX / gridSize) * gridSize; + const distToGridX = Math.abs(rawX - nearestGridX); + let snappedX = distToGridX <= magneticThreshold + ? nearestGridX + : Math.round(rawX / subGridSize) * subGridSize; + + // Y 좌표 스냅 + const nearestGridY = Math.round(rawY / gridSize) * gridSize; + const distToGridY = Math.abs(rawY - nearestGridY); + const snappedY = distToGridY <= magneticThreshold + ? nearestGridY + : Math.round(rawY / subGridSize) * subGridSize; // X 좌표가 캔버스 너비를 벗어나지 않도록 제한 const maxX = canvasWidth - cellSize * 2; // 최소 2칸 너비 보장 @@ -161,7 +217,7 @@ export const DashboardCanvas = forwardRef( isSelected={selectedElement === element.id} cellSize={cellSize} canvasWidth={canvasWidth} - onUpdate={onUpdateElement} + onUpdate={handleUpdateWithCollisionDetection} onRemove={onRemoveElement} onSelect={onSelectElement} onConfigure={onConfigureElement} diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 9cbff1b8..2972ca2f 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -6,9 +6,11 @@ import { DashboardCanvas } from "./DashboardCanvas"; import { DashboardTopMenu } from "./DashboardTopMenu"; import { ElementConfigModal } from "./ElementConfigModal"; import { ListWidgetConfigModal } from "./widgets/ListWidgetConfigModal"; +import { TodoWidgetConfigModal } from "./widgets/TodoWidgetConfigModal"; import { DashboardElement, ElementType, ElementSubtype } from "./types"; import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize } from "./gridUtils"; import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector"; +import { DashboardProvider } from "@/contexts/DashboardContext"; interface DashboardDesignerProps { dashboardId?: string; @@ -302,6 +304,15 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D [configModalElement, updateElement], ); + const saveTodoWidgetConfig = useCallback( + (updates: Partial) => { + if (configModalElement) { + updateElement(configModalElement.id, updates); + } + }, + [configModalElement, updateElement], + ); + // 레이아웃 저장 const saveLayout = useCallback(async () => { if (elements.length === 0) { @@ -400,20 +411,21 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D } return ( -
- {/* 상단 메뉴바 */} - router.push(`/dashboard/${dashboardId}`) : undefined} - dashboardTitle={dashboardTitle} - onAddElement={addElementFromMenu} - resolution={resolution} - onResolutionChange={handleResolutionChange} - currentScreenResolution={screenResolution} - backgroundColor={canvasBackgroundColor} - onBackgroundColorChange={setCanvasBackgroundColor} - /> + +
+ {/* 상단 메뉴바 */} + router.push(`/dashboard/${dashboardId}`) : undefined} + dashboardTitle={dashboardTitle} + onAddElement={addElementFromMenu} + resolution={resolution} + onResolutionChange={handleResolutionChange} + currentScreenResolution={screenResolution} + backgroundColor={canvasBackgroundColor} + onBackgroundColorChange={setCanvasBackgroundColor} + /> {/* 캔버스 영역 - 해상도에 따른 크기, 중앙 정렬 */}
@@ -450,6 +462,13 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D onClose={closeConfigModal} onSave={saveListWidgetConfig} /> + ) : configModalElement.type === "widget" && configModalElement.subtype === "todo" ? ( + ) : ( )} -
+
+
); } diff --git a/frontend/components/admin/dashboard/DashboardSidebar.tsx b/frontend/components/admin/dashboard/DashboardSidebar.tsx index 6c03b398..4ae3b3be 100644 --- a/frontend/components/admin/dashboard/DashboardSidebar.tsx +++ b/frontend/components/admin/dashboard/DashboardSidebar.tsx @@ -157,14 +157,13 @@ export function DashboardSidebar() { subtype="map-summary" onDragStart={handleDragStart} /> - {/* 주석: 다른 분이 범용 리스트 작업 중 - 충돌 방지를 위해 임시 주석처리 */} - {/* */} + /> + 데이터 위젯 리스트 위젯 - 지도 + {/* 지도 */} + 커스텀 지도 카드 + {/* 커스텀 목록 카드 */} + 커스텀 상태 카드 일반 위젯 @@ -198,12 +201,13 @@ export function DashboardTopMenu({ 문서 리스크 알림 - + {/* 범용 위젯으로 대체 가능하여 주석처리 */} + {/* 차량 관리 차량 상태 차량 목록 차량 위치 - + */}
diff --git a/frontend/components/admin/dashboard/collisionUtils.ts b/frontend/components/admin/dashboard/collisionUtils.ts new file mode 100644 index 00000000..ff597eda --- /dev/null +++ b/frontend/components/admin/dashboard/collisionUtils.ts @@ -0,0 +1,162 @@ +/** + * 대시보드 위젯 충돌 감지 및 자동 재배치 유틸리티 + */ + +import { DashboardElement } from "./types"; + +export interface Rectangle { + x: number; + y: number; + width: number; + height: number; +} + +/** + * 두 사각형이 겹치는지 확인 (여유있는 충돌 감지) + * @param rect1 첫 번째 사각형 + * @param rect2 두 번째 사각형 + * @param cellSize 한 그리드 칸의 크기 (기본: 130px) + */ +export function isColliding(rect1: Rectangle, rect2: Rectangle, cellSize: number = 130): boolean { + // 겹친 영역 계산 + const overlapX = Math.max( + 0, + Math.min(rect1.x + rect1.width, rect2.x + rect2.width) - Math.max(rect1.x, rect2.x) + ); + const overlapY = Math.max( + 0, + Math.min(rect1.y + rect1.height, rect2.y + rect2.height) - Math.max(rect1.y, rect2.y) + ); + + // 큰 그리드의 절반(cellSize/2 ≈ 65px) 이상 겹쳐야 충돌로 간주 + const collisionThreshold = Math.floor(cellSize / 2); + return overlapX >= collisionThreshold && overlapY >= collisionThreshold; +} + +/** + * 특정 위젯과 충돌하는 다른 위젯들을 찾기 + */ +export function findCollisions( + element: DashboardElement, + allElements: DashboardElement[], + cellSize: number = 130, + excludeId?: string +): DashboardElement[] { + const elementRect: Rectangle = { + x: element.position.x, + y: element.position.y, + width: element.size.width, + height: element.size.height, + }; + + return allElements.filter((other) => { + if (other.id === element.id || other.id === excludeId) { + return false; + } + + const otherRect: Rectangle = { + x: other.position.x, + y: other.position.y, + width: other.size.width, + height: other.size.height, + }; + + return isColliding(elementRect, otherRect, cellSize); + }); +} + +/** + * 충돌을 해결하기 위해 위젯을 아래로 이동 + */ +export function resolveCollisionVertically( + movingElement: DashboardElement, + collidingElement: DashboardElement, + gridSize: number = 10 +): { x: number; y: number } { + // 충돌하는 위젯 아래로 이동 + const newY = collidingElement.position.y + collidingElement.size.height + gridSize; + + return { + x: collidingElement.position.x, + y: Math.round(newY / gridSize) * gridSize, // 그리드에 스냅 + }; +} + +/** + * 여러 위젯의 충돌을 재귀적으로 해결 + */ +export function resolveAllCollisions( + elements: DashboardElement[], + movedElementId: string, + subGridSize: number = 10, + canvasWidth: number = 1560, + cellSize: number = 130, + maxIterations: number = 50 +): DashboardElement[] { + let result = [...elements]; + let iterations = 0; + + // 이동한 위젯부터 시작 + const movedIndex = result.findIndex((el) => el.id === movedElementId); + if (movedIndex === -1) return result; + + // Y 좌표로 정렬 (위에서 아래로 처리) + const sortedIndices = result + .map((el, idx) => ({ el, idx })) + .sort((a, b) => a.el.position.y - b.el.position.y) + .map((item) => item.idx); + + while (iterations < maxIterations) { + let hasCollision = false; + + for (const idx of sortedIndices) { + const element = result[idx]; + const collisions = findCollisions(element, result, cellSize); + + if (collisions.length > 0) { + hasCollision = true; + + // 첫 번째 충돌만 처리 (가장 위에 있는 것) + const collision = collisions.sort((a, b) => a.position.y - b.position.y)[0]; + + // 충돌하는 위젯을 아래로 이동 + const collisionIdx = result.findIndex((el) => el.id === collision.id); + if (collisionIdx !== -1) { + const newY = element.position.y + element.size.height + subGridSize; + + result[collisionIdx] = { + ...result[collisionIdx], + position: { + ...result[collisionIdx].position, + y: Math.round(newY / subGridSize) * subGridSize, + }, + }; + } + } + } + + if (!hasCollision) break; + iterations++; + } + + return result; +} + +/** + * 위젯이 캔버스 경계를 벗어나지 않도록 제한 + */ +export function constrainToCanvas( + element: DashboardElement, + canvasWidth: number, + canvasHeight: number, + gridSize: number = 10 +): { x: number; y: number } { + const maxX = canvasWidth - element.size.width; + const maxY = canvasHeight - element.size.height; + + return { + x: Math.max(0, Math.min(Math.round(element.position.x / gridSize) * gridSize, maxX)), + y: Math.max(0, Math.min(Math.round(element.position.y / gridSize) * gridSize, maxY)), + }; +} + diff --git a/frontend/components/admin/dashboard/gridUtils.ts b/frontend/components/admin/dashboard/gridUtils.ts index 54149222..3864e861 100644 --- a/frontend/components/admin/dashboard/gridUtils.ts +++ b/frontend/components/admin/dashboard/gridUtils.ts @@ -8,9 +8,10 @@ // 기본 그리드 설정 (FHD 기준) export const GRID_CONFIG = { COLUMNS: 12, // 모든 해상도에서 12칸 고정 - GAP: 8, // 셀 간격 고정 - SNAP_THRESHOLD: 15, // 스냅 임계값 (px) + GAP: 5, // 셀 간격 고정 + SNAP_THRESHOLD: 10, // 스냅 임계값 (px) ELEMENT_PADDING: 4, // 요소 주위 여백 (px) + SUB_GRID_DIVISIONS: 5, // 각 그리드 칸을 5x5로 세분화 (세밀한 조정용) // CELL_SIZE와 CANVAS_WIDTH는 해상도에 따라 동적 계산 } as const; @@ -23,14 +24,23 @@ export function calculateCellSize(canvasWidth: number): number { return Math.floor((canvasWidth + GRID_CONFIG.GAP) / GRID_CONFIG.COLUMNS) - GRID_CONFIG.GAP; } +/** + * 서브 그리드 크기 계산 (세밀한 조정용) + */ +export function calculateSubGridSize(cellSize: number): number { + return Math.floor(cellSize / GRID_CONFIG.SUB_GRID_DIVISIONS); +} + /** * 해상도별 그리드 설정 계산 */ export function calculateGridConfig(canvasWidth: number) { const cellSize = calculateCellSize(canvasWidth); + const subGridSize = calculateSubGridSize(cellSize); return { ...GRID_CONFIG, CELL_SIZE: cellSize, + SUB_GRID_SIZE: subGridSize, CANVAS_WIDTH: canvasWidth, }; } @@ -51,15 +61,18 @@ export const getCanvasWidth = () => { }; /** - * 좌표를 가장 가까운 그리드 포인트로 스냅 (여백 포함) + * 좌표를 서브 그리드에 스냅 (세밀한 조정 가능) * @param value - 스냅할 좌표값 - * @param cellSize - 셀 크기 (선택사항, 기본값은 GRID_CONFIG.CELL_SIZE) - * @returns 스냅된 좌표값 (여백 포함) + * @param subGridSize - 서브 그리드 크기 (선택사항, 기본값: cellSize/3 ≈ 43px) + * @returns 스냅된 좌표값 */ -export const snapToGrid = (value: number, cellSize: number = GRID_CONFIG.CELL_SIZE): number => { - const cellWithGap = cellSize + GRID_CONFIG.GAP; - const gridIndex = Math.round(value / cellWithGap); - return gridIndex * cellWithGap + GRID_CONFIG.ELEMENT_PADDING; +export const snapToGrid = (value: number, subGridSize?: number): number => { + // 서브 그리드 크기가 지정되지 않으면 기본 그리드 크기의 1/3 사용 (3x3 서브그리드) + const snapSize = subGridSize ?? Math.floor(GRID_CONFIG.CELL_SIZE / 3); + + // 서브 그리드 단위로 스냅 + const gridIndex = Math.round(value / snapSize); + return gridIndex * snapSize; }; /** diff --git a/frontend/components/admin/dashboard/widgets/CalendarWidget.tsx b/frontend/components/admin/dashboard/widgets/CalendarWidget.tsx index 4f54ac65..b5638f94 100644 --- a/frontend/components/admin/dashboard/widgets/CalendarWidget.tsx +++ b/frontend/components/admin/dashboard/widgets/CalendarWidget.tsx @@ -8,6 +8,7 @@ import { generateCalendarDays, getMonthName, navigateMonth } from "./calendarUti import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; import { Settings, ChevronLeft, ChevronRight, Calendar } from "lucide-react"; +import { useDashboard } from "@/contexts/DashboardContext"; interface CalendarWidgetProps { element: DashboardElement; @@ -21,11 +22,19 @@ interface CalendarWidgetProps { * - 내장 설정 UI */ export function CalendarWidget({ element, onConfigUpdate }: CalendarWidgetProps) { + // Context에서 선택된 날짜 관리 + const { selectedDate, setSelectedDate } = useDashboard(); + // 현재 표시 중인 년/월 const today = new Date(); const [currentYear, setCurrentYear] = useState(today.getFullYear()); const [currentMonth, setCurrentMonth] = useState(today.getMonth()); const [settingsOpen, setSettingsOpen] = useState(false); + + // 날짜 클릭 핸들러 + const handleDateClick = (date: Date) => { + setSelectedDate(date); + }; // 기본 설정값 const config = element.calendarConfig || { @@ -98,7 +107,15 @@ export function CalendarWidget({ element, onConfigUpdate }: CalendarWidgetProps) {/* 달력 콘텐츠 */}
- {config.view === "month" && } + {config.view === "month" && ( + + )} {/* 추후 WeekView, DayView 추가 가능 */}
diff --git a/frontend/components/admin/dashboard/widgets/MonthView.tsx b/frontend/components/admin/dashboard/widgets/MonthView.tsx index c0fd3871..67c1596c 100644 --- a/frontend/components/admin/dashboard/widgets/MonthView.tsx +++ b/frontend/components/admin/dashboard/widgets/MonthView.tsx @@ -7,12 +7,14 @@ interface MonthViewProps { days: CalendarDay[]; config: CalendarConfig; isCompact?: boolean; // 작은 크기 (2x2, 3x3) + selectedDate?: Date | null; // 선택된 날짜 + onDateClick?: (date: Date) => void; // 날짜 클릭 핸들러 } /** * 월간 달력 뷰 컴포넌트 */ -export function MonthView({ days, config, isCompact = false }: MonthViewProps) { +export function MonthView({ days, config, isCompact = false, selectedDate, onDateClick }: MonthViewProps) { const weekDayNames = getWeekDayNames(config.startWeekOn); // 테마별 스타일 @@ -43,10 +45,27 @@ export function MonthView({ days, config, isCompact = false }: MonthViewProps) { const themeStyles = getThemeStyles(); + // 날짜가 선택된 날짜인지 확인 + const isSelected = (day: CalendarDay) => { + if (!selectedDate || !day.isCurrentMonth) return false; + return ( + selectedDate.getFullYear() === day.date.getFullYear() && + selectedDate.getMonth() === day.date.getMonth() && + selectedDate.getDate() === day.date.getDate() + ); + }; + + // 날짜 클릭 핸들러 + const handleDayClick = (day: CalendarDay) => { + if (!day.isCurrentMonth || !onDateClick) return; + onDateClick(day.date); + }; + // 날짜 셀 스타일 클래스 const getDayCellClass = (day: CalendarDay) => { const baseClass = "flex aspect-square items-center justify-center rounded-lg transition-colors"; const sizeClass = isCompact ? "text-xs" : "text-sm"; + const cursorClass = day.isCurrentMonth ? "cursor-pointer" : "cursor-default"; let colorClass = "text-gray-700"; @@ -54,6 +73,10 @@ export function MonthView({ days, config, isCompact = false }: MonthViewProps) { if (!day.isCurrentMonth) { colorClass = "text-gray-300"; } + // 선택된 날짜 + else if (isSelected(day)) { + colorClass = "text-white font-bold"; + } // 오늘 else if (config.highlightToday && day.isToday) { colorClass = "text-white font-bold"; @@ -67,9 +90,16 @@ export function MonthView({ days, config, isCompact = false }: MonthViewProps) { colorClass = "text-red-600"; } - const bgClass = config.highlightToday && day.isToday ? "" : "hover:bg-gray-100"; + let bgClass = ""; + if (isSelected(day)) { + bgClass = ""; // 선택된 날짜는 배경색이 style로 적용됨 + } else if (config.highlightToday && day.isToday) { + bgClass = ""; + } else { + bgClass = "hover:bg-gray-100"; + } - return `${baseClass} ${sizeClass} ${colorClass} ${bgClass}`; + return `${baseClass} ${sizeClass} ${colorClass} ${bgClass} ${cursorClass}`; }; return ( @@ -97,9 +127,13 @@ export function MonthView({ days, config, isCompact = false }: MonthViewProps) {
handleDayClick(day)} style={{ - backgroundColor: - config.highlightToday && day.isToday ? themeStyles.todayBg : undefined, + backgroundColor: isSelected(day) + ? "#10b981" // 선택된 날짜는 초록색 + : config.highlightToday && day.isToday + ? themeStyles.todayBg + : undefined, color: config.showHolidays && day.isHoliday && day.isCurrentMonth ? themeStyles.holidayText diff --git a/frontend/components/admin/dashboard/widgets/TodoWidgetConfigModal.tsx b/frontend/components/admin/dashboard/widgets/TodoWidgetConfigModal.tsx new file mode 100644 index 00000000..3c238873 --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/TodoWidgetConfigModal.tsx @@ -0,0 +1,336 @@ +"use client"; + +import React, { useState, useCallback, useEffect } from "react"; +import { DashboardElement, ChartDataSource, QueryResult } from "../types"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { ChevronLeft, ChevronRight, Save, X } from "lucide-react"; +import { DataSourceSelector } from "../data-sources/DataSourceSelector"; +import { DatabaseConfig } from "../data-sources/DatabaseConfig"; +import { ApiConfig } from "../data-sources/ApiConfig"; +import { QueryEditor } from "../QueryEditor"; + +interface TodoWidgetConfigModalProps { + isOpen: boolean; + element: DashboardElement; + onClose: () => void; + onSave: (updates: Partial) => void; +} + +/** + * To-Do 위젯 설정 모달 + * - 2단계 설정: 데이터 소스 → 쿼리 입력/테스트 + */ +export function TodoWidgetConfigModal({ isOpen, element, onClose, onSave }: TodoWidgetConfigModalProps) { + const [currentStep, setCurrentStep] = useState<1 | 2>(1); + const [title, setTitle] = useState(element.title || "✅ To-Do / 긴급 지시"); + const [dataSource, setDataSource] = useState( + element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 }, + ); + const [queryResult, setQueryResult] = useState(null); + + // 모달 열릴 때 element에서 설정 로드 + useEffect(() => { + if (isOpen) { + setTitle(element.title || "✅ To-Do / 긴급 지시"); + if (element.dataSource) { + setDataSource(element.dataSource); + } + setCurrentStep(1); + } + }, [isOpen, element.id]); + + // 데이터 소스 타입 변경 + const handleDataSourceTypeChange = useCallback((type: "database" | "api") => { + if (type === "database") { + setDataSource((prev) => ({ + ...prev, + type: "database", + connectionType: "current", + })); + } else { + setDataSource((prev) => ({ + ...prev, + type: "api", + method: "GET", + })); + } + setQueryResult(null); + }, []); + + // 데이터 소스 업데이트 + const handleDataSourceUpdate = useCallback((updates: Partial) => { + setDataSource((prev) => ({ ...prev, ...updates })); + }, []); + + // 쿼리 실행 결과 처리 + const handleQueryTest = useCallback( + (result: QueryResult) => { + console.log("🎯 TodoWidget - handleQueryTest 호출됨!"); + console.log("📊 쿼리 결과:", result); + console.log("📝 rows 개수:", result.rows?.length); + console.log("❌ error:", result.error); + setQueryResult(result); + console.log("✅ setQueryResult 호출 완료!"); + + // 강제 리렌더링 확인 + setTimeout(() => { + console.log("🔄 1초 후 queryResult 상태:", result); + }, 1000); + }, + [], + ); + + // 저장 + const handleSave = useCallback(() => { + if (!dataSource.query || !queryResult || queryResult.error) { + alert("쿼리를 입력하고 테스트를 먼저 실행해주세요."); + return; + } + + if (!queryResult.rows || queryResult.rows.length === 0) { + alert("쿼리 결과가 없습니다. 데이터가 반환되는 쿼리를 입력해주세요."); + return; + } + + onSave({ + title, + dataSource, + }); + + onClose(); + }, [title, dataSource, queryResult, onSave, onClose]); + + // 다음 단계로 + const handleNext = useCallback(() => { + if (currentStep === 1) { + if (dataSource.type === "database") { + if (!dataSource.connectionId && dataSource.connectionType === "external") { + alert("외부 데이터베이스를 선택해주세요."); + return; + } + } else if (dataSource.type === "api") { + if (!dataSource.url) { + alert("API URL을 입력해주세요."); + return; + } + } + setCurrentStep(2); + } + }, [currentStep, dataSource]); + + // 이전 단계로 + const handlePrev = useCallback(() => { + if (currentStep === 2) { + setCurrentStep(1); + } + }, [currentStep]); + + if (!isOpen) return null; + + return ( +
+
+ {/* 헤더 */} +
+
+

To-Do 위젯 설정

+

+ 데이터 소스와 쿼리를 설정하면 자동으로 To-Do 목록이 표시됩니다 +

+
+ +
+ + {/* 진행 상태 */} +
+
+
+
+ 1 +
+ 데이터 소스 선택 +
+ +
+
+ 2 +
+ 쿼리 입력 및 테스트 +
+
+
+ + {/* 본문 */} +
+ {/* Step 1: 데이터 소스 선택 */} + {currentStep === 1 && ( +
+
+ + setTitle(e.target.value)} + placeholder="예: ✅ 오늘의 할 일" + className="mt-2" + /> +
+ +
+ + +
+ + {dataSource.type === "database" && ( + + )} + + {dataSource.type === "api" && } +
+ )} + + {/* Step 2: 쿼리 입력 및 테스트 */} + {currentStep === 2 && ( +
+
+
+

💡 컬럼명 가이드

+

+ 쿼리 결과에 다음 컬럼명이 있으면 자동으로 To-Do 항목으로 변환됩니다: +

+
    +
  • + id - 고유 ID (없으면 자동 생성) +
  • +
  • + title,{" "} + task,{" "} + name - 제목 (필수) +
  • +
  • + description,{" "} + desc,{" "} + content - 상세 설명 +
  • +
  • + priority - 우선순위 (urgent, high, + normal, low) +
  • +
  • + status - 상태 (pending, in_progress, + completed) +
  • +
  • + assigned_to,{" "} + assignedTo,{" "} + user - 담당자 +
  • +
  • + due_date,{" "} + dueDate,{" "} + deadline - 마감일 +
  • +
  • + is_urgent,{" "} + isUrgent,{" "} + urgent - 긴급 여부 +
  • +
+
+ + +
+ + {/* 디버그: 항상 표시되는 테스트 메시지 */} +
+

+ 🔍 디버그: queryResult 상태 = {queryResult ? "있음" : "없음"} +

+ {queryResult && ( +

+ rows: {queryResult.rows?.length}개, error: {queryResult.error || "없음"} +

+ )} +
+ + {queryResult && !queryResult.error && queryResult.rows && queryResult.rows.length > 0 && ( +
+

✅ 쿼리 테스트 성공!

+

+ 총 {queryResult.rows.length}개의 To-Do 항목을 찾았습니다. +

+
+

첫 번째 데이터 미리보기:

+
+                      {JSON.stringify(queryResult.rows[0], null, 2)}
+                    
+
+
+ )} +
+ )} +
+ + {/* 하단 버튼 */} +
+
+ {currentStep > 1 && ( + + )} +
+ +
+ + + {currentStep < 2 ? ( + + ) : ( + + )} +
+
+
+
+ ); +} + diff --git a/frontend/components/dashboard/DashboardViewer.tsx b/frontend/components/dashboard/DashboardViewer.tsx index 1c9fd7ed..cbc2738d 100644 --- a/frontend/components/dashboard/DashboardViewer.tsx +++ b/frontend/components/dashboard/DashboardViewer.tsx @@ -3,6 +3,7 @@ import React, { useState, useEffect, useCallback } from "react"; import { DashboardElement, QueryResult } from "@/components/admin/dashboard/types"; import { ChartRenderer } from "@/components/admin/dashboard/charts/ChartRenderer"; +import { DashboardProvider } from "@/contexts/DashboardContext"; import dynamic from "next/dynamic"; // 위젯 동적 import - 모든 위젯 @@ -231,28 +232,30 @@ export function DashboardViewer({ elements, dashboardId, refreshInterval, backgr } return ( -
- {/* 새로고침 상태 표시 */} -
- 마지막 업데이트: {lastRefresh.toLocaleTimeString()} - {Array.from(loadingElements).length > 0 && ( - ({Array.from(loadingElements).length}개 로딩 중...) - )} -
+ +
+ {/* 새로고침 상태 표시 */} +
+ 마지막 업데이트: {lastRefresh.toLocaleTimeString()} + {Array.from(loadingElements).length > 0 && ( + ({Array.from(loadingElements).length}개 로딩 중...) + )} +
- {/* 대시보드 요소들 */} -
- {elements.map((element) => ( - loadElementData(element)} - /> - ))} + {/* 대시보드 요소들 */} +
+ {elements.map((element) => ( + loadElementData(element)} + /> + ))} +
-
+
); } @@ -286,21 +289,21 @@ function ViewerElement({ element, data, isLoading, onRefresh }: ViewerElementPro

{element.customTitle || element.title}

- {/* 새로고침 버튼 (호버 시에만 표시) */} - {isHovered && ( - - )} + {/* 새로고침 버튼 (항상 렌더링하되 opacity로 제어) */} +
)} diff --git a/frontend/components/dashboard/widgets/TodoWidget.tsx b/frontend/components/dashboard/widgets/TodoWidget.tsx index f43ba325..977a9b6c 100644 --- a/frontend/components/dashboard/widgets/TodoWidget.tsx +++ b/frontend/components/dashboard/widgets/TodoWidget.tsx @@ -1,8 +1,9 @@ "use client"; import React, { useState, useEffect } from "react"; -import { Plus, Check, X, Clock, AlertCircle, GripVertical, ChevronDown } from "lucide-react"; +import { Plus, Check, X, Clock, AlertCircle, GripVertical, ChevronDown, Calendar as CalendarIcon } from "lucide-react"; import { DashboardElement } from "@/components/admin/dashboard/types"; +import { useDashboard } from "@/contexts/DashboardContext"; interface TodoItem { id: string; @@ -33,6 +34,9 @@ interface TodoWidgetProps { } export default function TodoWidget({ element }: TodoWidgetProps) { + // Context에서 선택된 날짜 가져오기 + const { selectedDate } = useDashboard(); + const [todos, setTodos] = useState([]); const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); @@ -51,22 +55,85 @@ export default function TodoWidget({ element }: TodoWidgetProps) { fetchTodos(); const interval = setInterval(fetchTodos, 30000); // 30초마다 갱신 return () => clearInterval(interval); - }, [filter]); + }, [filter, selectedDate]); // selectedDate도 의존성에 추가 const fetchTodos = async () => { try { const token = localStorage.getItem("authToken"); - const filterParam = filter !== "all" ? `?status=${filter}` : ""; - const response = await fetch(`http://localhost:9771/api/todos${filterParam}`, { - headers: { - Authorization: `Bearer ${token}`, - }, - }); + const userLang = localStorage.getItem("userLang") || "KR"; + + // 외부 DB 조회 (dataSource가 설정된 경우) + if (element?.dataSource?.query) { + console.log("🔍 TodoWidget - 외부 DB 조회 시작"); + console.log("📝 Query:", element.dataSource.query); + console.log("🔗 ConnectionId:", element.dataSource.externalConnectionId); + console.log("🔗 ConnectionType:", element.dataSource.connectionType); + + // 현재 DB vs 외부 DB 분기 + const apiUrl = element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId + ? `http://localhost:9771/api/external-db/query?userLang=${userLang}` + : `http://localhost:9771/api/dashboards/execute-query?userLang=${userLang}`; + + const requestBody = element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId + ? { + connectionId: parseInt(element.dataSource.externalConnectionId), + query: element.dataSource.query, + } + : { + query: element.dataSource.query, + }; - if (response.ok) { - const result = await response.json(); - setTodos(result.data || []); - setStats(result.stats); + console.log("🌐 API URL:", apiUrl); + console.log("📦 Request Body:", requestBody); + + const response = await fetch(apiUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(requestBody), + }); + + console.log("📡 Response status:", response.status); + + if (response.ok) { + const result = await response.json(); + console.log("✅ API 응답:", result); + console.log("📦 result.data:", result.data); + console.log("📦 result.data.rows:", result.data?.rows); + + // API 응답 형식에 따라 데이터 추출 + const rows = result.data?.rows || result.data || []; + console.log("📊 추출된 rows:", rows); + + const externalTodos = mapExternalDataToTodos(rows); + console.log("📋 변환된 Todos:", externalTodos); + console.log("📋 변환된 Todos 개수:", externalTodos.length); + + setTodos(externalTodos); + setStats(calculateStatsFromTodos(externalTodos)); + + console.log("✅ setTodos, setStats 호출 완료!"); + } else { + const errorText = await response.text(); + console.error("❌ API 오류:", errorText); + } + } + // 내장 API 조회 (기본) + else { + const filterParam = filter !== "all" ? `?status=${filter}` : ""; + const response = await fetch(`http://localhost:9771/api/todos${filterParam}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (response.ok) { + const result = await response.json(); + setTodos(result.data || []); + setStats(result.stats); + } } } catch (error) { // console.error("To-Do 로딩 오류:", error); @@ -75,8 +142,48 @@ export default function TodoWidget({ element }: TodoWidgetProps) { } }; + // 외부 DB 데이터를 TodoItem 형식으로 변환 + const mapExternalDataToTodos = (data: any[]): TodoItem[] => { + return data.map((row, index) => ({ + id: row.id || `todo-${index}`, + title: row.title || row.task || row.name || "제목 없음", + description: row.description || row.desc || row.content, + priority: row.priority || "normal", + status: row.status || "pending", + assignedTo: row.assigned_to || row.assignedTo || row.user, + dueDate: row.due_date || row.dueDate || row.deadline, + createdAt: row.created_at || row.createdAt || new Date().toISOString(), + updatedAt: row.updated_at || row.updatedAt || new Date().toISOString(), + completedAt: row.completed_at || row.completedAt, + isUrgent: row.is_urgent || row.isUrgent || row.urgent || false, + order: row.display_order || row.order || index, + })); + }; + + // Todo 배열로부터 통계 계산 + const calculateStatsFromTodos = (todoList: TodoItem[]): TodoStats => { + return { + total: todoList.length, + pending: todoList.filter((t) => t.status === "pending").length, + inProgress: todoList.filter((t) => t.status === "in_progress").length, + completed: todoList.filter((t) => t.status === "completed").length, + urgent: todoList.filter((t) => t.isUrgent).length, + overdue: todoList.filter((t) => { + if (!t.dueDate) return false; + return new Date(t.dueDate) < new Date() && t.status !== "completed"; + }).length, + }; + }; + + // 외부 DB 조회 여부 확인 + const isExternalData = !!element?.dataSource?.query; + const handleAddTodo = async () => { if (!newTodo.title.trim()) return; + if (isExternalData) { + alert("외부 데이터베이스 조회 모드에서는 추가할 수 없습니다."); + return; + } try { const token = localStorage.getItem("authToken"); @@ -185,6 +292,27 @@ export default function TodoWidget({ element }: TodoWidgetProps) { return "⚠️ 오늘 마감"; }; + // 선택된 날짜로 필터링 + const filteredTodos = selectedDate + ? todos.filter((todo) => { + if (!todo.dueDate) return false; + const todoDate = new Date(todo.dueDate); + return ( + todoDate.getFullYear() === selectedDate.getFullYear() && + todoDate.getMonth() === selectedDate.getMonth() && + todoDate.getDate() === selectedDate.getDate() + ); + }) + : todos; + + const formatSelectedDate = () => { + if (!selectedDate) return null; + const year = selectedDate.getFullYear(); + const month = selectedDate.getMonth() + 1; + const day = selectedDate.getDate(); + return `${year}년 ${month}월 ${day}일`; + }; + if (loading) { return (
@@ -198,7 +326,15 @@ export default function TodoWidget({ element }: TodoWidgetProps) { {/* 헤더 */}
-

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

+
+

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

+ {selectedDate && ( +
+ + {formatSelectedDate()} 할일 +
+ )} +
- {/* 편집 버튼 */} + {/* 편집 버튼 *\/}
- {/* 메타 정보 */} + {/* 메타 정보 *\/}
생성: {new Date(dashboard.createdAt).toLocaleString()} 수정: {new Date(dashboard.updatedAt).toLocaleString()} 요소: {dashboard.elements.length}개
-
+
*/} {/* 대시보드 뷰어 */} -
- -
+
); } diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 8ab08710..0f35ede6 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -470,7 +470,8 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D /> {/* 캔버스 영역 - 해상도에 따른 크기, 중앙 정렬 */} -
+ {/* overflow-auto 제거 - 외부 페이지 스크롤 사용 */} +
-
+ {/* overflow-auto 제거 - 외부 페이지 스크롤 사용 */} +
{/* 고정 크기 캔버스 (편집 화면과 동일한 레이아웃) */}
{ + const handleKeyDown = (event: KeyboardEvent) => { + const key = event.key; + + // 숫자 키 (0-9) + if (/^[0-9]$/.test(key)) { + event.preventDefault(); + handleNumber(key); + } + // 연산자 키 + else if (key === '+' || key === '-' || key === '*' || key === '/') { + event.preventDefault(); + handleOperation(key); + } + // 소수점 + else if (key === '.') { + event.preventDefault(); + handleDecimal(); + } + // Enter 또는 = (계산) + else if (key === 'Enter' || key === '=') { + event.preventDefault(); + handleEquals(); + } + // Escape 또는 c (초기화) + else if (key === 'Escape' || key.toLowerCase() === 'c') { + event.preventDefault(); + handleClear(); + } + // Backspace (지우기) + else if (key === 'Backspace') { + event.preventDefault(); + handleBackspace(); + } + // % (퍼센트) + else if (key === '%') { + event.preventDefault(); + handlePercent(); + } + }; + + // 이벤트 리스너 등록 + window.addEventListener('keydown', handleKeyDown); + + // 클린업 + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [display, previousValue, operation, waitingForOperand]); + return (
diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index 3fee965f..b6e0139b 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -457,7 +457,7 @@ function AppLayoutInner({ children }: AppLayoutProps) { {/* 가운데 컨텐츠 영역 - overflow 문제 해결 */} -
{children}
+
{children}
{/* 프로필 수정 모달 */} From 3b57d4acda64b6a6f168fdf68d93432d89a5af89 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Fri, 17 Oct 2025 13:44:51 +0900 Subject: [PATCH 07/13] =?UTF-8?q?=EC=B0=BD=EA=B3=A0=203d=20=EC=9C=84?= =?UTF-8?q?=EC=A0=AF=20=EA=B8=B0=EB=8A=A5=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/app.ts | 4 +- .../src/controllers/WarehouseController.ts | 97 ---- backend-node/src/routes/warehouseRoutes.ts | 22 - backend-node/src/services/WarehouseService.ts | 170 ------- .../admin/dashboard/CanvasElement.tsx | 68 ++- .../admin/dashboard/DashboardDesigner.tsx | 219 +++++---- .../admin/dashboard/DashboardTopMenu.tsx | 1 - frontend/components/admin/dashboard/types.ts | 3 +- .../dashboard/widgets/Warehouse3DWidget.tsx | 418 ------------------ 9 files changed, 135 insertions(+), 867 deletions(-) delete mode 100644 backend-node/src/controllers/WarehouseController.ts delete mode 100644 backend-node/src/routes/warehouseRoutes.ts delete mode 100644 backend-node/src/services/WarehouseService.ts delete mode 100644 frontend/components/admin/dashboard/widgets/Warehouse3DWidget.tsx diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 6660bc13..4ba9405d 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -55,7 +55,6 @@ 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"; // 임시 주석 @@ -205,7 +204,6 @@ 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); @@ -235,7 +233,7 @@ app.listen(PORT, HOST, async () => { // 대시보드 마이그레이션 실행 try { - const { runDashboardMigration } = await import('./database/runMigration'); + const { runDashboardMigration } = await import("./database/runMigration"); await runDashboardMigration(); } catch (error) { logger.error(`❌ 대시보드 마이그레이션 실패:`, error); diff --git a/backend-node/src/controllers/WarehouseController.ts b/backend-node/src/controllers/WarehouseController.ts deleted file mode 100644 index 1fe140e8..00000000 --- a/backend-node/src/controllers/WarehouseController.ts +++ /dev/null @@ -1,97 +0,0 @@ -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 deleted file mode 100644 index 15625a35..00000000 --- a/backend-node/src/routes/warehouseRoutes.ts +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index fe0433c7..00000000 --- a/backend-node/src/services/WarehouseService.ts +++ /dev/null @@ -1,170 +0,0 @@ -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/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index b45d8ed3..3be76d41 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -98,17 +98,6 @@ const DocumentWidget = dynamic(() => import("@/components/dashboard/widgets/Docu loading: () =>
로딩 중...
, }); -const Warehouse3DWidget = dynamic( - () => - import("@/components/admin/dashboard/widgets/Warehouse3DWidget").then((mod) => ({ - default: mod.Warehouse3DWidget, - })), - { - ssr: false, - loading: () =>
로딩 중...
, - }, -); - // 시계 위젯 임포트 import { ClockWidget } from "./widgets/ClockWidget"; // 달력 위젯 임포트 @@ -231,20 +220,16 @@ export function CanvasElement({ const subGridSize = Math.floor(cellSize / 3); const gridSize = cellSize + 5; // GAP 포함한 실제 그리드 크기 const magneticThreshold = 15; // 큰 그리드에 끌리는 거리 (px) - + // X 좌표 스냅 (큰 그리드 우선, 없으면 서브그리드) const nearestGridX = Math.round(rawX / gridSize) * gridSize; const distToGridX = Math.abs(rawX - nearestGridX); - const snappedX = distToGridX <= magneticThreshold - ? nearestGridX - : Math.round(rawX / subGridSize) * subGridSize; - + const snappedX = distToGridX <= magneticThreshold ? nearestGridX : Math.round(rawX / subGridSize) * subGridSize; + // Y 좌표 스냅 (큰 그리드 우선, 없으면 서브그리드) const nearestGridY = Math.round(rawY / gridSize) * gridSize; const distToGridY = Math.abs(rawY - nearestGridY); - const snappedY = distToGridY <= magneticThreshold - ? nearestGridY - : Math.round(rawY / subGridSize) * subGridSize; + const snappedY = distToGridY <= magneticThreshold ? nearestGridY : Math.round(rawY / subGridSize) * subGridSize; setTempPosition({ x: snappedX, y: snappedY }); } else if (isResizing) { @@ -293,45 +278,49 @@ export function CanvasElement({ const subGridSize = Math.floor(cellSize / 3); const gridSize = cellSize + 5; // GAP 포함한 실제 그리드 크기 const magneticThreshold = 15; - + // 위치 스냅 const nearestGridX = Math.round(newX / gridSize) * gridSize; const distToGridX = Math.abs(newX - nearestGridX); - const snappedX = distToGridX <= magneticThreshold - ? nearestGridX - : Math.round(newX / subGridSize) * subGridSize; - + const snappedX = distToGridX <= magneticThreshold ? nearestGridX : Math.round(newX / subGridSize) * subGridSize; + const nearestGridY = Math.round(newY / gridSize) * gridSize; const distToGridY = Math.abs(newY - nearestGridY); - const snappedY = distToGridY <= magneticThreshold - ? nearestGridY - : Math.round(newY / subGridSize) * subGridSize; - + const snappedY = distToGridY <= magneticThreshold ? nearestGridY : Math.round(newY / subGridSize) * subGridSize; + // 크기 스냅 (그리드 칸 단위로 스냅하되, 마지막 GAP은 제외) // 예: 1칸 = cellSize, 2칸 = cellSize*2 + GAP, 3칸 = cellSize*3 + GAP*2 const calculateGridWidth = (cells: number) => cells * cellSize + Math.max(0, cells - 1) * 5; - + // 가장 가까운 그리드 칸 수 계산 const nearestWidthCells = Math.round(newWidth / gridSize); const nearestGridWidth = calculateGridWidth(nearestWidthCells); const distToGridWidth = Math.abs(newWidth - nearestGridWidth); - const snappedWidth = distToGridWidth <= magneticThreshold - ? nearestGridWidth - : Math.round(newWidth / subGridSize) * subGridSize; - + const snappedWidth = + distToGridWidth <= magneticThreshold ? nearestGridWidth : Math.round(newWidth / subGridSize) * subGridSize; + const nearestHeightCells = Math.round(newHeight / gridSize); const nearestGridHeight = calculateGridWidth(nearestHeightCells); const distToGridHeight = Math.abs(newHeight - nearestGridHeight); - const snappedHeight = distToGridHeight <= magneticThreshold - ? nearestGridHeight - : Math.round(newHeight / subGridSize) * subGridSize; + const snappedHeight = + distToGridHeight <= magneticThreshold ? nearestGridHeight : Math.round(newHeight / subGridSize) * subGridSize; // 임시 크기/위치 저장 (스냅됨) setTempPosition({ x: Math.max(0, snappedX), y: Math.max(0, snappedY) }); setTempSize({ width: snappedWidth, height: snappedHeight }); } }, - [isDragging, isResizing, dragStart, resizeStart, element.size.width, element.type, element.subtype, canvasWidth, cellSize], + [ + isDragging, + isResizing, + dragStart, + resizeStart, + element.size.width, + element.type, + element.subtype, + canvasWidth, + cellSize, + ], ); // 마우스 업 처리 (이미 스냅된 위치 사용) @@ -738,11 +727,6 @@ export function CanvasElement({
- ) : element.type === "widget" && element.subtype === "warehouse-3d" ? ( - // 창고 현황 3D 위젯 렌더링 -
- -
) : ( // 기타 위젯 렌더링
router.push(`/dashboard/${dashboardId}`) : undefined} dashboardTitle={dashboardTitle} onAddElement={addElementFromMenu} resolution={resolution} @@ -469,117 +468,117 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D onBackgroundColorChange={setCanvasBackgroundColor} /> - {/* 캔버스 영역 - 해상도에 따른 크기, 중앙 정렬 */} -
-
+
+ +
+
+ + {/* 요소 설정 모달 */} + {configModalElement && ( + <> + {configModalElement.type === "widget" && configModalElement.subtype === "list" ? ( + + ) : configModalElement.type === "widget" && configModalElement.subtype === "todo" ? ( + + ) : ( + + )} + + )} + + {/* 저장 모달 */} + setSaveModalOpen(false)} + onSave={handleSave} + initialTitle={dashboardTitle} + initialDescription={dashboardDescription} + isEditing={!!dashboardId} + /> + + {/* 저장 성공 모달 */} + { + setSuccessModalOpen(false); + router.push("/admin/dashboard"); }} > - -
-
- - {/* 요소 설정 모달 */} - {configModalElement && ( - <> - {configModalElement.type === "widget" && configModalElement.subtype === "list" ? ( - - ) : configModalElement.type === "widget" && configModalElement.subtype === "todo" ? ( - - ) : ( - - )} - - )} - - {/* 저장 모달 */} - setSaveModalOpen(false)} - onSave={handleSave} - initialTitle={dashboardTitle} - initialDescription={dashboardDescription} - isEditing={!!dashboardId} - /> - - {/* 저장 성공 모달 */} - { - setSuccessModalOpen(false); - router.push("/admin/dashboard"); - }} - > - - -
- + + +
+ +
+ 저장 완료 + 대시보드가 성공적으로 저장되었습니다. +
+
+
- 저장 완료 - 대시보드가 성공적으로 저장되었습니다. - -
- -
-
-
+ + - {/* 초기화 확인 모달 */} - - - - 캔버스 초기화 - - 모든 요소가 삭제됩니다. 이 작업은 되돌릴 수 없습니다. -
- 계속하시겠습니까? -
-
- - 취소 - - 초기화 - - -
-
+ {/* 초기화 확인 모달 */} + + + + 캔버스 초기화 + + 모든 요소가 삭제됩니다. 이 작업은 되돌릴 수 없습니다. +
+ 계속하시겠습니까? +
+
+ + 취소 + + 초기화 + + +
+
); @@ -618,8 +617,6 @@ function getElementTitle(type: ElementType, subtype: ElementSubtype): string { return "🚚 기사 관리 위젯"; case "list": return "📋 리스트 위젯"; - case "warehouse-3d": - return "🏭 창고 현황 (3D)"; default: return "🔧 위젯"; } @@ -660,8 +657,6 @@ 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 00957f91..03464aee 100644 --- a/frontend/components/admin/dashboard/DashboardTopMenu.tsx +++ b/frontend/components/admin/dashboard/DashboardTopMenu.tsx @@ -185,7 +185,6 @@ export function DashboardTopMenu({ 커스텀 지도 카드 {/* 커스텀 목록 카드 */} 커스텀 상태 카드 - 창고 현황 (3D) 일반 위젯 diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts index 73a4bf89..05a656f3 100644 --- a/frontend/components/admin/dashboard/types.ts +++ b/frontend/components/admin/dashboard/types.ts @@ -35,8 +35,7 @@ export type ElementSubtype = | "booking-alert" | "maintenance" | "document" - | "list" - | "warehouse-3d"; // 위젯 타입 + | "list"; // 위젯 타입 export interface Position { x: number; diff --git a/frontend/components/admin/dashboard/widgets/Warehouse3DWidget.tsx b/frontend/components/admin/dashboard/widgets/Warehouse3DWidget.tsx deleted file mode 100644 index 71d2df0e..00000000 --- a/frontend/components/admin/dashboard/widgets/Warehouse3DWidget.tsx +++ /dev/null @@ -1,418 +0,0 @@ -"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 ( - - ); - })} - - - )} -
-
-
- ); -} From b41da3261c9b08f4991997d9ade2d9e4e34759c5 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Fri, 17 Oct 2025 14:05:54 +0900 Subject: [PATCH 08/13] =?UTF-8?q?=EC=95=BC=EB=93=9C=203d=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=20=EA=B3=84=ED=9A=8D=20md=20=ED=8C=8C=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- YARD_MANAGEMENT_3D_PLAN.md | 925 +++++++++++++++++++++++++++++++++++++ 1 file changed, 925 insertions(+) create mode 100644 YARD_MANAGEMENT_3D_PLAN.md diff --git a/YARD_MANAGEMENT_3D_PLAN.md b/YARD_MANAGEMENT_3D_PLAN.md new file mode 100644 index 00000000..49938406 --- /dev/null +++ b/YARD_MANAGEMENT_3D_PLAN.md @@ -0,0 +1,925 @@ +# 야드 관리 3D 기능 구현 계획서 + +## 1. 기능 개요 + +### 목적 + +대시보드에서 야드(Yard)의 자재 배치 상태를 3D로 시각화하고 관리하는 위젯 + +### 주요 특징 + +- 각 대시보드는 하나의 야드(창고 내부 상태)를 나타냄 +- 3D 공간에서 자재를 직접 배치 및 이동 가능 +- 배치된 자재 클릭 시 상세 정보 표시 +- 여러 야드 레이아웃을 저장하고 선택 가능 + +--- + +## 2. 데이터베이스 설계 + +### 2.1. yard_layout 테이블 + +야드 레이아웃 정보 저장 + +```sql +CREATE TABLE yard_layout ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, -- 야드 이름 (예: "A구역", "1번 야드") + description TEXT, -- 설명 + created_by VARCHAR(50), -- 생성자 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +### 2.2. yard_material_placement 테이블 + +야드 내 자재 배치 정보 (외부 자재 데이터 참조) + +```sql +CREATE TABLE yard_material_placement ( + id SERIAL PRIMARY KEY, + yard_layout_id INTEGER REFERENCES yard_layout(id) ON DELETE CASCADE, + + -- 외부 자재 참조 (API로 받아올 데이터) + external_material_id VARCHAR(100) NOT NULL, -- 외부 시스템 자재 ID + material_code VARCHAR(50) NOT NULL, -- 자재 코드 (캐시) + material_name VARCHAR(100) NOT NULL, -- 자재 이름 (캐시) + quantity INTEGER NOT NULL DEFAULT 1, -- 수량 (캐시) + unit VARCHAR(20) DEFAULT 'EA', -- 단위 (캐시) + + -- 3D 위치 정보 + position_x NUMERIC(10, 2) NOT NULL DEFAULT 0, -- X 좌표 + position_y NUMERIC(10, 2) NOT NULL DEFAULT 0, -- Y 좌표 (높이) + position_z NUMERIC(10, 2) NOT NULL DEFAULT 0, -- Z 좌표 + + -- 3D 크기 정보 + size_x NUMERIC(10, 2) NOT NULL DEFAULT 5, -- 너비 + size_y NUMERIC(10, 2) NOT NULL DEFAULT 5, -- 높이 + size_z NUMERIC(10, 2) NOT NULL DEFAULT 5, -- 깊이 + + -- 외관 정보 + color VARCHAR(7) DEFAULT '#3b82f6', -- 색상 (HEX) + + -- 추가 정보 + status VARCHAR(20) DEFAULT 'normal', -- 상태 (normal, alert, warning) + memo TEXT, -- 메모 + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 외부 자재 ID와 야드 레이아웃의 조합은 유니크해야 함 (중복 배치 방지) +CREATE UNIQUE INDEX idx_yard_material_unique + ON yard_material_placement(yard_layout_id, external_material_id); +``` + +### 2.3. temp_material_master 테이블 (임시 자재 마스터) + +외부 API를 받기 전까지 사용할 임시 자재 데이터 + +```sql +CREATE TABLE temp_material_master ( + id SERIAL PRIMARY KEY, + material_code VARCHAR(50) UNIQUE NOT NULL, -- 자재 코드 + material_name VARCHAR(100) NOT NULL, -- 자재 이름 + category VARCHAR(50), -- 카테고리 + unit VARCHAR(20) DEFAULT 'EA', -- 기본 단위 + default_color VARCHAR(7) DEFAULT '#3b82f6', -- 기본 색상 + description TEXT, -- 설명 + is_active BOOLEAN DEFAULT true, -- 사용 여부 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 임시 자재 마스터 샘플 데이터 +INSERT INTO temp_material_master (material_code, material_name, category, unit, default_color, description) VALUES +('MAT-STEEL-001', '철판 A타입 (1200x2400)', '철강', 'EA', '#ef4444', '두께 10mm 철판'), +('MAT-STEEL-002', '철판 B타입 (1000x2000)', '철강', 'EA', '#dc2626', '두께 8mm 철판'), +('MAT-PIPE-001', '강관 파이프 (Φ100)', '파이프', 'EA', '#10b981', '길이 6m'), +('MAT-PIPE-002', '강관 파이프 (Φ150)', '파이프', 'EA', '#059669', '길이 6m'), +('MAT-BOLT-001', '볼트 세트 M12', '부품', 'BOX', '#f59e0b', '100개/박스'), +('MAT-BOLT-002', '볼트 세트 M16', '부품', 'BOX', '#d97706', '100개/박스'), +('MAT-ANGLE-001', '앵글 (75x75x6)', '형강', 'EA', '#8b5cf6', '길이 6m'), +('MAT-ANGLE-002', '앵글 (100x100x10)', '형강', 'EA', '#7c3aed', '길이 6m'), +('MAT-CHANNEL-001', '채널 (100x50x5)', '형강', 'EA', '#06b6d4', '길이 6m'), +('MAT-WIRE-001', '와이어 로프 (Φ12)', '케이블', 'M', '#ec4899', '롤 단위'); +``` + +### 2.4. 초기 데이터 마이그레이션 스크립트 + +```sql +-- 샘플 야드 레이아웃 +INSERT INTO yard_layout (name, description, created_by) VALUES +('A구역', '메인 야드 A구역', 'admin'), +('B구역', '메인 야드 B구역', 'admin'), +('C구역', '보조 야드 C구역', 'admin'); + +-- 샘플 자재 배치 (A구역) - 임시 자재 마스터 참조 +INSERT INTO yard_material_placement (yard_layout_id, external_material_id, material_code, material_name, + quantity, unit, position_x, position_y, position_z, size_x, size_y, size_z, color, status) VALUES +(1, 'TEMP-1', 'MAT-STEEL-001', '철판 A타입 (1200x2400)', 50, 'EA', 10, 0, 10, 8, 4, 8, '#ef4444', 'normal'), +(1, 'TEMP-2', 'MAT-STEEL-002', '철판 B타입 (1000x2000)', 30, 'EA', 25, 0, 10, 8, 4, 8, '#dc2626', 'normal'), +(1, 'TEMP-3', 'MAT-PIPE-001', '강관 파이프 (Φ100)', 100, 'EA', 40, 0, 10, 6, 6, 6, '#10b981', 'normal'), +(1, 'TEMP-4', 'MAT-BOLT-001', '볼트 세트 M12', 500, 'BOX', 55, 0, 10, 4, 4, 4, '#f59e0b', 'warning'); +``` + +### 2.5. 외부 자재 API 연동 구조 + +**현재 (Phase 1)**: 임시 자재 마스터 사용 + +```typescript +// temp_material_master 테이블에서 조회 +GET / api / materials / temp; +``` + +**향후 (Phase 2)**: 외부 API 연동 + +```typescript +// 외부 시스템 자재 API +GET /api/external/materials +Response: [ + { + id: "EXT-12345", + code: "MAT-STEEL-001", + name: "철판 A타입", + quantity: 150, + unit: "EA", + location: "창고A-1", + ... + } +] +``` + +--- + +## 3. 백엔드 API 설계 + +### 3.1. YardLayoutService + +**경로**: `backend-node/src/services/YardLayoutService.ts` + +```typescript +- getAllLayouts(): 모든 야드 레이아웃 목록 조회 +- getLayoutById(id): 특정 야드 레이아웃 상세 조회 +- createLayout(data): 새 야드 레이아웃 생성 +- updateLayout(id, data): 야드 레이아웃 수정 (이름, 설명) +- deleteLayout(id): 야드 레이아웃 삭제 +- getPlacementsByLayoutId(layoutId): 특정 야드의 모든 배치 자재 조회 +- addMaterialPlacement(layoutId, placementData): 야드에 자재 배치 추가 +- updatePlacement(placementId, data): 배치 정보 수정 (위치, 크기, 색상, 메모만) +- removePlacement(placementId): 배치 해제 (자재는 삭제되지 않음) +- batchUpdatePlacements(layoutId, placements[]): 여러 배치 일괄 업데이트 +``` + +**중요**: 자재 마스터 데이터(코드, 이름, 수량, 단위)는 읽기 전용 + +### 3.2. YardLayoutController + +**경로**: `backend-node/src/controllers/YardLayoutController.ts` + +- GET `/api/yard-layouts`: 모든 레이아웃 목록 +- GET `/api/yard-layouts/:id`: 특정 레이아웃 상세 +- POST `/api/yard-layouts`: 새 레이아웃 생성 +- PUT `/api/yard-layouts/:id`: 레이아웃 수정 (이름, 설명만) +- DELETE `/api/yard-layouts/:id`: 레이아웃 삭제 +- GET `/api/yard-layouts/:id/placements`: 레이아웃의 배치 자재 목록 +- POST `/api/yard-layouts/:id/placements`: 자재 배치 추가 +- PUT `/api/yard-placements/:id`: 배치 정보 수정 (위치, 크기, 색상, 메모만) +- DELETE `/api/yard-placements/:id`: 배치 해제 +- PUT `/api/yard-layouts/:id/placements/batch`: 배치 일괄 업데이트 + +### 3.3. MaterialController (임시 자재 조회용) + +**경로**: `backend-node/src/controllers/MaterialController.ts` + +- GET `/api/materials/temp`: 임시 자재 마스터 목록 조회 (검색, 필터링) +- GET `/api/materials/temp/:code`: 특정 자재 상세 조회 + +**향후**: 외부 API 프록시로 변경 예정 + +### 3.4. Routes + +**경로**: `backend-node/src/routes/yardLayoutRoutes.ts` + `materialRoutes.ts` + +--- + +## 4. 프론트엔드 컴포넌트 설계 + +### 4.1. YardManagement3DWidget (메인 위젯) + +**경로**: `frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx` + +**주요 기능**: + +1. 야드 레이아웃 선택/생성 모드 전환 +2. 3D 캔버스 렌더링 +3. 자재 배치 및 이동 인터랙션 +4. 선택된 자재 정보 패널 표시 + +**상태 관리**: + +```typescript +- mode: 'select' | 'edit' | 'create' // 현재 모드 +- selectedLayoutId: number | null // 선택된 레이아웃 ID +- layoutData: YardLayout | null // 현재 레이아웃 데이터 +- materials: YardMaterial[] // 배치된 자재 목록 +- selectedMaterialId: string | null // 선택된 자재 ID +- isDragging: boolean // 드래그 중 여부 +``` + +### 4.2. YardLayoutSelector (레이아웃 선택) + +**경로**: `frontend/components/admin/dashboard/widgets/YardLayoutSelector.tsx` + +- 저장된 야드 레이아웃 목록 표시 +- 레이아웃 선택 기능 +- 새 레이아웃 생성 버튼 + +### 4.3. YardLayoutCreator (레이아웃 생성) + +**경로**: `frontend/components/admin/dashboard/widgets/YardLayoutCreator.tsx` + +- 야드 이름, 설명 입력 +- 야드 크기 설정 (width, depth, height) +- 그리드 크기 설정 + +### 4.4. Yard3DCanvas (3D 캔버스) + +**경로**: `frontend/components/admin/dashboard/widgets/Yard3DCanvas.tsx` + +**기술 스택**: + +- React Three Fiber +- @react-three/drei (OrbitControls, Grid, Box) +- Three.js + +**주요 기능**: + +1. 야드 바닥 그리드 표시 +2. 자재 3D 박스 렌더링 +3. 자재 클릭 이벤트 처리 +4. 자재 드래그 앤 드롭 (위치 이동) +5. 카메라 컨트롤 (회전, 줌) + +### 4.5. MaterialInfoPanel (자재 정보 패널) + +**경로**: `frontend/components/admin/dashboard/widgets/MaterialInfoPanel.tsx` + +**읽기 전용 정보** (외부 자재 데이터): + +- 자재 코드 +- 자재 이름 +- 수량 +- 단위 +- 카테고리 + +**수정 가능 정보** (배치 데이터): + +- 3D 위치 (X, Y, Z) +- 3D 크기 (너비, 높이, 깊이) +- 색상 +- 메모 + +**기능**: + +- 배치 정보 수정 +- 배치 해제 (야드에서 자재 제거) + +### 4.6. MaterialLibrary (자재 라이브러리) + +**경로**: `frontend/components/admin/dashboard/widgets/MaterialLibrary.tsx` + +- 사용 가능한 자재 목록 표시 +- 자재 검색 기능 +- 자재를 3D 캔버스로 드래그하여 배치 +- 자재 마스터 데이터 조회 (기존 테이블 활용 가능) + +--- + +## 5. UI/UX 설계 + +### 5.1. 초기 화면 (선택/생성 모드) + +야드 레이아웃 목록을 테이블 형식으로 표시 + +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ 야드 관리 3D [+ 새 야드 생성] │ +├──────────────────────────────────────────────────────────────────────────┤ +│ │ +│ [검색: ________________] [정렬: 최근순 ▼] │ +│ │ +│ ┌────────────────────────────────────────────────────────────────────┐ │ +│ │ 야드명 │ 설명 │ 배치 자재 │ 최종 수정 │ 작업 │ │ +│ ├────────────────────────────────────────────────────────────────────┤ │ +│ │ A구역 │ 메인 야드 A구역 │ 15개 │ 2025-01-15 14:30 │ ⋮ │ │ +│ │ │ │ │ │ │ │ +│ ├────────────────────────────────────────────────────────────────────┤ │ +│ │ B구역 │ 메인 야드 B구역 │ 8개 │ 2025-01-14 10:20 │ ⋮ │ │ +│ │ │ │ │ │ │ │ +│ ├────────────────────────────────────────────────────────────────────┤ │ +│ │ C구역 │ 보조 야드 C구역 │ 3개 │ 2025-01-10 09:15 │ ⋮ │ │ +│ │ │ │ │ │ │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 총 3개 [1] 2 3 4 5 > │ +│ │ +└──────────────────────────────────────────────────────────────────────────┘ + +작업 메뉴 (⋮ 클릭 시): +┌─────────────┐ +│ 편집 │ +│ 복제 │ +│ 삭제 │ +└─────────────┘ +``` + +### 5.2. 레이아웃 생성 모달 + +새 야드 레이아웃을 생성할 때 표시되는 모달 + +``` +┌─────────────────────────────────────────────────┐ +│ 새 야드 레이아웃 생성 [X] │ +├─────────────────────────────────────────────────┤ +│ │ +│ 야드 이름 * │ +│ [____________________________________] │ +│ │ +│ 설명 │ +│ [____________________________________] │ +│ [____________________________________] │ +│ │ +│ [취소] [생성] │ +└─────────────────────────────────────────────────┘ +``` + +### 5.3. 편집 모드 - 전체 레이아웃 + +야드 편집 화면의 전체 구성 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ A구역 [저장] [미리보기] [취소] │ +├───────────────────────────────────────┬─────────────────────────────────────┤ +│ │ 도구 패널 [최소화] │ +│ ├─────────────────────────────────────┤ +│ │ │ +│ │ 자재 라이브러리 │ +│ │ ───────────────────── │ +│ │ [검색: ____________] [카테고리 ▼] │ +│ │ │ +│ 3D 캔버스 │ ┌───────────────────────┐ │ +│ │ │ MAT-STEEL-001 │ │ +│ (야드 그리드 + 자재 배치) │ │ 철판 A타입 │ │ +│ │ │ 50 EA 재고 있음 │ │ +│ - 마우스 드래그: 카메라 회전 │ │ [배치] │ │ +│ - 휠: 줌 인/아웃 │ └───────────────────────┘ │ +│ - 자재 클릭: 선택 │ │ +│ - 자재 드래그: 이동 │ ┌───────────────────────┐ │ +│ │ │ MAT-STEEL-002 │ │ +│ │ │ 철판 B타입 │ │ +│ │ │ 30 EA 재고 있음 │ │ +│ │ │ [배치] │ │ +│ │ └───────────────────────┘ │ +│ │ │ +│ │ ┌───────────────────────┐ │ +│ │ │ MAT-PIPE-001 │ │ +│ │ │ 강관 파이프 │ │ +│ │ │ 100 EA 재고 있음 │ │ +│ │ │ [배치] │ │ +│ │ └───────────────────────┘ │ +│ │ │ +│ │ ... (스크롤 가능) │ +│ │ │ +├───────────────────────────────────────┴─────────────────────────────────────┤ +│ 자재 정보 │ +│ ───────────────── │ +│ 선택된 자재: MAT-STEEL-001 (철판 A타입) │ +│ │ +│ 기본 정보 (읽기 전용) │ +│ 자재 코드: MAT-STEEL-001 │ +│ 자재 이름: 철판 A타입 (1200x2400) │ +│ 수량: 50 EA │ +│ 카테고리: 철강 │ +│ │ +│ 배치 정보 (수정 가능) │ +│ 3D 위치 │ +│ X: [____10.00____] m Y: [____0.00____] m Z: [____10.00____] m │ +│ │ +│ 3D 크기 │ +│ 너비: [____8.00____] m 높이: [____4.00____] m 깊이: [____8.00____] m │ +│ │ +│ 외관 │ +│ 색상: [■ #ef4444] [색상 선택] │ +│ │ +│ 메모 │ +│ [_____________________________________________________________________] │ +│ │ +│ [배치 해제] [변경 적용] [초기화] │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### 5.4. 3D 캔버스 상세 + +3D 캔버스 내부의 시각적 요소 + +``` +┌─────────────────────────────────────────────────┐ +│ 카메라 컨트롤 [리셋] │ +│ 회전: 45° | 기울기: 30° | 줌: 100% │ +├─────────────────────────────────────────────────┤ +│ Y (높이) │ +│ ↑ │ +│ │ │ +│ │ │ +│ │ ┌───────┐ (자재) │ +│ │ │ │ │ +│ Z (깊이) │ │ MAT-1 │ (선택됨) │ +│ ↗ │ │ │ │ +│ / └────┴───────┴──→ X (너비) │ +│ / │ +│ / ┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐ │ +│ ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤ (그리드) │ +│ ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤ │ +│ ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤ │ +│ │ │ │ │ │ │□│ │ │ │ │ ← MAT-2 │ +│ ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤ │ +│ ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤ │ +│ └─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘ │ +│ │ +│ 범례: │ +│ ■ 선택된 자재 □ 일반 자재 │ +│ ─ 그리드 라인 (5m 단위) │ +│ │ +│ 조작 가이드: │ +│ • 마우스 왼쪽 드래그: 카메라 회전 │ +│ • 마우스 휠: 줌 인/아웃 │ +│ • 자재 클릭: 선택 │ +│ • 선택된 자재 드래그: 위치 이동 │ +└─────────────────────────────────────────────────┘ +``` + +### 5.5. 자재 배치 플로우 + +자재를 배치하는 과정 + +``` +1. 자재 라이브러리에서 자재 선택 + ┌─────────────────┐ + │ MAT-STEEL-001 │ ← 클릭 + │ 철판 A타입 │ + │ [배치] │ + └─────────────────┘ + ↓ +2. 3D 캔버스에 자재가 임시로 표시됨 (투명) + ┌─────────────────┐ + │ 3D 캔버스 │ + │ │ + │ [반투명 박스] │ ← 마우스 커서 따라 이동 + │ │ + └─────────────────┘ + ↓ +3. 원하는 위치에 클릭하여 배치 + ┌─────────────────┐ + │ 3D 캔버스 │ + │ │ + │ [실제 박스] │ ← 배치 완료 + │ │ + └─────────────────┘ + ↓ +4. 자재 정보 패널에 자동으로 선택됨 + ┌─────────────────────────┐ + │ 자재 정보 패널 │ + │ ─────────────────── │ + │ 선택된 자재: │ + │ MAT-STEEL-001 │ + │ │ + │ 수량: 50 EA │ + │ 위치: X:10, Y:0, Z:10 │ + │ ... │ + └─────────────────────────┘ +``` + +### 5.6. 반응형 레이아웃 (모바일/태블릿) + +모바일에서는 패널을 접거나 탭으로 전환 + +``` +모바일 (세로 모드): +┌───────────────────────┐ +│ A구역 [저장] │ +├───────────────────────┤ +│ │ +│ 3D 캔버스 │ +│ (전체 화면) │ +│ │ +│ │ +├───────────────────────┤ +│ [자재 라이브러리] [정보]│ ← 탭 전환 +├───────────────────────┤ +│ 선택된 자재: │ +│ MAT-STEEL-001 │ +│ 수량: 50 EA │ +│ ... │ +└───────────────────────┘ + +태블릿 (가로 모드): +┌─────────────────────────────────────┐ +│ A구역 [저장] │ +├──────────────────┬──────────────────┤ +│ │ 자재 라이브러리 │ +│ 3D 캔버스 │ ────────────── │ +│ │ [검색: ______] │ +│ │ MAT-STEEL-001 │ +│ │ ... │ +├──────────────────┴──────────────────┤ +│ 선택된 자재: MAT-STEEL-001 │ +│ 수량: 50 EA | 위치: X:10, Z:10 │ +└─────────────────────────────────────┘ +``` + +--- + +## 6. 구현 단계 + +### Phase 1: 데이터베이스 및 백엔드 API + +1. 테이블 생성 스크립트 작성 +2. 마이그레이션 실행 +3. Service, Controller, Routes 구현 +4. API 테스트 + +### Phase 2: 야드 레이아웃 선택/생성 + +1. YardLayoutSelector 컴포넌트 구현 +2. YardLayoutCreator 컴포넌트 구현 +3. API 연동 +4. 레이아웃 CRUD 기능 테스트 + +### Phase 3: 3D 캔버스 기본 구조 + +1. Yard3DCanvas 컴포넌트 기본 구조 +2. 야드 바닥 그리드 렌더링 +3. 카메라 컨트롤 (OrbitControls) +4. 자재 3D 박스 렌더링 + +### Phase 4: 자재 배치 및 인터랙션 + +1. MaterialLibrary 컴포넌트 구현 +2. 자재 드래그 앤 드롭 배치 +3. 자재 클릭 선택 +4. 자재 위치 이동 (드래그) + +### Phase 5: 자재 정보 패널 및 편집 + +1. MaterialInfoPanel 컴포넌트 구현 +2. 자재 정보 표시 +3. 자재 정보 수정 (수량, 위치, 크기 등) +4. 자재 삭제 + +### Phase 6: 통합 및 최적화 + +1. YardManagement3DWidget 통합 +2. 상태 관리 최적화 +3. 성능 최적화 (대량 자재 렌더링) +4. 에러 처리 및 로딩 상태 +5. 모바일/반응형 대응 (선택사항) + +### Phase 7: 대시보드 위젯 등록 + +1. types.ts에 위젯 타입 추가 +2. DashboardTopMenu에 위젯 추가 +3. CanvasElement에 위젯 렌더링 추가 +4. 위젯 설정 모달 (레이아웃 선택) + +--- + +## 7. 기술적 고려사항 + +### 7.1. 3D 렌더링 최적화 + +- 자재 수가 많을 경우 인스턴싱 사용 +- LOD (Level of Detail) 적용 고려 +- 카메라 거리에 따른 렌더링 최적화 + +### 7.2. 드래그 앤 드롭 + +- 3D 공간에서의 레이캐스팅 +- 그리드 스냅 기능 +- 충돌 감지 (자재 간 겹침 방지) + +### 7.3. 상태 관리 + +- 자재 위치 변경 시 실시간 업데이트 +- Debounce를 사용한 API 호출 최적화 +- 낙관적 업데이트 (Optimistic Update) + +### 7.4. 데이터 동기화 + +- 여러 사용자가 동시에 편집하는 경우 충돌 처리 +- WebSocket을 통한 실시간 동기화 (선택사항) + +### 7.5. UI/UX 규칙 + +#### 이모지 사용 금지 + +#### 모달 사용 규칙 + +**`window.alert`, `window.confirm` 사용 금지** + +모든 알림, 확인, 에러 메시지는 Shadcn UI 모달 컴포넌트 사용: + +- **일반 알림**: `Dialog` 컴포넌트 +- **확인 필요**: `AlertDialog` 컴포넌트 +- **삭제/해제 확인**: `AlertDialog` (Destructive 스타일) +- **성공 메시지**: `Dialog` 또는 `Toast` +- **에러 메시지**: `Dialog` (Error 스타일) + +**예시**: + +```typescript +// 잘못된 방법 ❌ +window.alert("저장되었습니다"); +if (window.confirm("삭제하시겠습니까?")) { ... } + +// 올바른 방법 ✅ + + + + 배치 해제 + + 이 자재를 야드에서 제거하시겠습니까? + + + + 취소 + 확인 + + + +``` + +--- + +## 8. API 명세서 + +### 8.1. 야드 레이아웃 API + +#### GET /api/yard-layouts + +**설명**: 모든 야드 레이아웃 목록 조회 + +**응답**: + +```json +{ + "success": true, + "data": [ + { + "id": 1, + "name": "A구역", + "description": "메인 야드 A구역", + "placement_count": 15, + "created_at": "2025-01-01T00:00:00Z" + } + ] +} +``` + +#### GET /api/yard-layouts/:id + +**설명**: 특정 야드 레이아웃 상세 조회 + +**응답**: + +```json +{ + "success": true, + "data": { + "id": 1, + "name": "A구역", + "description": "메인 야드 A구역", + "created_at": "2025-01-01T00:00:00Z" + } +} +``` + +#### POST /api/yard-layouts + +**설명**: 새 야드 레이아웃 생성 + +**요청**: + +```json +{ + "name": "D구역", + "description": "신규 야드" +} +``` + +#### PUT /api/yard-layouts/:id + +**설명**: 야드 레이아웃 수정 (이름, 설명만) + +**요청**: + +```json +{ + "name": "D구역 (수정)", + "description": "수정된 설명" +} +``` + +#### DELETE /api/yard-layouts/:id + +**설명**: 야드 레이아웃 삭제 + +### 8.2. 자재 배치 API + +#### GET /api/yard-layouts/:id/materials + +**설명**: 특정 야드의 모든 자재 조회 + +**응답**: + +```json +{ + "success": true, + "data": [ + { + "id": 1, + "material_code": "MAT-001", + "material_name": "철판 A타입", + "quantity": 50, + "unit": "EA", + "position_x": 10, + "position_y": 0, + "position_z": 10, + "size_x": 8, + "size_y": 4, + "size_z": 8, + "color": "#ef4444", + "status": "normal", + "memo": null + } + ] +} +``` + +#### POST /api/yard-layouts/:id/materials + +**설명**: 야드에 자재 추가 + +**요청**: + +```json +{ + "material_code": "MAT-005", + "material_name": "신규 자재", + "quantity": 10, + "unit": "EA", + "position_x": 20, + "position_y": 0, + "position_z": 20, + "size_x": 5, + "size_y": 5, + "size_z": 5, + "color": "#3b82f6" +} +``` + +#### PUT /api/yard-materials/:id + +**설명**: 자재 정보 수정 + +**요청**: + +```json +{ + "position_x": 25, + "position_z": 25, + "quantity": 55 +} +``` + +#### DELETE /api/yard-materials/:id + +**설명**: 자재 삭제 + +#### PUT /api/yard-layouts/:id/materials/batch + +**설명**: 여러 자재 일괄 업데이트 (드래그로 여러 자재 이동 시) + +**요청**: + +```json +{ + "materials": [ + { "id": 1, "position_x": 15, "position_z": 15 }, + { "id": 2, "position_x": 30, "position_z": 15 } + ] +} +``` + +--- + +## 9. 테스트 시나리오 + +### 9.1. 기본 기능 테스트 + +- [ ] 야드 레이아웃 목록 조회 +- [ ] 야드 레이아웃 생성 +- [ ] 야드 레이아웃 선택 +- [ ] 3D 캔버스 렌더링 +- [ ] 자재 목록 조회 및 표시 + +### 9.2. 자재 배치 테스트 + +- [ ] 자재 라이브러리에서 드래그하여 배치 +- [ ] 배치된 자재 클릭하여 선택 +- [ ] 선택된 자재 정보 패널 표시 +- [ ] 자재 드래그하여 위치 이동 +- [ ] 자재 정보 수정 (수량, 크기 등) +- [ ] 자재 삭제 + +### 9.3. 인터랙션 테스트 + +- [ ] 카메라 회전 (OrbitControls) +- [ ] 카메라 줌 인/아웃 +- [ ] 그리드 스냅 기능 +- [ ] 여러 자재 동시 이동 +- [ ] 자재 간 충돌 방지 + +### 9.4. 저장 및 로드 테스트 + +- [ ] 자재 배치 후 저장 +- [ ] 저장된 레이아웃 다시 로드 +- [ ] 레이아웃 삭제 +- [ ] 레이아웃 복제 (선택사항) + +--- + +## 10. 향후 확장 가능성 + +- 자재 검색 및 필터링 (상태별, 자재 코드별) +- 자재 배치 히스토리 (변경 이력) +- 자재 배치 템플릿 (자주 사용하는 배치 저장) +- 자재 입출고 연동 (실시간 재고 반영) +- 자재 경로 최적화 (피킹 경로 표시) +- AR/VR 지원 (모바일 AR로 실제 야드 확인) +- 다중 사용자 동시 편집 (WebSocket) +- 자재 배치 분석 (공간 활용률, 접근성 등) + +--- + +## 11. 파일 구조 + +``` +backend-node/src/ +├── services/ +│ └── YardLayoutService.ts (신규) +├── controllers/ +│ └── YardLayoutController.ts (신규) +├── routes/ +│ └── yardLayoutRoutes.ts (신규) +└── app.ts (수정) + +frontend/components/admin/dashboard/ +├── widgets/ +│ ├── YardManagement3DWidget.tsx (신규 - 메인) +│ ├── YardLayoutSelector.tsx (신규) +│ ├── YardLayoutCreator.tsx (신규) +│ ├── Yard3DCanvas.tsx (신규) +│ ├── MaterialInfoPanel.tsx (신규) +│ └── MaterialLibrary.tsx (신규) +├── types.ts (수정 - 위젯 타입 추가) +├── DashboardTopMenu.tsx (수정 - 메뉴 추가) +└── CanvasElement.tsx (수정 - 렌더링 추가) + +db/ +└── migrations/ + └── create_yard_tables.sql (신규) +``` + +--- + +## 12. 예상 개발 기간 + +- Phase 1 (DB & API): 1일 +- Phase 2 (선택/생성): 1일 +- Phase 3 (3D 기본): 1일 +- Phase 4 (배치 인터랙션): 2일 +- Phase 5 (정보 패널): 1일 +- Phase 6 (통합/최적화): 1일 +- Phase 7 (대시보드 등록): 0.5일 + +**총 예상 기간: 7.5일** + +--- + +## 13. 참고 자료 + +- React Three Fiber: https://docs.pmnd.rs/react-three-fiber +- @react-three/drei: https://github.com/pmndrs/drei +- Three.js: https://threejs.org/docs/ From 0a02a6c7ab1c637f93569810fa70fdbb314b134d Mon Sep 17 00:00:00 2001 From: leeheejin Date: Fri, 17 Oct 2025 14:52:08 +0900 Subject: [PATCH 09/13] =?UTF-8?q?=EC=9E=90=EC=9E=98=ED=95=9C=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95=EA=B3=BC=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A1=A4,=20=ED=97=A4=EB=8D=94=20=EB=B3=80=EA=B2=BD=EC=99=84?= =?UTF-8?q?=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/.env.example | 5 + .../src/controllers/openApiProxyController.ts | 177 ++++++++++++------ backend-node/src/routes/vehicleRoutes.ts | 52 +++++ .../admin/dashboard/CanvasElement.tsx | 7 +- .../admin/dashboard/DashboardDesigner.tsx | 69 +++---- .../admin/dashboard/DashboardSidebar.tsx | 26 +-- .../admin/dashboard/ElementConfigModal.tsx | 62 +++--- .../admin/dashboard/QueryEditor.tsx | 2 +- .../admin/dashboard/widgets/ListWidget.tsx | 3 +- .../widgets/ListWidgetConfigModal.tsx | 11 +- .../dashboard/widgets/Warehouse3DWidget.tsx | 2 +- .../dashboard/widgets/BookingAlertWidget.tsx | 2 +- .../dashboard/widgets/CalculatorWidget.tsx | 2 +- .../widgets/CustomerIssuesWidget.tsx | 2 +- .../widgets/DeliveryTodayStatsWidget.tsx | 2 +- .../dashboard/widgets/DocumentWidget.tsx | 2 +- .../dashboard/widgets/ExchangeWidget.tsx | 2 +- .../dashboard/widgets/MapSummaryWidget.tsx | 2 +- .../dashboard/widgets/TodoWidget.tsx | 118 ++++++------ .../dashboard/widgets/VehicleListWidget.tsx | 2 +- .../dashboard/widgets/WeatherWidget.tsx | 28 ++- 21 files changed, 359 insertions(+), 219 deletions(-) create mode 100644 backend-node/src/routes/vehicleRoutes.ts diff --git a/backend-node/.env.example b/backend-node/.env.example index fdba2895..807ae916 100644 --- a/backend-node/.env.example +++ b/backend-node/.env.example @@ -10,3 +10,8 @@ BOOKING_DATA_SOURCE=file MAINTENANCE_DATA_SOURCE=memory DOCUMENT_DATA_SOURCE=memory + +# OpenWeatherMap API 키 추가 (실시간 날씨) +# https://openweathermap.org/api 에서 무료 가입 후 발급 +OPENWEATHER_API_KEY=your_openweathermap_api_key_here + diff --git a/backend-node/src/controllers/openApiProxyController.ts b/backend-node/src/controllers/openApiProxyController.ts index f737a833..b84dc218 100644 --- a/backend-node/src/controllers/openApiProxyController.ts +++ b/backend-node/src/controllers/openApiProxyController.ts @@ -17,19 +17,54 @@ export class OpenApiProxyController { console.log(`🌤️ 날씨 조회 요청: ${city}`); - // 기상청 API Hub 키 확인 + // 1순위: OpenWeatherMap API (실시간에 가까움, 10분마다 업데이트) + const openWeatherKey = process.env.OPENWEATHER_API_KEY; + if (openWeatherKey) { + try { + console.log(`🌍 OpenWeatherMap API 호출: ${city}`); + const response = await axios.get('https://api.openweathermap.org/data/2.5/weather', { + params: { + q: `${city},KR`, + appid: openWeatherKey, + units: 'metric', + lang: 'kr', + }, + timeout: 10000, + }); + + const data = response.data; + const weatherData = { + city: data.name, + country: data.sys.country, + temperature: Math.round(data.main.temp), + feelsLike: Math.round(data.main.feels_like), + humidity: data.main.humidity, + pressure: data.main.pressure, + weatherMain: data.weather[0].main, + weatherDescription: data.weather[0].description, + weatherIcon: data.weather[0].icon, + windSpeed: Math.round(data.wind.speed * 10) / 10, + clouds: data.clouds.all, + timestamp: new Date().toISOString(), + }; + + console.log(`✅ OpenWeatherMap 날씨 조회 성공: ${weatherData.city} ${weatherData.temperature}°C`); + res.json({ success: true, data: weatherData }); + return; + } catch (error) { + console.warn('⚠️ OpenWeatherMap API 실패, 기상청 API로 폴백:', error instanceof Error ? error.message : error); + } + } + + // 2순위: 기상청 API Hub (매시간 정시 데이터) const apiKey = process.env.KMA_API_KEY; - // API 키가 없으면 테스트 모드로 실시간 날씨 제공 + // API 키가 없으면 오류 반환 if (!apiKey) { - console.log('⚠️ 기상청 API 키가 없습니다. 테스트 데이터를 반환합니다.'); - - const regionCode = getKMARegionCode(city as string); - const weatherData = generateRealisticWeatherData(regionCode?.name || (city as string)); - - res.json({ - success: true, - data: weatherData, + console.log('⚠️ 기상청 API 키가 설정되지 않았습니다.'); + res.status(503).json({ + success: false, + message: '기상청 API 키가 설정되지 않았습니다. 관리자에게 문의하세요.', }); return; } @@ -48,32 +83,39 @@ export class OpenApiProxyController { // 기상청 API Hub 사용 (apihub.kma.go.kr) const now = new Date(); - // 기상청 데이터는 매시간 정시(XX:00)에 발표되고 약 10분 후 조회 가능 - // 현재 시각이 XX:10 이전이면 이전 시간 데이터 조회 - const minute = now.getMinutes(); - let targetTime = new Date(now); + // 한국 시간(KST = UTC+9)으로 변환 + const kstOffset = 9 * 60 * 60 * 1000; // 9시간을 밀리초로 + const kstNow = new Date(now.getTime() + kstOffset); - if (minute < 10) { - // 아직 이번 시간 데이터가 업데이트되지 않음 → 이전 시간으로 - targetTime = new Date(now.getTime() - 60 * 60 * 1000); - } + // 기상청 지상관측 데이터는 매시간 정시(XX:00)에 발표 + // 가장 최근의 정시 데이터를 가져오기 위해 현재 시간의 정시로 설정 + const targetTime = new Date(kstNow); // tm 파라미터: YYYYMMDDHH00 형식 (정시만 조회) - const year = targetTime.getFullYear(); - const month = String(targetTime.getMonth() + 1).padStart(2, '0'); - const day = String(targetTime.getDate()).padStart(2, '0'); - const hour = String(targetTime.getHours()).padStart(2, '0'); + const year = targetTime.getUTCFullYear(); + const month = String(targetTime.getUTCMonth() + 1).padStart(2, '0'); + const day = String(targetTime.getUTCDate()).padStart(2, '0'); + const hour = String(targetTime.getUTCHours()).padStart(2, '0'); const tm = `${year}${month}${day}${hour}00`; + + console.log(`🕐 현재 시각(KST): ${kstNow.toISOString().slice(0, 16).replace('T', ' ')}, 조회 시각: ${tm}`); - // 기상청 API Hub - 지상관측시간자료 - const url = 'https://apihub.kma.go.kr/api/typ01/url/kma_sfctm2.php'; + // 기상청 API Hub - 지상관측시간자료 (시간 범위 조회로 최신 데이터 확보) + // sfctm3: 시간 범위 조회 가능 (tm1~tm2) + const url = 'https://apihub.kma.go.kr/api/typ01/url/kma_sfctm3.php'; + + // 최근 1시간 범위 조회 (현재 시간 - 1시간 ~ 현재 시간) - KST 기준 + const tm1Time = new Date(kstNow.getTime() - 60 * 60 * 1000); // 1시간 전 + const tm1 = `${tm1Time.getUTCFullYear()}${String(tm1Time.getUTCMonth() + 1).padStart(2, '0')}${String(tm1Time.getUTCDate()).padStart(2, '0')}${String(tm1Time.getUTCHours()).padStart(2, '0')}00`; + const tm2 = tm; // 현재 시간 - console.log(`📡 기상청 API Hub 호출: ${regionCode.name} (관측소: ${regionCode.stnId}, 시간: ${tm})`); + console.log(`📡 기상청 API Hub 호출: ${regionCode.name} (관측소: ${regionCode.stnId}, 기간: ${tm1}~${tm2})`); const response = await axios.get(url, { params: { - tm: tm, - stn: 0, // 0 = 전체 관측소 데이터 조회 + tm1: tm1, + tm2: tm2, + stn: regionCode.stnId, // 특정 관측소만 조회 authKey: apiKey, help: 0, disp: 1, @@ -95,30 +137,36 @@ export class OpenApiProxyController { } catch (error: unknown) { console.error('❌ 날씨 조회 실패:', error); - // API 호출 실패 시 자동으로 테스트 모드로 전환 + // API 호출 실패 시 명확한 오류 메시지 반환 if (axios.isAxiosError(error)) { const status = error.response?.status; - // 모든 오류 → 테스트 데이터 반환 - console.log('⚠️ API 오류 발생. 테스트 데이터를 반환합니다.'); - const { city = '서울' } = req.query; - const regionCode = getKMARegionCode(city as string); - const weatherData = generateRealisticWeatherData(regionCode?.name || (city as string)); - - res.json({ - success: true, - data: weatherData, - }); + if (status === 401 || status === 403) { + res.status(401).json({ + success: false, + message: '기상청 API 인증에 실패했습니다. API 키를 확인하세요.', + }); + } else if (status === 404) { + res.status(404).json({ + success: false, + message: '기상청 API에서 데이터를 찾을 수 없습니다.', + }); + } else if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') { + res.status(504).json({ + success: false, + message: '기상청 API 연결 시간이 초과되었습니다. 잠시 후 다시 시도하세요.', + }); + } else { + res.status(500).json({ + success: false, + message: '기상청 API 호출 중 오류가 발생했습니다.', + error: error.message, + }); + } } else { - // 예상치 못한 오류 → 테스트 데이터 반환 - console.log('⚠️ 예상치 못한 오류. 테스트 데이터를 반환합니다.'); - const { city = '서울' } = req.query; - const regionCode = getKMARegionCode(city as string); - const weatherData = generateRealisticWeatherData(regionCode?.name || (city as string)); - - res.json({ - success: true, - data: weatherData, + res.status(500).json({ + success: false, + message: '날씨 정보를 가져오는 중 예상치 못한 오류가 발생했습니다.', }); } } @@ -169,15 +217,19 @@ export class OpenApiProxyController { } catch (error: unknown) { console.error('❌ 환율 조회 실패:', error); - // API 호출 실패 시 실제 근사값 반환 - console.log('⚠️ API 오류 발생. 근사값을 반환합니다.'); - const { base = 'KRW', target = 'USD' } = req.query; - const approximateRate = generateRealisticExchangeRate(base as string, target as string); - - res.json({ - success: true, - data: approximateRate, - }); + // API 호출 실패 시 명확한 오류 메시지 반환 + if (axios.isAxiosError(error)) { + res.status(500).json({ + success: false, + message: '환율 정보를 가져오는 중 오류가 발생했습니다.', + error: error.message, + }); + } else { + res.status(500).json({ + success: false, + message: '환율 정보를 가져오는 중 예상치 못한 오류가 발생했습니다.', + }); + } } } @@ -605,19 +657,26 @@ function parseKMAHubWeatherData(data: any, regionCode: { name: string; stnId: st throw new Error('날씨 데이터를 파싱할 수 없습니다.'); } - // 요청한 관측소(stnId)의 데이터 찾기 - const targetLine = lines.find((line: string) => { + // 요청한 관측소(stnId)의 모든 데이터 찾기 (시간 범위 조회 시 여러 줄 반환됨) + const targetLines = lines.filter((line: string) => { const cols = line.trim().split(/\s+/); return cols[1] === regionCode.stnId; // STN 컬럼 (인덱스 1) }); - if (!targetLine) { + if (targetLines.length === 0) { throw new Error(`${regionCode.name} 관측소 데이터를 찾을 수 없습니다.`); } + + // 가장 최근 데이터 선택 (마지막 줄) + const targetLine = targetLines[targetLines.length - 1]; // 데이터 라인 파싱 (공백으로 구분) const values = targetLine.trim().split(/\s+/); + // 관측 시각 로깅 + const obsTime = values[0]; // YYMMDDHHMI + console.log(`🕐 관측 시각: ${obsTime} (${regionCode.name})`); + // 기상청 API Hub 데이터 형식 (실제 응답 기준): // [0]YYMMDDHHMI [1]STN [2]WD [3]WS [4]GST_WD [5]GST_WS [6]GST_TM [7]PA [8]PS [9]PT [10]PR [11]TA [12]TD [13]HM [14]PV [15]RN ... const temperature = parseFloat(values[11]) || 0; // TA: 기온 (인덱스 11) diff --git a/backend-node/src/routes/vehicleRoutes.ts b/backend-node/src/routes/vehicleRoutes.ts new file mode 100644 index 00000000..b8cfa8ac --- /dev/null +++ b/backend-node/src/routes/vehicleRoutes.ts @@ -0,0 +1,52 @@ +import express from "express"; +import { query } from "../database/db"; + +const router = express.Router(); + +/** + * 차량 위치 자동 업데이트 API + * - 모든 active/warning 상태 차량의 위치를 랜덤하게 조금씩 이동 + */ +router.post("/move", async (req, res) => { + try { + // move_vehicles() 함수 실행 + await query("SELECT move_vehicles()"); + + res.json({ + success: true, + message: "차량 위치가 업데이트되었습니다" + }); + } catch (error) { + console.error("차량 위치 업데이트 오류:", error); + res.status(500).json({ + success: false, + error: "차량 위치 업데이트 실패" + }); + } +}); + +/** + * 차량 위치 목록 조회 + */ +router.get("/locations", async (req, res) => { + try { + const result = await query(` + SELECT * FROM vehicle_locations + ORDER BY last_update DESC + `); + + res.json({ + success: true, + data: result.rows + }); + } catch (error) { + console.error("차량 위치 조회 오류:", error); + res.status(500).json({ + success: false, + error: "차량 위치 조회 실패" + }); + } +}); + +export default router; + diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 1d8c6fbf..9b93bca6 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -526,12 +526,9 @@ export function CanvasElement({
{element.customTitle || element.title}
- {/* 설정 버튼 (시계, 달력, 기사관리 위젯은 자체 설정 UI 사용) */} + {/* 설정 버튼 (기사관리 위젯만 자체 설정 UI 사용) */} {onConfigure && - !( - element.type === "widget" && - (element.subtype === "clock" || element.subtype === "calendar" || element.subtype === "driver-management") - ) && ( + !(element.type === "widget" && element.subtype === "driver-management") && (
- {/* 헤더 표시 여부 */} -
+ {/* 헤더 표시 옵션 */} +
setShowHeader(e.target.checked)} className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" /> -
- {/* 진행 상황 표시 - 간단한 위젯은 표시 안 함 */} - {!isSimpleWidget && ( + {/* 진행 상황 표시 - 간단한 위젯과 헤더 전용 위젯은 표시 안 함 */} + {!isSimpleWidget && !isHeaderOnlyWidget && (
@@ -247,12 +249,13 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element )} {/* 단계별 내용 */} -
- {currentStep === 1 && ( - - )} + {!isHeaderOnlyWidget && ( +
+ {currentStep === 1 && ( + + )} - {currentStep === 2 && ( + {currentStep === 2 && (
{/* 왼쪽: 데이터 설정 */}
@@ -308,15 +311,16 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
)}
- )} -
+ )} +
+ )} {/* 모달 푸터 */}
{queryResult && {queryResult.rows.length}개 데이터 로드됨}
- {!isSimpleWidget && currentStep > 1 && ( + {!isSimpleWidget && !isHeaderOnlyWidget && currentStep > 1 && ( - {currentStep === 1 ? ( - // 1단계: 다음 버튼 (모든 타입 공통) + {isHeaderOnlyWidget ? ( + // 헤더 전용 위젯: 바로 저장 + + ) : currentStep === 1 ? ( + // 1단계: 다음 버튼 ) : ( - // 2단계: 저장 버튼 (모든 타입 공통) + // 2단계: 저장 버튼
{/* 진행 상태 표시 */} diff --git a/frontend/components/admin/dashboard/widgets/Warehouse3DWidget.tsx b/frontend/components/admin/dashboard/widgets/Warehouse3DWidget.tsx index 770bb672..480909b8 100644 --- a/frontend/components/admin/dashboard/widgets/Warehouse3DWidget.tsx +++ b/frontend/components/admin/dashboard/widgets/Warehouse3DWidget.tsx @@ -274,7 +274,7 @@ export function Warehouse3DWidget({ element }: Warehouse3DWidgetProps) { return ( - 🏭 창고 현황 (3D) + {element?.customTitle || "창고 현황 (3D)"}
{warehouses.length}개 창고 | {materials.length}개 자재 diff --git a/frontend/components/dashboard/widgets/BookingAlertWidget.tsx b/frontend/components/dashboard/widgets/BookingAlertWidget.tsx index b47f0fb4..cffab99b 100644 --- a/frontend/components/dashboard/widgets/BookingAlertWidget.tsx +++ b/frontend/components/dashboard/widgets/BookingAlertWidget.tsx @@ -161,7 +161,7 @@ export default function BookingAlertWidget({ element }: BookingAlertWidgetProps)
-

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

+

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

{newCount > 0 && ( {newCount} diff --git a/frontend/components/dashboard/widgets/CalculatorWidget.tsx b/frontend/components/dashboard/widgets/CalculatorWidget.tsx index fb652fbf..d86c44e3 100644 --- a/frontend/components/dashboard/widgets/CalculatorWidget.tsx +++ b/frontend/components/dashboard/widgets/CalculatorWidget.tsx @@ -172,7 +172,7 @@ export default function CalculatorWidget({ element, className = '' }: Calculator
{/* 제목 */} -

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

+

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

{/* 디스플레이 */}
diff --git a/frontend/components/dashboard/widgets/CustomerIssuesWidget.tsx b/frontend/components/dashboard/widgets/CustomerIssuesWidget.tsx index f7f50a43..26d6d27d 100644 --- a/frontend/components/dashboard/widgets/CustomerIssuesWidget.tsx +++ b/frontend/components/dashboard/widgets/CustomerIssuesWidget.tsx @@ -150,7 +150,7 @@ export default function CustomerIssuesWidget({ element }: CustomerIssuesWidgetPr
{/* 헤더 */}
-

⚠️ 고객 클레임/이슈

+

고객 클레임/이슈

diff --git a/frontend/components/dashboard/widgets/ExchangeWidget.tsx b/frontend/components/dashboard/widgets/ExchangeWidget.tsx index d7fb2128..20e0cea7 100644 --- a/frontend/components/dashboard/widgets/ExchangeWidget.tsx +++ b/frontend/components/dashboard/widgets/ExchangeWidget.tsx @@ -139,7 +139,7 @@ export default function ExchangeWidget({ {/* 헤더 */}
-

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

+

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

{lastUpdated ? `업데이트: ${lastUpdated.toLocaleTimeString('ko-KR', { diff --git a/frontend/components/dashboard/widgets/MapSummaryWidget.tsx b/frontend/components/dashboard/widgets/MapSummaryWidget.tsx index e91746a9..3ad09305 100644 --- a/frontend/components/dashboard/widgets/MapSummaryWidget.tsx +++ b/frontend/components/dashboard/widgets/MapSummaryWidget.tsx @@ -158,7 +158,7 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) { {/* 헤더 */}

-

📍 {displayTitle}

+

{displayTitle}

{element?.dataSource?.query ? (

총 {markers.length.toLocaleString()}개 마커

) : ( diff --git a/frontend/components/dashboard/widgets/TodoWidget.tsx b/frontend/components/dashboard/widgets/TodoWidget.tsx index 428e849e..dd4652d5 100644 --- a/frontend/components/dashboard/widgets/TodoWidget.tsx +++ b/frontend/components/dashboard/widgets/TodoWidget.tsx @@ -323,67 +323,71 @@ export default function TodoWidget({ element }: TodoWidgetProps) { return (
- {/* 헤더 */} -
-
-
-

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

- {selectedDate && ( -
- - {formatSelectedDate()} 할일 -
- )} -
- -
- - {/* 통계 */} - {stats && ( -
-
-
{stats.pending}
-
대기
-
-
-
{stats.inProgress}
-
진행중
-
-
-
{stats.urgent}
-
긴급
-
-
-
{stats.overdue}
-
지연
-
+ {/* 제목 - 항상 표시 */} +
+

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

+ {selectedDate && ( +
+ + {formatSelectedDate()} 할일
)} - - {/* 필터 */} -
- {(["all", "pending", "in_progress", "completed"] as const).map((f) => ( - - ))} -
+ {/* 헤더 (추가 버튼, 통계, 필터) - showHeader가 false일 때만 숨김 */} + {element?.showHeader !== false && ( +
+
+ +
+ + {/* 통계 */} + {stats && ( +
+
+
{stats.pending}
+
대기
+
+
+
{stats.inProgress}
+
진행중
+
+
+
{stats.urgent}
+
긴급
+
+
+
{stats.overdue}
+
지연
+
+
+ )} + + {/* 필터 */} +
+ {(["all", "pending", "in_progress", "completed"] as const).map((f) => ( + + ))} +
+
+ )} + {/* 추가 폼 */} {showAddForm && (
diff --git a/frontend/components/dashboard/widgets/VehicleListWidget.tsx b/frontend/components/dashboard/widgets/VehicleListWidget.tsx index 1ea927a8..d15d8ffa 100644 --- a/frontend/components/dashboard/widgets/VehicleListWidget.tsx +++ b/frontend/components/dashboard/widgets/VehicleListWidget.tsx @@ -97,7 +97,7 @@ export default function VehicleListWidget({ element, refreshInterval = 30000 }: {/* 헤더 */}
-

📋 차량 목록

+

차량 목록

마지막 업데이트: {lastUpdate.toLocaleTimeString("ko-KR")}

+ ) : element.type === "widget" && element.subtype === "yard-management-3d" ? ( + // 야드 관리 3D 위젯 렌더링 +
+ { + onUpdate(element.id, { yardConfig: newConfig }); + }} + /> +
) : element.type === "widget" && element.subtype === "todo" ? ( // To-Do 위젯 렌더링
diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 6d8ef8fc..6f127191 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -612,13 +612,15 @@ function getElementTitle(type: ElementType, subtype: ElementSubtype): string { case "vehicle-map": return "🚚 차량 위치 지도"; case "calendar": - return "📅 달력 위젯"; + return "달력 위젯"; case "driver-management": - return "🚚 기사 관리 위젯"; + return "기사 관리 위젯"; case "list": - return "📋 리스트 위젯"; + return "리스트 위젯"; + case "yard-management-3d": + return "야드 관리 3D"; default: - return "🔧 위젯"; + return "위젯"; } } return "요소"; @@ -657,6 +659,8 @@ function getElementContent(type: ElementType, subtype: ElementSubtype): string { return "driver-management"; case "list": return "list-widget"; + case "yard-management-3d": + return "yard-3d"; default: return "위젯 내용이 여기에 표시됩니다"; } diff --git a/frontend/components/admin/dashboard/DashboardTopMenu.tsx b/frontend/components/admin/dashboard/DashboardTopMenu.tsx index 03464aee..35062400 100644 --- a/frontend/components/admin/dashboard/DashboardTopMenu.tsx +++ b/frontend/components/admin/dashboard/DashboardTopMenu.tsx @@ -181,6 +181,7 @@ export function DashboardTopMenu({ 데이터 위젯 리스트 위젯 + 야드 관리 3D {/* 지도 */} 커스텀 지도 카드 {/* 커스텀 목록 카드 */} diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts index 05a656f3..08308cc4 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" + | "yard-management-3d"; // 야드 관리 3D 위젯 export interface Position { x: number; @@ -63,6 +64,7 @@ export interface DashboardElement { calendarConfig?: CalendarConfig; // 달력 설정 driverManagementConfig?: DriverManagementConfig; // 기사 관리 설정 listConfig?: ListWidgetConfig; // 리스트 위젯 설정 + yardConfig?: YardManagementConfig; // 야드 관리 3D 설정 } export interface DragData { @@ -271,3 +273,9 @@ export interface ListColumn { align?: "left" | "center" | "right"; // 정렬 visible?: boolean; // 표시 여부 (기본: true) } + +// 야드 관리 3D 설정 +export interface YardManagementConfig { + layoutId: number; // 선택된 야드 레이아웃 ID + layoutName?: string; // 레이아웃 이름 (표시용) +} diff --git a/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx b/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx new file mode 100644 index 00000000..2ba2e697 --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Plus, Check } from "lucide-react"; +import YardLayoutList from "./yard-3d/YardLayoutList"; +import YardLayoutCreateModal from "./yard-3d/YardLayoutCreateModal"; +import YardEditor from "./yard-3d/YardEditor"; +import Yard3DViewer from "./yard-3d/Yard3DViewer"; +import { yardLayoutApi } from "@/lib/api/yardLayoutApi"; +import type { YardManagementConfig } from "../types"; + +interface YardLayout { + id: number; + name: string; + description: string; + placement_count: number; + updated_at: string; +} + +interface YardManagement3DWidgetProps { + isEditMode?: boolean; + config?: YardManagementConfig; + onConfigChange?: (config: YardManagementConfig) => void; +} + +export default function YardManagement3DWidget({ + isEditMode = false, + config, + onConfigChange, +}: YardManagement3DWidgetProps) { + const [layouts, setLayouts] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [editingLayout, setEditingLayout] = useState(null); + + // 레이아웃 목록 로드 + const loadLayouts = async () => { + try { + setIsLoading(true); + const response = await yardLayoutApi.getAllLayouts(); + if (response.success) { + setLayouts(response.data); + } + } catch (error) { + console.error("야드 레이아웃 목록 조회 실패:", error); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + if (isEditMode) { + loadLayouts(); + } + }, [isEditMode]); + + // 레이아웃 선택 (편집 모드에서만) + const handleSelectLayout = (layout: YardLayout) => { + if (onConfigChange) { + onConfigChange({ + layoutId: layout.id, + layoutName: layout.name, + }); + } + }; + + // 새 레이아웃 생성 + const handleCreateLayout = async (name: string, description: string) => { + try { + const response = await yardLayoutApi.createLayout({ name, description }); + if (response.success) { + await loadLayouts(); + setIsCreateModalOpen(false); + setEditingLayout(response.data); + } + } catch (error) { + console.error("야드 레이아웃 생성 실패:", error); + throw error; + } + }; + + // 편집 완료 + const handleEditComplete = () => { + if (editingLayout && onConfigChange) { + onConfigChange({ + layoutId: editingLayout.id, + layoutName: editingLayout.name, + }); + } + setEditingLayout(null); + loadLayouts(); + }; + + // 편집 모드: 편집 중인 경우 YardEditor 표시 + if (isEditMode && editingLayout) { + return ( +
+ +
+ ); + } + + // 편집 모드: 레이아웃 선택 UI + if (isEditMode) { + return ( +
+
+
+

야드 레이아웃 선택

+

+ {config?.layoutName ? `선택됨: ${config.layoutName}` : "표시할 야드 레이아웃을 선택하세요"} +

+
+ +
+ +
+ {isLoading ? ( +
+
로딩 중...
+
+ ) : layouts.length === 0 ? ( +
+
+
🏗️
+
생성된 야드 레이아웃이 없습니다
+
먼저 야드 레이아웃을 생성하세요
+
+
+ ) : ( +
+ {layouts.map((layout) => ( +
+
+ + +
+
+ ))} +
+ )} +
+ + {/* 생성 모달 */} + setIsCreateModalOpen(false)} + onCreate={handleCreateLayout} + /> +
+ ); + } + + // 뷰 모드: 선택된 레이아웃의 3D 뷰어 표시 + if (!config?.layoutId) { + return ( +
+
+
🏗️
+
야드 레이아웃이 설정되지 않았습니다
+
대시보드 편집에서 레이아웃을 선택하세요
+
+
+ ); + } + + // 선택된 레이아웃의 3D 뷰어 표시 + return ( +
+ +
+ ); +} diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/MaterialAddModal.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/MaterialAddModal.tsx new file mode 100644 index 00000000..2d813744 --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/yard-3d/MaterialAddModal.tsx @@ -0,0 +1,247 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Loader2 } from "lucide-react"; + +interface TempMaterial { + id: number; + material_code: string; + material_name: string; + category: string; + unit: string; + default_color: string; + description: string; +} + +interface MaterialAddModalProps { + isOpen: boolean; + material: TempMaterial | null; + onClose: () => void; + onAdd: (placementData: any) => Promise; +} + +export default function MaterialAddModal({ isOpen, material, onClose, onAdd }: MaterialAddModalProps) { + const [quantity, setQuantity] = useState("1"); + const [positionX, setPositionX] = useState("0"); + const [positionY, setPositionY] = useState("0"); + const [positionZ, setPositionZ] = useState("0"); + const [sizeX, setSizeX] = useState("5"); + const [sizeY, setSizeY] = useState("5"); + const [sizeZ, setSizeZ] = useState("5"); + const [color, setColor] = useState(""); + const [isAdding, setIsAdding] = useState(false); + + // 모달이 열릴 때 기본값 설정 + const handleOpen = (open: boolean) => { + if (open && material) { + setColor(material.default_color); + setQuantity("1"); + setPositionX("0"); + setPositionY("0"); + setPositionZ("0"); + setSizeX("5"); + setSizeY("5"); + setSizeZ("5"); + } + }; + + // 자재 추가 + const handleAdd = async () => { + if (!material) return; + + setIsAdding(true); + try { + await onAdd({ + external_material_id: `TEMP-${Date.now()}`, + material_code: material.material_code, + material_name: material.material_name, + quantity: parseInt(quantity) || 1, + unit: material.unit, + position_x: parseFloat(positionX) || 0, + position_y: parseFloat(positionY) || 0, + position_z: parseFloat(positionZ) || 0, + size_x: parseFloat(sizeX) || 5, + size_y: parseFloat(sizeY) || 5, + size_z: parseFloat(sizeZ) || 5, + color: color || material.default_color, + }); + onClose(); + } catch (error) { + console.error("자재 추가 실패:", error); + } finally { + setIsAdding(false); + } + }; + + if (!material) return null; + + return ( + { + handleOpen(open); + if (!open) onClose(); + }} + > + + + 자재 배치 설정 + + +
+ {/* 자재 정보 */} +
+
선택한 자재
+
+
+
+
{material.material_name}
+
{material.material_code}
+
+
+
+ + {/* 수량 */} +
+ +
+ setQuantity(e.target.value)} + min="1" + className="flex-1" + /> + {material.unit} +
+
+ + {/* 3D 위치 */} +
+ +
+
+ + setPositionX(e.target.value)} + step="0.5" + /> +
+
+ + setPositionY(e.target.value)} + step="0.5" + /> +
+
+ + setPositionZ(e.target.value)} + step="0.5" + /> +
+
+
+ + {/* 3D 크기 */} +
+ +
+
+ + setSizeX(e.target.value)} + min="1" + step="0.5" + /> +
+
+ + setSizeY(e.target.value)} + min="1" + step="0.5" + /> +
+
+ + setSizeZ(e.target.value)} + min="1" + step="0.5" + /> +
+
+
+ + {/* 색상 */} +
+ +
+ setColor(e.target.value)} + className="h-10 w-20 cursor-pointer rounded border" + /> + setColor(e.target.value)} className="flex-1" /> +
+
+
+ + + + + + +
+ ); +} diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/MaterialEditPanel.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/MaterialEditPanel.tsx new file mode 100644 index 00000000..d2388711 --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/yard-3d/MaterialEditPanel.tsx @@ -0,0 +1,277 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Trash2 } from "lucide-react"; + +interface YardPlacement { + id: number; + external_material_id: string; + material_code: string; + material_name: 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; + memo?: string; +} + +interface MaterialEditPanelProps { + placement: YardPlacement | null; + onClose: () => void; + onUpdate: (id: number, updates: Partial) => void; + onRemove: (id: number) => void; +} + +export default function MaterialEditPanel({ placement, onClose, onUpdate, onRemove }: MaterialEditPanelProps) { + const [editData, setEditData] = useState>({}); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + + // placement 변경 시 editData 초기화 + useEffect(() => { + if (placement) { + setEditData({ + position_x: placement.position_x, + position_y: placement.position_y, + position_z: placement.position_z, + size_x: placement.size_x, + size_y: placement.size_y, + size_z: placement.size_z, + color: placement.color, + memo: placement.memo, + }); + } + }, [placement]); + + if (!placement) return null; + + // 변경사항 적용 + const handleApply = () => { + onUpdate(placement.id, editData); + }; + + // 배치 해제 + const handleRemove = () => { + onRemove(placement.id); + setIsDeleteDialogOpen(false); + }; + + return ( +
+
+

자재 정보

+ +
+ +
+ {/* 읽기 전용 정보 */} +
+
자재 정보 (읽기 전용)
+
+
자재 코드
+
{placement.material_code}
+
+
+
자재 이름
+
{placement.material_name}
+
+
+
수량
+
+ {placement.quantity} {placement.unit} +
+
+
+ + {/* 배치 정보 (편집 가능) */} +
+
배치 정보 (편집 가능)
+ + {/* 3D 위치 */} +
+ +
+
+ + setEditData({ ...editData, position_x: parseFloat(e.target.value) || 0 })} + step="0.5" + className="h-8 text-xs" + /> +
+
+ + setEditData({ ...editData, position_y: parseFloat(e.target.value) || 0 })} + step="0.5" + className="h-8 text-xs" + /> +
+
+ + setEditData({ ...editData, position_z: parseFloat(e.target.value) || 0 })} + step="0.5" + className="h-8 text-xs" + /> +
+
+
+ + {/* 3D 크기 */} +
+ +
+
+ + setEditData({ ...editData, size_x: parseFloat(e.target.value) || 1 })} + min="1" + step="0.5" + className="h-8 text-xs" + /> +
+
+ + setEditData({ ...editData, size_y: parseFloat(e.target.value) || 1 })} + min="1" + step="0.5" + className="h-8 text-xs" + /> +
+
+ + setEditData({ ...editData, size_z: parseFloat(e.target.value) || 1 })} + min="1" + step="0.5" + className="h-8 text-xs" + /> +
+
+
+ + {/* 색상 */} +
+ +
+ setEditData({ ...editData, color: e.target.value })} + className="h-8 w-16 cursor-pointer rounded border" + /> + setEditData({ ...editData, color: e.target.value })} + className="h-8 flex-1 text-xs" + /> +
+
+ + {/* 메모 */} +
+ +