From bd88424167b7a0dece7fbc959f183280725c2891 Mon Sep 17 00:00:00 2001 From: natib21 Date: Sat, 4 Jul 2026 01:47:28 +0000 Subject: [PATCH] fix --- ...00000000-AddFirstMileVehicleAssignments.ts | 42 +++ .../first-mile/dto/set-distances.dto.ts | 24 ++ .../first-mile/dto/set-vehicles.dto.ts | 19 + .../first-mile-vehicle-assignment.entity.ts | 39 +++ .../first-mile/entities/first-mile.entity.ts | 4 + .../first-mile/first-mile.controller.ts | 22 ++ .../modules/first-mile/first-mile.module.ts | 3 +- .../modules/first-mile/first-mile.service.ts | 212 ++++++++++- .../src/pages/operations/FirstMilePage.tsx | 331 ++++++++++++++---- .../src/services/first-mile.service.ts | 28 ++ 10 files changed, 637 insertions(+), 87 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1940000000000-AddFirstMileVehicleAssignments.ts create mode 100644 apps/edr-freight-api/src/modules/first-mile/dto/set-distances.dto.ts create mode 100644 apps/edr-freight-api/src/modules/first-mile/dto/set-vehicles.dto.ts create mode 100644 apps/edr-freight-api/src/modules/first-mile/entities/first-mile-vehicle-assignment.entity.ts diff --git a/apps/edr-freight-api/src/migrations/1940000000000-AddFirstMileVehicleAssignments.ts b/apps/edr-freight-api/src/migrations/1940000000000-AddFirstMileVehicleAssignments.ts new file mode 100644 index 000000000..42f2c4eba --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1940000000000-AddFirstMileVehicleAssignments.ts @@ -0,0 +1,42 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Allow more than one vehicle per first-mile pickup. Junction table joins + * first_mile ⇄ vehicles, with each truck's container number + actual distance; + * existing single vehicle_id values are backfilled as the first assignment so + * nothing is lost. Mirrors the last-mile vehicle-assignment schema. + */ +export class AddFirstMileVehicleAssignments1940000000000 implements MigrationInterface { + name = "AddFirstMileVehicleAssignments1940000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.first_mile_vehicle_assignments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + first_mile_id uuid NOT NULL REFERENCES freight.first_mile(id) ON DELETE CASCADE, + vehicle_id uuid NOT NULL REFERENCES freight.vehicles(id), + container_number varchar, + distance_km numeric(10,2), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT "UQ_FIRST_MILE_VEHICLE" UNIQUE (first_mile_id, vehicle_id) + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_FM_VEHICLE_ASSIGNMENTS_VEHICLE" + ON freight.first_mile_vehicle_assignments (vehicle_id) + `); + // Backfill: existing single-vehicle assignments become the first row + await queryRunner.query(` + INSERT INTO freight.first_mile_vehicle_assignments (first_mile_id, vehicle_id) + SELECT id, vehicle_id FROM freight.first_mile + WHERE vehicle_id IS NOT NULL AND deleted_at IS NULL + ON CONFLICT (first_mile_id, vehicle_id) DO NOTHING + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.first_mile_vehicle_assignments`); + } +} diff --git a/apps/edr-freight-api/src/modules/first-mile/dto/set-distances.dto.ts b/apps/edr-freight-api/src/modules/first-mile/dto/set-distances.dto.ts new file mode 100644 index 000000000..84247708b --- /dev/null +++ b/apps/edr-freight-api/src/modules/first-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 first-mile pickup (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/first-mile/dto/set-vehicles.dto.ts b/apps/edr-freight-api/src/modules/first-mile/dto/set-vehicles.dto.ts new file mode 100644 index 000000000..8656b2109 --- /dev/null +++ b/apps/edr-freight-api/src/modules/first-mile/dto/set-vehicles.dto.ts @@ -0,0 +1,19 @@ +import { IsArray, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class FirstMileVehicleInput { + @IsUUID() + vehicleId!: string; + + @IsOptional() + @IsString() + containerNumber?: string; +} + +/** Replace the full set of vehicles (with their container numbers) on a pickup. */ +export class SetVehiclesDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => FirstMileVehicleInput) + vehicles!: FirstMileVehicleInput[]; +} diff --git a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-vehicle-assignment.entity.ts b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-vehicle-assignment.entity.ts new file mode 100644 index 000000000..39bf51a50 --- /dev/null +++ b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-vehicle-assignment.entity.ts @@ -0,0 +1,39 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, Unique } from 'typeorm'; + +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; +import { FirstMile } from './first-mile.entity'; + +/** + * One row per vehicle assigned to a first-mile pickup. A pickup can be served + * by several vehicles at once (multi-truck bookings); the legacy + * `first_mile.vehicle_id` column keeps pointing at the first assignment for + * backward compatibility. + */ +@Entity({ name: 'first_mile_vehicle_assignments', schema: 'freight' }) +@Unique(['firstMileId', 'vehicleId']) +@Index(['vehicleId']) +export class FirstMileVehicleAssignment extends BaseEntity { + @Column({ name: 'first_mile_id', type: 'uuid' }) + firstMileId!: string; + + @ManyToOne(() => FirstMile, (fm) => fm.vehicleAssignments, { nullable: false, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'first_mile_id' }) + firstMile?: FirstMile; + + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @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/first-mile/entities/first-mile.entity.ts b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts index 27dcfef87..45a051028 100644 --- a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts @@ -4,6 +4,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm import { Booking } from '../../bookings/entities/booking.entity'; import { Vehicle } from '../../vehicles/entities/vehicle.entity'; import { FirstMileContainerAllocation } from './first-mile-container-allocation.entity'; +import { FirstMileVehicleAssignment } from './first-mile-vehicle-assignment.entity'; export const FIRST_MILE_STATUSES = [ 'PAYMENT_PENDING', @@ -61,4 +62,7 @@ export class FirstMile extends BaseEntity { { eager: false }, ) containerAllocations!: FirstMileContainerAllocation[]; + + @OneToMany(() => FirstMileVehicleAssignment, (va) => va.firstMile) + vehicleAssignments?: FirstMileVehicleAssignment[]; } 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 49175a6f3..e43fbcae8 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 @@ -18,6 +18,8 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking import { CreateFirstMileDto } from './dto/create-first-mile.dto'; import { UpdateFirstMileDto } from './dto/update-first-mile.dto'; +import { SetVehiclesDto } from './dto/set-vehicles.dto'; +import { SetDistancesDto } from './dto/set-distances.dto'; import { FirstMileStatus } from './entities/first-mile.entity'; import { FirstMileService } from './first-mile.service'; import { FirstMileInvoiceService } from './first-mile-invoice.service'; @@ -103,6 +105,26 @@ export class FirstMileController { return invoice; } + @Post(':id/vehicles') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Set the vehicles assigned to a first-mile pickup (multi-truck)' }) + async setVehicles( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SetVehiclesDto, + ) { + return this.firstMileService.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.firstMileService.setDistances(id, dto.distances, dto.remainingPayment); + } + @Delete(':id') @TrainSchedulingManage() @HttpCode(HttpStatus.NO_CONTENT) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts index 799ae14e6..51f5ccf4e 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts @@ -8,6 +8,7 @@ import { NotificationsModule } from '../notifications/notifications.module'; import { VehiclesModule } from '../vehicles/vehicles.module'; import { FirstMile } from './entities/first-mile.entity'; import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity'; +import { FirstMileVehicleAssignment } from './entities/first-mile-vehicle-assignment.entity'; import { FirstMileController } from './first-mile.controller'; import { FirstMileInvoiceService } from './first-mile-invoice.service'; import { FirstMileRepository } from './first-mile.repository'; @@ -15,7 +16,7 @@ import { FirstMileService } from './first-mile.service'; @Module({ imports: [ - TypeOrmModule.forFeature([FirstMile, FirstMileContainerAllocation]), + TypeOrmModule.forFeature([FirstMile, FirstMileContainerAllocation, FirstMileVehicleAssignment]), forwardRef(() => BillingModule), forwardRef(() => BookingsModule), VehiclesModule, 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 dba5e48c7..7cdf6398b 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, IsNull, Not } from 'typeorm'; +import { FindOptionsWhere, In, IsNull, Not } from 'typeorm'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; @@ -11,6 +11,7 @@ import { CreateFirstMileDto } from "./dto/create-first-mile.dto"; import { UpdateFirstMileDto } from "./dto/update-first-mile.dto"; import { FirstMile, FirstMileStatus } from "./entities/first-mile.entity"; import { FirstMileContainerAllocation } from "./entities/first-mile-container-allocation.entity"; +import { FirstMileVehicleAssignment } from "./entities/first-mile-vehicle-assignment.entity"; import { FirstMileRepository } from "./first-mile.repository"; import { OnEvent } from "@nestjs/event-emitter"; import { BillingService, InvoiceEventPayload } from "../billing/billing.service"; @@ -92,10 +93,15 @@ export class FirstMileService { 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; + const [junction, allocations] = await Promise.all([ + this.dataSource.manager.count(FirstMileVehicleAssignment, { + where: { firstMileId: recordId }, + }), + this.dataSource.manager.count(FirstMileContainerAllocation, { + where: { firstMileId: recordId, vehicleId: Not(IsNull()) }, + }), + ]); + return junction > 0 || allocations > 0; } /** Human booking reference for a first-mile record, for the history timeline. */ @@ -204,6 +210,7 @@ export class FirstMileService { cargoType: true, }, vehicle: true, + vehicleAssignments: { vehicle: true }, }, order: { [sortBy]: sortOrder }, skip: (page - 1) * pageSize, @@ -250,6 +257,7 @@ export class FirstMileService { cargoType: true, }, vehicle: true, + vehicleAssignments: { vehicle: true }, }, }); @@ -490,18 +498,154 @@ export class FirstMileService { * allocations), unless still in use by another active trip. */ private async releaseVehicles(record: FirstMile): Promise { - const recordAllocations = await this.dataSource.manager.find(FirstMileContainerAllocation, { - where: { firstMileId: 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(FirstMileVehicleAssignment, { + where: { firstMileId: record.id }, + }), + this.dataSource.manager.find(FirstMileContainerAllocation, { + where: { firstMileId: 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); } + /** + * Replace the full set of vehicles serving a first-mile pickup (multi-truck). + * Diffs against the current junction rows, syncing availability + audit history + * 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, + inputs: Array<{ vehicleId: string; containerNumber?: string | null }>, + ): Promise { + const existing = await this.findById(id); + // 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(FirstMileVehicleAssignment, { + where: { firstMileId: id }, + }); + 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) { + await tx.delete(FirstMileVehicleAssignment, { + firstMileId: id, + vehicleId: In(removed), + }); + } + for (const vehicleId of added) { + await tx.insert(FirstMileVehicleAssignment, { + firstMileId: id, + vehicleId, + containerNumber: desiredMap.get(vehicleId) ?? null, + }); + } + for (const row of changed) { + await tx.update( + FirstMileVehicleAssignment, + { firstMileId: id, vehicleId: row.vehicleId }, + { containerNumber: desiredMap.get(row.vehicleId) ?? null }, + ); + } + }); + + // Legacy primary vehicle = first of the set (null when cleared). + await this.firstMileRepository.update(id, { vehicleId: desired[0] ?? null } as any); + + const bookingRef = await this.resolveBookingRef(existing); + for (const vehicleId of added) { + await this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY); + void this.notifyDriverAssignment(vehicleId, existing); + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId, + firstMileId: id, + driverId: info.driverId, + label: existing.status, + metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, + }); + } + for (const vehicleId of removed) { + await this.vehiclesService.releaseIfUnused([vehicleId]); + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_RELEASED, + vehicleId, + firstMileId: id, + driverId: info.driverId, + metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, + }); + } + + return this.findById(id); + } + + /** + * Record each truck's actual distance. The pickup 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('first_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( + FirstMileVehicleAssignment, + { firstMileId: id, vehicleId: d.vehicleId }, + { distanceKm: d.distanceKm }, + ); + } + const total = distances.reduce((s, d) => s + (Number(d.distanceKm) || 0), 0); + await this.firstMileRepository.update(id, { + exactKm: total, + ...(remainingPayment != null ? { remainingPayment } : {}), + } as any); + return this.findById(id); + } + private async notifyDriverAssignment(vehicleId: string, record: FirstMile): Promise { try { const vehicle = await this.vehiclesService.findById(vehicleId); @@ -570,8 +714,44 @@ export class FirstMileService { ); } + // Every vehicle this pickup holds — junction + legacy + container rows. + const [assignments, allocations] = await Promise.all([ + this.dataSource.manager.find(FirstMileVehicleAssignment, { + where: { firstMileId: id }, + }), + this.dataSource.manager.find(FirstMileContainerAllocation, { + where: { firstMileId: 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.firstMileRepository.softDelete(id); - // Free the trucks it was holding (direct + container), unless still in use. - await this.releaseVehicles(existing); + if (assignments.length) { + await this.dataSource.manager.softDelete(FirstMileVehicleAssignment, { firstMileId: id }); + } + + // Free every vehicle no longer held by another active trip and audit 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, + firstMileId: id, + driverId: info.driverId, + metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, + }); + } + } } } 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 f7a4b82bc..7a5602751 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -5,12 +5,14 @@ import { Eye, MoreHorizontal, PackageCheck, + Plus, Printer, Receipt, RefreshCw, Ruler, Trash, Truck, + X, } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "react-router-dom"; @@ -45,6 +47,7 @@ import { FIRST_MILE_STATUSES, type FirstMileApiStatus, type FirstMileRecord, + type FirstMileVehicle, firstMileService, } from "@/services/first-mile.service"; import { bookingsService } from "@/services/bookings.service"; @@ -109,7 +112,14 @@ const vehicleLabel = (record: FirstMileRecord) => { return parts.join(" · "); }; -const isAssigned = (record: FirstMileRecord) => Boolean(record.vehicleId); +const isAssigned = (record: FirstMileRecord) => + Boolean(record.vehicleId) || Boolean(record.vehicleAssignments?.length); + +/** Container numbers on a booking, in line order (skips lines without one). */ +const bookingContainerNumbers = (record: FirstMileRecord): string[] => + (record.booking?.bookingContainers ?? []) + .map((c) => c.containerNumber) + .filter((n): n is string => Boolean(n)); // Paid = record flag set OR its invoice reached PAID. const isPaidRecord = (r: FirstMileRecord) => @@ -360,7 +370,10 @@ const FirstMilePage = () => { const [tripSlipOpen, setTripSlipOpen] = useState(false); const [tripSlipRecord, setTripSlipRecord] = useState(null); const [activeId, setActiveId] = useState(null); - const [vehicleValue, setVehicleValue] = useState(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: "" }]); const [acceptOpen, setAcceptOpen] = useState(false); const [acceptStep, setAcceptStep] = useState<1 | 2>(1); @@ -369,7 +382,8 @@ const FirstMilePage = () => { const [bookingSearch, setBookingSearch] = useState(""); const [distanceOpen, setDistanceOpen] = useState(false); - const [distanceValue, setDistanceValue] = useState(""); + // Per-vehicle actual distance, keyed by vehicleId. + const [distanceRows, setDistanceRows] = useState>({}); const [warehouseReceiveOpen, setWarehouseReceiveOpen] = useState(false); const [warehouseReceiveRecord, setWarehouseReceiveRecord] = useState(null); @@ -435,14 +449,36 @@ const FirstMilePage = () => { }, }); - const updateDistanceMutation = useMutation({ - mutationFn: ({ id, exactKm, remainingPayment }: { id: string; exactKm: number; remainingPayment?: number }) => - firstMileService.update(id, { exactKm, ...(remainingPayment != null && { remainingPayment }) }), + const setVehiclesMutation = useMutation({ + mutationFn: ({ + id, + vehicles, + }: { + id: string; + vehicles: Array<{ vehicleId: string; containerNumber?: string | null }>; + }) => firstMileService.setVehicles(id, vehicles), onSuccess: () => { void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() }); - if (activeRecord) { - toast({ title: "Distance updated", description: `${bookingRef(activeRecord)} → ${distanceValue} km` }); - } + void qc.invalidateQueries({ queryKey: ["vehicles"] }); + }, + onError: () => { + toast({ title: "Assign failed", variant: "destructive" }); + }, + }); + + const setDistancesMutation = useMutation({ + mutationFn: ({ + id, + distances, + remainingPayment, + }: { + id: string; + distances: Array<{ vehicleId: string; distanceKm: number }>; + remainingPayment?: number; + }) => firstMileService.setDistances(id, distances, remainingPayment), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() }); + toast({ title: "Distances saved", description: activeRecord ? bookingRef(activeRecord) : undefined }); closeDistance(); }, onError: () => { @@ -501,6 +537,37 @@ const FirstMilePage = () => { [records, activeId], ); + // Picker options = free vehicles PLUS the ones already on this record (which are + // BUSY, so absent from the free list) so a reassign shows its current trucks + // selected instead of blank. + const assignVehicleOptions = useMemo(() => { + const opts = [...vehicleOptions]; + const seen = new Set(opts.map((o) => o.value)); + const pushVehicle = (v?: FirstMileVehicle | 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); + 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]); + const selectedIds = useMemo( () => Object.keys(rowSelection).filter((id) => rowSelection[id]), [rowSelection], @@ -554,15 +621,20 @@ const FirstMilePage = () => { }; 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({}); }; @@ -577,24 +649,27 @@ const FirstMilePage = () => { }; 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 firstMileRate = ratesData.data.find( (r) => r.rateType === "FIRST_MILE" && (r.status === "LIVE" || r.status === "DRAFT") ); if (firstMileRate) { - const rateValue = parseFloat(firstMileRate.rateValue); - remainingPayment = distance * rateValue; + remainingPayment = total * parseFloat(firstMileRate.rateValue); } } - updateDistanceMutation.mutate({ id: activeId, exactKm: distance, remainingPayment }); + setDistancesMutation.mutate({ id: activeId, distances, remainingPayment }); }; const matchesFilter = (r: FirstMileRecord) => { @@ -651,16 +726,29 @@ const FirstMilePage = () => { const openAssign = (id: string | null) => { const resolved = id ?? filteredRecords.find((r) => !isAssigned(r))?.id ?? null; + const rec = records.find((r) => r.id === resolved); + // 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); - setVehicleValue(null); + setVehicleRows(rows.length ? rows : [{ vehicleId: null, containerNumber: nums[0] ?? "" }]); setAssignOpen(true); }; const openBulkAssign = () => { setBulkMode(true); setActiveId(null); - setVehicleValue(null); + setVehicleRows([{ vehicleId: null, containerNumber: "" }]); setAssignOpen(true); }; @@ -668,28 +756,31 @@ const FirstMilePage = () => { setAssignOpen(false); setBulkMode(false); setActiveId(null); - setVehicleValue(null); + setVehicleRows([{ vehicleId: null, containerNumber: "" }]); }; const handleAssign = () => { - if (!vehicleValue) { - toast({ title: "Select a vehicle", description: "Choose a vehicle to assign.", variant: "destructive" }); - return; - } - + 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)); if (!targetIds.length) return; - const selectedLabel = vehicleOptions.find((o) => o.value === vehicleValue)?.label ?? vehicleValue; - - Promise.all(targetIds.map((id) => updateMutation.mutateAsync({ id, data: { vehicleId: vehicleValue } }))) + // Empty set = unassign all (setVehicles releases the removed vehicles). + Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicles }))) .then(() => { toast({ - title: "Vehicle assigned", - description: bulkMode ? `${targetIds.length} pickups → ${selectedLabel}` : selectedLabel, + title: count === 0 ? "Vehicles unassigned" : count > 1 ? "Vehicles assigned" : "Vehicle assigned", + description: + count === 0 + ? bulkMode ? `${targetIds.length} pickups` : undefined + : `${bulkMode ? `${targetIds.length} pickups · ` : ""}${count} vehicle${count > 1 ? "s" : ""}`, }); if (bulkMode) setRowSelection({}); closeAssign(); @@ -772,8 +863,39 @@ const FirstMilePage = () => { id: "vehicle", header: "Vehicle", meta: { headerClassName, cellClassName }, - cell: ({ row }) => - vehicleLabel(row.original) ?? Unassigned, + cell: ({ row }) => { + const assigns = row.original.vehicleAssignments ?? []; + if (assigns.length > 1) { + 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 ( + + {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; + }, }, { id: "exactKm", @@ -1036,7 +1158,7 @@ const FirstMilePage = () => { {bulkMode ? ( - Assigning a vehicle to{" "} + Assigning vehicles to{" "} {selectedIds.length}{" "} selected {selectedIds.length === 1 ? "pickup" : "pickups"}. @@ -1046,28 +1168,80 @@ const FirstMilePage = () => { No unassigned pickups available. )} - o.value === row.vehicleId || !vehicleRows.some((r) => r.vehicleId === o.value), + )} + value={row.vehicleId} + onChange={(v) => + setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x))) + } + searchable + clearable + disabled={assignVehicleOptions.length === 0} + /> + { + const value = e.currentTarget.value; + setVehicleRows((prev) => + prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value } : x)), + ); + }} + /> + {vehicleRows.length > 1 && ( + setVehicleRows((prev) => prev.filter((_, idx) => idx !== i))} + > + + + )} + + ))} + + @@ -1274,34 +1448,51 @@ const FirstMilePage = () => { {activeRecord && ( - + {bookingRef(activeRecord)} - - Customer - {customerName(activeRecord)} - - - Est. Distance (KM) - {activeRecord.estimatedKm ?? "—"} - - + Est. {activeRecord.estimatedKm ?? "—"} km + )} - setDistanceValue(String(v ?? ""))} - min={0} - step={0.1} - decimalScale={2} - /> + {(activeRecord?.vehicleAssignments?.length ?? 0) === 0 ? ( + Assign a vehicle before entering distance. + ) : ( + + {activeRecord!.vehicleAssignments!.map((a) => { + const v = a.vehicle; + const label = v ? [v.code, v.plateNumber].filter(Boolean).join(" · ") : a.vehicleId; + return ( + + setDistanceRows((prev) => ({ ...prev, [a.vehicleId]: String(val ?? "") })) + } + min={0} + step={0.1} + decimalScale={2} + /> + ); + })} + + Total + + {Object.values(distanceRows) + .reduce((s, val) => s + (parseFloat(val) || 0), 0) + .toFixed(2)}{" "} + km + + + + )} diff --git a/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts b/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts index a1c2ec8e2..ab5dfb7d9 100644 --- a/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts @@ -23,6 +23,14 @@ export interface FirstMileBooking { originYard?: { id: string; label?: string } | null; destinationYard?: { id: string; label?: string } | null; cargoType?: { id: string; label?: string } | null; + /** Container lines — total container count drives how many trucks are needed. */ + bookingContainers?: Array<{ + id: string; + quantity: number; + containerNumber?: string | null; + containerSize?: string | null; + containerType?: { id: string; name?: string; label?: string; code?: string } | null; + }>; } export interface FirstMileVehicle { @@ -30,9 +38,12 @@ export interface FirstMileVehicle { plateNumber: string; manufacturer: string; model: string; + vehicleType?: string | null; code?: string | null; powerPlateNo?: string | null; trailerPlateNo?: string | null; + assignedDriverId?: string | null; + assignedDriverName?: string | null; } export interface FirstMileRecord { @@ -46,6 +57,14 @@ export interface FirstMileRecord { vehicleId?: string | null; booking?: FirstMileBooking | null; vehicle?: FirstMileVehicle | null; + /** Full set of vehicles serving this pickup (multi-truck). */ + vehicleAssignments?: Array<{ + id: string; + vehicleId: string; + containerNumber?: string | null; + distanceKm?: number | null; + vehicle?: FirstMileVehicle | null; + }>; /** Present only when an invoice has actually been generated (not on distance). */ invoice?: { id: string; number: string; status: string } | null; createdAt: string; @@ -69,6 +88,15 @@ export const firstMileService = { api.post(FM.ACCEPT(bookingReference)), remove: (id: string) => api.delete(FM.BY_ID(id)), + setVehicles: ( + id: string, + vehicles: Array<{ vehicleId: string; containerNumber?: string | null }>, + ) => api.post(`${FM.BASE}/${id}/vehicles`, { vehicles }), + setDistances: ( + id: string, + distances: Array<{ vehicleId: string; distanceKm: number }>, + remainingPayment?: number, + ) => api.post(`${FM.BASE}/${id}/distances`, { distances, remainingPayment }), generateInvoice: (id: string) => api.post<{ id: string; invoiceNumber?: string } | null>(`${FM.BASE}/${id}/invoice`), };