From e4429592d380809147fb74e28eb84da0959bbbf4 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 9 Jul 2026 20:26:14 +0000 Subject: [PATCH] fix issues --- .../modules/bookings/bookings.repository.ts | 31 ++ .../src/modules/bookings/bookings.service.ts | 11 + .../bookings/dto/filter-booking.dto.ts | 25 ++ .../train-scheduling/booking-batch.service.ts | 190 ++++++++-- .../booking-journey.service.ts | 44 +++ .../corridor-capacity.util.spec.ts | 45 +++ .../corridor-capacity.util.ts | 19 + .../dto/batch-board-query.dto.ts | 99 ++++++ .../train-scheduling.controller.ts | 8 +- .../train-scheduling.service.ts | 11 + .../TrainCompositionDiagram.tsx | 41 ++- .../backoffice/src/constants/QUERY_KEYS.ts | 3 +- .../features/contracts/mapContractListRow.ts | 2 +- .../backoffice/src/lib/queryClient.ts | 4 + .../pages/bookings/BookingRequestsPage.tsx | 236 ++++++++++++- .../contracts/ContractRequestDetailPage.tsx | 2 +- .../pages/contracts/ContractRequestsPage.tsx | 10 +- .../pages/trainScheduling/BatchBoardPage.tsx | 331 ++++++++++++++---- .../BatchScheduleDetailPage.tsx | 17 +- .../backoffice/src/services/api.ts | 12 +- .../src/services/bookings.service.ts | 29 ++ .../src/services/trainScheduling.service.ts | 23 +- .../backoffice/src/types/trainScheduling.ts | 38 ++ .../ContractClearanceAction.tsx | 2 +- .../components/StatusHero.tsx | 4 +- .../bookings/BookingDetailPage/constants.ts | 39 ++- .../bookings/clearance/BookingActionModal.tsx | 2 +- .../contracts/ContractClearancePanel.tsx | 10 + packages/types/src/freight/contracts.ts | 2 + 29 files changed, 1143 insertions(+), 147 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/dto/batch-board-query.dto.ts diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 843f91d24..46b40d212 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -44,6 +44,11 @@ export interface BookingListFilterOptions { customsClearingEnabled?: boolean; createdFrom?: string; createdTo?: string; + scheduledFrom?: string; + scheduledTo?: string; + originYardId?: string; + destinationYardId?: string; + isGovernment?: 'true' | 'false'; consolidationPaired?: string; } @@ -814,6 +819,32 @@ export class BookingsRepository extends BaseRepository { 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) { qb.andWhere('booking.trade_direction = :tradeDirection', { tradeDirection: options.tradeDirection, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 48a29988a..50b5dd767 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1158,6 +1158,11 @@ export class BookingsService { paymentStatus: filter.paymentStatus, createdFrom: filter.createdFrom, createdTo: filter.createdTo, + scheduledFrom: filter.scheduledFrom, + scheduledTo: filter.scheduledTo, + originYardId: filter.originYardId, + destinationYardId: filter.destinationYardId, + isGovernment: filter.isGovernment, consolidationPaired: filter.consolidationPaired, sortBy: filter.sortBy, sortOrder: filter.sortOrder, @@ -1371,11 +1376,17 @@ export class BookingsService { serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, freightType: filter.freightType, + bookingType: filter.bookingType, tradeDirection: filter.tradeDirection, paymentCurrency: filter.paymentCurrency, paymentStatus: filter.paymentStatus, createdFrom: filter.createdFrom, createdTo: filter.createdTo, + scheduledFrom: filter.scheduledFrom, + scheduledTo: filter.scheduledTo, + originYardId: filter.originYardId, + destinationYardId: filter.destinationYardId, + isGovernment: filter.isGovernment, consolidationPaired: filter.consolidationPaired, }; diff --git a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts index d189f5448..ae973b97d 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts @@ -81,6 +81,31 @@ export class FilterBookingDto { @IsDateString() 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 }) @IsOptional() @IsIn([...TRADE_DIRECTIONS]) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index b8987e4e4..7dd78d167 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -11,7 +11,15 @@ import { } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; 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 { BookingsRepository } from '../bookings/bookings.repository'; @@ -27,6 +35,10 @@ import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-r import { BookingNotifierService } from './booking-notifier.service'; import { TrainSchedulingService } from './train-scheduling.service'; 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 { BillingService } from "../billing/billing.service"; @@ -158,6 +170,8 @@ export interface BatchWindowGroup { export interface BatchBoardScheduleDetail { scheduleId: string; + /** Human-facing schedule reference (S-YYYY-NNNNN). */ + scheduleReference: string | null; trainNumber: string | null; routeName: string | null; origin: string | null; @@ -182,11 +196,14 @@ export interface BatchBoardScheduleDetail { export interface BatchBoardSchedule { scheduleId: string; + /** Human-facing schedule reference (S-YYYY-NNNNN). */ + scheduleReference: string | null; trainNumber: string | null; routeName: string | null; origin: string | null; destination: string | null; scheduleDate: string | null; + createdAt: string | null; status: string; bookingWindowStatus: string; direction: string | null; @@ -225,6 +242,16 @@ export interface BatchBoardSchedule { 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 * 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( booking.trainScheduleId, ); - if (schedule && (await this.remainingWagons(schedule)) <= 0) { + if (schedule && (await this.isTrainFull(schedule))) { await this.setWindow(booking.trainScheduleId, "FULL"); } @@ -612,7 +639,7 @@ export class BookingBatchService implements OnModuleInit { this.armSettle(scheduleId); const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); - if (schedule && (await this.remainingWagons(schedule)) <= 0) { + if (schedule && (await this.isTrainFull(schedule))) { await this.setWindow(scheduleId, 'FULL'); } } @@ -653,12 +680,70 @@ export class BookingBatchService implements OnModuleInit { // ---- monitoring board ----------------------------------------------------- /** - * Read model for the batch monitoring page: every still-relevant schedule (not arrived/ - * cancelled) with its locomotive, capacity usage and its bookings grouped by lifecycle - * state (allocated / awaiting payment / paid-waiting / pending contract / expired). + * Read model for the batch monitoring page: every import schedule — including + * dispatched, arrived and cancelled history — with its locomotive, capacity + * 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 { - const schedules = await this.trainSchedulesRepository.findAll({ + async getBatchBoard( + query: BatchBoardQueryDto = {}, + ): Promise { + 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(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 = { 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 | FindOptionsWhere[] = + 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[]; + } + + const sortBy = query.sortBy ?? "createdAt"; + const sortOrder = query.sortOrder ?? "DESC"; + + const [schedules, total] = await this.trainSchedulesRepository.findAndCount({ + where, relations: { trainSet: { locomotive: true }, originStation: true, @@ -666,7 +751,9 @@ export class BookingBatchService implements OnModuleInit { // Yards supply the route's display name for `routeName` below. route: { originYard: true, destinationYard: true }, }, - order: { scheduledDepartureDate: "ASC" }, + order: { [sortBy]: sortOrder } as never, + skip: (page - 1) * pageSize, + take: pageSize, }); const wagonDims = await this.loadWagonDims(); @@ -675,11 +762,6 @@ export class BookingBatchService implements OnModuleInit { const board: BatchBoardSchedule[] = []; 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 linkedIds = new Set(links.map((l) => l.bookingId)); const bookings = await this.bookingsRepository.findAllBySchedule(s.id); @@ -707,7 +789,14 @@ export class BookingBatchService implements OnModuleInit { 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. */ @@ -718,9 +807,8 @@ export class BookingBatchService implements OnModuleInit { await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!s) throw new NotFoundException(`Train schedule ${scheduleId} not found`); - if (s.status === "ARRIVED" || s.status === "CANCELLED") { - throw new BadRequestException("Schedule is no longer active"); - } + // Arrived / cancelled schedules stay viewable — the board is also the + // historical record of what each train carried. // Batch board is IMPORT-only (export is FCFS, no batch/priority calc). if (s.direction !== "IMPORT") { throw new BadRequestException( @@ -895,6 +983,7 @@ export class BookingBatchService implements OnModuleInit { return { scheduleId: s.id, + scheduleReference: s.reference ?? null, trainNumber: s.trainNumber ?? null, routeName: s.route ? formatRouteLabel(s.route) : null, origin: s.originStation?.label ?? s.originStation?.code ?? null, @@ -1016,6 +1105,7 @@ export class BookingBatchService implements OnModuleInit { return { scheduleId: s.id, + scheduleReference: s.reference ?? null, trainNumber: s.trainNumber ?? null, routeName: s.route ? formatRouteLabel(s.route) : null, origin: s.originStation?.label ?? s.originStation?.code ?? null, @@ -1024,6 +1114,7 @@ export class BookingBatchService implements OnModuleInit { scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null, + createdAt: s.createdAt ? s.createdAt.toISOString() : null, status: s.status, bookingWindowStatus: s.bookingWindowStatus, direction: s.direction ?? null, @@ -1117,7 +1208,8 @@ export class BookingBatchService implements OnModuleInit { const limits = await this.capacityLimits(locomotive, rules); await this.syncScheduleMaxWagons(schedule, locomotive, rules); 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"); return 0; } @@ -1213,7 +1305,7 @@ export class BookingBatchService implements OnModuleInit { this.logger.log( `[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); void this.triggerWagonAllocation(scheduleId); return commercialReserved; @@ -1461,8 +1553,9 @@ export class BookingBatchService implements OnModuleInit { `[fillRouteDay ${originYardId}->${destinationYardId} ${day}] reserved ${reservedThisPass}/${units.length} unit(s) this pass`, ); + const minPerWagon = this.minPerWagonNeed(wagonDims); 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); void this.triggerWagonAllocation(t.id); } @@ -1783,7 +1876,7 @@ export class BookingBatchService implements OnModuleInit { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( booking.trainScheduleId, ); - if (schedule && (await this.remainingWagons(schedule)) <= 0) { + if (schedule && (await this.isTrainFull(schedule))) { await this.setWindow(booking.trainScheduleId, "FULL"); } 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). - * ≤ 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 { 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 { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); 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 { + 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 = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); 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 = schedule.windowPhase == null || schedule.windowPhase === "OPEN"; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts index cdc58083c..2df741bd3 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts @@ -28,6 +28,9 @@ import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; * unload at its destination yard (IN_TRANSIT → ARRIVED for import/export, * → 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. + * 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 * 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 { + 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 * final yard that operators didn't unload individually gets its per-booking diff --git a/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.spec.ts index a00e1f59f..15d065d09 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.spec.ts @@ -72,4 +72,49 @@ describe('corridor-capacity.util — overage tolerance', () => { strict.subtract(need(3500, 10, 170), strict.fullLeg()); 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); + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.ts index dbf304141..f2602a157 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.ts @@ -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 * board anywhere and the schedule's window is genuinely FULL. (A train can be diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/batch-board-query.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/batch-board-query.dto.ts new file mode 100644 index 000000000..11bc8ceb5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/batch-board-query.dto.ts @@ -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'; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 7dba44f83..fd41d4a29 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -39,6 +39,7 @@ import { UploadImportDjiboutiDocumentDto, } from "./dto/import-djibouti-operation.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 { AvailableDaysQueryDto } from "./dto/available-days-query.dto"; import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-query.dto"; @@ -124,10 +125,11 @@ export class TrainSchedulingController { @Get("batch-board") @TrainSchedulingView() @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() { - return this.bookingBatchService.getBatchBoard(); + getBatchBoard(@Query() query: BatchBoardQueryDto) { + return this.bookingBatchService.getBatchBoard(query); } @Get("batch-board/:scheduleId") diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 953698ae0..f53af2a93 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -2514,6 +2514,12 @@ export class TrainSchedulingService { if (dto.sequenceNo === finalSeq) { 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); @@ -4518,6 +4524,11 @@ export class TrainSchedulingService { capacityTons: roundTons(Number(wagon.capacityTons)), lengthMeters: roundTons(Number(wagon.lengthMeters)), 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, physicalWagonId: wagon.physicalWagonId ?? null, physicalWagonNumber: wagon.physicalWagon?.wagonNumber ?? null, diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx index c7530209b..6ba1a0e29 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx @@ -16,6 +16,7 @@ type DiagramWagonInput = { sequenceNo: number; capacityTons: number; assignedWeightTons: number; + tareWeightTons?: number | null; slotLoadType?: string | null; wagonType?: { code?: string | null } | null; wagonTypeCode?: string | null; @@ -33,6 +34,7 @@ type NormalizedWagon = { sequenceNo: number; capacityTons: number; assignedWeightTons: number; + tareWeightTons: number; wagonTypeCode: string | null; physicalWagonNumber: string | null; isEmpty: boolean; @@ -69,6 +71,7 @@ function normalizeWagon(w: DiagramWagonInput, freightType?: string | null): Norm sequenceNo: w.sequenceNo, capacityTons: Number(w.capacityTons) || 0, assignedWeightTons: Number(w.assignedWeightTons) || 0, + tareWeightTons: Number(w.tareWeightTons) || 0, wagonTypeCode: w.wagonType?.code ?? w.wagonTypeCode ?? null, physicalWagonNumber: w.physicalWagonNumber ?? null, isEmpty: allocations.length === 0, @@ -278,7 +281,9 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) { wagon.bookingRefs.length ? wagon.bookingRefs.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) const blocks = wagon.containerNumbers.slice(0, 2); @@ -523,15 +528,22 @@ export function TrainCompositionDiagram({ const assigned = normalized.filter((w) => !w.isEmpty).length; const totalWeight = normalized.reduce((s, w) => s + w.assignedWeightTons, 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 { total: normalized.length, assigned, empty: normalized.length - assigned, totalWeight: Math.round(totalWeight * 100) / 100, + totalTare: Math.round(totalTare * 100) / 100, + grossWeight: Math.round(grossWeight * 100) / 100, totalCapacity, pullUtil: locomotive?.maxPullWeightTons && locomotive.maxPullWeightTons > 0 - ? Math.min(100, Math.round((totalWeight / locomotive.maxPullWeightTons) * 100)) + ? Math.min(100, Math.round((grossWeight / locomotive.maxPullWeightTons) * 100)) : null, }; }, [normalized, locomotive]); @@ -598,12 +610,30 @@ export function TrainCompositionDiagram({ }} > - {stats.totalWeight}T + {stats.totalWeight}T cargo of {stats.totalCapacity}T capacity + {stats.totalTare > 0 ? ( + + + {stats.grossWeight}T gross + + + incl. {stats.totalTare}T tare + + + ) : null} @@ -626,7 +656,10 @@ export function TrainCompositionDiagram({ - 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`} 95 ? "red.7" : "edr-green.7"}> diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index 2ceaae870..dbac021f3 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -103,7 +103,8 @@ export const QUERY_KEYS = { schedules: () => ["train-scheduling", "schedules"] as const, scheduleById: (id: string) => ["train-scheduling", "schedule", 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) => ["train-scheduling", "batch-board", scheduleId] as const, unassignedBookings: (id: string) => diff --git a/apps/edr-freight-web/backoffice/src/features/contracts/mapContractListRow.ts b/apps/edr-freight-web/backoffice/src/features/contracts/mapContractListRow.ts index 69c394927..0cd842830 100644 --- a/apps/edr-freight-web/backoffice/src/features/contracts/mapContractListRow.ts +++ b/apps/edr-freight-web/backoffice/src/features/contracts/mapContractListRow.ts @@ -80,7 +80,7 @@ export function toContractListRow(contract: Freight.IContract): ContractListRow approvalSteps: contract.approvalSteps, customerLabel: contract.isGovernment ? (contract.governmentInstitution ?? "Government") - : (contract.companyId ?? "—"), + : (contract.company?.name ?? "—"), status: contract.status, contractKind: contract.contractKind, tradeDirection: contract.tradeDirection, diff --git a/apps/edr-freight-web/backoffice/src/lib/queryClient.ts b/apps/edr-freight-web/backoffice/src/lib/queryClient.ts index 781ffba74..5b2bcb255 100644 --- a/apps/edr-freight-web/backoffice/src/lib/queryClient.ts +++ b/apps/edr-freight-web/backoffice/src/lib/queryClient.ts @@ -28,6 +28,10 @@ export const queryClient = new QueryClient({ queries: { retry: 1, 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, }, }, }); diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index 477900ee1..c406b7a4b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -4,18 +4,21 @@ import { Button, Card, Group, + MultiSelect, Select, Stack, Tabs, Text, TextInput, } from "@mantine/core"; +import { DateInput } from "@mantine/dates"; import { AlertTriangle, ArrowRight, Calendar, CheckCircle2, Clock, + FilterX, LayoutList, Package, Plus, @@ -26,6 +29,7 @@ import { } from "lucide-react"; import { useCallback, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu"; import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell"; @@ -43,6 +47,7 @@ import { useBookingList, useBookingListSummary, } from "@/hooks/bookings/useBookings"; +import { api } from "@/services/api"; import type { BookingListFilter } from "@/services/bookings.service"; import type { BookingListRow } from "@/types/booking"; import { @@ -77,6 +82,33 @@ const FREIGHT_TYPE_OPTIONS = [ { 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 { if (!value) return "—"; const d = new Date(value); @@ -95,10 +127,18 @@ export default function BookingRequestsPage() { const [query, setQuery] = useState(""); // Booking-kind tabs (one-time vs general contract) replace the old status tabs. const [kindTab, setKindTab] = useState("ONE_TIME"); - // Per-tab filter selects (each nullable = "all"). - const [statusFilter, setStatusFilter] = useState(null); + // Per-tab filter controls (empty/null = "all"). + const [statusFilter, setStatusFilter] = useState([]); const [directionFilter, setDirectionFilter] = useState(null); const [freightTypeFilter, setFreightTypeFilter] = useState(null); + const [paymentStatusFilter, setPaymentStatusFilter] = useState(null); + const [ownershipFilter, setOwnershipFilter] = useState(null); + const [originYardFilter, setOriginYardFilter] = useState(null); + const [destinationYardFilter, setDestinationYardFilter] = useState(null); + const [createdFrom, setCreatedFrom] = useState(null); + const [createdTo, setCreatedTo] = useState(null); + const [scheduledFrom, setScheduledFrom] = useState(null); + const [scheduledTo, setScheduledTo] = useState(null); const [allocateOpen, setAllocateOpen] = useState(false); const [allocateIds, setAllocateIds] = useState([]); const suppressRowClickRef = useRef(false); @@ -118,9 +158,21 @@ export default function BookingRequestsPage() { // React Query cache key per kind tab. tab: kindTab, bookingType: kindTab, - ...(statusFilter ? { statuses: statusFilter } : {}), + ...(statusFilter.length ? { statuses: statusFilter.join(",") } : {}), ...(directionFilter ? { tradeDirection: directionFilter } : {}), ...(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, @@ -129,6 +181,14 @@ export default function BookingRequestsPage() { statusFilter, directionFilter, freightTypeFilter, + paymentStatusFilter, + ownershipFilter, + originYardFilter, + destinationYardFilter, + createdFrom, + createdTo, + scheduledFrom, + scheduledTo, ]); const { data, isLoading, isError, refetch, isFetching } = useBookingList(filter); @@ -142,6 +202,49 @@ export default function BookingRequestsPage() { refetch: refetchSummary, } = 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 items = (data?.items ?? []).map(toBookingListRow); const q = query.trim().toLowerCase(); @@ -415,18 +518,44 @@ export default function BookingRequestsPage() { - { + setOriginYardFilter(v); + resetPage(); + }} + clearable + searchable + radius="lg" + style={{ minWidth: 180 }} + /> + { setDirectionFilter(v); - setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); + resetPage(); }} clearable radius="lg" - style={{ minWidth: 170 }} + style={{ minWidth: 150 }} /> { + setPaymentStatusFilter(v); + resetPage(); + }} + clearable + radius="lg" + style={{ minWidth: 180 }} + /> + v && setStatusFilter(v)} + data={[ + { value: "ALL", label: "All statuses" }, + { value: "DRAFT", label: "Draft" }, + { value: "SCHEDULED", label: "Scheduled" }, + { value: "DISPATCHED", label: "Dispatched" }, + { value: "ARRIVED", label: "Arrived" }, + { value: "CANCELLED", label: "Cancelled" }, + ]} + w={150} + styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }} + /> + 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)" } }} + /> + + + setSortOrder((o) => (o === "DESC" ? "ASC" : "DESC")) + } + > + {sortOrder === "DESC" ? ( + + ) : ( + + )} + + + } /> @@ -686,7 +847,7 @@ export default function BatchBoardPage() { {viewMode === "table" ? ( navigate(`/dashboard/operations/batch-board/${schedule.scheduleId}`) @@ -699,12 +860,12 @@ export default function BatchBoardPage() { } : undefined } - emptyMessage="No active schedules" + emptyMessage="No schedules match the current filters" pagination={{ pageIndex: pagination.pageIndex, pageSize: pagination.pageSize, pageCount, - totalCount: filtered.length, + totalCount: total, }} tableOptions={{ manualPagination: true, @@ -727,7 +888,7 @@ export default function BatchBoardPage() { - ) : filtered.length === 0 ? ( + ) : schedules.length === 0 ? ( - No active schedules + No schedules match the current filters - Schedules with an open booking window appear here. Create or activate a - schedule to get started. + Every import schedule — live and historical — appears here. Loosen the + filters or clear the search to see more. ) : ( - - {filtered.map((s) => ( - - ))} - + <> + + {schedules.map((s) => ( + + ))} + + + + {total} schedule{total === 1 ? "" : "s"} + + + + + Page {pagination.pageIndex + 1} of {pageCount} + + + + + )} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx index 34ff013cb..114b2e742 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx @@ -25,6 +25,7 @@ import { ClipboardCheck, Clock, FileSignature, + Hash, Hourglass, Layers, Package, @@ -621,6 +622,9 @@ export default function BatchScheduleDetailPage() { api.trainScheduling.scheduleDetail.queryOptions({ input: { id: scheduleId ?? "", freightType: "CONTAINER" }, 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={[ { label: "Operations" }, { 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() { {data.trainNumber ?? data.routeName ?? "Schedule"} + {data.scheduleReference ? ( + }> + {data.scheduleReference} + + ) : null} {data.windowPhase ? ( QUERY_KEYS.TRAIN_SCHEDULING.schedules(), ), - batchBoard: endpoint( + batchBoard: endpoint< + { filters?: BatchBoardFilters }, + BatchBoardListResponse + >( "train-scheduling", "batch-board", - () => trainSchedulingService.getBatchBoard(), - () => QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(), + ({ filters }) => trainSchedulingService.getBatchBoard(filters), + ({ filters }) => QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(filters), ), allBookingWindows: endpoint( diff --git a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts index 6a4c0cb1c..b97ac2bc3 100644 --- a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts @@ -21,6 +21,19 @@ export interface BookingListFilter { bookingType?: string; tradeDirection?: 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; pageSize?: number; sortBy?: string; @@ -130,6 +143,14 @@ export const bookingsService = { if (filter.bookingType) params.bookingType = filter.bookingType; if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection; 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(B.LIST_SUMMARY, { params, @@ -154,6 +175,14 @@ export const bookingsService = { if (filter.bookingType) params.bookingType = filter.bookingType; if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection; 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(B.BASE, { params, diff --git a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts index be05c6d8c..fb26b9605 100644 --- a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts @@ -3,7 +3,8 @@ import { api as client } from "../auth/http"; import { unwrap } from "@/utils/endpoint"; import { URL_CONSTANTS } from "@/constants/URLS"; import type { - BatchBoardSchedule, + BatchBoardFilters, + BatchBoardListResponse, BatchBoardScheduleDetail, BookableSchedule, BookingWindow, @@ -100,9 +101,25 @@ export const trainSchedulingService = { return unwrap(response.data); }, - getBatchBoard: async (): Promise => { - const response = await client.get( + getBatchBoard: async ( + filters: BatchBoardFilters = {}, + ): Promise => { + const params: Record = {}; + 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( URL_CONSTANTS.TRAIN_SCHEDULING.BATCH_BOARD, + { params }, ); return unwrap(response.data); }, diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index c09c067d8..129171b7f 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -257,11 +257,14 @@ export interface StaffBookingWindow { export interface BatchBoardSchedule { scheduleId: string; + /** Human-facing schedule reference (S-YYYY-NNNNN). */ + scheduleReference: string | null; trainNumber: string | null; routeName: string | null; origin: string | null; destination: string | null; scheduleDate: string | null; + createdAt: string | null; status: string; bookingWindowStatus: string; direction: string | null; @@ -300,6 +303,37 @@ export interface BatchBoardSchedule { 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 = | "NOT_ATTEMPTED" | "ASSIGNED" @@ -337,6 +371,8 @@ export interface BatchWindowGroup { export interface BatchBoardScheduleDetail { scheduleId: string; + /** Human-facing schedule reference (S-YYYY-NNNNN). */ + scheduleReference: string | null; trainNumber: string | null; routeName: string | null; origin: string | null; @@ -506,6 +542,8 @@ export interface TrainScheduleDetail { capacityTons: number; lengthMeters: number; assignedWeightTons: number; + /** Empty-wagon weight from the wagon type — gross = tare + cargo. */ + tareWeightTons?: number | null; status?: string; physicalWagonId?: string | null; physicalWagonNumber?: string | null; diff --git a/apps/edr-freight-web/portal/src/components/customer-actions/ContractClearanceAction.tsx b/apps/edr-freight-web/portal/src/components/customer-actions/ContractClearanceAction.tsx index 4aa131c23..fbb8f97c2 100644 --- a/apps/edr-freight-web/portal/src/components/customer-actions/ContractClearanceAction.tsx +++ b/apps/edr-freight-web/portal/src/components/customer-actions/ContractClearanceAction.tsx @@ -83,7 +83,7 @@ export function ContractClearanceAction({ centered overlayProps={{ blur: 2, backgroundOpacity: 0.55 }} > - + ); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx index 13404f8f6..82c692a90 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx @@ -142,7 +142,7 @@ function ProgressTracker({ const activeRing = tone === "ink" ? "#D9E0E7" : "#BFE8D4"; return ( - /* Scrollable on mobile so 5 stages never overflow */ + /* Scrollable on mobile so the stages never overflow */ -
+
{PROGRESS_STAGES.map((stage, idx) => { const state = idx < current ? "done" : idx === current ? "active" : "idle"; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts index 4b32a059c..7b91f8250 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts @@ -3,6 +3,7 @@ import { FileText, MapPin, PackageCheck, + PackageOpen, ShieldCheck, Ship, Train, @@ -54,12 +55,22 @@ export const PROGRESS_STAGES = [ statuses: ["EXPIRED", "IN_TRANSIT"], }, { - // ARRIVED: cargo unloaded at the booking's own destination yard (segment - // corridor journeys). Legacy bookings stay IN_TRANSIT until delivery, so - // this stage also lights up from the assigned train's own status - // (trainScheduleStatus === "ARRIVED") — see resolveStage. + // The train reached the booking's destination yard — cargo may still be + // on board. No booking status of its own: it lights up from the assigned + // train's status (trainScheduleStatus === "ARRIVED") while the booking is + // still IN_TRANSIT — see resolveStage. Bookings unloaded mid-corridor (or + // auto-unloaded on a checkpoint) jump straight past it to Unloading. label: "Arrival", 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"], }, { @@ -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( (s) => s.label === "Arrival", ); /** * 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 - * at its own destination yard; a legacy booking is stuck at IN_TRANSIT between - * dispatch and delivery, so once its train has ARRIVED the tracker advances to - * the Arrival stage. + * a booking with per-booking journey data reaches ARRIVED (the Unloading + * stage) when it is unloaded at its own destination yard; a legacy booking is + * stuck at IN_TRANSIT between dispatch and delivery, so once its train has + * ARRIVED the tracker advances to the Arrival stage. */ export function resolveStage(booking: { status: string; @@ -181,10 +192,10 @@ export const STATUS_MAP: Record< stage: 6, }, ARRIVED: { - title: "Arrived at destination", + title: "Unloaded at destination", description: "Your cargo has been unloaded at its destination yard and is being prepared for release.", - stage: 7, + stage: 8, }, OPERATION_REQUEST_PENDING: { title: "Operation request under review", @@ -251,7 +262,7 @@ export const STATUS_MAP: Record< title: "Contract closed", description: "This general contract is closed — its reserved quantity has been used or its window has elapsed.", - stage: 8, + stage: 9, }, PRICE_CHANGED_PENDING_CONFIRM: { title: "Price changed — confirm to proceed", @@ -277,12 +288,12 @@ export const STATUS_MAP: Record< COMPLETED: { title: "Service complete", description: "Cargo delivered and service successfully terminated.", - stage: 8, + stage: 9, }, DELIVERED: { title: "Service complete", description: "Cargo delivered and service successfully terminated.", - stage: 8, + stage: 9, }, REJECTED: { title: "Booking rejected", diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx index 23676efe8..0cabf0878 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx @@ -42,7 +42,7 @@ function BookingActionModalBody({ const flow = useClearanceFlow(booking); const reference = booking.reference; - const handleSubmit = () => flow.submitDocuments(); + const handleSubmit = () => flow.submitDocuments({ onSuccess: onClose }); const handleProceed = () => flow.proceedToOperation({ onSuccess: onClose }); return ( diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractClearancePanel.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractClearancePanel.tsx index 6fc88c678..67b3fead8 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractClearancePanel.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractClearancePanel.tsx @@ -39,6 +39,8 @@ export interface ContractClearancePanelProps { tradeDirection?: string; /** Show the loading state without the surrounding Paper (e.g. inside a modal). */ 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, tradeDirection = "IMPORT", bare, + onSubmitted, }: ContractClearancePanelProps) { const queryClient = useQueryClient(); const [pending, setPending] = useState>({}); @@ -77,6 +80,13 @@ export function ContractClearancePanel({ queryClient.invalidateQueries({ 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?.(); }, }); diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index c6b97fab1..49af6b736 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -519,6 +519,8 @@ export interface IContract extends BaseEntity { companyId?: string | null; companyProfileId?: string | null; + /** Loaded company relation (list + detail responses include it). */ + company?: BookingRequestCompany | null; isGovernment: boolean; governmentInstitution?: string | null;