496 lines
18 KiB
TypeScript
496 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { RefreshCw, Package, TruckIcon, AlertTriangle, CheckCircle, Clock, XCircle } from "lucide-react";
|
|
|
|
interface DeliveryItem {
|
|
id: string;
|
|
trackingNumber: string;
|
|
customer: string;
|
|
origin: string;
|
|
destination: string;
|
|
status: "in_transit" | "delivered" | "delayed" | "pickup_waiting";
|
|
estimatedDelivery: string;
|
|
delayReason?: string;
|
|
priority: "high" | "normal" | "low";
|
|
}
|
|
|
|
interface CustomerIssue {
|
|
id: string;
|
|
customer: string;
|
|
trackingNumber: string;
|
|
issueType: "damage" | "delay" | "missing" | "other";
|
|
description: string;
|
|
status: "open" | "in_progress" | "resolved";
|
|
reportedAt: string;
|
|
}
|
|
|
|
interface DeliveryStatusWidgetProps {
|
|
element?: any; // 대시보드 요소 (dataSource 포함)
|
|
refreshInterval?: number;
|
|
}
|
|
|
|
export default function DeliveryStatusWidget({ element, refreshInterval = 60000 }: DeliveryStatusWidgetProps) {
|
|
const [deliveries, setDeliveries] = useState<DeliveryItem[]>([]);
|
|
const [issues, setIssues] = useState<CustomerIssue[]>([]);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
|
|
const [selectedStatus, setSelectedStatus] = useState<string>("all"); // 필터 상태 추가
|
|
|
|
const loadData = async () => {
|
|
setIsLoading(true);
|
|
|
|
// 설정된 쿼리가 없으면 로딩 중단 (더미 데이터 사용 안 함)
|
|
if (!element?.dataSource?.query) {
|
|
setIsLoading(false);
|
|
setDeliveries([]);
|
|
setIssues([]);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch("/api/dashboards/execute-query", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`,
|
|
},
|
|
body: JSON.stringify({ query: element.dataSource.query }),
|
|
});
|
|
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
if (result.success && result.data.rows.length > 0) {
|
|
// TODO: DB 데이터를 DeliveryItem 형식으로 변환
|
|
setDeliveries(result.data.rows);
|
|
setLastUpdate(new Date());
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("배송 데이터 로드 실패:", error);
|
|
}
|
|
|
|
setIsLoading(false);
|
|
};
|
|
|
|
// 데이터 로드 및 자동 새로고침
|
|
useEffect(() => {
|
|
loadData();
|
|
const interval = setInterval(loadData, refreshInterval);
|
|
return () => clearInterval(interval);
|
|
}, [element?.dataSource?.query, refreshInterval]);
|
|
|
|
// 더미 데이터 완전히 제거 (아래 코드 삭제)
|
|
/*
|
|
// 가상 배송 데이터 (개발용 - 실제 DB 연동 시 삭제)
|
|
const dummyDeliveries: DeliveryItem[] = [
|
|
{
|
|
id: "D001",
|
|
trackingNumber: "TRK-2025-001",
|
|
customer: "삼성전자",
|
|
origin: "서울 물류센터",
|
|
destination: "부산 공장",
|
|
status: "in_transit",
|
|
estimatedDelivery: "2025-10-15 14:00",
|
|
priority: "high",
|
|
},
|
|
{
|
|
id: "D002",
|
|
trackingNumber: "TRK-2025-002",
|
|
customer: "LG화학",
|
|
origin: "인천항",
|
|
destination: "광주 공장",
|
|
status: "delivered",
|
|
estimatedDelivery: "2025-10-14 16:30",
|
|
priority: "normal",
|
|
},
|
|
{
|
|
id: "D003",
|
|
trackingNumber: "TRK-2025-003",
|
|
customer: "현대자동차",
|
|
origin: "평택 물류센터",
|
|
destination: "울산 공장",
|
|
status: "delayed",
|
|
estimatedDelivery: "2025-10-14 18:00",
|
|
delayReason: "교통 체증",
|
|
priority: "high",
|
|
},
|
|
{
|
|
id: "D004",
|
|
trackingNumber: "TRK-2025-004",
|
|
customer: "SK하이닉스",
|
|
origin: "이천 물류센터",
|
|
destination: "청주 공장",
|
|
status: "pickup_waiting",
|
|
estimatedDelivery: "2025-10-15 10:00",
|
|
priority: "normal",
|
|
},
|
|
{
|
|
id: "D005",
|
|
trackingNumber: "TRK-2025-005",
|
|
customer: "포스코",
|
|
origin: "포항 물류센터",
|
|
destination: "광양 제철소",
|
|
status: "delayed",
|
|
estimatedDelivery: "2025-10-14 20:00",
|
|
delayReason: "기상 악화",
|
|
priority: "high",
|
|
},
|
|
];
|
|
|
|
// 가상 고객 이슈 데이터
|
|
const dummyIssues: CustomerIssue[] = [
|
|
{
|
|
id: "I001",
|
|
customer: "삼성전자",
|
|
trackingNumber: "TRK-2025-001",
|
|
issueType: "delay",
|
|
description: "배송 지연으로 인한 생산 일정 차질",
|
|
status: "in_progress",
|
|
reportedAt: "2025-10-14 15:30",
|
|
},
|
|
{
|
|
id: "I002",
|
|
customer: "LG디스플레이",
|
|
trackingNumber: "TRK-2024-998",
|
|
issueType: "damage",
|
|
description: "화물 일부 파손",
|
|
status: "open",
|
|
reportedAt: "2025-10-14 14:20",
|
|
},
|
|
{
|
|
id: "I003",
|
|
customer: "SK이노베이션",
|
|
trackingNumber: "TRK-2024-995",
|
|
issueType: "missing",
|
|
description: "화물 일부 누락",
|
|
status: "resolved",
|
|
reportedAt: "2025-10-13 16:45",
|
|
},
|
|
];
|
|
|
|
*/
|
|
|
|
const getStatusColor = (status: DeliveryItem["status"]) => {
|
|
switch (status) {
|
|
case "in_transit":
|
|
return "bg-blue-100 text-blue-700 border-blue-300";
|
|
case "delivered":
|
|
return "bg-green-100 text-green-700 border-green-300";
|
|
case "delayed":
|
|
return "bg-red-100 text-red-700 border-red-300";
|
|
case "pickup_waiting":
|
|
return "bg-yellow-100 text-yellow-700 border-yellow-300";
|
|
default:
|
|
return "bg-gray-100 text-gray-700 border-gray-300";
|
|
}
|
|
};
|
|
|
|
const getStatusText = (status: DeliveryItem["status"]) => {
|
|
switch (status) {
|
|
case "in_transit":
|
|
return "배송중";
|
|
case "delivered":
|
|
return "완료";
|
|
case "delayed":
|
|
return "지연";
|
|
case "pickup_waiting":
|
|
return "픽업 대기";
|
|
default:
|
|
return "알 수 없음";
|
|
}
|
|
};
|
|
|
|
const getStatusIcon = (status: DeliveryItem["status"]) => {
|
|
switch (status) {
|
|
case "in_transit":
|
|
return <TruckIcon className="h-4 w-4" />;
|
|
case "delivered":
|
|
return <CheckCircle className="h-4 w-4" />;
|
|
case "delayed":
|
|
return <AlertTriangle className="h-4 w-4" />;
|
|
case "pickup_waiting":
|
|
return <Clock className="h-4 w-4" />;
|
|
default:
|
|
return <Package className="h-4 w-4" />;
|
|
}
|
|
};
|
|
|
|
const getIssueTypeText = (type: CustomerIssue["issueType"]) => {
|
|
switch (type) {
|
|
case "damage":
|
|
return "파손";
|
|
case "delay":
|
|
return "지연";
|
|
case "missing":
|
|
return "누락";
|
|
case "other":
|
|
return "기타";
|
|
default:
|
|
return "알 수 없음";
|
|
}
|
|
};
|
|
|
|
const getIssueStatusColor = (status: CustomerIssue["status"]) => {
|
|
switch (status) {
|
|
case "open":
|
|
return "bg-red-100 text-red-700 border-red-300";
|
|
case "in_progress":
|
|
return "bg-yellow-100 text-yellow-700 border-yellow-300";
|
|
case "resolved":
|
|
return "bg-green-100 text-green-700 border-green-300";
|
|
default:
|
|
return "bg-gray-100 text-gray-700 border-gray-300";
|
|
}
|
|
};
|
|
|
|
const getIssueStatusText = (status: CustomerIssue["status"]) => {
|
|
switch (status) {
|
|
case "open":
|
|
return "접수";
|
|
case "in_progress":
|
|
return "처리중";
|
|
case "resolved":
|
|
return "해결";
|
|
default:
|
|
return "알 수 없음";
|
|
}
|
|
};
|
|
|
|
const statusStats = {
|
|
in_transit: deliveries.filter((d) => d.status === "in_transit").length,
|
|
delivered: deliveries.filter((d) => d.status === "delivered").length,
|
|
delayed: deliveries.filter((d) => d.status === "delayed").length,
|
|
pickup_waiting: deliveries.filter((d) => d.status === "pickup_waiting").length,
|
|
};
|
|
|
|
// 필터링된 배송 목록
|
|
const filteredDeliveries = selectedStatus === "all"
|
|
? deliveries
|
|
: deliveries.filter((d) => d.status === selectedStatus);
|
|
|
|
// 오늘 통계 계산
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
|
|
const todayStats = {
|
|
// 오늘 발송 건수 (created_at이 오늘인 것)
|
|
shipped: deliveries.filter((d: any) => {
|
|
if (!d.created_at) return false;
|
|
const createdDate = new Date(d.created_at);
|
|
createdDate.setHours(0, 0, 0, 0);
|
|
return createdDate.getTime() === today.getTime();
|
|
}).length,
|
|
// 오늘 도착 건수 (status가 delivered이고 estimated_delivery가 오늘인 것)
|
|
delivered: deliveries.filter((d: any) => {
|
|
if (d.status !== "delivered" && d.status !== "delivered") return false;
|
|
if (!d.estimated_delivery && !d.estimatedDelivery) return false;
|
|
const deliveredDate = new Date(d.estimated_delivery || d.estimatedDelivery);
|
|
deliveredDate.setHours(0, 0, 0, 0);
|
|
return deliveredDate.getTime() === today.getTime();
|
|
}).length,
|
|
};
|
|
|
|
return (
|
|
<div className="h-full w-full bg-gradient-to-br from-slate-50 to-blue-50 p-4 overflow-auto">
|
|
{/* 헤더 */}
|
|
<div className="mb-3 flex items-center justify-between">
|
|
<div>
|
|
<h3 className="text-lg font-bold text-gray-900">📦 배송 / 화물 처리 현황</h3>
|
|
<p className="text-xs text-gray-500">
|
|
마지막 업데이트: {lastUpdate.toLocaleTimeString("ko-KR")}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={loadData}
|
|
disabled={isLoading}
|
|
className="h-8 w-8 p-0"
|
|
>
|
|
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 배송 상태 요약 */}
|
|
<div className="mb-3">
|
|
<h4 className="mb-2 text-sm font-semibold text-gray-700">배송 상태 요약 (클릭하여 필터링)</h4>
|
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
|
<button
|
|
onClick={() => setSelectedStatus("all")}
|
|
className={`rounded-lg p-1.5 shadow-sm border-l-4 transition-all ${
|
|
selectedStatus === "all"
|
|
? "border-gray-900 bg-gray-100 ring-2 ring-gray-900"
|
|
: "border-gray-500 bg-white hover:bg-gray-50"
|
|
}`}
|
|
>
|
|
<div className="text-xs text-gray-600 mb-0.5">전체</div>
|
|
<div className="text-lg font-bold text-gray-900">{deliveries.length}</div>
|
|
</button>
|
|
<button
|
|
onClick={() => setSelectedStatus("in_transit")}
|
|
className={`rounded-lg p-1.5 shadow-sm border-l-4 transition-all ${
|
|
selectedStatus === "in_transit"
|
|
? "border-blue-900 bg-blue-100 ring-2 ring-blue-900"
|
|
: "border-blue-500 bg-white hover:bg-blue-50"
|
|
}`}
|
|
>
|
|
<div className="text-xs text-gray-600 mb-0.5">배송중</div>
|
|
<div className="text-lg font-bold text-blue-600">{statusStats.in_transit}</div>
|
|
</button>
|
|
<button
|
|
onClick={() => setSelectedStatus("delivered")}
|
|
className={`rounded-lg p-1.5 shadow-sm border-l-4 transition-all ${
|
|
selectedStatus === "delivered"
|
|
? "border-green-900 bg-green-100 ring-2 ring-green-900"
|
|
: "border-green-500 bg-white hover:bg-green-50"
|
|
}`}
|
|
>
|
|
<div className="text-xs text-gray-600 mb-0.5">완료</div>
|
|
<div className="text-lg font-bold text-green-600">{statusStats.delivered}</div>
|
|
</button>
|
|
<button
|
|
onClick={() => setSelectedStatus("delayed")}
|
|
className={`rounded-lg p-1.5 shadow-sm border-l-4 transition-all ${
|
|
selectedStatus === "delayed"
|
|
? "border-red-900 bg-red-100 ring-2 ring-red-900"
|
|
: "border-red-500 bg-white hover:bg-red-50"
|
|
}`}
|
|
>
|
|
<div className="text-xs text-gray-600 mb-0.5">지연</div>
|
|
<div className="text-lg font-bold text-red-600">{statusStats.delayed}</div>
|
|
</button>
|
|
<button
|
|
onClick={() => setSelectedStatus("pickup_waiting")}
|
|
className={`rounded-lg p-1.5 shadow-sm border-l-4 transition-all ${
|
|
selectedStatus === "pickup_waiting"
|
|
? "border-yellow-900 bg-yellow-100 ring-2 ring-yellow-900"
|
|
: "border-yellow-500 bg-white hover:bg-yellow-50"
|
|
}`}
|
|
>
|
|
<div className="text-xs text-gray-600 mb-0.5">픽업 대기</div>
|
|
<div className="text-lg font-bold text-yellow-600">{statusStats.pickup_waiting}</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 오늘 발송/도착 건수 */}
|
|
<div className="mb-3">
|
|
<h4 className="mb-2 text-sm font-semibold text-gray-700">오늘 처리 현황</h4>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div className="rounded-lg bg-white p-1.5 shadow-sm border-l-4 border-gray-500">
|
|
<div className="text-xs text-gray-600 mb-0.5">발송 건수</div>
|
|
<div className="text-lg font-bold text-gray-900">{todayStats.shipped}</div>
|
|
<div className="text-xs text-gray-500">건</div>
|
|
</div>
|
|
<div className="rounded-lg bg-white p-1.5 shadow-sm border-l-4 border-gray-500">
|
|
<div className="text-xs text-gray-600 mb-0.5">도착 건수</div>
|
|
<div className="text-lg font-bold text-gray-900">{todayStats.delivered}</div>
|
|
<div className="text-xs text-gray-500">건</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 필터링된 화물 리스트 */}
|
|
<div className="mb-3">
|
|
<h4 className="mb-2 text-sm font-semibold text-gray-700 flex items-center gap-2">
|
|
<Package className="h-4 w-4 text-gray-600" />
|
|
{selectedStatus === "all" && `전체 화물 (${filteredDeliveries.length})`}
|
|
{selectedStatus === "in_transit" && `배송 중인 화물 (${filteredDeliveries.length})`}
|
|
{selectedStatus === "delivered" && `배송 완료 (${filteredDeliveries.length})`}
|
|
{selectedStatus === "delayed" && `지연 중인 화물 (${filteredDeliveries.length})`}
|
|
{selectedStatus === "pickup_waiting" && `픽업 대기 (${filteredDeliveries.length})`}
|
|
</h4>
|
|
<div className="rounded-lg bg-white shadow-sm border border-gray-200 overflow-hidden">
|
|
{filteredDeliveries.length === 0 ? (
|
|
<div className="p-6 text-center text-sm text-gray-500">
|
|
{selectedStatus === "all" ? "화물이 없습니다" : "해당 상태의 화물이 없습니다"}
|
|
</div>
|
|
) : (
|
|
<div className="max-h-[200px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100">
|
|
{filteredDeliveries.map((delivery) => (
|
|
<div
|
|
key={delivery.id}
|
|
className="p-3 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 transition-colors"
|
|
>
|
|
<div className="flex items-start justify-between mb-2">
|
|
<div>
|
|
<div className="font-semibold text-sm text-gray-900">{delivery.customer}</div>
|
|
<div className="text-xs text-gray-600">{delivery.trackingNumber}</div>
|
|
</div>
|
|
<span className={`rounded-md px-2 py-1 text-xs font-semibold border ${getStatusColor(delivery.status)}`}>
|
|
{getStatusText(delivery.status)}
|
|
</span>
|
|
</div>
|
|
<div className="text-xs text-gray-600 space-y-1">
|
|
<div className="flex items-center gap-1">
|
|
<span className="font-medium">경로:</span>
|
|
<span>{delivery.origin} → {delivery.destination}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<span className="font-medium">예정:</span>
|
|
<span>{delivery.estimatedDelivery}</span>
|
|
</div>
|
|
{delivery.delayReason && (
|
|
<div className="flex items-center gap-1 text-red-600">
|
|
<AlertTriangle className="h-3 w-3" />
|
|
<span className="font-medium">사유:</span>
|
|
<span>{delivery.delayReason}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 고객 클레임/이슈 리포트 */}
|
|
<div>
|
|
<h4 className="mb-2 text-sm font-semibold text-gray-700 flex items-center gap-2">
|
|
<XCircle className="h-4 w-4 text-orange-600" />
|
|
고객 클레임/이슈 ({issues.filter((i) => i.status !== "resolved").length})
|
|
</h4>
|
|
<div className="rounded-lg bg-white shadow-sm border border-gray-200 overflow-hidden">
|
|
{issues.length === 0 ? (
|
|
<div className="p-6 text-center text-sm text-gray-500">
|
|
이슈가 없습니다
|
|
</div>
|
|
) : (
|
|
<div className="max-h-[200px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100">
|
|
{issues.map((issue) => (
|
|
<div
|
|
key={issue.id}
|
|
className="p-3 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 transition-colors"
|
|
>
|
|
<div className="flex items-start justify-between mb-2">
|
|
<div>
|
|
<div className="font-semibold text-sm text-gray-900">{issue.customer}</div>
|
|
<div className="text-xs text-gray-600">{issue.trackingNumber}</div>
|
|
</div>
|
|
<div className="flex gap-1">
|
|
<span className="rounded-md px-2 py-1 text-xs font-semibold bg-gray-100 text-gray-700 border border-gray-300">
|
|
{getIssueTypeText(issue.issueType)}
|
|
</span>
|
|
<span className={`rounded-md px-2 py-1 text-xs font-semibold border ${getIssueStatusColor(issue.status)}`}>
|
|
{getIssueStatusText(issue.status)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="text-xs text-gray-600 space-y-1">
|
|
<div>{issue.description}</div>
|
|
<div className="text-gray-500">접수: {issue.reportedAt}</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|