diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 2b8daba3..5c75acb7 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -37,11 +37,42 @@ const VehicleMapOnlyWidget = dynamic(() => import("@/components/dashboard/widget loading: () =>
로딩 중...
, }); -const DeliveryStatusWidget = dynamic(() => import("@/components/dashboard/widgets/DeliveryStatusWidget"), { +// 범용 지도 위젯 (차량, 창고, 고객 등 모든 위치 위젯 통합) +const MapSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/MapSummaryWidget"), { ssr: false, loading: () =>
로딩 중...
, }); +// 범용 상태 요약 위젯 (차량, 배송 등 모든 상태 위젯 통합) +const StatusSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/StatusSummaryWidget"), { + ssr: false, + loading: () =>
로딩 중...
, +}); + +// 범용 목록 위젯 (차량, 기사, 제품 등 모든 목록 위젯 통합) - 다른 분 작업 중, 임시 주석 +/* const ListSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/ListSummaryWidget"), { + ssr: false, + loading: () =>
로딩 중...
, +}); */ + +// 개별 위젯들 (주석 처리 - StatusSummaryWidget으로 통합됨) +// const DeliveryStatusSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/DeliveryStatusSummaryWidget"), { +// ssr: false, +// loading: () =>
로딩 중...
, +// }); +// const DeliveryTodayStatsWidget = dynamic(() => import("@/components/dashboard/widgets/DeliveryTodayStatsWidget"), { +// ssr: false, +// loading: () =>
로딩 중...
, +// }); +// const CargoListWidget = dynamic(() => import("@/components/dashboard/widgets/CargoListWidget"), { +// ssr: false, +// loading: () =>
로딩 중...
, +// }); +// const CustomerIssuesWidget = dynamic(() => import("@/components/dashboard/widgets/CustomerIssuesWidget"), { +// ssr: false, +// loading: () =>
로딩 중...
, +// }); + const RiskAlertWidget = dynamic(() => import("@/components/dashboard/widgets/RiskAlertWidget"), { ssr: false, loading: () =>
로딩 중...
, @@ -501,15 +532,86 @@ export function CanvasElement({
+ ) : element.type === "widget" && element.subtype === "map-summary" ? ( + // 커스텀 지도 카드 - 범용 위젯 +
+ +
) : element.type === "widget" && element.subtype === "vehicle-map" ? ( - // 차량 위치 지도 위젯 렌더링 + // 차량 위치 지도 위젯 렌더링 (구버전 - 호환용)
- ) : element.type === "widget" && element.subtype === "delivery-status" ? ( - // 배송/화물 현황 위젯 렌더링 + ) : element.type === "widget" && element.subtype === "status-summary" ? ( + // 커스텀 상태 카드 - 범용 위젯
- + +
+ ) : /* element.type === "widget" && element.subtype === "list-summary" ? ( + // 커스텀 목록 카드 - 범용 위젯 (다른 분 작업 중 - 임시 주석) +
+ +
+ ) : */ element.type === "widget" && element.subtype === "delivery-status" ? ( + // 배송/화물 현황 위젯 - 범용 위젯 사용 (구버전 호환) +
+ +
+ ) : element.type === "widget" && element.subtype === "delivery-status-summary" ? ( + // 배송 상태 요약 - 범용 위젯 사용 +
+ +
+ ) : element.type === "widget" && element.subtype === "delivery-today-stats" ? ( + // 오늘 처리 현황 - 범용 위젯 사용 +
+ +
+ ) : element.type === "widget" && element.subtype === "cargo-list" ? ( + // 화물 목록 - 범용 위젯 사용 +
+ +
+ ) : element.type === "widget" && element.subtype === "customer-issues" ? ( + // 고객 클레임/이슈 - 범용 위젯 사용 +
+
) : element.type === "widget" && element.subtype === "risk-alert" ? ( // 리스크/알림 위젯 렌더링 diff --git a/frontend/components/admin/dashboard/DashboardSidebar.tsx b/frontend/components/admin/dashboard/DashboardSidebar.tsx index 60457000..cc34feb4 100644 --- a/frontend/components/admin/dashboard/DashboardSidebar.tsx +++ b/frontend/components/admin/dashboard/DashboardSidebar.tsx @@ -1,14 +1,26 @@ "use client"; -import React from "react"; +import React, { useState } from "react"; import { DragData, ElementType, ElementSubtype } from "./types"; +import { ChevronDown, ChevronRight } from "lucide-react"; /** * 대시보드 사이드바 컴포넌트 * - 드래그 가능한 차트/위젯 목록 - * - 카테고리별 구분 + * - 아코디언 방식으로 카테고리별 구분 */ export function DashboardSidebar() { + const [expandedSections, setExpandedSections] = useState({ + charts: true, + widgets: true, + operations: true, + }); + + // 섹션 토글 + const toggleSection = (section: keyof typeof expandedSections) => { + setExpandedSections((prev) => ({ ...prev, [section]: !prev[section] })); + }; + // 드래그 시작 처리 const handleDragStart = (e: React.DragEvent, type: ElementType, subtype: ElementSubtype) => { const dragData: DragData = { type, subtype }; @@ -17,27 +29,36 @@ export function DashboardSidebar() { }; return ( -
+
{/* 차트 섹션 */} -
-

📊 차트 종류

+
+ -
- + {expandedSections.charts && ( +
+ -
+
+ )}
{/* 위젯 섹션 */} -
-

🔧 위젯 종류

+
+ -
- + {expandedSections.widgets && ( +
+ - - - + /> */} -
+
+ )}
{/* 운영/작업 지원 섹션 */} -
-

📋 운영/작업 지원

+
+ -
- - - - - -
+ {expandedSections.operations && ( +
+ + + + + +
+ )}
); @@ -253,10 +264,9 @@ function DraggableItem({ icon, title, type, subtype, className = "", onDragStart return (
onDragStart(e, type, subtype)} > - {icon} {title}
); diff --git a/frontend/components/admin/dashboard/ElementConfigModal.tsx b/frontend/components/admin/dashboard/ElementConfigModal.tsx index 1c8921eb..05c254bc 100644 --- a/frontend/components/admin/dashboard/ElementConfigModal.tsx +++ b/frontend/components/admin/dashboard/ElementConfigModal.tsx @@ -36,11 +36,17 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element const isSimpleWidget = element.subtype === "vehicle-status" || element.subtype === "vehicle-list" || + element.subtype === "status-summary" || // 커스텀 상태 카드 + // element.subtype === "list-summary" || // 커스텀 목록 카드 (다른 분 작업 중 - 임시 주석) element.subtype === "delivery-status" || + element.subtype === "delivery-status-summary" || + element.subtype === "delivery-today-stats" || + element.subtype === "cargo-list" || + element.subtype === "customer-issues" || element.subtype === "driver-management"; // 지도 위젯 (위도/경도 매핑 필요) - const isMapWidget = element.subtype === "vehicle-map"; + const isMapWidget = element.subtype === "vehicle-map" || element.subtype === "map-summary"; // 주석 // 모달이 열릴 때 초기화 diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts index 27cb4992..833c033a 100644 --- a/frontend/components/admin/dashboard/types.ts +++ b/frontend/components/admin/dashboard/types.ts @@ -19,11 +19,18 @@ export type ElementSubtype = | "calendar" | "calculator" | "vehicle-status" - | "vehicle-list" - | "vehicle-map" + | "vehicle-list" // (구버전 - 호환용) + | "vehicle-map" // (구버전 - 호환용) + | "map-summary" // 범용 지도 카드 (통합) | "delivery-status" + | "status-summary" // 범용 상태 카드 (통합) + // | "list-summary" // 범용 목록 카드 (다른 분 작업 중 - 임시 주석) + | "delivery-status-summary" // (구버전 - 호환용) + | "delivery-today-stats" // (구버전 - 호환용) + | "cargo-list" // (구버전 - 호환용) + | "customer-issues" // (구버전 - 호환용) | "risk-alert" - | "driver-management" + | "driver-management" // (구버전 - 호환용) | "todo" | "booking-alert" | "maintenance" @@ -131,6 +138,9 @@ export interface ChartConfig { // 애니메이션 enableAnimation?: boolean; // 애니메이션 활성화 + + // 상태 필터링 (커스텀 상태 카드용) + statusFilter?: string[]; // 표시할 상태 목록 (예: ["driving", "parked"]) animationDuration?: number; // 애니메이션 시간 (ms) // 툴팁 diff --git a/frontend/components/dashboard/widgets/CargoListWidget.tsx b/frontend/components/dashboard/widgets/CargoListWidget.tsx new file mode 100644 index 00000000..86b7b726 --- /dev/null +++ b/frontend/components/dashboard/widgets/CargoListWidget.tsx @@ -0,0 +1,227 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { DashboardElement } from "@/components/admin/dashboard/types"; + +interface CargoListWidgetProps { + element: DashboardElement; +} + +interface Cargo { + id: string | number; + tracking_number?: string; + trackingNumber?: string; + customer_name?: string; + customerName?: string; + destination?: string; + status?: string; + weight?: number; +} + +/** + * 화물 목록 위젯 + * - 화물 목록 테이블 표시 + * - 상태별 배지 표시 + */ +export default function CargoListWidget({ element }: CargoListWidgetProps) { + const [cargoList, setCargoList] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchTerm, setSearchTerm] = useState(""); + + useEffect(() => { + loadData(); + + // 자동 새로고침 (30초마다) + const interval = setInterval(loadData, 30000); + return () => clearInterval(interval); + }, [element]); + + const loadData = async () => { + if (!element?.dataSource?.query) { + setError("쿼리가 설정되지 않았습니다"); + setLoading(false); + return; + } + + try { + setLoading(true); + const token = localStorage.getItem("authToken"); + const response = await fetch("/api/dashboards/execute-query", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + query: element.dataSource.query, + connectionType: element.dataSource.connectionType || "current", + connectionId: element.dataSource.connectionId, + }), + }); + + if (!response.ok) throw new Error("데이터 로딩 실패"); + + const result = await response.json(); + + if (result.success && result.data?.rows) { + setCargoList(result.data.rows); + } + + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "데이터 로딩 실패"); + } finally { + setLoading(false); + } + }; + + const getStatusBadge = (status: string) => { + const statusLower = status?.toLowerCase() || ""; + + if (statusLower.includes("배송중") || statusLower.includes("delivering")) { + return "bg-primary text-primary-foreground"; + } else if (statusLower.includes("완료") || statusLower.includes("delivered")) { + return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100"; + } else if (statusLower.includes("지연") || statusLower.includes("delayed")) { + return "bg-destructive text-destructive-foreground"; + } else if (statusLower.includes("픽업") || statusLower.includes("pending")) { + return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100"; + } + return "bg-muted text-muted-foreground"; + }; + + const filteredList = cargoList.filter((cargo) => { + if (!searchTerm) return true; + + const trackingNum = cargo.tracking_number || cargo.trackingNumber || ""; + const customerName = cargo.customer_name || cargo.customerName || ""; + const destination = cargo.destination || ""; + + const searchLower = searchTerm.toLowerCase(); + return ( + trackingNum.toLowerCase().includes(searchLower) || + customerName.toLowerCase().includes(searchLower) || + destination.toLowerCase().includes(searchLower) + ); + }); + + if (loading) { + return ( +
+
+
+

데이터 로딩 중...

+
+
+ ); + } + + if (error) { + return ( +
+
+

⚠️ {error}

+ +
+
+ ); + } + + if (!element?.dataSource?.query) { + return ( +
+
+

⚙️ 톱니바퀴를 클릭하여 데이터를 연결하세요

+
+
+ ); + } + + return ( +
+ {/* 헤더 */} +
+

📦 화물 목록

+
+ setSearchTerm(e.target.value)} + className="rounded-md border border-input bg-background px-3 py-1 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring" + /> + +
+
+ + {/* 총 건수 */} +
+ 총 {filteredList.length}건 +
+ + {/* 테이블 */} +
+ + + + + + + + + + + + {filteredList.length === 0 ? ( + + + + ) : ( + filteredList.map((cargo, index) => ( + + + + + + + + )) + )} + +
운송장번호고객명목적지무게(kg)상태
+ {searchTerm ? "검색 결과가 없습니다" : "화물이 없습니다"} +
+ {cargo.tracking_number || cargo.trackingNumber || "-"} + + {cargo.customer_name || cargo.customerName || "-"} + + {cargo.destination || "-"} + + {cargo.weight ? `${cargo.weight}kg` : "-"} + + + {cargo.status || "알 수 없음"} + +
+
+
+ ); +} + diff --git a/frontend/components/dashboard/widgets/CustomerIssuesWidget.tsx b/frontend/components/dashboard/widgets/CustomerIssuesWidget.tsx new file mode 100644 index 00000000..f7f50a43 --- /dev/null +++ b/frontend/components/dashboard/widgets/CustomerIssuesWidget.tsx @@ -0,0 +1,260 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { DashboardElement } from "@/components/admin/dashboard/types"; + +interface CustomerIssuesWidgetProps { + element: DashboardElement; +} + +interface Issue { + id: string | number; + issue_type?: string; + issueType?: string; + customer_name?: string; + customerName?: string; + description?: string; + priority?: string; + created_at?: string; + createdAt?: string; + status?: string; +} + +/** + * 고객 클레임/이슈 위젯 + * - 클레임/이슈 목록 표시 + * - 우선순위별 배지 표시 + */ +export default function CustomerIssuesWidget({ element }: CustomerIssuesWidgetProps) { + const [issues, setIssues] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [filterPriority, setFilterPriority] = useState("all"); + + useEffect(() => { + loadData(); + + // 자동 새로고침 (30초마다) + const interval = setInterval(loadData, 30000); + return () => clearInterval(interval); + }, [element]); + + const loadData = async () => { + if (!element?.dataSource?.query) { + setError("쿼리가 설정되지 않았습니다"); + setLoading(false); + return; + } + + try { + setLoading(true); + const token = localStorage.getItem("authToken"); + const response = await fetch("/api/dashboards/execute-query", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + query: element.dataSource.query, + connectionType: element.dataSource.connectionType || "current", + connectionId: element.dataSource.connectionId, + }), + }); + + if (!response.ok) throw new Error("데이터 로딩 실패"); + + const result = await response.json(); + + if (result.success && result.data?.rows) { + setIssues(result.data.rows); + } + + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "데이터 로딩 실패"); + } finally { + setLoading(false); + } + }; + + const getPriorityBadge = (priority: string) => { + const priorityLower = priority?.toLowerCase() || ""; + + if (priorityLower.includes("긴급") || priorityLower.includes("high") || priorityLower.includes("urgent")) { + return "bg-destructive text-destructive-foreground"; + } else if (priorityLower.includes("보통") || priorityLower.includes("medium") || priorityLower.includes("normal")) { + return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100"; + } else if (priorityLower.includes("낮음") || priorityLower.includes("low")) { + return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100"; + } + return "bg-muted text-muted-foreground"; + }; + + const getStatusBadge = (status: string) => { + const statusLower = status?.toLowerCase() || ""; + + if (statusLower.includes("처리중") || statusLower.includes("processing") || statusLower.includes("pending")) { + return "bg-primary text-primary-foreground"; + } else if (statusLower.includes("완료") || statusLower.includes("resolved") || statusLower.includes("closed")) { + return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100"; + } + return "bg-muted text-muted-foreground"; + }; + + const filteredIssues = filterPriority === "all" + ? issues + : issues.filter((issue) => { + const priority = (issue.priority || "").toLowerCase(); + return priority.includes(filterPriority); + }); + + if (loading) { + return ( +
+
+
+

데이터 로딩 중...

+
+
+ ); + } + + if (error) { + return ( +
+
+

⚠️ {error}

+ +
+
+ ); + } + + if (!element?.dataSource?.query) { + return ( +
+
+

⚙️ 톱니바퀴를 클릭하여 데이터를 연결하세요

+
+
+ ); + } + + return ( +
+ {/* 헤더 */} +
+

⚠️ 고객 클레임/이슈

+ +
+ + {/* 필터 버튼 */} +
+ + + + +
+ + {/* 총 건수 */} +
+ 총 {filteredIssues.length}건 +
+ + {/* 이슈 리스트 */} +
+ {filteredIssues.length === 0 ? ( +
+

이슈가 없습니다

+
+ ) : ( + filteredIssues.map((issue, index) => ( +
+
+
+
+ + {issue.priority || "보통"} + + + {issue.status || "처리중"} + +
+

+ {issue.issue_type || issue.issueType || "기타"} +

+
+
+ +

+ 고객: {issue.customer_name || issue.customerName || "-"} +

+ +

+ {issue.description || "설명 없음"} +

+ + {(issue.created_at || issue.createdAt) && ( +

+ {new Date(issue.created_at || issue.createdAt || "").toLocaleDateString("ko-KR")} +

+ )} +
+ )) + )} +
+
+ ); +} + diff --git a/frontend/components/dashboard/widgets/DeliveryStatusSummaryWidget.tsx b/frontend/components/dashboard/widgets/DeliveryStatusSummaryWidget.tsx new file mode 100644 index 00000000..74c93ad3 --- /dev/null +++ b/frontend/components/dashboard/widgets/DeliveryStatusSummaryWidget.tsx @@ -0,0 +1,214 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { DashboardElement } from "@/components/admin/dashboard/types"; + +interface DeliveryStatusSummaryWidgetProps { + element: DashboardElement; +} + +interface DeliveryStatus { + status: string; + count: number; +} + +/** + * 배송 상태 요약 위젯 + * - 배송중, 완료, 지연, 픽업 대기 상태별 카운트 표시 + */ +export default function DeliveryStatusSummaryWidget({ element }: DeliveryStatusSummaryWidgetProps) { + const [statusData, setStatusData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + loadData(); + + // 자동 새로고침 (30초마다) + const interval = setInterval(loadData, 30000); + return () => clearInterval(interval); + }, [element]); + + const loadData = async () => { + if (!element?.dataSource?.query) { + setError("쿼리가 설정되지 않았습니다"); + setLoading(false); + return; + } + + try { + setLoading(true); + const token = localStorage.getItem("authToken"); + const response = await fetch("/api/dashboards/execute-query", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + query: element.dataSource.query, + connectionType: element.dataSource.connectionType || "current", + connectionId: element.dataSource.connectionId, + }), + }); + + if (!response.ok) throw new Error("데이터 로딩 실패"); + + const result = await response.json(); + + // 데이터 처리 + if (result.success && result.data?.rows) { + const rows = result.data.rows; + + // 상태별 카운트 계산 + const statusCounts = rows.reduce((acc: any, row: any) => { + const status = row.status || "알 수 없음"; + acc[status] = (acc[status] || 0) + 1; + return acc; + }, {}); + + const formattedData: DeliveryStatus[] = [ + { status: "배송중", count: statusCounts["배송중"] || statusCounts["delivering"] || 0 }, + { status: "완료", count: statusCounts["완료"] || statusCounts["delivered"] || 0 }, + { status: "지연", count: statusCounts["지연"] || statusCounts["delayed"] || 0 }, + { status: "픽업 대기", count: statusCounts["픽업 대기"] || statusCounts["pending"] || 0 }, + ]; + + setStatusData(formattedData); + } + + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "데이터 로딩 실패"); + } finally { + setLoading(false); + } + }; + + const getBorderColor = (status: string) => { + switch (status) { + case "배송중": + return "border-blue-500"; + case "완료": + return "border-green-500"; + case "지연": + return "border-red-500"; + case "픽업 대기": + return "border-yellow-500"; + default: + return "border-gray-500"; + } + }; + + const getDotColor = (status: string) => { + switch (status) { + case "배송중": + return "bg-blue-500"; + case "완료": + return "bg-green-500"; + case "지연": + return "bg-red-500"; + case "픽업 대기": + return "bg-yellow-500"; + default: + return "bg-gray-500"; + } + }; + + const getTextColor = (status: string) => { + switch (status) { + case "배송중": + return "text-blue-600"; + case "완료": + return "text-green-600"; + case "지연": + return "text-red-600"; + case "픽업 대기": + return "text-yellow-600"; + default: + return "text-gray-600"; + } + }; + + if (loading) { + return ( +
+
+
+

데이터 로딩 중...

+
+
+ ); + } + + if (error) { + return ( +
+
+

⚠️ {error}

+ +
+
+ ); + } + + if (!element?.dataSource?.query) { + return ( +
+
+

⚙️ 톱니바퀴를 클릭하여 데이터를 연결하세요

+
+
+ ); + } + + const totalCount = statusData.reduce((sum, item) => sum + item.count, 0); + + return ( +
+ {/* 헤더 */} +
+
+

📊 배송 상태 요약

+ {totalCount > 0 ? ( +

총 {totalCount.toLocaleString()}건

+ ) : ( +

⚙️ 데이터 연결 필요

+ )} +
+ +
+ + {/* 스크롤 가능한 콘텐츠 영역 */} +
+ {/* 상태별 카드 */} +
+ {statusData.map((item) => ( +
+
+
+
{item.status}
+
+
{item.count.toLocaleString()}
+
+ ))} +
+
+
+ ); +} + diff --git a/frontend/components/dashboard/widgets/DeliveryTodayStatsWidget.tsx b/frontend/components/dashboard/widgets/DeliveryTodayStatsWidget.tsx new file mode 100644 index 00000000..1d9e0367 --- /dev/null +++ b/frontend/components/dashboard/widgets/DeliveryTodayStatsWidget.tsx @@ -0,0 +1,164 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { DashboardElement } from "@/components/admin/dashboard/types"; + +interface DeliveryTodayStatsWidgetProps { + element: DashboardElement; +} + +interface TodayStats { + shipped: number; + delivered: number; +} + +/** + * 오늘 처리 현황 위젯 + * - 오늘 발송 건수 + * - 오늘 도착 건수 + */ +export default function DeliveryTodayStatsWidget({ element }: DeliveryTodayStatsWidgetProps) { + const [todayStats, setTodayStats] = useState({ shipped: 0, delivered: 0 }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + loadData(); + + // 자동 새로고침 (30초마다) + const interval = setInterval(loadData, 30000); + return () => clearInterval(interval); + }, [element]); + + const loadData = async () => { + if (!element?.dataSource?.query) { + setError("쿼리가 설정되지 않았습니다"); + setLoading(false); + return; + } + + try { + setLoading(true); + const token = localStorage.getItem("authToken"); + const response = await fetch("/api/dashboards/execute-query", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + query: element.dataSource.query, + connectionType: element.dataSource.connectionType || "current", + connectionId: element.dataSource.connectionId, + }), + }); + + if (!response.ok) throw new Error("데이터 로딩 실패"); + + const result = await response.json(); + + // 데이터 처리 + if (result.success && result.data?.rows) { + const rows = result.data.rows; + const today = new Date().toISOString().split("T")[0]; + + // 오늘 발송 건수 (created_at 기준) + const shippedToday = rows.filter((row: any) => { + const createdDate = row.created_at?.split("T")[0] || row.createdAt?.split("T")[0]; + return createdDate === today; + }).length; + + // 오늘 도착 건수 (status === 'delivered' AND estimated_delivery 기준) + const deliveredToday = rows.filter((row: any) => { + const status = row.status || ""; + const deliveryDate = row.estimated_delivery?.split("T")[0] || row.estimatedDelivery?.split("T")[0]; + return (status === "delivered" || status === "완료") && deliveryDate === today; + }).length; + + setTodayStats({ + shipped: shippedToday, + delivered: deliveredToday, + }); + } + + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "데이터 로딩 실패"); + } finally { + setLoading(false); + } + }; + + if (loading) { + return ( +
+
+
+

데이터 로딩 중...

+
+
+ ); + } + + if (error) { + return ( +
+
+

⚠️ {error}

+ +
+
+ ); + } + + if (!element?.dataSource?.query) { + return ( +
+
+

⚙️ 톱니바퀴를 클릭하여 데이터를 연결하세요

+
+
+ ); + } + + return ( +
+ {/* 헤더 */} +
+

📅 오늘 처리 현황

+ +
+ + {/* 통계 카드 */} +
+ {/* 오늘 발송 */} +
+
📤
+

오늘 발송

+

{todayStats.shipped.toLocaleString()}

+

+
+ + {/* 오늘 도착 */} +
+
📥
+

오늘 도착

+

{todayStats.delivered.toLocaleString()}

+

+
+
+
+ ); +} + diff --git a/frontend/components/dashboard/widgets/ListSummaryWidget.tsx b/frontend/components/dashboard/widgets/ListSummaryWidget.tsx new file mode 100644 index 00000000..a9488f44 --- /dev/null +++ b/frontend/components/dashboard/widgets/ListSummaryWidget.tsx @@ -0,0 +1,326 @@ +/** + * ⚠️ 임시 주석 처리된 파일 + * 다른 분이 범용 리스트 작업 중이어서 충돌 방지를 위해 주석 처리 + * 나중에 merge 시 활성화 필요 + */ + +"use client"; + +import React, { useState, useEffect } from "react"; +import { DashboardElement } from "@/components/admin/dashboard/types"; + +interface ListSummaryWidgetProps { + element: DashboardElement; +} + +interface ColumnInfo { + key: string; + label: string; +} + +// 컬럼명 한글 번역 +const translateColumnName = (colName: string): string => { + const columnTranslations: { [key: string]: string } = { + // 공통 + "id": "ID", + "name": "이름", + "status": "상태", + "created_at": "생성일", + "updated_at": "수정일", + "created_date": "생성일", + "updated_date": "수정일", + + // 기사 관련 + "driver_id": "기사ID", + "phone": "전화번호", + "license_number": "면허번호", + "vehicle_id": "차량ID", + "current_location": "현재위치", + "rating": "평점", + "total_deliveries": "총배송건수", + "average_delivery_time": "평균배송시간", + "total_distance": "총운행거리", + "join_date": "가입일", + "last_active": "마지막활동", + + // 차량 관련 + "vehicle_number": "차량번호", + "model": "모델", + "year": "연식", + "color": "색상", + "type": "종류", + + // 배송 관련 + "delivery_id": "배송ID", + "order_id": "주문ID", + "customer_name": "고객명", + "address": "주소", + "delivery_date": "배송일", + "estimated_time": "예상시간", + + // 제품 관련 + "product_id": "제품ID", + "product_name": "제품명", + "price": "가격", + "stock": "재고", + "category": "카테고리", + "description": "설명", + + // 주문 관련 + "order_date": "주문일", + "quantity": "수량", + "total_amount": "총금액", + "payment_status": "결제상태", + + // 고객 관련 + "customer_id": "고객ID", + "email": "이메일", + "company": "회사", + "department": "부서", + }; + + return columnTranslations[colName.toLowerCase()] || + columnTranslations[colName.replace(/_/g, '').toLowerCase()] || + colName; +}; + +/** + * 범용 목록 위젯 + * - SQL 쿼리 결과를 테이블 형식으로 표시 + * - 어떤 데이터든 표시 가능 (기사, 차량, 제품, 주문 등) + */ +export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) { + const [data, setData] = useState([]); + const [columns, setColumns] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [tableName, setTableName] = useState(null); + const [searchTerm, setSearchTerm] = useState(""); + + useEffect(() => { + loadData(); + + // 자동 새로고침 (30초마다) + const interval = setInterval(loadData, 30000); + return () => clearInterval(interval); + }, [element]); + + const loadData = async () => { + if (!element?.dataSource?.query) { + setError(null); + setLoading(false); + setTableName(null); + return; + } + + // 쿼리에서 테이블 이름 추출 + const extractTableName = (query: string): string | null => { + const fromMatch = query.match(/FROM\s+([a-zA-Z0-9_가-힣]+)/i); + if (fromMatch) { + return fromMatch[1]; + } + return null; + }; + + try { + setLoading(true); + const extractedTableName = extractTableName(element.dataSource.query); + setTableName(extractedTableName); + + const token = localStorage.getItem("authToken"); + const response = await fetch("/api/dashboards/execute-query", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + query: element.dataSource.query, + connectionType: element.dataSource.connectionType || "current", + connectionId: element.dataSource.connectionId, + }), + }); + + if (!response.ok) throw new Error("데이터 로딩 실패"); + + const result = await response.json(); + + if (result.success && result.data?.rows) { + const rows = result.data.rows; + + // 컬럼 정보 추출 (한글 번역 적용) + if (rows.length > 0) { + const cols: ColumnInfo[] = Object.keys(rows[0]).map((key) => ({ + key, + label: translateColumnName(key), + })); + setColumns(cols); + } + + setData(rows); + } + + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "데이터 로딩 실패"); + } finally { + setLoading(false); + } + }; + + // 테이블 이름 한글 번역 + const translateTableName = (name: string): string => { + const tableTranslations: { [key: string]: string } = { + "drivers": "기사", + "driver": "기사", + "vehicles": "차량", + "vehicle": "차량", + "products": "제품", + "product": "제품", + "orders": "주문", + "order": "주문", + "customers": "고객", + "customer": "고객", + "deliveries": "배송", + "delivery": "배송", + "users": "사용자", + "user": "사용자", + }; + + return tableTranslations[name.toLowerCase()] || + tableTranslations[name.replace(/_/g, '').toLowerCase()] || + name; + }; + + const displayTitle = tableName ? `${translateTableName(tableName)} 목록` : "데이터 목록"; + + // 검색 필터링 + const filteredData = data.filter((row) => + Object.values(row).some((value) => + String(value).toLowerCase().includes(searchTerm.toLowerCase()) + ) + ); + + if (loading) { + return ( +
+
+
+

데이터 로딩 중...

+
+
+ ); + } + + if (error) { + return ( +
+
+

⚠️ {error}

+ +
+
+ ); + } + + if (!element?.dataSource?.query) { + return ( +
+
+
📋
+

데이터 목록

+
+

📋 테이블 형식 데이터 표시 위젯

+
    +
  • • SQL 쿼리로 데이터를 불러옵니다
  • +
  • • 테이블 형식으로 자동 표시
  • +
  • • 검색 기능 지원
  • +
  • • 실시간 데이터 모니터링 가능
  • +
+
+
+

⚙️ 설정 방법

+

우측 상단 톱니바퀴 버튼을 클릭하여

+

SQL 쿼리를 입력하고 저장하세요

+
+
+
+ ); + } + + return ( +
+ {/* 헤더 */} +
+
+

📋 {displayTitle}

+

총 {filteredData.length.toLocaleString()}건

+
+ +
+ + {/* 검색 */} + {data.length > 0 && ( +
+ setSearchTerm(e.target.value)} + className="w-full rounded border border-gray-300 px-2 py-1 text-xs focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" + /> +
+ )} + + {/* 테이블 */} +
+ {filteredData.length > 0 ? ( + + + + {columns.map((col) => ( + + ))} + + + + {filteredData.map((row, idx) => ( + + {columns.map((col) => ( + + ))} + + ))} + +
+ {col.label} +
+ {String(row[col.key] || "")} +
+ ) : ( +
+

검색 결과가 없습니다

+
+ )} +
+
+ ); +} + diff --git a/frontend/components/dashboard/widgets/MapSummaryWidget.tsx b/frontend/components/dashboard/widgets/MapSummaryWidget.tsx new file mode 100644 index 00000000..53db0b8e --- /dev/null +++ b/frontend/components/dashboard/widgets/MapSummaryWidget.tsx @@ -0,0 +1,224 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import dynamic from "next/dynamic"; +import { DashboardElement } from "@/components/admin/dashboard/types"; +import "leaflet/dist/leaflet.css"; + +// Leaflet 아이콘 경로 설정 (엑박 방지) +if (typeof window !== "undefined") { + const L = require("leaflet"); + delete (L.Icon.Default.prototype as any)._getIconUrl; + L.Icon.Default.mergeOptions({ + iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png", + iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png", + shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png", + }); +} + +// Leaflet 동적 import (SSR 방지) +const MapContainer = dynamic(() => import("react-leaflet").then((mod) => mod.MapContainer), { ssr: false }); +const TileLayer = dynamic(() => import("react-leaflet").then((mod) => mod.TileLayer), { ssr: false }); +const Marker = dynamic(() => import("react-leaflet").then((mod) => mod.Marker), { ssr: false }); +const Popup = dynamic(() => import("react-leaflet").then((mod) => mod.Popup), { ssr: false }); + +// 브이월드 API 키 +const VWORLD_API_KEY = "97AD30D5-FDC4-3481-99C3-158E36422033"; + +interface MapSummaryWidgetProps { + element: DashboardElement; +} + +interface MarkerData { + lat: number; + lng: number; + name: string; + info: any; +} + +// 테이블명 한글 번역 +const translateTableName = (name: string): string => { + const tableTranslations: { [key: string]: string } = { + "vehicle_locations": "차량", + "vehicles": "차량", + "warehouses": "창고", + "warehouse": "창고", + "customers": "고객", + "customer": "고객", + "deliveries": "배송", + "delivery": "배송", + "drivers": "기사", + "driver": "기사", + "stores": "매장", + "store": "매장", + }; + + return tableTranslations[name.toLowerCase()] || + tableTranslations[name.replace(/_/g, '').toLowerCase()] || + name; +}; + +/** + * 범용 지도 위젯 (커스텀 지도 카드) + * - 위도/경도가 있는 모든 데이터를 지도에 표시 + * - 차량, 창고, 고객, 배송 등 모든 위치 데이터 지원 + * - Leaflet + 브이월드 지도 사용 + */ +export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) { + const [markers, setMarkers] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [tableName, setTableName] = useState(null); + + useEffect(() => { + if (element?.dataSource?.query) { + loadMapData(); + } + + // 자동 새로고침 (30초마다) + const interval = setInterval(() => { + if (element?.dataSource?.query) { + loadMapData(); + } + }, 30000); + + return () => clearInterval(interval); + }, [element]); + + const loadMapData = async () => { + if (!element?.dataSource?.query) { + return; + } + + // 쿼리에서 테이블 이름 추출 + const extractTableName = (query: string): string | null => { + const fromMatch = query.match(/FROM\s+([a-zA-Z0-9_가-힣]+)/i); + if (fromMatch) { + return fromMatch[1]; + } + return null; + }; + + try { + setLoading(true); + const extractedTableName = extractTableName(element.dataSource.query); + setTableName(extractedTableName); + + const token = localStorage.getItem("authToken"); + const response = await fetch("/api/dashboards/execute-query", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + query: element.dataSource.query, + connectionType: element.dataSource.connectionType || "current", + connectionId: element.dataSource.connectionId, + }), + }); + + if (!response.ok) throw new Error("데이터 로딩 실패"); + + const result = await response.json(); + + if (result.success && result.data?.rows) { + const rows = result.data.rows; + + // 위도/경도 컬럼 찾기 + const latCol = element.chartConfig?.latitudeColumn || "latitude"; + const lngCol = element.chartConfig?.longitudeColumn || "longitude"; + + // 유효한 좌표 필터링 및 마커 데이터 생성 + const markerData = rows + .filter((row: any) => row[latCol] && row[lngCol]) + .map((row: any) => ({ + lat: parseFloat(row[latCol]), + lng: parseFloat(row[lngCol]), + name: row.name || row.vehicle_number || row.warehouse_name || row.customer_name || "알 수 없음", + info: row, + })); + + setMarkers(markerData); + } + + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "데이터 로딩 실패"); + } finally { + setLoading(false); + } + }; + + const displayTitle = tableName ? `${translateTableName(tableName)} 위치` : "위치 지도"; + + return ( +
+ {/* 헤더 */} +
+
+

📍 {displayTitle}

+ {element?.dataSource?.query ? ( +

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

+ ) : ( +

⚙️ 톱니바퀴 버튼을 눌러 데이터를 연결하세요

+ )} +
+ +
+ + {/* 에러 메시지 (지도 위에 오버레이) */} + {error && ( +
+ ⚠️ {error} +
+ )} + + {/* 지도 (항상 표시) */} +
+ + {/* 브이월드 타일맵 */} + + + {/* 마커 표시 */} + {markers.map((marker, idx) => ( + + +
+
{marker.name}
+ {Object.entries(marker.info) + .filter(([key]) => !["latitude", "longitude", "lat", "lng"].includes(key.toLowerCase())) + .map(([key, value]) => ( +
+ {key}: {String(value)} +
+ ))} +
+
+
+ ))} +
+
+
+ ); +} diff --git a/frontend/components/dashboard/widgets/StatusSummaryWidget.tsx b/frontend/components/dashboard/widgets/StatusSummaryWidget.tsx new file mode 100644 index 00000000..e9641eee --- /dev/null +++ b/frontend/components/dashboard/widgets/StatusSummaryWidget.tsx @@ -0,0 +1,399 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { DashboardElement } from "@/components/admin/dashboard/types"; + +interface StatusSummaryWidgetProps { + element: DashboardElement; + title?: string; + icon?: string; + bgGradient?: string; + statusConfig?: StatusConfig; +} + +interface StatusConfig { + [key: string]: { + label: string; + color: "blue" | "green" | "red" | "yellow" | "orange" | "purple" | "gray"; + }; +} + +// 영어 상태명 → 한글 자동 변환 +const statusTranslations: { [key: string]: string } = { + // 배송 관련 + "delayed": "지연", + "pickup_waiting": "픽업 대기", + "in_transit": "배송 중", + "delivered": "배송완료", + "pending": "대기중", + "processing": "처리중", + "completed": "완료", + "cancelled": "취소됨", + "failed": "실패", + + // 일반 상태 + "active": "활성", + "inactive": "비활성", + "enabled": "사용중", + "disabled": "사용안함", + "online": "온라인", + "offline": "오프라인", + "available": "사용가능", + "unavailable": "사용불가", + + // 승인 관련 + "approved": "승인됨", + "rejected": "거절됨", + "waiting": "대기중", + + // 차량 관련 + "driving": "운행중", + "parked": "주차", + "maintenance": "정비중", + + // 기사 관련 (존중하는 표현) + "waiting": "대기중", + "resting": "휴식중", + "unavailable": "운행불가", + + // 기사 평가 + "excellent": "우수", + "good": "양호", + "average": "보통", + "poor": "미흡", + + // 기사 경력 + "veteran": "베테랑", + "experienced": "숙련", + "intermediate": "중급", + "beginner": "초급", +}; + +// 영어 테이블명 → 한글 자동 변환 +const tableTranslations: { [key: string]: string } = { + // 배송/물류 관련 + "deliveries": "배송", + "delivery": "배송", + "shipments": "출하", + "shipment": "출하", + "orders": "주문", + "order": "주문", + "cargo": "화물", + "cargos": "화물", + "packages": "소포", + "package": "소포", + + // 차량 관련 + "vehicles": "차량", + "vehicle": "차량", + "vehicle_locations": "차량위치", + "vehicle_status": "차량상태", + "drivers": "기사", + "driver": "기사", + + // 사용자/고객 관련 + "users": "사용자", + "user": "사용자", + "customers": "고객", + "customer": "고객", + "members": "회원", + "member": "회원", + + // 제품/재고 관련 + "products": "제품", + "product": "제품", + "items": "항목", + "item": "항목", + "inventory": "재고", + "stock": "재고", + + // 업무 관련 + "tasks": "작업", + "task": "작업", + "projects": "프로젝트", + "project": "프로젝트", + "issues": "이슈", + "issue": "이슈", + "tickets": "티켓", + "ticket": "티켓", + + // 기타 + "logs": "로그", + "log": "로그", + "reports": "리포트", + "report": "리포트", + "alerts": "알림", + "alert": "알림", +}; + +interface StatusData { + status: string; + count: number; +} + +/** + * 범용 상태 요약 위젯 + * - 쿼리 결과를 상태별로 카운트해서 카드로 표시 + * - 색상과 라벨은 statusConfig로 커스터마이징 가능 + */ +export default function StatusSummaryWidget({ + element, + title = "상태 요약", + icon = "📊", + bgGradient = "from-slate-50 to-blue-50", + statusConfig +}: StatusSummaryWidgetProps) { + const [statusData, setStatusData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [tableName, setTableName] = useState(null); + + useEffect(() => { + loadData(); + + // 자동 새로고침 (30초마다) + const interval = setInterval(loadData, 30000); + return () => clearInterval(interval); + }, [element]); + + const loadData = async () => { + if (!element?.dataSource?.query) { + // 쿼리가 없으면 에러가 아니라 초기 상태로 처리 + setError(null); + setLoading(false); + setTableName(null); + return; + } + + // 쿼리에서 테이블 이름 추출 + const extractTableName = (query: string): string | null => { + const fromMatch = query.match(/FROM\s+([a-zA-Z0-9_가-힣]+)/i); + if (fromMatch) { + return fromMatch[1]; + } + return null; + }; + + try { + setLoading(true); + const extractedTableName = extractTableName(element.dataSource.query); + setTableName(extractedTableName); + + const token = localStorage.getItem("authToken"); + const response = await fetch("/api/dashboards/execute-query", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + query: element.dataSource.query, + connectionType: element.dataSource.connectionType || "current", + connectionId: element.dataSource.connectionId, + }), + }); + + if (!response.ok) throw new Error("데이터 로딩 실패"); + + const result = await response.json(); + + // 데이터 처리 + if (result.success && result.data?.rows) { + const rows = result.data.rows; + + // 상태별 카운트 계산 + const statusCounts: { [key: string]: number } = {}; + + // GROUP BY 형식인지 확인 + const isGroupedData = rows.length > 0 && rows[0].count !== undefined; + + if (isGroupedData) { + // GROUP BY 형식: SELECT status, COUNT(*) as count + rows.forEach((row: any) => { + // 다양한 컬럼명 지원 (status, 상태, state 등) + let status = row.status || row.상태 || row.state || row.STATUS || row.label || row.name || "알 수 없음"; + // 영어 → 한글 자동 번역 + status = statusTranslations[status] || statusTranslations[status.toLowerCase()] || status; + const count = parseInt(row.count || row.개수 || row.COUNT || row.cnt) || 0; + statusCounts[status] = count; + }); + } else { + // SELECT * 형식: 전체 데이터를 가져와서 카운트 + rows.forEach((row: any) => { + // 다양한 컬럼명 지원 + let status = row.status || row.상태 || row.state || row.STATUS || row.label || row.name || "알 수 없음"; + // 영어 → 한글 자동 번역 + status = statusTranslations[status] || statusTranslations[status.toLowerCase()] || status; + statusCounts[status] = (statusCounts[status] || 0) + 1; + }); + } + + // statusConfig가 있으면 해당 순서대로, 없으면 전체 표시 + let formattedData: StatusData[]; + if (statusConfig) { + formattedData = Object.keys(statusConfig).map((key) => ({ + status: statusConfig[key].label, + count: statusCounts[key] || 0, + })); + } else { + formattedData = Object.entries(statusCounts).map(([status, count]) => ({ + status, + count, + })); + } + + setStatusData(formattedData); + } + + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "데이터 로딩 실패"); + } finally { + setLoading(false); + } + }; + + const getColorClasses = (status: string) => { + // statusConfig에서 색상 찾기 + let color: string = "gray"; + if (statusConfig) { + const configEntry = Object.entries(statusConfig).find(([_, v]) => v.label === status); + if (configEntry) { + color = configEntry[1].color; + } + } + + const colorMap = { + blue: { border: "border-blue-500", dot: "bg-blue-500", text: "text-blue-600" }, + green: { border: "border-green-500", dot: "bg-green-500", text: "text-green-600" }, + red: { border: "border-red-500", dot: "bg-red-500", text: "text-red-600" }, + yellow: { border: "border-yellow-500", dot: "bg-yellow-500", text: "text-yellow-600" }, + orange: { border: "border-orange-500", dot: "bg-orange-500", text: "text-orange-600" }, + purple: { border: "border-purple-500", dot: "bg-purple-500", text: "text-purple-600" }, + gray: { border: "border-gray-500", dot: "bg-gray-500", text: "text-gray-600" }, + }; + + return colorMap[color as keyof typeof colorMap] || colorMap.gray; + }; + + if (loading) { + return ( +
+
+
+

데이터 로딩 중...

+
+
+ ); + } + + if (error) { + return ( +
+
+

⚠️ {error}

+ +
+
+ ); + } + + if (!element?.dataSource?.query) { + return ( +
+
+
{icon}
+

{title}

+
+

📊 상태별 데이터 집계 위젯

+
    +
  • • SQL 쿼리로 데이터를 불러옵니다
  • +
  • • 상태별로 자동 집계하여 카드로 표시
  • +
  • • 실시간 데이터 모니터링 가능
  • +
  • • 색상과 라벨 커스터마이징 지원
  • +
+
+
+

⚙️ 설정 방법

+

우측 상단 톱니바퀴 버튼을 클릭하여

+

SQL 쿼리를 입력하고 저장하세요

+
+
+
+ ); + } + + const totalCount = statusData.reduce((sum, item) => sum + item.count, 0); + + // 테이블 이름이 있으면 제목을 테이블 이름으로 변경 + const translateTableName = (name: string): string => { + // 정확한 매칭 시도 + if (tableTranslations[name]) { + return tableTranslations[name]; + } + // 소문자로 변환하여 매칭 시도 + if (tableTranslations[name.toLowerCase()]) { + return tableTranslations[name.toLowerCase()]; + } + // 언더스코어 제거하고 매칭 시도 + const nameWithoutUnderscore = name.replace(/_/g, ''); + if (tableTranslations[nameWithoutUnderscore.toLowerCase()]) { + return tableTranslations[nameWithoutUnderscore.toLowerCase()]; + } + // 번역이 없으면 원본 반환 + return name; + }; + + const displayTitle = tableName ? `${translateTableName(tableName)} 현황` : title; + + return ( +
+ {/* 헤더 */} +
+
+

{icon} {displayTitle}

+ {totalCount > 0 ? ( +

총 {totalCount.toLocaleString()}건

+ ) : ( +

⚙️ 데이터 연결 필요

+ )} +
+ +
+ + {/* 스크롤 가능한 콘텐츠 영역 */} +
+ {/* 상태별 카드 */} +
+ {statusData.map((item) => { + const colors = getColorClasses(item.status); + return ( +
+
+
+
{item.status}
+
+
{item.count.toLocaleString()}
+
+ ); + })} +
+
+
+ ); +} + diff --git a/frontend/lib/registry/ComponentRegistry.ts b/frontend/lib/registry/ComponentRegistry.ts index 7a71947b..75f02a74 100644 --- a/frontend/lib/registry/ComponentRegistry.ts +++ b/frontend/lib/registry/ComponentRegistry.ts @@ -348,36 +348,16 @@ export class ComponentRegistry { // Hot Reload 제어 hotReload: { status: async () => { - try { - const hotReload = await import("../utils/hotReload"); - return { - active: hotReload.isHotReloadActive(), - componentCount: this.getComponentCount(), - timestamp: new Date(), - }; - } catch (error) { - console.warn("Hot Reload 모듈 로드 실패:", error); - return { - active: false, - componentCount: this.getComponentCount(), - timestamp: new Date(), - error: "Hot Reload 모듈을 로드할 수 없습니다", - }; - } + // hotReload 기능 제거 (불필요) + return { + active: false, + componentCount: this.getComponentCount(), + timestamp: new Date(), + }; }, force: async () => { - try { - // hotReload 모듈이 존재하는 경우에만 실행 - const hotReload = await import("../utils/hotReload").catch(() => null); - if (hotReload) { - hotReload.forceReloadComponents(); - console.log("✅ 강제 Hot Reload 실행 완료"); - } else { - console.log("⚠️ hotReload 모듈이 없어 건너뜀"); - } - } catch (error) { - console.error("❌ 강제 Hot Reload 실행 실패:", error); - } + // hotReload 기능 비활성화 (불필요) + console.log("⚠️ 강제 Hot Reload는 더 이상 필요하지 않습니다"); }, },