From 50c6381341b13697959ddf64c2719f365dd99a29 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 20 Jun 2026 17:52:01 +0000 Subject: [PATCH] =?UTF-8?q?feat(warehouse):=20Batch=203+4=20=E2=80=94=20ex?= =?UTF-8?q?port=20receive-selected=20label=20+=20bulk-mark-inspected=20wor?= =?UTF-8?q?kflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BulkReceiveModal: Receive Selected shows "received at facility" for EXPORT - Export inspection: POST /warehouse-inventory/bulk-mark-inspected reuses WarehouseInspectionService.create - EXPORT items advance to READY_FOR_LOADING after inspection PASSED - InventoryWorkbench: selection state + "Mark Selected as Inspected" bulk button - WarehouseInventoryTable: optional Checkbox column for bulk selection - Route-based direction in eligibleBookings, bulkReceive, getBookingDirection - New BulkInspectDto; service + controller wired Co-Authored-By: Claude Sonnet 4.6 --- .../warehouses/dto/bulk-inspect.dto.ts | 26 +++++++ .../warehouse-inventory.controller.ts | 7 ++ .../warehouses/warehouse-inventory.service.ts | 66 ++++++++++++++++ .../warehouses/InventoryWorkbench.tsx | 78 ++++++++++++++++--- .../warehouses/ReceiveInventoryModal.tsx | 2 +- .../warehouses/WarehouseInventoryTable.tsx | 33 +++++++- .../backoffice/src/constants/URLS.ts | 1 + .../backoffice/src/hooks/useWarehouses.ts | 3 + .../src/services/warehouse.service.ts | 4 + .../backoffice/src/types/warehouse.ts | 12 +++ 10 files changed, 220 insertions(+), 12 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/warehouses/dto/bulk-inspect.dto.ts diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/bulk-inspect.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-inspect.dto.ts new file mode 100644 index 000000000..bfdc813f0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-inspect.dto.ts @@ -0,0 +1,26 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ArrayNotEmpty, IsArray, IsOptional, IsString, IsUUID } from 'class-validator'; + +/** Bulk-mark received inventory items as inspection PASSED. */ +export class BulkInspectDto { + @ApiProperty({ type: [String], format: 'uuid' }) + @IsArray() + @ArrayNotEmpty() + @IsUUID('all', { each: true }) + inventoryIds!: string[]; + + @ApiPropertyOptional({ description: 'Inspection type label (e.g. ORIGIN_INSPECTION).' }) + @IsOptional() + @IsString() + inspectionType?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + remarks?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + inspectedBy?: string; +} 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 e3dc46577..aec3d3311 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 @@ -2,6 +2,7 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { BulkReceiveDto } from './dto/bulk-receive.dto'; +import { BulkInspectDto } from './dto/bulk-inspect.dto'; import { DeliverInventoryDto } from './dto/deliver-inventory.dto'; import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto'; import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto'; @@ -77,6 +78,12 @@ export class WarehouseInventoryController { return this.inventoryService.loadPassedExport(performedBy); } + @Post('bulk-mark-inspected') + @ApiOperation({ summary: 'Bulk mark received inventory inspection PASSED (EXPORT → READY_FOR_LOADING)' }) + bulkMarkInspected(@Body() dto: BulkInspectDto) { + return this.inventoryService.bulkMarkInspected(dto); + } + @Post('bookings/:bookingId/unload') @ApiOperation({ summary: 'Unload a single arrived booking into a location' }) unloadBooking( 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 29685efd3..355a79839 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 @@ -3,6 +3,7 @@ import { DataSource, EntityManager, FindManyOptions, ILike } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { Cargo } from '../cargoes/entities/cargoes.entity'; +import { BulkInspectDto } from './dto/bulk-inspect.dto'; import { BulkReceiveDto } from './dto/bulk-receive.dto'; import { DeliverInventoryDto } from './dto/deliver-inventory.dto'; import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto'; @@ -14,6 +15,7 @@ import { ReleaseOrderDto } from './dto/release-order.dto'; import { ReserveInventoryDto } from './dto/reserve-inventory.dto'; import { UnloadBookingDto } from './dto/unload-booking.dto'; import { WarehouseAllocationService } from './warehouse-allocation.service'; +import { WarehouseInspectionService } from './warehouse-inspection.service'; import { WarehouseInvoiceService } from './warehouse-invoice.service'; import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity'; import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity'; @@ -146,6 +148,12 @@ export interface LoadPassedExportResult { results: { inventoryId: string; status: string; reason?: string }[]; } +export interface BulkInspectResult { + inspectedCount: number; + skippedCount: number; + results: { inventoryId: string; status: string; reason?: string }[]; +} + @Injectable() export class WarehouseInventoryService { constructor( @@ -156,6 +164,7 @@ export class WarehouseInventoryService { private readonly scheduling: SchedulingReadFacade, private readonly allocation: WarehouseAllocationService, private readonly invoices: WarehouseInvoiceService, + private readonly inspectionService: WarehouseInspectionService, ) {} /** @@ -592,6 +601,63 @@ export class WarehouseInventoryService { 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. + * For damage / weight-loss / images, use the per-item Inspect / Report action instead. + */ + async bulkMarkInspected(dto: BulkInspectDto): Promise { + const result: BulkInspectResult = { inspectedCount: 0, skippedCount: 0, results: [] }; + const eligible = ['RECEIVED', 'STORED', 'RESERVED']; + + for (const inventoryId of dto.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.inspectionStatus === 'PASSED') { skip('Already inspected'); continue; } + if (!eligible.includes(item.status)) { skip(`Status ${item.status} not eligible for inspection`); continue; } + + // Reuse the existing inspection service: creates a minimal PASSED report + sets inspectionStatus/inspectedAt. + await this.inspectionService.create(inventoryId, { + reportType: 'INSPECTION', + inspectionStatus: 'PASSED', + remarks: dto.remarks ?? 'Bulk marked inspected (PASSED).', + inspectedById: dto.inspectedBy, + }); + + // EXPORT: a passed item moves straight to Ready To Load. + const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null; + if (direction === 'EXPORT') { + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(WarehouseInventory).update(inventoryId, { + status: 'READY_FOR_LOADING', + readyForLoadingAt: new Date(), + }); + await this.activityLog.record( + { + activityType: 'READY_FOR_LOADING', + inventoryId, + warehouseId: item.warehouseId, + description: 'Inspection passed → ready for loading', + performedBy: dto.inspectedBy, + }, + manager, + ); + }); + result.results.push({ inventoryId, status: 'READY_FOR_LOADING' }); + } else { + result.results.push({ inventoryId, status: 'INSPECTED' }); + } + result.inspectedCount += 1; + } + + return result; + } + // ── Receive ────────────────────────────────────────────────────────────── async receive(dto: ReceiveWarehouseInventoryDto): Promise { diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx index a2b1f7c2b..7322c2cde 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx @@ -1,8 +1,10 @@ import { useState } from 'react'; -import { Center, Loader } from '@mantine/core'; +import { Button, Center, Group, Loader, Stack, Text } from '@mantine/core'; +import { ClipboardCheck } from 'lucide-react'; import { useToast } from '@/hooks/use-toast'; import { + useBulkMarkInspected, useDispatchInventory, useMarkReadyForLoading, useMarkReadyForPickup, @@ -42,6 +44,39 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps const readyMutation = useMarkReadyForLoading(); const pickupMutation = useMarkReadyForPickup(); const dispatchMutation = useDispatchInventory(); + const inspectMutation = useBulkMarkInspected(); + + const [selected, setSelected] = useState>(new Set()); + const allSelected = items.length > 0 && selected.size === items.length; + const someSelected = selected.size > 0 && !allSelected; + const toggleSelect = (id: string) => + setSelected((prev) => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + const toggleSelectAll = () => + setSelected(allSelected ? new Set() : new Set(items.map((i) => i.id))); + + 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: { inspectedCount: number; skippedCount: number }; + }; + 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) }); + } + }; const runDirect = async (item: WarehouseInventoryItem, fn: () => Promise, label: string) => { setBusyId(item.id); @@ -92,15 +127,38 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps return ( <> - + + + + Selected: {selected.size} + + + + + + setMoveItem(null)} item={moveItem} /> void; onInspect?: (item: WarehouseInventoryItem) => void; onFeePreview?: (item: WarehouseInventoryItem) => void; + // Optional row selection (used for bulk Mark-as-Inspected). + selectedIds?: Set; + onToggleSelect?: (id: string) => void; + onToggleSelectAll?: () => void; + allSelected?: boolean; + someSelected?: boolean; } const itemKind = (item: WarehouseInventoryItem) => { @@ -42,7 +48,13 @@ export function WarehouseInventoryTable({ onHistory, onInspect, onFeePreview, + selectedIds, + onToggleSelect, + onToggleSelectAll, + allSelected, + someSelected, }: WarehouseInventoryTableProps) { + const selectable = Boolean(onToggleSelect); if (items.length === 0) { return ( @@ -56,6 +68,16 @@ export function WarehouseInventoryTable({ + {selectable && ( + + + + )} Booking Facility Warehouse @@ -76,6 +98,15 @@ export function WarehouseInventoryTable({ const nextAction = getNextInventoryAction(item); return ( + {selectable && ( + + onToggleSelect?.(item.id)} + /> + + )} {item.bookingId ? ( diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index bc1d1f312..6cdce3931 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -310,6 +310,7 @@ export const URL_CONSTANTS = { ELIGIBLE_BOOKINGS: (direction: string) => `/warehouse-inventory/eligible-bookings?direction=${direction}`, RECEIVE_BULK: '/warehouse-inventory/receive-bulk', LOAD_PASSED_EXPORT: '/warehouse-inventory/load-passed-export', + BULK_MARK_INSPECTED: '/warehouse-inventory/bulk-mark-inspected', }, 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 8fb3ea04c..6683d0b85 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts @@ -15,6 +15,7 @@ import type { ReleaseOrderPayload, DeliverInventoryPayload, BulkReceivePayload, + BulkInspectPayload, ReserveInventoryPayload, SaveWarehousePayload, SaveYardPayload, @@ -199,6 +200,8 @@ export const useBulkReceive = () => useInventoryMutation((payload: BulkReceivePayload) => warehouseService.receiveBulk(payload)); export const useLoadPassedExport = () => useInventoryMutation(() => warehouseService.loadPassedExport()); +export const useBulkMarkInspected = () => + useInventoryMutation((payload: BulkInspectPayload) => warehouseService.bulkMarkInspected(payload)); // ── Loading (Batch 3) ──────────────────────────────────────────────────────── 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 b1cde2c85..c77167bb0 100644 --- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts @@ -33,6 +33,8 @@ import type { BulkReceivePayload, BulkReceiveResult, LoadPassedExportResult, + BulkInspectPayload, + BulkInspectResult, ReserveInventoryPayload, SaveWarehousePayload, SaveYardPayload, @@ -126,6 +128,8 @@ export const warehouseService = { apiClient.post(URL_CONSTANTS.WAREHOUSE_INVENTORY.RECEIVE_BULK, payload), loadPassedExport: () => apiClient.post(URL_CONSTANTS.WAREHOUSE_INVENTORY.LOAD_PASSED_EXPORT, {}), + bulkMarkInspected: (payload: BulkInspectPayload) => + apiClient.post(URL_CONSTANTS.WAREHOUSE_INVENTORY.BULK_MARK_INSPECTED, payload), 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 86f315ce5..cc7663ae9 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -368,6 +368,18 @@ export interface LoadPassedExportResult { results: { inventoryId: string; status: string; reason?: string }[]; } +export interface BulkInspectPayload { + inventoryIds: string[]; + inspectionType?: string; + remarks?: string; +} + +export interface BulkInspectResult { + inspectedCount: number; + skippedCount: number; + results: { inventoryId: string; status: string; reason?: string }[]; +} + export interface InventoryInquiryResult { id: string; bookingId: string;