mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 23:35:42 +00:00
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.
321 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|