From 40e9958690f5ee92e23287ca3b349de9cb536680 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Mon, 20 Oct 2025 15:53:08 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8D=9C=EB=90=9C=EB=93=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/TransportStatsWidget.tsx | 314 ++++++++++++++++++ 1 file changed, 314 insertions(+) create mode 100644 frontend/components/dashboard/widgets/TransportStatsWidget.tsx diff --git a/frontend/components/dashboard/widgets/TransportStatsWidget.tsx b/frontend/components/dashboard/widgets/TransportStatsWidget.tsx new file mode 100644 index 00000000..f8af7db4 --- /dev/null +++ b/frontend/components/dashboard/widgets/TransportStatsWidget.tsx @@ -0,0 +1,314 @@ +/** + * 운송 통계 위젯 + * - 총 운송량 (톤) + * - 누적 거리 (km) + * - 정시 도착률 (%) + * - 쿼리 결과 기반 통계 계산 + */ + +"use client"; + +import { useState, useEffect } from "react"; +import { DashboardElement } from "@/components/admin/dashboard/types"; + +interface TransportStatsWidgetProps { + element?: DashboardElement; + refreshInterval?: number; +} + +interface StatItem { + label: string; + value: number; + unit: string; + color: string; + icon?: string; +} + +interface StatsData { + total_count: number; + total_weight: number; + total_distance: number; + on_time_rate: number; + avg_delivery_time: number; + [key: string]: number; // 동적 속성 추가 +} + +export default function TransportStatsWidget({ element, refreshInterval = 60000 }: TransportStatsWidgetProps) { + const [stats, setStats] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // 데이터 로드 + const loadData = async () => { + try { + setIsLoading(true); + setError(null); + + // 쿼리가 설정되어 있지 않으면 안내 메시지만 표시 + if (!element?.dataSource?.query) { + setError("쿼리를 설정해주세요"); + setIsLoading(false); + return; + } + + // 쿼리 실행하여 통계 계산 + 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", + externalConnectionId: element.dataSource.externalConnectionId, + }), + }); + + if (!response.ok) throw new Error("데이터 로딩 실패"); + + const result = await response.json(); + + if (!result.success || !result.data?.rows) { + throw new Error(result.message || "데이터 로드 실패"); + } + + const data = result.data.rows || []; + + if (data.length === 0) { + setStats({ total_count: 0, total_weight: 0, total_distance: 0, on_time_rate: 0, avg_delivery_time: 0 }); + return; + } + + // 자동으로 숫자 컬럼 감지 및 합계 계산 + const firstRow = data[0]; + const numericColumns: { [key: string]: number } = {}; + + // 모든 컬럼을 순회하며 숫자 컬럼 찾기 + Object.keys(firstRow).forEach((key) => { + const value = firstRow[key]; + // 숫자로 변환 가능한 컬럼만 선택 + if (value !== null && !isNaN(parseFloat(value))) { + numericColumns[key] = data.reduce((sum: number, item: any) => { + return sum + (parseFloat(item[key]) || 0); + }, 0); + } + }); + + // 특정 키워드를 포함한 컬럼 자동 매핑 + const weightKeys = ["weight", "cargo_weight", "total_weight", "중량", "무게"]; + const distanceKeys = ["distance", "total_distance", "거리", "주행거리"]; + const onTimeKeys = ["is_on_time", "on_time", "onTime", "정시", "정시도착"]; + const deliveryTimeKeys = ["delivery_duration", "delivery_time", "duration", "배송시간", "소요시간", "배송소요시간"]; + + // 총 운송량 찾기 + let total_weight = 0; + for (const key of Object.keys(numericColumns)) { + if (weightKeys.some((keyword) => key.toLowerCase().includes(keyword.toLowerCase()))) { + total_weight = numericColumns[key]; + break; + } + } + + // 누적 거리 찾기 + let total_distance = 0; + for (const key of Object.keys(numericColumns)) { + if (distanceKeys.some((keyword) => key.toLowerCase().includes(keyword.toLowerCase()))) { + total_distance = numericColumns[key]; + break; + } + } + + // 정시 도착률 계산 + let on_time_rate = 0; + for (const key of Object.keys(firstRow)) { + if (onTimeKeys.some((keyword) => key.toLowerCase().includes(keyword.toLowerCase()))) { + const onTimeItems = data.filter((item: any) => { + const onTime = item[key]; + return onTime !== null && onTime !== undefined; + }); + + if (onTimeItems.length > 0) { + const onTimeCount = onTimeItems.filter((item: any) => { + const onTime = item[key]; + return onTime === true || onTime === "true" || onTime === 1 || onTime === "1"; + }).length; + + on_time_rate = (onTimeCount / onTimeItems.length) * 100; + } + break; + } + } + + // 평균 배송시간 계산 + let avg_delivery_time = 0; + + // 1. 먼저 배송시간 컬럼이 있는지 확인 + let foundTimeColumn = false; + for (const key of Object.keys(numericColumns)) { + if (deliveryTimeKeys.some((keyword) => key.toLowerCase().includes(keyword.toLowerCase()))) { + const validItems = data.filter((item: any) => { + const time = parseFloat(item[key]); + return !isNaN(time) && time > 0; + }); + + if (validItems.length > 0) { + const totalTime = validItems.reduce((sum: number, item: any) => { + return sum + parseFloat(item[key]); + }, 0); + avg_delivery_time = totalTime / validItems.length; + foundTimeColumn = true; + } + break; + } + } + + // 2. 배송시간 컬럼이 없으면 날짜 컬럼에서 자동 계산 + if (!foundTimeColumn) { + const startTimeKeys = ["created_at", "start_time", "departure_time", "출발시간", "시작시간"]; + const endTimeKeys = ["actual_delivery", "end_time", "arrival_time", "도착시간", "완료시간", "estimated_delivery"]; + + let startKey = null; + let endKey = null; + + // 시작 시간 컬럼 찾기 + for (const key of Object.keys(firstRow)) { + if (startTimeKeys.some((keyword) => key.toLowerCase().includes(keyword.toLowerCase()))) { + startKey = key; + break; + } + } + + // 종료 시간 컬럼 찾기 + for (const key of Object.keys(firstRow)) { + if (endTimeKeys.some((keyword) => key.toLowerCase().includes(keyword.toLowerCase()))) { + endKey = key; + break; + } + } + + // 두 컬럼이 모두 있으면 시간 차이 계산 + if (startKey && endKey) { + const validItems = data.filter((item: any) => { + return item[startKey] && item[endKey]; + }); + + if (validItems.length > 0) { + const totalMinutes = validItems.reduce((sum: number, item: any) => { + const start = new Date(item[startKey]).getTime(); + const end = new Date(item[endKey]).getTime(); + const diffMinutes = (end - start) / (1000 * 60); // 밀리초 -> 분 + return sum + (diffMinutes > 0 ? diffMinutes : 0); + }, 0); + avg_delivery_time = totalMinutes / validItems.length; + } + } + } + + const calculatedStats: StatsData = { + total_count: data.length, // 총 건수 + total_weight, + total_distance, + on_time_rate, + avg_delivery_time, + }; + + setStats(calculatedStats); + } catch (err) { + console.error("통계 로드 실패:", err); + setError(err instanceof Error ? err.message : "데이터를 불러올 수 없습니다"); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + loadData(); + const interval = setInterval(loadData, refreshInterval); + return () => clearInterval(interval); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [refreshInterval, element?.dataSource]); + + if (isLoading && !stats) { + return ( +
+
+
+
로딩 중...
+
+
+ ); + } + + if (error || !stats) { + return ( +
+
+
⚠️
+
{error || "데이터 없음"}
+ {!element?.dataSource?.query && ( +
톱니바퀴 아이콘을 클릭하여 쿼리를 설정하세요
+ )} + +
+
+ ); + } + + return ( +
+
+ {/* 총 건수 */} +
+
총 건수
+
+ {stats.total_count.toLocaleString()} + +
+
+ + {/* 총 운송량 */} +
+
총 운송량
+
+ {stats.total_weight.toFixed(1)} + +
+
+ + {/* 누적 거리 */} +
+
누적 거리
+
+ {stats.total_distance.toFixed(1)} + km +
+
+ + {/* 정시 도착률 */} +
+
정시 도착률
+
+ {stats.on_time_rate.toFixed(1)} + % +
+
+ + {/* 평균 배송시간 */} +
+
평균 배송시간
+
+ {stats.avg_delivery_time.toFixed(1)} + +
+
+
+
+ ); +}