From 0ab508e377974a95a21eb1e25f4537d7926d5061 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 20 Jun 2026 20:04:18 +0000 Subject: [PATCH] =?UTF-8?q?feat(warehouse):=20Batch=206=20=E2=80=94=20Expo?= =?UTF-8?q?rt=20Loaded=20queue=20+=20Dispatch=20Queue=20with=20bulk=20disp?= =?UTF-8?q?atch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /warehouse-inventory/loaded-export: EXPORT+LOADED items (route-derived direction) - POST /warehouse-inventory/bulk-dispatch-export: reuses existing dispatch() transition (LOADED → DISPATCHED, capacity freed, movement/activity logged); skips non-LOADED/non-EXPORT - Extracted shared exportInventoryByStatus() helper (readyToLoadExport now delegates to it) - Frontend LoadedExportTab serves both Loaded (read-only) and Dispatch Queue (dispatchable) sub-tabs with Dispatch / Dispatch Selected / Dispatch All + per-row Dispatch - Replaces "Loaded" and "Dispatch Queue — coming in the next batch" placeholders - Train/schedule flow (DISPATCHED → IN_TRANSIT) unchanged Co-Authored-By: Claude Sonnet 4.6 --- .../warehouse-inventory.controller.ts | 12 ++ .../warehouses/warehouse-inventory.service.ts | 60 +++++- .../warehouses/ReceiveInventoryModal.tsx | 195 +++++++++++++++++- .../backoffice/src/constants/URLS.ts | 2 + .../backoffice/src/hooks/useWarehouses.ts | 11 + .../src/services/warehouse.service.ts | 7 + .../backoffice/src/types/warehouse.ts | 6 + 7 files changed, 282 insertions(+), 11 deletions(-) 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 cb6147e42..9fb31f11a 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 @@ -84,6 +84,18 @@ export class WarehouseInventoryController { return this.inventoryService.readyToLoadExport(); } + @Get('loaded-export') + @ApiOperation({ summary: 'EXPORT inventory that is LOADED and queued for dispatch' }) + loadedExport() { + return this.inventoryService.loadedExport(); + } + + @Post('bulk-dispatch-export') + @ApiOperation({ summary: 'Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)' }) + bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }) { + return this.inventoryService.bulkDispatchExport(dto.inventoryIds ?? [], dto.performedBy); + } + @Post('bulk-mark-inspected') @ApiOperation({ summary: 'Bulk mark received inventory inspection PASSED (EXPORT → READY_FOR_LOADING)' }) bulkMarkInspected(@Body() dto: BulkInspectDto) { 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 2cf586f14..79cb9dde2 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 @@ -169,6 +169,12 @@ export interface ReadyToLoadRow { status: string; } +export interface BulkDispatchResult { + dispatchedCount: number; + skippedCount: number; + results: { inventoryId: string; status: string; reason?: string }[]; +} + @Injectable() export class WarehouseInventoryService { constructor( @@ -616,8 +622,11 @@ export class WarehouseInventoryService { return result; } - /** EXPORT inventory that passed inspection and is waiting to be loaded (READY_FOR_LOADING). */ - async readyToLoadExport(): Promise { + /** EXPORT inventory rows at a given status (route-derived direction), with booking detail. */ + private async exportInventoryByStatus( + status: WarehouseInventoryStatus, + requireInspectionPassed = false, + ): Promise { const rows: Array< ReadyToLoadRow & { originCountry: string | null; destinationCountry: string | null } > = await this.dataSource.query( @@ -643,9 +652,10 @@ export class WarehouseInventoryService { LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id LEFT JOIN freight.containers ct ON ct.id = inv.container_id WHERE inv.deleted_at IS NULL - AND inv.status = 'READY_FOR_LOADING' - AND inv.inspection_status = 'PASSED' + AND inv.status = $1 + ${requireInspectionPassed ? `AND inv.inspection_status = 'PASSED'` : ''} ORDER BY inv.created_at DESC`, + [status], ); return rows @@ -656,6 +666,48 @@ export class WarehouseInventoryService { .map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => rest); } + /** EXPORT inventory that passed inspection and is waiting to be loaded (READY_FOR_LOADING). */ + async readyToLoadExport(): Promise { + return this.exportInventoryByStatus('READY_FOR_LOADING', true); + } + + /** EXPORT inventory that has been loaded onto a wagon and is queued for dispatch (LOADED). */ + async loadedExport(): Promise { + return this.exportInventoryByStatus('LOADED'); + } + + /** + * Bulk-dispatch loaded EXPORT inventory. Reuses the single-item dispatch transition + * (status LOADED → DISPATCHED, capacity freed, movement/activity logged). Items not + * LOADED or not EXPORT are skipped. The train/schedule flow later moves DISPATCHED → IN_TRANSIT. + */ + async bulkDispatchExport(inventoryIds: string[], performedBy?: string): Promise { + const result: BulkDispatchResult = { dispatchedCount: 0, skippedCount: 0, results: [] }; + + for (const inventoryId of inventoryIds) { + const skip = (reason: string) => { + result.skippedCount += 1; + result.results.push({ inventoryId, status: 'SKIPPED', reason }); + }; + + const item = await this.inventoryRepository.findById(inventoryId); + if (!item) { skip('Inventory not found'); continue; } + if (item.status !== 'LOADED') { skip(`Status is ${item.status}, not LOADED`); continue; } + const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null; + if (direction !== 'EXPORT') { skip('Not an EXPORT item'); continue; } + + try { + await this.dispatch(inventoryId, performedBy); + result.dispatchedCount += 1; + result.results.push({ inventoryId, status: 'DISPATCHED' }); + } catch (error) { + skip(error instanceof Error ? error.message : String(error)); + } + } + + return result; + } + /** * Bulk-mark received items inspection PASSED (reusing the inspection service to create a minimal * report + sync inspectionStatus/inspectedAt). EXPORT items advance straight to READY_FOR_LOADING. 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 c0e513f27..9a14cdfb3 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -20,16 +20,24 @@ import { Info, PackageSearch, Truck } from 'lucide-react'; import { useToast } from '@/hooks/use-toast'; import { + useBulkDispatchExport, useBulkReceive, useEligibleBookings, useLoadPassedExport, + useLoadedExport, useReadyToLoadExport, useReceiveInventory, useWarehouseYards, useWarehouseZones, useWarehouses, } from '@/hooks/useWarehouses'; -import type { BulkReceiveResult, LoadPassedExportResult, ReadyToLoadRow, ReceiveInventoryPayload } from '@/types/warehouse'; +import type { + BulkDispatchResult, + BulkReceiveResult, + LoadPassedExportResult, + ReadyToLoadRow, + ReceiveInventoryPayload, +} from '@/types/warehouse'; import { BookingSelect } from './BookingSelect'; import { extractErrorMessage, formatNumber } from './options'; @@ -458,6 +466,183 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: ); } +/** + * Export items that are LOADED onto a wagon. Serves both the "Loaded" tab (read-only) + * and the "Dispatch Queue" tab (dispatchable=true → selection + Dispatch actions). + */ +function LoadedExportTab({ + enabled, + dispatchable, + onChanged, +}: { + enabled: boolean; + dispatchable: boolean; + onChanged?: () => void; +}) { + const { toast } = useToast(); + const { data: rows = [], isLoading } = useLoadedExport(enabled); + const bulkDispatch = useBulkDispatchExport(); + const [selected, setSelected] = useState>(new Set()); + + const allSelected = rows.length > 0 && selected.size === rows.length; + const someSelected = selected.size > 0 && !allSelected; + const toggleAll = () => setSelected(allSelected ? new Set() : new Set(rows.map((r) => r.id))); + const toggleOne = (id: string) => + setSelected((prev) => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + + const dispatch = async (inventoryIds: string[]) => { + if (inventoryIds.length === 0) { + toast({ variant: 'destructive', title: 'Select at least one item' }); + return; + } + try { + const res = (await bulkDispatch.mutateAsync(inventoryIds)) as { data: BulkDispatchResult }; + const r = res.data; + toast({ + title: `${r.dispatchedCount} dispatched`, + description: r.skippedCount ? `${r.skippedCount} skipped` : undefined, + }); + setSelected(new Set()); + onChanged?.(); + } catch (error) { + toast({ variant: 'destructive', title: 'Dispatch failed', description: extractErrorMessage(error) }); + } + }; + + return ( + + + + {dispatchable ? ( + <> + Selected: {selected.size} / {rows.length} loaded + + ) : ( + <> + {rows.length} item{rows.length !== 1 ? 's' : ''} loaded + + )} + + {dispatchable && ( + + + + + )} + + + {isLoading ? ( + + + + ) : rows.length === 0 ? ( + + No LOADED export items {dispatchable ? 'waiting to dispatch' : 'yet'}. + + ) : ( + + + + + {dispatchable && ( + + + + )} + Booking Ref + Booking ID + Customer ID + Customer Name + Container # + Cargo Type + Weight + Route + Status + {dispatchable && Actions} + + + + {rows.map((r: ReadyToLoadRow) => ( + + {dispatchable && ( + + toggleOne(r.id)} + /> + + )} + + {r.bookingReference ?? '—'} + + + {r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'} + + + {r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'} + + {r.customerName ?? '—'} + {r.containerNumber ?? '—'} + {r.cargoType ?? '—'} + {formatNumber(Number(r.weight))} + + {r.origin || r.destination ? `${r.origin ?? '?'} → ${r.destination ?? '?'}` : '—'} + + + + {r.status} + + + {dispatchable && ( + + + + )} + + ))} + +
+
+ )} +
+ ); +} + /** New bulk Receive: Import / Export tabs with eligible PAID bookings. */ function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModalProps) { const [location, setLocation] = useState({ warehouseId: '', yardId: '', zoneId: '' }); @@ -501,14 +686,10 @@ function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModal - - Loaded — coming in the next batch. - + - - Dispatch Queue — coming in the next batch. - + diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index cb8a83d94..c9254c447 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -312,6 +312,8 @@ export const URL_CONSTANTS = { LOAD_PASSED_EXPORT: '/warehouse-inventory/load-passed-export', BULK_MARK_INSPECTED: '/warehouse-inventory/bulk-mark-inspected', READY_TO_LOAD_EXPORT: '/warehouse-inventory/ready-to-load-export', + LOADED_EXPORT: '/warehouse-inventory/loaded-export', + BULK_DISPATCH_EXPORT: '/warehouse-inventory/bulk-dispatch-export', }, 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 5b9a3d6e5..84af0e714 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts @@ -211,6 +211,17 @@ export function useReadyToLoadExport(enabled = true) { }); } +export function useLoadedExport(enabled = true) { + return useQuery({ + queryKey: ['warehouse-inventory', 'loaded-export'], + queryFn: () => warehouseService.loadedExport().then((r) => r.data), + enabled, + }); +} + +export const useBulkDispatchExport = () => + useInventoryMutation((inventoryIds: string[]) => warehouseService.bulkDispatchExport(inventoryIds)); + // ── 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 275fa6c61..2a1ccf29f 100644 --- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts @@ -36,6 +36,7 @@ import type { BulkInspectPayload, BulkInspectResult, ReadyToLoadRow, + BulkDispatchResult, ReserveInventoryPayload, SaveWarehousePayload, SaveYardPayload, @@ -133,6 +134,12 @@ export const warehouseService = { apiClient.post(URL_CONSTANTS.WAREHOUSE_INVENTORY.BULK_MARK_INSPECTED, payload), readyToLoadExport: () => apiClient.get(URL_CONSTANTS.WAREHOUSE_INVENTORY.READY_TO_LOAD_EXPORT), + loadedExport: () => + apiClient.get(URL_CONSTANTS.WAREHOUSE_INVENTORY.LOADED_EXPORT), + bulkDispatchExport: (inventoryIds: string[]) => + apiClient.post(URL_CONSTANTS.WAREHOUSE_INVENTORY.BULK_DISPATCH_EXPORT, { + inventoryIds, + }), 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 8968e50e2..e0d39f559 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -395,6 +395,12 @@ export interface ReadyToLoadRow { status: string; } +export interface BulkDispatchResult { + dispatchedCount: number; + skippedCount: number; + results: { inventoryId: string; status: string; reason?: string }[]; +} + export interface InventoryInquiryResult { id: string; bookingId: string;