From 80904eeeb2ac2fef959286d100c9adc9bef82b95 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 8 Jul 2026 09:32:24 +0000 Subject: [PATCH] Handover SIGN notification, resending until signed no exit before sign --- .../bookings/customer-truck.service.ts | 9 + .../notifications/notify-company.util.ts | 37 ++++ .../warehouses/dto/create-warehouse.dto.ts | 7 +- .../warehouses/dto/store-inventory.dto.ts | 29 +++ .../entities/warehouse-inventory.entity.ts | 4 +- .../modules/warehouses/handover.service.ts | 33 +++- .../warehouse-inspection.service.ts | 43 ++++- .../warehouse-inventory.controller.ts | 19 +- .../warehouses/warehouse-inventory.service.ts | 104 +++++++++-- .../modules/warehouses/warehouses.service.ts | 4 +- .../warehouses/ContainerItemsModal.tsx | 50 ++++- .../warehouses/CreateWarehouseModal.tsx | 18 +- .../warehouses/InventoryWorkbench.tsx | 14 +- .../warehouses/ReceiveInventoryModal.tsx | 156 ++++++++-------- .../warehouses/ReleaseOrderModal.tsx | 172 ++++++++++-------- .../warehouses/StoreInventoryModal.tsx | 143 +++++++++++++++ .../warehouses/WarehouseInventoryTable.tsx | 1 - .../src/components/warehouses/options.ts | 21 +++ .../src/pages/operations/LastMilePage.tsx | 3 + .../backoffice/src/services/api.ts | 8 +- .../src/services/warehouse.service.ts | 26 ++- .../backoffice/src/types/warehouse.ts | 17 +- 22 files changed, 703 insertions(+), 215 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/notifications/notify-company.util.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/dto/store-inventory.dto.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/warehouses/StoreInventoryModal.tsx diff --git a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts index 693b60e09..14402f830 100644 --- a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts @@ -57,6 +57,15 @@ export class CustomerTruckService { if (requested.length) { const bookingNumbers = await this.bookingContainerNumbers(bookingId); + // Never assign more trucks than the booking has containers. + const existingTrucks = await this.dataSource + .getRepository(CustomerTruckAssignment) + .count({ where: { bookingId } }); + if (existingTrucks + 1 > bookingNumbers.length) { + throw new BadRequestException( + `Cannot assign more trucks than containers — this booking has ${bookingNumbers.length} container(s) and ${existingTrucks} truck(s) already assigned.`, + ); + } for (const n of requested) { if (!bookingNumbers.includes(n)) { throw new BadRequestException(`Container ${n} is not one of this booking's containers`); diff --git a/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts b/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts new file mode 100644 index 000000000..9d7f32c3d --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts @@ -0,0 +1,37 @@ +import { DataSource } from 'typeorm'; + +import { NotificationsService } from './notifications.service'; + +/** + * Best-effort SMS + email fan-out to a company's contacts. Looks up the + * company's phone/email and sends the message over both channels, swallowing + * per-channel failures so a missing provider never breaks the caller's flow. + */ +export async function sendCompanyChannels( + dataSource: DataSource, + notifications: NotificationsService, + companyId: string, + message: string, +): Promise { + const [contact]: Array<{ phone: string | null; email: string | null }> = + await dataSource.query( + `SELECT COALESCE(phone, etrade_phone) AS phone, email + FROM freight.companies + WHERE id = $1 AND deleted_at IS NULL`, + [companyId], + ); + if (contact?.phone) { + try { + await notifications.directSend('sms', contact.phone, message); + } catch { + /* best-effort: SMS provider unavailable */ + } + } + if (contact?.email) { + try { + await notifications.directSend('email', contact.email, message); + } catch { + /* best-effort: email provider unavailable */ + } + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts index 900a9d8ac..a99ca4f46 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, Matches, MaxLength, Min } from 'class-validator'; -import { WAREHOUSE_TYPES, WarehouseType } from '../entities/warehouse.entity'; +import { WAREHOUSE_STATUSES, WAREHOUSE_TYPES, WarehouseStatus, WarehouseType } from '../entities/warehouse.entity'; export class CreateWarehouseDto { @ApiProperty() @@ -58,4 +58,9 @@ export class CreateWarehouseDto { @IsNumber() @Min(0) maxVolume?: number; + + @ApiPropertyOptional({ enum: WAREHOUSE_STATUSES, default: 'ACTIVE' }) + @IsOptional() + @IsEnum(WAREHOUSE_STATUSES) + status?: WarehouseStatus; } diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/store-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/store-inventory.dto.ts new file mode 100644 index 000000000..08b15d536 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/store-inventory.dto.ts @@ -0,0 +1,29 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, IsUUID } from 'class-validator'; + +/** + * Optional explicit storage location. When warehouse/yard/zone are all provided, + * the item is stored there directly; otherwise store() falls back to the + * allocation-rule / capacity-balanced auto pick. + */ +export class StoreInventoryDto { + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + warehouseId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + yardId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + zoneId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + performedBy?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts index 290b6f0c2..a54f40973 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts @@ -34,7 +34,9 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record { + const repo = this.dataSource.getRepository(BookingHandover); + const existing = await repo.find({ where: { bookingId }, order: { generatedAt: 'ASC' } }); + + if (existing.length === 0) { + // No handover yet (truck not arrived): create a booking-level one so the + // customer has something to sign. ensureForArrivedTruck notifies on create. + const created = await this.ensureForArrivedTruck(bookingId, {}); + return { notified: true, reference: created.reference, alreadySigned: false }; + } + + const unsigned = existing.find((h) => !h.signedAt); + if (!unsigned) { + return { notified: false, reference: existing[0].reference, alreadySigned: true }; + } + await this.notifySignNeeded(bookingId, unsigned.reference); + return { notified: true, reference: unsigned.reference, alreadySigned: false }; + } + /** * Self-haul: ensure a handover exists for a customer truck that just arrived. * Idempotent — one per (booking, truck). Runs inside the caller's transaction diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts index 0a0c56821..b5894c21b 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts @@ -1,8 +1,13 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import { NotificationAudience, NotificationType } from '@edr/types'; + import { FilesService } from '../files/files.service'; import { LastMileService } from '../last-mile/last-mile.service'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { sendCompanyChannels } from '../notifications/notify-company.util'; import { CreateInspectionReportDto } from './dto/create-inspection-report.dto'; import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto'; import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity'; @@ -13,11 +18,15 @@ const INSPECTION_RESOURCE = 'warehouse-inspection-report'; @Injectable() export class WarehouseInspectionService { + private readonly logger = new Logger(WarehouseInspectionService.name); + constructor( private readonly dataSource: DataSource, private readonly inspectionRepository: WarehouseInspectionRepository, private readonly filesService: FilesService, private readonly lastMileService: LastMileService, + private readonly inbox: NotificationInboxService, + private readonly notifications: NotificationsService, ) {} /** Create or update the inspection report for an inventory item and sync its inspectionStatus. */ @@ -83,8 +92,10 @@ export class WarehouseInspectionService { const [row] = await this.dataSource.query( `SELECT inv.booking_id AS "bookingId", b.reference AS "bookingReference", + b.company_id AS "companyId", b.trade_direction AS "tradeDirection", b.last_mile_delivery_address AS "lastMileDeliveryAddress", + b.customer_truck_assigned_at AS "customerTruckAssignedAt", COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile" FROM freight.warehouse_inventory inv LEFT JOIN freight.bookings b ON b.id = inv.booking_id @@ -105,6 +116,36 @@ export class WarehouseInspectionService { if (row.bookingReference && hasLastMile) { await this.lastMileService.acceptBooking(row.bookingReference); + } else if (!hasLastMile && !row.customerTruckAssignedAt) { + // Self-haul import: goods are pickup-ready but no collection truck is + // assigned yet — nudge the customer to assign one from the portal. + void this.notifyTruckAssignmentNeeded(row); + } + } + + /** Portal nudge: import goods are ready for pickup but no customer truck is assigned. */ + private async notifyTruckAssignmentNeeded(row: { + bookingId?: string | null; + bookingReference?: string | null; + companyId?: string | null; + }): Promise { + if (!row.companyId || !row.bookingId) return; + const body = `Booking ${row.bookingReference ?? row.bookingId} has passed inspection and is ready for pickup. Please assign your collection truck(s) from the portal to proceed.`; + try { + await this.inbox.notify({ + recipients: { companyId: row.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title: 'Assign a truck for pickup', + body, + link: `/bookings/${row.bookingId}`, + data: { bookingId: row.bookingId, action: 'ASSIGN_TRUCK' }, + }); + await sendCompanyChannels(this.dataSource, this.notifications, row.companyId, body); + } catch (err) { + this.logger.warn( + `Truck-assignment notify failed for ${row.bookingId}: ${(err as Error).message}`, + ); } } 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 84baaf4da..9eb4ea502 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 @@ -9,6 +9,7 @@ import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto'; import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto'; import { LoadInventoryDto } from './dto/load-inventory.dto'; import { MoveInventoryDto } from './dto/move-inventory.dto'; +import { StoreInventoryDto } from './dto/store-inventory.dto'; import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto'; import { ReleaseOrderDto } from './dto/release-order.dto'; import { ReserveInventoryDto } from './dto/reserve-inventory.dto'; @@ -267,9 +268,9 @@ export class WarehouseInventoryController { } @Post(':id/store') - @ApiOperation({ summary: 'Mark received inventory as STORED' }) - store(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { - return this.inventoryService.store(id, performedBy); + @ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' }) + store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto) { + return this.inventoryService.store(id, dto.performedBy, dto); } @Post(':id/ready-for-loading') @@ -354,12 +355,24 @@ export class WarehouseInventoryController { return this.handoverService.list(bookingId); } + @Post('bookings/:bookingId/request-handover-signature') + @ApiOperation({ summary: 'Ask the customer to sign the handover (creates one if none, then notifies)' }) + requestHandoverSignature(@Param('bookingId', ParseUUIDPipe) bookingId: string) { + return this.handoverService.requestSignature(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); } + @Get('bookings/:bookingId/container-weights') + @ApiOperation({ summary: "A booking's containers + VGM cargo weight (tonnes) for exit weighing" }) + containerWeights(@Param('bookingId', ParseUUIDPipe) bookingId: string) { + return this.inventoryService.bookingContainerWeights(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 004704bea..14f4e27f3 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 @@ -7,6 +7,7 @@ import { InterchangeDocumentsService } from '../interchange-documents/interchang import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity'; import { LastMileService } from '../last-mile/last-mile.service'; import { NotificationsService } from '../notifications/notifications.service'; +import { sendCompanyChannels } from '../notifications/notify-company.util'; import { SignaturesService } from '../signatures/signatures.service'; import { BulkInspectDto } from './dto/bulk-inspect.dto'; import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto'; @@ -358,12 +359,14 @@ export interface ImportUnloadedRow { customerTruckType: string | null; customerTruckContainerNumber: string | null; customerTruckAssignedAt: string | null; + hasAssignedTruck: boolean; currentStatus: string; releaseDate: string | null; releaseOrderReference: string | null; handoverDocumentReference: string | null; handoverDocumentDate: string | null; deliveredAt: string | null; + notes: string | null; } @Injectable() @@ -403,16 +406,18 @@ export class WarehouseInventoryService { if (!booking.companyId) return; if (booking.hasFirstMile || booking.hasLastMile) return; // EDR mile — no customer truck if (booking.customerTruckAssignedAt) return; // already assigned + const body = `Booking ${booking.reference ?? bookingId} has been received at the warehouse. Please assign your collection truck(s) from the portal to proceed.`; try { await this.inbox.notify({ recipients: { companyId: booking.companyId }, audience: NotificationAudience.PORTAL, type: NotificationType.BOOKING_STATUS, title: 'Assign a truck for pickup', - body: `Booking ${booking.reference ?? bookingId} has been received at the warehouse. Please assign your collection truck(s) from the portal to proceed.`, + body, link: `/bookings/${bookingId}`, data: { bookingId, action: 'ASSIGN_TRUCK' }, }); + await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body); } catch (err) { this.logger.warn(`Truck-assignment notify failed for ${bookingId}: ${(err as Error).message}`); } @@ -1338,12 +1343,18 @@ export class WarehouseInventoryService { b.customer_truck_type AS "customerTruckType", b.customer_truck_container_number AS "customerTruckContainerNumber", b.customer_truck_assigned_at AS "customerTruckAssignedAt", + (b.customer_truck_assigned_at IS NOT NULL + OR EXISTS (SELECT 1 FROM freight.last_mile lm + WHERE lm.booking_id = b.id + AND lm.vehicle_id IS NOT NULL + AND lm.deleted_at IS NULL)) AS "hasAssignedTruck", inv.status AS "currentStatus", inv.release_date AS "releaseDate", inv.release_order_reference AS "releaseOrderReference", substring(inv.notes FROM 'Handover Reference: ([^\\n\\r]+)') AS "handoverDocumentReference", substring(inv.notes FROM 'Generated At: ([^\\n\\r]+)') AS "handoverDocumentDate", inv.delivered_at AS "deliveredAt", + inv.notes AS "notes", oy.country AS "originCountry", dy.country AS "destinationCountry" FROM freight.warehouse_inventory inv @@ -2141,13 +2152,30 @@ export class WarehouseInventoryService { // ── Lifecycle transitions ──────────────────────────────────────────────── - async store(id: string, performedBy?: string): Promise { + async store( + id: string, + performedBy?: string, + chosen?: { warehouseId?: string; yardId?: string; zoneId?: string }, + ): Promise { const item = await this.findById(id); this.assertTransition(item.status, 'STORED'); + // Explicit location wins when the operator picked warehouse + yard + zone; + // otherwise fall back to the allocation-rule / capacity-balanced auto pick. + const manualLocation = + chosen?.warehouseId && chosen?.yardId && chosen?.zoneId + ? { + warehouseId: chosen.warehouseId, + yardId: chosen.yardId, + zoneId: chosen.zoneId, + path: undefined as string | undefined, + } + : null; + const criteria = await this.getInventoryAllocationCriteria(item); - const ruleLocation = await this.allocation.resolveLocation(criteria); - const location = ruleLocation ?? (await this.pickCapacityBalancedStorageLocation(item, criteria)); + const ruleLocation = manualLocation ? null : await this.allocation.resolveLocation(criteria); + const location = + manualLocation ?? ruleLocation ?? (await this.pickCapacityBalancedStorageLocation(item, criteria)); if (!location) { throw new BadRequestException('No active warehouse yard/zone is available for this inventory item'); @@ -2191,18 +2219,19 @@ export class WarehouseInventoryService { await this.applyCapacityDelta(manager, location, weight, volume, containerCount); } + const storedReason = manualLocation + ? `Stored at operator-selected location -> ${location.path ?? 'chosen yard/zone'}` + : ruleLocation?.rule + ? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}` + : `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`; + await manager.getRepository(WarehouseInventory).update(id, { status: 'STORED', storedAt: new Date(), warehouseId: location.warehouseId, yardId: location.yardId, zoneId: location.zoneId, - notes: this.appendNote( - locked.notes, - ruleLocation?.rule - ? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}` - : `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`, - ), + notes: this.appendNote(locked.notes, storedReason), }); await this.activityLog.record( @@ -2210,9 +2239,7 @@ export class WarehouseInventoryService { activityType: 'INVENTORY_STORED', inventoryId: id, warehouseId: location.warehouseId, - description: ruleLocation?.rule - ? `Inventory stored by rule "${ruleLocation.rule.name}" at ${ruleLocation.path}` - : `Inventory stored at ${location.path ?? 'assigned yard/zone'}`, + description: storedReason.replace(/^Stored/, 'Inventory stored'), performedBy, }, manager, @@ -2331,6 +2358,26 @@ export class WarehouseInventoryService { 'Customer must sign the handover before the exit paper can be generated', ); } + + // Authoritative weight match: the truck's net (gross − tare) must equal the + // total VGM cargo weight of the containers selected as loaded on it. + if (dto.containerNumber && dto.grossWeight != null && dto.tareWeight != null) { + const selected = dto.containerNumber + .split(/[,;\n]+/) + .map((n) => n.trim()) + .filter(Boolean); + if (selected.length) { + const weights = await this.bookingContainerWeights(item.bookingId); + const byNumber = new Map(weights.map((w) => [w.containerNumber.toUpperCase(), w.weightTons])); + const expected = selected.reduce((sum, n) => sum + (byNumber.get(n.toUpperCase()) ?? 0), 0); + const computedNet = Number((dto.grossWeight - dto.tareWeight).toFixed(3)); + if (expected > 0 && Math.abs(computedNet - expected) > 0.001) { + throw new BadRequestException( + `Weight mismatch: gross − tare (${computedNet} t) must equal the selected containers' cargo weight (${expected} t).`, + ); + } + } + } } } const releaseDate = isTruckLeaving @@ -2565,6 +2612,7 @@ export class WarehouseInventoryService { bookingReference: string | null; contractId: string | null; hasLastMile: boolean; + handoverSigned: boolean; }> > { const rows: Array<{ @@ -2611,6 +2659,10 @@ export class WarehouseInventoryService { [bookingId], ); + // Booking-level gate: the per-truck exit paper is blocked until the handover + // is fully signed, so the UI can disable "Exit Paper" with a clear reason. + const handoverSigned = await this.handover.isFullySigned(bookingId); + return rows.map((r) => ({ containerNumber: r.containerNumber, goods: r.goods, @@ -2633,6 +2685,32 @@ export class WarehouseInventoryService { bookingReference: r.bookingReference, contractId: r.contractId, hasLastMile: r.hasLastMile, + handoverSigned, + })); + } + + /** + * The booking's containers with their VGM cargo weight (tonnes), keyed by + * container number. Drives the truck-leaving exit weighing: the selected + * containers' total cargo weight must match (gross − tare). + */ + async bookingContainerWeights( + bookingId: string, + ): Promise> { + const rows: Array<{ containerNumber: string; weightTons: string }> = + await this.dataSource.query( + `SELECT bcu.container_number AS "containerNumber", + COALESCE(bcu.vgm_tons, 0) AS "weightTons" + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc + ON bc.id = bcu.booking_container_id AND bc.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, + weightTons: Number(r.weightTons) || 0, })); } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts index f92401dfc..140d9f6b4 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts @@ -64,8 +64,8 @@ export class WarehousesService { currentWeight: 0, currentContainers: 0, currentVolume: 0, - status: 'ACTIVE', - isActive: true, + status: dto.status ?? 'ACTIVE', + isActive: (dto.status ?? 'ACTIVE') === 'ACTIVE', }); } catch (error) { this.mapDbError(error); diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx index 168bfd744..bb769df46 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx @@ -11,6 +11,7 @@ import { Table, Tabs, Text, + Tooltip, } from '@mantine/core'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { FileText } from 'lucide-react'; @@ -22,7 +23,7 @@ import { type ContainerItem, type ContainerItemStage, } from '@/services/warehouse.service'; -import { extractErrorMessage } from './options'; +import { extractDownloadErrorMessage, extractErrorMessage } from './options'; import { openPdfBlob } from './pdf'; interface ContainerItemsModalProps { @@ -90,12 +91,29 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen onError: (e) => toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }), }); + const requestSign = async () => { + try { + const res = await warehouseService.requestHandoverSignature(bookingId as string); + queryClient.invalidateQueries({ queryKey: itemsKey }); + if (res.alreadySigned) { + toast({ title: 'Handover already signed', description: 'You can generate the exit paper now.' }); + } else { + toast({ + title: 'Handover not signed', + description: `Signature request sent to the customer${res.reference ? ` (${res.reference})` : ''}.`, + }); + } + } catch (e) { + toast({ variant: 'destructive', title: 'Could not request signature', 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) }); + toast({ variant: 'destructive', title: 'Exit paper not ready', description: await extractDownloadErrorMessage(e) }); } }; @@ -164,15 +182,27 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen {i.hasLastMile ? EDR : Self-haul} {i.truckAssignmentId && ( - + + )} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx index b1afff27b..8c8560442 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx @@ -102,7 +102,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh await updateMutation.mutateAsync({ id: warehouse.id, payload: { ...payload, status: form.status } }); toast({ title: 'Warehouse updated' }); } else { - await createMutation.mutateAsync(payload); + await createMutation.mutateAsync({ ...payload, status: form.status }); toast({ title: 'Warehouse created' }); } onClose(); @@ -149,15 +149,13 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh onChange={(value) => setForm((f) => ({ ...f, type: (value as WarehouseType) ?? 'OPEN_WAREHOUSE' }))} allowDeselect={false} /> - {isEdit && ( - setForm((f) => ({ ...f, status: (value as 'ACTIVE' | 'INACTIVE') ?? 'ACTIVE' }))} + allowDeselect={false} + /> void; } -/** Inventory table + all lifecycle actions (advance / move / reserve / history). */ +/** Inventory table + all lifecycle actions (advance / move / history). */ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWorkbenchProps) { const { toast } = useToast(); const [busyId, setBusyId] = useState(null); const [moveItem, setMoveItem] = useState(null); - const [reserveItem, setReserveItem] = useState(null); const [loadItem, setLoadItem] = useState(null); const [historyItem, setHistoryItem] = useState(null); const [viewItem, setViewItem] = useState(null); @@ -171,7 +169,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo const storeInventory = async (item: WarehouseInventoryItem) => { setBusyId(item.id); try { - const stored = await storeMutation.mutateAsync(item.id); + const stored = await storeMutation.mutateAsync({ id: item.id }); toast({ title: 'Inventory stored', description: [stored.warehouse?.code, stored.yard?.code, stored.zone?.code].filter(Boolean).join(' / '), @@ -187,9 +185,6 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo switch (action) { case 'store': return storeInventory(item); - case 'reserve': - setReserveItem(item); - return; case 'ready-for-loading': return runDirect(item, () => readyMutation.mutateAsync(item.id), 'Ready for loading'); case 'load': @@ -258,11 +253,6 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo setMoveItem(null)} item={moveItem} /> - setReserveItem(null)} - item={reserveItem} - /> setLoadItem(null)} item={loadItem} /> >(new Set()); const [inspectId, setInspectId] = useState(null); @@ -2192,8 +2196,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { const [feeItem, setFeeItem] = useState(null); const [releaseItem, setReleaseItem] = useState(null); const [deliverItem, setDeliverItem] = useState(null); - const [loadTruckItem, setLoadTruckItem] = useState(null); const [containerItemsItem, setContainerItemsItem] = useState(null); + const [storeItem, setStoreItem] = useState(null); + const [moveItem, setMoveItem] = useState(null); const allSelected = rows.length > 0 && selected.size === rows.length; const someSelected = selected.size > 0 && !allSelected; @@ -2413,49 +2418,16 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { - {r.currentStatus === 'UNLOADED' && ( + {/* Primary stage action stays visible; the rest live under the kebab. */} + {r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && r.hasAssignedTruck && ( - )} - {['UNLOADED', 'STORED'].includes(r.currentStatus) && r.inspectionStatus === 'PASSED' && ( - - )} - {r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && ( - <> - - - )} - {r.currentStatus === 'READY_FOR_PICKUP' && ( - )} {r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && ( @@ -2470,40 +2442,64 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { Exit Paper )} - {r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && ( - - )} - {r.inspectionStatus === 'PASSED' && ( - - )} - - - setFeeItem(toInventoryItem(r))}> - - - - - setHistoryItem(toInventoryItem(r))}> - - - + + + + + + + + {r.currentStatus === 'UNLOADED' && ( + } onClick={() => setStoreItem(toInventoryItem(r))}> + Store… + + )} + {r.currentStatus !== 'UNLOADED' && ( + } onClick={() => setMoveItem(toInventoryItem(r))}> + Move… + + )} + {['UNLOADED', 'STORED'].includes(r.currentStatus) && r.inspectionStatus === 'PASSED' && ( + runRowAction(r, 'Ready for pickup', () => readyMutation.mutateAsync(r.id))}> + Ready for pickup + + )} + {r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && ( + } + disabled={!r.hasAssignedTruck} + onClick={() => setReleaseItem(toInventoryItem(r))} + > + {r.hasAssignedTruck + ? r.releaseOrderReference + ? 'Truck leaving' + : 'Truck arrival' + : 'Truck arrival — assign a truck first'} + + )} + {r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && ( + } onClick={() => openReleaseDocument(r)}> + Exit paper + + )} + {r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && ( + setDeliverItem(toInventoryItem(r))}>Deliver + )} + {r.inspectionStatus === 'PASSED' && ( + } onClick={() => openHandoverDocument(r)}> + {r.handoverDocumentReference ? 'View handover' : 'Handover'} + + )} + setInspectId(r.id)}>Inspect / report + + } onClick={() => setFeeItem(toInventoryItem(r))}> + Storage / fee preview + + } onClick={() => setHistoryItem(toInventoryItem(r))}> + History + + + @@ -2526,13 +2522,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { inventoryId={feeItem?.id ?? null} /> setReleaseItem(null)} item={releaseItem} /> + setStoreItem(null)} item={storeItem} /> + setMoveItem(null)} item={moveItem} /> setDeliverItem(null)} item={deliverItem} /> - setLoadTruckItem(null)} - bookingId={loadTruckItem?.booking?.id ?? null} - bookingReference={loadTruckItem?.booking?.reference ?? null} - /> setContainerItemsItem(null)} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx index 1bf371e5d..4268b8732 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { Alert, Button, Group, Modal, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core'; +import { Alert, Button, Group, Modal, MultiSelect, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core'; import { Info, Scale } from 'lucide-react'; import { useMutation, useQuery } from '@tanstack/react-query'; @@ -28,29 +28,6 @@ export interface ReleaseOrderTruckPrefill { containerNumber?: string | null; } -const REGISTERED_FIRST_LAST_MILE_TRUCKS = [ - ['03-ET A45843', '43495'], ['03-ET A45866', '43470'], ['03-ET A45853', '43508'], ['03-ET A45849', '43414'], - ['03-ET A45845', '43492'], ['03-ET A45820', '43487'], ['03-ET A45842', '43478'], ['03-ET A45841', '43515'], - ['03-ET A45832', '43504'], ['03-ET A45856', '43510'], ['03-ET A45865', '43490'], ['03-ET A45855', '43485'], - ['03-ET A45840', '43499'], ['03-ET A45867', '43493'], ['03-ET A45833', '43466'], ['03-ET A45858', '43496'], - ['03-ET A45819', '43474'], ['03-ET A45834', '43502'], ['03-ET A45868', '43469'], ['03-ET A45831', '43488'], - ['03-ET A45828', '43479'], ['03-ET A45850', '43505'], ['03-ET A45823', '43480'], ['03-ET A45838', '43472'], - ['03-ET A45854', '43500'], ['03-ET A45839', '43486'], ['03-ET A45861', '43513'], ['03-ET A45830', '43501'], - ['03-ET A45826', '43498'], ['03-ET A45836', '43467'], ['03-ET A45822', '43512'], ['03-ET A45821', '43210'], - ['03-ET A45837', '43475'], ['03-ET A45860', '43497'], ['03-ET A45863', '43477'], ['03-ET A45825', '43483'], - ['03-ET A45829', '43473'], ['03-ET A45824', '43491'], ['03-ET A45857', '43481'], ['03-ET A45851', '43509'], - ['03-ET A45827', '43468'], ['03-ET A45859', '43887'], ['03-ET A45846', '43471'], ['03-ET A45847', '43511'], - ['03-ET A45852', '43484'], ['03-ET A45844', '43476'], ['03-ET A45835', '43482'], ['03-ET A45864', '43503'], - ['03-ET A45848', '43494'], ['03-ET A45862', '43465'], ['03-ET A39105', '41218'], ['03-ET A39098', '41220'], - ['03-ET A29900', '41865'], ['03-ET A39097', '41226'], ['03-ET A39104', '41225'], ['03-ET A39103', '41223'], - ['03-ET A39106', '41221'], ['03-ET A39107', '41215'], ['03-ET A39094', '41222'], ['03-ET A39099', '41216'], - ['03-ET A39092', '41224'], ['03-ET A31801', '41214'], -].map(([powerPlate, trailerPlate], index) => ({ - value: powerPlate, - label: `${index + 1}. ${powerPlate} / ${trailerPlate}`, - trailerPlate, -})); - const toIsoDateTime = (value: string) => { if (!value) return undefined; const date = new Date(value); @@ -141,6 +118,13 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea queryFn: () => warehouseService.getCustomerTrucks(bookingId as string), enabled: opened && Boolean(bookingId), }); + // Per-container cargo weights — the truck's net (gross − tare) must equal the + // total cargo weight of the containers selected as loaded on it. + const { data: containerWeights = [] } = useQuery({ + queryKey: ['release-container-weights', bookingId], + queryFn: () => warehouseService.getContainerWeights(bookingId as string), + enabled: opened && Boolean(bookingId), + }); const [reference, setReference] = useState(''); const [truckPlateNumber, setTruckPlateNumber] = useState(''); const [trailerPlateNumber, setTrailerPlateNumber] = useState(''); @@ -210,22 +194,39 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea truckType: t.truckType, })), ]; - const truckSelectOptions = [ - ...assignedTruckOptions, - ...REGISTERED_FIRST_LAST_MILE_TRUCKS.map((t) => ({ - value: t.value, - label: t.label, - trailerPlate: t.trailerPlate, - driverName: '', - driverPhone: '', - truckType: '', - })), - ]; + // Only trucks actually assigned to THIS booking (last-mile prefill or customer + // portal) are selectable. No global fleet list — if nothing is assigned, the + // operator types the plate manually in the field below. + const truckSelectOptions = assignedTruckOptions; // Neither a last-mile truck nor a customer truck has been assigned yet. const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck; const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill; const isDriverNameLocked = isEntranceLocked || isCustomerAssignedTruck || Boolean(truckPrefill?.driverName); - const systemNetWeight = item?.weight == null ? netWeight : Number(item.weight); + + // Which containers ride this truck, and their combined cargo weight. When the + // booking has container weights, that sum is the authoritative net; the + // operator selects the containers loaded on the truck at exit. + const hasContainerWeights = containerWeights.length > 0; + const containerWeightByNumber = new Map( + containerWeights.map((c) => [c.containerNumber.toUpperCase(), Number(c.weightTons) || 0]), + ); + const containerSelectData = containerWeights.map((c) => ({ + value: c.containerNumber, + label: `${c.containerNumber} · ${(Number(c.weightTons) || 0).toLocaleString()} t`, + })); + const selectedContainerNumbers = containerNumbers.map((n) => n.trim()).filter(Boolean); + const selectedCargoWeight = Number( + selectedContainerNumbers + .reduce((sum, n) => sum + (containerWeightByNumber.get(n.toUpperCase()) ?? 0), 0) + .toFixed(3), + ); + const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0; + + const systemNetWeight = useContainerNet + ? selectedCargoWeight + : item?.weight == null + ? netWeight + : Number(item.weight); const computedNetWeight = tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null; const weightMismatch = @@ -246,6 +247,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea toast({ variant: 'destructive', title: 'Gate out time and gross weight are required' }); return; } + if (isExitStep && hasContainerWeights && selectedContainerNumbers.length === 0) { + toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' }); + return; + } if (isExitStep && systemNetWeight === '') { toast({ variant: 'destructive', title: 'System recorded net weight is missing' }); return; @@ -336,23 +341,25 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea Truck is not assigned yet — assign a last-mile or customer truck, or enter the plate manually below. )} - truck.value === truckPlateNumber) ? truckPlateNumber : null} + onChange={(value) => { + const truck = truckSelectOptions.find((row) => row.value === value); + setTruckPlateNumber(truck?.value ?? ''); + setTrailerPlateNumber(truck?.trailerPlate ?? ''); + if (truck?.driverName) setDriverName(truck.driverName); + if (truck?.driverPhone) setDriverPhone(truck.driverPhone); + if (truck?.truckType) setTruckType(truck.truckType); + }} + /> + )} setDriverPhone(e.currentTarget.value)} readOnly={isEntranceLocked} /> setTruckType(e.currentTarget.value)} readOnly={isTruckIdentityLocked} /> - - - 1 ? 2 : 1} spacing="sm"> - {containerNumbers.map((containerNumber, index) => ( - 1 ? `Container number ${index + 1}` : 'Container number'} - value={containerNumber} - onChange={(e) => - setContainerNumbers((numbers) => - numbers.map((number, numberIndex) => (numberIndex === index ? e.currentTarget.value : number)), - ) - } - readOnly={isTruckIdentityLocked} - /> - ))} - - + + {hasContainerWeights ? ( + setContainerNumbers(values.length ? values : [''])} + /> + ) : ( + + 1 ? 2 : 1} spacing="sm"> + {containerNumbers.map((containerNumber, index) => ( + 1 ? `Container number ${index + 1}` : 'Container number'} + value={containerNumber} + onChange={(e) => + setContainerNumbers((numbers) => + numbers.map((number, numberIndex) => (numberIndex === index ? e.currentTarget.value : number)), + ) + } + readOnly={isTruckIdentityLocked} + /> + ))} + + + )} setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} /> setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} /> setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep} /> - + diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/StoreInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/StoreInventoryModal.tsx new file mode 100644 index 000000000..f1a5aa4ad --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/StoreInventoryModal.tsx @@ -0,0 +1,143 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Alert, Button, Group, Modal, Select, Stack, Text } from '@mantine/core'; +import { Info } from 'lucide-react'; + +import { useMutation, useQuery } from '@tanstack/react-query'; + +import { api } from '@/services/api'; +import { useToast } from '@/hooks/use-toast'; +import type { WarehouseInventoryItem } from '@/types/warehouse'; +import { extractErrorMessage } from './options'; + +interface StoreInventoryModalProps { + opened: boolean; + onClose: () => void; + item: WarehouseInventoryItem | null; +} + +/** + * Store an unloaded import item. The operator may pick warehouse → yard → zone + * explicitly; leaving them blank falls back to the backend auto allocation. + */ +export function StoreInventoryModal({ opened, onClose, item }: StoreInventoryModalProps) { + const { toast } = useToast(); + const storeMutation = useMutation(api.warehouses.store.mutationOptions()); + const [warehouseId, setWarehouseId] = useState(''); + const [yardId, setYardId] = useState(''); + const [zoneId, setZoneId] = useState(''); + + useEffect(() => { + if (opened) { + setWarehouseId(''); + setYardId(''); + setZoneId(''); + } + }, [opened]); + + const warehousesQuery = useQuery( + api.warehouses.list.queryOptions({ input: { filter: { status: 'ACTIVE' } } }), + ); + const yardsQuery = useQuery( + api.warehouses.listYards.queryOptions({ + input: { warehouseId }, + enabled: Boolean(warehouseId), + }), + ); + const zonesQuery = useQuery( + api.warehouses.listZones.queryOptions({ + input: { yardId }, + enabled: Boolean(yardId), + }), + ); + + const warehouseOptions = useMemo( + () => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })), + [warehousesQuery.data], + ); + const yardOptions = useMemo( + () => (yardsQuery.data ?? []).filter((y) => y.status === 'ACTIVE').map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })), + [yardsQuery.data], + ); + const zoneOptions = useMemo( + () => (zonesQuery.data ?? []).filter((z) => z.status === 'ACTIVE').map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })), + [zonesQuery.data], + ); + + const isManual = Boolean(warehouseId || yardId || zoneId); + const manualComplete = Boolean(warehouseId && yardId && zoneId); + + const handleSubmit = async () => { + if (!item) return; + if (isManual && !manualComplete) { + toast({ variant: 'destructive', title: 'Pick warehouse, yard and zone — or clear all to auto-allocate' }); + return; + } + try { + await storeMutation.mutateAsync({ + id: item.id, + payload: manualComplete ? { warehouseId, yardId, zoneId } : undefined, + }); + toast({ title: manualComplete ? 'Inventory stored at selected location' : 'Inventory stored (auto-allocated)' }); + onClose(); + } catch (error) { + toast({ variant: 'destructive', title: 'Store failed', description: extractErrorMessage(error) }); + } + }; + + return ( + + + } color="blue" variant="light"> + + Choose a warehouse, yard and zone to store this item at a specific location, or leave them + blank to let the system auto-allocate by rule / available capacity. + + + { + setYardId(v ?? ''); + setZoneId(''); + }} + /> +