532 lines
19 KiB
TypeScript
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>
|
|
);
|
|
}
|
|
|