mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: ( reports ) add fleet seat overview to seat status landing page
This commit is contained in:
@@ -60,6 +60,23 @@ export class ReportsController {
|
|||||||
return this.service.getSeatStatusReport(scheduleId);
|
return this.service.getSeatStatusReport(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Two segments, so `@Get(":reportId")` below cannot shadow it whatever the order.
|
||||||
|
@Get("seat-status/overview")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Fleet-wide seat status across a departure window",
|
||||||
|
description:
|
||||||
|
"Landing view for the seat status report, shown before a schedule is picked. Returns the same four " +
|
||||||
|
"counters as the per-schedule report (paid, unpaid, expired holds, blocked) rolled up over a window of " +
|
||||||
|
"departures, plus per-day buckets and one row per schedule.\n\n" +
|
||||||
|
"The window is forward-looking — the next `days` days. If nothing is departing in that window, it falls " +
|
||||||
|
"back to the most recent `days` of departures on record and says so via `window.direction`.\n\n" +
|
||||||
|
"Counts apply the same rules as `GET /reports/seat-status`, so a schedule's row here equals what the " +
|
||||||
|
"drill-down shows after selecting it.",
|
||||||
|
})
|
||||||
|
getSeatStatusOverview(@Query('days') days?: string) {
|
||||||
|
return this.service.getSeatStatusOverview(days ? Number(days) : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
@Get("boarding")
|
@Get("boarding")
|
||||||
@ApiOperation({ summary: "Boarding report for a schedule — boarded vs not-boarded passengers" })
|
@ApiOperation({ summary: "Boarding report for a schedule — boarded vs not-boarded passengers" })
|
||||||
getBoardingReport(@Query('scheduleId') scheduleId: string) {
|
getBoardingReport(@Query('scheduleId') scheduleId: string) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { InjectDataSource } from "@nestjs/typeorm";
|
import { InjectDataSource } from "@nestjs/typeorm";
|
||||||
import { DataSource } from "typeorm";
|
import { DataSource } from "typeorm";
|
||||||
|
import { BookingStatus } from "@prisma/client";
|
||||||
import {
|
import {
|
||||||
BlockedSeatRevenueLossReport,
|
BlockedSeatRevenueLossReport,
|
||||||
UNCATEGORIZED_REASON_CATEGORY,
|
UNCATEGORIZED_REASON_CATEGORY,
|
||||||
@@ -35,6 +36,43 @@ const FARE_QUOTE_CONCURRENCY = 4;
|
|||||||
/** CSV export is not paginated, but still needs an upper bound. */
|
/** CSV export is not paginated, but still needs an upper bound. */
|
||||||
const CSV_EXPORT_MAX_SCHEDULES = 5000;
|
const CSV_EXPORT_MAX_SCHEDULES = 5000;
|
||||||
|
|
||||||
|
// ── Fleet seat-status overview (landing view of the seat status report) ──────
|
||||||
|
const MS_PER_DAY_OVERVIEW = 24 * 60 * 60 * 1000;
|
||||||
|
const OVERVIEW_DEFAULT_DAYS = 7;
|
||||||
|
const OVERVIEW_MAX_DAYS = 31;
|
||||||
|
/** Upper bound on schedules charted at once. Signalled back as `window.truncated`. */
|
||||||
|
const OVERVIEW_MAX_SCHEDULES = 60;
|
||||||
|
/** The booking statuses that put a seat on a schedule — same set as the drill-down. */
|
||||||
|
const OVERVIEW_ACTIVE_BOOKING_STATUSES: BookingStatus[] = [
|
||||||
|
'CONFIRMED',
|
||||||
|
'BOARDED',
|
||||||
|
'PENDING_PAYMENT',
|
||||||
|
];
|
||||||
|
|
||||||
|
const EMPTY_OVERVIEW_TOTALS = {
|
||||||
|
scheduleCount: 0,
|
||||||
|
sellableSeats: 0,
|
||||||
|
paidCount: 0,
|
||||||
|
unpaidCount: 0,
|
||||||
|
expiredHoldCount: 0,
|
||||||
|
blockedCount: 0,
|
||||||
|
availableCount: 0,
|
||||||
|
loadFactorPercent: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function emptyDayBucket(date: string) {
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
scheduleCount: 0,
|
||||||
|
sellableSeats: 0,
|
||||||
|
paid: 0,
|
||||||
|
unpaid: 0,
|
||||||
|
expiredHolds: 0,
|
||||||
|
blocked: 0,
|
||||||
|
available: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const EMPTY_LOSS_INPUT: LossCalculatorInput = {
|
const EMPTY_LOSS_INPUT: LossCalculatorInput = {
|
||||||
schedules: [],
|
schedules: [],
|
||||||
seatsById: new Map(),
|
seatsById: new Map(),
|
||||||
@@ -728,6 +766,349 @@ export class ReportsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fleet-wide seat status across a departure window — the landing view for the seat
|
||||||
|
* status report, shown before a schedule is picked.
|
||||||
|
*
|
||||||
|
* Deliberately a separate method from {@link getSeatStatusReport}: that one answers
|
||||||
|
* "this schedule, row by row" and its response shape is consumed by the drill-down UI.
|
||||||
|
* This one answers "the whole window, counts only". They share no code path, but they
|
||||||
|
* *do* share predicates — every filter below is the same rule the drill-down applies
|
||||||
|
* (the three-branch seat `OR`, the dining-coach exclusion, the counted-block
|
||||||
|
* resolution), so a schedule's row here always equals what you see after clicking it.
|
||||||
|
* Change one and the other must change with it.
|
||||||
|
*/
|
||||||
|
async getSeatStatusOverview(daysRaw?: number) {
|
||||||
|
const days = Math.min(
|
||||||
|
Math.max(Math.trunc(daysRaw || OVERVIEW_DEFAULT_DAYS), 1),
|
||||||
|
OVERVIEW_MAX_DAYS,
|
||||||
|
);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Forward-looking by default. But a database whose schedules are all in the past
|
||||||
|
// would render an empty chart, which reads as a broken page rather than an honest
|
||||||
|
// "nothing departing" — so fall back to the most recent window that has departures.
|
||||||
|
let from = now;
|
||||||
|
let to = new Date(now.getTime() + days * MS_PER_DAY_OVERVIEW);
|
||||||
|
let direction: 'UPCOMING' | 'RECENT' = 'UPCOMING';
|
||||||
|
|
||||||
|
const upcomingCount = await this.prisma.trainSchedule.count({
|
||||||
|
where: { departureAt: { gte: from, lte: to }, status: { not: 'CANCELLED' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (upcomingCount === 0) {
|
||||||
|
const latest = await this.prisma.trainSchedule.findFirst({
|
||||||
|
where: { departureAt: { lt: now }, status: { not: 'CANCELLED' } },
|
||||||
|
orderBy: { departureAt: 'desc' },
|
||||||
|
select: { departureAt: true },
|
||||||
|
});
|
||||||
|
if (latest) {
|
||||||
|
direction = 'RECENT';
|
||||||
|
to = latest.departureAt;
|
||||||
|
from = new Date(to.getTime() - days * MS_PER_DAY_OVERVIEW);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedules = await this.prisma.trainSchedule.findMany({
|
||||||
|
where: { departureAt: { gte: from, lte: to }, status: { not: 'CANCELLED' } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
departureAt: true,
|
||||||
|
isPackageOnly: true,
|
||||||
|
train: { select: { number: true } },
|
||||||
|
originStation: { select: { name: true } },
|
||||||
|
destinationStation: { select: { name: true } },
|
||||||
|
},
|
||||||
|
orderBy: { departureAt: 'asc' },
|
||||||
|
take: OVERVIEW_MAX_SCHEDULES,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (schedules.length === 0) {
|
||||||
|
return {
|
||||||
|
window: { from, to, days, direction, truncated: false },
|
||||||
|
totals: EMPTY_OVERVIEW_TOTALS,
|
||||||
|
byDay: [],
|
||||||
|
schedules: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleIds = schedules.map((s) => s.id);
|
||||||
|
const since24h = new Date(now.getTime() - MS_PER_DAY_OVERVIEW);
|
||||||
|
|
||||||
|
const [assignments, bookingSeats, holds, blockRows] = await Promise.all([
|
||||||
|
this.prisma.coachAssignment.findMany({
|
||||||
|
where: { scheduleId: { in: scheduleIds } },
|
||||||
|
select: {
|
||||||
|
scheduleId: true,
|
||||||
|
coachId: true,
|
||||||
|
coach: {
|
||||||
|
select: {
|
||||||
|
coachType: { select: { name: true, type: true } },
|
||||||
|
seats: { select: { seatNumber: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
// Same three-branch OR as the drill-down (`getSeatStatusReport`): a seat reaches a
|
||||||
|
// schedule by its own `scheduleId`, by being leg 2 of a return booking, or — on
|
||||||
|
// older rows with no `scheduleId` — by its booking's outbound schedule. A plain
|
||||||
|
// `groupBy scheduleId` would silently drop the last two.
|
||||||
|
this.prisma.bookingSeat.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
scheduleId: { in: scheduleIds },
|
||||||
|
booking: { status: { in: OVERVIEW_ACTIVE_BOOKING_STATUSES } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
leg: 2,
|
||||||
|
booking: {
|
||||||
|
returnScheduleId: { in: scheduleIds },
|
||||||
|
status: { in: OVERVIEW_ACTIVE_BOOKING_STATUSES },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scheduleId: null,
|
||||||
|
leg: 1,
|
||||||
|
booking: {
|
||||||
|
scheduleId: { in: scheduleIds },
|
||||||
|
status: { in: OVERVIEW_ACTIVE_BOOKING_STATUSES },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
scheduleId: true,
|
||||||
|
leg: true,
|
||||||
|
booking: {
|
||||||
|
select: { status: true, scheduleId: true, returnScheduleId: true },
|
||||||
|
},
|
||||||
|
seat: {
|
||||||
|
select: {
|
||||||
|
coach: { select: { coachType: { select: { name: true, type: true } } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.seatHold.findMany({
|
||||||
|
where: {
|
||||||
|
scheduleId: { in: scheduleIds },
|
||||||
|
expiresAt: { lt: now, gte: since24h },
|
||||||
|
},
|
||||||
|
select: { scheduleId: true },
|
||||||
|
}),
|
||||||
|
this.prisma.seatBlock.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [{ scheduleId: { in: scheduleIds } }, { scheduleId: null }],
|
||||||
|
NOT: [
|
||||||
|
{ reason: { startsWith: 'MAINTENANCE:' } },
|
||||||
|
{ reason: { startsWith: TICKETING_BLOCK_REASON_PREFIX } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
scheduleId: true,
|
||||||
|
blockedAt: true,
|
||||||
|
unblockAt: true,
|
||||||
|
seat: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
coachId: true,
|
||||||
|
seatNumber: true,
|
||||||
|
coach: { select: { coachType: { select: { name: true, type: true } } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { blockedAt: 'desc' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Sellable seats and assigned coaches, per schedule ───────────────────────
|
||||||
|
// Sellable = every seat on every assigned coach, minus dining coaches and
|
||||||
|
// placeholder rows — the same denominator the revenue-loss report uses.
|
||||||
|
const sellableBySchedule = new Map<string, number>();
|
||||||
|
const coachIdsBySchedule = new Map<string, Set<string>>();
|
||||||
|
for (const assignment of assignments) {
|
||||||
|
const coachIds =
|
||||||
|
coachIdsBySchedule.get(assignment.scheduleId) ?? new Set<string>();
|
||||||
|
coachIds.add(assignment.coachId);
|
||||||
|
coachIdsBySchedule.set(assignment.scheduleId, coachIds);
|
||||||
|
|
||||||
|
const coachType = assignment.coach?.coachType;
|
||||||
|
if (
|
||||||
|
isDiningCoach({
|
||||||
|
coachTypeType: coachType?.type ?? null,
|
||||||
|
coachTypeName: coachType?.name ?? null,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const sellable = (assignment.coach?.seats ?? []).filter(
|
||||||
|
(seat) => !isPlaceholderSeat(seat),
|
||||||
|
).length;
|
||||||
|
sellableBySchedule.set(
|
||||||
|
assignment.scheduleId,
|
||||||
|
(sellableBySchedule.get(assignment.scheduleId) ?? 0) + sellable,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Paid / unpaid, per schedule ────────────────────────────────────────────
|
||||||
|
const scheduleIdSet = new Set(scheduleIds);
|
||||||
|
const paidBySchedule = new Map<string, number>();
|
||||||
|
const unpaidBySchedule = new Map<string, number>();
|
||||||
|
for (const bs of bookingSeats) {
|
||||||
|
// Dining seats only — the drill-down does not drop placeholder rows from the
|
||||||
|
// passenger seat list, so neither does this.
|
||||||
|
const coachType = bs.seat?.coach?.coachType;
|
||||||
|
if (
|
||||||
|
isDiningCoach({
|
||||||
|
coachTypeType: coachType?.type ?? null,
|
||||||
|
coachTypeName: coachType?.name ?? null,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors the OR branches, in the same order: an explicit `scheduleId` inside the
|
||||||
|
// window wins, then the return leg, then the booking's outbound schedule.
|
||||||
|
const scheduleId =
|
||||||
|
bs.scheduleId && scheduleIdSet.has(bs.scheduleId)
|
||||||
|
? bs.scheduleId
|
||||||
|
: bs.leg === 2
|
||||||
|
? bs.booking.returnScheduleId
|
||||||
|
: bs.booking.scheduleId;
|
||||||
|
if (!scheduleId || !scheduleIdSet.has(scheduleId)) continue;
|
||||||
|
|
||||||
|
const target =
|
||||||
|
bs.booking.status === 'PENDING_PAYMENT' ? unpaidBySchedule : paidBySchedule;
|
||||||
|
target.set(scheduleId, (target.get(scheduleId) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Expired holds, per schedule ────────────────────────────────────────────
|
||||||
|
const holdsBySchedule = new Map<string, number>();
|
||||||
|
for (const hold of holds) {
|
||||||
|
holdsBySchedule.set(
|
||||||
|
hold.scheduleId,
|
||||||
|
(holdsBySchedule.get(hold.scheduleId) ?? 0) + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Blocked seats, per schedule ────────────────────────────────────────────
|
||||||
|
// One counted block per seat, resolved exactly as the drill-down resolves it: a
|
||||||
|
// schedule-scoped block beats a global one, and rows arrive newest-first so the
|
||||||
|
// first of a kind seen for a seat is already the most recent.
|
||||||
|
const blockedBySchedule = new Map<string, number>();
|
||||||
|
for (const schedule of schedules) {
|
||||||
|
const assignedCoachIds = coachIdsBySchedule.get(schedule.id) ?? new Set<string>();
|
||||||
|
const countedSeatIds = new Map<string, string | null>();
|
||||||
|
|
||||||
|
for (const block of blockRows) {
|
||||||
|
const seat = block.seat;
|
||||||
|
if (!seat || isPlaceholderSeat(seat)) continue;
|
||||||
|
|
||||||
|
const coachType = seat.coach?.coachType;
|
||||||
|
if (
|
||||||
|
isDiningCoach({
|
||||||
|
coachTypeType: coachType?.type ?? null,
|
||||||
|
coachTypeName: coachType?.name ?? null,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.scheduleId !== null) {
|
||||||
|
if (block.scheduleId !== schedule.id) continue;
|
||||||
|
} else {
|
||||||
|
if (!assignedCoachIds.has(seat.coachId)) continue;
|
||||||
|
if (!isGlobalBlockInEffectAt(block, schedule.departureAt)) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = countedSeatIds.get(seat.id);
|
||||||
|
if (existing === undefined || (existing === null && block.scheduleId !== null)) {
|
||||||
|
countedSeatIds.set(seat.id, block.scheduleId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (countedSeatIds.size > 0) {
|
||||||
|
blockedBySchedule.set(schedule.id, countedSeatIds.size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Assemble ───────────────────────────────────────────────────────────────
|
||||||
|
const scheduleRows = schedules.map((s) => {
|
||||||
|
const sellableSeats = sellableBySchedule.get(s.id) ?? 0;
|
||||||
|
const paid = paidBySchedule.get(s.id) ?? 0;
|
||||||
|
const unpaid = unpaidBySchedule.get(s.id) ?? 0;
|
||||||
|
const expiredHolds = holdsBySchedule.get(s.id) ?? 0;
|
||||||
|
const blocked = blockedBySchedule.get(s.id) ?? 0;
|
||||||
|
// Floored at zero: a seat can be both sold and blocked, so the parts can
|
||||||
|
// over-subtract. Never render a negative slice.
|
||||||
|
const available = Math.max(0, sellableSeats - paid - unpaid - blocked);
|
||||||
|
|
||||||
|
return {
|
||||||
|
scheduleId: s.id,
|
||||||
|
trainNumber: s.train.number,
|
||||||
|
originStation: s.originStation.name,
|
||||||
|
destinationStation: s.destinationStation.name,
|
||||||
|
departureAt: s.departureAt,
|
||||||
|
isPackage: s.isPackageOnly,
|
||||||
|
sellableSeats,
|
||||||
|
paid,
|
||||||
|
unpaid,
|
||||||
|
expiredHolds,
|
||||||
|
blocked,
|
||||||
|
available,
|
||||||
|
loadFactorPercent:
|
||||||
|
sellableSeats > 0 ? +((paid / sellableSeats) * 100).toFixed(1) : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Day buckets keyed on the UTC calendar date of departure, so the chart's axis and
|
||||||
|
// its bars are derived from one value and cannot disagree with each other.
|
||||||
|
const byDayMap = new Map<string, ReturnType<typeof emptyDayBucket>>();
|
||||||
|
for (const row of scheduleRows) {
|
||||||
|
const date = row.departureAt.toISOString().slice(0, 10);
|
||||||
|
const bucket = byDayMap.get(date) ?? emptyDayBucket(date);
|
||||||
|
bucket.scheduleCount += 1;
|
||||||
|
bucket.sellableSeats += row.sellableSeats;
|
||||||
|
bucket.paid += row.paid;
|
||||||
|
bucket.unpaid += row.unpaid;
|
||||||
|
bucket.expiredHolds += row.expiredHolds;
|
||||||
|
bucket.blocked += row.blocked;
|
||||||
|
bucket.available += row.available;
|
||||||
|
byDayMap.set(date, bucket);
|
||||||
|
}
|
||||||
|
const byDay = [...byDayMap.values()].sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
|
||||||
|
const sum = (pick: (r: (typeof scheduleRows)[number]) => number) =>
|
||||||
|
scheduleRows.reduce((total, row) => total + pick(row), 0);
|
||||||
|
|
||||||
|
const totalSellable = sum((r) => r.sellableSeats);
|
||||||
|
const totalPaid = sum((r) => r.paid);
|
||||||
|
|
||||||
|
return {
|
||||||
|
window: {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
days,
|
||||||
|
direction,
|
||||||
|
truncated: schedules.length === OVERVIEW_MAX_SCHEDULES,
|
||||||
|
},
|
||||||
|
totals: {
|
||||||
|
scheduleCount: scheduleRows.length,
|
||||||
|
sellableSeats: totalSellable,
|
||||||
|
paidCount: totalPaid,
|
||||||
|
unpaidCount: sum((r) => r.unpaid),
|
||||||
|
expiredHoldCount: sum((r) => r.expiredHolds),
|
||||||
|
blockedCount: sum((r) => r.blocked),
|
||||||
|
availableCount: sum((r) => r.available),
|
||||||
|
loadFactorPercent:
|
||||||
|
totalSellable > 0 ? +((totalPaid / totalSellable) * 100).toFixed(1) : 0,
|
||||||
|
},
|
||||||
|
byDay,
|
||||||
|
schedules: scheduleRows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async getPaymentDiscrepancyReport(params: {
|
async getPaymentDiscrepancyReport(params: {
|
||||||
from?: string;
|
from?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { CheckCircle, Clock, AlertCircle, Ban, Armchair, Download } from "lucide-react";
|
import { CheckCircle, Clock, AlertCircle, Ban, Download } from "lucide-react";
|
||||||
import { apiClient } from "@/lib/api-client";
|
import { apiClient } from "@/lib/api-client";
|
||||||
|
import FleetSeatOverview from "@/components/reports/FleetSeatOverview";
|
||||||
import Badge from "@/components/ui/Badge";
|
import Badge from "@/components/ui/Badge";
|
||||||
import ActionButton from "@/components/ui/ActionButton";
|
import ActionButton from "@/components/ui/ActionButton";
|
||||||
import { formatDateTime, formatCurrency } from "@/lib/utils";
|
import { formatDateTime, formatCurrency } from "@/lib/utils";
|
||||||
@@ -165,12 +166,9 @@ export default function SeatStatusReportPage() {
|
|||||||
{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>
|
||||||
|
|
||||||
{!scheduleId && (
|
{/* Landing state only. Unmounts the moment a schedule is selected, leaving the
|
||||||
<div className="card py-16 text-center text-muted-foreground">
|
per-schedule report below untouched. */}
|
||||||
<Armchair className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
{!scheduleId && <FleetSeatOverview onSelectSchedule={setScheduleId} />}
|
||||||
<p>Select a schedule above to load the seat status report</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{data && (
|
{data && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -0,0 +1,469 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fleet seat overview — the landing state of the Seat Status Report, shown only while
|
||||||
|
* no schedule is selected. Once a schedule is picked this component unmounts and the
|
||||||
|
* per-schedule drill-down takes over unchanged.
|
||||||
|
*
|
||||||
|
* Its numbers come from `/reports/seat-status/overview`, which applies the same counting
|
||||||
|
* rules as `/reports/seat-status`, so a schedule's row here equals what the drill-down
|
||||||
|
* shows after clicking it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Cell,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import { Armchair, CalendarClock, TrendingUp } from "lucide-react";
|
||||||
|
import { apiClient } from "@/lib/api-client";
|
||||||
|
import { categoricalColor, getChartPalette } from "@/lib/chart-palette";
|
||||||
|
import { useTheme } from "@/lib/theme-store";
|
||||||
|
|
||||||
|
interface OverviewScheduleRow {
|
||||||
|
scheduleId: string;
|
||||||
|
trainNumber: string;
|
||||||
|
originStation: string;
|
||||||
|
destinationStation: string;
|
||||||
|
departureAt: string;
|
||||||
|
isPackage: boolean;
|
||||||
|
sellableSeats: number;
|
||||||
|
paid: number;
|
||||||
|
unpaid: number;
|
||||||
|
expiredHolds: number;
|
||||||
|
blocked: number;
|
||||||
|
available: number;
|
||||||
|
loadFactorPercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OverviewDayBucket {
|
||||||
|
date: string;
|
||||||
|
scheduleCount: number;
|
||||||
|
sellableSeats: number;
|
||||||
|
paid: number;
|
||||||
|
unpaid: number;
|
||||||
|
expiredHolds: number;
|
||||||
|
blocked: number;
|
||||||
|
available: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SeatStatusOverview {
|
||||||
|
window: {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
days: number;
|
||||||
|
direction: "UPCOMING" | "RECENT";
|
||||||
|
truncated: boolean;
|
||||||
|
};
|
||||||
|
totals: {
|
||||||
|
scheduleCount: number;
|
||||||
|
sellableSeats: number;
|
||||||
|
paidCount: number;
|
||||||
|
unpaidCount: number;
|
||||||
|
expiredHoldCount: number;
|
||||||
|
blockedCount: number;
|
||||||
|
availableCount: number;
|
||||||
|
loadFactorPercent: number;
|
||||||
|
};
|
||||||
|
byDay: OverviewDayBucket[];
|
||||||
|
schedules: OverviewScheduleRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixed domain order for the inventory series, matching the left-to-right order of the
|
||||||
|
* drill-down's summary cards. Colour is keyed by position here and never by rank in the
|
||||||
|
* data, so a quiet day does not repaint the series.
|
||||||
|
*
|
||||||
|
* Expired holds are deliberately absent: a hold that has expired no longer occupies a
|
||||||
|
* seat, so stacking it against sellable capacity would double-count. It is reported as a
|
||||||
|
* standalone counter instead.
|
||||||
|
*/
|
||||||
|
const INVENTORY_SERIES = [
|
||||||
|
{ key: "paid", label: "Paid", slot: 0 },
|
||||||
|
{ key: "unpaid", label: "Unpaid", slot: 1 },
|
||||||
|
{ key: "blocked", label: "Blocked", slot: 3 },
|
||||||
|
{ key: "available", label: "Available", slot: -1 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const MAX_SCHEDULE_BARS = 12;
|
||||||
|
|
||||||
|
/** `YYYY-MM-DD` → `05 Mar`, parsed by parts so no timezone can shift the label. */
|
||||||
|
function formatDayLabel(date: string): string {
|
||||||
|
const [, month, day] = date.split("-");
|
||||||
|
const monthName = [
|
||||||
|
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||||
|
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||||
|
][Number(month) - 1];
|
||||||
|
return `${day} ${monthName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWindow(from: string, to: string): string {
|
||||||
|
const opts: Intl.DateTimeFormatOptions = { day: "2-digit", month: "short" };
|
||||||
|
return `${new Date(from).toLocaleDateString("en-GB", opts)} – ${new Date(
|
||||||
|
to,
|
||||||
|
).toLocaleDateString("en-GB", opts)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FleetSeatOverviewProps {
|
||||||
|
/** Selecting a schedule from a chart hands control to the drill-down. */
|
||||||
|
onSelectSchedule: (scheduleId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FleetSeatOverview({ onSelectSchedule }: FleetSeatOverviewProps) {
|
||||||
|
const isDark = useTheme((s) => s.isDark);
|
||||||
|
const palette = getChartPalette(isDark);
|
||||||
|
|
||||||
|
const { data, isLoading, isError } = useQuery<SeatStatusOverview>({
|
||||||
|
queryKey: ["seat-status-overview"],
|
||||||
|
queryFn: () => apiClient.get("/reports/seat-status/overview"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const seriesColor = (slot: number) =>
|
||||||
|
slot < 0 ? palette.grid : categoricalColor(palette, slot);
|
||||||
|
|
||||||
|
const dayRows = useMemo(
|
||||||
|
() => (data?.byDay ?? []).map((d) => ({ ...d, label: formatDayLabel(d.date) })),
|
||||||
|
[data],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Busiest departures first — a 12-bar chart of the whole window would be unreadable,
|
||||||
|
// and the ones carrying the most seats are the ones worth looking at.
|
||||||
|
const scheduleRows = useMemo(
|
||||||
|
() =>
|
||||||
|
(data?.schedules ?? [])
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => b.sellableSeats - a.sellableSeats)
|
||||||
|
.slice(0, MAX_SCHEDULE_BARS)
|
||||||
|
.map((s) => ({
|
||||||
|
...s,
|
||||||
|
label: `${s.trainNumber} · ${new Date(s.departureAt).toLocaleDateString("en-GB", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
})}`,
|
||||||
|
})),
|
||||||
|
[data],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="card py-16 text-center text-muted-foreground">
|
||||||
|
<p className="text-sm">Loading fleet seat overview…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="card py-12 text-center">
|
||||||
|
<p className="text-sm text-red-500">Failed to load the fleet seat overview.</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Select a schedule above to load its report directly.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || data.totals.scheduleCount === 0) {
|
||||||
|
return (
|
||||||
|
<div className="card py-16 text-center text-muted-foreground">
|
||||||
|
<Armchair className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||||
|
<p>No departures on record to summarise</p>
|
||||||
|
<p className="text-xs mt-1">Select a schedule above to load its seat status report</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { window: win, totals } = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Window banner — the report is fleet-wide until a schedule is chosen, and the
|
||||||
|
window may be historic, so both facts are stated rather than implied. */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<CalendarClock className="h-5 w-5 text-muted-foreground mt-0.5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
All schedules · {formatWindow(win.from, win.to)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{win.direction === "UPCOMING"
|
||||||
|
? `Next ${win.days} days — ${totals.scheduleCount} departure${totals.scheduleCount === 1 ? "" : "s"}`
|
||||||
|
: `No upcoming departures — showing the most recent ${win.days} days (${totals.scheduleCount} departure${totals.scheduleCount === 1 ? "" : "s"})`}
|
||||||
|
{win.truncated && " · truncated to the first 60 departures"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">Load factor</span>
|
||||||
|
<span className="font-semibold tabular-nums text-foreground">
|
||||||
|
{totals.loadFactorPercent}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Window totals. Distinct wording from the per-schedule summary cards so the two
|
||||||
|
are never mistaken for each other. */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||||
|
{[
|
||||||
|
{ label: "Paid", value: totals.paidCount, hint: "Payment confirmed", slot: 0 },
|
||||||
|
{ label: "Unpaid", value: totals.unpaidCount, hint: "Awaiting payment", slot: 1 },
|
||||||
|
{ label: "Blocked", value: totals.blockedCount, hint: "Withheld from sale", slot: 3 },
|
||||||
|
{ label: "Available", value: totals.availableCount, hint: "Still sellable", slot: -1 },
|
||||||
|
{
|
||||||
|
label: "Expired Holds",
|
||||||
|
value: totals.expiredHoldCount,
|
||||||
|
hint: "Last 24h, seats released",
|
||||||
|
slot: -2,
|
||||||
|
},
|
||||||
|
].map((tile) => (
|
||||||
|
<div key={tile.label} className="card">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
{tile.slot !== -2 && (
|
||||||
|
<span
|
||||||
|
className="h-2.5 w-2.5 rounded-full mt-1.5 shrink-0"
|
||||||
|
style={{ backgroundColor: seriesColor(tile.slot) }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">{tile.label}</p>
|
||||||
|
<p className="text-2xl font-bold mt-1 tabular-nums text-foreground">
|
||||||
|
{tile.value}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">{tile.hint}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Seat mix per departure day */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-baseline justify-between gap-4 flex-wrap mb-1">
|
||||||
|
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
Seat mix by departure day
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{totals.sellableSeats} sellable seats in window
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mb-4">
|
||||||
|
Every seat running on each day, split by what happened to it — paid, waiting on
|
||||||
|
payment, blocked, or still on sale. The whole bar is that day's capacity.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Legend carries visible text labels — the palette's light-mode contrast is
|
||||||
|
validated only with that relief in place. */}
|
||||||
|
<div className="flex items-center gap-4 flex-wrap mb-3">
|
||||||
|
{INVENTORY_SERIES.map((s) => (
|
||||||
|
<div key={s.key} className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="h-2.5 w-2.5 rounded-sm"
|
||||||
|
style={{ backgroundColor: seriesColor(s.slot) }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">{s.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<BarChart data={dayRows} margin={{ top: 4, right: 8, bottom: 4, left: 0 }}>
|
||||||
|
<CartesianGrid stroke={palette.grid} vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="label"
|
||||||
|
tick={{ fill: palette.textMuted, fontSize: 12 }}
|
||||||
|
stroke={palette.axis}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fill: palette.textMuted, fontSize: 12 }}
|
||||||
|
stroke={palette.axis}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
allowDecimals={false}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
cursor={{ fill: palette.grid, fillOpacity: 0.35 }}
|
||||||
|
contentStyle={{
|
||||||
|
background: palette.tooltipBg,
|
||||||
|
border: `1px solid ${palette.tooltipBorder}`,
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: palette.textMuted }}
|
||||||
|
/>
|
||||||
|
{INVENTORY_SERIES.map((s) => (
|
||||||
|
<Bar
|
||||||
|
key={s.key}
|
||||||
|
dataKey={s.key}
|
||||||
|
name={s.label}
|
||||||
|
stackId="seats"
|
||||||
|
fill={seriesColor(s.slot)}
|
||||||
|
radius={s.key === "available" ? [3, 3, 0, 0] : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Load factor per schedule — doubles as the picker */}
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-1">
|
||||||
|
Load factor by departure
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mb-4">
|
||||||
|
How full each train is — paid seats as a share of the seats it can sell, so 100%
|
||||||
|
means sold out. Showing the {scheduleRows.length} busiest departure
|
||||||
|
{scheduleRows.length === 1 ? "" : "s"}; click a bar to open that train's
|
||||||
|
report.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ResponsiveContainer width="100%" height={44 * scheduleRows.length + 32}>
|
||||||
|
<BarChart
|
||||||
|
data={scheduleRows}
|
||||||
|
layout="vertical"
|
||||||
|
margin={{ top: 0, right: 40, bottom: 0, left: 8 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid stroke={palette.grid} horizontal={false} />
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
domain={[0, 100]}
|
||||||
|
unit="%"
|
||||||
|
tick={{ fill: palette.textMuted, fontSize: 12 }}
|
||||||
|
stroke={palette.axis}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="label"
|
||||||
|
width={130}
|
||||||
|
tick={{ fill: palette.textMuted, fontSize: 12 }}
|
||||||
|
stroke={palette.axis}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
cursor={{ fill: palette.grid, fillOpacity: 0.35 }}
|
||||||
|
contentStyle={{
|
||||||
|
background: palette.tooltipBg,
|
||||||
|
border: `1px solid ${palette.tooltipBorder}`,
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: palette.textMuted }}
|
||||||
|
formatter={(value: number, _name, entry: any) => [
|
||||||
|
`${value}% · ${entry?.payload?.paid ?? 0} of ${entry?.payload?.sellableSeats ?? 0} seats`,
|
||||||
|
"Load factor",
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="loadFactorPercent"
|
||||||
|
name="Load factor"
|
||||||
|
fill={palette.sequential}
|
||||||
|
radius={[0, 3, 3, 0]}
|
||||||
|
cursor="pointer"
|
||||||
|
onClick={(entry: any) => {
|
||||||
|
const id = entry?.payload?.scheduleId ?? entry?.scheduleId;
|
||||||
|
if (id) onSelectSchedule(id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{scheduleRows.map((row) => (
|
||||||
|
<Cell key={row.scheduleId} fill={palette.sequential} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The same numbers as a table — required relief for the palette's light-mode
|
||||||
|
contrast, and the only place the per-schedule detail is readable exactly. */}
|
||||||
|
<div className="card p-0">
|
||||||
|
<div className="px-4 pt-4 pb-3">
|
||||||
|
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
Schedules in window
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
The exact numbers behind the charts, one row per departure. Click a row to
|
||||||
|
open that train's full seat status report.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||||
|
<tr>
|
||||||
|
{[
|
||||||
|
"Departure",
|
||||||
|
"Train",
|
||||||
|
"Route",
|
||||||
|
"Sellable",
|
||||||
|
"Paid",
|
||||||
|
"Unpaid",
|
||||||
|
"Blocked",
|
||||||
|
"Available",
|
||||||
|
"Load",
|
||||||
|
].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">
|
||||||
|
{data.schedules.map((s) => (
|
||||||
|
<tr
|
||||||
|
key={s.scheduleId}
|
||||||
|
onClick={() => onSelectSchedule(s.scheduleId)}
|
||||||
|
className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap text-xs text-muted-foreground">
|
||||||
|
{new Date(s.departureAt).toLocaleString("en-GB", {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
})}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap font-medium">
|
||||||
|
{s.trainNumber}
|
||||||
|
{s.isPackage && (
|
||||||
|
<span className="text-muted-foreground text-xs"> (package)</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap text-xs">
|
||||||
|
{s.originStation} → {s.destinationStation}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap tabular-nums">
|
||||||
|
{s.sellableSeats}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap tabular-nums">{s.paid}</td>
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap tabular-nums">{s.unpaid}</td>
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap tabular-nums">{s.blocked}</td>
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap tabular-nums">
|
||||||
|
{s.available}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap tabular-nums">
|
||||||
|
{s.loadFactorPercent}%
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user