덜된듯
This commit is contained in:
parent
7ceecd15af
commit
40e9958690
|
|
@ -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<StatItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex h-full items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-8 w-8 animate-spin rounded-full border-4 border-blue-500 border-t-transparent" />
|
||||
<div className="mt-2 text-sm text-gray-600">로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !stats) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-gray-50 p-4">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">⚠️</div>
|
||||
<div className="text-sm font-medium text-gray-600">{error || "데이터 없음"}</div>
|
||||
{!element?.dataSource?.query && (
|
||||
<div className="mt-2 text-xs text-gray-500">톱니바퀴 아이콘을 클릭하여 쿼리를 설정하세요</div>
|
||||
)}
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="mt-3 rounded-lg bg-blue-500 px-4 py-2 text-sm text-white hover:bg-blue-600"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-white p-6">
|
||||
<div className="grid w-full gap-4" style={{ gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))" }}>
|
||||
{/* 총 건수 */}
|
||||
<div className="rounded-lg border bg-indigo-50 p-4 text-center">
|
||||
<div className="text-sm text-gray-600">총 건수</div>
|
||||
<div className="mt-2 text-3xl font-bold text-indigo-600">
|
||||
{stats.total_count.toLocaleString()}
|
||||
<span className="ml-1 text-lg">건</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 총 운송량 */}
|
||||
<div className="rounded-lg border bg-green-50 p-4 text-center">
|
||||
<div className="text-sm text-gray-600">총 운송량</div>
|
||||
<div className="mt-2 text-3xl font-bold text-green-600">
|
||||
{stats.total_weight.toFixed(1)}
|
||||
<span className="ml-1 text-lg">톤</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 누적 거리 */}
|
||||
<div className="rounded-lg border bg-blue-50 p-4 text-center">
|
||||
<div className="text-sm text-gray-600">누적 거리</div>
|
||||
<div className="mt-2 text-3xl font-bold text-blue-600">
|
||||
{stats.total_distance.toFixed(1)}
|
||||
<span className="ml-1 text-lg">km</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 정시 도착률 */}
|
||||
<div className="rounded-lg border bg-purple-50 p-4 text-center">
|
||||
<div className="text-sm text-gray-600">정시 도착률</div>
|
||||
<div className="mt-2 text-3xl font-bold text-purple-600">
|
||||
{stats.on_time_rate.toFixed(1)}
|
||||
<span className="ml-1 text-lg">%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 평균 배송시간 */}
|
||||
<div className="rounded-lg border bg-orange-50 p-4 text-center">
|
||||
<div className="text-sm text-gray-600">평균 배송시간</div>
|
||||
<div className="mt-2 text-3xl font-bold text-orange-600">
|
||||
{stats.avg_delivery_time.toFixed(1)}
|
||||
<span className="ml-1 text-lg">분</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue