mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Adding more detail messages for seat blocking
This commit is contained in:
@@ -5,12 +5,9 @@ import { BlockedSeatRevenueLossStat } from '@edr/types';
|
|||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { ReportsService } from '../reports/reports.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. */
|
/** Shown when nothing is blocked, or when the loss roll-up could not be computed. */
|
||||||
const EMPTY_BLOCKED_SEAT_LOSS: BlockedSeatRevenueLossStat = {
|
const EMPTY_BLOCKED_SEAT_LOSS: BlockedSeatRevenueLossStat = {
|
||||||
periodDays: BLOCKED_SEAT_LOSS_PERIOD_DAYS,
|
periodDays: null,
|
||||||
lossByCurrency: [],
|
lossByCurrency: [],
|
||||||
schedulesAffected: 0,
|
schedulesAffected: 0,
|
||||||
blockedSeatCount: 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
|
* 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
|
* 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;
|
const { summary } = report;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
periodDays: BLOCKED_SEAT_LOSS_PERIOD_DAYS,
|
periodDays: null,
|
||||||
lossByCurrency: summary.lossByCurrency,
|
lossByCurrency: summary.lossByCurrency,
|
||||||
schedulesAffected: summary.schedulesAffected,
|
schedulesAffected: summary.schedulesAffected,
|
||||||
blockedSeatCount: summary.blockedSeatCount,
|
blockedSeatCount: summary.blockedSeatCount,
|
||||||
|
|||||||
@@ -346,6 +346,7 @@ export function assembleReport(
|
|||||||
// so a plain sum here never crosses currencies.
|
// so a plain sum here never crosses currencies.
|
||||||
const estimatedLossMinor = blocks.reduce((sum, b) => sum + b.estimatedLossMinor, 0);
|
const estimatedLossMinor = blocks.reduce((sum, b) => sum + b.estimatedLossMinor, 0);
|
||||||
const currency = blocks.find((b) => b.currency)?.currency ?? 'ETB';
|
const currency = blocks.find((b) => b.currency)?.currency ?? 'ETB';
|
||||||
|
const blockedByNames = [...new Set(blocks.map(blockerDisplayName))];
|
||||||
|
|
||||||
scheduleRows.push({
|
scheduleRows.push({
|
||||||
scheduleId: schedule.id,
|
scheduleId: schedule.id,
|
||||||
@@ -359,6 +360,7 @@ export function assembleReport(
|
|||||||
soldSeats,
|
soldSeats,
|
||||||
loadFactorPercent: +(loadFactor * 100).toFixed(1),
|
loadFactorPercent: +(loadFactor * 100).toFixed(1),
|
||||||
blockedSeatCount: blocks.length,
|
blockedSeatCount: blocks.length,
|
||||||
|
blockedByNames,
|
||||||
estimatedLossMinor,
|
estimatedLossMinor,
|
||||||
adjustedLossMinor: Math.round(estimatedLossMinor * loadFactor),
|
adjustedLossMinor: Math.round(estimatedLossMinor * loadFactor),
|
||||||
currency,
|
currency,
|
||||||
@@ -525,6 +527,11 @@ function groupByReasonCategory(
|
|||||||
return [...groups.values()].sort((a, b) => b.estimatedLossMinor - a.estimatedLossMinor);
|
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<BlockedSeatLossDetail, 'blockedBy' | 'blockedByName'>): string {
|
||||||
|
return block.blockedByName ?? (block.blockedBy === 'SYSTEM' ? 'System' : 'Unknown');
|
||||||
|
}
|
||||||
|
|
||||||
function groupByBlocker(rows: BlockedSeatLossSchedule[]): BlockedSeatLossByBlocker[] {
|
function groupByBlocker(rows: BlockedSeatLossSchedule[]): BlockedSeatLossByBlocker[] {
|
||||||
const groups = new Map<string, BlockedSeatLossByBlocker>();
|
const groups = new Map<string, BlockedSeatLossByBlocker>();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
@@ -532,9 +539,7 @@ function groupByBlocker(rows: BlockedSeatLossSchedule[]): BlockedSeatLossByBlock
|
|||||||
const key = `${block.blockedBy}|${block.currency}`;
|
const key = `${block.blockedBy}|${block.currency}`;
|
||||||
const entry = groups.get(key) ?? {
|
const entry = groups.get(key) ?? {
|
||||||
blockedBy: block.blockedBy,
|
blockedBy: block.blockedBy,
|
||||||
// Legacy rows carry no name; 'SYSTEM' blocks are not a person.
|
blockedByName: blockerDisplayName(block),
|
||||||
blockedByName:
|
|
||||||
block.blockedByName ?? (block.blockedBy === 'SYSTEM' ? 'System' : 'Unknown'),
|
|
||||||
count: 0,
|
count: 0,
|
||||||
estimatedLossMinor: 0,
|
estimatedLossMinor: 0,
|
||||||
currency: block.currency,
|
currency: block.currency,
|
||||||
|
|||||||
@@ -46,13 +46,13 @@ export enum BlockedSeatsLossSortBy {
|
|||||||
export class BlockedSeatsRevenueLossQueryDto {
|
export class BlockedSeatsRevenueLossQueryDto {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: '2026-07-01',
|
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;
|
@IsOptional() @IsDateString() dateFrom?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: '2026-07-31',
|
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;
|
@IsOptional() @IsDateString() dateTo?: string;
|
||||||
|
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ import {
|
|||||||
|
|
||||||
/** Fares are quoted at the local tariff unless the caller asks otherwise. */
|
/** Fares are quoted at the local tariff unless the caller asks otherwise. */
|
||||||
const DEFAULT_LOSS_NATIONALITY = "Ethiopian";
|
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;
|
const DEFAULT_LOSS_PAGE_SIZE = 25;
|
||||||
/** How many schedules are priced in parallel. Keeps the DB from being flooded. */
|
/** How many schedules are priced in parallel. Keeps the DB from being flooded. */
|
||||||
const FARE_QUOTE_CONCURRENCY = 4;
|
const FARE_QUOTE_CONCURRENCY = 4;
|
||||||
@@ -42,25 +40,6 @@ const EMPTY_LOSS_INPUT: LossCalculatorInput = {
|
|||||||
blocks: [],
|
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<BlockedSeatsRevenueLossQueryDto, "dateFrom" | "dateTo">,
|
|
||||||
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. */
|
/** Mirrors the fare engine's own LOCAL/INTERNATIONAL split. */
|
||||||
function resolveNationalityType(nationality: string): string {
|
function resolveNationalityType(nationality: string): string {
|
||||||
const upper = nationality.toUpperCase();
|
const upper = nationality.toUpperCase();
|
||||||
@@ -1235,6 +1214,37 @@ export class ReportsService {
|
|||||||
|
|
||||||
// ── Blocked Seat Revenue Loss ──────────────────────────────────────────────
|
// ── 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<BlockedSeatsRevenueLossQueryDto, "dateFrom" | "dateTo">,
|
||||||
|
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.
|
* Potential revenue lost to seats that were blocked and therefore never sellable.
|
||||||
*
|
*
|
||||||
@@ -1247,7 +1257,7 @@ export class ReportsService {
|
|||||||
query: BlockedSeatsRevenueLossQueryDto,
|
query: BlockedSeatsRevenueLossQueryDto,
|
||||||
): Promise<BlockedSeatRevenueLossReport> {
|
): Promise<BlockedSeatRevenueLossReport> {
|
||||||
const now = new Date();
|
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 nationalityAssumption = query.nationality?.trim() || DEFAULT_LOSS_NATIONALITY;
|
||||||
const nationalityType = resolveNationalityType(nationalityAssumption);
|
const nationalityType = resolveNationalityType(nationalityAssumption);
|
||||||
|
|
||||||
|
|||||||
@@ -329,16 +329,16 @@ function DashboardPageContent() {
|
|||||||
|
|
||||||
{/* Blocked-seat revenue loss — rides the same backoffice-stats payload, so the
|
{/* Blocked-seat revenue loss — rides the same backoffice-stats payload, so the
|
||||||
dashboard makes no extra request for it. */}
|
dashboard makes no extra request for it. */}
|
||||||
<div className="card flex flex-col gap-3">
|
<div className="card flex flex-col gap-3 border-rose-200 bg-rose-50/60 dark:border-rose-900/50 dark:bg-rose-950/20">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="rounded-lg bg-slate-100 dark:bg-slate-800 p-1.5">
|
<div className="rounded-lg bg-rose-100 dark:bg-rose-900/40 p-1.5">
|
||||||
<Ban className="h-4 w-4 text-slate-600 dark:text-slate-400" />
|
<Ban className="h-4 w-4 text-rose-600 dark:text-rose-400" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
<span className="text-xs font-semibold uppercase tracking-wider text-rose-700 dark:text-rose-400">
|
||||||
Blocked Seats
|
Blocked Seats / Revenue Not Collected
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-auto text-[11px] text-muted-foreground">
|
<span className="ml-auto text-[11px] text-muted-foreground">
|
||||||
Last {blockedLoss?.periodDays ?? 30}d
|
{blockedLoss?.periodDays ? `Last ${blockedLoss.periodDays}d` : "All time"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{statsLoading ? (
|
{statsLoading ? (
|
||||||
@@ -355,8 +355,8 @@ function DashboardPageContent() {
|
|||||||
key={row.currency}
|
key={row.currency}
|
||||||
className={
|
className={
|
||||||
i === 0
|
i === 0
|
||||||
? "text-3xl font-bold text-foreground tabular-nums"
|
? "text-3xl font-bold text-rose-600 dark:text-rose-400 tabular-nums"
|
||||||
: "text-lg font-semibold text-foreground tabular-nums"
|
: "text-lg font-semibold text-rose-600/80 dark:text-rose-400/80 tabular-nums"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{formatCurrency(row.estimatedLossMinor, row.currency)}
|
{formatCurrency(row.estimatedLossMinor, row.currency)}
|
||||||
@@ -386,9 +386,9 @@ function DashboardPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href="/reports/blocked-seats"
|
href="/reports/blocked-seats"
|
||||||
className="flex items-center gap-1 text-xs text-primary hover:underline mt-auto pt-1"
|
className="flex items-center justify-center gap-1.5 rounded-md bg-rose-600 hover:bg-rose-700 dark:bg-rose-600 dark:hover:bg-rose-500 px-3 py-2 text-sm font-semibold text-white shadow-sm transition-colors mt-auto"
|
||||||
>
|
>
|
||||||
View full report <ArrowRight className="h-3 w-3" />
|
View full report <ArrowRight className="h-3.5 w-3.5" />
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -69,12 +69,6 @@ function reasonLabel(category: string | null): string {
|
|||||||
return SEAT_BLOCK_REASON_CATEGORY_LABELS[key] ?? key;
|
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;
|
const TABLE_PAGE_SIZE = 25;
|
||||||
|
|
||||||
export default function BlockedSeatRevenueLossPage() {
|
export default function BlockedSeatRevenueLossPage() {
|
||||||
@@ -82,8 +76,11 @@ export default function BlockedSeatRevenueLossPage() {
|
|||||||
const palette = getChartPalette(isDark);
|
const palette = getChartPalette(isDark);
|
||||||
|
|
||||||
// ── Filters ───────────────────────────────────────────────────────────────
|
// ── Filters ───────────────────────────────────────────────────────────────
|
||||||
const [dateFrom, setDateFrom] = useState(isoDaysAgo(30));
|
// Blank dateFrom/dateTo are dropped before the request (see toQueryString), so the report
|
||||||
const [dateTo, setDateTo] = useState(() => new Date().toISOString().split("T")[0]);
|
// 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 [scheduleId, setScheduleId] = useState("");
|
||||||
const [routeId, setRouteId] = useState("");
|
const [routeId, setRouteId] = useState("");
|
||||||
const [trainId, setTrainId] = 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 totalPages = Math.max(1, Math.ceil((data?.meta.total ?? 0) / TABLE_PAGE_SIZE));
|
||||||
|
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
setDateFrom(isoDaysAgo(30));
|
setDateFrom("");
|
||||||
setDateTo(new Date().toISOString().split("T")[0]);
|
setDateTo("");
|
||||||
setScheduleId("");
|
setScheduleId("");
|
||||||
setRouteId("");
|
setRouteId("");
|
||||||
setTrainId("");
|
setTrainId("");
|
||||||
@@ -674,6 +671,7 @@ export default function BlockedSeatRevenueLossPage() {
|
|||||||
"Route",
|
"Route",
|
||||||
"Departure",
|
"Departure",
|
||||||
"Blocked",
|
"Blocked",
|
||||||
|
"Blocked by",
|
||||||
"Load factor",
|
"Load factor",
|
||||||
"Estimated loss",
|
"Estimated loss",
|
||||||
"Adjusted loss",
|
"Adjusted loss",
|
||||||
@@ -699,7 +697,7 @@ export default function BlockedSeatRevenueLossPage() {
|
|||||||
{scheduleRows.length === 0 && (
|
{scheduleRows.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={7}
|
colSpan={8}
|
||||||
className="py-8 text-center text-sm text-muted-foreground"
|
className="py-8 text-center text-sm text-muted-foreground"
|
||||||
>
|
>
|
||||||
No schedules on this page
|
No schedules on this page
|
||||||
@@ -826,6 +824,11 @@ function ScheduleRow({
|
|||||||
{formatDateTime(row.departureAt)}
|
{formatDateTime(row.departureAt)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 tabular-nums whitespace-nowrap">{row.blockedSeatCount}</td>
|
<td className="px-4 py-3 tabular-nums whitespace-nowrap">{row.blockedSeatCount}</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-muted-foreground max-w-[12rem] truncate" title={row.blockedByNames.join(", ")}>
|
||||||
|
{row.blockedByNames.length > 1
|
||||||
|
? `${row.blockedByNames[0]} +${row.blockedByNames.length - 1}`
|
||||||
|
: (row.blockedByNames[0] ?? "—")}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3 whitespace-nowrap text-xs text-muted-foreground tabular-nums">
|
<td className="px-4 py-3 whitespace-nowrap text-xs text-muted-foreground tabular-nums">
|
||||||
{row.loadFactorPercent}%{" "}
|
{row.loadFactorPercent}%{" "}
|
||||||
<span className="opacity-70">
|
<span className="opacity-70">
|
||||||
@@ -841,7 +844,7 @@ function ScheduleRow({
|
|||||||
</tr>
|
</tr>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="bg-gray-50/60 dark:bg-gray-800/40 px-4 py-4">
|
<td colSpan={8} className="bg-gray-50/60 dark:bg-gray-800/40 px-4 py-4">
|
||||||
<BlockDetailTable blocks={row.blocks} />
|
<BlockDetailTable blocks={row.blocks} />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -866,8 +869,6 @@ function BlockDetailTable({ blocks }: { blocks: BlockedSeatLossDetail[] }) {
|
|||||||
"Blocked by",
|
"Blocked by",
|
||||||
"Approved by",
|
"Approved by",
|
||||||
"Blocked at",
|
"Blocked at",
|
||||||
"Until",
|
|
||||||
"Days",
|
|
||||||
"Estimated loss",
|
"Estimated loss",
|
||||||
].map((h) => (
|
].map((h) => (
|
||||||
<th key={h} className="px-3 py-2 text-left font-medium whitespace-nowrap">
|
<th key={h} className="px-3 py-2 text-left font-medium whitespace-nowrap">
|
||||||
@@ -903,16 +904,6 @@ function BlockDetailTable({ blocks }: { blocks: BlockedSeatLossDetail[] }) {
|
|||||||
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
|
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
|
||||||
{formatDateTime(b.blockedAt)}
|
{formatDateTime(b.blockedAt)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
|
|
||||||
{b.stillBlocked ? (
|
|
||||||
<span className="text-amber-600 dark:text-amber-400">Still blocked</span>
|
|
||||||
) : (
|
|
||||||
formatDateTime(b.unblockAt)
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 whitespace-nowrap tabular-nums text-muted-foreground">
|
|
||||||
{b.daysBlocked}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 whitespace-nowrap tabular-nums font-medium text-foreground">
|
<td className="px-3 py-2 whitespace-nowrap tabular-nums font-medium text-foreground">
|
||||||
{formatCurrency(b.estimatedLossMinor, b.currency)}
|
{formatCurrency(b.estimatedLossMinor, b.currency)}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ export interface BlockedSeatLossSchedule {
|
|||||||
/** `soldSeats / sellableSeats`, as a percentage rounded to one decimal. */
|
/** `soldSeats / sellableSeats`, as a percentage rounded to one decimal. */
|
||||||
loadFactorPercent: number;
|
loadFactorPercent: number;
|
||||||
blockedSeatCount: 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. */
|
/** Loss at full occupancy — the sum of the fares these seats would have sold for. */
|
||||||
estimatedLossMinor: number;
|
estimatedLossMinor: number;
|
||||||
/** `estimatedLossMinor × loadFactor` — what the train's actual demand supports. */
|
/** `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`. */
|
/** Compact roll-up embedded in `GET /dashboard/backoffice-stats`. */
|
||||||
export interface BlockedSeatRevenueLossStat {
|
export interface BlockedSeatRevenueLossStat {
|
||||||
periodDays: number;
|
/** `null` means the roll-up covers full history — the earliest schedule to the latest. */
|
||||||
|
periodDays: number | null;
|
||||||
lossByCurrency: BlockedSeatLossByCurrency[];
|
lossByCurrency: BlockedSeatLossByCurrency[];
|
||||||
schedulesAffected: number;
|
schedulesAffected: number;
|
||||||
blockedSeatCount: number;
|
blockedSeatCount: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user