From 42710261e2a05c56df96939021ea1100ba9bd817 Mon Sep 17 00:00:00 2001 From: natib21 Date: Mon, 6 Jul 2026 13:49:30 +0000 Subject: [PATCH] fix --- .../src/modules/drivers/drivers.controller.ts | 30 ++- .../src/modules/drivers/drivers.module.ts | 3 +- .../src/modules/drivers/drivers.service.ts | 34 ++++ .../src/modules/files/files.service.ts | 5 + .../first-mile/first-mile-invoice.service.ts | 28 ++- .../modules/first-mile/first-mile.service.ts | 18 +- .../last-mile/last-mile-invoice.service.ts | 28 ++- .../modules/last-mile/last-mile.service.ts | 18 +- .../src/pages/fleet/DriverDetailPage.tsx | 125 +++++++++++- .../src/pages/operations/FirstMilePage.tsx | 182 ++++++++++++++---- .../src/pages/operations/LastMilePage.tsx | 95 +++++---- .../src/services/drivers.service.ts | 25 +++ .../src/services/first-mile.service.ts | 2 + .../src/services/last-mile.service.ts | 2 + 14 files changed, 517 insertions(+), 78 deletions(-) diff --git a/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts b/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts index b4da558e2..e6b00dce5 100644 --- a/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts +++ b/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts @@ -8,8 +8,11 @@ import { Body, Query, ParseUUIDPipe, + UploadedFiles, + UseInterceptors, } from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { AnyFilesInterceptor } from '@nestjs/platform-express'; +import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger'; import { FleetManage, FleetView } from '../../common/booking-guards'; import { DriversService } from './drivers.service'; import { CreateDriverDto } from './dto/create-driver.dto'; @@ -65,6 +68,31 @@ export class DriversController { return this.fleetHistory.getDriverHistory(id); } + @Post(':id/documents') + @FleetManage() + @ApiConsumes('multipart/form-data') + @UseInterceptors(AnyFilesInterceptor()) + @ApiOperation({ summary: 'Upload driver documents (code driver_docs)' }) + uploadDocuments( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + ) { + return this.driversService.uploadDocuments(id, files ?? []); + } + + @Get(':id/documents') + @ApiOperation({ summary: "List a driver's documents" }) + listDocuments(@Param('id', ParseUUIDPipe) id: string) { + return this.driversService.listDocuments(id); + } + + @Delete(':id/documents/:fileId') + @FleetManage() + @ApiOperation({ summary: 'Delete a driver document' }) + removeDocument(@Param('fileId', ParseUUIDPipe) fileId: string) { + return this.driversService.removeDocument(fileId); + } + @Patch(':id') @FleetManage() @ApiOperation({ summary: 'Update a driver' }) diff --git a/apps/edr-freight-api/src/modules/drivers/drivers.module.ts b/apps/edr-freight-api/src/modules/drivers/drivers.module.ts index 9e685dcd6..1a6e29e15 100644 --- a/apps/edr-freight-api/src/modules/drivers/drivers.module.ts +++ b/apps/edr-freight-api/src/modules/drivers/drivers.module.ts @@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Driver } from './entities/driver.entity'; import { DriversService } from './drivers.service'; import { DriversController } from './drivers.controller'; +import { FilesModule } from '../files/files.module'; @Module({ - imports: [TypeOrmModule.forFeature([Driver])], + imports: [TypeOrmModule.forFeature([Driver]), FilesModule], providers: [DriversService], controllers: [DriversController], exports: [DriversService], diff --git a/apps/edr-freight-api/src/modules/drivers/drivers.service.ts b/apps/edr-freight-api/src/modules/drivers/drivers.service.ts index 6e7c1f69c..e4fa992e8 100644 --- a/apps/edr-freight-api/src/modules/drivers/drivers.service.ts +++ b/apps/edr-freight-api/src/modules/drivers/drivers.service.ts @@ -6,6 +6,11 @@ import { UpdateDriverDto } from './dto/update-driver.dto'; import { Driver, DriverStatus } from './entities/driver.entity'; import { FleetHistoryService } from '../fleet-history/fleet-history.service'; import { FleetEventType } from '../fleet-history/entities/fleet-event.entity'; +import { FilesService } from '../files/files.service'; + +/** Resource + code the driver-documents upload area is stored under. */ +const DRIVER_DOCS_RESOURCE = 'driver'; +const DRIVER_DOCS_CODE = 'driver_docs'; @Injectable() export class DriversService { @@ -13,8 +18,37 @@ export class DriversService { @InjectRepository(Driver) private readonly driverRepo: Repository, private readonly history: FleetHistoryService, + private readonly filesService: FilesService, ) {} + /** Upload one or more driver documents (code "driver_docs"). */ + async uploadDocuments(driverId: string, files: Express.Multer.File[]) { + const driver = await this.driverRepo.findOneBy({ id: driverId }); + if (!driver) throw new NotFoundException(`Driver ${driverId} not found`); + if (!files?.length) throw new BadRequestException('No files provided'); + return Promise.all( + files.map((file) => + this.filesService.upload({ + resourceId: driverId, + resource: DRIVER_DOCS_RESOURCE, + code: DRIVER_DOCS_CODE, + file, + }), + ), + ); + } + + /** List a driver's uploaded documents (code "driver_docs"). */ + async listDocuments(driverId: string) { + const all = await this.filesService.findByResource(driverId, DRIVER_DOCS_RESOURCE); + return all.filter((f) => f.code === DRIVER_DOCS_CODE); + } + + /** Delete a single driver document by file id. */ + async removeDocument(fileId: string): Promise { + await this.filesService.remove(fileId); + } + async create(dto: CreateDriverDto): Promise { if (dto.faydaVerified !== true) { throw new BadRequestException( diff --git a/apps/edr-freight-api/src/modules/files/files.service.ts b/apps/edr-freight-api/src/modules/files/files.service.ts index a5c641dd7..4966d7ff9 100644 --- a/apps/edr-freight-api/src/modules/files/files.service.ts +++ b/apps/edr-freight-api/src/modules/files/files.service.ts @@ -119,6 +119,11 @@ export class FilesService { return record; } + /** Soft-delete a stored file row by id (object bytes are left in MinIO). */ + async remove(id: string): Promise { + await this.filesRepository.softDelete(id); + } + findByResource(resourceId: string, resource: string): Promise { return this.filesRepository.findByResource(resourceId, resource); } diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts index f4935a87b..aa618cdb7 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import { Freight } from '@edr/types'; @@ -66,13 +66,37 @@ export class FirstMileInvoiceService { return null; } + // Reject mixed-currency truck sets — a single invoice can only be one + // currency, and amounts across currencies can't be summed. + const billableTrucks = (record.vehicleAssignments ?? []).filter( + (a) => Number(a.distanceKm) > 0, + ); + const currencies = [ + ...new Set( + billableTrucks + .map((a) => (a.vehicle as { currency?: string } | undefined)?.currency) + .filter((c): c is string => Boolean(c)), + ), + ]; + if (currencies.length > 1) { + throw new BadRequestException( + `Cannot generate invoice: assigned trucks use mixed currencies (${currencies.join(', ')}). Assign trucks that share one currency.`, + ); + } + + // Currency follows the truck (price/km is quoted per vehicle), falling back + // to the booking's currency, then ETB. + const truckCurrency = + (record.vehicle as { currency?: string } | undefined)?.currency || + (record.vehicleAssignments?.[0]?.vehicle as { currency?: string } | undefined)?.currency; + return this.billing.generateInvoice({ source: 'first_mile' as Freight.InvoiceSource, sourceId: record.id, type: 'DELIVERY_FEE', companyId: fm.booking!.companyId, companyProfileId: fm.booking!.companyProfileId || '', - currency: fm.booking!.paymentCurrency || 'ETB', + currency: truckCurrency || fm.booking!.paymentCurrency || 'ETB', lines: [ { chargeType: 'DELIVERY', 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 5c12d94ee..00dbb80cc 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 @@ -640,10 +640,24 @@ export class FirstMileService { { distanceKm: d.distanceKm }, ); } - const total = distances.reduce((s, d) => s + (Number(d.distanceKm) || 0), 0); + + // Billing is per truck: amount = Σ (truck distance × truck price/km). The + // per-vehicle rate + currency live on the vehicle, so we ignore the legacy + // FIRST_MILE flat rate and any client-sent amount. `remainingPayment` param + // kept only for signature back-compat. + void remainingPayment; + const assignments = await this.dataSource.manager.find(FirstMileVehicleAssignment, { + where: { firstMileId: id }, + relations: { vehicle: true }, + }); + const total = assignments.reduce((s, a) => s + (Number(a.distanceKm) || 0), 0); + const amount = assignments.reduce( + (s, a) => s + (Number(a.distanceKm) || 0) * (Number(a.vehicle?.pricePerKm) || 0), + 0, + ); await this.firstMileRepository.update(id, { exactKm: total, - ...(remainingPayment != null ? { remainingPayment } : {}), + remainingPayment: amount, } as any); return this.findById(id); } diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts index 7b14f6887..8a6ec0779 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import { Freight } from '@edr/types'; @@ -63,6 +63,30 @@ export class LastMileInvoiceService { return null; } + // Reject mixed-currency truck sets — a single invoice can only be one + // currency, and amounts across currencies can't be summed. + const billableTrucks = (record.vehicleAssignments ?? []).filter( + (a) => Number(a.distanceKm) > 0, + ); + const currencies = [ + ...new Set( + billableTrucks + .map((a) => (a.vehicle as { currency?: string } | undefined)?.currency) + .filter((c): c is string => Boolean(c)), + ), + ]; + if (currencies.length > 1) { + throw new BadRequestException( + `Cannot generate invoice: assigned trucks use mixed currencies (${currencies.join(', ')}). Assign trucks that share one currency.`, + ); + } + + // Currency follows the truck (price/km is quoted per vehicle), falling back + // to the booking's currency, then ETB. + const truckCurrency = + (record.vehicle as { currency?: string } | undefined)?.currency || + (record.vehicleAssignments?.[0]?.vehicle as { currency?: string } | undefined)?.currency; + // Generate invoice with remainingPayment as totalAmount const input: GenerateInvoiceInput = { source: 'last_mile' as Freight.InvoiceSource, @@ -70,7 +94,7 @@ export class LastMileInvoiceService { type: 'DELIVERY_FEE', companyId: lm.booking!.companyId, companyProfileId: lm.booking!.companyProfileId || '', - currency: lm.booking!.paymentCurrency || 'ETB', + currency: truckCurrency || lm.booking!.paymentCurrency || 'ETB', lines: [ { chargeType: 'DELIVERY', 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 22f25a6aa..5876f7c05 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 @@ -510,10 +510,24 @@ export class LastMileService { { distanceKm: d.distanceKm }, ); } - const total = distances.reduce((s, d) => s + (Number(d.distanceKm) || 0), 0); + + // Billing is per truck: amount = Σ (truck distance × truck price/km). The + // per-vehicle rate + currency live on the vehicle, so we ignore the legacy + // LAST_MILE flat rate and any client-sent amount. `remainingPayment` param + // kept only for signature back-compat. + void remainingPayment; + const assignments = await this.dataSource.manager.find(LastMileVehicleAssignment, { + where: { lastMileId: id }, + relations: { vehicle: true }, + }); + const total = assignments.reduce((s, a) => s + (Number(a.distanceKm) || 0), 0); + const amount = assignments.reduce( + (s, a) => s + (Number(a.distanceKm) || 0) * (Number(a.vehicle?.pricePerKm) || 0), + 0, + ); await this.lastMileRepository.update(id, { exactKm: total, - ...(remainingPayment != null ? { remainingPayment } : {}), + remainingPayment: amount, } as any); return this.findById(id); } diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx index d5e8655be..238adffcb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx @@ -1,12 +1,14 @@ import { useMemo } from "react"; import { useParams, useNavigate } from "react-router-dom"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ActionIcon, Badge, + Button, Card, Center, Container, + FileButton, Group, Loader, SimpleGrid, @@ -19,9 +21,14 @@ import { } from "@mantine/core"; import { ArrowLeft, + Download, + Eye, + FileText, History, Route, ShieldCheck, + Trash2, + Upload, Truck, User, } from "lucide-react"; @@ -29,6 +36,7 @@ import { import { driversService } from "@/services/drivers.service"; import { vehiclesService } from "@/services/vehicles.service"; import { fleetHistoryService, type FleetHistoryEvent } from "@/services/fleet-history.service"; +import { useToast } from "@/hooks/use-toast"; const fmtDate = (iso?: string | null) => { if (!iso) return "—"; @@ -55,6 +63,116 @@ const Loading = () => (
); +const fmtSize = (bytes: number) => { + if (!bytes) return "—"; + const kb = bytes / 1024; + return kb < 1024 ? `${kb.toFixed(0)} KB` : `${(kb / 1024).toFixed(1)} MB`; +}; + +/** Driver documents upload + view area (files stored under code "driver_docs"). */ +const DriverDocuments = ({ driverId }: { driverId: string }) => { + const { toast } = useToast(); + const qc = useQueryClient(); + + const { data: docs = [], isLoading } = useQuery({ + queryKey: ["driver", driverId, "documents"], + queryFn: () => driversService.listDocuments(driverId).then((r) => r.data ?? []), + enabled: Boolean(driverId), + }); + + const uploadMutation = useMutation({ + mutationFn: (files: File[]) => driversService.uploadDocuments(driverId, files), + onSuccess: () => { + toast({ title: "Documents uploaded" }); + void qc.invalidateQueries({ queryKey: ["driver", driverId, "documents"] }); + }, + onError: (err: unknown) => { + const description = + (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? + "Upload failed"; + toast({ title: "Upload failed", description, variant: "destructive" }); + }, + }); + + const removeMutation = useMutation({ + mutationFn: (fileId: string) => driversService.removeDocument(driverId, fileId), + onSuccess: () => { + toast({ title: "Document deleted" }); + void qc.invalidateQueries({ queryKey: ["driver", driverId, "documents"] }); + }, + onError: () => toast({ title: "Delete failed", variant: "destructive" }), + }); + + // /files/:id is a public inline-serving route; open directly for preview/download. + const fileUrl = (fileId: string, download = false) => + `${import.meta.env.VITE_API_URL}/files/${fileId}${download ? "?download=1" : ""}`; + + return ( + + + Documents ({docs.length}) + files.length && uploadMutation.mutate(files)}> + {(props) => ( + + )} + + + + {isLoading ? ( + + ) : docs.length === 0 ? ( + No documents uploaded yet. + ) : ( + + + + Name + Size + Uploaded + Actions + + + + {docs.map((doc) => ( + + + + + {doc.name} + + + {fmtSize(doc.size)} + {fmtDate(doc.createdAt)} + + + window.open(fileUrl(doc.id), "_blank")}> + + + window.open(fileUrl(doc.id, true), "_blank")}> + + + removeMutation.mutate(doc.id)} + > + + + + + + ))} + +
+ )} +
+ ); +}; + const DriverDetailPage = () => { const { id = "" } = useParams<{ id: string }>(); const navigate = useNavigate(); @@ -106,6 +224,7 @@ const DriverDetailPage = () => { }>Vehicles }>History }>Trips + }>Documents @@ -149,6 +268,10 @@ const DriverDetailPage = () => { + + + + )} 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 2b130848c..a8c55ecf4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -20,7 +20,7 @@ import type { ColumnDef } from "@edr/ui-common"; import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; import { ActionIcon, - Autocomplete, + Alert, Badge, Box, Button, @@ -54,7 +54,6 @@ import { } from "@/services/first-mile.service"; import { bookingsService } from "@/services/bookings.service"; import { vehiclesService } from "@/services/vehicles.service"; -import { ratesService } from "@/services/rates.service"; import type { BookingDetail } from "@/types/booking"; const formatPrice = (amount: number | string | null | undefined, currency = "ETB") => @@ -191,7 +190,24 @@ const isPostPaymentPending = (r: FirstMileRecord) => // Map API record → display fields used in modals and trip slip const bookingRef = (r: FirstMileRecord) => r.booking?.reference ?? r.bookingId; -const currencyOf = (r: FirstMileRecord) => r.booking?.paymentCurrency ?? "ETB"; +const currencyOf = (r: FirstMileRecord) => + r.vehicle?.currency ?? + r.vehicleAssignments?.[0]?.vehicle?.currency ?? + r.booking?.paymentCurrency ?? + "ETB"; + +type FmAssignment = NonNullable[number]; +const truckShort = (a: FmAssignment) => + a.vehicle ? [a.vehicle.code, a.vehicle.plateNumber].filter(Boolean).join(" · ") : a.vehicleId; +/** Billing problems on the trucks that have distance: zero price/km, mixed currency. */ +const billingIssues = (r: FirstMileRecord) => { + const trucks = (r.vehicleAssignments ?? []).filter((a) => Number(a.distanceKm) > 0); + const zeroPrice = trucks.filter((a) => !(Number(a.vehicle?.pricePerKm) > 0)).map(truckShort); + const currencies = [ + ...new Set(trucks.map((a) => a.vehicle?.currency).filter((c): c is string => Boolean(c))), + ]; + return { zeroPrice, mixedCurrency: currencies.length > 1, currencies }; +}; const customerName = (r: FirstMileRecord) => r.booking?.company?.name ?? "—"; const pickupLocation = (r: FirstMileRecord) => r.booking?.firstMilePickupAddress ?? "—"; const cargoDesc = (r: FirstMileRecord) => { @@ -480,6 +496,8 @@ const FirstMilePage = () => { const [distanceOpen, setDistanceOpen] = useState(false); // Per-vehicle actual distance, keyed by vehicleId. const [distanceRows, setDistanceRows] = useState>({}); + // Record pending invoice-generation confirmation (shows a summary first). + const [invoiceConfirm, setInvoiceConfirm] = useState(null); const [warehouseReceiveOpen, setWarehouseReceiveOpen] = useState(false); const [warehouseReceiveRecord, setWarehouseReceiveRecord] = useState(null); @@ -500,14 +518,6 @@ const FirstMilePage = () => { }, }); - const { data: ratesData } = useQuery({ - queryKey: ["rates", "FIRST_MILE"], - queryFn: async () => { - const res = await ratesService.getByType("FIRST_MILE"); - return res.data; - }, - }); - const { data: paidBookingsData, isLoading: bookingsLoading } = useQuery({ queryKey: QUERY_KEYS.BOOKINGS.list({ status: "PAID" }), queryFn: () => bookingsService.list({ status: "PAID", pageSize: 100 }), @@ -572,10 +582,17 @@ const FirstMilePage = () => { distances: Array<{ vehicleId: string; distanceKm: number }>; remainingPayment?: number; }) => firstMileService.setDistances(id, distances, remainingPayment), - onSuccess: () => { + onSuccess: (res) => { void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() }); toast({ title: "Distances saved", description: activeRecord ? bookingRef(activeRecord) : undefined }); + const updated = res?.data as FirstMileRecord | undefined; closeDistance(); + // Every truck has a distance and it isn't billed yet → offer to invoice now. + const trucks = updated?.vehicleAssignments ?? []; + const allFilled = trucks.length > 0 && trucks.every((a) => Number(a.distanceKm) > 0); + if (updated && allFilled && !updated.invoice) { + setInvoiceConfirm(updated); + } }, onError: () => { toast({ title: "Update failed", variant: "destructive" }); @@ -783,18 +800,9 @@ const FirstMilePage = () => { 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) { - remainingPayment = total * parseFloat(firstMileRate.rateValue); - } - } - - setDistancesMutation.mutate({ id: activeId, distances, remainingPayment }); + // Amount is computed server-side per truck (distance × the vehicle's + // price/km, in the vehicle's currency) — no flat FIRST_MILE rate. + setDistancesMutation.mutate({ id: activeId, distances }); }; const matchesFilter = (r: FirstMileRecord) => { @@ -849,6 +857,33 @@ const FirstMilePage = () => { return filteredRecords.slice(start, start + pagination.pageSize); }, [filteredRecords, pagination]); + // Billing problems on the leg pending invoice confirmation. + const confirmIssues = invoiceConfirm + ? billingIssues(invoiceConfirm) + : { zeroPrice: [] as string[], mixedCurrency: false, currencies: [] as string[] }; + + // Guard invoice generation: block mixed currency, warn (but proceed) on trucks + // priced at 0/km. + const handleGenerateInvoice = (r: FirstMileRecord) => { + const { zeroPrice, mixedCurrency, currencies } = billingIssues(r); + if (mixedCurrency) { + toast({ + title: "Mixed truck currencies", + description: `Trucks use ${currencies.join(", ")}. Assign trucks that share one currency.`, + variant: "destructive", + }); + return; + } + if (zeroPrice.length) { + toast({ + title: "Truck has no price/km", + description: `${zeroPrice.join(", ")} will bill 0 — set Price per KM on the vehicle.`, + variant: "destructive", + }); + } + generateInvoiceMutation.mutate(r.id); + }; + const openAssign = (id: string | null) => { const resolved = id ?? filteredRecords.find((r) => !isAssigned(r))?.id ?? null; const rec = records.find((r) => r.id === resolved); @@ -1173,7 +1208,7 @@ const FirstMilePage = () => { !(row.original.exactKm != null && row.original.exactKm > 0) || Boolean(row.original.invoice) } - onClick={() => generateInvoiceMutation.mutate(row.original.id)} + onClick={() => handleGenerateInvoice(row.original)} > {row.original.invoice ? "Invoice generated" : "Generate Invoice"} @@ -1337,21 +1372,29 @@ const FirstMilePage = () => { clearable disabled={assignVehicleOptions.length === 0} /> - - n === row.containerNumber || - !vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n), - )} - value={row.containerNumber} + placeholder={containerOptions.length ? "Select container" : "No container numbers"} + data={[ + ...containerOptions.filter( + (n) => + n === row.containerNumber || + !vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n), + ), + // keep a manual/legacy value selectable even if not in the booking + ...(row.containerNumber && !containerOptions.includes(row.containerNumber) + ? [row.containerNumber] + : []), + ]} + value={row.containerNumber || null} onChange={(value) => setVehicleRows((prev) => - prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value } : x)), + prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value ?? "" } : x)), ) } + searchable + clearable /> {vehicleRows.length > 1 && ( { + + {/* Generate Invoice — confirmation summary */} + setInvoiceConfirm(null)} + title={Generate Invoice} + size="md" + radius="lg" + centered + > + {invoiceConfirm && ( + + + {bookingRef(invoiceConfirm)} + {customerName(invoiceConfirm)} + + + + {(invoiceConfirm.vehicleAssignments ?? []).map((a) => { + const v = a.vehicle; + const label = v ? [v.code, v.plateNumber].filter(Boolean).join(" · ") : a.vehicleId; + return ( + + + {label} + {a.containerNumber ? ` · ${a.containerNumber}` : ""} + + {a.distanceKm != null ? `${a.distanceKm} km` : "—"} + + ); + })} + + + + Total distance + {invoiceConfirm.exactKm ?? 0} km + + + Invoice amount + {formatPrice(invoiceConfirm.remainingPayment, currencyOf(invoiceConfirm))} + + {confirmIssues.mixedCurrency && ( + + Trucks use {confirmIssues.currencies.join(", ")}. Assign trucks that share one currency before invoicing. + + )} + {confirmIssues.zeroPrice.length > 0 && ( + + {confirmIssues.zeroPrice.join(", ")} will bill 0 — set Price per KM on the vehicle. + + )} + + Generate the delivery-fee invoice now, or close and generate later from the row actions. + + + + + + + )} + ); }; 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 4b5cec080..d7f420171 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -19,7 +19,6 @@ import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; import { ActionIcon, Alert, - Autocomplete, Badge, Box, Button, @@ -56,7 +55,6 @@ import { } from "@/services/last-mile.service"; import { vehiclesService } from "@/services/vehicles.service"; import { driversService, type Driver } from "@/services/drivers.service"; -import { ratesService } from "@/services/rates.service"; import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal"; import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps"; @@ -233,7 +231,24 @@ const computeLastMileSteps = ( }; const bookingRef = (r: LastMileRecord) => r.booking?.reference ?? r.bookingId; -const currencyOf = (r: LastMileRecord) => r.booking?.paymentCurrency ?? "ETB"; +const currencyOf = (r: LastMileRecord) => + r.vehicle?.currency ?? + r.vehicleAssignments?.[0]?.vehicle?.currency ?? + r.booking?.paymentCurrency ?? + "ETB"; + +type LmAssignment = NonNullable[number]; +const truckShort = (a: LmAssignment) => + a.vehicle ? [a.vehicle.code, a.vehicle.plateNumber].filter(Boolean).join(" · ") : a.vehicleId; +/** Billing problems on the trucks that have distance: zero price/km, mixed currency. */ +const billingIssues = (r: LastMileRecord) => { + const trucks = (r.vehicleAssignments ?? []).filter((a) => Number(a.distanceKm) > 0); + const zeroPrice = trucks.filter((a) => !(Number(a.vehicle?.pricePerKm) > 0)).map(truckShort); + const currencies = [ + ...new Set(trucks.map((a) => a.vehicle?.currency).filter((c): c is string => Boolean(c))), + ]; + return { zeroPrice, mixedCurrency: currencies.length > 1, currencies }; +}; const customerName = (r: LastMileRecord) => r.booking?.company?.name ?? "—"; const deliveryLocation = (r: LastMileRecord) => r.booking?.lastMileDeliveryAddress ?? "—"; const cargoDesc = (r: LastMileRecord) => { @@ -581,14 +596,6 @@ const LastMilePage = () => { }, }); - const { data: ratesData } = useQuery({ - queryKey: ["rates", "LAST_MILE"], - queryFn: async () => { - const res = await ratesService.getByType("LAST_MILE"); - return res.data; - }, - }); - const records = listData?.data ?? []; const existingLastMileBookingIds = useMemo( () => new Set(records.map((record) => record.bookingId)), @@ -667,10 +674,17 @@ const LastMilePage = () => { distances: Array<{ vehicleId: string; distanceKm: number }>; remainingPayment?: number; }) => lastMileService.setDistances(id, distances, remainingPayment), - onSuccess: () => { + onSuccess: (res) => { void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT }); toast({ title: "Distances saved", description: activeRecord ? bookingRef(activeRecord) : undefined }); + const updated = res?.data as LastMileRecord | undefined; closeDistance(); + // Every truck has a distance and it isn't billed yet → offer to invoice now. + const trucks = updated?.vehicleAssignments ?? []; + const allFilled = trucks.length > 0 && trucks.every((a) => Number(a.distanceKm) > 0); + if (updated && allFilled && !updated.invoice) { + setInvoiceConfirm(updated); + } }, onError: () => { toast({ title: "Update failed", variant: "destructive" }); @@ -802,18 +816,9 @@ const LastMilePage = () => { return; } - const total = distances.reduce((s, d) => s + d.distanceKm, 0); - let remainingPayment: number | undefined; - if (ratesData?.data) { - const lastMileRate = ratesData.data.find( - (r) => r.rateType === "LAST_MILE" && (r.status === "LIVE" || r.status === "DRAFT") - ); - if (lastMileRate) { - remainingPayment = total * parseFloat(lastMileRate.rateValue); - } - } - - distanceMutation.mutate({ id: activeId, distances, remainingPayment }); + // Amount is computed server-side per truck (distance × the vehicle's + // price/km, in the vehicle's currency) — no flat LAST_MILE rate. + distanceMutation.mutate({ id: activeId, distances }); }; const activeRecord = useMemo( @@ -929,6 +934,11 @@ const LastMilePage = () => { [records], ); + // Billing problems on the leg pending invoice confirmation. + const confirmIssues = invoiceConfirm + ? billingIssues(invoiceConfirm) + : { zeroPrice: [] as string[], mixedCurrency: false, currencies: [] as string[] }; + const filteredRecords = useMemo(() => { const term = search.trim().toLowerCase(); return records.filter((r) => { @@ -1697,21 +1707,29 @@ const LastMilePage = () => { clearable disabled={assignVehicleOptions.length === 0} /> - - n === row.containerNumber || - !vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n), - )} - value={row.containerNumber} + placeholder={containerOptions.length ? "Select container" : "No container numbers"} + data={[ + ...containerOptions.filter( + (n) => + n === row.containerNumber || + !vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n), + ), + // keep a manual/legacy value selectable even if not in the booking + ...(row.containerNumber && !containerOptions.includes(row.containerNumber) + ? [row.containerNumber] + : []), + ]} + value={row.containerNumber || null} onChange={(value) => setVehicleRows((prev) => - prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value } : x)), + prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value ?? "" } : x)), ) } + searchable + clearable /> {vehicleRows.length > 1 && ( { Invoice amount {formatPrice(invoiceConfirm.remainingPayment, currencyOf(invoiceConfirm))} + {confirmIssues.mixedCurrency && ( + + Trucks use {confirmIssues.currencies.join(", ")}. Assign trucks that share one currency before invoicing. + + )} + {confirmIssues.zeroPrice.length > 0 && ( + + {confirmIssues.zeroPrice.join(", ")} will bill 0 — set Price per KM on the vehicle. + + )} This creates the delivery-fee invoice. Confirm the distances and amount are correct. @@ -2000,6 +2028,7 @@ const LastMilePage = () => {