From 6a3ee2f13227dc14bec7476d34dd1fa1f4498331 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Tue, 14 Oct 2025 16:45:16 +0900 Subject: [PATCH 1/3] =?UTF-8?q?=EB=AA=A8=EB=93=A0=20=EC=9C=84=EC=A0=AF=20?= =?UTF-8?q?=EB=B3=B5=EA=B5=AC=20(=EC=BA=98=EB=A6=B0=EB=8D=94,=20=EC=9A=B4?= =?UTF-8?q?=EC=A0=84=EC=9E=90=20=EA=B4=80=EB=A6=AC=20=ED=8F=AC=ED=95=A8)?= =?UTF-8?q?=20-=20main=EA=B3=BC=20=EB=8F=99=EC=9D=BC=ED=95=98=EA=B2=8C=20?= =?UTF-8?q?=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/CanvasElement.tsx | 178 ++++++++---------- .../admin/dashboard/DashboardSidebar.tsx | 24 ++- frontend/components/admin/dashboard/types.ts | 45 ++++- 3 files changed, 144 insertions(+), 103 deletions(-) diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 35c3bb02..f646061c 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -39,6 +39,10 @@ const RiskAlertWidget = dynamic(() => import("@/components/dashboard/widgets/Ris // 시계 위젯 임포트 import { ClockWidget } from "./widgets/ClockWidget"; +// 달력 위젯 임포트 +import { CalendarWidget } from "./widgets/CalendarWidget"; +// 기사 관리 위젯 임포트 +import { DriverManagementWidget } from "./widgets/DriverManagementWidget"; interface CanvasElementProps { element: DashboardElement; @@ -137,9 +141,13 @@ export function CanvasElement({ const deltaY = e.clientY - dragStart.y; // 임시 위치 계산 (스냅 안 됨) - const rawX = Math.max(0, dragStart.elementX + deltaX); + let rawX = Math.max(0, dragStart.elementX + deltaX); const rawY = Math.max(0, dragStart.elementY + deltaY); + // X 좌표가 캔버스 너비를 벗어나지 않도록 제한 + const maxX = GRID_CONFIG.CANVAS_WIDTH - element.size.width; + rawX = Math.min(rawX, maxX); + setTempPosition({ x: rawX, y: rawY }); } else if (isResizing) { const deltaX = e.clientX - resizeStart.x; @@ -150,46 +158,58 @@ export function CanvasElement({ let newX = resizeStart.elementX; let newY = resizeStart.elementY; - const minSize = GRID_CONFIG.CELL_SIZE * 2; // 최소 2셀 + // 최소 크기 설정: 달력은 2x3, 나머지는 2x2 + const minWidthCells = 2; + const minHeightCells = element.type === "widget" && element.subtype === "calendar" ? 3 : 2; + const minWidth = GRID_CONFIG.CELL_SIZE * minWidthCells; + const minHeight = GRID_CONFIG.CELL_SIZE * minHeightCells; switch (resizeStart.handle) { case "se": // 오른쪽 아래 - newWidth = Math.max(minSize, resizeStart.width + deltaX); - newHeight = Math.max(minSize, resizeStart.height + deltaY); + newWidth = Math.max(minWidth, resizeStart.width + deltaX); + newHeight = Math.max(minHeight, resizeStart.height + deltaY); break; case "sw": // 왼쪽 아래 - newWidth = Math.max(minSize, resizeStart.width - deltaX); - newHeight = Math.max(minSize, resizeStart.height + deltaY); + newWidth = Math.max(minWidth, resizeStart.width - deltaX); + newHeight = Math.max(minHeight, resizeStart.height + deltaY); newX = resizeStart.elementX + deltaX; break; case "ne": // 오른쪽 위 - newWidth = Math.max(minSize, resizeStart.width + deltaX); - newHeight = Math.max(minSize, resizeStart.height - deltaY); + newWidth = Math.max(minWidth, resizeStart.width + deltaX); + newHeight = Math.max(minHeight, resizeStart.height - deltaY); newY = resizeStart.elementY + deltaY; break; case "nw": // 왼쪽 위 - newWidth = Math.max(minSize, resizeStart.width - deltaX); - newHeight = Math.max(minSize, resizeStart.height - deltaY); + newWidth = Math.max(minWidth, resizeStart.width - deltaX); + newHeight = Math.max(minHeight, resizeStart.height - deltaY); newX = resizeStart.elementX + deltaX; newY = resizeStart.elementY + deltaY; break; } + // 가로 너비가 캔버스를 벗어나지 않도록 제한 + const maxWidth = GRID_CONFIG.CANVAS_WIDTH - newX; + newWidth = Math.min(newWidth, maxWidth); + // 임시 크기/위치 저장 (스냅 안 됨) setTempPosition({ x: Math.max(0, newX), y: Math.max(0, newY) }); setTempSize({ width: newWidth, height: newHeight }); } }, - [isDragging, isResizing, dragStart, resizeStart], + [isDragging, isResizing, dragStart, resizeStart, element.size.width, element.type, element.subtype], ); // 마우스 업 처리 (그리드 스냅 적용) const handleMouseUp = useCallback(() => { if (isDragging && tempPosition) { // 드래그 종료 시 그리드에 스냅 (동적 셀 크기 사용) - const snappedX = snapToGrid(tempPosition.x, cellSize); + let snappedX = snapToGrid(tempPosition.x, cellSize); const snappedY = snapToGrid(tempPosition.y, cellSize); + // X 좌표가 캔버스 너비를 벗어나지 않도록 최종 제한 + const maxX = GRID_CONFIG.CANVAS_WIDTH - element.size.width; + snappedX = Math.min(snappedX, maxX); + onUpdate(element.id, { position: { x: snappedX, y: snappedY }, }); @@ -201,9 +221,13 @@ export function CanvasElement({ // 리사이즈 종료 시 그리드에 스냅 (동적 셀 크기 사용) const snappedX = snapToGrid(tempPosition.x, cellSize); const snappedY = snapToGrid(tempPosition.y, cellSize); - const snappedWidth = snapSizeToGrid(tempSize.width, 2, cellSize); + let snappedWidth = snapSizeToGrid(tempSize.width, 2, cellSize); const snappedHeight = snapSizeToGrid(tempSize.height, 2, cellSize); + // 가로 너비가 캔버스를 벗어나지 않도록 최종 제한 + const maxWidth = GRID_CONFIG.CANVAS_WIDTH - snappedX; + snappedWidth = Math.min(snappedWidth, maxWidth); + onUpdate(element.id, { position: { x: snappedX, y: snappedY }, size: { width: snappedWidth, height: snappedHeight }, @@ -215,7 +239,7 @@ export function CanvasElement({ setIsDragging(false); setIsResizing(false); - }, [isDragging, isResizing, tempPosition, tempSize, element.id, onUpdate, cellSize]); + }, [isDragging, isResizing, tempPosition, tempSize, element.id, element.size.width, onUpdate, cellSize]); // 전역 마우스 이벤트 등록 React.useEffect(() => { @@ -253,12 +277,11 @@ export function CanvasElement({ executionTime: 0, }); } catch (error) { - // console.error('❌ 데이터 로딩 오류:', error); setChartData(null); } finally { setIsLoadingData(false); } - }, [element.dataSource?.query, element.type, element.subtype]); + }, [element.dataSource?.query, element.type]); // 컴포넌트 마운트 시 및 쿼리 변경 시 데이터 로딩 useEffect(() => { @@ -301,6 +324,10 @@ export function CanvasElement({ return "bg-gradient-to-br from-cyan-400 to-indigo-800"; case "clock": return "bg-gradient-to-br from-teal-400 to-cyan-600"; + case "calendar": + return "bg-gradient-to-br from-indigo-400 to-purple-600"; + case "driver-management": + return "bg-gradient-to-br from-blue-400 to-indigo-600"; default: return "bg-gray-200"; } @@ -330,16 +357,20 @@ export function CanvasElement({
{element.title}
- {/* 설정 버튼 (시계 위젯은 자체 설정 UI 사용) */} - {onConfigure && !(element.type === "widget" && element.subtype === "clock") && ( - - )} + {/* 설정 버튼 (시계, 달력, 기사관리 위젯은 자체 설정 UI 사용) */} + {onConfigure && + !( + element.type === "widget" && + (element.subtype === "clock" || element.subtype === "calendar" || element.subtype === "driver-management") + ) && ( + + )} {/* 삭제 버튼 */}
diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts index 50909504..eb936652 100644 --- a/frontend/components/admin/dashboard/types.ts +++ b/frontend/components/admin/dashboard/types.ts @@ -15,10 +15,12 @@ export type ElementSubtype = | "exchange" | "weather" | "clock" + | "calendar" | "calculator" | "vehicle-map" | "delivery-status" - | "risk-alert"; // 위젯 타입 + | "risk-alert" + | "driver-management"; // 위젯 타입 export interface Position { x: number; @@ -41,6 +43,8 @@ export interface DashboardElement { dataSource?: ChartDataSource; // 데이터 소스 설정 chartConfig?: ChartConfig; // 차트 설정 clockConfig?: ClockConfig; // 시계 설정 + calendarConfig?: CalendarConfig; // 달력 설정 + driverManagementConfig?: DriverManagementConfig; // 기사 관리 설정 } export interface DragData { @@ -90,3 +94,42 @@ export interface ClockConfig { theme: "light" | "dark" | "custom"; // 테마 customColor?: string; // 사용자 지정 색상 (custom 테마일 때) } + +// 달력 위젯 설정 +export interface CalendarConfig { + view: "month" | "week" | "day"; // 뷰 타입 + startWeekOn: "monday" | "sunday"; // 주 시작 요일 + highlightWeekends: boolean; // 주말 강조 + highlightToday: boolean; // 오늘 강조 + showHolidays: boolean; // 공휴일 표시 + theme: "light" | "dark" | "custom"; // 테마 + customColor?: string; // 사용자 지정 색상 + showWeekNumbers?: boolean; // 주차 표시 (선택) +} + +// 기사 관리 위젯 설정 +export interface DriverManagementConfig { + viewType: "list"; // 뷰 타입 (현재는 리스트만) + autoRefreshInterval: number; // 자동 새로고침 간격 (초) + visibleColumns: string[]; // 표시할 컬럼 목록 + theme: "light" | "dark" | "custom"; // 테마 + customColor?: string; // 사용자 지정 색상 + statusFilter: "all" | "driving" | "standby" | "resting" | "maintenance"; // 상태 필터 + sortBy: "name" | "vehicleNumber" | "status" | "departureTime"; // 정렬 기준 + sortOrder: "asc" | "desc"; // 정렬 순서 +} + +// 기사 정보 +export interface DriverInfo { + id: string; // 기사 고유 ID + name: string; // 기사 이름 + vehicleNumber: string; // 차량 번호 + vehicleType: string; // 차량 유형 + phone: string; // 연락처 + status: "standby" | "driving" | "resting" | "maintenance"; // 운행 상태 + departure?: string; // 출발지 + destination?: string; // 목적지 + departureTime?: string; // 출발 시간 + estimatedArrival?: string; // 예상 도착 시간 + progress?: number; // 운행 진행률 (0-100) +} From 104c5671f6e531448d71e9f971f94791f6d7e8a1 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Tue, 14 Oct 2025 16:46:42 +0900 Subject: [PATCH 2/3] =?UTF-8?q?=EC=BA=98=EB=A6=B0=EB=8D=94=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9A=B4=EC=A0=84=EC=9E=90=20=EA=B4=80=EB=A6=AC=20=EC=9C=84?= =?UTF-8?q?=EC=A0=AF=20=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80=20(main?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=B3=B5=EC=82=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/widgets/CalendarSettings.tsx | 207 ++++++++++++++ .../dashboard/widgets/CalendarWidget.tsx | 121 +++++++++ .../dashboard/widgets/DriverListView.tsx | 160 +++++++++++ .../widgets/DriverManagementSettings.tsx | 139 ++++++++++ .../widgets/DriverManagementWidget.tsx | 159 +++++++++++ .../admin/dashboard/widgets/MonthView.tsx | 117 ++++++++ .../admin/dashboard/widgets/calendarUtils.ts | 162 +++++++++++ .../admin/dashboard/widgets/driverMockData.ts | 181 +++++++++++++ .../admin/dashboard/widgets/driverUtils.ts | 256 ++++++++++++++++++ 9 files changed, 1502 insertions(+) create mode 100644 frontend/components/admin/dashboard/widgets/CalendarSettings.tsx create mode 100644 frontend/components/admin/dashboard/widgets/CalendarWidget.tsx create mode 100644 frontend/components/admin/dashboard/widgets/DriverListView.tsx create mode 100644 frontend/components/admin/dashboard/widgets/DriverManagementSettings.tsx create mode 100644 frontend/components/admin/dashboard/widgets/DriverManagementWidget.tsx create mode 100644 frontend/components/admin/dashboard/widgets/MonthView.tsx create mode 100644 frontend/components/admin/dashboard/widgets/calendarUtils.ts create mode 100644 frontend/components/admin/dashboard/widgets/driverMockData.ts create mode 100644 frontend/components/admin/dashboard/widgets/driverUtils.ts diff --git a/frontend/components/admin/dashboard/widgets/CalendarSettings.tsx b/frontend/components/admin/dashboard/widgets/CalendarSettings.tsx new file mode 100644 index 00000000..89633cc8 --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/CalendarSettings.tsx @@ -0,0 +1,207 @@ +"use client"; + +import { useState } from "react"; +import { CalendarConfig } from "../types"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Input } from "@/components/ui/input"; +import { Card } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; + +interface CalendarSettingsProps { + config: CalendarConfig; + onSave: (config: CalendarConfig) => void; + onClose: () => void; +} + +/** + * 달력 위젯 설정 UI (Popover 내부용) + */ +export function CalendarSettings({ config, onSave, onClose }: CalendarSettingsProps) { + const [localConfig, setLocalConfig] = useState(config); + + const handleSave = () => { + onSave(localConfig); + }; + + return ( +
+ {/* 헤더 */} +
+

+ 📅 + 달력 설정 +

+
+ + {/* 내용 - 스크롤 가능 */} +
+ {/* 뷰 타입 선택 (현재는 month만) */} +
+ + +
+ + + + {/* 시작 요일 선택 */} +
+ +
+ + +
+
+ + + + {/* 테마 선택 */} +
+ +
+ {[ + { + value: "light", + label: "Light", + gradient: "bg-gradient-to-br from-white to-gray-100", + text: "text-gray-900", + }, + { + value: "dark", + label: "Dark", + gradient: "bg-gradient-to-br from-gray-800 to-gray-900", + text: "text-white", + }, + { + value: "custom", + label: "사용자", + gradient: "bg-gradient-to-br from-blue-400 to-purple-600", + text: "text-white", + }, + ].map((theme) => ( + + ))} +
+ + {/* 사용자 지정 색상 */} + {localConfig.theme === "custom" && ( + + +
+ setLocalConfig({ ...localConfig, customColor: e.target.value })} + className="h-10 w-16 cursor-pointer" + /> + setLocalConfig({ ...localConfig, customColor: e.target.value })} + placeholder="#3b82f6" + className="flex-1 font-mono text-xs" + /> +
+
+ )} +
+ + + + {/* 표시 옵션 */} +
+ +
+ {/* 오늘 강조 */} +
+
+ 📍 + +
+ setLocalConfig({ ...localConfig, highlightToday: checked })} + /> +
+ + {/* 주말 강조 */} +
+
+ 🎨 + +
+ setLocalConfig({ ...localConfig, highlightWeekends: checked })} + /> +
+ + {/* 공휴일 표시 */} +
+
+ 🎉 + +
+ setLocalConfig({ ...localConfig, showHolidays: checked })} + /> +
+
+
+
+ + {/* 푸터 */} +
+ + +
+
+ ); +} + diff --git a/frontend/components/admin/dashboard/widgets/CalendarWidget.tsx b/frontend/components/admin/dashboard/widgets/CalendarWidget.tsx new file mode 100644 index 00000000..4f54ac65 --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/CalendarWidget.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { useState } from "react"; +import { DashboardElement, CalendarConfig } from "../types"; +import { MonthView } from "./MonthView"; +import { CalendarSettings } from "./CalendarSettings"; +import { generateCalendarDays, getMonthName, navigateMonth } from "./calendarUtils"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Button } from "@/components/ui/button"; +import { Settings, ChevronLeft, ChevronRight, Calendar } from "lucide-react"; + +interface CalendarWidgetProps { + element: DashboardElement; + onConfigUpdate?: (config: CalendarConfig) => void; +} + +/** + * 달력 위젯 메인 컴포넌트 + * - 월간/주간/일간 뷰 지원 + * - 네비게이션 (이전/다음 월, 오늘) + * - 내장 설정 UI + */ +export function CalendarWidget({ element, onConfigUpdate }: CalendarWidgetProps) { + // 현재 표시 중인 년/월 + const today = new Date(); + const [currentYear, setCurrentYear] = useState(today.getFullYear()); + const [currentMonth, setCurrentMonth] = useState(today.getMonth()); + const [settingsOpen, setSettingsOpen] = useState(false); + + // 기본 설정값 + const config = element.calendarConfig || { + view: "month", + startWeekOn: "sunday", + highlightWeekends: true, + highlightToday: true, + showHolidays: true, + theme: "light", + }; + + // 설정 저장 핸들러 + const handleSaveSettings = (newConfig: CalendarConfig) => { + onConfigUpdate?.(newConfig); + setSettingsOpen(false); + }; + + // 이전 월로 이동 + const handlePrevMonth = () => { + const { year, month } = navigateMonth(currentYear, currentMonth, "prev"); + setCurrentYear(year); + setCurrentMonth(month); + }; + + // 다음 월로 이동 + const handleNextMonth = () => { + const { year, month } = navigateMonth(currentYear, currentMonth, "next"); + setCurrentYear(year); + setCurrentMonth(month); + }; + + // 오늘로 돌아가기 + const handleToday = () => { + setCurrentYear(today.getFullYear()); + setCurrentMonth(today.getMonth()); + }; + + // 달력 날짜 생성 + const calendarDays = generateCalendarDays(currentYear, currentMonth, config.startWeekOn); + + // 크기에 따른 컴팩트 모드 판단 + const isCompact = element.size.width < 400 || element.size.height < 400; + + return ( +
+ {/* 헤더 - 네비게이션 */} +
+ {/* 이전 월 버튼 */} + + + {/* 현재 년월 표시 */} +
+ + {currentYear}년 {getMonthName(currentMonth)} + + {!isCompact && ( + + )} +
+ + {/* 다음 월 버튼 */} + +
+ + {/* 달력 콘텐츠 */} +
+ {config.view === "month" && } + {/* 추후 WeekView, DayView 추가 가능 */} +
+ + {/* 설정 버튼 - 우측 하단 */} +
+ + + + + + setSettingsOpen(false)} /> + + +
+
+ ); +} + diff --git a/frontend/components/admin/dashboard/widgets/DriverListView.tsx b/frontend/components/admin/dashboard/widgets/DriverListView.tsx new file mode 100644 index 00000000..cddbe6c6 --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/DriverListView.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { DriverInfo, DriverManagementConfig } from "../types"; +import { getStatusColor, getStatusLabel, formatTime, COLUMN_LABELS } from "./driverUtils"; +import { Progress } from "@/components/ui/progress"; + +interface DriverListViewProps { + drivers: DriverInfo[]; + config: DriverManagementConfig; + isCompact?: boolean; // 작은 크기 (2x2 등) +} + +export function DriverListView({ drivers, config, isCompact = false }: DriverListViewProps) { + const { visibleColumns } = config; + + // 컴팩트 모드: 요약 정보만 표시 + if (isCompact) { + const stats = { + driving: drivers.filter((d) => d.status === "driving").length, + standby: drivers.filter((d) => d.status === "standby").length, + resting: drivers.filter((d) => d.status === "resting").length, + maintenance: drivers.filter((d) => d.status === "maintenance").length, + }; + + return ( +
+
+
{drivers.length}
+
전체 기사
+
+
+
+
{stats.driving}
+
운행중
+
+
+
{stats.standby}
+
대기중
+
+
+
{stats.resting}
+
휴식중
+
+
+
{stats.maintenance}
+
점검중
+
+
+
+ ); + } + + // 빈 데이터 처리 + if (drivers.length === 0) { + return ( +
조회된 기사 정보가 없습니다
+ ); + } + + return ( +
+ + + + {visibleColumns.includes("status") && ( + + )} + {visibleColumns.includes("name") && ( + + )} + {visibleColumns.includes("vehicleNumber") && ( + + )} + {visibleColumns.includes("vehicleType") && ( + + )} + {visibleColumns.includes("departure") && ( + + )} + {visibleColumns.includes("destination") && ( + + )} + {visibleColumns.includes("departureTime") && ( + + )} + {visibleColumns.includes("estimatedArrival") && ( + + )} + {visibleColumns.includes("phone") && ( + + )} + {visibleColumns.includes("progress") && ( + + )} + + + + {drivers.map((driver) => { + const statusColors = getStatusColor(driver.status); + return ( + + {visibleColumns.includes("status") && ( + + )} + {visibleColumns.includes("name") && ( + + )} + {visibleColumns.includes("vehicleNumber") && ( + + )} + {visibleColumns.includes("vehicleType") && ( + + )} + {visibleColumns.includes("departure") && ( + + )} + {visibleColumns.includes("destination") && ( + + )} + {visibleColumns.includes("departureTime") && ( + + )} + {visibleColumns.includes("estimatedArrival") && ( + + )} + {visibleColumns.includes("phone") && ( + + )} + {visibleColumns.includes("progress") && ( + + )} + + ); + })} + +
{COLUMN_LABELS.status}{COLUMN_LABELS.name}{COLUMN_LABELS.vehicleNumber}{COLUMN_LABELS.vehicleType}{COLUMN_LABELS.departure}{COLUMN_LABELS.destination}{COLUMN_LABELS.departureTime} + {COLUMN_LABELS.estimatedArrival} + {COLUMN_LABELS.phone}{COLUMN_LABELS.progress}
+ + {getStatusLabel(driver.status)} + + {driver.name}{driver.vehicleNumber}{driver.vehicleType} + {driver.departure || -} + + {driver.destination || -} + {formatTime(driver.departureTime)}{formatTime(driver.estimatedArrival)}{driver.phone} + {driver.progress !== undefined ? ( +
+ + {driver.progress}% +
+ ) : ( + - + )} +
+
+ ); +} diff --git a/frontend/components/admin/dashboard/widgets/DriverManagementSettings.tsx b/frontend/components/admin/dashboard/widgets/DriverManagementSettings.tsx new file mode 100644 index 00000000..0f09286e --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/DriverManagementSettings.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { useState } from "react"; +import { DriverManagementConfig } from "../types"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Input } from "@/components/ui/input"; +import { Card } from "@/components/ui/card"; +import { COLUMN_LABELS, DEFAULT_VISIBLE_COLUMNS } from "./driverUtils"; + +interface DriverManagementSettingsProps { + config: DriverManagementConfig; + onSave: (config: DriverManagementConfig) => void; + onClose: () => void; +} + +export function DriverManagementSettings({ config, onSave, onClose }: DriverManagementSettingsProps) { + const [localConfig, setLocalConfig] = useState(config); + + const handleSave = () => { + onSave(localConfig); + }; + + // 컬럼 토글 + const toggleColumn = (column: string) => { + const newColumns = localConfig.visibleColumns.includes(column) + ? localConfig.visibleColumns.filter((c) => c !== column) + : [...localConfig.visibleColumns, column]; + setLocalConfig({ ...localConfig, visibleColumns: newColumns }); + }; + + return ( +
+
+ {/* 자동 새로고침 */} +
+ + +
+ + {/* 정렬 설정 */} +
+ +
+ + + +
+
+ + {/* 표시 컬럼 선택 */} +
+
+ + +
+
+ {Object.entries(COLUMN_LABELS).map(([key, label]) => ( + toggleColumn(key)} + > +
+ + toggleColumn(key)} + /> +
+
+ ))} +
+
+
+ + {/* 푸터 - 고정 */} +
+ + +
+
+ ); +} diff --git a/frontend/components/admin/dashboard/widgets/DriverManagementWidget.tsx b/frontend/components/admin/dashboard/widgets/DriverManagementWidget.tsx new file mode 100644 index 00000000..60d5c615 --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/DriverManagementWidget.tsx @@ -0,0 +1,159 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { DashboardElement, DriverManagementConfig, DriverInfo } from "../types"; +import { DriverListView } from "./DriverListView"; +import { DriverManagementSettings } from "./DriverManagementSettings"; +import { MOCK_DRIVERS } from "./driverMockData"; +import { filterDrivers, sortDrivers, DEFAULT_VISIBLE_COLUMNS } from "./driverUtils"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Settings, Search, RefreshCw } from "lucide-react"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; + +interface DriverManagementWidgetProps { + element: DashboardElement; + onConfigUpdate?: (config: DriverManagementConfig) => void; +} + +export function DriverManagementWidget({ element, onConfigUpdate }: DriverManagementWidgetProps) { + const [drivers, setDrivers] = useState(MOCK_DRIVERS); + const [searchTerm, setSearchTerm] = useState(""); + const [settingsOpen, setSettingsOpen] = useState(false); + const [lastRefresh, setLastRefresh] = useState(new Date()); + + // 기본 설정 + const config = element.driverManagementConfig || { + viewType: "list", + autoRefreshInterval: 30, + visibleColumns: DEFAULT_VISIBLE_COLUMNS, + theme: "light", + statusFilter: "all", + sortBy: "name", + sortOrder: "asc", + }; + + // 자동 새로고침 + useEffect(() => { + if (config.autoRefreshInterval <= 0) return; + + const interval = setInterval(() => { + // 실제 환경에서는 API 호출 + setDrivers(MOCK_DRIVERS); + setLastRefresh(new Date()); + }, config.autoRefreshInterval * 1000); + + return () => clearInterval(interval); + }, [config.autoRefreshInterval]); + + // 수동 새로고침 + const handleRefresh = () => { + setDrivers(MOCK_DRIVERS); + setLastRefresh(new Date()); + }; + + // 설정 저장 + const handleSaveSettings = (newConfig: DriverManagementConfig) => { + onConfigUpdate?.(newConfig); + setSettingsOpen(false); + }; + + // 필터링 및 정렬 + const filteredDrivers = sortDrivers( + filterDrivers(drivers, config.statusFilter, searchTerm), + config.sortBy, + config.sortOrder, + ); + + // 컴팩트 모드 판단 (위젯 크기가 작을 때) + const isCompact = element.size.width < 400 || element.size.height < 300; + + return ( +
+ {/* 헤더 - 컴팩트 모드가 아닐 때만 표시 */} + {!isCompact && ( +
+
+ {/* 검색 */} +
+ + setSearchTerm(e.target.value)} + className="h-8 pl-8 text-xs" + /> +
+ + {/* 상태 필터 */} + + + {/* 새로고침 버튼 */} + + + {/* 설정 버튼 */} + + + + + + setSettingsOpen(false)} + /> + + +
+ + {/* 통계 정보 */} +
+ + 전체 {filteredDrivers.length}명 + + | + + 운행중{" "} + + {filteredDrivers.filter((d) => d.status === "driving").length} + + 명 + + | + 최근 업데이트: {lastRefresh.toLocaleTimeString("ko-KR")} +
+
+ )} + + {/* 리스트 뷰 */} +
+ +
+
+ ); +} diff --git a/frontend/components/admin/dashboard/widgets/MonthView.tsx b/frontend/components/admin/dashboard/widgets/MonthView.tsx new file mode 100644 index 00000000..c0fd3871 --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/MonthView.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { CalendarConfig } from "../types"; +import { CalendarDay, getWeekDayNames } from "./calendarUtils"; + +interface MonthViewProps { + days: CalendarDay[]; + config: CalendarConfig; + isCompact?: boolean; // 작은 크기 (2x2, 3x3) +} + +/** + * 월간 달력 뷰 컴포넌트 + */ +export function MonthView({ days, config, isCompact = false }: MonthViewProps) { + const weekDayNames = getWeekDayNames(config.startWeekOn); + + // 테마별 스타일 + const getThemeStyles = () => { + if (config.theme === "custom" && config.customColor) { + return { + todayBg: config.customColor, + holidayText: config.customColor, + weekendText: "#dc2626", + }; + } + + if (config.theme === "dark") { + return { + todayBg: "#3b82f6", + holidayText: "#f87171", + weekendText: "#f87171", + }; + } + + // light 테마 + return { + todayBg: "#3b82f6", + holidayText: "#dc2626", + weekendText: "#dc2626", + }; + }; + + const themeStyles = getThemeStyles(); + + // 날짜 셀 스타일 클래스 + const getDayCellClass = (day: CalendarDay) => { + const baseClass = "flex aspect-square items-center justify-center rounded-lg transition-colors"; + const sizeClass = isCompact ? "text-xs" : "text-sm"; + + let colorClass = "text-gray-700"; + + // 현재 월이 아닌 날짜 + if (!day.isCurrentMonth) { + colorClass = "text-gray-300"; + } + // 오늘 + else if (config.highlightToday && day.isToday) { + colorClass = "text-white font-bold"; + } + // 공휴일 + else if (config.showHolidays && day.isHoliday) { + colorClass = "font-semibold"; + } + // 주말 + else if (config.highlightWeekends && day.isWeekend) { + colorClass = "text-red-600"; + } + + const bgClass = config.highlightToday && day.isToday ? "" : "hover:bg-gray-100"; + + return `${baseClass} ${sizeClass} ${colorClass} ${bgClass}`; + }; + + return ( +
+ {/* 요일 헤더 */} + {!isCompact && ( +
+ {weekDayNames.map((name, index) => { + const isWeekend = config.startWeekOn === "sunday" ? index === 0 || index === 6 : index === 5 || index === 6; + return ( +
+ {name} +
+ ); + })} +
+ )} + + {/* 날짜 그리드 */} +
+ {days.map((day, index) => ( +
+ {day.day} +
+ ))} +
+
+ ); +} + diff --git a/frontend/components/admin/dashboard/widgets/calendarUtils.ts b/frontend/components/admin/dashboard/widgets/calendarUtils.ts new file mode 100644 index 00000000..4bdb8deb --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/calendarUtils.ts @@ -0,0 +1,162 @@ +/** + * 달력 유틸리티 함수 + */ + +// 한국 공휴일 데이터 (2025년 기준) +export interface Holiday { + date: string; // 'MM-DD' 형식 + name: string; + isRecurring: boolean; +} + +export const KOREAN_HOLIDAYS: Holiday[] = [ + { date: "01-01", name: "신정", isRecurring: true }, + { date: "01-28", name: "설날 연휴", isRecurring: false }, + { date: "01-29", name: "설날", isRecurring: false }, + { date: "01-30", name: "설날 연휴", isRecurring: false }, + { date: "03-01", name: "삼일절", isRecurring: true }, + { date: "05-05", name: "어린이날", isRecurring: true }, + { date: "06-06", name: "현충일", isRecurring: true }, + { date: "08-15", name: "광복절", isRecurring: true }, + { date: "10-03", name: "개천절", isRecurring: true }, + { date: "10-09", name: "한글날", isRecurring: true }, + { date: "12-25", name: "크리스마스", isRecurring: true }, +]; + +/** + * 특정 월의 첫 날 Date 객체 반환 + */ +export function getFirstDayOfMonth(year: number, month: number): Date { + return new Date(year, month, 1); +} + +/** + * 특정 월의 마지막 날짜 반환 + */ +export function getLastDateOfMonth(year: number, month: number): number { + return new Date(year, month + 1, 0).getDate(); +} + +/** + * 특정 월의 첫 날의 요일 반환 (0=일요일, 1=월요일, ...) + */ +export function getFirstDayOfWeek(year: number, month: number): number { + return new Date(year, month, 1).getDay(); +} + +/** + * 달력 그리드에 표시할 날짜 배열 생성 + * @param year 년도 + * @param month 월 (0-11) + * @param startWeekOn 주 시작 요일 ('monday' | 'sunday') + * @returns 6주 * 7일 = 42개의 날짜 정보 배열 + */ +export interface CalendarDay { + date: Date; + day: number; + isCurrentMonth: boolean; + isToday: boolean; + isWeekend: boolean; + isHoliday: boolean; + holidayName?: string; +} + +export function generateCalendarDays( + year: number, + month: number, + startWeekOn: "monday" | "sunday" = "sunday", +): CalendarDay[] { + const days: CalendarDay[] = []; + const firstDay = getFirstDayOfWeek(year, month); + const lastDate = getLastDateOfMonth(year, month); + const today = new Date(); + today.setHours(0, 0, 0, 0); + + // 시작 오프셋 계산 + let startOffset = firstDay; + if (startWeekOn === "monday") { + startOffset = firstDay === 0 ? 6 : firstDay - 1; + } + + // 이전 달 날짜들 + const prevMonthLastDate = getLastDateOfMonth(year, month - 1); + for (let i = startOffset - 1; i >= 0; i--) { + const date = new Date(year, month - 1, prevMonthLastDate - i); + days.push(createCalendarDay(date, false, today)); + } + + // 현재 달 날짜들 + for (let day = 1; day <= lastDate; day++) { + const date = new Date(year, month, day); + days.push(createCalendarDay(date, true, today)); + } + + // 다음 달 날짜들 (42개 채우기) + const remainingDays = 42 - days.length; + for (let day = 1; day <= remainingDays; day++) { + const date = new Date(year, month + 1, day); + days.push(createCalendarDay(date, false, today)); + } + + return days; +} + +/** + * CalendarDay 객체 생성 + */ +function createCalendarDay(date: Date, isCurrentMonth: boolean, today: Date): CalendarDay { + const dayOfWeek = date.getDay(); + const isToday = date.getTime() === today.getTime(); + const isWeekend = dayOfWeek === 0 || dayOfWeek === 6; + + // 공휴일 체크 + const monthStr = String(date.getMonth() + 1).padStart(2, "0"); + const dayStr = String(date.getDate()).padStart(2, "0"); + const dateKey = `${monthStr}-${dayStr}`; + const holiday = KOREAN_HOLIDAYS.find((h) => h.date === dateKey); + + return { + date, + day: date.getDate(), + isCurrentMonth, + isToday, + isWeekend, + isHoliday: !!holiday, + holidayName: holiday?.name, + }; +} + +/** + * 요일 이름 배열 반환 + */ +export function getWeekDayNames(startWeekOn: "monday" | "sunday" = "sunday"): string[] { + const sundayFirst = ["일", "월", "화", "수", "목", "금", "토"]; + const mondayFirst = ["월", "화", "수", "목", "금", "토", "일"]; + return startWeekOn === "monday" ? mondayFirst : sundayFirst; +} + +/** + * 월 이름 반환 + */ +export function getMonthName(month: number): string { + const months = ["1월", "2월", "3월", "4월", "5월", "6월", "7월", "8월", "9월", "10월", "11월", "12월"]; + return months[month]; +} + +/** + * 이전/다음 월로 이동 + */ +export function navigateMonth(year: number, month: number, direction: "prev" | "next"): { year: number; month: number } { + if (direction === "prev") { + if (month === 0) { + return { year: year - 1, month: 11 }; + } + return { year, month: month - 1 }; + } else { + if (month === 11) { + return { year: year + 1, month: 0 }; + } + return { year, month: month + 1 }; + } +} + diff --git a/frontend/components/admin/dashboard/widgets/driverMockData.ts b/frontend/components/admin/dashboard/widgets/driverMockData.ts new file mode 100644 index 00000000..85271e16 --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/driverMockData.ts @@ -0,0 +1,181 @@ +import { DriverInfo } from "../types"; + +/** + * 기사 관리 목업 데이터 + * 실제 환경에서는 REST API로 대체됨 + */ +export const MOCK_DRIVERS: DriverInfo[] = [ + { + id: "DRV001", + name: "홍길동", + vehicleNumber: "12가 3456", + vehicleType: "1톤 트럭", + phone: "010-1234-5678", + status: "driving", + departure: "서울시 강남구", + destination: "경기도 성남시", + departureTime: "2025-10-14T09:00:00", + estimatedArrival: "2025-10-14T11:30:00", + progress: 65, + }, + { + id: "DRV002", + name: "김철수", + vehicleNumber: "34나 7890", + vehicleType: "2.5톤 트럭", + phone: "010-2345-6789", + status: "standby", + }, + { + id: "DRV003", + name: "이영희", + vehicleNumber: "56다 1234", + vehicleType: "5톤 트럭", + phone: "010-3456-7890", + status: "driving", + departure: "인천광역시", + destination: "충청남도 천안시", + departureTime: "2025-10-14T08:30:00", + estimatedArrival: "2025-10-14T10:00:00", + progress: 85, + }, + { + id: "DRV004", + name: "박민수", + vehicleNumber: "78라 5678", + vehicleType: "카고", + phone: "010-4567-8901", + status: "resting", + }, + { + id: "DRV005", + name: "정수진", + vehicleNumber: "90마 9012", + vehicleType: "냉동차", + phone: "010-5678-9012", + status: "maintenance", + }, + { + id: "DRV006", + name: "최동욱", + vehicleNumber: "11아 3344", + vehicleType: "1톤 트럭", + phone: "010-6789-0123", + status: "driving", + departure: "부산광역시", + destination: "울산광역시", + departureTime: "2025-10-14T07:45:00", + estimatedArrival: "2025-10-14T09:15:00", + progress: 92, + }, + { + id: "DRV007", + name: "강미선", + vehicleNumber: "22자 5566", + vehicleType: "탑차", + phone: "010-7890-1234", + status: "standby", + }, + { + id: "DRV008", + name: "윤성호", + vehicleNumber: "33차 7788", + vehicleType: "2.5톤 트럭", + phone: "010-8901-2345", + status: "driving", + departure: "대전광역시", + destination: "세종특별자치시", + departureTime: "2025-10-14T10:20:00", + estimatedArrival: "2025-10-14T11:00:00", + progress: 45, + }, + { + id: "DRV009", + name: "장혜진", + vehicleNumber: "44카 9900", + vehicleType: "냉동차", + phone: "010-9012-3456", + status: "resting", + }, + { + id: "DRV010", + name: "임태양", + vehicleNumber: "55타 1122", + vehicleType: "5톤 트럭", + phone: "010-0123-4567", + status: "driving", + departure: "광주광역시", + destination: "전라남도 목포시", + departureTime: "2025-10-14T06:30:00", + estimatedArrival: "2025-10-14T08:45:00", + progress: 78, + }, + { + id: "DRV011", + name: "오준석", + vehicleNumber: "66파 3344", + vehicleType: "카고", + phone: "010-1111-2222", + status: "standby", + }, + { + id: "DRV012", + name: "한소희", + vehicleNumber: "77하 5566", + vehicleType: "1톤 트럭", + phone: "010-2222-3333", + status: "maintenance", + }, + { + id: "DRV013", + name: "송민재", + vehicleNumber: "88거 7788", + vehicleType: "탑차", + phone: "010-3333-4444", + status: "driving", + departure: "경기도 수원시", + destination: "경기도 평택시", + departureTime: "2025-10-14T09:50:00", + estimatedArrival: "2025-10-14T11:20:00", + progress: 38, + }, + { + id: "DRV014", + name: "배수지", + vehicleNumber: "99너 9900", + vehicleType: "2.5톤 트럭", + phone: "010-4444-5555", + status: "driving", + departure: "강원도 춘천시", + destination: "강원도 원주시", + departureTime: "2025-10-14T08:00:00", + estimatedArrival: "2025-10-14T09:30:00", + progress: 72, + }, + { + id: "DRV015", + name: "신동엽", + vehicleNumber: "00더 1122", + vehicleType: "5톤 트럭", + phone: "010-5555-6666", + status: "standby", + }, +]; + +/** + * 차량 유형 목록 + */ +export const VEHICLE_TYPES = ["1톤 트럭", "2.5톤 트럭", "5톤 트럭", "카고", "탑차", "냉동차"]; + +/** + * 운행 상태별 통계 계산 + */ +export function getDriverStatistics(drivers: DriverInfo[]) { + return { + total: drivers.length, + driving: drivers.filter((d) => d.status === "driving").length, + standby: drivers.filter((d) => d.status === "standby").length, + resting: drivers.filter((d) => d.status === "resting").length, + maintenance: drivers.filter((d) => d.status === "maintenance").length, + }; +} diff --git a/frontend/components/admin/dashboard/widgets/driverUtils.ts b/frontend/components/admin/dashboard/widgets/driverUtils.ts new file mode 100644 index 00000000..bd2ddbd3 --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/driverUtils.ts @@ -0,0 +1,256 @@ +import { DriverInfo, DriverManagementConfig } from "../types"; + +/** + * 운행 상태별 색상 반환 + */ +export function getStatusColor(status: DriverInfo["status"]) { + switch (status) { + case "driving": + return { + bg: "bg-green-100", + text: "text-green-800", + border: "border-green-300", + badge: "bg-green-500", + }; + case "standby": + return { + bg: "bg-gray-100", + text: "text-gray-800", + border: "border-gray-300", + badge: "bg-gray-500", + }; + case "resting": + return { + bg: "bg-orange-100", + text: "text-orange-800", + border: "border-orange-300", + badge: "bg-orange-500", + }; + case "maintenance": + return { + bg: "bg-red-100", + text: "text-red-800", + border: "border-red-300", + badge: "bg-red-500", + }; + default: + return { + bg: "bg-gray-100", + text: "text-gray-800", + border: "border-gray-300", + badge: "bg-gray-500", + }; + } +} + +/** + * 운행 상태 한글 변환 + */ +export function getStatusLabel(status: DriverInfo["status"]) { + switch (status) { + case "driving": + return "운행중"; + case "standby": + return "대기중"; + case "resting": + return "휴식중"; + case "maintenance": + return "점검중"; + default: + return "알 수 없음"; + } +} + +/** + * 시간 포맷팅 (HH:MM) + */ +export function formatTime(dateString?: string): string { + if (!dateString) return "-"; + const date = new Date(dateString); + return date.toLocaleTimeString("ko-KR", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); +} + +/** + * 날짜 시간 포맷팅 (MM/DD HH:MM) + */ +export function formatDateTime(dateString?: string): string { + if (!dateString) return "-"; + const date = new Date(dateString); + return date.toLocaleString("ko-KR", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); +} + +/** + * 운행 진행률 계산 (실제로는 GPS 데이터 기반) + */ +export function calculateProgress(driver: DriverInfo): number { + if (!driver.departureTime || !driver.estimatedArrival) return 0; + + const now = new Date(); + const departure = new Date(driver.departureTime); + const arrival = new Date(driver.estimatedArrival); + + const totalTime = arrival.getTime() - departure.getTime(); + const elapsedTime = now.getTime() - departure.getTime(); + + const progress = Math.min(100, Math.max(0, (elapsedTime / totalTime) * 100)); + return Math.round(progress); +} + +/** + * 기사 필터링 + */ +export function filterDrivers( + drivers: DriverInfo[], + statusFilter: DriverManagementConfig["statusFilter"], + searchTerm: string, +): DriverInfo[] { + let filtered = drivers; + + // 상태 필터 + if (statusFilter !== "all") { + filtered = filtered.filter((driver) => driver.status === statusFilter); + } + + // 검색어 필터 + if (searchTerm.trim()) { + const term = searchTerm.toLowerCase(); + filtered = filtered.filter( + (driver) => + driver.name.toLowerCase().includes(term) || + driver.vehicleNumber.toLowerCase().includes(term) || + driver.phone.includes(term), + ); + } + + return filtered; +} + +/** + * 기사 정렬 + */ +export function sortDrivers( + drivers: DriverInfo[], + sortBy: DriverManagementConfig["sortBy"], + sortOrder: DriverManagementConfig["sortOrder"], +): DriverInfo[] { + const sorted = [...drivers]; + + sorted.sort((a, b) => { + let compareResult = 0; + + switch (sortBy) { + case "name": + compareResult = a.name.localeCompare(b.name, "ko-KR"); + break; + case "vehicleNumber": + compareResult = a.vehicleNumber.localeCompare(b.vehicleNumber); + break; + case "status": + const statusOrder = { driving: 0, resting: 1, standby: 2, maintenance: 3 }; + compareResult = statusOrder[a.status] - statusOrder[b.status]; + break; + case "departureTime": + const timeA = a.departureTime ? new Date(a.departureTime).getTime() : 0; + const timeB = b.departureTime ? new Date(b.departureTime).getTime() : 0; + compareResult = timeA - timeB; + break; + } + + return sortOrder === "asc" ? compareResult : -compareResult; + }); + + return sorted; +} + +/** + * 테마별 색상 반환 + */ +export function getThemeColors(theme: string, customColor?: string) { + if (theme === "custom" && customColor) { + const lighterColor = adjustColor(customColor, 40); + const darkerColor = adjustColor(customColor, -40); + + return { + background: lighterColor, + text: darkerColor, + border: customColor, + hover: customColor, + }; + } + + if (theme === "dark") { + return { + background: "#1f2937", + text: "#f3f4f6", + border: "#374151", + hover: "#374151", + }; + } + + // light theme (default) + return { + background: "#ffffff", + text: "#1f2937", + border: "#e5e7eb", + hover: "#f3f4f6", + }; +} + +/** + * 색상 밝기 조정 + */ +function adjustColor(color: string, amount: number): string { + const clamp = (num: number) => Math.min(255, Math.max(0, num)); + + const hex = color.replace("#", ""); + const r = parseInt(hex.substring(0, 2), 16); + const g = parseInt(hex.substring(2, 4), 16); + const b = parseInt(hex.substring(4, 6), 16); + + const newR = clamp(r + amount); + const newG = clamp(g + amount); + const newB = clamp(b + amount); + + return `#${newR.toString(16).padStart(2, "0")}${newG.toString(16).padStart(2, "0")}${newB.toString(16).padStart(2, "0")}`; +} + +/** + * 기본 표시 컬럼 목록 + */ +export const DEFAULT_VISIBLE_COLUMNS = [ + "status", + "name", + "vehicleNumber", + "vehicleType", + "departure", + "destination", + "departureTime", + "estimatedArrival", + "phone", +]; + +/** + * 컬럼 라벨 매핑 + */ +export const COLUMN_LABELS: Record = { + status: "상태", + name: "기사명", + vehicleNumber: "차량번호", + vehicleType: "차량유형", + departure: "출발지", + destination: "목적지", + departureTime: "출발시간", + estimatedArrival: "도착예정", + phone: "연락처", + progress: "진행률", +}; From 9599d34ba9a0c63129f49fd210a7336868bcb7d0 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Tue, 14 Oct 2025 17:21:28 +0900 Subject: [PATCH 3/3] =?UTF-8?q?=ED=88=AC=EB=91=90=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8,=20=EC=98=88=EC=95=BD=EC=9A=94=EC=B2=AD,=20=EC=A0=95?= =?UTF-8?q?=EB=B9=84,=EB=AC=B8=EC=84=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/.env.example | 12 + backend-node/data/bookings/bookings.json | 35 ++ backend-node/data/todos/todos.json | 1 + backend-node/src/app.ts | 4 + .../src/controllers/bookingController.ts | 80 ++++ .../src/controllers/todoController.ts | 132 +++++ backend-node/src/routes/bookingRoutes.ts | 20 + backend-node/src/routes/todoRoutes.ts | 26 + backend-node/src/services/bookingService.ts | 334 +++++++++++++ backend-node/src/services/documentService.ts | 282 +++++++++++ .../src/services/maintenanceService.ts | 267 +++++++++++ backend-node/src/services/todoService.ts | 449 ++++++++++++++++++ .../admin/dashboard/CanvasElement.tsx | 40 ++ .../admin/dashboard/DashboardSidebar.tsx | 40 ++ frontend/components/admin/dashboard/types.ts | 6 +- .../dashboard/widgets/BookingAlertWidget.tsx | 308 ++++++++++++ .../dashboard/widgets/DocumentWidget.tsx | 242 ++++++++++ .../dashboard/widgets/MaintenanceWidget.tsx | 244 ++++++++++ .../dashboard/widgets/TodoWidget.tsx | 405 ++++++++++++++++ 19 files changed, 2926 insertions(+), 1 deletion(-) create mode 100644 backend-node/.env.example create mode 100644 backend-node/data/bookings/bookings.json create mode 100644 backend-node/data/todos/todos.json create mode 100644 backend-node/src/controllers/bookingController.ts create mode 100644 backend-node/src/controllers/todoController.ts create mode 100644 backend-node/src/routes/bookingRoutes.ts create mode 100644 backend-node/src/routes/todoRoutes.ts create mode 100644 backend-node/src/services/bookingService.ts create mode 100644 backend-node/src/services/documentService.ts create mode 100644 backend-node/src/services/maintenanceService.ts create mode 100644 backend-node/src/services/todoService.ts create mode 100644 frontend/components/dashboard/widgets/BookingAlertWidget.tsx create mode 100644 frontend/components/dashboard/widgets/DocumentWidget.tsx create mode 100644 frontend/components/dashboard/widgets/MaintenanceWidget.tsx create mode 100644 frontend/components/dashboard/widgets/TodoWidget.tsx diff --git a/backend-node/.env.example b/backend-node/.env.example new file mode 100644 index 00000000..fdba2895 --- /dev/null +++ b/backend-node/.env.example @@ -0,0 +1,12 @@ + +# ==================== 운영/작업 지원 위젯 데이터 소스 설정 ==================== +# 옵션: file | database | memory +# - file: 파일 기반 (빠른 개발/테스트) +# - database: PostgreSQL DB (실제 운영) +# - memory: 메모리 목 데이터 (테스트) + +TODO_DATA_SOURCE=file +BOOKING_DATA_SOURCE=file +MAINTENANCE_DATA_SOURCE=memory +DOCUMENT_DATA_SOURCE=memory + diff --git a/backend-node/data/bookings/bookings.json b/backend-node/data/bookings/bookings.json new file mode 100644 index 00000000..d15aeef6 --- /dev/null +++ b/backend-node/data/bookings/bookings.json @@ -0,0 +1,35 @@ +[ + { + "id": "773568c7-0fc8-403d-ace2-01a11fae7189", + "customerName": "김철수", + "customerPhone": "010-1234-5678", + "pickupLocation": "서울시 강남구 역삼동 123", + "dropoffLocation": "경기도 성남시 분당구 정자동 456", + "scheduledTime": "2025-10-14T10:03:32.556Z", + "vehicleType": "truck", + "cargoType": "전자제품", + "weight": 500, + "status": "accepted", + "priority": "urgent", + "createdAt": "2025-10-14T08:03:32.556Z", + "updatedAt": "2025-10-14T08:06:45.073Z", + "estimatedCost": 150000, + "acceptedAt": "2025-10-14T08:06:45.073Z" + }, + { + "id": "0751b297-18df-42c0-871c-85cded1f6dae", + "customerName": "이영희", + "customerPhone": "010-9876-5432", + "pickupLocation": "서울시 송파구 잠실동 789", + "dropoffLocation": "인천시 남동구 구월동 321", + "scheduledTime": "2025-10-14T12:03:32.556Z", + "vehicleType": "van", + "cargoType": "가구", + "weight": 300, + "status": "pending", + "priority": "normal", + "createdAt": "2025-10-14T07:53:32.556Z", + "updatedAt": "2025-10-14T07:53:32.556Z", + "estimatedCost": 80000 + } +] \ No newline at end of file diff --git a/backend-node/data/todos/todos.json b/backend-node/data/todos/todos.json new file mode 100644 index 00000000..0637a088 --- /dev/null +++ b/backend-node/data/todos/todos.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 6f6d9d2f..c771f9a3 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -52,6 +52,8 @@ import reportRoutes from "./routes/reportRoutes"; import openApiProxyRoutes from "./routes/openApiProxyRoutes"; // 날씨/환율 API import deliveryRoutes from "./routes/deliveryRoutes"; // 배송/화물 관리 import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관리 +import todoRoutes from "./routes/todoRoutes"; // To-Do 관리 +import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -198,6 +200,8 @@ app.use("/api/admin/reports", reportRoutes); app.use("/api/open-api", openApiProxyRoutes); // 날씨/환율 외부 API app.use("/api/delivery", deliveryRoutes); // 배송/화물 관리 app.use("/api/risk-alerts", riskAlertRoutes); // 리스크/알림 관리 +app.use("/api/todos", todoRoutes); // To-Do 관리 +app.use("/api/bookings", bookingRoutes); // 예약 요청 관리 // app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석 // app.use('/api/users', userRoutes); diff --git a/backend-node/src/controllers/bookingController.ts b/backend-node/src/controllers/bookingController.ts new file mode 100644 index 00000000..b4a1a0bd --- /dev/null +++ b/backend-node/src/controllers/bookingController.ts @@ -0,0 +1,80 @@ +import { Request, Response } from "express"; +import { BookingService } from "../services/bookingService"; +import { logger } from "../utils/logger"; + +const bookingService = BookingService.getInstance(); + +/** + * 모든 예약 조회 + */ +export const getBookings = async (req: Request, res: Response): Promise => { + try { + const { status, priority } = req.query; + + const result = await bookingService.getAllBookings({ + status: status as string, + priority: priority as string, + }); + + res.status(200).json({ + success: true, + data: result.bookings, + newCount: result.newCount, + }); + } catch (error) { + logger.error("❌ 예약 목록 조회 실패:", error); + res.status(500).json({ + success: false, + message: "예약 목록 조회에 실패했습니다.", + error: error instanceof Error ? error.message : String(error), + }); + } +}; + +/** + * 예약 수락 + */ +export const acceptBooking = async (req: Request, res: Response): Promise => { + try { + const { id } = req.params; + const booking = await bookingService.acceptBooking(id); + + res.status(200).json({ + success: true, + data: booking, + message: "예약이 수락되었습니다.", + }); + } catch (error) { + logger.error("❌ 예약 수락 실패:", error); + res.status(500).json({ + success: false, + message: "예약 수락에 실패했습니다.", + error: error instanceof Error ? error.message : String(error), + }); + } +}; + +/** + * 예약 거절 + */ +export const rejectBooking = async (req: Request, res: Response): Promise => { + try { + const { id } = req.params; + const { reason } = req.body; + const booking = await bookingService.rejectBooking(id, reason); + + res.status(200).json({ + success: true, + data: booking, + message: "예약이 거절되었습니다.", + }); + } catch (error) { + logger.error("❌ 예약 거절 실패:", error); + res.status(500).json({ + success: false, + message: "예약 거절에 실패했습니다.", + error: error instanceof Error ? error.message : String(error), + }); + } +}; + diff --git a/backend-node/src/controllers/todoController.ts b/backend-node/src/controllers/todoController.ts new file mode 100644 index 00000000..4dc88113 --- /dev/null +++ b/backend-node/src/controllers/todoController.ts @@ -0,0 +1,132 @@ +import { Request, Response } from "express"; +import { TodoService } from "../services/todoService"; +import { logger } from "../utils/logger"; + +const todoService = TodoService.getInstance(); + +/** + * 모든 To-Do 항목 조회 + */ +export const getTodos = async (req: Request, res: Response): Promise => { + try { + const { status, priority, assignedTo } = req.query; + + const result = await todoService.getAllTodos({ + status: status as string, + priority: priority as string, + assignedTo: assignedTo as string, + }); + + res.status(200).json({ + success: true, + data: result.todos, + stats: result.stats, + }); + } catch (error) { + logger.error("❌ To-Do 목록 조회 실패:", error); + res.status(500).json({ + success: false, + message: "To-Do 목록 조회에 실패했습니다.", + error: error instanceof Error ? error.message : String(error), + }); + } +}; + +/** + * To-Do 항목 생성 + */ +export const createTodo = async (req: Request, res: Response): Promise => { + try { + const newTodo = await todoService.createTodo(req.body); + + res.status(201).json({ + success: true, + data: newTodo, + message: "To-Do가 생성되었습니다.", + }); + } catch (error) { + logger.error("❌ To-Do 생성 실패:", error); + res.status(500).json({ + success: false, + message: "To-Do 생성에 실패했습니다.", + error: error instanceof Error ? error.message : String(error), + }); + } +}; + +/** + * To-Do 항목 수정 + */ +export const updateTodo = async (req: Request, res: Response): Promise => { + try { + const { id } = req.params; + const updatedTodo = await todoService.updateTodo(id, req.body); + + res.status(200).json({ + success: true, + data: updatedTodo, + message: "To-Do가 수정되었습니다.", + }); + } catch (error) { + logger.error("❌ To-Do 수정 실패:", error); + res.status(500).json({ + success: false, + message: "To-Do 수정에 실패했습니다.", + error: error instanceof Error ? error.message : String(error), + }); + } +}; + +/** + * To-Do 항목 삭제 + */ +export const deleteTodo = async (req: Request, res: Response): Promise => { + try { + const { id } = req.params; + await todoService.deleteTodo(id); + + res.status(200).json({ + success: true, + message: "To-Do가 삭제되었습니다.", + }); + } catch (error) { + logger.error("❌ To-Do 삭제 실패:", error); + res.status(500).json({ + success: false, + message: "To-Do 삭제에 실패했습니다.", + error: error instanceof Error ? error.message : String(error), + }); + } +}; + +/** + * To-Do 항목 순서 변경 + */ +export const reorderTodos = async (req: Request, res: Response): Promise => { + try { + const { todoIds } = req.body; + + if (!Array.isArray(todoIds)) { + res.status(400).json({ + success: false, + message: "todoIds는 배열이어야 합니다.", + }); + return; + } + + await todoService.reorderTodos(todoIds); + + res.status(200).json({ + success: true, + message: "To-Do 순서가 변경되었습니다.", + }); + } catch (error) { + logger.error("❌ To-Do 순서 변경 실패:", error); + res.status(500).json({ + success: false, + message: "To-Do 순서 변경에 실패했습니다.", + error: error instanceof Error ? error.message : String(error), + }); + } +}; + diff --git a/backend-node/src/routes/bookingRoutes.ts b/backend-node/src/routes/bookingRoutes.ts new file mode 100644 index 00000000..d931ab75 --- /dev/null +++ b/backend-node/src/routes/bookingRoutes.ts @@ -0,0 +1,20 @@ +import { Router } from "express"; +import * as bookingController from "../controllers/bookingController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); + +// 모든 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +// 예약 목록 조회 +router.get("/", bookingController.getBookings); + +// 예약 수락 +router.post("/:id/accept", bookingController.acceptBooking); + +// 예약 거절 +router.post("/:id/reject", bookingController.rejectBooking); + +export default router; + diff --git a/backend-node/src/routes/todoRoutes.ts b/backend-node/src/routes/todoRoutes.ts new file mode 100644 index 00000000..d18c905b --- /dev/null +++ b/backend-node/src/routes/todoRoutes.ts @@ -0,0 +1,26 @@ +import { Router } from "express"; +import * as todoController from "../controllers/todoController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); + +// 모든 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +// To-Do 목록 조회 +router.get("/", todoController.getTodos); + +// To-Do 생성 +router.post("/", todoController.createTodo); + +// To-Do 수정 +router.put("/:id", todoController.updateTodo); + +// To-Do 삭제 +router.delete("/:id", todoController.deleteTodo); + +// To-Do 순서 변경 +router.post("/reorder", todoController.reorderTodos); + +export default router; + diff --git a/backend-node/src/services/bookingService.ts b/backend-node/src/services/bookingService.ts new file mode 100644 index 00000000..79935414 --- /dev/null +++ b/backend-node/src/services/bookingService.ts @@ -0,0 +1,334 @@ +import * as fs from "fs"; +import * as path from "path"; +import { v4 as uuidv4 } from "uuid"; +import { logger } from "../utils/logger"; +import { query } from "../database/db"; + +const BOOKING_DIR = path.join(__dirname, "../../data/bookings"); +const BOOKING_FILE = path.join(BOOKING_DIR, "bookings.json"); + +// 환경 변수로 데이터 소스 선택 +const DATA_SOURCE = process.env.BOOKING_DATA_SOURCE || "file"; + +export interface BookingRequest { + id: string; + customerName: string; + customerPhone: string; + pickupLocation: string; + dropoffLocation: string; + scheduledTime: string; + vehicleType: "truck" | "van" | "car"; + cargoType?: string; + weight?: number; + status: "pending" | "accepted" | "rejected" | "completed"; + priority: "normal" | "urgent"; + createdAt: string; + updatedAt: string; + acceptedAt?: string; + rejectedAt?: string; + completedAt?: string; + notes?: string; + estimatedCost?: number; +} + +/** + * 예약 요청 관리 서비스 (File/DB 하이브리드) + */ +export class BookingService { + private static instance: BookingService; + + private constructor() { + if (DATA_SOURCE === "file") { + this.ensureDataDirectory(); + this.generateMockData(); + } + logger.info(`📋 예약 요청 데이터 소스: ${DATA_SOURCE.toUpperCase()}`); + } + + public static getInstance(): BookingService { + if (!BookingService.instance) { + BookingService.instance = new BookingService(); + } + return BookingService.instance; + } + + private ensureDataDirectory(): void { + if (!fs.existsSync(BOOKING_DIR)) { + fs.mkdirSync(BOOKING_DIR, { recursive: true }); + logger.info(`📁 예약 데이터 디렉토리 생성: ${BOOKING_DIR}`); + } + if (!fs.existsSync(BOOKING_FILE)) { + fs.writeFileSync(BOOKING_FILE, JSON.stringify([], null, 2)); + logger.info(`📄 예약 파일 생성: ${BOOKING_FILE}`); + } + } + + private generateMockData(): void { + const bookings = this.loadBookingsFromFile(); + if (bookings.length > 0) return; + + const mockBookings: BookingRequest[] = [ + { + id: uuidv4(), + customerName: "김철수", + customerPhone: "010-1234-5678", + pickupLocation: "서울시 강남구 역삼동 123", + dropoffLocation: "경기도 성남시 분당구 정자동 456", + scheduledTime: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(), + vehicleType: "truck", + cargoType: "전자제품", + weight: 500, + status: "pending", + priority: "urgent", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + estimatedCost: 150000, + }, + { + id: uuidv4(), + customerName: "이영희", + customerPhone: "010-9876-5432", + pickupLocation: "서울시 송파구 잠실동 789", + dropoffLocation: "인천시 남동구 구월동 321", + scheduledTime: new Date(Date.now() + 4 * 60 * 60 * 1000).toISOString(), + vehicleType: "van", + cargoType: "가구", + weight: 300, + status: "pending", + priority: "normal", + createdAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(), + updatedAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(), + estimatedCost: 80000, + }, + ]; + + this.saveBookingsToFile(mockBookings); + logger.info(`✅ 예약 목 데이터 생성: ${mockBookings.length}개`); + } + + public async getAllBookings(filter?: { + status?: string; + priority?: string; + }): Promise<{ bookings: BookingRequest[]; newCount: number }> { + try { + const bookings = DATA_SOURCE === "database" + ? await this.loadBookingsFromDB(filter) + : this.loadBookingsFromFile(filter); + + bookings.sort((a, b) => { + if (a.priority !== b.priority) return a.priority === "urgent" ? -1 : 1; + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + }); + + const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); + const newCount = bookings.filter( + (b) => b.status === "pending" && new Date(b.createdAt) > fiveMinutesAgo + ).length; + + return { bookings, newCount }; + } catch (error) { + logger.error("❌ 예약 목록 조회 오류:", error); + throw error; + } + } + + public async acceptBooking(id: string): Promise { + try { + if (DATA_SOURCE === "database") { + return await this.acceptBookingDB(id); + } else { + return this.acceptBookingFile(id); + } + } catch (error) { + logger.error("❌ 예약 수락 오류:", error); + throw error; + } + } + + public async rejectBooking(id: string, reason?: string): Promise { + try { + if (DATA_SOURCE === "database") { + return await this.rejectBookingDB(id, reason); + } else { + return this.rejectBookingFile(id, reason); + } + } catch (error) { + logger.error("❌ 예약 거절 오류:", error); + throw error; + } + } + + // ==================== DATABASE 메서드 ==================== + + private async loadBookingsFromDB(filter?: { + status?: string; + priority?: string; + }): Promise { + let sql = ` + SELECT + id, customer_name as "customerName", customer_phone as "customerPhone", + pickup_location as "pickupLocation", dropoff_location as "dropoffLocation", + scheduled_time as "scheduledTime", vehicle_type as "vehicleType", + cargo_type as "cargoType", weight, status, priority, + created_at as "createdAt", updated_at as "updatedAt", + accepted_at as "acceptedAt", rejected_at as "rejectedAt", + completed_at as "completedAt", notes, estimated_cost as "estimatedCost" + FROM booking_requests + WHERE 1=1 + `; + const params: any[] = []; + let paramIndex = 1; + + if (filter?.status) { + sql += ` AND status = $${paramIndex++}`; + params.push(filter.status); + } + if (filter?.priority) { + sql += ` AND priority = $${paramIndex++}`; + params.push(filter.priority); + } + + const rows = await query(sql, params); + return rows.map((row: any) => ({ + ...row, + scheduledTime: new Date(row.scheduledTime).toISOString(), + createdAt: new Date(row.createdAt).toISOString(), + updatedAt: new Date(row.updatedAt).toISOString(), + acceptedAt: row.acceptedAt ? new Date(row.acceptedAt).toISOString() : undefined, + rejectedAt: row.rejectedAt ? new Date(row.rejectedAt).toISOString() : undefined, + completedAt: row.completedAt ? new Date(row.completedAt).toISOString() : undefined, + })); + } + + private async acceptBookingDB(id: string): Promise { + const rows = await query( + `UPDATE booking_requests + SET status = 'accepted', accepted_at = NOW(), updated_at = NOW() + WHERE id = $1 + RETURNING + id, customer_name as "customerName", customer_phone as "customerPhone", + pickup_location as "pickupLocation", dropoff_location as "dropoffLocation", + scheduled_time as "scheduledTime", vehicle_type as "vehicleType", + cargo_type as "cargoType", weight, status, priority, + created_at as "createdAt", updated_at as "updatedAt", + accepted_at as "acceptedAt", notes, estimated_cost as "estimatedCost"`, + [id] + ); + + if (rows.length === 0) { + throw new Error(`예약을 찾을 수 없습니다: ${id}`); + } + + const row = rows[0]; + logger.info(`✅ 예약 수락: ${id} - ${row.customerName}`); + return { + ...row, + scheduledTime: new Date(row.scheduledTime).toISOString(), + createdAt: new Date(row.createdAt).toISOString(), + updatedAt: new Date(row.updatedAt).toISOString(), + acceptedAt: new Date(row.acceptedAt).toISOString(), + }; + } + + private async rejectBookingDB(id: string, reason?: string): Promise { + const rows = await query( + `UPDATE booking_requests + SET status = 'rejected', rejected_at = NOW(), updated_at = NOW(), rejection_reason = $2 + WHERE id = $1 + RETURNING + id, customer_name as "customerName", customer_phone as "customerPhone", + pickup_location as "pickupLocation", dropoff_location as "dropoffLocation", + scheduled_time as "scheduledTime", vehicle_type as "vehicleType", + cargo_type as "cargoType", weight, status, priority, + created_at as "createdAt", updated_at as "updatedAt", + rejected_at as "rejectedAt", notes, estimated_cost as "estimatedCost"`, + [id, reason] + ); + + if (rows.length === 0) { + throw new Error(`예약을 찾을 수 없습니다: ${id}`); + } + + const row = rows[0]; + logger.info(`✅ 예약 거절: ${id} - ${row.customerName}`); + return { + ...row, + scheduledTime: new Date(row.scheduledTime).toISOString(), + createdAt: new Date(row.createdAt).toISOString(), + updatedAt: new Date(row.updatedAt).toISOString(), + rejectedAt: new Date(row.rejectedAt).toISOString(), + }; + } + + // ==================== FILE 메서드 ==================== + + private loadBookingsFromFile(filter?: { + status?: string; + priority?: string; + }): BookingRequest[] { + try { + const data = fs.readFileSync(BOOKING_FILE, "utf-8"); + let bookings: BookingRequest[] = JSON.parse(data); + + if (filter?.status) { + bookings = bookings.filter((b) => b.status === filter.status); + } + if (filter?.priority) { + bookings = bookings.filter((b) => b.priority === filter.priority); + } + + return bookings; + } catch (error) { + logger.error("❌ 예약 파일 로드 오류:", error); + return []; + } + } + + private saveBookingsToFile(bookings: BookingRequest[]): void { + try { + fs.writeFileSync(BOOKING_FILE, JSON.stringify(bookings, null, 2)); + } catch (error) { + logger.error("❌ 예약 파일 저장 오류:", error); + throw error; + } + } + + private acceptBookingFile(id: string): BookingRequest { + const bookings = this.loadBookingsFromFile(); + const booking = bookings.find((b) => b.id === id); + + if (!booking) { + throw new Error(`예약을 찾을 수 없습니다: ${id}`); + } + + booking.status = "accepted"; + booking.acceptedAt = new Date().toISOString(); + booking.updatedAt = new Date().toISOString(); + + this.saveBookingsToFile(bookings); + logger.info(`✅ 예약 수락: ${id} - ${booking.customerName}`); + + return booking; + } + + private rejectBookingFile(id: string, reason?: string): BookingRequest { + const bookings = this.loadBookingsFromFile(); + const booking = bookings.find((b) => b.id === id); + + if (!booking) { + throw new Error(`예약을 찾을 수 없습니다: ${id}`); + } + + booking.status = "rejected"; + booking.rejectedAt = new Date().toISOString(); + booking.updatedAt = new Date().toISOString(); + if (reason) { + booking.notes = reason; + } + + this.saveBookingsToFile(bookings); + logger.info(`✅ 예약 거절: ${id} - ${booking.customerName}`); + + return booking; + } +} diff --git a/backend-node/src/services/documentService.ts b/backend-node/src/services/documentService.ts new file mode 100644 index 00000000..4c75ae22 --- /dev/null +++ b/backend-node/src/services/documentService.ts @@ -0,0 +1,282 @@ +import { logger } from "../utils/logger"; +import { query } from "../database/db"; + +// 환경 변수로 데이터 소스 선택 +const DATA_SOURCE = process.env.DOCUMENT_DATA_SOURCE || "memory"; + +export interface Document { + id: string; + name: string; + category: "계약서" | "보험" | "세금계산서" | "기타"; + fileSize: number; + filePath: string; + mimeType?: string; + uploadDate: string; + description?: string; + uploadedBy?: string; + relatedEntityType?: string; + relatedEntityId?: string; + tags?: string[]; + isArchived: boolean; + archivedAt?: string; +} + +// 메모리 목 데이터 +const mockDocuments: Document[] = [ + { + id: "doc-1", + name: "2025년 1월 세금계산서.pdf", + category: "세금계산서", + fileSize: 1258291, + filePath: "/uploads/documents/tax-invoice-202501.pdf", + uploadDate: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(), + description: "1월 매출 세금계산서", + uploadedBy: "admin", + isArchived: false, + }, + { + id: "doc-2", + name: "차량보험증권_서울12가3456.pdf", + category: "보험", + fileSize: 876544, + filePath: "/uploads/documents/insurance-vehicle-1.pdf", + uploadDate: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(), + description: "1톤 트럭 종합보험", + uploadedBy: "admin", + isArchived: false, + }, + { + id: "doc-3", + name: "운송계약서_ABC물류.pdf", + category: "계약서", + fileSize: 2457600, + filePath: "/uploads/documents/contract-abc-logistics.pdf", + uploadDate: new Date(Date.now() - 15 * 24 * 60 * 60 * 1000).toISOString(), + description: "ABC물류 연간 운송 계약", + uploadedBy: "admin", + isArchived: false, + }, + { + id: "doc-4", + name: "2024년 12월 세금계산서.pdf", + category: "세금계산서", + fileSize: 1124353, + filePath: "/uploads/documents/tax-invoice-202412.pdf", + uploadDate: new Date(Date.now() - 40 * 24 * 60 * 60 * 1000).toISOString(), + uploadedBy: "admin", + isArchived: false, + }, + { + id: "doc-5", + name: "화물배상책임보험증권.pdf", + category: "보험", + fileSize: 720384, + filePath: "/uploads/documents/cargo-insurance.pdf", + uploadDate: new Date(Date.now() - 50 * 24 * 60 * 60 * 1000).toISOString(), + description: "화물 배상책임보험", + uploadedBy: "admin", + isArchived: false, + }, + { + id: "doc-6", + name: "차고지 임대계약서.pdf", + category: "계약서", + fileSize: 1843200, + filePath: "/uploads/documents/garage-lease-contract.pdf", + uploadDate: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString(), + uploadedBy: "admin", + isArchived: false, + }, +]; + +/** + * 문서 관리 서비스 (Memory/DB 하이브리드) + */ +export class DocumentService { + private static instance: DocumentService; + + private constructor() { + logger.info(`📂 문서 관리 데이터 소스: ${DATA_SOURCE.toUpperCase()}`); + } + + public static getInstance(): DocumentService { + if (!DocumentService.instance) { + DocumentService.instance = new DocumentService(); + } + return DocumentService.instance; + } + + public async getAllDocuments(filter?: { + category?: string; + searchTerm?: string; + uploadedBy?: string; + }): Promise { + try { + const documents = DATA_SOURCE === "database" + ? await this.loadDocumentsFromDB(filter) + : this.loadDocumentsFromMemory(filter); + + // 최신순 정렬 + documents.sort((a, b) => + new Date(b.uploadDate).getTime() - new Date(a.uploadDate).getTime() + ); + + return documents; + } catch (error) { + logger.error("❌ 문서 목록 조회 오류:", error); + throw error; + } + } + + public async getDocumentById(id: string): Promise { + try { + if (DATA_SOURCE === "database") { + return await this.getDocumentByIdDB(id); + } else { + return this.getDocumentByIdMemory(id); + } + } catch (error) { + logger.error("❌ 문서 조회 오류:", error); + throw error; + } + } + + public async getStatistics(): Promise<{ + total: number; + byCategory: Record; + totalSize: number; + }> { + try { + const documents = await this.getAllDocuments(); + + const byCategory: Record = { + "계약서": 0, + "보험": 0, + "세금계산서": 0, + "기타": 0, + }; + + documents.forEach((doc) => { + byCategory[doc.category] = (byCategory[doc.category] || 0) + 1; + }); + + const totalSize = documents.reduce((sum, doc) => sum + doc.fileSize, 0); + + return { + total: documents.length, + byCategory, + totalSize, + }; + } catch (error) { + logger.error("❌ 문서 통계 조회 오류:", error); + throw error; + } + } + + // ==================== DATABASE 메서드 ==================== + + private async loadDocumentsFromDB(filter?: { + category?: string; + searchTerm?: string; + uploadedBy?: string; + }): Promise { + let sql = ` + SELECT + id, name, category, file_size as "fileSize", file_path as "filePath", + mime_type as "mimeType", upload_date as "uploadDate", + description, uploaded_by as "uploadedBy", + related_entity_type as "relatedEntityType", + related_entity_id as "relatedEntityId", + tags, is_archived as "isArchived", archived_at as "archivedAt" + FROM document_files + WHERE is_archived = false + `; + const params: any[] = []; + let paramIndex = 1; + + if (filter?.category) { + sql += ` AND category = $${paramIndex++}`; + params.push(filter.category); + } + if (filter?.searchTerm) { + sql += ` AND (name ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`; + params.push(`%${filter.searchTerm}%`); + paramIndex++; + } + if (filter?.uploadedBy) { + sql += ` AND uploaded_by = $${paramIndex++}`; + params.push(filter.uploadedBy); + } + + const rows = await query(sql, params); + return rows.map((row: any) => ({ + ...row, + uploadDate: new Date(row.uploadDate).toISOString(), + archivedAt: row.archivedAt ? new Date(row.archivedAt).toISOString() : undefined, + })); + } + + private async getDocumentByIdDB(id: string): Promise { + const rows = await query( + `SELECT + id, name, category, file_size as "fileSize", file_path as "filePath", + mime_type as "mimeType", upload_date as "uploadDate", + description, uploaded_by as "uploadedBy", + related_entity_type as "relatedEntityType", + related_entity_id as "relatedEntityId", + tags, is_archived as "isArchived", archived_at as "archivedAt" + FROM document_files + WHERE id = $1`, + [id] + ); + + if (rows.length === 0) { + throw new Error(`문서를 찾을 수 없습니다: ${id}`); + } + + const row = rows[0]; + return { + ...row, + uploadDate: new Date(row.uploadDate).toISOString(), + archivedAt: row.archivedAt ? new Date(row.archivedAt).toISOString() : undefined, + }; + } + + // ==================== MEMORY 메서드 ==================== + + private loadDocumentsFromMemory(filter?: { + category?: string; + searchTerm?: string; + uploadedBy?: string; + }): Document[] { + let documents = mockDocuments.filter((d) => !d.isArchived); + + if (filter?.category) { + documents = documents.filter((d) => d.category === filter.category); + } + if (filter?.searchTerm) { + const term = filter.searchTerm.toLowerCase(); + documents = documents.filter( + (d) => + d.name.toLowerCase().includes(term) || + d.description?.toLowerCase().includes(term) + ); + } + if (filter?.uploadedBy) { + documents = documents.filter((d) => d.uploadedBy === filter.uploadedBy); + } + + return documents; + } + + private getDocumentByIdMemory(id: string): Document { + const document = mockDocuments.find((d) => d.id === id); + + if (!document) { + throw new Error(`문서를 찾을 수 없습니다: ${id}`); + } + + return document; + } +} + diff --git a/backend-node/src/services/maintenanceService.ts b/backend-node/src/services/maintenanceService.ts new file mode 100644 index 00000000..53f568e9 --- /dev/null +++ b/backend-node/src/services/maintenanceService.ts @@ -0,0 +1,267 @@ +import { logger } from "../utils/logger"; +import { query } from "../database/db"; + +// 환경 변수로 데이터 소스 선택 +const DATA_SOURCE = process.env.MAINTENANCE_DATA_SOURCE || "memory"; + +export interface MaintenanceSchedule { + id: string; + vehicleNumber: string; + vehicleType: string; + maintenanceType: "정기점검" | "수리" | "타이어교체" | "오일교환" | "기타"; + scheduledDate: string; + status: "scheduled" | "in_progress" | "completed" | "overdue"; + notes?: string; + estimatedCost?: number; + actualCost?: number; + createdAt: string; + updatedAt: string; + startedAt?: string; + completedAt?: string; + mechanicName?: string; + location?: string; +} + +// 메모리 목 데이터 +const mockSchedules: MaintenanceSchedule[] = [ + { + id: "maint-1", + vehicleNumber: "서울12가3456", + vehicleType: "1톤 트럭", + maintenanceType: "정기점검", + scheduledDate: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString(), + status: "scheduled", + notes: "6개월 정기점검", + estimatedCost: 300000, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + location: "본사 정비소", + }, + { + id: "maint-2", + vehicleNumber: "경기34나5678", + vehicleType: "2.5톤 트럭", + maintenanceType: "오일교환", + scheduledDate: new Date(Date.now() + 1 * 24 * 60 * 60 * 1000).toISOString(), + status: "scheduled", + estimatedCost: 150000, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + location: "본사 정비소", + }, + { + id: "maint-3", + vehicleNumber: "인천56다7890", + vehicleType: "라보", + maintenanceType: "타이어교체", + scheduledDate: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), + status: "overdue", + notes: "긴급", + estimatedCost: 400000, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + location: "외부 정비소", + }, + { + id: "maint-4", + vehicleNumber: "부산78라1234", + vehicleType: "1톤 트럭", + maintenanceType: "수리", + scheduledDate: new Date().toISOString(), + status: "in_progress", + notes: "엔진 점검 중", + estimatedCost: 800000, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + startedAt: new Date().toISOString(), + location: "본사 정비소", + }, +]; + +/** + * 정비 일정 관리 서비스 (Memory/DB 하이브리드) + */ +export class MaintenanceService { + private static instance: MaintenanceService; + + private constructor() { + logger.info(`🔧 정비 일정 데이터 소스: ${DATA_SOURCE.toUpperCase()}`); + } + + public static getInstance(): MaintenanceService { + if (!MaintenanceService.instance) { + MaintenanceService.instance = new MaintenanceService(); + } + return MaintenanceService.instance; + } + + public async getAllSchedules(filter?: { + status?: string; + vehicleNumber?: string; + }): Promise { + try { + const schedules = DATA_SOURCE === "database" + ? await this.loadSchedulesFromDB(filter) + : this.loadSchedulesFromMemory(filter); + + // 자동으로 overdue 상태 업데이트 + const now = new Date(); + schedules.forEach((s) => { + if (s.status === "scheduled" && new Date(s.scheduledDate) < now) { + s.status = "overdue"; + } + }); + + // 정렬: 지연 > 진행중 > 예정 > 완료 + schedules.sort((a, b) => { + const statusOrder = { overdue: 0, in_progress: 1, scheduled: 2, completed: 3 }; + if (a.status !== b.status) { + return statusOrder[a.status] - statusOrder[b.status]; + } + return new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime(); + }); + + return schedules; + } catch (error) { + logger.error("❌ 정비 일정 조회 오류:", error); + throw error; + } + } + + public async updateScheduleStatus( + id: string, + status: MaintenanceSchedule["status"] + ): Promise { + try { + if (DATA_SOURCE === "database") { + return await this.updateScheduleStatusDB(id, status); + } else { + return this.updateScheduleStatusMemory(id, status); + } + } catch (error) { + logger.error("❌ 정비 상태 업데이트 오류:", error); + throw error; + } + } + + // ==================== DATABASE 메서드 ==================== + + private async loadSchedulesFromDB(filter?: { + status?: string; + vehicleNumber?: string; + }): Promise { + let sql = ` + SELECT + id, vehicle_number as "vehicleNumber", vehicle_type as "vehicleType", + maintenance_type as "maintenanceType", scheduled_date as "scheduledDate", + status, notes, estimated_cost as "estimatedCost", actual_cost as "actualCost", + created_at as "createdAt", updated_at as "updatedAt", + started_at as "startedAt", completed_at as "completedAt", + mechanic_name as "mechanicName", location + FROM maintenance_schedules + WHERE 1=1 + `; + const params: any[] = []; + let paramIndex = 1; + + if (filter?.status) { + sql += ` AND status = $${paramIndex++}`; + params.push(filter.status); + } + if (filter?.vehicleNumber) { + sql += ` AND vehicle_number = $${paramIndex++}`; + params.push(filter.vehicleNumber); + } + + const rows = await query(sql, params); + return rows.map((row: any) => ({ + ...row, + scheduledDate: new Date(row.scheduledDate).toISOString(), + createdAt: new Date(row.createdAt).toISOString(), + updatedAt: new Date(row.updatedAt).toISOString(), + startedAt: row.startedAt ? new Date(row.startedAt).toISOString() : undefined, + completedAt: row.completedAt ? new Date(row.completedAt).toISOString() : undefined, + })); + } + + private async updateScheduleStatusDB( + id: string, + status: MaintenanceSchedule["status"] + ): Promise { + let additionalSet = ""; + if (status === "in_progress") { + additionalSet = ", started_at = NOW()"; + } else if (status === "completed") { + additionalSet = ", completed_at = NOW()"; + } + + const rows = await query( + `UPDATE maintenance_schedules + SET status = $1, updated_at = NOW() ${additionalSet} + WHERE id = $2 + RETURNING + id, vehicle_number as "vehicleNumber", vehicle_type as "vehicleType", + maintenance_type as "maintenanceType", scheduled_date as "scheduledDate", + status, notes, estimated_cost as "estimatedCost", + created_at as "createdAt", updated_at as "updatedAt", + started_at as "startedAt", completed_at as "completedAt", + mechanic_name as "mechanicName", location`, + [status, id] + ); + + if (rows.length === 0) { + throw new Error(`정비 일정을 찾을 수 없습니다: ${id}`); + } + + const row = rows[0]; + return { + ...row, + scheduledDate: new Date(row.scheduledDate).toISOString(), + createdAt: new Date(row.createdAt).toISOString(), + updatedAt: new Date(row.updatedAt).toISOString(), + startedAt: row.startedAt ? new Date(row.startedAt).toISOString() : undefined, + completedAt: row.completedAt ? new Date(row.completedAt).toISOString() : undefined, + }; + } + + // ==================== MEMORY 메서드 ==================== + + private loadSchedulesFromMemory(filter?: { + status?: string; + vehicleNumber?: string; + }): MaintenanceSchedule[] { + let schedules = [...mockSchedules]; + + if (filter?.status) { + schedules = schedules.filter((s) => s.status === filter.status); + } + if (filter?.vehicleNumber) { + schedules = schedules.filter((s) => s.vehicleNumber === filter.vehicleNumber); + } + + return schedules; + } + + private updateScheduleStatusMemory( + id: string, + status: MaintenanceSchedule["status"] + ): MaintenanceSchedule { + const schedule = mockSchedules.find((s) => s.id === id); + + if (!schedule) { + throw new Error(`정비 일정을 찾을 수 없습니다: ${id}`); + } + + schedule.status = status; + schedule.updatedAt = new Date().toISOString(); + + if (status === "in_progress") { + schedule.startedAt = new Date().toISOString(); + } else if (status === "completed") { + schedule.completedAt = new Date().toISOString(); + } + + return schedule; + } +} + diff --git a/backend-node/src/services/todoService.ts b/backend-node/src/services/todoService.ts new file mode 100644 index 00000000..1347c665 --- /dev/null +++ b/backend-node/src/services/todoService.ts @@ -0,0 +1,449 @@ +import * as fs from "fs"; +import * as path from "path"; +import { v4 as uuidv4 } from "uuid"; +import { logger } from "../utils/logger"; +import { query } from "../database/db"; + +const TODO_DIR = path.join(__dirname, "../../data/todos"); +const TODO_FILE = path.join(TODO_DIR, "todos.json"); + +// 환경 변수로 데이터 소스 선택 (file | database) +const DATA_SOURCE = process.env.TODO_DATA_SOURCE || "file"; + +export interface TodoItem { + id: string; + title: string; + description?: string; + priority: "urgent" | "high" | "normal" | "low"; + status: "pending" | "in_progress" | "completed"; + assignedTo?: string; + dueDate?: string; + createdAt: string; + updatedAt: string; + completedAt?: string; + isUrgent: boolean; + order: number; +} + +export interface TodoListResponse { + todos: TodoItem[]; + stats: { + total: number; + pending: number; + inProgress: number; + completed: number; + urgent: number; + overdue: number; + }; +} + +/** + * To-Do 리스트 관리 서비스 (File/DB 하이브리드) + */ +export class TodoService { + private static instance: TodoService; + + private constructor() { + if (DATA_SOURCE === "file") { + this.ensureDataDirectory(); + } + logger.info(`📋 To-Do 데이터 소스: ${DATA_SOURCE.toUpperCase()}`); + } + + public static getInstance(): TodoService { + if (!TodoService.instance) { + TodoService.instance = new TodoService(); + } + return TodoService.instance; + } + + /** + * 데이터 디렉토리 생성 (파일 모드) + */ + private ensureDataDirectory(): void { + if (!fs.existsSync(TODO_DIR)) { + fs.mkdirSync(TODO_DIR, { recursive: true }); + logger.info(`📁 To-Do 데이터 디렉토리 생성: ${TODO_DIR}`); + } + if (!fs.existsSync(TODO_FILE)) { + fs.writeFileSync(TODO_FILE, JSON.stringify([], null, 2)); + logger.info(`📄 To-Do 파일 생성: ${TODO_FILE}`); + } + } + + /** + * 모든 To-Do 항목 조회 + */ + public async getAllTodos(filter?: { + status?: string; + priority?: string; + assignedTo?: string; + }): Promise { + try { + const todos = DATA_SOURCE === "database" + ? await this.loadTodosFromDB(filter) + : this.loadTodosFromFile(filter); + + // 정렬: 긴급 > 우선순위 > 순서 + todos.sort((a, b) => { + if (a.isUrgent !== b.isUrgent) return a.isUrgent ? -1 : 1; + const priorityOrder = { urgent: 0, high: 1, normal: 2, low: 3 }; + if (a.priority !== b.priority) return priorityOrder[a.priority] - priorityOrder[b.priority]; + return a.order - b.order; + }); + + const stats = this.calculateStats(todos); + + return { todos, stats }; + } catch (error) { + logger.error("❌ To-Do 목록 조회 오류:", error); + throw error; + } + } + + /** + * To-Do 항목 생성 + */ + public async createTodo(todoData: Partial): Promise { + try { + const newTodo: TodoItem = { + id: uuidv4(), + title: todoData.title || "", + description: todoData.description, + priority: todoData.priority || "normal", + status: "pending", + assignedTo: todoData.assignedTo, + dueDate: todoData.dueDate, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + isUrgent: todoData.isUrgent || false, + order: 0, // DB에서 자동 계산 + }; + + if (DATA_SOURCE === "database") { + await this.createTodoDB(newTodo); + } else { + const todos = this.loadTodosFromFile(); + newTodo.order = todos.length > 0 ? Math.max(...todos.map((t) => t.order)) + 1 : 0; + todos.push(newTodo); + this.saveTodosToFile(todos); + } + + logger.info(`✅ To-Do 생성: ${newTodo.id} - ${newTodo.title}`); + return newTodo; + } catch (error) { + logger.error("❌ To-Do 생성 오류:", error); + throw error; + } + } + + /** + * To-Do 항목 수정 + */ + public async updateTodo(id: string, updates: Partial): Promise { + try { + if (DATA_SOURCE === "database") { + return await this.updateTodoDB(id, updates); + } else { + return this.updateTodoFile(id, updates); + } + } catch (error) { + logger.error("❌ To-Do 수정 오류:", error); + throw error; + } + } + + /** + * To-Do 항목 삭제 + */ + public async deleteTodo(id: string): Promise { + try { + if (DATA_SOURCE === "database") { + await this.deleteTodoDB(id); + } else { + this.deleteTodoFile(id); + } + logger.info(`✅ To-Do 삭제: ${id}`); + } catch (error) { + logger.error("❌ To-Do 삭제 오류:", error); + throw error; + } + } + + /** + * To-Do 항목 순서 변경 + */ + public async reorderTodos(todoIds: string[]): Promise { + try { + if (DATA_SOURCE === "database") { + await this.reorderTodosDB(todoIds); + } else { + this.reorderTodosFile(todoIds); + } + logger.info(`✅ To-Do 순서 변경: ${todoIds.length}개 항목`); + } catch (error) { + logger.error("❌ To-Do 순서 변경 오류:", error); + throw error; + } + } + + // ==================== DATABASE 메서드 ==================== + + private async loadTodosFromDB(filter?: { + status?: string; + priority?: string; + assignedTo?: string; + }): Promise { + let sql = ` + SELECT + id, title, description, priority, status, + assigned_to as "assignedTo", + due_date as "dueDate", + created_at as "createdAt", + updated_at as "updatedAt", + completed_at as "completedAt", + is_urgent as "isUrgent", + display_order as "order" + FROM todo_items + WHERE 1=1 + `; + const params: any[] = []; + let paramIndex = 1; + + if (filter?.status) { + sql += ` AND status = $${paramIndex++}`; + params.push(filter.status); + } + if (filter?.priority) { + sql += ` AND priority = $${paramIndex++}`; + params.push(filter.priority); + } + if (filter?.assignedTo) { + sql += ` AND assigned_to = $${paramIndex++}`; + params.push(filter.assignedTo); + } + + sql += ` ORDER BY display_order ASC`; + + const rows = await query(sql, params); + return rows.map((row: any) => ({ + ...row, + dueDate: row.dueDate ? new Date(row.dueDate).toISOString() : undefined, + createdAt: new Date(row.createdAt).toISOString(), + updatedAt: new Date(row.updatedAt).toISOString(), + completedAt: row.completedAt ? new Date(row.completedAt).toISOString() : undefined, + })); + } + + private async createTodoDB(todo: TodoItem): Promise { + // 현재 최대 order 값 조회 + const maxOrderRows = await query( + "SELECT COALESCE(MAX(display_order), -1) + 1 as next_order FROM todo_items" + ); + const nextOrder = maxOrderRows[0].next_order; + + await query( + `INSERT INTO todo_items ( + id, title, description, priority, status, assigned_to, due_date, + created_at, updated_at, is_urgent, display_order + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, + [ + todo.id, + todo.title, + todo.description, + todo.priority, + todo.status, + todo.assignedTo, + todo.dueDate ? new Date(todo.dueDate) : null, + new Date(todo.createdAt), + new Date(todo.updatedAt), + todo.isUrgent, + nextOrder, + ] + ); + } + + private async updateTodoDB(id: string, updates: Partial): Promise { + const setClauses: string[] = ["updated_at = NOW()"]; + const params: any[] = []; + let paramIndex = 1; + + if (updates.title !== undefined) { + setClauses.push(`title = $${paramIndex++}`); + params.push(updates.title); + } + if (updates.description !== undefined) { + setClauses.push(`description = $${paramIndex++}`); + params.push(updates.description); + } + if (updates.priority !== undefined) { + setClauses.push(`priority = $${paramIndex++}`); + params.push(updates.priority); + } + if (updates.status !== undefined) { + setClauses.push(`status = $${paramIndex++}`); + params.push(updates.status); + if (updates.status === "completed") { + setClauses.push(`completed_at = NOW()`); + } + } + if (updates.assignedTo !== undefined) { + setClauses.push(`assigned_to = $${paramIndex++}`); + params.push(updates.assignedTo); + } + if (updates.dueDate !== undefined) { + setClauses.push(`due_date = $${paramIndex++}`); + params.push(updates.dueDate ? new Date(updates.dueDate) : null); + } + if (updates.isUrgent !== undefined) { + setClauses.push(`is_urgent = $${paramIndex++}`); + params.push(updates.isUrgent); + } + + params.push(id); + const sql = ` + UPDATE todo_items + SET ${setClauses.join(", ")} + WHERE id = $${paramIndex} + RETURNING + id, title, description, priority, status, + assigned_to as "assignedTo", + due_date as "dueDate", + created_at as "createdAt", + updated_at as "updatedAt", + completed_at as "completedAt", + is_urgent as "isUrgent", + display_order as "order" + `; + + const rows = await query(sql, params); + if (rows.length === 0) { + throw new Error(`To-Do 항목을 찾을 수 없습니다: ${id}`); + } + + const row = rows[0]; + return { + ...row, + dueDate: row.dueDate ? new Date(row.dueDate).toISOString() : undefined, + createdAt: new Date(row.createdAt).toISOString(), + updatedAt: new Date(row.updatedAt).toISOString(), + completedAt: row.completedAt ? new Date(row.completedAt).toISOString() : undefined, + }; + } + + private async deleteTodoDB(id: string): Promise { + const rows = await query("DELETE FROM todo_items WHERE id = $1 RETURNING id", [id]); + if (rows.length === 0) { + throw new Error(`To-Do 항목을 찾을 수 없습니다: ${id}`); + } + } + + private async reorderTodosDB(todoIds: string[]): Promise { + for (let i = 0; i < todoIds.length; i++) { + await query( + "UPDATE todo_items SET display_order = $1, updated_at = NOW() WHERE id = $2", + [i, todoIds[i]] + ); + } + } + + // ==================== FILE 메서드 ==================== + + private loadTodosFromFile(filter?: { + status?: string; + priority?: string; + assignedTo?: string; + }): TodoItem[] { + try { + const data = fs.readFileSync(TODO_FILE, "utf-8"); + let todos: TodoItem[] = JSON.parse(data); + + if (filter?.status) { + todos = todos.filter((t) => t.status === filter.status); + } + if (filter?.priority) { + todos = todos.filter((t) => t.priority === filter.priority); + } + if (filter?.assignedTo) { + todos = todos.filter((t) => t.assignedTo === filter.assignedTo); + } + + return todos; + } catch (error) { + logger.error("❌ To-Do 파일 로드 오류:", error); + return []; + } + } + + private saveTodosToFile(todos: TodoItem[]): void { + try { + fs.writeFileSync(TODO_FILE, JSON.stringify(todos, null, 2)); + } catch (error) { + logger.error("❌ To-Do 파일 저장 오류:", error); + throw error; + } + } + + private updateTodoFile(id: string, updates: Partial): TodoItem { + const todos = this.loadTodosFromFile(); + const index = todos.findIndex((t) => t.id === id); + + if (index === -1) { + throw new Error(`To-Do 항목을 찾을 수 없습니다: ${id}`); + } + + const updatedTodo: TodoItem = { + ...todos[index], + ...updates, + updatedAt: new Date().toISOString(), + }; + + if (updates.status === "completed" && todos[index].status !== "completed") { + updatedTodo.completedAt = new Date().toISOString(); + } + + todos[index] = updatedTodo; + this.saveTodosToFile(todos); + + return updatedTodo; + } + + private deleteTodoFile(id: string): void { + const todos = this.loadTodosFromFile(); + const filteredTodos = todos.filter((t) => t.id !== id); + + if (todos.length === filteredTodos.length) { + throw new Error(`To-Do 항목을 찾을 수 없습니다: ${id}`); + } + + this.saveTodosToFile(filteredTodos); + } + + private reorderTodosFile(todoIds: string[]): void { + const todos = this.loadTodosFromFile(); + + todoIds.forEach((id, index) => { + const todo = todos.find((t) => t.id === id); + if (todo) { + todo.order = index; + todo.updatedAt = new Date().toISOString(); + } + }); + + this.saveTodosToFile(todos); + } + + // ==================== 공통 메서드 ==================== + + private calculateStats(todos: TodoItem[]): TodoListResponse["stats"] { + const now = new Date(); + return { + total: todos.length, + pending: todos.filter((t) => t.status === "pending").length, + inProgress: todos.filter((t) => t.status === "in_progress").length, + completed: todos.filter((t) => t.status === "completed").length, + urgent: todos.filter((t) => t.isUrgent).length, + overdue: todos.filter((t) => t.dueDate && new Date(t.dueDate) < now && t.status !== "completed").length, + }; + } +} diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index f646061c..6c1e5a66 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -37,6 +37,26 @@ const RiskAlertWidget = dynamic(() => import("@/components/dashboard/widgets/Ris loading: () =>
로딩 중...
, }); +const TodoWidget = dynamic(() => import("@/components/dashboard/widgets/TodoWidget"), { + ssr: false, + loading: () =>
로딩 중...
, +}); + +const BookingAlertWidget = dynamic(() => import("@/components/dashboard/widgets/BookingAlertWidget"), { + ssr: false, + loading: () =>
로딩 중...
, +}); + +const MaintenanceWidget = dynamic(() => import("@/components/dashboard/widgets/MaintenanceWidget"), { + ssr: false, + loading: () =>
로딩 중...
, +}); + +const DocumentWidget = dynamic(() => import("@/components/dashboard/widgets/DocumentWidget"), { + ssr: false, + loading: () =>
로딩 중...
, +}); + // 시계 위젯 임포트 import { ClockWidget } from "./widgets/ClockWidget"; // 달력 위젯 임포트 @@ -463,6 +483,26 @@ export function CanvasElement({ }} />
+ ) : element.type === "widget" && element.subtype === "todo" ? ( + // To-Do 위젯 렌더링 +
+ +
+ ) : element.type === "widget" && element.subtype === "booking-alert" ? ( + // 예약 요청 알림 위젯 렌더링 +
+ +
+ ) : element.type === "widget" && element.subtype === "maintenance" ? ( + // 정비 일정 위젯 렌더링 +
+ +
+ ) : element.type === "widget" && element.subtype === "document" ? ( + // 문서 다운로드 위젯 렌더링 +
+ +
) : ( // 기타 위젯 렌더링
+ + {/* 운영/작업 지원 섹션 */} +
+

📋 운영/작업 지원

+ +
+ + + + +
+
); } diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts index eb936652..16b078ed 100644 --- a/frontend/components/admin/dashboard/types.ts +++ b/frontend/components/admin/dashboard/types.ts @@ -20,7 +20,11 @@ export type ElementSubtype = | "vehicle-map" | "delivery-status" | "risk-alert" - | "driver-management"; // 위젯 타입 + | "driver-management" + | "todo" + | "booking-alert" + | "maintenance" + | "document"; // 위젯 타입 export interface Position { x: number; diff --git a/frontend/components/dashboard/widgets/BookingAlertWidget.tsx b/frontend/components/dashboard/widgets/BookingAlertWidget.tsx new file mode 100644 index 00000000..4c600079 --- /dev/null +++ b/frontend/components/dashboard/widgets/BookingAlertWidget.tsx @@ -0,0 +1,308 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Check, X, Phone, MapPin, Package, Clock, AlertCircle } from "lucide-react"; + +interface BookingRequest { + id: string; + customerName: string; + customerPhone: string; + pickupLocation: string; + dropoffLocation: string; + scheduledTime: string; + vehicleType: "truck" | "van" | "car"; + cargoType?: string; + weight?: number; + status: "pending" | "accepted" | "rejected" | "completed"; + priority: "normal" | "urgent"; + createdAt: string; + estimatedCost?: number; +} + +export default function BookingAlertWidget() { + const [bookings, setBookings] = useState([]); + const [newCount, setNewCount] = useState(0); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState<"all" | "pending" | "accepted">("pending"); + const [showNotification, setShowNotification] = useState(false); + + useEffect(() => { + fetchBookings(); + const interval = setInterval(fetchBookings, 10000); // 10초마다 갱신 + return () => clearInterval(interval); + }, [filter]); + + const fetchBookings = async () => { + try { + const token = localStorage.getItem("authToken"); + const filterParam = filter !== "all" ? `?status=${filter}` : ""; + const response = await fetch(`http://localhost:9771/api/bookings${filterParam}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (response.ok) { + const result = await response.json(); + const newBookings = result.data || []; + + // 신규 예약이 있으면 알림 표시 + if (result.newCount > 0 && newBookings.length > bookings.length) { + setShowNotification(true); + setTimeout(() => setShowNotification(false), 5000); + } + + setBookings(newBookings); + setNewCount(result.newCount); + } + } catch (error) { + // console.error("예약 로딩 오류:", error); + } finally { + setLoading(false); + } + }; + + const handleAccept = async (id: string) => { + if (!confirm("이 예약을 수락하시겠습니까?")) return; + + try { + const token = localStorage.getItem("authToken"); + const response = await fetch(`http://localhost:9771/api/bookings/${id}/accept`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (response.ok) { + fetchBookings(); + } + } catch (error) { + // console.error("예약 수락 오류:", error); + } + }; + + const handleReject = async (id: string) => { + const reason = prompt("거절 사유를 입력하세요:"); + if (!reason) return; + + try { + const token = localStorage.getItem("authToken"); + const response = await fetch(`http://localhost:9771/api/bookings/${id}/reject`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ reason }), + }); + + if (response.ok) { + fetchBookings(); + } + } catch (error) { + // console.error("예약 거절 오류:", error); + } + }; + + const getVehicleIcon = (type: string) => { + switch (type) { + case "truck": + return "🚚"; + case "van": + return "🚐"; + case "car": + return "🚗"; + default: + return "🚗"; + } + }; + + const getTimeStatus = (scheduledTime: string) => { + const now = new Date(); + const scheduled = new Date(scheduledTime); + const diff = scheduled.getTime() - now.getTime(); + const hours = Math.floor(diff / (1000 * 60 * 60)); + + if (hours < 0) return { text: "⏰ 시간 초과", color: "text-red-600" }; + if (hours < 2) return { text: `⏱️ ${hours}시간 후`, color: "text-red-600" }; + if (hours < 4) return { text: `⏱️ ${hours}시간 후`, color: "text-orange-600" }; + return { text: `📅 ${hours}시간 후`, color: "text-gray-600" }; + }; + + const isNew = (createdAt: string) => { + const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); + return new Date(createdAt) > fiveMinutesAgo; + }; + + if (loading) { + return ( +
+
로딩 중...
+
+ ); + } + + return ( +
+ {/* 신규 알림 배너 */} + {showNotification && newCount > 0 && ( +
+ 🔔 새로운 예약 {newCount}건이 도착했습니다! +
+ )} + + {/* 헤더 */} +
+
+
+

🔔 예약 요청 알림

+ {newCount > 0 && ( + + {newCount} + + )} +
+ +
+ + {/* 필터 */} +
+ {(["pending", "accepted", "all"] as const).map((f) => ( + + ))} +
+
+ + {/* 예약 리스트 */} +
+ {bookings.length === 0 ? ( +
+
+
📭
+
예약 요청이 없습니다
+
+
+ ) : ( +
+ {bookings.map((booking) => ( +
+ {/* NEW 뱃지 */} + {isNew(booking.createdAt) && booking.status === "pending" && ( +
+ + 🆕 + +
+ )} + + {/* 우선순위 표시 */} + {booking.priority === "urgent" && ( +
+ + 긴급 예약 +
+ )} + + {/* 고객 정보 */} +
+
+
+ {getVehicleIcon(booking.vehicleType)} +
+
{booking.customerName}
+
+ + {booking.customerPhone} +
+
+
+
+ {booking.status === "pending" && ( +
+ + +
+ )} + {booking.status === "accepted" && ( + + ✓ 수락됨 + + )} +
+ + {/* 경로 정보 */} +
+
+ +
+
출발지
+
{booking.pickupLocation}
+
+
+
+ +
+
도착지
+
{booking.dropoffLocation}
+
+
+
+ + {/* 상세 정보 */} +
+
+ + + {booking.cargoType} ({booking.weight}kg) + +
+
+ + {getTimeStatus(booking.scheduledTime).text} +
+ {booking.estimatedCost && ( +
+ 예상 비용: {booking.estimatedCost.toLocaleString()}원 +
+ )} +
+
+ ))} +
+ )} +
+
+ ); +} + diff --git a/frontend/components/dashboard/widgets/DocumentWidget.tsx b/frontend/components/dashboard/widgets/DocumentWidget.tsx new file mode 100644 index 00000000..7a85a556 --- /dev/null +++ b/frontend/components/dashboard/widgets/DocumentWidget.tsx @@ -0,0 +1,242 @@ +"use client"; + +import React, { useState } from "react"; +import { FileText, Download, Calendar, Folder, Search } from "lucide-react"; + +interface Document { + id: string; + name: string; + category: "계약서" | "보험" | "세금계산서" | "기타"; + size: string; + uploadDate: string; + url: string; + description?: string; +} + +// 목 데이터 +const mockDocuments: Document[] = [ + { + id: "1", + name: "2025년 1월 세금계산서.pdf", + category: "세금계산서", + size: "1.2 MB", + uploadDate: "2025-01-05", + url: "/documents/tax-invoice-202501.pdf", + description: "1월 매출 세금계산서", + }, + { + id: "2", + name: "차량보험증권_서울12가3456.pdf", + category: "보험", + size: "856 KB", + uploadDate: "2024-12-20", + url: "/documents/insurance-vehicle-1.pdf", + description: "1톤 트럭 종합보험", + }, + { + id: "3", + name: "운송계약서_ABC물류.pdf", + category: "계약서", + size: "2.4 MB", + uploadDate: "2024-12-15", + url: "/documents/contract-abc-logistics.pdf", + description: "ABC물류 연간 운송 계약", + }, + { + id: "4", + name: "2024년 12월 세금계산서.pdf", + category: "세금계산서", + size: "1.1 MB", + uploadDate: "2024-12-05", + url: "/documents/tax-invoice-202412.pdf", + }, + { + id: "5", + name: "화물배상책임보험증권.pdf", + category: "보험", + size: "720 KB", + uploadDate: "2024-11-30", + url: "/documents/cargo-insurance.pdf", + description: "화물 배상책임보험", + }, + { + id: "6", + name: "차고지 임대계약서.pdf", + category: "계약서", + size: "1.8 MB", + uploadDate: "2024-11-15", + url: "/documents/garage-lease-contract.pdf", + }, +]; + +export default function DocumentWidget() { + const [documents] = useState(mockDocuments); + const [filter, setFilter] = useState<"all" | Document["category"]>("all"); + const [searchTerm, setSearchTerm] = useState(""); + + const filteredDocuments = documents.filter((doc) => { + const matchesFilter = filter === "all" || doc.category === filter; + const matchesSearch = + searchTerm === "" || + doc.name.toLowerCase().includes(searchTerm.toLowerCase()) || + doc.description?.toLowerCase().includes(searchTerm.toLowerCase()); + return matchesFilter && matchesSearch; + }); + + const getCategoryIcon = (category: Document["category"]) => { + switch (category) { + case "계약서": + return "📄"; + case "보험": + return "🛡️"; + case "세금계산서": + return "💰"; + case "기타": + return "📁"; + } + }; + + const getCategoryColor = (category: Document["category"]) => { + switch (category) { + case "계약서": + return "bg-blue-100 text-blue-700"; + case "보험": + return "bg-green-100 text-green-700"; + case "세금계산서": + return "bg-amber-100 text-amber-700"; + case "기타": + return "bg-gray-100 text-gray-700"; + } + }; + + const handleDownload = (doc: Document) => { + // 실제로는 백엔드 API 호출 + alert(`다운로드: ${doc.name}\n(실제 구현 시 파일 다운로드 처리)`); + }; + + const stats = { + total: documents.length, + contract: documents.filter((d) => d.category === "계약서").length, + insurance: documents.filter((d) => d.category === "보험").length, + tax: documents.filter((d) => d.category === "세금계산서").length, + }; + + return ( +
+ {/* 헤더 */} +
+
+

📂 문서 관리

+ +
+ + {/* 통계 */} +
+
+
{stats.total}
+
전체
+
+
+
{stats.contract}
+
계약서
+
+
+
{stats.insurance}
+
보험
+
+
+
{stats.tax}
+
계산서
+
+
+ + {/* 검색 */} +
+ + setSearchTerm(e.target.value)} + className="w-full rounded border border-gray-300 py-2 pl-10 pr-3 text-sm focus:border-primary focus:outline-none" + /> +
+ + {/* 필터 */} +
+ {(["all", "계약서", "보험", "세금계산서", "기타"] as const).map((f) => ( + + ))} +
+
+ + {/* 문서 리스트 */} +
+ {filteredDocuments.length === 0 ? ( +
+
+
📭
+
문서가 없습니다
+
+
+ ) : ( +
+ {filteredDocuments.map((doc) => ( +
+ {/* 아이콘 */} +
+ {getCategoryIcon(doc.category)} +
+ + {/* 정보 */} +
+
+
+
{doc.name}
+ {doc.description && ( +
{doc.description}
+ )} +
+ + {doc.category} + + + + {new Date(doc.uploadDate).toLocaleDateString()} + + {doc.size} +
+
+
+
+ + {/* 다운로드 버튼 */} + +
+ ))} +
+ )} +
+
+ ); +} + diff --git a/frontend/components/dashboard/widgets/MaintenanceWidget.tsx b/frontend/components/dashboard/widgets/MaintenanceWidget.tsx new file mode 100644 index 00000000..634b8df8 --- /dev/null +++ b/frontend/components/dashboard/widgets/MaintenanceWidget.tsx @@ -0,0 +1,244 @@ +"use client"; + +import React, { useState } from "react"; +import { Calendar, Wrench, Truck, Check, Clock, AlertTriangle } from "lucide-react"; + +interface MaintenanceSchedule { + id: string; + vehicleNumber: string; + vehicleType: string; + maintenanceType: "정기점검" | "수리" | "타이어교체" | "오일교환" | "기타"; + scheduledDate: string; + status: "scheduled" | "in_progress" | "completed" | "overdue"; + notes?: string; + estimatedCost?: number; +} + +// 목 데이터 +const mockSchedules: MaintenanceSchedule[] = [ + { + id: "1", + vehicleNumber: "서울12가3456", + vehicleType: "1톤 트럭", + maintenanceType: "정기점검", + scheduledDate: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString(), + status: "scheduled", + notes: "6개월 정기점검", + estimatedCost: 300000, + }, + { + id: "2", + vehicleNumber: "경기34나5678", + vehicleType: "2.5톤 트럭", + maintenanceType: "오일교환", + scheduledDate: new Date(Date.now() + 1 * 24 * 60 * 60 * 1000).toISOString(), + status: "scheduled", + estimatedCost: 150000, + }, + { + id: "3", + vehicleNumber: "인천56다7890", + vehicleType: "라보", + maintenanceType: "타이어교체", + scheduledDate: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), + status: "overdue", + notes: "긴급", + estimatedCost: 400000, + }, + { + id: "4", + vehicleNumber: "부산78라1234", + vehicleType: "1톤 트럭", + maintenanceType: "수리", + scheduledDate: new Date().toISOString(), + status: "in_progress", + notes: "엔진 점검 중", + estimatedCost: 800000, + }, +]; + +export default function MaintenanceWidget() { + const [schedules] = useState(mockSchedules); + const [filter, setFilter] = useState<"all" | MaintenanceSchedule["status"]>("all"); + const [selectedDate, setSelectedDate] = useState(new Date()); + + const filteredSchedules = schedules.filter( + (s) => filter === "all" || s.status === filter + ); + + const getStatusBadge = (status: MaintenanceSchedule["status"]) => { + switch (status) { + case "scheduled": + return 예정; + case "in_progress": + return 진행중; + case "completed": + return 완료; + case "overdue": + return 지연; + } + }; + + const getMaintenanceIcon = (type: MaintenanceSchedule["maintenanceType"]) => { + switch (type) { + case "정기점검": + return "🔍"; + case "수리": + return "🔧"; + case "타이어교체": + return "⚙️"; + case "오일교환": + return "🛢️"; + default: + return "🔧"; + } + }; + + const getDaysUntil = (date: string) => { + const now = new Date(); + const scheduled = new Date(date); + const diff = scheduled.getTime() - now.getTime(); + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + + if (days < 0) return `${Math.abs(days)}일 지연`; + if (days === 0) return "오늘"; + if (days === 1) return "내일"; + return `${days}일 후`; + }; + + const stats = { + total: schedules.length, + scheduled: schedules.filter((s) => s.status === "scheduled").length, + inProgress: schedules.filter((s) => s.status === "in_progress").length, + overdue: schedules.filter((s) => s.status === "overdue").length, + }; + + return ( +
+ {/* 헤더 */} +
+
+

🔧 정비 일정 관리

+ +
+ + {/* 통계 */} +
+
+
{stats.scheduled}
+
예정
+
+
+
{stats.inProgress}
+
진행중
+
+
+
{stats.overdue}
+
지연
+
+
+
{stats.total}
+
전체
+
+
+ + {/* 필터 */} +
+ {(["all", "scheduled", "in_progress", "overdue"] as const).map((f) => ( + + ))} +
+
+ + {/* 일정 리스트 */} +
+ {filteredSchedules.length === 0 ? ( +
+
+
📅
+
정비 일정이 없습니다
+
+
+ ) : ( +
+ {filteredSchedules.map((schedule) => ( +
+
+
+ {getMaintenanceIcon(schedule.maintenanceType)} +
+
{schedule.vehicleNumber}
+
{schedule.vehicleType}
+
+
+ {getStatusBadge(schedule.status)} +
+ +
+
{schedule.maintenanceType}
+ {schedule.notes &&
{schedule.notes}
} +
+ +
+
+ + {new Date(schedule.scheduledDate).toLocaleDateString()} +
+
+ + {getDaysUntil(schedule.scheduledDate)} +
+ {schedule.estimatedCost && ( +
+ 예상 비용: {schedule.estimatedCost.toLocaleString()}원 +
+ )} +
+ + {/* 액션 버튼 */} + {schedule.status === "scheduled" && ( +
+ + +
+ )} + {schedule.status === "in_progress" && ( +
+ +
+ )} +
+ ))} +
+ )} +
+
+ ); +} + diff --git a/frontend/components/dashboard/widgets/TodoWidget.tsx b/frontend/components/dashboard/widgets/TodoWidget.tsx new file mode 100644 index 00000000..f2cf3625 --- /dev/null +++ b/frontend/components/dashboard/widgets/TodoWidget.tsx @@ -0,0 +1,405 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Plus, Check, X, Clock, AlertCircle, GripVertical, ChevronDown } from "lucide-react"; + +interface TodoItem { + id: string; + title: string; + description?: string; + priority: "urgent" | "high" | "normal" | "low"; + status: "pending" | "in_progress" | "completed"; + assignedTo?: string; + dueDate?: string; + createdAt: string; + updatedAt: string; + completedAt?: string; + isUrgent: boolean; + order: number; +} + +interface TodoStats { + total: number; + pending: number; + inProgress: number; + completed: number; + urgent: number; + overdue: number; +} + +export default function TodoWidget() { + const [todos, setTodos] = useState([]); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState<"all" | "pending" | "in_progress" | "completed">("all"); + const [showAddForm, setShowAddForm] = useState(false); + const [newTodo, setNewTodo] = useState({ + title: "", + description: "", + priority: "normal" as TodoItem["priority"], + isUrgent: false, + dueDate: "", + assignedTo: "", + }); + + useEffect(() => { + fetchTodos(); + const interval = setInterval(fetchTodos, 30000); // 30초마다 갱신 + return () => clearInterval(interval); + }, [filter]); + + 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}`, + }, + }); + + if (response.ok) { + const result = await response.json(); + setTodos(result.data || []); + setStats(result.stats); + } + } catch (error) { + // console.error("To-Do 로딩 오류:", error); + } finally { + setLoading(false); + } + }; + + const handleAddTodo = async () => { + if (!newTodo.title.trim()) return; + + try { + const token = localStorage.getItem("authToken"); + const response = await fetch("http://localhost:9771/api/todos", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(newTodo), + }); + + if (response.ok) { + setNewTodo({ + title: "", + description: "", + priority: "normal", + isUrgent: false, + dueDate: "", + assignedTo: "", + }); + setShowAddForm(false); + fetchTodos(); + } + } catch (error) { + // console.error("To-Do 추가 오류:", error); + } + }; + + const handleUpdateStatus = async (id: string, status: TodoItem["status"]) => { + try { + const token = localStorage.getItem("authToken"); + const response = await fetch(`http://localhost:9771/api/todos/${id}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ status }), + }); + + if (response.ok) { + fetchTodos(); + } + } catch (error) { + // console.error("상태 업데이트 오류:", error); + } + }; + + const handleDelete = async (id: string) => { + if (!confirm("이 To-Do를 삭제하시겠습니까?")) return; + + try { + const token = localStorage.getItem("authToken"); + const response = await fetch(`http://localhost:9771/api/todos/${id}`, { + method: "DELETE", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (response.ok) { + fetchTodos(); + } + } catch (error) { + // console.error("To-Do 삭제 오류:", error); + } + }; + + const getPriorityColor = (priority: TodoItem["priority"]) => { + switch (priority) { + case "urgent": + return "bg-red-100 text-red-700 border-red-300"; + case "high": + return "bg-orange-100 text-orange-700 border-orange-300"; + case "normal": + return "bg-blue-100 text-blue-700 border-blue-300"; + case "low": + return "bg-gray-100 text-gray-700 border-gray-300"; + } + }; + + const getPriorityIcon = (priority: TodoItem["priority"]) => { + switch (priority) { + case "urgent": + return "🔴"; + case "high": + return "🟠"; + case "normal": + return "🟡"; + case "low": + return "🟢"; + } + }; + + const getTimeRemaining = (dueDate: string) => { + const now = new Date(); + const due = new Date(dueDate); + const diff = due.getTime() - now.getTime(); + const hours = Math.floor(diff / (1000 * 60 * 60)); + const days = Math.floor(hours / 24); + + if (diff < 0) return "⏰ 기한 초과"; + if (days > 0) return `📅 ${days}일 남음`; + if (hours > 0) return `⏱️ ${hours}시간 남음`; + return "⚠️ 오늘 마감"; + }; + + if (loading) { + return ( +
+
로딩 중...
+
+ ); + } + + return ( +
+ {/* 헤더 */} +
+
+

✅ To-Do / 긴급 지시

+ +
+ + {/* 통계 */} + {stats && ( +
+
+
{stats.pending}
+
대기
+
+
+
{stats.inProgress}
+
진행중
+
+
+
{stats.urgent}
+
긴급
+
+
+
{stats.overdue}
+
지연
+
+
+ )} + + {/* 필터 */} +
+ {(["all", "pending", "in_progress", "completed"] as const).map((f) => ( + + ))} +
+
+ + {/* 추가 폼 */} + {showAddForm && ( +
+
+ setNewTodo({ ...newTodo, title: e.target.value })} + className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:outline-none" + /> +