diff --git a/apps/edr-freight-api/src/migrations/1890000000009-AddLastMileAssignmentContainerNumber.ts b/apps/edr-freight-api/src/migrations/1890000000009-AddLastMileAssignmentContainerNumber.ts new file mode 100644 index 000000000..d8bc1c5c0 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000009-AddLastMileAssignmentContainerNumber.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Container number carried by each vehicle on a last-mile delivery. Auto-filled + * from the booking's container number when present, else entered by the operator + * at assignment time. + */ +export class AddLastMileAssignmentContainerNumber1890000000009 + implements MigrationInterface +{ + name = "AddLastMileAssignmentContainerNumber1890000000009"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.last_mile_vehicle_assignments + ADD COLUMN IF NOT EXISTS container_number varchar + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.last_mile_vehicle_assignments + DROP COLUMN IF EXISTS container_number + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1890000000010-AddLastMileAssignmentDistance.ts b/apps/edr-freight-api/src/migrations/1890000000010-AddLastMileAssignmentDistance.ts new file mode 100644 index 000000000..fc2d30552 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000010-AddLastMileAssignmentDistance.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Per-vehicle actual distance on a last-mile delivery. A booking served by + * several trucks records each truck's km; the record's total (last_mile.exact_km) + * is their sum and drives the invoice. + */ +export class AddLastMileAssignmentDistance1890000000010 + implements MigrationInterface +{ + name = "AddLastMileAssignmentDistance1890000000010"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.last_mile_vehicle_assignments + ADD COLUMN IF NOT EXISTS distance_km numeric(10,2) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.last_mile_vehicle_assignments + DROP COLUMN IF EXISTS distance_km + `); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 536129122..fdd230d33 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -307,6 +307,16 @@ export class BillingService { }); } + /** Invoices for a batch of source records (e.g. many last-mile legs), so a + * list can show which records already have an invoice without N+1 queries. */ + findBySourceIds(source: string, sourceIds: string[]): Promise { + if (!sourceIds.length) return Promise.resolve([]); + return this.invoices.findAll({ + where: { source, sourceId: In(sourceIds) }, + order: { createdAt: "DESC" }, + }); + } + /** Invoices for the signed-in customer; empty when they have no company. */ async findForUser( userId: string, diff --git a/apps/edr-freight-api/src/modules/first-mile/dto/allocate-containers.dto.ts b/apps/edr-freight-api/src/modules/first-mile/dto/allocate-containers.dto.ts deleted file mode 100644 index b750f1147..000000000 --- a/apps/edr-freight-api/src/modules/first-mile/dto/allocate-containers.dto.ts +++ /dev/null @@ -1,8 +0,0 @@ -export class FirstMileContainerAllocationDto { - containerId!: string; - vehicleId!: string; -} - -export class AllocateFirstMileContainersDto { - allocations!: FirstMileContainerAllocationDto[]; -} 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 444cbee87..928882a7f 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 @@ -17,13 +17,9 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking import { CreateFirstMileDto } from './dto/create-first-mile.dto'; import { UpdateFirstMileDto } from './dto/update-first-mile.dto'; -import { AllocateFirstMileContainersDto } from './dto/allocate-containers.dto'; import { FirstMileStatus } from './entities/first-mile.entity'; import { FirstMileService } from './first-mile.service'; import { FirstMileInvoiceService } from './first-mile-invoice.service'; -import { BillingService } from '../billing/billing.service'; -import { BookingsService } from '../bookings/bookings.service'; -import { Freight } from '@edr/types'; @ApiTags('first-mile') @ApiBearerAuth() @@ -33,8 +29,6 @@ export class FirstMileController { constructor( private readonly firstMileService: FirstMileService, private readonly firstMileInvoiceService: FirstMileInvoiceService, - private readonly billingService: BillingService, - private readonly bookingsService: BookingsService ) { } @Get() @@ -89,40 +83,17 @@ export class FirstMileController { @TrainSchedulingManage() @ApiOperation({ summary: 'Update a first-mile leg' }) async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) { - 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, - sourceId: record.id, - type: "FIRST_MILE", - companyId: booking.companyId, - companyProfileId: booking.companyProfileId, - currency, + // No invoice side-effects — invoices are generated only via the explicit + // POST :id/invoice endpoint (the "Generate Invoice" action). + return this.firstMileService.update(id, dto); + } - lines: [ - { - chargeType: "FIRST_MILE", - description: "First Mile Transportation Service", - quantity: 1, - unitRate: record.remainingPayment, - amount: record.remainingPayment, - currency, - }, - ], - - subtotalAmount: record.remainingPayment, - taxAmount: 0, // Replace if VAT/tax applies - totalAmount: record.remainingPayment, - - dueInDays: 7, - status: Freight.InvoiceStatus.Pending, - }); - await this.firstMileInvoiceService.ensureInvoiceFor(record); - } - return record; + @Post(':id/invoice') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Generate the first-mile delivery-fee invoice' }) + async generateInvoice(@Param('id', ParseUUIDPipe) id: string) { + const record = await this.firstMileService.findById(id); + return this.firstMileInvoiceService.ensureInvoiceFor(record); } @Delete(':id') @@ -132,14 +103,4 @@ export class FirstMileController { remove(@Param('id', ParseUUIDPipe) id: string) { return this.firstMileService.remove(id); } - - @Post(':firstMileId/allocate-containers') - @TrainSchedulingManage() - @ApiOperation({ summary: 'Allocate containers to vehicles for a first-mile leg' }) - allocateContainers( - @Param('firstMileId', ParseUUIDPipe) firstMileId: string, - @Body() dto: AllocateFirstMileContainersDto, - ) { - return this.firstMileService.allocateContainers(firstMileId, dto.allocations); - } } 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 1a7db810d..dba5e48c7 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 { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { FindOptionsWhere, In, IsNull, Not } from 'typeorm'; +import { FindOptionsWhere, IsNull, Not } from 'typeorm'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; @@ -13,7 +13,7 @@ import { FirstMile, FirstMileStatus } from "./entities/first-mile.entity"; import { FirstMileContainerAllocation } from "./entities/first-mile-container-allocation.entity"; import { FirstMileRepository } from "./first-mile.repository"; import { OnEvent } from "@nestjs/event-emitter"; -import { InvoiceEventPayload } from "../billing/billing.service"; +import { BillingService, InvoiceEventPayload } from "../billing/billing.service"; import { FleetHistoryService } from "../fleet-history/fleet-history.service"; import { FleetEventType } from "../fleet-history/entities/fleet-event.entity"; @@ -46,8 +46,27 @@ export class FirstMileService { private readonly driversService: DriversService, private readonly smsClient: SmsClientService, private readonly history: FleetHistoryService, + private readonly billing: BillingService, ) { } + /** Attach real invoice info so the UI shows an invoice link only when one + * exists — not merely because distance was entered. Batched (no N+1). */ + private async attachInvoices(records: FirstMile[]): Promise { + const invoices = await this.billing.findBySourceIds( + 'first_mile', + records.map((r) => r.id), + ); + const byId = new Map(); + for (const inv of invoices) { + if (!byId.has(inv.sourceId)) { + byId.set(inv.sourceId, { id: inv.id, number: inv.invoiceNumber, status: String(inv.status) }); + } + } + for (const r of records) { + (r as FirstMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? 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( @@ -191,6 +210,8 @@ export class FirstMileService { take: pageSize, }); + await this.attachInvoices(data); + return { data, meta: { @@ -236,6 +257,8 @@ export class FirstMileService { throw new NotFoundException(`First-mile record ${id} not found`); } + await this.attachInvoices([record]); + return record; } @@ -537,84 +560,18 @@ export class FirstMileService { } async remove(id: string): Promise { - await this.findById(id); + const existing = await this.findById(id); + + // Can't delete once billed. + const invoices = await this.billing.findBySourceIds('first_mile', [id]); + if (invoices.length) { + throw new BadRequestException( + 'Cannot delete a first-mile leg after its invoice is generated', + ); + } + await this.firstMileRepository.softDelete(id); - } - - async allocateContainers( - firstMileId: string, - allocations: Array<{ containerId: string; vehicleId: string }>, - ) { - const firstMile = await this.findById(firstMileId); - if (!firstMile) { - throw new NotFoundException(`First-mile record ${firstMileId} not found`); - } - - const previousAllocations = await this.dataSource.manager.find(FirstMileContainerAllocation, { - where: { - firstMileId, - 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(FirstMileContainerAllocation, { - firstMileId, - containerId: allocation.containerId, - }); - await manager.insert(FirstMileContainerAllocation, { - firstMileId, - containerId: allocation.containerId, - vehicleId: allocation.vehicleId, - containerType: "CONTAINER", - quantity: 1, - }); - } - }); - - const vehicleIds = new Set(allocations.map((a) => a.vehicleId)); - await Promise.all( - [...vehicleIds].map((vehicleId) => this.vehiclesService.setAvailability(vehicleId, 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 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, - }; + // Free the trucks it was holding (direct + container), unless still in use. + await this.releaseVehicles(existing); } } diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/allocate-containers.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/allocate-containers.dto.ts deleted file mode 100644 index 7fff8247e..000000000 --- a/apps/edr-freight-api/src/modules/last-mile/dto/allocate-containers.dto.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { IsArray, IsUUID, ValidateNested } from 'class-validator'; -import { Type } from 'class-transformer'; - -export class LastMileContainerAllocationDto { - @IsUUID() - containerId!: string; - - @IsUUID() - vehicleId!: string; -} - -export class AllocateLastMileContainersDto { - @IsArray() - @ValidateNested({ each: true }) - @Type(() => LastMileContainerAllocationDto) - allocations!: LastMileContainerAllocationDto[]; -} diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/set-distances.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/set-distances.dto.ts new file mode 100644 index 000000000..3b8b26bfe --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/dto/set-distances.dto.ts @@ -0,0 +1,24 @@ +import { IsArray, IsNumber, IsOptional, IsUUID, Min, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class VehicleDistanceInput { + @IsUUID() + vehicleId!: string; + + @IsNumber() + @Min(0) + distanceKm!: number; +} + +/** Per-vehicle actual distances for a last-mile delivery (multi-truck). */ +export class SetDistancesDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => VehicleDistanceInput) + distances!: VehicleDistanceInput[]; + + /** Recomputed remaining payment (total km × rate), from the client. */ + @IsOptional() + @IsNumber() + remainingPayment?: number; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/set-vehicles.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/set-vehicles.dto.ts index 40426b663..e07eec0b4 100644 --- a/apps/edr-freight-api/src/modules/last-mile/dto/set-vehicles.dto.ts +++ b/apps/edr-freight-api/src/modules/last-mile/dto/set-vehicles.dto.ts @@ -1,8 +1,19 @@ -import { IsArray, IsUUID } from 'class-validator'; +import { IsArray, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; -/** Replace the full set of vehicles assigned to a last-mile delivery. */ +export class LastMileVehicleInput { + @IsUUID() + vehicleId!: string; + + @IsOptional() + @IsString() + containerNumber?: string; +} + +/** Replace the full set of vehicles (with their container numbers) on a delivery. */ export class SetVehiclesDto { @IsArray() - @IsUUID('4', { each: true }) - vehicleIds!: string[]; + @ValidateNested({ each: true }) + @Type(() => LastMileVehicleInput) + vehicles!: LastMileVehicleInput[]; } diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-container-allocation.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-container-allocation.entity.ts index 8a61c73bf..187d9aea1 100644 --- a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-container-allocation.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-container-allocation.entity.ts @@ -24,7 +24,7 @@ export class LastMileContainerAllocation extends BaseEntity { @Column('uuid', { name: 'vehicle_id', nullable: true }) vehicleId?: string | null; - @Column('text') + @Column('text', { name: 'container_type' }) containerType!: string; @Column('integer', { default: 1 }) diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts index 0ba0f7d06..eee414275 100644 --- a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts @@ -27,4 +27,13 @@ export class LastMileVehicleAssignment extends BaseEntity { @ManyToOne(() => Vehicle, { nullable: false, eager: false }) @JoinColumn({ name: 'vehicle_id' }) vehicle?: Vehicle; + + /** Container this truck carries — auto-filled from the booking's container + * number when known, else entered manually at assignment time. */ + @Column({ name: 'container_number', type: 'varchar', nullable: true }) + containerNumber?: string | null; + + /** Actual distance driven by this truck (km), entered per vehicle. */ + @Column({ name: 'distance_km', type: 'numeric', precision: 10, scale: 2, nullable: true }) + distanceKm?: number | null; } 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 047d6b3e9..0b2ec1dbe 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 @@ -17,14 +17,11 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; -import { AllocateLastMileContainersDto } from './dto/allocate-containers.dto'; import { SetVehiclesDto } from './dto/set-vehicles.dto'; +import { SetDistancesDto } from './dto/set-distances.dto'; import { LastMileStatus } from './entities/last-mile.entity'; import { LastMileService } from './last-mile.service'; import { LastMileInvoiceService } from './last-mile-invoice.service'; -import { Freight } from '@edr/types'; -import { BillingService } from '../billing/billing.service'; -import { BookingsService } from '../bookings/bookings.service'; @ApiTags('last-mile') @ApiBearerAuth() @@ -34,8 +31,6 @@ export class LastMileController { constructor( private readonly lastMileService: LastMileService, private readonly lastMileInvoiceService: LastMileInvoiceService, - private readonly billingService: BillingService, - private readonly bookingsService: BookingsService ) {} @Get() @@ -84,40 +79,9 @@ export class LastMileController { @TrainSchedulingManage() @ApiOperation({ summary: 'Update a last-mile leg' }) async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) { - 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, - sourceId: record.id, - type: "LAST_MILE", - companyId: booking.companyId, - companyProfileId: booking.companyProfileId, - currency, - - lines: [ - { - chargeType: "LAST_MILE", - description: "Last Mile Transportation Service", - quantity: 1, - unitRate: record.remainingPayment, - amount: record.remainingPayment, - currency, - }, - ], - - subtotalAmount: record.remainingPayment, - taxAmount: 0, // Replace if VAT/tax applies - totalAmount: record.remainingPayment, - - dueInDays: 7, - status: Freight.InvoiceStatus.Pending, - }); - await this.lastMileInvoiceService.ensureInvoiceFor(record); - } - return record; + // No invoice side-effects here — invoices are generated only via the + // explicit POST :id/invoice endpoint (the "Generate Invoice" action). + return this.lastMileService.update(id, dto); } @Delete(':id') @@ -128,15 +92,6 @@ export class LastMileController { return this.lastMileService.remove(id); } - @Post(':id/allocate-containers') - @TrainSchedulingManage() - @ApiOperation({ summary: 'Allocate containers to vehicles' }) - async allocateContainers( - @Param('id', ParseUUIDPipe) id: string, - @Body() dto: AllocateLastMileContainersDto, - ) { - return this.lastMileService.allocateContainers(id, dto.allocations); - } @Post(':id/vehicles') @TrainSchedulingManage() @@ -145,6 +100,24 @@ export class LastMileController { @Param('id', ParseUUIDPipe) id: string, @Body() dto: SetVehiclesDto, ) { - return this.lastMileService.setVehicles(id, dto.vehicleIds); + return this.lastMileService.setVehicles(id, dto.vehicles); + } + + @Post(':id/distances') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Set per-vehicle actual distances (does not generate an invoice)' }) + async setDistances( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SetDistancesDto, + ) { + return this.lastMileService.setDistances(id, dto.distances, dto.remainingPayment); + } + + @Post(':id/invoice') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Generate the delivery-fee invoice for a last-mile leg' }) + async generateInvoice(@Param('id', ParseUUIDPipe) id: string) { + const record = await this.lastMileService.findById(id); + return this.lastMileInvoiceService.ensureInvoiceFor(record); } } 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 201692fdb..a42ce289a 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,7 +12,7 @@ import { LastMile, LastMileStatus } from './entities/last-mile.entity'; import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity'; import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity'; import { LastMileRepository } from './last-mile.repository'; -import { InvoiceEventPayload } from '../billing/billing.service'; +import { BillingService, 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'; @@ -46,8 +46,28 @@ export class LastMileService { private readonly smsClient: SmsClientService, private readonly dataSource: DataSource, private readonly history: FleetHistoryService, + private readonly billing: BillingService, ) {} + /** Attach real invoice info (number/status) to records so the UI can show an + * invoice link only when one actually exists — NOT merely because distance + * was entered. Batched to avoid N+1. */ + private async attachInvoices(records: LastMile[]): Promise { + const invoices = await this.billing.findBySourceIds( + 'last_mile', + records.map((r) => r.id), + ); + const byId = new Map(); + for (const inv of invoices) { + if (!byId.has(inv.sourceId)) { + byId.set(inv.sourceId, { id: inv.id, number: inv.invoiceNumber, status: String(inv.status) }); + } + } + for (const r of records) { + (r as LastMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? 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( @@ -159,6 +179,8 @@ export class LastMileService { take: pageSize, }); + await this.attachInvoices(data); + return { data, meta: { @@ -183,6 +205,8 @@ export class LastMileService { throw new NotFoundException(`Last-mile record ${id} not found`); } + await this.attachInvoices([record]); + return record; } @@ -219,14 +243,16 @@ export class LastMileService { return record; } - @OnEvent("lastmile.invoice.paid") + @OnEvent("last_mile.invoice.paid") async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { try { - await this.lastMileRepository.update(payload.sourceId, { paid: true } as any); - this.logger.log(`Marked last-mile record ${payload.sourceId} as paid (invoice ${payload.invoiceId})`); + // Invoice paid → the delivery is complete. Route through update() so it + // also frees the trucks + records history (same as "Mark Delivered"). + await this.update(payload.sourceId, { status: 'DELIVERED', paid: true } as unknown as UpdateLastMileDto); + this.logger.log(`Last-mile ${payload.sourceId} marked DELIVERED on invoice ${payload.invoiceId} payment`); } catch (err) { this.logger.error( - `Failed to update last-mile payment status for record ${payload.sourceId}: ${String(err)}`, + `Failed to deliver last-mile ${payload.sourceId} on payment: ${String(err)}`, ); } } @@ -339,20 +365,28 @@ export class LastMileService { } /** - * Free every vehicle held by this record (direct assignment + container - * allocations), unless still in use by another active trip. + * Free every vehicle held by this record — junction assignments, the legacy + * direct vehicle, and container allocations — unless still used 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); - } + const [assignments, recordAllocations] = await Promise.all([ + this.dataSource.manager.find(LastMileVehicleAssignment, { + where: { lastMileId: record.id }, + }), + this.dataSource.manager.find(LastMileContainerAllocation, { + where: { lastMileId: record.id }, + }), + ]); + const vehicleIds = [ + ...new Set( + [ + ...assignments.map((a) => a.vehicleId), + ...recordAllocations.map((a) => a.vehicleId), + record.vehicleId ?? null, + ].filter((id): id is string => Boolean(id)), + ), + ]; await this.vehiclesService.releaseIfUnused(vehicleIds); } @@ -362,19 +396,37 @@ export class LastMileService { * for each added/removed vehicle. The first vehicle is mirrored onto the legacy * `vehicleId` column for back-compat with single-vehicle readers. */ - async setVehicles(id: string, vehicleIds: string[]): Promise { + async setVehicles( + id: string, + inputs: Array<{ vehicleId: string; containerNumber?: string | null }>, + ): Promise { const existing = await this.findById(id); - const desired = [...new Set(vehicleIds.filter(Boolean))]; + // Dedupe by vehicleId, keeping the container number; preserve order. + const desiredMap = new Map(); + for (const inp of inputs) { + if (inp.vehicleId) desiredMap.set(inp.vehicleId, inp.containerNumber ?? null); + } + const desired = [...desiredMap.keys()]; + const desiredSet = new Set(desired); const manager = this.dataSource.manager; const current = await manager.find(LastMileVehicleAssignment, { where: { lastMileId: id }, }); - const currentIds = current.map((a) => a.vehicleId); - const currentSet = new Set(currentIds); - const desiredSet = new Set(desired); - const added = desired.filter((v) => !currentSet.has(v)); - const removed = currentIds.filter((v) => !desiredSet.has(v)); + const junctionSet = new Set(current.map((a) => a.vehicleId)); + // Fold the legacy vehicleId into the release set — a vehicle assigned via the + // old single-vehicle path has no junction row but must still be freed. + const releaseIds = [...new Set( + current.map((a) => a.vehicleId).concat(existing.vehicleId ? [existing.vehicleId] : []), + )]; + const added = desired.filter((v) => !junctionSet.has(v)); + const removed = releaseIds.filter((v) => !desiredSet.has(v)); + // Vehicles that stay but whose container number changed. + const changed = current.filter( + (a) => + desiredMap.has(a.vehicleId) && + (a.containerNumber ?? null) !== (desiredMap.get(a.vehicleId) ?? null), + ); await this.dataSource.transaction(async (tx) => { if (removed.length) { @@ -384,7 +436,18 @@ export class LastMileService { }); } for (const vehicleId of added) { - await tx.insert(LastMileVehicleAssignment, { lastMileId: id, vehicleId }); + await tx.insert(LastMileVehicleAssignment, { + lastMileId: id, + vehicleId, + containerNumber: desiredMap.get(vehicleId) ?? null, + }); + } + for (const row of changed) { + await tx.update( + LastMileVehicleAssignment, + { lastMileId: id, vehicleId: row.vehicleId }, + { containerNumber: desiredMap.get(row.vehicleId) ?? null }, + ); } }); @@ -420,6 +483,41 @@ export class LastMileService { return this.findById(id); } + /** + * Record each truck's actual distance. The delivery total (exact_km) is their + * sum and drives billing; `remainingPayment` (total km × rate) is recomputed + * client-side. Does NOT generate an invoice — that's a separate explicit step. + */ + async setDistances( + id: string, + distances: Array<{ vehicleId: string; distanceKm: number }>, + remainingPayment?: number, + ): Promise { + await this.findById(id); + + // Distances are locked once the invoice exists. + const invoices = await this.billing.findBySourceIds('last_mile', [id]); + if (invoices.length) { + throw new BadRequestException( + 'Distances cannot be changed after the invoice is generated', + ); + } + + for (const d of distances) { + await this.dataSource.manager.update( + LastMileVehicleAssignment, + { lastMileId: id, vehicleId: d.vehicleId }, + { distanceKm: d.distanceKm }, + ); + } + const total = distances.reduce((s, d) => s + (Number(d.distanceKm) || 0), 0); + await this.lastMileRepository.update(id, { + exactKm: total, + ...(remainingPayment != null ? { remainingPayment } : {}), + } as any); + return this.findById(id); + } + private async notifyDriverAssignment(vehicleId: string, record: LastMile): Promise { try { const vehicle = await this.vehiclesService.findById(vehicleId); @@ -460,93 +558,54 @@ export class LastMileService { } async remove(id: string): Promise { - await this.findById(id); - await this.lastMileRepository.softDelete(id); - } + const existing = await this.findById(id); - async allocateContainers( - lastMileId: string, - allocations: Array<{ containerId: string; vehicleId: string }>, - ) { - const lastMile = await this.findById(lastMileId); - if (!lastMile) { - throw new NotFoundException(`Last-mile record ${lastMileId} not found`); + // Can't delete once billed. + const invoices = await this.billing.findBySourceIds('last_mile', [id]); + if (invoices.length) { + throw new BadRequestException( + 'Cannot delete a last-mile delivery after its invoice is generated', + ); } - // 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)); + // Every vehicle this delivery holds — junction + legacy + container rows. + const assignments = await this.dataSource.manager.find(LastMileVehicleAssignment, { + where: { lastMileId: id }, + }); + const allocations = await this.dataSource.manager.find(LastMileContainerAllocation, { + where: { lastMileId: id }, + }); + const vehicleIds = [ + ...new Set( + [ + ...assignments.map((a) => a.vehicleId), + ...allocations.map((a) => a.vehicleId), + existing.vehicleId ?? null, + ].filter((v): v is string => Boolean(v)), + ), + ]; - await this.dataSource.transaction(async (manager) => { - for (const allocation of allocations) { - await manager.delete(LastMileContainerAllocation, { - lastMileId, - containerId: allocation.containerId, - }); - await manager.insert(LastMileContainerAllocation, { - lastMileId, - containerId: allocation.containerId, - vehicleId: allocation.vehicleId, - containerType: 'CONTAINER', - quantity: 1, + await this.lastMileRepository.softDelete(id); + if (assignments.length) { + await this.dataSource.manager.softDelete(LastMileVehicleAssignment, { lastMileId: id }); + } + + // Free every vehicle no longer held by another active trip (releaseIfUnused + // ignores this now soft-deleted record) and audit the release. + if (vehicleIds.length) { + await this.vehiclesService.releaseIfUnused(vehicleIds); + const bookingRef = await this.resolveBookingRef(existing); + for (const vehicleId of vehicleIds) { + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_RELEASED, + vehicleId, + lastMileId: id, + driverId: info.driverId, + metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, }); } - }); - - // 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 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, - }; } + } diff --git a/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx b/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx deleted file mode 100644 index 78c5160ca..000000000 --- a/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import { useState, useMemo } from "react"; -import { useMutation, useQuery } from "@tanstack/react-query"; -import { - Box, - Button, - Group, - Loader, - Select, - Stack, - Table, - Text, - Alert, -} from "@mantine/core"; -import { AlertCircle } from "lucide-react"; -import toast from "react-hot-toast"; - -import { vehiclesService } from "@/services/vehicles.service"; - -export interface ContainerAllocationRow { - id: string; - type: string; - qty: number; -} - -export interface FirstMileContainerAllocationTableProps { - firstMileId: string; - containers: ContainerAllocationRow[]; - onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise; -} - -/** - * Manual container-to-vehicle allocation table for first-mile pickups. - * Displays containers with type/qty, vehicle dropdown per row, and save action. - */ -export function FirstMileContainerAllocationTable({ - firstMileId, - containers, - onSave, -}: FirstMileContainerAllocationTableProps) { - const [allocations, setAllocations] = useState>( - () => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), - ); - - const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({ - queryKey: ["vehicles", "free"], - queryFn: () => vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }), - }); - - const vehicleOptions = useMemo( - () => - vehicles.map((v) => ({ - value: v.id, - label: `${v.plateNumber} (${v.vehicleType})`, - description: `${v.model} · ${v.manufacturer}`, - })), - [vehicles], - ); - - const saveAllocation = useMutation({ - mutationFn: async () => { - const mappings = containers - .filter((c) => allocations[c.id]) - .map((c) => ({ - containerId: c.id, - vehicleId: allocations[c.id]!, - })); - - if (mappings.length === 0) { - throw new Error("No containers allocated to vehicles"); - } - - await onSave(mappings); - }, - onSuccess: () => { - toast.success("Container allocations saved"); - setAllocations( - containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), - ); - }, - onError: (error) => { - toast.error( - error instanceof Error ? error.message : "Failed to save allocations", - ); - }, - }); - - const allocatedCount = Object.values(allocations).filter(Boolean).length; - const allAllocated = allocatedCount === containers.length; - - if (vehiclesLoading) { - return ( - - - - ); - } - - return ( - - {vehicles.length === 0 && ( - } color="yellow"> - No free vehicles available. Free up or add vehicles before allocating containers. - - )} - - - - - - Container ID - Type - Qty - Assigned Vehicle - - - - {containers.map((container) => ( - - - - {container.id} - - - {container.type} - {container.qty} - -
-
- - - - {allocatedCount} of {containers.length} containers allocated - - - -
- ); -} diff --git a/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx b/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx deleted file mode 100644 index 02b62ec4c..000000000 --- a/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import { useState, useMemo } from "react"; -import { useMutation, useQuery } from "@tanstack/react-query"; -import { - Box, - Button, - Group, - Loader, - Select, - Stack, - Table, - Text, - Alert, -} from "@mantine/core"; -import { AlertCircle } from "lucide-react"; -import toast from "react-hot-toast"; - -import { vehiclesService } from "@/services/vehicles.service"; - -export interface LastMileContainerRow { - id: string; - type: string; - qty: number; -} - -/** One vehicle (with trailer) carries at most this many containers. */ -const CONTAINERS_PER_VEHICLE = 2; - -export interface LastMileContainerAllocationTableProps { - containers: LastMileContainerRow[]; - onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise; -} - -/** - * Manual container-to-vehicle allocation table for last-mile deliveries. - * Displays containers with type/qty, vehicle dropdown per row, and save action. - */ -export function LastMileContainerAllocationTable({ - containers, - onSave, -}: LastMileContainerAllocationTableProps) { - const [allocations, setAllocations] = useState>( - () => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), - ); - - const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({ - queryKey: ["vehicles", "free"], - queryFn: () => - vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }).then((r) => r.data), - }); - - const vehicleOptions = useMemo( - () => - vehicles.map((v) => ({ - value: v.id, - label: `${v.plateNumber} (${v.vehicleType})`, - description: `${v.model} · ${v.manufacturer}`, - })), - [vehicles], - ); - - const saveAllocation = useMutation({ - mutationFn: async () => { - const mappings = containers - .filter((c) => allocations[c.id]) - .map((c) => ({ - containerId: c.id, - vehicleId: allocations[c.id]!, - })); - - if (mappings.length === 0) { - throw new Error("No containers allocated to vehicles"); - } - - await onSave(mappings); - }, - onSuccess: () => { - toast.success("Container allocations saved"); - setAllocations( - containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), - ); - }, - onError: (error) => { - toast.error( - error instanceof Error ? error.message : "Failed to save allocations", - ); - }, - }); - - const allocatedCount = Object.values(allocations).filter(Boolean).length; - - // Containers already loaded onto each vehicle, to enforce the 2-per-vehicle cap. - const loadByVehicle = useMemo(() => { - const map: Record = {}; - for (const c of containers) { - const v = allocations[c.id]; - if (v) map[v] = (map[v] ?? 0) + (c.qty || 1); - } - return map; - }, [allocations, containers]); - - /** Options for a given row: a vehicle is disabled if assigning this container - * to it would exceed its 2-container capacity. */ - const optionsForRow = (row: LastMileContainerRow) => - vehicleOptions.map((o) => { - const already = loadByVehicle[o.value] ?? 0; - const selfHere = allocations[row.id] === o.value ? row.qty || 1 : 0; - const over = already - selfHere + (row.qty || 1) > CONTAINERS_PER_VEHICLE; - return { ...o, disabled: over }; - }); - - if (vehiclesLoading) { - return ( - - - - ); - } - - return ( - - {vehicles.length === 0 && ( - } color="yellow"> - No free vehicles available. Free up or add vehicles before allocating containers. - - )} - - - - - - Container ID - Type - Qty - Assigned Vehicle - - - - {containers.map((container) => ( - - - - {container.id} - - - {container.type} - {container.qty} - -
-
- - - - {allocatedCount} of {containers.length} containers allocated · max{" "} - {CONTAINERS_PER_VEHICLE} per vehicle - - - -
- ); -} 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 5f1790f42..182ff7cac 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -13,6 +13,7 @@ import { Truck, } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "react-router-dom"; import type { ColumnDef } from "@edr/ui-common"; import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; import { @@ -33,11 +34,9 @@ import { Text, TextInput, UnstyledButton, - Alert, } from "@mantine/core"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; -import { FirstMileContainerAllocationTable } from "@/components/FirstMileContainerAllocationTable"; import { ReceiveInventoryModal } from "@/components/warehouses/ReceiveInventoryModal"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { useToast } from "@/hooks/use-toast"; @@ -50,7 +49,6 @@ import { import { bookingsService } from "@/services/bookings.service"; import { vehiclesService } from "@/services/vehicles.service"; import { ratesService } from "@/services/rates.service"; -import { api } from "@/auth/http"; import type { BookingDetail } from "@/types/booking"; const formatPrice = (amount: number) => @@ -320,6 +318,7 @@ const buildTripSlipHtml = (record: FirstMileRecord) => { const FirstMilePage = () => { const { toast } = useToast(); const qc = useQueryClient(); + const navigate = useNavigate(); const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [search, setSearch] = useState(""); @@ -343,13 +342,9 @@ const FirstMilePage = () => { const [distanceOpen, setDistanceOpen] = useState(false); const [distanceValue, setDistanceValue] = useState(""); - const [invoiceOpen, setInvoiceOpen] = useState(false); - const [invoiceRecord, setInvoiceRecord] = useState(null); const [warehouseReceiveOpen, setWarehouseReceiveOpen] = useState(false); const [warehouseReceiveRecord, setWarehouseReceiveRecord] = useState(null); - const [containerAllocationOpen, setContainerAllocationOpen] = useState(false); - const [containerAllocationFirstMileId, setContainerAllocationFirstMileId] = useState(null); const { data: listData, isLoading } = useQuery({ queryKey: QUERY_KEYS.FIRST_MILE.list(), @@ -438,6 +433,17 @@ const FirstMilePage = () => { }, }); + const generateInvoiceMutation = useMutation({ + mutationFn: (id: string) => firstMileService.generateInvoice(id), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() }); + toast({ title: "Invoice generated" }); + }, + onError: () => { + toast({ title: "Invoice generation failed", variant: "destructive" }); + }, + }); + const acceptMutation = useMutation({ mutationFn: async ({ reference, vehicleId }: { reference: string; vehicleId: string | null }) => { const res = await firstMileService.accept(reference); @@ -462,20 +468,6 @@ const FirstMilePage = () => { }, }); - const allocateMutation = useMutation({ - mutationFn: (data) => api.post(`/first-mile/${containerAllocationFirstMileId}/allocate-containers`, data), - onSuccess: () => { - toast({ title: "Containers allocated" }); - void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.byId(containerAllocationFirstMileId ?? "") }); - void qc.invalidateQueries({ queryKey: ["vehicles"] }); - setContainerAllocationOpen(false); - setContainerAllocationFirstMileId(null); - }, - onError: () => { - toast({ title: "Allocation failed", variant: "destructive" }); - }, - }); - const activeRecord = useMemo( () => records.find((r) => r.id === activeId) ?? null, [records, activeId], @@ -545,15 +537,6 @@ const FirstMilePage = () => { setDistanceValue(""); }; - const openInvoice = (record: FirstMileRecord) => { - setInvoiceRecord(record); - setInvoiceOpen(true); - }; - - const closeInvoice = () => { - setInvoiceOpen(false); - setInvoiceRecord(null); - }; const openWarehouseReceive = (record: FirstMileRecord) => { setWarehouseReceiveRecord(record); @@ -565,16 +548,6 @@ const FirstMilePage = () => { setWarehouseReceiveRecord(null); }; - const openContainerAllocation = (firstMileId: string) => { - setContainerAllocationFirstMileId(firstMileId); - setContainerAllocationOpen(true); - }; - - const closeContainerAllocation = () => { - setContainerAllocationOpen(false); - setContainerAllocationFirstMileId(null); - }; - const handleSaveDistance = () => { const distance = parseFloat(distanceValue); if (!activeId || isNaN(distance) || distance < 0) { @@ -779,35 +752,28 @@ const FirstMilePage = () => { header: "Invoice", meta: { headerClassName, cellClassName }, cell: ({ row }) => { - const hasDistance = row.original.exactKm != null && row.original.exactKm > 0; - const isPaid = (row.original as any).paid; - if (!hasDistance) { + // Only show once actually generated — not merely on distance. + const invoice = row.original.invoice; + if (!invoice) { return ; } - if (isPaid) { - return ( - - openInvoice(row.original)} - c="blue" - fw={500} - style={{ textDecoration: "underline", cursor: "pointer" }} - > - #345 - - Paid - - ); - } + const isPaid = (row.original as any).paid || invoice.status === "Paid"; return ( - openInvoice(row.original)} - c="blue" - fw={500} - style={{ textDecoration: "underline", cursor: "pointer" }} - > - #345 - + + + invoice.id + ? navigate(`/dashboard/invoices/${invoice.id}`) + : toast({ title: "Invoice link unavailable", description: "Refresh after the API restart.", variant: "destructive" }) + } + c="blue" + fw={500} + style={{ textDecoration: "underline", cursor: "pointer" }} + > + {invoice.number} + + {isPaid && Paid} + ); }, }, @@ -881,16 +847,20 @@ const FirstMilePage = () => { } + disabled={Boolean(row.original.invoice)} onClick={() => openDistance(row.original.id)} > Add distance } - disabled={!(row.original.exactKm != null && row.original.exactKm > 0)} - onClick={() => openInvoice(row.original)} + disabled={ + !(row.original.exactKm != null && row.original.exactKm > 0) || + Boolean(row.original.invoice) + } + onClick={() => generateInvoiceMutation.mutate(row.original.id)} > - Generate Invoice + {row.original.invoice ? "Invoice generated" : "Generate Invoice"} {canPrint && ( { } color="red" + disabled={Boolean(row.original.invoice)} onClick={() => { if (confirm(`Delete first-mile record ${bookingRef(row.original)}?`)) { deleteMutation.mutate(row.original.id); @@ -1295,137 +1266,6 @@ const FirstMilePage = () => { - - {/* Invoice modal */} - Invoice #345} - size="lg" - radius="lg" - centered - > - - {invoiceRecord && ( - <> - - - - EDR Freight - Invoice #345 - - - - - - - - - - - - - - - - - Post Payment - {formatPrice(invoiceRecord.remainingPayment)} - - - Advanced Payment - {formatPrice(invoiceRecord.advancedPayment)} - - - {(() => { - const postPayment = parseFloat(String(invoiceRecord.remainingPayment)); - const advancedPayment = parseFloat(String(invoiceRecord.advancedPayment)); - const difference = postPayment - advancedPayment; - - if (difference > 0) { - return ( - - Remaining to Pay - {formatPrice(difference)} - - ); - } else if (difference < 0) { - return ( - - Refund - {formatPrice(Math.abs(difference))} - - ); - } else { - return ( - - Status - Settled - - ); - } - })()} - - - - - - )} - - - - - - - {/* Container Allocation modal */} - Allocate Containers to Vehicles} - size="xl" - radius="lg" - centered - > - - {activeRecord && ( - <> - {/* Capacity guidance */} - {activeRecord.booking?.cargoType?.label === "BULK" ? ( - - - Select multiple containers per vehicle based on capacity. Each vehicle can carry multiple containers if capacity allows. - - - Capacity: TBD — TODO: add vehicle capacity_tons to vehicle API if missing - - - ) : ( - - - One vehicle per container. Each container will be assigned to a single vehicle. - - - )} - - - {/* Container table */} - { - await allocateMutation.mutateAsync(allocations); - }} - /> - - )} - - - - - ); }; 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 6fa102ff1..0267bd1ce 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -1,7 +1,6 @@ import { type ReactNode, useMemo, useState } from "react"; import { ArrowRight, - Boxes, Eye, MoreHorizontal, Plus, @@ -14,6 +13,7 @@ import { X, } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "react-router-dom"; import type { ColumnDef } from "@edr/ui-common"; import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; import { @@ -36,6 +36,7 @@ import { Stack, Text, TextInput, + Tooltip, UnstyledButton, } from "@mantine/core"; import type { ArrivalQueueItem, ImportUnloadedItem, WarehouseInventoryItem } from "@/types/warehouse"; @@ -48,15 +49,14 @@ import { LAST_MILE_STATUSES, type LastMileApiStatus, type LastMileRecord, + type LastMileVehicle, lastMileService, } from "@/services/last-mile.service"; import { vehiclesService } from "@/services/vehicles.service"; 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 { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps"; -import { api } from "@/auth/http"; const formatPrice = (amount: number) => `ETB ${amount.toLocaleString("en-US", { @@ -110,18 +110,11 @@ const requiredVehicles = (record: LastMileRecord) => { return n > 0 ? Math.ceil(n / CONTAINERS_PER_VEHICLE) : 0; }; -/** Container rows for the per-container→vehicle allocation table. */ -const allocationRowsFor = (record: LastMileRecord): LastMileContainerRow[] => - (record.booking?.bookingContainers ?? []).map((c) => ({ - id: c.id, - type: - c.containerNumber ?? - c.containerType?.code ?? - c.containerType?.label ?? - c.containerType?.name ?? - (c.containerSize || "Container"), - qty: c.quantity || 1, - })); +/** Container numbers on a booking, in line order (skips lines without one). */ +const bookingContainerNumbers = (record: LastMileRecord): string[] => + (record.booking?.bookingContainers ?? []) + .map((c) => c.containerNumber) + .filter((n): n is string => Boolean(n)); /** Container badges for a booking: the container number when known, else the * type × quantity. */ @@ -314,21 +307,46 @@ const BookingInfo = ({ record }: { record: LastMileRecord }) => { ); }; -const tripSlipRows = (record: LastMileRecord): [string, string][] => [ - ["Customer", customerName(record)], - ["Service", serviceTypeName(record)], - ["Pickup (origin yard)", originYardName(record)], - ["Destination", deliveryLocation(record)], - ["Cargo", cargoDesc(record)], - ["Advanced Payment", formatPrice(record.advancedPayment)], - ["Post Payment", formatPrice(record.remainingPayment)], - ["Vehicle", vehicleLabel(record) ?? "Unassigned"], - ["Contact", `${contactPersonName(record)} · ${contactPhone(record)}`], - ["Requested date", requestedDate(record)], - ["Est. Distance (KM)", record.estimatedKm != null ? String(record.estimatedKm) : "—"], - ["Actual Distance (KM)", record.exactKm != null ? String(record.exactKm) : "—"], - ["Status", STATUS_META[record.status].label], -]; +type TripSlipVehicle = NonNullable[number]; + +const tripSlipRows = ( + record: LastMileRecord, + vehicle?: TripSlipVehicle | null, +): [string, string][] => { + // Per-vehicle block when a specific truck is chosen (its own driver, container(s) + // and distance); else fall back to the record-level vehicle summary. + const vehicleRows: [string, string][] = vehicle + ? [ + [ + "Vehicle", + vehicle.vehicle + ? [vehicle.vehicle.code, vehicle.vehicle.plateNumber].filter(Boolean).join(" · ") + : vehicle.vehicleId, + ], + ["Driver", vehicle.vehicle?.assignedDriverName || "—"], + [ + "Container(s)", + vehicle.containerNumber || bookingContainerNumbers(record).join(", ") || "—", + ], + ["Distance (KM)", vehicle.distanceKm != null ? String(vehicle.distanceKm) : "—"], + ] + : [ + ["Vehicle", vehicleLabel(record) ?? "Unassigned"], + ["Actual Distance (KM)", record.exactKm != null ? String(record.exactKm) : "—"], + ]; + return [ + ["Customer", customerName(record)], + ["Service", serviceTypeName(record)], + ["Pickup (origin yard)", originYardName(record)], + ["Destination", deliveryLocation(record)], + ["Cargo", cargoDesc(record)], + ["Post Payment", formatPrice(record.remainingPayment)], + ...vehicleRows, + ["Contact", `${contactPersonName(record)} · ${contactPhone(record)}`], + ["Requested date", requestedDate(record)], + ["Status", STATUS_META[record.status].label], + ]; +}; const SampleStamp = () => ( @@ -386,7 +404,13 @@ const SignatureBlock = ({ title, stamp }: { title: string; stamp?: ReactNode }) ); -const TripSlipDocument = ({ record }: { record: LastMileRecord }) => ( +const TripSlipDocument = ({ + record, + vehicle, +}: { + record: LastMileRecord; + vehicle?: TripSlipVehicle | null; +}) => ( EDR Freight @@ -398,7 +422,7 @@ const TripSlipDocument = ({ record }: { record: LastMileRecord }) => ( - {tripSlipRows(record).map(([label, value]) => ( + {tripSlipRows(record, vehicle).map(([label, value]) => ( ))} @@ -413,8 +437,8 @@ const TripSlipDocument = ({ record }: { record: LastMileRecord }) => ( const escapeHtml = (v: string) => v.replace(/&/g, "&").replace(//g, ">"); -const buildTripSlipHtml = (record: LastMileRecord) => { - const rows = tripSlipRows(record) +const buildTripSlipHtml = (record: LastMileRecord, vehicle?: TripSlipVehicle | null) => { + const rows = tripSlipRows(record, vehicle) .map(([l, v]) => `${escapeHtml(l)}${escapeHtml(v)}`) .join(""); const sig = (title: string, withStamp: boolean) => ` @@ -448,7 +472,7 @@ const buildTripSlipHtml = (record: LastMileRecord) => { .ring-inner span { font-size: 9px; font-weight: 700; letter-spacing: 1px; } .ring-inner strong { font-size: 13px; font-weight: 800; } - +

EDR Freight

Last Mile Trip Slip

${escapeHtml(bookingRef(record))}${escapeHtml(requestedDate(record))}
${rows}
@@ -460,6 +484,7 @@ const buildTripSlipHtml = (record: LastMileRecord) => { const LastMilePage = () => { const { toast } = useToast(); const qc = useQueryClient(); + const navigate = useNavigate(); const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [search, setSearch] = useState(""); @@ -472,9 +497,14 @@ const LastMilePage = () => { const [detailOpen, setDetailOpen] = useState(false); const [tripSlipOpen, setTripSlipOpen] = useState(false); const [tripSlipRecord, setTripSlipRecord] = useState(null); + // Which vehicle the trip slip is for (per-truck), + the pre-print picker. + const [tripSlipVehicleId, setTripSlipVehicleId] = useState(null); + const [tripSlipSelectOpen, setTripSlipSelectOpen] = useState(false); const [activeId, setActiveId] = useState(null); - // Multi-vehicle assign: one entry per selected vehicle (null = empty picker). - const [vehicleValues, setVehicleValues] = useState<(string | null)[]>([null]); + // Multi-vehicle assign: one row per truck — vehicle + the container it carries. + const [vehicleRows, setVehicleRows] = useState< + Array<{ vehicleId: string | null; containerNumber: string }> + >([{ vehicleId: null, containerNumber: "" }]); // 2-step "Assign Mile" accept modal (arrival queue → vehicle) const [acceptOpen, setAcceptOpen] = useState(false); @@ -484,12 +514,11 @@ const LastMilePage = () => { const [arrivalSearch, setArrivalSearch] = useState(""); const [distanceOpen, setDistanceOpen] = useState(false); - const [distanceValue, setDistanceValue] = useState(""); - const [invoiceOpen, setInvoiceOpen] = useState(false); - const [invoiceRecord, setInvoiceRecord] = useState(null); + // Per-vehicle actual distance, keyed by vehicleId. + const [distanceRows, setDistanceRows] = useState>({}); + // Record pending invoice-generation confirmation (shows a summary first). + const [invoiceConfirm, setInvoiceConfirm] = useState(null); - const [allocationOpen, setAllocationOpen] = useState(false); - const [allocationContainers, setAllocationContainers] = useState([]); const [releaseItem, setReleaseItem] = useState(null); const [releaseTruckPrefill, setReleaseTruckPrefill] = useState(null); @@ -569,8 +598,13 @@ const LastMilePage = () => { }); const setVehiclesMutation = useMutation({ - mutationFn: ({ id, vehicleIds }: { id: string; vehicleIds: string[] }) => - lastMileService.setVehicles(id, vehicleIds), + mutationFn: ({ + id, + vehicles, + }: { + id: string; + vehicles: Array<{ vehicleId: string; containerNumber?: string | null }>; + }) => lastMileService.setVehicles(id, vehicles), onSuccess: () => { void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT }); void qc.invalidateQueries({ queryKey: ["vehicles"] }); @@ -580,14 +614,19 @@ const LastMilePage = () => { }, }); - const updateDistanceMutation = useMutation({ - mutationFn: ({ id, exactKm, remainingPayment }: { id: string; exactKm: number; remainingPayment?: number }) => - lastMileService.update(id, { exactKm, ...(remainingPayment != null && { remainingPayment }) }), + const distanceMutation = useMutation({ + mutationFn: ({ + id, + distances, + remainingPayment, + }: { + id: string; + distances: Array<{ vehicleId: string; distanceKm: number }>; + remainingPayment?: number; + }) => lastMileService.setDistances(id, distances, remainingPayment), onSuccess: () => { - void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.list() }); - if (activeRecord) { - toast({ title: "Distance updated", description: `${bookingRef(activeRecord)} → ${distanceValue} km` }); - } + void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT }); + toast({ title: "Distances saved", description: activeRecord ? bookingRef(activeRecord) : undefined }); closeDistance(); }, onError: () => { @@ -595,6 +634,17 @@ const LastMilePage = () => { }, }); + const generateInvoiceMutation = useMutation({ + mutationFn: (id: string) => lastMileService.generateInvoice(id), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT }); + toast({ title: "Invoice generated" }); + }, + onError: () => { + toast({ title: "Invoice generation failed", variant: "destructive" }); + }, + }); + const deleteMutation = useMutation({ mutationFn: (id: string) => lastMileService.remove(id), onSuccess: () => { @@ -606,19 +656,6 @@ const LastMilePage = () => { }, }); - const allocateMutation = useMutation({ - mutationFn: (data: Array<{ containerId: string; vehicleId: string }>) => - api.post(`/last-mile/${activeId}/allocate-containers`, { allocations: data }), - onSuccess: () => { - toast({ title: "Containers allocated", variant: "default" }); - void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT }); - void qc.invalidateQueries({ queryKey: ["vehicles"] }); - closeAllocation(); - }, - onError: () => { - toast({ title: "Allocation failed", variant: "destructive" }); - }, - }); const { data: arrivalQueueData, isLoading: arrivalLoading } = useQuery({ queryKey: ["warehouse-inventory", "arrival-queue"], @@ -645,7 +682,8 @@ const LastMilePage = () => { items.map((item) => lastMileService.accept(item.bookingReference).then((r) => r.data)), ); if (vehicleIds.length) { - await Promise.all(created.map((record) => lastMileService.setVehicles(record.id, vehicleIds))); + const vehicles = vehicleIds.map((v) => ({ vehicleId: v })); + await Promise.all(created.map((record) => lastMileService.setVehicles(record.id, vehicles))); } return created; }, @@ -693,58 +731,46 @@ const LastMilePage = () => { }; const openDistance = (id: string) => { + const rec = records.find((r) => r.id === id); + const rows: Record = {}; + for (const a of rec?.vehicleAssignments ?? []) { + rows[a.vehicleId] = a.distanceKm != null ? String(a.distanceKm) : ""; + } setActiveId(id); - setDistanceValue(""); + setDistanceRows(rows); setDistanceOpen(true); }; const closeDistance = () => { setDistanceOpen(false); setActiveId(null); - setDistanceValue(""); + setDistanceRows({}); }; - const openInvoice = (record: LastMileRecord) => { - setInvoiceRecord(record); - setInvoiceOpen(true); - }; - const closeInvoice = () => { - setInvoiceOpen(false); - setInvoiceRecord(null); - }; - - const openAllocation = (id: string, containers?: LastMileContainerRow[]) => { - setActiveId(id); - setAllocationContainers(containers ?? []); - setAllocationOpen(true); - }; - - const closeAllocation = () => { - setAllocationOpen(false); - setActiveId(null); - setAllocationContainers([]); - }; const handleSaveDistance = () => { - const distance = parseFloat(distanceValue); - if (!activeId || isNaN(distance) || distance < 0) { - toast({ title: "Invalid distance", description: "Enter a valid distance value.", variant: "destructive" }); + const distances = Object.entries(distanceRows) + .map(([vehicleId, val]) => ({ vehicleId, distanceKm: parseFloat(val) })) + .filter((d) => !Number.isNaN(d.distanceKm) && d.distanceKm >= 0); + + if (!activeId || !distances.length) { + toast({ title: "Invalid distance", description: "Enter a distance for at least one vehicle.", variant: "destructive" }); return; } + const total = distances.reduce((s, d) => s + d.distanceKm, 0); let remainingPayment: number | undefined; if (ratesData?.data) { const lastMileRate = ratesData.data.find( (r) => r.rateType === "LAST_MILE" && (r.status === "LIVE" || r.status === "DRAFT") ); if (lastMileRate) { - const rateValue = parseFloat(lastMileRate.rateValue); - remainingPayment = distance * rateValue; + remainingPayment = total * parseFloat(lastMileRate.rateValue); } } - updateDistanceMutation.mutate({ id: activeId, exactKm: distance, remainingPayment }); + distanceMutation.mutate({ id: activeId, distances, remainingPayment }); }; const activeRecord = useMemo( @@ -758,17 +784,29 @@ const LastMilePage = () => { const assignVehicleOptions = useMemo(() => { const opts = [...vehicleOptions]; const seen = new Set(opts.map((o) => o.value)); - const current = [ - ...(activeRecord?.vehicleAssignments?.map((a) => a.vehicle) ?? []), - activeRecord?.vehicle, - ]; - for (const v of current) { + const pushVehicle = (v?: LastMileVehicle | null) => { if (v && !seen.has(v.id)) { seen.add(v.id); const parts = [`${v.manufacturer} ${v.model}`, v.plateNumber]; if (v.code) parts.unshift(v.code); opts.push({ value: v.id, label: parts.join(" · ") }); } + }; + for (const a of activeRecord?.vehicleAssignments ?? []) pushVehicle(a.vehicle); + pushVehicle(activeRecord?.vehicle); + // Fallback: an assigned vehicle whose relation didn't load still needs an + // option so the reassign Select can render it as selected (not blank). + for (const a of activeRecord?.vehicleAssignments ?? []) { + if (!seen.has(a.vehicleId)) { + seen.add(a.vehicleId); + opts.push({ + value: a.vehicleId, + label: a.containerNumber ? `Assigned · ${a.containerNumber}` : "Assigned vehicle", + }); + } + } + if (activeRecord?.vehicleId && !seen.has(activeRecord.vehicleId)) { + opts.push({ value: activeRecord.vehicleId, label: "Assigned vehicle" }); } return opts; }, [vehicleOptions, activeRecord]); @@ -837,21 +875,28 @@ const LastMilePage = () => { const openAssign = (id: string | null) => { const resolved = id ?? filteredRecords.find((r) => !isAssigned(r))?.id ?? null; const rec = records.find((r) => r.id === resolved); - const existing = rec?.vehicleAssignments?.length - ? rec.vehicleAssignments.map((a) => a.vehicleId) - : rec?.vehicleId - ? [rec.vehicleId] - : []; + // Prefill each row's container number from the booking's container numbers + // (by order) when the assignment doesn't already carry one. + const nums = rec ? bookingContainerNumbers(rec) : []; + const rows = + rec?.vehicleAssignments?.length + ? rec.vehicleAssignments.map((a, i) => ({ + vehicleId: a.vehicleId, + containerNumber: a.containerNumber ?? nums[i] ?? "", + })) + : rec?.vehicleId + ? [{ vehicleId: rec.vehicleId, containerNumber: nums[0] ?? "" }] + : [{ vehicleId: null, containerNumber: nums[0] ?? "" }]; setBulkMode(false); setActiveId(resolved); - setVehicleValues(existing.length ? existing : [null]); + setVehicleRows(rows.length ? rows : [{ vehicleId: null, containerNumber: nums[0] ?? "" }]); setAssignOpen(true); }; const openBulkAssign = () => { setBulkMode(true); setActiveId(null); - setVehicleValues([null]); + setVehicleRows([{ vehicleId: null, containerNumber: "" }]); setAssignOpen(true); }; @@ -859,11 +904,16 @@ const LastMilePage = () => { setAssignOpen(false); setBulkMode(false); setActiveId(null); - setVehicleValues([null]); + setVehicleRows([{ vehicleId: null, containerNumber: "" }]); }; const handleAssign = () => { - const ids = [...new Set(vehicleValues.filter((v): v is string => Boolean(v)))]; + const seen = new Set(); + const vehicles = vehicleRows + .filter((r): r is { vehicleId: string; containerNumber: string } => Boolean(r.vehicleId)) + .filter((r) => (seen.has(r.vehicleId) ? false : seen.add(r.vehicleId))) + .map((r) => ({ vehicleId: r.vehicleId, containerNumber: r.containerNumber.trim() || null })); + const count = vehicles.length; const targetIds = bulkMode ? selectedIds : [activeId ?? filteredRecords.find((r) => !isAssigned(r))?.id].filter((id): id is string => Boolean(id)); @@ -871,14 +921,14 @@ const LastMilePage = () => { if (!targetIds.length) return; // Empty set = unassign all (setVehicles releases the removed vehicles). - Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicleIds: ids }))) + Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicles }))) .then(() => { toast({ - title: ids.length === 0 ? "Vehicles unassigned" : ids.length > 1 ? "Vehicles assigned" : "Vehicle assigned", + title: count === 0 ? "Vehicles unassigned" : count > 1 ? "Vehicles assigned" : "Vehicle assigned", description: - ids.length === 0 + count === 0 ? bulkMode ? `${targetIds.length} deliveries` : undefined - : `${bulkMode ? `${targetIds.length} deliveries · ` : ""}${ids.length} vehicle${ids.length > 1 ? "s" : ""}`, + : `${bulkMode ? `${targetIds.length} deliveries · ` : ""}${count} vehicle${count > 1 ? "s" : ""}`, }); if (bulkMode) setRowSelection({}); closeAssign(); @@ -899,7 +949,21 @@ const LastMilePage = () => { }; const handlePrintTripSlip = (record: LastMileRecord) => { + // Always open the picker so the operator chooses which truck to print. setTripSlipRecord(record); + setTripSlipVehicleId(null); + setTripSlipSelectOpen(true); + }; + + const printBookingSlip = () => { + setTripSlipVehicleId(null); + setTripSlipSelectOpen(false); + setTripSlipOpen(true); + }; + + const chooseTripSlipVehicle = (vehicleId: string) => { + setTripSlipVehicleId(vehicleId); + setTripSlipSelectOpen(false); setTripSlipOpen(true); }; @@ -927,6 +991,15 @@ const LastMilePage = () => { setReleaseItem(toReleaseInventoryItem(row)); }; + // Truck leaving the warehouse = the leg is now in transit. Advance the status + // (same as "Mark In Transit") alongside the warehouse exit-weighing flow. + const handleTruckLeaving = (record: LastMileRecord) => { + openTruckArrival(record); + if (record.status === "READY_TO_TRANSIT") { + updateMutation.mutate({ id: record.id, data: { status: "IN_TRANSIT" } }); + } + }; + const closeTruckArrival = () => { setReleaseItem(null); setReleaseTruckPrefill(null); @@ -934,6 +1007,9 @@ const LastMilePage = () => { void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT }); }; + const tripSlipVehicle = + tripSlipRecord?.vehicleAssignments?.find((a) => a.vehicleId === tripSlipVehicleId) ?? null; + const printTripSlip = () => { if (!tripSlipRecord) return; const win = window.open("", "_blank", "width=820,height=920"); @@ -941,8 +1017,17 @@ const LastMilePage = () => { toast({ title: "Pop-up blocked", description: "Allow pop-ups to print the trip slip.", variant: "destructive" }); return; } - win.document.write(buildTripSlipHtml(tripSlipRecord)); + win.document.write(buildTripSlipHtml(tripSlipRecord, tripSlipVehicle)); win.document.close(); + win.focus(); + // Explicit print after the doc paints (onload can miss with document.write). + setTimeout(() => { + try { + win.print(); + } catch { + /* window may have been closed */ + } + }, 250); }; const columns = useMemo((): ColumnDef[] => { @@ -995,17 +1080,32 @@ const LastMilePage = () => { cell: ({ row }) => { const assigns = row.original.vehicleAssignments ?? []; if (assigns.length > 1) { - const first = assigns[0]?.vehicle; - const firstLabel = first - ? [first.code, first.plateNumber].filter(Boolean).join(" · ") - : "Vehicle"; + const labelFor = (a: (typeof assigns)[number]) => { + const v = a.vehicle; + const l = v ? [v.code, v.plateNumber].filter(Boolean).join(" · ") : a.vehicleId; + return a.containerNumber ? `${l} · ${a.containerNumber}` : l; + }; return ( - - {firstLabel} - - +{assigns.length - 1} - - + + {assigns.map(labelFor).join("\n")} + + } + > + + + {assigns[0].vehicle + ? [assigns[0].vehicle.code, assigns[0].vehicle.plateNumber].filter(Boolean).join(" · ") + : assigns[0].vehicleId} + + + +{assigns.length - 1} + + + ); } return vehicleLabel(row.original) ?? Unassigned; @@ -1022,35 +1122,29 @@ const LastMilePage = () => { header: "Invoice", meta: { headerClassName, cellClassName }, cell: ({ row }) => { - const hasDistance = row.original.exactKm != null && row.original.exactKm > 0; - const isPaid = (row.original as any).paid; - if (!hasDistance) { + // Only show an invoice once it's actually been generated — NOT merely + // because distance was entered. + const invoice = row.original.invoice; + if (!invoice) { return ; } - if (isPaid) { - return ( - - openInvoice(row.original)} - c="blue" - fw={500} - style={{ textDecoration: "underline", cursor: "pointer" }} - > - #345 - - Paid - - ); - } + const isPaid = (row.original as any).paid || invoice.status === "Paid"; return ( - openInvoice(row.original)} - c="blue" - fw={500} - style={{ textDecoration: "underline", cursor: "pointer" }} - > - #345 - + + + invoice.id + ? navigate(`/dashboard/invoices/${invoice.id}`) + : toast({ title: "Invoice link unavailable", description: "Refresh after the API restart.", variant: "destructive" }) + } + c="blue" + fw={500} + style={{ textDecoration: "underline", cursor: "pointer" }} + > + {invoice.number} + + {isPaid && Paid} + ); }, }, @@ -1088,14 +1182,20 @@ const LastMilePage = () => { (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; + // Truck arrival/leaving are independent — each driven by its own + // warehouse state — but both are done once the leg is IN_TRANSIT/DELIVERED. + const pastTransit = status === "IN_TRANSIT" || status === "DELIVERED"; + const canArrive = assigned && !releaseRow?.releaseOrderReference && !pastTransit; + const canLeave = + Boolean(releaseRow?.releaseOrderReference) && !releaseRow?.releaseDate && !pastTransit; return ( - + @@ -1132,7 +1232,7 @@ const LastMilePage = () => { disabled={!assigned || delivered} onClick={() => setVehiclesMutation.mutate( - { id: row.original.id, vehicleIds: [] }, + { id: row.original.id, vehicles: [] }, { onSuccess: () => toast({ title: "Vehicles unassigned", description: bookingRef(row.original) }), @@ -1142,13 +1242,6 @@ const LastMilePage = () => { > Unassign - } - disabled={allocationRowsFor(row.original).length === 0 || delivered} - onClick={() => openAllocation(row.original.id, allocationRowsFor(row.original))} - > - Allocate to trucks - } disabled={!canArrive} @@ -1159,7 +1252,7 @@ const LastMilePage = () => { } disabled={!canLeave} - onClick={() => openTruckArrival(row.original)} + onClick={() => handleTruckLeaving(row.original)} > Truck Leaving @@ -1172,17 +1265,20 @@ const LastMilePage = () => { } - disabled={!canDistance} + disabled={!canDistance || Boolean(row.original.invoice)} onClick={() => openDistance(row.original.id)} > Add distance } - disabled={!(row.original.exactKm != null && row.original.exactKm > 0)} - onClick={() => openInvoice(row.original)} + disabled={ + !(row.original.exactKm != null && row.original.exactKm > 0) || + Boolean(row.original.invoice) + } + onClick={() => setInvoiceConfirm(row.original)} > - Generate Invoice + {row.original.invoice ? "Invoice generated" : "Generate Invoice"} {canPrint && ( { } color="red" - disabled={delivered} + disabled={delivered || Boolean(row.original.invoice)} onClick={() => { if (confirm(`Delete last-mile record ${bookingRef(row.original)}?`)) { deleteMutation.mutate(row.original.id); @@ -1467,7 +1563,7 @@ const LastMilePage = () => { {!bulkMode && activeRecord && (() => { const containers = containerCount(activeRecord); const needed = requiredVehicles(activeRecord); - const picked = vehicleValues.filter(Boolean).length; + const picked = vehicleRows.filter((r) => r.vehicleId).length; if (needed === 0) { return ( @@ -1504,34 +1600,41 @@ const LastMilePage = () => { )} - {vehicleValues.map((val, i) => ( + {vehicleRows.map((row, i) => (