mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 10:10:57 +00:00
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:
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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 }));
|
||||
};
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user