From 1fae0752a062d05d9eac9c7317714d28793b9597 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Tue, 21 Jul 2026 11:02:03 +0000 Subject: [PATCH] =?UTF-8?q?Ticket:=20Warehouse=20dashboard=20Needs=20Atten?= =?UTF-8?q?tion=20cards=20=E2=80=94=20fix=20trucks-on-site=20aging=20count?= =?UTF-8?q?ers=20(EDR=20trucks,=20UNLOADED=20status)=20and=20make=20each?= =?UTF-8?q?=20card=20open=20the=20exact=20list=20behind=20its=20count=20vi?= =?UTF-8?q?a=20new=20inventory=20drill-down=20filters.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2440000000000-AddHandoverEdrAssignment.ts | 35 ++ .../modules/last-mile/last-mile.service.ts | 12 +- .../warehouses/dto/filter-inventory.dto.ts | 26 +- .../entities/booking-handover.entity.ts | 9 +- .../warehouses/exit-inspection-blocks.spec.ts | 66 ++++ .../modules/warehouses/handover.service.ts | 160 +++++++- .../warehouses/warehouse-inventory.service.ts | 258 ++++++++++-- .../warehouses/ReceiveInventoryModal.tsx | 10 +- .../warehouses/ReleaseOrderModal.tsx | 371 ++++++++++++------ .../warehouses/WarehouseInventoryTable.tsx | 17 +- .../warehouses/WarehouseOpsKpiStrip.tsx | 14 +- .../src/pages/warehouses/TrucksOnSitePage.tsx | 9 +- .../warehouses/WarehouseInventoryPage.tsx | 39 +- .../src/services/warehouse.service.ts | 2 + .../backoffice/src/types/warehouse.ts | 4 + packages/types/src/freight/index.ts | 1 + 16 files changed, 840 insertions(+), 193 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2440000000000-AddHandoverEdrAssignment.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/exit-inspection-blocks.spec.ts diff --git a/apps/edr-freight-api/src/migrations/2440000000000-AddHandoverEdrAssignment.ts b/apps/edr-freight-api/src/migrations/2440000000000-AddHandoverEdrAssignment.ts new file mode 100644 index 000000000..c8f48af05 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2440000000000-AddHandoverEdrAssignment.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-truck EDR last-mile handovers. `truck_assignment_id` FKs + * customer_truck_assignments (self-haul only), so EDR trucks need their own + * link to the last-mile vehicle assignment that hauled the goods. Generated + * when the EDR truck exits the warehouse (with its exit paper) and signed by + * the customer in the portal — one per truck, or booking-level (both ids null) + * when the truck cannot be resolved. + */ +export class AddHandoverEdrAssignment2440000000000 implements MigrationInterface { + name = 'AddHandoverEdrAssignment2440000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.booking_handovers + ADD COLUMN IF NOT EXISTS edr_assignment_id uuid + REFERENCES freight.last_mile_vehicle_assignments(id) ON DELETE SET NULL; + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_booking_handovers_booking_edr_truck" + ON freight.booking_handovers (booking_id, edr_assignment_id) + WHERE deleted_at IS NULL AND edr_assignment_id IS NOT NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight."UQ_booking_handovers_booking_edr_truck";`, + ); + await queryRunner.query( + `ALTER TABLE freight.booking_handovers DROP COLUMN IF EXISTS edr_assignment_id;`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index b31a2ecbb..43887d468 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -333,6 +333,8 @@ export class LastMileService { driverPhone: string | null; truckType: string | null; containerNumber: string | null; + arrivedAt: string | null; + departedAt: string | null; }> > { const [lm] = await this.lastMileRepository.findAll({ @@ -347,9 +349,11 @@ export class LastMileService { ? lm.vehicleAssignments.map((va) => ({ vehicle: va.vehicle, containerNumber: va.containerNumber ?? null, + arrivedAt: va.arrivedAt ?? null, + departedAt: va.departedAt ?? null, })) : lm.vehicle - ? [{ vehicle: lm.vehicle, containerNumber: null }] + ? [{ vehicle: lm.vehicle, containerNumber: null, arrivedAt: null, departedAt: null }] : []; const out: Array<{ @@ -361,8 +365,10 @@ export class LastMileService { driverPhone: string | null; truckType: string | null; containerNumber: string | null; + arrivedAt: string | null; + departedAt: string | null; }> = []; - for (const { vehicle, containerNumber } of sources) { + for (const { vehicle, containerNumber, arrivedAt, departedAt } of sources) { if (!vehicle) continue; let driverName = vehicle.assignedDriverName ?? null; let driverLicense: string | null = null; @@ -386,6 +392,8 @@ export class LastMileService { driverPhone, truckType: vehicle.vehicleType || null, containerNumber, + arrivedAt: arrivedAt ? new Date(arrivedAt).toISOString() : null, + departedAt: departedAt ? new Date(departedAt).toISOString() : null, }); } return out; diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts index 49fb22fde..f86261d3c 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts @@ -1,5 +1,6 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, Min } from 'class-validator'; import { WAREHOUSE_INVENTORY_STATUSES, @@ -71,4 +72,27 @@ export class FilterWarehouseInventoryDto { @IsOptional() @IsString() dateTo?: string; + + // ── KPI drill-down filters ────────────────────────────────────────────── + // Each mirrors one opsStats() counter so a dashboard card's count always + // equals the length of the list it opens. + + @ApiPropertyOptional({ type: Boolean, description: 'Only items received (created) today' }) + @IsOptional() + @Transform(({ value }) => (value == null ? undefined : value === true || value === 'true' || value === '1')) + @IsBoolean() + receivedToday?: boolean; + + @ApiPropertyOptional({ type: Boolean, description: 'Only RECEIVED items with no inspection yet' }) + @IsOptional() + @Transform(({ value }) => (value == null ? undefined : value === true || value === 'true' || value === '1')) + @IsBoolean() + pendingInspection?: boolean; + + @ApiPropertyOptional({ type: Number, minimum: 1, description: 'Only in-warehouse items older than N days' }) + @IsOptional() + @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) + @IsInt() + @Min(1) + agingOverDays?: number; } diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts index a0a7ad70f..c90fb5a16 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts @@ -8,8 +8,9 @@ export type HandoverMileType = (typeof HANDOVER_MILE_TYPES)[number]; * One import handover. A booking has a single handover when one truck takes the * whole booking (`truckAssignmentId` null = per-booking), or one per truck when * multiple trucks are used. Self-haul handovers are generated on truck arrival - * and signed before the truck leaves; EDR last-mile handovers are generated at - * delivery (after exit). + * and signed before the truck leaves; EDR last-mile handovers are generated + * when the EDR truck exits the warehouse (with its exit paper) and signed by + * the customer in the portal on delivery — one signature per truck. */ @Entity({ schema: 'freight', name: 'booking_handovers' }) @Index(['bookingId']) @@ -21,6 +22,10 @@ export class BookingHandover extends BaseEntity { @Column({ name: 'truck_assignment_id', type: 'uuid', nullable: true }) truckAssignmentId?: string | null; + /** EDR last-mile vehicle assignment this handover belongs to; null = per-booking. */ + @Column({ name: 'edr_assignment_id', type: 'uuid', nullable: true }) + edrAssignmentId?: string | null; + /** Denormalised plate for display / EDR trucks (which aren't customer trucks). */ @Column({ name: 'truck_plate', type: 'varchar', length: 32, nullable: true }) truckPlate?: string | null; diff --git a/apps/edr-freight-api/src/modules/warehouses/exit-inspection-blocks.spec.ts b/apps/edr-freight-api/src/modules/warehouses/exit-inspection-blocks.spec.ts new file mode 100644 index 000000000..f89d987ce --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/exit-inspection-blocks.spec.ts @@ -0,0 +1,66 @@ +import { WarehouseInventoryService } from './warehouse-inventory.service'; + +// Exercises the per-truck [Exit Inspection] block helpers directly (no DI). +const svc = Object.create(WarehouseInventoryService.prototype) as any; + +const arrivalA = + '[Exit Inspection]\nTruck Plate: 3-15288/56858\nDriver: Abebe Lemeno\nGate In Time: 2026-07-21T08:00:00.000Z\nTare Weight: 12 t'; +const arrivalB = + '[Exit Inspection]\nTruck Plate: 3-85957/48562\nDriver: Suleman Tamrat\nGate In Time: 2026-07-21T09:00:00.000Z\nWeighing: SKIPPED'; + +describe('per-truck exit inspection blocks', () => { + it('keeps truck A intact when truck B arrives', () => { + const afterA = svc.replaceExitInspectionNote('Receive note', arrivalA, '3-15288/56858'); + const afterB = svc.replaceExitInspectionNote(afterA, arrivalB, '3-85957/48562'); + expect(afterB).toContain('Abebe Lemeno'); + expect(afterB).toContain('Suleman Tamrat'); + expect(afterB.match(/\[Exit Inspection\]/g)).toHaveLength(2); + expect(afterB.startsWith('Receive note')).toBe(true); + }); + + it("exit for truck A updates only A's block and preserves arrival data", () => { + const notes = svc.replaceExitInspectionNote( + svc.replaceExitInspectionNote(null, arrivalA, '3-15288/56858'), + arrivalB, + '3-85957/48562', + ); + const dto = svc.preserveTruckArrivalForExit( + { truckPlateNumber: '3-15288/56858', grossWeight: 40, gateOutTime: '2026-07-21T12:00:00.000Z' }, + notes, + ); + expect(dto.driverName).toBe('Abebe Lemeno'); + expect(dto.tareWeight).toBe(12); + expect(dto.weighingSkipped).toBeUndefined(); + const exitNote = svc.buildExitInspectionNote(dto); + const replaced = svc.replaceExitInspectionNote(notes, exitNote, dto.truckPlateNumber); + expect(replaced).toContain('Gross Weight: 40 t'); + expect(replaced).toContain('Net Weight: 28 t'); + expect(replaced).toContain('Suleman Tamrat'); // B untouched + expect(replaced.match(/\[Exit Inspection\]/g)).toHaveLength(2); + }); + + it('skipped weighing records the container-derived net in the note', () => { + const dto = { + truckPlateNumber: '3-85957/48562', + driverName: 'Suleman Tamrat', + weighingSkipped: true, + netWeight: 27.5, + gateInTime: '2026-07-21T09:00:00.000Z', + gateOutTime: '2026-07-21T13:00:00.000Z', + }; + const note = svc.buildExitInspectionNote(dto); + expect(note).toContain('Weighing: SKIPPED'); + expect(note).toContain('Net Weight: 27.5 t'); + }); + + it('matches a legacy comma-joined plate list and keeps foreign notes', () => { + const legacy = + 'Receive note\n\n[Exit Inspection]\nTruck Plate: 3-15288/56858, 3-85957/48562\nDriver: Abebe Lemeno\nTare Weight: 12 t\nCUSTOMER_DELIVERY_APPROVAL:{"ok":true}'; + const block = svc.extractExitInspectionForPlate(legacy, '3-15288/56858'); + expect(block).toContain('Abebe Lemeno'); + const replaced = svc.replaceExitInspectionNote(legacy, arrivalA, '3-15288/56858'); + expect(replaced.match(/\[Exit Inspection\]/g)).toHaveLength(1); + expect(replaced).toContain('CUSTOMER_DELIVERY_APPROVAL:{"ok":true}'); + expect(replaced).toContain('Receive note'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/warehouses/handover.service.ts b/apps/edr-freight-api/src/modules/warehouses/handover.service.ts index d50b27150..81f83a57a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/handover.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/handover.service.ts @@ -1,9 +1,9 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { NotificationAudience, NotificationType } from '@edr/types'; -import { DataSource, EntityManager, IsNull } from 'typeorm'; +import { DataSource, EntityManager, IsNull, Repository } from 'typeorm'; -import { BookingHandover } from './entities/booking-handover.entity'; +import { BookingHandover, HandoverMileType } from './entities/booking-handover.entity'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { NotificationsService } from '../notifications/notifications.service'; import { sendCompanyChannels } from '../notifications/notify-company.util'; @@ -12,7 +12,10 @@ import { sendCompanyChannels } from '../notifications/notify-company.util'; * Import handover records. A booking has one handover per truck (single truck ⇒ * one, effectively per-booking; multiple trucks ⇒ one each). Timing by mile type: * - SELF_HAUL: generated when the customer truck arrives, signed before it leaves. - * - EDR_LAST_MILE: generated at delivery (after exit). + * - EDR_LAST_MILE: generated when the EDR truck exits the warehouse (with its + * exit paper), signed by the customer in the portal per truck; once every + * handover is signed the delivery auto-completes (inventory / cargo / + * booking → delivered). */ @Injectable() export class HandoverService { @@ -25,14 +28,22 @@ export class HandoverService { ) {} /** Tell the customer a handover is ready and needs their signature. */ - private async notifySignNeeded(bookingId: string, reference: string): Promise { + private async notifySignNeeded( + bookingId: string, + reference: string, + opts: { mileType?: HandoverMileType; truckPlate?: string | null } = {}, + ): Promise { try { const [b]: Array<{ companyId: string | null; reference: string }> = await this.dataSource.query( `SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, [bookingId], ); if (!b?.companyId) return; - const body = `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`; + const truck = opts.truckPlate ? ` (truck ${opts.truckPlate})` : ''; + const body = + opts.mileType === 'EDR_LAST_MILE' + ? `Your goods for booking ${b.reference} are on their way${truck}. Please review and sign handover ${reference} from the portal to confirm receipt of the delivery.` + : `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`; await this.inbox.notify({ recipients: { companyId: b.companyId }, audience: NotificationAudience.PORTAL, @@ -117,31 +128,98 @@ export class HandoverService { return saved; } - /** - * EDR last-mile: generate a handover at delivery (after exit). One per EDR - * truck (by plate) or per booking. Idempotent by (booking, plate). - */ - async ensureAtDelivery( + /** Find an existing EDR handover by assignment, else by plate, else booking-level. */ + private async findEdrHandover( + repo: Repository, bookingId: string, - opts: { truckPlate?: string | null; truckAssignmentId?: string | null }, + opts: { truckPlate?: string | null; edrAssignmentId?: string | null }, + ): Promise { + if (opts.edrAssignmentId) { + const byAssignment = await repo.findOne({ + where: { bookingId, edrAssignmentId: opts.edrAssignmentId }, + }); + if (byAssignment) return byAssignment; + } + if (opts.truckPlate) { + return repo.findOne({ + where: { bookingId, mileType: 'EDR_LAST_MILE', truckPlate: opts.truckPlate }, + }); + } + return repo.findOne({ + where: { + bookingId, + mileType: 'EDR_LAST_MILE', + truckPlate: IsNull(), + edrAssignmentId: IsNull(), + }, + }); + } + + /** + * EDR last-mile: generate the handover when the EDR truck exits the warehouse + * (alongside its exit paper) and ask the customer to sign it from the portal. + * One per truck (multiple trucks ⇒ one each) or booking-level when the truck + * cannot be resolved. Idempotent by (booking, assignment) / (booking, plate). + */ + async ensureForDepartedEdrTruck( + bookingId: string, + opts: { truckPlate?: string | null; edrAssignmentId?: string | null }, manager?: EntityManager, ): Promise { const m = manager ?? this.dataSource.manager; const repo = m.getRepository(BookingHandover); - const existing = await repo.findOne({ - where: { - bookingId, - truckPlate: opts.truckPlate ?? IsNull(), - truckAssignmentId: opts.truckAssignmentId ?? IsNull(), - }, - }); + const existing = await this.findEdrHandover(repo, bookingId, opts); if (existing) return existing; const reference = await this.generateReference(bookingId, m); - return repo.save( + const saved = await repo.save( repo.create({ bookingId, - truckAssignmentId: opts.truckAssignmentId ?? null, + edrAssignmentId: opts.edrAssignmentId ?? null, + truckPlate: opts.truckPlate ?? null, + mileType: 'EDR_LAST_MILE', + reference, + generatedAt: new Date(), + }), + ); + this.logger.log( + `EDR handover ${reference} generated on truck exit for booking ${bookingId}` + + (opts.truckPlate ? ` (truck ${opts.truckPlate})` : ''), + ); + void this.notifySignNeeded(bookingId, reference, { + mileType: 'EDR_LAST_MILE', + truckPlate: opts.truckPlate, + }); + return saved; + } + + /** + * EDR last-mile: ensure a handover exists at delivery and stamp delivered_at. + * Normally the handover was already generated on truck exit — this only fills + * the delivery timestamp; a handover is created here only for legacy flows + * where the exit was recorded before this feature existed. + */ + async ensureAtDelivery( + bookingId: string, + opts: { truckPlate?: string | null; edrAssignmentId?: string | null }, + manager?: EntityManager, + ): Promise { + const m = manager ?? this.dataSource.manager; + const repo = m.getRepository(BookingHandover); + const existing = await this.findEdrHandover(repo, bookingId, opts); + if (existing) { + if (!existing.deliveredAt) { + existing.deliveredAt = new Date(); + await repo.save(existing); + } + return existing; + } + + const reference = await this.generateReference(bookingId, m); + const saved = await repo.save( + repo.create({ + bookingId, + edrAssignmentId: opts.edrAssignmentId ?? null, truckPlate: opts.truckPlate ?? null, mileType: 'EDR_LAST_MILE', reference, @@ -149,6 +227,25 @@ export class HandoverService { deliveredAt: new Date(), }), ); + void this.notifySignNeeded(bookingId, reference, { + mileType: 'EDR_LAST_MILE', + truckPlate: opts.truckPlate, + }); + return saved; + } + + /** Re-send the sign notification for every unsigned handover on the booking. */ + async notifyUnsignedForBooking(bookingId: string): Promise { + const unsigned = await this.dataSource.getRepository(BookingHandover).find({ + where: { bookingId, signedAt: IsNull() }, + order: { generatedAt: 'ASC' }, + }); + for (const h of unsigned) { + await this.notifySignNeeded(bookingId, h.reference, { + mileType: h.mileType, + truckPlate: h.truckPlate, + }); + } } /** @@ -182,6 +279,27 @@ export class HandoverService { } } + /** + * Sign one handover (EDR last-mile: the customer signs per truck). Returns the + * fresh handover; idempotent — an already-signed handover is returned as-is. + */ + async sign( + handoverId: string, + userId?: string | null, + signerName?: string | null, + ): Promise { + const repo = this.dataSource.getRepository(BookingHandover); + const handover = await repo.findOne({ where: { id: handoverId } }); + if (!handover) { + throw new NotFoundException(`Handover ${handoverId} not found`); + } + if (handover.signedAt) return handover; + handover.signedAt = new Date(); + handover.signedByUserId = userId ?? null; + handover.signerName = signerName?.trim() || null; + return repo.save(handover); + } + /** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */ async signForBooking( bookingId: string, 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 96122d9bd..c45764bbd 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 @@ -1,6 +1,18 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; -import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; +import { + Between, + DataSource, + EntityManager, + FindManyOptions, + FindOptionsWhere, + ILike, + In, + IsNull, + LessThanOrEqual, + MoreThanOrEqual, + Raw, +} from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { generateGrnNumber } from '../../common/grn.util'; @@ -68,6 +80,7 @@ const isLoadableWagonStatus = (status: string | null | undefined) => const CUSTOMER_DELIVERY_APPROVAL_PREFIX = 'CUSTOMER_DELIVERY_APPROVAL:'; const HANDOVER_DOCUMENT_MARKER = '[Handover Document]'; +const EXIT_INSPECTION_MARKER = '[Exit Inspection]'; export interface InventoryInquiryResult { id: string; @@ -508,12 +521,20 @@ export class WarehouseInventoryService { WHERE deleted_at IS NULL AND created_at::date = CURRENT_DATE - 1) AS "receivedYesterday", (SELECT count(*)::int FROM freight.warehouse_inventory WHERE deleted_at IS NULL AND status = 'RECEIVED' AND inspection_status IS NULL) AS "pendingInspection", - (SELECT count(*)::int FROM freight.customer_truck_assignments - WHERE deleted_at IS NULL AND arrived_at IS NOT NULL AND departed_at IS NULL) AS "trucksOnSite", + -- Both haulage paths, mirroring the ON_SITE rows of trucksOnSite() + ((SELECT count(*)::int FROM freight.customer_truck_assignments a + JOIN freight.bookings b ON b.id = a.booking_id AND b.deleted_at IS NULL + WHERE a.deleted_at IS NULL AND a.arrived_at IS NOT NULL AND a.departed_at IS NULL) + + + (SELECT count(*)::int FROM freight.last_mile_vehicle_assignments va + JOIN freight.last_mile lm ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL + JOIN freight.bookings b ON b.id = lm.booking_id AND b.deleted_at IS NULL + WHERE va.deleted_at IS NULL AND va.arrived_at IS NOT NULL AND va.departed_at IS NULL)) AS "trucksOnSite", (SELECT count(*)::int FROM freight.warehouse_inventory WHERE deleted_at IS NULL - AND status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING', 'READY_FOR_PICKUP', 'RESERVED') + AND status = ANY($1) AND created_at < now() - interval '7 days') AS "itemsAging"`, + [this.IN_WAREHOUSE_STATUSES], ); return { receivedToday: row?.receivedToday ?? 0, @@ -525,7 +546,7 @@ export class WarehouseInventoryService { } /** In-warehouse statuses used by the dwell / aging metrics. */ - private readonly IN_WAREHOUSE_STATUSES = [ + private readonly IN_WAREHOUSE_STATUSES: WarehouseInventoryStatus[] = [ 'RECEIVED', 'UNLOADED', 'STORED', @@ -953,7 +974,7 @@ export class WarehouseInventoryService { ? LessThanOrEqual(new Date(filter.dateTo)) : undefined; - const base = { + const base: FindOptionsWhere = { ...(filter.warehouseId ? { warehouseId: filter.warehouseId } : {}), ...(filter.yardId ? { yardId: filter.yardId } : {}), ...(filter.zoneId ? { zoneId: filter.zoneId } : {}), @@ -967,6 +988,24 @@ export class WarehouseInventoryService { ...(filter.direction ? { booking: { tradeDirection: filter.direction } } : {}), }; + // KPI drill-down filters — predicates mirror opsStats() exactly so the + // dashboard card's count equals the length of the list it opens. + if (filter.receivedToday) { + base.createdAt = Raw((alias) => `${alias}::date = CURRENT_DATE`); + } + if (filter.pendingInspection) { + base.status = 'RECEIVED'; + base.inspectionStatus = IsNull(); + } + if (filter.agingOverDays) { + if (!filter.status && !filter.pendingInspection) { + base.status = In(this.IN_WAREHOUSE_STATUSES); + } + base.createdAt = Raw((alias) => `${alias} < now() - make_interval(days => :days)`, { + days: filter.agingOverDays, + }); + } + const search = filter.search?.trim(); const where: FindManyOptions['where'] = search ? [ @@ -2972,12 +3011,29 @@ export class WarehouseInventoryService { const releaseDate = isTruckLeaving ? dto.releaseDate ? new Date(dto.releaseDate) : new Date() : item.releaseDate ?? null; - const reference = isTruckLeaving - ? item.releaseOrderReference || dto.reference?.trim() || (await this.generateReleaseReference(item)) - : dto.reference?.trim() || (await this.generateReleaseReference(item)); - const exitInspectionDto = isTruckLeaving - ? this.preserveTruckArrivalForExit(dto, item.notes) - : dto; + // One reference per item — the first truck's arrival mints it, later trucks + // (arrival or exit) reuse it so all exit papers share the release order. + const reference = + item.releaseOrderReference || dto.reference?.trim() || (await this.generateReleaseReference(item)); + const exitInspectionDto = { + ...(isTruckLeaving ? this.preserveTruckArrivalForExit(dto, item.notes) : dto), + }; + // Weighbridge skipped on exit: the recorded net still comes from what the + // truck is holding — the summed cargo weight of its selected containers. + if (isTruckLeaving && exitInspectionDto.weighingSkipped && item.bookingId) { + const selected = (exitInspectionDto.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 heldTons = Number( + selected.reduce((sum, n) => sum + (byNumber.get(n.toUpperCase()) ?? 0), 0).toFixed(3), + ); + if (heldTons > 0) exitInspectionDto.netWeight = heldTons; + } + } const exitInspectionNote = this.buildExitInspectionNote(exitInspectionDto); // The load actually leaving on this truck, in TONNES (the weighing UI is in @@ -2990,12 +3046,46 @@ export class WarehouseInventoryService { ? Math.round((grossTons - tareTons) * 1000) / 1000 : (exitInspectionDto.netWeight ?? null); + // The weight to record on the inventory when this truck leaves: prefer the + // item's own container cargo weight (a truck may carry other items too); + // fall back to the truck's recorded net. Fills an empty weight only. + let recordedItemTons: number | null = null; + if (isTruckLeaving && netTons != null) { + recordedItemTons = netTons; + if (item.containerId && item.bookingId) { + const [cont]: Array<{ containerNumber: string | null }> = await this.dataSource.query( + `SELECT container_number AS "containerNumber" FROM freight.containers WHERE id = $1`, + [item.containerId], + ); + const ownNumber = cont?.containerNumber?.trim().toUpperCase(); + if (ownNumber) { + const weights = await this.bookingContainerWeights(item.bookingId); + const own = weights.find((w) => w.containerNumber.toUpperCase() === ownNumber); + if (own && own.weightTons > 0) recordedItemTons = own.weightTons; + } + } + } + await this.dataSource.transaction(async (manager) => { await manager.getRepository(WarehouseInventory).update(id, { releaseDate, releaseOrderReference: reference, - notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote), + notes: this.replaceExitInspectionNote( + item.notes, + exitInspectionNote, + exitInspectionDto.truckPlateNumber, + ), }); + // Even an unweighed truck records the inventory weight it is holding — + // without this the handover/exit papers print "0 t" for skipped weighings. + if (recordedItemTons != null) { + await manager.query( + `UPDATE freight.warehouse_inventory + SET weight = $2, updated_at = NOW() + WHERE id = $1 AND COALESCE(weight, 0) = 0`, + [id, recordedItemTons], + ); + } if (!isTruckLeaving && item.bookingId) { // Per-truck arrival: mark the customer truck carrying THIS item's // container as arrived (matched via the physical container number). @@ -3861,7 +3951,9 @@ export class WarehouseInventoryService { `SELECT inv.id, inv.booking_id AS "bookingId", inv.quantity, - inv.weight, + -- An unweighed item still reports the cargo weight it holds: fall + -- back to the item's container VGM when no weight was recorded. + COALESCE(NULLIF(inv.weight, 0), item_vgm.tons, 0) AS weight, inv.status, inv.notes, inv.inspection_status AS "inspectionStatus", @@ -3913,6 +4005,16 @@ export class WarehouseInventoryService { WHERE bc.booking_id = b.id AND bc.deleted_at IS NULL ) booking_container ON true + LEFT JOIN LATERAL ( + SELECT SUM(bcu.vgm_tons) AS tons + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc2 + ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL + WHERE bc2.booking_id = b.id + AND bcu.deleted_at IS NULL + AND (container.container_number IS NULL + OR bcu.container_number = container.container_number) + ) item_vgm ON true LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id) LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL @@ -4009,14 +4111,25 @@ export class WarehouseInventoryService { if (!(await this.handover.isFullySigned(item.bookingId))) { throw new BadRequestException('Handover must be signed before delivery'); } - const [left]: Array<{ n: string }> = await this.dataSource.query( - `SELECT COUNT(*) AS n FROM freight.customer_truck_assignments - WHERE booking_id = $1 AND departed_at IS NOT NULL AND deleted_at IS NULL`, + const [trucks]: Array<{ total: string; left: string }> = await this.dataSource.query( + `SELECT COUNT(*) AS total, + COUNT(*) FILTER (WHERE departed_at IS NOT NULL) AS "left" + FROM freight.customer_truck_assignments + WHERE booking_id = $1 AND deleted_at IS NULL`, [item.bookingId], ); - if (Number(left?.n ?? 0) === 0) { + const totalTrucks = Number(trucks?.total ?? 0); + const leftTrucks = Number(trucks?.left ?? 0); + if (leftTrucks === 0) { throw new BadRequestException('Deliver is available only after the customer truck has left'); } + // Multi-truck booking: every assigned truck must arrive and leave — + // each is weighed out separately before the goods count as delivered. + if (leftTrucks < totalTrucks) { + throw new BadRequestException( + `Deliver is available only after every assigned truck has left (${leftTrucks} of ${totalTrucks} so far)`, + ); + } } } @@ -5297,7 +5410,11 @@ export class WarehouseInventoryService { weighingSkipped ? 'Weighing: SKIPPED' : null, tareWeight == null ? null : `Tare Weight: ${tareWeight} t`, grossWeight == null ? null : `Gross Weight: ${grossWeight} t`, - computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} t`, + // Skipped weighing still records a net — the cargo weight of the + // containers the truck is holding, resolved by the caller. + (computedNetWeight ?? (weighingSkipped ? dto.netWeight : null)) == null + ? null + : `Net Weight: ${computedNetWeight ?? Number(dto.netWeight)} t`, dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null, ]; @@ -5305,12 +5422,14 @@ export class WarehouseInventoryService { } private preserveTruckArrivalForExit(dto: ReleaseOrderDto, notes: string | null | undefined): ReleaseOrderDto { - const inspection = this.extractExitInspectionNote(notes); + const inspection = this.extractExitInspectionForPlate(notes, dto.truckPlateNumber); if (!inspection) return dto; return { ...dto, - truckPlateNumber: this.extractExitInspectionLine(inspection, 'Truck Plate') || dto.truckPlateNumber, + // The submitted plate wins: a legacy block may store a comma-joined list + // of plates, and the exit must be recorded against the ONE truck leaving. + truckPlateNumber: dto.truckPlateNumber?.trim() || this.extractExitInspectionLine(inspection, 'Truck Plate') || dto.truckPlateNumber, trailerPlateNumber: this.extractExitInspectionLine(inspection, 'Trailer Plate') || dto.trailerPlateNumber, driverName: this.extractExitInspectionLine(inspection, 'Driver') || dto.driverName, driverLicense: this.extractExitInspectionLine(inspection, 'Driver License') || dto.driverLicense, @@ -5324,25 +5443,94 @@ export class WarehouseInventoryService { }; } - private replaceExitInspectionNote(notes: string | null | undefined, exitInspectionNote: string | null): string | null { + /** + * Split notes into exit-inspection blocks (one per truck, in order) and + * everything else. A block ends at the first line that isn't one of the + * known inspection labels, so appended notes (delivery approval, handover + * marker) are preserved as "other" content instead of being swallowed by + * the block they happen to follow. + */ + private splitExitInspectionSections(notes?: string | null): { others: string[]; blocks: string[] } { const trimmed = notes?.trim(); - if (!exitInspectionNote) return trimmed || null; - if (!trimmed) return exitInspectionNote; - - const marker = '[Exit Inspection]'; - const index = trimmed.lastIndexOf(marker); - if (index < 0) { - return `${trimmed}\n\n${exitInspectionNote}`; + if (!trimmed) return { others: [], blocks: [] }; + const labelPattern = + /^(Booking ID|Customer ID|Truck Plate|Trailer Plate|Driver|Driver License|Driver Phone|Truck Type|Container Number|Gate In Time|Weighing|Tare Weight|Gross Weight|Net Weight|Gate Out Time):/i; + const parts = trimmed.split(EXIT_INSPECTION_MARKER); + const others: string[] = []; + const blocks: string[] = []; + if (parts[0]?.trim()) others.push(parts[0].trim()); + for (const part of parts.slice(1)) { + const lines = part.split('\n'); + const kept: string[] = []; + let i = 0; + while (i < lines.length && !lines[i].trim()) i += 1; + for (; i < lines.length; i += 1) { + const line = lines[i].trim(); + if (!line || !labelPattern.test(line)) break; + kept.push(line); + } + if (kept.length) blocks.push(kept.join('\n')); + const tail = lines.slice(i).join('\n').trim(); + if (tail) others.push(tail); } - return [trimmed.slice(0, index).trim(), exitInspectionNote].filter(Boolean).join('\n\n'); + return { others, blocks }; } + /** + * A block belongs to a plate when its stored `Truck Plate` equals it, or is a + * legacy comma-joined list ("P1, P2") containing it. + */ + private blockMatchesPlate(block: string, plateNumber?: string | null): boolean { + const plate = plateNumber?.trim().toUpperCase(); + if (!plate) return false; + const stored = this.extractExitInspectionLine(block, 'Truck Plate')?.toUpperCase(); + if (!stored) return false; + if (stored === plate) return true; + return stored.split(/[,;]+/).map((p) => p.trim()).includes(plate); + } + + /** + * Replace THIS truck's inspection block (matched by plate), keeping every + * other truck's block untouched; append when the plate has no block yet. + * A single legacy block (comma-joined plates or plate-less caller) is + * replaced in place so old single-truck items keep their behaviour. + */ + private replaceExitInspectionNote( + notes: string | null | undefined, + exitInspectionNote: string | null, + plateNumber?: string | null, + ): string | null { + const { others, blocks } = this.splitExitInspectionSections(notes); + if (exitInspectionNote) { + const content = exitInspectionNote.replace(EXIT_INSPECTION_MARKER, '').trim(); + const index = plateNumber + ? blocks.findIndex((b) => this.blockMatchesPlate(b, plateNumber)) + : blocks.length - 1; + if (index >= 0) blocks[index] = content; + else blocks.push(content); + } + const sections = [...others, ...blocks.map((b) => `${EXIT_INSPECTION_MARKER}\n${b}`)]; + return sections.join('\n\n') || null; + } + + /** Latest truck's inspection block — legacy summary for documents. */ private extractExitInspectionNote(notes?: string | null): string | null { - if (!notes) return null; - const marker = '[Exit Inspection]'; - const index = notes.lastIndexOf(marker); - if (index < 0) return null; - return notes.slice(index + marker.length).trim() || null; + const { blocks } = this.splitExitInspectionSections(notes); + return blocks.length ? blocks[blocks.length - 1] : null; + } + + /** + * The inspection block for one truck. Falls back to a lone existing block so + * legacy single-truck items (saved before per-plate blocks) keep working. + */ + private extractExitInspectionForPlate( + notes: string | null | undefined, + plateNumber?: string | null, + ): string | null { + const { blocks } = this.splitExitInspectionSections(notes); + const match = blocks.find((b) => this.blockMatchesPlate(b, plateNumber)); + if (match) return match; + return blocks.length === 1 ? blocks[0] : null; } private extractExitInspectionLine(note: string | null | undefined, label: string): string | null { 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 93c3bf64e..41e5fcc81 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -2647,7 +2647,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { {/* Primary stage action stays visible; the rest live under the kebab. */} - {r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && r.hasAssignedTruck && ( + {/* Stays visible after the first exit — multi-truck bookings + weigh each truck in and out until all have left. */} + {r.currentStatus === 'READY_FOR_PICKUP' && r.hasAssignedTruck && ( )} {r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && ( @@ -2692,7 +2694,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { Ready for pickup )} - {r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && ( + {r.currentStatus === 'READY_FOR_PICKUP' && ( } disabled={!r.hasAssignedTruck} @@ -2700,7 +2702,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { > {r.hasAssignedTruck ? r.releaseOrderReference - ? 'Truck leaving' + ? 'Truck arrival / leaving' : 'Truck arrival' : 'Truck arrival — assign a truck first'} 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 5f0a8a092..fcced78d9 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx @@ -1,8 +1,8 @@ -import { useEffect, useState } from 'react'; -import { Alert, Button, Group, Modal, MultiSelect, NumberInput, SegmentedControl, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core'; -import { Info, Scale } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { Alert, Badge, Button, Group, Modal, MultiSelect, NumberInput, SegmentedControl, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core'; +import { Info, Scale, Truck } from 'lucide-react'; -import { useMutation, useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; @@ -28,6 +28,8 @@ export interface ReleaseOrderTruckPrefill { containerNumber?: string | null; } +const EXIT_INSPECTION_MARKER = '[Exit Inspection]'; + const toIsoDateTime = (value: string) => { if (!value) return undefined; const date = new Date(value); @@ -70,9 +72,6 @@ const splitContainerNumbers = (value: string | null | undefined) => const getItemContainerNumber = (item: WarehouseInventoryItem | null) => (item as (WarehouseInventoryItem & { containerNumber?: string | null }) | null)?.containerNumber ?? ''; -const assignedTruckValue = (item: WarehouseInventoryItem | null, key: keyof NonNullable) => - item?.booking?.[key] == null ? '' : String(item.booking[key]); - const isContainerInventory = (item: WarehouseInventoryItem | null, containerCount: number) => { const freightType = (item as (WarehouseInventoryItem & { booking?: { freightType?: string | null } | null }) | null) ?.booking?.freightType; @@ -88,29 +87,66 @@ const initialContainerNumbers = (item: WarehouseInventoryItem | null, savedConta return Array.from({ length: expectedCount }, (_, index) => sourceNumbers[index] ?? ''); }; -const parseInspectionNote = (notes: string | null | undefined) => { - const marker = '[Exit Inspection]'; - const index = notes?.lastIndexOf(marker) ?? -1; - const note = index >= 0 ? notes?.slice(index + marker.length) : notes; - return { - truckPlateNumber: lineValue(note, 'Truck Plate'), - trailerPlateNumber: lineValue(note, 'Trailer Plate'), - driverName: lineValue(note, 'Driver'), - driverLicense: lineValue(note, 'Driver License'), - driverPhone: lineValue(note, 'Driver Phone'), - truckType: lineValue(note, 'Truck Type'), - containerNumber: lineValue(note, 'Container Number'), - gateInTime: toLocalDateTimeInput(lineValue(note, 'Gate In Time')), - tareWeight: lineNumber(note, 'Tare Weight'), - grossWeight: lineNumber(note, 'Gross Weight'), - netWeight: lineNumber(note, 'Net Weight'), - gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')), - weighingSkipped: /^Weighing:\s*SKIPPED/im.test(note ?? ''), - }; +/** One truck's saved arrival/exit weighing, parsed from its inspection block. */ +interface InspectionBlock { + truckPlateNumber: string; + trailerPlateNumber: string; + driverName: string; + driverLicense: string; + driverPhone: string; + truckType: string; + containerNumber: string; + gateInTime: string; + tareWeight: number | ''; + grossWeight: number | ''; + netWeight: number | ''; + gateOutTime: string; + weighingSkipped: boolean; +} + +const parseInspectionSection = (note: string): InspectionBlock => ({ + truckPlateNumber: lineValue(note, 'Truck Plate'), + trailerPlateNumber: lineValue(note, 'Trailer Plate'), + driverName: lineValue(note, 'Driver'), + driverLicense: lineValue(note, 'Driver License'), + driverPhone: lineValue(note, 'Driver Phone'), + truckType: lineValue(note, 'Truck Type'), + containerNumber: lineValue(note, 'Container Number'), + gateInTime: toLocalDateTimeInput(lineValue(note, 'Gate In Time')), + tareWeight: lineNumber(note, 'Tare Weight'), + grossWeight: lineNumber(note, 'Gross Weight'), + netWeight: lineNumber(note, 'Net Weight'), + gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')), + weighingSkipped: /^Weighing:\s*SKIPPED/im.test(note), +}); + +/** Every truck's saved block — multi-truck bookings weigh each truck separately. */ +const parseInspectionBlocks = (notes: string | null | undefined): InspectionBlock[] => + (notes ?? '') + .split(EXIT_INSPECTION_MARKER) + .slice(1) + .map(parseInspectionSection) + .filter((block) => block.truckPlateNumber); + +/** Match by plate; a legacy block may hold a comma-joined plate list. */ +const blockForPlate = (blocks: InspectionBlock[], plate: string): InspectionBlock | undefined => { + const key = plate.trim().toUpperCase(); + if (!key) return undefined; + return blocks.find((block) => { + const stored = block.truckPlateNumber.toUpperCase(); + return stored === key || stored.split(/[,;]+/).map((p) => p.trim()).includes(key); + }); }; +const blockArrived = (block: InspectionBlock | undefined) => + Boolean(block && (block.tareWeight !== '' || block.weighingSkipped)); + +const blockLeft = (block: InspectionBlock | undefined) => + Boolean(block?.gateOutTime && (block.grossWeight !== '' || block.weighingSkipped)); + export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: ReleaseOrderModalProps) { const { toast } = useToast(); + const queryClient = useQueryClient(); const releaseMutation = useMutation(api.warehouses.release.mutationOptions()); // Some openers (inventory workbench) supply bookingId without the booking // relation — fall back to it, or the truck/container-weight queries never run. @@ -152,78 +188,16 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea const [netWeight, setNetWeight] = useState(''); const [gateOutTime, setGateOutTime] = useState(''); const [downloading, setDownloading] = useState(false); + // Plate whose saved block was last loaded into the form — stops the + // per-plate loader effect from clobbering operator edits in a loop. + const loadedPlateRef = useRef(null); - useEffect(() => { - if (opened) { - const inspection = parseInspectionNote(item?.notes); - const assignedTruckPlate = assignedTruckValue(item, 'customerTruckPlateNumber'); - const assignedDriverName = assignedTruckValue(item, 'customerTruckDriverName'); - const assignedTruckType = assignedTruckValue(item, 'customerTruckType'); - const assignedContainerNumber = assignedTruckValue(item, 'customerTruckContainerNumber'); - const prefillContainerNumber = truckPrefill?.containerNumber ?? ''; - setReference(item?.releaseOrderReference ?? generateReleaseReference(item)); - setTruckPlateNumber(inspection.truckPlateNumber || truckPrefill?.truckPlateNumber || assignedTruckPlate || ''); - setTrailerPlateNumber(inspection.trailerPlateNumber || truckPrefill?.trailerPlateNumber || ''); - setDriverName(inspection.driverName || truckPrefill?.driverName || assignedDriverName || ''); - setDriverLicense(inspection.driverLicense || truckPrefill?.driverLicense || ''); - setDriverPhone(inspection.driverPhone || truckPrefill?.driverPhone || ''); - setTruckType(inspection.truckType || truckPrefill?.truckType || assignedTruckType || ''); - setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber || prefillContainerNumber || assignedContainerNumber)); - setGateInTime(inspection.gateInTime); - setTareWeight(inspection.tareWeight); - setWeighTruck(inspection.weighingSkipped ? 'no' : 'yes'); - setGrossWeight(inspection.grossWeight); - setNetWeight(item?.weight == null ? inspection.netWeight : Number(item.weight)); - setGateOutTime(inspection.gateOutTime); - } - }, [opened, item, truckPrefill]); - - const savedInspection = parseInspectionNote(item?.notes); - const isExitStep = savedInspection.tareWeight !== '' || savedInspection.weighingSkipped; - const isEntranceLocked = isExitStep; - const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt); - const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber); - - // Opened from the warehouse flow (no truckPrefill prop): once the last-mile - // truck query resolves, auto-fill the first assigned EDR truck — without - // overwriting anything the operator typed or the locked exit-step values. - useEffect(() => { - if (!opened || truckPrefill || isExitStep) return; - const first = lastMileTrucks[0]; - if (!first) return; - setTruckPlateNumber((p) => p || first.truckPlateNumber || ''); - setTrailerPlateNumber((p) => p || first.trailerPlateNumber || ''); - setDriverName((p) => p || first.driverName || ''); - setDriverLicense((p) => p || first.driverLicense || ''); - setDriverPhone((p) => p || first.driverPhone || ''); - setTruckType((p) => p || first.truckType || ''); - setContainerNumbers((prev) => - prev.length === 1 && !prev[0] && first.containerNumber ? [first.containerNumber] : prev, - ); - }, [opened, truckPrefill, isExitStep, lastMileTrucks]); - - // The same for a customer self-haul truck. The prefill above reads the - // booking.customer_truck_* columns, but multi-truck self-haul writes the plate - // and driver to customer_truck_assignments and leaves those columns null — so - // a booking with a truck on file still opened this form blank. Only auto-fills - // a single truck: with several, the operator picks which one is at the gate. - useEffect(() => { - if (!opened || truckPrefill || isExitStep) return; - if (customerTrucks.length !== 1) return; - const [truck] = customerTrucks; - setTruckPlateNumber((p) => p || truck.plateNumber || ''); - setDriverName((p) => p || truck.driverName || ''); - setTruckType((p) => p || truck.truckType || ''); - setContainerNumbers((prev) => { - const loaded = (truck.containers ?? []).map((c) => c.containerNumber).filter(Boolean); - return prev.every((n) => !n) && loaded.length ? loaded : prev; - }); - }, [opened, truckPrefill, isExitStep, customerTrucks]); + const savedBlocks = parseInspectionBlocks(item?.notes); // Registered trucks for THIS booking, from both sources: EDR last-mile // (truckPrefill) and the customer portal (customer_truck_assignments). const assignedTruckOptions = [ - ...(truckPrefill?.truckPlateNumber + ...(truckPrefill?.truckPlateNumber && !truckPrefill.truckPlateNumber.includes(',') ? [ { value: truckPrefill.truckPlateNumber, @@ -232,6 +206,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea driverName: truckPrefill.driverName ?? '', driverPhone: truckPrefill.driverPhone ?? '', truckType: truckPrefill.truckType ?? '', + containerNumbers: splitContainerNumbers(truckPrefill.containerNumber), + arrived: false, + left: false, }, ] : []), @@ -242,6 +219,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea driverName: t.driverName, driverPhone: '', truckType: t.truckType, + containerNumbers: (t.containers ?? []).map((c) => c.containerNumber).filter(Boolean), + arrived: Boolean(t.arrivedAt), + left: Boolean(t.departedAt), })), ...lastMileTrucks .filter((t) => t.truckPlateNumber || t.vehicleId) @@ -252,6 +232,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea driverName: t.driverName ?? '', driverPhone: t.driverPhone ?? '', truckType: t.truckType ?? '', + containerNumbers: splitContainerNumbers(t.containerNumber), + arrived: Boolean(t.arrivedAt), + left: Boolean(t.departedAt), })), ]; // Only trucks actually assigned to THIS booking (last-mile prefill or customer @@ -261,10 +244,132 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea const truckSelectOptions = [ ...new Map(assignedTruckOptions.map((t) => [t.value, t])).values(), ]; + const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt); // 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); + + // Per-truck progress: every truck is weighed in and out on its own; the saved + // blocks also cover walk-in trucks that were never formally assigned. + const truckProgress = new Map(); + for (const option of truckSelectOptions) { + truckProgress.set(option.value.trim().toUpperCase(), { arrived: option.arrived, left: option.left }); + } + for (const block of savedBlocks) { + const key = block.truckPlateNumber.trim().toUpperCase(); + const prior = truckProgress.get(key); + truckProgress.set(key, { + arrived: Boolean(prior?.arrived) || blockArrived(block), + left: Boolean(prior?.left) || blockLeft(block), + }); + } + const totalTrucks = truckProgress.size; + const arrivedTrucks = [...truckProgress.values()].filter((t) => t.arrived).length; + const leftTrucks = [...truckProgress.values()].filter((t) => t.left).length; + + // The step is decided PER TRUCK: the selected plate's saved block. A new plate + // (or a truck without a saved arrival) starts at the arrival step even when + // other trucks of the booking are already mid-flow or gone. + const selectedBlock = blockForPlate(savedBlocks, truckPlateNumber); + const isExitStep = blockArrived(selectedBlock); + const hasTruckLeft = blockLeft(selectedBlock); + const isEntranceLocked = isExitStep; + const selectedOption = truckSelectOptions.find( + (option) => option.value.trim().toUpperCase() === truckPlateNumber.trim().toUpperCase(), + ); + // Identity comes from the arrival record or the assignment — locked either + // way. A walk-in truck (typed plate, no assignment) stays editable at arrival. + const isTruckIdentityLocked = isEntranceLocked || Boolean(selectedOption); + const isDriverNameLocked = isEntranceLocked || Boolean(selectedOption?.driverName); + const referenceLocked = Boolean(item?.releaseOrderReference) || savedBlocks.length > 0; + + /** Load a truck into the form: its saved block if any, else its assignment. */ + const applyTruckSelection = (plate: string) => { + const block = blockForPlate(savedBlocks, plate); + const option = truckSelectOptions.find( + (o) => o.value.trim().toUpperCase() === plate.trim().toUpperCase(), + ); + loadedPlateRef.current = plate.trim().toUpperCase(); + setTruckPlateNumber(plate); + setTrailerPlateNumber(block?.trailerPlateNumber || option?.trailerPlate || ''); + setDriverName(block?.driverName || option?.driverName || ''); + setDriverLicense(block?.driverLicense || ''); + setDriverPhone(block?.driverPhone || option?.driverPhone || ''); + setTruckType(block?.truckType || option?.truckType || ''); + const loaded = block + ? splitContainerNumbers(block.containerNumber) + : (option?.containerNumbers ?? []); + setContainerNumbers(loaded.length ? loaded : initialContainerNumbers(item, '')); + setGateInTime(block?.gateInTime ?? ''); + setTareWeight(block?.tareWeight ?? ''); + setWeighTruck(block?.weighingSkipped ? 'no' : 'yes'); + setGrossWeight(block?.grossWeight ?? ''); + setNetWeight(block?.netWeight ?? (item?.weight == null ? '' : Number(item.weight))); + setGateOutTime(block?.gateOutTime ?? ''); + }; + + useEffect(() => { + if (opened) { + loadedPlateRef.current = null; + setReference(item?.releaseOrderReference ?? generateReleaseReference(item)); + // Initial truck: the caller's prefill, else the first truck still mid-flow + // (arrived but not left) — the operator can switch trucks in the select. + const prefillPlate = + truckPrefill?.truckPlateNumber && !truckPrefill.truckPlateNumber.includes(',') + ? truckPrefill.truckPlateNumber + : ''; + const blocks = parseInspectionBlocks(item?.notes); + const inProgress = blocks.find((block) => blockArrived(block) && !blockLeft(block)); + // Legacy single-truck bookings stored the truck on the booking columns; a + // comma-joined value means several trucks, so the operator picks instead. + const bookingPlate = item?.booking?.customerTruckPlateNumber ?? ''; + const legacyPlate = bookingPlate && !bookingPlate.includes(',') ? bookingPlate : ''; + const initialPlate = prefillPlate || inProgress?.truckPlateNumber || legacyPlate || ''; + const block = blockForPlate(blocks, initialPlate); + loadedPlateRef.current = initialPlate ? initialPlate.trim().toUpperCase() : null; + setTruckPlateNumber(initialPlate); + setTrailerPlateNumber(block?.trailerPlateNumber || truckPrefill?.trailerPlateNumber || ''); + setDriverName( + block?.driverName || + truckPrefill?.driverName || + (legacyPlate && initialPlate === legacyPlate ? (item?.booking?.customerTruckDriverName ?? '') : ''), + ); + setDriverLicense(block?.driverLicense || truckPrefill?.driverLicense || ''); + setDriverPhone(block?.driverPhone || truckPrefill?.driverPhone || ''); + setTruckType( + block?.truckType || + truckPrefill?.truckType || + (legacyPlate && initialPlate === legacyPlate ? (item?.booking?.customerTruckType ?? '') : ''), + ); + setContainerNumbers( + initialContainerNumbers(item, block?.containerNumber || truckPrefill?.containerNumber || ''), + ); + setGateInTime(block?.gateInTime ?? ''); + setTareWeight(block?.tareWeight ?? ''); + setWeighTruck(block?.weighingSkipped ? 'no' : 'yes'); + setGrossWeight(block?.grossWeight ?? ''); + setNetWeight(block?.netWeight ?? (item?.weight == null ? '' : Number(item.weight))); + setGateOutTime(block?.gateOutTime ?? ''); + } + }, [opened, item, truckPrefill]); + + // No truck chosen yet and exactly one is assigned — load it. With several + // trucks the operator picks which one is at the gate. + useEffect(() => { + if (!opened || truckPlateNumber || loadedPlateRef.current) return; + if (truckSelectOptions.length !== 1) return; + applyTruckSelection(truckSelectOptions[0].value); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [opened, truckPlateNumber, customerTrucks, lastMileTrucks]); + + // A typed plate that matches a saved arrival reloads that truck's record, so + // the exit step opens with the weigh-in data instead of blank fields. + useEffect(() => { + if (!opened) return; + const key = truckPlateNumber.trim().toUpperCase(); + if (!key || loadedPlateRef.current === key) return; + if (blockForPlate(savedBlocks, truckPlateNumber)) applyTruckSelection(truckPlateNumber); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [opened, truckPlateNumber]); // Which containers ride this truck, and their combined cargo weight. When the // booking has container weights, that sum is the authoritative net; the @@ -294,7 +399,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea ); // Skip is only offered for container bookings; bulk always weighs. const skipWeighing = hasContainerWeights && weighTruck === 'no'; - const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0 && !skipWeighing; + // Even an unweighed truck records the cargo weight it is holding — the + // selected containers' sum is the net that goes on the exit record. + const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0; const systemNetWeight = useContainerNet ? selectedCargoWeight @@ -314,6 +421,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea toast({ variant: 'destructive', title: 'Truck plate and driver name are required' }); return; } + if (hasTruckLeft) { + toast({ variant: 'destructive', title: `Truck ${truckPlateNumber} has already left — its exit record is locked` }); + return; + } if (!gateInTime || (!skipWeighing && tareWeight === '')) { toast({ variant: 'destructive', @@ -363,13 +474,17 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea weighingSkipped: skipWeighing || undefined, tareWeight: skipWeighing ? undefined : Number(tareWeight), grossWeight: skipWeighing || grossWeight === '' ? undefined : Number(grossWeight), - netWeight: !skipWeighing && isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined, + // Skipped weighing still records the net from what the truck holds. + netWeight: isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined, gateOutTime: isExitStep ? toIsoDateTime(gateOutTime) : undefined, }, }); + await queryClient.invalidateQueries({ queryKey: ['release-customer-trucks', bookingId] }); + await queryClient.invalidateQueries({ queryKey: ['release-last-mile-trucks', bookingId] }); if (!isExitStep) { + const remaining = totalTrucks > 1 ? ` (${Math.min(arrivedTrucks + 1, totalTrucks)} of ${totalTrucks} trucks arrived)` : ''; toast({ - title: 'Truck arrival saved', + title: `Truck ${truckPlateNumber.trim()} arrival saved${remaining}`, description: `${released.releaseOrderReference ?? reference} is ready for exit weighing.`, }); onClose(); @@ -380,11 +495,12 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea const blob = response.data; const filename = `release-${released.booking?.reference ?? released.bookingId ?? item.id}.pdf`; const opened = openPdfBlob(blob, filename, pdfWindow); + const remainingExit = totalTrucks > 1 ? ` ${Math.min(leftTrucks + 1, totalTrucks)} of ${totalTrucks} trucks have left.` : ''; toast({ title: 'Release exit paper issued', - description: opened + description: (opened ? 'The PDF opened in a browser tab for printing or saving.' - : 'The browser blocked the preview tab, so the PDF was downloaded.', + : 'The browser blocked the preview tab, so the PDF was downloaded.') + remainingExit, }); onClose(); } catch (error) { @@ -411,12 +527,35 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea )} + {totalTrucks > 1 && ( + } color="blue" variant="light"> + + + {totalTrucks} trucks on this booking — each is weighed in and out separately. + + + {arrivedTrucks}/{totalTrucks} arrived + + + {leftTrucks}/{totalTrucks} left + + + + )} + {hasTruckLeft && ( + } color="green" variant="light"> + + Truck {truckPlateNumber} has already left — its exit record is locked. Pick another + truck to continue the remaining arrivals and exits. + + + )} setReference(e.currentTarget.value)} - readOnly={isEntranceLocked} + readOnly={referenceLocked} /> {noTruckAssigned && ( }> @@ -425,22 +564,19 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea )} {truckSelectOptions.length > 0 && (