mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-06 12:25:02 +00:00
feat(warehouses): gate loading queues on the train's station loading window
Warehouse loading queues and the train schedule page now share one truth: the schedule's per-yard stationWorkLogs. A booking's items aren't loadable until "Start loading" has been clicked for their boarding yard, mirroring the same assertStationWorkStarted check the train schedule page's own Load button already enforces. - LoadableTrainRow/LoadableTrain carry originStationId + stationWorkLogs. - TrainLoadableItem carries originYardId/originYardLabel/loadingWindowStarted, computed from station_work_logs in the same query. - New YardLoadingWindows component surfaces the per-yard windows on the loading panel; ReceiveInventoryModal and LoadToTrainPanel wire it in. - Invalidate loadable-trains/train-loadable-items/warehouse-inventory queries alongside train-scheduling ones, since they render off the same data. Fixes the StationWorkLogJson typo that broke the freight-api build.
This commit is contained in:
@@ -22,6 +22,7 @@ import {
|
||||
type TrainLoadableItem,
|
||||
} from '@/services/warehouse.service';
|
||||
import { extractErrorMessage } from './options';
|
||||
import { YardLoadingWindows } from './YardLoadingWindows';
|
||||
|
||||
const STAGE_COLOR: Record<string, string> = {
|
||||
RECEIVED: 'blue',
|
||||
@@ -161,6 +162,11 @@ function TrainRow({ train, expanded, onToggle }: { train: LoadableTrain; expande
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
<YardLoadingWindows
|
||||
scheduleId={train.scheduleId}
|
||||
items={items}
|
||||
logs={train.stationWorkLogs}
|
||||
/>
|
||||
{bookings.map((b) => (
|
||||
<BookingBlock key={b.bookingId ?? b.bookingReference} scheduleId={train.scheduleId} booking={b} />
|
||||
))}
|
||||
|
||||
@@ -90,6 +90,7 @@ import { MoveInventoryModal } from './MoveInventoryModal';
|
||||
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||
import { StoreInventoryModal } from './StoreInventoryModal';
|
||||
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
|
||||
import { YardLoadingWindows } from './YardLoadingWindows';
|
||||
import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions, warehousesAtStation, yardsForBooking } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
import ListControls from '@/components/common/ListControls';
|
||||
@@ -1598,6 +1599,11 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
||||
queryFn: () => warehouseService.getLoadableTrains(),
|
||||
enabled: enabled && trainPickerOpen,
|
||||
});
|
||||
const { data: pickerItems = [] } = useQuery({
|
||||
queryKey: ['train-loadable-items', targetScheduleId],
|
||||
queryFn: () => warehouseService.getTrainLoadableItems(targetScheduleId!),
|
||||
enabled: Boolean(targetScheduleId) && trainPickerOpen,
|
||||
});
|
||||
const loadOntoTrain = useMutation({
|
||||
mutationFn: async ({ scheduleId, onlyIds }: { scheduleId: string; onlyIds: string[] }) => {
|
||||
const items = await warehouseService.getTrainLoadableItems(scheduleId);
|
||||
@@ -1608,6 +1614,17 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
||||
loadableIds = loadableIds.filter((id) => picked.has(id));
|
||||
}
|
||||
if (!loadableIds.length) {
|
||||
const scope = onlyIds.length
|
||||
? items.filter((i) => onlyIds.includes(i.id))
|
||||
: items.filter((i) => i.status === 'READY_FOR_LOADING');
|
||||
// A closed loading window is the blocker staff hit most, and the old
|
||||
// wagon-only message sent them to fix the wrong thing.
|
||||
const shut = scope.find((i) => !i.loadingWindowStarted);
|
||||
if (shut) {
|
||||
throw new Error(
|
||||
`Start loading at ${shut.originYardLabel ?? 'the boarding yard'} first — the loading time window has not been started`,
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
onlyIds.length
|
||||
? 'None of the selected items have an allocated wagon on this train'
|
||||
@@ -1701,6 +1718,13 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
||||
searchable
|
||||
/>
|
||||
)}
|
||||
{targetScheduleId ? (
|
||||
<YardLoadingWindows
|
||||
scheduleId={targetScheduleId}
|
||||
items={pickerItems}
|
||||
logs={trains.find((t) => t.scheduleId === targetScheduleId)?.stationWorkLogs}
|
||||
/>
|
||||
) : null}
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setTrainPickerOpen(false)} disabled={loadOntoTrain.isPending}>
|
||||
Cancel
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Alert, Badge, Group, Stack, Text } from '@mantine/core';
|
||||
import { Info, MapPin } from 'lucide-react';
|
||||
|
||||
import { StationWorkControls } from '@/components/trainScheduling/StationWorkControls';
|
||||
import type { TrainLoadableItem } from '@/services/warehouse.service';
|
||||
import type { StationWorkLog } from '@/types/trainScheduling';
|
||||
|
||||
/**
|
||||
* The train schedule's per-yard loading window, shown where the warehouse
|
||||
* actually loads. Cargo may only go onto a wagon inside a started window
|
||||
* (assertStationWorkStarted on the API side), so the same Start/End loading
|
||||
* controls the train schedule page carries belong here too — otherwise the
|
||||
* warehouse operator sees a Load button that the server refuses.
|
||||
*
|
||||
* One block per boarding yard of the items waiting to load, since a train can
|
||||
* pick cargo up at more than one stop and each stop has its own window.
|
||||
*/
|
||||
export function YardLoadingWindows({
|
||||
scheduleId,
|
||||
items,
|
||||
logs,
|
||||
}: {
|
||||
scheduleId: string;
|
||||
items: TrainLoadableItem[];
|
||||
logs: Record<string, StationWorkLog> | null | undefined;
|
||||
}) {
|
||||
const yards = useMemo(() => {
|
||||
const byYard = new Map<string, { label: string; count: number }>();
|
||||
for (const item of items) {
|
||||
// Everything still queued for this train, not only the ready ones — the
|
||||
// operator starts the window before the cargo finishes becoming ready.
|
||||
if (item.status === 'LOADED' || !item.originYardId) continue;
|
||||
const entry = byYard.get(item.originYardId);
|
||||
if (entry) entry.count += 1;
|
||||
else byYard.set(item.originYardId, { label: item.originYardLabel ?? 'Boarding yard', count: 1 });
|
||||
}
|
||||
return [...byYard.entries()].map(([yardId, v]) => ({ yardId, ...v }));
|
||||
}, [items]);
|
||||
|
||||
if (yards.length === 0) return null;
|
||||
|
||||
const anyOpen = yards.some((y) => logs?.[y.yardId]?.loading?.startedAt);
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
Loading window
|
||||
</Text>
|
||||
{!anyOpen ? (
|
||||
<Alert color="yellow" variant="light" icon={<Info size={16} />}>
|
||||
Start loading at the yard before loading cargo — the train schedule records the same
|
||||
window, and the server refuses cargo outside it.
|
||||
</Alert>
|
||||
) : null}
|
||||
{yards.map((yard) => (
|
||||
<Stack key={yard.yardId} gap={6} p="xs" style={{ border: '1px solid var(--mantine-color-gray-3)', borderRadius: 8 }}>
|
||||
<Group gap={8} align="center">
|
||||
<MapPin size={13} />
|
||||
<Text size="xs" fw={700}>
|
||||
{yard.label}
|
||||
</Text>
|
||||
<Badge size="sm" radius="sm" variant="light" color="gray">
|
||||
{yard.count} queued
|
||||
</Badge>
|
||||
</Group>
|
||||
<StationWorkControls
|
||||
scheduleId={scheduleId}
|
||||
yardId={yard.yardId}
|
||||
phase="loading"
|
||||
log={logs?.[yard.yardId]?.loading}
|
||||
/>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -295,6 +295,11 @@ const INVENTORY_INVALIDATIONS: ReadonlyArray<readonly unknown[]> = [
|
||||
const TRAIN_SCHEDULING_INVALIDATIONS: ReadonlyArray<readonly unknown[]> = [
|
||||
QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
|
||||
QUERY_KEYS.BOOKINGS.ROOT,
|
||||
// A station's loading window gates the warehouse loading queues too — they
|
||||
// render the same Start/End controls, so they must refresh on the same click.
|
||||
["loadable-trains"],
|
||||
["train-loadable-items"],
|
||||
["warehouse-inventory"],
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -82,6 +82,7 @@ import type {
|
||||
WarehouseZone,
|
||||
ZoneContentItem,
|
||||
} from '@/types/warehouse';
|
||||
import type { StationWorkLog } from '@/types/trainScheduling';
|
||||
|
||||
export type ContainerItemStage = 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED';
|
||||
|
||||
@@ -115,6 +116,10 @@ export interface LoadableTrain {
|
||||
departureTime: string | null;
|
||||
readyCount: number;
|
||||
loadedCount: number;
|
||||
/** freight.yards.id the train departs from. */
|
||||
originStationId: string | null;
|
||||
/** The schedule's per-yard loading/unloading windows — same store the train schedule page writes. */
|
||||
stationWorkLogs: Record<string, StationWorkLog> | null;
|
||||
}
|
||||
|
||||
/** A container/cargo inventory item assigned to a train, with its allocated wagon. */
|
||||
@@ -132,6 +137,11 @@ export interface TrainLoadableItem {
|
||||
wagonId: string | null;
|
||||
wagonNumber: string | null;
|
||||
sequenceNo: number | null;
|
||||
/** The booking's boarding yard — the yard whose loading window gates this item. */
|
||||
originYardId: string | null;
|
||||
originYardLabel: string | null;
|
||||
/** True once "Start loading" was clicked for this item's boarding yard on this train. */
|
||||
loadingWindowStarted: boolean;
|
||||
loadable: boolean;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user