This commit is contained in:
Marshal
2026-07-14 13:11:38 +00:00
1915 changed files with 241099 additions and 165123 deletions

View File

@@ -8,6 +8,12 @@ export interface ActionShellProps {
subtitle?: string;
/** When true the action is already done — children are hidden, a done badge shows. */
done?: boolean;
/**
* Keep the input controls mounted alongside the done badge. For actions whose
* value stays correctable after completion (e.g. customs risk), rather than
* the default one-and-done actions.
*/
keepChildrenWhenDone?: boolean;
doneLabel?: ReactNode;
children: ReactNode;
}
@@ -22,6 +28,7 @@ export function ActionShell({
title,
subtitle,
done,
keepChildrenWhenDone,
doneLabel,
children,
}: ActionShellProps) {
@@ -64,7 +71,7 @@ export function ActionShell({
)
) : null}
</Group>
{!done ? children : null}
{!done || keepChildrenWhenDone ? children : null}
</Box>
);
}

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { Badge, Box, Button, Group, SegmentedControl, Text } from "@mantine/core";
import { ShieldAlert } from "lucide-react";
import type { Freight } from "@edr/types";
@@ -15,22 +15,42 @@ const RISK_COLOR: Record<Freight.CustomsRiskLevel, string> = {
export function AssignRiskCard({
bookingId,
milestone,
locked = false,
}: {
bookingId: string;
milestone: Freight.IClearanceMilestone;
/**
* Duty has already been advised off this risk level, so the decision is now
* final. Until then a mis-assigned level must stay correctable — the server
* accepts reassignment and overwrites the milestone metadata.
*/
locked?: boolean;
}) {
const assign = useAssignRisk(bookingId);
const [level, setLevel] = useState<Freight.CustomsRiskLevel>("GREEN");
const assigned = milestone.status === "COMPLETED";
const current = milestone.metadata?.riskLevel;
const [level, setLevel] = useState<Freight.CustomsRiskLevel>(
current ?? "GREEN",
);
// The milestone loads (and refetches after a reassignment) after first render,
// so mirror the persisted level onto the control whenever it changes.
useEffect(() => {
if (current) setLevel(current);
}, [current]);
return (
<ActionShell
icon={ShieldAlert}
title="Customs risk"
subtitle="Assign the customs examination risk level."
subtitle={
assigned && !locked
? "Reassign the customs examination risk level."
: "Assign the customs examination risk level."
}
done={assigned}
keepChildrenWhenDone={!locked}
doneLabel={
current ? (
<Badge color={RISK_COLOR[current]} variant="filled" radius="sm">
@@ -60,9 +80,10 @@ export function AssignRiskCard({
size="compact-sm"
color="edr-green"
loading={assign.isPending}
disabled={assigned && level === current}
onClick={() => assign.mutate({ riskLevel: level })}
>
Assign risk
{assigned ? "Reassign risk" : "Assign risk"}
</Button>
</Group>
</Box>

View File

@@ -70,7 +70,11 @@ export function GlActionsPanel({ bookingId, milestones }: GlActionsPanelProps) {
{showTransport ? <TransportDocumentCard bookingId={bookingId} /> : null}
{riskMs ? (
<AssignRiskCard bookingId={bookingId} milestone={riskMs} />
<AssignRiskCard
bookingId={bookingId}
milestone={riskMs}
locked={dutyMs?.status === "COMPLETED"}
/>
) : null}
<IncidentReportCard bookingId={bookingId} />

View File

@@ -0,0 +1,157 @@
import { useState } from "react";
import {
Button,
FileInput,
Group,
Modal,
Stack,
Text,
TextInput,
Textarea,
} from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import { Camera, PenLine } from "lucide-react";
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
import { lastMileService } from "@/services/last-mile.service";
import { useToast } from "@/hooks/use-toast";
interface ProofOfDeliveryModalProps {
opened: boolean;
onClose: () => void;
lastMileId: string | null;
reference?: string | null;
/** Called after a successful capture so the caller can refetch. */
onDone: () => void;
}
/**
* Proof of delivery capture for an EDR last-mile leg: recipient name, a drawn
* signature, and proof photos. On confirm it uploads everything and completes
* the delivery (marks the leg DELIVERED).
*/
export function ProofOfDeliveryModal({
opened,
onClose,
lastMileId,
reference,
onDone,
}: ProofOfDeliveryModalProps) {
const { toast } = useToast();
const [recipient, setRecipient] = useState("");
const [notes, setNotes] = useState("");
const [signatureUrl, setSignatureUrl] = useState<string | null>(null);
const [photos, setPhotos] = useState<File[]>([]);
const reset = () => {
setRecipient("");
setNotes("");
setSignatureUrl(null);
setPhotos([]);
};
const close = () => {
reset();
onClose();
};
const submit = useMutation({
mutationFn: async () => {
if (!lastMileId) throw new Error("No delivery selected");
const signature = signatureUrl
? await (await fetch(signatureUrl)).blob()
: null;
return lastMileService.recordProofOfDelivery(lastMileId, {
recipientName: recipient.trim(),
notes: notes.trim() || undefined,
signature,
photos,
});
},
onSuccess: () => {
toast({
title: "Proof of delivery recorded",
description: "The delivery has been completed.",
});
reset();
onDone();
onClose();
},
onError: (e) =>
toast({
variant: "destructive",
title: "Could not record delivery",
description: e instanceof Error ? e.message : undefined,
}),
});
// Require a recipient plus at least one form of proof (signature or a photo).
const canSubmit =
recipient.trim().length > 0 && (Boolean(signatureUrl) || photos.length > 0);
return (
<Modal
opened={opened}
onClose={close}
title={`Record delivery${reference ? `${reference}` : ""}`}
size="lg"
centered
>
<Stack gap="md">
<TextInput
label="Received by"
placeholder="Recipient's name"
required
value={recipient}
onChange={(e) => setRecipient(e.currentTarget.value)}
/>
<div>
<Text size="sm" fw={500} mb={4}>
Recipient signature
</Text>
<ContractSignaturePad onChange={setSignatureUrl} />
</div>
<FileInput
label="Proof photos"
placeholder="Attach delivery photo(s)"
leftSection={<Camera size={16} />}
accept="image/*"
multiple
clearable
value={photos}
onChange={setPhotos}
/>
<Textarea
label="Notes"
placeholder="Optional delivery notes"
autosize
minRows={2}
value={notes}
onChange={(e) => setNotes(e.currentTarget.value)}
/>
<Text size="xs" c="dimmed">
Provide a signature or at least one photo. Confirming completes the delivery.
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={close} disabled={submit.isPending}>
Cancel
</Button>
<Button
color="green"
leftSection={<PenLine size={16} />}
loading={submit.isPending}
disabled={!canSubmit}
onClick={() => submit.mutate()}
>
Confirm delivery
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -24,7 +24,7 @@ const links = [
{
title: "User management",
description: "Employees, roles, and permissions",
href: "/dashboard/user-management",
href: "/user-management",
icon: Users,
},
];

View File

@@ -0,0 +1,241 @@
import { useMemo } from 'react';
import { ActionIcon, Badge, Card, Group, Loader, Menu, SimpleGrid, Stack, Table, Text, ThemeIcon } from '@mantine/core';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, Bell, BellOff, Check, Clock, DollarSign, MoreVertical } from 'lucide-react';
import { useAccrualDashboard } from '@/hooks/useWarehouses';
import { warehouseService } from '@/services/warehouse.service';
import { useToast } from '@/hooks/use-toast';
import type { AccrualAlert, AccrualDashboardRow } from '@/types/warehouse';
const ALERT_META: Record<AccrualAlert, { color: string; label: string }> = {
CHARGING: { color: 'red', label: 'Charging' },
WARNING: { color: 'orange', label: 'Free days ending' },
OK: { color: 'teal', label: 'Within free days' },
};
function money(amount: number, currency: string): string {
return `${amount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}`;
}
function freeDaysLabel(row: AccrualDashboardRow): string {
if (row.charging) return 'charging now';
if (row.freeDaysLeft == null) return '—';
return `${row.freeDaysLeft} day${row.freeDaysLeft === 1 ? '' : 's'} left`;
}
/**
* Live accrual dashboard: storage / demurrage ticking per in-warehouse item,
* sorted so items already charging (or about to) surface first. Read-only.
*/
export function AccrualDashboard() {
const { data: rows = [], isLoading } = useAccrualDashboard();
const { toast } = useToast();
const qc = useQueryClient();
const refresh = () =>
qc.invalidateQueries({ queryKey: ['warehouse-fees', 'accrual-dashboard'] });
const ack = useMutation({
mutationFn: ({ id, snoozeDays }: { id: string; snoozeDays?: number }) =>
warehouseService.acknowledgeAccrual(id, snoozeDays ? { snoozeDays } : {}),
onSuccess: (_r, v) => {
toast({ title: v.snoozeDays ? `Snoozed ${v.snoozeDays} days` : 'Marked reviewed' });
void refresh();
},
onError: () => toast({ variant: 'destructive', title: 'Could not acknowledge' }),
});
const unack = useMutation({
mutationFn: (id: string) => warehouseService.unacknowledgeAccrual(id),
onSuccess: () => {
toast({ title: 'Acknowledgement removed' });
void refresh();
},
onError: () => toast({ variant: 'destructive', title: 'Could not un-acknowledge' }),
});
const summary = useMemo(() => {
const currency = rows[0]?.currency ?? 'USD';
return {
currency,
charging: rows.filter((r) => r.alert === 'CHARGING').length,
atRisk: rows.filter((r) => r.alert === 'WARNING').length,
totalAccruing: Math.round(rows.reduce((s, r) => s + r.accruedAmount, 0) * 100) / 100,
};
}, [rows]);
if (isLoading) {
return (
<Group justify="center" py="xl">
<Loader />
</Group>
);
}
return (
<Stack gap="md">
<SimpleGrid cols={{ base: 1, xs: 3 }} spacing="sm">
<StatCard
icon={<DollarSign size={18} />}
label="Accruing now"
value={money(summary.totalAccruing, summary.currency)}
color="edr-green"
/>
<StatCard
icon={<AlertTriangle size={18} />}
label="Charging"
value={summary.charging}
color={summary.charging > 0 ? 'red' : 'gray'}
/>
<StatCard
icon={<Clock size={18} />}
label="Free days ending (≤2d)"
value={summary.atRisk}
color={summary.atRisk > 0 ? 'orange' : 'gray'}
/>
</SimpleGrid>
<Card withBorder radius="md" padding={0}>
{rows.length === 0 ? (
<Text c="dimmed" ta="center" py="xl" size="sm">
No in-warehouse items are accruing fees.
</Text>
) : (
<Table.ScrollContainer minWidth={900}>
<Table verticalSpacing="sm" highlightOnHover striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Location</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th ta="right">Accrued</Table.Th>
<Table.Th>Free days</Table.Th>
<Table.Th>Alert</Table.Th>
<Table.Th ta="right" />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((row) => {
const meta = ALERT_META[row.alert];
const busy = ack.isPending || unack.isPending;
return (
<Table.Tr key={row.inventoryId} style={{ opacity: row.acknowledged ? 0.55 : 1 }}>
<Table.Td>
<Text fw={600} size="sm">
{row.bookingReference ?? row.inventoryId.slice(0, 8)}
</Text>
</Table.Td>
<Table.Td>{row.customerName ?? '—'}</Table.Td>
<Table.Td>
<Text size="sm">
{[row.warehouseCode, row.zoneCode].filter(Boolean).join(' · ') || '—'}
</Text>
</Table.Td>
<Table.Td>
<Badge variant="light" color="gray" size="sm">
{row.status}
</Badge>
</Table.Td>
<Table.Td ta="right">
<Text fw={600} size="sm" c={row.accruedAmount > 0 ? 'red' : undefined}>
{money(row.accruedAmount, row.currency)}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c={row.charging ? 'red' : undefined}>
{freeDaysLabel(row)}
</Text>
</Table.Td>
<Table.Td>
{row.acknowledged ? (
<Badge color="gray" variant="light" size="sm" leftSection={<Check size={11} />}>
Reviewed{row.snoozeUntil ? ' (snoozed)' : ''}
</Badge>
) : (
<Badge color={meta.color} variant={row.alert === 'OK' ? 'light' : 'filled'} size="sm">
{meta.label}
</Badge>
)}
</Table.Td>
<Table.Td ta="right">
<Menu shadow="md" position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" loading={busy} aria-label="Accrual actions">
<MoreVertical size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{row.acknowledged ? (
<Menu.Item
leftSection={<Bell size={14} />}
onClick={() => unack.mutate(row.inventoryId)}
>
Un-acknowledge
</Menu.Item>
) : (
<>
<Menu.Item
leftSection={<Check size={14} />}
onClick={() => ack.mutate({ id: row.inventoryId })}
>
Mark reviewed
</Menu.Item>
<Menu.Item
leftSection={<BellOff size={14} />}
onClick={() => ack.mutate({ id: row.inventoryId, snoozeDays: 3 })}
>
Snooze 3 days
</Menu.Item>
<Menu.Item
leftSection={<BellOff size={14} />}
onClick={() => ack.mutate({ id: row.inventoryId, snoozeDays: 7 })}
>
Snooze 7 days
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Card>
</Stack>
);
}
function StatCard({
icon,
label,
value,
color,
}: {
icon: React.ReactNode;
label: string;
value: React.ReactNode;
color: string;
}) {
return (
<Card withBorder radius="md" padding="md">
<Group gap="sm" wrap="nowrap">
<ThemeIcon color={color} variant="light" size={40} radius="md">
{icon}
</ThemeIcon>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
{label}
</Text>
<Text fw={800} fz={20} lh={1.1} truncate>
{value}
</Text>
</Stack>
</Group>
</Card>
);
}

View File

@@ -19,7 +19,7 @@ import { MoveInventoryModal } from './MoveInventoryModal';
import { ReleaseOrderModal } from './ReleaseOrderModal';
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
import { openPdfBlob } from './pdf';
import { openPdfBlob, saveBlob } from './pdf';
interface InventoryWorkbenchProps {
items: WarehouseInventoryItem[];
@@ -138,6 +138,42 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
}
};
// One-click bundle: download every available document for the item (GRN +
// gate clearance / release order + handover). Best-effort — docs that aren't
// generatable yet for this item are skipped.
const downloadDocumentBundle = async (item: WarehouseInventoryItem) => {
setBusyId(item.id);
const ref = item.booking?.reference ?? item.bookingId ?? item.id;
const jobs: Array<{ name: string; fn: () => Promise<{ data: Blob }> }> = [
{ name: `GRN-${ref}.pdf`, fn: () => warehouseService.downloadGrnDocument(item.id) },
{ name: `gate-clearance-${ref}.pdf`, fn: () => warehouseService.downloadReleaseDocument(item.id) },
{ name: `handover-${ref}.pdf`, fn: () => warehouseService.downloadHandoverDocument(item.id) },
];
let saved = 0;
for (const job of jobs) {
try {
const response = await job.fn();
saveBlob(response.data, job.name);
saved += 1;
} catch {
// Document not available for this item yet — skip it.
}
}
setBusyId(null);
if (saved === 0) {
toast({
variant: 'destructive',
title: 'No documents available',
description: 'This item has no GRN, gate clearance or handover document yet.',
});
} else {
toast({
title: `Downloaded ${saved} document${saved !== 1 ? 's' : ''}`,
description: `Bundle for ${ref} (available documents only).`,
});
}
};
const acceptLastMile = async (item: WarehouseInventoryItem) => {
const reference = item.booking?.reference;
if (!reference) {
@@ -243,6 +279,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
onFeePreview={setFeeItem}
onReleaseDocument={downloadReleaseDocument}
onHandoverDocument={openHandoverDocument}
onDownloadBundle={downloadDocumentBundle}
onLastMile={onLastMile ? acceptLastMile : undefined}
selectedIds={selected}
onToggleSelect={toggleSelect}

View File

@@ -51,6 +51,7 @@ import { warehouseService } from '@/services/warehouse.service';
import type {
EligibleBooking,
InventoryInquiryFilter,
InventoryStatus,
InventoryInquiryResult,
ImportTrain,
ImportTrainItem,
@@ -63,6 +64,7 @@ import type {
WarehouseYard,
WarehouseZone,
} from '@/types/warehouse';
import { InventoryStatusBadge } from './badges';
import { BookingSelect } from './BookingSelect';
import { DeliverInventoryModal } from './DeliverInventoryModal';
import { ContainerItemsModal } from './ContainerItemsModal';
@@ -253,6 +255,127 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl
warehouseManagerName: form.warehouseManagerName.trim() || undefined,
});
const SUB_STAGE_COLOR: Record<string, string> = {
PENDING: 'gray',
RECEIVED: 'blue',
GRN: 'teal',
ASSIGNED: 'indigo',
LOADED: 'grape',
LEFT: 'orange',
DELIVERED: 'green',
};
/**
* Expanded booking row: the booking's containers / bulk items with their
* lifecycle stage. Shares the ['container-items', bookingId] cache with
* ContainerItemsModal, so expanding after using the modal is instant.
*/
function BookingItemsExpansion({
bookingId,
colSpan,
bulkFallback,
}: {
bookingId: string | null;
colSpan: number;
bulkFallback?: string;
}) {
const { data: items = [], isLoading } = useQuery({
queryKey: ['container-items', bookingId],
queryFn: () => warehouseService.getContainerItems(bookingId as string),
enabled: Boolean(bookingId),
});
return (
<Table.Tr>
<Table.Td colSpan={colSpan} bg="var(--mantine-color-gray-0)">
{isLoading ? (
<Group justify="center" py="sm">
<Loader size="xs" />
</Group>
) : items.length === 0 ? (
<Text size="xs" c="dimmed" py={6}>
{bulkFallback ?? 'No container units recorded on this booking.'}
</Text>
) : (
<Table verticalSpacing={4} fz="xs" withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Container #</Table.Th>
<Table.Th>Goods</Table.Th>
<Table.Th>Stage</Table.Th>
<Table.Th>Truck</Table.Th>
<Table.Th>GRN</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((i) => (
<Table.Tr key={i.containerNumber}>
<Table.Td>
<Text size="xs" fw={600}>{i.containerNumber}</Text>
</Table.Td>
<Table.Td>{i.goods ?? '—'}</Table.Td>
<Table.Td>
<Badge size="xs" variant="light" color={SUB_STAGE_COLOR[i.stage] ?? 'gray'}>
{i.stage}
</Badge>
</Table.Td>
<Table.Td>{i.truckPlate ?? '—'}</Table.Td>
<Table.Td>{i.grnNumber ?? '—'}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Table.Td>
</Table.Tr>
);
}
type ConfirmAction = { title: string; message: string; confirmLabel: string; run: () => void };
/** One-click bulk actions are irreversible — make the click deliberate. */
function ConfirmActionModal({
action,
onClose,
}: {
action: ConfirmAction | null;
onClose: () => void;
}) {
return (
<Modal opened={Boolean(action)} onClose={onClose} title={action?.title ?? ''} centered size="sm">
<Stack gap="md">
<Text size="sm">{action?.message}</Text>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button
onClick={() => {
action?.run();
onClose();
}}
>
{action?.confirmLabel}
</Button>
</Group>
</Stack>
</Modal>
);
}
/** "3 skipped — Booking not PAID" instead of a bare count. */
const skippedSummary = (
skippedCount: number,
results: Array<{ reason?: string; message?: string }>,
): string | undefined => {
if (!skippedCount) return undefined;
const reason = results.find((x) => x.reason || x.message);
return `${skippedCount} skipped${reason ? `${reason.reason ?? reason.message}` : ''}`;
};
const commonNonEmptyValue = (values: Array<string | null | undefined>) => {
const unique = [...new Set(values.map((value) => value?.trim()).filter(Boolean))] as string[];
return unique.length === 1 ? unique[0] : '';
@@ -860,7 +983,7 @@ function EligibleTab({
});
toast({
title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`,
description: r.results.find((item) => item.grnNumber)?.grnNumber ?? (r.skippedCount ? `${r.skippedCount} skipped` : undefined),
description: r.results.find((item) => item.grnNumber)?.grnNumber ?? skippedSummary(r.skippedCount, r.results),
});
const firstGrn = r.results.find((item) => item.inventoryId && item.grnNumber);
if (focusedBookingId && firstGrn?.inventoryId && firstGrn.grnNumber) {
@@ -1030,7 +1153,7 @@ function EligibleTab({
: `No eligible PAID ${direction.toLowerCase()} bookings to receive.`}
</Text>
) : (
<Table.ScrollContainer minWidth={1700}>
<Table.ScrollContainer minWidth={1350}>
<Table highlightOnHover verticalSpacing="xs" striped>
<Table.Thead>
<Table.Tr>
@@ -1043,8 +1166,6 @@ function EligibleTab({
/>
</Table.Th>
<Table.Th>Booking Ref</Table.Th>
<Table.Th>Booking ID</Table.Th>
<Table.Th>Customer ID</Table.Th>
<Table.Th>Customer Name</Table.Th>
<Table.Th>Origin</Table.Th>
<Table.Th>Destination</Table.Th>
@@ -1077,12 +1198,6 @@ function EligibleTab({
{r.reference}
</Text>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{r.id.slice(0, 8)}</Text>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{r.customerId ? `${r.customerId.slice(0, 8)}` : '—'}</Text>
</Table.Td>
<Table.Td>{r.customer ?? '—'}</Table.Td>
<Table.Td>{r.origin ?? '—'}</Table.Td>
<Table.Td>{r.destination ?? '—'}</Table.Td>
@@ -1256,6 +1371,8 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
api.warehouses.bulkMarkInspected.mutationOptions(),
);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
const [expandedRow, setExpandedRow] = useState<string | null>(null);
const [inspectId, setInspectId] = useState<string | null>(null);
const pendingRows = rows.filter((r) => r.inspectionStatus !== 'PASSED');
@@ -1279,7 +1396,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
const r = await inspectMutation.mutateAsync({ inventoryIds: [...selected] });
toast({
title: `${r.inspectedCount} marked inspected`,
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,
description: skippedSummary(r.skippedCount, r.results),
});
setSelected(new Set());
onChanged?.();
@@ -1300,7 +1417,14 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
leftSection={<ClipboardCheck size={14} />}
disabled={selected.size === 0}
loading={inspectMutation.isPending}
onClick={markInspected}
onClick={() =>
setConfirmAction({
title: 'Mark inspected',
message: `Mark ${selected.size} selected item(s) as inspection PASSED?`,
confirmLabel: `Mark ${selected.size} inspected`,
run: markInspected,
})
}
>
Mark Selected as Inspected
</Button>
@@ -1315,10 +1439,11 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
No received export items awaiting inspection.
</Text>
) : (
<Table.ScrollContainer minWidth={1700}>
<Table.ScrollContainer minWidth={1350}>
<Table highlightOnHover verticalSpacing="xs" striped>
<Table.Thead>
<Table.Tr>
<Table.Th w={34} />
<Table.Th w={40}>
<Checkbox
aria-label="Select all"
@@ -1329,8 +1454,6 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
</Table.Th>
<Table.Th>Booking Ref</Table.Th>
<Table.Th>GRN</Table.Th>
<Table.Th>Booking ID</Table.Th>
<Table.Th>Customer ID</Table.Th>
<Table.Th>Customer Name</Table.Th>
<Table.Th>Container / Cargo Items</Table.Th>
<Table.Th>Cargo Type</Table.Th>
@@ -1345,7 +1468,18 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
{rows.map((r: ReadyToLoadRow) => {
const selectable = r.inspectionStatus !== 'PASSED';
return (
<Table.Tr key={r.id}>
<Fragment key={r.id}>
<Table.Tr>
<Table.Td>
<ActionIcon
variant="subtle"
color="gray"
aria-label="Show containers"
onClick={() => setExpandedRow(expandedRow === r.id ? null : r.id)}
>
{expandedRow === r.id ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</ActionIcon>
</Table.Td>
<Table.Td>
<Checkbox
aria-label={`Select ${r.bookingReference ?? r.id}`}
@@ -1355,20 +1489,11 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
/>
</Table.Td>
<Table.Td>
<Stack gap={2}>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Stack>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
</Table.Td>
<Table.Td>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}` : '—'}</Text>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{r.customerId ? `${r.customerId.slice(0, 8)}` : '—'}</Text>
</Table.Td>
<Table.Td>{r.customerName ?? '—'}</Table.Td>
<Table.Td>{r.containerNumber ?? '—'}</Table.Td>
<Table.Td>{r.cargoType ?? '—'}</Table.Td>
@@ -1382,9 +1507,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
</Badge>
</Table.Td>
<Table.Td>
<Badge color="blue" variant="light" size="sm">
{r.status}
</Badge>
<InventoryStatusBadge status={r.status as InventoryStatus} />
</Table.Td>
<Table.Td ta="right">
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
@@ -1392,6 +1515,14 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
</Button>
</Table.Td>
</Table.Tr>
{expandedRow === r.id && (
<BookingItemsExpansion
bookingId={r.bookingId}
colSpan={18}
bulkFallback={r.cargoType ? `Bulk cargo — ${r.cargoType}, ${formatNumber(Number(r.weight))} t` : undefined}
/>
)}
</Fragment>
);
})}
</Table.Tbody>
@@ -1404,6 +1535,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
opened={Boolean(inspectId)}
onClose={() => setInspectId(null)}
/>
<ConfirmActionModal action={confirmAction} onClose={() => setConfirmAction(null)} />
</Stack>
);
}
@@ -1415,29 +1547,10 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
api.warehouses.readyToLoadExport.queryOptions({ enabled }),
);
const qc = useQueryClient();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [trainPickerOpen, setTrainPickerOpen] = useState(false);
const [expandedRow, setExpandedRow] = useState<string | null>(null);
const [targetScheduleId, setTargetScheduleId] = useState<string | null>(null);
// Loading is always onto a SPECIFIC pre-dispatch train. No train -> no auto-load.
const { data: trains = [], isLoading: trainsLoading } = useQuery({
queryKey: ['warehouse-inventory', 'loadable-trains'],
queryFn: () => warehouseService.getLoadableTrains(),
enabled: enabled && trainPickerOpen,
});
const loadOntoTrain = useMutation({
mutationFn: async (scheduleId: string) => {
const items = await warehouseService.getTrainLoadableItems(scheduleId);
const loadableIds = items.filter((i) => i.loadable).map((i) => i.id);
if (!loadableIds.length) {
throw new Error('No ready items with an allocated wagon on this train');
}
return warehouseService.loadItemsOntoTrain(scheduleId, loadableIds);
},
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
void qc.invalidateQueries({ queryKey: ['warehouse-loadings'] });
},
});
const [selected, setSelected] = useState<Set<string>>(new Set());
const allSelected = rows.length > 0 && selected.size === rows.length;
const someSelected = selected.size > 0 && !allSelected;
@@ -1449,13 +1562,47 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
return next;
});
// Loading is always onto a SPECIFIC pre-dispatch train. No train -> no auto-load.
const { data: trains = [], isLoading: trainsLoading } = useQuery({
queryKey: ['warehouse-inventory', 'loadable-trains'],
queryFn: () => warehouseService.getLoadableTrains(),
enabled: enabled && trainPickerOpen,
});
const loadOntoTrain = useMutation({
mutationFn: async ({ scheduleId, onlyIds }: { scheduleId: string; onlyIds: string[] }) => {
const items = await warehouseService.getTrainLoadableItems(scheduleId);
let loadableIds = items.filter((i) => i.loadable).map((i) => i.id);
// When rows are checked, load only those; otherwise load every loadable item.
if (onlyIds.length) {
const picked = new Set(onlyIds);
loadableIds = loadableIds.filter((id) => picked.has(id));
}
if (!loadableIds.length) {
throw new Error(
onlyIds.length
? 'None of the selected items have an allocated wagon on this train'
: 'No ready items with an allocated wagon on this train',
);
}
return warehouseService.loadItemsOntoTrain(scheduleId, loadableIds);
},
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
void qc.invalidateQueries({ queryKey: ['warehouse-loadings'] });
},
});
const confirmLoad = async () => {
if (!targetScheduleId) {
toast({ variant: 'destructive', title: 'Select a train to load onto' });
return;
}
try {
const r = await loadOntoTrain.mutateAsync(targetScheduleId);
const r = await loadOntoTrain.mutateAsync({
scheduleId: targetScheduleId,
onlyIds: [...selected],
});
const train = trains.find((t) => t.scheduleId === targetScheduleId);
toast({
title: `${r.loadedCount} item(s) loaded onto train ${train?.trainNumber ?? ''}`.trim(),
@@ -1476,7 +1623,11 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
<Stack gap="sm" mt="sm">
<Group justify="space-between">
<Text size="sm" c="dimmed">
<b>{rows.length}</b> item{rows.length !== 1 ? 's' : ''} ready to load
{selected.size > 0 ? (
<><b>{selected.size}</b> of {rows.length} selected</>
) : (
<><b>{rows.length}</b> item{rows.length !== 1 ? 's' : ''} ready to load</>
)}
</Text>
<Button
size="compact-sm"
@@ -1486,7 +1637,7 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
disabled={rows.length === 0}
onClick={() => setTrainPickerOpen(true)}
>
Auto Load Ready Items
{selected.size > 0 ? `Load Selected (${selected.size})` : 'Auto Load Ready Items'}
</Button>
</Group>
@@ -1544,7 +1695,7 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
No EXPORT items with inspection PASSED waiting to be loaded.
</Text>
) : (
<Table.ScrollContainer minWidth={1600}>
<Table.ScrollContainer minWidth={1200}>
<Table highlightOnHover verticalSpacing="xs" striped>
<Table.Thead>
<Table.Tr>
@@ -1556,10 +1707,9 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
onChange={toggleAll}
/>
</Table.Th>
<Table.Th w={34} />
<Table.Th>Booking Ref</Table.Th>
<Table.Th>GRN</Table.Th>
<Table.Th>Booking ID</Table.Th>
<Table.Th>Customer ID</Table.Th>
<Table.Th>Customer Name</Table.Th>
<Table.Th>Container #</Table.Th>
<Table.Th>Cargo Type</Table.Th>
@@ -1571,7 +1721,8 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
</Table.Thead>
<Table.Tbody>
{rows.map((r: ReadyToLoadRow) => (
<Table.Tr key={r.id}>
<Fragment key={r.id}>
<Table.Tr>
<Table.Td>
<Checkbox
aria-label={`Select ${r.bookingReference ?? r.id}`}
@@ -1580,20 +1731,21 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
/>
</Table.Td>
<Table.Td>
<Stack gap={2}>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Stack>
<ActionIcon
variant="subtle"
color="gray"
aria-label="Show containers"
onClick={() => setExpandedRow(expandedRow === r.id ? null : r.id)}
>
{expandedRow === r.id ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</ActionIcon>
</Table.Td>
<Table.Td>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
</Table.Td>
<Table.Td>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}` : '—'}</Text>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{r.customerId ? `${r.customerId.slice(0, 8)}` : '—'}</Text>
</Table.Td>
<Table.Td>{r.customerName ?? '—'}</Table.Td>
<Table.Td>{r.containerNumber ?? '—'}</Table.Td>
<Table.Td>{r.cargoType ?? '—'}</Table.Td>
@@ -1607,11 +1759,17 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
</Badge>
</Table.Td>
<Table.Td>
<Badge color="teal" variant="light" size="sm">
{r.status}
</Badge>
<InventoryStatusBadge status={r.status as InventoryStatus} />
</Table.Td>
</Table.Tr>
{expandedRow === r.id && (
<BookingItemsExpansion
bookingId={r.bookingId}
colSpan={11}
bulkFallback={r.cargoType ? `Bulk cargo — ${r.cargoType}, ${formatNumber(Number(r.weight))} t` : undefined}
/>
)}
</Fragment>
))}
</Table.Tbody>
</Table>
@@ -1638,6 +1796,8 @@ function LoadedExportTab({
const { data: rows = [], isLoading } = useQuery(
api.warehouses.loadedExport.queryOptions({ enabled }),
);
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
const [expandedRow, setExpandedRow] = useState<string | null>(null);
const bulkDispatch = useMutation(
api.warehouses.bulkDispatchExport.mutationOptions(),
);
@@ -1662,7 +1822,7 @@ function LoadedExportTab({
const r = await bulkDispatch.mutateAsync(inventoryIds);
toast({
title: `${r.dispatchedCount} dispatched`,
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,
description: skippedSummary(r.skippedCount, r.results),
});
setSelected(new Set());
onChanged?.();
@@ -1692,7 +1852,14 @@ function LoadedExportTab({
variant="default"
disabled={rows.length === 0}
loading={bulkDispatch.isPending}
onClick={() => dispatch(rows.map((r) => r.id))}
onClick={() =>
setConfirmAction({
title: 'Dispatch all',
message: `Dispatch all ${rows.length} loaded item(s)? They leave warehouse inventory for the train.`,
confirmLabel: `Dispatch ${rows.length}`,
run: () => dispatch(rows.map((r) => r.id)),
})
}
>
Dispatch All
</Button>
@@ -1702,7 +1869,14 @@ function LoadedExportTab({
leftSection={<Truck size={14} />}
disabled={selected.size === 0}
loading={bulkDispatch.isPending}
onClick={() => dispatch([...selected])}
onClick={() =>
setConfirmAction({
title: 'Dispatch selected',
message: `Dispatch ${selected.size} selected item(s)? They leave warehouse inventory for the train.`,
confirmLabel: `Dispatch ${selected.size}`,
run: () => dispatch([...selected]),
})
}
>
Dispatch Selected
</Button>
@@ -1719,7 +1893,7 @@ function LoadedExportTab({
No LOADED export items {dispatchable ? 'waiting to dispatch' : 'yet'}.
</Text>
) : (
<Table.ScrollContainer minWidth={1600}>
<Table.ScrollContainer minWidth={1200}>
<Table highlightOnHover verticalSpacing="xs" striped>
<Table.Thead>
<Table.Tr>
@@ -1733,10 +1907,9 @@ function LoadedExportTab({
/>
</Table.Th>
)}
<Table.Th w={34} />
<Table.Th>Booking Ref</Table.Th>
<Table.Th>GRN</Table.Th>
<Table.Th>Booking ID</Table.Th>
<Table.Th>Customer ID</Table.Th>
<Table.Th>Customer Name</Table.Th>
<Table.Th>Container #</Table.Th>
<Table.Th>Cargo Type</Table.Th>
@@ -1747,7 +1920,8 @@ function LoadedExportTab({
</Table.Thead>
<Table.Tbody>
{rows.map((r: ReadyToLoadRow) => (
<Table.Tr key={r.id}>
<Fragment key={r.id}>
<Table.Tr>
{dispatchable && (
<Table.Td>
<Checkbox
@@ -1758,20 +1932,21 @@ function LoadedExportTab({
</Table.Td>
)}
<Table.Td>
<Stack gap={2}>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Stack>
<ActionIcon
variant="subtle"
color="gray"
aria-label="Show containers"
onClick={() => setExpandedRow(expandedRow === r.id ? null : r.id)}
>
{expandedRow === r.id ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</ActionIcon>
</Table.Td>
<Table.Td>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
</Table.Td>
<Table.Td>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}` : '—'}</Text>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{r.customerId ? `${r.customerId.slice(0, 8)}` : '—'}</Text>
</Table.Td>
<Table.Td>{r.customerName ?? '—'}</Table.Td>
<Table.Td>{r.containerNumber ?? '—'}</Table.Td>
<Table.Td>{r.cargoType ?? '—'}</Table.Td>
@@ -1780,16 +1955,23 @@ function LoadedExportTab({
{r.origin || r.destination ? `${r.origin ?? '?'}${r.destination ?? '?'}` : '—'}
</Table.Td>
<Table.Td>
<Badge color="blue" variant="light" size="sm">
{r.status}
</Badge>
<InventoryStatusBadge status={r.status as InventoryStatus} />
</Table.Td>
</Table.Tr>
{expandedRow === r.id && (
<BookingItemsExpansion
bookingId={r.bookingId}
colSpan={11}
bulkFallback={r.cargoType ? `Bulk cargo — ${r.cargoType}, ${formatNumber(Number(r.weight))} t` : undefined}
/>
)}
</Fragment>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
<ConfirmActionModal action={confirmAction} onClose={() => setConfirmAction(null)} />
</Stack>
);
}
@@ -1891,9 +2073,7 @@ function ImportTrainDetailTable({
<Table.Thead>
<Table.Tr>
<Table.Th>Wagon</Table.Th>
<Table.Th>Booking ID</Table.Th>
<Table.Th>Booking Ref</Table.Th>
<Table.Th>Customer ID</Table.Th>
<Table.Th>Customer Name</Table.Th>
<Table.Th>Container #</Table.Th>
<Table.Th>Cargo Type</Table.Th>
@@ -1927,15 +2107,9 @@ function ImportTrainDetailTable({
{it.sequenceNo ? `#${it.sequenceNo}` : '-'} {it.wagonNumber ?? ''}
</Text>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{it.bookingId.slice(0, 8)}</Text>
</Table.Td>
<Table.Td>
<Text size="xs" fw={600}>{it.bookingReference ?? '—'}</Text>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{it.customerId ? `${it.customerId.slice(0, 8)}` : '—'}</Text>
</Table.Td>
<Table.Td>{it.customerName ?? '—'}</Table.Td>
<Table.Td>{it.containerNumber ?? '—'}</Table.Td>
<Table.Td>{it.cargoType ?? '—'}</Table.Td>
@@ -2025,6 +2199,7 @@ function ImportArriveQueueTab({
api.warehouses.autoUnloadArrivedBookings.mutationOptions(),
);
const [openId, setOpenId] = useState<string | null>(null);
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [assignmentsBySchedule, setAssignmentsBySchedule] = useState<
Record<string, Record<string, ImportUnloadAssignmentDraft>>
@@ -2066,7 +2241,7 @@ function ImportArriveQueueTab({
const alreadyUnloaded = r.unloadedCount === 0 && r.skippedCount > 0 && r.failedCount === 0;
const firstReason = r.results.find((item) => item.reason)?.reason;
const extra = [
r.skippedCount ? `${r.skippedCount} skipped` : '',
skippedSummary(r.skippedCount, r.results) ?? '',
r.failedCount ? `${r.failedCount} failed` : '',
]
.filter(Boolean)
@@ -2162,7 +2337,14 @@ function ImportArriveQueueTab({
leftSection={<Truck size={14} />}
loading={busyId === t.scheduleId}
disabled={fullyUnloaded || t.totalBookings === 0 || !readyBySchedule[t.scheduleId] || warehousesLoading}
onClick={() => autoUnload(t)}
onClick={() =>
setConfirmAction({
title: 'Auto unload train',
message: `Unload all arrived bookings from train ${t.trainNumber ?? t.scheduleId.slice(0, 8)} into their assigned warehouse locations?`,
confirmLabel: 'Unload train',
run: () => autoUnload(t),
})
}
>
{fullyUnloaded ? 'Already Unloaded' : 'Auto Unload Arrived Bookings'}
</Button>
@@ -2201,6 +2383,7 @@ function ImportArriveQueueTab({
</Table>
</Table.ScrollContainer>
)}
<ConfirmActionModal action={confirmAction} onClose={() => setConfirmAction(null)} />
</Stack>
);
}
@@ -2221,6 +2404,8 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
);
const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions());
const [selected, setSelected] = useState<Set<string>>(new Set());
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
const [expandedRow, setExpandedRow] = useState<string | null>(null);
const [inspectId, setInspectId] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [viewItem, setViewItem] = useState<WarehouseInventoryItem | null>(null);
@@ -2252,7 +2437,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
const r = await inspectMutation.mutateAsync({ inventoryIds: [...selected] });
toast({
title: `${r.inspectedCount} marked inspected`,
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,
description: skippedSummary(r.skippedCount, r.results),
});
setSelected(new Set());
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
@@ -2356,7 +2541,14 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
leftSection={<ClipboardCheck size={14} />}
disabled={selected.size === 0}
loading={inspectMutation.isPending}
onClick={markInspected}
onClick={() =>
setConfirmAction({
title: 'Mark inspected',
message: `Mark ${selected.size} selected item(s) as inspection PASSED? Passed import items become ready for pickup.`,
confirmLabel: `Mark ${selected.size} inspected`,
run: markInspected,
})
}
>
Mark Selected as Inspected
</Button>
@@ -2372,10 +2564,11 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
No unloaded import items. Items appear here after Auto Unload on an arrived train.
</Text>
) : (
<Table.ScrollContainer minWidth={2000}>
<Table.ScrollContainer minWidth={1650}>
<Table highlightOnHover verticalSpacing="xs" striped>
<Table.Thead>
<Table.Tr>
<Table.Th w={34} />
<Table.Th w={40}>
<Checkbox
aria-label="Select all"
@@ -2384,10 +2577,8 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
onChange={() => (allSelected ? unselectAll() : selectAll())}
/>
</Table.Th>
<Table.Th>Booking ID</Table.Th>
<Table.Th>Booking Ref</Table.Th>
<Table.Th>GRN</Table.Th>
<Table.Th>Customer ID</Table.Th>
<Table.Th>Customer Name</Table.Th>
<Table.Th>Arrival Time</Table.Th>
<Table.Th>Container #</Table.Th>
@@ -2403,7 +2594,18 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
</Table.Thead>
<Table.Tbody>
{rows.map((r: ImportUnloadedItem) => (
<Table.Tr key={r.id}>
<Fragment key={r.id}>
<Table.Tr>
<Table.Td>
<ActionIcon
variant="subtle"
color="gray"
aria-label="Show containers"
onClick={() => setExpandedRow(expandedRow === r.id ? null : r.id)}
>
{expandedRow === r.id ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</ActionIcon>
</Table.Td>
<Table.Td>
<Checkbox
aria-label={`Select ${r.bookingReference ?? r.id}`}
@@ -2412,20 +2614,11 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
/>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}` : '—'}</Text>
</Table.Td>
<Table.Td>
<Stack gap={2}>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Stack>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
</Table.Td>
<Table.Td>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{r.customerId ? `${r.customerId.slice(0, 8)}` : '—'}</Text>
</Table.Td>
<Table.Td>{r.customerName ?? '—'}</Table.Td>
<Table.Td>{formatDate(r.arrivalTime)}</Table.Td>
<Table.Td>{r.containerNumber ?? '—'}</Table.Td>
@@ -2444,7 +2637,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
</Badge>
</Table.Td>
<Table.Td>
<Badge color="indigo" variant="light" size="sm">{r.currentStatus}</Badge>
<InventoryStatusBadge status={r.currentStatus as InventoryStatus} />
</Table.Td>
<Table.Td ta="right">
<Group gap="xs" justify="flex-end" wrap="nowrap">
@@ -2538,6 +2731,14 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
</Group>
</Table.Td>
</Table.Tr>
{expandedRow === r.id && (
<BookingItemsExpansion
bookingId={r.bookingId}
colSpan={16}
bulkFallback={r.cargoType ? `Bulk cargo — ${r.cargoType}, ${formatNumber(Number(r.weight))} t` : undefined}
/>
)}
</Fragment>
))}
</Table.Tbody>
</Table>
@@ -2566,6 +2767,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
bookingId={containerItemsItem?.booking?.id ?? null}
bookingReference={containerItemsItem?.booking?.reference ?? null}
/>
<ConfirmActionModal action={confirmAction} onClose={() => setConfirmAction(null)} />
</Stack>
);
}

View File

@@ -202,8 +202,11 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
];
// Only trucks actually assigned to THIS booking (last-mile prefill or customer
// portal) are selectable. No global fleet list — if nothing is assigned, the
// operator types the plate manually in the field below.
const truckSelectOptions = assignedTruckOptions;
// operator types the plate manually in the field below. Deduped by plate:
// duplicate option values crash Mantine's Select.
const truckSelectOptions = [
...new Map(assignedTruckOptions.map((t) => [t.value, t])).values(),
];
// Neither a last-mile truck nor a customer truck has been assigned yet.
const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck;
const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill;
@@ -216,10 +219,19 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
const containerWeightByNumber = new Map(
containerWeights.map((c) => [c.containerNumber.toUpperCase(), Number(c.weightTons) || 0]),
);
const containerSelectData = containerWeights.map((c) => ({
value: c.containerNumber,
label: `${c.containerNumber} · ${(Number(c.weightTons) || 0).toLocaleString()} t`,
}));
// Mantine Selects throw on duplicate option values — legacy bookings can carry
// the same container number on two lines, so dedupe defensively.
const containerSelectData = [
...new Map(
containerWeights.map((c) => [
c.containerNumber,
{
value: c.containerNumber,
label: `${c.containerNumber} · ${(Number(c.weightTons) || 0).toLocaleString()} t`,
},
]),
).values(),
];
const selectedContainerNumbers = containerNumbers.map((n) => n.trim()).filter(Boolean);
const selectedCargoWeight = Number(
selectedContainerNumbers

View File

@@ -1,6 +1,6 @@
import { useState, type MouseEvent } from 'react';
import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core';
import { ArrowRightLeft, ClipboardList, Coins, Eye, FileText, History, MapPin } from 'lucide-react';
import { ArrowRightLeft, ClipboardList, Coins, Download, Eye, FileText, History, MapPin } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { warehouseService } from '@/services/warehouse.service';
@@ -24,6 +24,7 @@ interface WarehouseInventoryTableProps {
onFeePreview?: (item: WarehouseInventoryItem) => void;
onReleaseDocument?: (item: WarehouseInventoryItem) => void;
onHandoverDocument?: (item: WarehouseInventoryItem) => void;
onDownloadBundle?: (item: WarehouseInventoryItem) => void;
onLastMile?: (item: WarehouseInventoryItem) => void;
selectedIds?: Set<string>;
onToggleSelect?: (id: string) => void;
@@ -110,6 +111,7 @@ export function WarehouseInventoryTable({
onFeePreview,
onReleaseDocument,
onHandoverDocument,
onDownloadBundle,
onLastMile,
selectedIds,
onToggleSelect,
@@ -285,6 +287,13 @@ export function WarehouseInventoryTable({
</ActionIcon>
</Tooltip>
)}
{onDownloadBundle && item.grnNumber && (
<Tooltip label="Download document bundle (GRN + gate clearance + handover)" withArrow>
<ActionIcon variant="subtle" color="grape" onClick={() => onDownloadBundle(item)}>
<Download size={16} />
</ActionIcon>
</Tooltip>
)}
{onLastMile && item.booking?.lastMileDeliveryAddress && (
<Tooltip label="Last mile delivery" withArrow>
<ActionIcon variant="subtle" color="blue" onClick={() => onLastMile(item)}>

View File

@@ -0,0 +1,45 @@
import { AlertTriangle, ClipboardCheck, PackageCheck, Truck } from "lucide-react";
import { KpiStrip } from "@/components/page";
import { useWarehouseOpsStats } from "@/hooks/useWarehouses";
/**
* At-a-glance warehouse ops KPIs (received today, pending inspection, trucks
* on-site, items aging). Drop-in for any warehouse ops page header.
*/
export function WarehouseOpsKpiStrip() {
const { data, isLoading } = useWarehouseOpsStats();
return (
<KpiStrip
loading={isLoading}
items={[
{
label: "Received today",
value: data?.receivedToday ?? 0,
icon: PackageCheck,
color: "edr-green",
},
{
label: "Pending inspection",
value: data?.pendingInspection ?? 0,
icon: ClipboardCheck,
color: "yellow",
},
{
label: "Trucks on-site",
value: data?.trucksOnSite ?? 0,
icon: Truck,
color: "blue",
},
{
label: "Items aging (>7d)",
value: data?.itemsAging ?? 0,
icon: AlertTriangle,
color: (data?.itemsAging ?? 0) > 0 ? "red" : "edr-green",
hint: "In warehouse over 7 days",
},
]}
/>
);
}

View File

@@ -0,0 +1,96 @@
import { Badge, Card, Group, Loader, Progress, SimpleGrid, Stack, Text } from '@mantine/core';
import { LayoutGrid } from 'lucide-react';
import { useZoneOccupancy } from '@/hooks/useWarehouses';
import type { ZoneOccupancy } from '@/types/warehouse';
/** Green < 60%, amber 6085%, red > 85%. */
function tone(pct: number | null): { color: string; label: string } {
if (pct == null) return { color: 'gray', label: 'No capacity set' };
if (pct > 85) return { color: 'red', label: 'Full' };
if (pct >= 60) return { color: 'orange', label: 'Filling' };
return { color: 'teal', label: 'Space' };
}
function capacityLabel(z: ZoneOccupancy): string {
if (z.capacityContainers && z.capacityContainers > 0) {
return `${z.usedItems} / ${z.capacityContainers} items`;
}
if (z.capacityWeight && z.capacityWeight > 0) {
return `${z.usedItems} item(s) · ${z.usedWeight.toLocaleString()} kg`;
}
return `${z.usedItems} item(s)`;
}
interface ZoneOccupancyHeatmapProps {
/** Scope to one yard; omit for all zones. */
yardId?: string;
}
/**
* Occupancy heatmap: one tile per zone, coloured by how full it is. Occupancy is
* container-count based (unit-consistent); weight is shown as context only.
*/
export function ZoneOccupancyHeatmap({ yardId }: ZoneOccupancyHeatmapProps) {
const { data: zones = [], isLoading } = useZoneOccupancy(yardId);
if (isLoading) {
return (
<Group justify="center" py="lg">
<Loader size="sm" />
</Group>
);
}
if (zones.length === 0) {
return (
<Text c="dimmed" ta="center" py="lg" size="sm">
No active zones to show occupancy for.
</Text>
);
}
return (
<Stack gap="sm">
<Group gap="xs">
<LayoutGrid size={16} />
<Text fw={600} size="sm">
Zone occupancy
</Text>
<Text size="xs" c="dimmed">
({zones.length} zone{zones.length !== 1 ? 's' : ''})
</Text>
</Group>
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3, lg: 4 }} spacing="sm">
{zones.map((z) => {
const t = tone(z.occupancyPct);
const pct = z.occupancyPct ?? 0;
return (
<Card key={z.id} withBorder radius="md" padding="sm">
<Stack gap={6}>
<Group justify="space-between" wrap="nowrap" gap="xs">
<Text fw={600} size="sm" truncate title={z.name}>
{z.name}
</Text>
<Badge color={t.color} variant="light" size="sm">
{z.occupancyPct == null ? '—' : `${Math.round(pct)}%`}
</Badge>
</Group>
<Progress value={Math.min(pct, 100)} color={t.color} size="lg" radius="sm" />
<Group justify="space-between" gap="xs">
<Text size="xs" c="dimmed">
{capacityLabel(z)}
</Text>
<Text size="xs" c={t.color === 'gray' ? 'dimmed' : t.color}>
{t.label}
</Text>
</Group>
</Stack>
</Card>
);
})}
</SimpleGrid>
</Stack>
);
}

View File

@@ -29,3 +29,6 @@ export { VisualEmptyState } from './VisualEmptyState';
export { WarehouseDashboardCharts } from './WarehouseDashboardCharts';
export { InspectionReportModal } from './InspectionReportModal';
export { FeePreviewModal } from './FeePreviewModal';
export { ZoneOccupancyHeatmap } from './ZoneOccupancyHeatmap';
export { WarehouseOpsKpiStrip } from './WarehouseOpsKpiStrip';
export { AccrualDashboard } from './AccrualDashboard';

View File

@@ -22,3 +22,16 @@ export function openPdfBlob(blob: Blob, filename: string, targetWindow?: Window
URL.revokeObjectURL(url);
return false;
}
/** Force a browser download of a blob under the given filename (no preview tab). */
export function saveBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
// Delay revoke so the download has time to start (esp. for rapid multi-saves).
setTimeout(() => URL.revokeObjectURL(url), 10_000);
}