mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 08:48:11 +00:00
Build issue resolution
This commit is contained in:
@@ -1,264 +1,129 @@
|
|||||||
"use client";
|
'use client';
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from 'react';
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Users, Armchair, TrendingUp, Train, Download } from "lucide-react";
|
import { Users, Armchair, BarChart3, Train, Download } from 'lucide-react';
|
||||||
import {
|
import { apiClient } from '@/lib/api-client';
|
||||||
BarChart,
|
import { formatDateTime } from '@/lib/utils';
|
||||||
Bar,
|
import ActionButton from '@/components/ui/ActionButton';
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
Cell,
|
|
||||||
} from "recharts";
|
|
||||||
import { apiClient } from "@/lib/api-client";
|
|
||||||
import { formatDateTime } from "@/lib/utils";
|
|
||||||
import ActionButton from "@/components/ui/ActionButton";
|
|
||||||
|
|
||||||
const COLORS = [
|
interface ScheduleOption { id: string; label: string; }
|
||||||
"#10b981",
|
|
||||||
"#3b82f6",
|
|
||||||
"#f59e0b",
|
|
||||||
"#8b5cf6",
|
|
||||||
"#ef4444",
|
|
||||||
"#06b6d4",
|
|
||||||
];
|
|
||||||
|
|
||||||
function StatCard({
|
interface PassengersReport {
|
||||||
label,
|
schedule: { id: string; trainName: string; origin: string; destination: string; departureAt: string; arrivalAt: string; };
|
||||||
value,
|
summary: { totalSeats: number; totalPassengers: number; occupancyRate: number };
|
||||||
sub,
|
byCoach: { coachNumber: string; coachType: string; totalSeats: number; booked: number; occupancyRate: number }[];
|
||||||
icon: Icon,
|
byClass: { className: string; totalSeats: number; booked: number; occupancyRate: number }[];
|
||||||
color,
|
byOrigin: { stationName: string; passengers: number }[];
|
||||||
}: {
|
byDestination: { stationName: string; passengers: number }[];
|
||||||
label: string;
|
|
||||||
value: string | number;
|
|
||||||
sub?: string;
|
|
||||||
icon: any;
|
|
||||||
color: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="card flex flex-col gap-1">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
||||||
{label}
|
|
||||||
</p>
|
|
||||||
<div className={`rounded-lg p-1.5 ${color}`}>
|
|
||||||
<Icon className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-2xl font-bold tabular-nums mt-1">{value}</p>
|
|
||||||
{sub && <p className="text-xs text-muted-foreground">{sub}</p>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PassengerRow {
|
interface PassengerRow {
|
||||||
bookingRef: string;
|
bookingRef: string;
|
||||||
bookingStatus: string;
|
|
||||||
passengerName: string;
|
passengerName: string;
|
||||||
passengerCategory: string;
|
coachSeat: string;
|
||||||
idDocumentType: string | null;
|
origin: string;
|
||||||
idDocumentNumber: string | null;
|
destination: string;
|
||||||
passportNumber: string | null;
|
departureAt: string | null;
|
||||||
passportCountry: string | null;
|
|
||||||
seatLabel: string | null;
|
|
||||||
coachNumber: string | null;
|
|
||||||
coachType: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tab = "occupancy" | "list";
|
type Tab = 'occupancy' | 'list';
|
||||||
|
|
||||||
export default function PassengersReportPage() {
|
export default function PassengersReportPage() {
|
||||||
const [scheduleId, setScheduleId] = useState("");
|
const [scheduleId, setScheduleId] = useState('');
|
||||||
const [tab, setTab] = useState<Tab>("occupancy");
|
const [tab, setTab] = useState<Tab>('occupancy');
|
||||||
const [listSearch, setListSearch] = useState("");
|
const [listSearch, setListSearch] = useState('');
|
||||||
|
|
||||||
const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery<
|
const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery<ScheduleOption[]>({
|
||||||
ScheduleOption[]
|
queryKey: ['report-schedules'],
|
||||||
>({
|
queryFn: () => apiClient.get('/reports/schedules'),
|
||||||
queryKey: ["report-schedules"],
|
|
||||||
queryFn: () => apiClient.get("/reports/schedules"),
|
|
||||||
});
|
});
|
||||||
const schedules = schedulesRaw ?? [];
|
const schedules = schedulesRaw ?? [];
|
||||||
|
|
||||||
const { data, isLoading, isError } = useQuery<PassengersReport>({
|
const { data, isLoading, isError } = useQuery<PassengersReport>({
|
||||||
queryKey: ["passengers-report", scheduleId],
|
queryKey: ['passengers-report', scheduleId],
|
||||||
queryFn: () =>
|
queryFn: () => apiClient.get(`/reports/passengers?scheduleId=${scheduleId}`),
|
||||||
apiClient.get(`/reports/passengers?scheduleId=${scheduleId}`),
|
|
||||||
enabled: !!scheduleId,
|
enabled: !!scheduleId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: passengerList = [], isLoading: listLoading } = useQuery<
|
const { data: passengerList = [], isLoading: listLoading } = useQuery<PassengerRow[]>({
|
||||||
PassengerRow[]
|
queryKey: ['passengers-list', scheduleId],
|
||||||
>({
|
queryFn: () => apiClient.get(`/reports/passengers/list?scheduleId=${scheduleId}`),
|
||||||
queryKey: ["passengers-list", scheduleId],
|
|
||||||
queryFn: () =>
|
|
||||||
apiClient.get(`/reports/passengers/list?scheduleId=${scheduleId}`),
|
|
||||||
enabled: !!scheduleId,
|
enabled: !!scheduleId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredList = listSearch.trim()
|
const filteredList = listSearch.trim()
|
||||||
? passengerList.filter(
|
? passengerList.filter(p =>
|
||||||
(p) =>
|
p.passengerName.toLowerCase().includes(listSearch.toLowerCase()) ||
|
||||||
p.passengerName.toLowerCase().includes(listSearch.toLowerCase()) ||
|
p.bookingRef.toLowerCase().includes(listSearch.toLowerCase()),
|
||||||
p.bookingRef.toLowerCase().includes(listSearch.toLowerCase()) ||
|
|
||||||
(p.idDocumentNumber ?? "")
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(listSearch.toLowerCase()) ||
|
|
||||||
(p.passportNumber ?? "")
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(listSearch.toLowerCase()),
|
|
||||||
)
|
)
|
||||||
: passengerList;
|
: passengerList;
|
||||||
|
|
||||||
const downloadCsv = (csv: string, filename: string) => {
|
const downloadCsv = (csv: string, filename: string) => {
|
||||||
const blob = new Blob([csv], { type: "text/csv" });
|
const blob = new Blob([csv], { type: 'text/csv' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url; a.download = filename; a.click();
|
||||||
a.download = filename;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
const doExportOccupancy = () => {
|
const doExportOccupancy = () => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
const rows = data.byCoach.map((c) => [
|
const rows = data.byCoach.map(c => [c.coachNumber, c.coachType, String(c.totalSeats), String(c.booked), `${c.occupancyRate}%`]);
|
||||||
c.coachNumber,
|
downloadCsv([['Coach', 'Type', 'Total Seats', 'Booked', 'Occupancy'].join(','), ...rows.map(r => r.join(','))].join('\n'), `occupancy-${scheduleId}.csv`);
|
||||||
c.coachType,
|
|
||||||
String(c.totalSeats),
|
|
||||||
String(c.booked),
|
|
||||||
`${c.occupancyRate}%`,
|
|
||||||
]);
|
|
||||||
downloadCsv(
|
|
||||||
[
|
|
||||||
["Coach", "Type", "Total Seats", "Booked", "Occupancy"].join(","),
|
|
||||||
...rows.map((r) => r.join(",")),
|
|
||||||
].join("\n"),
|
|
||||||
`occupancy-${scheduleId}.csv`,
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const doExportList = () => {
|
const doExportList = () => {
|
||||||
if (!passengerList.length) return;
|
if (!passengerList.length) return;
|
||||||
const headers = [
|
const headers = ['#', 'Name', 'Coach·Seat', 'Origin', 'Destination', 'Date', 'Booking Ref'];
|
||||||
"Booking Ref",
|
const rows = passengerList.map((p, i) =>
|
||||||
"Status",
|
[String(i + 1), p.passengerName, p.coachSeat, p.origin, p.destination, p.departureAt ? formatDateTime(p.departureAt) : '—', p.bookingRef]
|
||||||
"Name",
|
.map(v => `"${String(v).replace(/"/g, '""')}"`)
|
||||||
"Category",
|
|
||||||
"ID Type",
|
|
||||||
"ID Number",
|
|
||||||
"Passport",
|
|
||||||
"Country",
|
|
||||||
"Seat",
|
|
||||||
"Coach",
|
|
||||||
"Class",
|
|
||||||
];
|
|
||||||
const rows = passengerList.map((p) =>
|
|
||||||
[
|
|
||||||
p.bookingRef,
|
|
||||||
p.bookingStatus,
|
|
||||||
p.passengerName,
|
|
||||||
p.passengerCategory,
|
|
||||||
p.idDocumentType ?? "",
|
|
||||||
p.idDocumentNumber ?? "",
|
|
||||||
p.passportNumber ?? "",
|
|
||||||
p.passportCountry ?? "",
|
|
||||||
p.seatLabel ?? "",
|
|
||||||
p.coachNumber ?? "",
|
|
||||||
p.coachType ?? "",
|
|
||||||
].map((v) => `"${String(v).replace(/"/g, '""')}"`),
|
|
||||||
);
|
|
||||||
downloadCsv(
|
|
||||||
[headers.join(","), ...rows.map((r) => r.join(","))].join("\n"),
|
|
||||||
`passengers-${scheduleId}.csv`,
|
|
||||||
);
|
);
|
||||||
|
downloadCsv([headers.join(','), ...rows.map(r => r.join(','))].join('\n'), `passengers-${scheduleId}.csv`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-foreground">
|
<h1 className="text-3xl font-bold text-foreground">Passengers Report</h1>
|
||||||
Passengers Report
|
<p className="text-muted-foreground mt-1">Occupancy and passenger breakdown for a schedule</p>
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground mt-1">
|
|
||||||
Select a schedule to view passenger occupancy breakdown
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Schedule selector */}
|
{/* Schedule selector */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="flex flex-wrap items-end gap-4">
|
<div className="flex items-end gap-4 flex-wrap">
|
||||||
<div className="flex-1 min-w-64">
|
<div className="flex-1 min-w-72">
|
||||||
<label className="label">Schedule</label>
|
<label className="label">Schedule</label>
|
||||||
<select
|
<select
|
||||||
className="input"
|
className="input"
|
||||||
value={scheduleId}
|
value={scheduleId}
|
||||||
onChange={(e) => {
|
onChange={e => { setScheduleId(e.target.value); setTab('occupancy'); setListSearch(''); }}
|
||||||
setScheduleId(e.target.value);
|
|
||||||
setTab("occupancy");
|
|
||||||
setListSearch("");
|
|
||||||
}}
|
|
||||||
disabled={loadingSchedules}
|
disabled={loadingSchedules}
|
||||||
>
|
>
|
||||||
<option value="">
|
<option value="">{loadingSchedules ? 'Loading schedules…' : 'Select a schedule…'}</option>
|
||||||
{loadingSchedules ? "Loading schedules…" : "Select a schedule…"}
|
{schedules.map(s => <option key={s.id} value={s.id}>{s.label}</option>)}
|
||||||
</option>
|
|
||||||
{schedules.map((s) => (
|
|
||||||
<option key={s.id} value={s.id}>
|
|
||||||
{s.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{data && tab === "occupancy" && (
|
{data && tab === 'occupancy' && (
|
||||||
<ActionButton
|
<ActionButton icon={Download} variant="secondary" onClick={doExportOccupancy}>Export CSV</ActionButton>
|
||||||
icon={Download}
|
|
||||||
variant="secondary"
|
|
||||||
onClick={doExportOccupancy}
|
|
||||||
>
|
|
||||||
Export CSV
|
|
||||||
</ActionButton>
|
|
||||||
)}
|
|
||||||
{passengerList.length > 0 && tab === "list" && (
|
|
||||||
<ActionButton
|
|
||||||
icon={Download}
|
|
||||||
variant="secondary"
|
|
||||||
onClick={doExportList}
|
|
||||||
>
|
|
||||||
Export CSV
|
|
||||||
</ActionButton>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{(isLoading || listLoading) && (
|
{(isLoading || listLoading) && <p className="text-xs text-muted-foreground mt-2">Loading…</p>}
|
||||||
<p className="text-xs text-muted-foreground mt-2">Loading…</p>
|
{isError && <p className="text-xs text-red-500 mt-2">Failed to load report.</p>}
|
||||||
)}
|
|
||||||
{isError && (
|
|
||||||
<p className="text-xs text-red-500 mt-2">Failed to load report.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isFetching && (
|
{data && (
|
||||||
<div className="card py-12 text-center text-muted-foreground">
|
|
||||||
Loading passengers data…
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{report && (
|
|
||||||
<>
|
<>
|
||||||
{/* Schedule Info */}
|
{/* Schedule info */}
|
||||||
<div className="card flex items-center gap-4">
|
<div className="card flex items-center gap-4">
|
||||||
<div className="rounded-lg bg-emerald-100 dark:bg-emerald-900/30 p-2.5">
|
<div className="rounded-lg bg-emerald-100 dark:bg-emerald-900/30 p-2.5">
|
||||||
<Train className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
|
<Train className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">{report.schedule.trainName}</p>
|
<p className="font-semibold">{data.schedule.trainName}</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{report.schedule.origin} → {report.schedule.destination} ·
|
{data.schedule.origin} → {data.schedule.destination} · Departure: {formatDateTime(data.schedule.departureAt)}
|
||||||
Departure: {formatDateTime(report.schedule.departureAt)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -266,75 +131,51 @@ export default function PassengersReportPage() {
|
|||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="border-b border-border flex">
|
<div className="border-b border-border flex">
|
||||||
<button
|
<button
|
||||||
onClick={() => setTab("occupancy")}
|
onClick={() => setTab('occupancy')}
|
||||||
className={`px-5 py-2.5 text-sm font-medium border-b-2 transition-colors ${tab === "occupancy" ? "border-emerald-500 text-emerald-600 dark:text-emerald-400" : "border-transparent text-muted-foreground hover:text-foreground"}`}
|
className={`px-5 py-2.5 text-sm font-medium border-b-2 transition-colors ${tab === 'occupancy' ? 'border-emerald-500 text-emerald-600 dark:text-emerald-400' : 'border-transparent text-muted-foreground hover:text-foreground'}`}
|
||||||
>
|
>
|
||||||
Occupancy
|
Occupancy
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setTab("list")}
|
onClick={() => setTab('list')}
|
||||||
className={`px-5 py-2.5 text-sm font-medium border-b-2 transition-colors ${tab === "list" ? "border-emerald-500 text-emerald-600 dark:text-emerald-400" : "border-transparent text-muted-foreground hover:text-foreground"}`}
|
className={`px-5 py-2.5 text-sm font-medium border-b-2 transition-colors ${tab === 'list' ? 'border-emerald-500 text-emerald-600 dark:text-emerald-400' : 'border-transparent text-muted-foreground hover:text-foreground'}`}
|
||||||
>
|
>
|
||||||
Passenger List
|
Passenger List{passengerList.length > 0 ? ` (${passengerList.length})` : ''}
|
||||||
{passengerList.length > 0 ? ` (${passengerList.length})` : ""}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Occupancy tab */}
|
{/* Occupancy tab */}
|
||||||
{tab === "occupancy" && (
|
{tab === 'occupancy' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
<div className="card flex flex-col gap-1">
|
<div className="card flex flex-col gap-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Total Seats</p>
|
||||||
Total Seats
|
<div className="rounded-lg bg-blue-100 dark:bg-blue-900/30 p-1.5"><Armchair className="h-4 w-4 text-blue-600 dark:text-blue-400" /></div>
|
||||||
</p>
|
|
||||||
<div className="rounded-lg bg-blue-100 dark:bg-blue-900/30 p-1.5">
|
|
||||||
<Armchair className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold tabular-nums mt-1">
|
<p className="text-2xl font-bold tabular-nums mt-1">{data.summary.totalSeats}</p>
|
||||||
{data.summary.totalSeats}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="card flex flex-col gap-1">
|
<div className="card flex flex-col gap-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Passengers</p>
|
||||||
Passengers
|
<div className="rounded-lg bg-emerald-100 dark:bg-emerald-900/30 p-1.5"><Users className="h-4 w-4 text-emerald-600 dark:text-emerald-400" /></div>
|
||||||
</p>
|
|
||||||
<div className="rounded-lg bg-emerald-100 dark:bg-emerald-900/30 p-1.5">
|
|
||||||
<Users className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold tabular-nums mt-1">
|
<p className="text-2xl font-bold tabular-nums mt-1">{data.summary.totalPassengers}</p>
|
||||||
{data.summary.totalPassengers}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="card flex flex-col gap-1">
|
<div className="card flex flex-col gap-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Occupancy Rate</p>
|
||||||
Occupancy Rate
|
<div className="rounded-lg bg-purple-100 dark:bg-purple-900/30 p-1.5"><BarChart3 className="h-4 w-4 text-purple-600 dark:text-purple-400" /></div>
|
||||||
</p>
|
|
||||||
<div className="rounded-lg bg-purple-100 dark:bg-purple-900/30 p-1.5">
|
|
||||||
<BarChart3 className="h-4 w-4 text-purple-600 dark:text-purple-400" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold tabular-nums mt-1">
|
<p className="text-2xl font-bold tabular-nums mt-1">{data.summary.occupancyRate}%</p>
|
||||||
{data.summary.occupancyRate}%
|
|
||||||
</p>
|
|
||||||
<div className="w-full bg-muted rounded-full h-1.5 mt-1">
|
<div className="w-full bg-muted rounded-full h-1.5 mt-1">
|
||||||
<div
|
<div className="bg-purple-500 h-1.5 rounded-full" style={{ width: `${data.summary.occupancyRate}%` }} />
|
||||||
className="bg-purple-500 h-1.5 rounded-full"
|
|
||||||
style={{ width: `${data.summary.occupancyRate}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">
|
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">By Coach</h3>
|
||||||
By Coach
|
|
||||||
</h3>
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -347,31 +188,18 @@ export default function PassengersReportPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
{data.byCoach.map((c) => (
|
{data.byCoach.map(c => (
|
||||||
<tr key={c.coachNumber} className="hover:bg-muted/30">
|
<tr key={c.coachNumber} className="hover:bg-muted/30">
|
||||||
<td className="py-2 pr-4 font-semibold">
|
<td className="py-2 pr-4 font-semibold">{c.coachNumber}</td>
|
||||||
{c.coachNumber}
|
<td className="py-2 pr-4 text-muted-foreground">{c.coachType}</td>
|
||||||
</td>
|
<td className="py-2 pr-4 text-right tabular-nums">{c.totalSeats}</td>
|
||||||
<td className="py-2 pr-4 text-muted-foreground">
|
<td className="py-2 pr-4 text-right tabular-nums">{c.booked}</td>
|
||||||
{c.coachType}
|
|
||||||
</td>
|
|
||||||
<td className="py-2 pr-4 text-right tabular-nums">
|
|
||||||
{c.totalSeats}
|
|
||||||
</td>
|
|
||||||
<td className="py-2 pr-4 text-right tabular-nums">
|
|
||||||
{c.booked}
|
|
||||||
</td>
|
|
||||||
<td className="py-2">
|
<td className="py-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex-1 bg-muted rounded-full h-1.5">
|
<div className="flex-1 bg-muted rounded-full h-1.5">
|
||||||
<div
|
<div className="bg-emerald-500 h-1.5 rounded-full" style={{ width: `${c.occupancyRate}%` }} />
|
||||||
className="bg-emerald-500 h-1.5 rounded-full"
|
|
||||||
style={{ width: `${c.occupancyRate}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="tabular-nums text-xs w-10 text-right">
|
<span className="tabular-nums text-xs w-10 text-right">{c.occupancyRate}%</span>
|
||||||
{c.occupancyRate}%
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -383,77 +211,46 @@ export default function PassengersReportPage() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">
|
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">By Class</h3>
|
||||||
By Class
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{data.byClass.map((c) => (
|
{data.byClass.map(c => (
|
||||||
<div key={c.className}>
|
<div key={c.className}>
|
||||||
<div className="flex justify-between text-sm mb-1">
|
<div className="flex justify-between text-sm mb-1">
|
||||||
<span className="font-medium">{c.className}</span>
|
<span className="font-medium">{c.className}</span>
|
||||||
<span className="tabular-nums text-muted-foreground">
|
<span className="tabular-nums text-muted-foreground">{c.booked}/{c.totalSeats}</span>
|
||||||
{c.booked}/{c.totalSeats}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex-1 bg-muted rounded-full h-1.5">
|
<div className="flex-1 bg-muted rounded-full h-1.5">
|
||||||
<div
|
<div className="bg-blue-500 h-1.5 rounded-full" style={{ width: `${c.occupancyRate}%` }} />
|
||||||
className="bg-blue-500 h-1.5 rounded-full"
|
|
||||||
style={{ width: `${c.occupancyRate}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs tabular-nums w-10 text-right">
|
<span className="text-xs tabular-nums w-10 text-right">{c.occupancyRate}%</span>
|
||||||
{c.occupancyRate}%
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">
|
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">By Boarding Station</h3>
|
||||||
By Boarding Station
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{data.byOrigin.map((o) => (
|
{data.byOrigin.map(o => (
|
||||||
<div
|
<div key={o.stationName} className="flex justify-between text-sm">
|
||||||
key={o.stationName}
|
<span className="text-muted-foreground truncate">{o.stationName}</span>
|
||||||
className="flex justify-between text-sm"
|
<span className="font-semibold tabular-nums ml-2">{o.passengers}</span>
|
||||||
>
|
|
||||||
<span className="text-muted-foreground truncate">
|
|
||||||
{o.stationName}
|
|
||||||
</span>
|
|
||||||
<span className="font-semibold tabular-nums ml-2">
|
|
||||||
{o.passengers}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{data.byOrigin.length === 0 && (
|
{data.byOrigin.length === 0 && <p className="text-xs text-muted-foreground">No data</p>}
|
||||||
<p className="text-xs text-muted-foreground">No data</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">
|
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">By Alighting Station</h3>
|
||||||
By Alighting Station
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{data.byDestination.map((d) => (
|
{data.byDestination.map(d => (
|
||||||
<div
|
<div key={d.stationName} className="flex justify-between text-sm">
|
||||||
key={d.stationName}
|
<span className="text-muted-foreground truncate">{d.stationName}</span>
|
||||||
className="flex justify-between text-sm"
|
<span className="font-semibold tabular-nums ml-2">{d.passengers}</span>
|
||||||
>
|
|
||||||
<span className="text-muted-foreground truncate">
|
|
||||||
{d.stationName}
|
|
||||||
</span>
|
|
||||||
<span className="font-semibold tabular-nums ml-2">
|
|
||||||
{d.passengers}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{data.byDestination.length === 0 && (
|
{data.byDestination.length === 0 && <p className="text-xs text-muted-foreground">No data</p>}
|
||||||
<p className="text-xs text-muted-foreground">No data</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -461,101 +258,60 @@ export default function PassengersReportPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Passenger List tab */}
|
{/* Passenger List tab */}
|
||||||
{tab === "list" && (
|
{tab === 'list' && (
|
||||||
<div className="card space-y-4">
|
<div className="space-y-4">
|
||||||
<input
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
type="text"
|
<input
|
||||||
className="input max-w-sm"
|
type="text"
|
||||||
placeholder="Search by name, booking ref or ID…"
|
className="input max-w-sm flex-1"
|
||||||
value={listSearch}
|
placeholder="Search by name or booking ref…"
|
||||||
onChange={(e) => setListSearch(e.target.value)}
|
value={listSearch}
|
||||||
/>
|
onChange={e => setListSearch(e.target.value)}
|
||||||
<div className="overflow-x-auto">
|
/>
|
||||||
<table className="w-full text-sm">
|
{passengerList.length > 0 && (
|
||||||
<thead>
|
<ActionButton icon={Download} variant="secondary" onClick={doExportList}>Export CSV</ActionButton>
|
||||||
<tr className="border-b border-border text-left text-xs text-muted-foreground uppercase tracking-wider">
|
)}
|
||||||
<th className="pb-2 pr-4">#</th>
|
</div>
|
||||||
<th className="pb-2 pr-4">Name</th>
|
<div className="card p-0">
|
||||||
<th className="pb-2 pr-4">Category</th>
|
<div className="overflow-x-auto">
|
||||||
<th className="pb-2 pr-4">ID / Passport</th>
|
<table className="w-full text-sm">
|
||||||
<th className="pb-2 pr-4">Seat</th>
|
<thead>
|
||||||
<th className="pb-2 pr-4">Coach</th>
|
<tr className="border-b border-border text-left text-xs text-muted-foreground uppercase tracking-wider">
|
||||||
<th className="pb-2 pr-4">Booking Ref</th>
|
<th className="px-4 py-3">#</th>
|
||||||
<th className="pb-2">Status</th>
|
<th className="px-4 py-3">Name</th>
|
||||||
</tr>
|
<th className="px-4 py-3">Coach · Seat</th>
|
||||||
</thead>
|
<th className="px-4 py-3">Origin</th>
|
||||||
<tbody className="divide-y divide-border">
|
<th className="px-4 py-3">Destination</th>
|
||||||
{filteredList.map((p, i) => (
|
<th className="px-4 py-3">Date</th>
|
||||||
<tr
|
<th className="px-4 py-3">Booking Ref</th>
|
||||||
key={`${p.bookingRef}-${i}`}
|
|
||||||
className="hover:bg-muted/30"
|
|
||||||
>
|
|
||||||
<td className="py-2 pr-4 text-muted-foreground tabular-nums">
|
|
||||||
{i + 1}
|
|
||||||
</td>
|
|
||||||
<td className="py-2 pr-4 font-medium">
|
|
||||||
{p.passengerName}
|
|
||||||
</td>
|
|
||||||
<td className="py-2 pr-4">
|
|
||||||
<span
|
|
||||||
className={`text-xs font-semibold px-1.5 py-0.5 rounded ${p.passengerCategory === "CHILD" ? "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400" : "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"}`}
|
|
||||||
>
|
|
||||||
{p.passengerCategory}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-2 pr-4 text-muted-foreground text-xs">
|
|
||||||
{p.idDocumentNumber ?? p.passportNumber ?? "—"}
|
|
||||||
{p.passportCountry && (
|
|
||||||
<span className="ml-1 text-muted-foreground/60">
|
|
||||||
({p.passportCountry})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="py-2 pr-4 font-mono text-xs">
|
|
||||||
{p.seatLabel ?? "—"}
|
|
||||||
</td>
|
|
||||||
<td className="py-2 pr-4 text-muted-foreground">
|
|
||||||
{p.coachNumber ?? "—"}
|
|
||||||
{p.coachType && (
|
|
||||||
<span className="ml-1 text-xs text-muted-foreground/60">
|
|
||||||
({p.coachType})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="py-2 pr-4 font-mono text-xs">
|
|
||||||
{p.bookingRef}
|
|
||||||
</td>
|
|
||||||
<td className="py-2">
|
|
||||||
<span
|
|
||||||
className={`text-xs font-semibold px-1.5 py-0.5 rounded ${p.bookingStatus === "BOARDED" ? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400" : "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"}`}
|
|
||||||
>
|
|
||||||
{p.bookingStatus}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
{filteredList.length === 0 && (
|
<tbody className="divide-y divide-border">
|
||||||
<tr>
|
{filteredList.map((p, i) => (
|
||||||
<td
|
<tr key={`${p.bookingRef}-${i}`} className="hover:bg-muted/30">
|
||||||
colSpan={8}
|
<td className="px-4 py-3 text-muted-foreground tabular-nums">{i + 1}</td>
|
||||||
className="py-8 text-center text-sm text-muted-foreground"
|
<td className="px-4 py-3 font-medium">{p.passengerName}</td>
|
||||||
>
|
<td className="px-4 py-3 font-mono text-xs">{p.coachSeat}</td>
|
||||||
No passengers found
|
<td className="px-4 py-3 text-muted-foreground">{p.origin}</td>
|
||||||
</td>
|
<td className="px-4 py-3 text-muted-foreground">{p.destination}</td>
|
||||||
</tr>
|
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">{p.departureAt ? formatDateTime(p.departureAt) : '—'}</td>
|
||||||
)}
|
<td className="px-4 py-3 font-mono text-xs">{p.bookingRef}</td>
|
||||||
</tbody>
|
</tr>
|
||||||
</table>
|
))}
|
||||||
|
{filteredList.length === 0 && (
|
||||||
|
<tr><td colSpan={7} className="py-8 text-center text-sm text-muted-foreground">No passengers found</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!report && !isFetching && scheduleId && (
|
{!data && !isLoading && scheduleId && (
|
||||||
<div className="card py-12 text-center text-muted-foreground">
|
<div className="card py-12 text-center text-muted-foreground">No data found for this schedule.</div>
|
||||||
No data found for this schedule.
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!scheduleId && (
|
{!scheduleId && (
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ export default function SchedulesPage() {
|
|||||||
plannedDepartureAt: eatDateStr && depTimePart ? `${eatDateStr}T${depTimePart}` : '',
|
plannedDepartureAt: eatDateStr && depTimePart ? `${eatDateStr}T${depTimePart}` : '',
|
||||||
};
|
};
|
||||||
}));
|
}));
|
||||||
}, [editRouteDetail, editingSchedule?.id, editForm.departureAt]);
|
}, [editRouteDetail, editingSchedule?.id, editForm.departureAt]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const [filters, setFilters] = useState({
|
const [filters, setFilters] = useState({
|
||||||
search: '',
|
search: '',
|
||||||
|
|||||||
Reference in New Issue
Block a user