mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #642 from Tria-plc/freight_feature/usermanagement
add wagon allocation snapshot to train schedules
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Add a frozen wagon-allocation snapshot to each train schedule.
|
||||
*
|
||||
* Once a schedule leaves the editable DRAFT/SCHEDULED phase (dispatch / arrive /
|
||||
* cancel), the same physical wagons get released and re-pinned onto later trains.
|
||||
* The live wagon↔slot joins then no longer describe THIS train's plan, so an
|
||||
* admin viewing a past schedule saw a mangled or "unavailable" allocation.
|
||||
*
|
||||
* This jsonb column stores a one-shot frozen copy of the wagon plan (per-slot
|
||||
* physical wagon + booking allocations) captured at the transition. Non-editable
|
||||
* schedules render from the snapshot; DRAFT/SCHEDULED still read live. NULL on
|
||||
* legacy rows and while editable — the read path falls back to the live joins.
|
||||
*/
|
||||
export class AddScheduleWagonAllocationSnapshot2120000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "AddScheduleWagonAllocationSnapshot2120000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
ADD COLUMN IF NOT EXISTS wagon_allocation_snapshot jsonb;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
DROP COLUMN IF EXISTS wagon_allocation_snapshot;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { Contract } from '../contracts/entities/contract.entity';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { ConsolidationService } from './consolidation.service';
|
||||
import { VehiclesService } from '../vehicles/vehicles.service';
|
||||
@@ -1482,6 +1483,18 @@ export class BookingsService {
|
||||
);
|
||||
}
|
||||
|
||||
// Surface the parent contract's reference for drawdown bookings — the
|
||||
// portal detail header shows it (the entity has no contract relation, so
|
||||
// the list attaches it via a raw join and the detail attaches it here).
|
||||
if (booking.contractId) {
|
||||
const contract = await this.dataSource.getRepository(Contract).findOne({
|
||||
where: { id: booking.contractId },
|
||||
select: { reference: true },
|
||||
});
|
||||
(booking as Booking & { contractReference?: string | null }).contractReference =
|
||||
contract?.reference ?? null;
|
||||
}
|
||||
|
||||
// Surface the assigned train's operational status so the portal stepper
|
||||
// can show the Arrival stage: the booking status stays IN_TRANSIT from
|
||||
// dispatch until delivery, so arrival is only knowable from the schedule.
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { TrainScheduleStatus as TrainScheduleStatusEnum } from '@edr/types';
|
||||
import {
|
||||
TrainScheduleStatus as TrainScheduleStatusEnum,
|
||||
WagonAllocationSnapshot,
|
||||
} from '@edr/types';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm';
|
||||
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
@@ -146,6 +149,14 @@ export class TrainSchedule extends BaseEntity {
|
||||
@Column({ name: 'rule_export_booking_lead_hours', type: 'int', nullable: true })
|
||||
ruleExportBookingLeadHours?: number | null;
|
||||
|
||||
// Frozen wagon plan captured once when the schedule leaves the editable
|
||||
// DRAFT/SCHEDULED phase (dispatch / arrive / cancel). Admin views of a
|
||||
// non-editable schedule read THIS instead of the live wagon↔slot joins, so the
|
||||
// historical allocation survives the same physical wagons being re-pinned onto
|
||||
// later trains. NULL while DRAFT/SCHEDULED (read live) and on legacy rows.
|
||||
@Column({ name: 'wagon_allocation_snapshot', type: 'jsonb', nullable: true })
|
||||
wagonAllocationSnapshot?: WagonAllocationSnapshot | null;
|
||||
|
||||
@OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule)
|
||||
scheduleBookings?: TrainScheduleBooking[];
|
||||
}
|
||||
|
||||
@@ -1191,9 +1191,11 @@ export class BookingBatchService implements OnModuleInit {
|
||||
maxWagons: number | null,
|
||||
): BatchBoardSchedule["capacity"] {
|
||||
const allocated = items.filter((i) => i.state === "ALLOCATED");
|
||||
const committed = items.filter(
|
||||
(i) => i.state === "ALLOCATED" || i.state === "SELECTED_FOR_BATCH",
|
||||
);
|
||||
// Every booking still targeting this train holds gross weight — including
|
||||
// PAID ones waiting for wagon allocation (WAITING) and post-dispatch
|
||||
// catch-all states. Counting only ALLOCATED + SELECTED_FOR_BATCH zeroed the
|
||||
// board's weight the moment customers paid. Only EXPIRED released its hold.
|
||||
const committed = items.filter((i) => i.state !== "EXPIRED");
|
||||
const caps = loco
|
||||
? trainHardCaps({
|
||||
maxPullWeightTons: Number(loco.maxPullWeightTons),
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
SchedulingStatus,
|
||||
TrainCheckpointKind,
|
||||
TrainScheduleStatus as TrainScheduleStatusEnum,
|
||||
WagonAllocationSnapshot,
|
||||
WagonMovementKind,
|
||||
WagonStatus,
|
||||
} from '@edr/types';
|
||||
@@ -132,6 +133,8 @@ import {
|
||||
computeImportWindowTimes,
|
||||
earliestSchedulableDeparture,
|
||||
eatDay,
|
||||
eatDayToUtc,
|
||||
shiftEatDay,
|
||||
} from './batch-window.util';
|
||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||
import { BookingJourneyService } from './booking-journey.service';
|
||||
@@ -395,6 +398,108 @@ export class TrainSchedulingService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A schedule GROUP is every schedule sharing an origin, destination, and EAT
|
||||
* departure day — regardless of intermediate stops (ADD→DJ and ADD→DIRE→DJ
|
||||
* group together, since the route entity keys only origin + destination). All
|
||||
* schedules in a group must run ONE shared booking-window timeline so a
|
||||
* customer booking on a later-created train is never expired by a sibling
|
||||
* train's payment window closing on a different clock.
|
||||
*
|
||||
* Grouping keys off the columns the schedule already carries — no new schema.
|
||||
* Callers pass a live `manager` so both the create (inside its transaction) and
|
||||
* the update paths see uncommitted siblings.
|
||||
*/
|
||||
private async findGroupSiblings(
|
||||
manager: EntityManager,
|
||||
originStationId: string,
|
||||
destinationStationId: string,
|
||||
departure: Date,
|
||||
excludeScheduleId?: string,
|
||||
): Promise<TrainSchedule[]> {
|
||||
const day = eatDay(departure);
|
||||
const dayStart = eatDayToUtc(day, 0);
|
||||
const nextDayStart = eatDayToUtc(shiftEatDay(day, 1), 0);
|
||||
const qb = manager
|
||||
.getRepository(TrainSchedule)
|
||||
.createQueryBuilder('s')
|
||||
.where('s.originStationId = :originStationId', { originStationId })
|
||||
.andWhere('s.destinationStationId = :destinationStationId', { destinationStationId })
|
||||
.andWhere('s.scheduledDepartureDate >= :dayStart', { dayStart })
|
||||
.andWhere('s.scheduledDepartureDate < :nextDayStart', { nextDayStart });
|
||||
if (excludeScheduleId) {
|
||||
qb.andWhere('s.id != :excludeScheduleId', { excludeScheduleId });
|
||||
}
|
||||
return qb.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* The window timeline a brand-new schedule must adopt to join its route+day
|
||||
* group. Returns the canonical open/close times + rule snapshot copied from an
|
||||
* existing sibling, or null when this is the first schedule in the group (the
|
||||
* caller then computes its own times as before — nothing changes for the
|
||||
* single-schedule case).
|
||||
*
|
||||
* The anchor is preferably a still-PRE_WINDOW sibling (its times are the live
|
||||
* group clock). If every sibling has already opened, we still copy the earliest
|
||||
* sibling's frozen times so the new train lines up with the group the customer
|
||||
* already sees rather than drifting onto its own `now`-based clock.
|
||||
*/
|
||||
private async findGroupWindowAnchor(
|
||||
manager: EntityManager,
|
||||
originStationId: string,
|
||||
destinationStationId: string,
|
||||
departure: Date,
|
||||
): Promise<TrainSchedule | null> {
|
||||
const siblings = await this.findGroupSiblings(
|
||||
manager,
|
||||
originStationId,
|
||||
destinationStationId,
|
||||
departure,
|
||||
);
|
||||
if (siblings.length === 0) return null;
|
||||
const withWindow = siblings.filter((s) => s.windowOpensAt != null);
|
||||
if (withWindow.length === 0) return null;
|
||||
const pending = withWindow.filter((s) => s.windowPhase === 'PRE_WINDOW');
|
||||
const pool = pending.length > 0 ? pending : withWindow;
|
||||
// Earliest-opening sibling defines the group clock — deterministic and the
|
||||
// one a customer would have seen first.
|
||||
return pool.reduce((earliest, s) =>
|
||||
s.windowOpensAt!.getTime() < earliest.windowOpensAt!.getTime() ? s : earliest,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The window fields (open/close times + frozen rule snapshot) an anchor sibling
|
||||
* hands down to the rest of its group. The shared open/close instants make every
|
||||
* schedule in the group advance through the SAME open, doc-review, payment, and
|
||||
* close instants on the shared 10s tick — doc-review/payment ends are derived
|
||||
* live from these shared times during phase advance, so they fall in sync.
|
||||
*
|
||||
* `targetDeparture` is the JOINING schedule's own departure: the shared times
|
||||
* are clamped to it so a group whose trains depart at different times of the
|
||||
* same day never hands an earlier-departing train a window that outlives its
|
||||
* departure (computeImport/ExportWindowTimes clamp to departure at source; this
|
||||
* preserves that invariant when the anchor departed later). A window that would
|
||||
* be entirely after this train's departure collapses to a zero-length window at
|
||||
* departure — truthful, not a window that never closes.
|
||||
*/
|
||||
private groupWindowFieldsFrom(anchor: TrainSchedule, targetDeparture: Date) {
|
||||
const cap = targetDeparture.getTime();
|
||||
const clamp = (d: Date | null | undefined): Date | null =>
|
||||
d == null ? null : d.getTime() > cap ? targetDeparture : d;
|
||||
return {
|
||||
windowOpensAt: clamp(anchor.windowOpensAt),
|
||||
windowClosesAt: clamp(anchor.windowClosesAt),
|
||||
ruleWindowOpenHour: anchor.ruleWindowOpenHour,
|
||||
ruleWindowCloseHour: anchor.ruleWindowCloseHour,
|
||||
ruleWindowDurationHours: anchor.ruleWindowDurationHours,
|
||||
ruleReopenDelayMinutes: anchor.ruleReopenDelayMinutes,
|
||||
ruleImportWindowLeadDays: anchor.ruleImportWindowLeadDays,
|
||||
ruleExportBookingLeadHours: anchor.ruleExportBookingLeadHours,
|
||||
};
|
||||
}
|
||||
|
||||
async getEligibleBookings(query: GetEligibleBookingsDto) {
|
||||
// Day-level pooling: when the wizard targets a schedule, surface the whole
|
||||
// (route, EAT day) pool — not just bookings pre-pinned to that train — by
|
||||
@@ -561,15 +666,53 @@ export class TrainSchedulingService {
|
||||
);
|
||||
}
|
||||
|
||||
await this.dataSource.getRepository(TrainSchedule).update(id, {
|
||||
windowOpensAt: times.windowOpensAt,
|
||||
windowClosesAt: times.windowClosesAt,
|
||||
...windowRuleSnapshot(merged),
|
||||
});
|
||||
this.logger.log(
|
||||
`Booking-window rule overridden for schedule ${id} — reopens ${times.windowOpensAt.toISOString()}`,
|
||||
// Route+day grouping (IMPORT/DOMESTIC only): the override applies to the
|
||||
// WHOLE group — every schedule sharing this origin + destination + EAT
|
||||
// departure day. They all adopt the SAME rule snapshot and share the SAME
|
||||
// window open/close timeline (the whole point of grouping). The shared times
|
||||
// are clamped to each train's OWN departure so a group whose trains depart at
|
||||
// different times of the same day never hands an earlier-departing sibling a
|
||||
// window that outlives its departure. Only still-PRE_WINDOW siblings are
|
||||
// touched — a sibling that has already opened, finalized, or dispatched stays
|
||||
// frozen on the times its customers were shown and simply drops out of the
|
||||
// group; the remaining pending trains stay in sync. EXPORT is excluded
|
||||
// (departure-anchored FCFS window, no cross-expiry), so an export override
|
||||
// only touches its own schedule.
|
||||
const ruleFields = windowRuleSnapshot(merged);
|
||||
const repo = this.dataSource.getRepository(TrainSchedule);
|
||||
const cap = (d: Date, departure: Date): Date =>
|
||||
d.getTime() > departure.getTime() ? departure : d;
|
||||
|
||||
const targets: Array<{ id: string; departure: Date }> = [
|
||||
{ id, departure: schedule.scheduledDepartureDate },
|
||||
];
|
||||
if (schedule.direction !== 'EXPORT') {
|
||||
const siblings = await this.findGroupSiblings(
|
||||
this.dataSource.manager,
|
||||
schedule.originStationId,
|
||||
schedule.destinationStationId,
|
||||
schedule.scheduledDepartureDate,
|
||||
id,
|
||||
);
|
||||
void this.emitWindowState(id);
|
||||
for (const sib of siblings) {
|
||||
if (sib.windowPhase === 'PRE_WINDOW' && sib.scheduledDepartureDate) {
|
||||
targets.push({ id: sib.id, departure: sib.scheduledDepartureDate });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const t of targets) {
|
||||
await repo.update(t.id, {
|
||||
windowOpensAt: cap(times.windowOpensAt, t.departure),
|
||||
windowClosesAt: cap(times.windowClosesAt, t.departure),
|
||||
...ruleFields,
|
||||
});
|
||||
}
|
||||
this.logger.log(
|
||||
`Booking-window rule overridden for schedule ${id} and ${targets.length - 1} ` +
|
||||
`route+day sibling(s) — reopens ${times.windowOpensAt.toISOString()}`,
|
||||
);
|
||||
for (const t of targets) void this.emitWindowState(t.id);
|
||||
|
||||
const fresh = await this.trainSchedulesRepository.findById(id);
|
||||
return fresh ?? schedule;
|
||||
@@ -634,14 +777,34 @@ export class TrainSchedulingService {
|
||||
? computeExportWindowTimes(departure, merged)
|
||||
: computeImportWindowTimes(departure, merged, now);
|
||||
|
||||
// Moving the departure moves this train between route+day GROUPS. If the
|
||||
// destination day already has a group (a sibling on the same origin +
|
||||
// destination + new EAT day), adopt that group's shared timeline instead of
|
||||
// the times just derived, so the rescheduled train lines up with the group
|
||||
// it lands in rather than drifting onto its own clock. Otherwise it keeps its
|
||||
// own re-derived times and becomes the anchor for that day. EXPORT is
|
||||
// excluded — its window is anchored to its own departure, not shared.
|
||||
const anchor =
|
||||
schedule.direction === 'EXPORT'
|
||||
? null
|
||||
: await this.findGroupWindowAnchor(
|
||||
this.dataSource.manager,
|
||||
schedule.originStationId,
|
||||
schedule.destinationStationId,
|
||||
departure,
|
||||
);
|
||||
const windowFields = anchor
|
||||
? this.groupWindowFieldsFrom(anchor, departure)
|
||||
: { windowOpensAt: times.windowOpensAt, windowClosesAt: times.windowClosesAt };
|
||||
|
||||
await this.dataSource.getRepository(TrainSchedule).update(id, {
|
||||
scheduledDepartureDate: departure,
|
||||
windowOpensAt: times.windowOpensAt,
|
||||
windowClosesAt: times.windowClosesAt,
|
||||
...windowFields,
|
||||
});
|
||||
this.logger.log(
|
||||
`Departure date changed for schedule ${id} → ${departure.toISOString()} ` +
|
||||
`(window reopens ${times.windowOpensAt.toISOString()})`,
|
||||
`(window reopens ${windowFields.windowOpensAt?.toISOString() ?? 'n/a'}` +
|
||||
`${anchor ? `, joined route+day group anchor ${anchor.id}` : ''})`,
|
||||
);
|
||||
void this.emitWindowState(id);
|
||||
|
||||
@@ -854,21 +1017,44 @@ export class TrainSchedulingService {
|
||||
// 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 windowFields =
|
||||
// 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 +
|
||||
// frozen rule) verbatim — it does NOT compute its own `now`-based times.
|
||||
// That keeps every train on the day advancing through the same open/
|
||||
// doc-review/payment/close instants, so a booking on one train is never
|
||||
// expired by a sibling train's payment window closing on a different clock.
|
||||
// First train in the group falls through to the normal computation.
|
||||
//
|
||||
// EXPORT is excluded: an export window is a single FCFS window anchored to
|
||||
// each train's OWN departure (windowClosesAt = departure) with no
|
||||
// doc-review/payment phase — so there is no cross-expiry to fix, and two
|
||||
// export trains departing the same day at different times must keep their
|
||||
// own departure-anchored windows.
|
||||
const groupAnchor =
|
||||
direction === 'EXPORT'
|
||||
? {
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
windowPhase: 'PRE_WINDOW',
|
||||
...ruleSnapshot,
|
||||
...computeExportWindowTimes(departure, windowCfg),
|
||||
}
|
||||
? null
|
||||
: await this.findGroupWindowAnchor(
|
||||
manager,
|
||||
route.originYardId,
|
||||
route.destinationYardId,
|
||||
departure,
|
||||
);
|
||||
const computedTimes =
|
||||
direction === 'EXPORT'
|
||||
? { ...ruleSnapshot, ...computeExportWindowTimes(departure, windowCfg) }
|
||||
: {
|
||||
// IMPORT and DOMESTIC share the import booking-day window cycle.
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
windowPhase: 'PRE_WINDOW',
|
||||
...ruleSnapshot,
|
||||
...computeImportWindowTimes(departure, windowCfg, new Date()),
|
||||
};
|
||||
const windowFields = {
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
windowPhase: 'PRE_WINDOW',
|
||||
...(groupAnchor
|
||||
? this.groupWindowFieldsFrom(groupAnchor, departure)
|
||||
: computedTimes),
|
||||
};
|
||||
const maxWagons = (await this.resolveTrainLimitConfig(dto, limitLoco))
|
||||
.maxWagonsPerTrain;
|
||||
// Retry past a concurrent insert that grabbed the same S-<year> sequence
|
||||
@@ -1522,12 +1708,34 @@ export class TrainSchedulingService {
|
||||
await this.trainSchedulesRepository.updateStatus(
|
||||
scheduleId,
|
||||
TrainScheduleStatusEnum.Dispatched,
|
||||
{ actualDepartureAt: now, trainNumber },
|
||||
{
|
||||
actualDepartureAt: now,
|
||||
trainNumber,
|
||||
// Freeze the wagon plan the moment the train leaves the editable phase.
|
||||
wagonAllocationSnapshot: this.buildWagonAllocationSnapshot(
|
||||
schedule,
|
||||
TrainScheduleStatusEnum.Dispatched,
|
||||
now,
|
||||
),
|
||||
},
|
||||
manager,
|
||||
);
|
||||
if (schedule.trainSetId) {
|
||||
await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'DISPATCHED' });
|
||||
}
|
||||
// The train is out — every pinned wagon is ASSIGNED to this schedule and
|
||||
// stays pinned so no other schedule can pick it while it's rolling.
|
||||
const dispatchedPhysicalIds = (schedule.trainSet?.wagons ?? [])
|
||||
.map((slot) => slot.physicalWagonId)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
if (dispatchedPhysicalIds.length) {
|
||||
await manager
|
||||
.getRepository(Wagon)
|
||||
.update(
|
||||
{ id: In(dispatchedPhysicalIds) },
|
||||
{ status: WagonStatus.Assigned, currentTrainScheduleId: scheduleId },
|
||||
);
|
||||
}
|
||||
for (const sb of schedule.scheduleBookings ?? []) {
|
||||
await this.bookingsRepository.updateSchedulingFields(
|
||||
sb.bookingId,
|
||||
@@ -1604,7 +1812,24 @@ export class TrainSchedulingService {
|
||||
);
|
||||
}
|
||||
void this.notifyScheduleBookings(schedule, 'dispatched');
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
|
||||
const detail = await this.getTrainScheduleById(scheduleId);
|
||||
// Surface a compact dispatch confirmation so the caller can toast the "train
|
||||
// is out" info (train number, departure, wagons committed) without re-deriving it.
|
||||
const dispatchedWagonCount = (schedule.trainSet?.wagons ?? []).filter(
|
||||
(slot) => slot.physicalWagonId,
|
||||
).length;
|
||||
return Object.assign(detail, {
|
||||
dispatchInfo: {
|
||||
// The real train number was assigned inside the txn — read it back off
|
||||
// the persisted detail (schedule.trainNumber is the pre-dispatch value).
|
||||
trainNumber: detail.trainNumber ?? schedule.trainNumber ?? null,
|
||||
departedAt: now.toISOString(),
|
||||
wagonsDispatched: dispatchedWagonCount,
|
||||
originStationId: schedule.originStationId,
|
||||
destinationStationId: schedule.destinationStationId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getImportDjiboutiOperation(scheduleId: string) {
|
||||
@@ -2561,7 +2786,15 @@ export class TrainSchedulingService {
|
||||
await this.trainSchedulesRepository.updateStatus(
|
||||
scheduleId,
|
||||
TrainScheduleStatusEnum.Arrived,
|
||||
{ actualArrivalAt: now },
|
||||
{
|
||||
actualArrivalAt: now,
|
||||
// Freeze the plan before the wagons below are released to their yards.
|
||||
wagonAllocationSnapshot: this.buildWagonAllocationSnapshot(
|
||||
schedule,
|
||||
TrainScheduleStatusEnum.Arrived,
|
||||
now,
|
||||
),
|
||||
},
|
||||
manager,
|
||||
);
|
||||
|
||||
@@ -2723,13 +2956,24 @@ export class TrainSchedulingService {
|
||||
throw new NotFoundException(`Train schedule ${id} not found`);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await this.trainSchedulesRepository.updateStatus(
|
||||
id,
|
||||
TrainScheduleStatusEnum.Cancelled,
|
||||
{
|
||||
// Retire the booking window so a canceled schedule never lingers as an
|
||||
// "open window" in booking-window lists or the legacy batch fill.
|
||||
{ bookingWindowStatus: 'CLOSED', windowPhase: 'DONE' },
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
windowPhase: 'DONE',
|
||||
// Freeze the plan before the wagons below are released back to the yard.
|
||||
wagonAllocationSnapshot: this.buildWagonAllocationSnapshot(
|
||||
schedule,
|
||||
TrainScheduleStatusEnum.Cancelled,
|
||||
now,
|
||||
),
|
||||
},
|
||||
manager,
|
||||
);
|
||||
if (schedule.trainSetId) {
|
||||
@@ -2757,6 +3001,9 @@ export class TrainSchedulingService {
|
||||
currentTrainScheduleId: null,
|
||||
trainSetWagonId: null,
|
||||
status: WagonStatus.Available,
|
||||
// A cancelled train never left — its wagons stay/return at the origin
|
||||
// yard, free to be re-pinned onto another schedule from there.
|
||||
currentYardId: schedule.originStationId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3296,6 +3543,47 @@ export class TrainSchedulingService {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Freeze the schedule's live wagon plan into a snapshot. Built from the fully
|
||||
* hydrated graph (findByIdWithFullGraph) BEFORE the transition releases the
|
||||
* physical wagons, so the historical allocation survives those wagons being
|
||||
* re-pinned onto later trains. `capturedStatus` is the status being applied.
|
||||
*/
|
||||
private buildWagonAllocationSnapshot(
|
||||
schedule: TrainSchedule,
|
||||
capturedStatus: TrainScheduleStatusEnum,
|
||||
capturedAt: Date,
|
||||
): WagonAllocationSnapshot {
|
||||
const slots = [...(schedule.trainSet?.wagons ?? [])]
|
||||
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||||
.map((wagon) => ({
|
||||
sequenceNo: wagon.sequenceNo,
|
||||
trainSetWagonId: wagon.id,
|
||||
physicalWagonId: wagon.physicalWagonId ?? null,
|
||||
physicalWagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
|
||||
wagonTypeId: wagon.wagonTypeId ?? null,
|
||||
wagonTypeCode: wagon.wagonType?.code ?? null,
|
||||
slotStatus: wagon.status ?? null,
|
||||
boardYardId: wagon.boardYardId ?? null,
|
||||
alightYardId: wagon.alightYardId ?? null,
|
||||
allocations: (wagon.allocations ?? []).map((allocation) => ({
|
||||
bookingId: allocation.bookingId,
|
||||
bookingReference: allocation.booking?.reference ?? null,
|
||||
allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0,
|
||||
loadType: allocation.loadType ?? null,
|
||||
containerNumbers: (allocation.containerItems ?? [])
|
||||
.map((item) => item.containerNumber)
|
||||
.filter((n): n is string => Boolean(n)),
|
||||
})),
|
||||
}));
|
||||
|
||||
return {
|
||||
capturedStatus,
|
||||
capturedAt: capturedAt.toISOString(),
|
||||
slots,
|
||||
};
|
||||
}
|
||||
|
||||
private async releasePinnedWagonsForTrainSet(manager: EntityManager, trainSetId: string) {
|
||||
const slots = await manager.getRepository(TrainSetWagon).find({ where: { trainSetId } });
|
||||
for (const slot of slots) {
|
||||
@@ -4523,6 +4811,20 @@ export class TrainSchedulingService {
|
||||
bulkLoads.map((load) => [load.wagonBookingAllocationId, load]),
|
||||
);
|
||||
|
||||
// Once a schedule leaves DRAFT/SCHEDULED, its physical wagons are released
|
||||
// and re-pinned onto later trains — the live wagon↔slot joins no longer
|
||||
// describe THIS train. If a frozen snapshot was captured at the transition,
|
||||
// the per-slot wagon number + booking allocations are read from it instead.
|
||||
const snapshot = schedule.wagonAllocationSnapshot ?? null;
|
||||
const isWagonAllocationFrozen = Boolean(
|
||||
snapshot &&
|
||||
schedule.status !== TrainScheduleStatusEnum.Draft &&
|
||||
schedule.status !== TrainScheduleStatusEnum.Scheduled,
|
||||
);
|
||||
const snapshotSlotByTrainSetWagonId = new Map(
|
||||
(snapshot?.slots ?? []).map((slot) => [slot.trainSetWagonId, slot]),
|
||||
);
|
||||
|
||||
return {
|
||||
id: schedule.id,
|
||||
reference: schedule.reference ?? null,
|
||||
@@ -4605,7 +4907,14 @@ export class TrainSchedulingService {
|
||||
})),
|
||||
wagons: [...(schedule.trainSet.wagons ?? [])]
|
||||
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||||
.map((wagon) => ({
|
||||
.map((wagon) => {
|
||||
// Frozen schedules read the wagon number + allocations from the
|
||||
// snapshot slot; the immutable slot geometry (capacity/type) still
|
||||
// comes live. Falls back to live if a slot is missing from the snap.
|
||||
const frozenSlot = isWagonAllocationFrozen
|
||||
? snapshotSlotByTrainSetWagonId.get(wagon.id)
|
||||
: undefined;
|
||||
return {
|
||||
id: wagon.id,
|
||||
sequenceNo: wagon.sequenceNo,
|
||||
capacityTons: roundTons(Number(wagon.capacityTons)),
|
||||
@@ -4617,13 +4926,37 @@ export class TrainSchedulingService {
|
||||
? roundTons(Number(wagon.wagonType.tareWeightTons))
|
||||
: null,
|
||||
status: wagon.status,
|
||||
physicalWagonId: wagon.physicalWagonId ?? null,
|
||||
physicalWagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
|
||||
physicalWagonId: frozenSlot
|
||||
? frozenSlot.physicalWagonId
|
||||
: wagon.physicalWagonId ?? null,
|
||||
physicalWagonNumber: frozenSlot
|
||||
? frozenSlot.physicalWagonNumber
|
||||
: wagon.physicalWagon?.wagonNumber ?? null,
|
||||
wagonType: wagon.wagonType
|
||||
? { id: wagon.wagonType.id, code: wagon.wagonType.code, name: wagon.wagonType.name }
|
||||
: null,
|
||||
allocations:
|
||||
wagon.allocations?.map((allocation) => ({
|
||||
allocations: frozenSlot
|
||||
? frozenSlot.allocations.map((allocation) => ({
|
||||
id: null,
|
||||
bookingId: allocation.bookingId,
|
||||
bookingReference: allocation.bookingReference,
|
||||
allocatedWeightTons: roundTons(allocation.allocatedWeightTons),
|
||||
loadType: allocation.loadType,
|
||||
status: null,
|
||||
// Frozen: container detail collapses to the captured numbers;
|
||||
// per-container geometry isn't re-derivable post-release.
|
||||
containerItems: allocation.containerNumbers.map((containerNumber) => ({
|
||||
id: null,
|
||||
containerNumber,
|
||||
containerTypeId: null,
|
||||
grossWeightTons: null,
|
||||
containerId: null,
|
||||
positionOnWagon: null,
|
||||
bookingContainerId: null,
|
||||
})),
|
||||
bulkLoad: null,
|
||||
}))
|
||||
: wagon.allocations?.map((allocation) => ({
|
||||
id: allocation.id,
|
||||
bookingId: allocation.bookingId,
|
||||
bookingReference: allocation.booking?.reference ?? null,
|
||||
@@ -4650,7 +4983,8 @@ export class TrainSchedulingService {
|
||||
}
|
||||
: null,
|
||||
})) ?? [],
|
||||
})),
|
||||
};
|
||||
}),
|
||||
}
|
||||
: null,
|
||||
bookings:
|
||||
@@ -4668,6 +5002,11 @@ export class TrainSchedulingService {
|
||||
loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded,
|
||||
wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId),
|
||||
})) ?? [],
|
||||
// True when the wagon plan above is served from the frozen snapshot (schedule
|
||||
// is dispatched/arrived/cancelled) rather than the live joins — the UI can badge
|
||||
// it "historical" and skip re-pin affordances.
|
||||
isWagonAllocationFrozen,
|
||||
wagonAllocationSnapshot: snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -232,6 +232,15 @@ export default function BookingWindowSettingsModal({
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack gap="lg">
|
||||
{!isExport ? (
|
||||
<Alert variant="light" color="orange" icon={<Info size={16} />}>
|
||||
These settings apply to every train on this route (same origin and
|
||||
destination) departing the same day — they all share one booking
|
||||
window, so it opens, moves to document review, opens for payment,
|
||||
and closes at the same time for all of them.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{isExport ? (
|
||||
<Alert variant="light" color="blue" icon={<Info size={16} />}>
|
||||
Export schedules use a single first-come-first-served window: it
|
||||
|
||||
@@ -196,11 +196,11 @@ const sidebarItems: SidebarItem[] = [
|
||||
href: "/bookings",
|
||||
icon: <Package size={18} />,
|
||||
},
|
||||
{
|
||||
label: "Tracking",
|
||||
href: "/tracking",
|
||||
icon: <MapPin size={18} />,
|
||||
},
|
||||
// {
|
||||
// label: "Tracking",
|
||||
// href: "/tracking",
|
||||
// icon: <MapPin size={18} />,
|
||||
// },
|
||||
{
|
||||
label: "Invoices",
|
||||
href: "/billing",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Box, SimpleGrid, Text } from "@mantine/core";
|
||||
import { Anchor, Box, SimpleGrid, Text } from "@mantine/core";
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
@@ -59,6 +60,22 @@ export function KeyFactsStrip({ booking }: { booking: BookingLike }) {
|
||||
}
|
||||
/>
|
||||
<Fact label="Cargo" value={freight} />
|
||||
{booking.contractId ? (
|
||||
<Fact
|
||||
label="Contract"
|
||||
value={
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={`/contracts/${booking.contractId}`}
|
||||
fz="15px"
|
||||
fw={700}
|
||||
underline="hover"
|
||||
>
|
||||
{booking.contractReference ?? "View contract"}
|
||||
</Anchor>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<Fact
|
||||
label="Route"
|
||||
value={`${yardLabel(booking.originYard)} → ${yardLabel(booking.destinationYard)}`}
|
||||
|
||||
@@ -38,6 +38,7 @@ import { PayNowButton } from "./payments/PayNowButton";
|
||||
import { BookingActionButton } from "./clearance/BookingActionButton";
|
||||
import { bookingHasInlineAction } from "./clearance/bookingNextAction";
|
||||
import {
|
||||
BookingStatusBadge as StatusBadge,
|
||||
BookingTypeBadge,
|
||||
CargoModeCell,
|
||||
PaymentBadge,
|
||||
@@ -46,7 +47,6 @@ import {
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { BookingListFilter } from "@/services/bookings.service";
|
||||
import { STATUS_CONFIG } from "@/pages/MyPortalPage/constants";
|
||||
import type { Freight } from "@edr/types";
|
||||
import {
|
||||
DataTable,
|
||||
@@ -157,46 +157,6 @@ const STAT_CARDS: Array<{
|
||||
},
|
||||
];
|
||||
|
||||
// ── Status badge (reuses the shared portal status config) ─────────────────────
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
const label = cfg?.badgeLabel ?? status.replace(/_/g, " ");
|
||||
const bg = cfg ? `var(--mantine-color-${cfg.badgeBg}-0, #F1F4F7)` : "#F1F4F7";
|
||||
const text = cfg
|
||||
? `var(--mantine-color-${cfg.badgeText}-7, #475569)`
|
||||
: "#475569";
|
||||
const dot = cfg
|
||||
? `var(--mantine-color-${cfg.badgeDot}-6, #94A3B8)`
|
||||
: "#94A3B8";
|
||||
return (
|
||||
<Group
|
||||
gap={6}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
borderRadius: 999,
|
||||
backgroundColor: bg,
|
||||
padding: "5px 11px",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: dot,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Text fz={11} fw={700} style={{ color: text, whiteSpace: "nowrap" }}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Context-sensitive action button ───────────────────────────────────────────
|
||||
|
||||
function PrimaryAction({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Badge, Group, Text } from "@mantine/core";
|
||||
import { Badge, Box, Group, Text } from "@mantine/core";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { STATUS_CONFIG } from "@/pages/MyPortalPage/constants";
|
||||
@@ -124,6 +124,45 @@ export function PaymentBadge({ status }: { status?: string | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Booking status pill (dot + label) driven by the shared STATUS_CONFIG. */
|
||||
export function BookingStatusBadge({ status }: { status: string }) {
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
const label = cfg?.badgeLabel ?? titleCaseStatus(status);
|
||||
const bg = cfg ? `var(--mantine-color-${cfg.badgeBg}-0, #F1F4F7)` : "#F1F4F7";
|
||||
const text = cfg
|
||||
? `var(--mantine-color-${cfg.badgeText}-7, #475569)`
|
||||
: "#475569";
|
||||
const dot = cfg
|
||||
? `var(--mantine-color-${cfg.badgeDot}-6, #94A3B8)`
|
||||
: "#94A3B8";
|
||||
return (
|
||||
<Group
|
||||
gap={6}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
borderRadius: 999,
|
||||
backgroundColor: bg,
|
||||
padding: "5px 11px",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: dot,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Text fz={11} fw={700} style={{ color: text, whiteSpace: "nowrap" }}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether a booking is assigned to a train yet (scheduling progress). */
|
||||
export function SchedulingCell({ booking }: { booking: BookingLike & { trainScheduleId?: string | null; schedulingStatus?: string } }) {
|
||||
const assigned = !!booking.trainScheduleId;
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
RingProgress,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
Title,
|
||||
@@ -60,6 +61,13 @@ import { fileViewUrl } from "@/constants/apiConfig";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import toast from "react-hot-toast";
|
||||
import { labelForDocCode } from "@/pages/bookings/resubmit";
|
||||
import {
|
||||
BookingStatusBadge,
|
||||
BookingTypeBadge,
|
||||
CargoModeCell,
|
||||
PaymentBadge,
|
||||
SchedulingCell,
|
||||
} from "@/pages/bookings/booking-display";
|
||||
import { ContractClearancePanel } from "./ContractClearancePanel";
|
||||
import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBanner";
|
||||
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
||||
@@ -148,8 +156,15 @@ interface DocGroup {
|
||||
* contract PDF, company profile / onboarding documents, and clearance documents
|
||||
* (everything else — the clearance set uses dynamic per-contract codes). Empty
|
||||
* groups are dropped so the tab only renders sections that have files.
|
||||
*
|
||||
* GENERAL contracts clear per booking, so their contract-level "clearance"
|
||||
* leftovers are not shown — pass includeClearance: false to keep only the
|
||||
* profile / business-licence sections.
|
||||
*/
|
||||
function groupContractDocuments(files: ContractFile[]): DocGroup[] {
|
||||
function groupContractDocuments(
|
||||
files: ContractFile[],
|
||||
{ includeClearance = true }: { includeClearance?: boolean } = {},
|
||||
): DocGroup[] {
|
||||
const businessLicense: ContractFile[] = [];
|
||||
const profile: ContractFile[] = [];
|
||||
const clearance: ContractFile[] = [];
|
||||
@@ -159,7 +174,7 @@ function groupContractDocuments(files: ContractFile[]): DocGroup[] {
|
||||
if (f.code === "contract" || f.code.startsWith("signature_")) continue;
|
||||
else if (BUSINESS_LICENSE_DOC_CODES.has(f.code)) businessLicense.push(f);
|
||||
else if (PROFILE_DOC_CODES.has(f.code)) profile.push(f);
|
||||
else clearance.push(f);
|
||||
else if (includeClearance) clearance.push(f);
|
||||
}
|
||||
return [
|
||||
{ key: "clearance", title: "Clearance documents", files: clearance },
|
||||
@@ -328,7 +343,12 @@ export default function ContractDetailPage() {
|
||||
const routes = contract.routes ?? [];
|
||||
const pricing = contract.pricingBreakdown;
|
||||
const files = contract.files ?? [];
|
||||
const docGroups = groupContractDocuments(files);
|
||||
// GENERAL contracts clear per booking — clearance documents live on each
|
||||
// booking's detail page, so this tab keeps only profile/licence documents.
|
||||
const docGroups = groupContractDocuments(files, {
|
||||
includeClearance: contract.contractKind !== "GENERAL",
|
||||
});
|
||||
const docCount = docGroups.reduce((sum, g) => sum + g.files.length, 0);
|
||||
// The generated contract PDF — surfaced via a dedicated "View contract" button
|
||||
// in the header (it's excluded from the Documents tab groups).
|
||||
const contractPdf = files.find((f) => f.code === "contract");
|
||||
@@ -621,7 +641,7 @@ export default function ContractDetailPage() {
|
||||
active={tab === "documents"}
|
||||
icon={<Download size={16} />}
|
||||
label="Documents"
|
||||
count={files.length + (isPhasedCustomsClearance ? workflowFileCount : 0)}
|
||||
count={docCount + (isPhasedCustomsClearance ? workflowFileCount : 0)}
|
||||
/>
|
||||
<DetailTab
|
||||
value="bookings"
|
||||
@@ -1191,8 +1211,9 @@ export default function ContractDetailPage() {
|
||||
No documents yet
|
||||
</Text>
|
||||
<Text fz={13} c="dimmed" ta="center" maw={420}>
|
||||
The signed contract and any uploaded clearance documents will
|
||||
appear here.
|
||||
{isGeneral
|
||||
? "Your company profile documents (TIN certificate, business licence, ID) appear here. Clearance documents are managed on each booking."
|
||||
: "The signed contract and any uploaded clearance documents will appear here."}
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
@@ -1399,21 +1420,44 @@ export default function ContractDetailPage() {
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap={10}>
|
||||
{contractBookings.map((booking) => (
|
||||
<Group
|
||||
<Table.ScrollContainer minWidth={980}>
|
||||
<Table
|
||||
verticalSpacing="sm"
|
||||
horizontalSpacing="md"
|
||||
highlightOnHover
|
||||
style={{ fontSize: 13 }}
|
||||
>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<BookingsTh label="Booking" />
|
||||
<BookingsTh label="Type" />
|
||||
<BookingsTh label="Cargo" />
|
||||
<BookingsTh label="Route" />
|
||||
<BookingsTh label="Payment" />
|
||||
<BookingsTh label="Train" />
|
||||
<BookingsTh label="Status" />
|
||||
<BookingsTh label="Amount" right />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{contractBookings.map((booking) => {
|
||||
const origin =
|
||||
booking.originYard?.label ??
|
||||
booking.originYard?.code ??
|
||||
"—";
|
||||
const dest =
|
||||
booking.destinationYard?.label ??
|
||||
booking.destinationYard?.code ??
|
||||
"—";
|
||||
const amount = Number(booking.totalAmount ?? 0);
|
||||
return (
|
||||
<Table.Tr
|
||||
key={booking.id}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${BORDER}`,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/bookings/${booking.id}`)}
|
||||
>
|
||||
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Table.Td>
|
||||
<Group gap={10} wrap="nowrap">
|
||||
<Package
|
||||
size={16}
|
||||
color={MUTED}
|
||||
@@ -1421,27 +1465,68 @@ export default function ContractDetailPage() {
|
||||
/>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text
|
||||
fz={14}
|
||||
fz={13.5}
|
||||
fw={700}
|
||||
style={{ color: INK }}
|
||||
truncate
|
||||
>
|
||||
{booking.reference}
|
||||
</Text>
|
||||
<Text fz={11.5} c="dimmed">
|
||||
{booking.freightType === "BULK"
|
||||
? "Bulk cargo"
|
||||
: "Container"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<BookingTypeBadge booking={booking} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<CargoModeCell booking={booking} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={13} fw={600} style={{ color: INK }}>
|
||||
{origin} → {dest}
|
||||
</Text>
|
||||
{booking.scheduledDate && (
|
||||
<Text fz={12} c="dimmed" truncate>
|
||||
Ship{" "}
|
||||
<Text fz={11.5} c="dimmed">
|
||||
{new Date(
|
||||
booking.scheduledDate,
|
||||
).toLocaleDateString()}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
<ContractStatusBadge status={booking.status} />
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<PaymentBadge status={booking.paymentStatus} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<SchedulingCell booking={booking} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<BookingStatusBadge status={booking.status} />
|
||||
</Table.Td>
|
||||
<Table.Td style={{ textAlign: "right" }}>
|
||||
<Text
|
||||
fz={13.5}
|
||||
fw={700}
|
||||
style={{
|
||||
color: amount > 0 ? INK : "#94A3B8",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{amount > 0
|
||||
? `ETB ${amount.toLocaleString()}`
|
||||
: "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
@@ -1572,6 +1657,23 @@ function SectionLabel({
|
||||
);
|
||||
}
|
||||
|
||||
/** Column header for the bookings table — mirrors the /bookings list styling. */
|
||||
function BookingsTh({ label, right }: { label: string; right?: boolean }) {
|
||||
return (
|
||||
<Table.Th style={{ textAlign: right ? "right" : "left" }}>
|
||||
<Text
|
||||
fz={11}
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
c="dimmed"
|
||||
style={{ letterSpacing: "0.06em", whiteSpace: "nowrap" }}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Table.Th>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unit noun for a capacity line: "containers" for CONTAINER freight, else the
|
||||
* bulk cargo's unit of measure ("tons" for PER_TON, "items" for PER_ITEM).
|
||||
|
||||
@@ -237,6 +237,42 @@ export enum WagonReadiness {
|
||||
ExportReady = "EXPORT_READY",
|
||||
}
|
||||
|
||||
/**
|
||||
* Frozen record of one wagon slot's allocation at the moment a schedule leaves
|
||||
* DRAFT/SCHEDULED (dispatch / arrive / cancel). Captured once into
|
||||
* `train_schedules.wagon_allocation_snapshot` so the historical wagon plan
|
||||
* survives later re-pinning of the same physical wagons onto other trains.
|
||||
*/
|
||||
export interface WagonAllocationSnapshotSlot {
|
||||
sequenceNo: number;
|
||||
trainSetWagonId: string;
|
||||
physicalWagonId: string | null;
|
||||
physicalWagonNumber: string | null;
|
||||
wagonTypeId: string | null;
|
||||
wagonTypeCode: string | null;
|
||||
/** Wagon slot status (RESERVED/LOADED/…) at capture time. */
|
||||
slotStatus: string | null;
|
||||
boardYardId: string | null;
|
||||
alightYardId: string | null;
|
||||
allocations: WagonAllocationSnapshotAllocation[];
|
||||
}
|
||||
|
||||
export interface WagonAllocationSnapshotAllocation {
|
||||
bookingId: string;
|
||||
bookingReference: string | null;
|
||||
allocatedWeightTons: number;
|
||||
loadType: string | null;
|
||||
containerNumbers: string[];
|
||||
}
|
||||
|
||||
/** Whole-schedule frozen wagon plan written at a terminal/transit transition. */
|
||||
export interface WagonAllocationSnapshot {
|
||||
/** Schedule status the snapshot was captured at (DISPATCHED/ARRIVED/CANCELLED). */
|
||||
capturedStatus: string;
|
||||
capturedAt: string;
|
||||
slots: WagonAllocationSnapshotSlot[];
|
||||
}
|
||||
|
||||
export type ScheduleTradeDirection = "IMPORT" | "EXPORT" | "DOMESTIC";
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user