warehouse

This commit is contained in:
hagiye
2026-06-19 17:13:17 +03:00
parent 63f177e6d0
commit 484ac187a9
22 changed files with 904 additions and 45 deletions

View File

@@ -42,8 +42,9 @@ import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedul
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
import TrainDetailPage from "./pages/trains/TrainDetailPage";
import RoutesPage from "./pages/fleet/RoutesPage";
import WarehouseListPage from "./pages/warehouses/WarehouseListPage";
import RoutesPage from "./pages/fleet/RoutesPage";
import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage";
import WarehouseListPage from "./pages/warehouses/WarehouseListPage";
import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage";
import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage";
import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage";
@@ -122,13 +123,18 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
],
},
{
title: "Warehouse Management",
items: [
{
label: "Warehouses",
href: "/dashboard/warehouses",
icon: <Container />,
},
title: "Warehouse Management",
items: [
{
label: "Warehouse dashboard",
href: "/dashboard/warehouses",
icon: <LayoutDashboard />,
},
{
label: "Warehouses",
href: "/dashboard/warehouses/list",
icon: <Container />,
},
{
label: "Inventory",
href: "/dashboard/warehouse-inventory",
@@ -296,8 +302,10 @@ const App = () => {
<Route path="containers" element={<FleetResourcePage />} />
<Route path="cargoes" element={<FleetResourcePage />} />
<Route path="warehouses" element={<WarehouseListPage />} />
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
<Route path="warehouse-dashboard" element={<Navigate to="/dashboard/warehouses" replace />} />
<Route path="warehouses" element={<WarehouseDashboardPage />} />
<Route path="warehouses/list" element={<WarehouseListPage />} />
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />

View File

@@ -64,6 +64,34 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
subtitle: "Manage route definitions built from freight yards",
},
},
{
prefix: "/dashboard/warehouses/list",
meta: {
title: "Warehouses",
subtitle: "Manage warehouses, yards, and zones",
},
},
{
prefix: "/dashboard/warehouse-inventory",
meta: {
title: "Warehouse inventory",
subtitle: "Track received items through inspection and loading",
},
},
{
prefix: "/dashboard/inventory-inquiry",
meta: {
title: "Inventory inquiry",
subtitle: "Locate cargo, containers, and goods inside the warehouse network",
},
},
{
prefix: "/dashboard/warehouses",
meta: {
title: "Warehouse dashboard",
subtitle: "Live overview of warehouse capacity and inventory lifecycle",
},
},
...getFleetRouteMeta(),
{
prefix: "/dashboard/trains/",

View File

@@ -10,7 +10,7 @@ import {
} from '@mantine/core';
import { useToast } from '@/hooks/use-toast';
import { useCreateWarehouse, useUpdateWarehouse } from '@/hooks/useWarehouses';
import { useCreateWarehouse, useUpdateWarehouse, useWarehouseFacilities } from '@/hooks/useWarehouses';
import type { SaveWarehousePayload, Warehouse, WarehouseType } from '@/types/warehouse';
import { extractErrorMessage, statusOptions, warehouseTypeOptions } from './options';
@@ -24,6 +24,7 @@ interface FormState {
name: string;
code: string;
type: WarehouseType;
stationId: string;
locationName: string;
capacityWeight: number | '';
capacityContainers: number | '';
@@ -34,6 +35,7 @@ const emptyForm = (): FormState => ({
name: '',
code: '',
type: 'OPEN_WAREHOUSE',
stationId: '',
locationName: '',
capacityWeight: '',
capacityContainers: '',
@@ -45,8 +47,14 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
const { toast } = useToast();
const createMutation = useCreateWarehouse();
const updateMutation = useUpdateWarehouse();
const facilitiesQuery = useWarehouseFacilities();
const [form, setForm] = useState<FormState>(emptyForm());
const facilityOptions = (facilitiesQuery.data ?? []).map((facility) => ({
value: facility.id,
label: `${facility.label ?? facility.name ?? facility.code} (${facility.code})`,
}));
useEffect(() => {
if (opened) {
setForm(
@@ -55,6 +63,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
name: warehouse.name,
code: warehouse.code,
type: warehouse.type,
stationId: warehouse.stationId ?? '',
locationName: warehouse.locationName ?? '',
capacityWeight: warehouse.capacityWeight ?? '',
capacityContainers: warehouse.capacityContainers ?? '',
@@ -77,6 +86,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
name: form.name.trim(),
code: form.code.trim(),
type: form.type,
stationId: form.stationId || undefined,
locationName: form.locationName.trim() || undefined,
capacityWeight: form.capacityWeight === '' ? undefined : Number(form.capacityWeight),
capacityContainers: form.capacityContainers === '' ? undefined : Number(form.capacityContainers),
@@ -135,6 +145,16 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
)}
</Group>
<Select
label="Facility / Port"
placeholder="Select facility"
clearable
searchable
data={facilityOptions}
value={form.stationId || null}
onChange={(value) => setForm((f) => ({ ...f, stationId: value ?? '' }))}
/>
<TextInput
label="Location name"
placeholder="Modjo, Oromia"

View File

@@ -39,6 +39,12 @@ export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardV
<WarehouseTypeBadge type={warehouse.type} />
</Group>
<Text size="sm" c="dimmed">
Facility: {warehouse.facility
? `${warehouse.facility.label ?? warehouse.facility.name ?? warehouse.facility.code} (${warehouse.facility.code})`
: '—'}
</Text>
{warehouse.locationName && (
<Group gap={6} c="dimmed">
<MapPin size={14} />

View File

@@ -1,5 +1,5 @@
import { Badge, Button, Group, Table, Text, Tooltip } from '@mantine/core';
import { ArrowLeftRight, ClipboardCheck, PackageCheck } from 'lucide-react';
import { Badge, Button, Group, Stack, Table, Text, Tooltip } from '@mantine/core';
import { ArrowLeftRight, ClipboardCheck, PackageCheck, Send, Truck, Warehouse } from 'lucide-react';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import { InventoryStatusBadge } from './badges';
@@ -8,7 +8,11 @@ import { formatDate, formatNumber } from './options';
interface WarehouseInventoryTableProps {
items: WarehouseInventoryItem[];
onInspect: (item: WarehouseInventoryItem) => void;
onStore?: (item: WarehouseInventoryItem) => void;
onReserve?: (item: WarehouseInventoryItem) => void;
onReadyForLoading: (item: WarehouseInventoryItem) => void;
onLoad?: (item: WarehouseInventoryItem) => void;
onDispatch?: (item: WarehouseInventoryItem) => void;
onMove?: (item: WarehouseInventoryItem) => void;
busyId?: string | null;
}
@@ -17,13 +21,20 @@ const itemKind = (item: WarehouseInventoryItem) => {
if (item.containerId) return { label: 'Container', color: 'blue' };
if (item.cargoId) return { label: 'Cargo', color: 'grape' };
if (item.goodsId) return { label: 'Goods', color: 'orange' };
return { label: '', color: 'gray' };
return { label: '-', color: 'gray' };
};
const isPaidBooking = (item: WarehouseInventoryItem) =>
item.booking?.status === 'PAID' || item.booking?.paymentStatus === 'PAID';
export function WarehouseInventoryTable({
items,
onInspect,
onStore,
onReserve,
onReadyForLoading,
onLoad,
onDispatch,
onMove,
busyId,
}: WarehouseInventoryTableProps) {
@@ -36,14 +47,12 @@ export function WarehouseInventoryTable({
}
return (
<Table.ScrollContainer minWidth={1100}>
<Table.ScrollContainer minWidth={1280}>
<Table highlightOnHover verticalSpacing="sm" striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
<Table.Th>Warehouse</Table.Th>
<Table.Th>Yard</Table.Th>
<Table.Th>Zone</Table.Th>
<Table.Th>Location Path</Table.Th>
<Table.Th>Item</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>Weight</Table.Th>
@@ -57,18 +66,38 @@ export function WarehouseInventoryTable({
{items.map((item) => {
const kind = itemKind(item);
const busy = busyId === item.id;
const paid = isPaidBooking(item);
const facility =
item.warehouse?.facility?.label ??
item.warehouse?.facility?.name ??
item.warehouse?.facility?.code ??
'No facility';
const locationPath = [
facility,
item.warehouse?.code ?? '-',
item.yard?.code ?? '-',
item.zone?.code ?? '-',
].join(' -> ');
return (
<Table.Tr key={item.id}>
<Table.Td>
<Tooltip label={item.bookingId} withArrow>
<Text size="sm" fw={600}>
{item.bookingId.slice(0, 8)}
</Text>
<Stack gap={2}>
<Text size="sm" fw={600}>
{item.booking?.reference ?? `${item.bookingId.slice(0, 8)}...`}
</Text>
<Text size="xs" c={paid ? 'green' : 'dimmed'}>
{item.booking?.paymentStatus ?? item.booking?.status ?? 'Unknown payment'}
</Text>
</Stack>
</Tooltip>
</Table.Td>
<Table.Td>{item.warehouse?.code ?? '—'}</Table.Td>
<Table.Td>{item.yard?.code ?? '—'}</Table.Td>
<Table.Td>{item.zone?.code ?? '—'}</Table.Td>
<Table.Td>
<Text size="sm" fw={500}>
{locationPath}
</Text>
</Table.Td>
<Table.Td>
<Badge color={kind.color} variant="light" size="sm" radius="md">
{kind.label}
@@ -109,17 +138,61 @@ export function WarehouseInventoryTable({
>
Inspect
</Button>
<Button
size="compact-xs"
variant="light"
color="blue"
leftSection={<Warehouse size={14} />}
disabled={!onStore || !['RECEIVED', 'ARRIVED_AT_WAREHOUSE'].includes(item.status) || busy}
loading={busy}
onClick={() => onStore?.(item)}
>
Store
</Button>
<Button
size="compact-xs"
variant="light"
color="grape"
leftSection={<ClipboardCheck size={14} />}
disabled={!onReserve || item.status !== 'STORED' || busy}
loading={busy}
onClick={() => onReserve?.(item)}
>
Reserve
</Button>
<Button
size="compact-xs"
variant="light"
color="green"
leftSection={<PackageCheck size={14} />}
disabled={item.status !== 'UNDER_INSPECTION' || busy}
disabled={item.status !== 'RESERVED' || busy}
loading={busy}
onClick={() => onReadyForLoading(item)}
>
Ready
</Button>
<Button
size="compact-xs"
variant="light"
color="teal"
leftSection={<Truck size={14} />}
disabled={!onLoad || item.status !== 'READY_FOR_LOADING' || !paid || busy}
loading={busy}
onClick={() => onLoad?.(item)}
>
Loaded
</Button>
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<Send size={14} />}
disabled={!onDispatch || item.status !== 'LOADED' || busy}
loading={busy}
onClick={() => onDispatch?.(item)}
>
Dispatch
</Button>
</Group>
</Table.Td>
</Table.Tr>

View File

@@ -27,6 +27,7 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro
<Table.Tr>
<Table.Th>Code</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th>Facility / Port</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Location</Table.Th>
<Table.Th>Weight (cur / cap)</Table.Th>
@@ -44,6 +45,11 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro
</Anchor>
</Table.Td>
<Table.Td>{warehouse.name}</Table.Td>
<Table.Td>
{warehouse.facility
? `${warehouse.facility.label ?? warehouse.facility.name ?? warehouse.facility.code} (${warehouse.facility.code})`
: '—'}
</Table.Td>
<Table.Td>
<WarehouseTypeBadge type={warehouse.type} />
</Table.Td>

View File

@@ -34,9 +34,14 @@ export function WarehouseStatusBadge({ status }: { status: WarehouseStatus }) {
}
const inventoryStatusColor: Record<InventoryStatus, string> = {
RECEIVED: 'yellow',
STORED: 'blue',
RESERVED: 'grape',
ARRIVED_AT_WAREHOUSE: 'yellow',
UNDER_INSPECTION: 'cyan',
READY_FOR_LOADING: 'green',
LOADED: 'teal',
DISPATCHED: 'gray',
};
export function InventoryStatusBadge({ status }: { status: InventoryStatus }) {

View File

@@ -244,10 +244,15 @@ export const URL_CONSTANTS = {
WAREHOUSE_INVENTORY: {
BASE: '/warehouse-inventory',
RECEIVE: '/warehouse-inventory/receive',
DASHBOARD_SUMMARY: '/warehouse-inventory/dashboard/summary',
READY_FOR_LOADING: '/warehouse-inventory/ready-for-loading',
INQUIRY: '/warehouse-inventory/inquiry',
STORE: (id: string) => `/warehouse-inventory/${id}/store`,
RESERVE: (id: string) => `/warehouse-inventory/${id}/reserve`,
INSPECT: (id: string) => `/warehouse-inventory/${id}/inspect`,
MARK_READY: (id: string) => `/warehouse-inventory/${id}/ready-for-loading`,
LOAD: (id: string) => `/warehouse-inventory/${id}/load`,
DISPATCH: (id: string) => `/warehouse-inventory/${id}/dispatch`,
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
},
};

View File

@@ -6,6 +6,7 @@ import type {
InventoryInquiryFilter,
MoveInventoryPayload,
ReceiveInventoryPayload,
ReserveInventoryPayload,
SaveWarehousePayload,
SaveYardPayload,
SaveZonePayload,
@@ -15,10 +16,12 @@ import type {
export const warehouseKeys = {
all: ['warehouses'] as const,
list: (filter?: WarehouseFilter) => ['warehouses', 'list', filter ?? {}] as const,
facilities: () => ['warehouses', 'facilities'] as const,
detail: (id: string) => ['warehouses', 'detail', id] as const,
yards: (warehouseId: string) => ['warehouses', warehouseId, 'yards'] as const,
zones: (yardId: string) => ['warehouse-yards', yardId, 'zones'] as const,
inventory: (filter?: InventoryFilter) => ['warehouse-inventory', 'list', filter ?? {}] as const,
dashboardSummary: (filter?: InventoryFilter) => ['warehouse-dashboard', 'summary', filter ?? {}] as const,
inquiry: (filter: InventoryInquiryFilter) => ['warehouse-inventory', 'inquiry', filter] as const,
};
@@ -39,6 +42,13 @@ export function useWarehouse(id?: string) {
});
}
export function useWarehouseFacilities() {
return useQuery({
queryKey: warehouseKeys.facilities(),
queryFn: () => warehouseService.listFacilities().then((r) => r.data),
});
}
export function useCreateWarehouse() {
const qc = useQueryClient();
return useMutation({
@@ -127,6 +137,13 @@ export function useWarehouseInventory(filter?: InventoryFilter) {
});
}
export function useWarehouseDashboardSummary(filter?: InventoryFilter) {
return useQuery({
queryKey: warehouseKeys.dashboardSummary(filter),
queryFn: () => warehouseService.getDashboardSummary(filter).then((r) => r.data),
});
}
export function useReceiveInventory() {
const qc = useQueryClient();
return useMutation({
@@ -134,6 +151,30 @@ export function useReceiveInventory() {
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
qc.invalidateQueries({ queryKey: warehouseKeys.all });
qc.invalidateQueries({ queryKey: ['warehouse-dashboard'] });
},
});
}
export function useStoreInventory() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => warehouseService.storeInventory(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
qc.invalidateQueries({ queryKey: ['warehouse-dashboard'] });
},
});
}
export function useReserveInventory() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, payload }: { id: string; payload: ReserveInventoryPayload }) =>
warehouseService.reserveInventory(id, payload),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
qc.invalidateQueries({ queryKey: ['warehouse-dashboard'] });
},
});
}
@@ -150,7 +191,32 @@ export function useMarkReadyForLoading() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => warehouseService.markReadyForLoading(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
qc.invalidateQueries({ queryKey: ['warehouse-dashboard'] });
},
});
}
export function useLoadInventory() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => warehouseService.loadInventory(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
qc.invalidateQueries({ queryKey: ['warehouse-dashboard'] });
},
});
}
export function useDispatchInventory() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => warehouseService.dispatchInventory(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
qc.invalidateQueries({ queryKey: ['warehouse-dashboard'] });
},
});
}

View File

@@ -0,0 +1,230 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Card,
Container,
Group,
Select,
SimpleGrid,
Skeleton,
Stack,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import {
ClipboardCheck,
Container as ContainerIcon,
Layers,
Package,
PackagePlus,
Send,
Truck,
Warehouse as WarehouseIcon,
} from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import {
useWarehouseDashboardSummary,
useWarehouseFacilities,
useWarehouses,
} from '@/hooks/useWarehouses';
import type { InventoryFilter } from '@/types/warehouse';
export default function WarehouseDashboardPage() {
const navigate = useNavigate();
const [filter, setFilter] = useState<InventoryFilter>({});
const facilitiesQuery = useWarehouseFacilities();
const warehousesQuery = useWarehouses(filter.facilityId ? { stationId: filter.facilityId } : undefined);
const summaryQuery = useWarehouseDashboardSummary(filter);
const counts = summaryQuery.data ?? {
totalWarehouses: 0,
totalInventory: 0,
receivedToday: 0,
stored: 0,
reserved: 0,
readyForLoading: 0,
loaded: 0,
dispatched: 0,
};
const facilityOptions = useMemo(
() =>
(facilitiesQuery.data ?? []).map((facility) => ({
value: facility.id,
label: `${facility.label ?? facility.name ?? facility.code} (${facility.code})`,
})),
[facilitiesQuery.data],
);
const warehouseOptions = useMemo(
() => (warehousesQuery.data ?? []).map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })),
[warehousesQuery.data],
);
const loading = summaryQuery.isLoading;
const hasError = summaryQuery.isError;
const cards: Array<{
label: string;
value: number;
icon: typeof WarehouseIcon;
color: string;
href: string;
disabled?: boolean;
}> = [
{
label: 'Total warehouses',
value: counts.totalWarehouses,
icon: WarehouseIcon,
color: 'indigo',
href: '/dashboard/warehouses/list',
},
{
label: 'Total inventory',
value: counts.totalInventory,
icon: Package,
color: 'gray',
href: '/dashboard/warehouse-inventory',
},
{
label: 'Received today',
value: counts.receivedToday,
icon: PackagePlus,
color: 'orange',
href: '/dashboard/warehouse-inventory',
},
{
label: 'Stored',
value: counts.stored,
icon: Layers,
color: 'blue',
href: '/dashboard/warehouse-inventory?status=STORED',
},
{
label: 'Reserved',
value: counts.reserved,
icon: ClipboardCheck,
color: 'grape',
href: '/dashboard/warehouse-inventory?status=RESERVED',
},
{
label: 'Ready for loading',
value: counts.readyForLoading,
icon: ContainerIcon,
color: 'cyan',
href: '/dashboard/warehouse-inventory?status=READY_FOR_LOADING',
},
{
label: 'Loaded',
value: counts.loaded,
icon: Truck,
color: 'teal',
href: '/dashboard/warehouse-inventory?status=LOADED',
},
{
label: 'Dispatched',
value: counts.dispatched,
icon: Send,
color: 'green',
href: '/dashboard/warehouse-inventory?status=DISPATCHED',
},
];
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Warehouse dashboard' }]} />
<Stack gap="xl" mt="sm">
<Card withBorder radius="md" padding="xl" bg="gray.0">
<Group justify="space-between" align="center" gap="xl">
<div>
<Title order={1}>Warehouse Dashboard</Title>
<Text c="dimmed" size="lg" mt={6}>
Live overview of warehouse capacity and inventory lifecycle.
</Text>
</div>
<Group gap="xs" visibleFrom="sm">
<WarehouseIcon size={58} color="var(--mantine-color-green-6)" />
<Truck size={58} color="var(--mantine-color-orange-6)" />
</Group>
</Group>
</Card>
{hasError && (
<Alert color="yellow" variant="light" title="Some dashboard metrics could not be loaded">
Available cards still show data from the APIs that responded.
</Alert>
)}
<Card withBorder radius="md" padding="lg">
<Group gap="sm" wrap="wrap">
<Select
placeholder="All facilities"
clearable
searchable
data={facilityOptions}
value={filter.facilityId ?? null}
onChange={(value) =>
setFilter((current) => ({ ...current, facilityId: value ?? undefined, warehouseId: undefined }))
}
w={260}
/>
<Select
placeholder="All warehouses"
clearable
searchable
data={warehouseOptions}
value={filter.warehouseId ?? null}
onChange={(value) => setFilter((current) => ({ ...current, warehouseId: value ?? undefined }))}
w={260}
/>
</Group>
</Card>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
{cards.map((card) => {
const Icon = card.icon;
return (
<Card
key={card.label}
withBorder
radius="md"
padding="xl"
component="button"
type="button"
disabled={card.disabled}
onClick={() => navigate(card.href)}
style={{
cursor: card.disabled ? 'not-allowed' : 'pointer',
opacity: card.disabled ? 0.65 : 1,
textAlign: 'left',
transition: 'transform 120ms ease, box-shadow 120ms ease',
}}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Stack gap={10}>
<Text tt="uppercase" fw={700} c="dimmed" size="sm">
{card.label}
</Text>
{loading ? (
<Skeleton height={38} width={76} radius="sm" />
) : (
<Text fw={800} size="36px" lh={1}>
{card.value.toLocaleString()}
</Text>
)}
</Stack>
<ThemeIcon color={card.color} variant="light" size={52} radius="md">
<Icon size={28} />
</ThemeIcon>
</Group>
</Card>
);
})}
</SimpleGrid>
</Stack>
</Container>
);
}

View File

@@ -118,7 +118,7 @@ export default function WarehouseDetailPage() {
<Container size="sm" py="xl">
<Stack align="center" gap="md">
<Text fw={700}>Warehouse not found</Text>
<Button variant="default" leftSection={<ArrowLeft size={16} />} onClick={() => navigate('/dashboard/warehouses')}>
<Button variant="default" leftSection={<ArrowLeft size={16} />} onClick={() => navigate('/dashboard/warehouses/list')}>
Back to warehouses
</Button>
</Stack>
@@ -130,7 +130,8 @@ export default function WarehouseDetailPage() {
<Container size="xxl" py="lg">
<Breadcrumbs
items={[
{ label: 'Warehouses', href: '/dashboard/warehouses' },
{ label: 'Warehouse dashboard', href: '/dashboard/warehouses' },
{ label: 'Warehouses', href: '/dashboard/warehouses/list' },
{ label: warehouse.name },
]}
/>
@@ -138,7 +139,7 @@ export default function WarehouseDetailPage() {
<Stack gap="lg" mt="sm">
<Group justify="space-between" align="flex-start">
<Group gap="md" align="center">
<ActionIcon variant="subtle" color="gray" onClick={() => navigate('/dashboard/warehouses')}>
<ActionIcon variant="subtle" color="gray" onClick={() => navigate('/dashboard/warehouses/list')}>
<ArrowLeft size={18} />
</ActionIcon>
<div>

View File

@@ -1,4 +1,5 @@
import { useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
Button,
Card,
@@ -9,6 +10,7 @@ import {
Modal,
Select,
Stack,
Tabs,
Text,
Textarea,
TextInput,
@@ -26,9 +28,13 @@ import {
} from '@/components/warehouses';
import { extractErrorMessage } from '@/components/warehouses/options';
import {
useDispatchInventory,
useInspectInventory,
useLoadInventory,
useMarkReadyForLoading,
useMoveInventory,
useReserveInventory,
useStoreInventory,
useWarehouseInventory,
useWarehouseYards,
useWarehouseZones,
@@ -38,8 +44,15 @@ import type { InventoryFilter, InventoryStatus, WarehouseInventoryItem } from '@
export default function WarehouseInventoryPage() {
const { toast } = useToast();
const [filter, setFilter] = useState<InventoryFilter>({});
const [search, setSearch] = useState('');
const [searchParams] = useSearchParams();
const initialStatus = searchParams.get('status') as InventoryStatus | null;
const [filter, setFilter] = useState<InventoryFilter>({
status: initialStatus ?? undefined,
warehouseId: searchParams.get('warehouseId') ?? undefined,
yardId: searchParams.get('yardId') ?? undefined,
zoneId: searchParams.get('zoneId') ?? undefined,
});
const [search, setSearch] = useState(searchParams.get('search') ?? '');
const [modalOpen, setModalOpen] = useState(false);
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
const [moveDraft, setMoveDraft] = useState<{
@@ -64,7 +77,11 @@ export default function WarehouseInventoryPage() {
const inventoryQuery = useWarehouseInventory(queryFilter);
const inspectMutation = useInspectInventory();
const storeMutation = useStoreInventory();
const reserveMutation = useReserveInventory();
const readyMutation = useMarkReadyForLoading();
const loadMutation = useLoadInventory();
const dispatchMutation = useDispatchInventory();
const moveMutation = useMoveInventory();
const warehouseOptions = useMemo(
@@ -127,6 +144,82 @@ export default function WarehouseInventoryPage() {
}
};
const handleStore = async (item: WarehouseInventoryItem) => {
setBusyId(item.id);
try {
await storeMutation.mutateAsync(item.id);
toast({ title: 'Inventory stored' });
} catch (error) {
toast({ variant: 'destructive', title: 'Store failed', description: extractErrorMessage(error) });
} finally {
setBusyId(null);
}
};
const handleReserve = async (item: WarehouseInventoryItem) => {
if (item.status !== 'STORED') {
toast({ variant: 'destructive', title: 'Only STORED inventory can be reserved.' });
return;
}
if (item.booking?.status !== 'PAID' && item.booking?.paymentStatus !== 'PAID') {
toast({ variant: 'destructive', title: 'Only PAID bookings can reserve stored inventory.' });
return;
}
setBusyId(item.id);
try {
await reserveMutation.mutateAsync({ id: item.id, payload: { bookingId: item.bookingId } });
toast({ title: 'Inventory reserved' });
} catch (error) {
toast({ variant: 'destructive', title: 'Reserve failed', description: extractErrorMessage(error) });
} finally {
setBusyId(null);
}
};
const handleLoad = async (item: WarehouseInventoryItem) => {
if (item.booking?.status !== 'PAID' && item.booking?.paymentStatus !== 'PAID') {
toast({ variant: 'destructive', title: 'Only PAID bookings can be loaded.' });
return;
}
setBusyId(item.id);
try {
await loadMutation.mutateAsync(item.id);
toast({ title: 'Inventory loaded' });
} catch (error) {
toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(error) });
} finally {
setBusyId(null);
}
};
const handleDispatch = async (item: WarehouseInventoryItem) => {
setBusyId(item.id);
try {
await dispatchMutation.mutateAsync(item.id);
toast({ title: 'Inventory dispatched' });
} catch (error) {
toast({ variant: 'destructive', title: 'Dispatch failed', description: extractErrorMessage(error) });
} finally {
setBusyId(null);
}
};
const inventory = inventoryQuery.data ?? [];
const readyToLoad = inventory.filter(
(item) =>
item.status === 'READY_FOR_LOADING' &&
(item.booking?.status === 'PAID' || item.booking?.paymentStatus === 'PAID'),
);
const pendingPayment = inventory.filter(
(item) =>
item.status === 'READY_FOR_LOADING' &&
item.booking?.status !== 'PAID' &&
item.booking?.paymentStatus !== 'PAID',
);
const loadedInventory = inventory.filter((item) => item.status === 'LOADED');
const handleMove = async () => {
if (!moveItem || !moveDraft.warehouseId || !moveDraft.yardId || !moveDraft.zoneId) return;
setBusyId(moveItem.id);
@@ -223,15 +316,72 @@ export default function WarehouseInventoryPage() {
</Center>
) : (
<WarehouseInventoryTable
items={inventoryQuery.data ?? []}
items={inventory}
onInspect={handleInspect}
onStore={handleStore}
onReserve={handleReserve}
onReadyForLoading={handleReady}
onLoad={handleLoad}
onDispatch={handleDispatch}
onMove={openMoveModal}
busyId={busyId}
/>
)}
</Stack>
</Card>
<Card withBorder radius="md" padding="lg">
<Tabs defaultValue="ready">
<Tabs.List>
<Tabs.Tab value="ready">Ready to Load</Tabs.Tab>
<Tabs.Tab value="pending">Pending Payment</Tabs.Tab>
<Tabs.Tab value="loaded">Loaded Inventory</Tabs.Tab>
<Tabs.Tab value="dispatch">Dispatch Queue</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="ready" pt="md">
<WarehouseInventoryTable
items={readyToLoad}
onInspect={handleInspect}
onStore={handleStore}
onReserve={handleReserve}
onReadyForLoading={handleReady}
onLoad={handleLoad}
onDispatch={handleDispatch}
onMove={openMoveModal}
busyId={busyId}
/>
</Tabs.Panel>
<Tabs.Panel value="pending" pt="md">
<WarehouseInventoryTable
items={pendingPayment}
onInspect={handleInspect}
onReadyForLoading={handleReady}
onMove={openMoveModal}
busyId={busyId}
/>
</Tabs.Panel>
<Tabs.Panel value="loaded" pt="md">
<WarehouseInventoryTable
items={loadedInventory}
onInspect={handleInspect}
onReadyForLoading={handleReady}
onDispatch={handleDispatch}
onMove={openMoveModal}
busyId={busyId}
/>
</Tabs.Panel>
<Tabs.Panel value="dispatch" pt="md">
<WarehouseInventoryTable
items={loadedInventory}
onInspect={handleInspect}
onReadyForLoading={handleReady}
onDispatch={handleDispatch}
busyId={busyId}
/>
</Tabs.Panel>
</Tabs>
</Card>
</Stack>
<ReceiveInventoryModal opened={modalOpen} onClose={() => setModalOpen(false)} />

View File

@@ -43,7 +43,7 @@ export default function WarehouseListPage() {
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Warehouses' }]} />
<Breadcrumbs items={[{ label: 'Warehouse dashboard', href: '/dashboard/warehouses' }, { label: 'Warehouses' }]} />
<Stack gap="lg" mt="sm">
<Group justify="space-between" align="flex-end">

View File

@@ -7,10 +7,13 @@ import type {
InventoryInquiryResult,
MoveInventoryPayload,
ReceiveInventoryPayload,
ReserveInventoryPayload,
SaveWarehousePayload,
SaveYardPayload,
SaveZonePayload,
Warehouse,
WarehouseDashboardSummary,
WarehouseFacility,
WarehouseFilter,
WarehouseInventoryItem,
WarehouseYard,
@@ -33,6 +36,7 @@ export const warehouseService = {
apiClient.post<Warehouse>(URL_CONSTANTS.WAREHOUSES.BASE, payload),
update: (id: string, payload: Partial<SaveWarehousePayload>) =>
apiClient.patch<Warehouse>(URL_CONSTANTS.WAREHOUSES.BY_ID(id), payload),
listFacilities: () => apiClient.get<WarehouseFacility[]>(URL_CONSTANTS.RULE_ENGINE.YARDS),
// ── Yards ────────────────────────────────────────────────────────────────
listYards: (warehouseId: string) =>
@@ -59,10 +63,22 @@ export const warehouseService = {
}),
receiveInventory: (payload: ReceiveInventoryPayload) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RECEIVE, payload),
getDashboardSummary: (filter?: InventoryFilter) =>
apiClient.get<WarehouseDashboardSummary>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DASHBOARD_SUMMARY, {
params: cleanParams(filter ?? {}),
}),
storeInventory: (id: string) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.STORE(id)),
reserveInventory: (id: string, payload: ReserveInventoryPayload) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RESERVE(id), payload),
inspectInventory: (id: string) =>
apiClient.patch<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.INSPECT(id)),
markReadyForLoading: (id: string) =>
apiClient.patch<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.MARK_READY(id)),
loadInventory: (id: string) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.LOAD(id)),
dispatchInventory: (id: string) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DISPATCH(id)),
moveInventory: (id: string, payload: MoveInventoryPayload) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.MOVE(id), payload),
listReadyForLoading: (filter?: InventoryFilter) =>

View File

@@ -23,12 +23,25 @@ export const WAREHOUSE_ZONE_TYPES = [
export type WarehouseZoneType = (typeof WAREHOUSE_ZONE_TYPES)[number];
export const INVENTORY_STATUSES = [
'RECEIVED',
'STORED',
'RESERVED',
'ARRIVED_AT_WAREHOUSE',
'UNDER_INSPECTION',
'READY_FOR_LOADING',
'LOADED',
'DISPATCHED',
] as const;
export type InventoryStatus = (typeof INVENTORY_STATUSES)[number];
export interface WarehouseFacility {
id: string;
name?: string;
label?: string;
code: string;
type?: string;
}
export interface WarehouseZone {
id: string;
yardId: string;
@@ -64,6 +77,7 @@ export interface Warehouse {
code: string;
type: WarehouseType;
stationId: string | null;
facility?: WarehouseFacility | null;
locationName: string | null;
capacityWeight: number | null;
capacityContainers: number | null;
@@ -93,6 +107,12 @@ export interface WarehouseInventoryItem {
inspectedAt: string | null;
readyForLoadingAt: string | null;
notes: string | null;
booking?: {
id: string;
reference: string;
status: string;
paymentStatus: string;
} | null;
warehouse?: Warehouse | null;
yard?: WarehouseYard | null;
zone?: WarehouseZone | null;
@@ -124,6 +144,7 @@ export interface SaveWarehousePayload {
code: string;
type: WarehouseType;
stationId?: string;
facilityId?: string;
locationName?: string;
capacityWeight?: number;
capacityContainers?: number;
@@ -169,6 +190,21 @@ export interface MoveInventoryPayload {
remarks?: string;
}
export interface ReserveInventoryPayload {
bookingId: string;
}
export interface WarehouseDashboardSummary {
totalWarehouses: number;
totalInventory: number;
receivedToday: number;
stored: number;
reserved: number;
readyForLoading: number;
loaded: number;
dispatched: number;
}
export interface WarehouseFilter {
search?: string;
type?: WarehouseType;
@@ -177,6 +213,7 @@ export interface WarehouseFilter {
}
export interface InventoryFilter {
facilityId?: string;
warehouseId?: string;
yardId?: string;
zoneId?: string;
@@ -186,6 +223,8 @@ export interface InventoryFilter {
goodsId?: string;
status?: InventoryStatus;
search?: string;
dateFrom?: string;
dateTo?: string;
}
export interface InventoryInquiryFilter {