Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/dashboard

This commit is contained in:
dohyeons
2025-10-14 16:52:46 +09:00
19 changed files with 2811 additions and 165 deletions

View File

@@ -27,6 +27,16 @@ const VehicleMapWidget = dynamic(() => import("@/components/dashboard/widgets/Ve
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500"> ...</div>,
});
const DeliveryStatusWidget = dynamic(() => import("@/components/dashboard/widgets/DeliveryStatusWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500"> ...</div>,
});
const RiskAlertWidget = dynamic(() => import("@/components/dashboard/widgets/RiskAlertWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500"> ...</div>,
});
// 시계 위젯 임포트
import { ClockWidget } from "./widgets/ClockWidget";
// 달력 위젯 임포트
@@ -448,6 +458,16 @@ export function CanvasElement({
<div className="widget-interactive-area h-full w-full">
<VehicleMapWidget />
</div>
) : element.type === "widget" && element.subtype === "delivery-status" ? (
// 배송/화물 현황 위젯 렌더링
<div className="widget-interactive-area h-full w-full">
<DeliveryStatusWidget />
</div>
) : element.type === "widget" && element.subtype === "risk-alert" ? (
// 리스크/알림 위젯 렌더링
<div className="widget-interactive-area h-full w-full">
<RiskAlertWidget />
</div>
) : element.type === "widget" && element.subtype === "calendar" ? (
// 달력 위젯 렌더링
<div className="h-full w-full">

View File

@@ -135,6 +135,22 @@ export function DashboardSidebar() {
onDragStart={handleDragStart}
className="border-l-4 border-red-500"
/>
<DraggableItem
icon="📦"
title="배송/화물 현황"
type="widget"
subtype="delivery-status"
onDragStart={handleDragStart}
className="border-l-4 border-amber-500"
/>
<DraggableItem
icon="⚠️"
title="리스크/알림 위젯"
type="widget"
subtype="risk-alert"
onDragStart={handleDragStart}
className="border-l-4 border-rose-500"
/>
<DraggableItem
icon="📅"
title="달력 위젯"
@@ -144,7 +160,7 @@ export function DashboardSidebar() {
className="border-l-4 border-indigo-500"
/>
<DraggableItem
icon="🚚"
icon="🚗"
title="기사 관리 위젯"
type="widget"
subtype="driver-management"

View File

@@ -19,6 +19,8 @@ export type ElementSubtype =
| "calendar"
| "calculator"
| "vehicle-map"
| "delivery-status"
| "risk-alert"
| "driver-management"; // 위젯 타입
export interface Position {

View File

@@ -0,0 +1,421 @@
"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 {
refreshInterval?: number;
}
export default function DeliveryStatusWidget({ refreshInterval = 60000 }: DeliveryStatusWidgetProps) {
const [deliveries, setDeliveries] = useState<DeliveryItem[]>([]);
const [issues, setIssues] = useState<CustomerIssue[]>([]);
const [todayStats, setTodayStats] = useState({
shipped: 0,
delivered: 0,
});
const [isLoading, setIsLoading] = useState(false);
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
const loadData = async () => {
setIsLoading(true);
// TODO: 실제 API 연동 시 아래 주석 해제
// try {
// const response = await fetch('/api/delivery/status', {
// headers: {
// 'Authorization': `Bearer ${localStorage.getItem('authToken')}`,
// },
// });
// const data = await response.json();
// setDeliveries(data.deliveries);
// setIssues(data.issues);
// setTodayStats(data.todayStats);
// setLastUpdate(new Date());
// } catch (error) {
// console.error('배송 데이터 로드 실패:', error);
// } finally {
// setIsLoading(false);
// }
// 가상 배송 데이터 (개발용 - 실제 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",
},
];
setTimeout(() => {
setDeliveries(dummyDeliveries);
setIssues(dummyIssues);
setTodayStats({
shipped: 24,
delivered: 18,
});
setLastUpdate(new Date());
setIsLoading(false);
}, 500);
};
useEffect(() => {
loadData();
const interval = setInterval(loadData, refreshInterval);
return () => clearInterval(interval);
}, [refreshInterval]);
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 delayedDeliveries = deliveries.filter((d) => d.status === "delayed");
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-4 gap-2">
<div className="rounded-lg bg-white p-1.5 shadow-sm border-l-4 border-blue-500">
<div className="text-xs text-gray-600 mb-0.5"></div>
<div className="text-lg font-bold text-blue-600">{statusStats.in_transit}</div>
</div>
<div className="rounded-lg bg-white p-1.5 shadow-sm border-l-4 border-green-500">
<div className="text-xs text-gray-600 mb-0.5"></div>
<div className="text-lg font-bold text-green-600">{statusStats.delivered}</div>
</div>
<div className="rounded-lg bg-white p-1.5 shadow-sm border-l-4 border-red-500">
<div className="text-xs text-gray-600 mb-0.5"></div>
<div className="text-lg font-bold text-red-600">{statusStats.delayed}</div>
</div>
<div className="rounded-lg bg-white p-1.5 shadow-sm border-l-4 border-yellow-500">
<div className="text-xs text-gray-600 mb-0.5"> </div>
<div className="text-lg font-bold text-yellow-600">{statusStats.pickup_waiting}</div>
</div>
</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">
<AlertTriangle className="h-4 w-4 text-red-600" />
({delayedDeliveries.length})
</h4>
<div className="rounded-lg bg-white shadow-sm border border-gray-200 overflow-hidden">
{delayedDeliveries.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">
{delayedDeliveries.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>
);
}

View File

@@ -0,0 +1,277 @@
"use client";
import React, { useState, useEffect } from "react";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { RefreshCw, AlertTriangle, Cloud, Construction } from "lucide-react";
import { apiClient } from "@/lib/api/client";
// 알림 타입
type AlertType = "accident" | "weather" | "construction";
// 알림 인터페이스
interface Alert {
id: string;
type: AlertType;
severity: "high" | "medium" | "low";
title: string;
location: string;
description: string;
timestamp: string;
}
export default function RiskAlertWidget() {
const [alerts, setAlerts] = useState<Alert[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false);
const [filter, setFilter] = useState<AlertType | "all">("all");
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [newAlertIds, setNewAlertIds] = useState<Set<string>>(new Set());
// 데이터 로드 (백엔드 통합 호출)
const loadData = async () => {
setIsRefreshing(true);
try {
// 백엔드 API 호출 (교통사고, 기상특보, 도로공사 통합)
const response = await apiClient.get<{
success: boolean;
data: Alert[];
count: number;
lastUpdated?: string;
cached?: boolean;
}>("/risk-alerts");
if (response.data.success && response.data.data) {
const newData = response.data.data;
// 새로운 알림 감지
const oldIds = new Set(alerts.map(a => a.id));
const newIds = new Set<string>();
newData.forEach(alert => {
if (!oldIds.has(alert.id)) {
newIds.add(alert.id);
}
});
setAlerts(newData);
setNewAlertIds(newIds);
setLastUpdated(new Date());
// 3초 후 새 알림 애니메이션 제거
if (newIds.size > 0) {
setTimeout(() => setNewAlertIds(new Set()), 3000);
}
} else {
console.error("❌ 리스크 알림 데이터 로드 실패");
setAlerts([]);
}
} catch (error: any) {
console.error("❌ 리스크 알림 API 오류:", error.message);
// API 오류 시 빈 배열 유지
setAlerts([]);
} finally {
setIsRefreshing(false);
}
};
useEffect(() => {
loadData();
// 1분마다 자동 새로고침 (60000ms)
const interval = setInterval(loadData, 60000);
return () => clearInterval(interval);
}, []);
// 필터링된 알림
const filteredAlerts = filter === "all" ? alerts : alerts.filter((alert) => alert.type === filter);
// 심각도별 색상
const getSeverityColor = (severity: string) => {
switch (severity) {
case "high":
return "border-red-500";
case "medium":
return "border-yellow-500";
case "low":
return "border-blue-500";
default:
return "border-gray-500";
}
};
// 심각도별 배지 색상
const getSeverityBadge = (severity: string) => {
switch (severity) {
case "high":
return "bg-red-100 text-red-700";
case "medium":
return "bg-yellow-100 text-yellow-700";
case "low":
return "bg-blue-100 text-blue-700";
default:
return "bg-gray-100 text-gray-700";
}
};
// 알림 타입별 아이콘
const getAlertIcon = (type: AlertType) => {
switch (type) {
case "accident":
return <AlertTriangle className="h-5 w-5 text-red-600" />;
case "weather":
return <Cloud className="h-5 w-5 text-blue-600" />;
case "construction":
return <Construction className="h-5 w-5 text-yellow-600" />;
}
};
// 알림 타입별 한글명
const getAlertTypeName = (type: AlertType) => {
switch (type) {
case "accident":
return "교통사고";
case "weather":
return "날씨특보";
case "construction":
return "도로공사";
}
};
// 시간 포맷
const formatTime = (isoString: string) => {
const date = new Date(isoString);
const now = new Date();
const diffMinutes = Math.floor((now.getTime() - date.getTime()) / 60000);
if (diffMinutes < 1) return "방금 전";
if (diffMinutes < 60) return `${diffMinutes}분 전`;
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) return `${diffHours}시간 전`;
return `${Math.floor(diffHours / 24)}일 전`;
};
// 통계 계산
const stats = {
accident: alerts.filter((a) => a.type === "accident").length,
weather: alerts.filter((a) => a.type === "weather").length,
construction: alerts.filter((a) => a.type === "construction").length,
high: alerts.filter((a) => a.severity === "high").length,
};
return (
<div className="flex h-full w-full flex-col gap-3 overflow-hidden bg-slate-50 p-3">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-red-600" />
<h3 className="text-base font-semibold text-gray-900"> / </h3>
{stats.high > 0 && (
<Badge className="bg-red-100 text-red-700 hover:bg-red-100"> {stats.high}</Badge>
)}
</div>
<div className="flex items-center gap-2">
{lastUpdated && newAlertIds.size > 0 && (
<Badge className="bg-blue-100 text-blue-700 text-xs animate-pulse">
{newAlertIds.size}
</Badge>
)}
{lastUpdated && (
<span className="text-xs text-gray-500">
{lastUpdated.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })}
</span>
)}
<Button variant="ghost" size="sm" onClick={loadData} disabled={isRefreshing} className="h-8 px-2">
<RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
</Button>
</div>
</div>
{/* 통계 카드 */}
<div className="grid grid-cols-3 gap-2">
<Card
className={`cursor-pointer border-l-4 border-red-500 p-2 transition-colors hover:bg-gray-50 ${filter === "accident" ? "bg-gray-100" : ""}`}
onClick={() => setFilter(filter === "accident" ? "all" : "accident")}
>
<div className="text-xs text-gray-600"></div>
<div className="text-lg font-bold text-gray-900">{stats.accident}</div>
</Card>
<Card
className={`cursor-pointer border-l-4 border-blue-500 p-2 transition-colors hover:bg-gray-50 ${filter === "weather" ? "bg-gray-100" : ""}`}
onClick={() => setFilter(filter === "weather" ? "all" : "weather")}
>
<div className="text-xs text-gray-600"></div>
<div className="text-lg font-bold text-gray-900">{stats.weather}</div>
</Card>
<Card
className={`cursor-pointer border-l-4 border-yellow-500 p-2 transition-colors hover:bg-gray-50 ${filter === "construction" ? "bg-gray-100" : ""}`}
onClick={() => setFilter(filter === "construction" ? "all" : "construction")}
>
<div className="text-xs text-gray-600"></div>
<div className="text-lg font-bold text-gray-900">{stats.construction}</div>
</Card>
</div>
{/* 필터 상태 표시 */}
{filter !== "all" && (
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
{getAlertTypeName(filter)}
</Badge>
<Button
variant="ghost"
size="sm"
onClick={() => setFilter("all")}
className="h-6 px-2 text-xs text-gray-600"
>
</Button>
</div>
)}
{/* 알림 목록 */}
<div className="flex-1 space-y-2 overflow-y-auto">
{filteredAlerts.length === 0 ? (
<Card className="p-4 text-center">
<div className="text-sm text-gray-500"> </div>
</Card>
) : (
filteredAlerts.map((alert) => (
<Card
key={alert.id}
className={`border-l-4 p-3 transition-all duration-300 ${getSeverityColor(alert.severity)} ${
newAlertIds.has(alert.id) ? 'bg-blue-50/30 ring-1 ring-blue-200' : ''
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2">
{getAlertIcon(alert.type)}
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold text-gray-900">{alert.title}</h4>
{newAlertIds.has(alert.id) && (
<Badge className="bg-blue-100 text-blue-700 text-xs">
NEW
</Badge>
)}
<Badge className={`text-xs ${getSeverityBadge(alert.severity)}`}>
{alert.severity === "high" ? "긴급" : alert.severity === "medium" ? "주의" : "정보"}
</Badge>
</div>
<p className="mt-1 text-xs font-medium text-gray-700">{alert.location}</p>
<p className="mt-1 text-xs text-gray-600">{alert.description}</p>
</div>
</div>
</div>
<div className="mt-2 text-right text-xs text-gray-500">{formatTime(alert.timestamp)}</div>
</Card>
))
)}
</div>
{/* 안내 메시지 */}
<div className="border-t border-gray-200 pt-2 text-center text-xs text-gray-500">
💡 1
</div>
</div>
);
}

View File

@@ -262,29 +262,29 @@ export default function VehicleMapWidget({ refreshInterval = 30000 }: VehicleMap
</div>
{/* 차량 상태 요약 */}
<div className="mb-3 grid grid-cols-4 gap-2">
<div className="rounded-lg bg-white p-2 shadow-sm border-l-4 border-green-500">
<div className="text-xs text-gray-600"> </div>
<div className="text-xl font-bold text-green-600">{statusStats.running}</div>
<div className="mb-3 grid grid-cols-2 md:grid-cols-4 gap-2">
<div className="rounded-lg bg-white p-1.5 shadow-sm border-l-4 border-green-500">
<div className="text-xs text-gray-600 mb-0.5"> </div>
<div className="text-lg font-bold text-green-600">{statusStats.running}</div>
</div>
<div className="rounded-lg bg-white p-2 shadow-sm border-l-4 border-yellow-500">
<div className="text-xs text-gray-600"></div>
<div className="text-xl font-bold text-yellow-600">{statusStats.idle}</div>
<div className="rounded-lg bg-white p-1.5 shadow-sm border-l-4 border-yellow-500">
<div className="text-xs text-gray-600 mb-0.5"></div>
<div className="text-lg font-bold text-yellow-600">{statusStats.idle}</div>
</div>
<div className="rounded-lg bg-white p-2 shadow-sm border-l-4 border-orange-500">
<div className="text-xs text-gray-600"></div>
<div className="text-xl font-bold text-orange-600">{statusStats.maintenance}</div>
<div className="rounded-lg bg-white p-1.5 shadow-sm border-l-4 border-orange-500">
<div className="text-xs text-gray-600 mb-0.5"></div>
<div className="text-lg font-bold text-orange-600">{statusStats.maintenance}</div>
</div>
<div className="rounded-lg bg-white p-2 shadow-sm border-l-4 border-red-500">
<div className="text-xs text-gray-600"></div>
<div className="text-xl font-bold text-red-600">{statusStats.breakdown}</div>
<div className="rounded-lg bg-white p-1.5 shadow-sm border-l-4 border-red-500">
<div className="text-xs text-gray-600 mb-0.5"></div>
<div className="text-lg font-bold text-red-600">{statusStats.breakdown}</div>
</div>
</div>
<div className="grid h-[calc(100%-120px)] gap-3 lg:grid-cols-3">
<div className="flex h-[calc(100%-120px)] gap-3">
{/* 지도 영역 - 브이월드 타일맵 */}
<div className="lg:col-span-2">
<div className="relative h-full rounded-lg overflow-hidden border-2 border-gray-300 bg-white">
<div className="flex-1 min-w-0 overflow-auto">
<div className="relative h-full min-h-[400px] min-w-[600px] rounded-lg overflow-hidden border-2 border-gray-300 bg-white">
{typeof window !== "undefined" && (
<MapContainer
center={[36.5, 127.5]}
@@ -358,175 +358,185 @@ export default function VehicleMapWidget({ refreshInterval = 30000 }: VehicleMap
</div>
</div>
{/* 차량 목록 */}
<div className="flex flex-col gap-2 overflow-y-auto">
<div className="rounded-lg bg-white/70 p-3">
<h4 className="mb-2 text-sm font-bold text-gray-700">
({vehicles.length})
</h4>
{/* 우측 사이드 패널 */}
<div className="w-80 flex flex-col gap-3 overflow-y-auto max-h-full">
{/* 차량 목록 */}
<div className="rounded-lg bg-white shadow-sm border border-gray-200 overflow-hidden">
<div className="bg-gray-50 border-b border-gray-200 p-3">
<h4 className="text-sm font-semibold text-gray-900 flex items-center gap-2">
<Truck className="h-4 w-4 text-gray-600" />
({vehicles.length})
</h4>
</div>
{vehicles.length === 0 ? (
<div className="py-8 text-center text-sm text-gray-500">
</div>
) : (
<div className="space-y-2">
{vehicles.map((vehicle) => (
<div
key={vehicle.id}
onClick={() => setSelectedVehicle(vehicle)}
className={`cursor-pointer rounded-lg border-2 p-3 transition-all hover:shadow-md ${
selectedVehicle?.id === vehicle.id
? "border-blue-500 bg-blue-50"
: "border-gray-200 bg-white hover:border-gray-300"
}`}
>
<div className="mb-2 flex items-start justify-between">
<div className="flex items-center gap-2">
<Truck className="h-4 w-4 text-gray-600" />
<span className="font-semibold text-gray-900">
<div className="p-2 max-h-[320px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100">
{vehicles.length === 0 ? (
<div className="py-8 text-center text-sm text-gray-500">
</div>
) : (
<div className="space-y-2">
{vehicles.map((vehicle) => (
<div
key={vehicle.id}
onClick={() => setSelectedVehicle(vehicle)}
className={`cursor-pointer rounded-lg border p-2 transition-all hover:shadow-sm ${
selectedVehicle?.id === vehicle.id
? "border-gray-900 bg-gray-50 ring-1 ring-gray-900"
: "border-gray-200 bg-white hover:border-gray-300"
}`}
>
<div className="flex items-center justify-between mb-1">
<span className="font-semibold text-sm text-gray-900">
{vehicle.name}
</span>
<span
className="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
style={{ backgroundColor: getStatusColor(vehicle.status) }}
>
{getStatusText(vehicle.status)}
</span>
</div>
<span
className="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
style={{ backgroundColor: getStatusColor(vehicle.status) }}
>
{getStatusText(vehicle.status)}
</span>
</div>
<div className="space-y-1 text-xs text-gray-600">
<div className="flex items-center gap-1">
<span className="font-medium">:</span>
<span>{vehicle.driver}</span>
</div>
<div className="flex items-center gap-1">
<div className="text-xs text-gray-600 flex items-center gap-1">
<Navigation className="h-3 w-3" />
<span>{vehicle.destination}</span>
<span className="truncate">{vehicle.destination}</span>
</div>
{vehicle.status === "running" && (
<>
<div className="flex items-center gap-1">
<span className="font-medium">:</span>
<span className="text-blue-600 font-semibold">
{vehicle.speed} km/h
</span>
</div>
<div className="flex items-center gap-1">
<span className="font-medium">:</span>
<span>{vehicle.distance} km</span>
</div>
<div className="flex items-center gap-1">
<span className="font-medium">:</span>
<span>{vehicle.fuel} L</span>
</div>
</>
)}
{vehicle.isRefrigerated && vehicle.temperature !== undefined && (
<div className="flex items-center gap-1 mt-1 pt-1 border-t border-gray-200">
<span className="font-medium">:</span>
<span className={`font-semibold ${
vehicle.temperature < -15 ? "text-blue-600" :
vehicle.temperature < 5 ? "text-cyan-600" :
"text-orange-600"
}`}>
{vehicle.temperature}°C
</span>
<span className="text-xs text-gray-500">
({vehicle.temperature < -10 ? "냉동" : "냉장"})
</span>
</div>
)}
</div>
</div>
))}
</div>
)}
))}
</div>
)}
</div>
</div>
{/* 선택된 차량 상세 정보 */}
{selectedVehicle && (
<div className="rounded-lg bg-blue-50 border-2 border-blue-200 p-3">
<h4 className="mb-2 text-sm font-bold text-blue-900">
📍 {selectedVehicle.name}
</h4>
<div className="space-y-2 text-xs text-gray-700">
<div className="flex justify-between">
<span> ID:</span>
<span className="font-semibold">{selectedVehicle.id}</span>
{selectedVehicle ? (
<div className="rounded-lg bg-white shadow-sm border border-gray-200 overflow-hidden max-h-[400px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100">
{/* 헤더 */}
<div className="bg-gray-900 p-4">
<div className="flex items-center justify-between mb-2">
<h4 className="text-base font-semibold text-white flex items-center gap-2">
<Truck className="h-5 w-5" />
{selectedVehicle.name}
</h4>
<button
onClick={() => setSelectedVehicle(null)}
className="text-gray-400 hover:text-white transition-colors"
>
</button>
</div>
<div className="flex justify-between">
<span>:</span>
<span className="font-semibold">{selectedVehicle.driver}</span>
</div>
<div className="flex justify-between">
<span>:</span>
<span className="font-mono text-xs">
{selectedVehicle.lat.toFixed(4)}, {selectedVehicle.lng.toFixed(4)}
<div className="flex items-center gap-2">
<span
className="rounded-full px-3 py-1 text-xs font-semibold text-white"
style={{ backgroundColor: getStatusColor(selectedVehicle.status) }}
>
{getStatusText(selectedVehicle.status)}
</span>
<span className="text-sm text-gray-400">{selectedVehicle.id}</span>
</div>
<div className="flex justify-between">
<span>:</span>
<span className="font-semibold">{selectedVehicle.destination}</span>
</div>
<div className="border-t border-blue-300 pt-2 mt-2">
<div className="font-semibold mb-1 text-blue-900"> </div>
<div className="flex justify-between">
<span> :</span>
<span className="font-semibold text-blue-600">{selectedVehicle.speed} km/h</span>
</div>
{/* 기사 정보 */}
<div className="p-4 border-b border-gray-200">
<h5 className="text-xs font-semibold text-gray-500 mb-2">👤 </h5>
<div className="space-y-1.5">
<div className="flex justify-between text-sm">
<span className="text-gray-600"></span>
<span className="font-semibold text-gray-900">{selectedVehicle.driver}</span>
</div>
<div className="flex justify-between">
<span> :</span>
<span>{selectedVehicle.avgSpeed} km/h</span>
</div>
<div className="flex justify-between">
<span> :</span>
<span>{selectedVehicle.distance} km</span>
</div>
<div className="flex justify-between">
<span> :</span>
<span>{selectedVehicle.fuel} L</span>
<div className="flex justify-between text-sm">
<span className="text-gray-600">GPS </span>
<span className="font-mono text-xs text-gray-700">
{selectedVehicle.lat.toFixed(4)}, {selectedVehicle.lng.toFixed(4)}
</span>
</div>
</div>
{selectedVehicle.isRefrigerated && selectedVehicle.temperature !== undefined && (
<div className="border-t border-blue-300 pt-2 mt-2">
<div className="font-semibold mb-1 text-blue-900">/ </div>
<div className="flex justify-between">
<span> :</span>
<span className={`font-bold ${
selectedVehicle.temperature < -15 ? "text-blue-600" :
selectedVehicle.temperature < 5 ? "text-cyan-600" :
"text-orange-600"
}`}>
</div>
{/* 운행 정보 */}
<div className="p-4 border-b border-gray-200">
<h5 className="text-xs font-semibold text-gray-500 mb-2">📍 </h5>
<div className="space-y-1.5">
<div className="flex justify-between text-sm">
<span className="text-gray-600"></span>
<span className="font-semibold text-gray-900">{selectedVehicle.destination}</span>
</div>
</div>
</div>
{/* 실시간 데이터 */}
<div className="p-4 border-b border-gray-200">
<h5 className="text-xs font-semibold text-gray-500 mb-2">📊 </h5>
<div className="grid grid-cols-2 gap-2">
<div className="bg-gray-50 rounded-lg p-2 border border-gray-200">
<div className="text-xs text-gray-600 mb-0.5"> </div>
<div className="text-lg font-bold text-gray-900">{selectedVehicle.speed}</div>
<div className="text-xs text-gray-500">km/h</div>
</div>
<div className="bg-gray-50 rounded-lg p-2 border border-gray-200">
<div className="text-xs text-gray-600 mb-0.5"> </div>
<div className="text-lg font-bold text-gray-900">{selectedVehicle.avgSpeed}</div>
<div className="text-xs text-gray-500">km/h</div>
</div>
<div className="bg-gray-50 rounded-lg p-2 border border-gray-200">
<div className="text-xs text-gray-600 mb-0.5"> </div>
<div className="text-lg font-bold text-gray-900">{selectedVehicle.distance}</div>
<div className="text-xs text-gray-500">km</div>
</div>
<div className="bg-gray-50 rounded-lg p-2 border border-gray-200">
<div className="text-xs text-gray-600 mb-0.5"> </div>
<div className="text-lg font-bold text-gray-900">{selectedVehicle.fuel}</div>
<div className="text-xs text-gray-500">L</div>
</div>
</div>
</div>
{/* 냉동/냉장 상태 */}
{selectedVehicle.isRefrigerated && selectedVehicle.temperature !== undefined && (
<div className="p-4">
<h5 className="text-xs font-semibold text-gray-500 mb-3"> / </h5>
<div className="rounded-lg p-4 border border-gray-200 bg-gray-50">
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-gray-600"> </span>
<span className="text-3xl font-bold text-gray-900">
{selectedVehicle.temperature}°C
</span>
</div>
<div className="flex justify-between">
<span> :</span>
<span className="text-gray-600">
{selectedVehicle.temperature < -10 ? "-18°C ~ -15°C" : "0°C ~ 5°C"}
</span>
</div>
<div className="flex justify-between">
<span>:</span>
<span className={`font-semibold ${
Math.abs(selectedVehicle.temperature - (selectedVehicle.temperature < -10 ? -18 : 2)) < 5
? "text-green-600"
: "text-orange-600"
}`}>
{Math.abs(selectedVehicle.temperature - (selectedVehicle.temperature < -10 ? -18 : 2)) < 5
? "정상"
: "주의"}
</span>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600"></span>
<span className="font-semibold text-gray-900">
{selectedVehicle.temperature < -10 ? "냉동" : "냉장"}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600"> </span>
<span className="font-semibold text-gray-900">
{selectedVehicle.temperature < -10 ? "-18°C ~ -15°C" : "0°C ~ 5°C"}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600"></span>
<span className={`px-3 py-1 rounded-md text-xs font-semibold border ${
Math.abs(selectedVehicle.temperature - (selectedVehicle.temperature < -10 ? -18 : 2)) < 5
? "bg-gray-900 text-white border-gray-900"
: "bg-white text-gray-900 border-gray-300"
}`}>
{Math.abs(selectedVehicle.temperature - (selectedVehicle.temperature < -10 ? -18 : 2)) < 5
? "✓ 정상"
: "⚠ 주의"}
</span>
</div>
</div>
</div>
)}
</div>
</div>
)}
</div>
) : (
<div className="rounded-lg bg-white shadow-lg border border-gray-200 p-8 text-center">
<Truck className="h-12 w-12 text-gray-300 mx-auto mb-3" />
<p className="text-sm text-gray-500"> </p>
<p className="text-sm text-gray-500"> </p>
</div>
)}
</div>