mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 17:10:56 +00:00
Merge branch 'freight/develop' of github.com:Tria-plc/edr-platform into freight_feature/priority
This commit is contained in:
@@ -6,12 +6,15 @@ import {
|
||||
LayoutGrid,
|
||||
Network,
|
||||
Paperclip,
|
||||
PackageCheck,
|
||||
Send,
|
||||
Settings,
|
||||
SlidersHorizontal,
|
||||
Train,
|
||||
Truck,
|
||||
Container,
|
||||
Package,
|
||||
PackageOpen,
|
||||
Users,
|
||||
Wallet,
|
||||
//TrainTrack,
|
||||
@@ -53,6 +56,17 @@ import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/perm
|
||||
import { RequirePermission } from "./components/auth/RequirePermission";
|
||||
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
||||
import RoutesPage from "./pages/fleet/RoutesPage";
|
||||
import WarehouseListPage from "./pages/warehouses/WarehouseListPage";
|
||||
import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage";
|
||||
import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage";
|
||||
import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage";
|
||||
import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage";
|
||||
import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage";
|
||||
import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage";
|
||||
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
|
||||
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
|
||||
import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage";
|
||||
import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage";
|
||||
|
||||
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
{
|
||||
@@ -143,6 +157,61 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
// },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Warehouse Management",
|
||||
items: [
|
||||
{
|
||||
label: "Warehouse Dashboard",
|
||||
href: "/dashboard/warehouse-dashboard",
|
||||
icon: <LayoutDashboard />,
|
||||
},
|
||||
{
|
||||
label: "Warehouses",
|
||||
href: "/dashboard/warehouses",
|
||||
icon: <Container />,
|
||||
},
|
||||
{
|
||||
label: "Inventory",
|
||||
href: "/dashboard/warehouse-inventory",
|
||||
icon: <Package />,
|
||||
},
|
||||
{
|
||||
label: "Arrival Queue",
|
||||
href: "/dashboard/arrival-queue",
|
||||
icon: <PackageOpen />,
|
||||
},
|
||||
{
|
||||
label: "Loading Queue",
|
||||
href: "/dashboard/loading-queue",
|
||||
icon: <Truck />,
|
||||
},
|
||||
{
|
||||
label: "Loaded Inventory",
|
||||
href: "/dashboard/loaded-inventory",
|
||||
icon: <PackageCheck />,
|
||||
},
|
||||
{
|
||||
label: "Dispatch Queue",
|
||||
href: "/dashboard/dispatch-queue",
|
||||
icon: <Send />,
|
||||
},
|
||||
{
|
||||
label: "Inventory Inquiry",
|
||||
href: "/dashboard/inventory-inquiry",
|
||||
icon: <Boxes />,
|
||||
},
|
||||
{
|
||||
label: "Allocation & Fees",
|
||||
href: "/dashboard/warehouse-rules",
|
||||
icon: <SlidersHorizontal />,
|
||||
},
|
||||
{
|
||||
label: "Fee Invoices",
|
||||
href: "/dashboard/warehouse-fee-invoices",
|
||||
icon: <Wallet />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Administration",
|
||||
items: [
|
||||
@@ -303,6 +372,18 @@ const App = () => {
|
||||
path="booking-requests/:id/contract"
|
||||
element={<BookingContractPage />}
|
||||
/>
|
||||
<Route path="warehouses" element={<WarehouseListPage />} />
|
||||
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
|
||||
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
|
||||
<Route path="arrival-queue" element={<ArrivalQueuePage />} />
|
||||
<Route path="loading-queue" element={<LoadingQueuePage />} />
|
||||
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
|
||||
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
|
||||
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
|
||||
<Route path="warehouse-rules" element={<WarehouseRulesPage />} />
|
||||
<Route path="warehouse-fee-invoices" element={<WarehouseInvoicesPage />} />
|
||||
<Route path="warehouse-dashboard" element={<WarehouseDashboardPage />} />
|
||||
|
||||
<Route
|
||||
path="operations/train-scheduling"
|
||||
element={<Navigate to="/dashboard/operations/train-scheduling-v2" replace />}
|
||||
|
||||
@@ -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,96 @@
|
||||
import { useState } from 'react';
|
||||
import { PackageCheck } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useDeliverCargo } from '@/hooks/useCargoes';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
/**
|
||||
* Customer Pickup + Proof of Delivery capture for a LOADED cargo.
|
||||
* Records receiver name, pickup date and remarks, then marks the cargo DELIVERED.
|
||||
*/
|
||||
export function DeliverCargoDialog({ cargoId, onSuccess }: { cargoId: string; onSuccess?: () => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [receiverName, setReceiverName] = useState('');
|
||||
const [pickupDate, setPickupDate] = useState('');
|
||||
const [deliveryRemarks, setDeliveryRemarks] = useState('');
|
||||
const deliver = useDeliverCargo();
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleDeliver = async () => {
|
||||
if (!receiverName.trim()) {
|
||||
toast({ title: 'Receiver name is required', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deliver.mutateAsync({
|
||||
id: cargoId,
|
||||
payload: {
|
||||
receiverName: receiverName.trim(),
|
||||
pickupDate: pickupDate ? new Date(pickupDate).toISOString() : undefined,
|
||||
deliveryRemarks: deliveryRemarks.trim() || undefined,
|
||||
},
|
||||
});
|
||||
toast({ title: 'Delivered', description: 'Proof of delivery recorded; cargo marked delivered.' });
|
||||
setOpen(false);
|
||||
setReceiverName('');
|
||||
setPickupDate('');
|
||||
setDeliveryRemarks('');
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
const message =
|
||||
(error as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
||||
'Could not record delivery.';
|
||||
toast({ title: 'Delivery failed', description: String(message), variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" title="Customer pickup / Proof of delivery">
|
||||
<PackageCheck className="size-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Customer Pickup & Proof of Delivery</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Receiver name*</Label>
|
||||
<Input
|
||||
required
|
||||
value={receiverName}
|
||||
placeholder="Name of person collecting the cargo"
|
||||
onChange={(e) => setReceiverName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Pickup date</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={pickupDate}
|
||||
onChange={(e) => setPickupDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Remarks</Label>
|
||||
<Textarea
|
||||
value={deliveryRemarks}
|
||||
placeholder="Condition on handover, ID checked, etc."
|
||||
onChange={(e) => setDeliveryRemarks(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleDeliver} disabled={deliver.isPending} className="w-full">
|
||||
{deliver.isPending ? 'Recording…' : 'Confirm Pickup & Mark Delivered'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Center, Loader, Text, Timeline } from '@mantine/core';
|
||||
import {
|
||||
ArrowRightLeft,
|
||||
ClipboardCheck,
|
||||
PackageCheck,
|
||||
PackagePlus,
|
||||
Send,
|
||||
Truck,
|
||||
Warehouse,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useInventoryActivity } from '@/hooks/useWarehouses';
|
||||
import type { ActivityType } from '@/types/warehouse';
|
||||
import { formatDate, humanizeEnum } from './options';
|
||||
|
||||
const activityIcon: Record<ActivityType, React.ReactNode> = {
|
||||
INVENTORY_RECEIVED: <PackagePlus size={14} />,
|
||||
INVENTORY_STORED: <Warehouse size={14} />,
|
||||
INVENTORY_MOVED: <ArrowRightLeft size={14} />,
|
||||
INVENTORY_RESERVED: <ClipboardCheck size={14} />,
|
||||
READY_FOR_LOADING: <PackageCheck size={14} />,
|
||||
INVENTORY_LOADED: <Truck size={14} />,
|
||||
INVENTORY_DISPATCHED: <Send size={14} />,
|
||||
};
|
||||
|
||||
export function ActivityTimeline({ inventoryId }: { inventoryId: string }) {
|
||||
const { data, isLoading } = useInventoryActivity(inventoryId);
|
||||
const items = data ?? [];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center py="lg">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<Text c="dimmed" ta="center" py="md" size="sm">
|
||||
No activity recorded yet.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Timeline active={items.length} bulletSize={24} lineWidth={2}>
|
||||
{items.map((log) => (
|
||||
<Timeline.Item key={log.id} bullet={activityIcon[log.activityType]} title={humanizeEnum(log.activityType)}>
|
||||
{log.description && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{log.description}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="xs" mt={4} c="dimmed">
|
||||
{log.performedBy ?? 'system'} · {formatDate(log.createdAt)}
|
||||
</Text>
|
||||
</Timeline.Item>
|
||||
))}
|
||||
</Timeline>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Select } from '@mantine/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { bookingsService } from '@/services/bookings.service';
|
||||
|
||||
interface BookingSelectProps {
|
||||
value: string;
|
||||
onChange: (bookingId: string) => void;
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
/** Comma-separated statuses to restrict the list (e.g. "PAID" for reservations). */
|
||||
statuses?: string;
|
||||
}
|
||||
|
||||
/** Searchable booking picker — shows the human reference (e.g. BKG-BULK-002), submits the UUID. */
|
||||
export function BookingSelect({ value, onChange, label = 'Booking', required, statuses }: BookingSelectProps) {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['bookings', 'options', statuses ?? 'all'],
|
||||
queryFn: () =>
|
||||
bookingsService.list({ pageSize: 200, ...(statuses ? { statuses } : {}) }).then((r) => r.items),
|
||||
});
|
||||
|
||||
const options = (data ?? []).map((b) => ({
|
||||
value: b.id,
|
||||
label: b.status ? `${b.reference} · ${b.status}` : b.reference,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Select
|
||||
label={label}
|
||||
required={required}
|
||||
searchable
|
||||
clearable
|
||||
data={options}
|
||||
value={value || null}
|
||||
onChange={(v) => onChange(v ?? '')}
|
||||
placeholder={isLoading ? 'Loading bookings…' : 'Search booking reference'}
|
||||
nothingFoundMessage="No bookings found"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useStations } from '@/hooks/useStations';
|
||||
import { useCreateWarehouse, useUpdateWarehouse } from '@/hooks/useWarehouses';
|
||||
import type { SaveWarehousePayload, Warehouse, WarehouseType } from '@/types/warehouse';
|
||||
import { extractErrorMessage, statusOptions, warehouseTypeOptions } from './options';
|
||||
|
||||
interface CreateWarehouseModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
warehouse?: Warehouse | null;
|
||||
}
|
||||
|
||||
interface FormState {
|
||||
name: string;
|
||||
code: string;
|
||||
type: WarehouseType;
|
||||
stationId: string | null;
|
||||
locationName: string;
|
||||
capacityWeight: number | '';
|
||||
capacityContainers: number | '';
|
||||
maxVolume: number | '';
|
||||
status: 'ACTIVE' | 'INACTIVE';
|
||||
}
|
||||
|
||||
const emptyForm = (): FormState => ({
|
||||
name: '',
|
||||
code: '',
|
||||
type: 'OPEN_WAREHOUSE',
|
||||
stationId: null,
|
||||
locationName: '',
|
||||
capacityWeight: '',
|
||||
capacityContainers: '',
|
||||
maxVolume: '',
|
||||
status: 'ACTIVE',
|
||||
});
|
||||
|
||||
export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWarehouseModalProps) {
|
||||
const isEdit = Boolean(warehouse);
|
||||
const { toast } = useToast();
|
||||
const createMutation = useCreateWarehouse();
|
||||
const updateMutation = useUpdateWarehouse();
|
||||
const { data: stations } = useStations();
|
||||
const [form, setForm] = useState<FormState>(emptyForm());
|
||||
|
||||
const stationOptions = (stations ?? []).map((s) => ({ value: s.id, label: `${s.name} (${s.code})` }));
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setForm(
|
||||
warehouse
|
||||
? {
|
||||
name: warehouse.name,
|
||||
code: warehouse.code,
|
||||
type: warehouse.type,
|
||||
stationId: warehouse.stationId ?? null,
|
||||
locationName: warehouse.locationName ?? '',
|
||||
capacityWeight: warehouse.capacityWeight ?? '',
|
||||
capacityContainers: warehouse.capacityContainers ?? '',
|
||||
maxVolume: warehouse.maxVolume ?? '',
|
||||
status: warehouse.status,
|
||||
}
|
||||
: emptyForm(),
|
||||
);
|
||||
}
|
||||
}, [opened, warehouse]);
|
||||
|
||||
const submitting = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.name.trim() || !form.code.trim()) {
|
||||
toast({ variant: 'destructive', title: 'Name and code are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: SaveWarehousePayload = {
|
||||
name: form.name.trim(),
|
||||
code: form.code.trim(),
|
||||
type: form.type,
|
||||
stationId: form.stationId ?? undefined,
|
||||
locationName: form.locationName.trim() || undefined,
|
||||
capacityWeight: form.capacityWeight === '' ? undefined : Number(form.capacityWeight),
|
||||
capacityContainers: form.capacityContainers === '' ? undefined : Number(form.capacityContainers),
|
||||
maxVolume: form.maxVolume === '' ? undefined : Number(form.maxVolume),
|
||||
};
|
||||
|
||||
try {
|
||||
if (warehouse) {
|
||||
await updateMutation.mutateAsync({ id: warehouse.id, payload: { ...payload, status: form.status } });
|
||||
toast({ title: 'Warehouse updated' });
|
||||
} else {
|
||||
await createMutation.mutateAsync(payload);
|
||||
toast({ title: 'Warehouse created' });
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Save failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={isEdit ? 'Edit warehouse' : 'Create warehouse'} centered size="lg">
|
||||
<Stack gap="md">
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Name"
|
||||
placeholder="Modjo Open Warehouse"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, name: v })); }}
|
||||
/>
|
||||
<TextInput
|
||||
label="Code"
|
||||
placeholder="MODJO-OW"
|
||||
required
|
||||
value={form.code}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, code: v })); }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Select
|
||||
label="Facility / Port (Station)"
|
||||
placeholder="Select parent station"
|
||||
data={stationOptions}
|
||||
value={form.stationId}
|
||||
onChange={(value) => setForm((f) => ({ ...f, stationId: value }))}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
|
||||
<Group grow>
|
||||
<Select
|
||||
label="Type"
|
||||
data={warehouseTypeOptions}
|
||||
value={form.type}
|
||||
onChange={(value) => setForm((f) => ({ ...f, type: (value as WarehouseType) ?? 'OPEN_WAREHOUSE' }))}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
{isEdit && (
|
||||
<Select
|
||||
label="Status"
|
||||
data={statusOptions}
|
||||
value={form.status}
|
||||
onChange={(value) => setForm((f) => ({ ...f, status: (value as 'ACTIVE' | 'INACTIVE') ?? 'ACTIVE' }))}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<TextInput
|
||||
label="Location name"
|
||||
placeholder="Modjo, Oromia"
|
||||
value={form.locationName}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, locationName: v })); }}
|
||||
/>
|
||||
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Capacity weight (kg)"
|
||||
placeholder="Optional"
|
||||
min={0}
|
||||
value={form.capacityWeight}
|
||||
onChange={(value) => setForm((f) => ({ ...f, capacityWeight: value === '' ? '' : Number(value) }))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Capacity containers"
|
||||
placeholder="Optional"
|
||||
min={0}
|
||||
value={form.capacityContainers}
|
||||
onChange={(value) => setForm((f) => ({ ...f, capacityContainers: value === '' ? '' : Number(value) }))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Max volume (m³)"
|
||||
placeholder="Optional"
|
||||
min={0}
|
||||
value={form.maxVolume}
|
||||
onChange={(value) => setForm((f) => ({ ...f, maxVolume: value === '' ? '' : Number(value) }))}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} loading={submitting}>
|
||||
{isEdit ? 'Save changes' : 'Create warehouse'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Group, Modal, NumberInput, Select, Stack, TextInput } from '@mantine/core';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useCreateYard, useUpdateYard } from '@/hooks/useWarehouses';
|
||||
import type { SaveYardPayload, WarehouseYard, WarehouseYardType } from '@/types/warehouse';
|
||||
import { extractErrorMessage, statusOptions, yardTypeOptions } from './options';
|
||||
|
||||
interface CreateYardModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
warehouseId: string;
|
||||
yard?: WarehouseYard | null;
|
||||
}
|
||||
|
||||
interface FormState {
|
||||
name: string;
|
||||
code: string;
|
||||
type: WarehouseYardType;
|
||||
capacityWeight: number | '';
|
||||
capacityContainers: number | '';
|
||||
maxVolume: number | '';
|
||||
status: 'ACTIVE' | 'INACTIVE';
|
||||
}
|
||||
|
||||
const emptyForm = (): FormState => ({
|
||||
name: '',
|
||||
code: '',
|
||||
type: 'CONTAINER_YARD',
|
||||
capacityWeight: '',
|
||||
capacityContainers: '',
|
||||
maxVolume: '',
|
||||
status: 'ACTIVE',
|
||||
});
|
||||
|
||||
export function CreateYardModal({ opened, onClose, warehouseId, yard }: CreateYardModalProps) {
|
||||
const isEdit = Boolean(yard);
|
||||
const { toast } = useToast();
|
||||
const createMutation = useCreateYard();
|
||||
const updateMutation = useUpdateYard();
|
||||
const [form, setForm] = useState<FormState>(emptyForm());
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setForm(
|
||||
yard
|
||||
? {
|
||||
name: yard.name,
|
||||
code: yard.code,
|
||||
type: yard.type,
|
||||
capacityWeight: yard.capacityWeight ?? '',
|
||||
capacityContainers: yard.capacityContainers ?? '',
|
||||
maxVolume: yard.maxVolume ?? '',
|
||||
status: yard.status,
|
||||
}
|
||||
: emptyForm(),
|
||||
);
|
||||
}
|
||||
}, [opened, yard]);
|
||||
|
||||
const submitting = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.name.trim() || !form.code.trim()) {
|
||||
toast({ variant: 'destructive', title: 'Name and code are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: SaveYardPayload = {
|
||||
name: form.name.trim(),
|
||||
code: form.code.trim(),
|
||||
type: form.type,
|
||||
capacityWeight: form.capacityWeight === '' ? undefined : Number(form.capacityWeight),
|
||||
capacityContainers: form.capacityContainers === '' ? undefined : Number(form.capacityContainers),
|
||||
maxVolume: form.maxVolume === '' ? undefined : Number(form.maxVolume),
|
||||
};
|
||||
|
||||
try {
|
||||
if (yard) {
|
||||
await updateMutation.mutateAsync({ id: yard.id, payload: { ...payload, status: form.status } });
|
||||
toast({ title: 'Yard updated' });
|
||||
} else {
|
||||
await createMutation.mutateAsync({ warehouseId, payload });
|
||||
toast({ title: 'Yard created' });
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Save failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={isEdit ? 'Edit yard' : 'Create yard'} centered size="lg">
|
||||
<Stack gap="md">
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Name"
|
||||
placeholder="Container Yard A"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, name: v })); }}
|
||||
/>
|
||||
<TextInput
|
||||
label="Code"
|
||||
placeholder="CY-A"
|
||||
required
|
||||
value={form.code}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, code: v })); }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group grow>
|
||||
<Select
|
||||
label="Type"
|
||||
data={yardTypeOptions}
|
||||
value={form.type}
|
||||
onChange={(value) => setForm((f) => ({ ...f, type: (value as WarehouseYardType) ?? 'CONTAINER_YARD' }))}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
{isEdit && (
|
||||
<Select
|
||||
label="Status"
|
||||
data={statusOptions}
|
||||
value={form.status}
|
||||
onChange={(value) => setForm((f) => ({ ...f, status: (value as 'ACTIVE' | 'INACTIVE') ?? 'ACTIVE' }))}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Capacity weight (kg)"
|
||||
placeholder="Optional"
|
||||
min={0}
|
||||
value={form.capacityWeight}
|
||||
onChange={(value) => setForm((f) => ({ ...f, capacityWeight: value === '' ? '' : Number(value) }))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Capacity containers"
|
||||
placeholder="Optional"
|
||||
min={0}
|
||||
value={form.capacityContainers}
|
||||
onChange={(value) => setForm((f) => ({ ...f, capacityContainers: value === '' ? '' : Number(value) }))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Max volume (m³)"
|
||||
placeholder="Optional"
|
||||
min={0}
|
||||
value={form.maxVolume}
|
||||
onChange={(value) => setForm((f) => ({ ...f, maxVolume: value === '' ? '' : Number(value) }))}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} loading={submitting}>
|
||||
{isEdit ? 'Save changes' : 'Create yard'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Group, Modal, NumberInput, Select, Stack, TextInput } from '@mantine/core';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useCreateZone, useUpdateZone } from '@/hooks/useWarehouses';
|
||||
import type { SaveZonePayload, WarehouseZone, WarehouseZoneType } from '@/types/warehouse';
|
||||
import { extractErrorMessage, statusOptions, zoneTypeOptions } from './options';
|
||||
|
||||
interface CreateZoneModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
yardId: string;
|
||||
zone?: WarehouseZone | null;
|
||||
}
|
||||
|
||||
interface FormState {
|
||||
name: string;
|
||||
code: string;
|
||||
type: WarehouseZoneType;
|
||||
capacityWeight: number | '';
|
||||
capacityContainers: number | '';
|
||||
maxVolume: number | '';
|
||||
status: 'ACTIVE' | 'INACTIVE';
|
||||
}
|
||||
|
||||
const emptyForm = (): FormState => ({
|
||||
name: '',
|
||||
code: '',
|
||||
type: 'CONTAINER_ZONE',
|
||||
capacityWeight: '',
|
||||
capacityContainers: '',
|
||||
maxVolume: '',
|
||||
status: 'ACTIVE',
|
||||
});
|
||||
|
||||
export function CreateZoneModal({ opened, onClose, yardId, zone }: CreateZoneModalProps) {
|
||||
const isEdit = Boolean(zone);
|
||||
const { toast } = useToast();
|
||||
const createMutation = useCreateZone();
|
||||
const updateMutation = useUpdateZone();
|
||||
const [form, setForm] = useState<FormState>(emptyForm());
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setForm(
|
||||
zone
|
||||
? {
|
||||
name: zone.name,
|
||||
code: zone.code,
|
||||
type: zone.type,
|
||||
capacityWeight: zone.capacityWeight ?? '',
|
||||
capacityContainers: zone.capacityContainers ?? '',
|
||||
maxVolume: zone.maxVolume ?? '',
|
||||
status: zone.status,
|
||||
}
|
||||
: emptyForm(),
|
||||
);
|
||||
}
|
||||
}, [opened, zone]);
|
||||
|
||||
const submitting = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.name.trim() || !form.code.trim()) {
|
||||
toast({ variant: 'destructive', title: 'Name and code are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: SaveZonePayload = {
|
||||
name: form.name.trim(),
|
||||
code: form.code.trim(),
|
||||
type: form.type,
|
||||
capacityWeight: form.capacityWeight === '' ? undefined : Number(form.capacityWeight),
|
||||
capacityContainers: form.capacityContainers === '' ? undefined : Number(form.capacityContainers),
|
||||
maxVolume: form.maxVolume === '' ? undefined : Number(form.maxVolume),
|
||||
};
|
||||
|
||||
try {
|
||||
if (zone) {
|
||||
await updateMutation.mutateAsync({ id: zone.id, payload: { ...payload, status: form.status } });
|
||||
toast({ title: 'Zone updated' });
|
||||
} else {
|
||||
await createMutation.mutateAsync({ yardId, payload });
|
||||
toast({ title: 'Zone created' });
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Save failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={isEdit ? 'Edit zone' : 'Create zone'} centered size="lg">
|
||||
<Stack gap="md">
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Name"
|
||||
placeholder="Zone A-01"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, name: v })); }}
|
||||
/>
|
||||
<TextInput
|
||||
label="Code"
|
||||
placeholder="A-01"
|
||||
required
|
||||
value={form.code}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, code: v })); }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group grow>
|
||||
<Select
|
||||
label="Type"
|
||||
data={zoneTypeOptions}
|
||||
value={form.type}
|
||||
onChange={(value) => setForm((f) => ({ ...f, type: (value as WarehouseZoneType) ?? 'CONTAINER_ZONE' }))}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
{isEdit && (
|
||||
<Select
|
||||
label="Status"
|
||||
data={statusOptions}
|
||||
value={form.status}
|
||||
onChange={(value) => setForm((f) => ({ ...f, status: (value as 'ACTIVE' | 'INACTIVE') ?? 'ACTIVE' }))}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Capacity weight (kg)"
|
||||
placeholder="Optional"
|
||||
min={0}
|
||||
value={form.capacityWeight}
|
||||
onChange={(value) => setForm((f) => ({ ...f, capacityWeight: value === '' ? '' : Number(value) }))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Capacity containers"
|
||||
placeholder="Optional"
|
||||
min={0}
|
||||
value={form.capacityContainers}
|
||||
onChange={(value) => setForm((f) => ({ ...f, capacityContainers: value === '' ? '' : Number(value) }))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Max volume (m³)"
|
||||
placeholder="Optional"
|
||||
min={0}
|
||||
value={form.maxVolume}
|
||||
onChange={(value) => setForm((f) => ({ ...f, maxVolume: value === '' ? '' : Number(value) }))}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} loading={submitting}>
|
||||
{isEdit ? 'Save changes' : 'Create zone'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { Badge, Button, Card, Divider, Group, Loader, Modal, Stack, Text } from '@mantine/core';
|
||||
import { CalendarClock, Coins, DoorOpen, FileText } from 'lucide-react';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
useFeePreview,
|
||||
useGateClearance,
|
||||
useGenerateInvoice,
|
||||
useInvoicesForInventory,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import { extractErrorMessage } from './options';
|
||||
import type { FeePreview, WarehouseInvoiceStatus } from '@/types/warehouse';
|
||||
|
||||
const INVOICE_STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
|
||||
DRAFT: 'gray',
|
||||
ISSUED: 'orange',
|
||||
PARTIALLY_PAID: 'yellow',
|
||||
PAID: 'green',
|
||||
CANCELLED: 'gray',
|
||||
};
|
||||
|
||||
interface FeePreviewModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
inventoryId: string | null;
|
||||
}
|
||||
|
||||
const LABELS: Record<string, { label: string; color: string }> = {
|
||||
DEMURRAGE_FEE: { label: 'Demurrage', color: 'orange' },
|
||||
STORAGE_FEE: { label: 'Storage', color: 'teal' },
|
||||
};
|
||||
|
||||
function fmtDate(iso: string | null) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
function FeeCard({ fee }: { fee: FeePreview }) {
|
||||
const meta = LABELS[fee.ruleType] ?? { label: fee.ruleType, color: 'gray' };
|
||||
const configured = Boolean(fee.ruleId);
|
||||
return (
|
||||
<Card withBorder radius="md" padding="md" style={{ borderColor: `var(--mantine-color-${meta.color}-3)` }}>
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Group gap="xs">
|
||||
<Coins size={16} />
|
||||
<Text fw={700}>{meta.label}</Text>
|
||||
{fee.endIsOpen && (
|
||||
<Badge size="xs" color={meta.color} variant="light">
|
||||
accruing
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text fw={800} size="lg" c={`${meta.color}.7`}>
|
||||
{fee.amount.toLocaleString()} {fee.currency}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{!configured ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
No active {meta.label.toLowerCase()} rule configured — amount shown as 0.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap={4}>
|
||||
<Row label="Rule" value={fee.ruleName ?? '—'} />
|
||||
<Row label="Free days" value={String(fee.freeDays)} />
|
||||
<Row label="Rate / day" value={`${fee.ratePerDay.toLocaleString()} ${fee.currency}`} />
|
||||
<Row label="Period" value={`${fmtDate(fee.startDate)} → ${fmtDate(fee.endDate)}${fee.endIsOpen ? ' (today)' : ''}`} />
|
||||
<Row label="Elapsed days" value={String(fee.elapsedDays)} />
|
||||
<Row label="Chargeable days" value={`${fee.chargeableDays} (after ${fee.freeDays} free)`} />
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm">{value}</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/** Batch 5 fee preview + Batch 6 invoice generation / gate clearance for an inventory item. */
|
||||
export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModalProps) {
|
||||
const { toast } = useToast();
|
||||
const enabledId = opened ? inventoryId ?? undefined : undefined;
|
||||
const { data, isLoading } = useFeePreview(enabledId);
|
||||
const { data: invoices } = useInvoicesForInventory(enabledId);
|
||||
const generate = useGenerateInvoice();
|
||||
const gateClear = useGateClearance();
|
||||
|
||||
const activeInvoice = (invoices ?? []).find((i) => i.status !== 'CANCELLED');
|
||||
|
||||
const handleGenerate = async (confirmZero = false) => {
|
||||
if (!inventoryId) return;
|
||||
try {
|
||||
const inv = await generate.mutateAsync({ inventoryId, confirmZero });
|
||||
toast({ title: 'Invoice generated', description: `${inv.invoiceNumber} — ${inv.totalAmount} ${inv.currency}` });
|
||||
} catch (error) {
|
||||
const msg = extractErrorMessage(error);
|
||||
if (/no payable warehouse fee/i.test(msg)) {
|
||||
if (window.confirm('No payable warehouse fee found. Create a zero-amount invoice anyway?')) {
|
||||
handleGenerate(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
toast({ variant: 'destructive', title: 'Generate failed', description: msg });
|
||||
}
|
||||
};
|
||||
|
||||
const handleGateClearance = async () => {
|
||||
if (!inventoryId) return;
|
||||
try {
|
||||
await gateClear.mutateAsync(inventoryId);
|
||||
toast({ title: 'Gate clearance recorded', description: 'Item released from terminal.' });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Release blocked', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={
|
||||
<Group gap="xs">
|
||||
<CalendarClock size={18} />
|
||||
<Text fw={700}>Storage & Demurrage Preview</Text>
|
||||
</Group>
|
||||
}
|
||||
centered
|
||||
size="md"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
{(data ?? []).map((fee) => (
|
||||
<FeeCard key={fee.ruleType} fee={fee} />
|
||||
))}
|
||||
|
||||
<Divider label="Invoice & Release" labelPosition="left" />
|
||||
|
||||
{activeInvoice ? (
|
||||
<Group justify="space-between">
|
||||
<Group gap="xs">
|
||||
<FileText size={16} />
|
||||
<Text size="sm" fw={600}>{activeInvoice.invoiceNumber}</Text>
|
||||
<Badge variant="light" color={INVOICE_STATUS_COLOR[activeInvoice.status]}>
|
||||
{activeInvoice.status.replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="sm">
|
||||
{Number(activeInvoice.balanceAmount).toLocaleString()} {activeInvoice.currency} due
|
||||
</Text>
|
||||
</Group>
|
||||
) : (
|
||||
<Button
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<FileText size={16} />}
|
||||
loading={generate.isPending}
|
||||
onClick={() => handleGenerate(false)}
|
||||
>
|
||||
Generate Fee Invoice
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
color="green"
|
||||
leftSection={<DoorOpen size={16} />}
|
||||
loading={gateClear.isPending}
|
||||
onClick={handleGateClearance}
|
||||
>
|
||||
Gate Clearance / Release
|
||||
</Button>
|
||||
|
||||
<Text size="xs" c="dimmed">
|
||||
Charges accrue from arrival until gate clearance / release. Final release is blocked while a
|
||||
demurrage/storage invoice is unpaid.
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import type { CSSProperties, ReactElement } from 'react';
|
||||
|
||||
export type FreightVisualVariant =
|
||||
| 'train'
|
||||
| 'warehouse'
|
||||
| 'container'
|
||||
| 'wagon'
|
||||
| 'cargo'
|
||||
| 'route'
|
||||
| 'empty';
|
||||
|
||||
interface FreightVisualProps {
|
||||
variant: FreightVisualVariant;
|
||||
/** Pixel size of the (square) artwork. Defaults to 64. */
|
||||
size?: number;
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight railway/freight illustrations — minimal, enterprise-logistics style.
|
||||
* Inline SVG (no network cost) using EDR brand colors: green, yellow, dark text,
|
||||
* light gray. Purposely low-contrast so it never overpowers tables/forms.
|
||||
*
|
||||
* Use only in page headers, empty states, and KPI cards.
|
||||
*/
|
||||
const EDR = {
|
||||
green: '#2F9E44',
|
||||
greenSoft: '#D3F9D8',
|
||||
yellow: '#F59F00',
|
||||
yellowSoft: '#FFF3BF',
|
||||
dark: '#343A40',
|
||||
gray: '#ADB5BD',
|
||||
graySoft: '#E9ECEF',
|
||||
};
|
||||
|
||||
function Train() {
|
||||
return (
|
||||
<>
|
||||
{/* track */}
|
||||
<rect x="2" y="52" width="60" height="3" rx="1.5" fill={EDR.graySoft} />
|
||||
{/* locomotive body */}
|
||||
<rect x="6" y="20" width="26" height="26" rx="3" fill={EDR.green} />
|
||||
<rect x="10" y="24" width="8" height="8" rx="1.5" fill={EDR.greenSoft} />
|
||||
<rect x="22" y="24" width="6" height="8" rx="1.5" fill={EDR.greenSoft} />
|
||||
{/* cab roof */}
|
||||
<rect x="9" y="15" width="14" height="6" rx="2" fill={EDR.dark} />
|
||||
{/* wagon */}
|
||||
<rect x="36" y="26" width="22" height="20" rx="2.5" fill={EDR.yellow} />
|
||||
<rect x="40" y="30" width="14" height="6" rx="1" fill={EDR.yellowSoft} />
|
||||
{/* wheels */}
|
||||
{[12, 24, 42, 52].map((cx) => (
|
||||
<circle key={cx} cx={cx} cy={48} r={3.2} fill={EDR.dark} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Warehouse() {
|
||||
return (
|
||||
<>
|
||||
{/* ground */}
|
||||
<rect x="4" y="50" width="56" height="3" rx="1.5" fill={EDR.graySoft} />
|
||||
{/* roof */}
|
||||
<path d="M10 24 L32 12 L54 24 Z" fill={EDR.green} />
|
||||
{/* body */}
|
||||
<rect x="14" y="24" width="36" height="26" rx="1.5" fill={EDR.greenSoft} />
|
||||
{/* shutter door */}
|
||||
<rect x="26" y="32" width="12" height="18" rx="1" fill={EDR.dark} />
|
||||
<rect x="27.5" y="35" width="9" height="2" fill={EDR.gray} />
|
||||
<rect x="27.5" y="39" width="9" height="2" fill={EDR.gray} />
|
||||
<rect x="27.5" y="43" width="9" height="2" fill={EDR.gray} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Container() {
|
||||
return (
|
||||
<>
|
||||
{/* stacked containers */}
|
||||
<rect x="8" y="34" width="22" height="16" rx="1.5" fill={EDR.green} />
|
||||
<rect x="34" y="34" width="22" height="16" rx="1.5" fill={EDR.yellow} />
|
||||
<rect x="20" y="16" width="24" height="16" rx="1.5" fill={EDR.dark} />
|
||||
{/* corrugation lines */}
|
||||
{[12, 16, 20, 24].map((x) => (
|
||||
<rect key={`a${x}`} x={x} y="37" width="1.5" height="10" fill={EDR.greenSoft} />
|
||||
))}
|
||||
{[38, 42, 46, 50].map((x) => (
|
||||
<rect key={`b${x}`} x={x} y="37" width="1.5" height="10" fill={EDR.yellowSoft} />
|
||||
))}
|
||||
{[25, 29, 33, 37].map((x) => (
|
||||
<rect key={`c${x}`} x={x} y="19" width="1.5" height="10" fill={EDR.gray} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Wagon() {
|
||||
return (
|
||||
<>
|
||||
<rect x="4" y="50" width="56" height="3" rx="1.5" fill={EDR.graySoft} />
|
||||
{/* flatbed wagon */}
|
||||
<rect x="8" y="38" width="48" height="8" rx="1.5" fill={EDR.dark} />
|
||||
{/* cargo on wagon */}
|
||||
<rect x="14" y="22" width="16" height="16" rx="1.5" fill={EDR.green} />
|
||||
<rect x="34" y="26" width="16" height="12" rx="1.5" fill={EDR.yellow} />
|
||||
{/* wheels */}
|
||||
{[16, 26, 40, 50].map((cx) => (
|
||||
<circle key={cx} cx={cx} cy={48} r={3.2} fill={EDR.dark} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Cargo() {
|
||||
return (
|
||||
<>
|
||||
{/* cargo boxes */}
|
||||
<rect x="12" y="30" width="20" height="20" rx="2" fill={EDR.yellow} />
|
||||
<rect x="34" y="34" width="18" height="16" rx="2" fill={EDR.green} />
|
||||
{/* tape */}
|
||||
<rect x="21" y="30" width="2" height="20" fill={EDR.yellowSoft} />
|
||||
<rect x="12" y="38" width="20" height="2" fill={EDR.yellowSoft} />
|
||||
<rect x="42" y="34" width="2" height="16" fill={EDR.greenSoft} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Route() {
|
||||
return (
|
||||
<>
|
||||
{/* track line with stations */}
|
||||
<rect x="6" y="31" width="52" height="2" rx="1" fill={EDR.gray} />
|
||||
{[10, 22, 34, 46, 58].map((x) => (
|
||||
<rect key={x} x={x - 0.5} y="28" width="1.5" height="8" fill={EDR.graySoft} />
|
||||
))}
|
||||
<circle cx="10" cy="32" r="5" fill={EDR.green} />
|
||||
<circle cx="54" cy="32" r="5" fill={EDR.yellow} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
return (
|
||||
<>
|
||||
{/* empty open box */}
|
||||
<path d="M14 28 L32 22 L50 28 L50 30 L32 24 L14 30 Z" fill={EDR.gray} />
|
||||
<path d="M14 30 L32 36 L32 50 L14 44 Z" fill={EDR.graySoft} />
|
||||
<path d="M50 30 L32 36 L32 50 L50 44 Z" fill={EDR.graySoft} />
|
||||
<path d="M14 30 L32 24 L50 30 L32 36 Z" fill="#F8F9FA" />
|
||||
<circle cx="32" cy="14" r="2.5" fill={EDR.yellow} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const VARIANTS: Record<FreightVisualVariant, () => ReactElement> = {
|
||||
train: Train,
|
||||
warehouse: Warehouse,
|
||||
container: Container,
|
||||
wagon: Wagon,
|
||||
cargo: Cargo,
|
||||
route: Route,
|
||||
empty: Empty,
|
||||
};
|
||||
|
||||
export function FreightVisual({ variant, size = 64, style, className, title }: FreightVisualProps) {
|
||||
const Art = VARIANTS[variant];
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
role="img"
|
||||
aria-label={title ?? `${variant} illustration`}
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
{title ? <title>{title}</title> : null}
|
||||
<Art />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
FileInput,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
Switch,
|
||||
Textarea,
|
||||
} from '@mantine/core';
|
||||
import { Upload } from 'lucide-react';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useCreateInspectionReport, useUploadInspectionAttachments } from '@/hooks/useWarehouses';
|
||||
import {
|
||||
INSPECTION_REPORT_TYPES,
|
||||
INSPECTION_STATUSES,
|
||||
type InspectionReportType,
|
||||
type InspectionResultStatus,
|
||||
} from '@/types/warehouse';
|
||||
import { extractErrorMessage } from './options';
|
||||
|
||||
interface InspectionReportModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
inventoryId: string | null;
|
||||
}
|
||||
|
||||
const REPORT_TYPE_LABELS: Record<InspectionReportType, string> = {
|
||||
INSPECTION: 'Inspection',
|
||||
DAMAGE: 'Damage',
|
||||
WEIGHT_LOSS: 'Weight loss',
|
||||
MISSING_ITEM: 'Missing item',
|
||||
GENERAL: 'General',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<InspectionResultStatus, string> = {
|
||||
PASSED: 'Passed',
|
||||
FAILED: 'Failed',
|
||||
NEEDS_REVIEW: 'Needs review',
|
||||
};
|
||||
|
||||
/** Batch 4.5 — record an inspection / damage report with optional image upload. */
|
||||
export function InspectionReportModal({ opened, onClose, inventoryId }: InspectionReportModalProps) {
|
||||
const { toast } = useToast();
|
||||
const createReport = useCreateInspectionReport();
|
||||
const uploadAttachments = useUploadInspectionAttachments();
|
||||
|
||||
const [reportType, setReportType] = useState<InspectionReportType>('INSPECTION');
|
||||
const [inspectionStatus, setInspectionStatus] = useState<InspectionResultStatus>('PASSED');
|
||||
const [hasDamage, setHasDamage] = useState(false);
|
||||
const [damageDescription, setDamageDescription] = useState('');
|
||||
const [hasWeightLoss, setHasWeightLoss] = useState(false);
|
||||
const [expectedWeight, setExpectedWeight] = useState<number | ''>('');
|
||||
const [actualWeight, setActualWeight] = useState<number | ''>('');
|
||||
const [hasMissingItems, setHasMissingItems] = useState(false);
|
||||
const [missingItemsDescription, setMissingItemsDescription] = useState('');
|
||||
const [remarks, setRemarks] = useState('');
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
|
||||
const submitting = createReport.isPending || uploadAttachments.isPending;
|
||||
|
||||
const reset = () => {
|
||||
setReportType('INSPECTION');
|
||||
setInspectionStatus('PASSED');
|
||||
setHasDamage(false);
|
||||
setDamageDescription('');
|
||||
setHasWeightLoss(false);
|
||||
setExpectedWeight('');
|
||||
setActualWeight('');
|
||||
setHasMissingItems(false);
|
||||
setMissingItemsDescription('');
|
||||
setRemarks('');
|
||||
setFiles([]);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!inventoryId) return;
|
||||
try {
|
||||
const report = await createReport.mutateAsync({
|
||||
inventoryId,
|
||||
payload: {
|
||||
reportType,
|
||||
inspectionStatus,
|
||||
hasDamage,
|
||||
damageDescription: damageDescription.trim() || undefined,
|
||||
hasWeightLoss,
|
||||
expectedWeight: expectedWeight === '' ? undefined : Number(expectedWeight),
|
||||
actualWeight: actualWeight === '' ? undefined : Number(actualWeight),
|
||||
hasMissingItems,
|
||||
missingItemsDescription: missingItemsDescription.trim() || undefined,
|
||||
remarks: remarks.trim() || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (files.length > 0) {
|
||||
await uploadAttachments.mutateAsync({ reportId: report.id, files });
|
||||
}
|
||||
|
||||
toast({ title: 'Inspection report saved' });
|
||||
reset();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Save failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Inspection / Report" centered size="lg">
|
||||
<Group grow>
|
||||
<Select
|
||||
label="Report type"
|
||||
data={INSPECTION_REPORT_TYPES.map((t) => ({ value: t, label: REPORT_TYPE_LABELS[t] }))}
|
||||
value={reportType}
|
||||
onChange={(v) => setReportType((v as InspectionReportType) ?? 'INSPECTION')}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<Select
|
||||
label="Inspection status"
|
||||
data={INSPECTION_STATUSES.map((s) => ({ value: s, label: STATUS_LABELS[s] }))}
|
||||
value={inspectionStatus}
|
||||
onChange={(v) => setInspectionStatus((v as InspectionResultStatus) ?? 'PASSED')}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Divider my="md" label="Damage" labelPosition="left" />
|
||||
<Switch
|
||||
label="Has damage"
|
||||
checked={hasDamage}
|
||||
onChange={(e) => setHasDamage(e.currentTarget.checked)}
|
||||
color="orange"
|
||||
/>
|
||||
{hasDamage && (
|
||||
<Textarea
|
||||
mt="xs"
|
||||
label="Damage description"
|
||||
value={damageDescription}
|
||||
onChange={(e) => setDamageDescription(e.currentTarget.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Divider my="md" label="Weight loss" labelPosition="left" />
|
||||
<Switch
|
||||
label="Has weight loss"
|
||||
checked={hasWeightLoss}
|
||||
onChange={(e) => setHasWeightLoss(e.currentTarget.checked)}
|
||||
color="orange"
|
||||
/>
|
||||
{hasWeightLoss && (
|
||||
<Group grow mt="xs">
|
||||
<NumberInput
|
||||
label="Expected weight (kg)"
|
||||
min={0}
|
||||
value={expectedWeight}
|
||||
onChange={(v) => setExpectedWeight(v === '' ? '' : Number(v))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Actual weight (kg)"
|
||||
min={0}
|
||||
value={actualWeight}
|
||||
onChange={(v) => setActualWeight(v === '' ? '' : Number(v))}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Divider my="md" label="Missing items" labelPosition="left" />
|
||||
<Switch
|
||||
label="Has missing items"
|
||||
checked={hasMissingItems}
|
||||
onChange={(e) => setHasMissingItems(e.currentTarget.checked)}
|
||||
color="orange"
|
||||
/>
|
||||
{hasMissingItems && (
|
||||
<Textarea
|
||||
mt="xs"
|
||||
label="Missing items description"
|
||||
value={missingItemsDescription}
|
||||
onChange={(e) => setMissingItemsDescription(e.currentTarget.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Divider my="md" />
|
||||
<Textarea
|
||||
label="Remarks"
|
||||
value={remarks}
|
||||
onChange={(e) => setRemarks(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<FileInput
|
||||
mt="md"
|
||||
label="Images / documents"
|
||||
placeholder="Upload jpg, png or pdf"
|
||||
accept="image/jpeg,image/png,application/pdf"
|
||||
leftSection={<Upload size={16} />}
|
||||
multiple
|
||||
value={files}
|
||||
onChange={setFiles}
|
||||
clearable
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="lg">
|
||||
<Button variant="default" onClick={onClose} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="green" onClick={handleSubmit} loading={submitting} disabled={!inventoryId}>
|
||||
Save report
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Modal, Tabs } from '@mantine/core';
|
||||
import { ArrowRightLeft, ListChecks } from 'lucide-react';
|
||||
|
||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { ActivityTimeline } from './ActivityTimeline';
|
||||
import { InventoryMovementHistoryTable } from './InventoryMovementHistoryTable';
|
||||
|
||||
interface InventoryHistoryModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
item: WarehouseInventoryItem | null;
|
||||
}
|
||||
|
||||
export function InventoryHistoryModal({ opened, onClose, item }: InventoryHistoryModalProps) {
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Inventory history" centered size="xl">
|
||||
{item && (
|
||||
<Tabs defaultValue="activity">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="activity" leftSection={<ListChecks size={16} />}>
|
||||
Activity
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="movements" leftSection={<ArrowRightLeft size={16} />}>
|
||||
Movements
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="activity" pt="md">
|
||||
<ActivityTimeline inventoryId={item.id} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="movements" pt="md">
|
||||
<InventoryMovementHistoryTable inventoryId={item.id} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Center, Loader, Table, Text } from '@mantine/core';
|
||||
|
||||
import { useInventoryMovements } from '@/hooks/useWarehouses';
|
||||
import { formatDate } from './options';
|
||||
|
||||
const shortId = (id?: string | null) => (id ? `${id.slice(0, 8)}…` : '—');
|
||||
|
||||
export function InventoryMovementHistoryTable({ inventoryId }: { inventoryId: string }) {
|
||||
const { data, isLoading } = useInventoryMovements(inventoryId);
|
||||
const movements = data ?? [];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center py="lg">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (movements.length === 0) {
|
||||
return (
|
||||
<Text c="dimmed" ta="center" py="md" size="sm">
|
||||
No movements recorded for this item.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table.ScrollContainer minWidth={640}>
|
||||
<Table verticalSpacing="xs" striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>From (W / Y / Z)</Table.Th>
|
||||
<Table.Th>To (W / Y / Z)</Table.Th>
|
||||
<Table.Th>Remarks</Table.Th>
|
||||
<Table.Th>By</Table.Th>
|
||||
<Table.Th>When</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{movements.map((m) => (
|
||||
<Table.Tr key={m.id}>
|
||||
<Table.Td>
|
||||
<Text size="xs">
|
||||
{shortId(m.fromWarehouseId)} / {shortId(m.fromYardId)} / {shortId(m.fromZoneId)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs">
|
||||
{shortId(m.toWarehouseId)} / {shortId(m.toYardId)} / {shortId(m.toZoneId)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{m.remarks ?? '—'}</Table.Td>
|
||||
<Table.Td>{m.movedBy ?? '—'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs">{formatDate(m.movedAt)}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useState } from 'react';
|
||||
import { Center, Loader } from '@mantine/core';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
useDispatchInventory,
|
||||
useMarkReadyForLoading,
|
||||
useStoreInventory,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { FeePreviewModal } from './FeePreviewModal';
|
||||
import { InspectionReportModal } from './InspectionReportModal';
|
||||
import { InventoryHistoryModal } from './InventoryHistoryModal';
|
||||
import { LoadInventoryModal } from './LoadInventoryModal';
|
||||
import { MoveInventoryModal } from './MoveInventoryModal';
|
||||
import { ReserveInventoryModal } from './ReserveInventoryModal';
|
||||
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
|
||||
import { extractErrorMessage } from './options';
|
||||
|
||||
interface InventoryWorkbenchProps {
|
||||
items: WarehouseInventoryItem[];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
/** Inventory table + all lifecycle actions (advance / move / reserve / history). */
|
||||
export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps) {
|
||||
const { toast } = useToast();
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [reserveItem, setReserveItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [loadItem, setLoadItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [historyItem, setHistoryItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [inspectItem, setInspectItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [feeItem, setFeeItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
|
||||
const storeMutation = useStoreInventory();
|
||||
const readyMutation = useMarkReadyForLoading();
|
||||
const dispatchMutation = useDispatchInventory();
|
||||
|
||||
const runDirect = async (item: WarehouseInventoryItem, fn: () => Promise<unknown>, label: string) => {
|
||||
setBusyId(item.id);
|
||||
try {
|
||||
await fn();
|
||||
toast({ title: label });
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Action failed', description: extractErrorMessage(error) });
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const advance = (item: WarehouseInventoryItem, action: InventoryAction) => {
|
||||
switch (action) {
|
||||
case 'store':
|
||||
return runDirect(item, () => storeMutation.mutateAsync(item.id), 'Inventory stored');
|
||||
case 'reserve':
|
||||
setReserveItem(item);
|
||||
return;
|
||||
case 'ready-for-loading':
|
||||
return runDirect(item, () => readyMutation.mutateAsync(item.id), 'Ready for loading');
|
||||
case 'load':
|
||||
setLoadItem(item);
|
||||
return;
|
||||
case 'dispatch':
|
||||
return runDirect(item, () => dispatchMutation.mutateAsync(item.id), 'Inventory dispatched');
|
||||
default:
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<WarehouseInventoryTable
|
||||
items={items}
|
||||
busyId={busyId}
|
||||
onAdvance={advance}
|
||||
onMove={setMoveItem}
|
||||
onHistory={setHistoryItem}
|
||||
onInspect={setInspectItem}
|
||||
onFeePreview={setFeeItem}
|
||||
/>
|
||||
|
||||
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
|
||||
<ReserveInventoryModal
|
||||
opened={Boolean(reserveItem)}
|
||||
onClose={() => setReserveItem(null)}
|
||||
item={reserveItem}
|
||||
/>
|
||||
<LoadInventoryModal opened={Boolean(loadItem)} onClose={() => setLoadItem(null)} item={loadItem} />
|
||||
<InventoryHistoryModal
|
||||
opened={Boolean(historyItem)}
|
||||
onClose={() => setHistoryItem(null)}
|
||||
item={historyItem}
|
||||
/>
|
||||
<InspectionReportModal
|
||||
opened={Boolean(inspectItem)}
|
||||
onClose={() => setInspectItem(null)}
|
||||
inventoryId={inspectItem?.id ?? null}
|
||||
/>
|
||||
<FeePreviewModal
|
||||
opened={Boolean(feeItem)}
|
||||
onClose={() => setFeeItem(null)}
|
||||
inventoryId={feeItem?.id ?? null}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Group, Modal, NumberInput, Stack, Text, Textarea } from '@mantine/core';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useLoadInventory } from '@/hooks/useWarehouses';
|
||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { WagonSelect } from './WagonSelect';
|
||||
import { extractErrorMessage } from './options';
|
||||
|
||||
interface LoadInventoryModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
item: WarehouseInventoryItem | null;
|
||||
}
|
||||
|
||||
/** Load READY_FOR_LOADING inventory onto a wagon (creates a loading record). */
|
||||
export function LoadInventoryModal({ opened, onClose, item }: LoadInventoryModalProps) {
|
||||
const { toast } = useToast();
|
||||
const loadMutation = useLoadInventory();
|
||||
const [wagonId, setWagonId] = useState('');
|
||||
const [loadedWeight, setLoadedWeight] = useState<number | ''>('');
|
||||
const [notes, setNotes] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setWagonId('');
|
||||
setLoadedWeight(item?.weight ?? '');
|
||||
setNotes('');
|
||||
}
|
||||
}, [opened, item]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!item) return;
|
||||
if (!wagonId.trim()) {
|
||||
toast({ variant: 'destructive', title: 'Select a wagon' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await loadMutation.mutateAsync({
|
||||
id: item.id,
|
||||
payload: {
|
||||
wagonId: wagonId.trim(),
|
||||
loadedWeight: loadedWeight === '' ? undefined : Number(loadedWeight),
|
||||
notes: notes.trim() || undefined,
|
||||
},
|
||||
});
|
||||
toast({ title: 'Inventory loaded', description: 'Status set to LOADED' });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Load onto wagon" centered size="md">
|
||||
<Stack gap="md">
|
||||
<Alert icon={<Info size={16} />} color="blue" variant="light">
|
||||
<Text size="sm">
|
||||
The item must be <b>READY_FOR_LOADING</b> and the wagon must be available or already on a
|
||||
train schedule.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<WagonSelect label="Wagon" required value={wagonId} onChange={setWagonId} />
|
||||
|
||||
<NumberInput
|
||||
label="Loaded weight (kg)"
|
||||
placeholder="Defaults to item weight"
|
||||
min={0}
|
||||
value={loadedWeight}
|
||||
onChange={(v) => setLoadedWeight(v === '' ? '' : Number(v))}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Notes"
|
||||
placeholder="Optional"
|
||||
autosize
|
||||
minRows={2}
|
||||
value={notes}
|
||||
onChange={(e) => {
|
||||
const v = e.currentTarget.value;
|
||||
setNotes(v);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={loadMutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} loading={loadMutation.isPending}>
|
||||
Load
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Group, Modal, Select, Stack, Textarea } from '@mantine/core';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useMoveInventory, useWarehouseYards, useWarehouseZones, useWarehouses } from '@/hooks/useWarehouses';
|
||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { extractErrorMessage } from './options';
|
||||
|
||||
interface MoveInventoryModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
item: WarehouseInventoryItem | null;
|
||||
}
|
||||
|
||||
export function MoveInventoryModal({ opened, onClose, item }: MoveInventoryModalProps) {
|
||||
const { toast } = useToast();
|
||||
const moveMutation = useMoveInventory();
|
||||
const [warehouseId, setWarehouseId] = useState('');
|
||||
const [yardId, setYardId] = useState('');
|
||||
const [zoneId, setZoneId] = useState('');
|
||||
const [remarks, setRemarks] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setWarehouseId('');
|
||||
setYardId('');
|
||||
setZoneId('');
|
||||
setRemarks('');
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
const warehousesQuery = useWarehouses({ status: 'ACTIVE' });
|
||||
const yardsQuery = useWarehouseYards(warehouseId || undefined);
|
||||
const zonesQuery = useWarehouseZones(yardId || undefined);
|
||||
|
||||
const warehouseOptions = useMemo(
|
||||
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
|
||||
[warehousesQuery.data],
|
||||
);
|
||||
const yardOptions = useMemo(
|
||||
() => (yardsQuery.data ?? []).filter((y) => y.status === 'ACTIVE').map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
|
||||
[yardsQuery.data],
|
||||
);
|
||||
const zoneOptions = useMemo(
|
||||
() => (zonesQuery.data ?? []).filter((z) => z.status === 'ACTIVE').map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })),
|
||||
[zonesQuery.data],
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!item) return;
|
||||
if (!warehouseId || !yardId || !zoneId) {
|
||||
toast({ variant: 'destructive', title: 'Select destination warehouse, yard and zone' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await moveMutation.mutateAsync({
|
||||
id: item.id,
|
||||
payload: { warehouseId, yardId, zoneId, remarks: remarks.trim() || undefined },
|
||||
});
|
||||
toast({ title: 'Inventory moved' });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Move failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Move inventory" centered size="lg">
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label="Destination warehouse"
|
||||
placeholder="Select warehouse"
|
||||
required
|
||||
searchable
|
||||
data={warehouseOptions}
|
||||
value={warehouseId || null}
|
||||
onChange={(v) => {
|
||||
setWarehouseId(v ?? '');
|
||||
setYardId('');
|
||||
setZoneId('');
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
label="Destination yard"
|
||||
placeholder={!warehouseId ? 'Select a warehouse first' : 'Select yard'}
|
||||
required
|
||||
searchable
|
||||
disabled={!warehouseId}
|
||||
data={yardOptions}
|
||||
value={yardId || null}
|
||||
onChange={(v) => {
|
||||
setYardId(v ?? '');
|
||||
setZoneId('');
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
label="Destination zone"
|
||||
placeholder={!yardId ? 'Select a yard first' : 'Select zone'}
|
||||
required
|
||||
searchable
|
||||
disabled={!yardId}
|
||||
data={zoneOptions}
|
||||
value={zoneId || null}
|
||||
onChange={(v) => setZoneId(v ?? '')}
|
||||
/>
|
||||
<Textarea
|
||||
label="Remarks"
|
||||
placeholder="Reason for the move"
|
||||
autosize
|
||||
minRows={2}
|
||||
value={remarks}
|
||||
onChange={(e) => setRemarks(e.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={moveMutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} loading={moveMutation.isPending}>
|
||||
Move inventory
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Group, Modal, NumberInput, Select, Stack, Textarea, TextInput } from '@mantine/core';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
useReceiveInventory,
|
||||
useWarehouseYards,
|
||||
useWarehouseZones,
|
||||
useWarehouses,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import type { ReceiveInventoryPayload } from '@/types/warehouse';
|
||||
import { BookingSelect } from './BookingSelect';
|
||||
import { extractErrorMessage } from './options';
|
||||
|
||||
interface ReceiveInventoryModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
/** When supplied the booking field is locked to this booking. */
|
||||
bookingId?: string;
|
||||
bookingLabel?: string;
|
||||
onReceived?: () => void;
|
||||
}
|
||||
|
||||
interface FormState {
|
||||
bookingId: string;
|
||||
warehouseId: string;
|
||||
yardId: string;
|
||||
zoneId: string;
|
||||
quantity: number | '';
|
||||
weight: number | '';
|
||||
volume: number | '';
|
||||
notes: string;
|
||||
}
|
||||
|
||||
const emptyForm = (bookingId?: string): FormState => ({
|
||||
bookingId: bookingId ?? '',
|
||||
warehouseId: '',
|
||||
yardId: '',
|
||||
zoneId: '',
|
||||
quantity: '',
|
||||
weight: '',
|
||||
volume: '',
|
||||
notes: '',
|
||||
});
|
||||
|
||||
export function ReceiveInventoryModal({
|
||||
opened,
|
||||
onClose,
|
||||
bookingId,
|
||||
bookingLabel,
|
||||
onReceived,
|
||||
}: ReceiveInventoryModalProps) {
|
||||
const { toast } = useToast();
|
||||
const receiveMutation = useReceiveInventory();
|
||||
const [form, setForm] = useState<FormState>(emptyForm(bookingId));
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) setForm(emptyForm(bookingId));
|
||||
}, [opened, bookingId]);
|
||||
|
||||
// Cascading data — only ACTIVE warehouses are selectable for receiving.
|
||||
const warehousesQuery = useWarehouses({ status: 'ACTIVE' });
|
||||
const yardsQuery = useWarehouseYards(form.warehouseId || undefined);
|
||||
const zonesQuery = useWarehouseZones(form.yardId || undefined);
|
||||
|
||||
const warehouseOptions = useMemo(
|
||||
() =>
|
||||
(warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
|
||||
[warehousesQuery.data],
|
||||
);
|
||||
const yardOptions = useMemo(
|
||||
() =>
|
||||
(yardsQuery.data ?? [])
|
||||
.filter((y) => y.status === 'ACTIVE')
|
||||
.map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
|
||||
[yardsQuery.data],
|
||||
);
|
||||
const zoneOptions = useMemo(
|
||||
() =>
|
||||
(zonesQuery.data ?? [])
|
||||
.filter((z) => z.status === 'ACTIVE')
|
||||
.map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })),
|
||||
[zonesQuery.data],
|
||||
);
|
||||
|
||||
const submitting = receiveMutation.isPending;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.bookingId.trim()) {
|
||||
toast({ variant: 'destructive', title: 'Booking is required' });
|
||||
return;
|
||||
}
|
||||
if (!form.warehouseId || !form.yardId || !form.zoneId) {
|
||||
toast({ variant: 'destructive', title: 'Select warehouse, yard and zone' });
|
||||
return;
|
||||
}
|
||||
if (form.quantity === '' || form.weight === '') {
|
||||
toast({ variant: 'destructive', title: 'Quantity and weight are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: ReceiveInventoryPayload = {
|
||||
bookingId: form.bookingId.trim(),
|
||||
warehouseId: form.warehouseId,
|
||||
yardId: form.yardId,
|
||||
zoneId: form.zoneId,
|
||||
quantity: Number(form.quantity),
|
||||
weight: Number(form.weight),
|
||||
volume: form.volume === '' ? undefined : Number(form.volume),
|
||||
notes: form.notes.trim() || undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
await receiveMutation.mutateAsync(payload);
|
||||
toast({ title: 'Inventory received', description: 'Status set to ARRIVED_AT_WAREHOUSE' });
|
||||
onReceived?.();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Receive failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Receive at warehouse" centered size="lg">
|
||||
<Stack gap="md">
|
||||
{bookingId ? (
|
||||
<TextInput label="Booking" value={bookingLabel ?? bookingId} readOnly />
|
||||
) : (
|
||||
<BookingSelect
|
||||
label="Booking"
|
||||
value={form.bookingId}
|
||||
onChange={(v) => setForm((f) => ({ ...f, bookingId: v }))}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Select
|
||||
label="Warehouse"
|
||||
placeholder={warehousesQuery.isLoading ? 'Loading…' : 'Select warehouse'}
|
||||
required
|
||||
searchable
|
||||
data={warehouseOptions}
|
||||
value={form.warehouseId || null}
|
||||
onChange={(value) =>
|
||||
setForm((f) => ({ ...f, warehouseId: value ?? '', yardId: '', zoneId: '' }))
|
||||
}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Yard"
|
||||
placeholder={!form.warehouseId ? 'Select a warehouse first' : 'Select yard'}
|
||||
required
|
||||
searchable
|
||||
disabled={!form.warehouseId}
|
||||
data={yardOptions}
|
||||
value={form.yardId || null}
|
||||
onChange={(value) => setForm((f) => ({ ...f, yardId: value ?? '', zoneId: '' }))}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Zone"
|
||||
placeholder={!form.yardId ? 'Select a yard first' : 'Select zone'}
|
||||
required
|
||||
searchable
|
||||
disabled={!form.yardId}
|
||||
data={zoneOptions}
|
||||
value={form.zoneId || null}
|
||||
onChange={(value) => setForm((f) => ({ ...f, zoneId: value ?? '' }))}
|
||||
/>
|
||||
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Quantity"
|
||||
required
|
||||
min={0}
|
||||
value={form.quantity}
|
||||
onChange={(value) => setForm((f) => ({ ...f, quantity: value === '' ? '' : Number(value) }))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Weight (kg)"
|
||||
required
|
||||
min={0}
|
||||
value={form.weight}
|
||||
onChange={(value) => setForm((f) => ({ ...f, weight: value === '' ? '' : Number(value) }))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Volume (m³)"
|
||||
placeholder="Optional"
|
||||
min={0}
|
||||
value={form.volume}
|
||||
onChange={(value) => setForm((f) => ({ ...f, volume: value === '' ? '' : Number(value) }))}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Textarea
|
||||
label="Notes"
|
||||
placeholder="Optional notes"
|
||||
autosize
|
||||
minRows={2}
|
||||
value={form.notes}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setForm((f) => ({ ...f, notes: v })); }}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} loading={submitting}>
|
||||
Receive inventory
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Group, Modal, Stack, Text } from '@mantine/core';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useReserveInventory } from '@/hooks/useWarehouses';
|
||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { BookingSelect } from './BookingSelect';
|
||||
import { extractErrorMessage } from './options';
|
||||
|
||||
interface ReserveInventoryModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
item: WarehouseInventoryItem | null;
|
||||
}
|
||||
|
||||
export function ReserveInventoryModal({ opened, onClose, item }: ReserveInventoryModalProps) {
|
||||
const { toast } = useToast();
|
||||
const reserveMutation = useReserveInventory();
|
||||
const [bookingId, setBookingId] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) setBookingId(item?.bookingId ?? '');
|
||||
}, [opened, item]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!item) return;
|
||||
if (!bookingId.trim()) {
|
||||
toast({ variant: 'destructive', title: 'Booking is required' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await reserveMutation.mutateAsync({ inventoryId: item.id, bookingId: bookingId.trim() });
|
||||
toast({ title: 'Inventory reserved' });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Reserve failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Reserve inventory" centered size="md">
|
||||
<Stack gap="md">
|
||||
<Alert icon={<Info size={16} />} color="blue" variant="light">
|
||||
<Text size="sm">The booking must be in <b>PAID</b> status and the inventory must be <b>STORED</b>.</Text>
|
||||
</Alert>
|
||||
<BookingSelect label="Booking (PAID)" required statuses="PAID" value={bookingId} onChange={setBookingId} />
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={reserveMutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} loading={reserveMutation.isPending}>
|
||||
Reserve
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Center, Stack, Text } from '@mantine/core';
|
||||
|
||||
import { FreightVisual, type FreightVisualVariant } from './FreightVisual';
|
||||
|
||||
interface VisualEmptyStateProps {
|
||||
variant?: FreightVisualVariant;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
/** Friendly empty state with a small freight illustration. */
|
||||
export function VisualEmptyState({
|
||||
variant = 'empty',
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: VisualEmptyStateProps) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Stack align="center" gap="xs" maw={360}>
|
||||
<FreightVisual variant={variant} size={88} style={{ opacity: 0.85 }} />
|
||||
<Text fw={600} ta="center">
|
||||
{title}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
{action}
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Select } from '@mantine/core';
|
||||
|
||||
import { useLoadableWagons } from '@/hooks/useWarehouses';
|
||||
|
||||
interface WagonSelectProps {
|
||||
value: string;
|
||||
onChange: (wagonId: string) => void;
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
/** Searchable wagon picker. Lists wagons that are loadable (read-only from scheduling). */
|
||||
export function WagonSelect({ value, onChange, label = 'Wagon', required }: WagonSelectProps) {
|
||||
const { data, isLoading } = useLoadableWagons();
|
||||
|
||||
const options = (data ?? []).map((w) => ({
|
||||
value: w.id,
|
||||
label: `${w.wagonNumber} · ${w.status}`,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Select
|
||||
label={label}
|
||||
required={required}
|
||||
searchable
|
||||
clearable
|
||||
data={options}
|
||||
value={value || null}
|
||||
onChange={(v) => onChange(v ?? '')}
|
||||
placeholder={isLoading ? 'Loading wagons…' : 'Search wagon number'}
|
||||
nothingFoundMessage="No loadable wagons found"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ActionIcon, Card, Group, SimpleGrid, Stack, Text } from '@mantine/core';
|
||||
import { Building2, Eye, MapPin, Pencil } from 'lucide-react';
|
||||
|
||||
import { useStations } from '@/hooks/useStations';
|
||||
import type { Warehouse } from '@/types/warehouse';
|
||||
import { WarehouseStatusBadge, WarehouseTypeBadge } from './badges';
|
||||
import { formatCapacity } from './options';
|
||||
|
||||
interface WarehouseCardViewProps {
|
||||
warehouses: Warehouse[];
|
||||
onView: (warehouse: Warehouse) => void;
|
||||
onEdit: (warehouse: Warehouse) => void;
|
||||
}
|
||||
|
||||
export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardViewProps) {
|
||||
const { data: stations } = useStations();
|
||||
const stationNameById = useMemo(
|
||||
() => new Map((stations ?? []).map((s) => [s.id, s.name])),
|
||||
[stations],
|
||||
);
|
||||
|
||||
if (warehouses.length === 0) {
|
||||
return (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
No warehouses found.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{warehouses.map((warehouse) => (
|
||||
<Card key={warehouse.id} withBorder radius="md" padding="lg">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||
<div>
|
||||
<Text fw={700}>{warehouse.name}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{warehouse.code}
|
||||
</Text>
|
||||
</div>
|
||||
<WarehouseStatusBadge status={warehouse.status} />
|
||||
</Group>
|
||||
|
||||
<Group gap="xs">
|
||||
<WarehouseTypeBadge type={warehouse.type} />
|
||||
</Group>
|
||||
|
||||
{warehouse.stationId && stationNameById.get(warehouse.stationId) && (
|
||||
<Group gap={6} c="dimmed">
|
||||
<Building2 size={14} />
|
||||
<Text size="sm">{stationNameById.get(warehouse.stationId)}</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{warehouse.locationName && (
|
||||
<Group gap={6} c="dimmed">
|
||||
<MapPin size={14} />
|
||||
<Text size="sm">{warehouse.locationName}</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" c="dimmed">
|
||||
Weight
|
||||
</Text>
|
||||
<Text size="sm">{formatCapacity(warehouse.currentWeight, warehouse.capacityWeight)}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" c="dimmed">
|
||||
Containers
|
||||
</Text>
|
||||
<Text size="sm">{formatCapacity(warehouse.currentContainers, warehouse.capacityContainers)}</Text>
|
||||
</Group>
|
||||
|
||||
<Group justify="flex-end" gap="xs" mt="xs">
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => onView(warehouse)} title="View">
|
||||
<Eye size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => onEdit(warehouse)} title="Edit">
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Card, Group, SegmentedControl, SimpleGrid, Text, ThemeIcon } from '@mantine/core';
|
||||
import { BarChart3, CalendarRange, PieChart as PieChartIcon } from 'lucide-react';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Legend,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import { useWarehouseInventory } from '@/hooks/useWarehouses';
|
||||
import type { WarehouseDashboard, WarehouseInventoryItem } from '@/types/warehouse';
|
||||
|
||||
interface WarehouseDashboardChartsProps {
|
||||
data?: WarehouseDashboard;
|
||||
}
|
||||
|
||||
const ORANGE = '#f08c00';
|
||||
const GREEN = '#5bbf4a';
|
||||
|
||||
/** Inventory lifecycle status series — alternating orange / light green. */
|
||||
const STATUS_SERIES = [
|
||||
{ key: 'stored', label: 'Stored', color: ORANGE },
|
||||
{ key: 'reserved', label: 'Reserved', color: GREEN },
|
||||
{ key: 'readyForLoading', label: 'Ready', color: ORANGE },
|
||||
{ key: 'loaded', label: 'Loaded', color: GREEN },
|
||||
{ key: 'dispatched', label: 'Dispatched', color: ORANGE },
|
||||
] as const;
|
||||
|
||||
type Granularity = 'week' | 'month' | 'year';
|
||||
|
||||
export function WarehouseDashboardCharts({ data }: WarehouseDashboardChartsProps) {
|
||||
const [granularity, setGranularity] = useState<Granularity>('month');
|
||||
const { data: inventory } = useWarehouseInventory();
|
||||
|
||||
const statusData = STATUS_SERIES.map((s) => ({
|
||||
name: s.label,
|
||||
value: data ? Number(data[s.key as keyof WarehouseDashboard] ?? 0) : 0,
|
||||
color: s.color,
|
||||
}));
|
||||
const hasStatus = statusData.some((d) => d.value > 0);
|
||||
|
||||
const trend = useMemo(
|
||||
() => buildTrend(inventory ?? [], granularity),
|
||||
[inventory, granularity],
|
||||
);
|
||||
const hasTrend = trend.some((b) => b.received > 0 || b.dispatched > 0);
|
||||
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
|
||||
{/* Time-filtered throughput */}
|
||||
<Card withBorder radius="lg" padding="lg" style={{ gridColumn: '1 / -1' }}>
|
||||
<Group justify="space-between" mb="md" wrap="wrap">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size="lg" radius="md" style={{ backgroundColor: ORANGE, color: '#fff' }}>
|
||||
<CalendarRange size={20} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700}>Throughput Over Time</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Received vs dispatched inventory
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<SegmentedControl
|
||||
value={granularity}
|
||||
onChange={(v) => setGranularity(v as Granularity)}
|
||||
data={[
|
||||
{ label: 'Weekly', value: 'week' },
|
||||
{ label: 'Monthly', value: 'month' },
|
||||
{ label: 'Yearly', value: 'year' },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{hasTrend ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={trend} margin={{ top: 8, right: 8, left: -16, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--mantine-color-gray-2)" />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 12 }} />
|
||||
<YAxis allowDecimals={false} tick={{ fontSize: 12 }} />
|
||||
<Tooltip cursor={{ fill: 'var(--mantine-color-gray-1)' }} />
|
||||
<Legend iconType="circle" />
|
||||
<Bar dataKey="received" name="Received" fill={ORANGE} radius={[6, 6, 0, 0]} />
|
||||
<Bar dataKey="dispatched" name="Dispatched" fill={GREEN} radius={[6, 6, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<EmptyChart />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Status distribution bar */}
|
||||
<Card withBorder radius="lg" padding="lg">
|
||||
<Group gap="sm" mb="md">
|
||||
<ThemeIcon size="lg" radius="md" style={{ backgroundColor: GREEN, color: '#fff' }}>
|
||||
<BarChart3 size={20} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700}>Inventory by Status</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Items at each lifecycle stage
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{hasStatus ? (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<BarChart data={statusData} margin={{ top: 8, right: 8, left: -16, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--mantine-color-gray-2)" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 12 }} />
|
||||
<YAxis allowDecimals={false} tick={{ fontSize: 12 }} />
|
||||
<Tooltip cursor={{ fill: 'var(--mantine-color-gray-1)' }} />
|
||||
<Bar dataKey="value" name="Items" radius={[6, 6, 0, 0]}>
|
||||
{statusData.map((entry) => (
|
||||
<Cell key={entry.name} fill={entry.color} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<EmptyChart />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Status distribution donut */}
|
||||
<Card withBorder radius="lg" padding="lg">
|
||||
<Group gap="sm" mb="md">
|
||||
<ThemeIcon size="lg" radius="md" style={{ backgroundColor: ORANGE, color: '#fff' }}>
|
||||
<PieChartIcon size={20} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700}>Lifecycle Distribution</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Share of inventory across statuses
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{hasStatus ? (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={statusData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={55}
|
||||
outerRadius={95}
|
||||
paddingAngle={2}
|
||||
>
|
||||
{statusData.map((entry, i) => (
|
||||
<Cell key={entry.name} fill={i % 2 === 0 ? ORANGE : GREEN} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend verticalAlign="bottom" height={36} iconType="circle" />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<EmptyChart />
|
||||
)}
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
||||
interface TrendBucket {
|
||||
label: string;
|
||||
received: number;
|
||||
dispatched: number;
|
||||
}
|
||||
|
||||
/** Bucket inventory by arrived/dispatched timestamps into recent week/month/year periods. */
|
||||
function buildTrend(items: WarehouseInventoryItem[], granularity: Granularity): TrendBucket[] {
|
||||
const now = new Date();
|
||||
const buckets: { label: string; start: Date; end: Date }[] = [];
|
||||
|
||||
if (granularity === 'week') {
|
||||
for (let i = 7; i >= 0; i--) {
|
||||
const end = new Date(now);
|
||||
end.setDate(now.getDate() - i * 7);
|
||||
const start = new Date(end);
|
||||
start.setDate(end.getDate() - 7);
|
||||
buckets.push({ label: `W${8 - i}`, start, end });
|
||||
}
|
||||
} else if (granularity === 'month') {
|
||||
for (let i = 11; i >= 0; i--) {
|
||||
const start = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
const end = new Date(now.getFullYear(), now.getMonth() - i + 1, 1);
|
||||
buckets.push({
|
||||
label: start.toLocaleString('en', { month: 'short' }),
|
||||
start,
|
||||
end,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for (let i = 4; i >= 0; i--) {
|
||||
const year = now.getFullYear() - i;
|
||||
buckets.push({
|
||||
label: String(year),
|
||||
start: new Date(year, 0, 1),
|
||||
end: new Date(year + 1, 0, 1),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const inRange = (iso: string | null | undefined, start: Date, end: Date) => {
|
||||
if (!iso) return false;
|
||||
const t = new Date(iso).getTime();
|
||||
return t >= start.getTime() && t < end.getTime();
|
||||
};
|
||||
|
||||
return buckets.map((b) => ({
|
||||
label: b.label,
|
||||
received: items.filter((it) => inRange(it.arrivedAt, b.start, b.end)).length,
|
||||
dispatched: items.filter((it) => inRange(it.dispatchedAt, b.start, b.end)).length,
|
||||
}));
|
||||
}
|
||||
|
||||
function EmptyChart() {
|
||||
return (
|
||||
<Group justify="center" align="center" h={280}>
|
||||
<Text c="dimmed" size="sm">
|
||||
No inventory data to chart yet.
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -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,57 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Box, Group, Stack, Text, Title } from '@mantine/core';
|
||||
|
||||
import { FreightVisual, type FreightVisualVariant } from './FreightVisual';
|
||||
|
||||
interface WarehouseHeroProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
/** Primary illustration shown on the right of the hero. */
|
||||
variant?: FreightVisualVariant;
|
||||
/** Optional secondary illustration tucked behind the primary. */
|
||||
secondaryVariant?: FreightVisualVariant;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Page header hero with a lightweight freight illustration. Low-contrast,
|
||||
* minimal — sets context without overpowering the data below.
|
||||
*/
|
||||
export function WarehouseHero({
|
||||
title,
|
||||
subtitle,
|
||||
variant = 'warehouse',
|
||||
secondaryVariant,
|
||||
actions,
|
||||
}: WarehouseHeroProps) {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #F8F9FA 0%, #F1F3F5 100%)',
|
||||
border: '1px solid #E9ECEF',
|
||||
borderRadius: 'var(--mantine-radius-md)',
|
||||
padding: 'var(--mantine-spacing-lg)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" align="center">
|
||||
<Stack gap={4}>
|
||||
<Title order={3}>{title}</Title>
|
||||
{subtitle && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
{actions && <Group mt="sm">{actions}</Group>}
|
||||
</Stack>
|
||||
|
||||
<Group gap="xs" wrap="nowrap" style={{ opacity: 0.95 }}>
|
||||
{secondaryVariant && (
|
||||
<FreightVisual variant={secondaryVariant} size={56} style={{ opacity: 0.7 }} />
|
||||
)}
|
||||
<FreightVisual variant={variant} size={84} />
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useState } from 'react';
|
||||
import { Badge, Button, Card, Divider, Group, Stack, Text } from '@mantine/core';
|
||||
import { PackagePlus, Train as TrainIcon, Warehouse as WarehouseIcon } from 'lucide-react';
|
||||
|
||||
import { useBookingSchedule, useWarehouseInventory } from '@/hooks/useWarehouses';
|
||||
import { InventoryStatusBadge } from './badges';
|
||||
import { FreightVisual } from './FreightVisual';
|
||||
import { formatDate } from './options';
|
||||
import { ReceiveInventoryModal } from './ReceiveInventoryModal';
|
||||
|
||||
interface WarehouseInfoCardProps {
|
||||
bookingId: string;
|
||||
bookingReference?: string;
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" fw={500} ta="right">
|
||||
{value}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export function WarehouseInfoCard({ bookingId, bookingReference }: WarehouseInfoCardProps) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const { data, isLoading } = useWarehouseInventory({ bookingId });
|
||||
const { data: scheduleView } = useBookingSchedule(bookingId);
|
||||
|
||||
const items = data ?? [];
|
||||
const latest = items[0];
|
||||
const schedule = scheduleView?.schedule;
|
||||
const wagon = scheduleView?.wagon;
|
||||
const isLoadedOrDispatched =
|
||||
latest?.status === 'LOADED' || latest?.status === 'DISPATCHED';
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Group gap="xs">
|
||||
<WarehouseIcon size={18} />
|
||||
<Text fw={700}>Warehouse Information</Text>
|
||||
</Group>
|
||||
{items.length > 0 && (
|
||||
<Badge variant="light" color="gray">
|
||||
{items.length} item{items.length > 1 ? 's' : ''}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Divider />
|
||||
|
||||
{isLoading ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
Loading…
|
||||
</Text>
|
||||
) : !latest ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
This booking has not been received at any warehouse yet.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
<Row label="Warehouse" value={latest.warehouse ? `${latest.warehouse.name} (${latest.warehouse.code})` : '—'} />
|
||||
<Row label="Yard" value={latest.yard ? `${latest.yard.name} (${latest.yard.code})` : '—'} />
|
||||
<Row label="Zone" value={latest.zone ? `${latest.zone.name} (${latest.zone.code})` : '—'} />
|
||||
<Row label="Inventory Status" value={<InventoryStatusBadge status={latest.status} />} />
|
||||
<Row label="Arrived At" value={formatDate(latest.arrivedAt)} />
|
||||
<Row label="Ready For Loading At" value={formatDate(latest.readyForLoadingAt)} />
|
||||
{isLoadedOrDispatched && (
|
||||
<>
|
||||
<Row
|
||||
label="Wagon"
|
||||
value={wagon?.wagonNumber ?? '—'}
|
||||
/>
|
||||
<Row label="Loaded At" value={formatDate(latest.loadedAt)} />
|
||||
<Row label="Dispatched At" value={formatDate(latest.dispatchedAt)} />
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{schedule && (
|
||||
<>
|
||||
<Divider
|
||||
label={
|
||||
<Group gap={6}>
|
||||
<TrainIcon size={14} />
|
||||
<Text size="xs" c="dimmed">
|
||||
Train schedule (read-only)
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
labelPosition="left"
|
||||
/>
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<FreightVisual variant="train" size={40} />
|
||||
<Stack gap="xs" style={{ flex: 1 }}>
|
||||
<Row
|
||||
label="Departure Status"
|
||||
value={
|
||||
<Badge variant="light" color="blue" size="sm">
|
||||
{schedule.status}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
<Row label="Scheduled Departure" value={formatDate(schedule.scheduledDepartureDate)} />
|
||||
<Row label="Scheduled Arrival" value={formatDate(schedule.scheduledArrivalDate)} />
|
||||
{wagon?.wagonNumber && <Row label="Assigned Wagon" value={wagon.wagonNumber} />}
|
||||
{wagon?.sequenceNo != null && <Row label="Wagon Position" value={`#${wagon.sequenceNo}`} />}
|
||||
</Stack>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<PackagePlus size={16} />}
|
||||
onClick={() => setModalOpen(true)}
|
||||
fullWidth
|
||||
>
|
||||
Receive At Warehouse
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<ReceiveInventoryModal
|
||||
opened={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
bookingId={bookingId}
|
||||
bookingLabel={bookingReference}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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,154 @@
|
||||
import { ActionIcon, Badge, Button, Group, Table, Text, Tooltip } from '@mantine/core';
|
||||
import { ArrowRightLeft, ClipboardList, Coins, History } from 'lucide-react';
|
||||
|
||||
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { INVENTORY_NEXT_ACTION } from '@/types/warehouse';
|
||||
import { InventoryStatusBadge } from './badges';
|
||||
import { formatDate, formatNumber, humanizeEnum } from './options';
|
||||
|
||||
interface WarehouseInventoryTableProps {
|
||||
items: WarehouseInventoryItem[];
|
||||
busyId?: string | null;
|
||||
onAdvance: (item: WarehouseInventoryItem, action: InventoryAction) => void;
|
||||
onMove: (item: WarehouseInventoryItem) => void;
|
||||
onHistory: (item: WarehouseInventoryItem) => void;
|
||||
onInspect?: (item: WarehouseInventoryItem) => void;
|
||||
onFeePreview?: (item: WarehouseInventoryItem) => void;
|
||||
}
|
||||
|
||||
const itemKind = (item: WarehouseInventoryItem) => {
|
||||
if (item.containerId) return { label: 'Container', color: 'blue' };
|
||||
if (item.cargoId) return { label: 'Cargo', color: 'grape' };
|
||||
if (item.goodsId) return { label: 'Goods', color: 'orange' };
|
||||
return { label: '—', color: 'gray' };
|
||||
};
|
||||
|
||||
const actionColor: Record<InventoryAction, string> = {
|
||||
store: 'blue',
|
||||
reserve: 'grape',
|
||||
'ready-for-loading': 'cyan',
|
||||
load: 'teal',
|
||||
dispatch: 'green',
|
||||
};
|
||||
|
||||
export function WarehouseInventoryTable({
|
||||
items,
|
||||
busyId,
|
||||
onAdvance,
|
||||
onMove,
|
||||
onHistory,
|
||||
onInspect,
|
||||
onFeePreview,
|
||||
}: WarehouseInventoryTableProps) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
No inventory items found.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table.ScrollContainer minWidth={1150}>
|
||||
<Table highlightOnHover verticalSpacing="sm" striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>Facility</Table.Th>
|
||||
<Table.Th>Warehouse</Table.Th>
|
||||
<Table.Th>Yard</Table.Th>
|
||||
<Table.Th>Zone</Table.Th>
|
||||
<Table.Th>Item</Table.Th>
|
||||
<Table.Th>Qty</Table.Th>
|
||||
<Table.Th>Weight</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Arrived</Table.Th>
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((item) => {
|
||||
const kind = itemKind(item);
|
||||
const busy = busyId === item.id;
|
||||
const nextAction = INVENTORY_NEXT_ACTION[item.status];
|
||||
return (
|
||||
<Table.Tr key={item.id}>
|
||||
<Table.Td>
|
||||
{item.bookingId ? (
|
||||
<Tooltip label={item.bookingId} withArrow>
|
||||
<Text size="sm" fw={600}>
|
||||
{item.bookingId.slice(0, 8)}…
|
||||
</Text>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{item.warehouse?.facility?.name ?? '—'}</Table.Td>
|
||||
<Table.Td>{item.warehouse?.code ?? '—'}</Table.Td>
|
||||
<Table.Td>{item.yard?.code ?? '—'}</Table.Td>
|
||||
<Table.Td>{item.zone?.code ?? '—'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={kind.color} variant="light" size="sm" radius="md">
|
||||
{kind.label}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>{formatNumber(item.quantity)}</Table.Td>
|
||||
<Table.Td>{formatNumber(item.weight)}</Table.Td>
|
||||
<Table.Td>
|
||||
<InventoryStatusBadge status={item.status} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs">{formatDate(item.arrivedAt)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
{nextAction && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color={actionColor[nextAction]}
|
||||
loading={busy}
|
||||
onClick={() => onAdvance(item, nextAction)}
|
||||
>
|
||||
{humanizeEnum(nextAction.replace(/-/g, '_'))}
|
||||
</Button>
|
||||
)}
|
||||
{item.status !== 'DISPATCHED' && (
|
||||
<Tooltip label="Move" withArrow>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => onMove(item)}>
|
||||
<ArrowRightLeft size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onInspect && (
|
||||
<Tooltip label="Inspection / Report" withArrow>
|
||||
<ActionIcon variant="subtle" color="orange" onClick={() => onInspect(item)}>
|
||||
<ClipboardList size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onFeePreview && (
|
||||
<Tooltip label="Storage / Demurrage preview" withArrow>
|
||||
<ActionIcon variant="subtle" color="teal" onClick={() => onFeePreview(item)}>
|
||||
<Coins size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label="History" withArrow>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => onHistory(item)}>
|
||||
<History size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ActionIcon, Anchor, Group, Table, Text } from '@mantine/core';
|
||||
import { Eye, Pencil } from 'lucide-react';
|
||||
|
||||
import { useStations } from '@/hooks/useStations';
|
||||
import type { Warehouse } from '@/types/warehouse';
|
||||
import { WarehouseStatusBadge, WarehouseTypeBadge } from './badges';
|
||||
import { formatCapacity } from './options';
|
||||
|
||||
interface WarehouseTableProps {
|
||||
warehouses: Warehouse[];
|
||||
onView: (warehouse: Warehouse) => void;
|
||||
onEdit: (warehouse: Warehouse) => void;
|
||||
}
|
||||
|
||||
export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTableProps) {
|
||||
const { data: stations } = useStations();
|
||||
const stationNameById = useMemo(
|
||||
() => new Map((stations ?? []).map((s) => [s.id, s.name])),
|
||||
[stations],
|
||||
);
|
||||
|
||||
if (warehouses.length === 0) {
|
||||
return (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
No warehouses found.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table.ScrollContainer minWidth={900}>
|
||||
<Table highlightOnHover verticalSpacing="sm" striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Code</Table.Th>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Facility</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Location</Table.Th>
|
||||
<Table.Th>Weight (cur / cap)</Table.Th>
|
||||
<Table.Th>Containers (cur / cap)</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{warehouses.map((warehouse) => (
|
||||
<Table.Tr key={warehouse.id}>
|
||||
<Table.Td>
|
||||
<Anchor fw={600} size="sm" onClick={() => onView(warehouse)}>
|
||||
{warehouse.code}
|
||||
</Anchor>
|
||||
</Table.Td>
|
||||
<Table.Td>{warehouse.name}</Table.Td>
|
||||
<Table.Td>
|
||||
{warehouse.stationId && stationNameById.get(warehouse.stationId) ? (
|
||||
<Text size="sm" fw={500}>
|
||||
{stationNameById.get(warehouse.stationId)}
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<WarehouseTypeBadge type={warehouse.type} />
|
||||
</Table.Td>
|
||||
<Table.Td>{warehouse.locationName ?? '—'}</Table.Td>
|
||||
<Table.Td>{formatCapacity(warehouse.currentWeight, warehouse.capacityWeight)}</Table.Td>
|
||||
<Table.Td>{formatCapacity(warehouse.currentContainers, warehouse.capacityContainers)}</Table.Td>
|
||||
<Table.Td>
|
||||
<WarehouseStatusBadge status={warehouse.status} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => onView(warehouse)} title="View">
|
||||
<Eye size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => onEdit(warehouse)} title="Edit">
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Badge } from '@mantine/core';
|
||||
|
||||
import type { InventoryStatus, WarehouseStatus, WarehouseType } from '@/types/warehouse';
|
||||
|
||||
const humanize = (value: string) =>
|
||||
value
|
||||
.toLowerCase()
|
||||
.split('_')
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
|
||||
const badgeStyle = {
|
||||
fontSize: '0.7rem',
|
||||
letterSpacing: '0.04em',
|
||||
whiteSpace: 'nowrap' as const,
|
||||
};
|
||||
|
||||
export function WarehouseTypeBadge({ type }: { type: WarehouseType }) {
|
||||
const color = type === 'CLOSED_WAREHOUSE' ? 'indigo' : 'teal';
|
||||
return (
|
||||
<Badge color={color} variant="light" size="sm" radius="md" fw={600} style={badgeStyle}>
|
||||
{humanize(type)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function WarehouseStatusBadge({ status }: { status: WarehouseStatus }) {
|
||||
const color = status === 'ACTIVE' ? 'green' : 'gray';
|
||||
return (
|
||||
<Badge color={color} variant="light" size="sm" radius="md" tt="uppercase" fw={600} style={badgeStyle}>
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
const inventoryStatusColor: Record<InventoryStatus, string> = {
|
||||
RECEIVED: 'yellow',
|
||||
STORED: 'blue',
|
||||
RESERVED: 'grape',
|
||||
READY_FOR_LOADING: 'cyan',
|
||||
LOADED: 'teal',
|
||||
DISPATCHED: 'green',
|
||||
};
|
||||
|
||||
export function InventoryStatusBadge({ status }: { status: InventoryStatus }) {
|
||||
const color = inventoryStatusColor[status] ?? 'gray';
|
||||
return (
|
||||
<Badge color={color} variant="light" size="sm" radius="md" fw={600} style={badgeStyle}>
|
||||
{humanize(status)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export * from './badges';
|
||||
export * from './options';
|
||||
export { WarehouseFilters } from './WarehouseFilters';
|
||||
export type { WarehouseView } from './WarehouseFilters';
|
||||
export { WarehouseTable } from './WarehouseTable';
|
||||
export { WarehouseCardView } from './WarehouseCardView';
|
||||
export { WarehouseInventoryTable } from './WarehouseInventoryTable';
|
||||
export { WarehouseInquiryTable } from './WarehouseInquiryTable';
|
||||
export { CreateWarehouseModal } from './CreateWarehouseModal';
|
||||
export { CreateYardModal } from './CreateYardModal';
|
||||
export { CreateZoneModal } from './CreateZoneModal';
|
||||
export { ReceiveInventoryModal } from './ReceiveInventoryModal';
|
||||
export { WarehouseInfoCard } from './WarehouseInfoCard';
|
||||
export { MoveInventoryModal } from './MoveInventoryModal';
|
||||
export { ReserveInventoryModal } from './ReserveInventoryModal';
|
||||
export { InventoryMovementHistoryTable } from './InventoryMovementHistoryTable';
|
||||
export { ActivityTimeline } from './ActivityTimeline';
|
||||
export { InventoryHistoryModal } from './InventoryHistoryModal';
|
||||
export { InventoryWorkbench } from './InventoryWorkbench';
|
||||
export { BookingSelect } from './BookingSelect';
|
||||
export { WagonSelect } from './WagonSelect';
|
||||
export { LoadInventoryModal } from './LoadInventoryModal';
|
||||
export { FreightVisual } from './FreightVisual';
|
||||
export type { FreightVisualVariant } from './FreightVisual';
|
||||
export { WarehouseHero } from './WarehouseHero';
|
||||
export { VisualEmptyState } from './VisualEmptyState';
|
||||
export { WarehouseDashboardCharts } from './WarehouseDashboardCharts';
|
||||
export { InspectionReportModal } from './InspectionReportModal';
|
||||
export { FeePreviewModal } from './FeePreviewModal';
|
||||
@@ -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;
|
||||
};
|
||||
@@ -259,4 +259,77 @@ export const URL_CONSTANTS = {
|
||||
CONTAINER_TYPES: '/api/reference/container-types',
|
||||
CURRENCIES: '/api/reference/currencies',
|
||||
},
|
||||
|
||||
FACILITIES: {
|
||||
BASE: '/facilities',
|
||||
BY_ID: (id: string) => `/facilities/${id}`,
|
||||
},
|
||||
|
||||
WAREHOUSES: {
|
||||
BASE: '/warehouses',
|
||||
DASHBOARD: '/warehouses/dashboard',
|
||||
BY_ID: (id: string) => `/warehouses/${id}`,
|
||||
YARDS: (warehouseId: string) => `/warehouses/${warehouseId}/yards`,
|
||||
},
|
||||
|
||||
WAREHOUSE_YARDS: {
|
||||
BY_ID: (id: string) => `/warehouse-yards/${id}`,
|
||||
ZONES: (yardId: string) => `/warehouse-yards/${yardId}/zones`,
|
||||
},
|
||||
|
||||
WAREHOUSE_ZONES: {
|
||||
BY_ID: (id: string) => `/warehouse-zones/${id}`,
|
||||
},
|
||||
|
||||
WAREHOUSE_INVENTORY: {
|
||||
BASE: '/warehouse-inventory',
|
||||
RECEIVE: '/warehouse-inventory/receive',
|
||||
RESERVE: '/warehouse-inventory/reserve',
|
||||
ARRIVAL_QUEUE: '/warehouse-inventory/arrival-queue',
|
||||
AUTO_UNLOAD_ARRIVED: '/warehouse-inventory/auto-unload-arrived',
|
||||
AUTO_LOAD_READY: '/warehouse-inventory/auto-load-ready',
|
||||
UNLOAD_BOOKING: (bookingId: string) => `/warehouse-inventory/bookings/${bookingId}/unload`,
|
||||
INSPECTION_REPORTS: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/inspection-reports`,
|
||||
READY_FOR_LOADING: '/warehouse-inventory/ready-for-loading',
|
||||
INQUIRY: '/warehouse-inventory/inquiry',
|
||||
LOADABLE_WAGONS: '/warehouse-inventory/loadable-wagons',
|
||||
BOOKING_SCHEDULE: (bookingId: string) => `/warehouse-inventory/booking/${bookingId}/schedule`,
|
||||
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
|
||||
MOVEMENTS: (id: string) => `/warehouse-inventory/${id}/movements`,
|
||||
ACTIVITY: (id: string) => `/warehouse-inventory/${id}/activity`,
|
||||
LOADINGS: (id: string) => `/warehouse-inventory/${id}/loadings`,
|
||||
STORE: (id: string) => `/warehouse-inventory/${id}/store`,
|
||||
MARK_READY: (id: string) => `/warehouse-inventory/${id}/ready-for-loading`,
|
||||
LOAD: (id: string) => `/warehouse-inventory/${id}/load`,
|
||||
DISPATCH: (id: string) => `/warehouse-inventory/${id}/dispatch`,
|
||||
},
|
||||
|
||||
WAREHOUSE_LOADINGS: {
|
||||
BASE: '/warehouse-loadings',
|
||||
},
|
||||
|
||||
WAREHOUSE_INSPECTION: {
|
||||
BY_ID: (id: string) => `/warehouse-inspection-reports/${id}`,
|
||||
ATTACHMENTS: (id: string) => `/warehouse-inspection-reports/${id}/attachments`,
|
||||
},
|
||||
|
||||
WAREHOUSE_RULES: {
|
||||
ALLOCATION: '/warehouse-allocation-rules',
|
||||
ALLOCATION_BY_ID: (id: string) => `/warehouse-allocation-rules/${id}`,
|
||||
ALLOCATION_PREVIEW: '/warehouse-allocation/preview',
|
||||
FEES: '/warehouse-fee-rules',
|
||||
FEES_BY_ID: (id: string) => `/warehouse-fee-rules/${id}`,
|
||||
FEE_PREVIEW: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/fee-preview`,
|
||||
},
|
||||
|
||||
WAREHOUSE_INVOICES: {
|
||||
BASE: '/warehouse-fee-invoices',
|
||||
BY_ID: (id: string) => `/warehouse-fee-invoices/${id}`,
|
||||
CANCEL: (id: string) => `/warehouse-fee-invoices/${id}/cancel`,
|
||||
PAY: (id: string) => `/warehouse-fee-invoices/${id}/pay`,
|
||||
GENERATE: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/generate-fee-invoice`,
|
||||
FOR_INVENTORY: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/fee-invoices`,
|
||||
FOR_BOOKING: (bookingId: string) => `/bookings/${bookingId}/warehouse-fee-invoices`,
|
||||
GATE_CLEARANCE: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/gate-clearance`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { cargoService } from '@/services/cargoService';
|
||||
import { cargoService, type DeliverCargoPayload } from '@/services/cargoService';
|
||||
|
||||
export const cargoKeys = {
|
||||
all: ['cargoes'] as const,
|
||||
@@ -53,7 +53,8 @@ export function useLoadCargo() {
|
||||
export function useDeliverCargo() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => cargoService.deliver(id),
|
||||
mutationFn: ({ id, payload }: { id: string; payload?: DeliverCargoPayload }) =>
|
||||
cargoService.deliver(id, payload),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: cargoKeys.all })
|
||||
});
|
||||
}
|
||||
|
||||
16
apps/edr-freight-web/backoffice/src/hooks/useFacilities.ts
Normal file
16
apps/edr-freight-web/backoffice/src/hooks/useFacilities.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { facilityService } from '@/services/facility.service';
|
||||
|
||||
export const facilityKeys = {
|
||||
all: ['facilities'] as const,
|
||||
list: () => ['facilities', 'list'] as const,
|
||||
detail: (id: string) => ['facilities', 'detail', id] as const,
|
||||
};
|
||||
|
||||
export function useFacilities() {
|
||||
return useQuery({
|
||||
queryKey: facilityKeys.list(),
|
||||
queryFn: () => facilityService.list().then((r) => r.data),
|
||||
});
|
||||
}
|
||||
16
apps/edr-freight-web/backoffice/src/hooks/useStations.ts
Normal file
16
apps/edr-freight-web/backoffice/src/hooks/useStations.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { trainSchedulingService } from '@/services/trainScheduling.service';
|
||||
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
|
||||
|
||||
/**
|
||||
* The 21 network stations / yards, sourced from the existing booking
|
||||
* reference-data API. Reused as the parent "Facility / Port" for warehouses.
|
||||
*/
|
||||
export function useStations() {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.stations(),
|
||||
queryFn: () => trainSchedulingService.getStations(),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
410
apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts
Normal file
410
apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts
Normal file
@@ -0,0 +1,410 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
import type {
|
||||
InspectionReportPayload,
|
||||
SaveAllocationRulePayload,
|
||||
SaveFeeRulePayload,
|
||||
WarehouseInvoiceFilter,
|
||||
PayInvoicePayload,
|
||||
InventoryFilter,
|
||||
InventoryInquiryFilter,
|
||||
LoadInventoryPayload,
|
||||
MoveInventoryPayload,
|
||||
ReceiveInventoryPayload,
|
||||
ReserveInventoryPayload,
|
||||
SaveWarehousePayload,
|
||||
SaveYardPayload,
|
||||
SaveZonePayload,
|
||||
WarehouseFilter,
|
||||
} from '@/types/warehouse';
|
||||
|
||||
export const warehouseKeys = {
|
||||
all: ['warehouses'] as const,
|
||||
list: (filter?: WarehouseFilter) => ['warehouses', 'list', filter ?? {}] as const,
|
||||
detail: (id: string) => ['warehouses', 'detail', id] as const,
|
||||
yards: (warehouseId: string) => ['warehouses', warehouseId, 'yards'] as const,
|
||||
zones: (yardId: string) => ['warehouse-yards', yardId, 'zones'] as const,
|
||||
inventory: (filter?: InventoryFilter) => ['warehouse-inventory', 'list', filter ?? {}] as const,
|
||||
inquiry: (filter: InventoryInquiryFilter) => ['warehouse-inventory', 'inquiry', filter] as const,
|
||||
};
|
||||
|
||||
// ── Warehouses ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function useWarehouses(filter?: WarehouseFilter) {
|
||||
return useQuery({
|
||||
queryKey: warehouseKeys.list(filter),
|
||||
queryFn: () => warehouseService.list(filter).then((r) => r.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useWarehouse(id?: string) {
|
||||
return useQuery({
|
||||
queryKey: warehouseKeys.detail(id ?? ''),
|
||||
queryFn: () => warehouseService.getById(id as string).then((r) => r.data),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateWarehouse() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (payload: SaveWarehousePayload) => warehouseService.create(payload),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: warehouseKeys.all }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateWarehouse() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, payload }: { id: string; payload: Partial<SaveWarehousePayload> }) =>
|
||||
warehouseService.update(id, payload),
|
||||
onSuccess: (_, { id }) => {
|
||||
qc.invalidateQueries({ queryKey: warehouseKeys.all });
|
||||
qc.invalidateQueries({ queryKey: warehouseKeys.detail(id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Yards ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useWarehouseYards(warehouseId?: string) {
|
||||
return useQuery({
|
||||
queryKey: warehouseKeys.yards(warehouseId ?? ''),
|
||||
queryFn: () => warehouseService.listYards(warehouseId as string).then((r) => r.data),
|
||||
enabled: Boolean(warehouseId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateYard() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ warehouseId, payload }: { warehouseId: string; payload: SaveYardPayload }) =>
|
||||
warehouseService.createYard(warehouseId, payload),
|
||||
onSuccess: (_, { warehouseId }) => {
|
||||
qc.invalidateQueries({ queryKey: warehouseKeys.yards(warehouseId) });
|
||||
qc.invalidateQueries({ queryKey: warehouseKeys.detail(warehouseId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateYard() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, payload }: { id: string; payload: Partial<SaveYardPayload> }) =>
|
||||
warehouseService.updateYard(id, payload),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: warehouseKeys.all }),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Zones ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useWarehouseZones(yardId?: string) {
|
||||
return useQuery({
|
||||
queryKey: warehouseKeys.zones(yardId ?? ''),
|
||||
queryFn: () => warehouseService.listZones(yardId as string).then((r) => r.data),
|
||||
enabled: Boolean(yardId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateZone() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ yardId, payload }: { yardId: string; payload: SaveZonePayload }) =>
|
||||
warehouseService.createZone(yardId, payload),
|
||||
onSuccess: (_, { yardId }) => qc.invalidateQueries({ queryKey: warehouseKeys.zones(yardId) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateZone() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, payload }: { id: string; payload: Partial<SaveZonePayload> }) =>
|
||||
warehouseService.updateZone(id, payload),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['warehouse-yards'] }),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Inventory ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function useWarehouseInventory(filter?: InventoryFilter) {
|
||||
return useQuery({
|
||||
queryKey: warehouseKeys.inventory(filter),
|
||||
queryFn: () => warehouseService.listInventory(filter).then((r) => r.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useReceiveInventory() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (payload: ReceiveInventoryPayload) => warehouseService.receiveInventory(payload),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||
qc.invalidateQueries({ queryKey: warehouseKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function useInventoryMutation<TArgs>(fn: (args: TArgs) => Promise<unknown>) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: fn,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||
qc.invalidateQueries({ queryKey: ['warehouse-loadings'] });
|
||||
qc.invalidateQueries({ queryKey: warehouseKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const useStoreInventory = () => useInventoryMutation((id: string) => warehouseService.store(id));
|
||||
export const useReserveInventory = () =>
|
||||
useInventoryMutation((payload: ReserveInventoryPayload) => warehouseService.reserve(payload));
|
||||
export const useMarkReadyForLoading = () =>
|
||||
useInventoryMutation((id: string) => warehouseService.markReadyForLoading(id));
|
||||
export const useLoadInventory = () =>
|
||||
useInventoryMutation((args: { id: string; payload: LoadInventoryPayload }) =>
|
||||
warehouseService.load(args.id, args.payload),
|
||||
);
|
||||
export const useDispatchInventory = () => useInventoryMutation((id: string) => warehouseService.dispatch(id));
|
||||
export const useMoveInventory = () =>
|
||||
useInventoryMutation((args: { id: string; payload: MoveInventoryPayload }) =>
|
||||
warehouseService.move(args.id, args.payload),
|
||||
);
|
||||
|
||||
// ── Loading (Batch 3) ────────────────────────────────────────────────────────
|
||||
|
||||
export function useLoadableWagons(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse', 'loadable-wagons'],
|
||||
queryFn: () => warehouseService.loadableWagons().then((r) => r.data),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useWarehouseLoadings(params?: { bookingId?: string; wagonId?: string }) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-loadings', params ?? {}],
|
||||
queryFn: () => warehouseService.loadings(params).then((r) => r.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useBookingSchedule(bookingId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse', 'booking-schedule', bookingId ?? ''],
|
||||
queryFn: () => warehouseService.bookingSchedule(bookingId as string).then((r) => r.data),
|
||||
enabled: Boolean(bookingId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useInventoryMovements(id?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', id, 'movements'],
|
||||
queryFn: () => warehouseService.movements(id as string).then((r) => r.data),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
}
|
||||
|
||||
export function useInventoryActivity(id?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', id, 'activity'],
|
||||
queryFn: () => warehouseService.activity(id as string).then((r) => r.data),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
}
|
||||
|
||||
export function useWarehouseDashboard() {
|
||||
return useQuery({
|
||||
queryKey: ['warehouses', 'dashboard'],
|
||||
queryFn: () => warehouseService.dashboard().then((r) => r.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useInventoryInquiry(filter: InventoryInquiryFilter, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: warehouseKeys.inquiry(filter),
|
||||
queryFn: () => warehouseService.inquiry(filter).then((r) => r.data),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Batch 4.5: Arrival / Unload / Inspection ────────────────────────────────
|
||||
|
||||
export function useArrivalQueue() {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', 'arrival-queue'],
|
||||
queryFn: () => warehouseService.arrivalQueue().then((r) => r.data),
|
||||
});
|
||||
}
|
||||
|
||||
function useArrivalInvalidation() {
|
||||
const qc = useQueryClient();
|
||||
return () => {
|
||||
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||
qc.invalidateQueries({ queryKey: warehouseKeys.all });
|
||||
};
|
||||
}
|
||||
|
||||
export function useAutoUnloadArrived() {
|
||||
const onSuccess = useArrivalInvalidation();
|
||||
return useMutation({ mutationFn: () => warehouseService.autoUnloadArrived(), onSuccess });
|
||||
}
|
||||
|
||||
export function useAutoLoadReady() {
|
||||
const onSuccess = useArrivalInvalidation();
|
||||
return useMutation({ mutationFn: () => warehouseService.autoLoadReady(), onSuccess });
|
||||
}
|
||||
|
||||
export function useUnloadBooking() {
|
||||
const onSuccess = useArrivalInvalidation();
|
||||
return useMutation({
|
||||
mutationFn: (args: { bookingId: string; payload?: Record<string, unknown> }) =>
|
||||
warehouseService.unloadBooking(args.bookingId, args.payload),
|
||||
onSuccess,
|
||||
});
|
||||
}
|
||||
|
||||
export function useInspectionReports(inventoryId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', inventoryId, 'inspection-reports'],
|
||||
queryFn: () => warehouseService.listInspectionReports(inventoryId as string).then((r) => r.data),
|
||||
enabled: Boolean(inventoryId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateInspectionReport() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ inventoryId, payload }: { inventoryId: string; payload: InspectionReportPayload }) =>
|
||||
warehouseService.createInspectionReport(inventoryId, payload).then((r) => r.data),
|
||||
onSuccess: (_, { inventoryId }) => {
|
||||
qc.invalidateQueries({ queryKey: ['warehouse-inventory', inventoryId, 'inspection-reports'] });
|
||||
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUploadInspectionAttachments() {
|
||||
return useMutation({
|
||||
mutationFn: ({ reportId, files }: { reportId: string; files: File[] }) =>
|
||||
warehouseService.uploadInspectionAttachments(reportId, files),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Batch 5: Allocation + Fee rules / preview ───────────────────────────────
|
||||
|
||||
export function useAllocationRules() {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-allocation-rules'],
|
||||
queryFn: () => warehouseService.listAllocationRules().then((r) => r.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useFeeRules() {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-fee-rules'],
|
||||
queryFn: () => warehouseService.listFeeRules().then((r) => r.data),
|
||||
});
|
||||
}
|
||||
|
||||
function useRuleMutation<TArgs>(fn: (args: TArgs) => Promise<unknown>, keys: string[]) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: fn,
|
||||
onSuccess: () => keys.forEach((k) => qc.invalidateQueries({ queryKey: [k] })),
|
||||
});
|
||||
}
|
||||
|
||||
export const useCreateAllocationRule = () =>
|
||||
useRuleMutation(
|
||||
(payload: SaveAllocationRulePayload) => warehouseService.createAllocationRule(payload),
|
||||
['warehouse-allocation-rules'],
|
||||
);
|
||||
export const useUpdateAllocationRule = () =>
|
||||
useRuleMutation(
|
||||
(args: { id: string; payload: Partial<SaveAllocationRulePayload> }) =>
|
||||
warehouseService.updateAllocationRule(args.id, args.payload),
|
||||
['warehouse-allocation-rules'],
|
||||
);
|
||||
export const useDeleteAllocationRule = () =>
|
||||
useRuleMutation((id: string) => warehouseService.deleteAllocationRule(id), ['warehouse-allocation-rules']);
|
||||
|
||||
export const useCreateFeeRule = () =>
|
||||
useRuleMutation((payload: SaveFeeRulePayload) => warehouseService.createFeeRule(payload), ['warehouse-fee-rules']);
|
||||
export const useUpdateFeeRule = () =>
|
||||
useRuleMutation(
|
||||
(args: { id: string; payload: Partial<SaveFeeRulePayload> }) =>
|
||||
warehouseService.updateFeeRule(args.id, args.payload),
|
||||
['warehouse-fee-rules'],
|
||||
);
|
||||
export const useDeleteFeeRule = () =>
|
||||
useRuleMutation((id: string) => warehouseService.deleteFeeRule(id), ['warehouse-fee-rules']);
|
||||
|
||||
export function useFeePreview(inventoryId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', inventoryId, 'fee-preview'],
|
||||
queryFn: () => warehouseService.feePreview(inventoryId as string).then((r) => r.data),
|
||||
enabled: Boolean(inventoryId),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Batch 6: Warehouse fee invoices ─────────────────────────────────────────
|
||||
|
||||
export function useWarehouseInvoices(filter?: WarehouseInvoiceFilter) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-fee-invoices', filter ?? {}],
|
||||
queryFn: () => warehouseService.listInvoices(filter).then((r) => r.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useWarehouseInvoice(id?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-fee-invoices', 'detail', id],
|
||||
queryFn: () => warehouseService.getInvoice(id as string).then((r) => r.data),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
}
|
||||
|
||||
export function useInvoicesForInventory(inventoryId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', inventoryId, 'fee-invoices'],
|
||||
queryFn: () => warehouseService.invoicesForInventory(inventoryId as string).then((r) => r.data),
|
||||
enabled: Boolean(inventoryId),
|
||||
});
|
||||
}
|
||||
|
||||
function useInvoiceInvalidation() {
|
||||
const qc = useQueryClient();
|
||||
return () => {
|
||||
qc.invalidateQueries({ queryKey: ['warehouse-fee-invoices'] });
|
||||
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||
};
|
||||
}
|
||||
|
||||
export function useGenerateInvoice() {
|
||||
const onSuccess = useInvoiceInvalidation();
|
||||
return useMutation({
|
||||
mutationFn: ({ inventoryId, confirmZero }: { inventoryId: string; confirmZero?: boolean }) =>
|
||||
warehouseService.generateInvoice(inventoryId, confirmZero).then((r) => r.data),
|
||||
onSuccess,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCancelInvoice() {
|
||||
const onSuccess = useInvoiceInvalidation();
|
||||
return useMutation({ mutationFn: (id: string) => warehouseService.cancelInvoice(id), onSuccess });
|
||||
}
|
||||
|
||||
export function usePayInvoice() {
|
||||
const onSuccess = useInvoiceInvalidation();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, payload }: { id: string; payload: PayInvoicePayload }) =>
|
||||
warehouseService.payInvoice(id, payload),
|
||||
onSuccess,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGateClearance() {
|
||||
const onSuccess = useInvoiceInvalidation();
|
||||
return useMutation({ mutationFn: (inventoryId: string) => warehouseService.gateClearance(inventoryId), onSuccess });
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
BookingDocumentsCard,
|
||||
type BookingFileView,
|
||||
} from "@/components/bookings/detail";
|
||||
import { WarehouseInfoCard } from "@/components/warehouses";
|
||||
import { getStatusMeta } from "@/features/bookings/booking-status.config";
|
||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||
import { downloadBookingFile } from "@/services/files.service";
|
||||
@@ -184,6 +185,7 @@ export default function BookingRequestDetailPage() {
|
||||
<Stack gap="lg">
|
||||
<BookingCompanyCard booking={booking} />
|
||||
<BookingPricingSummary booking={booking} />
|
||||
<WarehouseInfoCard bookingId={booking.id} bookingReference={booking.reference} />
|
||||
<BookingActionsToolbar booking={booking} mutations={mutations} />
|
||||
{showContractButton && (
|
||||
<Button
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
import { FormEvent, ReactNode, useMemo, useState } from 'react';
|
||||
import { Edit, Eye, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge as MantineBadge,
|
||||
Box,
|
||||
Button as MantineButton,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Pagination,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Select as MantineSelect,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table as MantineTable,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -31,6 +50,7 @@ import {
|
||||
useUpdateContainer,
|
||||
} from '@/hooks/useContainers';
|
||||
import { useCreateTrain, useDeleteTrain, useTrains, useUpdateTrain } from '@/hooks/useTrains';
|
||||
import { useRouteYards } from '@/hooks/useRoutes';
|
||||
import { useCreateWagon, useDeleteWagon, useUpdateWagon, useWagons } from '@/hooks/useWagons';
|
||||
import {
|
||||
useCreateLocomotive,
|
||||
@@ -39,6 +59,7 @@ import {
|
||||
useUpdateLocomotive,
|
||||
} from '@/hooks/useLocomotives';
|
||||
import type { Cargo } from '@/services/cargoService';
|
||||
import { DeliverCargoDialog } from '@/components/cargoes/DeliverCargoDialog';
|
||||
import type { Container } from '@/services/containerService';
|
||||
import type { Locomotive } from '@/services/locomotives.service';
|
||||
import type { Train } from '@/services/trains.service';
|
||||
@@ -84,6 +105,8 @@ type FleetCrudPageProps<T extends { id: string }> = {
|
||||
removeConfirmMessage?: string;
|
||||
removeSuccessMessage?: string;
|
||||
hideViewAction?: boolean;
|
||||
/** Optional custom actions rendered before the view/edit/delete buttons in each row. */
|
||||
rowActions?: (item: T) => React.ReactNode;
|
||||
};
|
||||
|
||||
const normalizePayload = (values: Record<string, FormValue>) =>
|
||||
@@ -165,6 +188,7 @@ function FleetCrudPage<T extends { id: string }>({
|
||||
removeConfirmMessage,
|
||||
removeSuccessMessage,
|
||||
hideViewAction = false,
|
||||
rowActions,
|
||||
}: FleetCrudPageProps<T>) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -333,6 +357,7 @@ function FleetCrudPage<T extends { id: string }>({
|
||||
))}
|
||||
<TableCell>
|
||||
<div className="flex justify-end gap-1">
|
||||
{rowActions?.(item)}
|
||||
{!hideViewAction ? (
|
||||
<Button variant="ghost" size="icon" onClick={() => setViewing(item)} title="View">
|
||||
<Eye className="size-4" />
|
||||
@@ -522,74 +547,366 @@ export function TrainMasterDataPage() {
|
||||
|
||||
export function WagonTypesCrudPage() {
|
||||
const query = useWagonTypes();
|
||||
const create = useCreateWagonType();
|
||||
const update = useUpdateWagonType();
|
||||
const remove = useDeleteWagonType();
|
||||
const { toast } = useToast();
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [sortKey, setSortKey] = useState<keyof WagonType>('code');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<WagonType | null>(null);
|
||||
const [viewing, setViewing] = useState<WagonType | null>(null);
|
||||
const [form, setForm] = useState<Record<string, FormValue>>({
|
||||
code: '',
|
||||
name: '',
|
||||
capacityTons: 0,
|
||||
lengthMeters: 0,
|
||||
maxWagonsPerTrain: '',
|
||||
supportedLoadTypes: '',
|
||||
isActive: true,
|
||||
});
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const pageSize = 10;
|
||||
const filtered = useMemo(() => {
|
||||
const queryText = search.trim().toLowerCase();
|
||||
const rows = query.data ?? [];
|
||||
if (!queryText) return rows;
|
||||
return rows.filter((type) =>
|
||||
[type.code, type.name, type.supportedLoadTypes?.join(' '), type.isActive ? 'active' : 'inactive']
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(queryText),
|
||||
);
|
||||
}, [query.data, search]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
return [...filtered].sort((left, right) => {
|
||||
const result = String(left[sortKey] ?? '').localeCompare(String(right[sortKey] ?? ''), undefined, {
|
||||
numeric: true,
|
||||
});
|
||||
return sortDirection === 'asc' ? result : -result;
|
||||
});
|
||||
}, [filtered, sortDirection, sortKey]);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(sorted.length / pageSize));
|
||||
const paged = sorted.slice((page - 1) * pageSize, page * pageSize);
|
||||
const isSaving = create.isPending || update.isPending;
|
||||
|
||||
const toggleSort = (key: keyof WagonType) => {
|
||||
setPage(1);
|
||||
if (sortKey === key) {
|
||||
setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'));
|
||||
return;
|
||||
}
|
||||
setSortKey(key);
|
||||
setSortDirection('asc');
|
||||
};
|
||||
|
||||
const closeForm = () => {
|
||||
setFormOpen(false);
|
||||
setEditing(null);
|
||||
setFieldErrors({});
|
||||
setForm({
|
||||
code: '',
|
||||
name: '',
|
||||
capacityTons: 0,
|
||||
lengthMeters: 0,
|
||||
maxWagonsPerTrain: '',
|
||||
supportedLoadTypes: '',
|
||||
isActive: true,
|
||||
});
|
||||
};
|
||||
|
||||
const openEdit = (type: WagonType) => {
|
||||
setEditing(type);
|
||||
setFieldErrors({});
|
||||
setForm({
|
||||
code: type.code ?? '',
|
||||
name: type.name ?? '',
|
||||
capacityTons: type.capacityTons ?? 0,
|
||||
lengthMeters: type.lengthMeters ?? 0,
|
||||
maxWagonsPerTrain: type.maxWagonsPerTrain ?? '',
|
||||
supportedLoadTypes: type.supportedLoadTypes?.join(', ') ?? '',
|
||||
isActive: type.isActive,
|
||||
});
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const validateWagonType = () => {
|
||||
const errors: Record<string, string> = {};
|
||||
if (!String(form.code ?? '').trim()) errors.code = 'Code is required';
|
||||
if (!String(form.name ?? '').trim()) errors.name = 'Name is required';
|
||||
if (!Number.isFinite(Number(form.capacityTons))) errors.capacityTons = 'Capacity must be a valid number';
|
||||
if (!Number.isFinite(Number(form.lengthMeters))) errors.lengthMeters = 'Length must be a valid number';
|
||||
return errors;
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const errors = validateWagonType();
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setFieldErrors(errors);
|
||||
toast({ title: 'Save failed', description: Object.values(errors)[0], variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = normalizePayload(form);
|
||||
setFieldErrors({});
|
||||
|
||||
try {
|
||||
if (editing) {
|
||||
await update.mutateAsync({ id: editing.id, data: payload });
|
||||
toast({ title: 'Wagon Type updated' });
|
||||
} else {
|
||||
await create.mutateAsync(payload);
|
||||
toast({ title: 'Wagon Type created' });
|
||||
}
|
||||
closeForm();
|
||||
} catch (error) {
|
||||
const { message, fieldErrors: backendFieldErrors } = extractBackendErrors(error);
|
||||
setFieldErrors(backendFieldErrors);
|
||||
toast({ title: 'Save failed', description: message, variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (type: WagonType) => {
|
||||
if (!window.confirm('Delete this wagon type?')) return;
|
||||
try {
|
||||
await remove.mutateAsync(type.id);
|
||||
toast({ title: 'Wagon Type deleted' });
|
||||
} catch {
|
||||
toast({ title: 'Delete failed', description: 'This wagon type may still be referenced.', variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
const sortLabel = (key: keyof WagonType) => (sortKey === key ? (sortDirection === 'asc' ? ' ASC' : ' DESC') : '');
|
||||
|
||||
return (
|
||||
<FleetCrudPage<WagonType>
|
||||
title="Wagon Types"
|
||||
description="Manage wagon type capacities and load compatibility used by wagon master data."
|
||||
addLabel="Add Wagon Type"
|
||||
data={query.data}
|
||||
isLoading={query.isLoading}
|
||||
create={useCreateWagonType()}
|
||||
update={useUpdateWagonType()}
|
||||
remove={useDeleteWagonType()}
|
||||
searchText={(type) =>
|
||||
[type.code, type.name, type.supportedLoadTypes?.join(' '), String(type.isActive)].join(' ')
|
||||
}
|
||||
columns={[
|
||||
{ key: 'code', label: 'Code' },
|
||||
{ key: 'name', label: 'Name' },
|
||||
{ key: 'capacityTons', label: 'Capacity (tons)' },
|
||||
{ key: 'lengthMeters', label: 'Length (m)' },
|
||||
{
|
||||
key: 'supportedLoadTypes',
|
||||
label: 'Load types',
|
||||
render: (type) => type.supportedLoadTypes?.join(', ') || '-',
|
||||
},
|
||||
{ key: 'isActive', label: 'Status', render: (type) => activeBadge(type.isActive) },
|
||||
]}
|
||||
fields={[
|
||||
{ key: 'code', label: 'Code', required: true },
|
||||
{ key: 'name', label: 'Name', required: true },
|
||||
{ key: 'capacityTons', label: 'Capacity (tons)', type: 'number', required: true },
|
||||
{ key: 'lengthMeters', label: 'Length (meters)', type: 'number', required: true },
|
||||
{ key: 'maxWagonsPerTrain', label: 'Max wagons per train', type: 'number' },
|
||||
{
|
||||
key: 'supportedLoadTypes',
|
||||
label: 'Supported load types',
|
||||
placeholder: 'container, break-bulk',
|
||||
},
|
||||
{
|
||||
key: 'isActive',
|
||||
label: 'Status',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'true', label: 'Active' },
|
||||
{ value: 'false', label: 'Inactive' },
|
||||
],
|
||||
onValueChange: (value) => ({ isActive: value === 'true' }),
|
||||
},
|
||||
]}
|
||||
emptyValues={{
|
||||
code: '',
|
||||
name: '',
|
||||
capacityTons: 0,
|
||||
lengthMeters: 0,
|
||||
maxWagonsPerTrain: '',
|
||||
supportedLoadTypes: '',
|
||||
isActive: true,
|
||||
}}
|
||||
/>
|
||||
<Box p="lg">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<Box>
|
||||
<Title order={2}>Wagon Types</Title>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
Manage wagon type capacities and load compatibility used by wagon master data.
|
||||
</Text>
|
||||
</Box>
|
||||
<MantineButton
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => {
|
||||
closeForm();
|
||||
setFormOpen(true);
|
||||
}}
|
||||
>
|
||||
Add Wagon Type
|
||||
</MantineButton>
|
||||
</Group>
|
||||
|
||||
<TextInput
|
||||
maw={420}
|
||||
leftSection={<Search size={16} />}
|
||||
placeholder="Search wagon types"
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.currentTarget.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Paper withBorder radius="md">
|
||||
<ScrollArea>
|
||||
<MantineTable striped highlightOnHover verticalSpacing="sm" miw={900}>
|
||||
<MantineTable.Thead>
|
||||
<MantineTable.Tr>
|
||||
<MantineTable.Th>
|
||||
<MantineButton variant="subtle" size="compact-sm" onClick={() => toggleSort('code')}>
|
||||
Code{sortLabel('code')}
|
||||
</MantineButton>
|
||||
</MantineTable.Th>
|
||||
<MantineTable.Th>
|
||||
<MantineButton variant="subtle" size="compact-sm" onClick={() => toggleSort('name')}>
|
||||
Name{sortLabel('name')}
|
||||
</MantineButton>
|
||||
</MantineTable.Th>
|
||||
<MantineTable.Th>
|
||||
<MantineButton variant="subtle" size="compact-sm" onClick={() => toggleSort('capacityTons')}>
|
||||
Capacity (tons){sortLabel('capacityTons')}
|
||||
</MantineButton>
|
||||
</MantineTable.Th>
|
||||
<MantineTable.Th>Length (m)</MantineTable.Th>
|
||||
<MantineTable.Th>Load types</MantineTable.Th>
|
||||
<MantineTable.Th>Status</MantineTable.Th>
|
||||
<MantineTable.Th ta="right">Actions</MantineTable.Th>
|
||||
</MantineTable.Tr>
|
||||
</MantineTable.Thead>
|
||||
<MantineTable.Tbody>
|
||||
{paged.map((type) => (
|
||||
<MantineTable.Tr key={type.id}>
|
||||
<MantineTable.Td fw={600}>{type.code}</MantineTable.Td>
|
||||
<MantineTable.Td>{type.name}</MantineTable.Td>
|
||||
<MantineTable.Td>{type.capacityTons}</MantineTable.Td>
|
||||
<MantineTable.Td>{type.lengthMeters}</MantineTable.Td>
|
||||
<MantineTable.Td>{type.supportedLoadTypes?.join(', ') || '-'}</MantineTable.Td>
|
||||
<MantineTable.Td>
|
||||
<MantineBadge color={type.isActive === false ? 'gray' : 'green'} variant="light">
|
||||
{type.isActive === false ? 'Inactive' : 'Active'}
|
||||
</MantineBadge>
|
||||
</MantineTable.Td>
|
||||
<MantineTable.Td>
|
||||
<Group gap="xs" justify="flex-end">
|
||||
<ActionIcon variant="subtle" aria-label="View wagon type" onClick={() => setViewing(type)}>
|
||||
<Eye size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" aria-label="Edit wagon type" onClick={() => openEdit(type)}>
|
||||
<Edit size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
color="red"
|
||||
variant="subtle"
|
||||
aria-label="Delete wagon type"
|
||||
onClick={() => handleDelete(type)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</MantineTable.Td>
|
||||
</MantineTable.Tr>
|
||||
))}
|
||||
{!query.isLoading && filtered.length === 0 ? (
|
||||
<MantineTable.Tr>
|
||||
<MantineTable.Td colSpan={7}>
|
||||
<Text ta="center" c="dimmed" py="xl">
|
||||
No wagon types found.
|
||||
</Text>
|
||||
</MantineTable.Td>
|
||||
</MantineTable.Tr>
|
||||
) : null}
|
||||
{query.isLoading ? (
|
||||
<MantineTable.Tr>
|
||||
<MantineTable.Td colSpan={7}>
|
||||
<Text ta="center" c="dimmed" py="xl">
|
||||
Loading...
|
||||
</Text>
|
||||
</MantineTable.Td>
|
||||
</MantineTable.Tr>
|
||||
) : null}
|
||||
</MantineTable.Tbody>
|
||||
</MantineTable>
|
||||
</ScrollArea>
|
||||
</Paper>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
Showing {sorted.length === 0 ? 0 : (page - 1) * pageSize + 1}-{Math.min(page * pageSize, sorted.length)} of{' '}
|
||||
{sorted.length}
|
||||
</Text>
|
||||
<Pagination total={pageCount} value={page} onChange={setPage} size="sm" />
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Modal opened={formOpen} onClose={closeForm} title={editing ? 'Edit Wagon Type' : 'Add Wagon Type'} centered>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }}>
|
||||
<TextInput
|
||||
label="Code"
|
||||
required
|
||||
value={String(form.code ?? '')}
|
||||
error={fieldErrors.code}
|
||||
onChange={(event) => setForm((current) => ({ ...current, code: event.currentTarget.value }))}
|
||||
/>
|
||||
<TextInput
|
||||
label="Name"
|
||||
required
|
||||
value={String(form.name ?? '')}
|
||||
error={fieldErrors.name}
|
||||
onChange={(event) => setForm((current) => ({ ...current, name: event.currentTarget.value }))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Capacity (tons)"
|
||||
required
|
||||
min={0}
|
||||
value={Number(form.capacityTons ?? 0)}
|
||||
error={fieldErrors.capacityTons}
|
||||
onChange={(value) => setForm((current) => ({ ...current, capacityTons: value }))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Length (meters)"
|
||||
required
|
||||
min={0}
|
||||
value={Number(form.lengthMeters ?? 0)}
|
||||
error={fieldErrors.lengthMeters}
|
||||
onChange={(value) => setForm((current) => ({ ...current, lengthMeters: value }))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Max wagons per train"
|
||||
min={0}
|
||||
value={form.maxWagonsPerTrain === '' ? '' : Number(form.maxWagonsPerTrain)}
|
||||
onChange={(value) => setForm((current) => ({ ...current, maxWagonsPerTrain: value }))}
|
||||
/>
|
||||
<MantineSelect
|
||||
label="Status"
|
||||
value={form.isActive ? 'true' : 'false'}
|
||||
data={[
|
||||
{ value: 'true', label: 'Active' },
|
||||
{ value: 'false', label: 'Inactive' },
|
||||
]}
|
||||
onChange={(value) => setForm((current) => ({ ...current, isActive: value !== 'false' }))}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<TextInput
|
||||
label="Supported load types"
|
||||
placeholder="container, break-bulk"
|
||||
value={Array.isArray(form.supportedLoadTypes) ? form.supportedLoadTypes.join(', ') : String(form.supportedLoadTypes ?? '')}
|
||||
onChange={(event) => setForm((current) => ({ ...current, supportedLoadTypes: event.currentTarget.value }))}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<MantineButton variant="default" type="button" onClick={closeForm}>
|
||||
Cancel
|
||||
</MantineButton>
|
||||
<MantineButton type="submit" loading={isSaving}>
|
||||
Save
|
||||
</MantineButton>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal opened={Boolean(viewing)} onClose={() => setViewing(null)} title="Wagon Type details" centered>
|
||||
<Stack gap="xs">
|
||||
{viewing
|
||||
? Object.entries(viewing).map(([key, value]) => (
|
||||
<Group key={key} justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{key}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="right">
|
||||
{Array.isArray(value) ? value.join(', ') : value == null ? '-' : String(value)}
|
||||
</Text>
|
||||
</Group>
|
||||
))
|
||||
: null}
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function WagonsCrudPage() {
|
||||
const query = useWagons();
|
||||
const { data: wagonTypes = [] } = useWagonTypes();
|
||||
const { data: yards = [] } = useRouteYards();
|
||||
const wagonTypeOptions = wagonTypes.map((type: any) => ({
|
||||
value: type.id,
|
||||
label: `${type.code} - ${type.name}`,
|
||||
}));
|
||||
const yardOptions = yards.map((yard: any) => ({
|
||||
value: yard.id,
|
||||
label: `${yard.label ?? yard.code} (${yard.country ?? '-'})`,
|
||||
}));
|
||||
return (
|
||||
<FleetCrudPage<Wagon>
|
||||
title="Wagons"
|
||||
@@ -600,10 +917,26 @@ export function WagonsCrudPage() {
|
||||
create={useCreateWagon()}
|
||||
update={useUpdateWagon()}
|
||||
remove={useDeleteWagon()}
|
||||
searchText={(wagon) => [wagon.wagonNumber, wagon.wagonTypeId, wagon.trainId, wagon.status].join(' ')}
|
||||
searchText={(wagon) => [
|
||||
wagon.wagonNumber,
|
||||
wagon.wagonTypeId,
|
||||
wagon.trainId,
|
||||
wagon.status,
|
||||
wagon.currentLocationYard?.label,
|
||||
wagon.currentLocationYard?.code,
|
||||
wagon.currentLocationYard?.country,
|
||||
].join(' ')}
|
||||
columns={[
|
||||
{ key: 'wagonNumber', label: 'Number' },
|
||||
{ key: 'wagonTypeId', label: 'Type', render: (wagon) => optionLabel(wagonTypeOptions, wagon.wagonTypeId) },
|
||||
{
|
||||
key: 'currentLocationYardId',
|
||||
label: 'Location',
|
||||
render: (wagon) =>
|
||||
wagon.currentLocationYard
|
||||
? `${wagon.currentLocationYard.label ?? wagon.currentLocationYard.code} (${wagon.currentLocationYard.country ?? '-'})`
|
||||
: '-',
|
||||
},
|
||||
{ key: 'maxPayloadWeight', label: 'Max payload' },
|
||||
{ key: 'status', label: 'Status', render: (wagon) => statusBadge(wagon.status) },
|
||||
]}
|
||||
@@ -621,12 +954,31 @@ export function WagonsCrudPage() {
|
||||
return { maxPayloadWeight: Number(selectedType.capacityTons) };
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'currentLocationYardId',
|
||||
label: 'Wagon location',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: yardOptions,
|
||||
},
|
||||
{ key: 'tareWeight', label: 'Tare weight', type: 'number', required: true },
|
||||
{ key: 'maxPayloadWeight', label: 'Max payload weight', type: 'number', required: true },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'AVAILABLE', label: 'Available' },
|
||||
{ value: 'IMPORT_READY', label: 'Import ready' },
|
||||
{ value: 'EXPORT_READY', label: 'Export ready' },
|
||||
{ value: 'ASSIGNED', label: 'Assigned' },
|
||||
{ value: 'MAINTENANCE', label: 'Maintenance' },
|
||||
{ value: 'RETIRED', label: 'Retired' },
|
||||
],
|
||||
},
|
||||
{ key: 'notes', label: 'Notes' },
|
||||
]}
|
||||
emptyValues={{ wagonNumber: '', wagonTypeId: '', tareWeight: 0, maxPayloadWeight: 0, status: 'AVAILABLE', notes: '' }}
|
||||
emptyValues={{ wagonNumber: '', wagonTypeId: '', currentLocationYardId: '', tareWeight: 0, maxPayloadWeight: 0, status: 'AVAILABLE', notes: '' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -718,7 +1070,18 @@ export function CargoesCrudPage() {
|
||||
{ key: 'quantity', label: 'Quantity' },
|
||||
{ key: 'weight', label: 'Weight' },
|
||||
{ key: 'status', label: 'Status', render: (cargo) => statusBadge(cargo.status) },
|
||||
{
|
||||
key: 'receiverName',
|
||||
label: 'Proof of delivery',
|
||||
render: (cargo) =>
|
||||
cargo.status === 'DELIVERED' && cargo.receiverName
|
||||
? `${cargo.receiverName}${cargo.deliveredAt ? ` · ${new Date(cargo.deliveredAt).toLocaleDateString()}` : ''}`
|
||||
: '—',
|
||||
},
|
||||
]}
|
||||
rowActions={(cargo) =>
|
||||
cargo.status === 'LOADED' ? <DeliverCargoDialog cargoId={cargo.id} /> : null
|
||||
}
|
||||
fields={[
|
||||
{ key: 'cargoReference', label: 'Cargo reference', required: true },
|
||||
{ key: 'shipmentId', label: 'Shipment ID', required: true },
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import { ClipboardList, Eye, PackageOpen, Truck } from 'lucide-react';
|
||||
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import {
|
||||
InspectionReportModal,
|
||||
VisualEmptyState,
|
||||
WarehouseHero,
|
||||
formatDate,
|
||||
} from '@/components/warehouses';
|
||||
import { useArrivalQueue, useAutoUnloadArrived, useUnloadBooking } from '@/hooks/useWarehouses';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import type { ArrivalQueueItem } from '@/types/warehouse';
|
||||
|
||||
function inspectionBadge(status: string | null) {
|
||||
if (!status) return <Badge variant="light" color="gray" size="sm">Not inspected</Badge>;
|
||||
const color = status === 'PASSED' ? 'green' : status === 'FAILED' ? 'red' : 'orange';
|
||||
return <Badge variant="light" color={color} size="sm">{status.replace(/_/g, ' ')}</Badge>;
|
||||
}
|
||||
|
||||
/** Batch 4.5 — arrived bookings awaiting unload / inspection. */
|
||||
export default function ArrivalQueuePage() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { data, isLoading } = useArrivalQueue();
|
||||
const autoUnload = useAutoUnloadArrived();
|
||||
const unloadOne = useUnloadBooking();
|
||||
const [inspectInventoryId, setInspectInventoryId] = useState<string | null>(null);
|
||||
|
||||
const items = data ?? [];
|
||||
|
||||
const handleAutoUnload = async () => {
|
||||
try {
|
||||
const res = await autoUnload.mutateAsync();
|
||||
const r = res.data;
|
||||
toast({
|
||||
title: 'Auto-unload complete',
|
||||
description: `Processed ${r.processedCount}, skipped ${r.skippedCount}, failed ${r.failedCount}.`,
|
||||
});
|
||||
} catch {
|
||||
toast({ variant: 'destructive', title: 'Auto-unload failed' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnloadOne = async (item: ArrivalQueueItem) => {
|
||||
try {
|
||||
await unloadOne.mutateAsync({ bookingId: item.bookingId });
|
||||
toast({ title: 'Booking unloaded', description: `${item.bookingReference} stored as RECEIVED.` });
|
||||
} catch {
|
||||
toast({ variant: 'destructive', title: 'Unload failed' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs items={[{ label: 'Arrival queue' }]} />
|
||||
|
||||
<Stack gap="lg" mt="sm">
|
||||
<WarehouseHero
|
||||
variant="container"
|
||||
secondaryVariant="warehouse"
|
||||
title="Arrival / Unloading Queue"
|
||||
subtitle="Arrived bookings ready to unload, store and inspect."
|
||||
/>
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text fw={600}>{items.length} arrived booking(s)</Text>
|
||||
<Button
|
||||
color="orange"
|
||||
leftSection={<PackageOpen size={16} />}
|
||||
loading={autoUnload.isPending}
|
||||
onClick={handleAutoUnload}
|
||||
>
|
||||
Auto Unload Arrived Bookings
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : items.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="container"
|
||||
title="No arrived bookings"
|
||||
description="Bookings in transit that arrive appear here for unloading and inspection."
|
||||
/>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={1100}>
|
||||
<Table verticalSpacing="sm" highlightOnHover striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Cargo / Container</Table.Th>
|
||||
<Table.Th>Arrival</Table.Th>
|
||||
<Table.Th>Facility</Table.Th>
|
||||
<Table.Th>Warehouse</Table.Th>
|
||||
<Table.Th>Yard</Table.Th>
|
||||
<Table.Th>Zone</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Inspection</Table.Th>
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((item) => (
|
||||
<Table.Tr key={item.bookingId}>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">{item.bookingReference}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{item.customer ?? '—'}</Table.Td>
|
||||
<Table.Td>{item.container ?? item.cargo ?? '—'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs">{formatDate(item.arrivalDate)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{item.facility ?? '—'}</Table.Td>
|
||||
<Table.Td>{item.warehouse ?? '—'}</Table.Td>
|
||||
<Table.Td>{item.yard ?? '—'}</Table.Td>
|
||||
<Table.Td>{item.zone ?? '—'}</Table.Td>
|
||||
<Table.Td>
|
||||
{item.unloaded ? (
|
||||
<Badge variant="light" color="green" size="sm">
|
||||
{item.currentStatus ?? 'RECEIVED'}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="light" color="orange" size="sm">
|
||||
Not unloaded
|
||||
</Badge>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{inspectionBadge(item.inspectionStatus)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
{!item.unloaded && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<Truck size={14} />}
|
||||
loading={unloadOne.isPending}
|
||||
onClick={() => handleUnloadOne(item)}
|
||||
>
|
||||
Unload
|
||||
</Button>
|
||||
)}
|
||||
{item.inventoryId && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
leftSection={<ClipboardList size={14} />}
|
||||
onClick={() => setInspectInventoryId(item.inventoryId)}
|
||||
>
|
||||
Inspect
|
||||
</Button>
|
||||
)}
|
||||
{item.inventoryId && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<Eye size={14} />}
|
||||
onClick={() => navigate('/dashboard/warehouse-inventory')}
|
||||
>
|
||||
Inventory
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
<InspectionReportModal
|
||||
opened={Boolean(inspectInventoryId)}
|
||||
onClose={() => setInspectInventoryId(null)}
|
||||
inventoryId={inspectInventoryId}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Card, Container, Stack } from '@mantine/core';
|
||||
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import { InventoryWorkbench, VisualEmptyState, WarehouseHero } from '@/components/warehouses';
|
||||
import { useWarehouseInventory } from '@/hooks/useWarehouses';
|
||||
|
||||
/** Items that are LOADED and awaiting dispatch (train departure). */
|
||||
export default function DispatchQueuePage() {
|
||||
const { data, isLoading } = useWarehouseInventory({ status: 'LOADED' });
|
||||
const items = data ?? [];
|
||||
|
||||
return (
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs items={[{ label: 'Dispatch queue' }]} />
|
||||
|
||||
<Stack gap="lg" mt="sm">
|
||||
<WarehouseHero
|
||||
variant="train"
|
||||
secondaryVariant="route"
|
||||
title="Dispatch Queue"
|
||||
subtitle="Loaded inventory awaiting train departure. Mark items dispatched once they leave."
|
||||
/>
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
{!isLoading && items.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="train"
|
||||
title="Nothing to dispatch"
|
||||
description="Loaded items appear here, ready to mark as dispatched."
|
||||
/>
|
||||
) : (
|
||||
<InventoryWorkbench items={items} isLoading={isLoading} />
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Button, Card, Center, Container, Group, Loader, Select, Stack, Text, TextInput, Title } from '@mantine/core';
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import { VisualEmptyState, WarehouseInquiryTable, inventoryStatusOptions } from '@/components/warehouses';
|
||||
import {
|
||||
useInventoryInquiry,
|
||||
useWarehouseYards,
|
||||
useWarehouseZones,
|
||||
useWarehouses,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import type { InventoryInquiryFilter, InventoryStatus } from '@/types/warehouse';
|
||||
|
||||
export default function InventoryInquiryPage() {
|
||||
const [draft, setDraft] = useState<InventoryInquiryFilter>({});
|
||||
const [applied, setApplied] = useState<InventoryInquiryFilter>({});
|
||||
|
||||
const warehousesQuery = useWarehouses();
|
||||
const yardsQuery = useWarehouseYards(draft.warehouseId);
|
||||
const zonesQuery = useWarehouseZones(draft.yardId);
|
||||
|
||||
const { data, isFetching } = useInventoryInquiry(applied);
|
||||
const results = data ?? [];
|
||||
|
||||
const warehouseOptions = useMemo(
|
||||
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
|
||||
[warehousesQuery.data],
|
||||
);
|
||||
const yardOptions = useMemo(
|
||||
() => (yardsQuery.data ?? []).map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
|
||||
[yardsQuery.data],
|
||||
);
|
||||
const zoneOptions = useMemo(
|
||||
() => (zonesQuery.data ?? []).map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })),
|
||||
[zonesQuery.data],
|
||||
);
|
||||
|
||||
const runSearch = () => setApplied(draft);
|
||||
const reset = () => {
|
||||
setDraft({});
|
||||
setApplied({});
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs items={[{ label: 'Inventory inquiry' }]} />
|
||||
|
||||
<Stack gap="lg" mt="sm">
|
||||
<div>
|
||||
<Title order={2}>Inventory Inquiry</Title>
|
||||
<Text c="dimmed" size="sm">
|
||||
Locate any cargo, container or goods inside the warehouse network.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Stack gap="md">
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<TextInput
|
||||
label="Booking number"
|
||||
placeholder="e.g. BKG-00123"
|
||||
value={draft.bookingNumber ?? ''}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, bookingNumber: v || undefined })); }}
|
||||
w={200}
|
||||
/>
|
||||
<TextInput
|
||||
label="Container number"
|
||||
placeholder="e.g. MSKU1234567"
|
||||
value={draft.containerNumber ?? ''}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, containerNumber: v || undefined })); }}
|
||||
w={200}
|
||||
/>
|
||||
<TextInput
|
||||
label="Goods name"
|
||||
placeholder="e.g. Coffee"
|
||||
value={draft.goodsName ?? ''}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, goodsName: v || undefined })); }}
|
||||
w={180}
|
||||
/>
|
||||
<Select
|
||||
label="Warehouse"
|
||||
placeholder="Any"
|
||||
clearable
|
||||
searchable
|
||||
data={warehouseOptions}
|
||||
value={draft.warehouseId ?? null}
|
||||
onChange={(value) =>
|
||||
setDraft((f) => ({ ...f, warehouseId: value ?? undefined, yardId: undefined, zoneId: undefined }))
|
||||
}
|
||||
w={200}
|
||||
/>
|
||||
<Select
|
||||
label="Yard"
|
||||
placeholder="Any"
|
||||
clearable
|
||||
searchable
|
||||
disabled={!draft.warehouseId}
|
||||
data={yardOptions}
|
||||
value={draft.yardId ?? null}
|
||||
onChange={(value) => setDraft((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined }))}
|
||||
w={180}
|
||||
/>
|
||||
<Select
|
||||
label="Zone"
|
||||
placeholder="Any"
|
||||
clearable
|
||||
searchable
|
||||
disabled={!draft.yardId}
|
||||
data={zoneOptions}
|
||||
value={draft.zoneId ?? null}
|
||||
onChange={(value) => setDraft((f) => ({ ...f, zoneId: value ?? undefined }))}
|
||||
w={180}
|
||||
/>
|
||||
<Select
|
||||
label="Status"
|
||||
placeholder="Any"
|
||||
clearable
|
||||
data={inventoryStatusOptions}
|
||||
value={draft.status ?? null}
|
||||
onChange={(value) => setDraft((f) => ({ ...f, status: (value as InventoryStatus) ?? undefined }))}
|
||||
w={180}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group>
|
||||
<Button leftSection={<Search size={16} />} onClick={runSearch}>
|
||||
Search
|
||||
</Button>
|
||||
<Button variant="default" onClick={reset}>
|
||||
Reset
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
{isFetching ? (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
) : results.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="container"
|
||||
title="No items found"
|
||||
description="Adjust your filters and search to locate cargo, containers or goods across the warehouse network."
|
||||
/>
|
||||
) : (
|
||||
<WarehouseInquiryTable results={results} />
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Badge, Card, Container, Group, Loader, Stack, Table, Text } from '@mantine/core';
|
||||
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import { FreightVisual, VisualEmptyState, WarehouseHero, formatDate, formatNumber } from '@/components/warehouses';
|
||||
import { useWarehouseLoadings } from '@/hooks/useWarehouses';
|
||||
|
||||
/** Record of every inventory item loaded onto a wagon. */
|
||||
export default function LoadedInventoryPage() {
|
||||
const { data, isLoading } = useWarehouseLoadings();
|
||||
const loadings = data ?? [];
|
||||
|
||||
return (
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs items={[{ label: 'Loaded inventory' }]} />
|
||||
|
||||
<Stack gap="lg" mt="sm">
|
||||
<WarehouseHero
|
||||
variant="container"
|
||||
secondaryVariant="wagon"
|
||||
title="Loaded Inventory"
|
||||
subtitle="Items loaded onto wagons, with their loading records."
|
||||
/>
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : loadings.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="container"
|
||||
title="No loaded inventory yet"
|
||||
description="Once items are loaded onto a wagon, their records show here."
|
||||
/>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={760}>
|
||||
<Table verticalSpacing="sm" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Wagon</Table.Th>
|
||||
<Table.Th>Warehouse</Table.Th>
|
||||
<Table.Th>Zone</Table.Th>
|
||||
<Table.Th>Loaded Weight (kg)</Table.Th>
|
||||
<Table.Th>Loaded At</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{loadings.map((l) => (
|
||||
<Table.Tr key={l.id}>
|
||||
<Table.Td>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<FreightVisual variant="wagon" size={22} />
|
||||
<Text fw={600} size="sm">
|
||||
{l.wagonNumber ?? l.wagonId.slice(0, 8)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{l.inventory?.warehouse
|
||||
? `${l.inventory.warehouse.name} (${l.inventory.warehouse.code})`
|
||||
: '—'}
|
||||
</Table.Td>
|
||||
<Table.Td>{l.inventory?.zone ? l.inventory.zone.name : '—'}</Table.Td>
|
||||
<Table.Td>{formatNumber(l.loadedWeight)}</Table.Td>
|
||||
<Table.Td>{formatDate(l.loadedAt)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={l.inventory?.status === 'DISPATCHED' ? 'green' : 'teal'}
|
||||
size="sm"
|
||||
>
|
||||
{l.inventory?.status ?? 'LOADED'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Badge, Button, Card, Container, Group, Stack, Table, Tabs, Text } from '@mantine/core';
|
||||
import { CreditCard, Eye, Truck } from 'lucide-react';
|
||||
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import {
|
||||
InventoryWorkbench,
|
||||
VisualEmptyState,
|
||||
WarehouseHero,
|
||||
formatNumber,
|
||||
} from '@/components/warehouses';
|
||||
import { useAutoLoadReady, useWarehouseInventory } from '@/hooks/useWarehouses';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||
|
||||
const isPaid = (item: WarehouseInventoryItem) => item.booking?.status === 'PAID';
|
||||
|
||||
/**
|
||||
* Loading Queue — manage inventory through the loading workflow.
|
||||
* Tabs:
|
||||
* - Ready to Load: READY_FOR_LOADING + booking PAID (can Mark as Loaded)
|
||||
* - Pending Payment: READY_FOR_LOADING + booking not PAID (no Load action)
|
||||
* - Loaded Inventory: LOADED (can Dispatch)
|
||||
* - Dispatch Queue: LOADED (can Dispatch)
|
||||
*/
|
||||
export default function LoadingQueuePage() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const autoLoad = useAutoLoadReady();
|
||||
const { data: readyData, isLoading: readyLoading } = useWarehouseInventory({
|
||||
status: 'READY_FOR_LOADING',
|
||||
});
|
||||
const { data: loadedData, isLoading: loadedLoading } = useWarehouseInventory({ status: 'LOADED' });
|
||||
|
||||
const handleAutoLoad = async () => {
|
||||
try {
|
||||
const res = await autoLoad.mutateAsync();
|
||||
const r = res.data;
|
||||
toast({
|
||||
title: 'Auto-load complete',
|
||||
description: `Loaded ${r.loadedCount} PAID item(s); skipped ${r.skippedCount} (unpaid stay pending).`,
|
||||
});
|
||||
} catch {
|
||||
toast({ variant: 'destructive', title: 'Auto-load failed' });
|
||||
}
|
||||
};
|
||||
|
||||
const readyItems = readyData ?? [];
|
||||
const loadedItems = loadedData ?? [];
|
||||
|
||||
const paidItems = useMemo(() => readyItems.filter(isPaid), [readyItems]);
|
||||
const unpaidItems = useMemo(() => readyItems.filter((i) => !isPaid(i)), [readyItems]);
|
||||
|
||||
return (
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs items={[{ label: 'Loading queue' }]} />
|
||||
|
||||
<Stack gap="lg" mt="sm">
|
||||
<WarehouseHero
|
||||
variant="wagon"
|
||||
secondaryVariant="cargo"
|
||||
title="Loading Queue"
|
||||
subtitle="Manage bookings and inventory through the loading workflow."
|
||||
/>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
color="green"
|
||||
leftSection={<Truck size={16} />}
|
||||
loading={autoLoad.isPending}
|
||||
onClick={handleAutoLoad}
|
||||
>
|
||||
Auto Load Ready Items
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Tabs defaultValue="ready">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab
|
||||
value="ready"
|
||||
leftSection={
|
||||
<Badge size="xs" color="green">
|
||||
{paidItems.length}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
Ready to Load
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="pending"
|
||||
leftSection={
|
||||
<Badge size="xs" color="orange">
|
||||
{unpaidItems.length}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
Pending Payment
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="loaded"
|
||||
leftSection={
|
||||
<Badge size="xs" color="teal">
|
||||
{loadedItems.length}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
Loaded Inventory
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="dispatch"
|
||||
leftSection={
|
||||
<Badge size="xs" color="blue">
|
||||
{loadedItems.length}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
Dispatch Queue
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
{/* Ready to Load — PAID bookings, can be marked Loaded */}
|
||||
<Tabs.Panel value="ready" pt="md">
|
||||
{!readyLoading && paidItems.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="wagon"
|
||||
title="Nothing ready to load"
|
||||
description="Paid bookings marked Ready For Loading appear here, ready to load onto a wagon."
|
||||
/>
|
||||
) : (
|
||||
<InventoryWorkbench items={paidItems} isLoading={readyLoading} />
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* Pending Payment — unpaid bookings, read-only (no Load action) */}
|
||||
<Tabs.Panel value="pending" pt="md">
|
||||
{!readyLoading && unpaidItems.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="cargo"
|
||||
title="No unpaid bookings"
|
||||
description="Ready-for-loading items whose booking is not yet PAID appear here."
|
||||
/>
|
||||
) : (
|
||||
<PendingPaymentTable items={unpaidItems} onNavigate={navigate} />
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* Loaded Inventory — LOADED items, can Dispatch */}
|
||||
<Tabs.Panel value="loaded" pt="md">
|
||||
{!loadedLoading && loadedItems.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="container"
|
||||
title="No loaded inventory yet"
|
||||
description="Items loaded onto a wagon appear here, ready to dispatch."
|
||||
/>
|
||||
) : (
|
||||
<InventoryWorkbench items={loadedItems} isLoading={loadedLoading} />
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* Dispatch Queue — LOADED items awaiting departure */}
|
||||
<Tabs.Panel value="dispatch" pt="md">
|
||||
{!loadedLoading && loadedItems.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="train"
|
||||
title="Nothing to dispatch"
|
||||
description="Loaded items appear here, ready to mark as dispatched."
|
||||
/>
|
||||
) : (
|
||||
<InventoryWorkbench items={loadedItems} isLoading={loadedLoading} />
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
interface PendingPaymentTableProps {
|
||||
items: WarehouseInventoryItem[];
|
||||
onNavigate: (path: string) => void;
|
||||
}
|
||||
|
||||
/** Read-only view of unpaid ready-for-loading items. No Mark-as-Loaded action. */
|
||||
function PendingPaymentTable({ items, onNavigate }: PendingPaymentTableProps) {
|
||||
return (
|
||||
<Table.ScrollContainer minWidth={820}>
|
||||
<Table verticalSpacing="sm" highlightOnHover striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>Warehouse</Table.Th>
|
||||
<Table.Th>Zone</Table.Th>
|
||||
<Table.Th>Weight (kg)</Table.Th>
|
||||
<Table.Th>Payment</Table.Th>
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((item) => (
|
||||
<Table.Tr key={item.id}>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">
|
||||
{item.booking?.reference ?? item.bookingId?.slice(0, 8) ?? '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{item.warehouse?.code ?? '—'}</Table.Td>
|
||||
<Table.Td>{item.zone?.code ?? '—'}</Table.Td>
|
||||
<Table.Td>{formatNumber(item.weight)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color="orange" variant="light" size="sm">
|
||||
{item.booking?.status ?? item.booking?.paymentStatus ?? 'UNPAID'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="gray"
|
||||
leftSection={<Eye size={14} />}
|
||||
disabled={!item.bookingId}
|
||||
onClick={() => item.bookingId && onNavigate(`/dashboard/booking-requests/${item.bookingId}`)}
|
||||
>
|
||||
Booking
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<CreditCard size={14} />}
|
||||
disabled={!item.bookingId}
|
||||
onClick={() => item.bookingId && onNavigate(`/dashboard/booking-requests/${item.bookingId}`)}
|
||||
>
|
||||
Payment
|
||||
</Button>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, Center, Container, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
|
||||
import {
|
||||
ClipboardCheck,
|
||||
PackageCheck,
|
||||
PackagePlus,
|
||||
Send,
|
||||
Truck,
|
||||
Warehouse as WarehouseIcon,
|
||||
Boxes,
|
||||
Layers,
|
||||
} from 'lucide-react';
|
||||
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import { WarehouseDashboardCharts, WarehouseHero } from '@/components/warehouses';
|
||||
import { useWarehouseDashboard } from '@/hooks/useWarehouses';
|
||||
import type { WarehouseDashboard } from '@/types/warehouse';
|
||||
|
||||
/** Brand palette: alternating orange + light green. */
|
||||
const ORANGE = { solid: '#f08c00', soft: '#fff4e6', border: '#ffd8a8', text: '#e8590c' };
|
||||
const GREEN = { solid: '#5bbf4a', soft: '#ebfbee', border: '#b2f2bb', text: '#2f9e44' };
|
||||
|
||||
interface Metric {
|
||||
key: keyof WarehouseDashboard;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
/** Route to navigate to when the card is clicked. */
|
||||
to: string;
|
||||
theme: typeof ORANGE;
|
||||
}
|
||||
|
||||
const METRICS: Metric[] = [
|
||||
{ key: 'totalWarehouses', label: 'Total Warehouses', icon: <WarehouseIcon size={22} />, to: '/dashboard/warehouses', theme: ORANGE },
|
||||
{ key: 'totalInventory', label: 'Total Inventory', icon: <Boxes size={22} />, to: '/dashboard/warehouse-inventory', theme: GREEN },
|
||||
{ key: 'receivedToday', label: 'Received Today', icon: <PackagePlus size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: ORANGE },
|
||||
{ key: 'stored', label: 'Stored', icon: <Layers size={22} />, to: '/dashboard/warehouse-inventory?status=STORED', theme: GREEN },
|
||||
{ key: 'reserved', label: 'Reserved', icon: <ClipboardCheck size={22} />, to: '/dashboard/warehouse-inventory?status=RESERVED', theme: ORANGE },
|
||||
{ key: 'readyForLoading', label: 'Ready For Loading', icon: <PackageCheck size={22} />, to: '/dashboard/loading-queue', theme: GREEN },
|
||||
{ key: 'loaded', label: 'Loaded', icon: <Truck size={22} />, to: '/dashboard/loaded-inventory', theme: ORANGE },
|
||||
{ key: 'dispatched', label: 'Dispatched', icon: <Send size={22} />, to: '/dashboard/dispatch-queue', theme: GREEN },
|
||||
];
|
||||
|
||||
export default function WarehouseDashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading } = useWarehouseDashboard();
|
||||
|
||||
return (
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs items={[{ label: 'Warehouse dashboard' }]} />
|
||||
|
||||
<Stack gap="lg" mt="sm">
|
||||
<WarehouseHero
|
||||
variant="train"
|
||||
secondaryVariant="warehouse"
|
||||
title="Warehouse Dashboard"
|
||||
subtitle="Live overview of warehouse capacity and inventory lifecycle."
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
) : (
|
||||
<>
|
||||
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
|
||||
{METRICS.map((metric) => (
|
||||
<Card
|
||||
key={metric.key}
|
||||
radius="lg"
|
||||
padding="lg"
|
||||
onClick={() => navigate(metric.to)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
background: `linear-gradient(135deg, ${metric.theme.soft} 0%, #ffffff 75%)`,
|
||||
border: `1px solid ${metric.theme.border}`,
|
||||
transition: 'box-shadow 150ms ease, transform 150ms ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.boxShadow = `0 10px 24px -12px ${metric.theme.solid}`;
|
||||
e.currentTarget.style.transform = 'translateY(-3px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.boxShadow = '';
|
||||
e.currentTarget.style.transform = '';
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700} style={{ letterSpacing: 0.4 }}>
|
||||
{metric.label}
|
||||
</Text>
|
||||
<Text fw={800} size="32px" mt={8} style={{ color: metric.theme.text, lineHeight: 1.1 }}>
|
||||
{data ? data[metric.key] : 0}
|
||||
</Text>
|
||||
</div>
|
||||
<ThemeIcon
|
||||
variant="filled"
|
||||
size={46}
|
||||
radius="md"
|
||||
style={{ backgroundColor: metric.theme.solid, color: '#fff' }}
|
||||
>
|
||||
{metric.icon}
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<WarehouseDashboardCharts data={data} />
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Select,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { ArrowLeft, Boxes, LayoutGrid, Package, Pencil, Plus } from 'lucide-react';
|
||||
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import {
|
||||
CreateYardModal,
|
||||
CreateZoneModal,
|
||||
InventoryWorkbench,
|
||||
WarehouseStatusBadge,
|
||||
WarehouseTypeBadge,
|
||||
formatCapacity,
|
||||
humanizeEnum,
|
||||
} from '@/components/warehouses';
|
||||
import {
|
||||
useWarehouse,
|
||||
useWarehouseInventory,
|
||||
useWarehouseYards,
|
||||
useWarehouseZones,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import type { WarehouseYard, WarehouseZone } from '@/types/warehouse';
|
||||
|
||||
function StatCard({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Card withBorder radius="md" padding="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fw={700} size="lg" mt={4}>
|
||||
{value}
|
||||
</Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WarehouseDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: warehouse, isLoading } = useWarehouse(id);
|
||||
const yardsQuery = useWarehouseYards(id);
|
||||
|
||||
const [yardModalOpen, setYardModalOpen] = useState(false);
|
||||
const [editingYard, setEditingYard] = useState<WarehouseYard | null>(null);
|
||||
|
||||
const [zoneModalOpen, setZoneModalOpen] = useState(false);
|
||||
const [editingZone, setEditingZone] = useState<WarehouseZone | null>(null);
|
||||
const [selectedYardId, setSelectedYardId] = useState<string | null>(null);
|
||||
|
||||
const zonesQuery = useWarehouseZones(selectedYardId ?? undefined);
|
||||
|
||||
const inventoryQuery = useWarehouseInventory(id ? { warehouseId: id } : undefined);
|
||||
|
||||
const yards = yardsQuery.data ?? [];
|
||||
const yardOptions = useMemo(
|
||||
() => yards.map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
|
||||
[yards],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center mih="60vh">
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (!warehouse) {
|
||||
return (
|
||||
<Container size="sm" py="xl">
|
||||
<Stack align="center" gap="md">
|
||||
<Text fw={700}>Warehouse not found</Text>
|
||||
<Button variant="default" leftSection={<ArrowLeft size={16} />} onClick={() => navigate('/dashboard/warehouses')}>
|
||||
Back to warehouses
|
||||
</Button>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Warehouses', href: '/dashboard/warehouses' },
|
||||
{ label: warehouse.name },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Stack gap="lg" mt="sm">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Group gap="md" align="center">
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => navigate('/dashboard/warehouses')}>
|
||||
<ArrowLeft size={18} />
|
||||
</ActionIcon>
|
||||
<div>
|
||||
<Group gap="sm">
|
||||
<Title order={2}>{warehouse.name}</Title>
|
||||
<WarehouseTypeBadge type={warehouse.type} />
|
||||
<WarehouseStatusBadge status={warehouse.status} />
|
||||
</Group>
|
||||
<Text c="dimmed" size="sm">
|
||||
{warehouse.code}
|
||||
{warehouse.locationName ? ` · ${warehouse.locationName}` : ''}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Tabs defaultValue="overview">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="overview" leftSection={<LayoutGrid size={16} />}>
|
||||
Overview
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="yards" leftSection={<Boxes size={16} />}>
|
||||
Yards
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="zones" leftSection={<LayoutGrid size={16} />}>
|
||||
Zones
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="inventory" leftSection={<Package size={16} />}>
|
||||
Inventory
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
{/* OVERVIEW */}
|
||||
<Tabs.Panel value="overview" pt="lg">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md">
|
||||
<StatCard label="Type" value={humanizeEnum(warehouse.type)} />
|
||||
<StatCard label="Yards" value={String(yards.length)} />
|
||||
<StatCard
|
||||
label="Weight (cur / cap)"
|
||||
value={formatCapacity(warehouse.currentWeight, warehouse.capacityWeight)}
|
||||
/>
|
||||
<StatCard
|
||||
label="Containers (cur / cap)"
|
||||
value={formatCapacity(warehouse.currentContainers, warehouse.capacityContainers)}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* YARDS */}
|
||||
<Tabs.Panel value="yards" pt="lg">
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600}>Yards</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => {
|
||||
setEditingYard(null);
|
||||
setYardModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Create Yard
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{yards.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="lg">
|
||||
No yards yet.
|
||||
</Text>
|
||||
) : (
|
||||
<Table highlightOnHover verticalSpacing="sm" striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Code</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Weight (cur / cap)</Table.Th>
|
||||
<Table.Th>Containers (cur / cap)</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{yards.map((yard) => (
|
||||
<Table.Tr key={yard.id}>
|
||||
<Table.Td>{yard.name}</Table.Td>
|
||||
<Table.Td>{yard.code}</Table.Td>
|
||||
<Table.Td>{humanizeEnum(yard.type)}</Table.Td>
|
||||
<Table.Td>{formatCapacity(yard.currentWeight, yard.capacityWeight)}</Table.Td>
|
||||
<Table.Td>{formatCapacity(yard.currentContainers, yard.capacityContainers)}</Table.Td>
|
||||
<Table.Td>
|
||||
<WarehouseStatusBadge status={yard.status} />
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => {
|
||||
setEditingYard(yard);
|
||||
setYardModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ZONES */}
|
||||
<Tabs.Panel value="zones" pt="lg">
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<Select
|
||||
label="Yard"
|
||||
placeholder="Select a yard"
|
||||
data={yardOptions}
|
||||
value={selectedYardId}
|
||||
onChange={setSelectedYardId}
|
||||
w={280}
|
||||
searchable
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
leftSection={<Plus size={16} />}
|
||||
disabled={!selectedYardId}
|
||||
onClick={() => {
|
||||
setEditingZone(null);
|
||||
setZoneModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Create Zone
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{!selectedYardId ? (
|
||||
<Text c="dimmed" ta="center" py="lg">
|
||||
Select a yard to view its zones.
|
||||
</Text>
|
||||
) : (zonesQuery.data ?? []).length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="lg">
|
||||
No zones in this yard yet.
|
||||
</Text>
|
||||
) : (
|
||||
<Table highlightOnHover verticalSpacing="sm" striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Code</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Weight (cur / cap)</Table.Th>
|
||||
<Table.Th>Containers (cur / cap)</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(zonesQuery.data ?? []).map((zone) => (
|
||||
<Table.Tr key={zone.id}>
|
||||
<Table.Td>{zone.name}</Table.Td>
|
||||
<Table.Td>{zone.code}</Table.Td>
|
||||
<Table.Td>{humanizeEnum(zone.type)}</Table.Td>
|
||||
<Table.Td>{formatCapacity(zone.currentWeight, zone.capacityWeight)}</Table.Td>
|
||||
<Table.Td>{formatCapacity(zone.currentContainers, zone.capacityContainers)}</Table.Td>
|
||||
<Table.Td>
|
||||
<WarehouseStatusBadge status={zone.status} />
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => {
|
||||
setEditingZone(zone);
|
||||
setZoneModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* INVENTORY */}
|
||||
<Tabs.Panel value="inventory" pt="lg">
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<InventoryWorkbench items={inventoryQuery.data ?? []} isLoading={inventoryQuery.isLoading} />
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
|
||||
{id && (
|
||||
<CreateYardModal
|
||||
opened={yardModalOpen}
|
||||
onClose={() => setYardModalOpen(false)}
|
||||
warehouseId={id}
|
||||
yard={editingYard}
|
||||
/>
|
||||
)}
|
||||
{selectedYardId && (
|
||||
<CreateZoneModal
|
||||
opened={zoneModalOpen}
|
||||
onClose={() => setZoneModalOpen(false)}
|
||||
yardId={selectedYardId}
|
||||
zone={editingZone}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Button, Card, Container, Group, Select, Stack, Text, TextInput, Title } from '@mantine/core';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { PackagePlus, Search } from 'lucide-react';
|
||||
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import {
|
||||
InventoryWorkbench,
|
||||
ReceiveInventoryModal,
|
||||
inventoryStatusOptions,
|
||||
} from '@/components/warehouses';
|
||||
import {
|
||||
useWarehouseInventory,
|
||||
useWarehouseYards,
|
||||
useWarehouseZones,
|
||||
useWarehouses,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import type { InventoryFilter, InventoryStatus } from '@/types/warehouse';
|
||||
|
||||
export default function WarehouseInventoryPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialStatus = (searchParams.get('status') as InventoryStatus | null) ?? undefined;
|
||||
const [filter, setFilter] = useState<InventoryFilter>(
|
||||
initialStatus ? { status: initialStatus } : {},
|
||||
);
|
||||
const [search, setSearch] = useState('');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||
const queryFilter = useMemo<InventoryFilter>(
|
||||
() => ({ ...filter, search: debouncedSearch || undefined }),
|
||||
[filter, debouncedSearch],
|
||||
);
|
||||
|
||||
const warehousesQuery = useWarehouses();
|
||||
const yardsQuery = useWarehouseYards(filter.warehouseId);
|
||||
const zonesQuery = useWarehouseZones(filter.yardId);
|
||||
const inventoryQuery = useWarehouseInventory(queryFilter);
|
||||
|
||||
const warehouseOptions = useMemo(
|
||||
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
|
||||
[warehousesQuery.data],
|
||||
);
|
||||
const yardOptions = useMemo(
|
||||
() => (yardsQuery.data ?? []).map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
|
||||
[yardsQuery.data],
|
||||
);
|
||||
const zoneOptions = useMemo(
|
||||
() => (zonesQuery.data ?? []).map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })),
|
||||
[zonesQuery.data],
|
||||
);
|
||||
|
||||
return (
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs items={[{ label: 'Warehouse inventory' }]} />
|
||||
|
||||
<Stack gap="lg" mt="sm">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<div>
|
||||
<Title order={2}>Warehouse Inventory</Title>
|
||||
<Text c="dimmed" size="sm">
|
||||
Track received items through the storage, reservation, loading and dispatch lifecycle.
|
||||
</Text>
|
||||
</div>
|
||||
<Button leftSection={<PackagePlus size={16} />} onClick={() => setModalOpen(true)}>
|
||||
Receive Inventory
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Stack gap="md">
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search notes"
|
||||
leftSection={<Search size={16} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
w={220}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All warehouses"
|
||||
clearable
|
||||
searchable
|
||||
data={warehouseOptions}
|
||||
value={filter.warehouseId ?? null}
|
||||
onChange={(value) =>
|
||||
setFilter((f) => ({ ...f, warehouseId: value ?? undefined, yardId: undefined, zoneId: undefined }))
|
||||
}
|
||||
w={220}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All yards"
|
||||
clearable
|
||||
searchable
|
||||
disabled={!filter.warehouseId}
|
||||
data={yardOptions}
|
||||
value={filter.yardId ?? null}
|
||||
onChange={(value) => setFilter((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined }))}
|
||||
w={200}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All zones"
|
||||
clearable
|
||||
searchable
|
||||
disabled={!filter.yardId}
|
||||
data={zoneOptions}
|
||||
value={filter.zoneId ?? null}
|
||||
onChange={(value) => setFilter((f) => ({ ...f, zoneId: value ?? undefined }))}
|
||||
w={200}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All statuses"
|
||||
clearable
|
||||
data={inventoryStatusOptions}
|
||||
value={filter.status ?? null}
|
||||
onChange={(value) => setFilter((f) => ({ ...f, status: (value as InventoryStatus) ?? undefined }))}
|
||||
w={200}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<InventoryWorkbench items={inventoryQuery.data ?? []} isLoading={inventoryQuery.isLoading} />
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
<ReceiveInventoryModal opened={modalOpen} onClose={() => setModalOpen(false)} />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { Ban, CreditCard, Eye, Search } from 'lucide-react';
|
||||
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import { WarehouseHero } from '@/components/warehouses';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
useCancelInvoice,
|
||||
usePayInvoice,
|
||||
useWarehouseInvoice,
|
||||
useWarehouseInvoices,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import {
|
||||
WAREHOUSE_INVOICE_STATUSES,
|
||||
type WarehouseFeeInvoice,
|
||||
type WarehouseInvoiceStatus,
|
||||
} from '@/types/warehouse';
|
||||
|
||||
const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
|
||||
DRAFT: 'gray',
|
||||
ISSUED: 'orange',
|
||||
PARTIALLY_PAID: 'yellow',
|
||||
PAID: 'green',
|
||||
CANCELLED: 'gray',
|
||||
};
|
||||
|
||||
const fmt = (n: number, c: string) => `${Number(n).toLocaleString()} ${c}`;
|
||||
const fmtDate = (d?: string | null) => (d ? new Date(d).toLocaleDateString() : '—');
|
||||
|
||||
export default function WarehouseInvoicesPage() {
|
||||
const [status, setStatus] = useState<WarehouseInvoiceStatus | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [detailId, setDetailId] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading } = useWarehouseInvoices(status ? { status } : undefined);
|
||||
const invoices = data ?? [];
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return invoices;
|
||||
return invoices.filter((i) => [i.invoiceNumber, i.bookingId, i.customerId].join(' ').toLowerCase().includes(q));
|
||||
}, [invoices, search]);
|
||||
|
||||
return (
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs items={[{ label: 'Warehouse fee invoices' }]} />
|
||||
<Stack gap="lg" mt="sm">
|
||||
<WarehouseHero
|
||||
variant="container"
|
||||
secondaryVariant="warehouse"
|
||||
title="Warehouse Fee Invoices"
|
||||
subtitle="Demurrage & storage invoices generated from warehouse fee rules."
|
||||
/>
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Group justify="space-between" mb="md" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search invoice no / booking / customer"
|
||||
leftSection={<Search size={16} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
w={320}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All statuses"
|
||||
data={WAREHOUSE_INVOICE_STATUSES.map((s) => ({ value: s, label: s.replace(/_/g, ' ') }))}
|
||||
value={status}
|
||||
onChange={(v) => setStatus((v as WarehouseInvoiceStatus) ?? null)}
|
||||
clearable
|
||||
w={200}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl"><Loader /></Group>
|
||||
) : filtered.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl">No invoices found.</Text>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={1000}>
|
||||
<Table striped highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Invoice No</Table.Th><Table.Th>Type</Table.Th><Table.Th>Total</Table.Th>
|
||||
<Table.Th>Paid</Table.Th><Table.Th>Balance</Table.Th><Table.Th>Status</Table.Th>
|
||||
<Table.Th>Issued</Table.Th><Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filtered.map((inv) => (
|
||||
<Table.Tr key={inv.id}>
|
||||
<Table.Td><Text fw={600} size="sm">{inv.invoiceNumber}</Text></Table.Td>
|
||||
<Table.Td>{inv.invoiceType.replace(/_/g, ' ')}</Table.Td>
|
||||
<Table.Td>{fmt(inv.totalAmount, inv.currency)}</Table.Td>
|
||||
<Table.Td>{fmt(inv.paidAmount, inv.currency)}</Table.Td>
|
||||
<Table.Td>{fmt(inv.balanceAmount, inv.currency)}</Table.Td>
|
||||
<Table.Td><Badge variant="light" color={STATUS_COLOR[inv.status]}>{inv.status.replace(/_/g, ' ')}</Badge></Table.Td>
|
||||
<Table.Td><Text size="xs">{fmtDate(inv.issuedAt)}</Text></Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => setDetailId(inv.id)} title="View">
|
||||
<Eye size={16} />
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
<InvoiceDetailModal id={detailId} onClose={() => setDetailId(null)} />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => void }) {
|
||||
const { toast } = useToast();
|
||||
const { data: inv, isLoading } = useWarehouseInvoice(id ?? undefined);
|
||||
const pay = usePayInvoice();
|
||||
const cancel = useCancelInvoice();
|
||||
const [payAmount, setPayAmount] = useState<number | ''>('');
|
||||
|
||||
const canPay = inv && (inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID');
|
||||
|
||||
const handlePay = async () => {
|
||||
if (!inv || !payAmount) return;
|
||||
try {
|
||||
await pay.mutateAsync({ id: inv.id, payload: { amount: Number(payAmount), method: 'MANUAL' } });
|
||||
toast({ title: 'Payment recorded' });
|
||||
setPayAmount('');
|
||||
} catch (e) {
|
||||
toast({ variant: 'destructive', title: 'Payment failed', description: (e as Error)?.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!inv) return;
|
||||
try {
|
||||
await cancel.mutateAsync(inv.id);
|
||||
toast({ title: 'Invoice cancelled' });
|
||||
onClose();
|
||||
} catch (e) {
|
||||
toast({ variant: 'destructive', title: 'Cancel failed', description: (e as Error)?.message });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={Boolean(id)} onClose={onClose} title="Fee invoice" centered size="lg">
|
||||
{isLoading || !inv ? (
|
||||
<Group justify="center" py="xl"><Loader /></Group>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text fw={700} size="lg">{inv.invoiceNumber}</Text>
|
||||
<Badge variant="light" color={STATUS_COLOR[inv.status]} size="lg">{inv.status.replace(/_/g, ' ')}</Badge>
|
||||
</Group>
|
||||
|
||||
<Table withRowBorders={false} verticalSpacing={4}>
|
||||
<Table.Tbody>
|
||||
{(inv.items ?? []).map((it) => (
|
||||
<Table.Tr key={it.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm">{it.description}</Text>
|
||||
<Text size="xs" c="dimmed">{it.feeType.replace(/_/g, ' ')} · {it.chargeableDays ?? 0} day(s) @ {fmt(it.unitRate, it.currency)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right"><Text fw={600}>{fmt(it.amount, it.currency)}</Text></Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
<Divider />
|
||||
<Group justify="space-between"><Text size="sm" c="dimmed">Subtotal</Text><Text>{fmt(inv.subtotalAmount, inv.currency)}</Text></Group>
|
||||
<Group justify="space-between"><Text size="sm" c="dimmed">Tax</Text><Text>{fmt(inv.taxAmount, inv.currency)}</Text></Group>
|
||||
<Group justify="space-between"><Text fw={700}>Total</Text><Text fw={700}>{fmt(inv.totalAmount, inv.currency)}</Text></Group>
|
||||
<Group justify="space-between"><Text size="sm" c="dimmed">Paid</Text><Text>{fmt(inv.paidAmount, inv.currency)}</Text></Group>
|
||||
<Group justify="space-between"><Text fw={600}>Balance</Text><Text fw={600}>{fmt(inv.balanceAmount, inv.currency)}</Text></Group>
|
||||
|
||||
{(inv.payments ?? []).length > 0 && (
|
||||
<>
|
||||
<Divider label="Payment history" labelPosition="left" />
|
||||
{(inv.payments ?? []).map((p, i) => (
|
||||
<Group key={i} justify="space-between">
|
||||
<Text size="xs" c="dimmed">{fmtDate(p.paidAt)} · {p.method ?? '—'}{p.reference ? ` · ${p.reference}` : ''}</Text>
|
||||
<Text size="sm">{fmt(p.amount, inv.currency)}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{canPay && (
|
||||
<>
|
||||
<Divider label="Record payment" labelPosition="left" />
|
||||
<Group align="flex-end">
|
||||
<NumberInput
|
||||
label="Amount"
|
||||
min={0}
|
||||
value={payAmount}
|
||||
onChange={(v) => setPayAmount(v === '' ? '' : Number(v))}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button color="green" leftSection={<CreditCard size={16} />} loading={pay.isPending} onClick={handlePay}>
|
||||
Pay
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
{inv.status !== 'PAID' && inv.status !== 'CANCELLED' && (
|
||||
<Button variant="light" color="red" leftSection={<Ban size={16} />} loading={cancel.isPending} onClick={handleCancel}>
|
||||
Cancel invoice
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import { WarehouseHero } from '@/components/warehouses';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
useAllocationRules,
|
||||
useCreateAllocationRule,
|
||||
useCreateFeeRule,
|
||||
useDeleteAllocationRule,
|
||||
useDeleteFeeRule,
|
||||
useFeeRules,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import { FEE_RULE_TYPES, type FeeRuleType } from '@/types/warehouse';
|
||||
|
||||
const FREIGHT = [
|
||||
{ value: 'CONTAINER', label: 'Container' },
|
||||
{ value: 'BULK', label: 'Bulk' },
|
||||
];
|
||||
const TRADE = [
|
||||
{ value: 'IMPORT', label: 'Import' },
|
||||
{ value: 'EXPORT', label: 'Export' },
|
||||
{ value: 'DOMESTIC', label: 'Domestic' },
|
||||
];
|
||||
|
||||
const clean = (s: string) => s.trim() || undefined;
|
||||
|
||||
export default function WarehouseRulesPage() {
|
||||
return (
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs items={[{ label: 'Warehouse rules' }]} />
|
||||
<Stack gap="lg" mt="sm">
|
||||
<WarehouseHero
|
||||
variant="warehouse"
|
||||
secondaryVariant="container"
|
||||
title="Allocation & Fee Rules"
|
||||
subtitle="Configure deterministic yard allocation and storage / demurrage free time and rates."
|
||||
/>
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Tabs defaultValue="allocation">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="allocation">Allocation Rules</Tabs.Tab>
|
||||
<Tabs.Tab value="fees">Storage / Demurrage Fees</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="allocation" pt="md">
|
||||
<AllocationRules />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="fees" pt="md">
|
||||
<FeeRules />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function AllocationRules() {
|
||||
const { toast } = useToast();
|
||||
const { data, isLoading } = useAllocationRules();
|
||||
const create = useCreateAllocationRule();
|
||||
const remove = useDeleteAllocationRule();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
priority: 100,
|
||||
freightType: '',
|
||||
tradeDirection: '',
|
||||
cargoTypeCode: '',
|
||||
containerStatus: '',
|
||||
targetYardCode: '',
|
||||
storageType: '',
|
||||
});
|
||||
const rules = data ?? [];
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.name.trim() || !form.targetYardCode.trim()) {
|
||||
toast({ variant: 'destructive', title: 'Name and target yard code are required' });
|
||||
return;
|
||||
}
|
||||
await create.mutateAsync({
|
||||
name: form.name.trim(),
|
||||
priority: form.priority,
|
||||
freightType: clean(form.freightType) ?? null,
|
||||
tradeDirection: clean(form.tradeDirection) ?? null,
|
||||
cargoTypeCode: clean(form.cargoTypeCode) ?? null,
|
||||
containerStatus: clean(form.containerStatus) ?? null,
|
||||
targetYardCode: form.targetYardCode.trim(),
|
||||
storageType: clean(form.storageType) ?? null,
|
||||
isActive: true,
|
||||
} as never);
|
||||
toast({ title: 'Allocation rule created' });
|
||||
setOpen(false);
|
||||
setForm({ name: '', priority: 100, freightType: '', tradeDirection: '', cargoTypeCode: '', containerStatus: '', targetYardCode: '', storageType: '' });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text c="dimmed" size="sm">{rules.length} rule(s) — matched by ascending priority</Text>
|
||||
<Button color="orange" leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>New allocation rule</Button>
|
||||
</Group>
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl"><Loader /></Group>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={900}>
|
||||
<Table striped highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Priority</Table.Th><Table.Th>Name</Table.Th><Table.Th>Freight</Table.Th>
|
||||
<Table.Th>Trade</Table.Th><Table.Th>Cargo code</Table.Th><Table.Th>Target yard</Table.Th>
|
||||
<Table.Th>Active</Table.Th><Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rules.map((r) => (
|
||||
<Table.Tr key={r.id}>
|
||||
<Table.Td>{r.priority}</Table.Td>
|
||||
<Table.Td>{r.name}</Table.Td>
|
||||
<Table.Td>{r.freightType ?? '—'}</Table.Td>
|
||||
<Table.Td>{r.tradeDirection ?? '—'}</Table.Td>
|
||||
<Table.Td>{r.cargoTypeCode ?? '—'}</Table.Td>
|
||||
<Table.Td><Badge variant="light">{r.targetYardCode}</Badge></Table.Td>
|
||||
<Table.Td><Badge color={r.isActive ? 'green' : 'gray'} variant="light">{r.isActive ? 'Yes' : 'No'}</Badge></Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<ActionIcon variant="subtle" color="red" onClick={() => remove.mutate(r.id)} title="Delete">
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
|
||||
<Modal opened={open} onClose={() => setOpen(false)} title="New allocation rule" centered size="lg">
|
||||
<Stack gap="sm">
|
||||
<Group grow>
|
||||
<TextInput label="Name" required value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))} />
|
||||
<NumberInput label="Priority" value={form.priority} onChange={(v) => setForm((f) => ({ ...f, priority: Number(v) || 100 }))} />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<Select label="Freight type" data={FREIGHT} value={form.freightType || null} onChange={(v) => setForm((f) => ({ ...f, freightType: v ?? '' }))} clearable />
|
||||
<Select label="Trade direction" data={TRADE} value={form.tradeDirection || null} onChange={(v) => setForm((f) => ({ ...f, tradeDirection: v ?? '' }))} clearable />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput label="Cargo type code" value={form.cargoTypeCode} onChange={(e) => setForm((f) => ({ ...f, cargoTypeCode: e.currentTarget.value }))} />
|
||||
<TextInput label="Container status" placeholder="e.g. MAINTENANCE" value={form.containerStatus} onChange={(e) => setForm((f) => ({ ...f, containerStatus: e.currentTarget.value }))} />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput label="Target yard code" required value={form.targetYardCode} onChange={(e) => setForm((f) => ({ ...f, targetYardCode: e.currentTarget.value }))} />
|
||||
<TextInput label="Storage type" value={form.storageType} onChange={(e) => setForm((f) => ({ ...f, storageType: e.currentTarget.value }))} />
|
||||
</Group>
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button color="orange" loading={create.isPending} onClick={submit}>Create</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FeeRules() {
|
||||
const { toast } = useToast();
|
||||
const { data, isLoading } = useFeeRules();
|
||||
const create = useCreateFeeRule();
|
||||
const remove = useDeleteFeeRule();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
ruleType: 'DEMURRAGE_FEE' as FeeRuleType,
|
||||
freightType: '',
|
||||
tradeDirection: '',
|
||||
cargoTypeCode: '',
|
||||
freeDays: 3,
|
||||
ratePerDay: 0,
|
||||
currency: 'USD',
|
||||
});
|
||||
const rules = data ?? [];
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.name.trim()) {
|
||||
toast({ variant: 'destructive', title: 'Name is required' });
|
||||
return;
|
||||
}
|
||||
await create.mutateAsync({
|
||||
name: form.name.trim(),
|
||||
ruleType: form.ruleType,
|
||||
freightType: clean(form.freightType) ?? null,
|
||||
tradeDirection: clean(form.tradeDirection) ?? null,
|
||||
cargoTypeCode: clean(form.cargoTypeCode) ?? null,
|
||||
freeDays: form.freeDays,
|
||||
ratePerDay: form.ratePerDay,
|
||||
currency: form.currency || 'USD',
|
||||
isActive: true,
|
||||
} as never);
|
||||
toast({ title: 'Fee rule created' });
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text c="dimmed" size="sm">{rules.length} rule(s) — most specific match applies</Text>
|
||||
<Button color="teal" leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>New fee rule</Button>
|
||||
</Group>
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl"><Loader /></Group>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={900}>
|
||||
<Table striped highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Type</Table.Th><Table.Th>Name</Table.Th><Table.Th>Freight</Table.Th>
|
||||
<Table.Th>Trade</Table.Th><Table.Th>Free days</Table.Th><Table.Th>Rate / day</Table.Th>
|
||||
<Table.Th>Active</Table.Th><Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rules.map((r) => (
|
||||
<Table.Tr key={r.id}>
|
||||
<Table.Td><Badge color={r.ruleType === 'DEMURRAGE_FEE' ? 'orange' : 'teal'} variant="light">{r.ruleType === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage'}</Badge></Table.Td>
|
||||
<Table.Td>{r.name}</Table.Td>
|
||||
<Table.Td>{r.freightType ?? '—'}</Table.Td>
|
||||
<Table.Td>{r.tradeDirection ?? '—'}</Table.Td>
|
||||
<Table.Td>{r.freeDays}</Table.Td>
|
||||
<Table.Td>{Number(r.ratePerDay).toLocaleString()} {r.currency}</Table.Td>
|
||||
<Table.Td><Badge color={r.isActive ? 'green' : 'gray'} variant="light">{r.isActive ? 'Yes' : 'No'}</Badge></Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<ActionIcon variant="subtle" color="red" onClick={() => remove.mutate(r.id)} title="Delete">
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
|
||||
<Modal opened={open} onClose={() => setOpen(false)} title="New fee rule" centered size="lg">
|
||||
<Stack gap="sm">
|
||||
<Group grow>
|
||||
<TextInput label="Name" required value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))} />
|
||||
<Select label="Rule type" data={FEE_RULE_TYPES.map((t) => ({ value: t, label: t === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage' }))} value={form.ruleType} onChange={(v) => setForm((f) => ({ ...f, ruleType: (v as FeeRuleType) ?? 'DEMURRAGE_FEE' }))} allowDeselect={false} />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<Select label="Freight type" data={FREIGHT} value={form.freightType || null} onChange={(v) => setForm((f) => ({ ...f, freightType: v ?? '' }))} clearable />
|
||||
<Select label="Trade direction" data={TRADE} value={form.tradeDirection || null} onChange={(v) => setForm((f) => ({ ...f, tradeDirection: v ?? '' }))} clearable />
|
||||
<TextInput label="Cargo type code" value={form.cargoTypeCode} onChange={(e) => setForm((f) => ({ ...f, cargoTypeCode: e.currentTarget.value }))} />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<NumberInput label="Free days" min={0} value={form.freeDays} onChange={(v) => setForm((f) => ({ ...f, freeDays: Number(v) || 0 }))} />
|
||||
<NumberInput label="Rate / day" min={0} value={form.ratePerDay} onChange={(v) => setForm((f) => ({ ...f, ratePerDay: Number(v) || 0 }))} />
|
||||
<TextInput label="Currency" value={form.currency} onChange={(e) => setForm((f) => ({ ...f, currency: e.currentTarget.value }))} />
|
||||
</Group>
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button color="teal" loading={create.isPending} onClick={submit}>Create</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,15 @@ export interface Cargo {
|
||||
status: 'PENDING' | 'LOADED' | 'IN_TRANSIT' | 'DELIVERED' | 'UNLOADED';
|
||||
loadedAt?: string;
|
||||
unloadedAt?: string;
|
||||
receiverName?: string | null;
|
||||
deliveredAt?: string | null;
|
||||
deliveryRemarks?: string | null;
|
||||
}
|
||||
|
||||
export interface DeliverCargoPayload {
|
||||
receiverName?: string;
|
||||
pickupDate?: string;
|
||||
deliveryRemarks?: string;
|
||||
}
|
||||
|
||||
export const cargoService = {
|
||||
@@ -26,6 +35,7 @@ export const cargoService = {
|
||||
delete: (id: string) => apiClient.delete(`/cargoes/${id}`),
|
||||
load: (cargoId: string, quantity: number, weight: number, volume?: number) =>
|
||||
apiClient.post(`/cargoes/${cargoId}/load`, { quantity, weight, volume }),
|
||||
deliver: (cargoId: string) => apiClient.post(`/cargoes/${cargoId}/deliver`),
|
||||
deliver: (cargoId: string, payload?: DeliverCargoPayload) =>
|
||||
apiClient.post(`/cargoes/${cargoId}/deliver`, payload ?? {}),
|
||||
unload: (cargoId: string) => apiClient.post(`/cargoes/${cargoId}/unload`),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { api as apiClient } from '../auth/http';
|
||||
|
||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||
import type { Facility } from '@/types/warehouse';
|
||||
|
||||
export const facilityService = {
|
||||
list: () => apiClient.get<Facility[]>(URL_CONSTANTS.FACILITIES.BASE),
|
||||
getById: (id: string) => apiClient.get<Facility>(URL_CONSTANTS.FACILITIES.BY_ID(id)),
|
||||
};
|
||||
@@ -5,6 +5,9 @@ import { URL_CONSTANTS } from '@/constants/URLS';
|
||||
export type LocomotiveType = 'DIESEL' | 'ELECTRIC';
|
||||
export type LocomotiveStatus =
|
||||
| 'AVAILABLE'
|
||||
| 'UNAVAILABLE'
|
||||
| 'IMPORT_READY'
|
||||
| 'EXPORT_READY'
|
||||
| 'MAINTENANCE'
|
||||
| 'ASSIGNED'
|
||||
| 'OUT_OF_SERVICE';
|
||||
|
||||
@@ -8,6 +8,19 @@ export interface Wagon {
|
||||
wagonTypeId: string;
|
||||
trainId: string | null;
|
||||
sequenceNumber: number | null;
|
||||
currentLocationYardId: string | null;
|
||||
currentLocationYard?: {
|
||||
id: string;
|
||||
code: string;
|
||||
label: string;
|
||||
country?: string;
|
||||
} | null;
|
||||
wagonType?: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
supportedLoadTypes?: string[];
|
||||
} | null;
|
||||
tareWeight: number;
|
||||
maxPayloadWeight: number;
|
||||
status: Freight.WagonStatus;
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { api as apiClient } from '../auth/http';
|
||||
|
||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||
import type {
|
||||
AllocationCriteria,
|
||||
AllocationPreviewResult,
|
||||
AllocationRule,
|
||||
ArrivalQueueItem,
|
||||
AutoLoadResult,
|
||||
AutoUnloadResult,
|
||||
FeePreview,
|
||||
FeeRule,
|
||||
InspectionAttachment,
|
||||
InspectionReport,
|
||||
InspectionReportPayload,
|
||||
SaveAllocationRulePayload,
|
||||
SaveFeeRulePayload,
|
||||
WarehouseFeeInvoice,
|
||||
WarehouseInvoiceFilter,
|
||||
PayInvoicePayload,
|
||||
BookingScheduleView,
|
||||
InventoryFilter,
|
||||
InventoryInquiryFilter,
|
||||
InventoryInquiryResult,
|
||||
InventoryMovement,
|
||||
LoadableWagon,
|
||||
LoadInventoryPayload,
|
||||
MoveInventoryPayload,
|
||||
ReceiveInventoryPayload,
|
||||
ReserveInventoryPayload,
|
||||
SaveWarehousePayload,
|
||||
SaveYardPayload,
|
||||
SaveZonePayload,
|
||||
Warehouse,
|
||||
WarehouseActivityLog,
|
||||
WarehouseDashboard,
|
||||
WarehouseFilter,
|
||||
WarehouseInventoryItem,
|
||||
WarehouseLoading,
|
||||
WarehouseYard,
|
||||
WarehouseZone,
|
||||
} from '@/types/warehouse';
|
||||
|
||||
const cleanParams = (params: object) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(params).filter(([, value]) => value !== undefined && value !== '' && value !== null),
|
||||
);
|
||||
|
||||
export const warehouseService = {
|
||||
// ── Warehouses ──────────────────────────────────────────────────────────
|
||||
list: (filter?: WarehouseFilter) =>
|
||||
apiClient.get<Warehouse[]>(URL_CONSTANTS.WAREHOUSES.BASE, {
|
||||
params: cleanParams(filter ?? {}),
|
||||
}),
|
||||
dashboard: () => apiClient.get<WarehouseDashboard>(URL_CONSTANTS.WAREHOUSES.DASHBOARD),
|
||||
getById: (id: string) => apiClient.get<Warehouse>(URL_CONSTANTS.WAREHOUSES.BY_ID(id)),
|
||||
create: (payload: SaveWarehousePayload) =>
|
||||
apiClient.post<Warehouse>(URL_CONSTANTS.WAREHOUSES.BASE, payload),
|
||||
update: (id: string, payload: Partial<SaveWarehousePayload>) =>
|
||||
apiClient.patch<Warehouse>(URL_CONSTANTS.WAREHOUSES.BY_ID(id), payload),
|
||||
|
||||
// ── Yards ────────────────────────────────────────────────────────────────
|
||||
listYards: (warehouseId: string) =>
|
||||
apiClient.get<WarehouseYard[]>(URL_CONSTANTS.WAREHOUSES.YARDS(warehouseId)),
|
||||
createYard: (warehouseId: string, payload: SaveYardPayload) =>
|
||||
apiClient.post<WarehouseYard>(URL_CONSTANTS.WAREHOUSES.YARDS(warehouseId), payload),
|
||||
getYard: (id: string) => apiClient.get<WarehouseYard>(URL_CONSTANTS.WAREHOUSE_YARDS.BY_ID(id)),
|
||||
updateYard: (id: string, payload: Partial<SaveYardPayload>) =>
|
||||
apiClient.patch<WarehouseYard>(URL_CONSTANTS.WAREHOUSE_YARDS.BY_ID(id), payload),
|
||||
|
||||
// ── Zones ──────────────────────────────────────────────────────────────
|
||||
listZones: (yardId: string) =>
|
||||
apiClient.get<WarehouseZone[]>(URL_CONSTANTS.WAREHOUSE_YARDS.ZONES(yardId)),
|
||||
createZone: (yardId: string, payload: SaveZonePayload) =>
|
||||
apiClient.post<WarehouseZone>(URL_CONSTANTS.WAREHOUSE_YARDS.ZONES(yardId), payload),
|
||||
getZone: (id: string) => apiClient.get<WarehouseZone>(URL_CONSTANTS.WAREHOUSE_ZONES.BY_ID(id)),
|
||||
updateZone: (id: string, payload: Partial<SaveZonePayload>) =>
|
||||
apiClient.patch<WarehouseZone>(URL_CONSTANTS.WAREHOUSE_ZONES.BY_ID(id), payload),
|
||||
|
||||
// ── Inventory ──────────────────────────────────────────────────────────
|
||||
listInventory: (filter?: InventoryFilter) =>
|
||||
apiClient.get<WarehouseInventoryItem[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.BASE, {
|
||||
params: cleanParams(filter ?? {}),
|
||||
}),
|
||||
receiveInventory: (payload: ReceiveInventoryPayload) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RECEIVE, payload),
|
||||
listReadyForLoading: (filter?: InventoryFilter) =>
|
||||
apiClient.get<WarehouseInventoryItem[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.READY_FOR_LOADING, {
|
||||
params: cleanParams(filter ?? {}),
|
||||
}),
|
||||
inquiry: (filter: InventoryInquiryFilter) =>
|
||||
apiClient.get<InventoryInquiryResult[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.INQUIRY, {
|
||||
params: cleanParams(filter ?? {}),
|
||||
}),
|
||||
|
||||
// ── Lifecycle (Batch 2) ──────────────────────────────────────────────────
|
||||
store: (id: string) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.STORE(id)),
|
||||
reserve: (payload: ReserveInventoryPayload) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RESERVE, payload),
|
||||
markReadyForLoading: (id: string) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.MARK_READY(id)),
|
||||
load: (id: string, payload: LoadInventoryPayload) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.LOAD(id), payload),
|
||||
dispatch: (id: string) =>
|
||||
apiClient.patch<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DISPATCH(id)),
|
||||
move: (id: string, payload: MoveInventoryPayload) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.MOVE(id), payload),
|
||||
movements: (id: string) =>
|
||||
apiClient.get<InventoryMovement[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.MOVEMENTS(id)),
|
||||
activity: (id: string) =>
|
||||
apiClient.get<WarehouseActivityLog[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.ACTIVITY(id)),
|
||||
|
||||
// ── Loading (Batch 3) ─────────────────────────────────────────────────────
|
||||
loadableWagons: () =>
|
||||
apiClient.get<LoadableWagon[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.LOADABLE_WAGONS),
|
||||
bookingSchedule: (bookingId: string) =>
|
||||
apiClient.get<BookingScheduleView>(URL_CONSTANTS.WAREHOUSE_INVENTORY.BOOKING_SCHEDULE(bookingId)),
|
||||
inventoryLoadings: (id: string) =>
|
||||
apiClient.get<WarehouseLoading[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.LOADINGS(id)),
|
||||
loadings: (params?: { bookingId?: string; wagonId?: string }) =>
|
||||
apiClient.get<WarehouseLoading[]>(URL_CONSTANTS.WAREHOUSE_LOADINGS.BASE, {
|
||||
params: cleanParams(params ?? {}),
|
||||
}),
|
||||
|
||||
// ── Batch 4.5: Arrival / Unload / Load automation ──────────────────────────
|
||||
arrivalQueue: () =>
|
||||
apiClient.get<ArrivalQueueItem[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.ARRIVAL_QUEUE),
|
||||
autoUnloadArrived: () =>
|
||||
apiClient.post<AutoUnloadResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.AUTO_UNLOAD_ARRIVED),
|
||||
autoLoadReady: () =>
|
||||
apiClient.post<AutoLoadResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.AUTO_LOAD_READY),
|
||||
unloadBooking: (bookingId: string, payload?: Record<string, unknown>) =>
|
||||
apiClient.post<WarehouseInventoryItem>(
|
||||
URL_CONSTANTS.WAREHOUSE_INVENTORY.UNLOAD_BOOKING(bookingId),
|
||||
payload ?? {},
|
||||
),
|
||||
|
||||
// ── Batch 4.5: Inspection reports ──────────────────────────────────────────
|
||||
listInspectionReports: (inventoryId: string) =>
|
||||
apiClient.get<InspectionReport[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.INSPECTION_REPORTS(inventoryId)),
|
||||
createInspectionReport: (inventoryId: string, payload: InspectionReportPayload) =>
|
||||
apiClient.post<InspectionReport>(
|
||||
URL_CONSTANTS.WAREHOUSE_INVENTORY.INSPECTION_REPORTS(inventoryId),
|
||||
payload,
|
||||
),
|
||||
getInspectionReport: (id: string) =>
|
||||
apiClient.get<InspectionReport>(URL_CONSTANTS.WAREHOUSE_INSPECTION.BY_ID(id)),
|
||||
uploadInspectionAttachments: (id: string, files: File[]) => {
|
||||
const form = new FormData();
|
||||
files.forEach((file) => form.append('files', file));
|
||||
return apiClient.post<InspectionAttachment[]>(
|
||||
URL_CONSTANTS.WAREHOUSE_INSPECTION.ATTACHMENTS(id),
|
||||
form,
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } },
|
||||
);
|
||||
},
|
||||
|
||||
// ── Batch 5: Allocation + Fee rules / previews ─────────────────────────────
|
||||
listAllocationRules: () =>
|
||||
apiClient.get<AllocationRule[]>(URL_CONSTANTS.WAREHOUSE_RULES.ALLOCATION),
|
||||
createAllocationRule: (payload: SaveAllocationRulePayload) =>
|
||||
apiClient.post<AllocationRule>(URL_CONSTANTS.WAREHOUSE_RULES.ALLOCATION, payload),
|
||||
updateAllocationRule: (id: string, payload: Partial<SaveAllocationRulePayload>) =>
|
||||
apiClient.patch<AllocationRule>(URL_CONSTANTS.WAREHOUSE_RULES.ALLOCATION_BY_ID(id), payload),
|
||||
deleteAllocationRule: (id: string) =>
|
||||
apiClient.delete(URL_CONSTANTS.WAREHOUSE_RULES.ALLOCATION_BY_ID(id)),
|
||||
previewAllocation: (criteria: AllocationCriteria) =>
|
||||
apiClient.post<AllocationPreviewResult | null>(URL_CONSTANTS.WAREHOUSE_RULES.ALLOCATION_PREVIEW, criteria),
|
||||
|
||||
listFeeRules: () => apiClient.get<FeeRule[]>(URL_CONSTANTS.WAREHOUSE_RULES.FEES),
|
||||
createFeeRule: (payload: SaveFeeRulePayload) =>
|
||||
apiClient.post<FeeRule>(URL_CONSTANTS.WAREHOUSE_RULES.FEES, payload),
|
||||
updateFeeRule: (id: string, payload: Partial<SaveFeeRulePayload>) =>
|
||||
apiClient.patch<FeeRule>(URL_CONSTANTS.WAREHOUSE_RULES.FEES_BY_ID(id), payload),
|
||||
deleteFeeRule: (id: string) => apiClient.delete(URL_CONSTANTS.WAREHOUSE_RULES.FEES_BY_ID(id)),
|
||||
feePreview: (inventoryId: string) =>
|
||||
apiClient.get<FeePreview[]>(URL_CONSTANTS.WAREHOUSE_RULES.FEE_PREVIEW(inventoryId)),
|
||||
|
||||
// ── Batch 6: Warehouse fee invoices ────────────────────────────────────────
|
||||
listInvoices: (filter?: WarehouseInvoiceFilter) =>
|
||||
apiClient.get<WarehouseFeeInvoice[]>(URL_CONSTANTS.WAREHOUSE_INVOICES.BASE, {
|
||||
params: cleanParams(filter ?? {}),
|
||||
}),
|
||||
getInvoice: (id: string) =>
|
||||
apiClient.get<WarehouseFeeInvoice>(URL_CONSTANTS.WAREHOUSE_INVOICES.BY_ID(id)),
|
||||
invoicesForInventory: (inventoryId: string) =>
|
||||
apiClient.get<WarehouseFeeInvoice[]>(URL_CONSTANTS.WAREHOUSE_INVOICES.FOR_INVENTORY(inventoryId)),
|
||||
invoicesForBooking: (bookingId: string) =>
|
||||
apiClient.get<WarehouseFeeInvoice[]>(URL_CONSTANTS.WAREHOUSE_INVOICES.FOR_BOOKING(bookingId)),
|
||||
generateInvoice: (inventoryId: string, confirmZero = false) =>
|
||||
apiClient.post<WarehouseFeeInvoice>(URL_CONSTANTS.WAREHOUSE_INVOICES.GENERATE(inventoryId), { confirmZero }),
|
||||
cancelInvoice: (id: string) =>
|
||||
apiClient.patch<WarehouseFeeInvoice>(URL_CONSTANTS.WAREHOUSE_INVOICES.CANCEL(id)),
|
||||
payInvoice: (id: string, payload: PayInvoicePayload) =>
|
||||
apiClient.post<WarehouseFeeInvoice>(URL_CONSTANTS.WAREHOUSE_INVOICES.PAY(id), payload),
|
||||
gateClearance: (inventoryId: string) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVOICES.GATE_CLEARANCE(inventoryId), {}),
|
||||
};
|
||||
@@ -6,6 +6,9 @@ export const BOOKING_STATUSES = [
|
||||
"PENDING_APPROVAL",
|
||||
"APPROVED_PENDING_SIGNATURE",
|
||||
"APPROVED",
|
||||
"READY_FOR_ASSIGNMENT",
|
||||
"WAGON_ASSIGNED",
|
||||
"INVOICED",
|
||||
"CONTRACT_READY",
|
||||
"SIGNED_CUSTOMER",
|
||||
"FULLY_EXECUTED",
|
||||
|
||||
596
apps/edr-freight-web/backoffice/src/types/warehouse.ts
Normal file
596
apps/edr-freight-web/backoffice/src/types/warehouse.ts
Normal file
@@ -0,0 +1,596 @@
|
||||
export const WAREHOUSE_TYPES = ['OPEN_WAREHOUSE', 'CLOSED_WAREHOUSE'] as const;
|
||||
export type WarehouseType = (typeof WAREHOUSE_TYPES)[number];
|
||||
|
||||
export const WAREHOUSE_STATUSES = ['ACTIVE', 'INACTIVE'] as const;
|
||||
export type WarehouseStatus = (typeof WAREHOUSE_STATUSES)[number];
|
||||
|
||||
export const WAREHOUSE_YARD_TYPES = [
|
||||
'CONTAINER_YARD',
|
||||
'BULK_YARD',
|
||||
'GENERAL_CARGO_YARD',
|
||||
'HAZARDOUS_YARD',
|
||||
'COLD_STORAGE_YARD',
|
||||
] as const;
|
||||
export type WarehouseYardType = (typeof WAREHOUSE_YARD_TYPES)[number];
|
||||
|
||||
export const WAREHOUSE_ZONE_TYPES = [
|
||||
'CONTAINER_ZONE',
|
||||
'BULK_ZONE',
|
||||
'GENERAL_CARGO_ZONE',
|
||||
'HAZARDOUS_ZONE',
|
||||
'COLD_STORAGE_ZONE',
|
||||
] as const;
|
||||
export type WarehouseZoneType = (typeof WAREHOUSE_ZONE_TYPES)[number];
|
||||
|
||||
export const INVENTORY_STATUSES = [
|
||||
'RECEIVED',
|
||||
'STORED',
|
||||
'RESERVED',
|
||||
'READY_FOR_LOADING',
|
||||
'LOADED',
|
||||
'DISPATCHED',
|
||||
] as const;
|
||||
export type InventoryStatus = (typeof INVENTORY_STATUSES)[number];
|
||||
|
||||
/** Next allowed lifecycle action keyed by current status. */
|
||||
export const INVENTORY_NEXT_ACTION: Record<InventoryStatus, InventoryAction | null> = {
|
||||
RECEIVED: 'store',
|
||||
STORED: 'reserve',
|
||||
RESERVED: 'ready-for-loading',
|
||||
READY_FOR_LOADING: 'load',
|
||||
LOADED: 'dispatch',
|
||||
DISPATCHED: null,
|
||||
};
|
||||
|
||||
export type InventoryAction = 'store' | 'reserve' | 'ready-for-loading' | 'load' | 'dispatch';
|
||||
|
||||
export interface WarehouseZone {
|
||||
id: string;
|
||||
yardId: string;
|
||||
name: string;
|
||||
code: string;
|
||||
type: WarehouseZoneType;
|
||||
capacityWeight: number | null;
|
||||
capacityContainers: number | null;
|
||||
maxWeight: number | null;
|
||||
maxVolume: number | null;
|
||||
currentWeight: number;
|
||||
currentContainers: number;
|
||||
currentVolume: number;
|
||||
status: WarehouseStatus;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface WarehouseYard {
|
||||
id: string;
|
||||
warehouseId: string;
|
||||
name: string;
|
||||
code: string;
|
||||
type: WarehouseYardType;
|
||||
capacityWeight: number | null;
|
||||
capacityContainers: number | null;
|
||||
maxWeight: number | null;
|
||||
maxVolume: number | null;
|
||||
currentWeight: number;
|
||||
currentContainers: number;
|
||||
currentVolume: number;
|
||||
status: WarehouseStatus;
|
||||
isActive: boolean;
|
||||
zones?: WarehouseZone[];
|
||||
}
|
||||
|
||||
export const FACILITY_TYPES = [
|
||||
'PORT',
|
||||
'DRY_PORT',
|
||||
'TERMINAL',
|
||||
'RAIL_YARD',
|
||||
'WAREHOUSE_COMPLEX',
|
||||
] as const;
|
||||
export type FacilityType = (typeof FACILITY_TYPES)[number];
|
||||
|
||||
export interface Facility {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
facilityType: FacilityType;
|
||||
facilityStatus?: string;
|
||||
locationName?: string | null;
|
||||
city?: string | null;
|
||||
country?: string | null;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface Warehouse {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
type: WarehouseType;
|
||||
stationId: string | null;
|
||||
facilityId: string | null;
|
||||
facility?: Facility | null;
|
||||
locationName: string | null;
|
||||
capacityWeight: number | null;
|
||||
capacityContainers: number | null;
|
||||
maxWeight: number | null;
|
||||
maxVolume: number | null;
|
||||
currentWeight: number;
|
||||
currentContainers: number;
|
||||
currentVolume: number;
|
||||
status: WarehouseStatus;
|
||||
isActive: boolean;
|
||||
yards?: WarehouseYard[];
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface WarehouseInventoryItem {
|
||||
id: string;
|
||||
warehouseId: string;
|
||||
yardId: string;
|
||||
zoneId: string;
|
||||
bookingId: string | null;
|
||||
cargoId: string | null;
|
||||
containerId: string | null;
|
||||
goodsId: string | null;
|
||||
quantity: number;
|
||||
weight: number;
|
||||
volume: number | null;
|
||||
status: InventoryStatus;
|
||||
arrivedAt: string | null;
|
||||
storedAt: string | null;
|
||||
reservedAt: string | null;
|
||||
inspectedAt: string | null;
|
||||
readyForLoadingAt: string | null;
|
||||
loadedAt: string | null;
|
||||
dispatchedAt: string | null;
|
||||
notes: string | null;
|
||||
warehouse?: Warehouse | null;
|
||||
yard?: WarehouseYard | null;
|
||||
zone?: WarehouseZone | null;
|
||||
booking?: InventoryBookingRef | null;
|
||||
}
|
||||
|
||||
/** Slim booking shape returned alongside inventory for the loading queue. */
|
||||
export interface InventoryBookingRef {
|
||||
id: string;
|
||||
reference?: string | null;
|
||||
status?: string | null;
|
||||
paymentStatus?: string | null;
|
||||
}
|
||||
|
||||
export interface InventoryMovement {
|
||||
id: string;
|
||||
inventoryId: string;
|
||||
fromWarehouseId: string;
|
||||
fromYardId: string;
|
||||
fromZoneId: string;
|
||||
toWarehouseId: string;
|
||||
toYardId: string;
|
||||
toZoneId: string;
|
||||
remarks: string | null;
|
||||
movedBy: string | null;
|
||||
movedAt: string;
|
||||
}
|
||||
|
||||
export const ACTIVITY_TYPES = [
|
||||
'INVENTORY_RECEIVED',
|
||||
'INVENTORY_STORED',
|
||||
'INVENTORY_MOVED',
|
||||
'INVENTORY_RESERVED',
|
||||
'READY_FOR_LOADING',
|
||||
'INVENTORY_LOADED',
|
||||
'INVENTORY_DISPATCHED',
|
||||
] as const;
|
||||
export type ActivityType = (typeof ACTIVITY_TYPES)[number];
|
||||
|
||||
export interface WarehouseActivityLog {
|
||||
id: string;
|
||||
inventoryId: string | null;
|
||||
warehouseId: string | null;
|
||||
activityType: ActivityType;
|
||||
description: string | null;
|
||||
performedBy: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface WarehouseDashboard {
|
||||
totalWarehouses: number;
|
||||
totalInventory: number;
|
||||
receivedToday: number;
|
||||
stored: number;
|
||||
reserved: number;
|
||||
readyForLoading: number;
|
||||
loaded: number;
|
||||
dispatched: number;
|
||||
}
|
||||
|
||||
// ── Loading (Batch 3) ────────────────────────────────────────────────────────
|
||||
|
||||
export interface WarehouseLoading {
|
||||
id: string;
|
||||
warehouseInventoryId: string;
|
||||
bookingId: string | null;
|
||||
wagonId: string;
|
||||
wagonNumber?: string | null;
|
||||
loadedAt: string;
|
||||
loadedBy: string | null;
|
||||
loadedWeight: number | null;
|
||||
notes: string | null;
|
||||
inventory?: WarehouseInventoryItem | null;
|
||||
}
|
||||
|
||||
export interface LoadInventoryPayload {
|
||||
wagonId: string;
|
||||
loadedWeight?: number;
|
||||
loadedBy?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
/** Read-only wagon view exposed by the scheduling facade. */
|
||||
export interface LoadableWagon {
|
||||
id: string;
|
||||
wagonNumber: string;
|
||||
status: string;
|
||||
trainId: string | null;
|
||||
}
|
||||
|
||||
/** Read-only schedule + wagon + departure status for a booking. */
|
||||
export interface BookingScheduleView {
|
||||
schedule: {
|
||||
id: string;
|
||||
status: string;
|
||||
scheduledDepartureDate: string | null;
|
||||
scheduledArrivalDate: string | null;
|
||||
originStationId: string | null;
|
||||
destinationStationId: string | null;
|
||||
} | null;
|
||||
wagon: {
|
||||
wagonId: string | null;
|
||||
wagonNumber: string | null;
|
||||
sequenceNo: number | null;
|
||||
allocatedWeightTons: number | null;
|
||||
} | null;
|
||||
departureStatus: string | null;
|
||||
}
|
||||
|
||||
export interface MoveInventoryPayload {
|
||||
warehouseId: string;
|
||||
yardId: string;
|
||||
zoneId: string;
|
||||
remarks?: string;
|
||||
}
|
||||
|
||||
export interface ReserveInventoryPayload {
|
||||
bookingId: string;
|
||||
inventoryId: string;
|
||||
}
|
||||
|
||||
export interface InventoryInquiryResult {
|
||||
id: string;
|
||||
bookingId: string;
|
||||
bookingNumber: string | null;
|
||||
customerName: string | null;
|
||||
containerNumber: string | null;
|
||||
cargoType: string | null;
|
||||
cargoDescription: string | null;
|
||||
goodsId: string | null;
|
||||
warehouse: { id: string; name: string; code: string } | null;
|
||||
yard: { id: string; name: string; code: string } | null;
|
||||
zone: { id: string; name: string; code: string } | null;
|
||||
status: InventoryStatus;
|
||||
quantity: number;
|
||||
weight: number;
|
||||
arrivedAt: string | null;
|
||||
readyForLoadingAt: string | null;
|
||||
}
|
||||
|
||||
// ── Batch 4.5: Arrival / Unload / Inspection ────────────────────────────────
|
||||
|
||||
export interface ArrivalQueueItem {
|
||||
bookingId: string;
|
||||
bookingReference: string;
|
||||
customer: string | null;
|
||||
cargo: string | null;
|
||||
container: string | null;
|
||||
facility: string | null;
|
||||
warehouse: string | null;
|
||||
yard: string | null;
|
||||
zone: string | null;
|
||||
inventoryId: string | null;
|
||||
currentStatus: string | null;
|
||||
arrivalDate: string | null;
|
||||
inspectionStatus: string | null;
|
||||
unloaded: boolean;
|
||||
}
|
||||
|
||||
export interface AutoUnloadResult {
|
||||
processedCount: number;
|
||||
skippedCount: number;
|
||||
failedCount: number;
|
||||
results: { bookingId: string; inventoryId?: string; status: string; reason?: string }[];
|
||||
}
|
||||
|
||||
export interface AutoLoadResult {
|
||||
loadedCount: number;
|
||||
skippedCount: number;
|
||||
results: { inventoryId: string; status: string; reason?: string }[];
|
||||
}
|
||||
|
||||
export const INSPECTION_REPORT_TYPES = [
|
||||
'INSPECTION',
|
||||
'DAMAGE',
|
||||
'WEIGHT_LOSS',
|
||||
'MISSING_ITEM',
|
||||
'GENERAL',
|
||||
] as const;
|
||||
export type InspectionReportType = (typeof INSPECTION_REPORT_TYPES)[number];
|
||||
|
||||
export const INSPECTION_STATUSES = ['PASSED', 'FAILED', 'NEEDS_REVIEW'] as const;
|
||||
export type InspectionResultStatus = (typeof INSPECTION_STATUSES)[number];
|
||||
|
||||
export interface InspectionReportPayload {
|
||||
reportType: InspectionReportType;
|
||||
inspectionStatus: InspectionResultStatus;
|
||||
hasDamage?: boolean;
|
||||
damageDescription?: string;
|
||||
hasWeightLoss?: boolean;
|
||||
expectedWeight?: number;
|
||||
actualWeight?: number;
|
||||
hasMissingItems?: boolean;
|
||||
missingItemsDescription?: string;
|
||||
remarks?: string;
|
||||
}
|
||||
|
||||
export interface InspectionAttachment {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface InspectionReport extends InspectionReportPayload {
|
||||
id: string;
|
||||
inventoryId: string;
|
||||
bookingId: string | null;
|
||||
weightLoss?: number | null;
|
||||
inspectedAt: string | null;
|
||||
createdAt: string;
|
||||
attachments?: InspectionAttachment[];
|
||||
}
|
||||
|
||||
// ── Batch 5: Allocation + Fee rules / preview ───────────────────────────────
|
||||
|
||||
export interface AllocationRule {
|
||||
id: string;
|
||||
name: string;
|
||||
priority: number;
|
||||
freightType?: string | null;
|
||||
tradeDirection?: string | null;
|
||||
cargoTypeCode?: string | null;
|
||||
containerStatus?: string | null;
|
||||
requiresInspection?: boolean | null;
|
||||
targetFacilityCode?: string | null;
|
||||
targetYardCode: string;
|
||||
targetWarehouseCode?: string | null;
|
||||
targetZoneCode?: string | null;
|
||||
storageType?: string | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
export type SaveAllocationRulePayload = Omit<AllocationRule, 'id'>;
|
||||
|
||||
export const FEE_RULE_TYPES = ['STORAGE_FEE', 'DEMURRAGE_FEE'] as const;
|
||||
export type FeeRuleType = (typeof FEE_RULE_TYPES)[number];
|
||||
|
||||
export interface FeeRule {
|
||||
id: string;
|
||||
name: string;
|
||||
ruleType: FeeRuleType;
|
||||
priority: number;
|
||||
freightType?: string | null;
|
||||
tradeDirection?: string | null;
|
||||
cargoTypeCode?: string | null;
|
||||
containerType?: string | null;
|
||||
facilityId?: string | null;
|
||||
warehouseId?: string | null;
|
||||
yardId?: string | null;
|
||||
zoneId?: string | null;
|
||||
freeDays: number;
|
||||
ratePerDay: number;
|
||||
currency: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
export type SaveFeeRulePayload = Omit<FeeRule, 'id'>;
|
||||
|
||||
export interface FeePreview {
|
||||
ruleType: FeeRuleType;
|
||||
ruleId: string | null;
|
||||
ruleName: string | null;
|
||||
freeDays: number;
|
||||
ratePerDay: number;
|
||||
currency: string;
|
||||
startDate: string | null;
|
||||
endDate: string;
|
||||
endIsOpen: boolean;
|
||||
elapsedDays: number;
|
||||
chargeableDays: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface AllocationPreviewResult {
|
||||
warehouseId: string;
|
||||
yardId: string;
|
||||
zoneId: string;
|
||||
facilityId: string | null;
|
||||
rule: { id: string; name: string; storageType: string | null } | null;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface AllocationCriteria {
|
||||
freightType?: string;
|
||||
tradeDirection?: string;
|
||||
cargoTypeCode?: string;
|
||||
containerStatus?: string;
|
||||
requiresInspection?: boolean;
|
||||
}
|
||||
|
||||
// ── Batch 6: Warehouse fee invoices ─────────────────────────────────────────
|
||||
|
||||
export const WAREHOUSE_INVOICE_STATUSES = [
|
||||
'DRAFT',
|
||||
'ISSUED',
|
||||
'PARTIALLY_PAID',
|
||||
'PAID',
|
||||
'CANCELLED',
|
||||
] as const;
|
||||
export type WarehouseInvoiceStatus = (typeof WAREHOUSE_INVOICE_STATUSES)[number];
|
||||
|
||||
export const WAREHOUSE_INVOICE_TYPES = ['DEMURRAGE', 'STORAGE_FEE', 'MIXED_WAREHOUSE_FEES'] as const;
|
||||
export type WarehouseInvoiceType = (typeof WAREHOUSE_INVOICE_TYPES)[number];
|
||||
|
||||
export interface WarehouseInvoicePaymentRecord {
|
||||
amount: number;
|
||||
method?: string | null;
|
||||
reference?: string | null;
|
||||
paidAt: string;
|
||||
}
|
||||
|
||||
export interface WarehouseFeeInvoiceItem {
|
||||
id: string;
|
||||
invoiceId: string;
|
||||
feeRuleId?: string | null;
|
||||
feeType: string;
|
||||
description: string;
|
||||
quantity: number;
|
||||
unitRate: number;
|
||||
amount: number;
|
||||
currency: string;
|
||||
chargeableDays?: number | null;
|
||||
freeDays?: number | null;
|
||||
}
|
||||
|
||||
export interface WarehouseFeeInvoice {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
bookingId?: string | null;
|
||||
customerId?: string | null;
|
||||
inventoryId: string;
|
||||
facilityId?: string | null;
|
||||
warehouseId?: string | null;
|
||||
yardId?: string | null;
|
||||
zoneId?: string | null;
|
||||
invoiceType: WarehouseInvoiceType;
|
||||
status: WarehouseInvoiceStatus;
|
||||
subtotalAmount: number;
|
||||
taxAmount: number;
|
||||
totalAmount: number;
|
||||
paidAmount: number;
|
||||
balanceAmount: number;
|
||||
currency: string;
|
||||
periodStart?: string | null;
|
||||
periodEnd?: string | null;
|
||||
issuedAt?: string | null;
|
||||
dueDate?: string | null;
|
||||
paidAt?: string | null;
|
||||
cancelledAt?: string | null;
|
||||
payments?: WarehouseInvoicePaymentRecord[];
|
||||
notes?: string | null;
|
||||
items?: WarehouseFeeInvoiceItem[];
|
||||
}
|
||||
|
||||
export interface WarehouseInvoiceFilter {
|
||||
status?: WarehouseInvoiceStatus;
|
||||
invoiceType?: WarehouseInvoiceType;
|
||||
warehouseId?: string;
|
||||
facilityId?: string;
|
||||
customerId?: string;
|
||||
bookingId?: string;
|
||||
}
|
||||
|
||||
export interface PayInvoicePayload {
|
||||
amount: number;
|
||||
method?: string;
|
||||
reference?: string;
|
||||
}
|
||||
|
||||
// ── Payloads ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SaveWarehousePayload {
|
||||
name: string;
|
||||
code: string;
|
||||
type: WarehouseType;
|
||||
stationId?: string;
|
||||
facilityId?: string;
|
||||
locationName?: string;
|
||||
capacityWeight?: number;
|
||||
capacityContainers?: number;
|
||||
maxWeight?: number;
|
||||
maxVolume?: number;
|
||||
status?: WarehouseStatus;
|
||||
}
|
||||
|
||||
export interface SaveYardPayload {
|
||||
name: string;
|
||||
code: string;
|
||||
type: WarehouseYardType;
|
||||
capacityWeight?: number;
|
||||
capacityContainers?: number;
|
||||
maxWeight?: number;
|
||||
maxVolume?: number;
|
||||
status?: WarehouseStatus;
|
||||
}
|
||||
|
||||
export interface SaveZonePayload {
|
||||
name: string;
|
||||
code: string;
|
||||
type: WarehouseZoneType;
|
||||
capacityWeight?: number;
|
||||
capacityContainers?: number;
|
||||
maxWeight?: number;
|
||||
maxVolume?: number;
|
||||
status?: WarehouseStatus;
|
||||
}
|
||||
|
||||
export interface ReceiveInventoryPayload {
|
||||
warehouseId: string;
|
||||
yardId: string;
|
||||
zoneId: string;
|
||||
bookingId: string;
|
||||
cargoId?: string;
|
||||
containerId?: string;
|
||||
goodsId?: string;
|
||||
quantity: number;
|
||||
weight: number;
|
||||
volume?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface WarehouseFilter {
|
||||
search?: string;
|
||||
type?: WarehouseType;
|
||||
stationId?: string;
|
||||
status?: WarehouseStatus;
|
||||
}
|
||||
|
||||
export interface InventoryFilter {
|
||||
warehouseId?: string;
|
||||
yardId?: string;
|
||||
zoneId?: string;
|
||||
bookingId?: string;
|
||||
cargoId?: string;
|
||||
containerId?: string;
|
||||
goodsId?: string;
|
||||
status?: InventoryStatus;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface InventoryInquiryFilter {
|
||||
bookingNumber?: string;
|
||||
containerNumber?: string;
|
||||
cargoType?: string;
|
||||
goodsName?: string;
|
||||
warehouseId?: string;
|
||||
yardId?: string;
|
||||
zoneId?: string;
|
||||
status?: InventoryStatus;
|
||||
}
|
||||
Reference in New Issue
Block a user