From 692d9074d09d70015f18b509ee873563163a0798 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 6 Jul 2026 13:46:16 +0000 Subject: [PATCH] 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) --- .../warehouse-inventory.controller.ts | 6 + .../warehouses/warehouse-inventory.service.ts | 89 ++++++++ .../warehouses/ContainerItemsModal.tsx | 211 ++++++++++++++++++ .../warehouses/ReceiveInventoryModal.tsx | 10 +- .../src/services/warehouse.service.ts | 24 ++ 5 files changed, 339 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index cd1aa432c..26242aeba 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -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) { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 8e9457831..6adbed278 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -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 diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx new file mode 100644 index 000000000..168bfd744 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx @@ -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 = { + 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([]); + const [truckId, setTruckId] = useState(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 ( + Container / bulk items {bookingReference ? `· ${bookingReference}` : ''}} + > + setTab(v ?? 'ALL')} mb="sm"> + + {STAGE_TABS.map((t) => { + const count = t.value === 'ALL' ? items.length : items.filter((i) => i.stage === t.value).length; + return ( + {count}}> + {t.label} + + ); + })} + + + + {isLoading ? ( + + + + ) : items.length === 0 ? ( + No container or bulk items on this booking. + ) : ( + + + + + + + Container + Goods + Stage + Truck + Booking + Contract + Last mile + Actions + + + + {visible.map((i) => ( + + + toggle(i.containerNumber)} + disabled={!isLoadable(i)} + /> + + {i.containerNumber} + {i.goods ?? '—'} + {i.stage} + {i.truckPlate ?? '—'} + {i.bookingReference ?? '—'} + {i.contractId ? Contract : '—'} + {i.hasLastMile ? EDR : Self-haul} + + {i.truckAssignmentId && ( + + )} + + + ))} + +
+
+ + {/* Multiselect → load onto a truck */} + + {selected.length} selected + +