661 lines
24 KiB
TypeScript
661 lines
24 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useCallback } from "react";
|
|
import {
|
|
getSummaryReport,
|
|
getDailyReport,
|
|
getMonthlyReport,
|
|
getDriverReport,
|
|
getRouteReport,
|
|
formatDistance,
|
|
formatDuration,
|
|
SummaryReport,
|
|
DailyStat,
|
|
MonthlyStat,
|
|
DriverStat,
|
|
RouteStat,
|
|
} from "@/lib/api/vehicleTrip";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import {
|
|
RefreshCw,
|
|
Car,
|
|
Route,
|
|
Clock,
|
|
Users,
|
|
TrendingUp,
|
|
MapPin,
|
|
} from "lucide-react";
|
|
import { format } from "date-fns";
|
|
import { ko } from "date-fns/locale";
|
|
|
|
export default function VehicleReport() {
|
|
// 요약 통계
|
|
const [summary, setSummary] = useState<SummaryReport | null>(null);
|
|
const [summaryPeriod, setSummaryPeriod] = useState("month");
|
|
const [summaryLoading, setSummaryLoading] = useState(false);
|
|
|
|
// 일별 통계
|
|
const [dailyData, setDailyData] = useState<DailyStat[]>([]);
|
|
const [dailyStartDate, setDailyStartDate] = useState(
|
|
new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0]
|
|
);
|
|
const [dailyEndDate, setDailyEndDate] = useState(
|
|
new Date().toISOString().split("T")[0]
|
|
);
|
|
const [dailyLoading, setDailyLoading] = useState(false);
|
|
|
|
// 월별 통계
|
|
const [monthlyData, setMonthlyData] = useState<MonthlyStat[]>([]);
|
|
const [monthlyYear, setMonthlyYear] = useState(new Date().getFullYear());
|
|
const [monthlyLoading, setMonthlyLoading] = useState(false);
|
|
|
|
// 운전자별 통계
|
|
const [driverData, setDriverData] = useState<DriverStat[]>([]);
|
|
const [driverLoading, setDriverLoading] = useState(false);
|
|
|
|
// 구간별 통계
|
|
const [routeData, setRouteData] = useState<RouteStat[]>([]);
|
|
const [routeLoading, setRouteLoading] = useState(false);
|
|
|
|
// 요약 로드
|
|
const loadSummary = useCallback(async () => {
|
|
setSummaryLoading(true);
|
|
try {
|
|
const response = await getSummaryReport(summaryPeriod);
|
|
if (response.success) {
|
|
setSummary(response.data);
|
|
}
|
|
} catch (error) {
|
|
console.error("요약 통계 조회 실패:", error);
|
|
} finally {
|
|
setSummaryLoading(false);
|
|
}
|
|
}, [summaryPeriod]);
|
|
|
|
// 일별 로드
|
|
const loadDaily = useCallback(async () => {
|
|
setDailyLoading(true);
|
|
try {
|
|
const response = await getDailyReport({
|
|
startDate: dailyStartDate,
|
|
endDate: dailyEndDate,
|
|
});
|
|
if (response.success) {
|
|
setDailyData(response.data?.data || []);
|
|
}
|
|
} catch (error) {
|
|
console.error("일별 통계 조회 실패:", error);
|
|
} finally {
|
|
setDailyLoading(false);
|
|
}
|
|
}, [dailyStartDate, dailyEndDate]);
|
|
|
|
// 월별 로드
|
|
const loadMonthly = useCallback(async () => {
|
|
setMonthlyLoading(true);
|
|
try {
|
|
const response = await getMonthlyReport({ year: monthlyYear });
|
|
if (response.success) {
|
|
setMonthlyData(response.data?.data || []);
|
|
}
|
|
} catch (error) {
|
|
console.error("월별 통계 조회 실패:", error);
|
|
} finally {
|
|
setMonthlyLoading(false);
|
|
}
|
|
}, [monthlyYear]);
|
|
|
|
// 운전자별 로드
|
|
const loadDrivers = useCallback(async () => {
|
|
setDriverLoading(true);
|
|
try {
|
|
const response = await getDriverReport({ limit: 20 });
|
|
if (response.success) {
|
|
setDriverData(response.data || []);
|
|
}
|
|
} catch (error) {
|
|
console.error("운전자별 통계 조회 실패:", error);
|
|
} finally {
|
|
setDriverLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
// 구간별 로드
|
|
const loadRoutes = useCallback(async () => {
|
|
setRouteLoading(true);
|
|
try {
|
|
const response = await getRouteReport({ limit: 20 });
|
|
if (response.success) {
|
|
setRouteData(response.data || []);
|
|
}
|
|
} catch (error) {
|
|
console.error("구간별 통계 조회 실패:", error);
|
|
} finally {
|
|
setRouteLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
// 초기 로드
|
|
useEffect(() => {
|
|
loadSummary();
|
|
}, [loadSummary]);
|
|
|
|
// 기간 레이블
|
|
const getPeriodLabel = (period: string) => {
|
|
switch (period) {
|
|
case "today":
|
|
return "오늘";
|
|
case "week":
|
|
return "최근 7일";
|
|
case "month":
|
|
return "최근 30일";
|
|
case "year":
|
|
return "올해";
|
|
default:
|
|
return period;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* 요약 통계 카드 */}
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-lg font-semibold">요약 통계</h2>
|
|
<div className="flex items-center gap-2">
|
|
<Select value={summaryPeriod} onValueChange={setSummaryPeriod}>
|
|
<SelectTrigger className="w-[140px]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="today">오늘</SelectItem>
|
|
<SelectItem value="week">최근 7일</SelectItem>
|
|
<SelectItem value="month">최근 30일</SelectItem>
|
|
<SelectItem value="year">올해</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={loadSummary}
|
|
disabled={summaryLoading}
|
|
>
|
|
<RefreshCw
|
|
className={`h-4 w-4 ${summaryLoading ? "animate-spin" : ""}`}
|
|
/>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{summary && (
|
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-6">
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<Car className="h-3 w-3" />
|
|
총 운행
|
|
</div>
|
|
<div className="mt-1 text-2xl font-bold">
|
|
{summary.totalTrips.toLocaleString()}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{getPeriodLabel(summaryPeriod)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<TrendingUp className="h-3 w-3" />
|
|
완료율
|
|
</div>
|
|
<div className="mt-1 text-2xl font-bold">
|
|
{summary.completionRate}%
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{summary.completedTrips} / {summary.totalTrips}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<Route className="h-3 w-3" />
|
|
총 거리
|
|
</div>
|
|
<div className="mt-1 text-2xl font-bold">
|
|
{formatDistance(summary.totalDistance)}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
평균 {formatDistance(summary.avgDistance)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<Clock className="h-3 w-3" />
|
|
총 시간
|
|
</div>
|
|
<div className="mt-1 text-2xl font-bold">
|
|
{formatDuration(summary.totalDuration)}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
평균 {formatDuration(Math.round(summary.avgDuration))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<Users className="h-3 w-3" />
|
|
운전자
|
|
</div>
|
|
<div className="mt-1 text-2xl font-bold">
|
|
{summary.activeDrivers}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">활동 중</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<Car className="h-3 w-3" />
|
|
진행 중
|
|
</div>
|
|
<div className="mt-1 text-2xl font-bold text-green-600">
|
|
{summary.activeTrips}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">현재 운행</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 상세 통계 탭 */}
|
|
<Tabs defaultValue="daily" className="space-y-4">
|
|
<TabsList>
|
|
<TabsTrigger value="daily" onClick={loadDaily}>
|
|
일별 통계
|
|
</TabsTrigger>
|
|
<TabsTrigger value="monthly" onClick={loadMonthly}>
|
|
월별 통계
|
|
</TabsTrigger>
|
|
<TabsTrigger value="drivers" onClick={loadDrivers}>
|
|
운전자별
|
|
</TabsTrigger>
|
|
<TabsTrigger value="routes" onClick={loadRoutes}>
|
|
구간별
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* 일별 통계 */}
|
|
<TabsContent value="daily">
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-base">일별 운행 통계</CardTitle>
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex items-center gap-1">
|
|
<Label className="text-xs">시작</Label>
|
|
<Input
|
|
type="date"
|
|
value={dailyStartDate}
|
|
onChange={(e) => setDailyStartDate(e.target.value)}
|
|
className="h-8 w-[130px]"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Label className="text-xs">종료</Label>
|
|
<Input
|
|
type="date"
|
|
value={dailyEndDate}
|
|
onChange={(e) => setDailyEndDate(e.target.value)}
|
|
className="h-8 w-[130px]"
|
|
/>
|
|
</div>
|
|
<Button size="sm" onClick={loadDaily} disabled={dailyLoading}>
|
|
조회
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="rounded-md border">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>날짜</TableHead>
|
|
<TableHead className="text-right">운행 수</TableHead>
|
|
<TableHead className="text-right">완료</TableHead>
|
|
<TableHead className="text-right">취소</TableHead>
|
|
<TableHead className="text-right">총 거리</TableHead>
|
|
<TableHead className="text-right">평균 거리</TableHead>
|
|
<TableHead className="text-right">총 시간</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{dailyLoading ? (
|
|
<TableRow>
|
|
<TableCell colSpan={7} className="h-24 text-center">
|
|
로딩 중...
|
|
</TableCell>
|
|
</TableRow>
|
|
) : dailyData.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={7} className="h-24 text-center">
|
|
데이터가 없습니다.
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
dailyData.map((row) => (
|
|
<TableRow key={row.date}>
|
|
<TableCell>
|
|
{format(new Date(row.date), "MM/dd (E)", {
|
|
locale: ko,
|
|
})}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
{row.tripCount}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
{row.completedCount}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
{row.cancelledCount}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
{formatDistance(row.totalDistance)}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
{formatDistance(row.avgDistance)}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
{formatDuration(row.totalDuration)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
{/* 월별 통계 */}
|
|
<TabsContent value="monthly">
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-base">월별 운행 통계</CardTitle>
|
|
<div className="flex items-center gap-2">
|
|
<Select
|
|
value={String(monthlyYear)}
|
|
onValueChange={(v) => setMonthlyYear(parseInt(v))}
|
|
>
|
|
<SelectTrigger className="w-[100px]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{[0, 1, 2].map((offset) => {
|
|
const year = new Date().getFullYear() - offset;
|
|
return (
|
|
<SelectItem key={year} value={String(year)}>
|
|
{year}년
|
|
</SelectItem>
|
|
);
|
|
})}
|
|
</SelectContent>
|
|
</Select>
|
|
<Button
|
|
size="sm"
|
|
onClick={loadMonthly}
|
|
disabled={monthlyLoading}
|
|
>
|
|
조회
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="rounded-md border">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>월</TableHead>
|
|
<TableHead className="text-right">운행 수</TableHead>
|
|
<TableHead className="text-right">완료</TableHead>
|
|
<TableHead className="text-right">취소</TableHead>
|
|
<TableHead className="text-right">총 거리</TableHead>
|
|
<TableHead className="text-right">평균 거리</TableHead>
|
|
<TableHead className="text-right">운전자 수</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{monthlyLoading ? (
|
|
<TableRow>
|
|
<TableCell colSpan={7} className="h-24 text-center">
|
|
로딩 중...
|
|
</TableCell>
|
|
</TableRow>
|
|
) : monthlyData.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={7} className="h-24 text-center">
|
|
데이터가 없습니다.
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
monthlyData.map((row) => (
|
|
<TableRow key={row.month}>
|
|
<TableCell>{row.month}월</TableCell>
|
|
<TableCell className="text-right">
|
|
{row.tripCount}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
{row.completedCount}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
{row.cancelledCount}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
{formatDistance(row.totalDistance)}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
{formatDistance(row.avgDistance)}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
{row.driverCount}
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
{/* 운전자별 통계 */}
|
|
<TabsContent value="drivers">
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-base">운전자별 통계</CardTitle>
|
|
<Button
|
|
size="sm"
|
|
onClick={loadDrivers}
|
|
disabled={driverLoading}
|
|
>
|
|
<RefreshCw
|
|
className={`mr-1 h-4 w-4 ${driverLoading ? "animate-spin" : ""}`}
|
|
/>
|
|
새로고침
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="rounded-md border">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>운전자</TableHead>
|
|
<TableHead className="text-right">운행 수</TableHead>
|
|
<TableHead className="text-right">완료</TableHead>
|
|
<TableHead className="text-right">총 거리</TableHead>
|
|
<TableHead className="text-right">평균 거리</TableHead>
|
|
<TableHead className="text-right">총 시간</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{driverLoading ? (
|
|
<TableRow>
|
|
<TableCell colSpan={6} className="h-24 text-center">
|
|
로딩 중...
|
|
</TableCell>
|
|
</TableRow>
|
|
) : driverData.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={6} className="h-24 text-center">
|
|
데이터가 없습니다.
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
driverData.map((row) => (
|
|
<TableRow key={row.userId}>
|
|
<TableCell className="font-medium">
|
|
{row.userName}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
{row.tripCount}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
{row.completedCount}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
{formatDistance(row.totalDistance)}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
{formatDistance(row.avgDistance)}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
{formatDuration(row.totalDuration)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
{/* 구간별 통계 */}
|
|
<TabsContent value="routes">
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-base">구간별 통계</CardTitle>
|
|
<Button size="sm" onClick={loadRoutes} disabled={routeLoading}>
|
|
<RefreshCw
|
|
className={`mr-1 h-4 w-4 ${routeLoading ? "animate-spin" : ""}`}
|
|
/>
|
|
새로고침
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="rounded-md border">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>
|
|
<div className="flex items-center gap-1">
|
|
<MapPin className="h-3 w-3" />
|
|
출발지
|
|
</div>
|
|
</TableHead>
|
|
<TableHead>
|
|
<div className="flex items-center gap-1">
|
|
<MapPin className="h-3 w-3" />
|
|
도착지
|
|
</div>
|
|
</TableHead>
|
|
<TableHead className="text-right">운행 수</TableHead>
|
|
<TableHead className="text-right">총 거리</TableHead>
|
|
<TableHead className="text-right">평균 거리</TableHead>
|
|
<TableHead className="text-right">평균 시간</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{routeLoading ? (
|
|
<TableRow>
|
|
<TableCell colSpan={6} className="h-24 text-center">
|
|
로딩 중...
|
|
</TableCell>
|
|
</TableRow>
|
|
) : routeData.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={6} className="h-24 text-center">
|
|
데이터가 없습니다.
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
routeData.map((row, idx) => (
|
|
<TableRow key={idx}>
|
|
<TableCell>{row.departureName}</TableCell>
|
|
<TableCell>{row.destinationName}</TableCell>
|
|
<TableCell className="text-right">
|
|
{row.tripCount}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
{formatDistance(row.totalDistance)}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
{formatDistance(row.avgDistance)}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
{formatDuration(Math.round(row.avgDuration))}
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
}
|
|
|