Files
edr-platform/apps/edr-freight-web/backoffice/src/components/warehouses/TrainWagonLoadModal.tsx
hager 076884623e feat(warehouses): load trains from the warehouse, mirroring the schedule
Ready to Load listed inventory and offered an auto-load picker, but the
warehouse floor works train by train: a train stands at the yard, its
bookings board wagon by wagon, it rolls. That view existed only on the
train schedule workspace, which the warehouse staff do not run.

Ready to Load now has an Items / By train switch. By train is a mirror
of the schedule's own column, placed where the loading actually happens.

Nothing in it has its own rules. Train position, the per-yard loading and
unloading windows, and the per-booking Load / Wagons / Unload actions all
come from the train-scheduling endpoints the schedule workspace already
calls, so a Load that would be refused there is disabled here with the
same reason and the two surfaces cannot disagree. No train-scheduling
code is touched.

What the warehouse adds is what the schedule cannot see: which of the
train's bookings are physically in the shed, with their GRN and
inspection state, laid out along the flow the staff follow -- receive and
GRN, inspect, ready, open the loading window, train at yard, load per
wagon, dispatch, unload at port.

Wagons
- The wagon modal calls the same per-wagon journey endpoints, so every
  server gate (train at the yard, window started, PAID, GRN) is the
  schedule's own.
- Wagons go one at a time in order: the server flips the booking to
  IN_TRANSIT or ARRIVED on whichever call clears the last wagon, so
  sequential is required, not merely tidy. A failure stops the run, the
  wagons already sent stay done, and the toast says how many, so a retry
  only resends the rest.
- Deliberately not mirrored: cancelling wagons that will not ride, and
  the direct truck-to-train handover. Both are commercial decisions
  (fees, credits, GRN waiver) that belong to the schedule workspace.

loadable-trains takes includeDispatched. Loading follows the train after
it rolls, since a mid-corridor warehouse boards its cargo when the train
stands at its yard, and the train-centric view needs the same set the
schedule offers Load on. The default stays pre-dispatch only, so the
existing auto-load picker is unchanged.

Also fixes the backoffice build: ReceiveInventoryModal used MultiSelect
without importing it, left behind by the self-haul assignment work.
2026-09-03 04:38:46 +00:00

321 lines
12 KiB
TypeScript

import { useState } from 'react';
import {
Alert,
Badge,
Button,
Checkbox,
Group,
Modal,
Paper,
Stack,
Text,
ThemeIcon,
Tooltip,
} from '@mantine/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { CheckCircle2, Info, PackageCheck, PackageOpen, Train } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { api } from '@/services/api';
import type { BookingWagonRow } from '@/types/trainScheduling';
import { extractErrorMessage } from './options';
/**
* Wagon-by-wagon load / unload of one booking on one train — the warehouse
* mirror of the train schedule's "Wagons" button.
*
* It calls the SAME per-wagon journey endpoints the schedule workspace calls
* (`schedules/:id/bookings/:bookingId/wagons/:allocationId/load|unload`), so
* every server gate — train at the yard, loading window started, PAID, GRN —
* is the schedule's own, and the two surfaces can never disagree on what got
* loaded. Wagons go one at a time in order: the server flips the booking to
* IN_TRANSIT / ARRIVED on whichever call clears the last wagon, so sequential
* is required, not just convenient. A failure stops the run; the wagons
* already sent stay done and the toast says how many, so a retry only resends
* the rest.
*
* Deliberately NOT mirrored here: cancelling wagons that will not ride and the
* direct truck-to-train handover. Both are commercial/allocation decisions
* (fees, credits, GRN waiver) that belong to the train schedule workspace, not
* the warehouse floor.
*/
export function TrainWagonLoadModal({
scheduleId,
bookingId,
reference,
phase,
onClose,
onChanged,
}: {
scheduleId: string;
bookingId: string;
reference: string;
phase: 'load' | 'unload';
onClose: () => void;
onChanged?: () => void;
}) {
const { toast } = useToast();
const qc = useQueryClient();
const [picked, setPicked] = useState<Set<string>>(new Set());
const [submitting, setSubmitting] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const wagonsQuery = useQuery(
api.trainScheduling.bookingWagons.queryOptions({ input: { bookingId } }),
);
const wagons: BookingWagonRow[] = wagonsQuery.data ?? [];
const isDone = (w: BookingWagonRow) =>
phase === 'load' ? w.status === 'LOADED' || w.status === 'DEPARTED' : w.status === 'DEPARTED';
const doneCount = wagons.filter(isDone).length;
const pending = wagons.filter((w) => !isDone(w));
const pickedPending = pending.filter((w) => picked.has(w.allocationId));
const loadWagon = useMutation(api.trainScheduling.loadScheduleBookingWagon.mutationOptions());
const unloadWagon = useMutation(api.trainScheduling.unloadScheduleBookingWagon.mutationOptions());
const act = phase === 'load' ? loadWagon : unloadWagon;
const toggle = (allocationId: string) =>
setPicked((prev) => {
const next = new Set(prev);
if (next.has(allocationId)) next.delete(allocationId);
else next.add(allocationId);
return next;
});
const afterChange = () => {
void wagonsQuery.refetch();
// The warehouse queues read inventory status, which the journey load moves.
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
void qc.invalidateQueries({ queryKey: ['train-loadable-items'] });
void qc.invalidateQueries({ queryKey: ['loadable-trains'] });
onChanged?.();
};
const submit = async () => {
const targets = pickedPending;
if (!targets.length) return;
setConfirmOpen(false);
setSubmitting(true);
let done = 0;
let completed = false;
try {
for (const w of targets) {
const r = await act.mutateAsync({
scheduleId,
bookingId,
allocationId: w.allocationId,
});
done += 1;
if (r.completed) completed = true;
}
afterChange();
setPicked(new Set());
if (completed) {
toast({
title: phase === 'load' ? 'Booking fully loaded' : 'Booking fully unloaded',
description:
phase === 'load'
? `${reference}: every wagon is loaded — the booking is in transit.`
: `${reference}: every wagon is unloaded — the booking arrived.`,
});
onClose();
} else {
toast({
title: phase === 'load' ? 'Wagons loaded' : 'Wagons unloaded',
description: `${reference}: ${done} wagon${done === 1 ? '' : 's'} ${phase === 'load' ? 'loaded' : 'unloaded'}.`,
});
}
} catch (error) {
if (done > 0) afterChange();
toast({
variant: 'destructive',
title: phase === 'load' ? 'Wagon load failed' : 'Wagon unload failed',
description: done
? `${done} wagon(s) went through before this: ${extractErrorMessage(error)}`
: extractErrorMessage(error),
});
} finally {
setSubmitting(false);
}
};
const color = phase === 'load' ? 'edr-green' : 'orange';
const Icon = phase === 'load' ? PackageCheck : PackageOpen;
return (
<Modal
opened
onClose={onClose}
title={
<Group gap={8}>
<Train size={18} />
<Text fw={700}>
{phase === 'load' ? 'Load' : 'Unload'} {reference} wagon by wagon
</Text>
</Group>
}
centered
radius="lg"
size="lg"
>
<Stack gap="sm">
<Group gap={8}>
<Badge size="sm" radius="sm" variant="light" color={doneCount ? color : 'gray'}>
{doneCount}/{wagons.length} {phase === 'load' ? 'loaded' : 'unloaded'}
</Badge>
{phase === 'load' && doneCount > 0 && pending.length > 0 ? (
<Text size="xs" c="dimmed">
The train cannot dispatch until the rest are loaded or cancelled.
</Text>
) : null}
</Group>
{wagonsQuery.isLoading ? (
<Text size="sm" c="dimmed">
Loading wagons
</Text>
) : wagons.length === 0 ? (
<Text size="sm" c="dimmed">
No wagon allocations yet use the whole-booking button instead.
</Text>
) : (
wagons.map((w) => (
<Paper key={w.allocationId} withBorder radius="md" p="xs">
<Group justify="space-between" wrap="nowrap">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
{!isDone(w) ? (
<Checkbox
checked={picked.has(w.allocationId)}
onChange={() => toggle(w.allocationId)}
disabled={submitting}
color={color}
aria-label={`Select wagon ${w.sequenceNo ?? ''} to ${phase}`}
/>
) : null}
<Badge size="sm" radius="sm" variant="outline" color="gray">
{w.sequenceNo != null ? `#${w.sequenceNo}` : '—'}
</Badge>
<Text size="sm" fw={600} truncate>
{w.wagonNumber ?? w.wagonType ?? 'Wagon'}
</Text>
<Text size="xs" c="dimmed">
{w.wagonTypeCode ?? ''}
{w.allocatedWeightTons ? ` · ${Number(w.allocatedWeightTons).toFixed(1)}T` : ''}
{w.containers?.length ? ` · ${w.containers.length} ctr` : ''}
</Text>
</Group>
{isDone(w) ? (
<Badge
size="sm"
radius="sm"
variant="filled"
color={color}
leftSection={<CheckCircle2 size={11} />}
>
{phase === 'load' ? 'Loaded' : 'Unloaded'}
</Badge>
) : null}
</Group>
</Paper>
))
)}
{pending.length > 0 && !confirmOpen ? (
<Group justify="space-between" wrap="wrap" gap="sm">
<Group gap={8}>
<Button
size="compact-sm"
variant="subtle"
radius="md"
disabled={submitting}
onClick={() =>
setPicked(
picked.size === pending.length
? new Set()
: new Set(pending.map((w) => w.allocationId)),
)
}
>
{picked.size === pending.length ? 'Clear all' : 'Select all'}
</Button>
<Text size="xs" c="dimmed">
{pickedPending.length} of {pending.length} selected
</Text>
</Group>
<Tooltip
label={
phase === 'load'
? 'Load the selected wagons — export cargo must already be received at the warehouse with a GRN.'
: 'Unload the selected wagons.'
}
withArrow
>
<Button
color={color}
radius="md"
leftSection={<Icon size={14} />}
disabled={!pickedPending.length || submitting}
loading={submitting}
onClick={() => setConfirmOpen(true)}
>
{phase === 'load' ? 'Load' : 'Unload'} {pickedPending.length || ''}
</Button>
</Tooltip>
</Group>
) : null}
{confirmOpen ? (
<Paper withBorder radius="md" p="sm">
<Stack gap="xs">
<Group gap={10} wrap="nowrap">
<ThemeIcon size={40} radius="md" variant="light" color={color}>
<Icon size={21} />
</ThemeIcon>
<div>
<Text fw={800}>
{phase === 'load' ? 'Load' : 'Unload'} {pickedPending.length} wagon
{pickedPending.length === 1 ? '' : 's'}?
</Text>
<Text size="xs" c="dimmed">
{reference}
</Text>
</div>
</Group>
<Text size="sm">
{phase === 'load'
? 'Stamps the selected wagons as loaded at this yard. Export cargo must already be received at the warehouse with a GRN.'
: 'Stamps the selected wagons as unloaded and frees them for reuse.'}
</Text>
{pickedPending.length < pending.length ? (
<Text size="xs" c="dimmed">
{pending.length - pickedPending.length} wagon
{pending.length - pickedPending.length === 1 ? '' : 's'} left un
{phase === 'load' ? 'loaded' : 'unloaded'} the train cannot dispatch until they
are {phase === 'load' ? 'loaded' : 'unloaded'} or cancelled.
</Text>
) : null}
<Group justify="flex-end" gap="sm">
<Button variant="default" radius="md" onClick={() => setConfirmOpen(false)}>
Back
</Button>
<Button color={color} radius="md" leftSection={<Icon size={14} />} onClick={submit}>
{phase === 'load' ? 'Load' : 'Unload'}
</Button>
</Group>
</Stack>
</Paper>
) : null}
{phase === 'load' && pending.length > 0 ? (
<Alert color="gray" variant="light" icon={<Info size={14} />} p="xs">
<Text size="xs">
A wagon that will not ride (cancel with fee / EDR fault) and direct truck-to-train
loading are decided on the train schedule workspace, not here.
</Text>
</Alert>
) : null}
</Stack>
</Modal>
);
}