Merge branch 'dev' into freight/chore/payment-test

This commit is contained in:
Nathnael
2026-07-31 10:57:07 +00:00
29 changed files with 2234 additions and 196 deletions

View File

@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* The draft/finalize phase is abolished: train schedules are created SCHEDULED
* and the Finalize button is gone from the backoffice. Promote every surviving
* DRAFT schedule so it stays reachable (dispatch requires SCHEDULED and there
* is no manual promotion path anymore). Idempotent; one-way — the original
* DRAFT set is not recorded, so down() cannot restore it.
*/
export class PromoteDraftSchedulesToScheduled3100000000000 implements MigrationInterface {
name = "PromoteDraftSchedulesToScheduled3100000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`UPDATE freight.train_schedules
SET status = 'SCHEDULED'
WHERE status = 'DRAFT'
AND deleted_at IS NULL`,
);
}
public async down(): Promise<void> {
// One-way data promotion — nothing to restore.
}
}

View File

@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Consist adjustments can now happen mid-route (train standing at a stop), so
* each history row records WHERE it happened. Nullable — rows written before
* this column simply have no yard.
*/
export class AddYardToScheduleWagonAdjustmentLogs3110000000000 implements MigrationInterface {
name = "AddYardToScheduleWagonAdjustmentLogs3110000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.schedule_wagon_adjustment_logs
ADD COLUMN IF NOT EXISTS yard_id uuid`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.schedule_wagon_adjustment_logs
DROP COLUMN IF EXISTS yard_id`,
);
}
}

View File

@@ -135,11 +135,20 @@ export class BookingContractService {
async generateContractForGovernment(bookingId: string): Promise<void> {
const booking = await this.requireBooking(bookingId);
if (!booking.isGovernment || booking.contractGeneratedAt) return;
const templateKey = this.templateResolver.resolve(booking);
await this.bookingsRepository.update(bookingId, {
contractSummary: this.buildContractSummary(booking),
contractTemplateKey: this.templateResolver.resolve(booking),
contractTemplateKey: templateKey,
contractGeneratedAt: new Date(),
} as never);
// Render the PDF eagerly but NEVER block creation on it — Chromium can take
// seconds (or hang on assets); the document re-renders on view/download.
void this.upsertContractPdf(bookingId, booking.reference, templateKey).catch(
(err) =>
this.logger.warn(
`Government contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download.`,
),
);
}
async streamContract(bookingId: string) {

View File

@@ -464,6 +464,26 @@ export class BookingsController {
res.send(buffer);
}
@Get(':id/carriage-acceptance-sheet')
@ApiOperation({
summary:
'Download the carriage acceptance sheet (one per booking, lists every allocated wagon)',
})
async carriageAcceptanceSheet(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
@Res() res: Response,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
const { filename, buffer } = await this.bookingsService.carriageAcceptanceSheet(id);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(buffer);
}
@Get(':id/customer-trucks')
@ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' })
async listCustomerTrucks(

View File

@@ -23,6 +23,7 @@ import {
DocumentReviewStatus,
} from './entities/booking-document-review.entity';
import { BookingContainer } from './entities/booking-container.entity';
import { BookingContainerUnit } from './entities/booking-container-unit.entity';
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
@@ -201,10 +202,12 @@ export class BookingsRepository extends BaseRepository<Booking> {
vgmPerUnitTons: number;
hazardousQuantity?: number;
reeferQuantity?: number;
containerNumbers?: string[];
weightResult: ContainerWeightResult;
}>,
): Promise<BookingContainer[]> {
const containerRepo = this.dataSource.getRepository(BookingContainer);
const unitRepo = this.dataSource.getRepository(BookingContainerUnit);
const typeRepo = this.dataSource.getRepository(ContainerType);
const saved: BookingContainer[] = [];
@@ -230,7 +233,26 @@ export class BookingsRepository extends BaseRepository<Booking> {
isOverweight: item.weightResult.isOverweight,
overweightExcessTons: item.weightResult.overweightExcessTons,
});
saved.push(await containerRepo.save(row));
const savedRow = await containerRepo.save(row);
saved.push(savedRow);
// Physical container numbers, one unit row each (capped to the line
// quantity; blanks skipped). Optional — units can also be entered later.
const numbers = (item.containerNumbers ?? [])
.map((n) => n.trim())
.filter(Boolean)
.slice(0, item.quantity);
let sortOrder = 0;
for (const containerNumber of numbers) {
await unitRepo.save(
unitRepo.create({
bookingContainerId: savedRow.id,
containerNumber,
vgmTons: item.vgmPerUnitTons,
sortOrder: sortOrder++,
}),
);
}
}
return saved;

View File

@@ -70,6 +70,23 @@ export interface PaginatedBookings {
};
}
/** One wagon line on the carriage acceptance sheet (raw SQL projection). */
interface CarriageAcceptanceWagonRow {
sequenceNo: number;
wagonType: string | null;
wagonNumber: string | null;
tareWeightTons: string | null;
equatedLength: string | null;
loadCapacityTons: string | null;
allocatedWeightTons: string | null;
trainNumber: string | null;
departureAt: Date | null;
marshalledAt: string | null;
arrivalAt: string | null;
containerNumbers: string | null;
sealNumbers: string | null;
}
const URGENT_PRIORITY_THRESHOLD = 1000;
const NEEDS_ACTION_STATUSES = [
'SUBMITTED',
@@ -208,6 +225,230 @@ export class BookingsService {
};
}
/**
* Carriage acceptance sheet — one per booking, listing every wagon the booking
* occupies. Handed to the customer when EDR accepts the cargo (export) and when
* the wagons are allocated before marshalling (import), so it is only available
* once the booking has wagon allocations.
*/
async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
const booking = await this.findById(bookingId);
const wagons: CarriageAcceptanceWagonRow[] = await this.dataSource.query(
`SELECT tsw.sequence_no AS "sequenceNo",
COALESCE(wt.code, wt.name) AS "wagonType",
w.wagon_number AS "wagonNumber",
wt.tare_weight_tons AS "tareWeightTons",
tsw.length_meters AS "equatedLength",
tsw.capacity_tons AS "loadCapacityTons",
a.allocated_weight_tons AS "allocatedWeightTons",
s.train_number AS "trainNumber",
s.scheduled_departure_date AS "departureAt",
so.label AS "marshalledAt",
sd.label AS "arrivalAt",
string_agg(DISTINCT ci.container_number, ', ') AS "containerNumbers",
string_agg(DISTINCT ci.seal_number, ', ') AS "sealNumbers"
FROM freight.wagon_booking_allocations a
JOIN freight.train_set_wagons tsw
ON tsw.id = a.train_set_wagon_id AND tsw.deleted_at IS NULL
LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
LEFT JOIN freight.train_schedules s
ON s.train_set_id = tsw.train_set_id AND s.deleted_at IS NULL
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id
LEFT JOIN freight.wagon_allocation_container_items ci
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
WHERE a.booking_id = $1 AND a.deleted_at IS NULL
GROUP BY tsw.id, a.id, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons,
s.train_number, s.scheduled_departure_date, so.label, sd.label
ORDER BY tsw.sequence_no`,
[bookingId],
);
if (wagons.length === 0) {
throw new BadRequestException(
'No wagons are allocated to this booking yet — the carriage acceptance sheet is issued after wagon allocation',
);
}
const html = this.buildCarriageAcceptanceSheetHtml(booking, wagons);
const buffer = await this.pdfRender.htmlToPdfBuffer(html, {
label: 'carriage acceptance sheet',
fallback: (prepared) => buildTabularFallbackPdf(prepared),
});
return {
filename: `carriage-acceptance-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
buffer,
};
}
/**
* Split the booking amount across its wagons, proportional to allocated weight
* (equal shares when no weights are recorded). The last row absorbs the rounding
* remainder so the Price column always sums to the Total Amount on the sheet.
*/
private splitAmountAcrossWagons(total: number, weights: number[]): number[] {
const sum = weights.reduce((acc, w) => acc + w, 0);
const shares = weights.map((w) =>
Math.round((sum > 0 ? (total * w) / sum : total / weights.length) * 100) / 100,
);
const drift = Math.round((total - shares.reduce((a, b) => a + b, 0)) * 100) / 100;
shares[shares.length - 1] = Math.round((shares[shares.length - 1] + drift) * 100) / 100;
return shares;
}
private buildCarriageAcceptanceSheetHtml(
booking: Booking,
wagons: CarriageAcceptanceWagonRow[],
): string {
const esc = (v: unknown) => this.escapeHtml(String(v ?? '-'));
const num = (v: unknown, digits = 3) => (Number(v) || 0).toFixed(digits);
const money = (v: number) =>
v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const departureStation = booking.originYard?.label ?? booking.originYard?.code ?? '-';
const arrivalStation = booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-';
const cargoName = booking.cargoType?.cargoTypeName ?? booking.cargoFreeText ?? '-';
const currency = booking.paymentCurrency ?? 'ETB';
const totalAmount = Number(booking.adjustedTotalAmount ?? booking.totalAmount) || 0;
const prices = this.splitAmountAcrossWagons(
totalAmount,
wagons.map((w) => Number(w.allocatedWeightTons) || 0),
);
const header = wagons[0];
const sheetDate = header.departureAt ? new Date(header.departureAt) : new Date();
const totals = wagons.reduce(
(acc, w) => ({
tare: acc.tare + (Number(w.tareWeightTons) || 0),
capacity: acc.capacity + (Number(w.loadCapacityTons) || 0),
load: acc.load + (Number(w.allocatedWeightTons) || 0),
length: acc.length + (Number(w.equatedLength) || 0),
}),
{ tare: 0, capacity: 0, load: 0, length: 0 },
);
// A wagon carrying no weight and no container is running empty under this booking.
const fullWagons = wagons.filter(
(w) => (Number(w.allocatedWeightTons) || 0) > 0 || Boolean(w.containerNumbers),
).length;
const rows = wagons
.map(
(w, i) => `<tr>
<td class="num">${i + 1}</td>
<td>${esc(w.wagonType)}</td>
<td>${esc(w.wagonNumber)}</td>
<td class="num">${num(w.tareWeightTons, 2)}</td>
<td class="num">${num(w.equatedLength)}</td>
<td class="num">${num(w.loadCapacityTons)}</td>
<td>${esc(arrivalStation)}</td>
<td>${esc(cargoName)}</td>
<td>${esc(departureStation)}</td>
<td>${esc(w.containerNumbers)}</td>
<td>${esc(w.sealNumbers)}</td>
<td class="num">${money(prices[i])}</td>
</tr>`,
)
.join('');
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Carriage Acceptance Sheet</title>
<style>
@page { size: A4 landscape; margin: 10mm; }
* { box-sizing: border-box; }
body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
.top { display: flex; justify-content: space-between; border-bottom: 3px solid #0f766e; padding-bottom: 10px; gap: 24px; }
.brand { font-size: 11px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
h1 { margin: 6px 0 0; font-size: 25px; line-height: 1.05; }
.subtitle { font-size: 11px; color: #475569; margin-top: 4px; }
.meta { text-align: right; font-size: 11px; color: #475569; min-width: 210px; }
.meta strong { display: block; margin-top: 4px; color: #0f172a; font-size: 15px; }
.summary { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin: 14px 0; }
.tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 50px; }
.tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
.tile strong { font-size: 11px; }
table { width: 100%; border-collapse: collapse; }
th { background: #f8fafc; color: #475569; text-align: left; }
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
.num { text-align: right; }
tfoot td { background: #f8fafc; font-weight: 700; }
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
.line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 34px; }
</style>
</head>
<body>
<div class="top">
<div>
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Carriage Acceptance Sheet</h1>
<div class="subtitle">Booking ${esc(booking.reference)}${esc(booking.tradeDirection)}</div>
</div>
<div class="meta">
Sheet No.
<strong>CAS-${esc(booking.reference)}</strong>
Generated: ${esc(new Date().toLocaleString('en-GB'))}
</div>
</div>
<div class="summary">
<div class="tile"><span>Marshalled at</span><strong>${esc(header.marshalledAt ?? departureStation)}</strong></div>
<div class="tile"><span>Arrival at</span><strong>${esc(header.arrivalAt ?? arrivalStation)}</strong></div>
<div class="tile"><span>Date and time</span><strong>${esc(sheetDate.toLocaleString('en-GB'))}</strong></div>
<div class="tile"><span>Train No.</span><strong>${esc(header.trainNumber)}</strong></div>
<div class="tile"><span>Customer</span><strong>${esc(booking.company?.name)}</strong></div>
<div class="tile"><span>Cargo</span><strong>${esc(cargoName)}</strong></div>
</div>
<table>
<thead>
<tr>
<th class="num">SN</th>
<th>Type of Wagon</th>
<th>Wagon No.</th>
<th class="num">Tare Weight</th>
<th class="num">Equated Length</th>
<th class="num">Load Capacity</th>
<th>Arrival Station</th>
<th>Cargo Name</th>
<th>Departure Station</th>
<th>Container No.</th>
<th>Seal No.</th>
<th class="num">Price (${esc(currency)})</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
<tfoot>
<tr>
<td colspan="3">Total wagons: ${wagons.length} (full ${fullWagons} / empty ${wagons.length - fullWagons})</td>
<td class="num">${num(totals.tare, 2)}</td>
<td class="num">${num(totals.length)}</td>
<td class="num">${num(totals.capacity)}</td>
<td colspan="5">Gross weight (tare + load): ${num(totals.tare + totals.load)} T</td>
<td class="num">${money(totalAmount)}</td>
</tr>
</tfoot>
</table>
<div class="notice">
The wagons listed above are accepted for carriage under booking ${esc(booking.reference)}.
Wagon identity, container and seal numbers must be verified against the physical consist
before the sheet is signed.
</div>
<div class="signatures">
<div class="line">Signed by — EDR operations / date</div>
<div class="line">Signed by — customer or agent / date</div>
<div class="line">Signed by — marshalling yard / date</div>
</div>
</body>
</html>`;
}
/** Resolve trade direction from yard countries; reject client mismatch. */
/**
* An intercity corridor is valid when both yards are Ethiopian and at least
@@ -934,6 +1175,7 @@ export class BookingsService {
vgmPerUnitTons: c.vgmPerUnitTons,
hazardousQuantity: c.hazardousQuantity,
reeferQuantity: c.reeferQuantity,
containerNumbers: c.containerNumbers,
weightResult: ruleResult.containerWeightResults[i],
})),
);

View File

@@ -0,0 +1,26 @@
import { BookingsService } from './bookings.service';
// The split is a pure helper on the prototype (never touches `this`), so it can be
// exercised without constructing the service and its dependency graph.
const split = (total: number, weights: number[]): number[] =>
(
BookingsService.prototype as unknown as {
splitAmountAcrossWagons(total: number, weights: number[]): number[];
}
).splitAmountAcrossWagons(total, weights);
describe('carriage acceptance sheet — price split', () => {
it('splits proportionally to allocated weight', () => {
expect(split(100, [30, 10])).toEqual([75, 25]);
});
it('splits equally when no weights are recorded', () => {
expect(split(90, [0, 0, 0])).toEqual([30, 30, 30]);
});
it('always sums back to the booking total despite rounding', () => {
const shares = split(100, [1, 1, 1]);
expect(shares.reduce((a, b) => a + b, 0)).toBe(100);
expect(shares).toEqual([33.33, 33.33, 33.34]);
});
});

View File

@@ -74,6 +74,17 @@ export class CreateBookingContainerDto {
@Min(0)
@Transform(({ value }) => Number(value ?? 0))
reeferQuantity?: number;
@ApiPropertyOptional({
description:
'Physical container numbers for this line (each becomes a booking_container_unit; extras beyond `quantity` are ignored)',
type: [String],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
@MaxLength(64, { each: true })
containerNumbers?: string[];
}
/**

View File

@@ -1,15 +1,17 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
export const WAGON_ADJUSTMENT_ACTIONS = ['ADD', 'REMOVE'] as const;
export const WAGON_ADJUSTMENT_ACTIONS = ['ADD', 'REMOVE', 'SWITCH'] as const;
export type WagonAdjustmentAction = (typeof WAGON_ADJUSTMENT_ACTIONS)[number];
/**
* History row for a consist adjustment made from a schedule: staff coupled a
* wagon onto (ADD) or detached one from (REMOVE) the schedule's built train —
* e.g. trimming free wagons whose tare pushed gross weight over the
* locomotives' pull limit. Plain columns (no FK relations) so the history
* survives the wagon or train being deleted later.
* wagon onto (ADD), detached one from (REMOVE), or swapped the physical wagon
* under a loaded slot (SWITCH — wagonNumber reads "OLD → NEW") on the
* schedule's built train. `yardId` records WHERE it happened: the origin yard
* before departure, or the mid-route stop the train was standing at. Plain
* columns (no FK relations) so the history survives the wagon or train being
* deleted later.
*/
@Entity({ schema: 'freight', name: 'schedule_wagon_adjustment_logs' })
@Index(['trainScheduleId'])
@@ -33,6 +35,9 @@ export class ScheduleWagonAdjustmentLog extends BaseEntity {
@Column({ name: 'adjusted_by_user_id', type: 'uuid', nullable: true })
adjustedByUserId!: string | null;
@Column({ name: 'yard_id', type: 'uuid', nullable: true })
yardId!: string | null;
@Column({ name: 'occurred_at', type: 'timestamptz', default: () => 'now()' })
occurredAt!: Date;
}

View File

@@ -614,6 +614,26 @@ export class BookingBatchService implements OnModuleInit {
const linked =
await this.trainScheduleBookingsRepository.existsForBooking(bookingId);
// Intercity is allocated MANUALLY: payment secures the ride, staff then
// place it on whichever same-route train suits (intercity panel). Unpin
// from the train it reserved against — that train may be the wrong one by
// the time it departs — and return it to the waiting pool as PAID.
if (!linked && booking.tradeDirection === "DOMESTIC" && !booking.isGovernment) {
await this.dataSource.getRepository(Booking).update(bookingId, {
trainScheduleId: null,
schedulingStatus: "ELIGIBLE",
paymentDeadline: null,
} as never);
this.logger.log(
`[BATCH] intercity ${booking.reference ?? bookingId} PAID — awaiting manual placement by staff`,
);
void this.completeTrackingMilestones(bookingId, [
"FREIGHT_PAYMENT_PENDING",
"FREIGHT_PAYMENT_SETTLED",
]);
this.notifyBoardChanged(booking.trainScheduleId, "intercity_paid_unplaced");
return;
}
if (!linked) {
if (await this.holdIfWagonShort(booking.trainScheduleId, booking)) return;
await this.allocate(booking.trainScheduleId, booking, "paid");
@@ -3143,6 +3163,19 @@ export class BookingBatchService implements OnModuleInit {
this.notifyBoardChanged(scheduleId, 'intercity_accepted');
return;
}
// Manual placement of an ALREADY-PAID intercity booking: payment landed
// earlier (and unpinned it back to the pool) — staff are now choosing its
// train, so link directly. No new pay window; wagon assignment stays with
// staff in the workspace.
if (booking.paymentStatus === 'PAID' || booking.status === 'PAID') {
await this.dataSource
.getRepository(Booking)
.update(booking.id, { trainScheduleId: scheduleId });
booking.trainScheduleId = scheduleId;
await this.allocate(scheduleId, booking, 'paid');
this.notifyBoardChanged(scheduleId, 'intercity_accepted');
return;
}
await this.reserve(booking, scheduleId);
this.armSettle(scheduleId);
this.notifyBoardChanged(scheduleId, 'intercity_accepted');
@@ -3322,6 +3355,22 @@ export class BookingBatchService implements OnModuleInit {
booking: Booking,
reason: "paid" | "gov",
): Promise<void> {
// Stamp the computed wagon need on the link. Several callers pass a booking
// loaded without cargo relations (ensurePaidBookingAllocated), and a NULL
// wagonsRequired makes every capacity/occupancy reader miscount this
// booking as 1 wagon — reload with the relations wagonsFor sizes from.
const wagonDims = await this.loadWagonDims();
const full =
booking.bookingContainers || booking.cargoType
? booking
: await this.dataSource.getRepository(Booking).findOne({
where: { id: booking.id },
relations: {
bookingContainers: { containerType: true },
cargoType: true,
},
});
const wagonsRequired = this.wagonsFor(full ?? booking, wagonDims);
await this.dataSource.transaction(async (manager) => {
const exists =
await this.trainScheduleBookingsRepository.existsForBooking(
@@ -3338,6 +3387,7 @@ export class BookingBatchService implements OnModuleInit {
status: reason === "paid" ? "PAID" : booking.status,
schedulingStatus: "SCHEDULED",
scheduledAt: new Date(),
wagonsRequired,
paymentDeadline: null,
selectedForBatchAt: null,
} as never);
@@ -3346,7 +3396,11 @@ export class BookingBatchService implements OnModuleInit {
`[BATCH] ALLOCATED ${booking.reference} (${reason}) to train on schedule ${scheduleId}`,
);
this.notifier.secured(booking, reason, scheduleId);
void this.triggerWagonAllocation(scheduleId);
// Intercity rides are placed on wagons BY STAFF (workspace wizard) — auto
// wagon assignment is for the import/export batch flow only.
if (booking.tradeDirection !== 'DOMESTIC') {
void this.triggerWagonAllocation(scheduleId);
}
void this.markWagonAllocatedMilestone(booking.id);
// Customer tracking: freight payment settled (commercial pay-window path).
// Government allocations don't pay upfront — theirs stay pending.

View File

@@ -1,5 +1,16 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsOptional, IsUUID } from 'class-validator';
import { Type } from 'class-transformer';
import { IsArray, IsOptional, IsUUID, ValidateNested } from 'class-validator';
export class ConsistWagonSwitchDto {
@ApiPropertyOptional({ format: 'uuid', description: 'Coupled wagon being taken out of the consist.' })
@IsUUID()
fromWagonId!: string;
@ApiPropertyOptional({ format: 'uuid', description: 'AVAILABLE same-type wagon from the current yard that takes its place (and its slot, cargo included).' })
@IsUUID()
toWagonId!: string;
}
export class AdjustScheduleConsistDto {
@ApiPropertyOptional({
@@ -23,4 +34,15 @@ export class AdjustScheduleConsistDto {
@IsArray()
@IsUUID('all', { each: true })
removeWagonIds?: string[];
@ApiPropertyOptional({
type: [ConsistWagonSwitchDto],
description:
"Wagon swaps: the replacement takes over the outgoing wagon's position AND its slot, so cargo allocations ride the new wagon. This is how a LOADED wagon leaves the train — removal is blocked for it, switching is not. Replacement must be the same wagon type, AVAILABLE, standing in the train's current yard.",
})
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => ConsistWagonSwitchDto)
switches?: ConsistWagonSwitchDto[];
}

View File

@@ -330,8 +330,10 @@ export class IntercityService {
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.where(`booking.trade_direction = 'DOMESTIC'`)
.andWhere('booking.train_schedule_id IS NULL')
// PAID = customer paid but staff have not placed it on a train yet
// (intercity allocation is manual) — it stays in the pool until they do.
.andWhere(
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
`((booking.is_government = false AND booking.status IN ('FULLY_EXECUTED', 'PAID'))
OR (booking.is_government = true AND booking.status = 'APPROVED'))`,
)
.orderBy('booking.is_government', 'DESC')
@@ -382,8 +384,12 @@ export class IntercityService {
if (booking.trainScheduleId) {
return 'Already assigned to a train';
}
const readyStatus = booking.isGovernment ? 'APPROVED' : 'FULLY_EXECUTED';
if (booking.status !== readyStatus) {
// Commercial: FULLY_EXECUTED opens a pay window; PAID (payment landed,
// awaiting manual placement) links straight onto the chosen train.
const readyStatuses = booking.isGovernment
? ['APPROVED']
: ['FULLY_EXECUTED', 'PAID'];
if (!readyStatuses.includes(booking.status)) {
return `Not ready to board (status ${booking.status})`;
}
if (!this.corridorOnRoute(booking, milestoneSeq)) {

View File

@@ -195,6 +195,16 @@ export class TrainSchedulingController {
);
}
@Get("schedules/:id/history")
@TrainSchedulingView()
@ApiOperation({
summary:
"Unified change history for a schedule: wagon consist adjustments (add/remove/switch, with the stop they happened at) merged with booking composition removals, newest first",
})
getScheduleHistory(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.getScheduleHistory(id);
}
@Get("bookable-schedules")
// No staff guard: customers hit this while creating a booking to find OPEN
// same-route schedules. Do not attach train_scheduling permissions here.

View File

@@ -549,9 +549,12 @@ export function validateMixedTrainLimitsPerEdge(
wagonTypes: Array<Pick<WagonType, 'lengthMeters'>>,
limits: TrainLimitConfig | undefined,
stops: string[],
/** Display names parallel to `stops` — violations then name the leg they hit. */
stopLabels?: string[],
): string[] {
if (stops.length <= 2) return validateMixedTrainLimits(wagonPlan, wagonTypes, limits);
const spans = slotSpans(wagonPlan, stops);
const label = (i: number) => stopLabels?.[i] ?? stops[i];
const violations = new Set<string>();
for (let edge = 0; edge < stops.length - 1; edge += 1) {
const active = wagonPlan.filter(
@@ -559,7 +562,7 @@ export function validateMixedTrainLimitsPerEdge(
);
if (!active.length) continue;
for (const violation of validateMixedTrainLimits(active, wagonTypes, limits)) {
violations.add(violation);
violations.add(`Leg ${label(edge)}${label(edge + 1)}: ${violation}`);
}
}
return [...violations];
@@ -603,7 +606,37 @@ export function maxEdgeConsistUsage(
wagonPlan: EdgeUsageSlot[],
stops: string[],
): { grossWeightTons: number; lengthMeters: number; loadedWagonCount: number } {
const totals = (slots: EdgeUsageSlot[]) => ({
return perEdgeConsistUsage(wagonPlan, stops).reduce(
(max, e) => ({
grossWeightTons: Math.max(max.grossWeightTons, e.grossWeightTons),
lengthMeters: Math.max(max.lengthMeters, e.lengthMeters),
loadedWagonCount: Math.max(max.loadedWagonCount, e.loadedWagonCount),
}),
{ grossWeightTons: 0, lengthMeters: 0, loadedWagonCount: 0 },
);
}
/** Usage of one corridor edge (between stops[edge] and stops[edge + 1]). */
export type EdgeConsistUsage = {
edge: number;
grossWeightTons: number;
lengthMeters: number;
loadedWagonCount: number;
wagonCount: number;
};
/**
* Per-edge breakdown behind {@link maxEdgeConsistUsage}: every edge's own
* consist totals, so callers can name WHICH leg breaks a limit instead of
* only reporting the heaviest figure. Two stops or fewer collapse to a
* single whole-route edge.
*/
export function perEdgeConsistUsage(
wagonPlan: EdgeUsageSlot[],
stops: string[],
): EdgeConsistUsage[] {
const totals = (edge: number, slots: EdgeUsageSlot[]): EdgeConsistUsage => ({
edge,
grossWeightTons: slots.reduce(
(sum, w) =>
sum + Number(w.tareWeightTons ?? 0) + Number(w.assignedWeightTons ?? 0),
@@ -611,19 +644,16 @@ export function maxEdgeConsistUsage(
),
lengthMeters: slots.reduce((sum, w) => sum + Number(w.lengthMeters ?? 0), 0),
loadedWagonCount: slots.filter((w) => (w.allocations?.length ?? 1) > 0).length,
wagonCount: slots.length,
});
if (stops.length <= 2) return totals(wagonPlan);
if (stops.length <= 2) return [totals(0, wagonPlan)];
const spans = slotSpans(wagonPlan, stops);
const usage = { grossWeightTons: 0, lengthMeters: 0, loadedWagonCount: 0 };
for (let edge = 0; edge < stops.length - 1; edge += 1) {
const active = totals(
return Array.from({ length: stops.length - 1 }, (_, edge) =>
totals(
edge,
wagonPlan.filter((_, i) => spans[i].from <= edge && edge < spans[i].to),
);
usage.grossWeightTons = Math.max(usage.grossWeightTons, active.grossWeightTons);
usage.lengthMeters = Math.max(usage.lengthMeters, active.lengthMeters);
usage.loadedWagonCount = Math.max(usage.loadedWagonCount, active.loadedWagonCount);
}
return usage;
),
);
}
export function validate20ftContainerRules(

View File

@@ -9,16 +9,18 @@ import {
Modal,
Progress,
ScrollArea,
Select,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import { AlertTriangle, History, Minus, Plus } from "lucide-react";
import { AlertTriangle, ArrowLeftRight, History, MapPin, Minus, Plus } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { api } from "@/services/api";
import type { ConsistWagonRef } from "@/services/trainBuilder.service";
import type { ConsistWagonRef, ScheduleConsist } from "@/services/trainBuilder.service";
import { useToast } from "@/hooks/use-toast";
const parseError = (error: unknown, fallback: string) => {
@@ -34,11 +36,16 @@ const tareOf = (w: ConsistWagonRef) => w.wagonType?.tareWeightTons ?? 0;
const lengthOf = (w: ConsistWagonRef) => w.wagonType?.lengthMeters ?? 0;
const round2 = (v: number) => Math.round(v * 100) / 100;
type ConsistWagon = ScheduleConsist["wagons"][number];
/**
* Adjust the built train's consist from a schedule: trim free wagons (their
* tare no longer rides — the fix when gross weight beats the pull limit) or
* couple extra yard wagons while weight/length headroom remains. Changes are
* permanent on the train and logged on the schedule.
* tare no longer rides — the fix when gross weight beats the pull limit),
* couple extra yard wagons while weight/length headroom remains, or SWITCH a
* wagon for a same-type replacement the replacement inherits the slot, cargo
* included, which is the only way a loaded wagon leaves the train. Works
* before departure and mid-route while the train stands at a checkpointed
* stop. Changes are permanent on the train and logged on the schedule.
*/
export default function AdjustConsistModal({
scheduleId,
@@ -48,6 +55,9 @@ export default function AdjustConsistModal({
const { toast } = useToast();
const [removeIds, setRemoveIds] = useState<string[]>([]);
const [addIds, setAddIds] = useState<string[]>([]);
// fromWagonId → toWagonId. A switch is same-type, so it never moves the
// weight/length/slot projections — it only changes which steel rides.
const [switchMap, setSwitchMap] = useState<Record<string, string>>({});
const consistQuery = useQuery(
api.trainScheduling.scheduleConsist.queryOptions({
@@ -62,13 +72,20 @@ export default function AdjustConsistModal({
if (opened) {
setRemoveIds([]);
setAddIds([]);
setSwitchMap({});
}
}, [opened]);
const switchCount = Object.keys(switchMap).length;
const usedReplacementIds = useMemo(
() => new Set(Object.values(switchMap)),
[switchMap],
);
// Live projection: gross = cargo + tare of (consist trims + adds), plus
// the schedule's wagon-slot picture — the consist IS the booking capacity
// (weight/length only bind while assembling the consist), so trims/adds
// move the FULL line in real time.
// move the FULL line in real time. Switches are same-type and cancel out.
const projection = useMemo(() => {
if (!data) return null;
const removed = new Set(removeIds);
@@ -117,25 +134,58 @@ export default function AdjustConsistModal({
};
}, [data, removeIds, addIds]);
const hasChanges = removeIds.length > 0 || addIds.length > 0;
const hasChanges = removeIds.length > 0 || addIds.length > 0 || switchCount > 0;
const toggle = (setter: typeof setRemoveIds) => (id: string, checked: boolean) =>
setter((prev) => (checked ? [...prev, id] : prev.filter((x) => x !== id)));
// Same-type replacements standing at the current stop, minus wagons already
// spoken for by another switch or a couple selection.
const switchOptionsFor = (wagon: ConsistWagon) =>
(data?.addableWagons ?? [])
.filter(
(candidate) =>
candidate.wagonType?.id === wagon.wagonType?.id &&
!addIds.includes(candidate.id) &&
(!usedReplacementIds.has(candidate.id) ||
switchMap[wagon.id] === candidate.id),
)
.map((candidate) => ({ value: candidate.id, label: candidate.wagonNumber }));
const setSwitch = (fromId: string, toId: string | null) =>
setSwitchMap((prev) => {
const next = { ...prev };
if (toId) next[fromId] = toId;
else delete next[fromId];
return next;
});
const handleSubmit = async () => {
if (!removeIds.length && !addIds.length) return;
if (!hasChanges) return;
try {
const result = await adjust.mutateAsync({
scheduleId,
payload: {
...(addIds.length ? { addWagonIds: addIds } : {}),
...(removeIds.length ? { removeWagonIds: removeIds } : {}),
...(switchCount
? {
switches: Object.entries(switchMap).map(([fromWagonId, toWagonId]) => ({
fromWagonId,
toWagonId,
})),
}
: {}),
},
});
toast({
title: `Consist updated — ${removeIds.length ? `${removeIds.length} trimmed` : ""}${
removeIds.length && addIds.length ? ", " : ""
}${addIds.length ? `${addIds.length} added` : ""}`,
title: `Consist updated — ${[
removeIds.length ? `${removeIds.length} trimmed` : "",
addIds.length ? `${addIds.length} added` : "",
switchCount ? `${switchCount} switched` : "",
]
.filter(Boolean)
.join(", ")}`,
});
// Schedule-impact warnings from the API: window reopened / now FULL /
// consist trimmed below what bookings already hold.
@@ -151,6 +201,7 @@ export default function AdjustConsistModal({
}
setRemoveIds([]);
setAddIds([]);
setSwitchMap({});
} catch (err) {
toast({
title: "Adjustment failed",
@@ -170,7 +221,7 @@ export default function AdjustConsistModal({
</Text>
}
radius="lg"
size={860}
size={920}
centered
>
{consistQuery.isLoading || !data ? (
@@ -181,9 +232,19 @@ export default function AdjustConsistModal({
</Text>
) : (
<Stack gap="md">
{data.currentStop?.isMidRoute ? (
<Alert color="blue" icon={<MapPin size={16} />}>
Standing at <strong>{data.currentStop.label}</strong> mid-route
consist work is open: couple or switch wagons standing at this
stop, trim wagons whose cargo was offloaded here. Detached wagons
stay at {data.currentStop.label}.
</Alert>
) : null}
{!data.editable ? (
<Alert color="yellow" icon={<AlertTriangle size={16} />}>
The consist is frozen once the train is dispatched.
{data.schedule.status === "DISPATCHED"
? "The train is rolling — consist changes are only possible while it stands at a route stop."
: "The consist can no longer be adjusted — the run is over."}
</Alert>
) : null}
@@ -256,37 +317,39 @@ export default function AdjustConsistModal({
) : null}
<Grid gap="md">
<Grid.Col span={{ base: 12, md: 6 }}>
<Grid.Col span={{ base: 12, md: 7 }}>
<Stack gap="xs">
<Group gap={6}>
<Minus size={14} />
<Text size="sm" fw={600}>
Trim coupled wagons ({data.totals.wagonCount})
Coupled wagons ({data.totals.wagonCount})
</Text>
</Group>
<Text size="xs" c="dimmed">
Only free (unloaded, unpinned) wagons can be detached. Detaching is
permanent the wagon returns to the yard as available.
Trim only wagons carrying nothing beyond this stop. A loaded
wagon can't leave — but it can be <strong>switched</strong>:
the same-type replacement takes its position and its cargo
slot. Detaching is permanent.
</Text>
<ScrollArea.Autosize mah={260} type="auto">
<ScrollArea.Autosize mah={280} type="auto">
<Stack gap={4}>
{data.wagons.map((wagon) => (
<WagonRow
<CoupledWagonRow
key={wagon.id}
wagon={wagon}
checked={removeIds.includes(wagon.id)}
disabled={!data.editable || !wagon.removable}
badge={
wagon.loaded ? "Loaded" : !wagon.removable ? "Pinned" : null
}
onToggle={toggle(setRemoveIds)}
editable={data.editable}
switchValue={switchMap[wagon.id] ?? null}
switchOptions={switchOptionsFor(wagon)}
onToggleRemove={toggle(setRemoveIds)}
onSwitch={setSwitch}
/>
))}
</Stack>
</ScrollArea.Autosize>
</Stack>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<Grid.Col span={{ base: 12, md: 5 }}>
<Stack gap="xs">
<Group gap={6}>
<Plus size={14} />
@@ -295,25 +358,30 @@ export default function AdjustConsistModal({
</Text>
</Group>
<Text size="xs" c="dimmed">
AVAILABLE wagons standing in the train's yard. Blocked when they push
gross weight or length past the locomotive limits incl. tolerance.
AVAILABLE wagons standing at{" "}
{data.currentStop?.label ?? "the train's yard"}. Blocked when
they push gross weight or length past the locomotive limits
incl. tolerance.
</Text>
<ScrollArea.Autosize mah={260} type="auto">
<ScrollArea.Autosize mah={280} type="auto">
<Stack gap={4}>
{data.addableWagons.length ? (
data.addableWagons.map((wagon) => (
<WagonRow
key={wagon.id}
wagon={wagon}
checked={addIds.includes(wagon.id)}
disabled={!data.editable}
badge={null}
onToggle={toggle(setAddIds)}
/>
))
data.addableWagons.map((wagon) => {
const takenBySwitch = usedReplacementIds.has(wagon.id);
return (
<AddableWagonRow
key={wagon.id}
wagon={wagon}
checked={addIds.includes(wagon.id)}
disabled={!data.editable || takenBySwitch}
badge={takenBySwitch ? "Switch target" : null}
onToggle={toggle(setAddIds)}
/>
);
})
) : (
<Text size="sm" c="dimmed" py="sm" ta="center">
No available wagons in this yard
No available wagons at this stop
</Text>
)}
</Stack>
@@ -322,6 +390,19 @@ export default function AdjustConsistModal({
</Grid.Col>
</Grid>
{switchCount ? (
<Alert color="blue" icon={<ArrowLeftRight size={16} />} py={8}>
{Object.entries(switchMap)
.map(([fromId, toId]) => {
const from = data.wagons.find((w) => w.id === fromId);
const to = data.addableWagons.find((w) => w.id === toId);
return `${from?.wagonNumber ?? "?"} → ${to?.wagonNumber ?? "?"}`;
})
.join(" · ")}{" "}
— cargo allocations move to the replacement wagon(s).
</Alert>
) : null}
{data.adjustments.length ? (
<>
<Divider />
@@ -339,9 +420,19 @@ export default function AdjustConsistModal({
<Badge
size="xs"
variant="light"
color={log.action === "ADD" ? "edr-green" : "red"}
color={
log.action === "ADD"
? "edr-green"
: log.action === "SWITCH"
? "blue"
: "red"
}
>
{log.action === "ADD" ? "Added" : "Trimmed"}
{log.action === "ADD"
? "Added"
: log.action === "SWITCH"
? "Switched"
: "Trimmed"}
</Badge>
<Text size="xs" ff="monospace">
{log.wagonNumber}
@@ -369,15 +460,19 @@ export default function AdjustConsistModal({
loading={adjust.isPending}
disabled={
!data.editable ||
(!removeIds.length && !addIds.length) ||
!hasChanges ||
(addIds.length > 0 && (projection?.overWeight || projection?.overLength))
}
onClick={handleSubmit}
>
Apply{" "}
{removeIds.length ? `${removeIds.length}` : ""}
{removeIds.length && addIds.length ? " / " : ""}
{addIds.length ? `+${addIds.length}` : ""}
{[
removeIds.length ? `${removeIds.length}` : "",
addIds.length ? `+${addIds.length}` : "",
switchCount ? `⇄${switchCount}` : "",
]
.filter(Boolean)
.join(" / ")}
</Button>
</Group>
</Group>
@@ -429,7 +524,89 @@ function LimitGauge({
);
}
function WagonRow({
/** Coupled row: trim checkbox (reason-badged when blocked) + switch picker. */
function CoupledWagonRow({
wagon,
checked,
editable,
switchValue,
switchOptions,
onToggleRemove,
onSwitch,
}: {
wagon: ConsistWagon;
checked: boolean;
editable: boolean;
switchValue: string | null;
switchOptions: Array<{ value: string; label: string }>;
onToggleRemove: (id: string, checked: boolean) => void;
onSwitch: (fromId: string, toId: string | null) => void;
}) {
const badge = wagon.loaded ? "Loaded" : !wagon.removable ? "Pinned" : null;
const checkbox = (
<Checkbox
size="sm"
checked={checked}
disabled={!editable || !wagon.removable || Boolean(switchValue)}
onChange={(e) => onToggleRemove(wagon.id, e.currentTarget.checked)}
aria-label={`Trim wagon ${wagon.wagonNumber}`}
/>
);
return (
<Group
gap="sm"
wrap="nowrap"
p={6}
style={{
border: switchValue
? "1px solid var(--mantine-color-blue-4)"
: "1px solid var(--mantine-color-gray-2)",
borderRadius: "var(--mantine-radius-md)",
background: switchValue ? "var(--mantine-color-blue-0)" : undefined,
}}
>
{wagon.blockReason ? (
<Tooltip label={wagon.blockReason} withArrow>
<span>{checkbox}</span>
</Tooltip>
) : (
checkbox
)}
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} ff="monospace" truncate>
{wagon.wagonNumber}
</Text>
<Text size="xs" c="dimmed" truncate>
{wagon.wagonType
? `${wagon.wagonType.code} · ${wagon.wagonType.tareWeightTons}T tare · ${wagon.wagonType.lengthMeters}m`
: "Unknown type"}
</Text>
</Stack>
{badge ? (
<Badge size="xs" variant="light" color={badge === "Loaded" ? "orange" : "gray"}>
{badge}
</Badge>
) : null}
{editable && wagon.switchable && switchOptions.length ? (
<Select
size="xs"
w={148}
placeholder="Switch with"
leftSection={<ArrowLeftRight size={12} />}
data={switchOptions}
value={switchValue}
onChange={(toId) => onSwitch(wagon.id, toId)}
clearable
searchable
disabled={checked}
aria-label={`Switch wagon ${wagon.wagonNumber}`}
/>
) : null}
</Group>
);
}
function AddableWagonRow({
wagon,
checked,
disabled,
@@ -471,7 +648,7 @@ function WagonRow({
</Text>
</Stack>
{badge ? (
<Badge size="xs" variant="light" color={badge === "Loaded" ? "orange" : "gray"}>
<Badge size="xs" variant="light" color="blue">
{badge}
</Badge>
) : null}

View File

@@ -574,6 +574,11 @@ export function AllocateBookingWizard({
id: b.id,
reference: b.reference ?? b.id.slice(0, 8),
weightTons: b.weightTons,
isGovernment: b.isGovernment,
wagonsRequired: b.wagonsRequired,
contractReference: b.contractReference,
origin: b.origin,
destination: b.destination,
}))}
eligibleItems={eligibleQuery.data?.items ?? []}
eligibleLoading={eligibleQuery.isLoading}

View File

@@ -0,0 +1,430 @@
import { Fragment, useMemo, useState } from "react";
import {
Alert,
Badge,
Box,
Collapse,
Group,
Paper,
Progress,
Stack,
Table,
Text,
Tooltip,
} from "@mantine/core";
import { ChevronDown, ChevronRight, Info, Train, Weight } from "lucide-react";
import type { TrainScheduleDetail } from "@/types/trainScheduling";
/**
* Per-leg capacity workspace tab. A multi-stop corridor (A→B→C→D→E) is
* capacity-checked edge by edge, so this shows, for EVERY adjacent leg, the
* wagons/weight/length the consist actually uses there — and for every
* possible origin→destination pair (A→C, B→E, …) the room left, which is the
* minimum over the legs the pair rides.
*/
interface Stop {
yardId: string;
label: string;
}
interface LegBookingUsage {
bookingId: string;
reference: string;
wagons: number;
grossTons: number;
}
interface EdgeUsage {
edge: number;
from: Stop;
to: Stop;
wagons: number;
grossTons: number;
lengthMeters: number;
bookingRefs: string[];
bookings: LegBookingUsage[];
}
const round1 = (n: number) => Math.round(n * 10) / 10;
function utilizationColor(used: number, cap: number | null): string {
if (cap == null || cap <= 0) return "gray";
const pct = used / cap;
if (pct > 1) return "red";
if (pct >= 0.9) return "orange";
if (pct >= 0.75) return "yellow";
return "teal";
}
/** Mirrors the API's slotSpans: unknown/missing yard = the schedule endpoint. */
function spanOf(
boardYardId: string | null | undefined,
alightYardId: string | null | undefined,
indexOf: Map<string, number>,
lastIdx: number,
): { from: number; to: number } {
const fromRaw = boardYardId ? indexOf.get(boardYardId) : 0;
const toRaw = alightYardId ? indexOf.get(alightYardId) : lastIdx;
const from = fromRaw != null && fromRaw >= 0 ? fromRaw : 0;
const to = toRaw != null && toRaw > 0 ? toRaw : lastIdx;
return { from, to };
}
function UsageCell({
used,
cap,
unit,
}: {
used: number;
cap: number | null;
unit: string;
}) {
const color = utilizationColor(used, cap);
const pct = cap ? Math.min(100, (used / cap) * 100) : 0;
return (
<Stack gap={4} miw={140}>
<Text size="sm" fw={600} c={cap != null && used > cap ? "red.7" : undefined}>
{round1(used)}
{cap != null ? ` / ${round1(cap)}` : ""} {unit}
</Text>
{cap != null ? <Progress value={pct} color={color} size="sm" radius="xl" /> : null}
</Stack>
);
}
export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }) {
const stops: Stop[] = schedule.stops ?? [];
const wagons = schedule.trainSet?.wagons ?? [];
const weightCap = schedule.maxGrossWeightTons ?? null;
const lengthCap = schedule.maxLengthMeters ?? null;
const wagonCap = schedule.maxWagons ?? null;
const [expandedEdge, setExpandedEdge] = useState<number | null>(null);
const edges: EdgeUsage[] = useMemo(() => {
if (stops.length < 2) return [];
const indexOf = new Map(stops.map((s, i) => [s.yardId, i]));
const lastIdx = stops.length - 1;
const spans = wagons.map((w) =>
spanOf(w.boardYardId, w.alightYardId, indexOf, lastIdx),
);
return stops.slice(0, -1).map((from, edge) => {
const active = wagons.filter(
(_, i) => spans[i].from <= edge && edge < spans[i].to,
);
const refs = new Set<string>();
let grossTons = 0;
let lengthMeters = 0;
// Per booking on this leg: wagon count (distinct wagons carrying at
// least one of its allocations — a shared wagon counts for each
// booking riding it, so per-booking wagon counts can sum to more than
// the leg's total) and its allocated weight share.
const byBooking = new Map<string, LegBookingUsage>();
for (const w of active) {
grossTons += (Number(w.tareWeightTons) || 0) + (Number(w.assignedWeightTons) || 0);
lengthMeters += Number(w.lengthMeters) || 0;
const bookingIdsOnWagon = new Set<string>();
for (const a of w.allocations ?? []) {
if (!a.bookingReference) continue;
refs.add(a.bookingReference);
const row = byBooking.get(a.bookingId) ?? {
bookingId: a.bookingId,
reference: a.bookingReference,
wagons: 0,
grossTons: 0,
};
row.grossTons += Number(a.allocatedWeightTons) || 0;
byBooking.set(a.bookingId, row);
bookingIdsOnWagon.add(a.bookingId);
}
for (const bookingId of bookingIdsOnWagon) {
const row = byBooking.get(bookingId);
if (row) row.wagons += 1;
}
}
const bookings = [...byBooking.values()]
.map((b) => ({ ...b, grossTons: round1(b.grossTons) }))
.sort((a, b) => b.grossTons - a.grossTons);
return {
edge,
from,
to: stops[edge + 1],
wagons: active.length,
grossTons: round1(grossTons),
lengthMeters: round1(lengthMeters),
bookingRefs: [...refs],
bookings,
};
});
}, [stops, wagons]);
if (stops.length < 2) {
return (
<Alert mt="lg" radius="lg" color="gray" icon={<Info size={16} />}>
This schedule has no corridor stops to break into legs.
</Alert>
);
}
if (!wagons.length) {
return (
<Alert mt="lg" radius="lg" color="gray" icon={<Info size={16} />}>
No wagon plan yet leg utilization appears once bookings are allocated
to wagons. The route strip in the header shows the booking-based
estimate meanwhile.
</Alert>
);
}
const legStatus = (e: EdgeUsage) => {
if (weightCap != null && e.grossTons > weightCap)
return <Badge color="red" variant="filled">Overweight</Badge>;
if (lengthCap != null && e.lengthMeters > lengthCap)
return <Badge color="red" variant="filled">Over length</Badge>;
const wagonsFree = wagonCap != null ? wagonCap - e.wagons : null;
const tonsFree = weightCap != null ? round1(weightCap - e.grossTons) : null;
if ((wagonsFree != null && wagonsFree <= 0) || (tonsFree != null && tonsFree <= 0))
return <Badge color="orange" variant="filled">Full</Badge>;
return (
<Badge color="teal" variant="light">
{tonsFree != null ? `${tonsFree}T free` : "Available"}
{wagonsFree != null ? ` · ${wagonsFree} wagons` : ""}
</Badge>
);
};
// Availability for a span = the tightest leg it rides.
const spanAvailability = (from: number, to: number) => {
const slice = edges.slice(from, to);
const wagonsUsed = Math.max(...slice.map((e) => e.wagons));
const tonsUsed = Math.max(...slice.map((e) => e.grossTons));
const binding = slice.reduce((worst, e) => (e.grossTons > worst.grossTons ? e : worst));
return {
wagonsFree: wagonCap != null ? wagonCap - wagonsUsed : null,
tonsFree: weightCap != null ? round1(weightCap - tonsUsed) : null,
tonsUsed,
binding,
};
};
return (
<Stack gap="lg" mt="lg">
<Paper radius="xl" p="lg" withBorder>
<Stack gap="md">
<Stack gap={2}>
<Text fw={700}>Per-leg utilization</Text>
<Text size="sm" c="dimmed">
Each adjacent leg is checked as its own train wagon tare + cargo
against the locomotive limits{weightCap != null ? ` (${weightCap}T` : ""}
{weightCap != null && lengthCap != null ? `, ${lengthCap}m` : ""}
{weightCap != null ? " incl. tolerance)" : ""}.
</Text>
</Stack>
<Table.ScrollContainer minWidth={720}>
<Table verticalSpacing="sm" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th style={{ width: 28 }} />
<Table.Th>Leg</Table.Th>
<Table.Th>Wagons</Table.Th>
<Table.Th>Gross weight</Table.Th>
<Table.Th>Length</Table.Th>
<Table.Th>Bookings</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{edges.map((e) => {
const isOpen = expandedEdge === e.edge;
const hasBookings = e.bookings.length > 0;
return (
<Fragment key={e.edge}>
<Table.Tr
style={{ cursor: hasBookings ? "pointer" : undefined }}
onClick={
hasBookings
? () => setExpandedEdge(isOpen ? null : e.edge)
: undefined
}
>
<Table.Td>
{hasBookings ? (
isOpen ? (
<ChevronDown size={14} />
) : (
<ChevronRight size={14} />
)
) : null}
</Table.Td>
<Table.Td>
<Text size="sm" fw={600} style={{ whiteSpace: "nowrap" }}>
{e.from.label} {e.to.label}
</Text>
</Table.Td>
<Table.Td>
<UsageCell used={e.wagons} cap={wagonCap} unit="wagons" />
</Table.Td>
<Table.Td>
<UsageCell used={e.grossTons} cap={weightCap} unit="T" />
</Table.Td>
<Table.Td>
<UsageCell used={e.lengthMeters} cap={lengthCap} unit="m" />
</Table.Td>
<Table.Td>
{hasBookings ? (
<Badge variant="light" color="gray" size="sm">
{e.bookings.length}
</Badge>
) : (
<Text size="sm" c="dimmed">
0
</Text>
)}
</Table.Td>
<Table.Td>{legStatus(e)}</Table.Td>
</Table.Tr>
{hasBookings ? (
<Table.Tr key={`${e.edge}-detail`}>
<Table.Td colSpan={7} p={0} style={{ border: 0 }}>
<Collapse expanded={isOpen}>
<Box
p="sm"
style={{
background: "var(--mantine-color-gray-0)",
borderTop: "1px solid var(--mantine-color-gray-2)",
borderBottom: "1px solid var(--mantine-color-gray-2)",
}}
>
<Text size="xs" fw={600} c="dimmed" mb={6} tt="uppercase">
Bookings riding {e.from.label} {e.to.label}
</Text>
<Table verticalSpacing={4} withRowBorders={false}>
<Table.Tbody>
{e.bookings.map((b) => (
<Table.Tr key={b.bookingId}>
<Table.Td w="40%">
<Text size="sm" fw={500}>
{b.reference}
</Text>
</Table.Td>
<Table.Td w="30%">
<Group gap={4}>
<Train size={12} />
<Text size="xs" c="dimmed">
{b.wagons} wagon{b.wagons === 1 ? "" : "s"}
</Text>
</Group>
</Table.Td>
<Table.Td w="30%">
<Group gap={4}>
<Weight size={12} />
<Text size="xs" c="dimmed">
{b.grossTons}T
</Text>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
</Collapse>
</Table.Td>
</Table.Tr>
) : null}
</Fragment>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</Stack>
</Paper>
{stops.length > 2 ? (
<Paper radius="xl" p="lg" withBorder>
<Stack gap="md">
<Stack gap={2}>
<Text fw={700}>Availability by origin destination</Text>
<Text size="sm" c="dimmed">
Every bookable pair along the corridor. Room for a pair is the
tightest leg it rides hover a cell to see which leg binds.
</Text>
</Stack>
<Table.ScrollContainer minWidth={520}>
<Table verticalSpacing="xs" withColumnBorders>
<Table.Thead>
<Table.Tr>
<Table.Th>From \ To</Table.Th>
{stops.slice(1).map((s) => (
<Table.Th key={s.yardId}>{s.label}</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{stops.slice(0, -1).map((from, fi) => (
<Table.Tr key={from.yardId}>
<Table.Th>{from.label}</Table.Th>
{stops.slice(1).map((to, ci) => {
const ti = ci + 1;
if (ti <= fi) {
return (
<Table.Td key={to.yardId}>
<Text size="sm" c="dimmed" ta="center">
</Text>
</Table.Td>
);
}
const avail = spanAvailability(fi, ti);
const over =
weightCap != null && avail.tonsUsed > weightCap;
const full =
!over &&
((avail.wagonsFree != null && avail.wagonsFree <= 0) ||
(avail.tonsFree != null && avail.tonsFree <= 0));
const color = over
? "var(--mantine-color-red-1)"
: full
? "var(--mantine-color-orange-1)"
: "var(--mantine-color-teal-0)";
return (
<Table.Td key={to.yardId} style={{ background: color }}>
<Tooltip
withArrow
label={`Binding leg: ${avail.binding.from.label}${avail.binding.to.label} (${avail.binding.grossTons}T${weightCap != null ? ` of ${weightCap}T` : ""})`}
>
<Box ta="center" style={{ cursor: "help" }}>
<Text
size="sm"
fw={600}
c={over ? "red.8" : full ? "orange.8" : "teal.8"}
>
{over
? "Overweight"
: full
? "Full"
: `${avail.tonsFree ?? "?"}T free`}
</Text>
{avail.wagonsFree != null && !over ? (
<Text size="xs" c="dimmed">
{Math.max(0, avail.wagonsFree)} wagons free
</Text>
) : null}
</Box>
</Tooltip>
</Table.Td>
);
})}
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</Stack>
</Paper>
) : null}
</Stack>
);
}

View File

@@ -7,7 +7,7 @@ import {
Tabs,
Text,
} from "@mantine/core";
import { ArrowRight, Landmark, Package, Train } from "lucide-react";
import { ArrowRight, FileText, Landmark, MapPin, Package, Train } from "lucide-react";
import type { EligibleContainerBooking, FreightType } from "@/types/trainScheduling";
@@ -18,6 +18,10 @@ export type AssignedBookingRow = {
reference: string;
weightTons?: number;
isGovernment?: boolean;
wagonsRequired?: number | null;
contractReference?: string | null;
origin?: string | null;
destination?: string | null;
};
export function ScheduleBookingsStep({
@@ -94,6 +98,16 @@ export function ScheduleBookingsStep({
{booking.weightTons}T
</Badge>
) : null}
{booking.wagonsRequired != null ? (
<Badge
variant="outline"
size="xs"
color="gray"
leftSection={<Train size={10} />}
>
{booking.wagonsRequired} wagon{booking.wagonsRequired === 1 ? "" : "s"}
</Badge>
) : null}
{booking.isGovernment ? (
<Badge
variant="light"
@@ -105,6 +119,26 @@ export function ScheduleBookingsStep({
</Badge>
) : null}
</Group>
{booking.contractReference || booking.origin || booking.destination ? (
<Group gap="xs">
{booking.contractReference ? (
<Group gap={4}>
<FileText size={12} />
<Text size="xs" c="dimmed">
{booking.contractReference}
</Text>
</Group>
) : null}
{booking.origin || booking.destination ? (
<Group gap={4}>
<MapPin size={12} />
<Text size="xs" c="dimmed">
{booking.origin ?? "?"} {booking.destination ?? "?"}
</Text>
</Group>
) : null}
</Group>
) : null}
<Group gap={6}>
<Text size="xs" c="dimmed">
Assigned to this consist

View File

@@ -0,0 +1,132 @@
import {
Badge,
Group,
Paper,
Stack,
Text,
ThemeIcon,
Timeline,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import {
ArrowLeftRight,
History,
MapPin,
Minus,
PackageMinus,
Plus,
User,
} from "lucide-react";
import { api } from "@/services/api";
import type { ScheduleHistoryEntry } from "@/services/trainBuilder.service";
const ACTION_META: Record<
ScheduleHistoryEntry["action"],
{ label: string; color: string; icon: typeof Plus }
> = {
ADD: { label: "Wagon coupled", color: "edr-green", icon: Plus },
REMOVE: { label: "Wagon trimmed", color: "red", icon: Minus },
SWITCH: { label: "Wagon switched", color: "blue", icon: ArrowLeftRight },
BOOKING_REMOVED: { label: "Booking removed", color: "orange", icon: PackageMinus },
};
/**
* "History" tab: every change made to the train after it was scheduled —
* wagons coupled/trimmed/switched (with the stop where it happened) and
* bookings removed from the composition — newest first.
*/
export default function ScheduleHistoryPanel({ scheduleId }: { scheduleId: string }) {
const historyQuery = useQuery(
api.trainScheduling.scheduleHistory.queryOptions({
input: { scheduleId },
enabled: Boolean(scheduleId),
}),
);
const entries = historyQuery.data ?? [];
return (
<Paper radius="xl" p="lg">
<Stack gap="lg">
<Group gap="md" align="center" wrap="nowrap">
<ThemeIcon size={44} radius="md" variant="light" color="edr-green">
<History size={22} />
</ThemeIcon>
<Stack gap={2}>
<Text fw={700} fz="lg">
Change history
</Text>
<Text size="sm" c="dimmed">
Wagons coupled, trimmed or switched and bookings removed after
this train was scheduled, newest first.
</Text>
</Stack>
</Group>
{historyQuery.isLoading ? (
<Text size="sm" c="dimmed" ta="center" py="md">
Loading history
</Text>
) : entries.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="md">
No changes recorded yet the consist and composition are as
scheduled.
</Text>
) : (
<Timeline bulletSize={26} lineWidth={2} color="edr-green">
{entries.map((entry) => {
const meta = ACTION_META[entry.action] ?? ACTION_META.ADD;
const Icon = meta.icon;
return (
<Timeline.Item
key={`${entry.kind}-${entry.id}`}
bullet={<Icon size={13} />}
color={meta.color}
title={
<Group gap="xs" wrap="nowrap">
<Badge size="sm" variant="light" color={meta.color}>
{meta.label}
</Badge>
{entry.subject ? (
<Text size="sm" fw={600} ff="monospace">
{entry.subject}
</Text>
) : null}
</Group>
}
>
<Group gap="md" mt={2}>
<Text size="xs" c="dimmed">
{new Date(entry.occurredAt).toLocaleString()}
</Text>
{entry.yardLabel ? (
<Group gap={4} wrap="nowrap">
<MapPin size={12} />
<Text size="xs" c="dimmed">
at {entry.yardLabel}
</Text>
</Group>
) : null}
{entry.actor ? (
<Group gap={4} wrap="nowrap">
<User size={12} />
<Text size="xs" c="dimmed">
{entry.actor}
</Text>
</Group>
) : null}
</Group>
{entry.note ? (
<Text size="xs" c="dimmed" mt={2} fs="italic">
{entry.note}
</Text>
) : null}
</Timeline.Item>
);
})}
</Timeline>
)}
</Stack>
</Paper>
);
}

View File

@@ -87,22 +87,46 @@ function phaseCountdown(
}
}
/** GROSS weight already on this train (each booking's cargo + wagon tare) —
* compared against the locomotive pull limit, which is a gross ceiling. */
/**
* GROSS weight the locomotives actually haul: the HEAVIEST LEG, never the
* whole-route sum — disjoint legs (Mojo→Dire + Dire→Doraleh) are pulled one
* at a time, so summing every booking over-reports a multi-stop train.
* Prefers the API's consist-derived heaviestLeg; before allocation it falls
* back to a per-leg max over the bookings (same span math as the header strip).
*/
function usedWeight(schedule: TrainScheduleDetail): number {
return (schedule.bookings ?? []).reduce(
(sum, b) => sum + (Number(b.weightTons) || 0),
0,
);
const consist = schedule.trainSet?.heaviestLeg?.grossWeightTons;
if (consist != null) return Number(consist) || 0;
const bookings = schedule.bookings ?? [];
const stops = schedule.stops ?? [];
if (stops.length <= 2) {
return bookings.reduce((sum, b) => sum + (Number(b.weightTons) || 0), 0);
}
const indexOf = new Map(stops.map((s, i) => [s.yardId, i]));
const lastIdx = stops.length - 1;
let heaviest = 0;
for (let edge = 0; edge < lastIdx; edge += 1) {
let legTons = 0;
for (const b of bookings) {
const from = (b.originYardId ? indexOf.get(b.originYardId) : undefined) ?? 0;
const toRaw = b.destinationYardId ? indexOf.get(b.destinationYardId) : lastIdx;
const to = toRaw != null && toRaw > from ? toRaw : lastIdx;
if (from <= edge && edge < to) legTons += Number(b.weightTons) || 0;
}
heaviest = Math.max(heaviest, legTons);
}
return heaviest;
}
/**
* Pull capacity of the set = the WEAKEST locomotive's max pull weight (0 when
* unknown). The API caps at the weakest loco, not the sum of all locos — a
* consist can only pull as hard as its weakest engine. Both sides of this meter
* are gross: `usedWeight` sums per-booking gross (cargo + wagon tare).
* Pull capacity of the set. Locomotive pull weights ADD UP (they haul
* together), so prefer the API's maxGrossWeightTons — the combined set limit
* incl. overage tolerance, the same ceiling the validator holds each leg to —
* and fall back to summing the locos' own limits.
*/
function pullCapacity(schedule: TrainScheduleDetail): number {
if (schedule.maxGrossWeightTons != null) return Number(schedule.maxGrossWeightTons) || 0;
const set = schedule.trainSet;
if (!set) return 0;
const locos =
@@ -111,8 +135,7 @@ function pullCapacity(schedule: TrainScheduleDetail): number {
: set.locomotive
? [set.locomotive]
: [];
if (locos.length === 0) return 0;
return Math.min(...locos.map((l) => Number(l.maxPullWeightTons) || 0));
return locos.reduce((sum, l) => sum + (Number(l.maxPullWeightTons) || 0), 0);
}
export function ScheduleWorkspacePanel({
@@ -371,6 +394,7 @@ export function ScheduleWorkspacePanel({
<Text size="xs" fw={600} c={over ? "red" : "dimmed"}>
Load {used.toFixed(1)}T
{capacity > 0 ? ` / ${capacity.toFixed(0)}T pull` : ""}
{(schedule.stops?.length ?? 0) > 2 ? " · heaviest leg" : ""}
</Text>
</Group>
{over ? (

View File

@@ -132,6 +132,8 @@ export const URL_CONSTANTS = {
CONTRACT_DOCUMENT: (id: string) => `/bookings/${id}/contract/document`,
CONTRACT_SIGN: (id: string) => `/bookings/${id}/contract/sign`,
CONTRACT_DOWNLOAD: (id: string) => `/bookings/${id}/contract`,
CARRIAGE_ACCEPTANCE_SHEET: (id: string) =>
`/bookings/${id}/carriage-acceptance-sheet`,
SUMMARY: (id: string) => `/bookings/${id}/summary`,
CUSTOMER_SIGN: (id: string) => `/bookings/${id}/customer/sign`,
MARKETING_APPROVE: (id: string) => `/bookings/${id}/marketing/approve`,

View File

@@ -1,7 +1,9 @@
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import toast from "react-hot-toast";
import {
ArrowLeft,
FileSignature,
FileText,
FolderOpen,
Layers,
LayoutGrid,
@@ -50,6 +52,7 @@ import {
useBookingMutations,
} from "@/hooks/bookings/useBookings";
import { useScrollToHash } from "@/hooks/useScrollToHash";
import { bookingsService } from "@/services/bookings.service";
export default function BookingRequestDetailPage() {
const { id } = useParams<{ id: string }>();
@@ -269,6 +272,33 @@ export default function BookingRequestDetailPage() {
View / sign contract
</Button>
)}
<Button
fullWidth
variant="default"
leftSection={<FileText size={16} />}
onClick={async () => {
try {
const blob =
await bookingsService.downloadCarriageAcceptanceSheet(
booking.id,
);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `carriage-acceptance-${booking.reference}.pdf`;
a.click();
URL.revokeObjectURL(url);
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Carriage acceptance sheet is not available yet",
);
}
}}
>
Carriage acceptance sheet
</Button>
{booking.customsClearingEnabled && (
<Button
fullWidth

View File

@@ -26,6 +26,7 @@ import {
Container as ContainerIcon,
Eye,
FileText,
History as HistoryIcon,
LayoutGrid,
Navigation,
Package,
@@ -50,6 +51,8 @@ import {
import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid";
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel";
import ScheduleHistoryPanel from "@/components/trainScheduling/ScheduleHistoryPanel";
import { YardWorkPanel } from "@/components/trainScheduling/YardWorkPanel";
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
@@ -645,6 +648,10 @@ export default function TrainScheduleV2DetailPage() {
reference: b.reference ?? b.id.slice(0, 8),
weightTons: b.weightTons,
isGovernment: b.isGovernment,
wagonsRequired: b.wagonsRequired,
contractReference: b.contractReference,
origin: b.origin,
destination: b.destination,
}))}
eligibleItems={eligibleQuery.data?.items ?? []}
eligibleLoading={eligibleQuery.isLoading}
@@ -1162,6 +1169,12 @@ export default function TrainScheduleV2DetailPage() {
<Tabs.Tab value="workspace" leftSection={<PackageCheck size={16} />}>
Workspace
</Tabs.Tab>
<Tabs.Tab value="legs" leftSection={<RouteIcon size={16} />}>
Leg capacity
</Tabs.Tab>
<Tabs.Tab value="history" leftSection={<HistoryIcon size={16} />}>
History
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="workflow">
@@ -1243,6 +1256,14 @@ export default function TrainScheduleV2DetailPage() {
/>
) : null}
</Tabs.Panel>
<Tabs.Panel value="legs">
<LegCapacityPanel schedule={schedule} />
</Tabs.Panel>
<Tabs.Panel value="history">
{scheduleId ? <ScheduleHistoryPanel scheduleId={scheduleId} /> : null}
</Tabs.Panel>
</Tabs>
{scheduleId ? (

View File

@@ -1,4 +1,4 @@
import { Fragment, useMemo, useState } from "react";
import { Fragment, useMemo, useState, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
ActionIcon,
@@ -69,6 +69,13 @@ export default function ContainerReturnsPage() {
},
});
const { data: returnedContainers = [] } = useQuery({
queryKey: ["empty-container-returns"],
queryFn: async () => {
return await importOperationsService.listEmptyReturns().catch(() => []);
},
});
const bookingIds = unloadedQueue.map((item) => item.bookingId).filter(Boolean) as string[];
const containerReturnsQuery = useQuery({
queryKey: ["container-returns", bookingIds],
@@ -249,6 +256,42 @@ export default function ContainerReturnsPage() {
</Button>
</Group>
{returnedContainers.length > 0 && (
<>
<Text fw={600} mb="xs">Returned Containers</Text>
<Table.ScrollContainer minWidth={1000} mb="lg">
<Table striped highlightOnHover verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Container Number</Table.Th>
<Table.Th>Booking Ref</Table.Th>
<Table.Th>Returned Date</Table.Th>
<Table.Th>Facility</Table.Th>
<Table.Th>Yard</Table.Th>
<Table.Th>Condition</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{returnedContainers.map((ret: any) => (
<Table.Tr key={ret.id}>
<Table.Td>{ret.containerNumber}</Table.Td>
<Table.Td>{ret.bookingId ? "Associated" : "—"}</Table.Td>
<Table.Td>{ret.returnDate ? new Date(ret.returnDate).toLocaleDateString() : "—"}</Table.Td>
<Table.Td>{ret.facility || "—"}</Table.Td>
<Table.Td>{ret.yard || "—"}</Table.Td>
<Table.Td>{ret.condition || "—"}</Table.Td>
<Table.Td>
<Badge size="sm">{ret.status}</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</>
)}
{filteredGroups.length === 0 ? (
<Alert color="gray">No {filterType !== "all" ? filterType : ""} container returns found.</Alert>
) : (
@@ -515,11 +558,15 @@ interface StandaloneReturnModalProps {
function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: StandaloneReturnModalProps) {
const [containerNumber, setContainerNumber] = useState<string>("");
const [returnedBy, setReturnedBy] = useState<"EDR" | "CUSTOMER" | null>(null);
const [returnDate, setReturnDate] = useState<string>(new Date().toISOString().split("T")[0]);
const [warehouse, setWarehouse] = useState<string | null>(null);
const [yard, setYard] = useState<string>("");
const [zone, setZone] = useState<string>("");
const [condition, setCondition] = useState<string>("");
const [handoverNote, setHandoverNote] = useState<string>("");
// Auto-populate yard and zone from selected warehouse
const { data: warehousesResponse } = useQuery({
queryKey: ["warehouses-list"],
queryFn: async () => {
@@ -528,6 +575,18 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
});
const warehouses = (warehousesResponse as any)?.data ?? warehousesResponse ?? [];
const selectedWarehouseData = warehouse && Array.isArray(warehouses) ? warehouses.find((wh: any) => wh.id === warehouse) : null;
useEffect(() => {
if (selectedWarehouseData) {
setYard(selectedWarehouseData.yard || selectedWarehouseData.code || "");
setZone(selectedWarehouseData.zone || "");
} else {
setYard("");
setZone("");
}
}, [selectedWarehouseData]);
const warehouseOptions = Array.isArray(warehouses)
? warehouses.map((wh: any) => ({
value: wh.id,
@@ -536,7 +595,7 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
: [];
const handleSubmit = () => {
if (!containerNumber || !warehouse) return;
if (!containerNumber || !warehouse || !returnedBy) return;
const selectedWarehouse = Array.isArray(warehouses) ? warehouses.find((wh: any) => wh.id === warehouse) : null;
@@ -545,7 +604,7 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
{
bookingId: null,
customerId: null,
returnType: "CUSTOMER",
returnType: returnedBy,
containers: [
{
containerNumber,
@@ -560,8 +619,11 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
});
setContainerNumber("");
setReturnedBy(null);
setReturnDate(new Date().toISOString().split("T")[0]);
setWarehouse(null);
setYard("");
setZone("");
setCondition("");
setHandoverNote("");
onClose();
@@ -582,6 +644,18 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
required
/>
<Select
label="Returned By"
placeholder="Select truck type"
value={returnedBy}
onChange={(val) => setReturnedBy(val as "EDR" | "CUSTOMER" | null)}
data={[
{ value: "EDR", label: "EDR Truck" },
{ value: "CUSTOMER", label: "Customer Truck" },
]}
required
/>
<Select
label="Return Warehouse"
placeholder="Select warehouse for container return"
@@ -592,6 +666,22 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
searchable
/>
<TextInput
label="Yard"
placeholder="Auto-populated from warehouse"
value={yard}
disabled
readOnly
/>
<TextInput
label="Zone"
placeholder="Auto-populated from warehouse"
value={zone}
disabled
readOnly
/>
<input
type="date"
value={returnDate}
@@ -622,7 +712,7 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
</Button>
<Button
onClick={handleSubmit}
disabled={!containerNumber || !warehouse}
disabled={!containerNumber || !returnedBy || !warehouse}
loading={loading}
>
Record Return

View File

@@ -194,6 +194,7 @@ import {
type BuiltTrainListFilters,
type BuiltTrainListResponse,
type ScheduleConsist,
type ScheduleHistoryEntry,
type TrainComposition,
type UpdateTrainDetailsPayload,
type UsedTrainNumbers,
@@ -362,6 +363,18 @@ export const api = {
() => TRAIN_BUILDER_INVALIDATIONS,
),
scheduleHistory: endpoint<{ scheduleId: string }, ScheduleHistoryEntry[]>(
"train-scheduling",
"schedule-history",
({ scheduleId }) =>
trainBuilderService.scheduleHistory(scheduleId).then((r) => r.data),
({ scheduleId }) => [
...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
"history",
scheduleId,
],
),
bookableSchedules: endpoint<
{ originYardId?: string | null; destinationYardId?: string | null },
BookableSchedule[]

View File

@@ -455,6 +455,13 @@ export const bookingsService = {
return (unwrap(response.data) ?? []) as BookingDetail[];
},
downloadCarriageAcceptanceSheet: async (id: string): Promise<Blob> => {
const response = await client.get(B.CARRIAGE_ACCEPTANCE_SHEET(id), {
responseType: "blob",
});
return ensurePdfBlob(response.data as Blob);
},
getDjClearanceQueue: async (): Promise<BookingDetail[]> => {
const response = await client.get(B.CLEARANCE_DJ_QUEUE);
return (unwrap(response.data) ?? []) as BookingDetail[];

View File

@@ -231,17 +231,28 @@ export interface ScheduleConsist {
grossTons: number;
consistLengthMeters: number;
};
wagons: Array<ConsistWagonRef & { loaded: boolean; removable: boolean }>;
wagons: Array<
ConsistWagonRef & {
loaded: boolean;
removable: boolean;
/** Loaded wagons can't leave, but their SLOT can change wagon. */
switchable: boolean;
blockReason: string | null;
}
>;
addableWagons: ConsistWagonRef[];
adjustments: Array<{
id: string;
action: "ADD" | "REMOVE";
action: "ADD" | "REMOVE" | "SWITCH";
wagonId: string;
wagonNumber: string;
adjustedByUserId: string | null;
yardId: string | null;
occurredAt: string;
}>;
editable: boolean;
/** Where the train stands — mid-route this is the checkpointed stop. */
currentStop: { yardId: string; label: string; isMidRoute: boolean } | null;
/**
* Wagon-slot picture of the schedule: the consist IS the booking capacity
* (weight/length only bind while building the consist), so the dialog can
@@ -259,6 +270,20 @@ export interface ScheduleConsist {
export interface AdjustConsistPayload {
addWagonIds?: string[];
removeWagonIds?: string[];
/** Replacement takes the outgoing wagon's position and slot, cargo included. */
switches?: Array<{ fromWagonId: string; toWagonId: string }>;
}
/** One row of the schedule's unified change history (History tab). */
export interface ScheduleHistoryEntry {
id: string;
kind: "WAGON" | "BOOKING";
action: "ADD" | "REMOVE" | "SWITCH" | "BOOKING_REMOVED";
subject: string | null;
yardLabel: string | null;
actor: string | null;
note: string | null;
occurredAt: string;
}
/** Adjust response: fresh consist + schedule-impact warnings to surface. */
@@ -302,10 +327,15 @@ export const trainBuilderService = {
/** Consist snapshot for a train-bound schedule (adjust-consist UI). */
scheduleConsist: (scheduleId: string) =>
apiClient.get<ScheduleConsist>(`/train-scheduling/schedules/${scheduleId}/consist`),
/** Permanently trim/add wagons on the schedule's built train. */
/** Permanently trim/add/switch wagons on the schedule's built train. */
adjustConsist: (scheduleId: string, payload: AdjustConsistPayload) =>
apiClient.post<AdjustConsistResult>(
`/train-scheduling/schedules/${scheduleId}/adjust-consist`,
payload,
),
/** Unified wagon/booking change history for the schedule's History tab. */
scheduleHistory: (scheduleId: string) =>
apiClient.get<ScheduleHistoryEntry[]>(
`/train-scheduling/schedules/${scheduleId}/history`,
),
};

View File

@@ -653,6 +653,9 @@ export interface TrainScheduleDetail {
/** Empty-wagon weight from the wagon type — gross = tare + cargo. */
tareWeightTons?: number | null;
status?: string;
/** Corridor span this slot rides; null = the schedule's own endpoint. */
boardYardId?: string | null;
alightYardId?: string | null;
physicalWagonId?: string | null;
physicalWagonNumber?: string | null;
wagonType?: {
@@ -682,6 +685,7 @@ export interface TrainScheduleDetail {
destinationYardId?: string | null;
origin?: string | null;
destination?: string | null;
contractReference?: string | null;
wagonsRequired?: number | null;
loadedAt?: string | null;
arrivedAt?: string | null;
@@ -693,6 +697,8 @@ export interface TrainScheduleDetail {
stops?: Array<{ yardId: string; label: string }>;
/** Loco pull ceiling incl. overage tolerance — per-leg gross is held to it. */
maxGrossWeightTons?: number | null;
/** Train length ceiling incl. overage tolerance — per-leg length is held to it. */
maxLengthMeters?: number | null;
warnings?: string[];
}