diff --git a/backend-node/src/services/DashboardService.ts b/backend-node/src/services/DashboardService.ts index 9829c49d..82ef499e 100644 --- a/backend-node/src/services/DashboardService.ts +++ b/backend-node/src/services/DashboardService.ts @@ -321,24 +321,34 @@ export class DashboardService { ]); // 3. 요소 데이터 변환 + console.log("📊 대시보드 요소 개수:", elementsResult.rows.length); + const elements: DashboardElement[] = elementsResult.rows.map( - (row: any) => ({ - id: row.id, - type: row.element_type, - subtype: row.element_subtype, - position: { - x: row.position_x, - y: row.position_y, - }, - size: { - width: row.width, - height: row.height, - }, - title: row.title, - content: row.content, - dataSource: JSON.parse(row.data_source_config || "{}"), - chartConfig: JSON.parse(row.chart_config || "{}"), - }) + (row: any, index: number) => { + const element = { + id: row.id, + type: row.element_type, + subtype: row.element_subtype, + position: { + x: row.position_x, + y: row.position_y, + }, + size: { + width: row.width, + height: row.height, + }, + title: row.title, + content: row.content, + dataSource: JSON.parse(row.data_source_config || "{}"), + chartConfig: JSON.parse(row.chart_config || "{}"), + }; + + console.log( + `📊 위젯 #${index + 1}: type="${element.type}", subtype="${element.subtype}", title="${element.title}"` + ); + + return element; + } ); return { diff --git a/backend-node/src/services/bookingService.ts b/backend-node/src/services/bookingService.ts index 79935414..b27544e1 100644 --- a/backend-node/src/services/bookingService.ts +++ b/backend-node/src/services/bookingService.ts @@ -53,13 +53,20 @@ export class BookingService { } private ensureDataDirectory(): void { - if (!fs.existsSync(BOOKING_DIR)) { - fs.mkdirSync(BOOKING_DIR, { recursive: true }); - logger.info(`📁 예약 데이터 디렉토리 생성: ${BOOKING_DIR}`); - } - if (!fs.existsSync(BOOKING_FILE)) { - fs.writeFileSync(BOOKING_FILE, JSON.stringify([], null, 2)); - logger.info(`📄 예약 파일 생성: ${BOOKING_FILE}`); + try { + if (!fs.existsSync(BOOKING_DIR)) { + fs.mkdirSync(BOOKING_DIR, { recursive: true, mode: 0o755 }); + logger.info(`📁 예약 데이터 디렉토리 생성: ${BOOKING_DIR}`); + } + if (!fs.existsSync(BOOKING_FILE)) { + fs.writeFileSync(BOOKING_FILE, JSON.stringify([], null, 2), { + mode: 0o644, + }); + logger.info(`📄 예약 파일 생성: ${BOOKING_FILE}`); + } + } catch (error) { + logger.error(`❌ 예약 디렉토리 생성 실패: ${BOOKING_DIR}`, error); + throw error; } } @@ -111,13 +118,16 @@ export class BookingService { priority?: string; }): Promise<{ bookings: BookingRequest[]; newCount: number }> { try { - const bookings = DATA_SOURCE === "database" - ? await this.loadBookingsFromDB(filter) - : this.loadBookingsFromFile(filter); + const bookings = + DATA_SOURCE === "database" + ? await this.loadBookingsFromDB(filter) + : this.loadBookingsFromFile(filter); bookings.sort((a, b) => { if (a.priority !== b.priority) return a.priority === "urgent" ? -1 : 1; - return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + return ( + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); }); const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); @@ -145,7 +155,10 @@ export class BookingService { } } - public async rejectBooking(id: string, reason?: string): Promise { + public async rejectBooking( + id: string, + reason?: string + ): Promise { try { if (DATA_SOURCE === "database") { return await this.rejectBookingDB(id, reason); @@ -194,9 +207,15 @@ export class BookingService { scheduledTime: new Date(row.scheduledTime).toISOString(), createdAt: new Date(row.createdAt).toISOString(), updatedAt: new Date(row.updatedAt).toISOString(), - acceptedAt: row.acceptedAt ? new Date(row.acceptedAt).toISOString() : undefined, - rejectedAt: row.rejectedAt ? new Date(row.rejectedAt).toISOString() : undefined, - completedAt: row.completedAt ? new Date(row.completedAt).toISOString() : undefined, + acceptedAt: row.acceptedAt + ? new Date(row.acceptedAt).toISOString() + : undefined, + rejectedAt: row.rejectedAt + ? new Date(row.rejectedAt).toISOString() + : undefined, + completedAt: row.completedAt + ? new Date(row.completedAt).toISOString() + : undefined, })); } @@ -230,7 +249,10 @@ export class BookingService { }; } - private async rejectBookingDB(id: string, reason?: string): Promise { + private async rejectBookingDB( + id: string, + reason?: string + ): Promise { const rows = await query( `UPDATE booking_requests SET status = 'rejected', rejected_at = NOW(), updated_at = NOW(), rejection_reason = $2 diff --git a/backend-node/src/services/mailAccountFileService.ts b/backend-node/src/services/mailAccountFileService.ts index 7b07b531..e547171a 100644 --- a/backend-node/src/services/mailAccountFileService.ts +++ b/backend-node/src/services/mailAccountFileService.ts @@ -33,11 +33,7 @@ class MailAccountFileService { try { await fs.access(this.accountsDir); } catch { - try { - await fs.mkdir(this.accountsDir, { recursive: true }); - } catch (error) { - console.error("메일 계정 디렉토리 생성 실패:", error); - } + await fs.mkdir(this.accountsDir, { recursive: true, mode: 0o755 }); } } diff --git a/backend-node/src/services/mailReceiveBasicService.ts b/backend-node/src/services/mailReceiveBasicService.ts index 741353fa..d5e3a78f 100644 --- a/backend-node/src/services/mailReceiveBasicService.ts +++ b/backend-node/src/services/mailReceiveBasicService.ts @@ -59,11 +59,7 @@ export class MailReceiveBasicService { try { await fs.access(this.attachmentsDir); } catch { - try { - await fs.mkdir(this.attachmentsDir, { recursive: true }); - } catch (error) { - console.error("메일 첨부파일 디렉토리 생성 실패:", error); - } + await fs.mkdir(this.attachmentsDir, { recursive: true, mode: 0o755 }); } } diff --git a/backend-node/src/services/mailSentHistoryService.ts b/backend-node/src/services/mailSentHistoryService.ts index 61fd6f89..c7828888 100644 --- a/backend-node/src/services/mailSentHistoryService.ts +++ b/backend-node/src/services/mailSentHistoryService.ts @@ -20,15 +20,13 @@ const SENT_MAIL_DIR = class MailSentHistoryService { constructor() { - // 디렉토리 생성 (없으면) - try-catch로 권한 에러 방지 try { if (!fs.existsSync(SENT_MAIL_DIR)) { - fs.mkdirSync(SENT_MAIL_DIR, { recursive: true }); + fs.mkdirSync(SENT_MAIL_DIR, { recursive: true, mode: 0o755 }); } } catch (error) { console.error("메일 발송 이력 디렉토리 생성 실패:", error); - // 디렉토리가 이미 존재하거나 권한이 없어도 서비스는 계속 실행 - // 실제 파일 쓰기 시점에 에러 처리 + throw error; } } @@ -45,13 +43,15 @@ class MailSentHistoryService { }; try { - // 디렉토리가 없으면 다시 시도 if (!fs.existsSync(SENT_MAIL_DIR)) { - fs.mkdirSync(SENT_MAIL_DIR, { recursive: true }); + fs.mkdirSync(SENT_MAIL_DIR, { recursive: true, mode: 0o755 }); } const filePath = path.join(SENT_MAIL_DIR, `${history.id}.json`); - fs.writeFileSync(filePath, JSON.stringify(history, null, 2), "utf-8"); + fs.writeFileSync(filePath, JSON.stringify(history, null, 2), { + encoding: "utf-8", + mode: 0o644, + }); console.log("발송 이력 저장:", history.id); } catch (error) { diff --git a/backend-node/src/services/mailTemplateFileService.ts b/backend-node/src/services/mailTemplateFileService.ts index 7a8d4300..e1a878b9 100644 --- a/backend-node/src/services/mailTemplateFileService.ts +++ b/backend-node/src/services/mailTemplateFileService.ts @@ -54,17 +54,13 @@ class MailTemplateFileService { } /** - * 템플릿 디렉토리 생성 (없으면) - try-catch로 권한 에러 방지 + * 템플릿 디렉토리 생성 */ private async ensureDirectoryExists() { try { await fs.access(this.templatesDir); } catch { - try { - await fs.mkdir(this.templatesDir, { recursive: true }); - } catch (error) { - console.error("메일 템플릿 디렉토리 생성 실패:", error); - } + await fs.mkdir(this.templatesDir, { recursive: true, mode: 0o755 }); } } diff --git a/backend-node/src/services/todoService.ts b/backend-node/src/services/todoService.ts index 1347c665..33becbb9 100644 --- a/backend-node/src/services/todoService.ts +++ b/backend-node/src/services/todoService.ts @@ -61,13 +61,20 @@ export class TodoService { * 데이터 디렉토리 생성 (파일 모드) */ private ensureDataDirectory(): void { - if (!fs.existsSync(TODO_DIR)) { - fs.mkdirSync(TODO_DIR, { recursive: true }); - logger.info(`📁 To-Do 데이터 디렉토리 생성: ${TODO_DIR}`); - } - if (!fs.existsSync(TODO_FILE)) { - fs.writeFileSync(TODO_FILE, JSON.stringify([], null, 2)); - logger.info(`📄 To-Do 파일 생성: ${TODO_FILE}`); + try { + if (!fs.existsSync(TODO_DIR)) { + fs.mkdirSync(TODO_DIR, { recursive: true, mode: 0o755 }); + logger.info(`📁 To-Do 데이터 디렉토리 생성: ${TODO_DIR}`); + } + if (!fs.existsSync(TODO_FILE)) { + fs.writeFileSync(TODO_FILE, JSON.stringify([], null, 2), { + mode: 0o644, + }); + logger.info(`📄 To-Do 파일 생성: ${TODO_FILE}`); + } + } catch (error) { + logger.error(`❌ To-Do 디렉토리 생성 실패: ${TODO_DIR}`, error); + throw error; } } @@ -80,15 +87,17 @@ export class TodoService { assignedTo?: string; }): Promise { try { - const todos = DATA_SOURCE === "database" - ? await this.loadTodosFromDB(filter) - : this.loadTodosFromFile(filter); + const todos = + DATA_SOURCE === "database" + ? await this.loadTodosFromDB(filter) + : this.loadTodosFromFile(filter); // 정렬: 긴급 > 우선순위 > 순서 todos.sort((a, b) => { if (a.isUrgent !== b.isUrgent) return a.isUrgent ? -1 : 1; const priorityOrder = { urgent: 0, high: 1, normal: 2, low: 3 }; - if (a.priority !== b.priority) return priorityOrder[a.priority] - priorityOrder[b.priority]; + if (a.priority !== b.priority) + return priorityOrder[a.priority] - priorityOrder[b.priority]; return a.order - b.order; }); @@ -124,7 +133,8 @@ export class TodoService { await this.createTodoDB(newTodo); } else { const todos = this.loadTodosFromFile(); - newTodo.order = todos.length > 0 ? Math.max(...todos.map((t) => t.order)) + 1 : 0; + newTodo.order = + todos.length > 0 ? Math.max(...todos.map((t) => t.order)) + 1 : 0; todos.push(newTodo); this.saveTodosToFile(todos); } @@ -140,7 +150,10 @@ export class TodoService { /** * To-Do 항목 수정 */ - public async updateTodo(id: string, updates: Partial): Promise { + public async updateTodo( + id: string, + updates: Partial + ): Promise { try { if (DATA_SOURCE === "database") { return await this.updateTodoDB(id, updates); @@ -231,7 +244,9 @@ export class TodoService { dueDate: row.dueDate ? new Date(row.dueDate).toISOString() : undefined, createdAt: new Date(row.createdAt).toISOString(), updatedAt: new Date(row.updatedAt).toISOString(), - completedAt: row.completedAt ? new Date(row.completedAt).toISOString() : undefined, + completedAt: row.completedAt + ? new Date(row.completedAt).toISOString() + : undefined, })); } @@ -263,7 +278,10 @@ export class TodoService { ); } - private async updateTodoDB(id: string, updates: Partial): Promise { + private async updateTodoDB( + id: string, + updates: Partial + ): Promise { const setClauses: string[] = ["updated_at = NOW()"]; const params: any[] = []; let paramIndex = 1; @@ -327,12 +345,17 @@ export class TodoService { dueDate: row.dueDate ? new Date(row.dueDate).toISOString() : undefined, createdAt: new Date(row.createdAt).toISOString(), updatedAt: new Date(row.updatedAt).toISOString(), - completedAt: row.completedAt ? new Date(row.completedAt).toISOString() : undefined, + completedAt: row.completedAt + ? new Date(row.completedAt).toISOString() + : undefined, }; } private async deleteTodoDB(id: string): Promise { - const rows = await query("DELETE FROM todo_items WHERE id = $1 RETURNING id", [id]); + const rows = await query( + "DELETE FROM todo_items WHERE id = $1 RETURNING id", + [id] + ); if (rows.length === 0) { throw new Error(`To-Do 항목을 찾을 수 없습니다: ${id}`); } @@ -443,7 +466,10 @@ export class TodoService { inProgress: todos.filter((t) => t.status === "in_progress").length, completed: todos.filter((t) => t.status === "completed").length, urgent: todos.filter((t) => t.isUrgent).length, - overdue: todos.filter((t) => t.dueDate && new Date(t.dueDate) < now && t.status !== "completed").length, + overdue: todos.filter( + (t) => + t.dueDate && new Date(t.dueDate) < now && t.status !== "completed" + ).length, }; } } diff --git a/docker/deploy/backend.Dockerfile b/docker/deploy/backend.Dockerfile index bbfd3438..a5dd1aeb 100644 --- a/docker/deploy/backend.Dockerfile +++ b/docker/deploy/backend.Dockerfile @@ -34,14 +34,11 @@ COPY --from=build /app/dist ./dist # Copy package files COPY package*.json ./ -# Create logs, uploads, and data directories and set permissions (use existing node user with UID 1000) -RUN mkdir -p logs \ - uploads/mail-attachments \ - uploads/mail-templates \ - uploads/mail-accounts \ - data/mail-sent && \ - chown -R node:node logs uploads data && \ - chmod -R 755 logs uploads data +# 루트 디렉토리만 생성하고 node 유저에게 쓰기 권한 부여 +# 하위 디렉토리는 애플리케이션이 런타임에 자동 생성 +RUN mkdir -p logs uploads data && \ + chown -R node:node /app && \ + chmod -R 755 /app EXPOSE 3001 USER node diff --git a/docker/deploy/docker-compose.yml b/docker/deploy/docker-compose.yml index d3934c00..e1c76ad9 100644 --- a/docker/deploy/docker-compose.yml +++ b/docker/deploy/docker-compose.yml @@ -20,8 +20,8 @@ services: LOG_LEVEL: info ENCRYPTION_KEY: ilshin-plm-mail-encryption-key-32characters-2024-secure volumes: - - /home/vexplor/backend_data/uploads:/app/uploads - - /home/vexplor/backend_data/data:/app/data + - backend_uploads:/app/uploads + - backend_data:/app/data labels: - traefik.enable=true - traefik.http.routers.backend.rule=Host(`api.vexplor.com`) @@ -46,7 +46,7 @@ services: PORT: "3000" HOSTNAME: 0.0.0.0 volumes: - - /home/vexplor/frontend_data:/app/data + - frontend_data:/app/data labels: - traefik.enable=true - traefik.http.routers.frontend.rule=Host(`v1.vexplor.com`) @@ -55,6 +55,14 @@ services: - traefik.http.routers.frontend.tls.certresolver=le - traefik.http.services.frontend.loadbalancer.server.port=3000 +volumes: + backend_uploads: + driver: local + backend_data: + driver: local + frontend_data: + driver: local + networks: default: name: toktork_server_default diff --git a/docker/dev/docker-compose.backend.mac.yml b/docker/dev/docker-compose.backend.mac.yml index b9675147..4d862d9e 100644 --- a/docker/dev/docker-compose.backend.mac.yml +++ b/docker/dev/docker-compose.backend.mac.yml @@ -20,7 +20,7 @@ services: - LOG_LEVEL=debug - ENCRYPTION_KEY=ilshin-plm-mail-encryption-key-32characters-2024-secure - KMA_API_KEY=ogdXr2e9T4iHV69nvV-IwA - - ITS_API_KEY=${ITS_API_KEY:-} + - ITS_API_KEY=d6b9befec3114d648284674b8fddcc32 - EXPRESSWAY_API_KEY=${EXPRESSWAY_API_KEY:-} volumes: - ../../backend-node:/app # 개발 모드: 코드 변경 시 자동 반영 diff --git a/docker/prod/backend.Dockerfile b/docker/prod/backend.Dockerfile index 8ef8a372..7944bc67 100644 --- a/docker/prod/backend.Dockerfile +++ b/docker/prod/backend.Dockerfile @@ -37,8 +37,11 @@ COPY --from=build /app/dist ./dist # Copy package files COPY package*.json ./ -# Create logs and uploads directories and set permissions -RUN mkdir -p logs uploads && chown -R appuser:appgroup logs uploads && chmod -R 755 logs uploads +# 루트 디렉토리만 생성하고 appuser에게 쓰기 권한 부여 +# 하위 디렉토리는 애플리케이션이 런타임에 자동 생성 +RUN mkdir -p logs uploads data && \ + chown -R appuser:appgroup /app && \ + chmod -R 755 /app EXPOSE 8080 USER appuser diff --git a/frontend/components/admin/dashboard/DashboardSidebar.tsx b/frontend/components/admin/dashboard/DashboardSidebar.tsx index cc34feb4..6c03b398 100644 --- a/frontend/components/admin/dashboard/DashboardSidebar.tsx +++ b/frontend/components/admin/dashboard/DashboardSidebar.tsx @@ -220,13 +220,7 @@ export function DashboardSidebar() { subtype="booking-alert" onDragStart={handleDragStart} /> - + {/* 정비 일정 관리 위젯 제거 - 커스텀 목록 카드로 대체 가능 */} (element.chartConfig || {}); const [queryResult, setQueryResult] = useState(null); const [currentStep, setCurrentStep] = useState<1 | 2>(1); + const [customTitle, setCustomTitle] = useState(element.customTitle || ""); // 차트 설정이 필요 없는 위젯 (쿼리/API만 필요) const isSimpleWidget = @@ -56,6 +57,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element setChartConfig(element.chartConfig || {}); setQueryResult(null); setCurrentStep(1); + setCustomTitle(element.customTitle || ""); } }, [isOpen, element]); @@ -119,13 +121,14 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element ...element, dataSource, chartConfig, + customTitle: customTitle.trim() || undefined, // 빈 문자열이면 undefined }; console.log(" 저장할 element:", updatedElement); onSave(updatedElement); onClose(); - }, [element, dataSource, chartConfig, onSave, onClose]); + }, [element, dataSource, chartConfig, customTitle, onSave, onClose]); // 모달이 열려있지 않으면 렌더링하지 않음 if (!isOpen) return null; @@ -147,28 +150,32 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element chartConfig.yAxis && (typeof chartConfig.yAxis === "string" || (Array.isArray(chartConfig.yAxis) && chartConfig.yAxis.length > 0)); - const canSave = isSimpleWidget - ? // 간단한 위젯: 2단계에서 쿼리 테스트 후 저장 가능 - currentStep === 2 && queryResult && queryResult.rows.length > 0 - : isMapWidget - ? // 지도 위젯: 위도/경도 매핑 필요 - currentStep === 2 && - queryResult && - queryResult.rows.length > 0 && - chartConfig.latitudeColumn && - chartConfig.longitudeColumn - : // 차트: 기존 로직 (2단계에서 차트 설정 필요) - currentStep === 2 && - queryResult && - queryResult.rows.length > 0 && - chartConfig.xAxis && - (isPieChart || isApiSource - ? // 파이/도넛 차트 또는 REST API - chartConfig.aggregation === "count" - ? true // count는 Y축 없어도 됨 - : hasYAxis // 다른 집계(sum, avg, max, min) 또는 집계 없음 → Y축 필수 - : // 일반 차트 (DB): Y축 필수 - hasYAxis); + // customTitle이 변경되었는지 확인 + const isTitleChanged = customTitle.trim() !== (element.customTitle || ""); + + const canSave = isTitleChanged || // 제목만 변경해도 저장 가능 + (isSimpleWidget + ? // 간단한 위젯: 2단계에서 쿼리 테스트 후 저장 가능 + currentStep === 2 && queryResult && queryResult.rows.length > 0 + : isMapWidget + ? // 지도 위젯: 위도/경도 매핑 필요 + currentStep === 2 && + queryResult && + queryResult.rows.length > 0 && + chartConfig.latitudeColumn && + chartConfig.longitudeColumn + : // 차트: 기존 로직 (2단계에서 차트 설정 필요) + currentStep === 2 && + queryResult && + queryResult.rows.length > 0 && + chartConfig.xAxis && + (isPieChart || isApiSource + ? // 파이/도넛 차트 또는 REST API + chartConfig.aggregation === "count" + ? true // count는 Y축 없어도 됨 + : hasYAxis // 다른 집계(sum, avg, max, min) 또는 집계 없음 → Y축 필수 + : // 일반 차트 (DB): Y축 필수 + hasYAxis)); return (
@@ -178,20 +185,39 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element }`} > {/* 모달 헤더 */} -
-
-

{element.title} 설정

-

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

+
+
+

{element.title} 설정

+

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

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

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

-
{/* 진행 상황 표시 - 간단한 위젯은 표시 안 함 */} diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts index 833c033a..cdf70550 100644 --- a/frontend/components/admin/dashboard/types.ts +++ b/frontend/components/admin/dashboard/types.ts @@ -54,6 +54,7 @@ export interface DashboardElement { position: Position; size: Size; title: string; + customTitle?: string; // 사용자 정의 제목 (옵션) content: string; dataSource?: ChartDataSource; // 데이터 소스 설정 chartConfig?: ChartConfig; // 차트 설정 diff --git a/frontend/components/dashboard/DashboardViewer.tsx b/frontend/components/dashboard/DashboardViewer.tsx index b8517d73..c6e941e3 100644 --- a/frontend/components/dashboard/DashboardViewer.tsx +++ b/frontend/components/dashboard/DashboardViewer.tsx @@ -3,6 +3,109 @@ import React, { useState, useEffect, useCallback } from "react"; import { DashboardElement, QueryResult } from "@/components/admin/dashboard/types"; import { ChartRenderer } from "@/components/admin/dashboard/charts/ChartRenderer"; +import dynamic from "next/dynamic"; + +// 위젯 동적 import - 모든 위젯 +const ListSummaryWidget = dynamic(() => import("./widgets/ListSummaryWidget"), { ssr: false }); +const MapSummaryWidget = dynamic(() => import("./widgets/MapSummaryWidget"), { ssr: false }); +const StatusSummaryWidget = dynamic(() => import("./widgets/StatusSummaryWidget"), { ssr: false }); +const RiskAlertWidget = dynamic(() => import("./widgets/RiskAlertWidget"), { ssr: false }); +const WeatherWidget = dynamic(() => import("./widgets/WeatherWidget"), { ssr: false }); +const ExchangeWidget = dynamic(() => import("./widgets/ExchangeWidget"), { ssr: false }); +const VehicleStatusWidget = dynamic(() => import("./widgets/VehicleStatusWidget"), { ssr: false }); +const VehicleListWidget = dynamic(() => import("./widgets/VehicleListWidget"), { ssr: false }); +const VehicleMapOnlyWidget = dynamic(() => import("./widgets/VehicleMapOnlyWidget"), { ssr: false }); +const CargoListWidget = dynamic(() => import("./widgets/CargoListWidget"), { ssr: false }); +const CustomerIssuesWidget = dynamic(() => import("./widgets/CustomerIssuesWidget"), { ssr: false }); +const DeliveryStatusWidget = dynamic(() => import("./widgets/DeliveryStatusWidget"), { ssr: false }); +const DeliveryStatusSummaryWidget = dynamic(() => import("./widgets/DeliveryStatusSummaryWidget"), { ssr: false }); +const DeliveryTodayStatsWidget = dynamic(() => import("./widgets/DeliveryTodayStatsWidget"), { ssr: false }); +const TodoWidget = dynamic(() => import("./widgets/TodoWidget"), { ssr: false }); +const DocumentWidget = dynamic(() => import("./widgets/DocumentWidget"), { ssr: false }); +const BookingAlertWidget = dynamic(() => import("./widgets/BookingAlertWidget"), { ssr: false }); +const MaintenanceWidget = dynamic(() => import("./widgets/MaintenanceWidget"), { ssr: false }); +const CalculatorWidget = dynamic(() => import("./widgets/CalculatorWidget"), { ssr: false }); +const CalendarWidget = dynamic(() => import("@/components/admin/dashboard/widgets/CalendarWidget").then(mod => ({ default: mod.CalendarWidget })), { ssr: false }); + +/** + * 위젯 렌더링 함수 - DashboardSidebar의 모든 subtype 처리 + * ViewerElement에서 사용하기 위해 컴포넌트 외부에 정의 + */ +function renderWidget(element: DashboardElement) { + switch (element.subtype) { + // 차트는 ChartRenderer에서 처리됨 (이 함수 호출 안됨) + + // === 위젯 종류 === + case "exchange": + return ; + case "weather": + return ; + case "calculator": + return ; + case "clock": + return ( +
+
+
+
시계 위젯 (개발 예정)
+
+
+ ); + case "map-summary": + return ; + case "list-summary": + return ; + case "risk-alert": + return ; + case "calendar": + return ; + case "status-summary": + return ; + + // === 운영/작업 지원 === + case "todo": + return ; + case "booking-alert": + return ; + case "maintenance": + return ; + case "document": + return ; + case "list": + return ; + + // === 차량 관련 (추가 위젯) === + case "vehicle-status": + return ; + case "vehicle-list": + return ; + case "vehicle-map": + return ; + + // === 배송 관련 (추가 위젯) === + case "delivery-status": + return ; + case "delivery-status-summary": + return ; + case "delivery-today-stats": + return ; + case "cargo-list": + return ; + case "customer-issues": + return ; + + // === 기본 fallback === + default: + return ( +
+
+
+
알 수 없는 위젯 타입: {element.subtype}
+
+
+ ); + } +} interface DashboardViewerProps { elements: DashboardElement[]; @@ -198,18 +301,7 @@ function ViewerElement({ element, data, isLoading, onRefresh }: ViewerElementPro
{element.type === "chart" ? ( - ) : ( - // 위젯 렌더링 -
-
-
- {element.subtype === "exchange" && "💱"} - {element.subtype === "weather" && "☁️"} -
-
{element.content}
-
-
- )} + ) : renderWidget(element)}
{/* 로딩 오버레이 */} diff --git a/frontend/components/dashboard/widgets/BookingAlertWidget.tsx b/frontend/components/dashboard/widgets/BookingAlertWidget.tsx index 4c600079..b47f0fb4 100644 --- a/frontend/components/dashboard/widgets/BookingAlertWidget.tsx +++ b/frontend/components/dashboard/widgets/BookingAlertWidget.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from "react"; import { Check, X, Phone, MapPin, Package, Clock, AlertCircle } from "lucide-react"; +import { DashboardElement } from "@/components/admin/dashboard/types"; interface BookingRequest { id: string; @@ -19,7 +20,11 @@ interface BookingRequest { estimatedCost?: number; } -export default function BookingAlertWidget() { +interface BookingAlertWidgetProps { + element?: DashboardElement; +} + +export default function BookingAlertWidget({ element }: BookingAlertWidgetProps) { const [bookings, setBookings] = useState([]); const [newCount, setNewCount] = useState(0); const [loading, setLoading] = useState(true); @@ -156,7 +161,7 @@ export default function BookingAlertWidget() {
-

🔔 예약 요청 알림

+

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

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

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

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

📂 문서 관리

+

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

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

💱 환율

+

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

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

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

리스크 / 알림

+

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

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

✅ To-Do / 긴급 지시

+

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

diff --git a/frontend/lib/api/dashboard.ts b/frontend/lib/api/dashboard.ts index 7767d89b..80663e1c 100644 --- a/frontend/lib/api/dashboard.ts +++ b/frontend/lib/api/dashboard.ts @@ -5,7 +5,7 @@ import { DashboardElement } from "@/components/admin/dashboard/types"; // API 기본 설정 -const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/api"; +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "/api"; // 토큰 가져오기 (실제 인증 시스템에 맞게 수정) function getAuthToken(): string | null { diff --git a/scripts/prod/deploy.sh b/scripts/prod/deploy.sh index b5388d54..d8430e3e 100755 --- a/scripts/prod/deploy.sh +++ b/scripts/prod/deploy.sh @@ -20,17 +20,10 @@ echo "" echo "[1/6] Git 최신 코드 가져오기..." git pull origin main -# 호스트 디렉토리 준비 +# Docker 볼륨 사용으로 호스트 디렉토리 준비 불필요 echo "" -echo "[2/6] 호스트 디렉토리 준비..." -mkdir -p /home/vexplor/backend_data/data/mail-sent -mkdir -p /home/vexplor/backend_data/uploads/mail-attachments -mkdir -p /home/vexplor/backend_data/uploads/mail-templates -mkdir -p /home/vexplor/backend_data/uploads/mail-accounts -mkdir -p /home/vexplor/frontend_data -chmod -R 755 /home/vexplor/backend_data -chmod -R 755 /home/vexplor/frontend_data -echo "디렉토리 생성 완료 (mail-sent, mail-attachments, mail-templates, mail-accounts, frontend)" +echo "[2/6] Docker 볼륨 확인..." +echo "Docker named volumes 사용 (권한 문제 없음)" # 기존 컨테이너 중지 및 제거 echo ""