feat(warehouse): add warehouse management module (batch 1)

Backend (edr-freight-api):
- Warehouse, WarehouseYard, WarehouseZone, WarehouseInventory entities
- CRUD for warehouses/yards/zones with scoped code uniqueness
- Inventory receive with location-hierarchy + capacity validation and
  transactional capacity-counter updates
- inspect / ready-for-loading status transitions
- inventory inquiry (booking/customer/container/cargo-type joins)
- migration creating freight.warehouse* tables

Frontend (freight backoffice):
- types, service, react-query hooks, URL constants
- reusable components: badges, filters, table/card views, inventory and
  inquiry tables, create/receive modals
- pages: Warehouse list, detail (overview/yards/zones/inventory tabs),
  inventory, inventory inquiry
- Warehouse Information card + Receive At Warehouse on booking detail
- routes + sidebar nav; app-wide ErrorBoundary

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-06-11 15:54:56 +00:00
parent 7facbeda22
commit c86118cd8d
54 changed files with 4257 additions and 1 deletions

View File

@@ -45,6 +45,10 @@ import {
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
import TrainDetailPage from "./pages/trains/TrainDetailPage";
import RoutesPage from "./pages/fleet/RoutesPage";
import WarehouseListPage from "./pages/warehouses/WarehouseListPage";
import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage";
import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage";
import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage";
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
@@ -114,6 +118,26 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
},
],
},
{
title: "Warehouse Management",
items: [
{
label: "Warehouses",
href: "/dashboard/warehouses",
icon: <Container />,
},
{
label: "Inventory",
href: "/dashboard/warehouse-inventory",
icon: <Package />,
},
{
label: "Inventory Inquiry",
href: "/dashboard/inventory-inquiry",
icon: <Boxes />,
},
],
},
{
title: "Administration",
items: [
@@ -256,6 +280,11 @@ const App = () => {
<Route path="containers" element={<ContainersCrudPage />} />
<Route path="cargoes" element={<CargoesCrudPage />} />
<Route path="warehouses" element={<WarehouseListPage />} />
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
<Route path="user-management" element={<UserManagementPage />} />
<Route path="user-management/users" element={<UsersPage />} />
<Route path="user-management/position-types" element={<PositionTypesPage />} />

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,173 @@
import { useEffect, useState } from 'react';
import {
Button,
Group,
Modal,
NumberInput,
Select,
Stack,
TextInput,
} from '@mantine/core';
import { useToast } from '@/hooks/use-toast';
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;
locationName: string;
capacityWeight: number | '';
capacityContainers: number | '';
status: 'ACTIVE' | 'INACTIVE';
}
const emptyForm = (): FormState => ({
name: '',
code: '',
type: 'OPEN_WAREHOUSE',
locationName: '',
capacityWeight: '',
capacityContainers: '',
status: 'ACTIVE',
});
export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWarehouseModalProps) {
const isEdit = Boolean(warehouse);
const { toast } = useToast();
const createMutation = useCreateWarehouse();
const updateMutation = useUpdateWarehouse();
const [form, setForm] = useState<FormState>(emptyForm());
useEffect(() => {
if (opened) {
setForm(
warehouse
? {
name: warehouse.name,
code: warehouse.code,
type: warehouse.type,
locationName: warehouse.locationName ?? '',
capacityWeight: warehouse.capacityWeight ?? '',
capacityContainers: warehouse.capacityContainers ?? '',
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,
locationName: form.locationName.trim() || undefined,
capacityWeight: form.capacityWeight === '' ? undefined : Number(form.capacityWeight),
capacityContainers: form.capacityContainers === '' ? undefined : Number(form.capacityContainers),
};
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) => setForm((f) => ({ ...f, name: e.currentTarget.value }))}
/>
<TextInput
label="Code"
placeholder="MODJO-OW"
required
value={form.code}
onChange={(e) => setForm((f) => ({ ...f, code: e.currentTarget.value }))}
/>
</Group>
<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) => setForm((f) => ({ ...f, locationName: e.currentTarget.value }))}
/>
<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) }))}
/>
</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,155 @@
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 | '';
status: 'ACTIVE' | 'INACTIVE';
}
const emptyForm = (): FormState => ({
name: '',
code: '',
type: 'CONTAINER_YARD',
capacityWeight: '',
capacityContainers: '',
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 ?? '',
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),
};
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) => setForm((f) => ({ ...f, name: e.currentTarget.value }))}
/>
<TextInput
label="Code"
placeholder="CY-A"
required
value={form.code}
onChange={(e) => setForm((f) => ({ ...f, code: e.currentTarget.value }))}
/>
</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) }))}
/>
</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,155 @@
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 | '';
status: 'ACTIVE' | 'INACTIVE';
}
const emptyForm = (): FormState => ({
name: '',
code: '',
type: 'CONTAINER_ZONE',
capacityWeight: '',
capacityContainers: '',
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 ?? '',
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),
};
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) => setForm((f) => ({ ...f, name: e.currentTarget.value }))}
/>
<TextInput
label="Code"
placeholder="A-01"
required
value={form.code}
onChange={(e) => setForm((f) => ({ ...f, code: e.currentTarget.value }))}
/>
</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) }))}
/>
</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,215 @@
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 { 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 />
) : (
<TextInput
label="Booking ID"
placeholder="Booking UUID"
required
value={form.bookingId}
onChange={(e) => setForm((f) => ({ ...f, bookingId: e.currentTarget.value }))}
/>
)}
<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) => setForm((f) => ({ ...f, notes: e.currentTarget.value }))}
/>
<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,75 @@
import { ActionIcon, Card, Group, SimpleGrid, Stack, Text } from '@mantine/core';
import { Eye, MapPin, Pencil } from 'lucide-react';
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) {
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.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,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,89 @@
import { useState } from 'react';
import { Badge, Button, Card, Divider, Group, Stack, Text } from '@mantine/core';
import { PackagePlus, Warehouse as WarehouseIcon } from 'lucide-react';
import { useWarehouseInventory } from '@/hooks/useWarehouses';
import { InventoryStatusBadge } from './badges';
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 items = data ?? [];
const latest = items[0];
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)} />
</Stack>
)}
<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,119 @@
import { Badge, Button, Group, Table, Text, Tooltip } from '@mantine/core';
import { ClipboardCheck, PackageCheck } from 'lucide-react';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import { InventoryStatusBadge } from './badges';
import { formatDate, formatNumber } from './options';
interface WarehouseInventoryTableProps {
items: WarehouseInventoryItem[];
onInspect: (item: WarehouseInventoryItem) => void;
onReadyForLoading: (item: WarehouseInventoryItem) => void;
busyId?: string | null;
}
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' };
};
export function WarehouseInventoryTable({
items,
onInspect,
onReadyForLoading,
busyId,
}: WarehouseInventoryTableProps) {
if (items.length === 0) {
return (
<Text c="dimmed" ta="center" py="xl">
No inventory items found.
</Text>
);
}
return (
<Table.ScrollContainer minWidth={1100}>
<Table highlightOnHover verticalSpacing="sm" striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
<Table.Th>Warehouse</Table.Th>
<Table.Th>Yard</Table.Th>
<Table.Th>Zone</Table.Th>
<Table.Th>Item</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Arrived</Table.Th>
<Table.Th>Ready</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((item) => {
const kind = itemKind(item);
const busy = busyId === item.id;
return (
<Table.Tr key={item.id}>
<Table.Td>
<Tooltip label={item.bookingId} withArrow>
<Text size="sm" fw={600}>
{item.bookingId.slice(0, 8)}
</Text>
</Tooltip>
</Table.Td>
<Table.Td>{item.warehouse?.code ?? '—'}</Table.Td>
<Table.Td>{item.yard?.code ?? '—'}</Table.Td>
<Table.Td>{item.zone?.code ?? '—'}</Table.Td>
<Table.Td>
<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>
<Text size="xs">{formatDate(item.readyForLoadingAt)}</Text>
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Button
size="compact-xs"
variant="light"
color="cyan"
leftSection={<ClipboardCheck size={14} />}
disabled={item.status !== 'ARRIVED_AT_WAREHOUSE' || busy}
loading={busy}
onClick={() => onInspect(item)}
>
Inspect
</Button>
<Button
size="compact-xs"
variant="light"
color="green"
leftSection={<PackageCheck size={14} />}
disabled={item.status !== 'UNDER_INSPECTION' || busy}
loading={busy}
onClick={() => onReadyForLoading(item)}
>
Ready
</Button>
</Group>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}

View File

@@ -0,0 +1,72 @@
import { ActionIcon, Anchor, Group, Table, Text } from '@mantine/core';
import { Eye, Pencil } from 'lucide-react';
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) {
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>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>
<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,49 @@
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> = {
ARRIVED_AT_WAREHOUSE: 'yellow',
UNDER_INSPECTION: 'cyan',
READY_FOR_LOADING: '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,13 @@
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';

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

@@ -183,4 +183,28 @@ export const URL_CONSTANTS = {
CONTAINER_TYPES: '/api/reference/container-types',
CURRENCIES: '/api/reference/currencies',
},
WAREHOUSES: {
BASE: '/warehouses',
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',
READY_FOR_LOADING: '/warehouse-inventory/ready-for-loading',
INQUIRY: '/warehouse-inventory/inquiry',
INSPECT: (id: string) => `/warehouse-inventory/${id}/inspect`,
MARK_READY: (id: string) => `/warehouse-inventory/${id}/ready-for-loading`,
},
};

View File

@@ -0,0 +1,162 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { warehouseService } from '@/services/warehouse.service';
import type {
InventoryFilter,
InventoryInquiryFilter,
ReceiveInventoryPayload,
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 });
},
});
}
export function useInspectInventory() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => warehouseService.inspectInventory(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }),
});
}
export function useMarkReadyForLoading() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => warehouseService.markReadyForLoading(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }),
});
}
export function useInventoryInquiry(filter: InventoryInquiryFilter, enabled = true) {
return useQuery({
queryKey: warehouseKeys.inquiry(filter),
queryFn: () => warehouseService.inquiry(filter).then((r) => r.data),
enabled,
});
}

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

@@ -25,6 +25,7 @@ import {
BookingCargoCard,
BookingContractSummaryCard,
} from "@/components/bookings/detail";
import { WarehouseInfoCard } from "@/components/warehouses";
import { getStatusMeta } from "@/features/bookings/booking-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings";
@@ -150,6 +151,7 @@ export default function BookingRequestDetailPage() {
<Box style={{ position: "sticky", top: 24 }}>
<Stack gap="lg">
<BookingPricingSummary booking={booking} />
<WarehouseInfoCard bookingId={booking.id} bookingReference={booking.reference} />
<BookingActionsToolbar booking={booking} mutations={mutations} />
{showContractButton && (
<Button

View File

@@ -0,0 +1,149 @@
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 { 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) => setDraft((f) => ({ ...f, bookingNumber: e.currentTarget.value || undefined }))}
w={200}
/>
<TextInput
label="Container number"
placeholder="e.g. MSKU1234567"
value={draft.containerNumber ?? ''}
onChange={(e) => setDraft((f) => ({ ...f, containerNumber: e.currentTarget.value || undefined }))}
w={200}
/>
<TextInput
label="Goods name"
placeholder="e.g. Coffee"
value={draft.goodsName ?? ''}
onChange={(e) => setDraft((f) => ({ ...f, goodsName: e.currentTarget.value || 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>
) : (
<WarehouseInquiryTable results={results} />
)}
</Card>
</Stack>
</Container>
);
}

View File

@@ -0,0 +1,375 @@
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 { useToast } from '@/hooks/use-toast';
import {
CreateYardModal,
CreateZoneModal,
WarehouseInventoryTable,
WarehouseStatusBadge,
WarehouseTypeBadge,
formatCapacity,
humanizeEnum,
} from '@/components/warehouses';
import {
useInspectInventory,
useMarkReadyForLoading,
useWarehouse,
useWarehouseInventory,
useWarehouseYards,
useWarehouseZones,
} from '@/hooks/useWarehouses';
import { extractErrorMessage } from '@/components/warehouses/options';
import type { WarehouseInventoryItem, WarehouseYard, WarehouseZone } from '@/types/warehouse';
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 { toast } = useToast();
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 inspectMutation = useInspectInventory();
const readyMutation = useMarkReadyForLoading();
const [busyId, setBusyId] = useState<string | null>(null);
const yards = yardsQuery.data ?? [];
const yardOptions = useMemo(
() => yards.map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
[yards],
);
const handleInspect = async (item: WarehouseInventoryItem) => {
setBusyId(item.id);
try {
await inspectMutation.mutateAsync(item.id);
toast({ title: 'Inventory under inspection' });
} catch (error) {
toast({ variant: 'destructive', title: 'Action failed', description: extractErrorMessage(error) });
} finally {
setBusyId(null);
}
};
const handleReady = async (item: WarehouseInventoryItem) => {
setBusyId(item.id);
try {
await readyMutation.mutateAsync(item.id);
toast({ title: 'Inventory ready for loading' });
} catch (error) {
toast({ variant: 'destructive', title: 'Action failed', description: extractErrorMessage(error) });
} finally {
setBusyId(null);
}
};
if (isLoading) {
return (
<Center mih="60vh">
<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">
{inventoryQuery.isLoading ? (
<Center py="xl">
<Loader />
</Center>
) : (
<WarehouseInventoryTable
items={inventoryQuery.data ?? []}
onInspect={handleInspect}
onReadyForLoading={handleReady}
busyId={busyId}
/>
)}
</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,169 @@
import { useMemo, useState } from 'react';
import { Button, Card, Center, Container, Group, Loader, Select, Stack, Text, TextInput, Title } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { PackagePlus, Search } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { useToast } from '@/hooks/use-toast';
import {
ReceiveInventoryModal,
WarehouseInventoryTable,
inventoryStatusOptions,
} from '@/components/warehouses';
import { extractErrorMessage } from '@/components/warehouses/options';
import {
useInspectInventory,
useMarkReadyForLoading,
useWarehouseInventory,
useWarehouseYards,
useWarehouseZones,
useWarehouses,
} from '@/hooks/useWarehouses';
import type { InventoryFilter, InventoryStatus, WarehouseInventoryItem } from '@/types/warehouse';
export default function WarehouseInventoryPage() {
const { toast } = useToast();
const [filter, setFilter] = useState<InventoryFilter>({});
const [search, setSearch] = useState('');
const [modalOpen, setModalOpen] = useState(false);
const [busyId, setBusyId] = useState<string | null>(null);
const [debouncedSearch] = useDebouncedValue(search, 300);
const queryFilter = useMemo<InventoryFilter>(
() => ({ ...filter, search: debouncedSearch || undefined }),
[filter, debouncedSearch],
);
const warehousesQuery = useWarehouses();
const yardsQuery = useWarehouseYards(filter.warehouseId);
const zonesQuery = useWarehouseZones(filter.yardId);
const inventoryQuery = useWarehouseInventory(queryFilter);
const inspectMutation = useInspectInventory();
const readyMutation = useMarkReadyForLoading();
const warehouseOptions = useMemo(
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
[warehousesQuery.data],
);
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 handleInspect = async (item: WarehouseInventoryItem) => {
setBusyId(item.id);
try {
await inspectMutation.mutateAsync(item.id);
toast({ title: 'Inventory under inspection' });
} catch (error) {
toast({ variant: 'destructive', title: 'Action failed', description: extractErrorMessage(error) });
} finally {
setBusyId(null);
}
};
const handleReady = async (item: WarehouseInventoryItem) => {
setBusyId(item.id);
try {
await readyMutation.mutateAsync(item.id);
toast({ title: 'Inventory ready for loading' });
} catch (error) {
toast({ variant: 'destructive', title: 'Action failed', description: extractErrorMessage(error) });
} finally {
setBusyId(null);
}
};
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Warehouse inventory' }]} />
<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 and move them through inspection to loading.
</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>
{inventoryQuery.isLoading ? (
<Center py="xl">
<Loader />
</Center>
) : (
<WarehouseInventoryTable
items={inventoryQuery.data ?? []}
onInspect={handleInspect}
onReadyForLoading={handleReady}
busyId={busyId}
/>
)}
</Stack>
</Card>
</Stack>
<ReceiveInventoryModal opened={modalOpen} onClose={() => setModalOpen(false)} />
</Container>
);
}

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,73 @@
import { api as apiClient } from '../auth/http';
import { URL_CONSTANTS } from '@/constants/URLS';
import type {
InventoryFilter,
InventoryInquiryFilter,
InventoryInquiryResult,
ReceiveInventoryPayload,
SaveWarehousePayload,
SaveYardPayload,
SaveZonePayload,
Warehouse,
WarehouseFilter,
WarehouseInventoryItem,
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 ?? {}),
}),
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),
inspectInventory: (id: string) =>
apiClient.patch<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.INSPECT(id)),
markReadyForLoading: (id: string) =>
apiClient.patch<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.MARK_READY(id)),
listReadyForLoading: (filter?: InventoryFilter) =>
apiClient.get<WarehouseInventoryItem[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.READY_FOR_LOADING, {
params: cleanParams(filter ?? {}),
}),
inquiry: (filter: InventoryInquiryFilter) =>
apiClient.get<InventoryInquiryResult[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.INQUIRY, {
params: cleanParams(filter ?? {}),
}),
};

View File

@@ -0,0 +1,193 @@
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 = [
'ARRIVED_AT_WAREHOUSE',
'UNDER_INSPECTION',
'READY_FOR_LOADING',
] as const;
export type InventoryStatus = (typeof INVENTORY_STATUSES)[number];
export interface WarehouseZone {
id: string;
yardId: string;
name: string;
code: string;
type: WarehouseZoneType;
capacityWeight: number | null;
capacityContainers: number | null;
currentWeight: number;
currentContainers: number;
status: WarehouseStatus;
isActive: boolean;
}
export interface WarehouseYard {
id: string;
warehouseId: string;
name: string;
code: string;
type: WarehouseYardType;
capacityWeight: number | null;
capacityContainers: number | null;
currentWeight: number;
currentContainers: number;
status: WarehouseStatus;
isActive: boolean;
zones?: WarehouseZone[];
}
export interface Warehouse {
id: string;
name: string;
code: string;
type: WarehouseType;
stationId: string | null;
locationName: string | null;
capacityWeight: number | null;
capacityContainers: number | null;
currentWeight: number;
currentContainers: number;
status: WarehouseStatus;
isActive: boolean;
yards?: WarehouseYard[];
createdAt?: string;
updatedAt?: string;
}
export interface WarehouseInventoryItem {
id: string;
warehouseId: string;
yardId: string;
zoneId: string;
bookingId: string;
cargoId: string | null;
containerId: string | null;
goodsId: string | null;
quantity: number;
weight: number;
volume: number | null;
status: InventoryStatus;
arrivedAt: string | null;
inspectedAt: string | null;
readyForLoadingAt: string | null;
notes: string | null;
warehouse?: Warehouse | null;
yard?: WarehouseYard | null;
zone?: WarehouseZone | null;
}
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;
}
// ── Payloads ───────────────────────────────────────────────────────────────
export interface SaveWarehousePayload {
name: string;
code: string;
type: WarehouseType;
stationId?: string;
locationName?: string;
capacityWeight?: number;
capacityContainers?: number;
status?: WarehouseStatus;
}
export interface SaveYardPayload {
name: string;
code: string;
type: WarehouseYardType;
capacityWeight?: number;
capacityContainers?: number;
status?: WarehouseStatus;
}
export interface SaveZonePayload {
name: string;
code: string;
type: WarehouseZoneType;
capacityWeight?: number;
capacityContainers?: 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;
}