Merge branch 'freight/develop' of github.com:Tria-plc/edr-platform into freight_feature/priority

This commit is contained in:
marshal
2026-06-18 17:04:35 +03:00
159 changed files with 13316 additions and 125 deletions

View File

@@ -6,12 +6,15 @@ import {
LayoutGrid,
Network,
Paperclip,
PackageCheck,
Send,
Settings,
SlidersHorizontal,
Train,
Truck,
Container,
Package,
PackageOpen,
Users,
Wallet,
//TrainTrack,
@@ -53,6 +56,17 @@ import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/perm
import { RequirePermission } from "./components/auth/RequirePermission";
import TrainDetailPage from "./pages/trains/TrainDetailPage";
import RoutesPage from "./pages/fleet/RoutesPage";
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";
import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage";
import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage";
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage";
import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage";
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
@@ -143,6 +157,61 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
// },
],
},
{
title: "Warehouse Management",
items: [
{
label: "Warehouse Dashboard",
href: "/dashboard/warehouse-dashboard",
icon: <LayoutDashboard />,
},
{
label: "Warehouses",
href: "/dashboard/warehouses",
icon: <Container />,
},
{
label: "Inventory",
href: "/dashboard/warehouse-inventory",
icon: <Package />,
},
{
label: "Arrival Queue",
href: "/dashboard/arrival-queue",
icon: <PackageOpen />,
},
{
label: "Loading Queue",
href: "/dashboard/loading-queue",
icon: <Truck />,
},
{
label: "Loaded Inventory",
href: "/dashboard/loaded-inventory",
icon: <PackageCheck />,
},
{
label: "Dispatch Queue",
href: "/dashboard/dispatch-queue",
icon: <Send />,
},
{
label: "Inventory Inquiry",
href: "/dashboard/inventory-inquiry",
icon: <Boxes />,
},
{
label: "Allocation & Fees",
href: "/dashboard/warehouse-rules",
icon: <SlidersHorizontal />,
},
{
label: "Fee Invoices",
href: "/dashboard/warehouse-fee-invoices",
icon: <Wallet />,
},
],
},
{
title: "Administration",
items: [
@@ -303,6 +372,18 @@ const App = () => {
path="booking-requests/:id/contract"
element={<BookingContractPage />}
/>
<Route path="warehouses" element={<WarehouseListPage />} />
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
<Route path="arrival-queue" element={<ArrivalQueuePage />} />
<Route path="loading-queue" element={<LoadingQueuePage />} />
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
<Route path="warehouse-rules" element={<WarehouseRulesPage />} />
<Route path="warehouse-fee-invoices" element={<WarehouseInvoicesPage />} />
<Route path="warehouse-dashboard" element={<WarehouseDashboardPage />} />
<Route
path="operations/train-scheduling"
element={<Navigate to="/dashboard/operations/train-scheduling-v2" replace />}

View File

@@ -0,0 +1,99 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
interface ErrorBoundaryProps {
children: ReactNode;
}
interface ErrorBoundaryState {
error: Error | null;
}
/**
* App-wide error boundary. Without this, any render-time exception unmounts the
* React tree and the user sees a blank white screen. This surfaces the actual
* error message + stack so failures are diagnosable in place.
*/
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { error: null };
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
// eslint-disable-next-line no-console
console.error("[ErrorBoundary] Uncaught render error:", error, info.componentStack);
}
handleReset = () => this.setState({ error: null });
render() {
const { error } = this.state;
if (!error) return this.props.children;
return (
<div
style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 24,
background: "#f8fafc",
fontFamily: "system-ui, sans-serif",
}}
>
<div
style={{
maxWidth: 720,
width: "100%",
background: "#fff",
border: "1px solid #fecaca",
borderRadius: 12,
padding: 24,
boxShadow: "0 1px 3px rgba(0,0,0,0.08)",
}}
>
<h2 style={{ margin: 0, color: "#b91c1c", fontSize: 18 }}>Something went wrong</h2>
<p style={{ color: "#64748b", fontSize: 14 }}>
A render error was caught. Details below share this with the developer.
</p>
<pre
style={{
whiteSpace: "pre-wrap",
wordBreak: "break-word",
background: "#0f172a",
color: "#fca5a5",
padding: 16,
borderRadius: 8,
fontSize: 12,
maxHeight: 320,
overflow: "auto",
}}
>
{error.message}
{"\n\n"}
{error.stack}
</pre>
<button
type="button"
onClick={this.handleReset}
style={{
marginTop: 12,
padding: "8px 16px",
border: "none",
borderRadius: 8,
background: "#0f766e",
color: "#fff",
cursor: "pointer",
fontSize: 14,
}}
>
Dismiss
</button>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,96 @@
import { useState } from 'react';
import { PackageCheck } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { useDeliverCargo } from '@/hooks/useCargoes';
import { useToast } from '@/hooks/use-toast';
/**
* Customer Pickup + Proof of Delivery capture for a LOADED cargo.
* Records receiver name, pickup date and remarks, then marks the cargo DELIVERED.
*/
export function DeliverCargoDialog({ cargoId, onSuccess }: { cargoId: string; onSuccess?: () => void }) {
const [open, setOpen] = useState(false);
const [receiverName, setReceiverName] = useState('');
const [pickupDate, setPickupDate] = useState('');
const [deliveryRemarks, setDeliveryRemarks] = useState('');
const deliver = useDeliverCargo();
const { toast } = useToast();
const handleDeliver = async () => {
if (!receiverName.trim()) {
toast({ title: 'Receiver name is required', variant: 'destructive' });
return;
}
try {
await deliver.mutateAsync({
id: cargoId,
payload: {
receiverName: receiverName.trim(),
pickupDate: pickupDate ? new Date(pickupDate).toISOString() : undefined,
deliveryRemarks: deliveryRemarks.trim() || undefined,
},
});
toast({ title: 'Delivered', description: 'Proof of delivery recorded; cargo marked delivered.' });
setOpen(false);
setReceiverName('');
setPickupDate('');
setDeliveryRemarks('');
onSuccess?.();
} catch (error) {
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data?.message ??
'Could not record delivery.';
toast({ title: 'Delivery failed', description: String(message), variant: 'destructive' });
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" title="Customer pickup / Proof of delivery">
<PackageCheck className="size-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Customer Pickup &amp; Proof of Delivery</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>Receiver name*</Label>
<Input
required
value={receiverName}
placeholder="Name of person collecting the cargo"
onChange={(e) => setReceiverName(e.target.value)}
/>
</div>
<div>
<Label>Pickup date</Label>
<Input
type="datetime-local"
value={pickupDate}
onChange={(e) => setPickupDate(e.target.value)}
/>
</div>
<div>
<Label>Remarks</Label>
<Textarea
value={deliveryRemarks}
placeholder="Condition on handover, ID checked, etc."
onChange={(e) => setDeliveryRemarks(e.target.value)}
/>
</div>
<Button onClick={handleDeliver} disabled={deliver.isPending} className="w-full">
{deliver.isPending ? 'Recording…' : 'Confirm Pickup & Mark Delivered'}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

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

@@ -0,0 +1,202 @@
import { useEffect, useState } from 'react';
import {
Button,
Group,
Modal,
NumberInput,
Select,
Stack,
TextInput,
} from '@mantine/core';
import { useToast } from '@/hooks/use-toast';
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';
interface CreateWarehouseModalProps {
opened: boolean;
onClose: () => void;
warehouse?: Warehouse | null;
}
interface FormState {
name: string;
code: string;
type: WarehouseType;
stationId: string | null;
locationName: string;
capacityWeight: number | '';
capacityContainers: number | '';
maxVolume: number | '';
status: 'ACTIVE' | 'INACTIVE';
}
const emptyForm = (): FormState => ({
name: '',
code: '',
type: 'OPEN_WAREHOUSE',
stationId: null,
locationName: '',
capacityWeight: '',
capacityContainers: '',
maxVolume: '',
status: 'ACTIVE',
});
export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWarehouseModalProps) {
const isEdit = Boolean(warehouse);
const { toast } = useToast();
const createMutation = useCreateWarehouse();
const updateMutation = useUpdateWarehouse();
const { data: stations } = useStations();
const [form, setForm] = useState<FormState>(emptyForm());
const stationOptions = (stations ?? []).map((s) => ({ value: s.id, label: `${s.name} (${s.code})` }));
useEffect(() => {
if (opened) {
setForm(
warehouse
? {
name: warehouse.name,
code: warehouse.code,
type: warehouse.type,
stationId: warehouse.stationId ?? null,
locationName: warehouse.locationName ?? '',
capacityWeight: warehouse.capacityWeight ?? '',
capacityContainers: warehouse.capacityContainers ?? '',
maxVolume: warehouse.maxVolume ?? '',
status: warehouse.status,
}
: emptyForm(),
);
}
}, [opened, warehouse]);
const submitting = createMutation.isPending || updateMutation.isPending;
const handleSubmit = async () => {
if (!form.name.trim() || !form.code.trim()) {
toast({ variant: 'destructive', title: 'Name and code are required' });
return;
}
const payload: SaveWarehousePayload = {
name: form.name.trim(),
code: form.code.trim(),
type: form.type,
stationId: form.stationId ?? undefined,
locationName: form.locationName.trim() || undefined,
capacityWeight: form.capacityWeight === '' ? undefined : Number(form.capacityWeight),
capacityContainers: form.capacityContainers === '' ? undefined : Number(form.capacityContainers),
maxVolume: form.maxVolume === '' ? undefined : Number(form.maxVolume),
};
try {
if (warehouse) {
await updateMutation.mutateAsync({ id: warehouse.id, payload: { ...payload, status: form.status } });
toast({ title: 'Warehouse updated' });
} else {
await createMutation.mutateAsync(payload);
toast({ title: 'Warehouse created' });
}
onClose();
} catch (error) {
toast({ variant: 'destructive', title: 'Save failed', description: extractErrorMessage(error) });
}
};
return (
<Modal opened={opened} onClose={onClose} title={isEdit ? 'Edit warehouse' : 'Create warehouse'} centered size="lg">
<Stack gap="md">
<Group grow>
<TextInput
label="Name"
placeholder="Modjo Open Warehouse"
required
value={form.name}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, name: v })); }}
/>
<TextInput
label="Code"
placeholder="MODJO-OW"
required
value={form.code}
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"
data={warehouseTypeOptions}
value={form.type}
onChange={(value) => setForm((f) => ({ ...f, type: (value as WarehouseType) ?? 'OPEN_WAREHOUSE' }))}
allowDeselect={false}
/>
{isEdit && (
<Select
label="Status"
data={statusOptions}
value={form.status}
onChange={(value) => setForm((f) => ({ ...f, status: (value as 'ACTIVE' | 'INACTIVE') ?? 'ACTIVE' }))}
allowDeselect={false}
/>
)}
</Group>
<TextInput
label="Location name"
placeholder="Modjo, Oromia"
value={form.locationName}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, locationName: v })); }}
/>
<Group grow>
<NumberInput
label="Capacity weight (kg)"
placeholder="Optional"
min={0}
value={form.capacityWeight}
onChange={(value) => setForm((f) => ({ ...f, capacityWeight: value === '' ? '' : Number(value) }))}
/>
<NumberInput
label="Capacity containers"
placeholder="Optional"
min={0}
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">
<Button variant="default" onClick={onClose} disabled={submitting}>
Cancel
</Button>
<Button onClick={handleSubmit} loading={submitting}>
{isEdit ? 'Save changes' : 'Create warehouse'}
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,166 @@
import { useEffect, useState } from 'react';
import { Button, Group, Modal, NumberInput, Select, Stack, TextInput } from '@mantine/core';
import { useToast } from '@/hooks/use-toast';
import { useCreateYard, useUpdateYard } from '@/hooks/useWarehouses';
import type { SaveYardPayload, WarehouseYard, WarehouseYardType } from '@/types/warehouse';
import { extractErrorMessage, statusOptions, yardTypeOptions } from './options';
interface CreateYardModalProps {
opened: boolean;
onClose: () => void;
warehouseId: string;
yard?: WarehouseYard | null;
}
interface FormState {
name: string;
code: string;
type: WarehouseYardType;
capacityWeight: number | '';
capacityContainers: number | '';
maxVolume: number | '';
status: 'ACTIVE' | 'INACTIVE';
}
const emptyForm = (): FormState => ({
name: '',
code: '',
type: 'CONTAINER_YARD',
capacityWeight: '',
capacityContainers: '',
maxVolume: '',
status: 'ACTIVE',
});
export function CreateYardModal({ opened, onClose, warehouseId, yard }: CreateYardModalProps) {
const isEdit = Boolean(yard);
const { toast } = useToast();
const createMutation = useCreateYard();
const updateMutation = useUpdateYard();
const [form, setForm] = useState<FormState>(emptyForm());
useEffect(() => {
if (opened) {
setForm(
yard
? {
name: yard.name,
code: yard.code,
type: yard.type,
capacityWeight: yard.capacityWeight ?? '',
capacityContainers: yard.capacityContainers ?? '',
maxVolume: yard.maxVolume ?? '',
status: yard.status,
}
: emptyForm(),
);
}
}, [opened, yard]);
const submitting = createMutation.isPending || updateMutation.isPending;
const handleSubmit = async () => {
if (!form.name.trim() || !form.code.trim()) {
toast({ variant: 'destructive', title: 'Name and code are required' });
return;
}
const payload: SaveYardPayload = {
name: form.name.trim(),
code: form.code.trim(),
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 {
if (yard) {
await updateMutation.mutateAsync({ id: yard.id, payload: { ...payload, status: form.status } });
toast({ title: 'Yard updated' });
} else {
await createMutation.mutateAsync({ warehouseId, payload });
toast({ title: 'Yard created' });
}
onClose();
} catch (error) {
toast({ variant: 'destructive', title: 'Save failed', description: extractErrorMessage(error) });
}
};
return (
<Modal opened={opened} onClose={onClose} title={isEdit ? 'Edit yard' : 'Create yard'} centered size="lg">
<Stack gap="md">
<Group grow>
<TextInput
label="Name"
placeholder="Container Yard A"
required
value={form.name}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, name: v })); }}
/>
<TextInput
label="Code"
placeholder="CY-A"
required
value={form.code}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, code: v })); }}
/>
</Group>
<Group grow>
<Select
label="Type"
data={yardTypeOptions}
value={form.type}
onChange={(value) => setForm((f) => ({ ...f, type: (value as WarehouseYardType) ?? 'CONTAINER_YARD' }))}
allowDeselect={false}
/>
{isEdit && (
<Select
label="Status"
data={statusOptions}
value={form.status}
onChange={(value) => setForm((f) => ({ ...f, status: (value as 'ACTIVE' | 'INACTIVE') ?? 'ACTIVE' }))}
allowDeselect={false}
/>
)}
</Group>
<Group grow>
<NumberInput
label="Capacity weight (kg)"
placeholder="Optional"
min={0}
value={form.capacityWeight}
onChange={(value) => setForm((f) => ({ ...f, capacityWeight: value === '' ? '' : Number(value) }))}
/>
<NumberInput
label="Capacity containers"
placeholder="Optional"
min={0}
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">
<Button variant="default" onClick={onClose} disabled={submitting}>
Cancel
</Button>
<Button onClick={handleSubmit} loading={submitting}>
{isEdit ? 'Save changes' : 'Create yard'}
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,166 @@
import { useEffect, useState } from 'react';
import { Button, Group, Modal, NumberInput, Select, Stack, TextInput } from '@mantine/core';
import { useToast } from '@/hooks/use-toast';
import { useCreateZone, useUpdateZone } from '@/hooks/useWarehouses';
import type { SaveZonePayload, WarehouseZone, WarehouseZoneType } from '@/types/warehouse';
import { extractErrorMessage, statusOptions, zoneTypeOptions } from './options';
interface CreateZoneModalProps {
opened: boolean;
onClose: () => void;
yardId: string;
zone?: WarehouseZone | null;
}
interface FormState {
name: string;
code: string;
type: WarehouseZoneType;
capacityWeight: number | '';
capacityContainers: number | '';
maxVolume: number | '';
status: 'ACTIVE' | 'INACTIVE';
}
const emptyForm = (): FormState => ({
name: '',
code: '',
type: 'CONTAINER_ZONE',
capacityWeight: '',
capacityContainers: '',
maxVolume: '',
status: 'ACTIVE',
});
export function CreateZoneModal({ opened, onClose, yardId, zone }: CreateZoneModalProps) {
const isEdit = Boolean(zone);
const { toast } = useToast();
const createMutation = useCreateZone();
const updateMutation = useUpdateZone();
const [form, setForm] = useState<FormState>(emptyForm());
useEffect(() => {
if (opened) {
setForm(
zone
? {
name: zone.name,
code: zone.code,
type: zone.type,
capacityWeight: zone.capacityWeight ?? '',
capacityContainers: zone.capacityContainers ?? '',
maxVolume: zone.maxVolume ?? '',
status: zone.status,
}
: emptyForm(),
);
}
}, [opened, zone]);
const submitting = createMutation.isPending || updateMutation.isPending;
const handleSubmit = async () => {
if (!form.name.trim() || !form.code.trim()) {
toast({ variant: 'destructive', title: 'Name and code are required' });
return;
}
const payload: SaveZonePayload = {
name: form.name.trim(),
code: form.code.trim(),
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 {
if (zone) {
await updateMutation.mutateAsync({ id: zone.id, payload: { ...payload, status: form.status } });
toast({ title: 'Zone updated' });
} else {
await createMutation.mutateAsync({ yardId, payload });
toast({ title: 'Zone created' });
}
onClose();
} catch (error) {
toast({ variant: 'destructive', title: 'Save failed', description: extractErrorMessage(error) });
}
};
return (
<Modal opened={opened} onClose={onClose} title={isEdit ? 'Edit zone' : 'Create zone'} centered size="lg">
<Stack gap="md">
<Group grow>
<TextInput
label="Name"
placeholder="Zone A-01"
required
value={form.name}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, name: v })); }}
/>
<TextInput
label="Code"
placeholder="A-01"
required
value={form.code}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, code: v })); }}
/>
</Group>
<Group grow>
<Select
label="Type"
data={zoneTypeOptions}
value={form.type}
onChange={(value) => setForm((f) => ({ ...f, type: (value as WarehouseZoneType) ?? 'CONTAINER_ZONE' }))}
allowDeselect={false}
/>
{isEdit && (
<Select
label="Status"
data={statusOptions}
value={form.status}
onChange={(value) => setForm((f) => ({ ...f, status: (value as 'ACTIVE' | 'INACTIVE') ?? 'ACTIVE' }))}
allowDeselect={false}
/>
)}
</Group>
<Group grow>
<NumberInput
label="Capacity weight (kg)"
placeholder="Optional"
min={0}
value={form.capacityWeight}
onChange={(value) => setForm((f) => ({ ...f, capacityWeight: value === '' ? '' : Number(value) }))}
/>
<NumberInput
label="Capacity containers"
placeholder="Optional"
min={0}
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">
<Button variant="default" onClick={onClose} disabled={submitting}>
Cancel
</Button>
<Button onClick={handleSubmit} loading={submitting}>
{isEdit ? 'Save changes' : 'Create zone'}
</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,115 @@
import { useState } from 'react';
import { Center, Loader } from '@mantine/core';
import { useToast } from '@/hooks/use-toast';
import {
useDispatchInventory,
useMarkReadyForLoading,
useStoreInventory,
} from '@/hooks/useWarehouses';
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
import { FeePreviewModal } from './FeePreviewModal';
import { InspectionReportModal } from './InspectionReportModal';
import { InventoryHistoryModal } from './InventoryHistoryModal';
import { LoadInventoryModal } from './LoadInventoryModal';
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 [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 storeMutation = useStoreInventory();
const readyMutation = useMarkReadyForLoading();
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');
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}
/>
</>
);
}

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

@@ -0,0 +1,214 @@
import { useEffect, useMemo, useState } from 'react';
import { Button, Group, Modal, NumberInput, Select, Stack, Textarea, TextInput } from '@mantine/core';
import { useToast } from '@/hooks/use-toast';
import {
useReceiveInventory,
useWarehouseYards,
useWarehouseZones,
useWarehouses,
} from '@/hooks/useWarehouses';
import type { ReceiveInventoryPayload } from '@/types/warehouse';
import { BookingSelect } from './BookingSelect';
import { extractErrorMessage } from './options';
interface ReceiveInventoryModalProps {
opened: boolean;
onClose: () => void;
/** When supplied the booking field is locked to this booking. */
bookingId?: string;
bookingLabel?: string;
onReceived?: () => void;
}
interface FormState {
bookingId: string;
warehouseId: string;
yardId: string;
zoneId: string;
quantity: number | '';
weight: number | '';
volume: number | '';
notes: string;
}
const emptyForm = (bookingId?: string): FormState => ({
bookingId: bookingId ?? '',
warehouseId: '',
yardId: '',
zoneId: '',
quantity: '',
weight: '',
volume: '',
notes: '',
});
export function ReceiveInventoryModal({
opened,
onClose,
bookingId,
bookingLabel,
onReceived,
}: ReceiveInventoryModalProps) {
const { toast } = useToast();
const receiveMutation = useReceiveInventory();
const [form, setForm] = useState<FormState>(emptyForm(bookingId));
useEffect(() => {
if (opened) setForm(emptyForm(bookingId));
}, [opened, bookingId]);
// Cascading data — only ACTIVE warehouses are selectable for receiving.
const warehousesQuery = useWarehouses({ status: 'ACTIVE' });
const yardsQuery = useWarehouseYards(form.warehouseId || undefined);
const zonesQuery = useWarehouseZones(form.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 submitting = receiveMutation.isPending;
const handleSubmit = async () => {
if (!form.bookingId.trim()) {
toast({ variant: 'destructive', title: 'Booking is required' });
return;
}
if (!form.warehouseId || !form.yardId || !form.zoneId) {
toast({ variant: 'destructive', title: 'Select warehouse, yard and zone' });
return;
}
if (form.quantity === '' || form.weight === '') {
toast({ variant: 'destructive', title: 'Quantity and weight are required' });
return;
}
const payload: ReceiveInventoryPayload = {
bookingId: form.bookingId.trim(),
warehouseId: form.warehouseId,
yardId: form.yardId,
zoneId: form.zoneId,
quantity: Number(form.quantity),
weight: Number(form.weight),
volume: form.volume === '' ? undefined : Number(form.volume),
notes: form.notes.trim() || undefined,
};
try {
await receiveMutation.mutateAsync(payload);
toast({ title: 'Inventory received', description: 'Status set to ARRIVED_AT_WAREHOUSE' });
onReceived?.();
onClose();
} catch (error) {
toast({ variant: 'destructive', title: 'Receive failed', description: extractErrorMessage(error) });
}
};
return (
<Modal opened={opened} onClose={onClose} title="Receive at warehouse" centered size="lg">
<Stack gap="md">
{bookingId ? (
<TextInput label="Booking" value={bookingLabel ?? bookingId} readOnly />
) : (
<BookingSelect
label="Booking"
value={form.bookingId}
onChange={(v) => setForm((f) => ({ ...f, bookingId: v }))}
/>
)}
<Select
label="Warehouse"
placeholder={warehousesQuery.isLoading ? 'Loading…' : 'Select warehouse'}
required
searchable
data={warehouseOptions}
value={form.warehouseId || null}
onChange={(value) =>
setForm((f) => ({ ...f, warehouseId: value ?? '', yardId: '', zoneId: '' }))
}
/>
<Select
label="Yard"
placeholder={!form.warehouseId ? 'Select a warehouse first' : 'Select yard'}
required
searchable
disabled={!form.warehouseId}
data={yardOptions}
value={form.yardId || null}
onChange={(value) => setForm((f) => ({ ...f, yardId: value ?? '', zoneId: '' }))}
/>
<Select
label="Zone"
placeholder={!form.yardId ? 'Select a yard first' : 'Select zone'}
required
searchable
disabled={!form.yardId}
data={zoneOptions}
value={form.zoneId || null}
onChange={(value) => setForm((f) => ({ ...f, zoneId: value ?? '' }))}
/>
<Group grow>
<NumberInput
label="Quantity"
required
min={0}
value={form.quantity}
onChange={(value) => setForm((f) => ({ ...f, quantity: value === '' ? '' : Number(value) }))}
/>
<NumberInput
label="Weight (kg)"
required
min={0}
value={form.weight}
onChange={(value) => setForm((f) => ({ ...f, weight: value === '' ? '' : Number(value) }))}
/>
<NumberInput
label="Volume (m³)"
placeholder="Optional"
min={0}
value={form.volume}
onChange={(value) => setForm((f) => ({ ...f, volume: value === '' ? '' : Number(value) }))}
/>
</Group>
<Textarea
label="Notes"
placeholder="Optional notes"
autosize
minRows={2}
value={form.notes}
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, notes: v })); }}
/>
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose} disabled={submitting}>
Cancel
</Button>
<Button onClick={handleSubmit} loading={submitting}>
Receive inventory
</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

@@ -0,0 +1,90 @@
import { useMemo } from 'react';
import { ActionIcon, Card, Group, SimpleGrid, Stack, Text } from '@mantine/core';
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';
interface WarehouseCardViewProps {
warehouses: Warehouse[];
onView: (warehouse: Warehouse) => void;
onEdit: (warehouse: Warehouse) => void;
}
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">
No warehouses found.
</Text>
);
}
return (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{warehouses.map((warehouse) => (
<Card key={warehouse.id} withBorder radius="md" padding="lg">
<Stack gap="sm">
<Group justify="space-between" wrap="nowrap" align="flex-start">
<div>
<Text fw={700}>{warehouse.name}</Text>
<Text size="xs" c="dimmed">
{warehouse.code}
</Text>
</div>
<WarehouseStatusBadge status={warehouse.status} />
</Group>
<Group gap="xs">
<WarehouseTypeBadge type={warehouse.type} />
</Group>
{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">
<MapPin size={14} />
<Text size="sm">{warehouse.locationName}</Text>
</Group>
)}
<Group justify="space-between">
<Text size="xs" c="dimmed">
Weight
</Text>
<Text size="sm">{formatCapacity(warehouse.currentWeight, warehouse.capacityWeight)}</Text>
</Group>
<Group justify="space-between">
<Text size="xs" c="dimmed">
Containers
</Text>
<Text size="sm">{formatCapacity(warehouse.currentContainers, warehouse.capacityContainers)}</Text>
</Group>
<Group justify="flex-end" gap="xs" mt="xs">
<ActionIcon variant="subtle" color="gray" onClick={() => onView(warehouse)} title="View">
<Eye size={16} />
</ActionIcon>
<ActionIcon variant="subtle" color="gray" onClick={() => onEdit(warehouse)} title="Edit">
<Pencil size={16} />
</ActionIcon>
</Group>
</Stack>
</Card>
))}
</SimpleGrid>
);
}

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,55 @@
import { Group, SegmentedControl, Select, TextInput } from '@mantine/core';
import { LayoutGrid, Search, Table as TableIcon } from 'lucide-react';
import type { WarehouseFilter, WarehouseStatus, WarehouseType } from '@/types/warehouse';
import { statusOptions, warehouseTypeOptions } from './options';
export type WarehouseView = 'table' | 'card';
interface WarehouseFiltersProps {
filter: WarehouseFilter;
onChange: (next: WarehouseFilter) => void;
view: WarehouseView;
onViewChange: (view: WarehouseView) => void;
}
export function WarehouseFilters({ filter, onChange, view, onViewChange }: WarehouseFiltersProps) {
return (
<Group justify="space-between" wrap="wrap" gap="sm">
<Group gap="sm" wrap="wrap">
<TextInput
placeholder="Search by name, code or location"
leftSection={<Search size={16} />}
value={filter.search ?? ''}
onChange={(e) => onChange({ ...filter, search: e.currentTarget.value || undefined })}
w={280}
/>
<Select
placeholder="All types"
clearable
data={warehouseTypeOptions}
value={filter.type ?? null}
onChange={(value) => onChange({ ...filter, type: (value as WarehouseType) || undefined })}
w={190}
/>
<Select
placeholder="All statuses"
clearable
data={statusOptions}
value={filter.status ?? null}
onChange={(value) => onChange({ ...filter, status: (value as WarehouseStatus) || undefined })}
w={160}
/>
</Group>
<SegmentedControl
value={view}
onChange={(value) => onViewChange(value as WarehouseView)}
data={[
{ value: 'table', label: <TableIcon size={16} /> },
{ value: 'card', label: <LayoutGrid size={16} /> },
]}
/>
</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

@@ -0,0 +1,138 @@
import { useState } from 'react';
import { Badge, Button, Card, Divider, Group, Stack, Text } from '@mantine/core';
import { PackagePlus, Train as TrainIcon, Warehouse as WarehouseIcon } from 'lucide-react';
import { useBookingSchedule, useWarehouseInventory } from '@/hooks/useWarehouses';
import { InventoryStatusBadge } from './badges';
import { FreightVisual } from './FreightVisual';
import { formatDate } from './options';
import { ReceiveInventoryModal } from './ReceiveInventoryModal';
interface WarehouseInfoCardProps {
bookingId: string;
bookingReference?: string;
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<Group justify="space-between" wrap="nowrap">
<Text size="sm" c="dimmed">
{label}
</Text>
<Text size="sm" fw={500} ta="right">
{value}
</Text>
</Group>
);
}
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">
<Stack gap="md">
<Group justify="space-between">
<Group gap="xs">
<WarehouseIcon size={18} />
<Text fw={700}>Warehouse Information</Text>
</Group>
{items.length > 0 && (
<Badge variant="light" color="gray">
{items.length} item{items.length > 1 ? 's' : ''}
</Badge>
)}
</Group>
<Divider />
{isLoading ? (
<Text size="sm" c="dimmed">
Loading
</Text>
) : !latest ? (
<Text size="sm" c="dimmed">
This booking has not been received at any warehouse yet.
</Text>
) : (
<Stack gap="xs">
<Row label="Warehouse" value={latest.warehouse ? `${latest.warehouse.name} (${latest.warehouse.code})` : '—'} />
<Row label="Yard" value={latest.yard ? `${latest.yard.name} (${latest.yard.code})` : '—'} />
<Row label="Zone" value={latest.zone ? `${latest.zone.name} (${latest.zone.code})` : '—'} />
<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} />}
onClick={() => setModalOpen(true)}
fullWidth
>
Receive At Warehouse
</Button>
</Stack>
<ReceiveInventoryModal
opened={modalOpen}
onClose={() => setModalOpen(false)}
bookingId={bookingId}
bookingLabel={bookingReference}
/>
</Card>
);
}

View File

@@ -0,0 +1,85 @@
import { Stack, Table, Text } from '@mantine/core';
import type { InventoryInquiryResult } from '@/types/warehouse';
import { InventoryStatusBadge } from './badges';
import { formatDate, formatNumber } from './options';
interface WarehouseInquiryTableProps {
results: InventoryInquiryResult[];
}
const itemDescriptor = (result: InventoryInquiryResult) => {
if (result.containerNumber) return `Container ${result.containerNumber}`;
if (result.cargoType) return `Cargo · ${result.cargoType}`;
if (result.cargoDescription) return `Cargo · ${result.cargoDescription}`;
if (result.goodsId) return 'Goods';
return '—';
};
export function WarehouseInquiryTable({ results }: WarehouseInquiryTableProps) {
if (results.length === 0) {
return (
<Text c="dimmed" ta="center" py="xl">
No matching items. Adjust your search to locate cargo, containers or goods.
</Text>
);
}
return (
<Table.ScrollContainer minWidth={1100}>
<Table highlightOnHover verticalSpacing="sm" striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Item</Table.Th>
<Table.Th>Warehouse</Table.Th>
<Table.Th>Yard</Table.Th>
<Table.Th>Zone</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th>Arrived</Table.Th>
<Table.Th>Ready</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{results.map((result) => (
<Table.Tr key={result.id}>
<Table.Td>
<Text size="sm" fw={600}>
{result.bookingNumber ?? result.bookingId.slice(0, 8)}
</Text>
</Table.Td>
<Table.Td>{result.customerName ?? '—'}</Table.Td>
<Table.Td>{itemDescriptor(result)}</Table.Td>
<Table.Td>
<Stack gap={0}>
<Text size="sm">{result.warehouse?.name ?? '—'}</Text>
{result.warehouse?.code && (
<Text size="xs" c="dimmed">
{result.warehouse.code}
</Text>
)}
</Stack>
</Table.Td>
<Table.Td>{result.yard?.name ?? '—'}</Table.Td>
<Table.Td>{result.zone?.name ?? '—'}</Table.Td>
<Table.Td>
<InventoryStatusBadge status={result.status} />
</Table.Td>
<Table.Td>{formatNumber(result.quantity)}</Table.Td>
<Table.Td>{formatNumber(result.weight)}</Table.Td>
<Table.Td>
<Text size="xs">{formatDate(result.arrivedAt)}</Text>
</Table.Td>
<Table.Td>
<Text size="xs">{formatDate(result.readyForLoadingAt)}</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}

View File

@@ -0,0 +1,154 @@
import { ActionIcon, Badge, Button, Group, Table, Text, Tooltip } from '@mantine/core';
import { ArrowRightLeft, ClipboardList, Coins, History } from 'lucide-react';
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
import { INVENTORY_NEXT_ACTION } from '@/types/warehouse';
import { InventoryStatusBadge } from './badges';
import { formatDate, formatNumber, humanizeEnum } from './options';
interface WarehouseInventoryTableProps {
items: WarehouseInventoryItem[];
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) => {
if (item.containerId) return { label: 'Container', color: 'blue' };
if (item.cargoId) return { label: 'Cargo', color: 'grape' };
if (item.goodsId) return { label: 'Goods', color: 'orange' };
return { label: '—', color: 'gray' };
};
const actionColor: Record<InventoryAction, string> = {
store: 'blue',
reserve: 'grape',
'ready-for-loading': 'cyan',
load: 'teal',
dispatch: 'green',
};
export function WarehouseInventoryTable({
items,
busyId,
onAdvance,
onMove,
onHistory,
onInspect,
onFeePreview,
}: WarehouseInventoryTableProps) {
if (items.length === 0) {
return (
<Text c="dimmed" ta="center" py="xl">
No inventory items found.
</Text>
);
}
return (
<Table.ScrollContainer minWidth={1150}>
<Table highlightOnHover verticalSpacing="sm" striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</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 ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{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>
{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>
)}
</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}
</Badge>
</Table.Td>
<Table.Td>{formatNumber(item.quantity)}</Table.Td>
<Table.Td>{formatNumber(item.weight)}</Table.Td>
<Table.Td>
<InventoryStatusBadge status={item.status} />
</Table.Td>
<Table.Td>
<Text size="xs">{formatDate(item.arrivedAt)}</Text>
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end" wrap="nowrap">
{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>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}

View File

@@ -0,0 +1,92 @@
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';
interface WarehouseTableProps {
warehouses: Warehouse[];
onView: (warehouse: Warehouse) => void;
onEdit: (warehouse: Warehouse) => void;
}
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">
No warehouses found.
</Text>
);
}
return (
<Table.ScrollContainer minWidth={900}>
<Table highlightOnHover verticalSpacing="sm" striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Code</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th>Facility</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Location</Table.Th>
<Table.Th>Weight (cur / cap)</Table.Th>
<Table.Th>Containers (cur / cap)</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{warehouses.map((warehouse) => (
<Table.Tr key={warehouse.id}>
<Table.Td>
<Anchor fw={600} size="sm" onClick={() => onView(warehouse)}>
{warehouse.code}
</Anchor>
</Table.Td>
<Table.Td>{warehouse.name}</Table.Td>
<Table.Td>
{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} />
</Table.Td>
<Table.Td>{warehouse.locationName ?? '—'}</Table.Td>
<Table.Td>{formatCapacity(warehouse.currentWeight, warehouse.capacityWeight)}</Table.Td>
<Table.Td>{formatCapacity(warehouse.currentContainers, warehouse.capacityContainers)}</Table.Td>
<Table.Td>
<WarehouseStatusBadge status={warehouse.status} />
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end" wrap="nowrap">
<ActionIcon variant="subtle" color="gray" onClick={() => onView(warehouse)} title="View">
<Eye size={16} />
</ActionIcon>
<ActionIcon variant="subtle" color="gray" onClick={() => onEdit(warehouse)} title="Edit">
<Pencil size={16} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}

View File

@@ -0,0 +1,52 @@
import { Badge } from '@mantine/core';
import type { InventoryStatus, WarehouseStatus, WarehouseType } from '@/types/warehouse';
const humanize = (value: string) =>
value
.toLowerCase()
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
const badgeStyle = {
fontSize: '0.7rem',
letterSpacing: '0.04em',
whiteSpace: 'nowrap' as const,
};
export function WarehouseTypeBadge({ type }: { type: WarehouseType }) {
const color = type === 'CLOSED_WAREHOUSE' ? 'indigo' : 'teal';
return (
<Badge color={color} variant="light" size="sm" radius="md" fw={600} style={badgeStyle}>
{humanize(type)}
</Badge>
);
}
export function WarehouseStatusBadge({ status }: { status: WarehouseStatus }) {
const color = status === 'ACTIVE' ? 'green' : 'gray';
return (
<Badge color={color} variant="light" size="sm" radius="md" tt="uppercase" fw={600} style={badgeStyle}>
{status}
</Badge>
);
}
const inventoryStatusColor: Record<InventoryStatus, string> = {
RECEIVED: 'yellow',
STORED: 'blue',
RESERVED: 'grape',
READY_FOR_LOADING: 'cyan',
LOADED: 'teal',
DISPATCHED: 'green',
};
export function InventoryStatusBadge({ status }: { status: InventoryStatus }) {
const color = inventoryStatusColor[status] ?? 'gray';
return (
<Badge color={color} variant="light" size="sm" radius="md" fw={600} style={badgeStyle}>
{humanize(status)}
</Badge>
);
}

View File

@@ -0,0 +1,29 @@
export * from './badges';
export * from './options';
export { WarehouseFilters } from './WarehouseFilters';
export type { WarehouseView } from './WarehouseFilters';
export { WarehouseTable } from './WarehouseTable';
export { WarehouseCardView } from './WarehouseCardView';
export { WarehouseInventoryTable } from './WarehouseInventoryTable';
export { WarehouseInquiryTable } from './WarehouseInquiryTable';
export { CreateWarehouseModal } from './CreateWarehouseModal';
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';

View File

@@ -0,0 +1,56 @@
import {
WAREHOUSE_TYPES,
WAREHOUSE_YARD_TYPES,
WAREHOUSE_ZONE_TYPES,
WAREHOUSE_STATUSES,
INVENTORY_STATUSES,
} from '@/types/warehouse';
export const humanizeEnum = (value: string) =>
value
.toLowerCase()
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
const toOptions = (values: readonly string[]) =>
values.map((value) => ({ value, label: humanizeEnum(value) }));
export const warehouseTypeOptions = toOptions(WAREHOUSE_TYPES);
export const yardTypeOptions = toOptions(WAREHOUSE_YARD_TYPES);
export const zoneTypeOptions = toOptions(WAREHOUSE_ZONE_TYPES);
export const statusOptions = toOptions(WAREHOUSE_STATUSES);
export const inventoryStatusOptions = toOptions(INVENTORY_STATUSES);
export const formatNumber = (value: number | null | undefined) => {
if (value === null || value === undefined) return '—';
const num = Number(value);
if (Number.isNaN(num)) return '—';
return num.toLocaleString(undefined, { maximumFractionDigits: 3 });
};
export const formatCapacity = (current: number, capacity: number | null | undefined) => {
const cur = formatNumber(current);
if (capacity === null || capacity === undefined) return cur;
return `${cur} / ${formatNumber(capacity)}`;
};
export const formatDate = (value: string | null | undefined) => {
if (!value) return '—';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '—';
return date.toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
export const extractErrorMessage = (error: unknown, fallback = 'Something went wrong') => {
const responseData = (error as { response?: { data?: unknown } })?.response?.data;
const data = responseData && typeof responseData === 'object' ? (responseData as Record<string, unknown>) : undefined;
const rawMessage = data?.message ?? data?.error ?? (error as Error)?.message;
return Array.isArray(rawMessage) ? rawMessage.join(', ') : rawMessage ? String(rawMessage) : fallback;
};

View File

@@ -259,4 +259,77 @@ export const URL_CONSTANTS = {
CONTAINER_TYPES: '/api/reference/container-types',
CURRENCIES: '/api/reference/currencies',
},
FACILITIES: {
BASE: '/facilities',
BY_ID: (id: string) => `/facilities/${id}`,
},
WAREHOUSES: {
BASE: '/warehouses',
DASHBOARD: '/warehouses/dashboard',
BY_ID: (id: string) => `/warehouses/${id}`,
YARDS: (warehouseId: string) => `/warehouses/${warehouseId}/yards`,
},
WAREHOUSE_YARDS: {
BY_ID: (id: string) => `/warehouse-yards/${id}`,
ZONES: (yardId: string) => `/warehouse-yards/${yardId}/zones`,
},
WAREHOUSE_ZONES: {
BY_ID: (id: string) => `/warehouse-zones/${id}`,
},
WAREHOUSE_INVENTORY: {
BASE: '/warehouse-inventory',
RECEIVE: '/warehouse-inventory/receive',
RESERVE: '/warehouse-inventory/reserve',
ARRIVAL_QUEUE: '/warehouse-inventory/arrival-queue',
AUTO_UNLOAD_ARRIVED: '/warehouse-inventory/auto-unload-arrived',
AUTO_LOAD_READY: '/warehouse-inventory/auto-load-ready',
UNLOAD_BOOKING: (bookingId: string) => `/warehouse-inventory/bookings/${bookingId}/unload`,
INSPECTION_REPORTS: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/inspection-reports`,
READY_FOR_LOADING: '/warehouse-inventory/ready-for-loading',
INQUIRY: '/warehouse-inventory/inquiry',
LOADABLE_WAGONS: '/warehouse-inventory/loadable-wagons',
BOOKING_SCHEDULE: (bookingId: string) => `/warehouse-inventory/booking/${bookingId}/schedule`,
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
MOVEMENTS: (id: string) => `/warehouse-inventory/${id}/movements`,
ACTIVITY: (id: string) => `/warehouse-inventory/${id}/activity`,
LOADINGS: (id: string) => `/warehouse-inventory/${id}/loadings`,
STORE: (id: string) => `/warehouse-inventory/${id}/store`,
MARK_READY: (id: string) => `/warehouse-inventory/${id}/ready-for-loading`,
LOAD: (id: string) => `/warehouse-inventory/${id}/load`,
DISPATCH: (id: string) => `/warehouse-inventory/${id}/dispatch`,
},
WAREHOUSE_LOADINGS: {
BASE: '/warehouse-loadings',
},
WAREHOUSE_INSPECTION: {
BY_ID: (id: string) => `/warehouse-inspection-reports/${id}`,
ATTACHMENTS: (id: string) => `/warehouse-inspection-reports/${id}/attachments`,
},
WAREHOUSE_RULES: {
ALLOCATION: '/warehouse-allocation-rules',
ALLOCATION_BY_ID: (id: string) => `/warehouse-allocation-rules/${id}`,
ALLOCATION_PREVIEW: '/warehouse-allocation/preview',
FEES: '/warehouse-fee-rules',
FEES_BY_ID: (id: string) => `/warehouse-fee-rules/${id}`,
FEE_PREVIEW: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/fee-preview`,
},
WAREHOUSE_INVOICES: {
BASE: '/warehouse-fee-invoices',
BY_ID: (id: string) => `/warehouse-fee-invoices/${id}`,
CANCEL: (id: string) => `/warehouse-fee-invoices/${id}/cancel`,
PAY: (id: string) => `/warehouse-fee-invoices/${id}/pay`,
GENERATE: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/generate-fee-invoice`,
FOR_INVENTORY: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/fee-invoices`,
FOR_BOOKING: (bookingId: string) => `/bookings/${bookingId}/warehouse-fee-invoices`,
GATE_CLEARANCE: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/gate-clearance`,
},
};

View File

@@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { cargoService } from '@/services/cargoService';
import { cargoService, type DeliverCargoPayload } from '@/services/cargoService';
export const cargoKeys = {
all: ['cargoes'] as const,
@@ -53,7 +53,8 @@ export function useLoadCargo() {
export function useDeliverCargo() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => cargoService.deliver(id),
mutationFn: ({ id, payload }: { id: string; payload?: DeliverCargoPayload }) =>
cargoService.deliver(id, payload),
onSuccess: () => qc.invalidateQueries({ queryKey: cargoKeys.all })
});
}

View File

@@ -0,0 +1,16 @@
import { useQuery } from '@tanstack/react-query';
import { facilityService } from '@/services/facility.service';
export const facilityKeys = {
all: ['facilities'] as const,
list: () => ['facilities', 'list'] as const,
detail: (id: string) => ['facilities', 'detail', id] as const,
};
export function useFacilities() {
return useQuery({
queryKey: facilityKeys.list(),
queryFn: () => facilityService.list().then((r) => r.data),
});
}

View File

@@ -0,0 +1,16 @@
import { useQuery } from '@tanstack/react-query';
import { trainSchedulingService } from '@/services/trainScheduling.service';
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
/**
* The 21 network stations / yards, sourced from the existing booking
* reference-data API. Reused as the parent "Facility / Port" for warehouses.
*/
export function useStations() {
return useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.stations(),
queryFn: () => trainSchedulingService.getStations(),
staleTime: 5 * 60 * 1000,
});
}

View File

@@ -0,0 +1,410 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { warehouseService } from '@/services/warehouse.service';
import type {
InspectionReportPayload,
SaveAllocationRulePayload,
SaveFeeRulePayload,
WarehouseInvoiceFilter,
PayInvoicePayload,
InventoryFilter,
InventoryInquiryFilter,
LoadInventoryPayload,
MoveInventoryPayload,
ReceiveInventoryPayload,
ReserveInventoryPayload,
SaveWarehousePayload,
SaveYardPayload,
SaveZonePayload,
WarehouseFilter,
} from '@/types/warehouse';
export const warehouseKeys = {
all: ['warehouses'] as const,
list: (filter?: WarehouseFilter) => ['warehouses', 'list', filter ?? {}] as const,
detail: (id: string) => ['warehouses', 'detail', id] as const,
yards: (warehouseId: string) => ['warehouses', warehouseId, 'yards'] as const,
zones: (yardId: string) => ['warehouse-yards', yardId, 'zones'] as const,
inventory: (filter?: InventoryFilter) => ['warehouse-inventory', 'list', filter ?? {}] as const,
inquiry: (filter: InventoryInquiryFilter) => ['warehouse-inventory', 'inquiry', filter] as const,
};
// ── Warehouses ─────────────────────────────────────────────────────────────
export function useWarehouses(filter?: WarehouseFilter) {
return useQuery({
queryKey: warehouseKeys.list(filter),
queryFn: () => warehouseService.list(filter).then((r) => r.data),
});
}
export function useWarehouse(id?: string) {
return useQuery({
queryKey: warehouseKeys.detail(id ?? ''),
queryFn: () => warehouseService.getById(id as string).then((r) => r.data),
enabled: Boolean(id),
});
}
export function useCreateWarehouse() {
const qc = useQueryClient();
return useMutation({
mutationFn: (payload: SaveWarehousePayload) => warehouseService.create(payload),
onSuccess: () => qc.invalidateQueries({ queryKey: warehouseKeys.all }),
});
}
export function useUpdateWarehouse() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, payload }: { id: string; payload: Partial<SaveWarehousePayload> }) =>
warehouseService.update(id, payload),
onSuccess: (_, { id }) => {
qc.invalidateQueries({ queryKey: warehouseKeys.all });
qc.invalidateQueries({ queryKey: warehouseKeys.detail(id) });
},
});
}
// ── Yards ────────────────────────────────────────────────────────────────
export function useWarehouseYards(warehouseId?: string) {
return useQuery({
queryKey: warehouseKeys.yards(warehouseId ?? ''),
queryFn: () => warehouseService.listYards(warehouseId as string).then((r) => r.data),
enabled: Boolean(warehouseId),
});
}
export function useCreateYard() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ warehouseId, payload }: { warehouseId: string; payload: SaveYardPayload }) =>
warehouseService.createYard(warehouseId, payload),
onSuccess: (_, { warehouseId }) => {
qc.invalidateQueries({ queryKey: warehouseKeys.yards(warehouseId) });
qc.invalidateQueries({ queryKey: warehouseKeys.detail(warehouseId) });
},
});
}
export function useUpdateYard() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, payload }: { id: string; payload: Partial<SaveYardPayload> }) =>
warehouseService.updateYard(id, payload),
onSuccess: () => qc.invalidateQueries({ queryKey: warehouseKeys.all }),
});
}
// ── Zones ──────────────────────────────────────────────────────────────────
export function useWarehouseZones(yardId?: string) {
return useQuery({
queryKey: warehouseKeys.zones(yardId ?? ''),
queryFn: () => warehouseService.listZones(yardId as string).then((r) => r.data),
enabled: Boolean(yardId),
});
}
export function useCreateZone() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ yardId, payload }: { yardId: string; payload: SaveZonePayload }) =>
warehouseService.createZone(yardId, payload),
onSuccess: (_, { yardId }) => qc.invalidateQueries({ queryKey: warehouseKeys.zones(yardId) }),
});
}
export function useUpdateZone() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, payload }: { id: string; payload: Partial<SaveZonePayload> }) =>
warehouseService.updateZone(id, payload),
onSuccess: () => qc.invalidateQueries({ queryKey: ['warehouse-yards'] }),
});
}
// ── Inventory ──────────────────────────────────────────────────────────────
export function useWarehouseInventory(filter?: InventoryFilter) {
return useQuery({
queryKey: warehouseKeys.inventory(filter),
queryFn: () => warehouseService.listInventory(filter).then((r) => r.data),
});
}
export function useReceiveInventory() {
const qc = useQueryClient();
return useMutation({
mutationFn: (payload: ReceiveInventoryPayload) => warehouseService.receiveInventory(payload),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
qc.invalidateQueries({ queryKey: warehouseKeys.all });
},
});
}
function useInventoryMutation<TArgs>(fn: (args: TArgs) => Promise<unknown>) {
const qc = useQueryClient();
return useMutation({
mutationFn: fn,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
qc.invalidateQueries({ queryKey: ['warehouse-loadings'] });
qc.invalidateQueries({ queryKey: warehouseKeys.all });
},
});
}
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((args: { id: string; payload: LoadInventoryPayload }) =>
warehouseService.load(args.id, args.payload),
);
export const useDispatchInventory = () => useInventoryMutation((id: string) => warehouseService.dispatch(id));
export const useMoveInventory = () =>
useInventoryMutation((args: { id: string; payload: MoveInventoryPayload }) =>
warehouseService.move(args.id, args.payload),
);
// ── Loading (Batch 3) ────────────────────────────────────────────────────────
export function useLoadableWagons(enabled = true) {
return useQuery({
queryKey: ['warehouse', 'loadable-wagons'],
queryFn: () => warehouseService.loadableWagons().then((r) => r.data),
enabled,
});
}
export function useWarehouseLoadings(params?: { bookingId?: string; wagonId?: string }) {
return useQuery({
queryKey: ['warehouse-loadings', params ?? {}],
queryFn: () => warehouseService.loadings(params).then((r) => r.data),
});
}
export function useBookingSchedule(bookingId?: string) {
return useQuery({
queryKey: ['warehouse', 'booking-schedule', bookingId ?? ''],
queryFn: () => warehouseService.bookingSchedule(bookingId as string).then((r) => r.data),
enabled: Boolean(bookingId),
});
}
export function useInventoryMovements(id?: string) {
return useQuery({
queryKey: ['warehouse-inventory', id, 'movements'],
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),
});
}
export function useInventoryInquiry(filter: InventoryInquiryFilter, enabled = true) {
return useQuery({
queryKey: warehouseKeys.inquiry(filter),
queryFn: () => warehouseService.inquiry(filter).then((r) => r.data),
enabled,
});
}
// ── Batch 4.5: Arrival / Unload / Inspection ────────────────────────────────
export function useArrivalQueue() {
return useQuery({
queryKey: ['warehouse-inventory', 'arrival-queue'],
queryFn: () => warehouseService.arrivalQueue().then((r) => r.data),
});
}
function useArrivalInvalidation() {
const qc = useQueryClient();
return () => {
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
qc.invalidateQueries({ queryKey: warehouseKeys.all });
};
}
export function useAutoUnloadArrived() {
const onSuccess = useArrivalInvalidation();
return useMutation({ mutationFn: () => warehouseService.autoUnloadArrived(), onSuccess });
}
export function useAutoLoadReady() {
const onSuccess = useArrivalInvalidation();
return useMutation({ mutationFn: () => warehouseService.autoLoadReady(), onSuccess });
}
export function useUnloadBooking() {
const onSuccess = useArrivalInvalidation();
return useMutation({
mutationFn: (args: { bookingId: string; payload?: Record<string, unknown> }) =>
warehouseService.unloadBooking(args.bookingId, args.payload),
onSuccess,
});
}
export function useInspectionReports(inventoryId?: string) {
return useQuery({
queryKey: ['warehouse-inventory', inventoryId, 'inspection-reports'],
queryFn: () => warehouseService.listInspectionReports(inventoryId as string).then((r) => r.data),
enabled: Boolean(inventoryId),
});
}
export function useCreateInspectionReport() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ inventoryId, payload }: { inventoryId: string; payload: InspectionReportPayload }) =>
warehouseService.createInspectionReport(inventoryId, payload).then((r) => r.data),
onSuccess: (_, { inventoryId }) => {
qc.invalidateQueries({ queryKey: ['warehouse-inventory', inventoryId, 'inspection-reports'] });
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
},
});
}
export function useUploadInspectionAttachments() {
return useMutation({
mutationFn: ({ reportId, files }: { reportId: string; files: File[] }) =>
warehouseService.uploadInspectionAttachments(reportId, files),
});
}
// ── Batch 5: Allocation + Fee rules / preview ───────────────────────────────
export function useAllocationRules() {
return useQuery({
queryKey: ['warehouse-allocation-rules'],
queryFn: () => warehouseService.listAllocationRules().then((r) => r.data),
});
}
export function useFeeRules() {
return useQuery({
queryKey: ['warehouse-fee-rules'],
queryFn: () => warehouseService.listFeeRules().then((r) => r.data),
});
}
function useRuleMutation<TArgs>(fn: (args: TArgs) => Promise<unknown>, keys: string[]) {
const qc = useQueryClient();
return useMutation({
mutationFn: fn,
onSuccess: () => keys.forEach((k) => qc.invalidateQueries({ queryKey: [k] })),
});
}
export const useCreateAllocationRule = () =>
useRuleMutation(
(payload: SaveAllocationRulePayload) => warehouseService.createAllocationRule(payload),
['warehouse-allocation-rules'],
);
export const useUpdateAllocationRule = () =>
useRuleMutation(
(args: { id: string; payload: Partial<SaveAllocationRulePayload> }) =>
warehouseService.updateAllocationRule(args.id, args.payload),
['warehouse-allocation-rules'],
);
export const useDeleteAllocationRule = () =>
useRuleMutation((id: string) => warehouseService.deleteAllocationRule(id), ['warehouse-allocation-rules']);
export const useCreateFeeRule = () =>
useRuleMutation((payload: SaveFeeRulePayload) => warehouseService.createFeeRule(payload), ['warehouse-fee-rules']);
export const useUpdateFeeRule = () =>
useRuleMutation(
(args: { id: string; payload: Partial<SaveFeeRulePayload> }) =>
warehouseService.updateFeeRule(args.id, args.payload),
['warehouse-fee-rules'],
);
export const useDeleteFeeRule = () =>
useRuleMutation((id: string) => warehouseService.deleteFeeRule(id), ['warehouse-fee-rules']);
export function useFeePreview(inventoryId?: string) {
return useQuery({
queryKey: ['warehouse-inventory', inventoryId, 'fee-preview'],
queryFn: () => warehouseService.feePreview(inventoryId as string).then((r) => r.data),
enabled: Boolean(inventoryId),
});
}
// ── Batch 6: Warehouse fee invoices ─────────────────────────────────────────
export function useWarehouseInvoices(filter?: WarehouseInvoiceFilter) {
return useQuery({
queryKey: ['warehouse-fee-invoices', filter ?? {}],
queryFn: () => warehouseService.listInvoices(filter).then((r) => r.data),
});
}
export function useWarehouseInvoice(id?: string) {
return useQuery({
queryKey: ['warehouse-fee-invoices', 'detail', id],
queryFn: () => warehouseService.getInvoice(id as string).then((r) => r.data),
enabled: Boolean(id),
});
}
export function useInvoicesForInventory(inventoryId?: string) {
return useQuery({
queryKey: ['warehouse-inventory', inventoryId, 'fee-invoices'],
queryFn: () => warehouseService.invoicesForInventory(inventoryId as string).then((r) => r.data),
enabled: Boolean(inventoryId),
});
}
function useInvoiceInvalidation() {
const qc = useQueryClient();
return () => {
qc.invalidateQueries({ queryKey: ['warehouse-fee-invoices'] });
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
};
}
export function useGenerateInvoice() {
const onSuccess = useInvoiceInvalidation();
return useMutation({
mutationFn: ({ inventoryId, confirmZero }: { inventoryId: string; confirmZero?: boolean }) =>
warehouseService.generateInvoice(inventoryId, confirmZero).then((r) => r.data),
onSuccess,
});
}
export function useCancelInvoice() {
const onSuccess = useInvoiceInvalidation();
return useMutation({ mutationFn: (id: string) => warehouseService.cancelInvoice(id), onSuccess });
}
export function usePayInvoice() {
const onSuccess = useInvoiceInvalidation();
return useMutation({
mutationFn: ({ id, payload }: { id: string; payload: PayInvoicePayload }) =>
warehouseService.payInvoice(id, payload),
onSuccess,
});
}
export function useGateClearance() {
const onSuccess = useInvoiceInvalidation();
return useMutation({ mutationFn: (inventoryId: string) => warehouseService.gateClearance(inventoryId), onSuccess });
}

View File

@@ -10,6 +10,7 @@ import "@edr/ui-common/theme.css";
import { Toaster } from "react-hot-toast";
import App from "./App";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { AuthProvider } from "./auth/AuthProvider";
import { queryClient } from "./lib/queryClient";
import { freightMantineTheme } from "./theme/freight-brand";
@@ -48,7 +49,9 @@ createRoot(rootElement).render(
<StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
<ErrorBoundary>
<App />
</ErrorBoundary>
<Toaster position="top-right" />
</AuthProvider>
</BrowserRouter>

View File

@@ -29,6 +29,7 @@ import {
BookingDocumentsCard,
type BookingFileView,
} from "@/components/bookings/detail";
import { WarehouseInfoCard } from "@/components/warehouses";
import { getStatusMeta } from "@/features/bookings/booking-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import { downloadBookingFile } from "@/services/files.service";
@@ -184,6 +185,7 @@ export default function BookingRequestDetailPage() {
<Stack gap="lg">
<BookingCompanyCard booking={booking} />
<BookingPricingSummary booking={booking} />
<WarehouseInfoCard bookingId={booking.id} bookingReference={booking.reference} />
<BookingActionsToolbar booking={booking} mutations={mutations} />
{showContractButton && (
<Button

View File

@@ -1,5 +1,24 @@
import { FormEvent, ReactNode, useMemo, useState } from 'react';
import { Edit, Eye, Plus, Search, Trash2 } from 'lucide-react';
import {
ActionIcon,
Badge as MantineBadge,
Box,
Button as MantineButton,
Group,
Modal,
NumberInput,
Pagination,
Paper,
ScrollArea,
Select as MantineSelect,
SimpleGrid,
Stack,
Table as MantineTable,
Text,
TextInput,
Title,
} from '@mantine/core';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -31,6 +50,7 @@ import {
useUpdateContainer,
} from '@/hooks/useContainers';
import { useCreateTrain, useDeleteTrain, useTrains, useUpdateTrain } from '@/hooks/useTrains';
import { useRouteYards } from '@/hooks/useRoutes';
import { useCreateWagon, useDeleteWagon, useUpdateWagon, useWagons } from '@/hooks/useWagons';
import {
useCreateLocomotive,
@@ -39,6 +59,7 @@ import {
useUpdateLocomotive,
} from '@/hooks/useLocomotives';
import type { Cargo } from '@/services/cargoService';
import { DeliverCargoDialog } from '@/components/cargoes/DeliverCargoDialog';
import type { Container } from '@/services/containerService';
import type { Locomotive } from '@/services/locomotives.service';
import type { Train } from '@/services/trains.service';
@@ -84,6 +105,8 @@ type FleetCrudPageProps<T extends { id: string }> = {
removeConfirmMessage?: string;
removeSuccessMessage?: string;
hideViewAction?: boolean;
/** Optional custom actions rendered before the view/edit/delete buttons in each row. */
rowActions?: (item: T) => React.ReactNode;
};
const normalizePayload = (values: Record<string, FormValue>) =>
@@ -165,6 +188,7 @@ function FleetCrudPage<T extends { id: string }>({
removeConfirmMessage,
removeSuccessMessage,
hideViewAction = false,
rowActions,
}: FleetCrudPageProps<T>) {
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
@@ -333,6 +357,7 @@ function FleetCrudPage<T extends { id: string }>({
))}
<TableCell>
<div className="flex justify-end gap-1">
{rowActions?.(item)}
{!hideViewAction ? (
<Button variant="ghost" size="icon" onClick={() => setViewing(item)} title="View">
<Eye className="size-4" />
@@ -522,74 +547,366 @@ export function TrainMasterDataPage() {
export function WagonTypesCrudPage() {
const query = useWagonTypes();
const create = useCreateWagonType();
const update = useUpdateWagonType();
const remove = useDeleteWagonType();
const { toast } = useToast();
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [sortKey, setSortKey] = useState<keyof WagonType>('code');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<WagonType | null>(null);
const [viewing, setViewing] = useState<WagonType | null>(null);
const [form, setForm] = useState<Record<string, FormValue>>({
code: '',
name: '',
capacityTons: 0,
lengthMeters: 0,
maxWagonsPerTrain: '',
supportedLoadTypes: '',
isActive: true,
});
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const pageSize = 10;
const filtered = useMemo(() => {
const queryText = search.trim().toLowerCase();
const rows = query.data ?? [];
if (!queryText) return rows;
return rows.filter((type) =>
[type.code, type.name, type.supportedLoadTypes?.join(' '), type.isActive ? 'active' : 'inactive']
.join(' ')
.toLowerCase()
.includes(queryText),
);
}, [query.data, search]);
const sorted = useMemo(() => {
return [...filtered].sort((left, right) => {
const result = String(left[sortKey] ?? '').localeCompare(String(right[sortKey] ?? ''), undefined, {
numeric: true,
});
return sortDirection === 'asc' ? result : -result;
});
}, [filtered, sortDirection, sortKey]);
const pageCount = Math.max(1, Math.ceil(sorted.length / pageSize));
const paged = sorted.slice((page - 1) * pageSize, page * pageSize);
const isSaving = create.isPending || update.isPending;
const toggleSort = (key: keyof WagonType) => {
setPage(1);
if (sortKey === key) {
setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'));
return;
}
setSortKey(key);
setSortDirection('asc');
};
const closeForm = () => {
setFormOpen(false);
setEditing(null);
setFieldErrors({});
setForm({
code: '',
name: '',
capacityTons: 0,
lengthMeters: 0,
maxWagonsPerTrain: '',
supportedLoadTypes: '',
isActive: true,
});
};
const openEdit = (type: WagonType) => {
setEditing(type);
setFieldErrors({});
setForm({
code: type.code ?? '',
name: type.name ?? '',
capacityTons: type.capacityTons ?? 0,
lengthMeters: type.lengthMeters ?? 0,
maxWagonsPerTrain: type.maxWagonsPerTrain ?? '',
supportedLoadTypes: type.supportedLoadTypes?.join(', ') ?? '',
isActive: type.isActive,
});
setFormOpen(true);
};
const validateWagonType = () => {
const errors: Record<string, string> = {};
if (!String(form.code ?? '').trim()) errors.code = 'Code is required';
if (!String(form.name ?? '').trim()) errors.name = 'Name is required';
if (!Number.isFinite(Number(form.capacityTons))) errors.capacityTons = 'Capacity must be a valid number';
if (!Number.isFinite(Number(form.lengthMeters))) errors.lengthMeters = 'Length must be a valid number';
return errors;
};
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
const errors = validateWagonType();
if (Object.keys(errors).length > 0) {
setFieldErrors(errors);
toast({ title: 'Save failed', description: Object.values(errors)[0], variant: 'destructive' });
return;
}
const payload = normalizePayload(form);
setFieldErrors({});
try {
if (editing) {
await update.mutateAsync({ id: editing.id, data: payload });
toast({ title: 'Wagon Type updated' });
} else {
await create.mutateAsync(payload);
toast({ title: 'Wagon Type created' });
}
closeForm();
} catch (error) {
const { message, fieldErrors: backendFieldErrors } = extractBackendErrors(error);
setFieldErrors(backendFieldErrors);
toast({ title: 'Save failed', description: message, variant: 'destructive' });
}
};
const handleDelete = async (type: WagonType) => {
if (!window.confirm('Delete this wagon type?')) return;
try {
await remove.mutateAsync(type.id);
toast({ title: 'Wagon Type deleted' });
} catch {
toast({ title: 'Delete failed', description: 'This wagon type may still be referenced.', variant: 'destructive' });
}
};
const sortLabel = (key: keyof WagonType) => (sortKey === key ? (sortDirection === 'asc' ? ' ASC' : ' DESC') : '');
return (
<FleetCrudPage<WagonType>
title="Wagon Types"
description="Manage wagon type capacities and load compatibility used by wagon master data."
addLabel="Add Wagon Type"
data={query.data}
isLoading={query.isLoading}
create={useCreateWagonType()}
update={useUpdateWagonType()}
remove={useDeleteWagonType()}
searchText={(type) =>
[type.code, type.name, type.supportedLoadTypes?.join(' '), String(type.isActive)].join(' ')
}
columns={[
{ key: 'code', label: 'Code' },
{ key: 'name', label: 'Name' },
{ key: 'capacityTons', label: 'Capacity (tons)' },
{ key: 'lengthMeters', label: 'Length (m)' },
{
key: 'supportedLoadTypes',
label: 'Load types',
render: (type) => type.supportedLoadTypes?.join(', ') || '-',
},
{ key: 'isActive', label: 'Status', render: (type) => activeBadge(type.isActive) },
]}
fields={[
{ key: 'code', label: 'Code', required: true },
{ key: 'name', label: 'Name', required: true },
{ key: 'capacityTons', label: 'Capacity (tons)', type: 'number', required: true },
{ key: 'lengthMeters', label: 'Length (meters)', type: 'number', required: true },
{ key: 'maxWagonsPerTrain', label: 'Max wagons per train', type: 'number' },
{
key: 'supportedLoadTypes',
label: 'Supported load types',
placeholder: 'container, break-bulk',
},
{
key: 'isActive',
label: 'Status',
type: 'select',
options: [
{ value: 'true', label: 'Active' },
{ value: 'false', label: 'Inactive' },
],
onValueChange: (value) => ({ isActive: value === 'true' }),
},
]}
emptyValues={{
code: '',
name: '',
capacityTons: 0,
lengthMeters: 0,
maxWagonsPerTrain: '',
supportedLoadTypes: '',
isActive: true,
}}
/>
<Box p="lg">
<Stack gap="md">
<Group justify="space-between" align="flex-end">
<Box>
<Title order={2}>Wagon Types</Title>
<Text size="sm" c="dimmed" mt={4}>
Manage wagon type capacities and load compatibility used by wagon master data.
</Text>
</Box>
<MantineButton
leftSection={<Plus size={16} />}
onClick={() => {
closeForm();
setFormOpen(true);
}}
>
Add Wagon Type
</MantineButton>
</Group>
<TextInput
maw={420}
leftSection={<Search size={16} />}
placeholder="Search wagon types"
value={search}
onChange={(event) => {
setSearch(event.currentTarget.value);
setPage(1);
}}
/>
<Paper withBorder radius="md">
<ScrollArea>
<MantineTable striped highlightOnHover verticalSpacing="sm" miw={900}>
<MantineTable.Thead>
<MantineTable.Tr>
<MantineTable.Th>
<MantineButton variant="subtle" size="compact-sm" onClick={() => toggleSort('code')}>
Code{sortLabel('code')}
</MantineButton>
</MantineTable.Th>
<MantineTable.Th>
<MantineButton variant="subtle" size="compact-sm" onClick={() => toggleSort('name')}>
Name{sortLabel('name')}
</MantineButton>
</MantineTable.Th>
<MantineTable.Th>
<MantineButton variant="subtle" size="compact-sm" onClick={() => toggleSort('capacityTons')}>
Capacity (tons){sortLabel('capacityTons')}
</MantineButton>
</MantineTable.Th>
<MantineTable.Th>Length (m)</MantineTable.Th>
<MantineTable.Th>Load types</MantineTable.Th>
<MantineTable.Th>Status</MantineTable.Th>
<MantineTable.Th ta="right">Actions</MantineTable.Th>
</MantineTable.Tr>
</MantineTable.Thead>
<MantineTable.Tbody>
{paged.map((type) => (
<MantineTable.Tr key={type.id}>
<MantineTable.Td fw={600}>{type.code}</MantineTable.Td>
<MantineTable.Td>{type.name}</MantineTable.Td>
<MantineTable.Td>{type.capacityTons}</MantineTable.Td>
<MantineTable.Td>{type.lengthMeters}</MantineTable.Td>
<MantineTable.Td>{type.supportedLoadTypes?.join(', ') || '-'}</MantineTable.Td>
<MantineTable.Td>
<MantineBadge color={type.isActive === false ? 'gray' : 'green'} variant="light">
{type.isActive === false ? 'Inactive' : 'Active'}
</MantineBadge>
</MantineTable.Td>
<MantineTable.Td>
<Group gap="xs" justify="flex-end">
<ActionIcon variant="subtle" aria-label="View wagon type" onClick={() => setViewing(type)}>
<Eye size={16} />
</ActionIcon>
<ActionIcon variant="subtle" aria-label="Edit wagon type" onClick={() => openEdit(type)}>
<Edit size={16} />
</ActionIcon>
<ActionIcon
color="red"
variant="subtle"
aria-label="Delete wagon type"
onClick={() => handleDelete(type)}
>
<Trash2 size={16} />
</ActionIcon>
</Group>
</MantineTable.Td>
</MantineTable.Tr>
))}
{!query.isLoading && filtered.length === 0 ? (
<MantineTable.Tr>
<MantineTable.Td colSpan={7}>
<Text ta="center" c="dimmed" py="xl">
No wagon types found.
</Text>
</MantineTable.Td>
</MantineTable.Tr>
) : null}
{query.isLoading ? (
<MantineTable.Tr>
<MantineTable.Td colSpan={7}>
<Text ta="center" c="dimmed" py="xl">
Loading...
</Text>
</MantineTable.Td>
</MantineTable.Tr>
) : null}
</MantineTable.Tbody>
</MantineTable>
</ScrollArea>
</Paper>
<Group justify="space-between">
<Text size="sm" c="dimmed">
Showing {sorted.length === 0 ? 0 : (page - 1) * pageSize + 1}-{Math.min(page * pageSize, sorted.length)} of{' '}
{sorted.length}
</Text>
<Pagination total={pageCount} value={page} onChange={setPage} size="sm" />
</Group>
</Stack>
<Modal opened={formOpen} onClose={closeForm} title={editing ? 'Edit Wagon Type' : 'Add Wagon Type'} centered>
<form onSubmit={handleSubmit}>
<Stack>
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<TextInput
label="Code"
required
value={String(form.code ?? '')}
error={fieldErrors.code}
onChange={(event) => setForm((current) => ({ ...current, code: event.currentTarget.value }))}
/>
<TextInput
label="Name"
required
value={String(form.name ?? '')}
error={fieldErrors.name}
onChange={(event) => setForm((current) => ({ ...current, name: event.currentTarget.value }))}
/>
<NumberInput
label="Capacity (tons)"
required
min={0}
value={Number(form.capacityTons ?? 0)}
error={fieldErrors.capacityTons}
onChange={(value) => setForm((current) => ({ ...current, capacityTons: value }))}
/>
<NumberInput
label="Length (meters)"
required
min={0}
value={Number(form.lengthMeters ?? 0)}
error={fieldErrors.lengthMeters}
onChange={(value) => setForm((current) => ({ ...current, lengthMeters: value }))}
/>
<NumberInput
label="Max wagons per train"
min={0}
value={form.maxWagonsPerTrain === '' ? '' : Number(form.maxWagonsPerTrain)}
onChange={(value) => setForm((current) => ({ ...current, maxWagonsPerTrain: value }))}
/>
<MantineSelect
label="Status"
value={form.isActive ? 'true' : 'false'}
data={[
{ value: 'true', label: 'Active' },
{ value: 'false', label: 'Inactive' },
]}
onChange={(value) => setForm((current) => ({ ...current, isActive: value !== 'false' }))}
/>
</SimpleGrid>
<TextInput
label="Supported load types"
placeholder="container, break-bulk"
value={Array.isArray(form.supportedLoadTypes) ? form.supportedLoadTypes.join(', ') : String(form.supportedLoadTypes ?? '')}
onChange={(event) => setForm((current) => ({ ...current, supportedLoadTypes: event.currentTarget.value }))}
/>
<Group justify="flex-end">
<MantineButton variant="default" type="button" onClick={closeForm}>
Cancel
</MantineButton>
<MantineButton type="submit" loading={isSaving}>
Save
</MantineButton>
</Group>
</Stack>
</form>
</Modal>
<Modal opened={Boolean(viewing)} onClose={() => setViewing(null)} title="Wagon Type details" centered>
<Stack gap="xs">
{viewing
? Object.entries(viewing).map(([key, value]) => (
<Group key={key} justify="space-between" align="flex-start" wrap="nowrap">
<Text size="sm" fw={600}>
{key}
</Text>
<Text size="sm" c="dimmed" ta="right">
{Array.isArray(value) ? value.join(', ') : value == null ? '-' : String(value)}
</Text>
</Group>
))
: null}
</Stack>
</Modal>
</Box>
);
}
export function WagonsCrudPage() {
const query = useWagons();
const { data: wagonTypes = [] } = useWagonTypes();
const { data: yards = [] } = useRouteYards();
const wagonTypeOptions = wagonTypes.map((type: any) => ({
value: type.id,
label: `${type.code} - ${type.name}`,
}));
const yardOptions = yards.map((yard: any) => ({
value: yard.id,
label: `${yard.label ?? yard.code} (${yard.country ?? '-'})`,
}));
return (
<FleetCrudPage<Wagon>
title="Wagons"
@@ -600,10 +917,26 @@ export function WagonsCrudPage() {
create={useCreateWagon()}
update={useUpdateWagon()}
remove={useDeleteWagon()}
searchText={(wagon) => [wagon.wagonNumber, wagon.wagonTypeId, wagon.trainId, wagon.status].join(' ')}
searchText={(wagon) => [
wagon.wagonNumber,
wagon.wagonTypeId,
wagon.trainId,
wagon.status,
wagon.currentLocationYard?.label,
wagon.currentLocationYard?.code,
wagon.currentLocationYard?.country,
].join(' ')}
columns={[
{ key: 'wagonNumber', label: 'Number' },
{ key: 'wagonTypeId', label: 'Type', render: (wagon) => optionLabel(wagonTypeOptions, wagon.wagonTypeId) },
{
key: 'currentLocationYardId',
label: 'Location',
render: (wagon) =>
wagon.currentLocationYard
? `${wagon.currentLocationYard.label ?? wagon.currentLocationYard.code} (${wagon.currentLocationYard.country ?? '-'})`
: '-',
},
{ key: 'maxPayloadWeight', label: 'Max payload' },
{ key: 'status', label: 'Status', render: (wagon) => statusBadge(wagon.status) },
]}
@@ -621,12 +954,31 @@ export function WagonsCrudPage() {
return { maxPayloadWeight: Number(selectedType.capacityTons) };
},
},
{
key: 'currentLocationYardId',
label: 'Wagon location',
type: 'select',
required: true,
options: yardOptions,
},
{ key: 'tareWeight', label: 'Tare weight', type: 'number', required: true },
{ key: 'maxPayloadWeight', label: 'Max payload weight', type: 'number', required: true },
{ key: 'status', label: 'Status' },
{
key: 'status',
label: 'Status',
type: 'select',
options: [
{ value: 'AVAILABLE', label: 'Available' },
{ value: 'IMPORT_READY', label: 'Import ready' },
{ value: 'EXPORT_READY', label: 'Export ready' },
{ value: 'ASSIGNED', label: 'Assigned' },
{ value: 'MAINTENANCE', label: 'Maintenance' },
{ value: 'RETIRED', label: 'Retired' },
],
},
{ key: 'notes', label: 'Notes' },
]}
emptyValues={{ wagonNumber: '', wagonTypeId: '', tareWeight: 0, maxPayloadWeight: 0, status: 'AVAILABLE', notes: '' }}
emptyValues={{ wagonNumber: '', wagonTypeId: '', currentLocationYardId: '', tareWeight: 0, maxPayloadWeight: 0, status: 'AVAILABLE', notes: '' }}
/>
);
}
@@ -718,7 +1070,18 @@ export function CargoesCrudPage() {
{ key: 'quantity', label: 'Quantity' },
{ key: 'weight', label: 'Weight' },
{ key: 'status', label: 'Status', render: (cargo) => statusBadge(cargo.status) },
{
key: 'receiverName',
label: 'Proof of delivery',
render: (cargo) =>
cargo.status === 'DELIVERED' && cargo.receiverName
? `${cargo.receiverName}${cargo.deliveredAt ? ` · ${new Date(cargo.deliveredAt).toLocaleDateString()}` : ''}`
: '—',
},
]}
rowActions={(cargo) =>
cargo.status === 'LOADED' ? <DeliverCargoDialog cargoId={cargo.id} /> : null
}
fields={[
{ key: 'cargoReference', label: 'Cargo reference', required: true },
{ key: 'shipmentId', label: 'Shipment ID', required: true },

View File

@@ -0,0 +1,200 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Badge,
Button,
Card,
Container,
Group,
Loader,
Stack,
Table,
Text,
} from '@mantine/core';
import { ClipboardList, Eye, PackageOpen, Truck } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import {
InspectionReportModal,
VisualEmptyState,
WarehouseHero,
formatDate,
} from '@/components/warehouses';
import { useArrivalQueue, useAutoUnloadArrived, useUnloadBooking } from '@/hooks/useWarehouses';
import { useToast } from '@/hooks/use-toast';
import type { ArrivalQueueItem } from '@/types/warehouse';
function inspectionBadge(status: string | null) {
if (!status) return <Badge variant="light" color="gray" size="sm">Not inspected</Badge>;
const color = status === 'PASSED' ? 'green' : status === 'FAILED' ? 'red' : 'orange';
return <Badge variant="light" color={color} size="sm">{status.replace(/_/g, ' ')}</Badge>;
}
/** Batch 4.5 — arrived bookings awaiting unload / inspection. */
export default function ArrivalQueuePage() {
const navigate = useNavigate();
const { toast } = useToast();
const { data, isLoading } = useArrivalQueue();
const autoUnload = useAutoUnloadArrived();
const unloadOne = useUnloadBooking();
const [inspectInventoryId, setInspectInventoryId] = useState<string | null>(null);
const items = data ?? [];
const handleAutoUnload = async () => {
try {
const res = await autoUnload.mutateAsync();
const r = res.data;
toast({
title: 'Auto-unload complete',
description: `Processed ${r.processedCount}, skipped ${r.skippedCount}, failed ${r.failedCount}.`,
});
} catch {
toast({ variant: 'destructive', title: 'Auto-unload failed' });
}
};
const handleUnloadOne = async (item: ArrivalQueueItem) => {
try {
await unloadOne.mutateAsync({ bookingId: item.bookingId });
toast({ title: 'Booking unloaded', description: `${item.bookingReference} stored as RECEIVED.` });
} catch {
toast({ variant: 'destructive', title: 'Unload failed' });
}
};
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Arrival queue' }]} />
<Stack gap="lg" mt="sm">
<WarehouseHero
variant="container"
secondaryVariant="warehouse"
title="Arrival / Unloading Queue"
subtitle="Arrived bookings ready to unload, store and inspect."
/>
<Card withBorder radius="md" padding="lg">
<Group justify="space-between" mb="md">
<Text fw={600}>{items.length} arrived booking(s)</Text>
<Button
color="orange"
leftSection={<PackageOpen size={16} />}
loading={autoUnload.isPending}
onClick={handleAutoUnload}
>
Auto Unload Arrived Bookings
</Button>
</Group>
{isLoading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : items.length === 0 ? (
<VisualEmptyState
variant="container"
title="No arrived bookings"
description="Bookings in transit that arrive appear here for unloading and inspection."
/>
) : (
<Table.ScrollContainer minWidth={1100}>
<Table verticalSpacing="sm" highlightOnHover striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Cargo / Container</Table.Th>
<Table.Th>Arrival</Table.Th>
<Table.Th>Facility</Table.Th>
<Table.Th>Warehouse</Table.Th>
<Table.Th>Yard</Table.Th>
<Table.Th>Zone</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Inspection</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((item) => (
<Table.Tr key={item.bookingId}>
<Table.Td>
<Text fw={600} size="sm">{item.bookingReference}</Text>
</Table.Td>
<Table.Td>{item.customer ?? '—'}</Table.Td>
<Table.Td>{item.container ?? item.cargo ?? '—'}</Table.Td>
<Table.Td>
<Text size="xs">{formatDate(item.arrivalDate)}</Text>
</Table.Td>
<Table.Td>{item.facility ?? '—'}</Table.Td>
<Table.Td>{item.warehouse ?? '—'}</Table.Td>
<Table.Td>{item.yard ?? '—'}</Table.Td>
<Table.Td>{item.zone ?? '—'}</Table.Td>
<Table.Td>
{item.unloaded ? (
<Badge variant="light" color="green" size="sm">
{item.currentStatus ?? 'RECEIVED'}
</Badge>
) : (
<Badge variant="light" color="orange" size="sm">
Not unloaded
</Badge>
)}
</Table.Td>
<Table.Td>{inspectionBadge(item.inspectionStatus)}</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end" wrap="nowrap">
{!item.unloaded && (
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<Truck size={14} />}
loading={unloadOne.isPending}
onClick={() => handleUnloadOne(item)}
>
Unload
</Button>
)}
{item.inventoryId && (
<Button
size="compact-xs"
variant="light"
color="green"
leftSection={<ClipboardList size={14} />}
onClick={() => setInspectInventoryId(item.inventoryId)}
>
Inspect
</Button>
)}
{item.inventoryId && (
<Button
size="compact-xs"
variant="subtle"
color="gray"
leftSection={<Eye size={14} />}
onClick={() => navigate('/dashboard/warehouse-inventory')}
>
Inventory
</Button>
)}
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Card>
</Stack>
<InspectionReportModal
opened={Boolean(inspectInventoryId)}
onClose={() => setInspectInventoryId(null)}
inventoryId={inspectInventoryId}
/>
</Container>
);
}

View File

@@ -0,0 +1,38 @@
import { Card, Container, Stack } from '@mantine/core';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { InventoryWorkbench, VisualEmptyState, WarehouseHero } from '@/components/warehouses';
import { useWarehouseInventory } from '@/hooks/useWarehouses';
/** Items that are LOADED and awaiting dispatch (train departure). */
export default function DispatchQueuePage() {
const { data, isLoading } = useWarehouseInventory({ status: 'LOADED' });
const items = data ?? [];
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Dispatch queue' }]} />
<Stack gap="lg" mt="sm">
<WarehouseHero
variant="train"
secondaryVariant="route"
title="Dispatch Queue"
subtitle="Loaded inventory awaiting train departure. Mark items dispatched once they leave."
/>
<Card withBorder radius="md" padding="lg">
{!isLoading && items.length === 0 ? (
<VisualEmptyState
variant="train"
title="Nothing to dispatch"
description="Loaded items appear here, ready to mark as dispatched."
/>
) : (
<InventoryWorkbench items={items} isLoading={isLoading} />
)}
</Card>
</Stack>
</Container>
);
}

View File

@@ -0,0 +1,155 @@
import { useMemo, useState } from 'react';
import { Button, Card, Center, Container, Group, Loader, Select, Stack, Text, TextInput, Title } from '@mantine/core';
import { Search } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { VisualEmptyState, WarehouseInquiryTable, inventoryStatusOptions } from '@/components/warehouses';
import {
useInventoryInquiry,
useWarehouseYards,
useWarehouseZones,
useWarehouses,
} from '@/hooks/useWarehouses';
import type { InventoryInquiryFilter, InventoryStatus } from '@/types/warehouse';
export default function InventoryInquiryPage() {
const [draft, setDraft] = useState<InventoryInquiryFilter>({});
const [applied, setApplied] = useState<InventoryInquiryFilter>({});
const warehousesQuery = useWarehouses();
const yardsQuery = useWarehouseYards(draft.warehouseId);
const zonesQuery = useWarehouseZones(draft.yardId);
const { data, isFetching } = useInventoryInquiry(applied);
const results = data ?? [];
const warehouseOptions = useMemo(
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
[warehousesQuery.data],
);
const yardOptions = useMemo(
() => (yardsQuery.data ?? []).map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
[yardsQuery.data],
);
const zoneOptions = useMemo(
() => (zonesQuery.data ?? []).map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })),
[zonesQuery.data],
);
const runSearch = () => setApplied(draft);
const reset = () => {
setDraft({});
setApplied({});
};
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Inventory inquiry' }]} />
<Stack gap="lg" mt="sm">
<div>
<Title order={2}>Inventory Inquiry</Title>
<Text c="dimmed" size="sm">
Locate any cargo, container or goods inside the warehouse network.
</Text>
</div>
<Card withBorder radius="md" padding="lg">
<Stack gap="md">
<Group gap="sm" wrap="wrap">
<TextInput
label="Booking number"
placeholder="e.g. BKG-00123"
value={draft.bookingNumber ?? ''}
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) => { 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) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, goodsName: v || undefined })); }}
w={180}
/>
<Select
label="Warehouse"
placeholder="Any"
clearable
searchable
data={warehouseOptions}
value={draft.warehouseId ?? null}
onChange={(value) =>
setDraft((f) => ({ ...f, warehouseId: value ?? undefined, yardId: undefined, zoneId: undefined }))
}
w={200}
/>
<Select
label="Yard"
placeholder="Any"
clearable
searchable
disabled={!draft.warehouseId}
data={yardOptions}
value={draft.yardId ?? null}
onChange={(value) => setDraft((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined }))}
w={180}
/>
<Select
label="Zone"
placeholder="Any"
clearable
searchable
disabled={!draft.yardId}
data={zoneOptions}
value={draft.zoneId ?? null}
onChange={(value) => setDraft((f) => ({ ...f, zoneId: value ?? undefined }))}
w={180}
/>
<Select
label="Status"
placeholder="Any"
clearable
data={inventoryStatusOptions}
value={draft.status ?? null}
onChange={(value) => setDraft((f) => ({ ...f, status: (value as InventoryStatus) ?? undefined }))}
w={180}
/>
</Group>
<Group>
<Button leftSection={<Search size={16} />} onClick={runSearch}>
Search
</Button>
<Button variant="default" onClick={reset}>
Reset
</Button>
</Group>
</Stack>
</Card>
<Card withBorder radius="md" padding="lg">
{isFetching ? (
<Center py="xl">
<Loader />
</Center>
) : results.length === 0 ? (
<VisualEmptyState
variant="container"
title="No items found"
description="Adjust your filters and search to locate cargo, containers or goods across the warehouse network."
/>
) : (
<WarehouseInquiryTable results={results} />
)}
</Card>
</Stack>
</Container>
);
}

View File

@@ -0,0 +1,86 @@
import { Badge, Card, Container, Group, Loader, Stack, Table, Text } from '@mantine/core';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { FreightVisual, VisualEmptyState, WarehouseHero, formatDate, formatNumber } from '@/components/warehouses';
import { useWarehouseLoadings } from '@/hooks/useWarehouses';
/** Record of every inventory item loaded onto a wagon. */
export default function LoadedInventoryPage() {
const { data, isLoading } = useWarehouseLoadings();
const loadings = data ?? [];
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Loaded inventory' }]} />
<Stack gap="lg" mt="sm">
<WarehouseHero
variant="container"
secondaryVariant="wagon"
title="Loaded Inventory"
subtitle="Items loaded onto wagons, with their loading records."
/>
<Card withBorder radius="md" padding="lg">
{isLoading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : loadings.length === 0 ? (
<VisualEmptyState
variant="container"
title="No loaded inventory yet"
description="Once items are loaded onto a wagon, their records show here."
/>
) : (
<Table.ScrollContainer minWidth={760}>
<Table verticalSpacing="sm" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Wagon</Table.Th>
<Table.Th>Warehouse</Table.Th>
<Table.Th>Zone</Table.Th>
<Table.Th>Loaded Weight (kg)</Table.Th>
<Table.Th>Loaded At</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{loadings.map((l) => (
<Table.Tr key={l.id}>
<Table.Td>
<Group gap={6} wrap="nowrap">
<FreightVisual variant="wagon" size={22} />
<Text fw={600} size="sm">
{l.wagonNumber ?? l.wagonId.slice(0, 8)}
</Text>
</Group>
</Table.Td>
<Table.Td>
{l.inventory?.warehouse
? `${l.inventory.warehouse.name} (${l.inventory.warehouse.code})`
: '—'}
</Table.Td>
<Table.Td>{l.inventory?.zone ? l.inventory.zone.name : '—'}</Table.Td>
<Table.Td>{formatNumber(l.loadedWeight)}</Table.Td>
<Table.Td>{formatDate(l.loadedAt)}</Table.Td>
<Table.Td>
<Badge
variant="light"
color={l.inventory?.status === 'DISPATCHED' ? 'green' : 'teal'}
size="sm"
>
{l.inventory?.status ?? 'LOADED'}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Card>
</Stack>
</Container>
);
}

View File

@@ -0,0 +1,247 @@
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Badge, Button, Card, Container, Group, Stack, Table, Tabs, Text } from '@mantine/core';
import { CreditCard, Eye, Truck } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import {
InventoryWorkbench,
VisualEmptyState,
WarehouseHero,
formatNumber,
} from '@/components/warehouses';
import { useAutoLoadReady, useWarehouseInventory } from '@/hooks/useWarehouses';
import { useToast } from '@/hooks/use-toast';
import type { WarehouseInventoryItem } from '@/types/warehouse';
const isPaid = (item: WarehouseInventoryItem) => item.booking?.status === 'PAID';
/**
* Loading Queue — manage inventory through the loading workflow.
* Tabs:
* - Ready to Load: READY_FOR_LOADING + booking PAID (can Mark as Loaded)
* - Pending Payment: READY_FOR_LOADING + booking not PAID (no Load action)
* - Loaded Inventory: LOADED (can Dispatch)
* - Dispatch Queue: LOADED (can Dispatch)
*/
export default function LoadingQueuePage() {
const navigate = useNavigate();
const { toast } = useToast();
const autoLoad = useAutoLoadReady();
const { data: readyData, isLoading: readyLoading } = useWarehouseInventory({
status: 'READY_FOR_LOADING',
});
const { data: loadedData, isLoading: loadedLoading } = useWarehouseInventory({ status: 'LOADED' });
const handleAutoLoad = async () => {
try {
const res = await autoLoad.mutateAsync();
const r = res.data;
toast({
title: 'Auto-load complete',
description: `Loaded ${r.loadedCount} PAID item(s); skipped ${r.skippedCount} (unpaid stay pending).`,
});
} catch {
toast({ variant: 'destructive', title: 'Auto-load failed' });
}
};
const readyItems = readyData ?? [];
const loadedItems = loadedData ?? [];
const paidItems = useMemo(() => readyItems.filter(isPaid), [readyItems]);
const unpaidItems = useMemo(() => readyItems.filter((i) => !isPaid(i)), [readyItems]);
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Loading queue' }]} />
<Stack gap="lg" mt="sm">
<WarehouseHero
variant="wagon"
secondaryVariant="cargo"
title="Loading Queue"
subtitle="Manage bookings and inventory through the loading workflow."
/>
<Group justify="flex-end">
<Button
color="green"
leftSection={<Truck size={16} />}
loading={autoLoad.isPending}
onClick={handleAutoLoad}
>
Auto Load Ready Items
</Button>
</Group>
<Card withBorder radius="md" padding="lg">
<Tabs defaultValue="ready">
<Tabs.List>
<Tabs.Tab
value="ready"
leftSection={
<Badge size="xs" color="green">
{paidItems.length}
</Badge>
}
>
Ready to Load
</Tabs.Tab>
<Tabs.Tab
value="pending"
leftSection={
<Badge size="xs" color="orange">
{unpaidItems.length}
</Badge>
}
>
Pending Payment
</Tabs.Tab>
<Tabs.Tab
value="loaded"
leftSection={
<Badge size="xs" color="teal">
{loadedItems.length}
</Badge>
}
>
Loaded Inventory
</Tabs.Tab>
<Tabs.Tab
value="dispatch"
leftSection={
<Badge size="xs" color="blue">
{loadedItems.length}
</Badge>
}
>
Dispatch Queue
</Tabs.Tab>
</Tabs.List>
{/* Ready to Load — PAID bookings, can be marked Loaded */}
<Tabs.Panel value="ready" pt="md">
{!readyLoading && paidItems.length === 0 ? (
<VisualEmptyState
variant="wagon"
title="Nothing ready to load"
description="Paid bookings marked Ready For Loading appear here, ready to load onto a wagon."
/>
) : (
<InventoryWorkbench items={paidItems} isLoading={readyLoading} />
)}
</Tabs.Panel>
{/* Pending Payment — unpaid bookings, read-only (no Load action) */}
<Tabs.Panel value="pending" pt="md">
{!readyLoading && unpaidItems.length === 0 ? (
<VisualEmptyState
variant="cargo"
title="No unpaid bookings"
description="Ready-for-loading items whose booking is not yet PAID appear here."
/>
) : (
<PendingPaymentTable items={unpaidItems} onNavigate={navigate} />
)}
</Tabs.Panel>
{/* Loaded Inventory — LOADED items, can Dispatch */}
<Tabs.Panel value="loaded" pt="md">
{!loadedLoading && loadedItems.length === 0 ? (
<VisualEmptyState
variant="container"
title="No loaded inventory yet"
description="Items loaded onto a wagon appear here, ready to dispatch."
/>
) : (
<InventoryWorkbench items={loadedItems} isLoading={loadedLoading} />
)}
</Tabs.Panel>
{/* Dispatch Queue — LOADED items awaiting departure */}
<Tabs.Panel value="dispatch" pt="md">
{!loadedLoading && loadedItems.length === 0 ? (
<VisualEmptyState
variant="train"
title="Nothing to dispatch"
description="Loaded items appear here, ready to mark as dispatched."
/>
) : (
<InventoryWorkbench items={loadedItems} isLoading={loadedLoading} />
)}
</Tabs.Panel>
</Tabs>
</Card>
</Stack>
</Container>
);
}
interface PendingPaymentTableProps {
items: WarehouseInventoryItem[];
onNavigate: (path: string) => void;
}
/** Read-only view of unpaid ready-for-loading items. No Mark-as-Loaded action. */
function PendingPaymentTable({ items, onNavigate }: PendingPaymentTableProps) {
return (
<Table.ScrollContainer minWidth={820}>
<Table verticalSpacing="sm" highlightOnHover striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
<Table.Th>Warehouse</Table.Th>
<Table.Th>Zone</Table.Th>
<Table.Th>Weight (kg)</Table.Th>
<Table.Th>Payment</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((item) => (
<Table.Tr key={item.id}>
<Table.Td>
<Text fw={600} size="sm">
{item.booking?.reference ?? item.bookingId?.slice(0, 8) ?? '—'}
</Text>
</Table.Td>
<Table.Td>{item.warehouse?.code ?? '—'}</Table.Td>
<Table.Td>{item.zone?.code ?? '—'}</Table.Td>
<Table.Td>{formatNumber(item.weight)}</Table.Td>
<Table.Td>
<Badge color="orange" variant="light" size="sm">
{item.booking?.status ?? item.booking?.paymentStatus ?? 'UNPAID'}
</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Button
size="compact-xs"
variant="light"
color="gray"
leftSection={<Eye size={14} />}
disabled={!item.bookingId}
onClick={() => item.bookingId && onNavigate(`/dashboard/booking-requests/${item.bookingId}`)}
>
Booking
</Button>
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<CreditCard size={14} />}
disabled={!item.bookingId}
onClick={() => item.bookingId && onNavigate(`/dashboard/booking-requests/${item.bookingId}`)}
>
Payment
</Button>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}

View File

@@ -0,0 +1,115 @@
import { useNavigate } from 'react-router-dom';
import { Card, Center, Container, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
import {
ClipboardCheck,
PackageCheck,
PackagePlus,
Send,
Truck,
Warehouse as WarehouseIcon,
Boxes,
Layers,
} from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { WarehouseDashboardCharts, WarehouseHero } from '@/components/warehouses';
import { useWarehouseDashboard } from '@/hooks/useWarehouses';
import type { WarehouseDashboard } from '@/types/warehouse';
/** Brand palette: alternating orange + light green. */
const ORANGE = { solid: '#f08c00', soft: '#fff4e6', border: '#ffd8a8', text: '#e8590c' };
const GREEN = { solid: '#5bbf4a', soft: '#ebfbee', border: '#b2f2bb', text: '#2f9e44' };
interface Metric {
key: keyof WarehouseDashboard;
label: string;
icon: React.ReactNode;
/** Route to navigate to when the card is clicked. */
to: string;
theme: typeof ORANGE;
}
const METRICS: Metric[] = [
{ key: 'totalWarehouses', label: 'Total Warehouses', icon: <WarehouseIcon size={22} />, to: '/dashboard/warehouses', theme: ORANGE },
{ key: 'totalInventory', label: 'Total Inventory', icon: <Boxes size={22} />, to: '/dashboard/warehouse-inventory', theme: GREEN },
{ key: 'receivedToday', label: 'Received Today', icon: <PackagePlus size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: ORANGE },
{ key: 'stored', label: 'Stored', icon: <Layers size={22} />, to: '/dashboard/warehouse-inventory?status=STORED', theme: GREEN },
{ key: 'reserved', label: 'Reserved', icon: <ClipboardCheck size={22} />, to: '/dashboard/warehouse-inventory?status=RESERVED', theme: ORANGE },
{ key: 'readyForLoading', label: 'Ready For Loading', icon: <PackageCheck size={22} />, to: '/dashboard/loading-queue', theme: GREEN },
{ key: 'loaded', label: 'Loaded', icon: <Truck size={22} />, to: '/dashboard/loaded-inventory', theme: ORANGE },
{ key: 'dispatched', label: 'Dispatched', icon: <Send size={22} />, to: '/dashboard/dispatch-queue', theme: GREEN },
];
export default function WarehouseDashboardPage() {
const navigate = useNavigate();
const { data, isLoading } = useWarehouseDashboard();
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Warehouse dashboard' }]} />
<Stack gap="lg" mt="sm">
<WarehouseHero
variant="train"
secondaryVariant="warehouse"
title="Warehouse Dashboard"
subtitle="Live overview of warehouse capacity and inventory lifecycle."
/>
{isLoading ? (
<Center py="xl">
<Loader />
</Center>
) : (
<>
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
{METRICS.map((metric) => (
<Card
key={metric.key}
radius="lg"
padding="lg"
onClick={() => navigate(metric.to)}
style={{
cursor: 'pointer',
background: `linear-gradient(135deg, ${metric.theme.soft} 0%, #ffffff 75%)`,
border: `1px solid ${metric.theme.border}`,
transition: 'box-shadow 150ms ease, transform 150ms ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.boxShadow = `0 10px 24px -12px ${metric.theme.solid}`;
e.currentTarget.style.transform = 'translateY(-3px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = '';
e.currentTarget.style.transform = '';
}}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700} style={{ letterSpacing: 0.4 }}>
{metric.label}
</Text>
<Text fw={800} size="32px" mt={8} style={{ color: metric.theme.text, lineHeight: 1.1 }}>
{data ? data[metric.key] : 0}
</Text>
</div>
<ThemeIcon
variant="filled"
size={46}
radius="md"
style={{ backgroundColor: metric.theme.solid, color: '#fff' }}
>
{metric.icon}
</ThemeIcon>
</Group>
</Card>
))}
</SimpleGrid>
<WarehouseDashboardCharts data={data} />
</>
)}
</Stack>
</Container>
);
}

View File

@@ -0,0 +1,332 @@
import { useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
ActionIcon,
Button,
Card,
Center,
Container,
Group,
Loader,
SimpleGrid,
Stack,
Select,
Table,
Tabs,
Text,
Title,
} from '@mantine/core';
import { ArrowLeft, Boxes, LayoutGrid, Package, Pencil, Plus } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import {
CreateYardModal,
CreateZoneModal,
InventoryWorkbench,
WarehouseStatusBadge,
WarehouseTypeBadge,
formatCapacity,
humanizeEnum,
} from '@/components/warehouses';
import {
useWarehouse,
useWarehouseInventory,
useWarehouseYards,
useWarehouseZones,
} from '@/hooks/useWarehouses';
import type { WarehouseYard, WarehouseZone } from '@/types/warehouse';
function StatCard({ label, value }: { label: string; value: string }) {
return (
<Card withBorder radius="md" padding="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{label}
</Text>
<Text fw={700} size="lg" mt={4}>
{value}
</Text>
</Card>
);
}
export default function WarehouseDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: warehouse, isLoading } = useWarehouse(id);
const yardsQuery = useWarehouseYards(id);
const [yardModalOpen, setYardModalOpen] = useState(false);
const [editingYard, setEditingYard] = useState<WarehouseYard | null>(null);
const [zoneModalOpen, setZoneModalOpen] = useState(false);
const [editingZone, setEditingZone] = useState<WarehouseZone | null>(null);
const [selectedYardId, setSelectedYardId] = useState<string | null>(null);
const zonesQuery = useWarehouseZones(selectedYardId ?? undefined);
const inventoryQuery = useWarehouseInventory(id ? { warehouseId: id } : undefined);
const yards = yardsQuery.data ?? [];
const yardOptions = useMemo(
() => yards.map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
[yards],
);
if (isLoading) {
return (
<Center mih="60vh">
<Loader />
</Center>
);
}
if (!warehouse) {
return (
<Container size="sm" py="xl">
<Stack align="center" gap="md">
<Text fw={700}>Warehouse not found</Text>
<Button variant="default" leftSection={<ArrowLeft size={16} />} onClick={() => navigate('/dashboard/warehouses')}>
Back to warehouses
</Button>
</Stack>
</Container>
);
}
return (
<Container size="xxl" py="lg">
<Breadcrumbs
items={[
{ label: 'Warehouses', href: '/dashboard/warehouses' },
{ label: warehouse.name },
]}
/>
<Stack gap="lg" mt="sm">
<Group justify="space-between" align="flex-start">
<Group gap="md" align="center">
<ActionIcon variant="subtle" color="gray" onClick={() => navigate('/dashboard/warehouses')}>
<ArrowLeft size={18} />
</ActionIcon>
<div>
<Group gap="sm">
<Title order={2}>{warehouse.name}</Title>
<WarehouseTypeBadge type={warehouse.type} />
<WarehouseStatusBadge status={warehouse.status} />
</Group>
<Text c="dimmed" size="sm">
{warehouse.code}
{warehouse.locationName ? ` · ${warehouse.locationName}` : ''}
</Text>
</div>
</Group>
</Group>
<Tabs defaultValue="overview">
<Tabs.List>
<Tabs.Tab value="overview" leftSection={<LayoutGrid size={16} />}>
Overview
</Tabs.Tab>
<Tabs.Tab value="yards" leftSection={<Boxes size={16} />}>
Yards
</Tabs.Tab>
<Tabs.Tab value="zones" leftSection={<LayoutGrid size={16} />}>
Zones
</Tabs.Tab>
<Tabs.Tab value="inventory" leftSection={<Package size={16} />}>
Inventory
</Tabs.Tab>
</Tabs.List>
{/* OVERVIEW */}
<Tabs.Panel value="overview" pt="lg">
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md">
<StatCard label="Type" value={humanizeEnum(warehouse.type)} />
<StatCard label="Yards" value={String(yards.length)} />
<StatCard
label="Weight (cur / cap)"
value={formatCapacity(warehouse.currentWeight, warehouse.capacityWeight)}
/>
<StatCard
label="Containers (cur / cap)"
value={formatCapacity(warehouse.currentContainers, warehouse.capacityContainers)}
/>
</SimpleGrid>
</Tabs.Panel>
{/* YARDS */}
<Tabs.Panel value="yards" pt="lg">
<Card withBorder radius="md" padding="lg">
<Stack gap="md">
<Group justify="space-between">
<Text fw={600}>Yards</Text>
<Button
size="sm"
leftSection={<Plus size={16} />}
onClick={() => {
setEditingYard(null);
setYardModalOpen(true);
}}
>
Create Yard
</Button>
</Group>
{yards.length === 0 ? (
<Text c="dimmed" ta="center" py="lg">
No yards yet.
</Text>
) : (
<Table highlightOnHover verticalSpacing="sm" striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Code</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Weight (cur / cap)</Table.Th>
<Table.Th>Containers (cur / cap)</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{yards.map((yard) => (
<Table.Tr key={yard.id}>
<Table.Td>{yard.name}</Table.Td>
<Table.Td>{yard.code}</Table.Td>
<Table.Td>{humanizeEnum(yard.type)}</Table.Td>
<Table.Td>{formatCapacity(yard.currentWeight, yard.capacityWeight)}</Table.Td>
<Table.Td>{formatCapacity(yard.currentContainers, yard.capacityContainers)}</Table.Td>
<Table.Td>
<WarehouseStatusBadge status={yard.status} />
</Table.Td>
<Table.Td ta="right">
<ActionIcon
variant="subtle"
color="gray"
onClick={() => {
setEditingYard(yard);
setYardModalOpen(true);
}}
>
<Pencil size={16} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Stack>
</Card>
</Tabs.Panel>
{/* ZONES */}
<Tabs.Panel value="zones" pt="lg">
<Card withBorder radius="md" padding="lg">
<Stack gap="md">
<Group justify="space-between" align="flex-end">
<Select
label="Yard"
placeholder="Select a yard"
data={yardOptions}
value={selectedYardId}
onChange={setSelectedYardId}
w={280}
searchable
/>
<Button
size="sm"
leftSection={<Plus size={16} />}
disabled={!selectedYardId}
onClick={() => {
setEditingZone(null);
setZoneModalOpen(true);
}}
>
Create Zone
</Button>
</Group>
{!selectedYardId ? (
<Text c="dimmed" ta="center" py="lg">
Select a yard to view its zones.
</Text>
) : (zonesQuery.data ?? []).length === 0 ? (
<Text c="dimmed" ta="center" py="lg">
No zones in this yard yet.
</Text>
) : (
<Table highlightOnHover verticalSpacing="sm" striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Code</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Weight (cur / cap)</Table.Th>
<Table.Th>Containers (cur / cap)</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(zonesQuery.data ?? []).map((zone) => (
<Table.Tr key={zone.id}>
<Table.Td>{zone.name}</Table.Td>
<Table.Td>{zone.code}</Table.Td>
<Table.Td>{humanizeEnum(zone.type)}</Table.Td>
<Table.Td>{formatCapacity(zone.currentWeight, zone.capacityWeight)}</Table.Td>
<Table.Td>{formatCapacity(zone.currentContainers, zone.capacityContainers)}</Table.Td>
<Table.Td>
<WarehouseStatusBadge status={zone.status} />
</Table.Td>
<Table.Td ta="right">
<ActionIcon
variant="subtle"
color="gray"
onClick={() => {
setEditingZone(zone);
setZoneModalOpen(true);
}}
>
<Pencil size={16} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Stack>
</Card>
</Tabs.Panel>
{/* INVENTORY */}
<Tabs.Panel value="inventory" pt="lg">
<Card withBorder radius="md" padding="lg">
<InventoryWorkbench items={inventoryQuery.data ?? []} isLoading={inventoryQuery.isLoading} />
</Card>
</Tabs.Panel>
</Tabs>
</Stack>
{id && (
<CreateYardModal
opened={yardModalOpen}
onClose={() => setYardModalOpen(false)}
warehouseId={id}
yard={editingYard}
/>
)}
{selectedYardId && (
<CreateZoneModal
opened={zoneModalOpen}
onClose={() => setZoneModalOpen(false)}
yardId={selectedYardId}
zone={editingZone}
/>
)}
</Container>
);
}

View File

@@ -0,0 +1,130 @@
import { useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
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 {
InventoryWorkbench,
ReceiveInventoryModal,
inventoryStatusOptions,
} from '@/components/warehouses';
import {
useWarehouseInventory,
useWarehouseYards,
useWarehouseZones,
useWarehouses,
} from '@/hooks/useWarehouses';
import type { InventoryFilter, InventoryStatus } from '@/types/warehouse';
export default function WarehouseInventoryPage() {
const [searchParams] = useSearchParams();
const initialStatus = (searchParams.get('status') as InventoryStatus | null) ?? undefined;
const [filter, setFilter] = useState<InventoryFilter>(
initialStatus ? { status: initialStatus } : {},
);
const [search, setSearch] = useState('');
const [modalOpen, setModalOpen] = useState(false);
const [debouncedSearch] = useDebouncedValue(search, 300);
const queryFilter = useMemo<InventoryFilter>(
() => ({ ...filter, search: debouncedSearch || undefined }),
[filter, debouncedSearch],
);
const warehousesQuery = useWarehouses();
const yardsQuery = useWarehouseYards(filter.warehouseId);
const zonesQuery = useWarehouseZones(filter.yardId);
const inventoryQuery = useWarehouseInventory(queryFilter);
const warehouseOptions = useMemo(
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
[warehousesQuery.data],
);
const yardOptions = useMemo(
() => (yardsQuery.data ?? []).map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
[yardsQuery.data],
);
const zoneOptions = useMemo(
() => (zonesQuery.data ?? []).map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })),
[zonesQuery.data],
);
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Warehouse inventory' }]} />
<Stack gap="lg" mt="sm">
<Group justify="space-between" align="flex-end">
<div>
<Title order={2}>Warehouse Inventory</Title>
<Text c="dimmed" size="sm">
Track received items through the storage, reservation, loading and dispatch lifecycle.
</Text>
</div>
<Button leftSection={<PackagePlus size={16} />} onClick={() => setModalOpen(true)}>
Receive Inventory
</Button>
</Group>
<Card withBorder radius="md" padding="lg">
<Stack gap="md">
<Group gap="sm" wrap="wrap">
<TextInput
placeholder="Search notes"
leftSection={<Search size={16} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
w={220}
/>
<Select
placeholder="All warehouses"
clearable
searchable
data={warehouseOptions}
value={filter.warehouseId ?? null}
onChange={(value) =>
setFilter((f) => ({ ...f, warehouseId: value ?? undefined, yardId: undefined, zoneId: undefined }))
}
w={220}
/>
<Select
placeholder="All yards"
clearable
searchable
disabled={!filter.warehouseId}
data={yardOptions}
value={filter.yardId ?? null}
onChange={(value) => setFilter((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined }))}
w={200}
/>
<Select
placeholder="All zones"
clearable
searchable
disabled={!filter.yardId}
data={zoneOptions}
value={filter.zoneId ?? null}
onChange={(value) => setFilter((f) => ({ ...f, zoneId: value ?? undefined }))}
w={200}
/>
<Select
placeholder="All statuses"
clearable
data={inventoryStatusOptions}
value={filter.status ?? null}
onChange={(value) => setFilter((f) => ({ ...f, status: (value as InventoryStatus) ?? undefined }))}
w={200}
/>
</Group>
<InventoryWorkbench items={inventoryQuery.data ?? []} isLoading={inventoryQuery.isLoading} />
</Stack>
</Card>
</Stack>
<ReceiveInventoryModal opened={modalOpen} onClose={() => setModalOpen(false)} />
</Container>
);
}

View File

@@ -0,0 +1,238 @@
import { useMemo, useState } from 'react';
import {
ActionIcon,
Badge,
Button,
Card,
Container,
Divider,
Group,
Loader,
Modal,
NumberInput,
Select,
Stack,
Table,
Text,
TextInput,
} from '@mantine/core';
import { Ban, CreditCard, Eye, Search } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { WarehouseHero } from '@/components/warehouses';
import { useToast } from '@/hooks/use-toast';
import {
useCancelInvoice,
usePayInvoice,
useWarehouseInvoice,
useWarehouseInvoices,
} from '@/hooks/useWarehouses';
import {
WAREHOUSE_INVOICE_STATUSES,
type WarehouseFeeInvoice,
type WarehouseInvoiceStatus,
} from '@/types/warehouse';
const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
DRAFT: 'gray',
ISSUED: 'orange',
PARTIALLY_PAID: 'yellow',
PAID: 'green',
CANCELLED: 'gray',
};
const fmt = (n: number, c: string) => `${Number(n).toLocaleString()} ${c}`;
const fmtDate = (d?: string | null) => (d ? new Date(d).toLocaleDateString() : '—');
export default function WarehouseInvoicesPage() {
const [status, setStatus] = useState<WarehouseInvoiceStatus | null>(null);
const [search, setSearch] = useState('');
const [detailId, setDetailId] = useState<string | null>(null);
const { data, isLoading } = useWarehouseInvoices(status ? { status } : undefined);
const invoices = data ?? [];
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return invoices;
return invoices.filter((i) => [i.invoiceNumber, i.bookingId, i.customerId].join(' ').toLowerCase().includes(q));
}, [invoices, search]);
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Warehouse fee invoices' }]} />
<Stack gap="lg" mt="sm">
<WarehouseHero
variant="container"
secondaryVariant="warehouse"
title="Warehouse Fee Invoices"
subtitle="Demurrage & storage invoices generated from warehouse fee rules."
/>
<Card withBorder radius="md" padding="lg">
<Group justify="space-between" mb="md" wrap="wrap">
<TextInput
placeholder="Search invoice no / booking / customer"
leftSection={<Search size={16} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
w={320}
/>
<Select
placeholder="All statuses"
data={WAREHOUSE_INVOICE_STATUSES.map((s) => ({ value: s, label: s.replace(/_/g, ' ') }))}
value={status}
onChange={(v) => setStatus((v as WarehouseInvoiceStatus) ?? null)}
clearable
w={200}
/>
</Group>
{isLoading ? (
<Group justify="center" py="xl"><Loader /></Group>
) : filtered.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">No invoices found.</Text>
) : (
<Table.ScrollContainer minWidth={1000}>
<Table striped highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Invoice No</Table.Th><Table.Th>Type</Table.Th><Table.Th>Total</Table.Th>
<Table.Th>Paid</Table.Th><Table.Th>Balance</Table.Th><Table.Th>Status</Table.Th>
<Table.Th>Issued</Table.Th><Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filtered.map((inv) => (
<Table.Tr key={inv.id}>
<Table.Td><Text fw={600} size="sm">{inv.invoiceNumber}</Text></Table.Td>
<Table.Td>{inv.invoiceType.replace(/_/g, ' ')}</Table.Td>
<Table.Td>{fmt(inv.totalAmount, inv.currency)}</Table.Td>
<Table.Td>{fmt(inv.paidAmount, inv.currency)}</Table.Td>
<Table.Td>{fmt(inv.balanceAmount, inv.currency)}</Table.Td>
<Table.Td><Badge variant="light" color={STATUS_COLOR[inv.status]}>{inv.status.replace(/_/g, ' ')}</Badge></Table.Td>
<Table.Td><Text size="xs">{fmtDate(inv.issuedAt)}</Text></Table.Td>
<Table.Td ta="right">
<ActionIcon variant="subtle" color="gray" onClick={() => setDetailId(inv.id)} title="View">
<Eye size={16} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Card>
</Stack>
<InvoiceDetailModal id={detailId} onClose={() => setDetailId(null)} />
</Container>
);
}
function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => void }) {
const { toast } = useToast();
const { data: inv, isLoading } = useWarehouseInvoice(id ?? undefined);
const pay = usePayInvoice();
const cancel = useCancelInvoice();
const [payAmount, setPayAmount] = useState<number | ''>('');
const canPay = inv && (inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID');
const handlePay = async () => {
if (!inv || !payAmount) return;
try {
await pay.mutateAsync({ id: inv.id, payload: { amount: Number(payAmount), method: 'MANUAL' } });
toast({ title: 'Payment recorded' });
setPayAmount('');
} catch (e) {
toast({ variant: 'destructive', title: 'Payment failed', description: (e as Error)?.message });
}
};
const handleCancel = async () => {
if (!inv) return;
try {
await cancel.mutateAsync(inv.id);
toast({ title: 'Invoice cancelled' });
onClose();
} catch (e) {
toast({ variant: 'destructive', title: 'Cancel failed', description: (e as Error)?.message });
}
};
return (
<Modal opened={Boolean(id)} onClose={onClose} title="Fee invoice" centered size="lg">
{isLoading || !inv ? (
<Group justify="center" py="xl"><Loader /></Group>
) : (
<Stack gap="sm">
<Group justify="space-between">
<Text fw={700} size="lg">{inv.invoiceNumber}</Text>
<Badge variant="light" color={STATUS_COLOR[inv.status]} size="lg">{inv.status.replace(/_/g, ' ')}</Badge>
</Group>
<Table withRowBorders={false} verticalSpacing={4}>
<Table.Tbody>
{(inv.items ?? []).map((it) => (
<Table.Tr key={it.id}>
<Table.Td>
<Text size="sm">{it.description}</Text>
<Text size="xs" c="dimmed">{it.feeType.replace(/_/g, ' ')} · {it.chargeableDays ?? 0} day(s) @ {fmt(it.unitRate, it.currency)}</Text>
</Table.Td>
<Table.Td ta="right"><Text fw={600}>{fmt(it.amount, it.currency)}</Text></Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<Divider />
<Group justify="space-between"><Text size="sm" c="dimmed">Subtotal</Text><Text>{fmt(inv.subtotalAmount, inv.currency)}</Text></Group>
<Group justify="space-between"><Text size="sm" c="dimmed">Tax</Text><Text>{fmt(inv.taxAmount, inv.currency)}</Text></Group>
<Group justify="space-between"><Text fw={700}>Total</Text><Text fw={700}>{fmt(inv.totalAmount, inv.currency)}</Text></Group>
<Group justify="space-between"><Text size="sm" c="dimmed">Paid</Text><Text>{fmt(inv.paidAmount, inv.currency)}</Text></Group>
<Group justify="space-between"><Text fw={600}>Balance</Text><Text fw={600}>{fmt(inv.balanceAmount, inv.currency)}</Text></Group>
{(inv.payments ?? []).length > 0 && (
<>
<Divider label="Payment history" labelPosition="left" />
{(inv.payments ?? []).map((p, i) => (
<Group key={i} justify="space-between">
<Text size="xs" c="dimmed">{fmtDate(p.paidAt)} · {p.method ?? '—'}{p.reference ? ` · ${p.reference}` : ''}</Text>
<Text size="sm">{fmt(p.amount, inv.currency)}</Text>
</Group>
))}
</>
)}
{canPay && (
<>
<Divider label="Record payment" labelPosition="left" />
<Group align="flex-end">
<NumberInput
label="Amount"
min={0}
value={payAmount}
onChange={(v) => setPayAmount(v === '' ? '' : Number(v))}
style={{ flex: 1 }}
/>
<Button color="green" leftSection={<CreditCard size={16} />} loading={pay.isPending} onClick={handlePay}>
Pay
</Button>
</Group>
</>
)}
<Group justify="flex-end" mt="sm">
{inv.status !== 'PAID' && inv.status !== 'CANCELLED' && (
<Button variant="light" color="red" leftSection={<Ban size={16} />} loading={cancel.isPending} onClick={handleCancel}>
Cancel invoice
</Button>
)}
</Group>
</Stack>
)}
</Modal>
);
}

View File

@@ -0,0 +1,85 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button, Card, Center, Container, Group, Loader, Stack, Text, Title } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { Plus } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import {
CreateWarehouseModal,
WarehouseCardView,
WarehouseFilters,
WarehouseTable,
type WarehouseView,
} from '@/components/warehouses';
import { useWarehouses } from '@/hooks/useWarehouses';
import type { Warehouse, WarehouseFilter } from '@/types/warehouse';
export default function WarehouseListPage() {
const navigate = useNavigate();
const [filter, setFilter] = useState<WarehouseFilter>({});
const [view, setView] = useState<WarehouseView>('table');
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Warehouse | null>(null);
const [debouncedSearch] = useDebouncedValue(filter.search, 300);
const queryFilter = useMemo<WarehouseFilter>(
() => ({ ...filter, search: debouncedSearch }),
[filter, debouncedSearch],
);
const { data, isLoading, isError } = useWarehouses(queryFilter);
const warehouses = data ?? [];
const openCreate = () => {
setEditing(null);
setModalOpen(true);
};
const openEdit = (warehouse: Warehouse) => {
setEditing(warehouse);
setModalOpen(true);
};
const openDetail = (warehouse: Warehouse) => navigate(`/dashboard/warehouses/${warehouse.id}`);
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Warehouses' }]} />
<Stack gap="lg" mt="sm">
<Group justify="space-between" align="flex-end">
<div>
<Title order={2}>Warehouses</Title>
<Text c="dimmed" size="sm">
Manage warehouses, yards and zones.
</Text>
</div>
<Button leftSection={<Plus size={16} />} onClick={openCreate}>
Create Warehouse
</Button>
</Group>
<Card withBorder radius="md" padding="lg">
<Stack gap="md">
<WarehouseFilters filter={filter} onChange={setFilter} view={view} onViewChange={setView} />
{isLoading ? (
<Center py="xl">
<Loader />
</Center>
) : isError ? (
<Text c="red" ta="center" py="xl">
Failed to load warehouses.
</Text>
) : view === 'table' ? (
<WarehouseTable warehouses={warehouses} onView={openDetail} onEdit={openEdit} />
) : (
<WarehouseCardView warehouses={warehouses} onView={openDetail} onEdit={openEdit} />
)}
</Stack>
</Card>
</Stack>
<CreateWarehouseModal opened={modalOpen} onClose={() => setModalOpen(false)} warehouse={editing} />
</Container>
);
}

View File

@@ -0,0 +1,285 @@
import { useState } from 'react';
import {
ActionIcon,
Badge,
Button,
Card,
Container,
Group,
Loader,
Modal,
NumberInput,
Select,
Stack,
Table,
Tabs,
Text,
TextInput,
} from '@mantine/core';
import { Plus, Trash2 } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { WarehouseHero } from '@/components/warehouses';
import { useToast } from '@/hooks/use-toast';
import {
useAllocationRules,
useCreateAllocationRule,
useCreateFeeRule,
useDeleteAllocationRule,
useDeleteFeeRule,
useFeeRules,
} from '@/hooks/useWarehouses';
import { FEE_RULE_TYPES, type FeeRuleType } from '@/types/warehouse';
const FREIGHT = [
{ value: 'CONTAINER', label: 'Container' },
{ value: 'BULK', label: 'Bulk' },
];
const TRADE = [
{ value: 'IMPORT', label: 'Import' },
{ value: 'EXPORT', label: 'Export' },
{ value: 'DOMESTIC', label: 'Domestic' },
];
const clean = (s: string) => s.trim() || undefined;
export default function WarehouseRulesPage() {
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Warehouse rules' }]} />
<Stack gap="lg" mt="sm">
<WarehouseHero
variant="warehouse"
secondaryVariant="container"
title="Allocation & Fee Rules"
subtitle="Configure deterministic yard allocation and storage / demurrage free time and rates."
/>
<Card withBorder radius="md" padding="lg">
<Tabs defaultValue="allocation">
<Tabs.List>
<Tabs.Tab value="allocation">Allocation Rules</Tabs.Tab>
<Tabs.Tab value="fees">Storage / Demurrage Fees</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="allocation" pt="md">
<AllocationRules />
</Tabs.Panel>
<Tabs.Panel value="fees" pt="md">
<FeeRules />
</Tabs.Panel>
</Tabs>
</Card>
</Stack>
</Container>
);
}
function AllocationRules() {
const { toast } = useToast();
const { data, isLoading } = useAllocationRules();
const create = useCreateAllocationRule();
const remove = useDeleteAllocationRule();
const [open, setOpen] = useState(false);
const [form, setForm] = useState({
name: '',
priority: 100,
freightType: '',
tradeDirection: '',
cargoTypeCode: '',
containerStatus: '',
targetYardCode: '',
storageType: '',
});
const rules = data ?? [];
const submit = async () => {
if (!form.name.trim() || !form.targetYardCode.trim()) {
toast({ variant: 'destructive', title: 'Name and target yard code are required' });
return;
}
await create.mutateAsync({
name: form.name.trim(),
priority: form.priority,
freightType: clean(form.freightType) ?? null,
tradeDirection: clean(form.tradeDirection) ?? null,
cargoTypeCode: clean(form.cargoTypeCode) ?? null,
containerStatus: clean(form.containerStatus) ?? null,
targetYardCode: form.targetYardCode.trim(),
storageType: clean(form.storageType) ?? null,
isActive: true,
} as never);
toast({ title: 'Allocation rule created' });
setOpen(false);
setForm({ name: '', priority: 100, freightType: '', tradeDirection: '', cargoTypeCode: '', containerStatus: '', targetYardCode: '', storageType: '' });
};
return (
<>
<Group justify="space-between" mb="sm">
<Text c="dimmed" size="sm">{rules.length} rule(s) matched by ascending priority</Text>
<Button color="orange" leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>New allocation rule</Button>
</Group>
{isLoading ? (
<Group justify="center" py="xl"><Loader /></Group>
) : (
<Table.ScrollContainer minWidth={900}>
<Table striped highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Priority</Table.Th><Table.Th>Name</Table.Th><Table.Th>Freight</Table.Th>
<Table.Th>Trade</Table.Th><Table.Th>Cargo code</Table.Th><Table.Th>Target yard</Table.Th>
<Table.Th>Active</Table.Th><Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rules.map((r) => (
<Table.Tr key={r.id}>
<Table.Td>{r.priority}</Table.Td>
<Table.Td>{r.name}</Table.Td>
<Table.Td>{r.freightType ?? '—'}</Table.Td>
<Table.Td>{r.tradeDirection ?? '—'}</Table.Td>
<Table.Td>{r.cargoTypeCode ?? '—'}</Table.Td>
<Table.Td><Badge variant="light">{r.targetYardCode}</Badge></Table.Td>
<Table.Td><Badge color={r.isActive ? 'green' : 'gray'} variant="light">{r.isActive ? 'Yes' : 'No'}</Badge></Table.Td>
<Table.Td ta="right">
<ActionIcon variant="subtle" color="red" onClick={() => remove.mutate(r.id)} title="Delete">
<Trash2 size={16} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
<Modal opened={open} onClose={() => setOpen(false)} title="New allocation rule" centered size="lg">
<Stack gap="sm">
<Group grow>
<TextInput label="Name" required value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))} />
<NumberInput label="Priority" value={form.priority} onChange={(v) => setForm((f) => ({ ...f, priority: Number(v) || 100 }))} />
</Group>
<Group grow>
<Select label="Freight type" data={FREIGHT} value={form.freightType || null} onChange={(v) => setForm((f) => ({ ...f, freightType: v ?? '' }))} clearable />
<Select label="Trade direction" data={TRADE} value={form.tradeDirection || null} onChange={(v) => setForm((f) => ({ ...f, tradeDirection: v ?? '' }))} clearable />
</Group>
<Group grow>
<TextInput label="Cargo type code" value={form.cargoTypeCode} onChange={(e) => setForm((f) => ({ ...f, cargoTypeCode: e.currentTarget.value }))} />
<TextInput label="Container status" placeholder="e.g. MAINTENANCE" value={form.containerStatus} onChange={(e) => setForm((f) => ({ ...f, containerStatus: e.currentTarget.value }))} />
</Group>
<Group grow>
<TextInput label="Target yard code" required value={form.targetYardCode} onChange={(e) => setForm((f) => ({ ...f, targetYardCode: e.currentTarget.value }))} />
<TextInput label="Storage type" value={form.storageType} onChange={(e) => setForm((f) => ({ ...f, storageType: e.currentTarget.value }))} />
</Group>
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={() => setOpen(false)}>Cancel</Button>
<Button color="orange" loading={create.isPending} onClick={submit}>Create</Button>
</Group>
</Stack>
</Modal>
</>
);
}
function FeeRules() {
const { toast } = useToast();
const { data, isLoading } = useFeeRules();
const create = useCreateFeeRule();
const remove = useDeleteFeeRule();
const [open, setOpen] = useState(false);
const [form, setForm] = useState({
name: '',
ruleType: 'DEMURRAGE_FEE' as FeeRuleType,
freightType: '',
tradeDirection: '',
cargoTypeCode: '',
freeDays: 3,
ratePerDay: 0,
currency: 'USD',
});
const rules = data ?? [];
const submit = async () => {
if (!form.name.trim()) {
toast({ variant: 'destructive', title: 'Name is required' });
return;
}
await create.mutateAsync({
name: form.name.trim(),
ruleType: form.ruleType,
freightType: clean(form.freightType) ?? null,
tradeDirection: clean(form.tradeDirection) ?? null,
cargoTypeCode: clean(form.cargoTypeCode) ?? null,
freeDays: form.freeDays,
ratePerDay: form.ratePerDay,
currency: form.currency || 'USD',
isActive: true,
} as never);
toast({ title: 'Fee rule created' });
setOpen(false);
};
return (
<>
<Group justify="space-between" mb="sm">
<Text c="dimmed" size="sm">{rules.length} rule(s) most specific match applies</Text>
<Button color="teal" leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>New fee rule</Button>
</Group>
{isLoading ? (
<Group justify="center" py="xl"><Loader /></Group>
) : (
<Table.ScrollContainer minWidth={900}>
<Table striped highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Type</Table.Th><Table.Th>Name</Table.Th><Table.Th>Freight</Table.Th>
<Table.Th>Trade</Table.Th><Table.Th>Free days</Table.Th><Table.Th>Rate / day</Table.Th>
<Table.Th>Active</Table.Th><Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rules.map((r) => (
<Table.Tr key={r.id}>
<Table.Td><Badge color={r.ruleType === 'DEMURRAGE_FEE' ? 'orange' : 'teal'} variant="light">{r.ruleType === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage'}</Badge></Table.Td>
<Table.Td>{r.name}</Table.Td>
<Table.Td>{r.freightType ?? '—'}</Table.Td>
<Table.Td>{r.tradeDirection ?? '—'}</Table.Td>
<Table.Td>{r.freeDays}</Table.Td>
<Table.Td>{Number(r.ratePerDay).toLocaleString()} {r.currency}</Table.Td>
<Table.Td><Badge color={r.isActive ? 'green' : 'gray'} variant="light">{r.isActive ? 'Yes' : 'No'}</Badge></Table.Td>
<Table.Td ta="right">
<ActionIcon variant="subtle" color="red" onClick={() => remove.mutate(r.id)} title="Delete">
<Trash2 size={16} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
<Modal opened={open} onClose={() => setOpen(false)} title="New fee rule" centered size="lg">
<Stack gap="sm">
<Group grow>
<TextInput label="Name" required value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))} />
<Select label="Rule type" data={FEE_RULE_TYPES.map((t) => ({ value: t, label: t === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage' }))} value={form.ruleType} onChange={(v) => setForm((f) => ({ ...f, ruleType: (v as FeeRuleType) ?? 'DEMURRAGE_FEE' }))} allowDeselect={false} />
</Group>
<Group grow>
<Select label="Freight type" data={FREIGHT} value={form.freightType || null} onChange={(v) => setForm((f) => ({ ...f, freightType: v ?? '' }))} clearable />
<Select label="Trade direction" data={TRADE} value={form.tradeDirection || null} onChange={(v) => setForm((f) => ({ ...f, tradeDirection: v ?? '' }))} clearable />
<TextInput label="Cargo type code" value={form.cargoTypeCode} onChange={(e) => setForm((f) => ({ ...f, cargoTypeCode: e.currentTarget.value }))} />
</Group>
<Group grow>
<NumberInput label="Free days" min={0} value={form.freeDays} onChange={(v) => setForm((f) => ({ ...f, freeDays: Number(v) || 0 }))} />
<NumberInput label="Rate / day" min={0} value={form.ratePerDay} onChange={(v) => setForm((f) => ({ ...f, ratePerDay: Number(v) || 0 }))} />
<TextInput label="Currency" value={form.currency} onChange={(e) => setForm((f) => ({ ...f, currency: e.currentTarget.value }))} />
</Group>
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={() => setOpen(false)}>Cancel</Button>
<Button color="teal" loading={create.isPending} onClick={submit}>Create</Button>
</Group>
</Stack>
</Modal>
</>
);
}

View File

@@ -15,6 +15,15 @@ export interface Cargo {
status: 'PENDING' | 'LOADED' | 'IN_TRANSIT' | 'DELIVERED' | 'UNLOADED';
loadedAt?: string;
unloadedAt?: string;
receiverName?: string | null;
deliveredAt?: string | null;
deliveryRemarks?: string | null;
}
export interface DeliverCargoPayload {
receiverName?: string;
pickupDate?: string;
deliveryRemarks?: string;
}
export const cargoService = {
@@ -26,6 +35,7 @@ export const cargoService = {
delete: (id: string) => apiClient.delete(`/cargoes/${id}`),
load: (cargoId: string, quantity: number, weight: number, volume?: number) =>
apiClient.post(`/cargoes/${cargoId}/load`, { quantity, weight, volume }),
deliver: (cargoId: string) => apiClient.post(`/cargoes/${cargoId}/deliver`),
deliver: (cargoId: string, payload?: DeliverCargoPayload) =>
apiClient.post(`/cargoes/${cargoId}/deliver`, payload ?? {}),
unload: (cargoId: string) => apiClient.post(`/cargoes/${cargoId}/unload`),
};

View File

@@ -0,0 +1,9 @@
import { api as apiClient } from '../auth/http';
import { URL_CONSTANTS } from '@/constants/URLS';
import type { Facility } from '@/types/warehouse';
export const facilityService = {
list: () => apiClient.get<Facility[]>(URL_CONSTANTS.FACILITIES.BASE),
getById: (id: string) => apiClient.get<Facility>(URL_CONSTANTS.FACILITIES.BY_ID(id)),
};

View File

@@ -5,6 +5,9 @@ import { URL_CONSTANTS } from '@/constants/URLS';
export type LocomotiveType = 'DIESEL' | 'ELECTRIC';
export type LocomotiveStatus =
| 'AVAILABLE'
| 'UNAVAILABLE'
| 'IMPORT_READY'
| 'EXPORT_READY'
| 'MAINTENANCE'
| 'ASSIGNED'
| 'OUT_OF_SERVICE';

View File

@@ -8,6 +8,19 @@ export interface Wagon {
wagonTypeId: string;
trainId: string | null;
sequenceNumber: number | null;
currentLocationYardId: string | null;
currentLocationYard?: {
id: string;
code: string;
label: string;
country?: string;
} | null;
wagonType?: {
id: string;
code: string;
name: string;
supportedLoadTypes?: string[];
} | null;
tareWeight: number;
maxPayloadWeight: number;
status: Freight.WagonStatus;

View File

@@ -0,0 +1,199 @@
import { api as apiClient } from '../auth/http';
import { URL_CONSTANTS } from '@/constants/URLS';
import type {
AllocationCriteria,
AllocationPreviewResult,
AllocationRule,
ArrivalQueueItem,
AutoLoadResult,
AutoUnloadResult,
FeePreview,
FeeRule,
InspectionAttachment,
InspectionReport,
InspectionReportPayload,
SaveAllocationRulePayload,
SaveFeeRulePayload,
WarehouseFeeInvoice,
WarehouseInvoiceFilter,
PayInvoicePayload,
BookingScheduleView,
InventoryFilter,
InventoryInquiryFilter,
InventoryInquiryResult,
InventoryMovement,
LoadableWagon,
LoadInventoryPayload,
MoveInventoryPayload,
ReceiveInventoryPayload,
ReserveInventoryPayload,
SaveWarehousePayload,
SaveYardPayload,
SaveZonePayload,
Warehouse,
WarehouseActivityLog,
WarehouseDashboard,
WarehouseFilter,
WarehouseInventoryItem,
WarehouseLoading,
WarehouseYard,
WarehouseZone,
} from '@/types/warehouse';
const cleanParams = (params: object) =>
Object.fromEntries(
Object.entries(params).filter(([, value]) => value !== undefined && value !== '' && value !== null),
);
export const warehouseService = {
// ── Warehouses ──────────────────────────────────────────────────────────
list: (filter?: WarehouseFilter) =>
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),
update: (id: string, payload: Partial<SaveWarehousePayload>) =>
apiClient.patch<Warehouse>(URL_CONSTANTS.WAREHOUSES.BY_ID(id), payload),
// ── Yards ────────────────────────────────────────────────────────────────
listYards: (warehouseId: string) =>
apiClient.get<WarehouseYard[]>(URL_CONSTANTS.WAREHOUSES.YARDS(warehouseId)),
createYard: (warehouseId: string, payload: SaveYardPayload) =>
apiClient.post<WarehouseYard>(URL_CONSTANTS.WAREHOUSES.YARDS(warehouseId), payload),
getYard: (id: string) => apiClient.get<WarehouseYard>(URL_CONSTANTS.WAREHOUSE_YARDS.BY_ID(id)),
updateYard: (id: string, payload: Partial<SaveYardPayload>) =>
apiClient.patch<WarehouseYard>(URL_CONSTANTS.WAREHOUSE_YARDS.BY_ID(id), payload),
// ── Zones ──────────────────────────────────────────────────────────────
listZones: (yardId: string) =>
apiClient.get<WarehouseZone[]>(URL_CONSTANTS.WAREHOUSE_YARDS.ZONES(yardId)),
createZone: (yardId: string, payload: SaveZonePayload) =>
apiClient.post<WarehouseZone>(URL_CONSTANTS.WAREHOUSE_YARDS.ZONES(yardId), payload),
getZone: (id: string) => apiClient.get<WarehouseZone>(URL_CONSTANTS.WAREHOUSE_ZONES.BY_ID(id)),
updateZone: (id: string, payload: Partial<SaveZonePayload>) =>
apiClient.patch<WarehouseZone>(URL_CONSTANTS.WAREHOUSE_ZONES.BY_ID(id), payload),
// ── Inventory ──────────────────────────────────────────────────────────
listInventory: (filter?: InventoryFilter) =>
apiClient.get<WarehouseInventoryItem[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.BASE, {
params: cleanParams(filter ?? {}),
}),
receiveInventory: (payload: ReceiveInventoryPayload) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RECEIVE, payload),
listReadyForLoading: (filter?: InventoryFilter) =>
apiClient.get<WarehouseInventoryItem[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.READY_FOR_LOADING, {
params: cleanParams(filter ?? {}),
}),
inquiry: (filter: InventoryInquiryFilter) =>
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, payload: LoadInventoryPayload) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.LOAD(id), payload),
dispatch: (id: string) =>
apiClient.patch<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DISPATCH(id)),
move: (id: string, payload: MoveInventoryPayload) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.MOVE(id), payload),
movements: (id: string) =>
apiClient.get<InventoryMovement[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.MOVEMENTS(id)),
activity: (id: string) =>
apiClient.get<WarehouseActivityLog[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.ACTIVITY(id)),
// ── Loading (Batch 3) ─────────────────────────────────────────────────────
loadableWagons: () =>
apiClient.get<LoadableWagon[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.LOADABLE_WAGONS),
bookingSchedule: (bookingId: string) =>
apiClient.get<BookingScheduleView>(URL_CONSTANTS.WAREHOUSE_INVENTORY.BOOKING_SCHEDULE(bookingId)),
inventoryLoadings: (id: string) =>
apiClient.get<WarehouseLoading[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.LOADINGS(id)),
loadings: (params?: { bookingId?: string; wagonId?: string }) =>
apiClient.get<WarehouseLoading[]>(URL_CONSTANTS.WAREHOUSE_LOADINGS.BASE, {
params: cleanParams(params ?? {}),
}),
// ── Batch 4.5: Arrival / Unload / Load automation ──────────────────────────
arrivalQueue: () =>
apiClient.get<ArrivalQueueItem[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.ARRIVAL_QUEUE),
autoUnloadArrived: () =>
apiClient.post<AutoUnloadResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.AUTO_UNLOAD_ARRIVED),
autoLoadReady: () =>
apiClient.post<AutoLoadResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.AUTO_LOAD_READY),
unloadBooking: (bookingId: string, payload?: Record<string, unknown>) =>
apiClient.post<WarehouseInventoryItem>(
URL_CONSTANTS.WAREHOUSE_INVENTORY.UNLOAD_BOOKING(bookingId),
payload ?? {},
),
// ── Batch 4.5: Inspection reports ──────────────────────────────────────────
listInspectionReports: (inventoryId: string) =>
apiClient.get<InspectionReport[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.INSPECTION_REPORTS(inventoryId)),
createInspectionReport: (inventoryId: string, payload: InspectionReportPayload) =>
apiClient.post<InspectionReport>(
URL_CONSTANTS.WAREHOUSE_INVENTORY.INSPECTION_REPORTS(inventoryId),
payload,
),
getInspectionReport: (id: string) =>
apiClient.get<InspectionReport>(URL_CONSTANTS.WAREHOUSE_INSPECTION.BY_ID(id)),
uploadInspectionAttachments: (id: string, files: File[]) => {
const form = new FormData();
files.forEach((file) => form.append('files', file));
return apiClient.post<InspectionAttachment[]>(
URL_CONSTANTS.WAREHOUSE_INSPECTION.ATTACHMENTS(id),
form,
{ headers: { 'Content-Type': 'multipart/form-data' } },
);
},
// ── Batch 5: Allocation + Fee rules / previews ─────────────────────────────
listAllocationRules: () =>
apiClient.get<AllocationRule[]>(URL_CONSTANTS.WAREHOUSE_RULES.ALLOCATION),
createAllocationRule: (payload: SaveAllocationRulePayload) =>
apiClient.post<AllocationRule>(URL_CONSTANTS.WAREHOUSE_RULES.ALLOCATION, payload),
updateAllocationRule: (id: string, payload: Partial<SaveAllocationRulePayload>) =>
apiClient.patch<AllocationRule>(URL_CONSTANTS.WAREHOUSE_RULES.ALLOCATION_BY_ID(id), payload),
deleteAllocationRule: (id: string) =>
apiClient.delete(URL_CONSTANTS.WAREHOUSE_RULES.ALLOCATION_BY_ID(id)),
previewAllocation: (criteria: AllocationCriteria) =>
apiClient.post<AllocationPreviewResult | null>(URL_CONSTANTS.WAREHOUSE_RULES.ALLOCATION_PREVIEW, criteria),
listFeeRules: () => apiClient.get<FeeRule[]>(URL_CONSTANTS.WAREHOUSE_RULES.FEES),
createFeeRule: (payload: SaveFeeRulePayload) =>
apiClient.post<FeeRule>(URL_CONSTANTS.WAREHOUSE_RULES.FEES, payload),
updateFeeRule: (id: string, payload: Partial<SaveFeeRulePayload>) =>
apiClient.patch<FeeRule>(URL_CONSTANTS.WAREHOUSE_RULES.FEES_BY_ID(id), payload),
deleteFeeRule: (id: string) => apiClient.delete(URL_CONSTANTS.WAREHOUSE_RULES.FEES_BY_ID(id)),
feePreview: (inventoryId: string) =>
apiClient.get<FeePreview[]>(URL_CONSTANTS.WAREHOUSE_RULES.FEE_PREVIEW(inventoryId)),
// ── Batch 6: Warehouse fee invoices ────────────────────────────────────────
listInvoices: (filter?: WarehouseInvoiceFilter) =>
apiClient.get<WarehouseFeeInvoice[]>(URL_CONSTANTS.WAREHOUSE_INVOICES.BASE, {
params: cleanParams(filter ?? {}),
}),
getInvoice: (id: string) =>
apiClient.get<WarehouseFeeInvoice>(URL_CONSTANTS.WAREHOUSE_INVOICES.BY_ID(id)),
invoicesForInventory: (inventoryId: string) =>
apiClient.get<WarehouseFeeInvoice[]>(URL_CONSTANTS.WAREHOUSE_INVOICES.FOR_INVENTORY(inventoryId)),
invoicesForBooking: (bookingId: string) =>
apiClient.get<WarehouseFeeInvoice[]>(URL_CONSTANTS.WAREHOUSE_INVOICES.FOR_BOOKING(bookingId)),
generateInvoice: (inventoryId: string, confirmZero = false) =>
apiClient.post<WarehouseFeeInvoice>(URL_CONSTANTS.WAREHOUSE_INVOICES.GENERATE(inventoryId), { confirmZero }),
cancelInvoice: (id: string) =>
apiClient.patch<WarehouseFeeInvoice>(URL_CONSTANTS.WAREHOUSE_INVOICES.CANCEL(id)),
payInvoice: (id: string, payload: PayInvoicePayload) =>
apiClient.post<WarehouseFeeInvoice>(URL_CONSTANTS.WAREHOUSE_INVOICES.PAY(id), payload),
gateClearance: (inventoryId: string) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVOICES.GATE_CLEARANCE(inventoryId), {}),
};

View File

@@ -6,6 +6,9 @@ export const BOOKING_STATUSES = [
"PENDING_APPROVAL",
"APPROVED_PENDING_SIGNATURE",
"APPROVED",
"READY_FOR_ASSIGNMENT",
"WAGON_ASSIGNED",
"INVOICED",
"CONTRACT_READY",
"SIGNED_CUSTOMER",
"FULLY_EXECUTED",

View File

@@ -0,0 +1,596 @@
export const WAREHOUSE_TYPES = ['OPEN_WAREHOUSE', 'CLOSED_WAREHOUSE'] as const;
export type WarehouseType = (typeof WAREHOUSE_TYPES)[number];
export const WAREHOUSE_STATUSES = ['ACTIVE', 'INACTIVE'] as const;
export type WarehouseStatus = (typeof WAREHOUSE_STATUSES)[number];
export const WAREHOUSE_YARD_TYPES = [
'CONTAINER_YARD',
'BULK_YARD',
'GENERAL_CARGO_YARD',
'HAZARDOUS_YARD',
'COLD_STORAGE_YARD',
] as const;
export type WarehouseYardType = (typeof WAREHOUSE_YARD_TYPES)[number];
export const WAREHOUSE_ZONE_TYPES = [
'CONTAINER_ZONE',
'BULK_ZONE',
'GENERAL_CARGO_ZONE',
'HAZARDOUS_ZONE',
'COLD_STORAGE_ZONE',
] as const;
export type WarehouseZoneType = (typeof WAREHOUSE_ZONE_TYPES)[number];
export const INVENTORY_STATUSES = [
'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;
name: string;
code: string;
type: WarehouseZoneType;
capacityWeight: number | null;
capacityContainers: number | null;
maxWeight: number | null;
maxVolume: number | null;
currentWeight: number;
currentContainers: number;
currentVolume: number;
status: WarehouseStatus;
isActive: boolean;
}
export interface WarehouseYard {
id: string;
warehouseId: string;
name: string;
code: string;
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[];
}
export const FACILITY_TYPES = [
'PORT',
'DRY_PORT',
'TERMINAL',
'RAIL_YARD',
'WAREHOUSE_COMPLEX',
] as const;
export type FacilityType = (typeof FACILITY_TYPES)[number];
export interface Facility {
id: string;
code: string;
name: string;
facilityType: FacilityType;
facilityStatus?: string;
locationName?: string | null;
city?: string | null;
country?: string | null;
isActive?: boolean;
}
export interface Warehouse {
id: string;
name: string;
code: string;
type: WarehouseType;
stationId: string | null;
facilityId: string | null;
facility?: Facility | null;
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[];
createdAt?: string;
updatedAt?: string;
}
export interface WarehouseInventoryItem {
id: string;
warehouseId: string;
yardId: string;
zoneId: string;
bookingId: string | null;
cargoId: string | null;
containerId: string | null;
goodsId: string | null;
quantity: number;
weight: number;
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;
booking?: InventoryBookingRef | null;
}
/** Slim booking shape returned alongside inventory for the loading queue. */
export interface InventoryBookingRef {
id: string;
reference?: string | null;
status?: string | null;
paymentStatus?: string | 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;
}
// ── Loading (Batch 3) ────────────────────────────────────────────────────────
export interface WarehouseLoading {
id: string;
warehouseInventoryId: string;
bookingId: string | null;
wagonId: string;
wagonNumber?: string | null;
loadedAt: string;
loadedBy: string | null;
loadedWeight: number | null;
notes: string | null;
inventory?: WarehouseInventoryItem | null;
}
export interface LoadInventoryPayload {
wagonId: string;
loadedWeight?: number;
loadedBy?: string;
notes?: string;
}
/** Read-only wagon view exposed by the scheduling facade. */
export interface LoadableWagon {
id: string;
wagonNumber: string;
status: string;
trainId: string | null;
}
/** Read-only schedule + wagon + departure status for a booking. */
export interface BookingScheduleView {
schedule: {
id: string;
status: string;
scheduledDepartureDate: string | null;
scheduledArrivalDate: string | null;
originStationId: string | null;
destinationStationId: string | null;
} | null;
wagon: {
wagonId: string | null;
wagonNumber: string | null;
sequenceNo: number | null;
allocatedWeightTons: number | null;
} | null;
departureStatus: string | null;
}
export interface MoveInventoryPayload {
warehouseId: string;
yardId: string;
zoneId: string;
remarks?: string;
}
export interface ReserveInventoryPayload {
bookingId: string;
inventoryId: string;
}
export interface InventoryInquiryResult {
id: string;
bookingId: string;
bookingNumber: string | null;
customerName: string | null;
containerNumber: string | null;
cargoType: string | null;
cargoDescription: string | null;
goodsId: string | null;
warehouse: { id: string; name: string; code: string } | null;
yard: { id: string; name: string; code: string } | null;
zone: { id: string; name: string; code: string } | null;
status: InventoryStatus;
quantity: number;
weight: number;
arrivedAt: string | null;
readyForLoadingAt: string | null;
}
// ── Batch 4.5: Arrival / Unload / Inspection ────────────────────────────────
export interface ArrivalQueueItem {
bookingId: string;
bookingReference: string;
customer: string | null;
cargo: string | null;
container: string | null;
facility: string | null;
warehouse: string | null;
yard: string | null;
zone: string | null;
inventoryId: string | null;
currentStatus: string | null;
arrivalDate: string | null;
inspectionStatus: string | null;
unloaded: boolean;
}
export interface AutoUnloadResult {
processedCount: number;
skippedCount: number;
failedCount: number;
results: { bookingId: string; inventoryId?: string; status: string; reason?: string }[];
}
export interface AutoLoadResult {
loadedCount: number;
skippedCount: number;
results: { inventoryId: string; status: string; reason?: string }[];
}
export const INSPECTION_REPORT_TYPES = [
'INSPECTION',
'DAMAGE',
'WEIGHT_LOSS',
'MISSING_ITEM',
'GENERAL',
] as const;
export type InspectionReportType = (typeof INSPECTION_REPORT_TYPES)[number];
export const INSPECTION_STATUSES = ['PASSED', 'FAILED', 'NEEDS_REVIEW'] as const;
export type InspectionResultStatus = (typeof INSPECTION_STATUSES)[number];
export interface InspectionReportPayload {
reportType: InspectionReportType;
inspectionStatus: InspectionResultStatus;
hasDamage?: boolean;
damageDescription?: string;
hasWeightLoss?: boolean;
expectedWeight?: number;
actualWeight?: number;
hasMissingItems?: boolean;
missingItemsDescription?: string;
remarks?: string;
}
export interface InspectionAttachment {
id: string;
name: string;
url: string;
mimeType: string;
size: number;
}
export interface InspectionReport extends InspectionReportPayload {
id: string;
inventoryId: string;
bookingId: string | null;
weightLoss?: number | null;
inspectedAt: string | null;
createdAt: string;
attachments?: InspectionAttachment[];
}
// ── Batch 5: Allocation + Fee rules / preview ───────────────────────────────
export interface AllocationRule {
id: string;
name: string;
priority: number;
freightType?: string | null;
tradeDirection?: string | null;
cargoTypeCode?: string | null;
containerStatus?: string | null;
requiresInspection?: boolean | null;
targetFacilityCode?: string | null;
targetYardCode: string;
targetWarehouseCode?: string | null;
targetZoneCode?: string | null;
storageType?: string | null;
isActive: boolean;
}
export type SaveAllocationRulePayload = Omit<AllocationRule, 'id'>;
export const FEE_RULE_TYPES = ['STORAGE_FEE', 'DEMURRAGE_FEE'] as const;
export type FeeRuleType = (typeof FEE_RULE_TYPES)[number];
export interface FeeRule {
id: string;
name: string;
ruleType: FeeRuleType;
priority: number;
freightType?: string | null;
tradeDirection?: string | null;
cargoTypeCode?: string | null;
containerType?: string | null;
facilityId?: string | null;
warehouseId?: string | null;
yardId?: string | null;
zoneId?: string | null;
freeDays: number;
ratePerDay: number;
currency: string;
isActive: boolean;
}
export type SaveFeeRulePayload = Omit<FeeRule, 'id'>;
export interface FeePreview {
ruleType: FeeRuleType;
ruleId: string | null;
ruleName: string | null;
freeDays: number;
ratePerDay: number;
currency: string;
startDate: string | null;
endDate: string;
endIsOpen: boolean;
elapsedDays: number;
chargeableDays: number;
amount: number;
}
export interface AllocationPreviewResult {
warehouseId: string;
yardId: string;
zoneId: string;
facilityId: string | null;
rule: { id: string; name: string; storageType: string | null } | null;
path: string;
}
export interface AllocationCriteria {
freightType?: string;
tradeDirection?: string;
cargoTypeCode?: string;
containerStatus?: string;
requiresInspection?: boolean;
}
// ── Batch 6: Warehouse fee invoices ─────────────────────────────────────────
export const WAREHOUSE_INVOICE_STATUSES = [
'DRAFT',
'ISSUED',
'PARTIALLY_PAID',
'PAID',
'CANCELLED',
] as const;
export type WarehouseInvoiceStatus = (typeof WAREHOUSE_INVOICE_STATUSES)[number];
export const WAREHOUSE_INVOICE_TYPES = ['DEMURRAGE', 'STORAGE_FEE', 'MIXED_WAREHOUSE_FEES'] as const;
export type WarehouseInvoiceType = (typeof WAREHOUSE_INVOICE_TYPES)[number];
export interface WarehouseInvoicePaymentRecord {
amount: number;
method?: string | null;
reference?: string | null;
paidAt: string;
}
export interface WarehouseFeeInvoiceItem {
id: string;
invoiceId: string;
feeRuleId?: string | null;
feeType: string;
description: string;
quantity: number;
unitRate: number;
amount: number;
currency: string;
chargeableDays?: number | null;
freeDays?: number | null;
}
export interface WarehouseFeeInvoice {
id: string;
invoiceNumber: string;
bookingId?: string | null;
customerId?: string | null;
inventoryId: string;
facilityId?: string | null;
warehouseId?: string | null;
yardId?: string | null;
zoneId?: string | null;
invoiceType: WarehouseInvoiceType;
status: WarehouseInvoiceStatus;
subtotalAmount: number;
taxAmount: number;
totalAmount: number;
paidAmount: number;
balanceAmount: number;
currency: string;
periodStart?: string | null;
periodEnd?: string | null;
issuedAt?: string | null;
dueDate?: string | null;
paidAt?: string | null;
cancelledAt?: string | null;
payments?: WarehouseInvoicePaymentRecord[];
notes?: string | null;
items?: WarehouseFeeInvoiceItem[];
}
export interface WarehouseInvoiceFilter {
status?: WarehouseInvoiceStatus;
invoiceType?: WarehouseInvoiceType;
warehouseId?: string;
facilityId?: string;
customerId?: string;
bookingId?: string;
}
export interface PayInvoicePayload {
amount: number;
method?: string;
reference?: string;
}
// ── Payloads ───────────────────────────────────────────────────────────────
export interface SaveWarehousePayload {
name: string;
code: string;
type: WarehouseType;
stationId?: string;
facilityId?: string;
locationName?: string;
capacityWeight?: number;
capacityContainers?: number;
maxWeight?: number;
maxVolume?: number;
status?: WarehouseStatus;
}
export interface SaveYardPayload {
name: string;
code: string;
type: WarehouseYardType;
capacityWeight?: number;
capacityContainers?: number;
maxWeight?: number;
maxVolume?: number;
status?: WarehouseStatus;
}
export interface SaveZonePayload {
name: string;
code: string;
type: WarehouseZoneType;
capacityWeight?: number;
capacityContainers?: number;
maxWeight?: number;
maxVolume?: number;
status?: WarehouseStatus;
}
export interface ReceiveInventoryPayload {
warehouseId: string;
yardId: string;
zoneId: string;
bookingId: string;
cargoId?: string;
containerId?: string;
goodsId?: string;
quantity: number;
weight: number;
volume?: number;
notes?: string;
}
export interface WarehouseFilter {
search?: string;
type?: WarehouseType;
stationId?: string;
status?: WarehouseStatus;
}
export interface InventoryFilter {
warehouseId?: string;
yardId?: string;
zoneId?: string;
bookingId?: string;
cargoId?: string;
containerId?: string;
goodsId?: string;
status?: InventoryStatus;
search?: string;
}
export interface InventoryInquiryFilter {
bookingNumber?: string;
containerNumber?: string;
cargoType?: string;
goodsName?: string;
warehouseId?: string;
yardId?: string;
zoneId?: string;
status?: InventoryStatus;
}