mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 11:21:18 +00:00
Warehouse
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} 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';
|
||||
@@ -24,6 +25,7 @@ interface FormState {
|
||||
name: string;
|
||||
code: string;
|
||||
type: WarehouseType;
|
||||
stationId: string | null;
|
||||
locationName: string;
|
||||
capacityWeight: number | '';
|
||||
capacityContainers: number | '';
|
||||
@@ -35,6 +37,7 @@ const emptyForm = (): FormState => ({
|
||||
name: '',
|
||||
code: '',
|
||||
type: 'OPEN_WAREHOUSE',
|
||||
stationId: null,
|
||||
locationName: '',
|
||||
capacityWeight: '',
|
||||
capacityContainers: '',
|
||||
@@ -47,8 +50,11 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
|
||||
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(
|
||||
@@ -57,6 +63,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
|
||||
name: warehouse.name,
|
||||
code: warehouse.code,
|
||||
type: warehouse.type,
|
||||
stationId: warehouse.stationId ?? null,
|
||||
locationName: warehouse.locationName ?? '',
|
||||
capacityWeight: warehouse.capacityWeight ?? '',
|
||||
capacityContainers: warehouse.capacityContainers ?? '',
|
||||
@@ -80,6 +87,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
|
||||
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),
|
||||
@@ -120,6 +128,16 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
|
||||
/>
|
||||
</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"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ActionIcon, Card, Group, SimpleGrid, Stack, Text } from '@mantine/core';
|
||||
import { Eye, MapPin, Pencil } from 'lucide-react';
|
||||
import { Building2, Eye, MapPin, Pencil } from 'lucide-react';
|
||||
|
||||
import { useStations } from '@/hooks/useStations';
|
||||
import type { Warehouse } from '@/types/warehouse';
|
||||
import { WarehouseStatusBadge, WarehouseTypeBadge } from './badges';
|
||||
import { formatCapacity } from './options';
|
||||
@@ -12,6 +14,12 @@ interface WarehouseCardViewProps {
|
||||
}
|
||||
|
||||
export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardViewProps) {
|
||||
const { data: stations } = useStations();
|
||||
const stationNameById = useMemo(
|
||||
() => new Map((stations ?? []).map((s) => [s.id, s.name])),
|
||||
[stations],
|
||||
);
|
||||
|
||||
if (warehouses.length === 0) {
|
||||
return (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
@@ -39,6 +47,13 @@ export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardV
|
||||
<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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -50,6 +50,7 @@ export function WarehouseInventoryTable({
|
||||
<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>
|
||||
@@ -81,6 +82,7 @@ export function WarehouseInventoryTable({
|
||||
</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>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ActionIcon, Anchor, Group, Table, Text } from '@mantine/core';
|
||||
import { Eye, Pencil } from 'lucide-react';
|
||||
|
||||
import { useStations } from '@/hooks/useStations';
|
||||
import type { Warehouse } from '@/types/warehouse';
|
||||
import { WarehouseStatusBadge, WarehouseTypeBadge } from './badges';
|
||||
import { formatCapacity } from './options';
|
||||
@@ -12,6 +14,12 @@ interface WarehouseTableProps {
|
||||
}
|
||||
|
||||
export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTableProps) {
|
||||
const { data: stations } = useStations();
|
||||
const stationNameById = useMemo(
|
||||
() => new Map((stations ?? []).map((s) => [s.id, s.name])),
|
||||
[stations],
|
||||
);
|
||||
|
||||
if (warehouses.length === 0) {
|
||||
return (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
@@ -27,6 +35,7 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro
|
||||
<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>
|
||||
@@ -44,6 +53,17 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro
|
||||
</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>
|
||||
|
||||
@@ -24,3 +24,4 @@ export { FreightVisual } from './FreightVisual';
|
||||
export type { FreightVisualVariant } from './FreightVisual';
|
||||
export { WarehouseHero } from './WarehouseHero';
|
||||
export { VisualEmptyState } from './VisualEmptyState';
|
||||
export { WarehouseDashboardCharts } from './WarehouseDashboardCharts';
|
||||
|
||||
Reference in New Issue
Block a user