feat(warehouse): batch 3 — loading, dispatch & train-departure visibility

- WarehouseLoading entity + Batch3 migration (wagon loading records)
- wagon-aware load() with validation; dispatch moved to PATCH
- read-only SchedulingReadFacade (schedule/wagon/departure) — never writes scheduling
- new endpoints: GET /warehouse-loadings, loadable-wagons, booking schedule
- Loading Queue / Loaded Inventory / Dispatch Queue pages + routes + sidebar
- FreightVisual illustrations (page heroes + empty states)
- booking detail: loaded/dispatched/wagon + read-only train schedule

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-06-12 12:44:40 +00:00
parent 72e2e3c985
commit 95c6a71438
27 changed files with 1193 additions and 30 deletions

View File

@@ -0,0 +1,184 @@
import type { CSSProperties, ReactElement } from 'react';
export type FreightVisualVariant =
| 'train'
| 'warehouse'
| 'container'
| 'wagon'
| 'cargo'
| 'route'
| 'empty';
interface FreightVisualProps {
variant: FreightVisualVariant;
/** Pixel size of the (square) artwork. Defaults to 64. */
size?: number;
style?: CSSProperties;
className?: string;
title?: string;
}
/**
* Lightweight railway/freight illustrations — minimal, enterprise-logistics style.
* Inline SVG (no network cost) using EDR brand colors: green, yellow, dark text,
* light gray. Purposely low-contrast so it never overpowers tables/forms.
*
* Use only in page headers, empty states, and KPI cards.
*/
const EDR = {
green: '#2F9E44',
greenSoft: '#D3F9D8',
yellow: '#F59F00',
yellowSoft: '#FFF3BF',
dark: '#343A40',
gray: '#ADB5BD',
graySoft: '#E9ECEF',
};
function Train() {
return (
<>
{/* track */}
<rect x="2" y="52" width="60" height="3" rx="1.5" fill={EDR.graySoft} />
{/* locomotive body */}
<rect x="6" y="20" width="26" height="26" rx="3" fill={EDR.green} />
<rect x="10" y="24" width="8" height="8" rx="1.5" fill={EDR.greenSoft} />
<rect x="22" y="24" width="6" height="8" rx="1.5" fill={EDR.greenSoft} />
{/* cab roof */}
<rect x="9" y="15" width="14" height="6" rx="2" fill={EDR.dark} />
{/* wagon */}
<rect x="36" y="26" width="22" height="20" rx="2.5" fill={EDR.yellow} />
<rect x="40" y="30" width="14" height="6" rx="1" fill={EDR.yellowSoft} />
{/* wheels */}
{[12, 24, 42, 52].map((cx) => (
<circle key={cx} cx={cx} cy={48} r={3.2} fill={EDR.dark} />
))}
</>
);
}
function Warehouse() {
return (
<>
{/* ground */}
<rect x="4" y="50" width="56" height="3" rx="1.5" fill={EDR.graySoft} />
{/* roof */}
<path d="M10 24 L32 12 L54 24 Z" fill={EDR.green} />
{/* body */}
<rect x="14" y="24" width="36" height="26" rx="1.5" fill={EDR.greenSoft} />
{/* shutter door */}
<rect x="26" y="32" width="12" height="18" rx="1" fill={EDR.dark} />
<rect x="27.5" y="35" width="9" height="2" fill={EDR.gray} />
<rect x="27.5" y="39" width="9" height="2" fill={EDR.gray} />
<rect x="27.5" y="43" width="9" height="2" fill={EDR.gray} />
</>
);
}
function Container() {
return (
<>
{/* stacked containers */}
<rect x="8" y="34" width="22" height="16" rx="1.5" fill={EDR.green} />
<rect x="34" y="34" width="22" height="16" rx="1.5" fill={EDR.yellow} />
<rect x="20" y="16" width="24" height="16" rx="1.5" fill={EDR.dark} />
{/* corrugation lines */}
{[12, 16, 20, 24].map((x) => (
<rect key={`a${x}`} x={x} y="37" width="1.5" height="10" fill={EDR.greenSoft} />
))}
{[38, 42, 46, 50].map((x) => (
<rect key={`b${x}`} x={x} y="37" width="1.5" height="10" fill={EDR.yellowSoft} />
))}
{[25, 29, 33, 37].map((x) => (
<rect key={`c${x}`} x={x} y="19" width="1.5" height="10" fill={EDR.gray} />
))}
</>
);
}
function Wagon() {
return (
<>
<rect x="4" y="50" width="56" height="3" rx="1.5" fill={EDR.graySoft} />
{/* flatbed wagon */}
<rect x="8" y="38" width="48" height="8" rx="1.5" fill={EDR.dark} />
{/* cargo on wagon */}
<rect x="14" y="22" width="16" height="16" rx="1.5" fill={EDR.green} />
<rect x="34" y="26" width="16" height="12" rx="1.5" fill={EDR.yellow} />
{/* wheels */}
{[16, 26, 40, 50].map((cx) => (
<circle key={cx} cx={cx} cy={48} r={3.2} fill={EDR.dark} />
))}
</>
);
}
function Cargo() {
return (
<>
{/* cargo boxes */}
<rect x="12" y="30" width="20" height="20" rx="2" fill={EDR.yellow} />
<rect x="34" y="34" width="18" height="16" rx="2" fill={EDR.green} />
{/* tape */}
<rect x="21" y="30" width="2" height="20" fill={EDR.yellowSoft} />
<rect x="12" y="38" width="20" height="2" fill={EDR.yellowSoft} />
<rect x="42" y="34" width="2" height="16" fill={EDR.greenSoft} />
</>
);
}
function Route() {
return (
<>
{/* track line with stations */}
<rect x="6" y="31" width="52" height="2" rx="1" fill={EDR.gray} />
{[10, 22, 34, 46, 58].map((x) => (
<rect key={x} x={x - 0.5} y="28" width="1.5" height="8" fill={EDR.graySoft} />
))}
<circle cx="10" cy="32" r="5" fill={EDR.green} />
<circle cx="54" cy="32" r="5" fill={EDR.yellow} />
</>
);
}
function Empty() {
return (
<>
{/* empty open box */}
<path d="M14 28 L32 22 L50 28 L50 30 L32 24 L14 30 Z" fill={EDR.gray} />
<path d="M14 30 L32 36 L32 50 L14 44 Z" fill={EDR.graySoft} />
<path d="M50 30 L32 36 L32 50 L50 44 Z" fill={EDR.graySoft} />
<path d="M14 30 L32 24 L50 30 L32 36 Z" fill="#F8F9FA" />
<circle cx="32" cy="14" r="2.5" fill={EDR.yellow} />
</>
);
}
const VARIANTS: Record<FreightVisualVariant, () => ReactElement> = {
train: Train,
warehouse: Warehouse,
container: Container,
wagon: Wagon,
cargo: Cargo,
route: Route,
empty: Empty,
};
export function FreightVisual({ variant, size = 64, style, className, title }: FreightVisualProps) {
const Art = VARIANTS[variant];
return (
<svg
width={size}
height={size}
viewBox="0 0 64 64"
fill="none"
role="img"
aria-label={title ?? `${variant} illustration`}
className={className}
style={style}
>
{title ? <title>{title}</title> : null}
<Art />
</svg>
);
}

View File

@@ -4,12 +4,12 @@ import { Center, Loader } from '@mantine/core';
import { useToast } from '@/hooks/use-toast';
import {
useDispatchInventory,
useLoadInventory,
useMarkReadyForLoading,
useStoreInventory,
} from '@/hooks/useWarehouses';
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
import { InventoryHistoryModal } from './InventoryHistoryModal';
import { LoadInventoryModal } from './LoadInventoryModal';
import { MoveInventoryModal } from './MoveInventoryModal';
import { ReserveInventoryModal } from './ReserveInventoryModal';
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
@@ -26,11 +26,11 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
const [busyId, setBusyId] = useState<string | null>(null);
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
const [reserveItem, setReserveItem] = useState<WarehouseInventoryItem | null>(null);
const [loadItem, setLoadItem] = useState<WarehouseInventoryItem | null>(null);
const [historyItem, setHistoryItem] = useState<WarehouseInventoryItem | null>(null);
const storeMutation = useStoreInventory();
const readyMutation = useMarkReadyForLoading();
const loadMutation = useLoadInventory();
const dispatchMutation = useDispatchInventory();
const runDirect = async (item: WarehouseInventoryItem, fn: () => Promise<unknown>, label: string) => {
@@ -55,7 +55,8 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
case 'ready-for-loading':
return runDirect(item, () => readyMutation.mutateAsync(item.id), 'Ready for loading');
case 'load':
return runDirect(item, () => loadMutation.mutateAsync(item.id), 'Inventory loaded');
setLoadItem(item);
return;
case 'dispatch':
return runDirect(item, () => dispatchMutation.mutateAsync(item.id), 'Inventory dispatched');
default:
@@ -87,6 +88,7 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
onClose={() => setReserveItem(null)}
item={reserveItem}
/>
<LoadInventoryModal opened={Boolean(loadItem)} onClose={() => setLoadItem(null)} item={loadItem} />
<InventoryHistoryModal
opened={Boolean(historyItem)}
onClose={() => setHistoryItem(null)}

View File

@@ -0,0 +1,98 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Group, Modal, NumberInput, Stack, Text, Textarea } from '@mantine/core';
import { Info } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { useLoadInventory } from '@/hooks/useWarehouses';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import { WagonSelect } from './WagonSelect';
import { extractErrorMessage } from './options';
interface LoadInventoryModalProps {
opened: boolean;
onClose: () => void;
item: WarehouseInventoryItem | null;
}
/** Load READY_FOR_LOADING inventory onto a wagon (creates a loading record). */
export function LoadInventoryModal({ opened, onClose, item }: LoadInventoryModalProps) {
const { toast } = useToast();
const loadMutation = useLoadInventory();
const [wagonId, setWagonId] = useState('');
const [loadedWeight, setLoadedWeight] = useState<number | ''>('');
const [notes, setNotes] = useState('');
useEffect(() => {
if (opened) {
setWagonId('');
setLoadedWeight(item?.weight ?? '');
setNotes('');
}
}, [opened, item]);
const handleSubmit = async () => {
if (!item) return;
if (!wagonId.trim()) {
toast({ variant: 'destructive', title: 'Select a wagon' });
return;
}
try {
await loadMutation.mutateAsync({
id: item.id,
payload: {
wagonId: wagonId.trim(),
loadedWeight: loadedWeight === '' ? undefined : Number(loadedWeight),
notes: notes.trim() || undefined,
},
});
toast({ title: 'Inventory loaded', description: 'Status set to LOADED' });
onClose();
} catch (error) {
toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(error) });
}
};
return (
<Modal opened={opened} onClose={onClose} title="Load onto wagon" centered size="md">
<Stack gap="md">
<Alert icon={<Info size={16} />} color="blue" variant="light">
<Text size="sm">
The item must be <b>READY_FOR_LOADING</b> and the wagon must be available or already on a
train schedule.
</Text>
</Alert>
<WagonSelect label="Wagon" required value={wagonId} onChange={setWagonId} />
<NumberInput
label="Loaded weight (kg)"
placeholder="Defaults to item weight"
min={0}
value={loadedWeight}
onChange={(v) => setLoadedWeight(v === '' ? '' : Number(v))}
/>
<Textarea
label="Notes"
placeholder="Optional"
autosize
minRows={2}
value={notes}
onChange={(e) => {
const v = e.currentTarget.value;
setNotes(v);
}}
/>
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose} disabled={loadMutation.isPending}>
Cancel
</Button>
<Button onClick={handleSubmit} loading={loadMutation.isPending}>
Load
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,36 @@
import type { ReactNode } from 'react';
import { Center, Stack, Text } from '@mantine/core';
import { FreightVisual, type FreightVisualVariant } from './FreightVisual';
interface VisualEmptyStateProps {
variant?: FreightVisualVariant;
title: string;
description?: string;
action?: ReactNode;
}
/** Friendly empty state with a small freight illustration. */
export function VisualEmptyState({
variant = 'empty',
title,
description,
action,
}: VisualEmptyStateProps) {
return (
<Center py="xl">
<Stack align="center" gap="xs" maw={360}>
<FreightVisual variant={variant} size={88} style={{ opacity: 0.85 }} />
<Text fw={600} ta="center">
{title}
</Text>
{description && (
<Text size="sm" c="dimmed" ta="center">
{description}
</Text>
)}
{action}
</Stack>
</Center>
);
}

View File

@@ -0,0 +1,34 @@
import { Select } from '@mantine/core';
import { useLoadableWagons } from '@/hooks/useWarehouses';
interface WagonSelectProps {
value: string;
onChange: (wagonId: string) => void;
label?: string;
required?: boolean;
}
/** Searchable wagon picker. Lists wagons that are loadable (read-only from scheduling). */
export function WagonSelect({ value, onChange, label = 'Wagon', required }: WagonSelectProps) {
const { data, isLoading } = useLoadableWagons();
const options = (data ?? []).map((w) => ({
value: w.id,
label: `${w.wagonNumber} · ${w.status}`,
}));
return (
<Select
label={label}
required={required}
searchable
clearable
data={options}
value={value || null}
onChange={(v) => onChange(v ?? '')}
placeholder={isLoading ? 'Loading wagons…' : 'Search wagon number'}
nothingFoundMessage="No loadable wagons found"
/>
);
}

View File

@@ -0,0 +1,57 @@
import type { ReactNode } from 'react';
import { Box, Group, Stack, Text, Title } from '@mantine/core';
import { FreightVisual, type FreightVisualVariant } from './FreightVisual';
interface WarehouseHeroProps {
title: string;
subtitle?: string;
/** Primary illustration shown on the right of the hero. */
variant?: FreightVisualVariant;
/** Optional secondary illustration tucked behind the primary. */
secondaryVariant?: FreightVisualVariant;
actions?: ReactNode;
}
/**
* Page header hero with a lightweight freight illustration. Low-contrast,
* minimal — sets context without overpowering the data below.
*/
export function WarehouseHero({
title,
subtitle,
variant = 'warehouse',
secondaryVariant,
actions,
}: WarehouseHeroProps) {
return (
<Box
style={{
background: 'linear-gradient(135deg, #F8F9FA 0%, #F1F3F5 100%)',
border: '1px solid #E9ECEF',
borderRadius: 'var(--mantine-radius-md)',
padding: 'var(--mantine-spacing-lg)',
overflow: 'hidden',
}}
>
<Group justify="space-between" wrap="nowrap" align="center">
<Stack gap={4}>
<Title order={3}>{title}</Title>
{subtitle && (
<Text size="sm" c="dimmed">
{subtitle}
</Text>
)}
{actions && <Group mt="sm">{actions}</Group>}
</Stack>
<Group gap="xs" wrap="nowrap" style={{ opacity: 0.95 }}>
{secondaryVariant && (
<FreightVisual variant={secondaryVariant} size={56} style={{ opacity: 0.7 }} />
)}
<FreightVisual variant={variant} size={84} />
</Group>
</Group>
</Box>
);
}

View File

@@ -1,9 +1,10 @@
import { useState } from 'react';
import { Badge, Button, Card, Divider, Group, Stack, Text } from '@mantine/core';
import { PackagePlus, Warehouse as WarehouseIcon } from 'lucide-react';
import { PackagePlus, Train as TrainIcon, Warehouse as WarehouseIcon } from 'lucide-react';
import { useWarehouseInventory } from '@/hooks/useWarehouses';
import { useBookingSchedule, useWarehouseInventory } from '@/hooks/useWarehouses';
import { InventoryStatusBadge } from './badges';
import { FreightVisual } from './FreightVisual';
import { formatDate } from './options';
import { ReceiveInventoryModal } from './ReceiveInventoryModal';
@@ -28,9 +29,14 @@ function Row({ label, value }: { label: string; value: React.ReactNode }) {
export function WarehouseInfoCard({ bookingId, bookingReference }: WarehouseInfoCardProps) {
const [modalOpen, setModalOpen] = useState(false);
const { data, isLoading } = useWarehouseInventory({ bookingId });
const { data: scheduleView } = useBookingSchedule(bookingId);
const items = data ?? [];
const latest = items[0];
const schedule = scheduleView?.schedule;
const wagon = scheduleView?.wagon;
const isLoadedOrDispatched =
latest?.status === 'LOADED' || latest?.status === 'DISPATCHED';
return (
<Card withBorder radius="md" padding="lg">
@@ -65,9 +71,52 @@ export function WarehouseInfoCard({ bookingId, bookingReference }: WarehouseInfo
<Row label="Inventory Status" value={<InventoryStatusBadge status={latest.status} />} />
<Row label="Arrived At" value={formatDate(latest.arrivedAt)} />
<Row label="Ready For Loading At" value={formatDate(latest.readyForLoadingAt)} />
{isLoadedOrDispatched && (
<>
<Row
label="Wagon"
value={wagon?.wagonNumber ?? '—'}
/>
<Row label="Loaded At" value={formatDate(latest.loadedAt)} />
<Row label="Dispatched At" value={formatDate(latest.dispatchedAt)} />
</>
)}
</Stack>
)}
{schedule && (
<>
<Divider
label={
<Group gap={6}>
<TrainIcon size={14} />
<Text size="xs" c="dimmed">
Train schedule (read-only)
</Text>
</Group>
}
labelPosition="left"
/>
<Group gap="sm" wrap="nowrap" align="flex-start">
<FreightVisual variant="train" size={40} />
<Stack gap="xs" style={{ flex: 1 }}>
<Row
label="Departure Status"
value={
<Badge variant="light" color="blue" size="sm">
{schedule.status}
</Badge>
}
/>
<Row label="Scheduled Departure" value={formatDate(schedule.scheduledDepartureDate)} />
<Row label="Scheduled Arrival" value={formatDate(schedule.scheduledArrivalDate)} />
{wagon?.wagonNumber && <Row label="Assigned Wagon" value={wagon.wagonNumber} />}
{wagon?.sequenceNo != null && <Row label="Wagon Position" value={`#${wagon.sequenceNo}`} />}
</Stack>
</Group>
</>
)}
<Button
variant="light"
leftSection={<PackagePlus size={16} />}

View File

@@ -18,3 +18,9 @@ export { ActivityTimeline } from './ActivityTimeline';
export { InventoryHistoryModal } from './InventoryHistoryModal';
export { InventoryWorkbench } from './InventoryWorkbench';
export { BookingSelect } from './BookingSelect';
export { WagonSelect } from './WagonSelect';
export { LoadInventoryModal } from './LoadInventoryModal';
export { FreightVisual } from './FreightVisual';
export type { FreightVisualVariant } from './FreightVisual';
export { WarehouseHero } from './WarehouseHero';
export { VisualEmptyState } from './VisualEmptyState';