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:
Hagernesh
2026-07-06 13:46:16 +00:00
parent 756ad73e9c
commit 692d9074d0
5 changed files with 339 additions and 1 deletions

View File

@@ -333,6 +333,12 @@ export class WarehouseInventoryController {
return this.handoverService.list(bookingId);
}
@Get('bookings/:bookingId/container-items')
@ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' })
containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.inventoryService.containerItems(bookingId);
}
@Post(':id/deliver')
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {

View File

@@ -2308,6 +2308,95 @@ export class WarehouseInventoryService {
};
}
/**
* Per-container (or bulk) items of a booking with their lifecycle stage and
* reference sources — drives the container-level detail datatable (stage tabs,
* multiselect load-to-truck, per-item actions).
*/
async containerItems(bookingId: string): Promise<
Array<{
containerNumber: string;
goods: string | null;
stage: 'PENDING' | 'RECEIVED' | 'GRN' | 'LOADED' | 'LEFT' | 'DELIVERED';
grnNumber: string | null;
truckAssignmentId: string | null;
truckPlate: string | null;
truckArrived: boolean;
truckLeft: boolean;
bookingReference: string | null;
contractId: string | null;
hasLastMile: boolean;
}>
> {
const rows: Array<{
containerNumber: string;
goods: string | null;
received: boolean;
grnNumber: string | null;
truckAssignmentId: string | null;
truckPlate: string | null;
truckArrived: boolean;
truckLeft: boolean;
bookingReference: string | null;
contractId: string | null;
hasLastMile: boolean;
delivered: boolean;
}> = await this.dataSource.query(
`SELECT bcu.container_number AS "containerNumber",
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods,
bcu.received_to_port AS received,
bcu.grn_number AS "grnNumber",
ctc.assignment_id AS "truckAssignmentId",
a.plate_number AS "truckPlate",
(a.arrived_at IS NOT NULL) AS "truckArrived",
(a.departed_at IS NOT NULL) AS "truckLeft",
b.reference AS "bookingReference",
b.contract_id AS "contractId",
(b.last_mile_delivery_address IS NOT NULL) AS "hasLastMile",
COALESCE(inv.status = 'DELIVERED', false) AS delivered
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
JOIN freight.bookings b ON b.id = bc.booking_id
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
LEFT JOIN freight.customer_truck_containers ctc
ON ctc.container_number = bcu.container_number
AND ctc.booking_id = b.id AND ctc.deleted_at IS NULL
LEFT JOIN freight.customer_truck_assignments a
ON a.id = ctc.assignment_id AND a.deleted_at IS NULL
LEFT JOIN freight.containers cont ON cont.container_number = bcu.container_number
LEFT JOIN freight.warehouse_inventory inv
ON inv.container_id = cont.id AND inv.deleted_at IS NULL
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL
ORDER BY bcu.container_number`,
[bookingId],
);
return rows.map((r) => ({
containerNumber: r.containerNumber,
goods: r.goods,
stage: r.delivered
? 'DELIVERED'
: r.truckLeft
? 'LEFT'
: r.truckAssignmentId
? 'LOADED'
: r.grnNumber
? 'GRN'
: r.received
? 'RECEIVED'
: 'PENDING',
grnNumber: r.grnNumber,
truckAssignmentId: r.truckAssignmentId,
truckPlate: r.truckPlate,
truckArrived: r.truckArrived,
truckLeft: r.truckLeft,
bookingReference: r.bookingReference,
contractId: r.contractId,
hasLastMile: r.hasLastMile,
}));
}
/**
* Per-truck exit paper: one paper covering the containers loaded on a specific
* customer truck (used when multiple trucks leave separately). Gated on the

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -63,6 +63,22 @@ import type {
WarehouseZone,
} from '@/types/warehouse';
export type ContainerItemStage = 'PENDING' | 'RECEIVED' | 'GRN' | 'LOADED' | 'LEFT' | 'DELIVERED';
export interface ContainerItem {
containerNumber: string;
goods: string | null;
stage: ContainerItemStage;
grnNumber: string | null;
truckAssignmentId: string | null;
truckPlate: string | null;
truckArrived: boolean;
truckLeft: boolean;
bookingReference: string | null;
contractId: string | null;
hasLastMile: boolean;
}
const cleanParams = (params: object) =>
Object.fromEntries(
Object.entries(params).filter(([, value]) => value !== undefined && value !== '' && value !== null),
@@ -75,6 +91,14 @@ export const warehouseService = {
return data?.data ?? data ?? [];
},
/** Per-container/bulk items of a booking with lifecycle stage + refs. */
getContainerItems: async (bookingId: string): Promise<ContainerItem[]> => {
const { data } = await apiClient.get(
`/warehouse-inventory/bookings/${bookingId}/container-items`,
);
return data?.data ?? data ?? [];
},
/** Booking container numbers not yet loaded onto any truck. */
getLoadableContainers: async (bookingId: string): Promise<string[]> => {
const { data } = await apiClient.get(