mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 13:05:44 +00:00
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:
@@ -5,6 +5,8 @@ import {
|
||||
LayoutDashboard,
|
||||
Network,
|
||||
Paperclip,
|
||||
PackageCheck,
|
||||
Send,
|
||||
Settings,
|
||||
SlidersHorizontal,
|
||||
Train,
|
||||
@@ -50,6 +52,9 @@ import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage";
|
||||
import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage";
|
||||
import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage";
|
||||
import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage";
|
||||
import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage";
|
||||
import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage";
|
||||
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
|
||||
|
||||
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
{
|
||||
@@ -137,6 +142,21 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
href: "/dashboard/warehouse-inventory",
|
||||
icon: <Package />,
|
||||
},
|
||||
{
|
||||
label: "Loading Queue",
|
||||
href: "/dashboard/loading-queue",
|
||||
icon: <Truck />,
|
||||
},
|
||||
{
|
||||
label: "Loaded Inventory",
|
||||
href: "/dashboard/loaded-inventory",
|
||||
icon: <PackageCheck />,
|
||||
},
|
||||
{
|
||||
label: "Dispatch Queue",
|
||||
href: "/dashboard/dispatch-queue",
|
||||
icon: <Send />,
|
||||
},
|
||||
{
|
||||
label: "Inventory Inquiry",
|
||||
href: "/dashboard/inventory-inquiry",
|
||||
@@ -289,6 +309,9 @@ const App = () => {
|
||||
<Route path="warehouses" element={<WarehouseListPage />} />
|
||||
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
|
||||
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
|
||||
<Route path="loading-queue" element={<LoadingQueuePage />} />
|
||||
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
|
||||
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
|
||||
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
|
||||
<Route path="warehouse-dashboard" element={<WarehouseDashboardPage />} />
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -206,12 +206,19 @@ export const URL_CONSTANTS = {
|
||||
RESERVE: '/warehouse-inventory/reserve',
|
||||
READY_FOR_LOADING: '/warehouse-inventory/ready-for-loading',
|
||||
INQUIRY: '/warehouse-inventory/inquiry',
|
||||
LOADABLE_WAGONS: '/warehouse-inventory/loadable-wagons',
|
||||
BOOKING_SCHEDULE: (bookingId: string) => `/warehouse-inventory/booking/${bookingId}/schedule`,
|
||||
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
|
||||
MOVEMENTS: (id: string) => `/warehouse-inventory/${id}/movements`,
|
||||
ACTIVITY: (id: string) => `/warehouse-inventory/${id}/activity`,
|
||||
LOADINGS: (id: string) => `/warehouse-inventory/${id}/loadings`,
|
||||
STORE: (id: string) => `/warehouse-inventory/${id}/store`,
|
||||
MARK_READY: (id: string) => `/warehouse-inventory/${id}/ready-for-loading`,
|
||||
LOAD: (id: string) => `/warehouse-inventory/${id}/load`,
|
||||
DISPATCH: (id: string) => `/warehouse-inventory/${id}/dispatch`,
|
||||
},
|
||||
|
||||
WAREHOUSE_LOADINGS: {
|
||||
BASE: '/warehouse-loadings',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { warehouseService } from '@/services/warehouse.service';
|
||||
import type {
|
||||
InventoryFilter,
|
||||
InventoryInquiryFilter,
|
||||
LoadInventoryPayload,
|
||||
MoveInventoryPayload,
|
||||
ReceiveInventoryPayload,
|
||||
ReserveInventoryPayload,
|
||||
@@ -145,6 +146,7 @@ function useInventoryMutation<TArgs>(fn: (args: TArgs) => Promise<unknown>) {
|
||||
mutationFn: fn,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||
qc.invalidateQueries({ queryKey: ['warehouse-loadings'] });
|
||||
qc.invalidateQueries({ queryKey: warehouseKeys.all });
|
||||
},
|
||||
});
|
||||
@@ -155,13 +157,41 @@ export const useReserveInventory = () =>
|
||||
useInventoryMutation((payload: ReserveInventoryPayload) => warehouseService.reserve(payload));
|
||||
export const useMarkReadyForLoading = () =>
|
||||
useInventoryMutation((id: string) => warehouseService.markReadyForLoading(id));
|
||||
export const useLoadInventory = () => useInventoryMutation((id: string) => warehouseService.load(id));
|
||||
export const useLoadInventory = () =>
|
||||
useInventoryMutation((args: { id: string; payload: LoadInventoryPayload }) =>
|
||||
warehouseService.load(args.id, args.payload),
|
||||
);
|
||||
export const useDispatchInventory = () => useInventoryMutation((id: string) => warehouseService.dispatch(id));
|
||||
export const useMoveInventory = () =>
|
||||
useInventoryMutation((args: { id: string; payload: MoveInventoryPayload }) =>
|
||||
warehouseService.move(args.id, args.payload),
|
||||
);
|
||||
|
||||
// ── Loading (Batch 3) ────────────────────────────────────────────────────────
|
||||
|
||||
export function useLoadableWagons(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse', 'loadable-wagons'],
|
||||
queryFn: () => warehouseService.loadableWagons().then((r) => r.data),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useWarehouseLoadings(params?: { bookingId?: string; wagonId?: string }) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-loadings', params ?? {}],
|
||||
queryFn: () => warehouseService.loadings(params).then((r) => r.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useBookingSchedule(bookingId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse', 'booking-schedule', bookingId ?? ''],
|
||||
queryFn: () => warehouseService.bookingSchedule(bookingId as string).then((r) => r.data),
|
||||
enabled: Boolean(bookingId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useInventoryMovements(id?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', id, 'movements'],
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Card, Container, Stack } from '@mantine/core';
|
||||
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import { InventoryWorkbench, VisualEmptyState, WarehouseHero } from '@/components/warehouses';
|
||||
import { useWarehouseInventory } from '@/hooks/useWarehouses';
|
||||
|
||||
/** Items that are LOADED and awaiting dispatch (train departure). */
|
||||
export default function DispatchQueuePage() {
|
||||
const { data, isLoading } = useWarehouseInventory({ status: 'LOADED' });
|
||||
const items = data ?? [];
|
||||
|
||||
return (
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs items={[{ label: 'Dispatch queue' }]} />
|
||||
|
||||
<Stack gap="lg" mt="sm">
|
||||
<WarehouseHero
|
||||
variant="train"
|
||||
secondaryVariant="route"
|
||||
title="Dispatch Queue"
|
||||
subtitle="Loaded inventory awaiting train departure. Mark items dispatched once they leave."
|
||||
/>
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
{!isLoading && items.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="train"
|
||||
title="Nothing to dispatch"
|
||||
description="Loaded items appear here, ready to mark as dispatched."
|
||||
/>
|
||||
) : (
|
||||
<InventoryWorkbench items={items} isLoading={isLoading} />
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Button, Card, Center, Container, Group, Loader, Select, Stack, Text, Te
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import { WarehouseInquiryTable, inventoryStatusOptions } from '@/components/warehouses';
|
||||
import { VisualEmptyState, WarehouseInquiryTable, inventoryStatusOptions } from '@/components/warehouses';
|
||||
import {
|
||||
useInventoryInquiry,
|
||||
useWarehouseYards,
|
||||
@@ -139,6 +139,12 @@ export default function InventoryInquiryPage() {
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
) : results.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="container"
|
||||
title="No items found"
|
||||
description="Adjust your filters and search to locate cargo, containers or goods across the warehouse network."
|
||||
/>
|
||||
) : (
|
||||
<WarehouseInquiryTable results={results} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Badge, Card, Container, Group, Loader, Stack, Table, Text } from '@mantine/core';
|
||||
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import { FreightVisual, VisualEmptyState, WarehouseHero, formatDate, formatNumber } from '@/components/warehouses';
|
||||
import { useWarehouseLoadings } from '@/hooks/useWarehouses';
|
||||
|
||||
/** Record of every inventory item loaded onto a wagon. */
|
||||
export default function LoadedInventoryPage() {
|
||||
const { data, isLoading } = useWarehouseLoadings();
|
||||
const loadings = data ?? [];
|
||||
|
||||
return (
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs items={[{ label: 'Loaded inventory' }]} />
|
||||
|
||||
<Stack gap="lg" mt="sm">
|
||||
<WarehouseHero
|
||||
variant="container"
|
||||
secondaryVariant="wagon"
|
||||
title="Loaded Inventory"
|
||||
subtitle="Items loaded onto wagons, with their loading records."
|
||||
/>
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : loadings.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="container"
|
||||
title="No loaded inventory yet"
|
||||
description="Once items are loaded onto a wagon, their records show here."
|
||||
/>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={760}>
|
||||
<Table verticalSpacing="sm" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Wagon</Table.Th>
|
||||
<Table.Th>Warehouse</Table.Th>
|
||||
<Table.Th>Zone</Table.Th>
|
||||
<Table.Th>Loaded Weight (kg)</Table.Th>
|
||||
<Table.Th>Loaded At</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{loadings.map((l) => (
|
||||
<Table.Tr key={l.id}>
|
||||
<Table.Td>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<FreightVisual variant="wagon" size={22} />
|
||||
<Text fw={600} size="sm">
|
||||
{l.wagonNumber ?? l.wagonId.slice(0, 8)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{l.inventory?.warehouse
|
||||
? `${l.inventory.warehouse.name} (${l.inventory.warehouse.code})`
|
||||
: '—'}
|
||||
</Table.Td>
|
||||
<Table.Td>{l.inventory?.zone ? l.inventory.zone.name : '—'}</Table.Td>
|
||||
<Table.Td>{formatNumber(l.loadedWeight)}</Table.Td>
|
||||
<Table.Td>{formatDate(l.loadedAt)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={l.inventory?.status === 'DISPATCHED' ? 'green' : 'teal'}
|
||||
size="sm"
|
||||
>
|
||||
{l.inventory?.status ?? 'LOADED'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Card, Container, Stack } from '@mantine/core';
|
||||
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import { InventoryWorkbench, VisualEmptyState, WarehouseHero } from '@/components/warehouses';
|
||||
import { useWarehouseInventory } from '@/hooks/useWarehouses';
|
||||
|
||||
/** Items that are READY_FOR_LOADING — load them onto a wagon from here. */
|
||||
export default function LoadingQueuePage() {
|
||||
const { data, isLoading } = useWarehouseInventory({ status: 'READY_FOR_LOADING' });
|
||||
const items = data ?? [];
|
||||
|
||||
return (
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs items={[{ label: 'Loading queue' }]} />
|
||||
|
||||
<Stack gap="lg" mt="sm">
|
||||
<WarehouseHero
|
||||
variant="wagon"
|
||||
secondaryVariant="cargo"
|
||||
title="Loading Queue"
|
||||
subtitle="Inventory that is ready for loading onto a wagon."
|
||||
/>
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
{!isLoading && items.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="wagon"
|
||||
title="Nothing waiting to load"
|
||||
description="Items appear here once they are marked Ready For Loading."
|
||||
/>
|
||||
) : (
|
||||
<InventoryWorkbench items={items} isLoading={isLoading} />
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Card, Center, Container, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon, Title } from '@mantine/core';
|
||||
import { Card, Center, Container, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
|
||||
import {
|
||||
ClipboardCheck,
|
||||
PackageCheck,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import { WarehouseHero } from '@/components/warehouses';
|
||||
import { useWarehouseDashboard } from '@/hooks/useWarehouses';
|
||||
import type { WarehouseDashboard } from '@/types/warehouse';
|
||||
|
||||
@@ -40,12 +41,12 @@ export default function WarehouseDashboardPage() {
|
||||
<Breadcrumbs items={[{ label: 'Warehouse dashboard' }]} />
|
||||
|
||||
<Stack gap="lg" mt="sm">
|
||||
<div>
|
||||
<Title order={2}>Warehouse Dashboard</Title>
|
||||
<Text c="dimmed" size="sm">
|
||||
Live overview of warehouse capacity and inventory lifecycle.
|
||||
</Text>
|
||||
</div>
|
||||
<WarehouseHero
|
||||
variant="train"
|
||||
secondaryVariant="warehouse"
|
||||
title="Warehouse Dashboard"
|
||||
subtitle="Live overview of warehouse capacity and inventory lifecycle."
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Center py="xl">
|
||||
|
||||
@@ -2,10 +2,13 @@ import { api as apiClient } from '../auth/http';
|
||||
|
||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||
import type {
|
||||
BookingScheduleView,
|
||||
InventoryFilter,
|
||||
InventoryInquiryFilter,
|
||||
InventoryInquiryResult,
|
||||
InventoryMovement,
|
||||
LoadableWagon,
|
||||
LoadInventoryPayload,
|
||||
MoveInventoryPayload,
|
||||
ReceiveInventoryPayload,
|
||||
ReserveInventoryPayload,
|
||||
@@ -17,6 +20,7 @@ import type {
|
||||
WarehouseDashboard,
|
||||
WarehouseFilter,
|
||||
WarehouseInventoryItem,
|
||||
WarehouseLoading,
|
||||
WarehouseYard,
|
||||
WarehouseZone,
|
||||
} from '@/types/warehouse';
|
||||
@@ -80,14 +84,26 @@ export const warehouseService = {
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RESERVE, payload),
|
||||
markReadyForLoading: (id: string) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.MARK_READY(id)),
|
||||
load: (id: string) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.LOAD(id)),
|
||||
load: (id: string, payload: LoadInventoryPayload) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.LOAD(id), payload),
|
||||
dispatch: (id: string) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DISPATCH(id)),
|
||||
apiClient.patch<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DISPATCH(id)),
|
||||
move: (id: string, payload: MoveInventoryPayload) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.MOVE(id), payload),
|
||||
movements: (id: string) =>
|
||||
apiClient.get<InventoryMovement[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.MOVEMENTS(id)),
|
||||
activity: (id: string) =>
|
||||
apiClient.get<WarehouseActivityLog[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.ACTIVITY(id)),
|
||||
|
||||
// ── Loading (Batch 3) ─────────────────────────────────────────────────────
|
||||
loadableWagons: () =>
|
||||
apiClient.get<LoadableWagon[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.LOADABLE_WAGONS),
|
||||
bookingSchedule: (bookingId: string) =>
|
||||
apiClient.get<BookingScheduleView>(URL_CONSTANTS.WAREHOUSE_INVENTORY.BOOKING_SCHEDULE(bookingId)),
|
||||
inventoryLoadings: (id: string) =>
|
||||
apiClient.get<WarehouseLoading[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.LOADINGS(id)),
|
||||
loadings: (params?: { bookingId?: string; wagonId?: string }) =>
|
||||
apiClient.get<WarehouseLoading[]>(URL_CONSTANTS.WAREHOUSE_LOADINGS.BASE, {
|
||||
params: cleanParams(params ?? {}),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -172,6 +172,55 @@ export interface WarehouseDashboard {
|
||||
dispatched: number;
|
||||
}
|
||||
|
||||
// ── Loading (Batch 3) ────────────────────────────────────────────────────────
|
||||
|
||||
export interface WarehouseLoading {
|
||||
id: string;
|
||||
warehouseInventoryId: string;
|
||||
bookingId: string | null;
|
||||
wagonId: string;
|
||||
wagonNumber?: string | null;
|
||||
loadedAt: string;
|
||||
loadedBy: string | null;
|
||||
loadedWeight: number | null;
|
||||
notes: string | null;
|
||||
inventory?: WarehouseInventoryItem | null;
|
||||
}
|
||||
|
||||
export interface LoadInventoryPayload {
|
||||
wagonId: string;
|
||||
loadedWeight?: number;
|
||||
loadedBy?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
/** Read-only wagon view exposed by the scheduling facade. */
|
||||
export interface LoadableWagon {
|
||||
id: string;
|
||||
wagonNumber: string;
|
||||
status: string;
|
||||
trainId: string | null;
|
||||
}
|
||||
|
||||
/** Read-only schedule + wagon + departure status for a booking. */
|
||||
export interface BookingScheduleView {
|
||||
schedule: {
|
||||
id: string;
|
||||
status: string;
|
||||
scheduledDepartureDate: string | null;
|
||||
scheduledArrivalDate: string | null;
|
||||
originStationId: string | null;
|
||||
destinationStationId: string | null;
|
||||
} | null;
|
||||
wagon: {
|
||||
wagonId: string | null;
|
||||
wagonNumber: string | null;
|
||||
sequenceNo: number | null;
|
||||
allocatedWeightTons: number | null;
|
||||
} | null;
|
||||
departureStatus: string | null;
|
||||
}
|
||||
|
||||
export interface MoveInventoryPayload {
|
||||
warehouseId: string;
|
||||
yardId: string;
|
||||
|
||||
Reference in New Issue
Block a user