Warehouse Enhancemendt

This commit is contained in:
hagiye
2026-06-20 11:49:22 +03:00
1393 changed files with 334619 additions and 20282 deletions

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

@@ -10,7 +10,8 @@ import {
} from '@mantine/core';
import { useToast } from '@/hooks/use-toast';
import { useCreateWarehouse, useUpdateWarehouse, useWarehouseFacilities } from '@/hooks/useWarehouses';
import { useStations } from '@/hooks/useStations';
import { useCreateWarehouse, useUpdateWarehouse } from '@/hooks/useWarehouses';
import type { SaveWarehousePayload, Warehouse, WarehouseType } from '@/types/warehouse';
import { extractErrorMessage, statusOptions, warehouseTypeOptions } from './options';
@@ -24,10 +25,11 @@ interface FormState {
name: string;
code: string;
type: WarehouseType;
stationId: string;
stationId: string | null;
locationName: string;
capacityWeight: number | '';
capacityContainers: number | '';
maxVolume: number | '';
status: 'ACTIVE' | 'INACTIVE';
}
@@ -35,10 +37,11 @@ const emptyForm = (): FormState => ({
name: '',
code: '',
type: 'OPEN_WAREHOUSE',
stationId: '',
stationId: null,
locationName: '',
capacityWeight: '',
capacityContainers: '',
maxVolume: '',
status: 'ACTIVE',
});
@@ -47,13 +50,10 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
const { toast } = useToast();
const createMutation = useCreateWarehouse();
const updateMutation = useUpdateWarehouse();
const facilitiesQuery = useWarehouseFacilities();
const { data: stations } = useStations();
const [form, setForm] = useState<FormState>(emptyForm());
const facilityOptions = (facilitiesQuery.data ?? []).map((facility) => ({
value: facility.id,
label: `${facility.label ?? facility.name ?? facility.code} (${facility.code})`,
}));
const stationOptions = (stations ?? []).map((s) => ({ value: s.id, label: `${s.name} (${s.code})` }));
useEffect(() => {
if (opened) {
@@ -63,10 +63,11 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
name: warehouse.name,
code: warehouse.code,
type: warehouse.type,
stationId: warehouse.stationId ?? '',
stationId: warehouse.stationId ?? null,
locationName: warehouse.locationName ?? '',
capacityWeight: warehouse.capacityWeight ?? '',
capacityContainers: warehouse.capacityContainers ?? '',
maxVolume: warehouse.maxVolume ?? '',
status: warehouse.status,
}
: emptyForm(),
@@ -86,10 +87,11 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
name: form.name.trim(),
code: form.code.trim(),
type: form.type,
stationId: form.stationId || undefined,
stationId: form.stationId ?? undefined,
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 {
@@ -115,17 +117,27 @@ 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>
<Select
label="Facility / Port (Station)"
placeholder="Select parent station"
data={stationOptions}
value={form.stationId}
onChange={(value) => setForm((f) => ({ ...f, stationId: value }))}
searchable
clearable
/>
<Group grow>
<Select
label="Type"
@@ -159,7 +171,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>
@@ -177,6 +189,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,80 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Group, Modal, Stack, Text, Textarea, TextInput } from '@mantine/core';
import { Info } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { useDeliverInventory } from '@/hooks/useWarehouses';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import { extractErrorMessage } from './options';
interface DeliverInventoryModalProps {
opened: boolean;
onClose: () => void;
item: WarehouseInventoryItem | null;
}
export function DeliverInventoryModal({ opened, onClose, item }: DeliverInventoryModalProps) {
const { toast } = useToast();
const deliverMutation = useDeliverInventory();
const [receiverName, setReceiverName] = useState('');
const [remarks, setRemarks] = useState('');
useEffect(() => {
if (opened) {
setReceiverName('');
setRemarks('');
}
}, [opened, item]);
const handleSubmit = async () => {
if (!item) return;
if (!receiverName.trim()) {
toast({ variant: 'destructive', title: 'Receiver name is required' });
return;
}
try {
await deliverMutation.mutateAsync({
id: item.id,
payload: { receiverName: receiverName.trim(), remarks: remarks.trim() || undefined },
});
toast({ title: 'Delivered — proof of delivery captured' });
onClose();
} catch (error) {
toast({ variant: 'destructive', title: 'Delivery failed', description: extractErrorMessage(error) });
}
};
return (
<Modal opened={opened} onClose={onClose} title="Deliver to customer (proof of delivery)" centered size="md">
<Stack gap="md">
<Alert icon={<Info size={16} />} color="green" variant="light">
<Text size="sm">
A release order must already be issued. Capturing the receiver marks the goods <b>DELIVERED</b>.
</Text>
</Alert>
<TextInput
label="Receiver name"
required
placeholder="Who received the goods"
value={receiverName}
onChange={(e) => setReceiverName(e.currentTarget.value)}
/>
<Textarea
label="Remarks"
placeholder="Optional delivery notes"
minRows={2}
value={remarks}
onChange={(e) => setRemarks(e.currentTarget.value)}
/>
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose} disabled={deliverMutation.isPending}>
Cancel
</Button>
<Button color="green" onClick={handleSubmit} loading={deliverMutation.isPending}>
Confirm delivery
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,194 @@
import { Badge, Button, Card, Divider, Group, Loader, Modal, Stack, Text } from '@mantine/core';
import { CalendarClock, Coins, DoorOpen, FileText } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import {
useFeePreview,
useGateClearance,
useGenerateInvoice,
useInvoicesForInventory,
} from '@/hooks/useWarehouses';
import { extractErrorMessage } from './options';
import type { FeePreview, WarehouseInvoiceStatus } from '@/types/warehouse';
const INVOICE_STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
DRAFT: 'gray',
ISSUED: 'orange',
PARTIALLY_PAID: 'yellow',
PAID: 'green',
CANCELLED: 'gray',
};
interface FeePreviewModalProps {
opened: boolean;
onClose: () => void;
inventoryId: string | null;
}
const LABELS: Record<string, { label: string; color: string }> = {
DEMURRAGE_FEE: { label: 'Demurrage', color: 'orange' },
STORAGE_FEE: { label: 'Storage', color: 'teal' },
};
function fmtDate(iso: string | null) {
if (!iso) return '—';
return new Date(iso).toLocaleDateString();
}
function FeeCard({ fee }: { fee: FeePreview }) {
const meta = LABELS[fee.ruleType] ?? { label: fee.ruleType, color: 'gray' };
const configured = Boolean(fee.ruleId);
return (
<Card withBorder radius="md" padding="md" style={{ borderColor: `var(--mantine-color-${meta.color}-3)` }}>
<Group justify="space-between" mb="xs">
<Group gap="xs">
<Coins size={16} />
<Text fw={700}>{meta.label}</Text>
{fee.endIsOpen && (
<Badge size="xs" color={meta.color} variant="light">
accruing
</Badge>
)}
</Group>
<Text fw={800} size="lg" c={`${meta.color}.7`}>
{fee.amount.toLocaleString()} {fee.currency}
</Text>
</Group>
{!configured ? (
<Text size="xs" c="dimmed">
No active {meta.label.toLowerCase()} rule configured amount shown as 0.
</Text>
) : (
<Stack gap={4}>
<Row label="Rule" value={fee.ruleName ?? '—'} />
<Row label="Free days" value={String(fee.freeDays)} />
<Row label="Rate / day" value={`${fee.ratePerDay.toLocaleString()} ${fee.currency}`} />
<Row label="Period" value={`${fmtDate(fee.startDate)}${fmtDate(fee.endDate)}${fee.endIsOpen ? ' (today)' : ''}`} />
<Row label="Elapsed days" value={String(fee.elapsedDays)} />
<Row label="Chargeable days" value={`${fee.chargeableDays} (after ${fee.freeDays} free)`} />
</Stack>
)}
</Card>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<Group justify="space-between" wrap="nowrap">
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="sm">{value}</Text>
</Group>
);
}
/** Batch 5 fee preview + Batch 6 invoice generation / gate clearance for an inventory item. */
export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModalProps) {
const { toast } = useToast();
const enabledId = opened ? inventoryId ?? undefined : undefined;
const { data, isLoading } = useFeePreview(enabledId);
const { data: invoices } = useInvoicesForInventory(enabledId);
const generate = useGenerateInvoice();
const gateClear = useGateClearance();
const activeInvoice = (invoices ?? []).find((i) => i.status !== 'CANCELLED');
const handleGenerate = async (confirmZero = false) => {
if (!inventoryId) return;
try {
const inv = await generate.mutateAsync({ inventoryId, confirmZero });
toast({ title: 'Invoice generated', description: `${inv.invoiceNumber}${inv.totalAmount} ${inv.currency}` });
} catch (error) {
const msg = extractErrorMessage(error);
if (/no payable warehouse fee/i.test(msg)) {
if (window.confirm('No payable warehouse fee found. Create a zero-amount invoice anyway?')) {
handleGenerate(true);
}
return;
}
toast({ variant: 'destructive', title: 'Generate failed', description: msg });
}
};
const handleGateClearance = async () => {
if (!inventoryId) return;
try {
await gateClear.mutateAsync(inventoryId);
toast({ title: 'Gate clearance recorded', description: 'Item released from terminal.' });
onClose();
} catch (error) {
toast({ variant: 'destructive', title: 'Release blocked', description: extractErrorMessage(error) });
}
};
return (
<Modal
opened={opened}
onClose={onClose}
title={
<Group gap="xs">
<CalendarClock size={18} />
<Text fw={700}>Storage &amp; Demurrage Preview</Text>
</Group>
}
centered
size="md"
>
{isLoading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : (
<Stack gap="md">
{(data ?? []).map((fee) => (
<FeeCard key={fee.ruleType} fee={fee} />
))}
<Divider label="Invoice & Release" labelPosition="left" />
{activeInvoice ? (
<Group justify="space-between">
<Group gap="xs">
<FileText size={16} />
<Text size="sm" fw={600}>{activeInvoice.invoiceNumber}</Text>
<Badge variant="light" color={INVOICE_STATUS_COLOR[activeInvoice.status]}>
{activeInvoice.status.replace(/_/g, ' ')}
</Badge>
</Group>
<Text size="sm">
{Number(activeInvoice.balanceAmount).toLocaleString()} {activeInvoice.currency} due
</Text>
</Group>
) : (
<Button
variant="light"
color="orange"
leftSection={<FileText size={16} />}
loading={generate.isPending}
onClick={() => handleGenerate(false)}
>
Generate Fee Invoice
</Button>
)}
<Button
variant="light"
color="green"
leftSection={<DoorOpen size={16} />}
loading={gateClear.isPending}
onClick={handleGateClearance}
>
Gate Clearance / Release
</Button>
<Text size="xs" c="dimmed">
Charges accrue from arrival until gate clearance / release. Final release is blocked while a
demurrage/storage invoice is unpaid.
</Text>
</Stack>
)}
</Modal>
);
}

View File

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

View File

@@ -0,0 +1,214 @@
import { useState } from 'react';
import {
Button,
Divider,
FileInput,
Group,
Modal,
NumberInput,
Select,
Switch,
Textarea,
} from '@mantine/core';
import { Upload } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { useCreateInspectionReport, useUploadInspectionAttachments } from '@/hooks/useWarehouses';
import {
INSPECTION_REPORT_TYPES,
INSPECTION_STATUSES,
type InspectionReportType,
type InspectionResultStatus,
} from '@/types/warehouse';
import { extractErrorMessage } from './options';
interface InspectionReportModalProps {
opened: boolean;
onClose: () => void;
inventoryId: string | null;
}
const REPORT_TYPE_LABELS: Record<InspectionReportType, string> = {
INSPECTION: 'Inspection',
DAMAGE: 'Damage',
WEIGHT_LOSS: 'Weight loss',
MISSING_ITEM: 'Missing item',
GENERAL: 'General',
};
const STATUS_LABELS: Record<InspectionResultStatus, string> = {
PASSED: 'Passed',
FAILED: 'Failed',
NEEDS_REVIEW: 'Needs review',
};
/** Batch 4.5 — record an inspection / damage report with optional image upload. */
export function InspectionReportModal({ opened, onClose, inventoryId }: InspectionReportModalProps) {
const { toast } = useToast();
const createReport = useCreateInspectionReport();
const uploadAttachments = useUploadInspectionAttachments();
const [reportType, setReportType] = useState<InspectionReportType>('INSPECTION');
const [inspectionStatus, setInspectionStatus] = useState<InspectionResultStatus>('PASSED');
const [hasDamage, setHasDamage] = useState(false);
const [damageDescription, setDamageDescription] = useState('');
const [hasWeightLoss, setHasWeightLoss] = useState(false);
const [expectedWeight, setExpectedWeight] = useState<number | ''>('');
const [actualWeight, setActualWeight] = useState<number | ''>('');
const [hasMissingItems, setHasMissingItems] = useState(false);
const [missingItemsDescription, setMissingItemsDescription] = useState('');
const [remarks, setRemarks] = useState('');
const [files, setFiles] = useState<File[]>([]);
const submitting = createReport.isPending || uploadAttachments.isPending;
const reset = () => {
setReportType('INSPECTION');
setInspectionStatus('PASSED');
setHasDamage(false);
setDamageDescription('');
setHasWeightLoss(false);
setExpectedWeight('');
setActualWeight('');
setHasMissingItems(false);
setMissingItemsDescription('');
setRemarks('');
setFiles([]);
};
const handleSubmit = async () => {
if (!inventoryId) return;
try {
const report = await createReport.mutateAsync({
inventoryId,
payload: {
reportType,
inspectionStatus,
hasDamage,
damageDescription: damageDescription.trim() || undefined,
hasWeightLoss,
expectedWeight: expectedWeight === '' ? undefined : Number(expectedWeight),
actualWeight: actualWeight === '' ? undefined : Number(actualWeight),
hasMissingItems,
missingItemsDescription: missingItemsDescription.trim() || undefined,
remarks: remarks.trim() || undefined,
},
});
if (files.length > 0) {
await uploadAttachments.mutateAsync({ reportId: report.id, files });
}
toast({ title: 'Inspection report saved' });
reset();
onClose();
} catch (error) {
toast({ variant: 'destructive', title: 'Save failed', description: extractErrorMessage(error) });
}
};
return (
<Modal opened={opened} onClose={onClose} title="Inspection / Report" centered size="lg">
<Group grow>
<Select
label="Report type"
data={INSPECTION_REPORT_TYPES.map((t) => ({ value: t, label: REPORT_TYPE_LABELS[t] }))}
value={reportType}
onChange={(v) => setReportType((v as InspectionReportType) ?? 'INSPECTION')}
allowDeselect={false}
/>
<Select
label="Inspection status"
data={INSPECTION_STATUSES.map((s) => ({ value: s, label: STATUS_LABELS[s] }))}
value={inspectionStatus}
onChange={(v) => setInspectionStatus((v as InspectionResultStatus) ?? 'PASSED')}
allowDeselect={false}
/>
</Group>
<Divider my="md" label="Damage" labelPosition="left" />
<Switch
label="Has damage"
checked={hasDamage}
onChange={(e) => setHasDamage(e.currentTarget.checked)}
color="orange"
/>
{hasDamage && (
<Textarea
mt="xs"
label="Damage description"
value={damageDescription}
onChange={(e) => setDamageDescription(e.currentTarget.value)}
/>
)}
<Divider my="md" label="Weight loss" labelPosition="left" />
<Switch
label="Has weight loss"
checked={hasWeightLoss}
onChange={(e) => setHasWeightLoss(e.currentTarget.checked)}
color="orange"
/>
{hasWeightLoss && (
<Group grow mt="xs">
<NumberInput
label="Expected weight (kg)"
min={0}
value={expectedWeight}
onChange={(v) => setExpectedWeight(v === '' ? '' : Number(v))}
/>
<NumberInput
label="Actual weight (kg)"
min={0}
value={actualWeight}
onChange={(v) => setActualWeight(v === '' ? '' : Number(v))}
/>
</Group>
)}
<Divider my="md" label="Missing items" labelPosition="left" />
<Switch
label="Has missing items"
checked={hasMissingItems}
onChange={(e) => setHasMissingItems(e.currentTarget.checked)}
color="orange"
/>
{hasMissingItems && (
<Textarea
mt="xs"
label="Missing items description"
value={missingItemsDescription}
onChange={(e) => setMissingItemsDescription(e.currentTarget.value)}
/>
)}
<Divider my="md" />
<Textarea
label="Remarks"
value={remarks}
onChange={(e) => setRemarks(e.currentTarget.value)}
/>
<FileInput
mt="md"
label="Images / documents"
placeholder="Upload jpg, png or pdf"
accept="image/jpeg,image/png,application/pdf"
leftSection={<Upload size={16} />}
multiple
value={files}
onChange={setFiles}
clearable
/>
<Group justify="flex-end" mt="lg">
<Button variant="default" onClick={onClose} disabled={submitting}>
Cancel
</Button>
<Button color="green" onClick={handleSubmit} loading={submitting} disabled={!inventoryId}>
Save report
</Button>
</Group>
</Modal>
);
}

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,131 @@
import { useState } from 'react';
import { Center, Loader } from '@mantine/core';
import { useToast } from '@/hooks/use-toast';
import {
useDispatchInventory,
useMarkReadyForLoading,
useMarkReadyForPickup,
useStoreInventory,
} from '@/hooks/useWarehouses';
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
import { DeliverInventoryModal } from './DeliverInventoryModal';
import { FeePreviewModal } from './FeePreviewModal';
import { InspectionReportModal } from './InspectionReportModal';
import { InventoryHistoryModal } from './InventoryHistoryModal';
import { LoadInventoryModal } from './LoadInventoryModal';
import { MoveInventoryModal } from './MoveInventoryModal';
import { ReleaseOrderModal } from './ReleaseOrderModal';
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 [loadItem, setLoadItem] = useState<WarehouseInventoryItem | null>(null);
const [historyItem, setHistoryItem] = useState<WarehouseInventoryItem | null>(null);
const [inspectItem, setInspectItem] = useState<WarehouseInventoryItem | null>(null);
const [feeItem, setFeeItem] = useState<WarehouseInventoryItem | null>(null);
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
const [deliverItem, setDeliverItem] = useState<WarehouseInventoryItem | null>(null);
const storeMutation = useStoreInventory();
const readyMutation = useMarkReadyForLoading();
const pickupMutation = useMarkReadyForPickup();
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':
setLoadItem(item);
return;
case 'dispatch':
return runDirect(item, () => dispatchMutation.mutateAsync(item.id), 'Inventory dispatched');
case 'ready-for-pickup':
return runDirect(item, () => pickupMutation.mutateAsync(item.id), 'Ready for pickup');
case 'release':
setReleaseItem(item);
return;
case 'deliver':
setDeliverItem(item);
return;
default:
return;
}
};
if (isLoading) {
return (
<Center py="xl">
<Loader />
</Center>
);
}
return (
<>
<WarehouseInventoryTable
items={items}
busyId={busyId}
onAdvance={advance}
onMove={setMoveItem}
onHistory={setHistoryItem}
onInspect={setInspectItem}
onFeePreview={setFeeItem}
/>
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
<ReserveInventoryModal
opened={Boolean(reserveItem)}
onClose={() => setReserveItem(null)}
item={reserveItem}
/>
<LoadInventoryModal opened={Boolean(loadItem)} onClose={() => setLoadItem(null)} item={loadItem} />
<InventoryHistoryModal
opened={Boolean(historyItem)}
onClose={() => setHistoryItem(null)}
item={historyItem}
/>
<InspectionReportModal
opened={Boolean(inspectItem)}
onClose={() => setInspectItem(null)}
inventoryId={inspectItem?.id ?? null}
/>
<FeePreviewModal
opened={Boolean(feeItem)}
onClose={() => setFeeItem(null)}
inventoryId={feeItem?.id ?? null}
/>
<ReleaseOrderModal opened={Boolean(releaseItem)} onClose={() => setReleaseItem(null)} item={releaseItem} />
<DeliverInventoryModal opened={Boolean(deliverItem)} onClose={() => setDeliverItem(null)} item={deliverItem} />
</>
);
}

View File

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

View File

@@ -0,0 +1,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,62 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Group, Modal, Stack, Text, TextInput } from '@mantine/core';
import { Info } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { useReleaseInventory } from '@/hooks/useWarehouses';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import { extractErrorMessage } from './options';
interface ReleaseOrderModalProps {
opened: boolean;
onClose: () => void;
item: WarehouseInventoryItem | null;
}
export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalProps) {
const { toast } = useToast();
const releaseMutation = useReleaseInventory();
const [reference, setReference] = useState('');
useEffect(() => {
if (opened) setReference(item?.releaseOrderReference ?? '');
}, [opened, item]);
const handleSubmit = async () => {
if (!item) return;
try {
await releaseMutation.mutateAsync({ id: item.id, payload: { reference: reference.trim() || undefined } });
toast({ title: 'Release order issued' });
onClose();
} catch (error) {
toast({ variant: 'destructive', title: 'Release failed', description: extractErrorMessage(error) });
}
};
return (
<Modal opened={opened} onClose={onClose} title="Issue release order (DO)" centered size="md">
<Stack gap="md">
<Alert icon={<Info size={16} />} color="orange" variant="light">
<Text size="sm">
Records the delivery order / release order sent to the customer. Once issued, the goods can be
picked up and delivered.
</Text>
</Alert>
<TextInput
label="Release order reference"
placeholder="e.g. DO-2026-001"
value={reference}
onChange={(e) => setReference(e.currentTarget.value)}
/>
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose} disabled={releaseMutation.isPending}>
Cancel
</Button>
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending}>
Issue release order
</Button>
</Group>
</Stack>
</Modal>
);
}

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

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

View File

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

View File

@@ -1,6 +1,8 @@
import { useMemo } from 'react';
import { ActionIcon, Card, Group, SimpleGrid, Stack, Text } from '@mantine/core';
import { Eye, MapPin, Pencil } from 'lucide-react';
import { Building2, Eye, MapPin, Pencil } from 'lucide-react';
import { useStations } from '@/hooks/useStations';
import type { Warehouse } from '@/types/warehouse';
import { WarehouseStatusBadge, WarehouseTypeBadge } from './badges';
import { formatCapacity } from './options';
@@ -12,6 +14,12 @@ interface WarehouseCardViewProps {
}
export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardViewProps) {
const { data: stations } = useStations();
const stationNameById = useMemo(
() => new Map((stations ?? []).map((s) => [s.id, s.name])),
[stations],
);
if (warehouses.length === 0) {
return (
<Text c="dimmed" ta="center" py="xl">
@@ -39,11 +47,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.stationId && stationNameById.get(warehouse.stationId) && (
<Group gap={6} c="dimmed">
<Building2 size={14} />
<Text size="sm">{stationNameById.get(warehouse.stationId)}</Text>
</Group>
)}
{warehouse.locationName && (
<Group gap={6} c="dimmed">

View File

@@ -0,0 +1,237 @@
import { useMemo, useState } from 'react';
import { Card, Group, SegmentedControl, SimpleGrid, Text, ThemeIcon } from '@mantine/core';
import { BarChart3, CalendarRange, PieChart as PieChartIcon } from 'lucide-react';
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { useWarehouseInventory } from '@/hooks/useWarehouses';
import type { WarehouseDashboard, WarehouseInventoryItem } from '@/types/warehouse';
interface WarehouseDashboardChartsProps {
data?: WarehouseDashboard;
}
const ORANGE = '#f08c00';
const GREEN = '#5bbf4a';
/** Inventory lifecycle status series — alternating orange / light green. */
const STATUS_SERIES = [
{ key: 'stored', label: 'Stored', color: ORANGE },
{ key: 'reserved', label: 'Reserved', color: GREEN },
{ key: 'readyForLoading', label: 'Ready', color: ORANGE },
{ key: 'loaded', label: 'Loaded', color: GREEN },
{ key: 'dispatched', label: 'Dispatched', color: ORANGE },
] as const;
type Granularity = 'week' | 'month' | 'year';
export function WarehouseDashboardCharts({ data }: WarehouseDashboardChartsProps) {
const [granularity, setGranularity] = useState<Granularity>('month');
const { data: inventory } = useWarehouseInventory();
const statusData = STATUS_SERIES.map((s) => ({
name: s.label,
value: data ? Number(data[s.key as keyof WarehouseDashboard] ?? 0) : 0,
color: s.color,
}));
const hasStatus = statusData.some((d) => d.value > 0);
const trend = useMemo(
() => buildTrend(inventory ?? [], granularity),
[inventory, granularity],
);
const hasTrend = trend.some((b) => b.received > 0 || b.dispatched > 0);
return (
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
{/* Time-filtered throughput */}
<Card withBorder radius="lg" padding="lg" style={{ gridColumn: '1 / -1' }}>
<Group justify="space-between" mb="md" wrap="wrap">
<Group gap="sm">
<ThemeIcon size="lg" radius="md" style={{ backgroundColor: ORANGE, color: '#fff' }}>
<CalendarRange size={20} />
</ThemeIcon>
<div>
<Text fw={700}>Throughput Over Time</Text>
<Text size="xs" c="dimmed">
Received vs dispatched inventory
</Text>
</div>
</Group>
<SegmentedControl
value={granularity}
onChange={(v) => setGranularity(v as Granularity)}
data={[
{ label: 'Weekly', value: 'week' },
{ label: 'Monthly', value: 'month' },
{ label: 'Yearly', value: 'year' },
]}
/>
</Group>
{hasTrend ? (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={trend} margin={{ top: 8, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--mantine-color-gray-2)" />
<XAxis dataKey="label" tick={{ fontSize: 12 }} />
<YAxis allowDecimals={false} tick={{ fontSize: 12 }} />
<Tooltip cursor={{ fill: 'var(--mantine-color-gray-1)' }} />
<Legend iconType="circle" />
<Bar dataKey="received" name="Received" fill={ORANGE} radius={[6, 6, 0, 0]} />
<Bar dataKey="dispatched" name="Dispatched" fill={GREEN} radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<EmptyChart />
)}
</Card>
{/* Status distribution bar */}
<Card withBorder radius="lg" padding="lg">
<Group gap="sm" mb="md">
<ThemeIcon size="lg" radius="md" style={{ backgroundColor: GREEN, color: '#fff' }}>
<BarChart3 size={20} />
</ThemeIcon>
<div>
<Text fw={700}>Inventory by Status</Text>
<Text size="xs" c="dimmed">
Items at each lifecycle stage
</Text>
</div>
</Group>
{hasStatus ? (
<ResponsiveContainer width="100%" height={280}>
<BarChart data={statusData} margin={{ top: 8, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--mantine-color-gray-2)" />
<XAxis dataKey="name" tick={{ fontSize: 12 }} />
<YAxis allowDecimals={false} tick={{ fontSize: 12 }} />
<Tooltip cursor={{ fill: 'var(--mantine-color-gray-1)' }} />
<Bar dataKey="value" name="Items" radius={[6, 6, 0, 0]}>
{statusData.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
) : (
<EmptyChart />
)}
</Card>
{/* Status distribution donut */}
<Card withBorder radius="lg" padding="lg">
<Group gap="sm" mb="md">
<ThemeIcon size="lg" radius="md" style={{ backgroundColor: ORANGE, color: '#fff' }}>
<PieChartIcon size={20} />
</ThemeIcon>
<div>
<Text fw={700}>Lifecycle Distribution</Text>
<Text size="xs" c="dimmed">
Share of inventory across statuses
</Text>
</div>
</Group>
{hasStatus ? (
<ResponsiveContainer width="100%" height={280}>
<PieChart>
<Pie
data={statusData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={55}
outerRadius={95}
paddingAngle={2}
>
{statusData.map((entry, i) => (
<Cell key={entry.name} fill={i % 2 === 0 ? ORANGE : GREEN} />
))}
</Pie>
<Tooltip />
<Legend verticalAlign="bottom" height={36} iconType="circle" />
</PieChart>
</ResponsiveContainer>
) : (
<EmptyChart />
)}
</Card>
</SimpleGrid>
);
}
interface TrendBucket {
label: string;
received: number;
dispatched: number;
}
/** Bucket inventory by arrived/dispatched timestamps into recent week/month/year periods. */
function buildTrend(items: WarehouseInventoryItem[], granularity: Granularity): TrendBucket[] {
const now = new Date();
const buckets: { label: string; start: Date; end: Date }[] = [];
if (granularity === 'week') {
for (let i = 7; i >= 0; i--) {
const end = new Date(now);
end.setDate(now.getDate() - i * 7);
const start = new Date(end);
start.setDate(end.getDate() - 7);
buckets.push({ label: `W${8 - i}`, start, end });
}
} else if (granularity === 'month') {
for (let i = 11; i >= 0; i--) {
const start = new Date(now.getFullYear(), now.getMonth() - i, 1);
const end = new Date(now.getFullYear(), now.getMonth() - i + 1, 1);
buckets.push({
label: start.toLocaleString('en', { month: 'short' }),
start,
end,
});
}
} else {
for (let i = 4; i >= 0; i--) {
const year = now.getFullYear() - i;
buckets.push({
label: String(year),
start: new Date(year, 0, 1),
end: new Date(year + 1, 0, 1),
});
}
}
const inRange = (iso: string | null | undefined, start: Date, end: Date) => {
if (!iso) return false;
const t = new Date(iso).getTime();
return t >= start.getTime() && t < end.getTime();
};
return buckets.map((b) => ({
label: b.label,
received: items.filter((it) => inRange(it.arrivedAt, b.start, b.end)).length,
dispatched: items.filter((it) => inRange(it.dispatchedAt, b.start, b.end)).length,
}));
}
function EmptyChart() {
return (
<Group justify="center" align="center" h={280}>
<Text c="dimmed" size="sm">
No inventory data to chart yet.
</Text>
</Group>
);
}

View File

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

View File

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

View File

@@ -1,20 +1,19 @@
import { Badge, Button, Group, Stack, Table, Text, Tooltip } from '@mantine/core';
import { ArrowLeftRight, ClipboardCheck, PackageCheck, Send, Truck, Warehouse } from 'lucide-react';
import { ActionIcon, Badge, Button, Group, Table, Text, Tooltip } from '@mantine/core';
import { ArrowRightLeft, ClipboardList, Coins, History } from 'lucide-react';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
import { getNextInventoryAction } 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;
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;
onAdvance: (item: WarehouseInventoryItem, action: InventoryAction) => void;
onMove: (item: WarehouseInventoryItem) => void;
onHistory: (item: WarehouseInventoryItem) => void;
onInspect?: (item: WarehouseInventoryItem) => void;
onFeePreview?: (item: WarehouseInventoryItem) => void;
}
const itemKind = (item: WarehouseInventoryItem) => {
@@ -24,19 +23,25 @@ const itemKind = (item: WarehouseInventoryItem) => {
return { label: '-', color: 'gray' };
};
const isPaidBooking = (item: WarehouseInventoryItem) =>
item.booking?.status === 'PAID' || item.booking?.paymentStatus === 'PAID';
const actionColor: Record<InventoryAction, string> = {
store: 'blue',
reserve: 'grape',
'ready-for-loading': 'cyan',
load: 'teal',
dispatch: 'green',
'ready-for-pickup': 'orange',
release: 'yellow',
deliver: 'green',
};
export function WarehouseInventoryTable({
items,
onInspect,
onStore,
onReserve,
onReadyForLoading,
onLoad,
onDispatch,
onMove,
busyId,
onAdvance,
onMove,
onHistory,
onInspect,
onFeePreview,
}: WarehouseInventoryTableProps) {
if (items.length === 0) {
return (
@@ -47,18 +52,20 @@ export function WarehouseInventoryTable({
}
return (
<Table.ScrollContainer minWidth={1280}>
<Table.ScrollContainer minWidth={1150}>
<Table highlightOnHover verticalSpacing="sm" striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
<Table.Th>Location Path</Table.Th>
<Table.Th>Facility</Table.Th>
<Table.Th>Warehouse</Table.Th>
<Table.Th>Yard</Table.Th>
<Table.Th>Zone</Table.Th>
<Table.Th>Item</Table.Th>
<Table.Th>Qty</Table.Th>
<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>
@@ -66,38 +73,26 @@ 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(' -> ');
const nextAction = getNextInventoryAction(item);
return (
<Table.Tr key={item.id}>
<Table.Td>
<Tooltip label={item.bookingId} withArrow>
<Stack gap={2}>
{item.bookingId ? (
<Tooltip label={item.bookingId} withArrow>
<Text size="sm" fw={600}>
{item.booking?.reference ?? `${item.bookingId.slice(0, 8)}...`}
{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>
<Text size="sm" fw={500}>
{locationPath}
</Text>
</Tooltip>
) : (
<Text size="sm" c="dimmed">
</Text>
)}
</Table.Td>
<Table.Td>{item.warehouse?.facility?.name ?? '—'}</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>
<Badge color={kind.color} variant="light" size="sm" radius="md">
{kind.label}
@@ -111,88 +106,45 @@ 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="subtle"
color="gray"
leftSection={<ArrowLeftRight size={14} />}
disabled={!onMove || busy}
loading={busy}
onClick={() => onMove?.(item)}
>
Move
</Button>
<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="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 !== '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>
{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>
)}
{onInspect && (
<Tooltip label="Inspection / Report" withArrow>
<ActionIcon variant="subtle" color="orange" onClick={() => onInspect(item)}>
<ClipboardList size={16} />
</ActionIcon>
</Tooltip>
)}
{onFeePreview && (
<Tooltip label="Storage / Demurrage preview" withArrow>
<ActionIcon variant="subtle" color="teal" onClick={() => onFeePreview(item)}>
<Coins 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

@@ -1,6 +1,8 @@
import { useMemo } from 'react';
import { ActionIcon, Anchor, Group, Table, Text } from '@mantine/core';
import { Eye, Pencil } from 'lucide-react';
import { useStations } from '@/hooks/useStations';
import type { Warehouse } from '@/types/warehouse';
import { WarehouseStatusBadge, WarehouseTypeBadge } from './badges';
import { formatCapacity } from './options';
@@ -12,6 +14,12 @@ interface WarehouseTableProps {
}
export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTableProps) {
const { data: stations } = useStations();
const stationNameById = useMemo(
() => new Map((stations ?? []).map((s) => [s.id, s.name])),
[stations],
);
if (warehouses.length === 0) {
return (
<Text c="dimmed" ta="center" py="xl">
@@ -27,7 +35,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>Facility</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Location</Table.Th>
<Table.Th>Weight (cur / cap)</Table.Th>
@@ -46,9 +54,15 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro
</Table.Td>
<Table.Td>{warehouse.name}</Table.Td>
<Table.Td>
{warehouse.facility
? `${warehouse.facility.label ?? warehouse.facility.name ?? warehouse.facility.code} (${warehouse.facility.code})`
: '—'}
{warehouse.stationId && stationNameById.get(warehouse.stationId) ? (
<Text size="sm" fw={500}>
{stationNameById.get(warehouse.stationId)}
</Text>
) : (
<Text size="sm" c="dimmed">
</Text>
)}
</Table.Td>
<Table.Td>
<WarehouseTypeBadge type={warehouse.type} />

View File

@@ -37,11 +37,11 @@ const inventoryStatusColor: Record<InventoryStatus, string> = {
RECEIVED: 'yellow',
STORED: 'blue',
RESERVED: 'grape',
ARRIVED_AT_WAREHOUSE: 'yellow',
UNDER_INSPECTION: 'cyan',
READY_FOR_LOADING: 'green',
READY_FOR_LOADING: 'cyan',
LOADED: 'teal',
DISPATCHED: 'gray',
DISPATCHED: 'green',
READY_FOR_PICKUP: 'orange',
DELIVERED: 'green',
};
export function InventoryStatusBadge({ status }: { status: InventoryStatus }) {

View File

@@ -11,3 +11,19 @@ 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';
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';
export { WarehouseDashboardCharts } from './WarehouseDashboardCharts';
export { InspectionReportModal } from './InspectionReportModal';
export { FeePreviewModal } from './FeePreviewModal';