From 77dc14f6a597380a01205700ee7e24c56f325768 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 20:00:41 +0000 Subject: [PATCH 01/13] mile --- ...09-AddLastMileAssignmentContainerNumber.ts | 26 ++++ .../modules/last-mile/dto/set-vehicles.dto.ts | 19 ++- .../last-mile-vehicle-assignment.entity.ts | 5 + .../modules/last-mile/last-mile.controller.ts | 2 +- .../modules/last-mile/last-mile.service.ts | 45 +++++-- .../src/pages/operations/LastMilePage.tsx | 113 ++++++++++++------ .../src/services/last-mile.service.ts | 13 +- 7 files changed, 171 insertions(+), 52 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1890000000009-AddLastMileAssignmentContainerNumber.ts 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/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-vehicle-assignment.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts index 0ba0f7d06..8abbb0805 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,9 @@ 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; } 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..7dcc29c1b 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 @@ -145,6 +145,6 @@ 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); } } 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..3eb5ca26e 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 @@ -362,19 +362,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 +402,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 }, + ); } }); 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..a66dda5de 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -110,6 +110,12 @@ const requiredVehicles = (record: LastMileRecord) => { return n > 0 ? Math.ceil(n / CONTAINERS_PER_VEHICLE) : 0; }; +/** 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 rows for the per-container→vehicle allocation table. */ const allocationRowsFor = (record: LastMileRecord): LastMileContainerRow[] => (record.booking?.bookingContainers ?? []).map((c) => ({ @@ -473,8 +479,10 @@ const LastMilePage = () => { const [tripSlipOpen, setTripSlipOpen] = useState(false); const [tripSlipRecord, setTripSlipRecord] = useState(null); 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); @@ -569,8 +577,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"] }); @@ -645,7 +658,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; }, @@ -837,21 +851,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 +880,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 +897,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(); @@ -1132,7 +1158,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) }), @@ -1467,7 +1493,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 +1530,40 @@ const LastMilePage = () => { )} - {vehicleValues.map((val, i) => ( + {vehicleRows.map((row, i) => ( - setAllocations((prev) => ({ - ...prev, - [container.id]: value, - })) - } - searchable - clearable - disabled={vehicles.length === 0} - style={{ minWidth: 200 }} - /> - - - ))} - - - - - - - {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); - }} - /> - - - - - - - Date: Fri, 3 Jul 2026 21:25:49 +0000 Subject: [PATCH 10/13] mile --- .../last-mile/entities/last-mile-container-allocation.entity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 }) From 9422e5588e8259023b0809f7bdd27417a2c07954 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 21:45:54 +0000 Subject: [PATCH 11/13] mile --- .../modules/last-mile/last-mile.service.ts | 32 ++-- .../src/pages/operations/LastMilePage.tsx | 175 ++++++++++++++---- 2 files changed, 158 insertions(+), 49 deletions(-) diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 0a6aaa56e..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 @@ -365,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); } 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 062577977..c3c59ca58 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -36,6 +36,7 @@ import { Stack, Text, TextInput, + Tooltip, UnstyledButton, } from "@mantine/core"; import type { ArrivalQueueItem, ImportUnloadedItem, WarehouseInventoryItem } from "@/types/warehouse"; @@ -306,21 +307,43 @@ 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 || "—"], + ["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 = () => ( @@ -378,7 +401,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 @@ -390,7 +419,7 @@ const TripSlipDocument = ({ record }: { record: LastMileRecord }) => ( - {tripSlipRows(record).map(([label, value]) => ( + {tripSlipRows(record, vehicle).map(([label, value]) => ( ))} @@ -405,8 +434,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) => ` @@ -465,6 +494,9 @@ 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 row per truck — vehicle + the container it carries. const [vehicleRows, setVehicleRows] = useState< @@ -915,6 +947,20 @@ const LastMilePage = () => { const handlePrintTripSlip = (record: LastMileRecord) => { setTripSlipRecord(record); + const assigns = record.vehicleAssignments ?? []; + if (assigns.length > 1) { + // Multiple trucks → let the operator pick which one to print. + setTripSlipVehicleId(null); + setTripSlipSelectOpen(true); + } else { + setTripSlipVehicleId(assigns[0]?.vehicleId ?? null); + setTripSlipOpen(true); + } + }; + + const chooseTripSlipVehicle = (vehicleId: string) => { + setTripSlipVehicleId(vehicleId); + setTripSlipSelectOpen(false); setTripSlipOpen(true); }; @@ -958,6 +1004,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"); @@ -965,7 +1014,7 @@ 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(); }; @@ -1019,17 +1068,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; @@ -1114,7 +1178,12 @@ const LastMilePage = () => { Boolean(releaseRow?.releaseOrderReference) && !releaseRow?.releaseDate && !pastTransit; return ( - + @@ -1661,7 +1730,7 @@ const LastMilePage = () => { centered > - {tripSlipRecord && } + {tripSlipRecord && } @@ -1670,6 +1739,42 @@ const LastMilePage = () => { + {/* Trip slip — pick a vehicle (multi-truck) */} + setTripSlipSelectOpen(false)} + title={Print trip slip — select vehicle} + radius="lg" + centered + > + + + {tripSlipRecord ? bookingRef(tripSlipRecord) : ""} has multiple trucks — choose one. + + {(tripSlipRecord?.vehicleAssignments ?? []).map((a) => { + const v = a.vehicle; + const label = v ? [v.code, v.plateNumber].filter(Boolean).join(" · ") : a.vehicleId; + return ( + + ); + })} + + + {/* Add Actual Distance modal */} { loading={generateInvoiceMutation.isPending} onClick={() => generateInvoiceMutation.mutate(invoiceConfirm.id, { - onSuccess: (res) => { - setInvoiceConfirm(null); - const invoiceId = res?.data?.id; - if (invoiceId) navigate(`/dashboard/invoices/${invoiceId}`); - }, + onSuccess: () => setInvoiceConfirm(null), }) } > From cad4b84b8c8c3f4905103c0e16ff26031756899f Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 21:57:53 +0000 Subject: [PATCH 12/13] mile --- .../src/pages/operations/LastMilePage.tsx | 43 +++++++++++++------ 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index c3c59ca58..f3f290304 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -324,7 +324,10 @@ const tripSlipRows = ( : vehicle.vehicleId, ], ["Driver", vehicle.vehicle?.assignedDriverName || "—"], - ["Container(s)", vehicle.containerNumber || "—"], + [ + "Container(s)", + vehicle.containerNumber || bookingContainerNumbers(record).join(", ") || "—", + ], ["Distance (KM)", vehicle.distanceKm != null ? String(vehicle.distanceKm) : "—"], ] : [ @@ -469,7 +472,7 @@ const buildTripSlipHtml = (record: LastMileRecord, vehicle?: TripSlipVehicle | n .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}
@@ -946,16 +949,16 @@ const LastMilePage = () => { }; const handlePrintTripSlip = (record: LastMileRecord) => { + // Always open the picker so the operator chooses which truck to print. setTripSlipRecord(record); - const assigns = record.vehicleAssignments ?? []; - if (assigns.length > 1) { - // Multiple trucks → let the operator pick which one to print. - setTripSlipVehicleId(null); - setTripSlipSelectOpen(true); - } else { - setTripSlipVehicleId(assigns[0]?.vehicleId ?? null); - setTripSlipOpen(true); - } + setTripSlipVehicleId(null); + setTripSlipSelectOpen(true); + }; + + const printBookingSlip = () => { + setTripSlipVehicleId(null); + setTripSlipSelectOpen(false); + setTripSlipOpen(true); }; const chooseTripSlipVehicle = (vehicleId: string) => { @@ -1016,6 +1019,15 @@ const LastMilePage = () => { } 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[] => { @@ -1749,7 +1761,8 @@ const LastMilePage = () => { > - {tripSlipRecord ? bookingRef(tripSlipRecord) : ""} has multiple trucks — choose one. + {tripSlipRecord ? bookingRef(tripSlipRecord) : ""} — choose a truck to print its slip + (vehicle, driver, container). {(tripSlipRecord?.vehicleAssignments ?? []).map((a) => { const v = a.vehicle; @@ -1772,6 +1785,12 @@ const LastMilePage = () => { ); })} + {(tripSlipRecord?.vehicleAssignments?.length ?? 0) === 0 && ( + No vehicles assigned yet. + )} +
From 1d8f09583167f8c742da585605046e58b8268f35 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 3 Jul 2026 22:43:45 +0000 Subject: [PATCH 13/13] fix --- .../first-mile/dto/allocate-containers.dto.ts | 8 - .../first-mile/first-mile.controller.ts | 59 +---- .../modules/first-mile/first-mile.service.ts | 117 +++------ .../FirstMileContainerAllocationTable.tsx | 164 ------------ .../src/pages/operations/FirstMilePage.tsx | 240 +++--------------- .../src/pages/operations/LastMilePage.tsx | 44 ++-- .../src/services/first-mile.service.ts | 4 + .../backoffice/src/types/booking.ts | 2 +- 8 files changed, 120 insertions(+), 518 deletions(-) delete mode 100644 apps/edr-freight-api/src/modules/first-mile/dto/allocate-containers.dto.ts delete mode 100644 apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx 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-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/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 f3f290304..0267bd1ce 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -1761,34 +1761,46 @@ const LastMilePage = () => { > - {tripSlipRecord ? bookingRef(tripSlipRecord) : ""} — choose a truck to print its slip - (vehicle, driver, container). + {tripSlipRecord ? bookingRef(tripSlipRecord) : ""} — pick a truck to print its slip. {(tripSlipRecord?.vehicleAssignments ?? []).map((a) => { const v = a.vehicle; const label = v ? [v.code, v.plateNumber].filter(Boolean).join(" · ") : a.vehicleId; return ( - + + + + + {label} + + + Driver: {v?.assignedDriverName || "—"} + + + Container: {a.containerNumber || "—"} + + + + + + + ); })} {(tripSlipRecord?.vehicleAssignments?.length ?? 0) === 0 && ( - No vehicles assigned yet. + No vehicles assigned yet. )} - 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 1c81f5dc5..1418cb636 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 @@ -45,6 +45,8 @@ export interface FirstMileRecord { vehicleId?: string | null; booking?: FirstMileBooking | 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; updatedAt: string; } @@ -66,4 +68,6 @@ export const firstMileService = { api.post(FM.ACCEPT(bookingReference)), remove: (id: string) => api.delete(FM.BY_ID(id)), + generateInvoice: (id: string) => + api.post<{ id: string; invoiceNumber?: string } | null>(`${FM.BASE}/${id}/invoice`), }; diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts index 298e749c7..c1be65fa6 100644 --- a/apps/edr-freight-web/backoffice/src/types/booking.ts +++ b/apps/edr-freight-web/backoffice/src/types/booking.ts @@ -188,7 +188,7 @@ export interface BookingDetail { company?: BookingNamedRef & Partial; originYard?: BookingNamedRef; destinationYard?: BookingNamedRef; - serviceType?: BookingNamedRef & { code?: string; priorityBonusPoints?: number; includesCustoms?: boolean }; + serviceType?: BookingNamedRef & { code?: string; priorityBonusPoints?: number; includesCustoms?: boolean; includesFirstMile?: boolean; includesLastMile?: boolean }; cargoType?: BookingNamedRef; shippingLine?: BookingNamedRef; bookingContainers?: BookingContainerLine[];