feat: implement wagon transfer management modals and page

- Add TransferFulfillModal for fulfilling wagon transfer requests.
- Create TransferRequestFormModal for filing new wagon transfer requests.
- Introduce TransferCloseShortModal for closing requests that cannot be fully fulfilled.
- Develop WagonTransfersPage to manage and display wagon transfer requests.
- Implement utility functions for handling wagon transfer request data and UI components.
- Enhance UI with Mantine components for better user experience.
This commit is contained in:
Marshal
2026-07-26 15:11:50 +00:00
parent 9a1c8e5603
commit 9b13fa2ac6
40 changed files with 2584 additions and 809 deletions

View File

@@ -0,0 +1,94 @@
import { orderConsistWagons } from './consist-order.util';
// Built train: A-B-C-D coupled in that order. Slots are created by the wagon
// PLAN, so their sequenceNo says nothing about where the wagon actually sits.
const TRAIN = ['A', 'B', 'C', 'D'];
const slot = (sequenceNo: number, physicalWagonId: string | null) => ({
sequenceNo,
physicalWagonId,
});
describe('orderConsistWagons', () => {
it('draws slots in the train coupling order, not slot order', () => {
// Plan order says D then B; the train says B sits ahead of D.
const drawn = orderConsistWagons([slot(1, 'D'), slot(2, 'B')], {
physicalWagonIdsInOrder: TRAIN,
});
expect(drawn.map((w) => w.physicalWagonId)).toEqual(['B', 'D']);
expect(drawn.map((w) => w.position)).toEqual([1, 2]);
});
it('interleaves empty consist wagons in their real place', () => {
// Loaded slots on A and C; B and D ride along empty. The empties used to be
// appended after every loaded slot, so the drawing was never the train.
const drawn = orderConsistWagons(
[slot(1, 'A'), slot(2, 'C'), slot(98, 'B'), slot(99, 'D')],
{ physicalWagonIdsInOrder: TRAIN },
);
expect(drawn.map((w) => w.physicalWagonId)).toEqual(['A', 'B', 'C', 'D']);
});
it('keeps every wagon in place when a load moves between wagons', () => {
// Load sat on A (slot 1); staff drag it onto empty D. The move repins the
// slot, so the SAME slot now reads as wagon D and A falls back to empty.
const before = orderConsistWagons([slot(1, 'A'), slot(98, 'D')], {
physicalWagonIdsInOrder: TRAIN,
});
const after = orderConsistWagons([slot(1, 'D'), slot(98, 'A')], {
physicalWagonIdsInOrder: TRAIN,
});
// A is drawn first and D last, before and after — the train did not shuffle.
expect(before.map((w) => w.physicalWagonId)).toEqual(['A', 'D']);
expect(after.map((w) => w.physicalWagonId)).toEqual(['A', 'D']);
});
it('follows a train-builder reorder without touching any slot row', () => {
const slots = [slot(1, 'A'), slot(2, 'B')];
// Builder swaps the coupling order; the slots are untouched.
const drawn = orderConsistWagons(slots, {
physicalWagonIdsInOrder: ['B', 'A', 'C', 'D'],
});
expect(drawn.map((w) => w.physicalWagonId)).toEqual(['B', 'A']);
});
it('draws back-to-front when the caller reverses the train', () => {
const drawn = orderConsistWagons([slot(1, 'A'), slot(2, 'C')], {
physicalWagonIdsInOrder: [...TRAIN].reverse(),
reverseWagonOrder: true,
});
expect(drawn.map((w) => w.physicalWagonId)).toEqual(['C', 'A']);
});
it('parks unpinned slots last, in slot order', () => {
const drawn = orderConsistWagons([slot(9, null), slot(4, null), slot(1, 'C')], {
physicalWagonIdsInOrder: TRAIN,
});
expect(drawn.map((w) => [w.physicalWagonId, w.sequenceNo])).toEqual([
['C', 1],
[null, 4],
[null, 9],
]);
});
it('falls back to slot order when there is no built train', () => {
// Frozen schedules and loose-wagon schedules pass no physical order.
const drawn = orderConsistWagons([slot(2, 'X'), slot(1, 'Y')], {
physicalWagonIdsInOrder: [],
});
expect(drawn.map((w) => w.sequenceNo)).toEqual([1, 2]);
const reversed = orderConsistWagons([slot(1, 'X'), slot(2, 'Y')], {
physicalWagonIdsInOrder: [],
reverseWagonOrder: true,
});
expect(reversed.map((w) => w.sequenceNo)).toEqual([2, 1]);
});
});

View File

@@ -0,0 +1,54 @@
/**
* Draw order for a schedule's consist.
*
* A slot's stored `sequenceNo` is its place in the wagon PLAN, not its place in
* the train. The train's real coupling order lives on the physical wagons
* (`wagons.sequence_number`), which the caller passes in already ordered — ASC
* normally, DESC for a `reverseWagonOrder` schedule.
*
* Ordering by the physical wagon is what keeps the drawing honest:
* - moving a load between wagons repaints WHICH wagon is loaded and never
* shuffles the train, because each slot is drawn wherever its wagon sits;
* - a train-builder reorder lands on the next read, allocations included,
* since the order is derived on every read instead of copied at pin time.
*
* Slots with no physical wagon (not pinned yet, or a schedule that isn't tied
* to a built train) have no place in the consist — they keep slot order, last.
*/
export interface ConsistOrderable {
sequenceNo: number;
physicalWagonId?: string | null;
}
export interface ConsistOrderOptions {
/**
* Every wagon coupled to the built train, in real coupling order (already
* reversed by the caller for a `reverseWagonOrder` schedule). Empty for a
* frozen schedule or one with no built train — the consist then keeps slot
* order.
*/
physicalWagonIdsInOrder: string[];
reverseWagonOrder?: boolean;
}
export const orderConsistWagons = <T extends ConsistOrderable>(
wagons: T[],
{ physicalWagonIdsInOrder, reverseWagonOrder }: ConsistOrderOptions,
): (T & { position: number })[] => {
const physicalOrder = new Map(physicalWagonIdsInOrder.map((id, index) => [id, index]));
const bySlotSequence = (a: T, b: T) =>
reverseWagonOrder ? b.sequenceNo - a.sequenceNo : a.sequenceNo - b.sequenceNo;
const ordered = physicalOrder.size
? [...wagons].sort((a, b) => {
const ai = a.physicalWagonId ? physicalOrder.get(a.physicalWagonId) : undefined;
const bi = b.physicalWagonId ? physicalOrder.get(b.physicalWagonId) : undefined;
if (ai == null && bi == null) return bySlotSequence(a, b);
if (ai == null) return 1;
if (bi == null) return -1;
return ai - bi;
})
: [...wagons].sort(bySlotSequence);
return ordered.map((wagon, index) => ({ ...wagon, position: index + 1 }));
};

View File

@@ -147,6 +147,7 @@ import {
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
DEFAULT_CONTAINER_WAGON_TARE_TONS,
} from './booking-batch.constants';
import { orderConsistWagons } from './consist-order.util';
import {
computeExportWindowTimes,
computeImportWindowTimes,
@@ -6696,6 +6697,18 @@ export class TrainSchedulingService {
consistOnly: true,
}));
// The consist is DRAWN in the built train's real coupling order (rawConsistWagons
// is already ASC/DESC per reverseWagonOrder), not in slot order — see
// consist-order.util. `position` is the drawn place, 1..n; `sequenceNo` stays
// the slot's own stored value.
const drawConsist = <T extends { sequenceNo: number; physicalWagonId: string | null }>(
list: T[],
) =>
orderConsistWagons(list, {
physicalWagonIdsInOrder: rawConsistWagons.map((wagon) => wagon.id),
reverseWagonOrder: schedule.reverseWagonOrder,
});
return {
id: schedule.id,
reference: schedule.reference ?? null,
@@ -6785,7 +6798,8 @@ export class TrainSchedulingService {
maxPullWeightTons: roundTons(Number(loco.maxPullWeightTons)),
maxTrainLengthMeters: roundTons(Number(loco.maxTrainLengthMeters)),
})),
wagons: (schedule.trainSet.wagons ?? [])
wagons: drawConsist(
(schedule.trainSet.wagons ?? [])
.map((wagon) => {
// Frozen schedules read the wagon number + allocations from the
// snapshot slot; the immutable slot geometry (capacity/type) still
@@ -6875,12 +6889,8 @@ export class TrainSchedulingService {
})) ?? [],
};
})
.concat(emptyConsistWagons)
.sort((a, b) =>
schedule.reverseWagonOrder
? b.sequenceNo - a.sequenceNo
: a.sequenceNo - b.sequenceNo,
),
.concat(emptyConsistWagons),
),
}
: null,
bookings:
@@ -7458,8 +7468,12 @@ export class TrainSchedulingService {
];
const cargoOf = (allocs: WagonBookingAllocation[]) =>
allocs.reduce((sum, a) => sum + Number(a.allocatedWeightTons || 0), 0);
const wagonLabel = (slot: { sequenceNo: number } | null, wagon: Wagon | null) =>
slot ? `#${slot.sequenceNo}` : (wagon?.wagonNumber ?? 'the target wagon');
// Name wagons by their physical number — the consist is drawn in the train's
// coupling order, so a slot's sequenceNo is not the position staff can see.
const slotLabel = (slot: TrainSetWagon) =>
slot.physicalWagon?.wagonNumber ?? `#${slot.sequenceNo}`;
const wagonLabel = (slot: TrainSetWagon | null, wagon: Wagon | null) =>
slot ? slotLabel(slot) : (wagon?.wagonNumber ?? 'the target wagon');
const checkReceives = (
allocs: WagonBookingAllocation[],
label: string,
@@ -7501,7 +7515,7 @@ export class TrainSchedulingService {
if (targetAllocs.length) {
checkReceives(
targetAllocs,
`#${source.sequenceNo}`,
slotLabel(source),
source.wagonType,
Number(source.capacityTons),
);