Files
edr-platform/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx

381 lines
15 KiB
TypeScript

import { Fragment, useState, type MouseEvent } from 'react';
import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core';
import { ArrowRightLeft, ChevronDown, ChevronRight, ClipboardList, Coins, Download, Eye, FileText, History, MapPin } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { warehouseService } from '@/services/warehouse.service';
import {
getNextInventoryAction,
type InventoryAction,
type WarehouseInventoryItem,
} from '@/types/warehouse';
import { InventoryStatusBadge } from './badges';
import { TruckBreakdownRow } from './TruckBreakdownRow';
import { extractDownloadErrorMessage, formatDate, formatNumber, humanizeEnum } from './options';
import { openPdfBlob } from './pdf';
interface WarehouseInventoryTableProps {
items: WarehouseInventoryItem[];
busyId?: string | null;
onAdvance: (item: WarehouseInventoryItem, action: InventoryAction) => void;
onMove: (item: WarehouseInventoryItem) => void;
onHistory: (item: WarehouseInventoryItem) => void;
onView?: (item: WarehouseInventoryItem) => void;
onInspect?: (item: WarehouseInventoryItem) => void;
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;
onToggleSelectAll?: () => void;
allSelected?: boolean;
someSelected?: boolean;
}
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',
'ready-for-loading': 'cyan',
load: 'teal',
dispatch: 'edr-green',
'ready-for-pickup': 'orange',
release: 'yellow',
deliver: 'green',
};
// After the first truck registers, the modal decides per truck whether it is
// arriving or leaving — the item-level label covers both for multi-truck.
const releaseActionLabel = (item: WarehouseInventoryItem) =>
item.releaseOrderReference ? 'Truck Arrival / Leaving' : 'Truck Arrival';
const noteLineValue = (notes: string | null | undefined, label: string) => {
const match = notes?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
return match?.[1]?.trim() ?? '';
};
const handoverDocumentReference = (item: WarehouseInventoryItem) =>
item.handoverDocumentReference ?? noteLineValue(item.notes, 'Handover Reference');
function GrnDocumentButton({ item }: { item: WarehouseInventoryItem }) {
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const openDocument = async (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
if (!item.grnNumber) {
toast({ variant: 'destructive', title: 'GRN document unavailable', description: 'This item has no GRN number yet.' });
return;
}
setLoading(true);
const pdfWindow = window.open('', '_blank');
try {
const response = await warehouseService.downloadGrnDocument(item.id);
const opened = openPdfBlob(response.data, `grn-${item.grnNumber}.pdf`, pdfWindow);
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
} catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) });
} finally {
setLoading(false);
}
};
return (
<Button
size="compact-xs"
variant="subtle"
color="teal"
leftSection={<FileText size={12} />}
disabled={!item.grnNumber}
loading={loading}
onClick={openDocument}
>
{item.grnNumber ?? 'No GRN'}
</Button>
);
}
export function WarehouseInventoryTable({
items,
busyId,
onAdvance,
onMove,
onHistory,
onView,
onInspect,
onFeePreview,
onReleaseDocument,
onHandoverDocument,
onDownloadBundle,
onLastMile,
selectedIds,
onToggleSelect,
onToggleSelectAll,
allSelected,
someSelected,
}: WarehouseInventoryTableProps) {
const selectable = Boolean(onToggleSelect);
// Bookings whose truck breakdown is open. Expanded rows fetch on demand, so a
// closed table costs nothing extra.
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const toggleExpanded = (bookingId: string) =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(bookingId)) next.delete(bookingId);
else next.add(bookingId);
return next;
});
if (items.length === 0) {
return (
<Text size="sm" 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>
{selectable && (
<Table.Th w={40}>
<Checkbox
aria-label="Select all"
checked={allSelected}
indeterminate={someSelected}
onChange={onToggleSelectAll}
/>
</Table.Th>
)}
{/* Expander for the per-truck breakdown. */}
<Table.Th w={32} />
<Table.Th>Booking</Table.Th>
<Table.Th>GRN</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;
// Per-booking Load and Dispatch are retired: wagon loading happens in
// the train flow and dispatch at the train level (which already
// advances inventory). Only the remaining lifecycle actions render.
const rawNextAction = getNextInventoryAction(item);
const nextAction =
rawNextAction === 'load' || rawNextAction === 'dispatch' ? null : rawNextAction;
const canGenerateHandover =
item.inspectionStatus === 'PASSED' &&
Boolean(item.bookingId) &&
(!item.booking?.tradeDirection || item.booking.tradeDirection === 'IMPORT');
const handoverReference = handoverDocumentReference(item);
// Only offer the breakdown where there is one: a plate means at
// least one customer truck is on the booking.
const hasCustomerTrucks = Boolean(
item.bookingId && item.booking?.customerTruckPlateNumber?.trim(),
);
const isExpanded = Boolean(item.bookingId && expanded.has(item.bookingId));
return (
<Fragment key={item.id}>
<Table.Tr>
{selectable && (
<Table.Td>
<Checkbox
aria-label={`Select ${item.bookingReference ?? item.booking?.reference ?? item.bookingId ?? item.id}`}
checked={selectedIds?.has(item.id) ?? false}
onChange={() => onToggleSelect?.(item.id)}
/>
</Table.Td>
)}
<Table.Td>
{hasCustomerTrucks ? (
<Tooltip
label={isExpanded ? 'Hide trucks' : 'Show which containers ride which truck'}
withArrow
>
<ActionIcon
variant="subtle"
size="sm"
aria-label={isExpanded ? 'Hide trucks' : 'Show trucks'}
onClick={() => toggleExpanded(item.bookingId as string)}
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</ActionIcon>
</Tooltip>
) : null}
</Table.Td>
<Table.Td>
{item.bookingReference || item.booking?.reference || item.bookingId ? (
<Tooltip label={item.bookingId ?? ''} withArrow disabled={!item.bookingId}>
<Text size="sm" fw={600}>
{item.bookingReference ?? item.booking?.reference ?? `${item.bookingId?.slice(0, 8)}...`}
</Text>
</Tooltip>
) : (
<Text size="sm" c="dimmed">
-
</Text>
)}
</Table.Td>
<Table.Td>
<GrnDocumentButton item={item} />
</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">
{onView && (
<Tooltip label="View details" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => onView(item)}>
<Eye size={16} />
</ActionIcon>
</Tooltip>
)}
{nextAction && (
<Button
size="compact-xs"
variant="light"
color={actionColor[nextAction]}
loading={busy}
onClick={() => onAdvance(item, nextAction)}
>
{nextAction === 'release' ? releaseActionLabel(item) : humanizeEnum(nextAction.replace(/-/g, '_'))}
</Button>
)}
{/* After the first exit the primary action flips to Deliver, but a
multi-truck booking still weighs its remaining trucks in and out. */}
{item.status === 'READY_FOR_PICKUP' && item.releaseDate && nextAction !== 'release' && (
<Button
size="compact-xs"
variant="light"
color="yellow"
loading={busy}
onClick={() => onAdvance(item, 'release')}
>
Truck Arrival / Leaving
</Button>
)}
{item.status === 'READY_FOR_PICKUP' && (
<Button
size="compact-xs"
variant="light"
color="blue"
loading={busy}
onClick={() => onAdvance(item, 'store')}
>
Store
</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>
)}
{onReleaseDocument && item.releaseDate && (
<Tooltip label="View release exit paper" withArrow>
<ActionIcon variant="subtle" color="orange" onClick={() => onReleaseDocument(item)}>
<FileText size={16} />
</ActionIcon>
</Tooltip>
)}
{onHandoverDocument && canGenerateHandover && (
<Tooltip
label={handoverReference ? `View handover document ${handoverReference}` : 'Generate customer handover document'}
withArrow
>
<ActionIcon variant="subtle" color="teal" onClick={() => onHandoverDocument(item)}>
<FileText size={16} />
</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)}>
<MapPin 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>
{isExpanded && item.bookingId ? (
<TruckBreakdownRow
bookingId={item.bookingId}
// Expander + every data column + actions, plus the checkbox
// when the table is selectable.
colSpan={selectable ? 14 : 13}
/>
) : null}
</Fragment>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}