fix issues

This commit is contained in:
Marshal
2026-07-09 20:26:14 +00:00
parent 1d77cc9219
commit e4429592d3
29 changed files with 1143 additions and 147 deletions

View File

@@ -44,6 +44,11 @@ export interface BookingListFilterOptions {
customsClearingEnabled?: boolean; customsClearingEnabled?: boolean;
createdFrom?: string; createdFrom?: string;
createdTo?: string; createdTo?: string;
scheduledFrom?: string;
scheduledTo?: string;
originYardId?: string;
destinationYardId?: string;
isGovernment?: 'true' | 'false';
consolidationPaired?: string; consolidationPaired?: string;
} }
@@ -814,6 +819,32 @@ export class BookingsRepository extends BaseRepository<Booking> {
createdTo: options.createdTo, createdTo: options.createdTo,
}); });
} }
if (options.scheduledFrom) {
qb.andWhere('booking.scheduled_date >= :scheduledFrom', {
scheduledFrom: options.scheduledFrom,
});
}
if (options.scheduledTo) {
// Inclusive end-of-day: callers pass a date; include the whole day.
qb.andWhere('booking.scheduled_date <= :scheduledTo', {
scheduledTo: options.scheduledTo,
});
}
if (options.originYardId) {
qb.andWhere('booking.origin_yard_id = :originYardId', {
originYardId: options.originYardId,
});
}
if (options.destinationYardId) {
qb.andWhere('booking.destination_yard_id = :destinationYardId', {
destinationYardId: options.destinationYardId,
});
}
if (options.isGovernment === 'true') {
qb.andWhere('booking.is_government = TRUE');
} else if (options.isGovernment === 'false') {
qb.andWhere('booking.is_government = FALSE');
}
if (options.tradeDirection) { if (options.tradeDirection) {
qb.andWhere('booking.trade_direction = :tradeDirection', { qb.andWhere('booking.trade_direction = :tradeDirection', {
tradeDirection: options.tradeDirection, tradeDirection: options.tradeDirection,

View File

@@ -1158,6 +1158,11 @@ export class BookingsService {
paymentStatus: filter.paymentStatus, paymentStatus: filter.paymentStatus,
createdFrom: filter.createdFrom, createdFrom: filter.createdFrom,
createdTo: filter.createdTo, createdTo: filter.createdTo,
scheduledFrom: filter.scheduledFrom,
scheduledTo: filter.scheduledTo,
originYardId: filter.originYardId,
destinationYardId: filter.destinationYardId,
isGovernment: filter.isGovernment,
consolidationPaired: filter.consolidationPaired, consolidationPaired: filter.consolidationPaired,
sortBy: filter.sortBy, sortBy: filter.sortBy,
sortOrder: filter.sortOrder, sortOrder: filter.sortOrder,
@@ -1371,11 +1376,17 @@ export class BookingsService {
serviceTypeId: filter.serviceTypeId, serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId, cargoTypeId: filter.cargoTypeId,
freightType: filter.freightType, freightType: filter.freightType,
bookingType: filter.bookingType,
tradeDirection: filter.tradeDirection, tradeDirection: filter.tradeDirection,
paymentCurrency: filter.paymentCurrency, paymentCurrency: filter.paymentCurrency,
paymentStatus: filter.paymentStatus, paymentStatus: filter.paymentStatus,
createdFrom: filter.createdFrom, createdFrom: filter.createdFrom,
createdTo: filter.createdTo, createdTo: filter.createdTo,
scheduledFrom: filter.scheduledFrom,
scheduledTo: filter.scheduledTo,
originYardId: filter.originYardId,
destinationYardId: filter.destinationYardId,
isGovernment: filter.isGovernment,
consolidationPaired: filter.consolidationPaired, consolidationPaired: filter.consolidationPaired,
}; };

View File

@@ -81,6 +81,31 @@ export class FilterBookingDto {
@IsDateString() @IsDateString()
createdTo?: string; createdTo?: string;
@ApiPropertyOptional({ description: 'Filter bookings scheduled on/after this date (ISO)' })
@IsOptional()
@IsDateString()
scheduledFrom?: string;
@ApiPropertyOptional({ description: 'Filter bookings scheduled on/before this date (ISO)' })
@IsOptional()
@IsDateString()
scheduledTo?: string;
@ApiPropertyOptional({ format: 'uuid', description: 'Filter by origin yard' })
@IsOptional()
@IsUUID()
originYardId?: string;
@ApiPropertyOptional({ format: 'uuid', description: 'Filter by destination yard' })
@IsOptional()
@IsUUID()
destinationYardId?: string;
@ApiPropertyOptional({ enum: ['true', 'false'], description: 'Filter government vs private bookings' })
@IsOptional()
@IsIn(['true', 'false'])
isGovernment?: 'true' | 'false';
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS }) @ApiPropertyOptional({ enum: TRADE_DIRECTIONS })
@IsOptional() @IsOptional()
@IsIn([...TRADE_DIRECTIONS]) @IsIn([...TRADE_DIRECTIONS])

View File

@@ -11,7 +11,15 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm'; import { InjectDataSource } from '@nestjs/typeorm';
import { SchedulerRegistry } from '@nestjs/schedule'; import { SchedulerRegistry } from '@nestjs/schedule';
import { DataSource, In } from 'typeorm'; import {
Between,
DataSource,
FindOptionsWhere,
ILike,
In,
LessThanOrEqual,
MoreThanOrEqual,
} from 'typeorm';
import { Booking } from '../bookings/entities/booking.entity'; import { Booking } from '../bookings/entities/booking.entity';
import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingsRepository } from '../bookings/bookings.repository';
@@ -27,6 +35,10 @@ import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-r
import { BookingNotifierService } from './booking-notifier.service'; import { BookingNotifierService } from './booking-notifier.service';
import { TrainSchedulingService } from './train-scheduling.service'; import { TrainSchedulingService } from './train-scheduling.service';
import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util'; import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util';
import {
BATCH_BOARD_STATUSES,
BatchBoardQueryDto,
} from './dto/batch-board-query.dto';
import { Freight, TrainScheduleStatus as TrainScheduleStatusEnum } from "@edr/types"; import { Freight, TrainScheduleStatus as TrainScheduleStatusEnum } from "@edr/types";
import { BillingService } from "../billing/billing.service"; import { BillingService } from "../billing/billing.service";
@@ -158,6 +170,8 @@ export interface BatchWindowGroup {
export interface BatchBoardScheduleDetail { export interface BatchBoardScheduleDetail {
scheduleId: string; scheduleId: string;
/** Human-facing schedule reference (S-YYYY-NNNNN). */
scheduleReference: string | null;
trainNumber: string | null; trainNumber: string | null;
routeName: string | null; routeName: string | null;
origin: string | null; origin: string | null;
@@ -182,11 +196,14 @@ export interface BatchBoardScheduleDetail {
export interface BatchBoardSchedule { export interface BatchBoardSchedule {
scheduleId: string; scheduleId: string;
/** Human-facing schedule reference (S-YYYY-NNNNN). */
scheduleReference: string | null;
trainNumber: string | null; trainNumber: string | null;
routeName: string | null; routeName: string | null;
origin: string | null; origin: string | null;
destination: string | null; destination: string | null;
scheduleDate: string | null; scheduleDate: string | null;
createdAt: string | null;
status: string; status: string;
bookingWindowStatus: string; bookingWindowStatus: string;
direction: string | null; direction: string | null;
@@ -225,6 +242,16 @@ export interface BatchBoardSchedule {
bookings: BatchBoardBooking[]; bookings: BatchBoardBooking[];
} }
/** Paginated batch-board list. `items` (not `data`) — the API response wrapper
* already uses `data`, and the frontend's unwrap() strips one `data` level. */
export interface BatchBoardListResponse {
items: BatchBoardSchedule[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
/** /**
* Demand-batching engine: every 3h (EAT) it ranks each OPEN schedule's ready pool * Demand-batching engine: every 3h (EAT) it ranks each OPEN schedule's ready pool
* by priority, greedily fills the train to capacity (skipping bookings that don't fit), * by priority, greedily fills the train to capacity (skipping bookings that don't fit),
@@ -454,7 +481,7 @@ export class BookingBatchService implements OnModuleInit {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
booking.trainScheduleId, booking.trainScheduleId,
); );
if (schedule && (await this.remainingWagons(schedule)) <= 0) { if (schedule && (await this.isTrainFull(schedule))) {
await this.setWindow(booking.trainScheduleId, "FULL"); await this.setWindow(booking.trainScheduleId, "FULL");
} }
@@ -612,7 +639,7 @@ export class BookingBatchService implements OnModuleInit {
this.armSettle(scheduleId); this.armSettle(scheduleId);
const schedule = const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (schedule && (await this.remainingWagons(schedule)) <= 0) { if (schedule && (await this.isTrainFull(schedule))) {
await this.setWindow(scheduleId, 'FULL'); await this.setWindow(scheduleId, 'FULL');
} }
} }
@@ -653,12 +680,70 @@ export class BookingBatchService implements OnModuleInit {
// ---- monitoring board ----------------------------------------------------- // ---- monitoring board -----------------------------------------------------
/** /**
* Read model for the batch monitoring page: every still-relevant schedule (not arrived/ * Read model for the batch monitoring page: every import schedule — including
* cancelled) with its locomotive, capacity usage and its bookings grouped by lifecycle * dispatched, arrived and cancelled history — with its locomotive, capacity
* state (allocated / awaiting payment / paid-waiting / pending contract / expired). * usage and its bookings grouped by lifecycle state (allocated / awaiting
* payment / paid-waiting / pending contract / expired). Paginated and
* filterable; per-schedule booking summaries are only computed for the
* requested page.
*/ */
async getBatchBoard(): Promise<BatchBoardSchedule[]> { async getBatchBoard(
const schedules = await this.trainSchedulesRepository.findAll({ query: BatchBoardQueryDto = {},
): Promise<BatchBoardListResponse> {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 12;
// Status filter: any subset of the lifecycle. Omitted = all statuses, so
// arrived / cancelled / dispatched schedules stay visible as history.
const allowedStatuses = new Set<string>(BATCH_BOARD_STATUSES);
const statuses = (query.statuses ?? "")
.split(",")
.map((v) => v.trim().toUpperCase())
.filter((v) => allowedStatuses.has(v));
const dateRange = (from?: string, to?: string) => {
const f = from ? new Date(from) : null;
const t = to ? new Date(to) : null;
if (f && t) return Between(f, t);
if (f) return MoreThanOrEqual(f);
if (t) return LessThanOrEqual(t);
return undefined;
};
// Batch board is IMPORT-only: export is FCFS with no batch/priority calc,
// and domestic/legacy schedules run the legacy fill, not the window batch.
const base: FindOptionsWhere<TrainSchedule> = { direction: "IMPORT" };
if (statuses.length) base.status = In(statuses) as never;
if (query.bookingWindowStatus) {
base.bookingWindowStatus = query.bookingWindowStatus;
}
const departure = dateRange(query.departureFrom, query.departureTo);
if (departure) base.scheduledDepartureDate = departure as never;
const created = dateRange(query.createdFrom, query.createdTo);
if (created) base.createdAt = created as never;
// Search fans out across every human-recognizable label. Each OR variant
// repeats the base filters so the search never widens them.
const term = query.search?.trim();
let where: FindOptionsWhere<TrainSchedule> | FindOptionsWhere<TrainSchedule>[] =
base;
if (term) {
const like = ILike(`%${term}%`);
where = [
{ ...base, trainNumber: like as never },
{ ...base, originStation: { label: like } },
{ ...base, destinationStation: { label: like } },
{ ...base, route: { originYard: { label: like } } },
{ ...base, route: { destinationYard: { label: like } } },
{ ...base, trainSet: { locomotive: { code: like } } },
] as FindOptionsWhere<TrainSchedule>[];
}
const sortBy = query.sortBy ?? "createdAt";
const sortOrder = query.sortOrder ?? "DESC";
const [schedules, total] = await this.trainSchedulesRepository.findAndCount({
where,
relations: { relations: {
trainSet: { locomotive: true }, trainSet: { locomotive: true },
originStation: true, originStation: true,
@@ -666,7 +751,9 @@ export class BookingBatchService implements OnModuleInit {
// Yards supply the route's display name for `routeName` below. // Yards supply the route's display name for `routeName` below.
route: { originYard: true, destinationYard: true }, route: { originYard: true, destinationYard: true },
}, },
order: { scheduledDepartureDate: "ASC" }, order: { [sortBy]: sortOrder } as never,
skip: (page - 1) * pageSize,
take: pageSize,
}); });
const wagonDims = await this.loadWagonDims(); const wagonDims = await this.loadWagonDims();
@@ -675,11 +762,6 @@ export class BookingBatchService implements OnModuleInit {
const board: BatchBoardSchedule[] = []; const board: BatchBoardSchedule[] = [];
for (const s of schedules) { for (const s of schedules) {
if (s.status === "ARRIVED" || s.status === "CANCELLED") continue;
// Batch board is IMPORT-only: export is FCFS with no batch/priority calc,
// and domestic/legacy schedules run the legacy fill, not the window batch.
if (s.direction !== "IMPORT") continue;
const links = await linkRepo.find({ where: { trainScheduleId: s.id } }); const links = await linkRepo.find({ where: { trainScheduleId: s.id } });
const linkedIds = new Set(links.map((l) => l.bookingId)); const linkedIds = new Set(links.map((l) => l.bookingId));
const bookings = await this.bookingsRepository.findAllBySchedule(s.id); const bookings = await this.bookingsRepository.findAllBySchedule(s.id);
@@ -707,7 +789,14 @@ export class BookingBatchService implements OnModuleInit {
board.push(this.buildScheduleSummary(s, items, rules)); board.push(this.buildScheduleSummary(s, items, rules));
} }
return board;
return {
items: board,
total,
page,
pageSize,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
};
} }
/** Schedule-level batch board with EAT 3h windows grouped by fullyExecutedAt. */ /** Schedule-level batch board with EAT 3h windows grouped by fullyExecutedAt. */
@@ -718,9 +807,8 @@ export class BookingBatchService implements OnModuleInit {
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!s) if (!s)
throw new NotFoundException(`Train schedule ${scheduleId} not found`); throw new NotFoundException(`Train schedule ${scheduleId} not found`);
if (s.status === "ARRIVED" || s.status === "CANCELLED") { // Arrived / cancelled schedules stay viewable — the board is also the
throw new BadRequestException("Schedule is no longer active"); // historical record of what each train carried.
}
// Batch board is IMPORT-only (export is FCFS, no batch/priority calc). // Batch board is IMPORT-only (export is FCFS, no batch/priority calc).
if (s.direction !== "IMPORT") { if (s.direction !== "IMPORT") {
throw new BadRequestException( throw new BadRequestException(
@@ -895,6 +983,7 @@ export class BookingBatchService implements OnModuleInit {
return { return {
scheduleId: s.id, scheduleId: s.id,
scheduleReference: s.reference ?? null,
trainNumber: s.trainNumber ?? null, trainNumber: s.trainNumber ?? null,
routeName: s.route ? formatRouteLabel(s.route) : null, routeName: s.route ? formatRouteLabel(s.route) : null,
origin: s.originStation?.label ?? s.originStation?.code ?? null, origin: s.originStation?.label ?? s.originStation?.code ?? null,
@@ -1016,6 +1105,7 @@ export class BookingBatchService implements OnModuleInit {
return { return {
scheduleId: s.id, scheduleId: s.id,
scheduleReference: s.reference ?? null,
trainNumber: s.trainNumber ?? null, trainNumber: s.trainNumber ?? null,
routeName: s.route ? formatRouteLabel(s.route) : null, routeName: s.route ? formatRouteLabel(s.route) : null,
origin: s.originStation?.label ?? s.originStation?.code ?? null, origin: s.originStation?.label ?? s.originStation?.code ?? null,
@@ -1024,6 +1114,7 @@ export class BookingBatchService implements OnModuleInit {
scheduleDate: s.scheduledDepartureDate scheduleDate: s.scheduledDepartureDate
? s.scheduledDepartureDate.toISOString() ? s.scheduledDepartureDate.toISOString()
: null, : null,
createdAt: s.createdAt ? s.createdAt.toISOString() : null,
status: s.status, status: s.status,
bookingWindowStatus: s.bookingWindowStatus, bookingWindowStatus: s.bookingWindowStatus,
direction: s.direction ?? null, direction: s.direction ?? null,
@@ -1117,7 +1208,8 @@ export class BookingBatchService implements OnModuleInit {
const limits = await this.capacityLimits(locomotive, rules); const limits = await this.capacityLimits(locomotive, rules);
await this.syncScheduleMaxWagons(schedule, locomotive, rules); await this.syncScheduleMaxWagons(schedule, locomotive, rules);
const budget = await this.remainingBudget(schedule, limits, wagonDims); const budget = await this.remainingBudget(schedule, limits, wagonDims);
if (budget.maxRemaining().wagons <= 0) { const minPerWagon = this.minPerWagonNeed(wagonDims);
if (budget.isExhausted(minPerWagon)) {
await this.setWindow(scheduleId, "FULL"); await this.setWindow(scheduleId, "FULL");
return 0; return 0;
} }
@@ -1213,7 +1305,7 @@ export class BookingBatchService implements OnModuleInit {
this.logger.log( this.logger.log(
`[fillSchedule ${scheduleId}] reserved ${reservedThisPass}/${units.length} unit(s) this pass`, `[fillSchedule ${scheduleId}] reserved ${reservedThisPass}/${units.length} unit(s) this pass`,
); );
if (budget.maxRemaining().wagons <= 0) await this.setWindow(scheduleId, "FULL"); if (budget.isExhausted(minPerWagon)) await this.setWindow(scheduleId, "FULL");
if (armed) this.armSettle(scheduleId); if (armed) this.armSettle(scheduleId);
void this.triggerWagonAllocation(scheduleId); void this.triggerWagonAllocation(scheduleId);
return commercialReserved; return commercialReserved;
@@ -1461,8 +1553,9 @@ export class BookingBatchService implements OnModuleInit {
`[fillRouteDay ${originYardId}->${destinationYardId} ${day}] reserved ${reservedThisPass}/${units.length} unit(s) this pass`, `[fillRouteDay ${originYardId}->${destinationYardId} ${day}] reserved ${reservedThisPass}/${units.length} unit(s) this pass`,
); );
const minPerWagon = this.minPerWagonNeed(wagonDims);
for (const t of trains) { for (const t of trains) {
if (t.budget.maxRemaining().wagons <= 0) await this.setWindow(t.id, "FULL"); if (t.budget.isExhausted(minPerWagon)) await this.setWindow(t.id, "FULL");
if (t.armed) this.armSettle(t.id); if (t.armed) this.armSettle(t.id);
void this.triggerWagonAllocation(t.id); void this.triggerWagonAllocation(t.id);
} }
@@ -1783,7 +1876,7 @@ export class BookingBatchService implements OnModuleInit {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
booking.trainScheduleId, booking.trainScheduleId,
); );
if (schedule && (await this.remainingWagons(schedule)) <= 0) { if (schedule && (await this.isTrainFull(schedule))) {
await this.setWindow(booking.trainScheduleId, "FULL"); await this.setWindow(booking.trainScheduleId, "FULL");
} }
void this.triggerWagonAllocation(booking.trainScheduleId!); void this.triggerWagonAllocation(booking.trainScheduleId!);
@@ -2616,7 +2709,9 @@ export class BookingBatchService implements OnModuleInit {
/** /**
* Wagon slots still boardable somewhere on the corridor (most-open edge). * Wagon slots still boardable somewhere on the corridor (most-open edge).
* ≤ 0 means no leg can take another booking — the train-wide FULL signal. * ≤ 0 means no leg can take another booking. Slot axis ONLY — the train-wide
* FULL signal is {@link isTrainFull}, which also closes weight/length-bound
* trains that still show free slots.
*/ */
private async remainingWagons(schedule: TrainSchedule): Promise<number> { private async remainingWagons(schedule: TrainSchedule): Promise<number> {
const wagonDims = await this.loadWagonDims(); const wagonDims = await this.loadWagonDims();
@@ -2681,12 +2776,52 @@ export class BookingBatchService implements OnModuleInit {
); );
} }
/** No wagon slots left for allocated + reserved bookings. */ /**
* FULL on ANY capacity axis: out of wagon slots, or out of pull weight /
* train length for even one more loaded wagon. The old slot-only check let
* a weight-bound train (PW2: weight binds at 37 wagons = 3522.4T of
* 3500+90T, slots bind at 44) cycle its booking window forever instead of
* finalizing — 7 phantom slots kept it "not full" while nothing could board.
*/
async isScheduleFull(scheduleId: string): Promise<boolean> { async isScheduleFull(scheduleId: string): Promise<boolean> {
const schedule = const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) return false; if (!schedule) return false;
return (await this.remainingWagons(schedule)) <= 0; return this.isTrainFull(schedule);
}
/** See {@link isScheduleFull} — same check for callers that already hold the full graph. */
private async isTrainFull(schedule: TrainSchedule): Promise<boolean> {
if ((await this.remainingWagons(schedule)) <= 0) return true;
const locomotive = schedule.trainSet?.locomotive;
if (!locomotive) return false; // no weight/length limits to bind against
const rules = await this.loadGlobalRules();
const wagonDims = await this.loadWagonDims();
const limits = await this.capacityLimits(locomotive, rules);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
return budget.isExhausted(this.minPerWagonNeed(wagonDims));
}
/**
* Smallest gross weight / shortest length one more wagon could add: the
* lightest wagon type at its rated payload. Feeds CorridorBudget.isExhausted,
* so FULL is only declared when not even this wagon fits anywhere.
*/
private minPerWagonNeed(wagonDims: WagonDims): {
grossWeightTons: number;
lengthMeters: number;
} {
const all = [
wagonDims.container,
wagonDims.bulk,
...wagonDims.byWagonTypeId.values(),
];
return {
grossWeightTons: Math.min(
...all.map((d) => d.tareWeightTons + d.capacityTons),
),
lengthMeters: Math.min(...all.map((d) => d.lengthMeters)),
};
} }
/** /**
@@ -2707,7 +2842,10 @@ export class BookingBatchService implements OnModuleInit {
const schedule = const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule || schedule.bookingWindowStatus !== "FULL") return; if (!schedule || schedule.bookingWindowStatus !== "FULL") return;
if ((await this.remainingWagons(schedule)) <= 0) return; // Symmetric with isScheduleFull: a weight/length-bound FULL is not stale
// just because slots remain — clearing it here would reopen a train
// nothing can board.
if (await this.isTrainFull(schedule)) return;
const customerWindowOpen = const customerWindowOpen =
schedule.windowPhase == null || schedule.windowPhase === "OPEN"; schedule.windowPhase == null || schedule.windowPhase === "OPEN";

View File

@@ -28,6 +28,9 @@ import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
* unload at its destination yard (IN_TRANSIT → ARRIVED for import/export, * unload at its destination yard (IN_TRANSIT → ARRIVED for import/export,
* → COMPLETED for intercity), possibly long before the train's final arrival. * → COMPLETED for intercity), possibly long before the train's final arrival.
* Both are gated on the train's latest recorded checkpoint being at that yard. * Both are gated on the train's latest recorded checkpoint being at that yard.
* Unload also fires automatically: recording a checkpoint at a yard auto-
* unloads every booking destined there (autoUnloadAtYard), so the manual
* unload endpoint remains only a fallback.
* *
* Unloading also settles the physical wagons: each wagon that alights with the * Unloading also settles the physical wagons: each wagon that alights with the
* booking is released at that yard and the move is written to the * booking is released at that yard and the move is written to the
@@ -198,6 +201,47 @@ export class BookingJourneyService {
}; };
} }
/**
* Auto-unload on checkpoint: every IN_TRANSIT booking on this schedule whose
* destination is the yard the train just reached alights automatically, so
* the customer's booking flips to ARRIVED (COMPLETED for intercity) the
* moment the train is recorded at their yard — no separate operator unload.
* Runs through the same per-booking unload path (wagon settle + ledger +
* milestones); one booking's failure is logged and never blocks the
* checkpoint or the other bookings. Returns the unloaded booking ids.
*/
async autoUnloadAtYard(
scheduleId: string,
yardId: string,
userId?: string | null,
): Promise<string[]> {
const bookings = await this.dataSource
.getRepository(Booking)
.createQueryBuilder('booking')
.innerJoin(
'freight.train_schedule_bookings',
'tsb',
'tsb.booking_id = booking.id AND tsb.train_schedule_id = :scheduleId AND tsb.deleted_at IS NULL',
{ scheduleId },
)
.where('booking.destination_yard_id = :yardId', { yardId })
.andWhere(`booking.status = 'IN_TRANSIT'`)
.getMany();
const unloaded: string[] = [];
for (const booking of bookings) {
try {
await this.unloadBooking(scheduleId, booking.id, userId);
unloaded.push(booking.id);
} catch (err) {
this.logger.warn(
`Auto-unload failed for booking ${booking.id} at yard ${yardId}: ${(err as Error).message}`,
);
}
}
return unloaded;
}
/** /**
* Bulk fallback at the train's FINAL arrival: any booking destined for the * Bulk fallback at the train's FINAL arrival: any booking destined for the
* final yard that operators didn't unload individually gets its per-booking * final yard that operators didn't unload individually gets its per-booking

View File

@@ -72,4 +72,49 @@ describe('corridor-capacity.util — overage tolerance', () => {
strict.subtract(need(3500, 10, 170), strict.fullLeg()); strict.subtract(need(3500, 10, 170), strict.fullLeg());
expect(strict.fits(need(1), strict.fullLeg())).toBe(false); expect(strict.fits(need(1), strict.fullLeg())).toBe(false);
}); });
describe('isExhausted — train-wide FULL across all axes', () => {
// Lightest wagon at rated payload: PW2 25.2T tare + 70T = 95.2T gross.
const perWagon = {
grossWeightTons: pw2.tareWeightTons + pw2.capacityTons,
lengthMeters: pw2.lengthMeters,
};
it('reports FULL when weight binds first, with wagon slots still free', () => {
// 37 loaded PW2 wagons = 3522.4T of 3500+90T. 7 length-derived slots
// remain, but wagon 38 would need 95.2T against 67.6T of room — the
// schedule must finalize and its window must disappear.
const budget = new CorridorBudget(stops, base, tolerance);
budget.subtract(need(3522.4, 37, 631.442), budget.fullLeg());
expect(budget.maxRemaining().wagons).toBeGreaterThan(0); // slot check alone says "not full"
expect(budget.isExhausted(perWagon)).toBe(true);
});
it('is not FULL while one more loaded wagon still fits within base + tolerance', () => {
const budget = budgetAt(3300); // 200T base room + 90T tolerance ≥ 95.2T
expect(budget.isExhausted(perWagon)).toBe(false);
});
it('reports FULL when wagon slots run out regardless of weight room', () => {
const budget = new CorridorBudget(stops, base, tolerance);
budget.subtract(need(1000, 44, 700), budget.fullLeg());
expect(budget.isExhausted(perWagon)).toBe(true);
});
it('reports FULL when length room cannot take one more wagon', () => {
const budget = new CorridorBudget(stops, base, tolerance);
budget.subtract(need(1000, 30, 750), budget.fullLeg()); // 10m left < 17.066m
expect(budget.isExhausted(perWagon)).toBe(true);
});
it('only counts an edge as open when EVERY axis has room on that same edge', () => {
// Three stops → two edges. Edge 0 has weight but no slots; edge 1 has
// slots but no weight. Neither can board a wagon, so the train is FULL
// even though the per-axis maxima both look open.
const budget = new CorridorBudget(['a', 'b', 'c'], base, tolerance);
budget.subtract(need(0, 44, 0), { fromEdge: 0, toEdge: 1 });
budget.subtract(need(3522.4, 0, 0), { fromEdge: 1, toEdge: 2 });
expect(budget.isExhausted(perWagon)).toBe(true);
});
});
}); });

View File

@@ -168,6 +168,25 @@ export class CorridorBudget {
} }
} }
/**
* Train-wide FULL across ALL capacity axes: true when no edge can board even
* one more loaded wagon. `perWagon` is the smallest gross weight and length
* a future wagon could add (lightest wagon type at rated payload); weight and
* length may dip into the overage tolerance, mirroring {@link fits}. Checked
* per edge — an edge with slots free but no pull weight is just as closed as
* one with no slots. A slot-only check misses weight-bound trains: PW2 at
* 37 × 95.2T = 3522.4T of 3500+90T has 7 length-derived slots free but no
* weight room for wagon 38, and its window must read FULL.
*/
isExhausted(perWagon: { grossWeightTons: number; lengthMeters: number }): boolean {
return this.edges.every(
(e) =>
e.wagons <= 0 ||
e.weightTons + this.tolerance.weightTons < perWagon.grossWeightTons ||
e.lengthMeters + this.tolerance.lengthMeters < perWagon.lengthMeters,
);
}
/** /**
* The most open edge — when even this has no wagon slots left, nothing can * The most open edge — when even this has no wagon slots left, nothing can
* board anywhere and the schedule's window is genuinely FULL. (A train can be * board anywhere and the schedule's window is genuinely FULL. (A train can be

View File

@@ -0,0 +1,99 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsIn,
IsInt,
IsISO8601,
IsOptional,
IsString,
Max,
MaxLength,
Min,
} from 'class-validator';
export const BATCH_BOARD_STATUSES = [
'DRAFT',
'SCHEDULED',
'DISPATCHED',
'ARRIVED',
'CANCELLED',
] as const;
export const BATCH_BOARD_SORT_FIELDS = [
'createdAt',
'scheduledDepartureDate',
'trainNumber',
'status',
] as const;
export type BatchBoardSortField = (typeof BATCH_BOARD_SORT_FIELDS)[number];
/** Filters for the batch monitoring board list (import schedules, all statuses). */
export class BatchBoardQueryDto {
@ApiPropertyOptional({ default: 1, minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@ApiPropertyOptional({ default: 12, minimum: 1, maximum: 100 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize?: number;
@ApiPropertyOptional({
description:
'Comma-separated schedule statuses (DRAFT,SCHEDULED,DISPATCHED,ARRIVED,CANCELLED). Omit for all.',
example: 'DISPATCHED,ARRIVED',
})
@IsOptional()
@IsString()
statuses?: string;
@ApiPropertyOptional({ enum: ['OPEN', 'FULL', 'CLOSED'] })
@IsOptional()
@IsIn(['OPEN', 'FULL', 'CLOSED'])
bookingWindowStatus?: 'OPEN' | 'FULL' | 'CLOSED';
@ApiPropertyOptional({
description:
'Case-insensitive match on train number, route yards, stations, or locomotive code.',
})
@IsOptional()
@IsString()
@MaxLength(120)
search?: string;
@ApiPropertyOptional({ description: 'Departure date lower bound (ISO 8601).' })
@IsOptional()
@IsISO8601()
departureFrom?: string;
@ApiPropertyOptional({ description: 'Departure date upper bound (ISO 8601).' })
@IsOptional()
@IsISO8601()
departureTo?: string;
@ApiPropertyOptional({ description: 'Created-at lower bound (ISO 8601).' })
@IsOptional()
@IsISO8601()
createdFrom?: string;
@ApiPropertyOptional({ description: 'Created-at upper bound (ISO 8601).' })
@IsOptional()
@IsISO8601()
createdTo?: string;
@ApiPropertyOptional({ enum: BATCH_BOARD_SORT_FIELDS, default: 'createdAt' })
@IsOptional()
@IsIn(BATCH_BOARD_SORT_FIELDS as unknown as string[])
sortBy?: BatchBoardSortField;
@ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' })
@IsOptional()
@IsIn(['ASC', 'DESC'])
sortOrder?: 'ASC' | 'DESC';
}

View File

@@ -39,6 +39,7 @@ import {
UploadImportDjiboutiDocumentDto, UploadImportDjiboutiDocumentDto,
} from "./dto/import-djibouti-operation.dto"; } from "./dto/import-djibouti-operation.dto";
import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto"; import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
import { BatchBoardQueryDto } from "./dto/batch-board-query.dto";
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto"; import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
import { AvailableDaysQueryDto } from "./dto/available-days-query.dto"; import { AvailableDaysQueryDto } from "./dto/available-days-query.dto";
import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-query.dto"; import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-query.dto";
@@ -124,10 +125,11 @@ export class TrainSchedulingController {
@Get("batch-board") @Get("batch-board")
@TrainSchedulingView() @TrainSchedulingView()
@ApiOperation({ @ApiOperation({
summary: "Batch monitoring board: schedules with bookings grouped by state", summary:
"Batch monitoring board: paginated import schedules (all statuses) with bookings grouped by state",
}) })
getBatchBoard() { getBatchBoard(@Query() query: BatchBoardQueryDto) {
return this.bookingBatchService.getBatchBoard(); return this.bookingBatchService.getBatchBoard(query);
} }
@Get("batch-board/:scheduleId") @Get("batch-board/:scheduleId")

View File

@@ -2514,6 +2514,12 @@ export class TrainSchedulingService {
if (dto.sequenceNo === finalSeq) { if (dto.sequenceNo === finalSeq) {
await this.arriveSchedule(scheduleId); await this.arriveSchedule(scheduleId);
} else {
// Mid-corridor auto-unload: bookings destined for this yard alight the
// moment the train is recorded here — the yard operator no longer has to
// unload each one by hand. The final station is covered by
// arriveSchedule's bulk fallback above.
await this.bookingJourneyService.autoUnloadAtYard(scheduleId, station.yardId);
} }
return this.getScheduleCheckpoints(scheduleId); return this.getScheduleCheckpoints(scheduleId);
@@ -4518,6 +4524,11 @@ export class TrainSchedulingService {
capacityTons: roundTons(Number(wagon.capacityTons)), capacityTons: roundTons(Number(wagon.capacityTons)),
lengthMeters: roundTons(Number(wagon.lengthMeters)), lengthMeters: roundTons(Number(wagon.lengthMeters)),
assignedWeightTons: roundTons(Number(wagon.assignedWeightTons)), assignedWeightTons: roundTons(Number(wagon.assignedWeightTons)),
// Empty-wagon weight — the pull limit hauls tare + cargo, so the
// frontend needs it to show the gross train weight.
tareWeightTons: wagon.wagonType
? roundTons(Number(wagon.wagonType.tareWeightTons))
: null,
status: wagon.status, status: wagon.status,
physicalWagonId: wagon.physicalWagonId ?? null, physicalWagonId: wagon.physicalWagonId ?? null,
physicalWagonNumber: wagon.physicalWagon?.wagonNumber ?? null, physicalWagonNumber: wagon.physicalWagon?.wagonNumber ?? null,

View File

@@ -16,6 +16,7 @@ type DiagramWagonInput = {
sequenceNo: number; sequenceNo: number;
capacityTons: number; capacityTons: number;
assignedWeightTons: number; assignedWeightTons: number;
tareWeightTons?: number | null;
slotLoadType?: string | null; slotLoadType?: string | null;
wagonType?: { code?: string | null } | null; wagonType?: { code?: string | null } | null;
wagonTypeCode?: string | null; wagonTypeCode?: string | null;
@@ -33,6 +34,7 @@ type NormalizedWagon = {
sequenceNo: number; sequenceNo: number;
capacityTons: number; capacityTons: number;
assignedWeightTons: number; assignedWeightTons: number;
tareWeightTons: number;
wagonTypeCode: string | null; wagonTypeCode: string | null;
physicalWagonNumber: string | null; physicalWagonNumber: string | null;
isEmpty: boolean; isEmpty: boolean;
@@ -69,6 +71,7 @@ function normalizeWagon(w: DiagramWagonInput, freightType?: string | null): Norm
sequenceNo: w.sequenceNo, sequenceNo: w.sequenceNo,
capacityTons: Number(w.capacityTons) || 0, capacityTons: Number(w.capacityTons) || 0,
assignedWeightTons: Number(w.assignedWeightTons) || 0, assignedWeightTons: Number(w.assignedWeightTons) || 0,
tareWeightTons: Number(w.tareWeightTons) || 0,
wagonTypeCode: w.wagonType?.code ?? w.wagonTypeCode ?? null, wagonTypeCode: w.wagonType?.code ?? w.wagonTypeCode ?? null,
physicalWagonNumber: w.physicalWagonNumber ?? null, physicalWagonNumber: w.physicalWagonNumber ?? null,
isEmpty: allocations.length === 0, isEmpty: allocations.length === 0,
@@ -278,7 +281,9 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
wagon.bookingRefs.length ? wagon.bookingRefs.join(", ") : "" wagon.bookingRefs.length ? wagon.bookingRefs.join(", ") : ""
}${ }${
wagon.containerNumbers.length ? `\nContainers: ${wagon.containerNumbers.join(", ")}` : "" wagon.containerNumbers.length ? `\nContainers: ${wagon.containerNumbers.join(", ")}` : ""
}${wagon.cargoDescription ? `\n${wagon.cargoDescription}` : ""}\nLoad: ${wagon.assignedWeightTons}/${wagon.capacityTons}T (${utilization}%)`; }${wagon.cargoDescription ? `\n${wagon.cargoDescription}` : ""}\nLoad: ${wagon.assignedWeightTons}/${wagon.capacityTons}T (${utilization}%)${
wagon.tareWeightTons ? `\nTare: ${wagon.tareWeightTons}T` : ""
}`;
// container blocks: one per container number (cap visual at 2 = TEU per wagon) // container blocks: one per container number (cap visual at 2 = TEU per wagon)
const blocks = wagon.containerNumbers.slice(0, 2); const blocks = wagon.containerNumbers.slice(0, 2);
@@ -523,15 +528,22 @@ export function TrainCompositionDiagram({
const assigned = normalized.filter((w) => !w.isEmpty).length; const assigned = normalized.filter((w) => !w.isEmpty).length;
const totalWeight = normalized.reduce((s, w) => s + w.assignedWeightTons, 0); const totalWeight = normalized.reduce((s, w) => s + w.assignedWeightTons, 0);
const totalCapacity = normalized.reduce((s, w) => s + w.capacityTons, 0); const totalCapacity = normalized.reduce((s, w) => s + w.capacityTons, 0);
// Every coupled wagon's tare is hauled — empty ones included — so the
// locomotive pull limit is measured against gross (tare + cargo), the same
// ceiling the allocation engine spends from.
const totalTare = normalized.reduce((s, w) => s + w.tareWeightTons, 0);
const grossWeight = totalWeight + totalTare;
return { return {
total: normalized.length, total: normalized.length,
assigned, assigned,
empty: normalized.length - assigned, empty: normalized.length - assigned,
totalWeight: Math.round(totalWeight * 100) / 100, totalWeight: Math.round(totalWeight * 100) / 100,
totalTare: Math.round(totalTare * 100) / 100,
grossWeight: Math.round(grossWeight * 100) / 100,
totalCapacity, totalCapacity,
pullUtil: pullUtil:
locomotive?.maxPullWeightTons && locomotive.maxPullWeightTons > 0 locomotive?.maxPullWeightTons && locomotive.maxPullWeightTons > 0
? Math.min(100, Math.round((totalWeight / locomotive.maxPullWeightTons) * 100)) ? Math.min(100, Math.round((grossWeight / locomotive.maxPullWeightTons) * 100))
: null, : null,
}; };
}, [normalized, locomotive]); }, [normalized, locomotive]);
@@ -598,12 +610,30 @@ export function TrainCompositionDiagram({
}} }}
> >
<Text size="xs" fw={700} c="dark.4"> <Text size="xs" fw={700} c="dark.4">
{stats.totalWeight}T {stats.totalWeight}T cargo
</Text> </Text>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
of {stats.totalCapacity}T capacity of {stats.totalCapacity}T capacity
</Text> </Text>
</Group> </Group>
{stats.totalTare > 0 ? (
<Group
gap={6}
style={{
padding: "4px 12px",
borderRadius: 999,
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<Text size="xs" fw={700} c="dark.4">
{stats.grossWeight}T gross
</Text>
<Text size="xs" c="dimmed">
incl. {stats.totalTare}T tare
</Text>
</Group>
) : null}
<Group gap="xs"> <Group gap="xs">
<LegendDot color="cyan" label="Container" /> <LegendDot color="cyan" label="Container" />
<LegendDot color="orange" label="Bulk" /> <LegendDot color="orange" label="Bulk" />
@@ -626,7 +656,10 @@ export function TrainCompositionDiagram({
<Group gap={6} wrap="nowrap"> <Group gap={6} wrap="nowrap">
<Gauge size={14} color={freightBrand.primary} /> <Gauge size={14} color={freightBrand.primary} />
<Text size="xs" fw={700} c="edr-green.8"> <Text size="xs" fw={700} c="edr-green.8">
Locomotive load · {stats.totalWeight}T of {locomotive?.maxPullWeightTons}T Locomotive load ·{" "}
{stats.totalTare > 0
? `${stats.grossWeight}T of ${locomotive?.maxPullWeightTons}T (${stats.totalWeight}T cargo + ${stats.totalTare}T tare)`
: `${stats.totalWeight}T of ${locomotive?.maxPullWeightTons}T`}
</Text> </Text>
</Group> </Group>
<Text size="sm" fw={800} c={stats.pullUtil > 95 ? "red.7" : "edr-green.7"}> <Text size="sm" fw={800} c={stats.pullUtil > 95 ? "red.7" : "edr-green.7"}>

View File

@@ -103,7 +103,8 @@ export const QUERY_KEYS = {
schedules: () => ["train-scheduling", "schedules"] as const, schedules: () => ["train-scheduling", "schedules"] as const,
scheduleById: (id: string) => ["train-scheduling", "schedule", id] as const, scheduleById: (id: string) => ["train-scheduling", "schedule", id] as const,
track: (id: string) => ["train-scheduling", "track", id] as const, track: (id: string) => ["train-scheduling", "track", id] as const,
batchBoard: () => ["train-scheduling", "batch-board"] as const, batchBoard: (filters?: unknown) =>
["train-scheduling", "batch-board", "list", filters ?? {}] as const,
batchBoardDetail: (scheduleId: string) => batchBoardDetail: (scheduleId: string) =>
["train-scheduling", "batch-board", scheduleId] as const, ["train-scheduling", "batch-board", scheduleId] as const,
unassignedBookings: (id: string) => unassignedBookings: (id: string) =>

View File

@@ -80,7 +80,7 @@ export function toContractListRow(contract: Freight.IContract): ContractListRow
approvalSteps: contract.approvalSteps, approvalSteps: contract.approvalSteps,
customerLabel: contract.isGovernment customerLabel: contract.isGovernment
? (contract.governmentInstitution ?? "Government") ? (contract.governmentInstitution ?? "Government")
: (contract.companyId ?? "—"), : (contract.company?.name ?? "—"),
status: contract.status, status: contract.status,
contractKind: contract.contractKind, contractKind: contract.contractKind,
tradeDirection: contract.tradeDirection, tradeDirection: contract.tradeDirection,

View File

@@ -28,6 +28,10 @@ export const queryClient = new QueryClient({
queries: { queries: {
retry: 1, retry: 1,
staleTime: 30_000, staleTime: 30_000,
// Data freshness is driven by mutation invalidation (MutationCache above),
// socket pushes, and explicit polling — not by tab focus. Focus refetch
// just re-fires every mounted query each time the window is refocused.
refetchOnWindowFocus: false,
}, },
}, },
}); });

View File

@@ -4,18 +4,21 @@ import {
Button, Button,
Card, Card,
Group, Group,
MultiSelect,
Select, Select,
Stack, Stack,
Tabs, Tabs,
Text, Text,
TextInput, TextInput,
} from "@mantine/core"; } from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { import {
AlertTriangle, AlertTriangle,
ArrowRight, ArrowRight,
Calendar, Calendar,
CheckCircle2, CheckCircle2,
Clock, Clock,
FilterX,
LayoutList, LayoutList,
Package, Package,
Plus, Plus,
@@ -26,6 +29,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu"; import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell"; import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell";
@@ -43,6 +47,7 @@ import {
useBookingList, useBookingList,
useBookingListSummary, useBookingListSummary,
} from "@/hooks/bookings/useBookings"; } from "@/hooks/bookings/useBookings";
import { api } from "@/services/api";
import type { BookingListFilter } from "@/services/bookings.service"; import type { BookingListFilter } from "@/services/bookings.service";
import type { BookingListRow } from "@/types/booking"; import type { BookingListRow } from "@/types/booking";
import { import {
@@ -77,6 +82,33 @@ const FREIGHT_TYPE_OPTIONS = [
{ value: "BULK", label: "Bulk" }, { value: "BULK", label: "Bulk" },
]; ];
const PAYMENT_STATUS_OPTIONS = [
{ value: "PENDING", label: "Payment pending" },
{ value: "PNR_GENERATED", label: "PNR generated" },
{ value: "VERIFICATION_IN_PROGRESS", label: "Verification in progress" },
{ value: "PAID", label: "Paid" },
{ value: "FAILED", label: "Payment failed" },
];
const OWNERSHIP_OPTIONS = [
{ value: "true", label: "Government" },
{ value: "false", label: "Private" },
];
/** Local start-of-day → ISO, for inclusive "from" date filters. */
function startOfDayIso(d: Date): string {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x.toISOString();
}
/** Local end-of-day → ISO, for inclusive "to" date filters. */
function endOfDayIso(d: Date): string {
const x = new Date(d);
x.setHours(23, 59, 59, 999);
return x.toISOString();
}
function formatDate(value: string | null | undefined): string { function formatDate(value: string | null | undefined): string {
if (!value) return "—"; if (!value) return "—";
const d = new Date(value); const d = new Date(value);
@@ -95,10 +127,18 @@ export default function BookingRequestsPage() {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
// Booking-kind tabs (one-time vs general contract) replace the old status tabs. // Booking-kind tabs (one-time vs general contract) replace the old status tabs.
const [kindTab, setKindTab] = useState<BookingKindTab>("ONE_TIME"); const [kindTab, setKindTab] = useState<BookingKindTab>("ONE_TIME");
// Per-tab filter selects (each nullable = "all"). // Per-tab filter controls (empty/null = "all").
const [statusFilter, setStatusFilter] = useState<string | null>(null); const [statusFilter, setStatusFilter] = useState<string[]>([]);
const [directionFilter, setDirectionFilter] = useState<string | null>(null); const [directionFilter, setDirectionFilter] = useState<string | null>(null);
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null); const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null);
const [paymentStatusFilter, setPaymentStatusFilter] = useState<string | null>(null);
const [ownershipFilter, setOwnershipFilter] = useState<string | null>(null);
const [originYardFilter, setOriginYardFilter] = useState<string | null>(null);
const [destinationYardFilter, setDestinationYardFilter] = useState<string | null>(null);
const [createdFrom, setCreatedFrom] = useState<Date | null>(null);
const [createdTo, setCreatedTo] = useState<Date | null>(null);
const [scheduledFrom, setScheduledFrom] = useState<Date | null>(null);
const [scheduledTo, setScheduledTo] = useState<Date | null>(null);
const [allocateOpen, setAllocateOpen] = useState(false); const [allocateOpen, setAllocateOpen] = useState(false);
const [allocateIds, setAllocateIds] = useState<string[]>([]); const [allocateIds, setAllocateIds] = useState<string[]>([]);
const suppressRowClickRef = useRef(false); const suppressRowClickRef = useRef(false);
@@ -118,9 +158,21 @@ export default function BookingRequestsPage() {
// React Query cache key per kind tab. // React Query cache key per kind tab.
tab: kindTab, tab: kindTab,
bookingType: kindTab, bookingType: kindTab,
...(statusFilter ? { statuses: statusFilter } : {}), ...(statusFilter.length ? { statuses: statusFilter.join(",") } : {}),
...(directionFilter ? { tradeDirection: directionFilter } : {}), ...(directionFilter ? { tradeDirection: directionFilter } : {}),
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}), ...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
...(paymentStatusFilter ? { paymentStatus: paymentStatusFilter } : {}),
...(ownershipFilter
? { isGovernment: ownershipFilter as "true" | "false" }
: {}),
...(originYardFilter ? { originYardId: originYardFilter } : {}),
...(destinationYardFilter
? { destinationYardId: destinationYardFilter }
: {}),
...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}),
...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}),
...(scheduledFrom ? { scheduledFrom: startOfDayIso(scheduledFrom) } : {}),
...(scheduledTo ? { scheduledTo: endOfDayIso(scheduledTo) } : {}),
}; };
}, [ }, [
pagination.pageIndex, pagination.pageIndex,
@@ -129,6 +181,14 @@ export default function BookingRequestsPage() {
statusFilter, statusFilter,
directionFilter, directionFilter,
freightTypeFilter, freightTypeFilter,
paymentStatusFilter,
ownershipFilter,
originYardFilter,
destinationYardFilter,
createdFrom,
createdTo,
scheduledFrom,
scheduledTo,
]); ]);
const { data, isLoading, isError, refetch, isFetching } = useBookingList(filter); const { data, isLoading, isError, refetch, isFetching } = useBookingList(filter);
@@ -142,6 +202,49 @@ export default function BookingRequestsPage() {
refetch: refetchSummary, refetch: refetchSummary,
} = useBookingListSummary(filter); } = useBookingListSummary(filter);
// Yard options for the origin/destination filters (shared routes reference list).
const { data: yardRefs } = useQuery(
api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }),
);
const yardOptions = useMemo(
() =>
(yardRefs ?? []).map((y) => ({
value: y.id,
label: y.label ?? y.code,
})),
[yardRefs],
);
const resetPage = useCallback(() => {
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}, [setPagination, pagination.pageSize]);
const activeFilterCount =
(statusFilter.length ? 1 : 0) +
(directionFilter ? 1 : 0) +
(freightTypeFilter ? 1 : 0) +
(paymentStatusFilter ? 1 : 0) +
(ownershipFilter ? 1 : 0) +
(originYardFilter ? 1 : 0) +
(destinationYardFilter ? 1 : 0) +
(createdFrom || createdTo ? 1 : 0) +
(scheduledFrom || scheduledTo ? 1 : 0);
const clearFilters = useCallback(() => {
setStatusFilter([]);
setDirectionFilter(null);
setFreightTypeFilter(null);
setPaymentStatusFilter(null);
setOwnershipFilter(null);
setOriginYardFilter(null);
setDestinationYardFilter(null);
setCreatedFrom(null);
setCreatedTo(null);
setScheduledFrom(null);
setScheduledTo(null);
resetPage();
}, [resetPage]);
const rows = useMemo(() => { const rows = useMemo(() => {
const items = (data?.items ?? []).map(toBookingListRow); const items = (data?.items ?? []).map(toBookingListRow);
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
@@ -415,18 +518,44 @@ export default function BookingRequestsPage() {
</Text> </Text>
</Group> </Group>
<Group gap="sm" wrap="wrap"> <Group gap="sm" wrap="wrap">
<Select <MultiSelect
placeholder="All statuses" placeholder={statusFilter.length ? undefined : "All statuses"}
data={STATUS_OPTIONS} data={STATUS_OPTIONS}
value={statusFilter} value={statusFilter}
onChange={(v) => { onChange={(v) => {
setStatusFilter(v); setStatusFilter(v);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); resetPage();
}} }}
clearable clearable
searchable searchable
radius="lg" radius="lg"
style={{ minWidth: 200 }} style={{ minWidth: 220 }}
/>
<Select
placeholder="All origins"
data={yardOptions}
value={originYardFilter}
onChange={(v) => {
setOriginYardFilter(v);
resetPage();
}}
clearable
searchable
radius="lg"
style={{ minWidth: 180 }}
/>
<Select
placeholder="All destinations"
data={yardOptions}
value={destinationYardFilter}
onChange={(v) => {
setDestinationYardFilter(v);
resetPage();
}}
clearable
searchable
radius="lg"
style={{ minWidth: 180 }}
/> />
<Select <Select
placeholder="All directions" placeholder="All directions"
@@ -434,11 +563,11 @@ export default function BookingRequestsPage() {
value={directionFilter} value={directionFilter}
onChange={(v) => { onChange={(v) => {
setDirectionFilter(v); setDirectionFilter(v);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); resetPage();
}} }}
clearable clearable
radius="lg" radius="lg"
style={{ minWidth: 170 }} style={{ minWidth: 150 }}
/> />
<Select <Select
placeholder="All freight types" placeholder="All freight types"
@@ -446,13 +575,98 @@ export default function BookingRequestsPage() {
value={freightTypeFilter} value={freightTypeFilter}
onChange={(v) => { onChange={(v) => {
setFreightTypeFilter(v); setFreightTypeFilter(v);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); resetPage();
}} }}
clearable clearable
radius="lg" radius="lg"
style={{ minWidth: 170 }} style={{ minWidth: 150 }}
/> />
</Group> </Group>
<Group gap="sm" wrap="wrap">
<Select
placeholder="All payment statuses"
data={PAYMENT_STATUS_OPTIONS}
value={paymentStatusFilter}
onChange={(v) => {
setPaymentStatusFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 180 }}
/>
<Select
placeholder="Gov / Private"
data={OWNERSHIP_OPTIONS}
value={ownershipFilter}
onChange={(v) => {
setOwnershipFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 140 }}
/>
<DateInput
placeholder="Created from"
value={createdFrom}
onChange={(v) => {
setCreatedFrom(v ? new Date(v) : null);
resetPage();
}}
maxDate={createdTo ?? undefined}
clearable
radius="lg"
style={{ minWidth: 140 }}
/>
<DateInput
placeholder="Created to"
value={createdTo}
onChange={(v) => {
setCreatedTo(v ? new Date(v) : null);
resetPage();
}}
minDate={createdFrom ?? undefined}
clearable
radius="lg"
style={{ minWidth: 140 }}
/>
<DateInput
placeholder="Scheduled from"
value={scheduledFrom}
onChange={(v) => {
setScheduledFrom(v ? new Date(v) : null);
resetPage();
}}
maxDate={scheduledTo ?? undefined}
clearable
radius="lg"
style={{ minWidth: 150 }}
/>
<DateInput
placeholder="Scheduled to"
value={scheduledTo}
onChange={(v) => {
setScheduledTo(v ? new Date(v) : null);
resetPage();
}}
minDate={scheduledFrom ?? undefined}
clearable
radius="lg"
style={{ minWidth: 150 }}
/>
{activeFilterCount > 0 ? (
<Button
variant="subtle"
color="gray"
radius="lg"
leftSection={<FilterX size={16} />}
onClick={clearFilters}
>
Clear filters ({activeFilterCount})
</Button>
) : null}
</Group>
</Stack> </Stack>
</Box> </Box>

View File

@@ -302,7 +302,7 @@ export default function ContractRequestDetailPage() {
const customerLabel = contract.isGovernment const customerLabel = contract.isGovernment
? (contract.governmentInstitution ?? "Government") ? (contract.governmentInstitution ?? "Government")
: (contract.companyId ?? "—"); : (contract.company?.name ?? "—");
return ( return (
<PageContainer> <PageContainer>

View File

@@ -133,21 +133,21 @@ export default function ContractRequestsPage() {
const columns: ColumnDef<ContractListRow>[] = [ const columns: ColumnDef<ContractListRow>[] = [
{ {
id: "contract", id: "contract",
header: () => <span className={bookingTable.headerCell}>Contract</span>, header: () => <span className={bookingTable.headerCell}>Customer</span>,
cell: ({ row }) => { cell: ({ row }) => {
const c = row.original; const c = row.original;
return ( return (
<div className="flex items-center gap-3 py-1.5"> <div className="flex items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}> <div className={bookingTable.rowIcon}>
<FileText className="size-4" strokeWidth={1.75} /> <User className="size-4" strokeWidth={1.75} />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate font-medium text-foreground"> <p className="truncate font-medium text-foreground">
{c.reference} {c.customerLabel}
</p> </p>
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground"> <p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
<User className="size-3 shrink-0 opacity-70" /> <FileText className="size-3 shrink-0 opacity-70" />
{c.customerLabel} {c.reference}
</p> </p>
</div> </div>
</div> </div>

View File

@@ -1,8 +1,9 @@
import { useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { import {
ActionIcon, ActionIcon,
Alert, Alert,
Badge,
Box, Box,
Button, Button,
Card, Card,
@@ -16,10 +17,15 @@ import {
Stack, Stack,
Text, Text,
ThemeIcon, ThemeIcon,
Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { useDebouncedValue } from "@mantine/hooks";
import { import {
AlertTriangle, AlertTriangle,
ArrowDownWideNarrow,
ArrowRight, ArrowRight,
ArrowUpNarrowWide,
CalendarClock, CalendarClock,
CalendarDays, CalendarDays,
Eye, Eye,
@@ -47,9 +53,31 @@ import {
import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals"; import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { FREIGHT_BRAND, FREIGHT_BRAND_DARK } from "@/theme/freight-brand"; import { FREIGHT_BRAND, FREIGHT_BRAND_DARK } from "@/theme/freight-brand";
import { useQuery } from "@tanstack/react-query"; import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api"; import { api } from "@/services/api";
import type { BatchBoardSchedule } from "@/types/trainScheduling"; import type {
BatchBoardFilters,
BatchBoardSchedule,
BatchBoardSortField,
TrainScheduleStatus,
} from "@/types/trainScheduling";
const STATUS_BADGE: Record<string, { color: string; label: string }> = {
DRAFT: { color: "gray", label: "Draft" },
SCHEDULED: { color: "blue", label: "Scheduled" },
DISPATCHED: { color: "orange", label: "Dispatched" },
ARRIVED: { color: "green", label: "Arrived" },
CANCELLED: { color: "red", label: "Cancelled" },
};
function StatusBadge({ status }: { status: string }) {
const meta = STATUS_BADGE[status] ?? { color: "gray", label: status };
return (
<Badge variant="light" color={meta.color} radius="sm">
{meta.label}
</Badge>
);
}
const fmtTons = (n: number) => const fmtTons = (n: number) =>
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`; `${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`;
@@ -237,7 +265,8 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
c="dimmed" c="dimmed"
style={{ letterSpacing: 0.8 }} style={{ letterSpacing: 0.8 }}
> >
Freight schedule · {schedule.status} {schedule.scheduleReference ?? "Freight schedule"} ·{" "}
{schedule.status}
</Text> </Text>
</Box> </Box>
</Group> </Group>
@@ -395,15 +424,73 @@ function CardSkeleton() {
export default function BatchBoardPage() { export default function BatchBoardPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { data, isLoading, isError, isFetching, refetch } = useQuery(
api.trainScheduling.batchBoard.queryOptions({ refetchInterval: 30_000 }),
);
const { viewMode, setViewMode } = useFleetViewMode("batch-board"); const { viewMode, setViewMode } = useFleetViewMode("batch-board");
const { pagination, setPagination } = usePagination({ pageSize: 10 }); const { pagination, setPagination } = usePagination({ pageSize: 12 });
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 300);
const [statusFilter, setStatusFilter] = useState("ALL");
const [windowFilter, setWindowFilter] = useState("ALL"); const [windowFilter, setWindowFilter] = useState("ALL");
const [sortBy, setSortBy] = useState<BatchBoardSortField>("createdAt");
const [sortOrder, setSortOrder] = useState<"ASC" | "DESC">("DESC");
const [departureFrom, setDepartureFrom] = useState<Date | null>(null);
const [departureTo, setDepartureTo] = useState<Date | null>(null);
const schedules = data ?? []; // Every knob maps straight onto the server-side batch-board query — the API
// filters, searches, sorts and paginates; this page just renders the page.
const filters = useMemo((): BatchBoardFilters => {
const endOfDay = (d: Date) =>
new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999);
return {
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
search: debouncedSearch.trim() || undefined,
statuses:
statusFilter === "ALL"
? undefined
: [statusFilter as TrainScheduleStatus],
bookingWindowStatus:
windowFilter === "ALL"
? undefined
: (windowFilter as "OPEN" | "FULL" | "CLOSED"),
departureFrom: departureFrom ? departureFrom.toISOString() : undefined,
departureTo: departureTo ? endOfDay(departureTo).toISOString() : undefined,
sortBy,
sortOrder,
};
}, [
pagination,
debouncedSearch,
statusFilter,
windowFilter,
departureFrom,
departureTo,
sortBy,
sortOrder,
]);
// Any filter change restarts from the first page.
useEffect(() => {
setPagination((p) => ({ ...p, pageIndex: 0 }));
}, [
debouncedSearch,
statusFilter,
windowFilter,
departureFrom,
departureTo,
sortBy,
sortOrder,
setPagination,
]);
const { data, isLoading, isError, isFetching, refetch } = useQuery({
...api.trainScheduling.batchBoard.queryOptions({ input: { filters } }),
refetchInterval: 30_000,
placeholderData: keepPreviousData,
});
const schedules = data?.items ?? [];
const total = data?.total ?? 0;
const pageCount = data?.totalPages ?? 1;
const summary = useMemo(() => { const summary = useMemo(() => {
const openWindows = schedules.filter((s) => s.bookingWindowStatus === "OPEN").length; const openWindows = schedules.filter((s) => s.bookingWindowStatus === "OPEN").length;
@@ -412,33 +499,6 @@ export default function BatchBoardPage() {
return { openWindows, totalBookings, totalWagons }; return { openWindows, totalBookings, totalWagons };
}, [schedules]); }, [schedules]);
const filtered = useMemo(() => {
const query = search.trim().toLowerCase();
return schedules.filter((s) => {
if (windowFilter !== "ALL" && s.bookingWindowStatus !== windowFilter) return false;
if (!query) return true;
const haystack = [
s.trainNumber,
s.routeName,
s.origin,
s.destination,
s.locomotive?.code,
s.status,
s.bookingWindowStatus,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return haystack.includes(query);
});
}, [schedules, search, windowFilter]);
const pageCount = Math.max(1, Math.ceil(filtered.length / pagination.pageSize));
const paged = useMemo(() => {
const start = pagination.pageIndex * pagination.pageSize;
return filtered.slice(start, start + pagination.pageSize);
}, [filtered, pagination]);
const columns = useMemo((): ColumnDef<BatchBoardSchedule>[] => { const columns = useMemo((): ColumnDef<BatchBoardSchedule>[] => {
const headerClassName = ruleEngineTable.headerCell; const headerClassName = ruleEngineTable.headerCell;
const cellClassName = ruleEngineTable.bodyCell; const cellClassName = ruleEngineTable.bodyCell;
@@ -456,6 +516,11 @@ export default function BatchBoardPage() {
<Text size="sm" fw={700} lh={1.2} truncate> <Text size="sm" fw={700} lh={1.2} truncate>
{row.original.trainNumber ?? row.original.routeName ?? "Schedule"} {row.original.trainNumber ?? row.original.routeName ?? "Schedule"}
</Text> </Text>
{row.original.scheduleReference ? (
<Text size="10px" fw={600} c="dimmed" lh={1.2}>
{row.original.scheduleReference}
</Text>
) : null}
<Box maw={220}> <Box maw={220}>
<RouteCorridor <RouteCorridor
origin={row.original.origin} origin={row.original.origin}
@@ -502,6 +567,30 @@ export default function BatchBoardPage() {
); );
}, },
}, },
{
id: "status",
header: "Status",
meta: { headerClassName, cellClassName },
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: "created",
header: "Created",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const { day, time } = splitDate(row.original.createdAt);
return (
<Stack gap={0}>
<Text size="sm" fw={600} lh={1.2}>
{day}
</Text>
<Text size="xs" c="dimmed" lh={1.2}>
{time || "—"}
</Text>
</Stack>
);
},
},
{ {
id: "window", id: "window",
header: "Window", header: "Window",
@@ -623,7 +712,7 @@ export default function BatchBoardPage() {
<PageContainer> <PageContainer>
<PageHeader <PageHeader
title="Batch Board" title="Batch Board"
subtitle="Active schedules and their booking windows." subtitle="All import schedules — live and historical — with their booking windows."
action={ action={
<Button variant="default" loading={isFetching} onClick={() => void refetch()}> <Button variant="default" loading={isFetching} onClick={() => void refetch()}>
Refresh Refresh
@@ -635,21 +724,21 @@ export default function BatchBoardPage() {
loading={isLoading} loading={isLoading}
items={[ items={[
{ {
label: "Active schedules", label: "Schedules",
value: schedules.length, value: total,
hint: "on the board right now", hint: "matching the current filters",
icon: Train, icon: Train,
}, },
{ {
label: "Open windows", label: "Open windows",
value: summary.openWindows, value: summary.openWindows,
hint: "accepting bookings", hint: "accepting bookings (this page)",
icon: CalendarDays, icon: CalendarDays,
}, },
{ {
label: "Bookings in play", label: "Bookings in play",
value: summary.totalBookings, value: summary.totalBookings,
hint: `${summary.totalWagons} wagons allocated`, hint: `${summary.totalWagons} wagons allocated (this page)`,
icon: Package, icon: Package,
}, },
]} ]}
@@ -661,24 +750,96 @@ export default function BatchBoardPage() {
<FleetToolbar <FleetToolbar
search={search} search={search}
onSearchChange={setSearch} onSearchChange={setSearch}
searchPlaceholder="Search schedules…" searchPlaceholder="Search train, route, station, loco…"
viewMode={viewMode} viewMode={viewMode}
onViewModeChange={setViewMode} onViewModeChange={setViewMode}
filters={ filters={
<Select <Group gap="xs" wrap="wrap">
size="sm" <Select
radius="lg" size="sm"
value={windowFilter} radius="lg"
onChange={(v) => v && setWindowFilter(v)} value={statusFilter}
data={[ onChange={(v) => v && setStatusFilter(v)}
{ value: "ALL", label: "All windows" }, data={[
{ value: "OPEN", label: "Open" }, { value: "ALL", label: "All statuses" },
{ value: "FULL", label: "Full" }, { value: "DRAFT", label: "Draft" },
{ value: "CLOSED", label: "Closed" }, { value: "SCHEDULED", label: "Scheduled" },
]} { value: "DISPATCHED", label: "Dispatched" },
w={150} { value: "ARRIVED", label: "Arrived" },
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }} { value: "CANCELLED", label: "Cancelled" },
/> ]}
w={150}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<Select
size="sm"
radius="lg"
value={windowFilter}
onChange={(v) => v && setWindowFilter(v)}
data={[
{ value: "ALL", label: "All windows" },
{ value: "OPEN", label: "Open" },
{ value: "FULL", label: "Full" },
{ value: "CLOSED", label: "Closed" },
]}
w={140}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<DateInput
size="sm"
radius="lg"
placeholder="Departs from"
value={departureFrom}
onChange={(v) => setDepartureFrom(v ? new Date(v) : null)}
clearable
w={140}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<DateInput
size="sm"
radius="lg"
placeholder="Departs to"
value={departureTo}
onChange={(v) => setDepartureTo(v ? new Date(v) : null)}
clearable
w={140}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<Select
size="sm"
radius="lg"
value={sortBy}
onChange={(v) => v && setSortBy(v as BatchBoardSortField)}
data={[
{ value: "createdAt", label: "Sort: Created" },
{ value: "scheduledDepartureDate", label: "Sort: Departure" },
{ value: "trainNumber", label: "Sort: Train no." },
{ value: "status", label: "Sort: Status" },
]}
w={160}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<Tooltip
label={sortOrder === "DESC" ? "Newest / ZA first" : "Oldest / AZ first"}
withArrow
>
<ActionIcon
variant="default"
size={36}
radius="lg"
aria-label="Toggle sort direction"
onClick={() =>
setSortOrder((o) => (o === "DESC" ? "ASC" : "DESC"))
}
>
{sortOrder === "DESC" ? (
<ArrowDownWideNarrow size={16} />
) : (
<ArrowUpNarrowWide size={16} />
)}
</ActionIcon>
</Tooltip>
</Group>
} }
/> />
</Box> </Box>
@@ -686,7 +847,7 @@ export default function BatchBoardPage() {
{viewMode === "table" ? ( {viewMode === "table" ? (
<DataTable <DataTable
columns={columns} columns={columns}
data={paged} data={schedules}
status={tableStatus} status={tableStatus}
onRowClick={(schedule) => onRowClick={(schedule) =>
navigate(`/dashboard/operations/batch-board/${schedule.scheduleId}`) navigate(`/dashboard/operations/batch-board/${schedule.scheduleId}`)
@@ -699,12 +860,12 @@ export default function BatchBoardPage() {
} }
: undefined : undefined
} }
emptyMessage="No active schedules" emptyMessage="No schedules match the current filters"
pagination={{ pagination={{
pageIndex: pagination.pageIndex, pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize, pageSize: pagination.pageSize,
pageCount, pageCount,
totalCount: filtered.length, totalCount: total,
}} }}
tableOptions={{ tableOptions={{
manualPagination: true, manualPagination: true,
@@ -727,7 +888,7 @@ export default function BatchBoardPage() {
<CardSkeleton /> <CardSkeleton />
<CardSkeleton /> <CardSkeleton />
</SimpleGrid> </SimpleGrid>
) : filtered.length === 0 ? ( ) : schedules.length === 0 ? (
<Paper radius="lg" p={48} m="md" bg="gray.0"> <Paper radius="lg" p={48} m="md" bg="gray.0">
<Stack align="center" gap="sm"> <Stack align="center" gap="sm">
<Box <Box
@@ -745,20 +906,52 @@ export default function BatchBoardPage() {
<Inbox size={28} color="var(--mantine-color-gray-5)" /> <Inbox size={28} color="var(--mantine-color-gray-5)" />
</Box> </Box>
<Text fw={700} c="gray.7"> <Text fw={700} c="gray.7">
No active schedules No schedules match the current filters
</Text> </Text>
<Text size="sm" c="dimmed" ta="center" maw={380}> <Text size="sm" c="dimmed" ta="center" maw={380}>
Schedules with an open booking window appear here. Create or activate a Every import schedule live and historical appears here. Loosen the
schedule to get started. filters or clear the search to see more.
</Text> </Text>
</Stack> </Stack>
</Paper> </Paper>
) : ( ) : (
<SimpleGrid cols={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="lg" p="md"> <>
{filtered.map((s) => ( <SimpleGrid cols={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="lg" p="md">
<ScheduleCard key={s.scheduleId} schedule={s} /> {schedules.map((s) => (
))} <ScheduleCard key={s.scheduleId} schedule={s} />
</SimpleGrid> ))}
</SimpleGrid>
<Group justify="space-between" px="md" pb="md">
<Text size="sm" c="dimmed">
{total} schedule{total === 1 ? "" : "s"}
</Text>
<Group gap="xs">
<Button
variant="default"
size="xs"
disabled={pagination.pageIndex === 0}
onClick={() =>
setPagination((p) => ({ ...p, pageIndex: p.pageIndex - 1 }))
}
>
Previous
</Button>
<Text size="sm" c="dimmed">
Page {pagination.pageIndex + 1} of {pageCount}
</Text>
<Button
variant="default"
size="xs"
disabled={pagination.pageIndex + 1 >= pageCount}
onClick={() =>
setPagination((p) => ({ ...p, pageIndex: p.pageIndex + 1 }))
}
>
Next
</Button>
</Group>
</Group>
</>
)} )}
</Stack> </Stack>
</Card> </Card>

View File

@@ -25,6 +25,7 @@ import {
ClipboardCheck, ClipboardCheck,
Clock, Clock,
FileSignature, FileSignature,
Hash,
Hourglass, Hourglass,
Layers, Layers,
Package, Package,
@@ -621,6 +622,9 @@ export default function BatchScheduleDetailPage() {
api.trainScheduling.scheduleDetail.queryOptions({ api.trainScheduling.scheduleDetail.queryOptions({
input: { id: scheduleId ?? "", freightType: "CONTAINER" }, input: { id: scheduleId ?? "", freightType: "CONTAINER" },
enabled: Boolean(scheduleId), enabled: Boolean(scheduleId),
// Composition data only changes through mutations, which invalidate the
// whole train-scheduling root — no need to refetch on remounts in between.
staleTime: 5 * 60_000,
}), }),
); );
@@ -744,7 +748,13 @@ export default function BatchScheduleDetailPage() {
items={[ items={[
{ label: "Operations" }, { label: "Operations" },
{ label: "Batch board", href: "/dashboard/operations/batch-board" }, { label: "Batch board", href: "/dashboard/operations/batch-board" },
{ label: data.trainNumber ?? data.routeName ?? "Schedule" }, {
label:
data.scheduleReference ??
data.trainNumber ??
data.routeName ??
"Schedule",
},
]} ]}
/> />
@@ -791,6 +801,11 @@ export default function BatchScheduleDetailPage() {
<Title order={2} fw={800}> <Title order={2} fw={800}>
{data.trainNumber ?? data.routeName ?? "Schedule"} {data.trainNumber ?? data.routeName ?? "Schedule"}
</Title> </Title>
{data.scheduleReference ? (
<HeroChip icon={<Hash size={12} />}>
{data.scheduleReference}
</HeroChip>
) : null}
<WindowStatusPill status={data.bookingWindowStatus} /> <WindowStatusPill status={data.bookingWindowStatus} />
{data.windowPhase ? ( {data.windowPhase ? (
<WindowPhasePill <WindowPhasePill

View File

@@ -42,7 +42,8 @@ import {
} from "@/types/rule-engine"; } from "@/types/rule-engine";
import type { import type {
AssignBookingsPayload, AssignBookingsPayload,
BatchBoardSchedule, BatchBoardFilters,
BatchBoardListResponse,
BatchBoardScheduleDetail, BatchBoardScheduleDetail,
BookableSchedule, BookableSchedule,
BookingWindow, BookingWindow,
@@ -221,11 +222,14 @@ export const api = {
() => QUERY_KEYS.TRAIN_SCHEDULING.schedules(), () => QUERY_KEYS.TRAIN_SCHEDULING.schedules(),
), ),
batchBoard: endpoint<void, BatchBoardSchedule[]>( batchBoard: endpoint<
{ filters?: BatchBoardFilters },
BatchBoardListResponse
>(
"train-scheduling", "train-scheduling",
"batch-board", "batch-board",
() => trainSchedulingService.getBatchBoard(), ({ filters }) => trainSchedulingService.getBatchBoard(filters),
() => QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(), ({ filters }) => QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(filters),
), ),
allBookingWindows: endpoint<void, StaffBookingWindow[]>( allBookingWindows: endpoint<void, StaffBookingWindow[]>(

View File

@@ -21,6 +21,19 @@ export interface BookingListFilter {
bookingType?: string; bookingType?: string;
tradeDirection?: string; tradeDirection?: string;
paymentCurrency?: string; paymentCurrency?: string;
paymentStatus?: string;
/** ISO date-time — bookings created on/after. */
createdFrom?: string;
/** ISO date-time — bookings created on/before (pass end-of-day for inclusive). */
createdTo?: string;
/** ISO date-time — bookings scheduled on/after. */
scheduledFrom?: string;
/** ISO date-time — bookings scheduled on/before (pass end-of-day for inclusive). */
scheduledTo?: string;
originYardId?: string;
destinationYardId?: string;
/** "true" = government bookings only, "false" = private only. */
isGovernment?: "true" | "false";
page?: number; page?: number;
pageSize?: number; pageSize?: number;
sortBy?: string; sortBy?: string;
@@ -130,6 +143,14 @@ export const bookingsService = {
if (filter.bookingType) params.bookingType = filter.bookingType; if (filter.bookingType) params.bookingType = filter.bookingType;
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection; if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency; if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
if (filter.paymentStatus) params.paymentStatus = filter.paymentStatus;
if (filter.createdFrom) params.createdFrom = filter.createdFrom;
if (filter.createdTo) params.createdTo = filter.createdTo;
if (filter.scheduledFrom) params.scheduledFrom = filter.scheduledFrom;
if (filter.scheduledTo) params.scheduledTo = filter.scheduledTo;
if (filter.originYardId) params.originYardId = filter.originYardId;
if (filter.destinationYardId) params.destinationYardId = filter.destinationYardId;
if (filter.isGovernment) params.isGovernment = filter.isGovernment;
} }
const response = await client.get<BookingListSummary>(B.LIST_SUMMARY, { const response = await client.get<BookingListSummary>(B.LIST_SUMMARY, {
params, params,
@@ -154,6 +175,14 @@ export const bookingsService = {
if (filter.bookingType) params.bookingType = filter.bookingType; if (filter.bookingType) params.bookingType = filter.bookingType;
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection; if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency; if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
if (filter.paymentStatus) params.paymentStatus = filter.paymentStatus;
if (filter.createdFrom) params.createdFrom = filter.createdFrom;
if (filter.createdTo) params.createdTo = filter.createdTo;
if (filter.scheduledFrom) params.scheduledFrom = filter.scheduledFrom;
if (filter.scheduledTo) params.scheduledTo = filter.scheduledTo;
if (filter.originYardId) params.originYardId = filter.originYardId;
if (filter.destinationYardId) params.destinationYardId = filter.destinationYardId;
if (filter.isGovernment) params.isGovernment = filter.isGovernment;
} }
const response = await client.get<PaginatedBookings>(B.BASE, { const response = await client.get<PaginatedBookings>(B.BASE, {
params, params,

View File

@@ -3,7 +3,8 @@ import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint"; import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS"; import { URL_CONSTANTS } from "@/constants/URLS";
import type { import type {
BatchBoardSchedule, BatchBoardFilters,
BatchBoardListResponse,
BatchBoardScheduleDetail, BatchBoardScheduleDetail,
BookableSchedule, BookableSchedule,
BookingWindow, BookingWindow,
@@ -100,9 +101,25 @@ export const trainSchedulingService = {
return unwrap(response.data); return unwrap(response.data);
}, },
getBatchBoard: async (): Promise<BatchBoardSchedule[]> => { getBatchBoard: async (
const response = await client.get<BatchBoardSchedule[]>( filters: BatchBoardFilters = {},
): Promise<BatchBoardListResponse> => {
const params: Record<string, string | number> = {};
if (filters.page) params.page = filters.page;
if (filters.pageSize) params.pageSize = filters.pageSize;
if (filters.statuses?.length) params.statuses = filters.statuses.join(",");
if (filters.bookingWindowStatus)
params.bookingWindowStatus = filters.bookingWindowStatus;
if (filters.search?.trim()) params.search = filters.search.trim();
if (filters.departureFrom) params.departureFrom = filters.departureFrom;
if (filters.departureTo) params.departureTo = filters.departureTo;
if (filters.createdFrom) params.createdFrom = filters.createdFrom;
if (filters.createdTo) params.createdTo = filters.createdTo;
if (filters.sortBy) params.sortBy = filters.sortBy;
if (filters.sortOrder) params.sortOrder = filters.sortOrder;
const response = await client.get<BatchBoardListResponse>(
URL_CONSTANTS.TRAIN_SCHEDULING.BATCH_BOARD, URL_CONSTANTS.TRAIN_SCHEDULING.BATCH_BOARD,
{ params },
); );
return unwrap(response.data); return unwrap(response.data);
}, },

View File

@@ -257,11 +257,14 @@ export interface StaffBookingWindow {
export interface BatchBoardSchedule { export interface BatchBoardSchedule {
scheduleId: string; scheduleId: string;
/** Human-facing schedule reference (S-YYYY-NNNNN). */
scheduleReference: string | null;
trainNumber: string | null; trainNumber: string | null;
routeName: string | null; routeName: string | null;
origin: string | null; origin: string | null;
destination: string | null; destination: string | null;
scheduleDate: string | null; scheduleDate: string | null;
createdAt: string | null;
status: string; status: string;
bookingWindowStatus: string; bookingWindowStatus: string;
direction: string | null; direction: string | null;
@@ -300,6 +303,37 @@ export interface BatchBoardSchedule {
bookings: BatchBoardBooking[]; bookings: BatchBoardBooking[];
} }
export type BatchBoardSortField =
| "createdAt"
| "scheduledDepartureDate"
| "trainNumber"
| "status";
/** Server-side filters for the paginated batch board list. */
export interface BatchBoardFilters {
page?: number;
pageSize?: number;
/** Subset of schedule statuses; omit for all (incl. arrived/cancelled). */
statuses?: TrainScheduleStatus[];
bookingWindowStatus?: "OPEN" | "FULL" | "CLOSED";
/** Matches train number, route yards, stations, locomotive code. */
search?: string;
departureFrom?: string;
departureTo?: string;
createdFrom?: string;
createdTo?: string;
sortBy?: BatchBoardSortField;
sortOrder?: "ASC" | "DESC";
}
export interface BatchBoardListResponse {
items: BatchBoardSchedule[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export type BookingAllocationStatus = export type BookingAllocationStatus =
| "NOT_ATTEMPTED" | "NOT_ATTEMPTED"
| "ASSIGNED" | "ASSIGNED"
@@ -337,6 +371,8 @@ export interface BatchWindowGroup {
export interface BatchBoardScheduleDetail { export interface BatchBoardScheduleDetail {
scheduleId: string; scheduleId: string;
/** Human-facing schedule reference (S-YYYY-NNNNN). */
scheduleReference: string | null;
trainNumber: string | null; trainNumber: string | null;
routeName: string | null; routeName: string | null;
origin: string | null; origin: string | null;
@@ -506,6 +542,8 @@ export interface TrainScheduleDetail {
capacityTons: number; capacityTons: number;
lengthMeters: number; lengthMeters: number;
assignedWeightTons: number; assignedWeightTons: number;
/** Empty-wagon weight from the wagon type — gross = tare + cargo. */
tareWeightTons?: number | null;
status?: string; status?: string;
physicalWagonId?: string | null; physicalWagonId?: string | null;
physicalWagonNumber?: string | null; physicalWagonNumber?: string | null;

View File

@@ -83,7 +83,7 @@ export function ContractClearanceAction({
centered centered
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }} overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
> >
<ContractClearancePanel contractId={contractId} bare /> <ContractClearancePanel contractId={contractId} bare onSubmitted={close} />
</Modal> </Modal>
</ModalSafeWrapper> </ModalSafeWrapper>
); );

View File

@@ -142,7 +142,7 @@ function ProgressTracker({
const activeRing = tone === "ink" ? "#D9E0E7" : "#BFE8D4"; const activeRing = tone === "ink" ? "#D9E0E7" : "#BFE8D4";
return ( return (
/* Scrollable on mobile so 5 stages never overflow */ /* Scrollable on mobile so the stages never overflow */
<Box <Box
className="overflow-x-auto pt-2" className="overflow-x-auto pt-2"
style={ style={
@@ -152,7 +152,7 @@ function ProgressTracker({
} as React.CSSProperties } as React.CSSProperties
} }
> >
<div className="flex items-start" style={{ minWidth: 520 }}> <div className="flex items-start" style={{ minWidth: 640 }}>
{PROGRESS_STAGES.map((stage, idx) => { {PROGRESS_STAGES.map((stage, idx) => {
const state = const state =
idx < current ? "done" : idx === current ? "active" : "idle"; idx < current ? "done" : idx === current ? "active" : "idle";

View File

@@ -3,6 +3,7 @@ import {
FileText, FileText,
MapPin, MapPin,
PackageCheck, PackageCheck,
PackageOpen,
ShieldCheck, ShieldCheck,
Ship, Ship,
Train, Train,
@@ -54,12 +55,22 @@ export const PROGRESS_STAGES = [
statuses: ["EXPIRED", "IN_TRANSIT"], statuses: ["EXPIRED", "IN_TRANSIT"],
}, },
{ {
// ARRIVED: cargo unloaded at the booking's own destination yard (segment // The train reached the booking's destination yard — cargo may still be
// corridor journeys). Legacy bookings stay IN_TRANSIT until delivery, so // on board. No booking status of its own: it lights up from the assigned
// this stage also lights up from the assigned train's own status // train's status (trainScheduleStatus === "ARRIVED") while the booking is
// (trainScheduleStatus === "ARRIVED") — see resolveStage. // still IN_TRANSIT — see resolveStage. Bookings unloaded mid-corridor (or
// auto-unloaded on a checkpoint) jump straight past it to Unloading.
label: "Arrival", label: "Arrival",
icon: MapPin, icon: MapPin,
statuses: [],
},
{
// ARRIVED: cargo unloaded off the train at the booking's own destination
// yard. Unloading is automatic — the API alights the booking the moment a
// checkpoint is recorded at its destination yard (or, as a fallback, when
// the train's final arrival is logged).
label: "Unloading",
icon: PackageOpen,
statuses: ["ARRIVED"], statuses: ["ARRIVED"],
}, },
{ {
@@ -69,17 +80,17 @@ export const PROGRESS_STAGES = [
}, },
]; ];
/** Stage index of the Arrival step (train ARRIVED, cargo not yet delivered). */ /** Stage index of the Arrival step (train ARRIVED, cargo not yet unloaded). */
export const ARRIVAL_STAGE = PROGRESS_STAGES.findIndex( export const ARRIVAL_STAGE = PROGRESS_STAGES.findIndex(
(s) => s.label === "Arrival", (s) => s.label === "Arrival",
); );
/** /**
* Stage for a booking, factoring in the assigned train's operational status: * Stage for a booking, factoring in the assigned train's operational status:
* a booking with per-booking journey data reaches ARRIVED when it is unloaded * a booking with per-booking journey data reaches ARRIVED (the Unloading
* at its own destination yard; a legacy booking is stuck at IN_TRANSIT between * stage) when it is unloaded at its own destination yard; a legacy booking is
* dispatch and delivery, so once its train has ARRIVED the tracker advances to * stuck at IN_TRANSIT between dispatch and delivery, so once its train has
* the Arrival stage. * ARRIVED the tracker advances to the Arrival stage.
*/ */
export function resolveStage(booking: { export function resolveStage(booking: {
status: string; status: string;
@@ -181,10 +192,10 @@ export const STATUS_MAP: Record<
stage: 6, stage: 6,
}, },
ARRIVED: { ARRIVED: {
title: "Arrived at destination", title: "Unloaded at destination",
description: description:
"Your cargo has been unloaded at its destination yard and is being prepared for release.", "Your cargo has been unloaded at its destination yard and is being prepared for release.",
stage: 7, stage: 8,
}, },
OPERATION_REQUEST_PENDING: { OPERATION_REQUEST_PENDING: {
title: "Operation request under review", title: "Operation request under review",
@@ -251,7 +262,7 @@ export const STATUS_MAP: Record<
title: "Contract closed", title: "Contract closed",
description: description:
"This general contract is closed — its reserved quantity has been used or its window has elapsed.", "This general contract is closed — its reserved quantity has been used or its window has elapsed.",
stage: 8, stage: 9,
}, },
PRICE_CHANGED_PENDING_CONFIRM: { PRICE_CHANGED_PENDING_CONFIRM: {
title: "Price changed — confirm to proceed", title: "Price changed — confirm to proceed",
@@ -277,12 +288,12 @@ export const STATUS_MAP: Record<
COMPLETED: { COMPLETED: {
title: "Service complete", title: "Service complete",
description: "Cargo delivered and service successfully terminated.", description: "Cargo delivered and service successfully terminated.",
stage: 8, stage: 9,
}, },
DELIVERED: { DELIVERED: {
title: "Service complete", title: "Service complete",
description: "Cargo delivered and service successfully terminated.", description: "Cargo delivered and service successfully terminated.",
stage: 8, stage: 9,
}, },
REJECTED: { REJECTED: {
title: "Booking rejected", title: "Booking rejected",

View File

@@ -42,7 +42,7 @@ function BookingActionModalBody({
const flow = useClearanceFlow(booking); const flow = useClearanceFlow(booking);
const reference = booking.reference; const reference = booking.reference;
const handleSubmit = () => flow.submitDocuments(); const handleSubmit = () => flow.submitDocuments({ onSuccess: onClose });
const handleProceed = () => flow.proceedToOperation({ onSuccess: onClose }); const handleProceed = () => flow.proceedToOperation({ onSuccess: onClose });
return ( return (

View File

@@ -39,6 +39,8 @@ export interface ContractClearancePanelProps {
tradeDirection?: string; tradeDirection?: string;
/** Show the loading state without the surrounding Paper (e.g. inside a modal). */ /** Show the loading state without the surrounding Paper (e.g. inside a modal). */
bare?: boolean; bare?: boolean;
/** Called after a successful document submission (e.g. to close the host modal). */
onSubmitted?: () => void;
} }
/** /**
@@ -52,6 +54,7 @@ export function ContractClearancePanel({
contractId, contractId,
tradeDirection = "IMPORT", tradeDirection = "IMPORT",
bare, bare,
onSubmitted,
}: ContractClearancePanelProps) { }: ContractClearancePanelProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [pending, setPending] = useState<Record<string, File>>({}); const [pending, setPending] = useState<Record<string, File>>({});
@@ -77,6 +80,13 @@ export function ContractClearancePanel({
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: api.contracts.get.queryKey({ id: contractId }), queryKey: api.contracts.get.queryKey({ id: contractId }),
}); });
// Submitting moves the contract to CLEARANCE_UNDER_REVIEW — refresh every
// contracts-list query (prefix key) so the /contracts table row and its
// action button update without a page reload.
queryClient.invalidateQueries({
queryKey: api.contracts.list.queryKey(),
});
onSubmitted?.();
}, },
}); });

View File

@@ -519,6 +519,8 @@ export interface IContract extends BaseEntity {
companyId?: string | null; companyId?: string | null;
companyProfileId?: string | null; companyProfileId?: string | null;
/** Loaded company relation (list + detail responses include it). */
company?: BookingRequestCompany | null;
isGovernment: boolean; isGovernment: boolean;
governmentInstitution?: string | null; governmentInstitution?: string | null;