From e68bdb7a1a9c9268473bddfc28b150d7018168de Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 3 Aug 2026 21:06:59 +0000 Subject: [PATCH] Enhance overview and train scheduling features - Updated OverviewContractsTabPanel to include a new donut chart for freight type distribution. - Modified OverviewOperationsTabPanel to improve data visualization with additional charts and refactored data handling. - Introduced CreateScheduleWindowFields component for configuring booking windows in train scheduling. - Added new API endpoints for allocation candidates and booking allocation in trainScheduling.service. - Enhanced BookingRequestsPage to support allocation of paid bookings with a modal for selecting alternative dates. - Updated QUERY_KEYS and URLS constants to accommodate new operations and features. - Improved type definitions for overview and train scheduling to support new functionalities. --- ...00000000000-AddScheduleWindowRuleCustom.ts | 28 ++ .../overview/dto/overview-response.dto.ts | 2 + .../overview/dto/overview-tab-response.dto.ts | 30 ++ .../modules/overview/overview.controller.ts | 6 +- .../src/modules/overview/overview.module.ts | 2 + .../modules/overview/overview.repository.ts | 172 +++++++- .../src/modules/overview/overview.service.ts | 24 +- .../entities/train-schedule.entity.ts | 9 + .../train-scheduling/booking-batch.service.ts | 130 +++++++ .../booking-notifier.service.ts | 13 + .../create-container-train-schedule.dto.ts | 113 ++++++ .../train-scheduling.controller.ts | 26 ++ .../train-scheduling.service.spec.ts | 45 +++ .../train-scheduling.service.ts | 125 ++++-- .../overview/OverviewStackedBarChart.tsx | 82 ++++ .../overview/OverviewTabContent.tsx | 2 +- .../tabs/OverviewBookingsTabPanel.tsx | 24 +- .../tabs/OverviewContractsTabPanel.tsx | 21 +- .../tabs/OverviewOperationsTabPanel.tsx | 135 ++++++- .../CreateScheduleWindowFields.tsx | 366 ++++++++++++++++++ .../backoffice/src/constants/QUERY_KEYS.ts | 3 +- .../backoffice/src/constants/URLS.ts | 4 + .../backoffice/src/hooks/useOverview.ts | 6 +- .../pages/bookings/BookingRequestsPage.tsx | 162 +++++++- .../TrainScheduleV2ListPage.tsx | 49 +++ .../src/services/overview.service.ts | 8 +- .../src/services/trainScheduling.service.ts | 20 + .../backoffice/src/types/overview.ts | 2 + .../backoffice/src/types/trainScheduling.ts | 32 ++ packages/types/src/freight/overview.ts | 21 + 30 files changed, 1580 insertions(+), 82 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3200000000000-AddScheduleWindowRuleCustom.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/OverviewStackedBarChart.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/CreateScheduleWindowFields.tsx diff --git a/apps/edr-freight-api/src/migrations/3200000000000-AddScheduleWindowRuleCustom.ts b/apps/edr-freight-api/src/migrations/3200000000000-AddScheduleWindowRuleCustom.ts new file mode 100644 index 000000000..821c67135 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3200000000000-AddScheduleWindowRuleCustom.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Marks a train schedule whose booking-window rule was configured by staff at + * creation rather than inherited from the live global rules. + * + * Without this flag `restampPendingWindows` — which re-derives EVERY still + * PRE_WINDOW schedule from the current global config after a global-rules edit — + * would silently overwrite those hand-picked settings, which is precisely what + * the per-schedule configuration exists to prevent. + * + * Defaults false, so every existing schedule keeps following the global rules. + */ +export class AddScheduleWindowRuleCustom3200000000000 implements MigrationInterface { + name = 'AddScheduleWindowRuleCustom3200000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."train_schedules" ADD COLUMN IF NOT EXISTS "window_rule_custom" boolean NOT NULL DEFAULT false`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."train_schedules" DROP COLUMN IF EXISTS "window_rule_custom"`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts b/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts index ab914deff..981ff35dd 100644 --- a/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts +++ b/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts @@ -21,6 +21,8 @@ export class OverviewOperationsKpisDto { @ApiProperty() wagonsAvailable!: number; @ApiProperty() containersInTransit!: number; @ApiProperty() cargoesLoaded!: number; + @ApiProperty() schedulesUpcoming!: number; + @ApiProperty() dispatchedToday!: number; } export class OverviewCustomerKpisDto { diff --git a/apps/edr-freight-api/src/modules/overview/dto/overview-tab-response.dto.ts b/apps/edr-freight-api/src/modules/overview/dto/overview-tab-response.dto.ts index d4e5c2782..7c60a91a6 100644 --- a/apps/edr-freight-api/src/modules/overview/dto/overview-tab-response.dto.ts +++ b/apps/edr-freight-api/src/modules/overview/dto/overview-tab-response.dto.ts @@ -104,10 +104,40 @@ export class OverviewBillingTabDto { generatedAt!: string; } +export class OverviewDirectionTrendPointDto { + @ApiProperty({ example: '2026-08-01' }) date!: string; + @ApiProperty() importCount!: number; + @ApiProperty() exportCount!: number; + @ApiProperty() domesticCount!: number; +} + +export class OverviewTonnagePointDto { + @ApiProperty() label!: string; + @ApiProperty() tons!: number; +} + export class OverviewOperationsTabDto { @ApiProperty({ type: OverviewOperationsKpisDto }) kpis!: OverviewOperationsKpisDto; + @ApiProperty({ type: [OverviewDirectionTrendPointDto] }) + departureTrend!: OverviewDirectionTrendPointDto[]; + + @ApiProperty({ type: [OverviewStatusCountDto] }) + scheduleStatusBreakdown!: OverviewStatusCountDto[]; + + @ApiProperty({ type: [OverviewLabelCountDto] }) + wagonsByType!: OverviewLabelCountDto[]; + + @ApiProperty({ type: [OverviewLabelCountDto] }) + wagonsByYard!: OverviewLabelCountDto[]; + + @ApiProperty({ type: [OverviewLabelCountDto] }) + containersBySize!: OverviewLabelCountDto[]; + + @ApiProperty({ type: [OverviewTonnagePointDto] }) + cargoTonnageByType!: OverviewTonnagePointDto[]; + @ApiProperty({ type: [OverviewStatusCountDto] }) trainStatusBreakdown!: OverviewStatusCountDto[]; diff --git a/apps/edr-freight-api/src/modules/overview/overview.controller.ts b/apps/edr-freight-api/src/modules/overview/overview.controller.ts index 4e1ecc6d6..661350f49 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.controller.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.controller.ts @@ -99,8 +99,10 @@ export class OverviewController { @BookingView() @ApiOperation({ summary: 'Operations tab metrics and charts' }) @ApiOkResponse({ type: OverviewOperationsTabDto }) - getOperationsTab(): Promise { - return this.overviewService.getOperationsTab(); + getOperationsTab( + @Query() query: OverviewQueryDto, + ): Promise { + return this.overviewService.getOperationsTab(query.range ?? '30d'); } @Get('customers') diff --git a/apps/edr-freight-api/src/modules/overview/overview.module.ts b/apps/edr-freight-api/src/modules/overview/overview.module.ts index ff19bb801..a20f75ed2 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.module.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.module.ts @@ -9,6 +9,7 @@ import { Container } from "../container-management/entities/container.entity"; import { Company } from "../companies/entities/company.entity"; import { Contract } from "../contracts/entities/contract.entity"; import { PaymentEntity } from "../payment/entities/payment.entity"; +import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity"; import { Train } from "../trains/entities/train.entity"; import { Wagon } from "../wagons/entities/wagon.entity"; import { UserTradeAccessModule } from "../user-trade-access/user-trade-access.module"; @@ -23,6 +24,7 @@ import { OverviewService } from "./overview.service"; PaymentEntity, Company, Train, + TrainSchedule, Wagon, Container, Cargo, diff --git a/apps/edr-freight-api/src/modules/overview/overview.repository.ts b/apps/edr-freight-api/src/modules/overview/overview.repository.ts index a8957d760..c75510c0e 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.repository.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.repository.ts @@ -11,7 +11,12 @@ import { Cargo } from "../cargoes/entities/cargoes.entity"; import { Container } from "../container-management/entities/container.entity"; import { Contract } from "../contracts/entities/contract.entity"; import { PaymentEntity } from "../payment/entities/payment.entity"; +import { CargoType } from "../rule-engine/entities/cargo-type.entity"; +import { ContainerType } from "../rule-engine/entities/container-type.entity"; +import { Yard } from "../rule-engine/entities/yard.entity"; +import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity"; import { Train } from "../trains/entities/train.entity"; +import { WagonType } from "../wagon-types/entities/wagon-type.entity"; import { Wagon } from "../wagons/entities/wagon.entity"; import { OVERVIEW_CLOSED_STATUSES, @@ -83,6 +88,8 @@ export class OverviewRepository { private readonly companyRepository: Repository, @InjectRepository(Train) private readonly trainRepository: Repository, + @InjectRepository(TrainSchedule) + private readonly trainScheduleRepository: Repository, @InjectRepository(Wagon) private readonly wagonRepository: Repository, @InjectRepository(Container) @@ -146,9 +153,17 @@ export class OverviewRepository { wagonsAvailable: number; containersInTransit: number; cargoesLoaded: number; + schedulesUpcoming: number; + dispatchedToday: number; }> { - const [trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded] = - await Promise.all([ + const [ + trainsActive, + wagonsAvailable, + containersInTransit, + cargoesLoaded, + schedulesUpcoming, + dispatchedToday, + ] = await Promise.all([ this.trainRepository .createQueryBuilder("train") .where("train.deleted_at IS NULL") @@ -178,6 +193,22 @@ export class OverviewRepository { statuses: ["LOADED", "IN_TRANSIT"], }) .getCount(), + this.trainScheduleRepository + .createQueryBuilder("schedule") + .where("schedule.deleted_at IS NULL") + .andWhere("schedule.status = :status", { + status: Freight.TrainScheduleStatus.Scheduled, + }) + .andWhere("schedule.scheduled_departure_date >= CURRENT_DATE") + .getCount(), + this.trainScheduleRepository + .createQueryBuilder("schedule") + .where("schedule.deleted_at IS NULL") + .andWhere("schedule.status = :status", { + status: Freight.TrainScheduleStatus.Dispatched, + }) + .andWhere("schedule.scheduled_departure_date::date = CURRENT_DATE") + .getCount(), ]); return { @@ -185,6 +216,8 @@ export class OverviewRepository { wagonsAvailable, containersInTransit, cargoesLoaded, + schedulesUpcoming, + dispatchedToday, }; } @@ -533,6 +566,141 @@ export class OverviewRepository { return this.statusBreakdown(this.cargoRepository, "cargo"); } + async getScheduleStatusBreakdown(): Promise< + { status: string; count: number }[] + > { + return this.statusBreakdown(this.trainScheduleRepository, "schedule"); + } + + /** Scheduled departures per day over the range, split by trade direction. */ + async getDepartureTrend(days: number): Promise< + { + date: string; + importCount: number; + exportCount: number; + domesticCount: number; + }[] + > { + const rows = await this.trainScheduleRepository + .createQueryBuilder("schedule") + .select( + `to_char(schedule.scheduled_departure_date::date, 'YYYY-MM-DD')`, + "date", + ) + .addSelect( + `COUNT(*) FILTER (WHERE schedule.direction = 'IMPORT')::int`, + "importCount", + ) + .addSelect( + `COUNT(*) FILTER (WHERE schedule.direction = 'EXPORT')::int`, + "exportCount", + ) + .addSelect( + `COUNT(*) FILTER (WHERE schedule.direction NOT IN ('IMPORT', 'EXPORT') OR schedule.direction IS NULL)::int`, + "domesticCount", + ) + .where("schedule.deleted_at IS NULL") + .andWhere("schedule.status != :draft", { + draft: Freight.TrainScheduleStatus.Draft, + }) + .andWhere( + `schedule.scheduled_departure_date >= CURRENT_DATE - :days::int + 1`, + { days }, + ) + .andWhere( + `schedule.scheduled_departure_date < CURRENT_DATE + :ahead::int`, + { ahead: 8 }, + ) + .groupBy("schedule.scheduled_departure_date::date") + .orderBy("schedule.scheduled_departure_date::date", "ASC") + .getRawMany<{ + date: string; + importCount: string; + exportCount: string; + domesticCount: string; + }>(); + + return rows.map((row) => ({ + date: row.date, + importCount: Number(row.importCount), + exportCount: Number(row.exportCount), + domesticCount: Number(row.domesticCount), + })); + } + + async getWagonsByType(): Promise<{ label: string; count: number }[]> { + const rows = await this.wagonRepository + .createQueryBuilder("wagon") + .leftJoin(WagonType, "wagon_type", "wagon_type.id = wagon.wagon_type_id") + .select(`COALESCE(wagon_type.name, 'Unknown')`, "label") + .addSelect("COUNT(*)::int", "count") + .where("wagon.deleted_at IS NULL") + .groupBy("wagon_type.name") + .orderBy("count", "DESC") + .getRawMany<{ label: string; count: string }>(); + + return rows.map((row) => ({ label: row.label, count: Number(row.count) })); + } + + async getWagonsByYard(limit: number): Promise< + { label: string; count: number }[] + > { + const rows = await this.wagonRepository + .createQueryBuilder("wagon") + .innerJoin(Yard, "yard", "yard.id = wagon.current_yard_id") + .select("yard.label", "label") + .addSelect("COUNT(*)::int", "count") + .where("wagon.deleted_at IS NULL") + .groupBy("yard.label") + .orderBy("count", "DESC") + .limit(limit) + .getRawMany<{ label: string; count: string }>(); + + return rows.map((row) => ({ label: row.label, count: Number(row.count) })); + } + + async getContainersBySize(): Promise<{ label: string; count: number }[]> { + const rows = await this.containerRepository + .createQueryBuilder("container") + .leftJoin( + ContainerType, + "container_type", + "container_type.id = container.container_type_id", + ) + .select( + `COALESCE(container_type.size_ft::text || ' ft', container_type.code, 'Unknown')`, + "label", + ) + .addSelect("COUNT(*)::int", "count") + .where("container.deleted_at IS NULL") + .groupBy("container_type.size_ft") + .addGroupBy("container_type.code") + .orderBy("count", "DESC") + .getRawMany<{ label: string; count: string }>(); + + return rows.map((row) => ({ label: row.label, count: Number(row.count) })); + } + + /** Total cargo weight (tons) grouped by cargo type, heaviest first. */ + async getCargoTonnageByType(limit: number): Promise< + { label: string; tons: number }[] + > { + const rows = await this.cargoRepository + .createQueryBuilder("cargo") + .leftJoin(CargoType, "cargo_type", "cargo_type.id = cargo.cargo_type_id") + .select(`COALESCE(cargo_type.cargo_type_name, 'Other')`, "label") + .addSelect(`ROUND(COALESCE(SUM(cargo.weight), 0) / 1000, 1)`, "tons") + .where("cargo.deleted_at IS NULL") + .groupBy("cargo_type.cargo_type_name") + .orderBy("tons", "DESC") + .limit(limit) + .getRawMany<{ label: string; tons: string }>(); + + return rows + .map((row) => ({ label: row.label, tons: Number(row.tons) })) + .filter((row) => row.tons > 0); + } + private async statusBreakdown( repository: Repository, alias: string, diff --git a/apps/edr-freight-api/src/modules/overview/overview.service.ts b/apps/edr-freight-api/src/modules/overview/overview.service.ts index 4b1f5d6bf..399bf4f72 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.service.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.service.ts @@ -202,15 +202,31 @@ export class OverviewService { }; } - async getOperationsTab(): Promise { + async getOperationsTab( + range: OverviewRangeQuery = '30d', + ): Promise { + const days = OVERVIEW_RANGE_DAYS[range]; + const [ kpis, + departureTrend, + scheduleStatusBreakdown, + wagonsByType, + wagonsByYard, + containersBySize, + cargoTonnageByType, trainStatusBreakdown, wagonStatusBreakdown, containerStatusBreakdown, cargoStatusBreakdown, ] = await Promise.all([ this.overviewRepository.getOperationsKpis(), + this.overviewRepository.getDepartureTrend(days), + this.overviewRepository.getScheduleStatusBreakdown(), + this.overviewRepository.getWagonsByType(), + this.overviewRepository.getWagonsByYard(8), + this.overviewRepository.getContainersBySize(), + this.overviewRepository.getCargoTonnageByType(8), this.overviewRepository.getTrainStatusBreakdown(), this.overviewRepository.getWagonStatusBreakdown(), this.overviewRepository.getContainerStatusBreakdown(), @@ -219,6 +235,12 @@ export class OverviewService { return { kpis, + departureTrend, + scheduleStatusBreakdown, + wagonsByType, + wagonsByYard, + containersBySize, + cargoTonnageByType, trainStatusBreakdown, wagonStatusBreakdown, containerStatusBreakdown, diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index 0581f6000..5730120e9 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -161,6 +161,15 @@ export class TrainSchedule extends BaseEntity { @Column({ name: 'rule_payment_window_minutes', type: 'int', nullable: true }) rulePaymentWindowMinutes?: number | null; + /** + * Staff configured this schedule's booking window by hand (at creation or via + * the per-schedule override) instead of inheriting the live global rules. + * `restampPendingWindows` skips these, so a later global-rules edit cannot + * silently overwrite the hand-picked settings. + */ + @Column({ name: 'window_rule_custom', type: 'boolean', default: false }) + windowRuleCustom!: boolean; + @Column({ name: 'rule_import_window_lead_days', type: 'int', nullable: true }) ruleImportWindowLeadDays?: number | null; 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 44f7c93b5..3e1af07b5 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 @@ -151,6 +151,14 @@ export interface ExportTrainOption { }>; } +/** A train a paid-unallocated booking can board (route + capacity verified). */ +export interface AllocationCandidate { + id: string; + reference: string | null; + direction: string | null; + scheduledDepartureDate: Date; +} + /** A day-level pool key: all trains on this route departing on this EAT day. */ interface RouteDayGroup { originYardId: string; @@ -3040,6 +3048,104 @@ export class BookingBatchService implements OnModuleInit { this.notifyBoardChanged(newScheduleId, "booking_moved"); } + /** + * Trains a paid-but-unallocated booking can board right now: OPEN window, + * future departure, route covers the booking's leg, and remaining corridor + * capacity fits it. Split by the booking's own scheduled day so the UI can + * offer one-click same-day allocation vs an explicit "another date" choice. + */ + async allocationCandidates(bookingId: string): Promise<{ + sameDay: AllocationCandidate[]; + otherDays: AllocationCandidate[]; + }> { + const booking = await this.dataSource.getRepository(Booking).findOne({ + where: { id: bookingId }, + relations: { + bookingContainers: { containerType: true }, + // wagonTypes drives the break-bulk items-per-wagon fit — size the + // booking exactly as the intercity accept check does. + cargoType: { wagonTypes: true }, + }, + }); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + const schedules = await this.trainSchedulesRepository.findAll({ + where: [ + { status: TrainScheduleStatusEnum.Draft }, + { status: TrainScheduleStatusEnum.Scheduled }, + ], + }); + const today = eatDay(new Date()); + const bookingDay = booking.scheduledDate ? eatDay(booking.scheduledDate) : null; + const sameDay: AllocationCandidate[] = []; + const otherDays: AllocationCandidate[] = []; + for (const s of schedules) { + if (!s.scheduledDepartureDate || eatDay(s.scheduledDepartureDate) < today) continue; + if (s.bookingWindowStatus !== "OPEN") continue; + if (s.id === booking.trainScheduleId) continue; + const stops = await this.stopsForSchedule(s); + const fromIdx = stops.indexOf(booking.originYardId); + const toIdx = stops.indexOf(booking.destinationYardId); + if (fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx) continue; + // ponytail: full capacity build per candidate is heavy; the set is small + // (future OPEN trains on the booking's route) — precompute if it grows. + const cap = await this.intercityCapacity(s.id); + if (!cap) continue; + const leg = cap.budget.legForYards(booking.originYardId, booking.destinationYardId); + if (!cap.budget.fits(cap.needFor(booking), leg)) continue; + const candidate: AllocationCandidate = { + id: s.id, + reference: s.reference ?? s.trainNumber ?? null, + direction: s.direction ?? null, + scheduledDepartureDate: s.scheduledDepartureDate, + }; + (eatDay(s.scheduledDepartureDate) === bookingDay ? sameDay : otherDays).push(candidate); + } + const byDate = (a: AllocationCandidate, b: AllocationCandidate) => + new Date(a.scheduledDepartureDate).getTime() - new Date(b.scheduledDepartureDate).getTime(); + sameDay.sort(byDate); + otherDays.sort(byDate); + return { sameDay, otherDays }; + } + + /** + * Place a PAID booking that lost (or never got) its train: re-point via + * moveToSchedule (window/route validation + day sync), then allocate it + * immediately — payment already landed, so no new pay window opens. The + * customer gets an in-app notice when the new train departs on a different + * day than their original choice. + */ + async allocatePaid(bookingId: string, scheduleId: string): Promise { + const before = await this.dataSource + .getRepository(Booking) + .findOne({ where: { id: bookingId } }); + if (!before) throw new NotFoundException(`Booking ${bookingId} not found`); + if (before.paymentStatus !== "PAID" && before.status !== "PAID") { + throw new BadRequestException( + "Booking is not paid — use the regular scheduling flow", + ); + } + const previousDay = before.scheduledDate ? eatDay(before.scheduledDate) : null; + await this.moveToSchedule(bookingId, scheduleId); + const fresh = await this.dataSource.getRepository(Booking).findOne({ + where: { id: bookingId }, + relations: { bookingContainers: { containerType: true }, cargoType: true }, + }); + if (!fresh) return; + if (!(await this.holdIfWagonShort(scheduleId, fresh))) { + await this.allocate(scheduleId, fresh, "paid"); + } + const schedule = await this.dataSource + .getRepository(TrainSchedule) + .findOne({ where: { id: scheduleId } }); + if ( + previousDay && + schedule?.scheduledDepartureDate && + eatDay(schedule.scheduledDepartureDate) !== previousDay + ) { + this.notifier.allocatedOtherDay(fresh, schedule.scheduledDepartureDate); + } + } + /** * One reminder per hold, shortly before its pay deadline (the window tick * calls this every pass; `payment_reminder_sent_at` dedups). Skips paid @@ -3526,6 +3632,16 @@ export class BookingBatchService implements OnModuleInit { } return; } + // Paid but detached from any train (staff removed it from an allocation, + // or a sweep caught it unpinned): money was taken, so it must board — it + // stays paid-unallocated for staff to place via the allocate action. + if (paid) { + this.logger.log( + `[BATCH] expire skipped for ${booking.reference} — payment landed ` + + `but no train attached; left paid-unallocated for manual placement`, + ); + return; + } // Reconcile-before-expire (only when a pay window was actually open): // no webhook arrived, so ask the gateway DIRECTLY whether the money // landed. A late capture found there is registered as SUCCEEDED and @@ -3854,12 +3970,26 @@ export class BookingBatchService implements OnModuleInit { // booking can use — don't kill it for nothing. const overlaps = victimLeg.fromEdge < leg.toEdge && leg.fromEdge < victimLeg.toEdge; if (!overlaps) continue; + const victimPaid = + victim.paymentStatus === "PAID" || victim.status === "PAID"; await this.dataSource.transaction(async (manager) => { await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking( scheduleId, victim.id, manager, ); + if (victimPaid) { + // Paid bookings are never expired — money was taken, so it boards. + // Detach it so it surfaces in the paid-unallocated queue for staff + // to re-place; the settled invoice stays untouched. + await manager.getRepository(Booking).update(victim.id, { + trainScheduleId: null, + schedulingStatus: "ELIGIBLE", + paymentDeadline: null, + selectedForBatchAt: null, + } as never); + return; + } await manager.getRepository(Booking).update(victim.id, { status: "EXPIRED", schedulingStatus: "ELIGIBLE", diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index b806de5ca..84d029e4f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -275,6 +275,19 @@ export class BookingNotifierService { this.inApp(b, 'Booking rescheduled', msg); } + /** + * Staff placed a paid booking onto a train departing on a DIFFERENT day than + * the customer's original choice. In-app only — staff drove the change and + * the allocation itself already notifies through the secured path. + */ + allocatedOtherDay(b: Booking, newDeparture: Date): void { + const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE }); + const msg = + `Booking ${b.reference ?? b.id} has been allocated to a train on a different date. ` + + `New departure date: ${when}.`; + this.inApp(b, 'Booking allocated to another date', msg); + } + /** * Booking was removed from its train during a staff reschedule (not a government * pre-empt). It returns to eligible — the customer must rebook or reschedule. diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts index 9dfea0781..e8716eb99 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts @@ -9,9 +9,107 @@ import { IsNumber, IsOptional, IsUUID, + Max, Min, + ValidateNested, } from 'class-validator'; +/** + * Per-schedule booking-window rule chosen AT CREATION, instead of inheriting the + * live global rules. Mirrors {@link UpdateScheduleWindowRuleDto}, plus the + * booking-close offset (which the post-creation override deliberately never + * touches). Every field is optional — an omitted field falls back to the global + * value, so staff can override just the one knob they care about. + */ +export class CreateScheduleWindowRuleDto { + @ApiPropertyOptional({ example: 8, description: 'Local EAT hour the booking desk opens each day' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + @Max(23) + windowOpenHour?: number; + + @ApiPropertyOptional({ + example: 17, + description: + 'Local EAT hour the booking desk shuts each day. Equal to windowOpenHour = 24-hour desk', + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + @Max(23) + windowCloseHour?: number; + + @ApiPropertyOptional({ example: 3, description: 'How long each booking cycle stays open, in hours' }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0.0166) + @Max(12) + windowDurationHours?: number; + + @ApiPropertyOptional({ example: 30, description: 'Max staff document-review minutes after the window closes' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + docReviewMinutes?: number; + + @ApiPropertyOptional({ example: 60, description: 'Customer payment window minutes' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + paymentWindowMinutes?: number; + + @ApiPropertyOptional({ + example: 3, + description: 'Days before departure the IMPORT/DOMESTIC booking window starts', + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + importWindowLeadDays?: number; + + @ApiPropertyOptional({ + example: 24, + description: 'Hours before departure the single FCFS EXPORT window opens', + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + exportBookingLeadHours?: number; + + @ApiPropertyOptional({ + example: 180, + nullable: true, + description: + 'Minutes before departure the booking window closes; 0/null = close at departure. ' + + 'Only the offset matching the schedule direction is used (import offset for ' + + 'IMPORT/DOMESTIC, export offset for EXPORT).', + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + importCloseOffsetMinutes?: number | null; + + @ApiPropertyOptional({ + example: 1440, + nullable: true, + description: 'Minutes before departure an EXPORT booking window closes; 0/null = at departure', + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + exportCloseOffsetMinutes?: number | null; +} + export class CreateContainerTrainScheduleDto { @ApiProperty({ format: 'uuid' }) @IsUUID() @@ -73,4 +171,19 @@ export class CreateContainerTrainScheduleDto { @IsOptional() @IsBoolean() reverseWagonOrder?: boolean; + + @ApiPropertyOptional({ + type: CreateScheduleWindowRuleDto, + description: + 'Configure the booking window for THIS schedule instead of inheriting the live ' + + 'global rules. Omit to use the global rules (the default). The values sent are ' + + 'frozen onto the schedule as its rule snapshot, exactly as a post-creation ' + + 'override would. Rejected for an IMPORT/DOMESTIC train that joins an existing ' + + 'route+day group — those siblings share one window timeline, so edit the group ' + + "window instead of giving one member its own.", + }) + @IsOptional() + @ValidateNested() + @Type(() => CreateScheduleWindowRuleDto) + windowRule?: CreateScheduleWindowRuleDto; } 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 485a3ceea..95c6aa6f2 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 @@ -842,6 +842,32 @@ export class TrainSchedulingController { return { ok: true }; } + @Get("bookings/:bookingId/allocation-candidates") + @TrainSchedulingView() + @ApiOperation({ + summary: + "Trains a paid-unallocated booking fits, split same-day vs other days", + }) + getAllocationCandidates( + @Param("bookingId", ParseUUIDPipe) bookingId: string, + ) { + return this.bookingBatchService.allocationCandidates(bookingId); + } + + @Post("bookings/:bookingId/allocate") + @TrainSchedulingUpdate() + @ApiOperation({ + summary: + "Staff: place a paid booking onto a fitting train (notifies customer on date change)", + }) + async allocatePaidBooking( + @Param("bookingId", ParseUUIDPipe) bookingId: string, + @Body("trainScheduleId", ParseUUIDPipe) trainScheduleId: string, + ) { + await this.bookingBatchService.allocatePaid(bookingId, trainScheduleId); + return { ok: true }; + } + @Get("schedules/:id/checkpoints") @TrainSchedulingView() @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index 406f51988..6a47f8ac8 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -805,6 +805,51 @@ describe('TrainSchedulingService', () => { ).rejects.toBeInstanceOf(BadRequestException); }); + describe('restampPendingWindows (hand-configured windows are exempt)', () => { + const future = new Date(Date.now() + 30 * 24 * 3600_000); + const update = jest.fn(); + + beforeEach(() => { + update.mockClear(); + // Global rules read + the TrainSchedule repo the restamp writes through. + dataSource.getRepository.mockImplementation((entity: unknown) => { + const name = (entity as { name?: string })?.name; + if (name === 'TrainSchedulingGlobalRules') { + return { find: jest.fn().mockResolvedValue([]) }; + } + return { update }; + }); + }); + + it('re-stamps a schedule that follows the global rules', async () => { + trainSchedulesRepository.findAll.mockResolvedValue([ + { + id: 'sched-global', + direction: 'IMPORT', + scheduledDepartureDate: future, + windowRuleCustom: false, + }, + ]); + await expect(service.restampPendingWindows()).resolves.toBe(1); + expect(update).toHaveBeenCalledWith('sched-global', expect.anything()); + }); + + it('leaves a hand-configured schedule alone', async () => { + trainSchedulesRepository.findAll.mockResolvedValue([ + { + id: 'sched-custom', + direction: 'IMPORT', + scheduledDepartureDate: future, + windowRuleCustom: true, + }, + ]); + // Staff picked these times deliberately — a global-rules edit must not + // overwrite them, or the per-schedule configuration would be pointless. + await expect(service.restampPendingWindows()).resolves.toBe(0); + expect(update).not.toHaveBeenCalled(); + }); + }); + describe('getUnassignedBookings', () => { const scheduleId = 'sched-unassigned-1'; const trainSetId = 'train-set-unassigned'; 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 db8236927..f8dfcd6d0 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 @@ -181,6 +181,13 @@ import { const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const; +/** Drops the keys a partial override left undefined, so `...` merges keep the base value. */ +function pickDefined(source: T): Partial { + return Object.fromEntries( + Object.entries(source).filter(([, v]) => v !== undefined), + ) as Partial; +} + /** * The booking-window rule fields frozen onto a train schedule at creation (and * refreshed by restampPendingWindows for not-yet-open schedules). The board draws @@ -906,6 +913,9 @@ export class TrainSchedulingService { windowClosesAt: cap(times.windowClosesAt, t.departure), ...ruleFields, rulePaymentWindowMinutes, + // Deliberately overridden — exempt from the global re-stamp, which would + // otherwise revert this schedule the next time global rules are saved. + windowRuleCustom: true, }); } this.logger.log( @@ -1197,6 +1207,9 @@ export class TrainSchedulingService { let restamped = 0; for (const s of schedules) { if (!s.scheduledDepartureDate || s.scheduledDepartureDate <= now) continue; + // Hand-configured windows are not "pending the global rule" — staff picked + // these times deliberately, so a global-rules edit must leave them alone. + if (s.windowRuleCustom) continue; const times = s.direction === 'EXPORT' ? computeExportWindowTimes(s.scheduledDepartureDate, cfg) @@ -1460,29 +1473,8 @@ export class TrainSchedulingService { // it on schedule. DOMESTIC runs the same one-booking-day cycle as IMPORT // (opens at 08:00 EAT `importWindowLeadDays` before departure); EXPORT opens // 24h before departure (FCFS). No schedule is ever always-open now. - const windowCfg = await this.getWindowConfig(); + const globalCfg = await this.getWindowConfig(); - // Staff cannot schedule inside the lead window — there must be room for a - // booking window before departure. IMPORT/DOMESTIC lead is in whole EAT - // days (lead 3, today 11th → first allowed departure is the 14th); EXPORT - // lead is in hours (24h = 1 day ahead). - const earliest = earliestSchedulableDeparture(direction, windowCfg, new Date()); - if (departure.getTime() < earliest.getTime()) { - const detail = - direction === 'EXPORT' - ? `at least ${windowCfg.exportBookingLeadHours} hour(s) ahead` - : `at least ${windowCfg.importWindowLeadDays} day(s) ahead`; - throw new BadRequestException( - `Departure ${departure.toISOString()} is inside the booking lead window; ` + - `${direction === 'EXPORT' ? 'export' : 'import'} trains must be scheduled ${detail} ` + - `(earliest ${earliest.toISOString()})`, - ); - } - // Freeze the rule this schedule is born with. A later global-rules edit - // only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an - // already-open schedule keeps this snapshot, and the batch board draws its - // windows from it rather than the live config. - const ruleSnapshot = windowRuleSnapshot(windowCfg); // Route+day grouping (IMPORT/DOMESTIC only): if a schedule already exists // on this origin + destination + EAT departure day, this new train JOINS // its group and adopts the group's shared window timeline (open/close + @@ -1506,6 +1498,77 @@ export class TrainSchedulingService { route.destinationYardId, departure, ); + + // Per-schedule window rule chosen at creation. Refused for a train that + // JOINS an existing route+day group: the group shares ONE window timeline, + // so a joining train adopts the anchor's times verbatim and its own + // settings would be silently discarded. Staff edit the group's window + // instead (Booking window settings, which fans out to every sibling). + if (dto.windowRule && groupAnchor) { + throw new BadRequestException( + 'This train joins an existing booking group (same route and departure day), ' + + 'which shares one booking window across all its trains. Create it with the ' + + 'group settings, then use Booking window settings to change the window for ' + + 'the whole group.', + ); + } + + // The rule this schedule is born under: staff overrides on top of the live + // global config, so an omitted field still follows the global value. + const windowCfg: BookingWindowConfig = dto.windowRule + ? { + ...globalCfg, + ...pickDefined({ + windowOpenHour: dto.windowRule.windowOpenHour, + windowCloseHour: dto.windowRule.windowCloseHour, + windowDurationHours: dto.windowRule.windowDurationHours, + docReviewMinutes: dto.windowRule.docReviewMinutes, + importWindowLeadDays: dto.windowRule.importWindowLeadDays, + exportBookingLeadHours: dto.windowRule.exportBookingLeadHours, + }), + // One pay-window override drives both directions (only the one + // matching this schedule's direction is ever read). + ...(dto.windowRule.paymentWindowMinutes !== undefined + ? { + paymentWindowMinutes: dto.windowRule.paymentWindowMinutes, + exportPaymentWindowMinutes: dto.windowRule.paymentWindowMinutes, + } + : {}), + // Close offsets are nullable-by-intent: null/0 means "close at + // departure", which must override a non-null global, so these are + // merged on presence rather than on definedness. + ...(dto.windowRule.importCloseOffsetMinutes !== undefined + ? { importCloseOffsetMinutes: dto.windowRule.importCloseOffsetMinutes ?? null } + : {}), + ...(dto.windowRule.exportCloseOffsetMinutes !== undefined + ? { exportCloseOffsetMinutes: dto.windowRule.exportCloseOffsetMinutes ?? null } + : {}), + } + : globalCfg; + + // Staff cannot schedule inside the lead window — there must be room for a + // booking window before departure. IMPORT/DOMESTIC lead is in whole EAT + // days (lead 3, today 11th → first allowed departure is the 14th); EXPORT + // lead is in hours (24h = 1 day ahead). Checked against the schedule's OWN + // lead, so a custom lead is honoured rather than rejected by the global one. + const earliest = earliestSchedulableDeparture(direction, windowCfg, new Date()); + if (departure.getTime() < earliest.getTime()) { + const detail = + direction === 'EXPORT' + ? `at least ${windowCfg.exportBookingLeadHours} hour(s) ahead` + : `at least ${windowCfg.importWindowLeadDays} day(s) ahead`; + throw new BadRequestException( + `Departure ${departure.toISOString()} is inside the booking lead window; ` + + `${direction === 'EXPORT' ? 'export' : 'import'} trains must be scheduled ${detail} ` + + `(earliest ${earliest.toISOString()})`, + ); + } + + // Freeze the rule this schedule is born with. A later global-rules edit + // only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an + // already-open schedule keeps this snapshot, and the batch board draws its + // windows from it rather than the live config. + const ruleSnapshot = windowRuleSnapshot(windowCfg); const computedTimes = direction === 'EXPORT' ? { ...ruleSnapshot, ...computeExportWindowTimes(departure, windowCfg) } @@ -1514,12 +1577,30 @@ export class TrainSchedulingService { ...ruleSnapshot, ...computeImportWindowTimes(departure, windowCfg, new Date()), }; + if ( + computedTimes.windowOpensAt.getTime() >= computedTimes.windowClosesAt.getTime() + ) { + throw new BadRequestException( + 'These booking-window settings leave no window before departure — with the ' + + 'desk hours and close offset applied, the window would only open once the ' + + 'train has left.', + ); + } const windowFields = { bookingWindowStatus: 'CLOSED', windowPhase: 'PRE_WINDOW', ...(groupAnchor ? this.groupWindowFieldsFrom(groupAnchor, departure) : computedTimes), + // `windowRuleSnapshot` never stamps the pay window (NULL = follow the + // live global value for the direction), so an explicit staff override is + // persisted here — the same field the post-creation override writes. + ...(dto.windowRule?.paymentWindowMinutes !== undefined + ? { rulePaymentWindowMinutes: dto.windowRule.paymentWindowMinutes } + : {}), + // Hand-configured windows opt OUT of the global re-stamp, or the next + // global-rules edit would overwrite exactly what staff chose here. + windowRuleCustom: dto.windowRule != null, }; // A built train's own consist is the schedule's capacity: full when all // its wagons are allocated. Trains built without wagons yet fall back to diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewStackedBarChart.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewStackedBarChart.tsx new file mode 100644 index 000000000..be913f5f0 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewStackedBarChart.tsx @@ -0,0 +1,82 @@ +import { + Bar, + BarChart, + CartesianGrid, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { Paper, Stack, Text } from "@mantine/core"; + +export interface StackedBarSeries { + /** Key into each data row holding this series' value. */ + key: string; + label: string; + color: string; +} + +interface OverviewStackedBarChartProps { + title: string; + data: T[]; + /** Fixed order + fixed color per series — colors follow the entity, not the rank. */ + series: StackedBarSeries[]; + xKey?: string; + emptyMessage?: string; + formatXLabel?: (value: string) => string; +} + +export function OverviewStackedBarChart({ + title, + data, + series, + xKey = "date", + emptyMessage = "No data available", + formatXLabel, +}: OverviewStackedBarChartProps) { + const hasData = data.some((row) => + series.some((s) => Number((row as Record)[s.key]) > 0), + ); + + return ( + + + {title} + {!hasData ? ( + + {emptyMessage} + + ) : ( + + + + + + formatXLabel(String(v)))} /> + + {series.map((s, index) => ( + + ))} + + + )} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx index 13a357cce..7452e36cf 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx @@ -36,7 +36,7 @@ export function OverviewTabContent({ tab, range }: OverviewTabContentProps) { const bookings = useOverviewBookingsTab(range, tab === "bookings"); const contracts = useOverviewContractsTab(range, tab === "contracts"); const billing = useOverviewBillingTab(range, tab === "billing"); - const operations = useOverviewOperationsTab(tab === "operations"); + const operations = useOverviewOperationsTab(range, tab === "operations"); const customers = useOverviewCustomersTab(range, tab === "customers"); const staff = useOverviewStaffTab(range, tab === "staff"); diff --git a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBookingsTabPanel.tsx b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBookingsTabPanel.tsx index c294a5199..9dce6cc9e 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBookingsTabPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBookingsTabPanel.tsx @@ -71,7 +71,7 @@ export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps - + ({ @@ -81,10 +81,20 @@ export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps emptyMessage="No bookings yet" /> - - + ({ + label: item.label, + value: item.count, + }))} + valueLabel="Bookings" + /> + + + ({ name: item.label, value: item.count, }))} @@ -92,14 +102,6 @@ export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps - ({ - label: item.label, - value: item.count, - }))} - /> - ); diff --git a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewContractsTabPanel.tsx b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewContractsTabPanel.tsx index 9786b11e2..e35600df9 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewContractsTabPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewContractsTabPanel.tsx @@ -94,7 +94,7 @@ export function OverviewContractsTabPanel({ - + ({ @@ -104,7 +104,7 @@ export function OverviewContractsTabPanel({ emptyMessage="No contracts yet" /> - + ({ @@ -113,16 +113,17 @@ export function OverviewContractsTabPanel({ }))} /> + + ({ + name: item.label === "CONTAINER" ? "Container" : "Bulk", + value: item.count, + }))} + /> + - ({ - label: item.label === "CONTAINER" ? "Container" : "Bulk", - value: item.count, - }))} - /> - ); diff --git a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewOperationsTabPanel.tsx b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewOperationsTabPanel.tsx index 355fb6b57..0fcdef9e6 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewOperationsTabPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewOperationsTabPanel.tsx @@ -1,13 +1,25 @@ -import { Box, Container as ContainerIcon, Train, Truck } from "lucide-react"; +import { + Box, + CalendarClock, + Container as ContainerIcon, + Send, + Train, + Truck, +} from "lucide-react"; import { Grid, Stack } from "@mantine/core"; import type { IOverviewOperationsTab } from "@/types/overview"; import { OverviewDonutChart } from "../OverviewDonutChart"; +import { OverviewHorizontalBarChart } from "../OverviewHorizontalBarChart"; import { OverviewKpiStrip } from "../OverviewKpiStrip"; +import { OverviewStackedBarChart } from "../OverviewStackedBarChart"; -interface OverviewOperationsTabPanelProps { - data: IOverviewOperationsTab; -} +/** Fixed direction colors (CVD-validated pair + violet): color follows the entity. */ +const DIRECTION_SERIES = [ + { key: "exportCount", label: "Export", color: "#D98A0B" }, + { key: "importCount", label: "Import", color: "#0369a1" }, + { key: "domesticCount", label: "Domestic", color: "#7c3aed" }, +]; function formatStatusLabel(status: string) { return status @@ -16,6 +28,22 @@ function formatStatusLabel(status: string) { .replace(/\b\w/g, (char) => char.toUpperCase()); } +function formatDateLabel(date: string) { + const parsed = new Date(`${date}T00:00:00`); + return parsed.toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +function toDonutData(items: { status: string; count: number }[]) { + return items.map((item) => ({ + name: formatStatusLabel(item.status), + value: item.count, + })); +} + +interface OverviewOperationsTabPanelProps { + data: IOverviewOperationsTab; +} + export function OverviewOperationsTabPanel({ data }: OverviewOperationsTabPanelProps) { return ( @@ -27,6 +55,19 @@ export function OverviewOperationsTabPanel({ data }: OverviewOperationsTabPanelP icon: Train, accent: "emerald", }, + { + label: "Upcoming departures", + value: data.kpis.schedulesUpcoming, + icon: CalendarClock, + accent: "sky", + hint: "Scheduled, not yet departed", + }, + { + label: "Dispatched today", + value: data.kpis.dispatchedToday, + icon: Send, + accent: "amber", + }, { label: "Wagons available", value: data.kpis.wagonsAvailable, @@ -45,41 +86,95 @@ export function OverviewOperationsTabPanel({ data }: OverviewOperationsTabPanelP ]} /> + + + + + + + + + + + + ({ + label: item.label, + value: item.count, + }))} + valueLabel="Wagons" + /> + + + ({ + label: item.label, + value: item.count, + }))} + valueLabel="Wagons" + emptyMessage="No wagons assigned to yards" + /> + + + + + + ({ + label: item.label, + value: item.tons, + }))} + valueLabel="Tons" + emptyMessage="No cargo recorded" + /> + + + ({ + name: item.label, + value: item.count, + }))} + /> + + + ({ - name: formatStatusLabel(item.status), - value: item.count, - }))} + data={toDonutData(data.trainStatusBreakdown)} /> ({ - name: formatStatusLabel(item.status), - value: item.count, - }))} + data={toDonutData(data.wagonStatusBreakdown)} /> ({ - name: formatStatusLabel(item.status), - value: item.count, - }))} + data={toDonutData(data.containerStatusBreakdown)} /> ({ - name: formatStatusLabel(item.status), - value: item.count, - }))} + data={toDonutData(data.cargoStatusBreakdown)} /> diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/CreateScheduleWindowFields.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/CreateScheduleWindowFields.tsx new file mode 100644 index 000000000..b0de39baa --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/CreateScheduleWindowFields.tsx @@ -0,0 +1,366 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Alert, + Badge, + Box, + Divider, + Group, + Loader, + NumberInput, + Select, + Stack, + Switch, + Text, +} from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { Info, Moon, Sun } from "lucide-react"; + +import DurationField from "@/components/trainScheduling/DurationField"; +import { trainSchedulingService } from "@/services/trainScheduling.service"; +import type { CreateScheduleWindowRulePayload } from "@/types/trainScheduling"; + +/** Fallbacks matching the API's global-rules defaults (used if the fetch fails). */ +const DEFAULTS = { + windowOpenHour: 8, + windowCloseHour: 17, + windowDurationHours: 3, + docReviewMinutes: 30, + paymentWindowMinutes: 60, + importWindowLeadDays: 3, + exportBookingLeadHours: 24, +}; + +/** 12-hour label for an EAT hour 0–23, e.g. 8 → "8:00 AM", 17 → "5:00 PM". */ +function hourLabel(hour: number): string { + const period = hour < 12 ? "AM" : "PM"; + const h12 = hour % 12 === 0 ? 12 : hour % 12; + return `${h12}:00 ${period}`; +} + +const HOUR_OPTIONS = Array.from({ length: 24 }, (_, h) => ({ + value: String(h), + label: `${hourLabel(h)} · ${String(h).padStart(2, "0")}:00`, +})); + +export interface WindowFormState { + windowOpenHour: number; + windowCloseHour: number; + windowDurationHours: number | ""; + docReviewMinutes: number | ""; + paymentWindowMinutes: number | ""; + importWindowLeadDays: number | ""; + exportBookingLeadHours: number | ""; + /** Blank = close exactly at departure. */ + closeOffsetMinutes: number | ""; +} + +/** + * Builds the create payload from form state, or returns an error message when a + * required field was left blank. The close offset is direction-scoped: only the + * offset matching this schedule's direction is sent, since the other is never read. + */ +export function buildWindowRulePayload( + form: WindowFormState, + isExport: boolean, +): { payload: CreateScheduleWindowRulePayload } | { error: string } { + const duration = Number(form.windowDurationHours); + const doc = Number(form.docReviewMinutes); + const pay = Number(form.paymentWindowMinutes); + const lead = Number(form.importWindowLeadDays); + const exportLead = Number(form.exportBookingLeadHours); + + const leadInvalid = isExport + ? form.exportBookingLeadHours === "" || !Number.isFinite(exportLead) || exportLead < 1 + : form.importWindowLeadDays === "" || !Number.isFinite(lead); + if ( + form.windowDurationHours === "" || + form.docReviewMinutes === "" || + form.paymentWindowMinutes === "" || + !Number.isFinite(duration) || + !Number.isFinite(doc) || + !Number.isFinite(pay) || + leadInvalid + ) { + return { error: "Fill every booking-window field, or turn the toggle off" }; + } + + // Blank offset = close at departure. Sent as null (not omitted) so it wins + // over a non-null global offset. + const offset = form.closeOffsetMinutes === "" ? null : Number(form.closeOffsetMinutes); + + return { + payload: { + windowOpenHour: form.windowOpenHour, + windowCloseHour: form.windowCloseHour, + windowDurationHours: duration, + docReviewMinutes: doc, + paymentWindowMinutes: pay, + ...(isExport + ? { exportBookingLeadHours: exportLead, exportCloseOffsetMinutes: offset } + : { importWindowLeadDays: lead, importCloseOffsetMinutes: offset }), + }, + }; +} + +export interface CreateScheduleWindowFieldsProps { + /** Direction of the selected route — picks lead/offset semantics. */ + isExport: boolean; + form: WindowFormState | null; + onChange: (next: WindowFormState) => void; +} + +/** + * Booking-window settings for a schedule being created. Prefills from the live + * global rules (so the fields show what the schedule WOULD inherit), then lets + * staff tune them for this one train. Mirrors BookingWindowSettingsModal, plus + * the booking-close offset. + */ +export default function CreateScheduleWindowFields({ + isExport, + form, + onChange, +}: CreateScheduleWindowFieldsProps) { + const rulesQuery = useQuery({ + queryKey: ["train-scheduling", "global-rules"], + queryFn: () => trainSchedulingService.getGlobalRules(), + staleTime: 5 * 60_000, + }); + + // Seed once from the global rules, so the toggle opens on the values this + // schedule would otherwise inherit rather than on hardcoded guesses. + const [seeded, setSeeded] = useState(false); + useEffect(() => { + if (seeded || form != null) return; + const r = rulesQuery.data; + if (!r && rulesQuery.isLoading) return; + const num = (v: unknown, fallback: number) => { + const n = v == null || v === "" ? NaN : Number(v); + return Number.isFinite(n) ? n : fallback; + }; + const offset = isExport + ? (r as { exportCloseOffsetMinutes?: number | null } | undefined) + ?.exportCloseOffsetMinutes + : (r as { importCloseOffsetMinutes?: number | null } | undefined) + ?.importCloseOffsetMinutes; + onChange({ + windowOpenHour: num(r?.windowOpenHour, DEFAULTS.windowOpenHour), + windowCloseHour: num(r?.windowCloseHour, DEFAULTS.windowCloseHour), + windowDurationHours: num(r?.windowDurationHours, DEFAULTS.windowDurationHours), + docReviewMinutes: num(r?.docReviewMinutes, DEFAULTS.docReviewMinutes), + paymentWindowMinutes: num( + isExport + ? (r as { exportPaymentWindowMinutes?: number } | undefined) + ?.exportPaymentWindowMinutes + : r?.paymentWindowMinutes, + DEFAULTS.paymentWindowMinutes, + ), + importWindowLeadDays: num(r?.importWindowLeadDays, DEFAULTS.importWindowLeadDays), + exportBookingLeadHours: num( + r?.exportBookingLeadHours, + DEFAULTS.exportBookingLeadHours, + ), + closeOffsetMinutes: offset == null || offset === 0 ? "" : Number(offset), + }); + setSeeded(true); + }, [seeded, form, rulesQuery.data, rulesQuery.isLoading, isExport, onChange]); + + const set = (patch: Partial) => { + if (form) onChange({ ...form, ...patch }); + }; + + const is24h = form != null && form.windowOpenHour === form.windowCloseHour; + // Close < open is a valid OVERNIGHT desk (e.g. 08:00 → 07:00 next morning). + const isOvernight = form != null && form.windowCloseHour < form.windowOpenHour; + + const reopenSummary = useMemo(() => { + if (!form) return ""; + const total = (Number(form.docReviewMinutes) || 0) + (Number(form.paymentWindowMinutes) || 0); + const h = Math.floor(total / 60); + const m = total % 60; + const parts = [h ? `${h}h` : "", m ? `${m}m` : ""].filter(Boolean); + return parts.length ? parts.join(" ") : "0m"; + }, [form]); + + if (!form) { + return ( + + + + ); + } + + return ( + + {isExport ? ( + }> + Export schedules use a single first-come-first-served window: it opens the + export lead time before departure — shifted to the next desk opening if that + lands outside desk hours — and stays open until it closes. Cycle timing below + doesn't apply. + + ) : ( + }> + These settings apply to this train only, and can be set only for the FIRST + train on a route and departure day. Later trains that day join its booking + group and share the same window. + + )} + + {/* ── Daily desk hours ─────────────────────────────────────────── */} + + + + Daily desk hours (EAT) + + {is24h ? ( + }> + 24-hour desk + + ) : ( + }> + {hourLabel(form.windowOpenHour)} – {hourLabel(form.windowCloseHour)} + + )} + + + v != null && set({ windowCloseHour: Number(v) })} + allowDeselect={false} + comboboxProps={{ withinPortal: true }} + /> + + {isOvernight && !is24h ? ( + + Overnight desk — opens {form.windowOpenHour}:00 and runs past midnight, + closing {form.windowCloseHour}:00 the next morning. + + ) : null} + + set({ + // On → close == open (24h desk). Off → restore a normal ~9h day. + windowCloseHour: e.currentTarget.checked + ? form.windowOpenHour + : Math.min(23, form.windowOpenHour + 9), + }) + } + /> + + + + + {/* ── Cycle timing ─────────────────────────────────────────────── */} + + + Cycle timing + + + set({ windowDurationHours: v })} + min={0.0166} + disabled={isExport} + /> + + set({ docReviewMinutes: v })} + min={0} + disabled={isExport} + /> + set({ paymentWindowMinutes: v })} + min={1} + /> + + {!isExport ? ( + + Reopen gap after each cycle = document review + payment ={" "} + {reopenSummary}. + + ) : null} + + + + + + {/* ── Lead time ────────────────────────────────────────────────── */} + {isExport ? ( + set({ exportBookingLeadHours: v === "" ? "" : Number(v) })} + min={1} + clampBehavior="none" + allowNegative={false} + allowDecimal={false} + /> + ) : ( + set({ importWindowLeadDays: v === "" ? "" : Number(v) })} + min={0} + clampBehavior="none" + allowNegative={false} + allowDecimal={false} + /> + )} + + + + {/* ── Booking close offset ─────────────────────────────────────── */} + + + Booking close offset + + + How long before departure this schedule stops accepting bookings. e.g. a + 3-hour import offset closes a 17:00 departure's window at 14:00; a 1-day + export offset closes a Jul-10 16:00 departure at Jul-9 16:00. Leave blank to + close exactly at departure. + + set({ closeOffsetMinutes: v })} + min={0} + /> + + + ); +} 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 6e95a27fc..f6df662d5 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -184,7 +184,8 @@ export const QUERY_KEYS = { ["overview", "contracts", range ?? "30d"] as const, billingTab: (range?: string) => ["overview", "billing", range ?? "30d"] as const, - operationsTab: () => ["overview", "operations"] as const, + operationsTab: (range?: string) => + ["overview", "operations", range ?? "30d"] as const, customersTab: (range?: string) => ["overview", "customers", range ?? "30d"] as const, staffTab: (range?: string) => diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index f7f1f9cbc..9e343017e 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -343,6 +343,10 @@ export const URL_CONSTANTS = { `/train-scheduling/bookings/${bookingId}/expire`, MOVE_BOOKING_SCHEDULE: (bookingId: string) => `/train-scheduling/bookings/${bookingId}/move-schedule`, + ALLOCATION_CANDIDATES: (bookingId: string) => + `/train-scheduling/bookings/${bookingId}/allocation-candidates`, + ALLOCATE_BOOKING: (bookingId: string) => + `/train-scheduling/bookings/${bookingId}/allocate`, GLOBAL_RULES: "/train-scheduling/global-rules", BOOKING_WINDOWS: "/train-scheduling/booking-windows", PREVIEW: "/train-scheduling/preview", diff --git a/apps/edr-freight-web/backoffice/src/hooks/useOverview.ts b/apps/edr-freight-web/backoffice/src/hooks/useOverview.ts index 023d3d9f1..f46fe865f 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useOverview.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useOverview.ts @@ -35,10 +35,10 @@ export function useOverviewBillingTab(range: OverviewRange, enabled: boolean) { }); } -export function useOverviewOperationsTab(enabled: boolean) { +export function useOverviewOperationsTab(range: OverviewRange, enabled: boolean) { return useQuery({ - queryKey: QUERY_KEYS.OVERVIEW.operationsTab(), - queryFn: () => overviewService.getOperationsTab(), + queryKey: QUERY_KEYS.OVERVIEW.operationsTab(range), + queryFn: () => overviewService.getOperationsTab(range), enabled, }); } 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 0f8cc87ea..91eb52033 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -4,7 +4,9 @@ import { Box, Button, Card, + Checkbox, Group, + Modal, MultiSelect, Select, Stack, @@ -50,7 +52,10 @@ import { import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink"; import { api } from "@/services/api"; import type { BookingListFilter } from "@/services/bookings.service"; +import { trainSchedulingService } from "@/services/trainScheduling.service"; +import type { AllocationCandidate } from "@/types/trainScheduling"; import type { BookingListRow } from "@/types/booking"; +import { useToast } from "@/hooks/use-toast"; import { Badge, DataTable, @@ -155,6 +160,15 @@ export default function BookingRequestsPage() { const [scheduledTo, setScheduledTo] = useState(null); const [allocateOpen, setAllocateOpen] = useState(false); const [allocateIds, setAllocateIds] = useState([]); + // Paid bookings with no train attached (staff removed them or a sweep + // detached them) — the queue the per-row Allocate action works through. + const [paidUnallocated, setPaidUnallocated] = useState(false); + const [allocatingId, setAllocatingId] = useState(null); + const [otherDayModal, setOtherDayModal] = useState<{ + booking: BookingListRow; + candidates: AllocationCandidate[]; + } | null>(null); + const { toast } = useToast(); const suppressRowClickRef = useRef(false); const suppressRowClick = useCallback(() => { suppressRowClickRef.current = true; @@ -187,6 +201,10 @@ export default function BookingRequestsPage() { ...(directionFilter ? { tradeDirection: directionFilter } : {}), ...(freightTypeFilter ? { freightType: freightTypeFilter } : {}), ...(paymentStatusFilter ? { paymentStatus: paymentStatusFilter } : {}), + // Wins over the payment-status select — the queue is by definition PAID. + ...(paidUnallocated + ? { paymentStatus: "PAID", assignedToSchedule: "false" as const } + : {}), ...(ownershipFilter ? { isGovernment: ownershipFilter as "true" | "false" } : {}), @@ -208,6 +226,7 @@ export default function BookingRequestsPage() { directionFilter, freightTypeFilter, paymentStatusFilter, + paidUnallocated, ownershipFilter, originYardFilter, destinationYardFilter, @@ -251,6 +270,7 @@ export default function BookingRequestsPage() { (directionFilter ? 1 : 0) + (freightTypeFilter ? 1 : 0) + (paymentStatusFilter ? 1 : 0) + + (paidUnallocated ? 1 : 0) + (ownershipFilter ? 1 : 0) + (originYardFilter ? 1 : 0) + (destinationYardFilter ? 1 : 0) + @@ -263,6 +283,7 @@ export default function BookingRequestsPage() { setDirectionFilter(null); setFreightTypeFilter(null); setPaymentStatusFilter(null); + setPaidUnallocated(false); setOwnershipFilter(null); setOriginYardFilter(null); setDestinationYardFilter(null); @@ -301,6 +322,67 @@ export default function BookingRequestsPage() { [navigate], ); + // One click: same-day fit → allocate straight away. No same-day fit but a + // train on another date fits → let staff pick it (customer is notified of + // the date change by the API). Nothing fits → say so. + const handleAllocatePaid = useCallback( + async (row: BookingListRow) => { + setAllocatingId(row.id); + try { + const candidates = + await trainSchedulingService.getAllocationCandidates(row.id); + if (candidates.sameDay.length > 0) { + const target = candidates.sameDay[0]; + await trainSchedulingService.allocatePaidBooking(row.id, target.id); + toast({ + title: `Allocated ${row.reference}`, + description: `Placed on ${target.reference ?? "train"} departing ${formatDate(target.scheduledDepartureDate)}.`, + }); + void refetch(); + } else if (candidates.otherDays.length > 0) { + setOtherDayModal({ booking: row, candidates: candidates.otherDays }); + } else { + toast({ + title: "No fitting train", + description: + "No open schedule covers this booking's route with enough capacity.", + variant: "destructive", + }); + } + } catch { + toast({ title: "Allocation failed", variant: "destructive" }); + } finally { + setAllocatingId(null); + } + }, + [refetch, toast], + ); + + const handleAllocateOtherDay = useCallback( + async (candidate: AllocationCandidate) => { + if (!otherDayModal) return; + const { booking } = otherDayModal; + setAllocatingId(booking.id); + try { + await trainSchedulingService.allocatePaidBooking( + booking.id, + candidate.id, + ); + toast({ + title: `Allocated ${booking.reference}`, + description: `Placed on ${candidate.reference ?? "train"} departing ${formatDate(candidate.scheduledDepartureDate)}. Customer notified of the date change.`, + }); + setOtherDayModal(null); + void refetch(); + } catch { + toast({ title: "Allocation failed", variant: "destructive" }); + } finally { + setAllocatingId(null); + } + }, + [otherDayModal, refetch, toast], + ); + const columns: ColumnDef[] = [ { id: "booking", @@ -435,13 +517,33 @@ export default function BookingRequestsPage() { { id: "actions", size: 140, - cell: ({ row }) => ( - - ), + cell: ({ row }) => { + const b = row.original; + const needsAllocation = b.paymentStatus === "PAID" && !b.trainScheduleId; + return ( + + {needsAllocation ? ( + + ) : null} + + + ); + }, }, ]; @@ -640,6 +742,16 @@ export default function BookingRequestsPage() { radius="lg" style={{ minWidth: 180 }} /> + { + setPaidUnallocated(e.currentTarget.checked); + resetPage(); + }} + radius="sm" + style={{ alignSelf: "center" }} + />