From 3a1d73b725c8498c8c33ffb442cf465d44134c78 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 7 Jul 2026 13:36:45 +0000 Subject: [PATCH 01/15] 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, From 246663641a7ca36ef8d1fc27427595fc22359fce Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 8 Jul 2026 13:15:39 +0000 Subject: [PATCH 02/15] feat(warehouses): approve-delivery opens handover doc for review and signing Customer reviews the generated handover before signing: - add booking-scoped handover-document endpoint (portal only has bookingId) - ApproveDeliveryModal renders the handover PDF, then applies the customer's saved signature on approve and returns the signed PDF - ApproveDeliveryButton opens the modal instead of one-click silent signing Handover generation + sign notification (in-app + SMS + email) and the "Approve delivery" visibility on an awaiting-signature handover were committed earlier; this wires the review-and-sign step. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../warehouse-inventory.controller.ts | 10 + .../warehouses/warehouse-inventory.service.ts | 15 ++ .../delivery/ApproveDeliveryButton.tsx | 91 +++------- .../delivery/ApproveDeliveryModal.tsx | 171 ++++++++++++++++++ .../portal/src/services/api.ts | 7 + .../portal/src/services/bookings.service.ts | 7 + 6 files changed, 232 insertions(+), 69 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryModal.tsx diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 9eb4ea502..242cecc76 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -361,6 +361,16 @@ export class WarehouseInventoryController { return this.handoverService.requestSignature(bookingId); } + @Get('bookings/:bookingId/handover-document') + @ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking)' }) + async bookingHandoverDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) { + const { filename, buffer } = await this.inventoryService.handoverDocumentForBooking(bookingId); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `inline; filename="${filename}"`); + res.setHeader('Content-Length', buffer.length); + return res.send(buffer); + } + @Get('bookings/:bookingId/container-items') @ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' }) containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 14f4e27f3..a7bc2353a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -3016,6 +3016,21 @@ export class WarehouseInventoryService { }; } + /** Handover PDF resolved by booking (for the portal, which only has bookingId). */ + async handoverDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> { + const [inv]: Array<{ id: string }> = await this.dataSource.query( + `SELECT id FROM freight.warehouse_inventory + WHERE booking_id = $1 AND deleted_at IS NULL + ORDER BY updated_at DESC NULLS LAST, created_at DESC + LIMIT 1`, + [bookingId], + ); + if (!inv) { + throw new NotFoundException(`No warehouse inventory found for booking ${bookingId}`); + } + return this.handoverDocument(inv.id); + } + async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> { const [row] = await this.dataSource.query( `SELECT inv.id, diff --git a/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryButton.tsx b/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryButton.tsx index e9ee31722..c3a0a8ccd 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryButton.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryButton.tsx @@ -1,11 +1,8 @@ import { Button, type ButtonProps } from "@mantine/core"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; import { CheckCircle2 } from "lucide-react"; -import type { MouseEvent } from "react"; -import toast from "react-hot-toast"; -import { useNavigate } from "react-router-dom"; +import { type MouseEvent, useState } from "react"; -import { api } from "@/services/api"; +import { ApproveDeliveryModal } from "./ApproveDeliveryModal"; type ApproveDeliveryButtonProps = ButtonProps & { bookingId: string; @@ -13,25 +10,6 @@ type ApproveDeliveryButtonProps = ButtonProps & { onApproved?: () => void; }; -const errorMessage = (error: unknown) => { - const data = (error as { response?: { data?: { message?: string | string[] } } }) - ?.response?.data; - if (Array.isArray(data?.message)) return data.message.join(", "); - if (data?.message) return data.message; - return error instanceof Error ? error.message : "Could not approve delivery"; -}; - -const downloadBlob = (blob: Blob, filename: string) => { - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = filename; - document.body.appendChild(link); - link.click(); - link.remove(); - URL.revokeObjectURL(url); -}; - export function ApproveDeliveryButton({ bookingId, stopPropagation, @@ -40,56 +18,31 @@ export function ApproveDeliveryButton({ variant = "filled", ...props }: ApproveDeliveryButtonProps) { - const navigate = useNavigate(); - const queryClient = useQueryClient(); - - const handoverMutation = useMutation(api.bookings.downloadHandoverDocument.mutationOptions()); - - const mutation = useMutation({ - ...api.bookings.approveDelivery.mutationOptions(), - onSuccess: async (result) => { - try { - const blob = await handoverMutation.mutateAsync({ inventoryId: result.inventoryId }); - downloadBlob(blob, `handover-${bookingId}.pdf`); - toast.success("Delivery approved and signed handover downloaded"); - } catch { - toast.success("Delivery approved and handover signed"); - toast.error("Signed handover document could not be downloaded"); - } - await Promise.all([ - queryClient.invalidateQueries({ queryKey: api.bookings.get.queryKey({ id: bookingId }) }), - queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }), - queryClient.invalidateQueries({ queryKey: ["companies", "getDashboard"] }), - ]); - onApproved?.(); - }, - onError: (error) => { - const message = errorMessage(error); - toast.error(message); - if (message.toLowerCase().includes("save your signature")) { - navigate("/signature"); - } else if (/demurrage|storage|warehouse invoice|fully paid/i.test(message)) { - navigate("/billing"); - } - }, - }); + const [opened, setOpened] = useState(false); const handleClick = (event: MouseEvent) => { if (stopPropagation) event.stopPropagation(); - mutation.mutate({ id: bookingId }); + setOpened(true); }; return ( - + <> + + setOpened(false)} + onApproved={onApproved} + /> + ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryModal.tsx new file mode 100644 index 000000000..a3723b650 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryModal.tsx @@ -0,0 +1,171 @@ +import { Alert, Button, Group, Loader, Modal, Stack, Text } from "@mantine/core"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { CheckCircle2, Info } from "lucide-react"; +import { useEffect, useState } from "react"; +import toast from "react-hot-toast"; +import { useNavigate } from "react-router-dom"; + +import { api } from "@/services/api"; +import { bookingsService } from "@/services/bookings.service"; + +type ApproveDeliveryModalProps = { + bookingId: string; + opened: boolean; + onClose: () => void; + onApproved?: () => void; +}; + +const errorMessage = (error: unknown) => { + const data = (error as { response?: { data?: { message?: string | string[] } } }) + ?.response?.data; + if (Array.isArray(data?.message)) return data.message.join(", "); + if (data?.message) return data.message; + return error instanceof Error ? error.message : "Could not approve delivery"; +}; + +const downloadBlob = (blob: Blob, filename: string) => { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); +}; + +/** + * Approve-delivery flow: open the handover document for the customer to review, + * then apply their saved signature (approve) and hand back the signed PDF. + */ +export function ApproveDeliveryModal({ + bookingId, + opened, + onClose, + onApproved, +}: ApproveDeliveryModalProps) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [pdfUrl, setPdfUrl] = useState(null); + + const { + data: docBlob, + isLoading, + isError, + } = useQuery({ + queryKey: ["booking-handover-doc", bookingId], + queryFn: () => bookingsService.downloadBookingHandoverDocument(bookingId), + enabled: opened && Boolean(bookingId), + staleTime: 0, + }); + + useEffect(() => { + if (!docBlob) { + setPdfUrl(null); + return; + } + const url = URL.createObjectURL(docBlob); + setPdfUrl(url); + return () => URL.revokeObjectURL(url); + }, [docBlob]); + + const handoverMutation = useMutation( + api.bookings.downloadHandoverDocument.mutationOptions(), + ); + + const approve = useMutation({ + ...api.bookings.approveDelivery.mutationOptions(), + onSuccess: async (result) => { + try { + const signed = await handoverMutation.mutateAsync({ + inventoryId: result.inventoryId, + }); + downloadBlob(signed, `handover-${bookingId}.pdf`); + toast.success("Delivery approved and signed handover downloaded"); + } catch { + toast.success("Delivery approved and handover signed"); + toast.error("Signed handover document could not be downloaded"); + } + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: api.bookings.get.queryKey({ id: bookingId }), + }), + queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }), + queryClient.invalidateQueries({ queryKey: ["companies", "getDashboard"] }), + ]); + onApproved?.(); + onClose(); + }, + onError: (error) => { + const message = errorMessage(error); + toast.error(message); + if (message.toLowerCase().includes("save your signature")) { + onClose(); + navigate("/signature"); + } else if (/demurrage|storage|warehouse invoice|fully paid/i.test(message)) { + onClose(); + navigate("/billing"); + } + }, + }); + + const busy = approve.isPending || handoverMutation.isPending; + + return ( + + + }> + + Review the handover document below. Approving applies your saved signature + and confirms you received the goods. + + + + {isLoading ? ( + + + + Loading handover document… + + + ) : isError || !pdfUrl ? ( + + Could not load the handover document. It may not be generated yet. + + ) : ( +