mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' into freight/chore/payment-test
This commit is contained in:
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
// 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.
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||
|
||||
@@ -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
|
||||
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}
|
||||
badge={null}
|
||||
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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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`,
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user