From 9599d34ba9a0c63129f49fd210a7336868bcb7d0 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Tue, 14 Oct 2025 17:21:28 +0900 Subject: [PATCH] =?UTF-8?q?=ED=88=AC=EB=91=90=EB=A6=AC=EC=8A=A4=ED=8A=B8,?= =?UTF-8?q?=20=EC=98=88=EC=95=BD=EC=9A=94=EC=B2=AD,=20=EC=A0=95=EB=B9=84,?= =?UTF-8?q?=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" + /> +