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 c2f1ce6f5..f28a4a5c7 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 @@ -136,6 +136,12 @@ export class WarehouseInventoryController { return this.inventoryService.autoUnloadArrivedBookings(dto.scheduleId, dto.performedBy); } + @Get('import/unloaded-queue') + @ApiOperation({ summary: 'IMPORT inventory in the Unloaded Queue (UNLOADED / destination inspection)' }) + importUnloadedQueue() { + return this.inventoryService.importUnloadedQueue(); + } + @Get('loadable-wagons') @ApiOperation({ summary: 'List wagons usable for loading (read-only from scheduling)' }) loadableWagons() { 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 ad24a8b31..ebebc92ea 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 @@ -182,6 +182,23 @@ export interface AutoUnloadArrivedResult { results: { bookingId: string; inventoryId?: string; status: string; reason?: string }[]; } +export interface ImportUnloadedRow { + id: string; + bookingId: string | null; + bookingReference: string | null; + customerId: string | null; + customerName: string | null; + arrivalTime: string | null; + containerNumber: string | null; + cargoType: string | null; + weight: number | null; + trainSchedule: string | null; + inspectionStatus: string | null; + pickupOption: string; + lastMileRequested: boolean; + currentStatus: string; +} + @Injectable() export class WarehouseInventoryService { constructor( @@ -689,6 +706,55 @@ export class WarehouseInventoryService { return this.exportInventoryByStatus('LOADED'); } + /** + * Batch 9 — IMPORT inventory sitting in the Unloaded Queue (UNLOADED / destination-inspection + * states), with the columns the inspection screen needs. Direction is route-derived so only + * import items appear. Read-only. + */ + async importUnloadedQueue(): Promise { + const rows: Array< + ImportUnloadedRow & { originCountry: string | null; destinationCountry: string | null } + > = await this.dataSource.query( + `SELECT inv.id, + inv.booking_id AS "bookingId", + b.reference AS "bookingReference", + b.company_id AS "customerId", + company.name AS "customerName", + COALESCE(inv.unloaded_at, inv.arrived_at) AS "arrivalTime", + (SELECT c.container_number FROM freight.containers c + WHERE c.booking_id = b.id AND c.deleted_at IS NULL + ORDER BY c.container_number LIMIT 1) AS "containerNumber", + COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType", + inv.weight AS "weight", + ts.train_number AS "trainSchedule", + inv.inspection_status AS "inspectionStatus", + CASE WHEN b.last_mile_delivery_address IS NOT NULL + THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption", + (b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested", + inv.status AS "currentStatus", + oy.country AS "originCountry", + dy.country AS "destinationCountry" + FROM freight.warehouse_inventory inv + LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.companies company ON company.id = b.company_id + LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id + LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id + LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id + LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL + LEFT JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id + WHERE inv.deleted_at IS NULL + AND inv.status IN ('UNLOADED', 'DESTINATION_INSPECTION', 'UNDER_INSPECTION') + ORDER BY inv.created_at DESC`, + ); + + return rows + .filter( + (r) => + deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }) === 'IMPORT', + ) + .map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => rest); + } + /** * Bulk-dispatch loaded EXPORT inventory. Reuses the single-item dispatch transition * (status LOADED → DISPATCHED, capacity freed, movement/activity logged). Items not @@ -876,7 +942,8 @@ export class WarehouseInventoryService { */ async bulkMarkInspected(dto: BulkInspectDto): Promise { const result: BulkInspectResult = { inspectedCount: 0, skippedCount: 0, results: [] }; - const eligible = ['RECEIVED', 'STORED', 'RESERVED']; + // UNLOADED added for Batch 9 import destination inspection (arrived-train unload landing state). + const eligible = ['UNLOADED', 'RECEIVED', 'STORED', 'RESERVED']; for (const inventoryId of dto.inventoryIds) { const skip = (reason: string) => { @@ -897,7 +964,9 @@ export class WarehouseInventoryService { inspectedById: dto.inspectedBy, }); - // EXPORT: a passed item moves straight to Ready To Load. + // A passed item advances by trade direction: + // EXPORT → Ready To Load (READY_FOR_LOADING) + // IMPORT → Pickup Ready (READY_FOR_PICKUP) — NOT ready-for-loading. const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null; if (direction === 'EXPORT') { await this.dataSource.transaction(async (manager) => { @@ -917,6 +986,24 @@ export class WarehouseInventoryService { ); }); result.results.push({ inventoryId, status: 'READY_FOR_LOADING' }); + } else if (direction === 'IMPORT') { + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(WarehouseInventory).update(inventoryId, { + status: 'READY_FOR_PICKUP', + readyForPickupAt: new Date(), + }); + await this.activityLog.record( + { + activityType: 'READY_FOR_PICKUP', + inventoryId, + warehouseId: item.warehouseId, + description: 'Destination inspection passed → pickup ready', + performedBy: dto.inspectedBy, + }, + manager, + ); + }); + result.results.push({ inventoryId, status: 'READY_FOR_PICKUP' }); } else { result.results.push({ inventoryId, status: 'INSPECTED' }); } diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index 760eacce7..03a0adcf6 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -16,21 +16,22 @@ import { Textarea, TextInput, } from '@mantine/core'; -import { ChevronDown, ChevronRight, Info, PackageSearch, Train, Truck } from 'lucide-react'; +import { ChevronDown, ChevronRight, ClipboardCheck, Info, PackageSearch, Train, Truck } from 'lucide-react'; import { useToast } from '@/hooks/use-toast'; import { useAutoUnloadArrivedBookings, useBulkDispatchExport, + useBulkMarkInspected, useBulkReceive, useEligibleBookings, useImportArriveQueue, useImportTrainItems, + useImportUnloadedQueue, useLoadPassedExport, useLoadedExport, useReadyToLoadExport, useReceiveInventory, - useWarehouseInventory, useWarehouseYards, useWarehouseZones, useWarehouses, @@ -38,15 +39,17 @@ import { import type { AutoUnloadArrivedResult, BulkDispatchResult, + BulkInspectResult, BulkReceiveResult, ImportTrain, ImportTrainItem, + ImportUnloadedItem, LoadPassedExportResult, ReadyToLoadRow, ReceiveInventoryPayload, } from '@/types/warehouse'; import { BookingSelect } from './BookingSelect'; -import { InventoryWorkbench } from './InventoryWorkbench'; +import { InspectionReportModal } from './InspectionReportModal'; import { extractErrorMessage, formatDate, formatNumber } from './options'; interface ReceiveInventoryModalProps { @@ -855,28 +858,166 @@ function ImportArriveQueueTab({ } /** - * Import Unloaded Queue: items unloaded off arrived trains (status UNLOADED), with the full set of - * lifecycle actions (Inspect / Store / Move / History / …) plus a Last Mile action shown only when - * the booking requested door delivery. No automatic storage happens here — the operator drives it. + * Import Unloaded Queue (Batch 9): all unloaded import items with the destination-inspection columns. + * Multi-select + Mark Selected as Inspected (import passed → READY_FOR_PICKUP), and the per-item + * Inspect / Report action stays for damage / images / weight-loss detail. */ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { const { toast } = useToast(); - const { data: items = [], isLoading } = useWarehouseInventory(enabled ? { status: 'UNLOADED' } : undefined); + const { data: rows = [], isLoading } = useImportUnloadedQueue(enabled); + const inspectMutation = useBulkMarkInspected(); + const [selected, setSelected] = useState>(new Set()); + const [inspectId, setInspectId] = useState(null); + + const allSelected = rows.length > 0 && selected.size === rows.length; + const someSelected = selected.size > 0 && !allSelected; + const selectAll = () => setSelected(new Set(rows.map((r) => r.id))); + const unselectAll = () => setSelected(new Set()); + const toggleOne = (id: string) => + setSelected((prev) => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + + const markInspected = async () => { + if (selected.size === 0) { + toast({ variant: 'destructive', title: 'Select at least one item' }); + return; + } + try { + const res = (await inspectMutation.mutateAsync({ inventoryIds: [...selected] })) as { + data: BulkInspectResult; + }; + const r = res.data; + toast({ + title: `${r.inspectedCount} marked inspected`, + description: r.skippedCount ? `${r.skippedCount} skipped` : undefined, + }); + setSelected(new Set()); + } catch (error) { + toast({ variant: 'destructive', title: 'Mark inspected failed', description: extractErrorMessage(error) }); + } + }; return ( - - {items.length} unloaded item{items.length !== 1 ? 's' : ''} - - - toast({ - title: 'Last mile delivery', - description: `Door delivery for ${it.booking?.reference ?? it.id} — handled in the next batch.`, - }) - } + + + Selected: {selected.size} / {rows.length} unloaded + + + + + + + + + {isLoading ? ( + + + + ) : rows.length === 0 ? ( + + No unloaded import items. Items appear here after Auto Unload on an arrived train. + + ) : ( + + + + + + (allSelected ? unselectAll() : selectAll())} + /> + + Booking ID + Booking Ref + Customer ID + Customer Name + Arrival Time + Container # + Cargo Type + Weight + Train Schedule + Inspection + Pickup Option + Last Mile + Current Status + Actions + + + + {rows.map((r: ImportUnloadedItem) => ( + + + toggleOne(r.id)} + /> + + + {r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'} + + + {r.bookingReference ?? '—'} + + + {r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'} + + {r.customerName ?? '—'} + {formatDate(r.arrivalTime)} + {r.containerNumber ?? '—'} + {r.cargoType ?? '—'} + {formatNumber(Number(r.weight))} + {r.trainSchedule ?? '—'} + + + {r.inspectionStatus ?? 'Not inspected'} + + + {r.pickupOption} + + + {r.lastMileRequested ? 'Yes' : 'No'} + + + + {r.currentStatus} + + + + + + ))} + +
+
+ )} + + setInspectId(null)} + inventoryId={inspectId} />
); diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 00bc08c34..c7a02c0c6 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -321,6 +321,7 @@ export const URL_CONSTANTS = { IMPORT_TRAIN_ITEMS: (scheduleId: string) => `/warehouse-inventory/import/trains/${scheduleId}/items`, IMPORT_AUTO_UNLOAD_ARRIVED: '/warehouse-inventory/import/auto-unload-arrived-bookings', + IMPORT_UNLOADED_QUEUE: '/warehouse-inventory/import/unloaded-queue', }, WAREHOUSE_LOADINGS: { diff --git a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts index ba44295a5..b7ff9b4e9 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts @@ -249,6 +249,15 @@ export function useImportTrainItems(scheduleId?: string) { export const useAutoUnloadArrivedBookings = () => useInventoryMutation((scheduleId: string) => warehouseService.autoUnloadArrivedBookings(scheduleId)); +/** IMPORT inventory in the Unloaded Queue (UNLOADED / destination inspection). Read-only. */ +export function useImportUnloadedQueue(enabled = true) { + return useQuery({ + queryKey: ['warehouse-inventory', 'import-unloaded-queue'], + queryFn: () => warehouseService.importUnloadedQueue().then((r) => r.data), + enabled, + }); +} + // ── Loading (Batch 3) ──────────────────────────────────────────────────────── export function useLoadableWagons(enabled = true) { 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 e223a1734..f11a62fd9 100644 --- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts @@ -39,6 +39,7 @@ import type { BulkDispatchResult, ImportTrain, ImportTrainItem, + ImportUnloadedItem, AutoUnloadArrivedResult, ReserveInventoryPayload, SaveWarehousePayload, @@ -152,6 +153,8 @@ export const warehouseService = { URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_AUTO_UNLOAD_ARRIVED, { scheduleId }, ), + importUnloadedQueue: () => + apiClient.get(URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_UNLOADED_QUEUE), move: (id: string, payload: MoveInventoryPayload) => apiClient.post(URL_CONSTANTS.WAREHOUSE_INVENTORY.MOVE(id), payload), movements: (id: string) => diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts index 16dd29b45..182baf63c 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -426,6 +426,23 @@ export interface AutoUnloadArrivedResult { results: { bookingId: string; inventoryId?: string; status: string; reason?: string }[]; } +export interface ImportUnloadedItem { + id: string; + bookingId: string | null; + bookingReference: string | null; + customerId: string | null; + customerName: string | null; + arrivalTime: string | null; + containerNumber: string | null; + cargoType: string | null; + weight: number | null; + trainSchedule: string | null; + inspectionStatus: string | null; + pickupOption: string; + lastMileRequested: boolean; + currentStatus: string; +} + export interface ImportTrainItem { bookingId: string; bookingReference: string | null;