Recieved invontory detals

This commit is contained in:
Hagernesh
2026-06-12 06:58:21 +00:00
parent c86118cd8d
commit 72e2e3c985
48 changed files with 1818 additions and 279 deletions

View File

@@ -49,6 +49,7 @@ import WarehouseListPage from "./pages/warehouses/WarehouseListPage";
import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage";
import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage";
import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage";
import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage";
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
@@ -121,6 +122,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
title: "Warehouse Management",
items: [
{
label: "Warehouse Dashboard",
href: "/dashboard/warehouse-dashboard",
icon: <LayoutDashboard />,
},
{
label: "Warehouses",
href: "/dashboard/warehouses",
@@ -284,6 +290,7 @@ const App = () => {
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
<Route path="warehouse-dashboard" element={<WarehouseDashboardPage />} />
<Route path="user-management" element={<UserManagementPage />} />
<Route path="user-management/users" element={<UsersPage />} />

View File

@@ -0,0 +1,62 @@
import { Center, Loader, Text, Timeline } from '@mantine/core';
import {
ArrowRightLeft,
ClipboardCheck,
PackageCheck,
PackagePlus,
Send,
Truck,
Warehouse,
} from 'lucide-react';
import { useInventoryActivity } from '@/hooks/useWarehouses';
import type { ActivityType } from '@/types/warehouse';
import { formatDate, humanizeEnum } from './options';
const activityIcon: Record<ActivityType, React.ReactNode> = {
INVENTORY_RECEIVED: <PackagePlus size={14} />,
INVENTORY_STORED: <Warehouse size={14} />,
INVENTORY_MOVED: <ArrowRightLeft size={14} />,
INVENTORY_RESERVED: <ClipboardCheck size={14} />,
READY_FOR_LOADING: <PackageCheck size={14} />,
INVENTORY_LOADED: <Truck size={14} />,
INVENTORY_DISPATCHED: <Send size={14} />,
};
export function ActivityTimeline({ inventoryId }: { inventoryId: string }) {
const { data, isLoading } = useInventoryActivity(inventoryId);
const items = data ?? [];
if (isLoading) {
return (
<Center py="lg">
<Loader size="sm" />
</Center>
);
}
if (items.length === 0) {
return (
<Text c="dimmed" ta="center" py="md" size="sm">
No activity recorded yet.
</Text>
);
}
return (
<Timeline active={items.length} bulletSize={24} lineWidth={2}>
{items.map((log) => (
<Timeline.Item key={log.id} bullet={activityIcon[log.activityType]} title={humanizeEnum(log.activityType)}>
{log.description && (
<Text size="sm" c="dimmed">
{log.description}
</Text>
)}
<Text size="xs" mt={4} c="dimmed">
{log.performedBy ?? 'system'} · {formatDate(log.createdAt)}
</Text>
</Timeline.Item>
))}
</Timeline>
);
}

View File

@@ -0,0 +1,41 @@
import { Select } from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import { bookingsService } from '@/services/bookings.service';
interface BookingSelectProps {
value: string;
onChange: (bookingId: string) => void;
label?: string;
required?: boolean;
/** Comma-separated statuses to restrict the list (e.g. "PAID" for reservations). */
statuses?: string;
}
/** Searchable booking picker — shows the human reference (e.g. BKG-BULK-002), submits the UUID. */
export function BookingSelect({ value, onChange, label = 'Booking', required, statuses }: BookingSelectProps) {
const { data, isLoading } = useQuery({
queryKey: ['bookings', 'options', statuses ?? 'all'],
queryFn: () =>
bookingsService.list({ pageSize: 200, ...(statuses ? { statuses } : {}) }).then((r) => r.items),
});
const options = (data ?? []).map((b) => ({
value: b.id,
label: b.status ? `${b.reference} · ${b.status}` : b.reference,
}));
return (
<Select
label={label}
required={required}
searchable
clearable
data={options}
value={value || null}
onChange={(v) => onChange(v ?? '')}
placeholder={isLoading ? 'Loading bookings…' : 'Search booking reference'}
nothingFoundMessage="No bookings found"
/>
);
}

View File

@@ -27,6 +27,7 @@ interface FormState {
locationName: string;
capacityWeight: number | '';
capacityContainers: number | '';
maxVolume: number | '';
status: 'ACTIVE' | 'INACTIVE';
}
@@ -37,6 +38,7 @@ const emptyForm = (): FormState => ({
locationName: '',
capacityWeight: '',
capacityContainers: '',
maxVolume: '',
status: 'ACTIVE',
});
@@ -58,6 +60,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
locationName: warehouse.locationName ?? '',
capacityWeight: warehouse.capacityWeight ?? '',
capacityContainers: warehouse.capacityContainers ?? '',
maxVolume: warehouse.maxVolume ?? '',
status: warehouse.status,
}
: emptyForm(),
@@ -80,6 +83,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
locationName: form.locationName.trim() || undefined,
capacityWeight: form.capacityWeight === '' ? undefined : Number(form.capacityWeight),
capacityContainers: form.capacityContainers === '' ? undefined : Number(form.capacityContainers),
maxVolume: form.maxVolume === '' ? undefined : Number(form.maxVolume),
};
try {
@@ -105,14 +109,14 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
placeholder="Modjo Open Warehouse"
required
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, name: v })); }}
/>
<TextInput
label="Code"
placeholder="MODJO-OW"
required
value={form.code}
onChange={(e) => setForm((f) => ({ ...f, code: e.currentTarget.value }))}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, code: v })); }}
/>
</Group>
@@ -139,7 +143,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
label="Location name"
placeholder="Modjo, Oromia"
value={form.locationName}
onChange={(e) => setForm((f) => ({ ...f, locationName: e.currentTarget.value }))}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, locationName: v })); }}
/>
<Group grow>
@@ -157,6 +161,13 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
value={form.capacityContainers}
onChange={(value) => setForm((f) => ({ ...f, capacityContainers: value === '' ? '' : Number(value) }))}
/>
<NumberInput
label="Max volume (m³)"
placeholder="Optional"
min={0}
value={form.maxVolume}
onChange={(value) => setForm((f) => ({ ...f, maxVolume: value === '' ? '' : Number(value) }))}
/>
</Group>
<Group justify="flex-end" mt="sm">

View File

@@ -19,6 +19,7 @@ interface FormState {
type: WarehouseYardType;
capacityWeight: number | '';
capacityContainers: number | '';
maxVolume: number | '';
status: 'ACTIVE' | 'INACTIVE';
}
@@ -28,6 +29,7 @@ const emptyForm = (): FormState => ({
type: 'CONTAINER_YARD',
capacityWeight: '',
capacityContainers: '',
maxVolume: '',
status: 'ACTIVE',
});
@@ -48,6 +50,7 @@ export function CreateYardModal({ opened, onClose, warehouseId, yard }: CreateYa
type: yard.type,
capacityWeight: yard.capacityWeight ?? '',
capacityContainers: yard.capacityContainers ?? '',
maxVolume: yard.maxVolume ?? '',
status: yard.status,
}
: emptyForm(),
@@ -69,6 +72,7 @@ export function CreateYardModal({ opened, onClose, warehouseId, yard }: CreateYa
type: form.type,
capacityWeight: form.capacityWeight === '' ? undefined : Number(form.capacityWeight),
capacityContainers: form.capacityContainers === '' ? undefined : Number(form.capacityContainers),
maxVolume: form.maxVolume === '' ? undefined : Number(form.maxVolume),
};
try {
@@ -94,14 +98,14 @@ export function CreateYardModal({ opened, onClose, warehouseId, yard }: CreateYa
placeholder="Container Yard A"
required
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, name: v })); }}
/>
<TextInput
label="Code"
placeholder="CY-A"
required
value={form.code}
onChange={(e) => setForm((f) => ({ ...f, code: e.currentTarget.value }))}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, code: v })); }}
/>
</Group>
@@ -139,6 +143,13 @@ export function CreateYardModal({ opened, onClose, warehouseId, yard }: CreateYa
value={form.capacityContainers}
onChange={(value) => setForm((f) => ({ ...f, capacityContainers: value === '' ? '' : Number(value) }))}
/>
<NumberInput
label="Max volume (m³)"
placeholder="Optional"
min={0}
value={form.maxVolume}
onChange={(value) => setForm((f) => ({ ...f, maxVolume: value === '' ? '' : Number(value) }))}
/>
</Group>
<Group justify="flex-end" mt="sm">

View File

@@ -19,6 +19,7 @@ interface FormState {
type: WarehouseZoneType;
capacityWeight: number | '';
capacityContainers: number | '';
maxVolume: number | '';
status: 'ACTIVE' | 'INACTIVE';
}
@@ -28,6 +29,7 @@ const emptyForm = (): FormState => ({
type: 'CONTAINER_ZONE',
capacityWeight: '',
capacityContainers: '',
maxVolume: '',
status: 'ACTIVE',
});
@@ -48,6 +50,7 @@ export function CreateZoneModal({ opened, onClose, yardId, zone }: CreateZoneMod
type: zone.type,
capacityWeight: zone.capacityWeight ?? '',
capacityContainers: zone.capacityContainers ?? '',
maxVolume: zone.maxVolume ?? '',
status: zone.status,
}
: emptyForm(),
@@ -69,6 +72,7 @@ export function CreateZoneModal({ opened, onClose, yardId, zone }: CreateZoneMod
type: form.type,
capacityWeight: form.capacityWeight === '' ? undefined : Number(form.capacityWeight),
capacityContainers: form.capacityContainers === '' ? undefined : Number(form.capacityContainers),
maxVolume: form.maxVolume === '' ? undefined : Number(form.maxVolume),
};
try {
@@ -94,14 +98,14 @@ export function CreateZoneModal({ opened, onClose, yardId, zone }: CreateZoneMod
placeholder="Zone A-01"
required
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, name: v })); }}
/>
<TextInput
label="Code"
placeholder="A-01"
required
value={form.code}
onChange={(e) => setForm((f) => ({ ...f, code: e.currentTarget.value }))}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, code: v })); }}
/>
</Group>
@@ -139,6 +143,13 @@ export function CreateZoneModal({ opened, onClose, yardId, zone }: CreateZoneMod
value={form.capacityContainers}
onChange={(value) => setForm((f) => ({ ...f, capacityContainers: value === '' ? '' : Number(value) }))}
/>
<NumberInput
label="Max volume (m³)"
placeholder="Optional"
min={0}
value={form.maxVolume}
onChange={(value) => setForm((f) => ({ ...f, maxVolume: value === '' ? '' : Number(value) }))}
/>
</Group>
<Group justify="flex-end" mt="sm">

View File

@@ -0,0 +1,37 @@
import { Modal, Tabs } from '@mantine/core';
import { ArrowRightLeft, ListChecks } from 'lucide-react';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import { ActivityTimeline } from './ActivityTimeline';
import { InventoryMovementHistoryTable } from './InventoryMovementHistoryTable';
interface InventoryHistoryModalProps {
opened: boolean;
onClose: () => void;
item: WarehouseInventoryItem | null;
}
export function InventoryHistoryModal({ opened, onClose, item }: InventoryHistoryModalProps) {
return (
<Modal opened={opened} onClose={onClose} title="Inventory history" centered size="xl">
{item && (
<Tabs defaultValue="activity">
<Tabs.List>
<Tabs.Tab value="activity" leftSection={<ListChecks size={16} />}>
Activity
</Tabs.Tab>
<Tabs.Tab value="movements" leftSection={<ArrowRightLeft size={16} />}>
Movements
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="activity" pt="md">
<ActivityTimeline inventoryId={item.id} />
</Tabs.Panel>
<Tabs.Panel value="movements" pt="md">
<InventoryMovementHistoryTable inventoryId={item.id} />
</Tabs.Panel>
</Tabs>
)}
</Modal>
);
}

View File

@@ -0,0 +1,64 @@
import { Center, Loader, Table, Text } from '@mantine/core';
import { useInventoryMovements } from '@/hooks/useWarehouses';
import { formatDate } from './options';
const shortId = (id?: string | null) => (id ? `${id.slice(0, 8)}` : '—');
export function InventoryMovementHistoryTable({ inventoryId }: { inventoryId: string }) {
const { data, isLoading } = useInventoryMovements(inventoryId);
const movements = data ?? [];
if (isLoading) {
return (
<Center py="lg">
<Loader size="sm" />
</Center>
);
}
if (movements.length === 0) {
return (
<Text c="dimmed" ta="center" py="md" size="sm">
No movements recorded for this item.
</Text>
);
}
return (
<Table.ScrollContainer minWidth={640}>
<Table verticalSpacing="xs" striped>
<Table.Thead>
<Table.Tr>
<Table.Th>From (W / Y / Z)</Table.Th>
<Table.Th>To (W / Y / Z)</Table.Th>
<Table.Th>Remarks</Table.Th>
<Table.Th>By</Table.Th>
<Table.Th>When</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{movements.map((m) => (
<Table.Tr key={m.id}>
<Table.Td>
<Text size="xs">
{shortId(m.fromWarehouseId)} / {shortId(m.fromYardId)} / {shortId(m.fromZoneId)}
</Text>
</Table.Td>
<Table.Td>
<Text size="xs">
{shortId(m.toWarehouseId)} / {shortId(m.toYardId)} / {shortId(m.toZoneId)}
</Text>
</Table.Td>
<Table.Td>{m.remarks ?? '—'}</Table.Td>
<Table.Td>{m.movedBy ?? '—'}</Table.Td>
<Table.Td>
<Text size="xs">{formatDate(m.movedAt)}</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}

View File

@@ -0,0 +1,97 @@
import { useState } from 'react';
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 { MoveInventoryModal } from './MoveInventoryModal';
import { ReserveInventoryModal } from './ReserveInventoryModal';
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
import { extractErrorMessage } from './options';
interface InventoryWorkbenchProps {
items: WarehouseInventoryItem[];
isLoading?: boolean;
}
/** Inventory table + all lifecycle actions (advance / move / reserve / history). */
export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps) {
const { toast } = useToast();
const [busyId, setBusyId] = useState<string | null>(null);
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
const [reserveItem, setReserveItem] = 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) => {
setBusyId(item.id);
try {
await fn();
toast({ title: label });
} catch (error) {
toast({ variant: 'destructive', title: 'Action failed', description: extractErrorMessage(error) });
} finally {
setBusyId(null);
}
};
const advance = (item: WarehouseInventoryItem, action: InventoryAction) => {
switch (action) {
case 'store':
return runDirect(item, () => storeMutation.mutateAsync(item.id), 'Inventory stored');
case 'reserve':
setReserveItem(item);
return;
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');
case 'dispatch':
return runDirect(item, () => dispatchMutation.mutateAsync(item.id), 'Inventory dispatched');
default:
return;
}
};
if (isLoading) {
return (
<Center py="xl">
<Loader />
</Center>
);
}
return (
<>
<WarehouseInventoryTable
items={items}
busyId={busyId}
onAdvance={advance}
onMove={setMoveItem}
onHistory={setHistoryItem}
/>
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
<ReserveInventoryModal
opened={Boolean(reserveItem)}
onClose={() => setReserveItem(null)}
item={reserveItem}
/>
<InventoryHistoryModal
opened={Boolean(historyItem)}
onClose={() => setHistoryItem(null)}
item={historyItem}
/>
</>
);
}

View File

@@ -0,0 +1,125 @@
import { useEffect, useMemo, useState } from 'react';
import { Button, Group, Modal, Select, Stack, Textarea } from '@mantine/core';
import { useToast } from '@/hooks/use-toast';
import { useMoveInventory, useWarehouseYards, useWarehouseZones, useWarehouses } from '@/hooks/useWarehouses';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import { extractErrorMessage } from './options';
interface MoveInventoryModalProps {
opened: boolean;
onClose: () => void;
item: WarehouseInventoryItem | null;
}
export function MoveInventoryModal({ opened, onClose, item }: MoveInventoryModalProps) {
const { toast } = useToast();
const moveMutation = useMoveInventory();
const [warehouseId, setWarehouseId] = useState('');
const [yardId, setYardId] = useState('');
const [zoneId, setZoneId] = useState('');
const [remarks, setRemarks] = useState('');
useEffect(() => {
if (opened) {
setWarehouseId('');
setYardId('');
setZoneId('');
setRemarks('');
}
}, [opened]);
const warehousesQuery = useWarehouses({ status: 'ACTIVE' });
const yardsQuery = useWarehouseYards(warehouseId || undefined);
const zonesQuery = useWarehouseZones(yardId || undefined);
const warehouseOptions = useMemo(
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
[warehousesQuery.data],
);
const yardOptions = useMemo(
() => (yardsQuery.data ?? []).filter((y) => y.status === 'ACTIVE').map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
[yardsQuery.data],
);
const zoneOptions = useMemo(
() => (zonesQuery.data ?? []).filter((z) => z.status === 'ACTIVE').map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })),
[zonesQuery.data],
);
const handleSubmit = async () => {
if (!item) return;
if (!warehouseId || !yardId || !zoneId) {
toast({ variant: 'destructive', title: 'Select destination warehouse, yard and zone' });
return;
}
try {
await moveMutation.mutateAsync({
id: item.id,
payload: { warehouseId, yardId, zoneId, remarks: remarks.trim() || undefined },
});
toast({ title: 'Inventory moved' });
onClose();
} catch (error) {
toast({ variant: 'destructive', title: 'Move failed', description: extractErrorMessage(error) });
}
};
return (
<Modal opened={opened} onClose={onClose} title="Move inventory" centered size="lg">
<Stack gap="md">
<Select
label="Destination warehouse"
placeholder="Select warehouse"
required
searchable
data={warehouseOptions}
value={warehouseId || null}
onChange={(v) => {
setWarehouseId(v ?? '');
setYardId('');
setZoneId('');
}}
/>
<Select
label="Destination yard"
placeholder={!warehouseId ? 'Select a warehouse first' : 'Select yard'}
required
searchable
disabled={!warehouseId}
data={yardOptions}
value={yardId || null}
onChange={(v) => {
setYardId(v ?? '');
setZoneId('');
}}
/>
<Select
label="Destination zone"
placeholder={!yardId ? 'Select a yard first' : 'Select zone'}
required
searchable
disabled={!yardId}
data={zoneOptions}
value={zoneId || null}
onChange={(v) => setZoneId(v ?? '')}
/>
<Textarea
label="Remarks"
placeholder="Reason for the move"
autosize
minRows={2}
value={remarks}
onChange={(e) => setRemarks(e.currentTarget.value)}
/>
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose} disabled={moveMutation.isPending}>
Cancel
</Button>
<Button onClick={handleSubmit} loading={moveMutation.isPending}>
Move inventory
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -9,6 +9,7 @@ import {
useWarehouses,
} from '@/hooks/useWarehouses';
import type { ReceiveInventoryPayload } from '@/types/warehouse';
import { BookingSelect } from './BookingSelect';
import { extractErrorMessage } from './options';
interface ReceiveInventoryModalProps {
@@ -125,12 +126,10 @@ export function ReceiveInventoryModal({
{bookingId ? (
<TextInput label="Booking" value={bookingLabel ?? bookingId} readOnly />
) : (
<TextInput
label="Booking ID"
placeholder="Booking UUID"
required
<BookingSelect
label="Booking"
value={form.bookingId}
onChange={(e) => setForm((f) => ({ ...f, bookingId: e.currentTarget.value }))}
onChange={(v) => setForm((f) => ({ ...f, bookingId: v }))}
/>
)}
@@ -198,7 +197,7 @@ export function ReceiveInventoryModal({
autosize
minRows={2}
value={form.notes}
onChange={(e) => setForm((f) => ({ ...f, notes: e.currentTarget.value }))}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, notes: v })); }}
/>
<Group justify="flex-end" mt="sm">

View File

@@ -0,0 +1,59 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Group, Modal, Stack, Text } from '@mantine/core';
import { Info } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { useReserveInventory } from '@/hooks/useWarehouses';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import { BookingSelect } from './BookingSelect';
import { extractErrorMessage } from './options';
interface ReserveInventoryModalProps {
opened: boolean;
onClose: () => void;
item: WarehouseInventoryItem | null;
}
export function ReserveInventoryModal({ opened, onClose, item }: ReserveInventoryModalProps) {
const { toast } = useToast();
const reserveMutation = useReserveInventory();
const [bookingId, setBookingId] = useState('');
useEffect(() => {
if (opened) setBookingId(item?.bookingId ?? '');
}, [opened, item]);
const handleSubmit = async () => {
if (!item) return;
if (!bookingId.trim()) {
toast({ variant: 'destructive', title: 'Booking is required' });
return;
}
try {
await reserveMutation.mutateAsync({ inventoryId: item.id, bookingId: bookingId.trim() });
toast({ title: 'Inventory reserved' });
onClose();
} catch (error) {
toast({ variant: 'destructive', title: 'Reserve failed', description: extractErrorMessage(error) });
}
};
return (
<Modal opened={opened} onClose={onClose} title="Reserve inventory" centered size="md">
<Stack gap="md">
<Alert icon={<Info size={16} />} color="blue" variant="light">
<Text size="sm">The booking must be in <b>PAID</b> status and the inventory must be <b>STORED</b>.</Text>
</Alert>
<BookingSelect label="Booking (PAID)" required statuses="PAID" value={bookingId} onChange={setBookingId} />
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose} disabled={reserveMutation.isPending}>
Cancel
</Button>
<Button onClick={handleSubmit} loading={reserveMutation.isPending}>
Reserve
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -1,15 +1,17 @@
import { Badge, Button, Group, Table, Text, Tooltip } from '@mantine/core';
import { ClipboardCheck, PackageCheck } from 'lucide-react';
import { ActionIcon, Badge, Button, Group, Table, Text, Tooltip } from '@mantine/core';
import { ArrowRightLeft, History } from 'lucide-react';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
import { INVENTORY_NEXT_ACTION } from '@/types/warehouse';
import { InventoryStatusBadge } from './badges';
import { formatDate, formatNumber } from './options';
import { formatDate, formatNumber, humanizeEnum } from './options';
interface WarehouseInventoryTableProps {
items: WarehouseInventoryItem[];
onInspect: (item: WarehouseInventoryItem) => void;
onReadyForLoading: (item: WarehouseInventoryItem) => void;
busyId?: string | null;
onAdvance: (item: WarehouseInventoryItem, action: InventoryAction) => void;
onMove: (item: WarehouseInventoryItem) => void;
onHistory: (item: WarehouseInventoryItem) => void;
}
const itemKind = (item: WarehouseInventoryItem) => {
@@ -19,11 +21,20 @@ const itemKind = (item: WarehouseInventoryItem) => {
return { label: '—', color: 'gray' };
};
const actionColor: Record<InventoryAction, string> = {
store: 'blue',
reserve: 'grape',
'ready-for-loading': 'cyan',
load: 'teal',
dispatch: 'green',
};
export function WarehouseInventoryTable({
items,
onInspect,
onReadyForLoading,
busyId,
onAdvance,
onMove,
onHistory,
}: WarehouseInventoryTableProps) {
if (items.length === 0) {
return (
@@ -34,7 +45,7 @@ export function WarehouseInventoryTable({
}
return (
<Table.ScrollContainer minWidth={1100}>
<Table.ScrollContainer minWidth={1150}>
<Table highlightOnHover verticalSpacing="sm" striped>
<Table.Thead>
<Table.Tr>
@@ -47,7 +58,6 @@ export function WarehouseInventoryTable({
<Table.Th>Weight</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Arrived</Table.Th>
<Table.Th>Ready</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
@@ -55,14 +65,21 @@ export function WarehouseInventoryTable({
{items.map((item) => {
const kind = itemKind(item);
const busy = busyId === item.id;
const nextAction = INVENTORY_NEXT_ACTION[item.status];
return (
<Table.Tr key={item.id}>
<Table.Td>
<Tooltip label={item.bookingId} withArrow>
<Text size="sm" fw={600}>
{item.bookingId.slice(0, 8)}
{item.bookingId ? (
<Tooltip label={item.bookingId} withArrow>
<Text size="sm" fw={600}>
{item.bookingId.slice(0, 8)}
</Text>
</Tooltip>
) : (
<Text size="sm" c="dimmed">
</Text>
</Tooltip>
)}
</Table.Td>
<Table.Td>{item.warehouse?.code ?? '—'}</Table.Td>
<Table.Td>{item.yard?.code ?? '—'}</Table.Td>
@@ -80,33 +97,31 @@ export function WarehouseInventoryTable({
<Table.Td>
<Text size="xs">{formatDate(item.arrivedAt)}</Text>
</Table.Td>
<Table.Td>
<Text size="xs">{formatDate(item.readyForLoadingAt)}</Text>
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Button
size="compact-xs"
variant="light"
color="cyan"
leftSection={<ClipboardCheck size={14} />}
disabled={item.status !== 'ARRIVED_AT_WAREHOUSE' || busy}
loading={busy}
onClick={() => onInspect(item)}
>
Inspect
</Button>
<Button
size="compact-xs"
variant="light"
color="green"
leftSection={<PackageCheck size={14} />}
disabled={item.status !== 'UNDER_INSPECTION' || busy}
loading={busy}
onClick={() => onReadyForLoading(item)}
>
Ready
</Button>
{nextAction && (
<Button
size="compact-xs"
variant="light"
color={actionColor[nextAction]}
loading={busy}
onClick={() => onAdvance(item, nextAction)}
>
{humanizeEnum(nextAction.replace(/-/g, '_'))}
</Button>
)}
{item.status !== 'DISPATCHED' && (
<Tooltip label="Move" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => onMove(item)}>
<ArrowRightLeft size={16} />
</ActionIcon>
</Tooltip>
)}
<Tooltip label="History" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => onHistory(item)}>
<History size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Table.Td>
</Table.Tr>

View File

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

View File

@@ -11,3 +11,10 @@ export { CreateYardModal } from './CreateYardModal';
export { CreateZoneModal } from './CreateZoneModal';
export { ReceiveInventoryModal } from './ReceiveInventoryModal';
export { WarehouseInfoCard } from './WarehouseInfoCard';
export { MoveInventoryModal } from './MoveInventoryModal';
export { ReserveInventoryModal } from './ReserveInventoryModal';
export { InventoryMovementHistoryTable } from './InventoryMovementHistoryTable';
export { ActivityTimeline } from './ActivityTimeline';
export { InventoryHistoryModal } from './InventoryHistoryModal';
export { InventoryWorkbench } from './InventoryWorkbench';
export { BookingSelect } from './BookingSelect';

View File

@@ -186,6 +186,7 @@ export const URL_CONSTANTS = {
WAREHOUSES: {
BASE: '/warehouses',
DASHBOARD: '/warehouses/dashboard',
BY_ID: (id: string) => `/warehouses/${id}`,
YARDS: (warehouseId: string) => `/warehouses/${warehouseId}/yards`,
},
@@ -202,9 +203,15 @@ export const URL_CONSTANTS = {
WAREHOUSE_INVENTORY: {
BASE: '/warehouse-inventory',
RECEIVE: '/warehouse-inventory/receive',
RESERVE: '/warehouse-inventory/reserve',
READY_FOR_LOADING: '/warehouse-inventory/ready-for-loading',
INQUIRY: '/warehouse-inventory/inquiry',
INSPECT: (id: string) => `/warehouse-inventory/${id}/inspect`,
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
MOVEMENTS: (id: string) => `/warehouse-inventory/${id}/movements`,
ACTIVITY: (id: string) => `/warehouse-inventory/${id}/activity`,
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`,
},
};

View File

@@ -4,7 +4,9 @@ import { warehouseService } from '@/services/warehouse.service';
import type {
InventoryFilter,
InventoryInquiryFilter,
MoveInventoryPayload,
ReceiveInventoryPayload,
ReserveInventoryPayload,
SaveWarehousePayload,
SaveYardPayload,
SaveZonePayload,
@@ -137,19 +139,49 @@ export function useReceiveInventory() {
});
}
export function useInspectInventory() {
function useInventoryMutation<TArgs>(fn: (args: TArgs) => Promise<unknown>) {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => warehouseService.inspectInventory(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }),
mutationFn: fn,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
qc.invalidateQueries({ queryKey: warehouseKeys.all });
},
});
}
export function useMarkReadyForLoading() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => warehouseService.markReadyForLoading(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }),
export const useStoreInventory = () => useInventoryMutation((id: string) => warehouseService.store(id));
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 useDispatchInventory = () => useInventoryMutation((id: string) => warehouseService.dispatch(id));
export const useMoveInventory = () =>
useInventoryMutation((args: { id: string; payload: MoveInventoryPayload }) =>
warehouseService.move(args.id, args.payload),
);
export function useInventoryMovements(id?: string) {
return useQuery({
queryKey: ['warehouse-inventory', id, 'movements'],
queryFn: () => warehouseService.movements(id as string).then((r) => r.data),
enabled: Boolean(id),
});
}
export function useInventoryActivity(id?: string) {
return useQuery({
queryKey: ['warehouse-inventory', id, 'activity'],
queryFn: () => warehouseService.activity(id as string).then((r) => r.data),
enabled: Boolean(id),
});
}
export function useWarehouseDashboard() {
return useQuery({
queryKey: ['warehouses', 'dashboard'],
queryFn: () => warehouseService.dashboard().then((r) => r.data),
});
}

View File

@@ -61,21 +61,21 @@ export default function InventoryInquiryPage() {
label="Booking number"
placeholder="e.g. BKG-00123"
value={draft.bookingNumber ?? ''}
onChange={(e) => setDraft((f) => ({ ...f, bookingNumber: e.currentTarget.value || undefined }))}
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, bookingNumber: v || undefined })); }}
w={200}
/>
<TextInput
label="Container number"
placeholder="e.g. MSKU1234567"
value={draft.containerNumber ?? ''}
onChange={(e) => setDraft((f) => ({ ...f, containerNumber: e.currentTarget.value || undefined }))}
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, containerNumber: v || undefined })); }}
w={200}
/>
<TextInput
label="Goods name"
placeholder="e.g. Coffee"
value={draft.goodsName ?? ''}
onChange={(e) => setDraft((f) => ({ ...f, goodsName: e.currentTarget.value || undefined }))}
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, goodsName: v || undefined })); }}
w={180}
/>
<Select

View File

@@ -0,0 +1,78 @@
import { Card, Center, Container, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon, Title } from '@mantine/core';
import {
ClipboardCheck,
PackageCheck,
PackagePlus,
Send,
Truck,
Warehouse as WarehouseIcon,
Boxes,
Layers,
} from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { useWarehouseDashboard } from '@/hooks/useWarehouses';
import type { WarehouseDashboard } from '@/types/warehouse';
interface Metric {
key: keyof WarehouseDashboard;
label: string;
color: string;
icon: React.ReactNode;
}
const METRICS: Metric[] = [
{ key: 'totalWarehouses', label: 'Total Warehouses', color: 'indigo', icon: <WarehouseIcon size={20} /> },
{ key: 'totalInventory', label: 'Total Inventory', color: 'gray', icon: <Boxes size={20} /> },
{ key: 'receivedToday', label: 'Received Today', color: 'yellow', icon: <PackagePlus size={20} /> },
{ key: 'stored', label: 'Stored', color: 'blue', icon: <Layers size={20} /> },
{ key: 'reserved', label: 'Reserved', color: 'grape', icon: <ClipboardCheck size={20} /> },
{ key: 'readyForLoading', label: 'Ready For Loading', color: 'cyan', icon: <PackageCheck size={20} /> },
{ key: 'loaded', label: 'Loaded', color: 'teal', icon: <Truck size={20} /> },
{ key: 'dispatched', label: 'Dispatched', color: 'green', icon: <Send size={20} /> },
];
export default function WarehouseDashboardPage() {
const { data, isLoading } = useWarehouseDashboard();
return (
<Container size="xxl" py="lg">
<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>
{isLoading ? (
<Center py="xl">
<Loader />
</Center>
) : (
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
{METRICS.map((metric) => (
<Card key={metric.key} withBorder radius="md" padding="lg">
<Group justify="space-between" align="flex-start">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{metric.label}
</Text>
<Text fw={700} size="28px" mt={6}>
{data ? data[metric.key] : 0}
</Text>
</div>
<ThemeIcon variant="light" color={metric.color} size="lg" radius="md">
{metric.icon}
</ThemeIcon>
</Group>
</Card>
))}
</SimpleGrid>
)}
</Stack>
</Container>
);
}

View File

@@ -19,26 +19,22 @@ import {
import { ArrowLeft, Boxes, LayoutGrid, Package, Pencil, Plus } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { useToast } from '@/hooks/use-toast';
import {
CreateYardModal,
CreateZoneModal,
WarehouseInventoryTable,
InventoryWorkbench,
WarehouseStatusBadge,
WarehouseTypeBadge,
formatCapacity,
humanizeEnum,
} from '@/components/warehouses';
import {
useInspectInventory,
useMarkReadyForLoading,
useWarehouse,
useWarehouseInventory,
useWarehouseYards,
useWarehouseZones,
} from '@/hooks/useWarehouses';
import { extractErrorMessage } from '@/components/warehouses/options';
import type { WarehouseInventoryItem, WarehouseYard, WarehouseZone } from '@/types/warehouse';
import type { WarehouseYard, WarehouseZone } from '@/types/warehouse';
function StatCard({ label, value }: { label: string; value: string }) {
return (
@@ -56,7 +52,6 @@ function StatCard({ label, value }: { label: string; value: string }) {
export default function WarehouseDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { toast } = useToast();
const { data: warehouse, isLoading } = useWarehouse(id);
const yardsQuery = useWarehouseYards(id);
@@ -71,9 +66,6 @@ export default function WarehouseDetailPage() {
const zonesQuery = useWarehouseZones(selectedYardId ?? undefined);
const inventoryQuery = useWarehouseInventory(id ? { warehouseId: id } : undefined);
const inspectMutation = useInspectInventory();
const readyMutation = useMarkReadyForLoading();
const [busyId, setBusyId] = useState<string | null>(null);
const yards = yardsQuery.data ?? [];
const yardOptions = useMemo(
@@ -81,30 +73,6 @@ export default function WarehouseDetailPage() {
[yards],
);
const handleInspect = async (item: WarehouseInventoryItem) => {
setBusyId(item.id);
try {
await inspectMutation.mutateAsync(item.id);
toast({ title: 'Inventory under inspection' });
} catch (error) {
toast({ variant: 'destructive', title: 'Action failed', description: extractErrorMessage(error) });
} finally {
setBusyId(null);
}
};
const handleReady = async (item: WarehouseInventoryItem) => {
setBusyId(item.id);
try {
await readyMutation.mutateAsync(item.id);
toast({ title: 'Inventory ready for loading' });
} catch (error) {
toast({ variant: 'destructive', title: 'Action failed', description: extractErrorMessage(error) });
} finally {
setBusyId(null);
}
};
if (isLoading) {
return (
<Center mih="60vh">
@@ -337,18 +305,7 @@ export default function WarehouseDetailPage() {
{/* INVENTORY */}
<Tabs.Panel value="inventory" pt="lg">
<Card withBorder radius="md" padding="lg">
{inventoryQuery.isLoading ? (
<Center py="xl">
<Loader />
</Center>
) : (
<WarehouseInventoryTable
items={inventoryQuery.data ?? []}
onInspect={handleInspect}
onReadyForLoading={handleReady}
busyId={busyId}
/>
)}
<InventoryWorkbench items={inventoryQuery.data ?? []} isLoading={inventoryQuery.isLoading} />
</Card>
</Tabs.Panel>
</Tabs>

View File

@@ -1,32 +1,26 @@
import { useMemo, useState } from 'react';
import { Button, Card, Center, Container, Group, Loader, Select, Stack, Text, TextInput, Title } from '@mantine/core';
import { Button, Card, Container, Group, Select, Stack, Text, TextInput, Title } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { PackagePlus, Search } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { useToast } from '@/hooks/use-toast';
import {
InventoryWorkbench,
ReceiveInventoryModal,
WarehouseInventoryTable,
inventoryStatusOptions,
} from '@/components/warehouses';
import { extractErrorMessage } from '@/components/warehouses/options';
import {
useInspectInventory,
useMarkReadyForLoading,
useWarehouseInventory,
useWarehouseYards,
useWarehouseZones,
useWarehouses,
} from '@/hooks/useWarehouses';
import type { InventoryFilter, InventoryStatus, WarehouseInventoryItem } from '@/types/warehouse';
import type { InventoryFilter, InventoryStatus } from '@/types/warehouse';
export default function WarehouseInventoryPage() {
const { toast } = useToast();
const [filter, setFilter] = useState<InventoryFilter>({});
const [search, setSearch] = useState('');
const [modalOpen, setModalOpen] = useState(false);
const [busyId, setBusyId] = useState<string | null>(null);
const [debouncedSearch] = useDebouncedValue(search, 300);
const queryFilter = useMemo<InventoryFilter>(
@@ -39,9 +33,6 @@ export default function WarehouseInventoryPage() {
const zonesQuery = useWarehouseZones(filter.yardId);
const inventoryQuery = useWarehouseInventory(queryFilter);
const inspectMutation = useInspectInventory();
const readyMutation = useMarkReadyForLoading();
const warehouseOptions = useMemo(
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
[warehousesQuery.data],
@@ -55,30 +46,6 @@ export default function WarehouseInventoryPage() {
[zonesQuery.data],
);
const handleInspect = async (item: WarehouseInventoryItem) => {
setBusyId(item.id);
try {
await inspectMutation.mutateAsync(item.id);
toast({ title: 'Inventory under inspection' });
} catch (error) {
toast({ variant: 'destructive', title: 'Action failed', description: extractErrorMessage(error) });
} finally {
setBusyId(null);
}
};
const handleReady = async (item: WarehouseInventoryItem) => {
setBusyId(item.id);
try {
await readyMutation.mutateAsync(item.id);
toast({ title: 'Inventory ready for loading' });
} catch (error) {
toast({ variant: 'destructive', title: 'Action failed', description: extractErrorMessage(error) });
} finally {
setBusyId(null);
}
};
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Warehouse inventory' }]} />
@@ -88,7 +55,7 @@ export default function WarehouseInventoryPage() {
<div>
<Title order={2}>Warehouse Inventory</Title>
<Text c="dimmed" size="sm">
Track received items and move them through inspection to loading.
Track received items through the storage, reservation, loading and dispatch lifecycle.
</Text>
</div>
<Button leftSection={<PackagePlus size={16} />} onClick={() => setModalOpen(true)}>
@@ -147,18 +114,7 @@ export default function WarehouseInventoryPage() {
/>
</Group>
{inventoryQuery.isLoading ? (
<Center py="xl">
<Loader />
</Center>
) : (
<WarehouseInventoryTable
items={inventoryQuery.data ?? []}
onInspect={handleInspect}
onReadyForLoading={handleReady}
busyId={busyId}
/>
)}
<InventoryWorkbench items={inventoryQuery.data ?? []} isLoading={inventoryQuery.isLoading} />
</Stack>
</Card>
</Stack>

View File

@@ -5,11 +5,16 @@ import type {
InventoryFilter,
InventoryInquiryFilter,
InventoryInquiryResult,
InventoryMovement,
MoveInventoryPayload,
ReceiveInventoryPayload,
ReserveInventoryPayload,
SaveWarehousePayload,
SaveYardPayload,
SaveZonePayload,
Warehouse,
WarehouseActivityLog,
WarehouseDashboard,
WarehouseFilter,
WarehouseInventoryItem,
WarehouseYard,
@@ -27,6 +32,7 @@ export const warehouseService = {
apiClient.get<Warehouse[]>(URL_CONSTANTS.WAREHOUSES.BASE, {
params: cleanParams(filter ?? {}),
}),
dashboard: () => apiClient.get<WarehouseDashboard>(URL_CONSTANTS.WAREHOUSES.DASHBOARD),
getById: (id: string) => apiClient.get<Warehouse>(URL_CONSTANTS.WAREHOUSES.BY_ID(id)),
create: (payload: SaveWarehousePayload) =>
apiClient.post<Warehouse>(URL_CONSTANTS.WAREHOUSES.BASE, payload),
@@ -58,10 +64,6 @@ export const warehouseService = {
}),
receiveInventory: (payload: ReceiveInventoryPayload) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RECEIVE, 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)),
listReadyForLoading: (filter?: InventoryFilter) =>
apiClient.get<WarehouseInventoryItem[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.READY_FOR_LOADING, {
params: cleanParams(filter ?? {}),
@@ -70,4 +72,22 @@ export const warehouseService = {
apiClient.get<InventoryInquiryResult[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.INQUIRY, {
params: cleanParams(filter ?? {}),
}),
// ── Lifecycle (Batch 2) ──────────────────────────────────────────────────
store: (id: string) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.STORE(id)),
reserve: (payload: ReserveInventoryPayload) =>
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)),
dispatch: (id: string) =>
apiClient.post<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)),
};

View File

@@ -23,12 +23,27 @@ export const WAREHOUSE_ZONE_TYPES = [
export type WarehouseZoneType = (typeof WAREHOUSE_ZONE_TYPES)[number];
export const INVENTORY_STATUSES = [
'ARRIVED_AT_WAREHOUSE',
'UNDER_INSPECTION',
'RECEIVED',
'STORED',
'RESERVED',
'READY_FOR_LOADING',
'LOADED',
'DISPATCHED',
] as const;
export type InventoryStatus = (typeof INVENTORY_STATUSES)[number];
/** Next allowed lifecycle action keyed by current status. */
export const INVENTORY_NEXT_ACTION: Record<InventoryStatus, InventoryAction | null> = {
RECEIVED: 'store',
STORED: 'reserve',
RESERVED: 'ready-for-loading',
READY_FOR_LOADING: 'load',
LOADED: 'dispatch',
DISPATCHED: null,
};
export type InventoryAction = 'store' | 'reserve' | 'ready-for-loading' | 'load' | 'dispatch';
export interface WarehouseZone {
id: string;
yardId: string;
@@ -37,8 +52,11 @@ export interface WarehouseZone {
type: WarehouseZoneType;
capacityWeight: number | null;
capacityContainers: number | null;
maxWeight: number | null;
maxVolume: number | null;
currentWeight: number;
currentContainers: number;
currentVolume: number;
status: WarehouseStatus;
isActive: boolean;
}
@@ -51,8 +69,11 @@ export interface WarehouseYard {
type: WarehouseYardType;
capacityWeight: number | null;
capacityContainers: number | null;
maxWeight: number | null;
maxVolume: number | null;
currentWeight: number;
currentContainers: number;
currentVolume: number;
status: WarehouseStatus;
isActive: boolean;
zones?: WarehouseZone[];
@@ -67,8 +88,11 @@ export interface Warehouse {
locationName: string | null;
capacityWeight: number | null;
capacityContainers: number | null;
maxWeight: number | null;
maxVolume: number | null;
currentWeight: number;
currentContainers: number;
currentVolume: number;
status: WarehouseStatus;
isActive: boolean;
yards?: WarehouseYard[];
@@ -81,7 +105,7 @@ export interface WarehouseInventoryItem {
warehouseId: string;
yardId: string;
zoneId: string;
bookingId: string;
bookingId: string | null;
cargoId: string | null;
containerId: string | null;
goodsId: string | null;
@@ -90,14 +114,76 @@ export interface WarehouseInventoryItem {
volume: number | null;
status: InventoryStatus;
arrivedAt: string | null;
storedAt: string | null;
reservedAt: string | null;
inspectedAt: string | null;
readyForLoadingAt: string | null;
loadedAt: string | null;
dispatchedAt: string | null;
notes: string | null;
warehouse?: Warehouse | null;
yard?: WarehouseYard | null;
zone?: WarehouseZone | null;
}
export interface InventoryMovement {
id: string;
inventoryId: string;
fromWarehouseId: string;
fromYardId: string;
fromZoneId: string;
toWarehouseId: string;
toYardId: string;
toZoneId: string;
remarks: string | null;
movedBy: string | null;
movedAt: string;
}
export const ACTIVITY_TYPES = [
'INVENTORY_RECEIVED',
'INVENTORY_STORED',
'INVENTORY_MOVED',
'INVENTORY_RESERVED',
'READY_FOR_LOADING',
'INVENTORY_LOADED',
'INVENTORY_DISPATCHED',
] as const;
export type ActivityType = (typeof ACTIVITY_TYPES)[number];
export interface WarehouseActivityLog {
id: string;
inventoryId: string | null;
warehouseId: string | null;
activityType: ActivityType;
description: string | null;
performedBy: string | null;
createdAt: string;
}
export interface WarehouseDashboard {
totalWarehouses: number;
totalInventory: number;
receivedToday: number;
stored: number;
reserved: number;
readyForLoading: number;
loaded: number;
dispatched: number;
}
export interface MoveInventoryPayload {
warehouseId: string;
yardId: string;
zoneId: string;
remarks?: string;
}
export interface ReserveInventoryPayload {
bookingId: string;
inventoryId: string;
}
export interface InventoryInquiryResult {
id: string;
bookingId: string;
@@ -127,6 +213,8 @@ export interface SaveWarehousePayload {
locationName?: string;
capacityWeight?: number;
capacityContainers?: number;
maxWeight?: number;
maxVolume?: number;
status?: WarehouseStatus;
}
@@ -136,6 +224,8 @@ export interface SaveYardPayload {
type: WarehouseYardType;
capacityWeight?: number;
capacityContainers?: number;
maxWeight?: number;
maxVolume?: number;
status?: WarehouseStatus;
}
@@ -145,6 +235,8 @@ export interface SaveZonePayload {
type: WarehouseZoneType;
capacityWeight?: number;
capacityContainers?: number;
maxWeight?: number;
maxVolume?: number;
status?: WarehouseStatus;
}