Boarding report added

This commit is contained in:
Stephanos A
2026-07-24 16:09:27 +03:00
parent 84346d77ac
commit 050a08f330
5 changed files with 505 additions and 0 deletions

View File

@@ -53,6 +53,12 @@ export class ReportsController {
return this.service.getSeatStatusReport(scheduleId);
}
@Get("boarding")
@ApiOperation({ summary: "Boarding report for a schedule — boarded vs not-boarded passengers" })
getBoardingReport(@Query('scheduleId') scheduleId: string) {
return this.service.getBoardingReport(scheduleId);
}
@Get("payments")
@ApiOperation({ summary: "Payments collected for a schedule" })
getPaymentsReport(@Query('scheduleId') scheduleId: string) {

View File

@@ -1022,6 +1022,119 @@ export class ReportsService {
return { total: rows.length, rows };
}
async getBoardingReport(scheduleId: string) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
select: {
id: true,
departureAt: true,
arrivalAt: true,
train: { select: { number: true, name: true } },
originStation: { select: { name: true } },
destinationStation: { select: { name: true } },
},
});
if (!schedule) return null;
const tickets = await this.prisma.ticket.findMany({
where: {
OR: [
{ scheduleId, booking: { status: { in: ['CONFIRMED', 'BOARDED'] } } },
{ leg: 2, booking: { returnScheduleId: scheduleId, status: { in: ['CONFIRMED', 'BOARDED'] } } },
{ scheduleId: null, leg: 1, booking: { scheduleId, status: { in: ['CONFIRMED', 'BOARDED'] } } },
],
},
select: {
id: true,
bookingRef: true,
passengerName: true,
boardedAt: true,
validatorId: true,
status: true,
booking: {
select: {
status: true,
originStationId: true,
destinationStationId: true,
},
},
seat: {
select: {
seatNumber: true,
bedPosition: true,
coach: {
select: {
number: true,
coachType: { select: { name: true, seatClasses: { select: { name: true, bedPosition: true } } } },
},
},
},
},
},
orderBy: [{ seat: { coach: { number: 'asc' } } }, { seat: { seatNumber: 'asc' } }],
});
const stationIds = [...new Set(
tickets.flatMap(t => [t.booking.originStationId, t.booking.destinationStationId]).filter(Boolean) as string[],
)];
const stations = stationIds.length > 0
? await this.prisma.station.findMany({ where: { id: { in: stationIds } }, select: { id: true, name: true } })
: [];
const stationName = new Map(stations.map(s => [s.id, s.name]));
const resolveSeatClass = (seat: any): string | null => {
const classes = seat?.coach?.coachType?.seatClasses ?? [];
const matched = seat?.bedPosition
? classes.find((sc: any) => sc.bedPosition?.toLowerCase() === seat.bedPosition.toLowerCase())
: null;
return (matched ?? classes[0])?.name ?? seat?.coach?.coachType?.name ?? null;
};
const rows = tickets.map(t => ({
bookingRef: t.bookingRef,
passengerName: t.passengerName,
coachNumber: t.seat?.coach?.number ?? null,
seatNumber: t.seat?.seatNumber ?? null,
seatClassName: resolveSeatClass(t.seat),
origin: t.booking.originStationId ? (stationName.get(t.booking.originStationId) ?? null) : null,
destination: t.booking.destinationStationId ? (stationName.get(t.booking.destinationStationId) ?? null) : null,
boarded: !!t.boardedAt,
boardedAt: t.boardedAt ?? null,
validatorId: t.validatorId ?? null,
bookingStatus: t.booking.status,
}));
const boardedCount = rows.filter(r => r.boarded).length;
const notBoardedCount = rows.length - boardedCount;
const byCoach = new Map<string, { coachNumber: string; total: number; boarded: number }>();
for (const r of rows) {
const key = r.coachNumber ?? 'Unknown';
if (!byCoach.has(key)) byCoach.set(key, { coachNumber: key, total: 0, boarded: 0 });
byCoach.get(key)!.total++;
if (r.boarded) byCoach.get(key)!.boarded++;
}
return {
schedule: {
id: schedule.id,
trainName: (schedule.train as any)?.name ?? (schedule.train as any)?.number,
origin: (schedule.originStation as any)?.name,
destination: (schedule.destinationStation as any)?.name,
departureAt: schedule.departureAt,
arrivalAt: schedule.arrivalAt,
},
summary: {
total: rows.length,
boardedCount,
notBoardedCount,
boardingRate: rows.length > 0 ? +((boardedCount / rows.length) * 100).toFixed(1) : 0,
},
byCoach: [...byCoach.values()].sort((a, b) => a.coachNumber.localeCompare(b.coachNumber)),
rows,
};
}
async getReport(reportId: string) {
return this.prisma.operationalReport.findUnique({
where: { id: reportId },

View File

@@ -0,0 +1,3 @@
export default function Layout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

View File

@@ -0,0 +1,382 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { LogIn, Users, CheckCircle, XCircle, BarChart3, Train, Download } from "lucide-react";
import { apiClient } from "@/lib/api-client";
import Badge from "@/components/ui/Badge";
import ActionButton from "@/components/ui/ActionButton";
import { formatDateTime } from "@/lib/utils";
import Pagination from "@/components/ui/Pagination";
import { usePagination } from "@/lib/use-pagination";
interface ScheduleOption {
id: string;
label: string;
}
interface BoardingRow {
bookingRef: string;
passengerName: string;
coachNumber: string | null;
seatNumber: string | null;
seatClassName: string | null;
origin: string | null;
destination: string | null;
boarded: boolean;
boardedAt: string | null;
validatorId: string | null;
bookingStatus: string;
}
interface BoardingReport {
schedule: {
id: string;
trainName: string;
origin: string;
destination: string;
departureAt: string;
arrivalAt: string;
};
summary: {
total: number;
boardedCount: number;
notBoardedCount: number;
boardingRate: number;
};
byCoach: { coachNumber: string; total: number; boarded: number }[];
rows: BoardingRow[];
}
type Tab = "summary" | "details";
export default function BoardingReportPage() {
const [scheduleId, setScheduleId] = useState("");
const [tab, setTab] = useState<Tab>("summary");
const [search, setSearch] = useState("");
const [filterBoarded, setFilterBoarded] = useState<"ALL" | "BOARDED" | "NOT_BOARDED">("ALL");
const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery<ScheduleOption[]>({
queryKey: ["report-schedules-all"],
queryFn: () => apiClient.get("/reports/schedules?all=true"),
});
const schedules = schedulesRaw ?? [];
const { data, isLoading, isError } = useQuery<BoardingReport>({
queryKey: ["boarding-report", scheduleId],
queryFn: () => apiClient.get(`/reports/boarding?scheduleId=${scheduleId}`),
enabled: !!scheduleId,
});
const filtered = (data?.rows ?? []).filter((r) => {
if (filterBoarded === "BOARDED" && !r.boarded) return false;
if (filterBoarded === "NOT_BOARDED" && r.boarded) return false;
if (search.trim()) {
const q = search.toLowerCase();
return (
r.passengerName.toLowerCase().includes(q) ||
r.bookingRef.toLowerCase().includes(q) ||
(r.seatNumber ?? "").toLowerCase().includes(q) ||
(r.coachNumber ?? "").toLowerCase().includes(q)
);
}
return true;
});
const { paged, page, totalPages, setPage, reset } = usePagination(filtered, 50);
const doExport = () => {
if (!filtered.length) return;
const headers = ["Booking Ref", "Passenger", "Seat Class", "Coach", "Seat", "Origin", "Destination", "Boarded", "Boarded At", "Validator"];
const rows = filtered.map((r) => [
r.bookingRef,
r.passengerName,
r.seatClassName ?? "—",
r.coachNumber ?? "—",
r.seatNumber ?? "—",
r.origin ?? "—",
r.destination ?? "—",
r.boarded ? "Yes" : "No",
r.boardedAt ? formatDateTime(r.boardedAt) : "—",
r.validatorId ?? "—",
]);
const csv = [
headers.map((h) => `"${h}"`).join(","),
...rows.map((row) => row.map((v) => `"${v}"`).join(",")),
].join("\n");
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `boarding-${scheduleId}-${new Date().toISOString().split("T")[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Boarding Report</h1>
<p className="text-muted-foreground mt-1">
Boarding status and passenger breakdown for a schedule
</p>
</div>
{/* Schedule selector */}
<div className="card">
<div className="flex items-end gap-4 flex-wrap">
<div className="flex-1 min-w-72">
<label className="label">Schedule</label>
<select
className="input"
value={scheduleId}
onChange={(e) => {
setScheduleId(e.target.value);
setTab("summary");
setSearch("");
setFilterBoarded("ALL");
}}
disabled={loadingSchedules}
>
<option value="">
{loadingSchedules ? "Loading schedules…" : "Select a schedule…"}
</option>
{schedules.map((s) => (
<option key={s.id} value={s.id}>
{s.label}
</option>
))}
</select>
</div>
</div>
{isLoading && <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>}
</div>
{!scheduleId && (
<div className="card py-16 text-center text-muted-foreground">
<LogIn className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>Select a schedule above to load the boarding report</p>
</div>
)}
{data && (
<>
{/* Schedule info */}
<div className="card flex items-center gap-4">
<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" />
</div>
<div>
<p className="font-semibold">{data.schedule.trainName}</p>
<p className="text-sm text-muted-foreground">
{data.schedule.origin} {data.schedule.destination} ·
Departure: {formatDateTime(data.schedule.departureAt)}
</p>
</div>
</div>
{/* Tabs */}
<div className="border-b border-border flex">
<button
onClick={() => setTab("summary")}
className={`px-5 py-2.5 text-sm font-medium border-b-2 transition-colors ${tab === "summary" ? "border-emerald-500 text-emerald-600 dark:text-emerald-400" : "border-transparent text-muted-foreground hover:text-foreground"}`}
>
Summary
</button>
<button
onClick={() => setTab("details")}
className={`px-5 py-2.5 text-sm font-medium border-b-2 transition-colors ${tab === "details" ? "border-emerald-500 text-emerald-600 dark:text-emerald-400" : "border-transparent text-muted-foreground hover:text-foreground"}`}
>
Passenger Details{data.rows.length > 0 ? ` (${data.rows.length})` : ""}
</button>
</div>
{/* Summary Tab */}
{tab === "summary" && (
<div className="space-y-6">
{/* KPI cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Total Tickets</p>
<p className="text-2xl font-bold mt-2">{data.summary.total}</p>
<p className="text-xs text-muted-foreground mt-1">Confirmed passengers</p>
</div>
<Users className="h-8 w-8 text-blue-500 opacity-30" />
</div>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Boarded</p>
<p className="text-2xl font-bold mt-2 text-green-600 dark:text-green-400">
{data.summary.boardedCount}
</p>
<p className="text-xs text-muted-foreground mt-1">Scanned at gate</p>
</div>
<CheckCircle className="h-8 w-8 text-green-500 opacity-30" />
</div>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Not Boarded</p>
<p className="text-2xl font-bold mt-2 text-red-600 dark:text-red-400">
{data.summary.notBoardedCount}
</p>
<p className="text-xs text-muted-foreground mt-1">No-shows / pending</p>
</div>
<XCircle className="h-8 w-8 text-red-500 opacity-30" />
</div>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Boarding Rate</p>
<p className="text-2xl font-bold mt-2 text-purple-600 dark:text-purple-400">
{data.summary.boardingRate}%
</p>
<div className="w-full bg-muted rounded-full h-1.5 mt-2">
<div
className="bg-purple-500 h-1.5 rounded-full"
style={{ width: `${data.summary.boardingRate}%` }}
/>
</div>
</div>
<BarChart3 className="h-8 w-8 text-purple-500 opacity-30" />
</div>
</div>
</div>
{/* By Coach */}
{data.byCoach.length > 0 && (
<div className="card">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-xs text-muted-foreground uppercase tracking-wider">
<th className="pb-2 pr-4">Coach</th>
<th className="pb-2 pr-4 text-right">Total</th>
<th className="pb-2 pr-4 text-right">Boarded</th>
<th className="pb-2 pr-4 text-right">Not Boarded</th>
<th className="pb-2">Rate</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{data.byCoach.map((c) => {
const rate = c.total > 0 ? +((c.boarded / c.total) * 100).toFixed(1) : 0;
return (
<tr key={c.coachNumber} className="hover:bg-muted/30">
<td className="py-2 pr-4 font-semibold">{c.coachNumber}</td>
<td className="py-2 pr-4 text-right tabular-nums">{c.total}</td>
<td className="py-2 pr-4 text-right tabular-nums text-green-600 dark:text-green-400">{c.boarded}</td>
<td className="py-2 pr-4 text-right tabular-nums text-red-600 dark:text-red-400">{c.total - c.boarded}</td>
<td className="py-2">
<div className="flex items-center gap-2">
<div className="flex-1 bg-muted rounded-full h-1.5">
<div
className="bg-emerald-500 h-1.5 rounded-full"
style={{ width: `${rate}%` }}
/>
</div>
<span className="tabular-nums text-xs w-10 text-right">{rate}%</span>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</div>
)}
{/* Details Tab */}
{tab === "details" && (
<div className="card p-0">
<div className="flex items-center gap-2 px-4 pt-4 pb-3 flex-wrap">
<input
type="text"
className="input max-w-xs"
placeholder="Name, booking ref, seat…"
value={search}
onChange={(e) => { setSearch(e.target.value); reset(); }}
/>
<select
className="input w-44"
value={filterBoarded}
onChange={(e) => { setFilterBoarded(e.target.value as "ALL" | "BOARDED" | "NOT_BOARDED"); reset(); }}
>
<option value="ALL">All Passengers</option>
<option value="BOARDED">Boarded Only</option>
<option value="NOT_BOARDED">Not Boarded</option>
</select>
<ActionButton icon={Download} variant="secondary" onClick={doExport} disabled={!filtered.length}>
Export CSV
</ActionButton>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>
{["Booking Ref", "Passenger", "Seat Class · Coach · Seat", "Route", "Status", "Boarded At"].map((h) => (
<th key={h} className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400 whitespace-nowrap">
{h}
</th>
))}
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{paged.map((row, i) => (
<tr key={i} className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
<td className="px-4 py-3 font-mono font-semibold whitespace-nowrap">{row.bookingRef}</td>
<td className="px-4 py-3 whitespace-nowrap">{row.passengerName}</td>
<td className="px-4 py-3 whitespace-nowrap text-xs">
<span className="font-medium">{row.seatClassName ?? "—"}</span>
{row.coachNumber && <span className="text-muted-foreground"> · {row.coachNumber}</span>}
{row.seatNumber && <span className="text-muted-foreground"> · #{row.seatNumber}</span>}
</td>
<td className="px-4 py-3 whitespace-nowrap text-xs text-muted-foreground">
{row.origin && row.destination ? `${row.origin}${row.destination}` : (row.origin ?? row.destination ?? "—")}
</td>
<td className="px-4 py-3 whitespace-nowrap">
<Badge variant="status" status={row.boarded ? "BOARDED" : "PENDING"}>
{row.boarded ? "Boarded" : "Not Boarded"}
</Badge>
</td>
<td className="px-4 py-3 whitespace-nowrap text-xs text-muted-foreground">
{row.boardedAt ? formatDateTime(row.boardedAt) : "—"}
</td>
</tr>
))}
{paged.length === 0 && (
<tr>
<td colSpan={6} className="py-8 text-center text-sm text-muted-foreground">
No passengers found
</td>
</tr>
)}
</tbody>
</table>
</div>
<Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
</div>
)}
</>
)}
{!data && !isLoading && scheduleId && (
<div className="card py-12 text-center text-muted-foreground">
No data found for this schedule.
</div>
)}
</div>
);
}

View File

@@ -124,6 +124,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
{ name: 'Overall', href: '/reports/overall', icon: BarChart3, permission: PERMS.reports.view },
{ name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view },
{ name: 'Passengers', href: '/reports/passengers', icon: Users, permission: PERMS.reports.view },
{ name: 'Boarding', href: '/reports/boarding', icon: LogIn, permission: PERMS.reports.view },
{ name: 'Payments', href: '/reports/payments', icon: CreditCard, permission: PERMS.reports.view },
// { name: 'Payment Discrepancy', href: '/reports/payment-discrepancy', icon: AlertTriangle, permission: PERMS.reports.view },
// { name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: PERMS.reports.view },