- Added new entries to .gitignore for multi-agent MCP task queue and related rules. - Removed "즉시 저장" (quick insert) options from the ScreenSettingModal and BasicTab components to streamline button configurations. - Cleaned up unused event options in the V2ButtonConfigPanel to enhance clarity and maintainability. These changes aim to improve project organization and simplify the user interface by eliminating redundant options.
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(
|
|
(() => { const d = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; })()
|
|
);
|
|
const [dailyEndDate, setDailyEndDate] = useState(
|
|
(() => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "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-emerald-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>
|
|
);
|
|
}
|
|
|