From 692d9074d09d70015f18b509ee873563163a0798 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 6 Jul 2026 13:46:16 +0000 Subject: [PATCH 1/3] 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 + + { + setScheduleId(v); + setSelected([]); + setTab('received'); + }} + disabled={trainOptions.length === 0} + leftSection={} + w={460} + searchable + /> + + + {!scheduleId ? ( + + Pick a train to see the arrived containers/cargoes allocated to it. Items appear here only + after train and wagon allocation. + + ) : ( + <> + setTab(v ?? 'received')}> + + + {received.length} + + } + > + Received + + + {loaded.length} + + } + > + Loaded + + + + + {isLoading ? ( + + + + ) : visible.length === 0 ? ( + + {tab === 'loaded' ? 'Nothing loaded onto this train yet.' : 'No arrived items ready for this train.'} + + ) : ( + + + + + + {tab === 'received' && ( + 0} + onChange={toggleAll} + disabled={selectableVisible.length === 0} + /> + )} + + Container / Cargo + Goods + Weight + Stage + Wagon + Booking + Customer + Inspection + + + {visible.map(renderRow)} +
+
+ )} + + {tab === 'received' && ( + + + {selected.length} selected · only READY_FOR_LOADING items with an allocated wagon can be loaded + + + + )} + + )} +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts index c5545687b..16037b199 100644 --- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts @@ -79,6 +79,42 @@ export interface ContainerItem { hasLastMile: boolean; } +/** A pre-dispatch EXPORT train that has inventory waiting to be loaded. */ +export interface LoadableTrain { + scheduleId: string; + trainNumber: string | null; + origin: string | null; + destination: string | null; + status: string; + departureTime: string | null; + readyCount: number; + loadedCount: number; +} + +/** A container/cargo inventory item assigned to a train, with its allocated wagon. */ +export interface TrainLoadableItem { + id: string; + bookingId: string | null; + bookingReference: string | null; + customerName: string | null; + containerNumber: string | null; + cargoType: string | null; + weight: number | null; + grnNumber: string | null; + inspectionStatus: string | null; + status: string; + wagonId: string | null; + wagonNumber: string | null; + sequenceNo: number | null; + loadable: boolean; +} + +export interface TrainLoadResult { + loadedCount: number; + skippedCount: number; + results: { inventoryId: string; status: string; reason?: string }[]; +} + const cleanParams = (params: object) => Object.fromEntries( Object.entries(params).filter(([, value]) => value !== undefined && value !== '' && value !== null), @@ -120,6 +156,33 @@ export const warehouseService = { return data?.data ?? data ?? []; }, + // ── Load to Train ───────────────────────────────────────────────────────── + /** Pre-dispatch EXPORT trains with inventory waiting to be loaded. */ + getLoadableTrains: async (): Promise => { + const { data } = await apiClient.get('/warehouse-inventory/loadable-trains'); + return data?.data ?? data ?? []; + }, + + /** Container/cargo items assigned to a train, with allocated wagon + stage. */ + getTrainLoadableItems: async (scheduleId: string): Promise => { + const { data } = await apiClient.get( + `/warehouse-inventory/train/${scheduleId}/loadable-items`, + ); + return data?.data ?? data ?? []; + }, + + /** Load selected inventory items onto their allocated wagons for a train. */ + loadItemsOntoTrain: async ( + scheduleId: string, + inventoryIds: string[], + ): Promise => { + const { data } = await apiClient.post( + `/warehouse-inventory/train/${scheduleId}/load`, + { inventoryIds }, + ); + return data?.data ?? data ?? { loadedCount: 0, skippedCount: 0, results: [] }; + }, + // ── Warehouses ────────────────────────────────────────────────────────── list: (filter?: WarehouseFilter) => apiClient.get(URL_CONSTANTS.WAREHOUSES.BASE, { From 8785d992ab07e641c50d5d867fc5f51603178acc Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 6 Jul 2026 19:00:04 +0000 Subject: [PATCH 3/3] export loading --- .../modules/warehouses/warehouse-inventory.service.ts | 2 +- .../src/pages/warehouses/LoadingQueuePage.tsx | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) 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 f8cc32fe8..bf7139d72 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 @@ -1179,7 +1179,7 @@ export class WarehouseInventoryService { ct.container_number AS "containerNumber", COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType", inv.weight AS "weight", - COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber", + substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') AS "grnNumber", inv.inspection_status AS "inspectionStatus", inv.status AS "status", wl.wagon_id AS "wagonId", diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx index f80db55f4..652a26863 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { Badge, Button, Card, Group, Tabs, Text } from '@mantine/core'; -import { CreditCard, Eye, Truck } from 'lucide-react'; +import { CreditCard, Eye, Truck, TrainFront } from 'lucide-react'; import { DataTable, type ColumnDef } from '@edr/ui-common'; import { PageContainer, PageHeader } from '@/components/page'; @@ -10,6 +10,7 @@ import { VisualEmptyState, formatNumber, } from '@/components/warehouses'; +import { LoadToTrainPanel } from '@/components/warehouses/LoadToTrainPanel'; import { useMutation, useQuery } from '@tanstack/react-query'; import { api } from '@/services/api'; @@ -116,6 +117,9 @@ export default function LoadingQueuePage() { > Dispatch Queue + }> + Load to Train + {/* Ready to Load — PAID bookings, can be marked Loaded */} @@ -169,6 +173,11 @@ export default function LoadingQueuePage() { )} + + {/* Load to Train — per-train arrived containers/cargoes, multiselect → load onto wagons */} + + +