From 0e6ebda6f6427eaca4a0d4d83b7b02b86c62ab8a Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 15:21:23 +0000 Subject: [PATCH 01/16] fix --- apps/edr-freight-api/src/app.module.ts | 2 + .../1890000000008-AddFleetEvents.ts | 43 +++++ .../src/modules/drivers/drivers.controller.ts | 12 +- .../src/modules/drivers/drivers.service.ts | 14 +- .../modules/first-mile/first-mile.service.ts | 69 +++++++ .../entities/fleet-event.entity.ts | 55 ++++++ .../fleet-history/fleet-history.module.ts | 17 ++ .../fleet-history/fleet-history.service.ts | 54 ++++++ .../modules/last-mile/last-mile.service.ts | 66 ++++++- .../modules/vehicles/vehicles.controller.ts | 12 +- .../src/modules/vehicles/vehicles.service.ts | 85 ++++++++- .../components/fleet/FleetHistoryModal.tsx | 176 ++++++++++++++++++ .../components/fleet/FleetRecordActions.tsx | 15 +- .../src/pages/fleet/FleetResourcePage.tsx | 10 + .../src/services/fleet-history.service.ts | 38 ++++ 15 files changed, 661 insertions(+), 7 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1890000000008-AddFleetEvents.ts create mode 100644 apps/edr-freight-api/src/modules/fleet-history/entities/fleet-event.entity.ts create mode 100644 apps/edr-freight-api/src/modules/fleet-history/fleet-history.module.ts create mode 100644 apps/edr-freight-api/src/modules/fleet-history/fleet-history.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx create mode 100644 apps/edr-freight-web/backoffice/src/services/fleet-history.service.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index d72c8b720..b5d63194c 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -77,6 +77,7 @@ import { LastMileModule } from './modules/last-mile/last-mile.module'; import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module'; import { ImportOperationsModule } from './modules/import-operations/import-operations.module'; import { VerifaydaModule } from './modules/verifayda/verifayda.module'; +import { FleetHistoryModule } from './modules/fleet-history/fleet-history.module'; @Module({ imports: [ @@ -144,6 +145,7 @@ import { VerifaydaModule } from './modules/verifayda/verifayda.module'; InterchangeDocumentsModule, ImportOperationsModule, VerifaydaModule, + FleetHistoryModule, ], providers: [ EdrOrgSeeder, diff --git a/apps/edr-freight-api/src/migrations/1890000000008-AddFleetEvents.ts b/apps/edr-freight-api/src/migrations/1890000000008-AddFleetEvents.ts new file mode 100644 index 000000000..8fbb688d3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000008-AddFleetEvents.ts @@ -0,0 +1,43 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Append-only audit log for fleet activity (driver↔vehicle assignments, vehicle + * status/availability transitions, first/last-mile vehicle assignments + mile + * status changes). Queried by vehicle_id or driver_id to build a per-record + * timeline. Populated going forward — existing records have no back-history. + */ +export class AddFleetEvents1890000000008 implements MigrationInterface { + name = "AddFleetEvents1890000000008"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.fleet_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + event_type varchar NOT NULL, + vehicle_id uuid, + driver_id uuid, + first_mile_id uuid, + last_mile_id uuid, + from_value varchar, + to_value varchar, + label varchar, + metadata jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_FLEET_EVENTS_VEHICLE" + ON freight.fleet_events (vehicle_id, created_at) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_FLEET_EVENTS_DRIVER" + ON freight.fleet_events (driver_id, created_at) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.fleet_events`); + } +} diff --git a/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts b/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts index eb0628b96..b4da558e2 100644 --- a/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts +++ b/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts @@ -14,13 +14,17 @@ import { FleetManage, FleetView } from '../../common/booking-guards'; import { DriversService } from './drivers.service'; import { CreateDriverDto } from './dto/create-driver.dto'; import { UpdateDriverDto } from './dto/update-driver.dto'; +import { FleetHistoryService } from '../fleet-history/fleet-history.service'; @ApiTags('drivers') @ApiBearerAuth() @Controller('drivers') @FleetView() export class DriversController { - constructor(private readonly driversService: DriversService) {} + constructor( + private readonly driversService: DriversService, + private readonly fleetHistory: FleetHistoryService, + ) {} @Post() @FleetManage() @@ -55,6 +59,12 @@ export class DriversController { return this.driversService.findById(id); } + @Get(':id/history') + @ApiOperation({ summary: 'Get driver assignment & activity history' }) + history(@Param('id', ParseUUIDPipe) id: string) { + return this.fleetHistory.getDriverHistory(id); + } + @Patch(':id') @FleetManage() @ApiOperation({ summary: 'Update a driver' }) diff --git a/apps/edr-freight-api/src/modules/drivers/drivers.service.ts b/apps/edr-freight-api/src/modules/drivers/drivers.service.ts index cbfb6787d..6e7c1f69c 100644 --- a/apps/edr-freight-api/src/modules/drivers/drivers.service.ts +++ b/apps/edr-freight-api/src/modules/drivers/drivers.service.ts @@ -4,12 +4,15 @@ import { Repository } from 'typeorm'; import { CreateDriverDto } from './dto/create-driver.dto'; import { UpdateDriverDto } from './dto/update-driver.dto'; import { Driver, DriverStatus } from './entities/driver.entity'; +import { FleetHistoryService } from '../fleet-history/fleet-history.service'; +import { FleetEventType } from '../fleet-history/entities/fleet-event.entity'; @Injectable() export class DriversService { constructor( @InjectRepository(Driver) private readonly driverRepo: Repository, + private readonly history: FleetHistoryService, ) {} async create(dto: CreateDriverDto): Promise { @@ -51,7 +54,16 @@ export class DriversService { } const driver = this.driverRepo.create(dto); - return this.driverRepo.save(driver); + const saved = await this.driverRepo.save(driver); + + await this.history.record({ + eventType: FleetEventType.DRIVER_REGISTERED, + driverId: saved.id, + label: `${saved.firstName ?? ''} ${saved.lastName ?? ''}`.trim() || null, + toValue: saved.status ?? null, + }); + + return saved; } async findAll(query: { diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 40d163220..419b69969 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -14,6 +14,8 @@ import { FirstMileContainerAllocation } from "./entities/first-mile-container-al import { FirstMileRepository } from "./first-mile.repository"; import { OnEvent } from "@nestjs/event-emitter"; import { InvoiceEventPayload } from "../billing/billing.service"; +import { FleetHistoryService } from "../fleet-history/fleet-history.service"; +import { FleetEventType } from "../fleet-history/entities/fleet-event.entity"; type FirstMileListFilter = { status?: FirstMileStatus; @@ -43,8 +45,21 @@ export class FirstMileService { private readonly vehiclesService: VehiclesService, private readonly driversService: DriversService, private readonly smsClient: SmsClientService, + private readonly history: FleetHistoryService, ) { } + /** Resolve the driver currently assigned to a vehicle, for stamping mile + * events onto that driver's timeline. Best-effort — never throws. */ + private async resolveDriverId(vehicleId?: string | null): Promise { + if (!vehicleId) return null; + try { + const vehicle = await this.vehiclesService.findById(vehicleId); + return vehicle.assignedDriverId ?? null; + } catch { + return null; + } + } + /** * Look up a booking by its human-readable reference and confirm it has been * paid before any first-mile work proceeds. Throws if the reference is @@ -210,6 +225,14 @@ export class FirstMileService { if (dto.vehicleId) { await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId: dto.vehicleId, + firstMileId: record.id, + driverId: await this.resolveDriverId(dto.vehicleId), + label: record.status, + metadata: { mile: 'FIRST' }, + }); } return record; @@ -278,6 +301,26 @@ export class FirstMileService { if (existing.vehicleId) { await this.vehiclesService.releaseIfUnused([existing.vehicleId]); } + // Audit the mile↔vehicle (re)assignment on both vehicle and driver lines. + if (existing.vehicleId) { + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_RELEASED, + vehicleId: existing.vehicleId, + firstMileId: id, + driverId: await this.resolveDriverId(existing.vehicleId), + metadata: { mile: 'FIRST' }, + }); + } + if (dto.vehicleId) { + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId: dto.vehicleId, + firstMileId: id, + driverId: await this.resolveDriverId(dto.vehicleId), + label: updated.status, + metadata: { mile: 'FIRST' }, + }); + } } // Notify assigned driver on every explicit vehicle assignment or reassignment @@ -285,6 +328,19 @@ export class FirstMileService { void this.notifyDriverAssignment(dto.vehicleId, existing); } + if (dto.status !== undefined && dto.status !== existing.status) { + const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null; + await this.history.record({ + eventType: FleetEventType.MILE_STATUS_CHANGED, + firstMileId: id, + vehicleId, + driverId: await this.resolveDriverId(vehicleId), + fromValue: existing.status, + toValue: dto.status, + metadata: { mile: 'FIRST' }, + }); + } + // Trip finished — release the vehicles it was holding if (dto.status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') { await this.releaseVehicles(updated); @@ -301,6 +357,19 @@ export class FirstMileService { throw new NotFoundException(`First-mile record ${id} not found`); } + if (status !== existing.status) { + const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null; + await this.history.record({ + eventType: FleetEventType.MILE_STATUS_CHANGED, + firstMileId: id, + vehicleId, + driverId: await this.resolveDriverId(vehicleId), + fromValue: existing.status, + toValue: status, + metadata: { mile: 'FIRST' }, + }); + } + if (status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') { await this.releaseVehicles(updated); } diff --git a/apps/edr-freight-api/src/modules/fleet-history/entities/fleet-event.entity.ts b/apps/edr-freight-api/src/modules/fleet-history/entities/fleet-event.entity.ts new file mode 100644 index 000000000..5096adbc6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/fleet-history/entities/fleet-event.entity.ts @@ -0,0 +1,55 @@ +import { Entity, Column, Index } from 'typeorm'; +import { BaseEntity } from '@edr/api-common'; + +/** + * Append-only audit log for fleet activity. One row per transition. Queried by + * `vehicleId` (vehicle timeline) or `driverId` (driver timeline); an event may + * carry both so a driver↔vehicle assignment or a mile assignment shows on both. + * `createdAt` (from BaseEntity) is the event time. + */ +export enum FleetEventType { + DRIVER_REGISTERED = 'DRIVER_REGISTERED', + VEHICLE_REGISTERED = 'VEHICLE_REGISTERED', + DRIVER_ASSIGNED = 'DRIVER_ASSIGNED', + DRIVER_UNASSIGNED = 'DRIVER_UNASSIGNED', + VEHICLE_STATUS_CHANGED = 'VEHICLE_STATUS_CHANGED', + VEHICLE_AVAILABILITY_CHANGED = 'VEHICLE_AVAILABILITY_CHANGED', + MILE_VEHICLE_ASSIGNED = 'MILE_VEHICLE_ASSIGNED', + MILE_VEHICLE_RELEASED = 'MILE_VEHICLE_RELEASED', + MILE_STATUS_CHANGED = 'MILE_STATUS_CHANGED', +} + +@Entity({ name: 'fleet_events', schema: 'freight' }) +export class FleetEvent extends BaseEntity { + @Column({ name: 'event_type', type: 'varchar' }) + eventType!: FleetEventType; + + @Index() + @Column({ name: 'vehicle_id', type: 'uuid', nullable: true }) + vehicleId?: string | null; + + @Index() + @Column({ name: 'driver_id', type: 'uuid', nullable: true }) + driverId?: string | null; + + @Column({ name: 'first_mile_id', type: 'uuid', nullable: true }) + firstMileId?: string | null; + + @Column({ name: 'last_mile_id', type: 'uuid', nullable: true }) + lastMileId?: string | null; + + /** Previous value for a transition (e.g. old status/availability). */ + @Column({ name: 'from_value', type: 'varchar', nullable: true }) + fromValue?: string | null; + + /** New value for a transition (e.g. new status/availability). */ + @Column({ name: 'to_value', type: 'varchar', nullable: true }) + toValue?: string | null; + + /** Human-readable summary token (driver name, plate, booking ref, mile). */ + @Column({ name: 'label', type: 'varchar', nullable: true }) + label?: string | null; + + @Column({ name: 'metadata', type: 'jsonb', nullable: true }) + metadata?: Record | null; +} diff --git a/apps/edr-freight-api/src/modules/fleet-history/fleet-history.module.ts b/apps/edr-freight-api/src/modules/fleet-history/fleet-history.module.ts new file mode 100644 index 000000000..14828e0a9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/fleet-history/fleet-history.module.ts @@ -0,0 +1,17 @@ +import { Global, Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { FleetEvent } from './entities/fleet-event.entity'; +import { FleetHistoryService } from './fleet-history.service'; + +/** + * Global so any fleet-touching service (vehicles, drivers, first/last-mile) can + * inject FleetHistoryService to append audit events without each module having + * to import this one. + */ +@Global() +@Module({ + imports: [TypeOrmModule.forFeature([FleetEvent])], + providers: [FleetHistoryService], + exports: [FleetHistoryService], +}) +export class FleetHistoryModule {} diff --git a/apps/edr-freight-api/src/modules/fleet-history/fleet-history.service.ts b/apps/edr-freight-api/src/modules/fleet-history/fleet-history.service.ts new file mode 100644 index 000000000..9c61119b9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/fleet-history/fleet-history.service.ts @@ -0,0 +1,54 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { FleetEvent, FleetEventType } from './entities/fleet-event.entity'; + +export interface FleetEventInput { + eventType: FleetEventType; + vehicleId?: string | null; + driverId?: string | null; + firstMileId?: string | null; + lastMileId?: string | null; + fromValue?: string | null; + toValue?: string | null; + label?: string | null; + metadata?: Record | null; +} + +@Injectable() +export class FleetHistoryService { + private readonly logger = new Logger(FleetHistoryService.name); + + constructor( + @InjectRepository(FleetEvent) + private readonly eventRepo: Repository, + ) {} + + /** + * Append an audit event. Best-effort: recording history must never break the + * business operation that triggered it, so failures are logged and swallowed. + */ + async record(input: FleetEventInput): Promise { + try { + await this.eventRepo.save(this.eventRepo.create(input)); + } catch (err) { + this.logger.error( + `Failed to record fleet event ${input.eventType}: ${String(err)}`, + ); + } + } + + getVehicleHistory(vehicleId: string): Promise { + return this.eventRepo.find({ + where: { vehicleId }, + order: { createdAt: 'DESC' }, + }); + } + + getDriverHistory(driverId: string): Promise { + return this.eventRepo.find({ + where: { driverId }, + order: { createdAt: 'DESC' }, + }); + } +} 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 69eec29ae..f0d39d51c 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 @@ -12,6 +12,8 @@ import { LastMileContainerAllocation } from './entities/last-mile-container-allo import { LastMileRepository } from './last-mile.repository'; import { InvoiceEventPayload } from '../billing/billing.service'; import { OnEvent } from '@nestjs/event-emitter'; +import { FleetHistoryService } from '../fleet-history/fleet-history.service'; +import { FleetEventType } from '../fleet-history/entities/fleet-event.entity'; type LastMileListFilter = { status?: LastMileStatus; @@ -41,8 +43,21 @@ export class LastMileService { private readonly driversService: DriversService, private readonly smsClient: SmsClientService, private readonly dataSource: DataSource, + private readonly history: FleetHistoryService, ) {} + /** Resolve the driver currently assigned to a vehicle, for stamping mile + * events onto that driver's timeline. Best-effort — never throws. */ + private async resolveDriverId(vehicleId?: string | null): Promise { + if (!vehicleId) return null; + try { + const vehicle = await this.vehiclesService.findById(vehicleId); + return vehicle.assignedDriverId ?? null; + } catch { + return null; + } + } + async acceptBooking(bookingReference: string): Promise { const booking = await this.bookingsRepository.findByReference(bookingReference); @@ -132,7 +147,7 @@ export class LastMileService { } async create(dto: CreateLastMileDto): Promise { - return this.lastMileRepository.create({ + const record = await this.lastMileRepository.create({ bookingId: dto.bookingId, status: dto.status ?? 'READY_TO_TRANSIT', advancedPayment: dto.advancedPayment ?? 0, @@ -142,6 +157,19 @@ export class LastMileService { vehicleId: dto.vehicleId ?? null, paid: (dto as any).paid ?? false, }); + + if (dto.vehicleId) { + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId: dto.vehicleId, + lastMileId: record.id, + driverId: await this.resolveDriverId(dto.vehicleId), + label: record.status, + metadata: { mile: 'LAST' }, + }); + } + + return record; } @OnEvent("lastmile.invoice.paid") @@ -180,6 +208,42 @@ export class LastMileService { void this.notifyDriverAssignment(dto.vehicleId, existing); } + // Audit the mile↔vehicle (re)assignment on both vehicle and driver lines. + if (dto.vehicleId !== undefined && dto.vehicleId !== existing.vehicleId) { + if (existing.vehicleId) { + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_RELEASED, + vehicleId: existing.vehicleId, + lastMileId: id, + driverId: await this.resolveDriverId(existing.vehicleId), + metadata: { mile: 'LAST' }, + }); + } + if (dto.vehicleId) { + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId: dto.vehicleId, + lastMileId: id, + driverId: await this.resolveDriverId(dto.vehicleId), + label: updated.status, + metadata: { mile: 'LAST' }, + }); + } + } + + if (dto.status !== undefined && dto.status !== existing.status) { + const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null; + await this.history.record({ + eventType: FleetEventType.MILE_STATUS_CHANGED, + lastMileId: id, + vehicleId, + driverId: await this.resolveDriverId(vehicleId), + fromValue: existing.status, + toValue: dto.status, + metadata: { mile: 'LAST' }, + }); + } + return updated; } diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts index 8e6d8a0a8..f0a77791a 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts @@ -14,13 +14,17 @@ import { FleetManage, FleetView } from '../../common/booking-guards'; import { VehiclesService } from './vehicles.service'; import { CreateVehicleDto } from './dto/create-vehicle.dto'; import { UpdateVehicleDto } from './dto/update-vehicle.dto'; +import { FleetHistoryService } from '../fleet-history/fleet-history.service'; @ApiTags('vehicles') @ApiBearerAuth() @Controller('vehicles') @FleetView() export class VehiclesController { - constructor(private readonly vehiclesService: VehiclesService) {} + constructor( + private readonly vehiclesService: VehiclesService, + private readonly fleetHistory: FleetHistoryService, + ) {} @Post() @FleetManage() @@ -57,6 +61,12 @@ export class VehiclesController { return this.vehiclesService.findById(id); } + @Get(':id/history') + @ApiOperation({ summary: 'Get vehicle assignment, status & mile history' }) + history(@Param('id', ParseUUIDPipe) id: string) { + return this.fleetHistory.getVehicleHistory(id); + } + @Patch(':id') @FleetManage() @ApiOperation({ summary: 'Update a vehicle' }) diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts index 69568e483..345d4f896 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts @@ -9,12 +9,15 @@ import { FirstMileContainerAllocation } from '../first-mile/entities/first-mile- import { LastMile, LastMileStatus } from '../last-mile/entities/last-mile.entity'; import { LastMileContainerAllocation } from '../last-mile/entities/last-mile-container-allocation.entity'; import { BookingContainerAllocation } from '../bookings/entities/booking-container-allocation.entity'; +import { FleetHistoryService } from '../fleet-history/fleet-history.service'; +import { FleetEventType } from '../fleet-history/entities/fleet-event.entity'; @Injectable() export class VehiclesService { constructor( @InjectRepository(Vehicle) private readonly vehicleRepo: Repository, + private readonly history: FleetHistoryService, ) {} async create(dto: CreateVehicleDto): Promise { @@ -34,7 +37,24 @@ export class VehiclesService { registrationNumber, }); - return this.vehicleRepo.save(vehicle); + const saved = await this.vehicleRepo.save(vehicle); + + await this.history.record({ + eventType: FleetEventType.VEHICLE_REGISTERED, + vehicleId: saved.id, + label: saved.plateNumber ?? saved.code ?? null, + toValue: saved.availability ?? null, + }); + if (saved.assignedDriverId) { + await this.history.record({ + eventType: FleetEventType.DRIVER_ASSIGNED, + vehicleId: saved.id, + driverId: saved.assignedDriverId, + label: saved.assignedDriverName ?? null, + }); + } + + return saved; } async findAll(query: { @@ -97,12 +117,73 @@ export class VehiclesService { } } + const prev = { + assignedDriverId: vehicle.assignedDriverId, + assignedDriverName: vehicle.assignedDriverName, + status: vehicle.status, + availability: vehicle.availability, + }; + Object.assign(vehicle, dto); - return this.vehicleRepo.save(vehicle); + const saved = await this.vehicleRepo.save(vehicle); + + // Driver (re)assignment — emit an unassign for the old driver and/or an + // assign for the new one so both drivers' timelines and the vehicle's line up. + if ( + dto.assignedDriverId !== undefined && + dto.assignedDriverId !== prev.assignedDriverId + ) { + if (prev.assignedDriverId) { + await this.history.record({ + eventType: FleetEventType.DRIVER_UNASSIGNED, + vehicleId: id, + driverId: prev.assignedDriverId, + label: prev.assignedDriverName ?? null, + }); + } + if (saved.assignedDriverId) { + await this.history.record({ + eventType: FleetEventType.DRIVER_ASSIGNED, + vehicleId: id, + driverId: saved.assignedDriverId, + label: saved.assignedDriverName ?? null, + }); + } + } + if (dto.status !== undefined && dto.status !== prev.status) { + await this.history.record({ + eventType: FleetEventType.VEHICLE_STATUS_CHANGED, + vehicleId: id, + fromValue: prev.status ?? null, + toValue: saved.status ?? null, + }); + } + if (dto.availability !== undefined && dto.availability !== prev.availability) { + await this.history.record({ + eventType: FleetEventType.VEHICLE_AVAILABILITY_CHANGED, + vehicleId: id, + fromValue: prev.availability ?? null, + toValue: saved.availability ?? null, + }); + } + + return saved; } async setAvailability(id: string, availability: VehicleAvailability): Promise { + // Read the current value so the audit event records an accurate from→to and + // we skip logging no-op writes (setAvailability is called in release loops). + const vehicle = await this.vehicleRepo.findOne({ where: { id } }); + const previous = vehicle?.availability; await this.vehicleRepo.update(id, { availability }); + if (previous !== availability) { + await this.history.record({ + eventType: FleetEventType.VEHICLE_AVAILABILITY_CHANGED, + vehicleId: id, + fromValue: previous ?? null, + toValue: availability, + }); + } } /** diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx new file mode 100644 index 000000000..868ed347d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx @@ -0,0 +1,176 @@ +import { Center, Loader, Modal, Text, Timeline } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { + Activity, + CircleDot, + Route, + Truck, + UserCheck, + UserMinus, + UserPlus, +} from "lucide-react"; + +import { + fleetHistoryService, + type FleetHistoryEvent, +} from "@/services/fleet-history.service"; +import type { FleetRecord } from "@/services/fleet/fleet.service"; + +export interface FleetHistoryModalProps { + opened: boolean; + onClose: () => void; + entity: "driver" | "vehicle"; + record: FleetRecord | null; +} + +const asObj = (r: FleetRecord | null) => (r ?? {}) as Record; + +const titleFor = (entity: "driver" | "vehicle", record: FleetRecord | null) => { + const r = asObj(record); + if (entity === "vehicle") { + return `Vehicle history — ${r.plateNumber ?? r.code ?? ""}`.trim(); + } + return `Driver history — ${[r.firstName, r.lastName] + .filter(Boolean) + .join(" ")}`.trim(); +}; + +const mileLabel = (e: FleetHistoryEvent) => + e.metadata?.mile === "LAST" ? "Last-mile" : "First-mile"; + +const arrow = (from?: string | null, to?: string | null) => + `${from ?? "—"} → ${to ?? "—"}`; + +function describe(e: FleetHistoryEvent, entity: "driver" | "vehicle") { + switch (e.eventType) { + case "DRIVER_REGISTERED": + return { + icon: , + title: "Driver registered", + text: e.toValue ? `Status: ${e.toValue}` : "", + }; + case "VEHICLE_REGISTERED": + return { + icon: , + title: "Vehicle registered", + text: e.toValue ? `Availability: ${e.toValue}` : "", + }; + case "DRIVER_ASSIGNED": + return { + icon: , + title: + entity === "vehicle" + ? `Driver assigned${e.label ? `: ${e.label}` : ""}` + : "Assigned to a vehicle", + text: "", + }; + case "DRIVER_UNASSIGNED": + return { + icon: , + title: + entity === "vehicle" + ? `Driver unassigned${e.label ? `: ${e.label}` : ""}` + : "Unassigned from a vehicle", + text: "", + }; + case "VEHICLE_STATUS_CHANGED": + return { + icon: , + title: "Status changed", + text: arrow(e.fromValue, e.toValue), + }; + case "VEHICLE_AVAILABILITY_CHANGED": + return { + icon: , + title: `Marked ${e.toValue ?? ""}`.trim(), + text: e.fromValue ? arrow(e.fromValue, e.toValue) : "", + }; + case "MILE_VEHICLE_ASSIGNED": + return { + icon: , + title: `${mileLabel(e)}: vehicle assigned`, + text: e.label ? `Status: ${e.label}` : "", + }; + case "MILE_VEHICLE_RELEASED": + return { + icon: , + title: `${mileLabel(e)}: vehicle released`, + text: "", + }; + case "MILE_STATUS_CHANGED": + return { + icon: , + title: `${mileLabel(e)} status`, + text: arrow(e.fromValue, e.toValue), + }; + default: + return { icon: , title: e.eventType, text: "" }; + } +} + +const fmt = (iso: string) => { + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? iso : d.toLocaleString(); +}; + +const FleetHistoryModal = ({ + opened, + onClose, + entity, + record, +}: FleetHistoryModalProps) => { + const id = asObj(record).id ? String(asObj(record).id) : ""; + + const { data, isLoading } = useQuery({ + queryKey: ["fleet-history", entity, id], + queryFn: () => + entity === "vehicle" + ? fleetHistoryService.vehicle(id) + : fleetHistoryService.driver(id), + enabled: opened && Boolean(id), + }); + + const events = data ?? []; + + return ( + {titleFor(entity, record)}} + radius="lg" + size="lg" + centered + > + {isLoading ? ( +
+ +
+ ) : events.length === 0 ? ( + + No history recorded yet. Activity appears here as this{" "} + {entity} is assigned, reassigned, or its status changes. + + ) : ( + + {events.map((e) => { + const d = describe(e, entity); + return ( + + {d.text && ( + + {d.text} + + )} + + {fmt(e.createdAt)} + + + ); + })} + + )} +
+ ); +}; + +export default FleetHistoryModal; diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx index 9096f52a1..d499d030c 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx @@ -1,4 +1,4 @@ -import { Edit2, Trash2, Eye, Users, MoreVertical } from "lucide-react"; +import { Edit2, Trash2, Eye, Users, MoreVertical, History } from "lucide-react"; import { ActionIcon, Menu, MenuItem, Tooltip } from "@mantine/core"; import { useNavigate } from "react-router-dom"; @@ -11,6 +11,7 @@ export interface FleetRecordActionsProps { onEdit: (record: FleetRecord) => void; onRemove: (record: FleetRecord) => void; onAssignDriver?: (record: FleetRecord) => void; + onHistory?: (record: FleetRecord) => void; layout?: "row" | "compact"; } @@ -20,12 +21,16 @@ const FleetRecordActions = ({ onEdit, onRemove, onAssignDriver, + onHistory, layout = "row", }: FleetRecordActionsProps) => { const navigate = useNavigate(); const removeLabel = config.removeActionLabel ?? "Delete"; const showDetail = Boolean(config.detailPath && "id" in record); const isVehicle = config.slug === "vehicles"; + const showHistory = + Boolean(onHistory) && + (config.slug === "drivers" || config.slug === "vehicles"); const handleDetail = () => { if (!config.detailPath || !("id" in record)) return; @@ -57,6 +62,14 @@ const FleetRecordActions = ({ > Edit + {showHistory ? ( + onHistory?.(record)} + leftSection={} + > + History + + ) : null} {showDetail ? ( { const [editing, setEditing] = useState(null); const [removeTarget, setRemoveTarget] = useState(null); const [assigningDriver, setAssigningDriver] = useState(null); + const [historyTarget, setHistoryTarget] = useState(null); const [selectedDriver, setSelectedDriver] = useState(""); const { viewMode, setViewMode } = useFleetViewMode(slug); @@ -273,6 +275,7 @@ const FleetResourcePage = () => { }} onRemove={setRemoveTarget} onAssignDriver={setAssigningDriver} + onHistory={setHistoryTarget} /> ), @@ -581,6 +584,13 @@ const FleetResourcePage = () => { + + setHistoryTarget(null)} + entity={slug === "vehicles" ? "vehicle" : "driver"} + record={historyTarget} + /> ); }; diff --git a/apps/edr-freight-web/backoffice/src/services/fleet-history.service.ts b/apps/edr-freight-web/backoffice/src/services/fleet-history.service.ts new file mode 100644 index 000000000..5ffbe4a26 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/fleet-history.service.ts @@ -0,0 +1,38 @@ +import { api as apiClient } from "../auth/http"; + +export type FleetEventType = + | "DRIVER_REGISTERED" + | "VEHICLE_REGISTERED" + | "DRIVER_ASSIGNED" + | "DRIVER_UNASSIGNED" + | "VEHICLE_STATUS_CHANGED" + | "VEHICLE_AVAILABILITY_CHANGED" + | "MILE_VEHICLE_ASSIGNED" + | "MILE_VEHICLE_RELEASED" + | "MILE_STATUS_CHANGED"; + +export interface FleetHistoryEvent { + id: string; + eventType: FleetEventType; + vehicleId?: string | null; + driverId?: string | null; + firstMileId?: string | null; + lastMileId?: string | null; + fromValue?: string | null; + toValue?: string | null; + label?: string | null; + metadata?: Record | null; + createdAt: string; +} + +/** Timeline of fleet events for a driver or a vehicle (newest first). */ +export const fleetHistoryService = { + driver: (id: string) => + apiClient + .get(`/drivers/${id}/history`) + .then((r) => r.data), + vehicle: (id: string) => + apiClient + .get(`/vehicles/${id}/history`) + .then((r) => r.data), +}; From f2f6c89c0eacb7e1010d9a8a1de1bb73e0f3cd17 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 15:37:24 +0000 Subject: [PATCH 02/16] fix --- .../modules/last-mile/last-mile.service.ts | 26 ++++++++++++++++--- .../components/fleet/FleetHistoryModal.tsx | 13 +++++++--- 2 files changed, 32 insertions(+), 7 deletions(-) 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 f0d39d51c..f5c9696e6 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 @@ -58,6 +58,23 @@ export class LastMileService { } } + /** Human booking reference for a last-mile record, for the history timeline. + * Uses the already-loaded relation when present, else looks it up. */ + private async resolveBookingRef( + record: LastMile, + ): Promise { + const loaded = (record as LastMile & { booking?: { reference?: string } }) + .booking?.reference; + if (loaded) return loaded; + if (!record.bookingId) return null; + try { + const booking = await this.bookingsRepository.findById(record.bookingId); + return (booking as { reference?: string } | null)?.reference ?? null; + } catch { + return null; + } + } + async acceptBooking(bookingReference: string): Promise { const booking = await this.bookingsRepository.findByReference(bookingReference); @@ -165,7 +182,7 @@ export class LastMileService { lastMileId: record.id, driverId: await this.resolveDriverId(dto.vehicleId), label: record.status, - metadata: { mile: 'LAST' }, + metadata: { mile: 'LAST', bookingRef: await this.resolveBookingRef(record) }, }); } @@ -209,6 +226,7 @@ export class LastMileService { } // Audit the mile↔vehicle (re)assignment on both vehicle and driver lines. + const bookingRef = await this.resolveBookingRef(existing); if (dto.vehicleId !== undefined && dto.vehicleId !== existing.vehicleId) { if (existing.vehicleId) { await this.history.record({ @@ -216,7 +234,7 @@ export class LastMileService { vehicleId: existing.vehicleId, lastMileId: id, driverId: await this.resolveDriverId(existing.vehicleId), - metadata: { mile: 'LAST' }, + metadata: { mile: 'LAST', bookingRef }, }); } if (dto.vehicleId) { @@ -226,7 +244,7 @@ export class LastMileService { lastMileId: id, driverId: await this.resolveDriverId(dto.vehicleId), label: updated.status, - metadata: { mile: 'LAST' }, + metadata: { mile: 'LAST', bookingRef }, }); } } @@ -240,7 +258,7 @@ export class LastMileService { driverId: await this.resolveDriverId(vehicleId), fromValue: existing.status, toValue: dto.status, - metadata: { mile: 'LAST' }, + metadata: { mile: 'LAST', bookingRef }, }); } diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx index 868ed347d..0e7a0e65b 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx @@ -42,6 +42,13 @@ const arrow = (from?: string | null, to?: string | null) => `${from ?? "—"} → ${to ?? "—"}`; function describe(e: FleetHistoryEvent, entity: "driver" | "vehicle") { + const bookingRef = + typeof e.metadata?.bookingRef === "string" ? e.metadata.bookingRef : null; + const withBooking = (rest?: string) => + [bookingRef ? `Booking ${bookingRef}` : "", rest ?? ""] + .filter(Boolean) + .join(" · "); + switch (e.eventType) { case "DRIVER_REGISTERED": return { @@ -89,19 +96,19 @@ function describe(e: FleetHistoryEvent, entity: "driver" | "vehicle") { return { icon: , title: `${mileLabel(e)}: vehicle assigned`, - text: e.label ? `Status: ${e.label}` : "", + text: withBooking(e.label ? `Status: ${e.label}` : ""), }; case "MILE_VEHICLE_RELEASED": return { icon: , title: `${mileLabel(e)}: vehicle released`, - text: "", + text: withBooking(), }; case "MILE_STATUS_CHANGED": return { icon: , title: `${mileLabel(e)} status`, - text: arrow(e.fromValue, e.toValue), + text: withBooking(arrow(e.fromValue, e.toValue)), }; default: return { icon: , title: e.eventType, text: "" }; From c643d30624825009a96d2f537f0c9fcfa39fc330 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 15:47:12 +0000 Subject: [PATCH 03/16] fix --- .../modules/first-mile/first-mile.service.ts | 83 +++++++++++++++---- .../modules/last-mile/last-mile.service.ts | 60 ++++++++++---- .../src/modules/vehicles/vehicles.service.ts | 7 ++ .../components/fleet/FleetHistoryModal.tsx | 52 ++++++++---- 4 files changed, 157 insertions(+), 45 deletions(-) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 419b69969..43f02442a 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -48,13 +48,33 @@ export class FirstMileService { private readonly history: FleetHistoryService, ) { } - /** Resolve the driver currently assigned to a vehicle, for stamping mile - * events onto that driver's timeline. Best-effort — never throws. */ - private async resolveDriverId(vehicleId?: string | null): Promise { - if (!vehicleId) return null; + /** Resolve a vehicle's driver + human labels, for stamping mile events onto + * the driver's timeline and naming the vehicle. Best-effort — never throws. */ + private async vehicleInfo( + vehicleId?: string | null, + ): Promise<{ driverId: string | null; plate: string | null; driverName: string | null }> { + if (!vehicleId) return { driverId: null, plate: null, driverName: null }; try { - const vehicle = await this.vehiclesService.findById(vehicleId); - return vehicle.assignedDriverId ?? null; + const v = await this.vehiclesService.findById(vehicleId); + return { + driverId: v.assignedDriverId ?? null, + plate: v.plateNumber ?? v.code ?? null, + driverName: v.assignedDriverName ?? null, + }; + } catch { + return { driverId: null, plate: null, driverName: null }; + } + } + + /** Human booking reference for a first-mile record, for the history timeline. */ + private async resolveBookingRef(record: FirstMile): Promise { + const loaded = (record as FirstMile & { booking?: { reference?: string } }) + .booking?.reference; + if (loaded) return loaded; + if (!record.bookingId) return null; + try { + const b = await this.bookingsRepository.findById(record.bookingId); + return (b as { reference?: string } | null)?.reference ?? null; } catch { return null; } @@ -225,13 +245,19 @@ export class FirstMileService { if (dto.vehicleId) { await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY); + const info = await this.vehicleInfo(dto.vehicleId); await this.history.record({ eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, vehicleId: dto.vehicleId, firstMileId: record.id, - driverId: await this.resolveDriverId(dto.vehicleId), + driverId: info.driverId, label: record.status, - metadata: { mile: 'FIRST' }, + metadata: { + mile: 'FIRST', + bookingRef: await this.resolveBookingRef(record), + vehiclePlate: info.plate, + driverName: info.driverName, + }, }); } @@ -302,23 +328,36 @@ export class FirstMileService { await this.vehiclesService.releaseIfUnused([existing.vehicleId]); } // Audit the mile↔vehicle (re)assignment on both vehicle and driver lines. + const bookingRef = await this.resolveBookingRef(existing); if (existing.vehicleId) { + const info = await this.vehicleInfo(existing.vehicleId); await this.history.record({ eventType: FleetEventType.MILE_VEHICLE_RELEASED, vehicleId: existing.vehicleId, firstMileId: id, - driverId: await this.resolveDriverId(existing.vehicleId), - metadata: { mile: 'FIRST' }, + driverId: info.driverId, + metadata: { + mile: 'FIRST', + bookingRef, + vehiclePlate: info.plate, + driverName: info.driverName, + }, }); } if (dto.vehicleId) { + const info = await this.vehicleInfo(dto.vehicleId); await this.history.record({ eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, vehicleId: dto.vehicleId, firstMileId: id, - driverId: await this.resolveDriverId(dto.vehicleId), + driverId: info.driverId, label: updated.status, - metadata: { mile: 'FIRST' }, + metadata: { + mile: 'FIRST', + bookingRef, + vehiclePlate: info.plate, + driverName: info.driverName, + }, }); } } @@ -330,14 +369,20 @@ export class FirstMileService { if (dto.status !== undefined && dto.status !== existing.status) { const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null; + const info = await this.vehicleInfo(vehicleId); await this.history.record({ eventType: FleetEventType.MILE_STATUS_CHANGED, firstMileId: id, vehicleId, - driverId: await this.resolveDriverId(vehicleId), + driverId: info.driverId, fromValue: existing.status, toValue: dto.status, - metadata: { mile: 'FIRST' }, + metadata: { + mile: 'FIRST', + bookingRef: await this.resolveBookingRef(existing), + vehiclePlate: info.plate, + driverName: info.driverName, + }, }); } @@ -359,14 +404,20 @@ export class FirstMileService { if (status !== existing.status) { const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null; + const info = await this.vehicleInfo(vehicleId); await this.history.record({ eventType: FleetEventType.MILE_STATUS_CHANGED, firstMileId: id, vehicleId, - driverId: await this.resolveDriverId(vehicleId), + driverId: info.driverId, fromValue: existing.status, toValue: status, - metadata: { mile: 'FIRST' }, + metadata: { + mile: 'FIRST', + bookingRef: await this.resolveBookingRef(existing), + vehiclePlate: info.plate, + driverName: info.driverName, + }, }); } 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 f5c9696e6..1e952b1ce 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 @@ -46,15 +46,21 @@ export class LastMileService { private readonly history: FleetHistoryService, ) {} - /** Resolve the driver currently assigned to a vehicle, for stamping mile - * events onto that driver's timeline. Best-effort — never throws. */ - private async resolveDriverId(vehicleId?: string | null): Promise { - if (!vehicleId) return null; + /** Resolve a vehicle's driver + human labels, for stamping mile events onto + * the driver's timeline and naming the vehicle. Best-effort — never throws. */ + private async vehicleInfo( + vehicleId?: string | null, + ): Promise<{ driverId: string | null; plate: string | null; driverName: string | null }> { + if (!vehicleId) return { driverId: null, plate: null, driverName: null }; try { - const vehicle = await this.vehiclesService.findById(vehicleId); - return vehicle.assignedDriverId ?? null; + const v = await this.vehiclesService.findById(vehicleId); + return { + driverId: v.assignedDriverId ?? null, + plate: v.plateNumber ?? v.code ?? null, + driverName: v.assignedDriverName ?? null, + }; } catch { - return null; + return { driverId: null, plate: null, driverName: null }; } } @@ -176,13 +182,19 @@ export class LastMileService { }); if (dto.vehicleId) { + const info = await this.vehicleInfo(dto.vehicleId); await this.history.record({ eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, vehicleId: dto.vehicleId, lastMileId: record.id, - driverId: await this.resolveDriverId(dto.vehicleId), + driverId: info.driverId, label: record.status, - metadata: { mile: 'LAST', bookingRef: await this.resolveBookingRef(record) }, + metadata: { + mile: 'LAST', + bookingRef: await this.resolveBookingRef(record), + vehiclePlate: info.plate, + driverName: info.driverName, + }, }); } @@ -229,36 +241,54 @@ export class LastMileService { const bookingRef = await this.resolveBookingRef(existing); if (dto.vehicleId !== undefined && dto.vehicleId !== existing.vehicleId) { if (existing.vehicleId) { + const info = await this.vehicleInfo(existing.vehicleId); await this.history.record({ eventType: FleetEventType.MILE_VEHICLE_RELEASED, vehicleId: existing.vehicleId, lastMileId: id, - driverId: await this.resolveDriverId(existing.vehicleId), - metadata: { mile: 'LAST', bookingRef }, + driverId: info.driverId, + metadata: { + mile: 'LAST', + bookingRef, + vehiclePlate: info.plate, + driverName: info.driverName, + }, }); } if (dto.vehicleId) { + const info = await this.vehicleInfo(dto.vehicleId); await this.history.record({ eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, vehicleId: dto.vehicleId, lastMileId: id, - driverId: await this.resolveDriverId(dto.vehicleId), + driverId: info.driverId, label: updated.status, - metadata: { mile: 'LAST', bookingRef }, + metadata: { + mile: 'LAST', + bookingRef, + vehiclePlate: info.plate, + driverName: info.driverName, + }, }); } } if (dto.status !== undefined && dto.status !== existing.status) { const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null; + const info = await this.vehicleInfo(vehicleId); await this.history.record({ eventType: FleetEventType.MILE_STATUS_CHANGED, lastMileId: id, vehicleId, - driverId: await this.resolveDriverId(vehicleId), + driverId: info.driverId, fromValue: existing.status, toValue: dto.status, - metadata: { mile: 'LAST', bookingRef }, + metadata: { + mile: 'LAST', + bookingRef, + vehiclePlate: info.plate, + driverName: info.driverName, + }, }); } diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts index 345d4f896..25260e86f 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts @@ -51,6 +51,10 @@ export class VehiclesService { vehicleId: saved.id, driverId: saved.assignedDriverId, label: saved.assignedDriverName ?? null, + metadata: { + vehiclePlate: saved.plateNumber ?? saved.code ?? null, + driverName: saved.assignedDriverName ?? null, + }, }); } @@ -133,12 +137,14 @@ export class VehiclesService { dto.assignedDriverId !== undefined && dto.assignedDriverId !== prev.assignedDriverId ) { + const vehiclePlate = saved.plateNumber ?? saved.code ?? null; if (prev.assignedDriverId) { await this.history.record({ eventType: FleetEventType.DRIVER_UNASSIGNED, vehicleId: id, driverId: prev.assignedDriverId, label: prev.assignedDriverName ?? null, + metadata: { vehiclePlate, driverName: prev.assignedDriverName ?? null }, }); } if (saved.assignedDriverId) { @@ -147,6 +153,7 @@ export class VehiclesService { vehicleId: id, driverId: saved.assignedDriverId, label: saved.assignedDriverName ?? null, + metadata: { vehiclePlate, driverName: saved.assignedDriverName ?? null }, }); } } diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx index 0e7a0e65b..43f2bce4f 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetHistoryModal.tsx @@ -41,11 +41,24 @@ const mileLabel = (e: FleetHistoryEvent) => const arrow = (from?: string | null, to?: string | null) => `${from ?? "—"} → ${to ?? "—"}`; +const metaStr = (e: FleetHistoryEvent, key: string) => { + const v = e.metadata?.[key]; + return typeof v === "string" && v ? v : null; +}; + function describe(e: FleetHistoryEvent, entity: "driver" | "vehicle") { - const bookingRef = - typeof e.metadata?.bookingRef === "string" ? e.metadata.bookingRef : null; - const withBooking = (rest?: string) => - [bookingRef ? `Booking ${bookingRef}` : "", rest ?? ""] + const vehiclePlate = metaStr(e, "vehiclePlate"); + const driverName = metaStr(e, "driverName") ?? (e.label || null); + const bookingRef = metaStr(e, "bookingRef"); + + // Compose the detail line with whatever the current view doesn't already + // know: on a driver's timeline show which vehicle; always show the booking. + const detail = (extra?: string) => + [ + entity === "driver" && vehiclePlate ? `Vehicle ${vehiclePlate}` : "", + bookingRef ? `Booking ${bookingRef}` : "", + extra ?? "", + ] .filter(Boolean) .join(" · "); @@ -65,20 +78,31 @@ function describe(e: FleetHistoryEvent, entity: "driver" | "vehicle") { case "DRIVER_ASSIGNED": return { icon: , - title: + title: entity === "vehicle" ? "Driver assigned" : "Assigned to vehicle", + text: entity === "vehicle" - ? `Driver assigned${e.label ? `: ${e.label}` : ""}` - : "Assigned to a vehicle", - text: "", + ? driverName + ? `Driver ${driverName}` + : "" + : vehiclePlate + ? `Vehicle ${vehiclePlate}` + : "", }; case "DRIVER_UNASSIGNED": return { icon: , title: entity === "vehicle" - ? `Driver unassigned${e.label ? `: ${e.label}` : ""}` - : "Unassigned from a vehicle", - text: "", + ? "Driver unassigned" + : "Unassigned from vehicle", + text: + entity === "vehicle" + ? driverName + ? `Driver ${driverName}` + : "" + : vehiclePlate + ? `Vehicle ${vehiclePlate}` + : "", }; case "VEHICLE_STATUS_CHANGED": return { @@ -96,19 +120,19 @@ function describe(e: FleetHistoryEvent, entity: "driver" | "vehicle") { return { icon: , title: `${mileLabel(e)}: vehicle assigned`, - text: withBooking(e.label ? `Status: ${e.label}` : ""), + text: detail(e.label ? `Status: ${e.label}` : ""), }; case "MILE_VEHICLE_RELEASED": return { icon: , title: `${mileLabel(e)}: vehicle released`, - text: withBooking(), + text: detail(), }; case "MILE_STATUS_CHANGED": return { icon: , title: `${mileLabel(e)} status`, - text: withBooking(arrow(e.fromValue, e.toValue)), + text: detail(arrow(e.fromValue, e.toValue)), }; default: return { icon: , title: e.eventType, text: "" }; From 4ec38bf0289fd2c25534d4ff2efa37b0bac61dd6 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 15:56:28 +0000 Subject: [PATCH 04/16] fix --- .../modules/first-mile/first-mile.service.ts | 28 +++++++++++ .../modules/last-mile/last-mile.service.ts | 46 ++++++++++++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 43f02442a..20048c94d 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -550,6 +550,34 @@ export class FirstMileService { previousVehicleIds.filter((id) => !vehicleIds.has(id)), ); + // History: one event per vehicle actually added or removed by this + // multi-car (re)allocation, so reassignments show on every timeline. + const prevSet = new Set(previousVehicleIds); + const bookingRef = await this.resolveBookingRef(firstMile); + for (const vehicleId of vehicleIds) { + if (prevSet.has(vehicleId)) continue; + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId, + firstMileId, + driverId: info.driverId, + label: firstMile.status, + metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, + }); + } + for (const vehicleId of previousVehicleIds) { + if (vehicleIds.has(vehicleId)) continue; + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_RELEASED, + vehicleId, + firstMileId, + driverId: info.driverId, + metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, + }); + } + return { success: true, allocated: allocations.length, 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 1e952b1ce..811ddd36b 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 @@ -1,5 +1,5 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { DataSource, FindOptionsWhere } from 'typeorm'; +import { DataSource, FindOptionsWhere, In } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; @@ -348,6 +348,21 @@ export class LastMileService { throw new NotFoundException(`Last-mile record ${lastMileId} not found`); } + // Capture the vehicles currently on these containers so a reallocation can + // be diffed into assigned/released history events below. + const previousAllocations = await this.dataSource.manager.find( + LastMileContainerAllocation, + { + where: { + lastMileId, + containerId: In(allocations.map((a) => a.containerId)), + }, + }, + ); + const previousVehicleIds = previousAllocations + .map((a) => a.vehicleId) + .filter((id): id is string => Boolean(id)); + await this.dataSource.transaction(async (manager) => { for (const allocation of allocations) { await manager.delete(LastMileContainerAllocation, { @@ -364,6 +379,35 @@ export class LastMileService { } }); + // History: one event per vehicle actually added or removed by this + // multi-car (re)allocation, so reassignments show on every timeline. + const vehicleIds = new Set(allocations.map((a) => a.vehicleId)); + const prevSet = new Set(previousVehicleIds); + const bookingRef = await this.resolveBookingRef(lastMile); + for (const vehicleId of vehicleIds) { + if (prevSet.has(vehicleId)) continue; + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId, + lastMileId, + driverId: info.driverId, + label: lastMile.status, + metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, + }); + } + for (const vehicleId of previousVehicleIds) { + if (vehicleIds.has(vehicleId)) continue; + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_RELEASED, + vehicleId, + lastMileId, + driverId: info.driverId, + metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, + }); + } + return { success: true, allocated: allocations.length, From c61bb787f2e95143b532890df247d3175b482a06 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 16:01:59 +0000 Subject: [PATCH 05/16] fix --- .../modules/last-mile/last-mile.service.ts | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) 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 811ddd36b..ade7402a9 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 @@ -5,6 +5,7 @@ import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; import { SmsClientService } from '../notifications/sms-client.service'; import { VehiclesService } from '../vehicles/vehicles.service'; +import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; import { LastMile, LastMileStatus } from './entities/last-mile.entity'; @@ -182,6 +183,7 @@ export class LastMileService { }); if (dto.vehicleId) { + await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY); const info = await this.vehicleInfo(dto.vehicleId); await this.history.record({ eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, @@ -240,6 +242,14 @@ export class LastMileService { // Audit the mile↔vehicle (re)assignment on both vehicle and driver lines. const bookingRef = await this.resolveBookingRef(existing); if (dto.vehicleId !== undefined && dto.vehicleId !== existing.vehicleId) { + // Keep vehicle availability in sync: new vehicle goes BUSY, replaced one + // is freed if no other active trip still holds it. + if (dto.vehicleId) { + await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY); + } + if (existing.vehicleId) { + await this.vehiclesService.releaseIfUnused([existing.vehicleId]); + } if (existing.vehicleId) { const info = await this.vehicleInfo(existing.vehicleId); await this.history.record({ @@ -292,9 +302,32 @@ export class LastMileService { }); } + // Delivery finished — free the vehicles this trip was holding. + if (dto.status === 'DELIVERED' && existing.status !== 'DELIVERED') { + await this.releaseVehicles(updated); + } + return updated; } + /** + * Free every vehicle held by this record (direct assignment + container + * allocations), unless still in use by another active trip. + */ + private async releaseVehicles(record: LastMile): Promise { + const recordAllocations = await this.dataSource.manager.find( + LastMileContainerAllocation, + { where: { lastMileId: record.id } }, + ); + const vehicleIds = recordAllocations + .map((a) => a.vehicleId) + .filter((id): id is string => Boolean(id)); + if (record.vehicleId) { + vehicleIds.push(record.vehicleId); + } + await this.vehiclesService.releaseIfUnused(vehicleIds); + } + private async notifyDriverAssignment(vehicleId: string, record: LastMile): Promise { try { const vehicle = await this.vehiclesService.findById(vehicleId); @@ -379,9 +412,20 @@ export class LastMileService { } }); + // Keep vehicle availability in sync: newly-allocated cars go BUSY, cars no + // longer on any of these containers are freed if unused elsewhere. + const vehicleIds = new Set(allocations.map((a) => a.vehicleId)); + await Promise.all( + [...vehicleIds].map((id) => + this.vehiclesService.setAvailability(id, VehicleAvailability.BUSY), + ), + ); + await this.vehiclesService.releaseIfUnused( + previousVehicleIds.filter((id) => !vehicleIds.has(id)), + ); + // History: one event per vehicle actually added or removed by this // multi-car (re)allocation, so reassignments show on every timeline. - const vehicleIds = new Set(allocations.map((a) => a.vehicleId)); const prevSet = new Set(previousVehicleIds); const bookingRef = await this.resolveBookingRef(lastMile); for (const vehicleId of vehicleIds) { From ed388042afe5627c5a85f574cbdc48631d51ccfc Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 16:14:33 +0000 Subject: [PATCH 06/16] fix --- .../src/modules/first-mile/first-mile-invoice.service.ts | 2 +- .../src/modules/first-mile/first-mile.controller.ts | 5 +++-- .../src/modules/last-mile/last-mile-invoice.service.ts | 2 +- .../src/modules/last-mile/last-mile.controller.ts | 7 ++++--- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts index 22520aac1..f7d9ee11f 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts @@ -71,7 +71,7 @@ export class FirstMileInvoiceService { type: 'DELIVERY_FEE', companyId: fm.booking!.companyId, companyProfileId: fm.booking!.companyProfileId || '', - currency: 'ETB', + currency: fm.booking!.paymentCurrency || 'ETB', lines: [ { chargeType: 'DELIVERY', diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts index d5f53ae0c..444cbee87 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts @@ -92,6 +92,7 @@ export class FirstMileController { const record = await this.firstMileService.update(id, dto); // Auto-generate invoice if distance or payment was updated const booking = await this.bookingsService.findById(record.bookingId); + const currency = booking.paymentCurrency || "ETB"; if (dto.exactKm !== undefined || dto.exactKm != record.exactKm || dto.remainingPayment !== undefined || dto.remainingPayment !== record.remainingPayment) { await this.billingService.generateInvoice({ source: Freight.InvoiceSource.FirstMile, @@ -99,7 +100,7 @@ export class FirstMileController { type: "FIRST_MILE", companyId: booking.companyId, companyProfileId: booking.companyProfileId, - currency: "ETB", + currency, lines: [ { @@ -108,7 +109,7 @@ export class FirstMileController { quantity: 1, unitRate: record.remainingPayment, amount: record.remainingPayment, - currency: "ETB", + currency, }, ], diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts index c304a89e8..e40e94509 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts @@ -61,7 +61,7 @@ export class LastMileInvoiceService { type: 'DELIVERY_FEE', companyId: lm.booking!.companyId, companyProfileId: lm.booking!.companyProfileId || '', - currency: 'ETB', + currency: lm.booking!.paymentCurrency || 'ETB', lines: [ { chargeType: 'DELIVERY', diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index 88a96e6c2..9ad5a00bf 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -86,6 +86,7 @@ export class LastMileController { const record = await this.lastMileService.update(id, dto); // Auto-generate invoice if distance or payment was updated const booking = await this.bookingsService.findById(record.bookingId); + const currency = booking.paymentCurrency || "ETB"; if (dto.exactKm !== undefined || dto.exactKm != record.exactKm || dto.remainingPayment !== undefined || dto.remainingPayment !== record.remainingPayment) { await this.billingService.generateInvoice({ source: Freight.InvoiceSource.LastMile, @@ -93,8 +94,8 @@ export class LastMileController { type: "LAST_MILE", companyId: booking.companyId, companyProfileId: booking.companyProfileId, - currency: "ETB", - + currency, + lines: [ { chargeType: "LAST_MILE", @@ -102,7 +103,7 @@ export class LastMileController { quantity: 1, unitRate: record.remainingPayment, amount: record.remainingPayment, - currency: "ETB", + currency, }, ], From 86615863017be36b99219b0abb22778e3ce8213b Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 16:21:07 +0000 Subject: [PATCH 07/16] fix --- .../modules/first-mile/first-mile.service.ts | 38 ++++++++++++++++++- .../modules/last-mile/last-mile.service.ts | 29 +++++++++++++- .../src/pages/operations/FirstMilePage.tsx | 8 +++- .../src/pages/operations/LastMilePage.tsx | 8 +++- 4 files changed, 75 insertions(+), 8 deletions(-) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 20048c94d..1a7db810d 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -1,5 +1,5 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { FindOptionsWhere, In } from 'typeorm'; +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { FindOptionsWhere, In, IsNull, Not } from 'typeorm'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; @@ -66,6 +66,19 @@ export class FirstMileService { } } + /** A leg counts as having a vehicle if it has a direct assignment or at least + * one container allocation carrying a vehicle. Gates the IN_TRANSIT move. */ + private async hasAssignedVehicle( + recordId: string, + directVehicleId?: string | null, + ): Promise { + if (directVehicleId) return true; + const count = await this.dataSource.manager.count(FirstMileContainerAllocation, { + where: { firstMileId: recordId, vehicleId: Not(IsNull()) }, + }); + return count > 0; + } + /** Human booking reference for a first-mile record, for the history timeline. */ private async resolveBookingRef(record: FirstMile): Promise { const loaded = (record as FirstMile & { booking?: { reference?: string } }) @@ -297,6 +310,18 @@ export class FirstMileService { async update(id: string, dto: UpdateFirstMileDto): Promise { const existing = await this.findById(id); + // A leg can only go IN_TRANSIT once a vehicle is assigned (allowing a vehicle + // assigned in this same request). + if (dto.status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT') { + const vehicleId = + dto.vehicleId !== undefined ? dto.vehicleId : existing.vehicleId; + if (!(await this.hasAssignedVehicle(id, vehicleId))) { + throw new BadRequestException( + 'Assign a vehicle before marking this first-mile leg in transit', + ); + } + } + const dtoAny = dto as any; const updated = await this.firstMileRepository.update(id, { ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}), @@ -396,6 +421,15 @@ export class FirstMileService { async updateStatus(id: string, status: FirstMileStatus): Promise { const existing = await this.findById(id); + + if (status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT') { + if (!(await this.hasAssignedVehicle(id, existing.vehicleId))) { + throw new BadRequestException( + 'Assign a vehicle before marking this first-mile leg in transit', + ); + } + } + const updated = await this.firstMileRepository.update(id, { status }); if (!updated) { 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 ade7402a9..884a02aae 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 @@ -1,5 +1,5 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { DataSource, FindOptionsWhere, In } from 'typeorm'; +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { DataSource, FindOptionsWhere, In, IsNull, Not } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; @@ -65,6 +65,19 @@ export class LastMileService { } } + /** A leg counts as having a vehicle if it has a direct assignment or at least + * one container allocation carrying a vehicle. Gates the IN_TRANSIT move. */ + private async hasAssignedVehicle( + recordId: string, + directVehicleId?: string | null, + ): Promise { + if (directVehicleId) return true; + const count = await this.dataSource.manager.count(LastMileContainerAllocation, { + where: { lastMileId: recordId, vehicleId: Not(IsNull()) }, + }); + return count > 0; + } + /** Human booking reference for a last-mile record, for the history timeline. * Uses the already-loaded relation when present, else looks it up. */ private async resolveBookingRef( @@ -218,6 +231,18 @@ export class LastMileService { async update(id: string, dto: UpdateLastMileDto): Promise { const existing = await this.findById(id); + // A leg can only go IN_TRANSIT once a vehicle is assigned (allowing a vehicle + // assigned in this same request). + if (dto.status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT') { + const vehicleId = + dto.vehicleId !== undefined ? dto.vehicleId : existing.vehicleId; + if (!(await this.hasAssignedVehicle(id, vehicleId))) { + throw new BadRequestException( + 'Assign a vehicle before marking this last-mile leg in transit', + ); + } + } + const dtoAny = dto as any; const updated = await this.lastMileRepository.update(id, { ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}), diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index cec4d4351..bbd5acc0c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -879,10 +879,14 @@ const FirstMilePage = () => { } - disabled={!nextStatus} + disabled={!nextStatus || (nextStatus === "IN_TRANSIT" && !assigned)} onClick={() => handleAdvanceStatus(row.original)} > - {nextStatus ? `Mark ${STATUS_META[nextStatus].label}` : STATUS_META[row.original.status].label} + {nextStatus === "IN_TRANSIT" && !assigned + ? "Assign a vehicle first" + : nextStatus + ? `Mark ${STATUS_META[nextStatus].label}` + : STATUS_META[row.original.status].label} { } - disabled={!nextStatus} + disabled={!nextStatus || (nextStatus === "IN_TRANSIT" && !assigned)} onClick={() => handleAdvanceStatus(row.original)} > - {nextStatus ? `Mark ${STATUS_META[nextStatus].label}` : STATUS_META[row.original.status].label} + {nextStatus === "IN_TRANSIT" && !assigned + ? "Assign a vehicle first" + : nextStatus + ? `Mark ${STATUS_META[nextStatus].label}` + : STATUS_META[row.original.status].label} Date: Fri, 3 Jul 2026 16:34:06 +0000 Subject: [PATCH 08/16] fix --- .../components/operations/LastMileSteps.tsx | 93 +++++++++++++++++++ .../src/pages/operations/LastMilePage.tsx | 71 ++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 apps/edr-freight-web/backoffice/src/components/operations/LastMileSteps.tsx diff --git a/apps/edr-freight-web/backoffice/src/components/operations/LastMileSteps.tsx b/apps/edr-freight-web/backoffice/src/components/operations/LastMileSteps.tsx new file mode 100644 index 000000000..d4860a414 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/operations/LastMileSteps.tsx @@ -0,0 +1,93 @@ +import { Group, Stack, Text, Timeline, Tooltip } from "@mantine/core"; +import { Check } from "lucide-react"; + +/** One stage of the last-mile delivery workflow. */ +export interface LastMileStepState { + label: string; + done: boolean; + active: boolean; + /** Optional stamp/value shown next to the step (plate, time, distance…). */ + detail?: string | null; +} + +/** + * Compact 6-dot progress bar for a table row — filled = done, ringed = current, + * hollow = pending. Hover a dot for its label + stamp. + */ +export function LastMileStepBar({ steps }: { steps: LastMileStepState[] }) { + return ( + + {steps.map((s, i) => { + const color = s.done + ? "var(--mantine-color-green-6)" + : s.active + ? "var(--mantine-color-blue-5)" + : "var(--mantine-color-gray-4)"; + return ( + + + + ); + })} + + ); +} + +/** + * Vertical stepper for the detail view — completed steps bulleted + green, the + * current step highlighted, each showing its stamp/value when known. + */ +export function LastMileStepper({ steps }: { steps: LastMileStepState[] }) { + const activeIndex = steps.findIndex((s) => s.active); + // Timeline highlights items with index < `active`; count of done steps drives it. + const doneCount = steps.filter((s) => s.done).length; + return ( + + {steps.map((s, i) => ( + : undefined} + title={ + + {s.label} + + } + lineVariant={s.done ? "solid" : "dashed"} + > + + + {s.done ? "Done" : s.active ? "Current step" : "Pending"} + + {s.detail && ( + + {s.detail} + + )} + + + ))} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 0a40e9d08..56fbcc3fc 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -49,6 +49,7 @@ import { driversService, type Driver } from "@/services/drivers.service"; import { ratesService } from "@/services/rates.service"; import { LastMileContainerAllocationTable, type LastMileContainerRow } from "@/components/LastMileContainerAllocationTable"; import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal"; +import { LastMileStepBar, LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps"; import { api } from "@/auth/http"; const formatPrice = (amount: number) => @@ -92,6 +93,50 @@ const vehicleLabel = (record: LastMileRecord) => { const isAssigned = (record: LastMileRecord) => Boolean(record.vehicleId); +const fmtStamp = (iso?: string | null) => { + if (!iso) return null; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? null : d.toLocaleString(); +}; + +/** + * Derive the 6-step last-mile workflow state for a record. Step completion is + * read from the record + its pickup-ready (warehouse release) row: + * assign→vehicleId, arrived→release order issued, leave→releaseDate, + * in-transit/delivered→status, distance→exactKm. + */ +const computeLastMileSteps = ( + record: LastMileRecord, + releaseRow?: ImportUnloadedItem, +): LastMileStepState[] => { + const exactKm = (record as { exactKm?: number | null }).exactKm; + const flags = [ + Boolean(record.vehicleId), + Boolean(releaseRow?.releaseOrderReference), + Boolean(releaseRow?.releaseDate), + record.status === "IN_TRANSIT" || record.status === "DELIVERED", + exactKm != null, + record.status === "DELIVERED", + ]; + // Current step = earliest incomplete one. + const activeIdx = flags.findIndex((f) => !f); + const labels = ["Assign vehicle", "Truck arrived", "Truck leave", "In transit", "Add distance", "Delivered"]; + const details: (string | null)[] = [ + record.vehicle?.plateNumber ?? null, + releaseRow?.releaseOrderReference ?? null, + fmtStamp(releaseRow?.releaseDate), + null, + exactKm != null ? `${exactKm} KM` : null, + fmtStamp(releaseRow?.deliveredAt), + ]; + return labels.map((label, i) => ({ + label, + done: flags[i], + active: i === activeIdx, + detail: details[i], + })); +}; + const bookingRef = (r: LastMileRecord) => r.booking?.reference ?? r.bookingId; const customerName = (r: LastMileRecord) => r.booking?.company?.name ?? "—"; const deliveryLocation = (r: LastMileRecord) => r.booking?.lastMileDeliveryAddress ?? "—"; @@ -943,6 +988,20 @@ const LastMilePage = () => { ), }, + { + id: "progress", + header: "Progress", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => ( + + ), + }, { id: "actions", header: "Actions", @@ -1324,6 +1383,18 @@ const LastMilePage = () => { > {activeRecord && } + {activeRecord && ( + + Delivery steps + + + )} From aeb06e8a552f7acb6c009e2e9c24615ce87b072c Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 16:39:41 +0000 Subject: [PATCH 09/16] fix --- .../src/pages/operations/LastMilePage.tsx | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 56fbcc3fc..b55320e7a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -49,7 +49,7 @@ import { driversService, type Driver } from "@/services/drivers.service"; import { ratesService } from "@/services/rates.service"; import { LastMileContainerAllocationTable, type LastMileContainerRow } from "@/components/LastMileContainerAllocationTable"; import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal"; -import { LastMileStepBar, LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps"; +import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps"; import { api } from "@/auth/http"; const formatPrice = (amount: number) => @@ -988,20 +988,6 @@ const LastMilePage = () => { ), }, - { - id: "progress", - header: "Progress", - meta: { headerClassName, cellClassName }, - cell: ({ row }) => ( - - ), - }, { id: "actions", header: "Actions", From 53229f3da9818e6403111865429f37f841e549ff Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 16:42:38 +0000 Subject: [PATCH 10/16] fix --- .../backoffice/src/pages/operations/LastMilePage.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index b55320e7a..780c6f881 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -111,6 +111,7 @@ const computeLastMileSteps = ( ): LastMileStepState[] => { const exactKm = (record as { exactKm?: number | null }).exactKm; const flags = [ + record.status !== "PAYMENT_PENDING", Boolean(record.vehicleId), Boolean(releaseRow?.releaseOrderReference), Boolean(releaseRow?.releaseDate), @@ -120,8 +121,9 @@ const computeLastMileSteps = ( ]; // Current step = earliest incomplete one. const activeIdx = flags.findIndex((f) => !f); - const labels = ["Assign vehicle", "Truck arrived", "Truck leave", "In transit", "Add distance", "Delivered"]; + const labels = ["Ready to Transit", "Assign vehicle", "Truck arrived", "Truck leave", "In transit", "Add distance", "Delivered"]; const details: (string | null)[] = [ + null, record.vehicle?.plateNumber ?? null, releaseRow?.releaseOrderReference ?? null, fmtStamp(releaseRow?.releaseDate), From 482c569216270e4b37ace9d4480c6d89bdbdbc2c Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 16:43:58 +0000 Subject: [PATCH 11/16] fix --- .../backoffice/src/pages/operations/FirstMilePage.tsx | 2 +- .../backoffice/src/pages/operations/LastMilePage.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index bbd5acc0c..68e9745f1 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -958,7 +958,7 @@ const FirstMilePage = () => { }, [vehicleOptions]); return ( - + diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 780c6f881..4854ee7a7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -1095,7 +1095,7 @@ const LastMilePage = () => { }, [vehicleOptions, pickupReadyByBooking]); return ( - + From b7f6c3150e78dedcd2a2ac56883459063e39eb91 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 16:46:31 +0000 Subject: [PATCH 12/16] fix --- .../src/pages/operations/LastMilePage.tsx | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 4854ee7a7..71964c038 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -1003,6 +1003,13 @@ const LastMilePage = () => { const releaseRow = pickupReadyByBooking.get(row.original.bookingId) ?? pickupReadyByBooking.get(bookingRef(row.original)); const truckArrivalLabel = releaseRow?.releaseOrderReference ? "Truck Leaving" : "Truck Arrival"; + // Only the current step's action is active; the rest stay disabled. + // Step order: 0 Ready·1 Assign·2 Arrived·3 Leave·4 In-transit·5 Distance·6 Delivered. + const activeStep = computeLastMileSteps(row.original, releaseRow).findIndex((s) => s.active); + const canAdvance = activeStep === 0 || activeStep === 4 || activeStep === 6; + const canAssignStep = activeStep === 1; + const canTruck = activeStep === 2 || activeStep === 3; + const canDistance = activeStep === 5; return ( @@ -1014,19 +1021,17 @@ const LastMilePage = () => { } - disabled={!nextStatus || (nextStatus === "IN_TRANSIT" && !assigned)} + disabled={!nextStatus || !canAdvance} onClick={() => handleAdvanceStatus(row.original)} > - {nextStatus === "IN_TRANSIT" && !assigned - ? "Assign a vehicle first" - : nextStatus - ? `Mark ${STATUS_META[nextStatus].label}` - : STATUS_META[row.original.status].label} + {nextStatus + ? `Mark ${STATUS_META[nextStatus].label}` + : STATUS_META[row.original.status].label} } - disabled={assigned || delivered} + disabled={!canAssignStep} onClick={() => openAssign(row.original.id)} > Assign @@ -1040,7 +1045,7 @@ const LastMilePage = () => { } - disabled={!assigned} + disabled={!canTruck} onClick={() => openTruckArrival(row.original)} > {truckArrivalLabel} @@ -1054,7 +1059,7 @@ const LastMilePage = () => { } - disabled={delivered} + disabled={!canDistance} onClick={() => openDistance(row.original.id)} > Add distance From f0035a3695475fba45c44596a4c5f09bf40e6ec3 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 16:52:31 +0000 Subject: [PATCH 13/16] fix --- .../backoffice/src/pages/operations/FirstMilePage.tsx | 8 ++++++++ .../backoffice/src/pages/operations/LastMilePage.tsx | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 68e9745f1..2ec99f496 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -6,6 +6,7 @@ import { MoreHorizontal, PackageCheck, Printer, + Receipt, RefreshCw, Ruler, Trash, @@ -923,6 +924,13 @@ const FirstMilePage = () => { > Add distance + } + disabled={!(row.original.exactKm != null && row.original.exactKm > 0)} + onClick={() => openInvoice(row.original)} + > + Generate Invoice + {canPrint && ( } diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 71964c038..664a23069 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -4,6 +4,7 @@ import { Eye, MoreHorizontal, Printer, + Receipt, RefreshCw, Ruler, Trash, @@ -1064,6 +1065,13 @@ const LastMilePage = () => { > Add distance + } + disabled={!(row.original.exactKm != null && row.original.exactKm > 0)} + onClick={() => openInvoice(row.original)} + > + Generate Invoice + {canPrint && ( } From ac55a867c0314e68f0474903c9831edc0d45223c Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 16:56:23 +0000 Subject: [PATCH 14/16] fix --- .../src/pages/operations/LastMilePage.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 664a23069..dd173ba3e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -1003,14 +1003,17 @@ const LastMilePage = () => { const delivered = row.original.status === "DELIVERED"; const releaseRow = pickupReadyByBooking.get(row.original.bookingId) ?? pickupReadyByBooking.get(bookingRef(row.original)); - const truckArrivalLabel = releaseRow?.releaseOrderReference ? "Truck Leaving" : "Truck Arrival"; // Only the current step's action is active; the rest stay disabled. // Step order: 0 Ready·1 Assign·2 Arrived·3 Leave·4 In-transit·5 Distance·6 Delivered. const activeStep = computeLastMileSteps(row.original, releaseRow).findIndex((s) => s.active); const canAdvance = activeStep === 0 || activeStep === 4 || activeStep === 6; const canAssignStep = activeStep === 1; - const canTruck = activeStep === 2 || activeStep === 3; const canDistance = activeStep === 5; + // Truck arrival/leaving are independent of the step sequence — each + // driven only by its own state: arrive once assigned & not arrived, + // leave once arrived & not departed. + const canArrive = assigned && !releaseRow?.releaseOrderReference; + const canLeave = Boolean(releaseRow?.releaseOrderReference) && !releaseRow?.releaseDate; return ( @@ -1046,10 +1049,17 @@ const LastMilePage = () => { } - disabled={!canTruck} + disabled={!canArrive} onClick={() => openTruckArrival(row.original)} > - {truckArrivalLabel} + Truck Arrival + + } + disabled={!canLeave} + onClick={() => openTruckArrival(row.original)} + > + Truck Leaving Date: Fri, 3 Jul 2026 16:58:42 +0000 Subject: [PATCH 15/16] fix --- .../backoffice/src/pages/operations/LastMilePage.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index dd173ba3e..565edcc79 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -118,11 +118,12 @@ const computeLastMileSteps = ( Boolean(releaseRow?.releaseDate), record.status === "IN_TRANSIT" || record.status === "DELIVERED", exactKm != null, + exactKm != null, // Generate Invoice — auto-generated when distance is saved record.status === "DELIVERED", ]; // Current step = earliest incomplete one. const activeIdx = flags.findIndex((f) => !f); - const labels = ["Ready to Transit", "Assign vehicle", "Truck arrived", "Truck leave", "In transit", "Add distance", "Delivered"]; + const labels = ["Ready to Transit", "Assign vehicle", "Truck arrived", "Truck leave", "In transit", "Add distance", "Generate Invoice", "Delivered"]; const details: (string | null)[] = [ null, record.vehicle?.plateNumber ?? null, @@ -130,6 +131,7 @@ const computeLastMileSteps = ( fmtStamp(releaseRow?.releaseDate), null, exactKm != null ? `${exactKm} KM` : null, + exactKm != null ? "Invoice ready" : null, fmtStamp(releaseRow?.deliveredAt), ]; return labels.map((label, i) => ({ @@ -1004,9 +1006,9 @@ const LastMilePage = () => { const releaseRow = pickupReadyByBooking.get(row.original.bookingId) ?? pickupReadyByBooking.get(bookingRef(row.original)); // Only the current step's action is active; the rest stay disabled. - // Step order: 0 Ready·1 Assign·2 Arrived·3 Leave·4 In-transit·5 Distance·6 Delivered. + // Steps: 0 Ready·1 Assign·2 Arrived·3 Leave·4 In-transit·5 Distance·6 Invoice·7 Delivered. const activeStep = computeLastMileSteps(row.original, releaseRow).findIndex((s) => s.active); - const canAdvance = activeStep === 0 || activeStep === 4 || activeStep === 6; + const canAdvance = activeStep === 0 || activeStep === 4 || activeStep === 7; const canAssignStep = activeStep === 1; const canDistance = activeStep === 5; // Truck arrival/leaving are independent of the step sequence — each From aaaa38f2344f295ff6a64243ed00f4396b9b0b74 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 17:04:41 +0000 Subject: [PATCH 16/16] fix --- .../src/pages/operations/LastMilePage.tsx | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 565edcc79..2fb05b0de 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -111,12 +111,16 @@ const computeLastMileSteps = ( releaseRow?: ImportUnloadedItem, ): LastMileStepState[] => { const exactKm = (record as { exactKm?: number | null }).exactKm; + // Truck arrival/leave live in the transient warehouse pickup-ready queue and + // vanish once the item is released. So once the leg is IN_TRANSIT/DELIVERED, + // treat both as done (the truck must have arrived + left to get there). + const past = record.status === "IN_TRANSIT" || record.status === "DELIVERED"; const flags = [ record.status !== "PAYMENT_PENDING", Boolean(record.vehicleId), - Boolean(releaseRow?.releaseOrderReference), - Boolean(releaseRow?.releaseDate), - record.status === "IN_TRANSIT" || record.status === "DELIVERED", + past || Boolean(releaseRow?.releaseOrderReference), + past || Boolean(releaseRow?.releaseDate), + past, exactKm != null, exactKm != null, // Generate Invoice — auto-generated when distance is saved record.status === "DELIVERED", @@ -1005,15 +1009,22 @@ const LastMilePage = () => { const delivered = row.original.status === "DELIVERED"; const releaseRow = pickupReadyByBooking.get(row.original.bookingId) ?? pickupReadyByBooking.get(bookingRef(row.original)); - // Only the current step's action is active; the rest stay disabled. - // Steps: 0 Ready·1 Assign·2 Arrived·3 Leave·4 In-transit·5 Distance·6 Invoice·7 Delivered. - const activeStep = computeLastMileSteps(row.original, releaseRow).findIndex((s) => s.active); - const canAdvance = activeStep === 0 || activeStep === 4 || activeStep === 7; - const canAssignStep = activeStep === 1; - const canDistance = activeStep === 5; - // Truck arrival/leaving are independent of the step sequence — each - // driven only by its own state: arrive once assigned & not arrived, - // leave once arrived & not departed. + // Gate on PERSISTENT state (status/vehicle/distance), not the truck + // arrival/leave signals — those live in the warehouse queue and vanish + // once the item is released, so they can't gate the status advance. + const status = row.original.status; + const hasDistance = row.original.exactKm != null; + // Advance: PAYMENT_PENDING→Ready, READY_TO_TRANSIT→In-transit (needs a + // vehicle), IN_TRANSIT→Delivered (needs distance/invoice). + const canAdvance = + status === "PAYMENT_PENDING" || + (status === "READY_TO_TRANSIT" && assigned) || + (status === "IN_TRANSIT" && hasDistance); + const canAssignStep = !assigned && status !== "DELIVERED"; + const canDistance = status === "IN_TRANSIT"; + // Truck arrival/leaving are independent — each driven only by its own + // warehouse state: arrive once assigned & not arrived, leave once + // arrived & not departed. const canArrive = assigned && !releaseRow?.releaseOrderReference; const canLeave = Boolean(releaseRow?.releaseOrderReference) && !releaseRow?.releaseDate; return (