ERP-node/frontend/components/vehicle/VehicleTripHistory.tsx

532 lines
19 KiB
TypeScript

"use client";
import React, { useState, useEffect, useCallback } from "react";
import {
getTripList,
getTripDetail,
formatDistance,
formatDuration,
getStatusLabel,
getStatusColor,
TripSummary,
TripDetail,
TripListFilters,
} 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 {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Search,
RefreshCw,
MapPin,
Clock,
Route,
ChevronLeft,
ChevronRight,
Eye,
} from "lucide-react";
import { format } from "date-fns";
import { ko } from "date-fns/locale";
const PAGE_SIZE = 20;
export default function VehicleTripHistory() {
// 상태
const [trips, setTrips] = useState<TripSummary[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
// 필터
const [filters, setFilters] = useState<TripListFilters>({
status: "",
startDate: "",
endDate: "",
departure: "",
arrival: "",
});
// 상세 모달
const [selectedTrip, setSelectedTrip] = useState<TripDetail | null>(null);
const [detailModalOpen, setDetailModalOpen] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
// 데이터 로드
const loadTrips = useCallback(async () => {
setLoading(true);
try {
const response = await getTripList({
...filters,
status: filters.status || undefined,
startDate: filters.startDate || undefined,
endDate: filters.endDate || undefined,
departure: filters.departure || undefined,
arrival: filters.arrival || undefined,
limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE,
});
if (response.success) {
setTrips(response.data || []);
setTotal(response.total || 0);
}
} catch (error) {
console.error("운행 이력 조회 실패:", error);
} finally {
setLoading(false);
}
}, [filters, page]);
useEffect(() => {
loadTrips();
}, [loadTrips]);
// 상세 조회
const handleViewDetail = async (tripId: string) => {
setDetailLoading(true);
setDetailModalOpen(true);
try {
const response = await getTripDetail(tripId);
if (response.success && response.data) {
setSelectedTrip(response.data);
}
} catch (error) {
console.error("운행 상세 조회 실패:", error);
} finally {
setDetailLoading(false);
}
};
// 필터 변경
const handleFilterChange = (key: keyof TripListFilters, value: string) => {
setFilters((prev) => ({ ...prev, [key]: value }));
setPage(1);
};
// 검색
const handleSearch = () => {
setPage(1);
loadTrips();
};
// 초기화
const handleReset = () => {
setFilters({
status: "",
startDate: "",
endDate: "",
departure: "",
arrival: "",
});
setPage(1);
};
// 페이지네이션
const totalPages = Math.ceil(total / PAGE_SIZE);
return (
<div className="space-y-4">
{/* 필터 영역 */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-5">
{/* 상태 */}
<div className="space-y-1">
<Label className="text-xs"></Label>
<Select
value={filters.status || "all"}
onValueChange={(v) =>
handleFilterChange("status", v === "all" ? "" : v)
}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="active"> </SelectItem>
<SelectItem value="completed"></SelectItem>
<SelectItem value="cancelled"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 시작일 */}
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
type="date"
value={filters.startDate || ""}
onChange={(e) => handleFilterChange("startDate", e.target.value)}
className="h-9"
/>
</div>
{/* 종료일 */}
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
type="date"
value={filters.endDate || ""}
onChange={(e) => handleFilterChange("endDate", e.target.value)}
className="h-9"
/>
</div>
{/* 출발지 */}
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
placeholder="출발지"
value={filters.departure || ""}
onChange={(e) => handleFilterChange("departure", e.target.value)}
className="h-9"
/>
</div>
{/* 도착지 */}
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
placeholder="도착지"
value={filters.arrival || ""}
onChange={(e) => handleFilterChange("arrival", e.target.value)}
className="h-9"
/>
</div>
</div>
<div className="mt-4 flex gap-2">
<Button onClick={handleSearch} size="sm">
<Search className="mr-1 h-4 w-4" />
</Button>
<Button onClick={handleReset} variant="outline" size="sm">
<RefreshCw className="mr-1 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
{/* 목록 */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg">
({total.toLocaleString()})
</CardTitle>
<Button onClick={loadTrips} variant="ghost" size="sm">
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[120px]">ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead> </TableHead>
<TableHead> </TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={10} className="h-24 text-center">
...
</TableCell>
</TableRow>
) : trips.length === 0 ? (
<TableRow>
<TableCell colSpan={10} className="h-24 text-center">
.
</TableCell>
</TableRow>
) : (
trips.map((trip) => (
<TableRow key={trip.trip_id}>
<TableCell className="font-mono text-xs">
{trip.trip_id.substring(0, 15)}...
</TableCell>
<TableCell>{trip.user_name || trip.user_id}</TableCell>
<TableCell>{trip.departure_name || trip.departure || "-"}</TableCell>
<TableCell>{trip.destination_name || trip.arrival || "-"}</TableCell>
<TableCell className="text-xs">
{format(new Date(trip.start_time), "MM/dd HH:mm", {
locale: ko,
})}
</TableCell>
<TableCell className="text-xs">
{trip.end_time
? format(new Date(trip.end_time), "MM/dd HH:mm", {
locale: ko,
})
: "-"}
</TableCell>
<TableCell className="text-right">
{trip.total_distance
? formatDistance(Number(trip.total_distance))
: "-"}
</TableCell>
<TableCell className="text-right">
{trip.duration_minutes
? formatDuration(trip.duration_minutes)
: "-"}
</TableCell>
<TableCell className="text-center">
<Badge className={getStatusColor(trip.status)}>
{getStatusLabel(trip.status)}
</Badge>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={() => handleViewDetail(trip.trip_id)}
>
<Eye className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* 페이지네이션 */}
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm">
{page} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
)}
</CardContent>
</Card>
{/* 상세 모달 */}
<Dialog open={detailModalOpen} onOpenChange={setDetailModalOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
{detailLoading ? (
<div className="flex h-48 items-center justify-center">
...
</div>
) : selectedTrip ? (
<div className="space-y-4">
{/* 요약 정보 */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<div className="rounded-lg bg-muted p-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<MapPin className="h-3 w-3" />
</div>
<div className="mt-1 font-medium">
{selectedTrip.summary.departure_name ||
selectedTrip.summary.departure ||
"-"}
</div>
</div>
<div className="rounded-lg bg-muted p-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<MapPin className="h-3 w-3" />
</div>
<div className="mt-1 font-medium">
{selectedTrip.summary.destination_name ||
selectedTrip.summary.arrival ||
"-"}
</div>
</div>
<div className="rounded-lg bg-muted p-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Route className="h-3 w-3" />
</div>
<div className="mt-1 font-medium">
{selectedTrip.summary.total_distance
? formatDistance(Number(selectedTrip.summary.total_distance))
: "-"}
</div>
</div>
<div className="rounded-lg bg-muted p-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
</div>
<div className="mt-1 font-medium">
{selectedTrip.summary.duration_minutes
? formatDuration(selectedTrip.summary.duration_minutes)
: "-"}
</div>
</div>
</div>
{/* 운행 정보 */}
<div className="rounded-lg border p-4">
<h4 className="mb-3 font-medium"> </h4>
<div className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground"> ID</span>
<span className="font-mono text-xs">
{selectedTrip.summary.trip_id}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span>
{selectedTrip.summary.user_name ||
selectedTrip.summary.user_id}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"> </span>
<span>
{format(
new Date(selectedTrip.summary.start_time),
"yyyy-MM-dd HH:mm:ss",
{ locale: ko }
)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"> </span>
<span>
{selectedTrip.summary.end_time
? format(
new Date(selectedTrip.summary.end_time),
"yyyy-MM-dd HH:mm:ss",
{ locale: ko }
)
: "-"}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<Badge className={getStatusColor(selectedTrip.summary.status)}>
{getStatusLabel(selectedTrip.summary.status)}
</Badge>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"> </span>
<span>{selectedTrip.summary.location_count}</span>
</div>
</div>
</div>
{/* 경로 데이터 */}
{selectedTrip.route && selectedTrip.route.length > 0 && (
<div className="rounded-lg border p-4">
<h4 className="mb-3 font-medium">
({selectedTrip.route.length} )
</h4>
<div className="max-h-48 overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[60px]">#</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead> </TableHead>
<TableHead> </TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedTrip.route.map((loc, idx) => (
<TableRow key={loc.id}>
<TableCell className="text-xs">{idx + 1}</TableCell>
<TableCell className="font-mono text-xs">
{loc.latitude.toFixed(6)}
</TableCell>
<TableCell className="font-mono text-xs">
{loc.longitude.toFixed(6)}
</TableCell>
<TableCell className="text-xs">
{loc.accuracy ? `${loc.accuracy.toFixed(0)}m` : "-"}
</TableCell>
<TableCell className="text-xs">
{loc.distance_from_prev
? formatDistance(Number(loc.distance_from_prev))
: "-"}
</TableCell>
<TableCell className="text-xs">
{format(new Date(loc.recorded_at), "HH:mm:ss", {
locale: ko,
})}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
) : (
<div className="flex h-48 items-center justify-center">
.
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}