From 3a1d73b725c8498c8c33ffb442cf465d44134c78 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 7 Jul 2026 13:36:45 +0000 Subject: [PATCH] fix last mile --- .../modules/bookings/bookings.controller.ts | 85 ++++++++++ .../src/modules/bookings/bookings.module.ts | 2 + .../gps-tracking/gps-tracking.controller.ts | 11 +- .../src/seed/freight-permissions.registry.ts | 2 + .../backoffice/src/lib/permissions.ts | 1 + .../src/pages/fleet/TrackingPage.tsx | 39 +++-- .../BookingDetailPage/ReadonlyBookingView.tsx | 3 + .../components/MileSummaryCard.tsx | 156 ++++++++++++++++++ .../portal/src/services/bookings.service.ts | 24 +++ 9 files changed, 304 insertions(+), 19 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/MileSummaryCard.tsx diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 444ac578a..974cf06a9 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -68,6 +68,8 @@ import { AddCustomerTruckDto } from './dto/add-customer-truck.dto'; import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto'; import { LoadCustomerTruckDto } from './dto/load-customer-truck.dto'; import { CustomerTruckService } from './customer-truck.service'; +import { FirstMileService } from '../first-mile/first-mile.service'; +import { LastMileService } from '../last-mile/last-mile.service'; import { GenerateGrnDto } from './dto/generate-grn.dto'; import { ContainerReceiptService } from './container-receipt.service'; import { SignContractDto } from './dto/sign-contract.dto'; @@ -81,6 +83,60 @@ import { hasFreightPermission, } from "../../common/freight-permission.util"; +interface MileVehicleSummary { + plate: string | null; + code: string | null; + driverName: string | null; + containerNumber: string | null; + distanceKm: number | null; +} + +interface MileLegSummary { + status: string; + exactKm: number | null; + remainingPayment: number | null; + currency: string; + invoiced: boolean; + vehicles: MileVehicleSummary[]; +} + +/** Trim a first/last-mile record down to a customer-safe operational summary. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function summarizeMileLeg(rec?: Record): MileLegSummary | null { + if (!rec) return null; + const num = (v: unknown) => (v == null ? null : Number(v)); + const assignments: Array> = rec.vehicleAssignments ?? []; // eslint-disable-line @typescript-eslint/no-explicit-any + const currency = + rec.vehicle?.currency ?? + assignments[0]?.vehicle?.currency ?? + rec.booking?.paymentCurrency ?? + 'ETB'; + const vehicles: MileVehicleSummary[] = assignments.map((a) => ({ + plate: a.vehicle?.plateNumber ?? null, + code: a.vehicle?.code ?? null, + driverName: a.vehicle?.assignedDriverName ?? null, + containerNumber: a.containerNumber ?? null, + distanceKm: num(a.distanceKm), + })); + if (!vehicles.length && rec.vehicle) { + vehicles.push({ + plate: rec.vehicle.plateNumber ?? null, + code: rec.vehicle.code ?? null, + driverName: rec.vehicle.assignedDriverName ?? null, + containerNumber: null, + distanceKm: num(rec.exactKm), + }); + } + return { + status: rec.status ?? '', + exactKm: num(rec.exactKm), + remainingPayment: num(rec.remainingPayment), + currency, + invoiced: Boolean(rec.invoice), + vehicles, + }; +} + @ApiTags("bookings") @Controller("bookings") @ApiBearerAuth() @@ -94,6 +150,8 @@ export class BookingsController { private readonly bookingClearanceService: BookingClearanceService, private readonly customerTruckService: CustomerTruckService, private readonly containerReceiptService: ContainerReceiptService, + private readonly firstMileService: FirstMileService, + private readonly lastMileService: LastMileService, ) {} @Post() @@ -290,6 +348,33 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + @Get(':id/mile-summary') + @ApiOperation({ + summary: 'First/last-mile operational summary for a booking (customer-safe)', + }) + async mileSummary( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + // Customers may only see their own booking's mile summary. + const booking = await this.bookingsService.findById(id); + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && + !hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView) + ) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + + const [first, last] = await Promise.all([ + this.firstMileService.findAll({ bookingId: id, pageSize: 1 }), + this.lastMileService.findAll({ bookingId: id, pageSize: 1 }), + ]); + return { + firstMile: summarizeMileLeg(first.data[0]), + lastMile: summarizeMileLeg(last.data[0]), + }; + } + @Post(':id/customer-truck-assignment') @ApiOperation({ summary: 'Customer assigns external truck and driver for terminal pickup' }) async assignCustomerTruck( diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 61dc78e13..82fc19e62 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -12,6 +12,7 @@ import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-se import { SignaturesModule } from '../signatures/signatures.module'; import { BillingModule } from '../billing/billing.module'; import { FirstMileModule } from '../first-mile/first-mile.module'; +import { LastMileModule } from '../last-mile/last-mile.module'; import { BookingContractService } from './booking-contract.service'; import { BookingInvoiceService } from './booking-invoice.service'; // import { BookingPaymentController } from './booking-payment.controller'; @@ -70,6 +71,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; NotificationsModule, NotificationInboxModule, forwardRef(() => FirstMileModule), + forwardRef(() => LastMileModule), forwardRef(() => TrainSchedulingModule), forwardRef(() => ContractsModule), forwardRef(() => ContractsModule), diff --git a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts index e380e541e..8bd4a31d8 100644 --- a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts +++ b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts @@ -11,14 +11,15 @@ import { } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { FleetManage, FleetView } from '../../common/booking-guards'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { GpsTrackingService } from './gps-tracking.service'; import { RegisterDeviceDto, UpdateDeviceDto } from './dto/gps-device.dto'; @ApiTags('gps-tracking') @ApiBearerAuth() @Controller('gps') -@FleetView() +@BookingStaff(FREIGHT_PERMS.tracking.view) export class GpsTrackingController { constructor(private readonly gps: GpsTrackingService) {} @@ -44,21 +45,21 @@ export class GpsTrackingController { } @Post('devices') - @FleetManage() + @BookingStaff(FREIGHT_PERMS.tracking.manage) @ApiOperation({ summary: 'Register a GPS tracker' }) register(@Body() dto: RegisterDeviceDto) { return this.gps.registerDevice(dto); } @Patch('devices/:id') - @FleetManage() + @BookingStaff(FREIGHT_PERMS.tracking.manage) @ApiOperation({ summary: 'Update a GPS tracker (name / assigned vehicle)' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateDeviceDto) { return this.gps.updateDevice(id, dto); } @Delete('devices/:id') - @FleetManage() + @BookingStaff(FREIGHT_PERMS.tracking.manage) @ApiOperation({ summary: 'Delete a GPS tracker' }) remove(@Param('id', ParseUUIDPipe) id: string) { return this.gps.removeDevice(id); diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index d4d85e84c..8d70bc61c 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -204,6 +204,7 @@ export const FLEET_ROAD_PERMISSIONS: FreightPermissionSeed[] = [ perm('e2b00001-0001-4000-8000-000000000003', 'edr_freight_app:drivers:update', 'Update driver'), perm('e2b00001-0001-4000-8000-000000000004', 'edr_freight_app:drivers:delete', 'Delete driver'), perm('e2c00001-0001-4000-8000-000000000001', 'edr_freight_app:tracking:view', 'Track vehicles'), + perm('e2c00001-0001-4000-8000-000000000002', 'edr_freight_app:tracking:manage', 'Manage GPS trackers'), perm('e2d00001-0001-4000-8000-000000000001', 'edr_freight_app:fuel:view', 'View fuel purchases'), perm('e2d00001-0001-4000-8000-000000000002', 'edr_freight_app:fuel:create', 'Create fuel purchase'), perm('e2d00001-0001-4000-8000-000000000003', 'edr_freight_app:fuel:update', 'Update fuel purchase'), @@ -477,6 +478,7 @@ export const FREIGHT_PERMS = { }, tracking: { view: 'edr_freight_app:tracking:view', + manage: 'edr_freight_app:tracking:manage', }, fuel: { view: 'edr_freight_app:fuel:view', diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index c8bfa7c84..e0c5c3d08 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -145,6 +145,7 @@ export const FREIGHT_PERMS = { }, tracking: { view: "edr_freight_app:tracking:view", + manage: "edr_freight_app:tracking:manage", }, fuel: { view: "edr_freight_app:fuel:view", diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx index 14bb32d33..958d99775 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx @@ -27,6 +27,8 @@ import { import { Activity, Pencil, Plus, Radio, Trash2 } from "lucide-react"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { useToast } from "@/hooks/use-toast"; +import { useAuth } from "@/auth/useAuth"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { vehiclesService } from "@/services/vehicles.service"; import { gpsTrackingService, type GpsDevice } from "@/services/gps-tracking.service"; import { freightBrand } from "@/theme/freight-brand"; @@ -151,6 +153,8 @@ function RouteTrail({ path }: { path: LatLng[] }) { export function TrackingPage() { const { toast } = useToast(); const qc = useQueryClient(); + const { user } = useAuth(); + const canManage = hasPermission(user, FREIGHT_PERMS.tracking.manage); const [selectedId, setSelectedId] = useState(null); const [hoverId, setHoverId] = useState(null); const [mapsReady, setMapsReady] = useState(false); @@ -288,9 +292,11 @@ export function TrackingPage() { Real-Time Vehicle Tracking Live GPS positions from GT06 trackers - + {canManage && ( + + )} @@ -358,9 +364,11 @@ export function TrackingPage() { }> {selected.online ? "Live" : "Offline"} - deleteMutation.mutate(selected.id)}> - - + {canManage && ( + deleteMutation.mutate(selected.id)}> + + + )} @@ -393,6 +401,7 @@ export function TrackingPage() { data={vehicleOptions} value={selected.vehicleId ?? null} onChange={(v) => assignMutation.mutate({ id: selected.id, vehicleId: v })} + disabled={!canManage} searchable clearable /> @@ -421,14 +430,16 @@ export function TrackingPage() { {d.online ? "Live" : "Offline"} - { e.stopPropagation(); openEdit(d); }} - > - - + {canManage && ( + { e.stopPropagation(); openEdit(d); }} + > + + + )} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index 6d274f22f..6a002e3e9 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -18,6 +18,7 @@ import { ContainersCard } from "./components/ContainersCard"; import { ContractCard } from "./components/ContractCard"; import { CustomerTruckAssignmentCard } from "./components/CustomerTruckAssignmentCard"; import { KeyFactsStrip } from "./components/KeyFactsStrip"; +import { MileSummaryCard } from "./components/MileSummaryCard"; import { BodyGrid, PageShell } from "./components/layout"; import { CancelledBanner, @@ -217,6 +218,8 @@ export function ReadonlyBookingView({ + + } right={ diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/MileSummaryCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/MileSummaryCard.tsx new file mode 100644 index 000000000..5128ddecd --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/MileSummaryCard.tsx @@ -0,0 +1,156 @@ +import { Box, Group, Stack, Text } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; + +import { bookingsService } from "@/services/bookings.service"; +import type { + MileLegSummary, + MileVehicleSummary, +} from "@/services/bookings.service"; + +import { CardTitle, SectionCard } from "./layout"; + +function StatusPill({ status }: { status: string }) { + const s = status.toUpperCase(); + const done = s.includes("DELIVER") || s.includes("COMPLET") || s.includes("PAID"); + const active = s.includes("TRANSIT") || s.includes("PROGRESS") || s.includes("ASSIGN"); + const dot = done ? "#0EA371" : active ? "#2563EB" : "#94A3B8"; + const color = done ? "#0A6F4D" : active ? "#1E40AF" : "#475569"; + const bg = done ? "#ECF6F1" : active ? "#EAF1FE" : "#F1F4F7"; + const border = done ? "#CDEBDD" : active ? "#CFDDFB" : "#E1E7EE"; + const label = status + .replace(/_/g, " ") + .toLowerCase() + .replace(/\b\w/g, (m) => m.toUpperCase()); + + return ( + + + {label} + + ); +} + +function VehicleRow({ v }: { v: MileVehicleSummary }) { + const parts: string[] = []; + if (v.driverName) parts.push(v.driverName); + if (v.containerNumber) parts.push(`Container ${v.containerNumber}`); + if (v.distanceKm != null) parts.push(`${v.distanceKm} km`); + + return ( + + + + {v.plate || v.code || "Vehicle"} + + {parts.length > 0 && ( + + {parts.join(" ยท ")} + + )} + + {v.code && v.plate && ( + + {v.code} + + )} + + ); +} + +function LegBlock({ title, leg }: { title: string; leg: MileLegSummary }) { + const fmtMoney = (n: number | null) => + n == null + ? null + : `${leg.currency} ${Number(n).toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; + + return ( + + + + {title} + + + + + {leg.vehicles.length > 0 ? ( + + {leg.vehicles.map((v, i) => ( + + ))} + + ) : ( + + No vehicle assigned yet. + + )} + + + + {leg.exactKm != null && ( + + Total distance {leg.exactKm} km + + )} + {leg.remainingPayment != null && leg.remainingPayment > 0 && ( + + Balance {fmtMoney(leg.remainingPayment)} + + )} + + {leg.invoiced && ( + + Invoiced + + )} + + + ); +} + +export function MileSummaryCard({ bookingId }: { bookingId: string }) { + const { data } = useQuery({ + queryKey: ["booking-mile-summary", bookingId], + queryFn: () => bookingsService.mileSummary(bookingId), + }); + + if (!data || (!data.firstMile && !data.lastMile)) return null; + + return ( + + + First & Last Mile + + + {data.firstMile && } + {data.lastMile && } + + + ); +} diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index c2729bebb..8112fabbd 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -7,6 +7,26 @@ import { client } from "../utils/api"; const B = URL_CONSTANTS.BOOKINGS; +export interface MileVehicleSummary { + plate: string | null; + code: string | null; + driverName: string | null; + containerNumber: string | null; + distanceKm: number | null; +} +export interface MileLegSummary { + status: string; + exactKm: number | null; + remainingPayment: number | null; + currency: string; + invoiced: boolean; + vehicles: MileVehicleSummary[]; +} +export interface MileSummaryResponse { + firstMile: MileLegSummary | null; + lastMile: MileLegSummary | null; +} + export type CreateBookingPayload = Freight.CreateBookingDto; export interface ContractView { @@ -150,6 +170,10 @@ export const bookingsService = { const { data } = await client.get(`/api/bookings/${id}`); return data.data; }, + mileSummary: async (id: string): Promise => { + const { data } = await client.get(`/api/bookings/${id}/mile-summary`); + return data.data; + }, assignCustomerTruck: async ( id: string, payload: CustomerTruckAssignmentPayload,