From 6bf8da2934a741e53464c2064b59ab8cb80216b6 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 21:21:42 +0000 Subject: [PATCH] mile --- .../last-mile/dto/allocate-containers.dto.ts | 17 -- .../modules/last-mile/last-mile.controller.ts | 10 - .../modules/last-mile/last-mile.service.ts | 112 +++-------- .../LastMileContainerAllocationTable.tsx | 186 ------------------ .../src/pages/operations/LastMilePage.tsx | 157 +++------------ 5 files changed, 51 insertions(+), 431 deletions(-) delete mode 100644 apps/edr-freight-api/src/modules/last-mile/dto/allocate-containers.dto.ts delete mode 100644 apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx 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/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index 3ed6a8ede..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,7 +17,6 @@ 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'; @@ -93,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() 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 09db565f9..0a6aaa56e 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 @@ -243,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)}`, ); } } @@ -484,6 +486,15 @@ export class LastMileService { 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, @@ -541,6 +552,14 @@ export class LastMileService { async remove(id: string): Promise { const existing = await this.findById(id); + // 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', + ); + } + // Every vehicle this delivery holds — junction + legacy + container rows. const assignments = await this.dataSource.manager.find(LastMileVehicleAssignment, { where: { lastMileId: id }, @@ -581,89 +600,4 @@ export class LastMileService { } } - 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`); - } - - // Capture the vehicles currently on these containers so a reallocation can - // be diffed into assigned/released history events below. - const previousAllocations = await this.dataSource.manager.find( - LastMileContainerAllocation, - { - where: { - lastMileId, - containerId: In(allocations.map((a) => a.containerId)), - }, - }, - ); - const previousVehicleIds = previousAllocations - .map((a) => a.vehicleId) - .filter((id): id is string => Boolean(id)); - - await this.dataSource.transaction(async (manager) => { - for (const allocation of allocations) { - await manager.delete(LastMileContainerAllocation, { - lastMileId, - containerId: allocation.containerId, - }); - await manager.insert(LastMileContainerAllocation, { - lastMileId, - containerId: allocation.containerId, - vehicleId: allocation.vehicleId, - containerType: 'CONTAINER', - quantity: 1, - }); - } - }); - - // 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/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/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 19a8fa290..062577977 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, @@ -55,10 +54,8 @@ import { 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", { @@ -118,19 +115,6 @@ const bookingContainerNumbers = (record: LastMileRecord): string[] => .map((c) => c.containerNumber) .filter((n): n is string => Boolean(n)); -/** 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 badges for a booking: the container number when known, else the * type × quantity. */ const containerLabels = (record: LastMileRecord): string[] => { @@ -500,8 +484,6 @@ const LastMilePage = () => { // 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); @@ -639,19 +621,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"], @@ -744,17 +713,6 @@ const LastMilePage = () => { }; - const openAllocation = (id: string, containers?: LastMileContainerRow[]) => { - setActiveId(id); - setAllocationContainers(containers ?? []); - setAllocationOpen(true); - }; - - const closeAllocation = () => { - setAllocationOpen(false); - setActiveId(null); - setAllocationContainers([]); - }; const handleSaveDistance = () => { const distances = Object.entries(distanceRows) @@ -984,6 +942,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); @@ -1089,7 +1056,11 @@ const LastMilePage = () => { return ( navigate(`/dashboard/invoices/${invoice.id}`)} + onClick={() => + 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" }} @@ -1135,11 +1106,12 @@ 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 ( @@ -1189,13 +1161,6 @@ const LastMilePage = () => { > Unassign - } - disabled={allocationRowsFor(row.original).length === 0 || delivered} - onClick={() => openAllocation(row.original.id, allocationRowsFor(row.original))} - > - Allocate to trucks - } disabled={!canArrive} @@ -1206,7 +1171,7 @@ const LastMilePage = () => { } disabled={!canLeave} - onClick={() => openTruckArrival(row.original)} + onClick={() => handleTruckLeaving(row.original)} > Truck Leaving @@ -1219,17 +1184,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)} + 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); @@ -1829,75 +1797,6 @@ const LastMilePage = () => { )} - {/* Container Allocation modal */} - Allocate Containers to Vehicles} - size="xl" - radius="lg" - centered - > - - {activeRecord && ( - <> - - - - - {bookingRef(activeRecord)} - {customerName(activeRecord)} - - - Cargo Type - {activeRecord.booking?.cargoType?.label ?? activeRecord.booking?.cargoType?.name ?? "—"} - - - - - - {/* Capacity logic based on cargo type */} - {activeRecord.booking?.cargoType?.name === "BULK" ? ( - - - - Smart Capacity Allocation - - - Capacity: TBD - - TODO: add vehicle capacity_tons to vehicle API if missing - - - TODO: add container weight to booking if missing - - - - Select multiple containers per vehicle based on capacity - - - - ) : ( - - Up to 2 containers per vehicle (trailer) - - )} - - )} - - { - await allocateMutation.mutateAsync(mappings); - }} - /> - - - - - - -