mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 09:30:59 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user