mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 09:28:19 +00:00
feat: container/bulk items datatable on warehouse view details
Adds a per-container/bulk items view for a booking with derived lifecycle stage (PENDING -> RECEIVED -> GRN -> LOADED -> LEFT -> DELIVERED) and reference badges (booking, contract, last-mile). Backend containerItems() aggregates booking_container_units + customer_truck_containers + inventory; exposed at GET warehouse-inventory/bookings/:id/container-items. Frontend ContainerItemsModal (opened from the View Details eye): stage tabs with counts, ref columns, checkbox multiselect of loadable items -> pick truck -> load (Truck_dispatch), and per-row per-truck Exit Paper. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { FileText } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
warehouseService,
|
||||
type ContainerItem,
|
||||
type ContainerItemStage,
|
||||
} from '@/services/warehouse.service';
|
||||
import { extractErrorMessage } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
|
||||
interface ContainerItemsModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
bookingId: string | null;
|
||||
bookingReference?: string | null;
|
||||
}
|
||||
|
||||
const STAGE_TABS: Array<{ value: string; label: string }> = [
|
||||
{ value: 'ALL', label: 'All' },
|
||||
{ value: 'RECEIVED', label: 'Received' },
|
||||
{ value: 'GRN', label: "GRN'd" },
|
||||
{ value: 'LOADED', label: 'Loaded' },
|
||||
{ value: 'LEFT', label: 'Left' },
|
||||
{ value: 'DELIVERED', label: 'Delivered' },
|
||||
];
|
||||
|
||||
const STAGE_COLOR: Record<ContainerItemStage, string> = {
|
||||
PENDING: 'gray',
|
||||
RECEIVED: 'blue',
|
||||
GRN: 'teal',
|
||||
LOADED: 'grape',
|
||||
LEFT: 'orange',
|
||||
DELIVERED: 'green',
|
||||
};
|
||||
|
||||
/** Loadable = not yet on a truck (before LOADED). */
|
||||
const isLoadable = (i: ContainerItem) => i.stage === 'PENDING' || i.stage === 'RECEIVED' || i.stage === 'GRN';
|
||||
|
||||
export function ContainerItemsModal({ opened, onClose, bookingId, bookingReference }: ContainerItemsModalProps) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [tab, setTab] = useState('ALL');
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [truckId, setTruckId] = useState<string | null>(null);
|
||||
|
||||
const itemsKey = ['container-items', bookingId];
|
||||
const { data: items = [], isLoading } = useQuery({
|
||||
queryKey: itemsKey,
|
||||
queryFn: () => warehouseService.getContainerItems(bookingId as string),
|
||||
enabled: opened && Boolean(bookingId),
|
||||
});
|
||||
const { data: trucks = [] } = useQuery({
|
||||
queryKey: ['ci-trucks', bookingId],
|
||||
queryFn: () => warehouseService.getCustomerTrucks(bookingId as string),
|
||||
enabled: opened && Boolean(bookingId),
|
||||
});
|
||||
|
||||
const visible = useMemo(
|
||||
() => (tab === 'ALL' ? items : items.filter((i) => i.stage === tab)),
|
||||
[items, tab],
|
||||
);
|
||||
const truckOptions = trucks
|
||||
.filter((t) => !(t as { departedAt?: string }).departedAt)
|
||||
.map((t) => ({ value: t.id, label: `${t.plateNumber} · ${t.driverName}` }));
|
||||
|
||||
const loadMutation = useMutation({
|
||||
mutationFn: () => warehouseService.loadTruck(bookingId as string, truckId as string, selected),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: itemsKey });
|
||||
setSelected([]);
|
||||
toast({ title: 'Containers loaded onto truck' });
|
||||
},
|
||||
onError: (e) => toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }),
|
||||
});
|
||||
|
||||
const openExitPaper = async (assignmentId: string, plate: string) => {
|
||||
try {
|
||||
const res = await warehouseService.downloadTruckExitPaper(assignmentId);
|
||||
openPdfBlob(res.data, `exit-${plate}.pdf`);
|
||||
} catch (e) {
|
||||
toast({ variant: 'destructive', title: 'Exit paper not ready', description: extractErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = (n: string) => setSelected((s) => (s.includes(n) ? s.filter((x) => x !== n) : [...s, n]));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
centered
|
||||
size="90%"
|
||||
title={<Text fw={700}>Container / bulk items {bookingReference ? `· ${bookingReference}` : ''}</Text>}
|
||||
>
|
||||
<Tabs value={tab} onChange={(v) => setTab(v ?? 'ALL')} mb="sm">
|
||||
<Tabs.List>
|
||||
{STAGE_TABS.map((t) => {
|
||||
const count = t.value === 'ALL' ? items.length : items.filter((i) => i.stage === t.value).length;
|
||||
return (
|
||||
<Tabs.Tab key={t.value} value={t.value} rightSection={<Badge size="xs" variant="light">{count}</Badge>}>
|
||||
{t.label}
|
||||
</Tabs.Tab>
|
||||
);
|
||||
})}
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="lg">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : items.length === 0 ? (
|
||||
<Alert color="gray" variant="light">No container or bulk items on this booking.</Alert>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
<Table.ScrollContainer minWidth={900}>
|
||||
<Table striped highlightOnHover verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th />
|
||||
<Table.Th>Container</Table.Th>
|
||||
<Table.Th>Goods</Table.Th>
|
||||
<Table.Th>Stage</Table.Th>
|
||||
<Table.Th>Truck</Table.Th>
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>Contract</Table.Th>
|
||||
<Table.Th>Last mile</Table.Th>
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{visible.map((i) => (
|
||||
<Table.Tr key={i.containerNumber}>
|
||||
<Table.Td>
|
||||
<Checkbox
|
||||
checked={selected.includes(i.containerNumber)}
|
||||
onChange={() => toggle(i.containerNumber)}
|
||||
disabled={!isLoadable(i)}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td><Text fw={600}>{i.containerNumber}</Text></Table.Td>
|
||||
<Table.Td>{i.goods ?? '—'}</Table.Td>
|
||||
<Table.Td><Badge color={STAGE_COLOR[i.stage]} variant="light">{i.stage}</Badge></Table.Td>
|
||||
<Table.Td>{i.truckPlate ?? '—'}</Table.Td>
|
||||
<Table.Td>{i.bookingReference ?? '—'}</Table.Td>
|
||||
<Table.Td>{i.contractId ? <Badge variant="outline" color="indigo">Contract</Badge> : '—'}</Table.Td>
|
||||
<Table.Td>{i.hasLastMile ? <Badge variant="light" color="cyan">EDR</Badge> : <Badge variant="light" color="gray">Self-haul</Badge>}</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
{i.truckAssignmentId && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<FileText size={13} />}
|
||||
onClick={() => openExitPaper(i.truckAssignmentId as string, i.truckPlate ?? '')}
|
||||
>
|
||||
Exit Paper
|
||||
</Button>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
|
||||
{/* Multiselect → load onto a truck */}
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<Text size="sm" c="dimmed">{selected.length} selected</Text>
|
||||
<Group gap="sm" align="flex-end">
|
||||
<Select
|
||||
label="Load onto truck"
|
||||
placeholder={truckOptions.length ? 'Select truck' : 'No arrived truck'}
|
||||
data={truckOptions}
|
||||
value={truckId}
|
||||
onChange={setTruckId}
|
||||
disabled={truckOptions.length === 0}
|
||||
w={260}
|
||||
/>
|
||||
<Button
|
||||
color="edr-green"
|
||||
disabled={selected.length === 0 || !truckId}
|
||||
loading={loadMutation.isPending}
|
||||
onClick={() => loadMutation.mutate()}
|
||||
>
|
||||
Load selected
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -62,6 +62,7 @@ import type {
|
||||
import { BookingSelect } from './BookingSelect';
|
||||
import { DeliverInventoryModal } from './DeliverInventoryModal';
|
||||
import { TruckDispatchModal } from './TruckDispatchModal';
|
||||
import { ContainerItemsModal } from './ContainerItemsModal';
|
||||
import { FeePreviewModal } from './FeePreviewModal';
|
||||
import { InspectionReportModal } from './InspectionReportModal';
|
||||
import { InventoryDetailModal } from './InventoryDetailModal';
|
||||
@@ -2161,6 +2162,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [deliverItem, setDeliverItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [loadTruckItem, setLoadTruckItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [containerItemsItem, setContainerItemsItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
|
||||
const allSelected = rows.length > 0 && selected.size === rows.length;
|
||||
const someSelected = selected.size > 0 && !allSelected;
|
||||
@@ -2376,7 +2378,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
<Table.Td ta="right">
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<Tooltip label="View details" withArrow>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => setViewItem(toInventoryItem(r))}>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => setContainerItemsItem(toInventoryItem(r))}>
|
||||
<Eye size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
@@ -2500,6 +2502,12 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
bookingId={loadTruckItem?.booking?.id ?? null}
|
||||
bookingReference={loadTruckItem?.booking?.reference ?? null}
|
||||
/>
|
||||
<ContainerItemsModal
|
||||
opened={Boolean(containerItemsItem)}
|
||||
onClose={() => setContainerItemsItem(null)}
|
||||
bookingId={containerItemsItem?.booking?.id ?? null}
|
||||
bookingReference={containerItemsItem?.booking?.reference ?? null}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user