feat(warehouses): auto-load targets a selected train, never loads trainless

"Auto Load Ready Items" now opens a train picker (pre-dispatch trains with
these bookings assigned, via the existing loadable-trains flow). No train
available -> no auto-loading, with a clear notice. Loading goes through the
existing per-wagon load path, so items without an allocated wagon are skipped
with a reason.

The train association is stored on the existing warehouse_loadings table
(no new table needed): new train_schedule_id column + a note recording train
number, origin -> destination, and departure time; wagon_id becomes nullable.
The trainless load-passed-export endpoint, its frontend wiring, and the unused
useLoadPassedExport hook are removed.

Migration 2100000000000 (idempotent) also applied to the dev database.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-07-10 07:53:13 +00:00
parent 8dd3f32a33
commit 9eb10bb4ce
9 changed files with 166 additions and 77 deletions

View File

@@ -1414,8 +1414,30 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
const { data: rows = [], isLoading } = useQuery(
api.warehouses.readyToLoadExport.queryOptions({ enabled }),
);
const loadPassed = useMutation(api.warehouses.loadPassedExport.mutationOptions());
const qc = useQueryClient();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [trainPickerOpen, setTrainPickerOpen] = useState(false);
const [targetScheduleId, setTargetScheduleId] = useState<string | null>(null);
// Loading is always onto a SPECIFIC pre-dispatch train. No train -> no auto-load.
const { data: trains = [], isLoading: trainsLoading } = useQuery({
queryKey: ['warehouse-inventory', 'loadable-trains'],
queryFn: () => warehouseService.getLoadableTrains(),
enabled: enabled && trainPickerOpen,
});
const loadOntoTrain = useMutation({
mutationFn: async (scheduleId: string) => {
const items = await warehouseService.getTrainLoadableItems(scheduleId);
const loadableIds = items.filter((i) => i.loadable).map((i) => i.id);
if (!loadableIds.length) {
throw new Error('No ready items with an allocated wagon on this train');
}
return warehouseService.loadItemsOntoTrain(scheduleId, loadableIds);
},
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
void qc.invalidateQueries({ queryKey: ['warehouse-loadings'] });
},
});
const allSelected = rows.length > 0 && selected.size === rows.length;
const someSelected = selected.size > 0 && !allSelected;
@@ -1427,13 +1449,22 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
return next;
});
const autoLoad = async () => {
const confirmLoad = async () => {
if (!targetScheduleId) {
toast({ variant: 'destructive', title: 'Select a train to load onto' });
return;
}
try {
const r = await loadPassed.mutateAsync(undefined);
const r = await loadOntoTrain.mutateAsync(targetScheduleId);
const train = trains.find((t) => t.scheduleId === targetScheduleId);
toast({
title: `${r.loadedCount} items loaded`,
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,
title: `${r.loadedCount} item(s) loaded onto train ${train?.trainNumber ?? ''}`.trim(),
description: r.skippedCount
? `${r.skippedCount} skipped — ${r.results.find((x) => x.reason)?.reason ?? 'see results'}`
: undefined,
});
setTrainPickerOpen(false);
setTargetScheduleId(null);
setSelected(new Set());
onChanged?.();
} catch (error) {
@@ -1452,14 +1483,58 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
variant="filled"
color="teal"
leftSection={<Truck size={14} />}
loading={loadPassed.isPending}
disabled={rows.length === 0}
onClick={autoLoad}
onClick={() => setTrainPickerOpen(true)}
>
Auto Load Ready Items
</Button>
</Group>
<Modal
opened={trainPickerOpen}
onClose={() => setTrainPickerOpen(false)}
title="Load ready items onto a train"
centered
size="lg"
>
<Stack gap="md">
{trainsLoading ? (
<Group justify="center" py="md"><Loader size="sm" /></Group>
) : trains.length === 0 ? (
<Alert color="yellow" variant="light" icon={<Info size={16} />}>
No train available. Auto-loading needs a scheduled (not yet dispatched) train with
these bookings assigned schedule the train and allocate wagons first.
</Alert>
) : (
<Select
label="Available trains"
placeholder="Select the train to load onto"
data={trains.map((t) => ({
value: t.scheduleId,
label: `${t.trainNumber ?? t.scheduleId.slice(0, 8)} · ${t.origin ?? '?'}${t.destination ?? '?'} · dep ${t.departureTime ? formatDate(t.departureTime) : '—'} · ${t.readyCount} ready`,
}))}
value={targetScheduleId}
onChange={setTargetScheduleId}
searchable
/>
)}
<Group justify="flex-end">
<Button variant="default" onClick={() => setTrainPickerOpen(false)} disabled={loadOntoTrain.isPending}>
Cancel
</Button>
<Button
color="teal"
leftSection={<Truck size={14} />}
loading={loadOntoTrain.isPending}
disabled={!targetScheduleId}
onClick={confirmLoad}
>
Load onto this train
</Button>
</Group>
</Stack>
</Modal>
{isLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" />