328 lines
11 KiB
TypeScript
328 lines
11 KiB
TypeScript
/**
|
||
* 운송 통계 위젯
|
||
* - 총 운송량 (톤)
|
||
* - 누적 거리 (km)
|
||
* - 정시 도착률 (%)
|
||
* - 쿼리 결과 기반 통계 계산
|
||
*/
|
||
|
||
"use client";
|
||
|
||
import { useState, useEffect } from "react";
|
||
import { DashboardElement } from "@/components/admin/dashboard/types";
|
||
import { getApiUrl } from "@/lib/utils/apiUrl";
|
||
|
||
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(getApiUrl("/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-muted">
|
||
<div className="text-center">
|
||
<div className="mx-auto h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||
<div className="mt-2 text-sm text-foreground">로딩 중...</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error || !stats) {
|
||
return (
|
||
<div className="flex h-full items-center justify-center bg-muted p-4">
|
||
<div className="text-center">
|
||
<div className="mb-2 text-4xl">⚠️</div>
|
||
<div className="text-sm font-medium text-foreground">{error || "데이터 없음"}</div>
|
||
{!element?.dataSource?.query && <div className="mt-2 text-xs text-muted-foreground">쿼리를 설정하세요</div>}
|
||
<button
|
||
onClick={loadData}
|
||
className="mt-3 rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
|
||
>
|
||
다시 시도
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="flex h-full items-center justify-center bg-background p-6">
|
||
<div className="grid w-full gap-4" style={{ gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))" }}>
|
||
{/* 총 건수 */}
|
||
<div className="rounded-lg border bg-primary/10 p-4 text-center">
|
||
<div className="text-sm text-foreground">총 건수</div>
|
||
<div className="mt-2 text-3xl font-bold text-primary">
|
||
{stats.total_count.toLocaleString()}
|
||
<span className="ml-1 text-lg">건</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 총 운송량 */}
|
||
<div className="rounded-lg border bg-success/10 p-4 text-center">
|
||
<div className="text-sm text-foreground">총 운송량</div>
|
||
<div className="mt-2 text-3xl font-bold text-success">
|
||
{stats.total_weight.toFixed(1)}
|
||
<span className="ml-1 text-lg">톤</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 누적 거리 */}
|
||
<div className="rounded-lg border bg-primary/10 p-4 text-center">
|
||
<div className="text-sm text-foreground">누적 거리</div>
|
||
<div className="mt-2 text-3xl font-bold text-primary">
|
||
{stats.total_distance.toFixed(1)}
|
||
<span className="ml-1 text-lg">km</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 정시 도착률 */}
|
||
<div className="rounded-lg border bg-primary/10 p-4 text-center">
|
||
<div className="text-sm text-foreground">정시 도착률</div>
|
||
<div className="mt-2 text-3xl font-bold text-primary">
|
||
{stats.on_time_rate.toFixed(1)}
|
||
<span className="ml-1 text-lg">%</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 평균 배송시간 */}
|
||
<div className="rounded-lg border bg-warning/10 p-4 text-center">
|
||
<div className="text-sm text-foreground">평균 배송시간</div>
|
||
<div className="mt-2 text-3xl font-bold text-warning">
|
||
{stats.avg_delivery_time.toFixed(1)}
|
||
<span className="ml-1 text-lg">분</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|