From 8594d75164af6b733c818528f5404a9480882f04 Mon Sep 17 00:00:00 2001 From: Mulu Mehari Date: Mon, 3 Aug 2026 11:37:16 +0300 Subject: [PATCH] Adding more detail messages for seat blocking --- .../modules/dashboard/dashboard.service.ts | 9 ++-- .../reports/blocked-seats-loss.calculator.ts | 11 ++-- .../src/modules/reports/reports.dto.ts | 4 +- .../src/modules/reports/reports.service.ts | 54 +++++++++++-------- .../backoffice/src/app/dashboard/page.tsx | 20 +++---- .../src/app/reports/blocked-seats/page.tsx | 39 ++++++-------- .../passenger/blocked-seat-revenue-loss.ts | 5 +- 7 files changed, 74 insertions(+), 68 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts b/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts index 70156ded9..6a5d83b89 100644 --- a/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts +++ b/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts @@ -5,12 +5,9 @@ import { BlockedSeatRevenueLossStat } from '@edr/types'; import { PrismaService } from '../../common/prisma.service'; import { ReportsService } from '../reports/reports.service'; -/** Window the dashboard's blocked-seat loss roll-up covers. Matches the report's default. */ -const BLOCKED_SEAT_LOSS_PERIOD_DAYS = 30; - /** Shown when nothing is blocked, or when the loss roll-up could not be computed. */ const EMPTY_BLOCKED_SEAT_LOSS: BlockedSeatRevenueLossStat = { - periodDays: BLOCKED_SEAT_LOSS_PERIOD_DAYS, + periodDays: null, lossByCurrency: [], schedulesAffected: 0, blockedSeatCount: 0, @@ -85,7 +82,7 @@ export class DashboardService { } /** - * Compact roll-up of the Blocked Seat Revenue Loss report over the last 30 days. + * Compact roll-up of the Blocked Seat Revenue Loss report over its full history. * * Reuses the report service rather than re-deriving the rule — there is exactly one * definition of what a blocked seat costs. A failure here degrades to zeroes instead of @@ -98,7 +95,7 @@ export class DashboardService { const { summary } = report; return { - periodDays: BLOCKED_SEAT_LOSS_PERIOD_DAYS, + periodDays: null, lossByCurrency: summary.lossByCurrency, schedulesAffected: summary.schedulesAffected, blockedSeatCount: summary.blockedSeatCount, diff --git a/apps/edr-passenger-api/src/modules/reports/blocked-seats-loss.calculator.ts b/apps/edr-passenger-api/src/modules/reports/blocked-seats-loss.calculator.ts index 64c0dc87e..6ce581067 100644 --- a/apps/edr-passenger-api/src/modules/reports/blocked-seats-loss.calculator.ts +++ b/apps/edr-passenger-api/src/modules/reports/blocked-seats-loss.calculator.ts @@ -346,6 +346,7 @@ export function assembleReport( // so a plain sum here never crosses currencies. const estimatedLossMinor = blocks.reduce((sum, b) => sum + b.estimatedLossMinor, 0); const currency = blocks.find((b) => b.currency)?.currency ?? 'ETB'; + const blockedByNames = [...new Set(blocks.map(blockerDisplayName))]; scheduleRows.push({ scheduleId: schedule.id, @@ -359,6 +360,7 @@ export function assembleReport( soldSeats, loadFactorPercent: +(loadFactor * 100).toFixed(1), blockedSeatCount: blocks.length, + blockedByNames, estimatedLossMinor, adjustedLossMinor: Math.round(estimatedLossMinor * loadFactor), currency, @@ -525,6 +527,11 @@ function groupByReasonCategory( return [...groups.values()].sort((a, b) => b.estimatedLossMinor - a.estimatedLossMinor); } +/** Legacy rows carry no name; 'SYSTEM' blocks are not a person. */ +function blockerDisplayName(block: Pick): string { + return block.blockedByName ?? (block.blockedBy === 'SYSTEM' ? 'System' : 'Unknown'); +} + function groupByBlocker(rows: BlockedSeatLossSchedule[]): BlockedSeatLossByBlocker[] { const groups = new Map(); for (const row of rows) { @@ -532,9 +539,7 @@ function groupByBlocker(rows: BlockedSeatLossSchedule[]): BlockedSeatLossByBlock const key = `${block.blockedBy}|${block.currency}`; const entry = groups.get(key) ?? { blockedBy: block.blockedBy, - // Legacy rows carry no name; 'SYSTEM' blocks are not a person. - blockedByName: - block.blockedByName ?? (block.blockedBy === 'SYSTEM' ? 'System' : 'Unknown'), + blockedByName: blockerDisplayName(block), count: 0, estimatedLossMinor: 0, currency: block.currency, diff --git a/apps/edr-passenger-api/src/modules/reports/reports.dto.ts b/apps/edr-passenger-api/src/modules/reports/reports.dto.ts index 384e83e1f..8b8049807 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.dto.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.dto.ts @@ -46,13 +46,13 @@ export enum BlockedSeatsLossSortBy { export class BlockedSeatsRevenueLossQueryDto { @ApiPropertyOptional({ example: '2026-07-01', - description: 'Start of the window, inclusive, matched on TrainSchedule.departureAt. Defaults to 30 days ago.', + description: 'Start of the window, inclusive, matched on TrainSchedule.departureAt. Defaults to the earliest scheduled departure on record.', }) @IsOptional() @IsDateString() dateFrom?: string; @ApiPropertyOptional({ example: '2026-07-31', - description: 'End of the window, inclusive, matched on TrainSchedule.departureAt. Defaults to today.', + description: 'End of the window, inclusive, matched on TrainSchedule.departureAt. Defaults to the latest scheduled departure on record.', }) @IsOptional() @IsDateString() dateTo?: string; diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index 5c4112773..8811a3855 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -25,8 +25,6 @@ import { /** Fares are quoted at the local tariff unless the caller asks otherwise. */ const DEFAULT_LOSS_NATIONALITY = "Ethiopian"; -/** Window used when the caller supplies neither `dateFrom` nor `dateTo`. */ -const DEFAULT_LOSS_WINDOW_DAYS = 30; const DEFAULT_LOSS_PAGE_SIZE = 25; /** How many schedules are priced in parallel. Keeps the DB from being flooded. */ const FARE_QUOTE_CONCURRENCY = 4; @@ -42,25 +40,6 @@ const EMPTY_LOSS_INPUT: LossCalculatorInput = { blocks: [], }; -/** - * Resolves the reporting window. Both bounds are inclusive and snap to whole local days, - * matching `generateReport`. Defaults to the last 30 days of departures. - */ -function resolveWindow( - query: Pick, - now: Date, -): { dateFrom: Date; dateTo: Date } { - const dateTo = query.dateTo ? new Date(query.dateTo) : new Date(now); - dateTo.setHours(23, 59, 59, 999); - - const dateFrom = query.dateFrom - ? new Date(query.dateFrom) - : new Date(dateTo.getTime() - DEFAULT_LOSS_WINDOW_DAYS * 24 * 60 * 60 * 1000); - dateFrom.setHours(0, 0, 0, 0); - - return { dateFrom, dateTo }; -} - /** Mirrors the fare engine's own LOCAL/INTERNATIONAL split. */ function resolveNationalityType(nationality: string): string { const upper = nationality.toUpperCase(); @@ -1235,6 +1214,37 @@ export class ReportsService { // ── Blocked Seat Revenue Loss ────────────────────────────────────────────── + /** + * Resolves the reporting window. Both bounds are inclusive and snap to whole local days, + * matching `generateReport`. When the caller supplies neither bound, defaults to the full + * history of scheduled departures on record — the earliest `TrainSchedule.departureAt` to + * the latest — not a rolling window, so nothing ages out of the report on its own. + */ + private async resolveWindow( + query: Pick, + now: Date, + ): Promise<{ dateFrom: Date; dateTo: Date }> { + let dateFrom: Date; + let dateTo: Date; + + if (query.dateFrom && query.dateTo) { + dateFrom = new Date(query.dateFrom); + dateTo = new Date(query.dateTo); + } else { + const bounds = await this.prisma.trainSchedule.aggregate({ + _min: { departureAt: true }, + _max: { departureAt: true }, + }); + dateFrom = query.dateFrom ? new Date(query.dateFrom) : (bounds._min.departureAt ?? new Date(now)); + dateTo = query.dateTo ? new Date(query.dateTo) : (bounds._max.departureAt ?? new Date(now)); + } + + dateTo.setHours(23, 59, 59, 999); + dateFrom.setHours(0, 0, 0, 0); + + return { dateFrom, dateTo }; + } + /** * Potential revenue lost to seats that were blocked and therefore never sellable. * @@ -1247,7 +1257,7 @@ export class ReportsService { query: BlockedSeatsRevenueLossQueryDto, ): Promise { const now = new Date(); - const { dateFrom, dateTo } = resolveWindow(query, now); + const { dateFrom, dateTo } = await this.resolveWindow(query, now); const nationalityAssumption = query.nationality?.trim() || DEFAULT_LOSS_NATIONALITY; const nationalityType = resolveNationalityType(nationalityAssumption); diff --git a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx index ab44cb799..649796cc5 100644 --- a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx @@ -329,16 +329,16 @@ function DashboardPageContent() { {/* Blocked-seat revenue loss — rides the same backoffice-stats payload, so the dashboard makes no extra request for it. */} -
+
-
- +
+
- - Blocked Seats + + Blocked Seats / Revenue Not Collected - Last {blockedLoss?.periodDays ?? 30}d + {blockedLoss?.periodDays ? `Last ${blockedLoss.periodDays}d` : "All time"}
{statsLoading ? ( @@ -355,8 +355,8 @@ function DashboardPageContent() { key={row.currency} className={ i === 0 - ? "text-3xl font-bold text-foreground tabular-nums" - : "text-lg font-semibold text-foreground tabular-nums" + ? "text-3xl font-bold text-rose-600 dark:text-rose-400 tabular-nums" + : "text-lg font-semibold text-rose-600/80 dark:text-rose-400/80 tabular-nums" } > {formatCurrency(row.estimatedLossMinor, row.currency)} @@ -386,9 +386,9 @@ function DashboardPageContent() {
- View full report + View full report )} diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/blocked-seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/blocked-seats/page.tsx index 33a011157..f2b1055e9 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/blocked-seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/blocked-seats/page.tsx @@ -69,12 +69,6 @@ function reasonLabel(category: string | null): string { return SEAT_BLOCK_REASON_CATEGORY_LABELS[key] ?? key; } -function isoDaysAgo(days: number): string { - const d = new Date(); - d.setDate(d.getDate() - days); - return d.toISOString().split("T")[0]; -} - const TABLE_PAGE_SIZE = 25; export default function BlockedSeatRevenueLossPage() { @@ -82,8 +76,11 @@ export default function BlockedSeatRevenueLossPage() { const palette = getChartPalette(isDark); // ── Filters ─────────────────────────────────────────────────────────────── - const [dateFrom, setDateFrom] = useState(isoDaysAgo(30)); - const [dateTo, setDateTo] = useState(() => new Date().toISOString().split("T")[0]); + // Blank dateFrom/dateTo are dropped before the request (see toQueryString), so the report + // defaults to its full history — the earliest schedule on record to the latest — rather + // than a rolling window. + const [dateFrom, setDateFrom] = useState(""); + const [dateTo, setDateTo] = useState(""); const [scheduleId, setScheduleId] = useState(""); const [routeId, setRouteId] = useState(""); const [trainId, setTrainId] = useState(""); @@ -143,8 +140,8 @@ export default function BlockedSeatRevenueLossPage() { const totalPages = Math.max(1, Math.ceil((data?.meta.total ?? 0) / TABLE_PAGE_SIZE)); const resetFilters = () => { - setDateFrom(isoDaysAgo(30)); - setDateTo(new Date().toISOString().split("T")[0]); + setDateFrom(""); + setDateTo(""); setScheduleId(""); setRouteId(""); setTrainId(""); @@ -674,6 +671,7 @@ export default function BlockedSeatRevenueLossPage() { "Route", "Departure", "Blocked", + "Blocked by", "Load factor", "Estimated loss", "Adjusted loss", @@ -699,7 +697,7 @@ export default function BlockedSeatRevenueLossPage() { {scheduleRows.length === 0 && ( No schedules on this page @@ -826,6 +824,11 @@ function ScheduleRow({ {formatDateTime(row.departureAt)} {row.blockedSeatCount} + + {row.blockedByNames.length > 1 + ? `${row.blockedByNames[0]} +${row.blockedByNames.length - 1}` + : (row.blockedByNames[0] ?? "—")} + {row.loadFactorPercent}%{" "} @@ -841,7 +844,7 @@ function ScheduleRow({ {expanded && ( - + @@ -866,8 +869,6 @@ function BlockDetailTable({ blocks }: { blocks: BlockedSeatLossDetail[] }) { "Blocked by", "Approved by", "Blocked at", - "Until", - "Days", "Estimated loss", ].map((h) => ( @@ -903,16 +904,6 @@ function BlockDetailTable({ blocks }: { blocks: BlockedSeatLossDetail[] }) { {formatDateTime(b.blockedAt)} - - {b.stillBlocked ? ( - Still blocked - ) : ( - formatDateTime(b.unblockAt) - )} - - - {b.daysBlocked} - {formatCurrency(b.estimatedLossMinor, b.currency)} diff --git a/packages/types/src/passenger/blocked-seat-revenue-loss.ts b/packages/types/src/passenger/blocked-seat-revenue-loss.ts index 6305792dc..f39791929 100644 --- a/packages/types/src/passenger/blocked-seat-revenue-loss.ts +++ b/packages/types/src/passenger/blocked-seat-revenue-loss.ts @@ -91,6 +91,8 @@ export interface BlockedSeatLossSchedule { /** `soldSeats / sellableSeats`, as a percentage rounded to one decimal. */ loadFactorPercent: number; blockedSeatCount: number; + /** Distinct blockers behind this schedule's blocked seats, in no particular order. */ + blockedByNames: string[]; /** Loss at full occupancy — the sum of the fares these seats would have sold for. */ estimatedLossMinor: number; /** `estimatedLossMinor × loadFactor` — what the train's actual demand supports. */ @@ -160,7 +162,8 @@ export interface BlockedSeatRevenueLossReport { /** Compact roll-up embedded in `GET /dashboard/backoffice-stats`. */ export interface BlockedSeatRevenueLossStat { - periodDays: number; + /** `null` means the roll-up covers full history — the earliest schedule to the latest. */ + periodDays: number | null; lossByCurrency: BlockedSeatLossByCurrency[]; schedulesAffected: number; blockedSeatCount: number;