From 1b1b0e7ade727bc1478a90992499c950ba157a6b Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 29 Jul 2026 11:12:32 +0000 Subject: [PATCH] feat(booking-trucks-tab): show truck-level detention, gate times, inspection status, and cargo costs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Booking detail now has a Trucks tab displaying every EDR or customer truck assigned to a booking's last mile. Each row shows warehouse-gate times (arrival/departure) and destination-detention times (arrival/return), with detention costs and inspection status. Detention rows only for EDR trucks; customer self-haul shows —. Reuses existing TruckDetentionModal and FeePreviewModal for edit/view actions. Backend: arrivalTrucksForBooking() adds lastMileId per truck (direct chain to detention preview), containerItems() adds inspection_status column to responses. No new SQL, no migrations, no new endpoints. Frontend: BookingTrucksPanel.tsx self-fetches all data via existing warehouse + last-mile services, renders adaptive table with cargo-cost strip above. Wired into BookingRequestDetailPage.tsx tab bar. --- .../modules/last-mile/last-mile.service.ts | 4 + .../warehouses/warehouse-inventory.service.ts | 6 +- .../bookings/detail/BookingTrucksPanel.tsx | 301 ++++++++++++++++++ .../src/components/bookings/detail/index.ts | 1 + .../bookings/BookingRequestDetailPage.tsx | 12 +- .../src/services/warehouse.service.ts | 3 + 6 files changed, 325 insertions(+), 2 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx 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 d7c45bb3c..6db55b66d 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 @@ -327,6 +327,8 @@ export class LastMileService { */ async arrivalTrucksForBooking(bookingId: string): Promise< Array<{ + /** The last-mile leg this truck belongs to — lets a caller chain straight into truck-detention-preview without a separate lookup. */ + lastMileId: string; vehicleId: string; truckPlateNumber: string | null; trailerPlateNumber: string | null; @@ -359,6 +361,7 @@ export class LastMileService { : []; const out: Array<{ + lastMileId: string; vehicleId: string; truckPlateNumber: string | null; trailerPlateNumber: string | null; @@ -386,6 +389,7 @@ export class LastMileService { } } out.push({ + lastMileId: lm.id, vehicleId: vehicle.id, truckPlateNumber: vehicle.powerPlateNo || vehicle.plateNumber || null, trailerPlateNumber: vehicle.trailerPlateNo || null, 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 c0b3cc060..7cd47f203 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 @@ -3679,6 +3679,7 @@ export class WarehouseInventoryService { contractId: string | null; hasLastMile: boolean; handoverSigned: boolean; + inspectionStatus: string | null; }> > { const rows: Array<{ @@ -3696,6 +3697,7 @@ export class WarehouseInventoryService { contractId: string | null; hasLastMile: boolean; delivered: boolean; + inspectionStatus: string | null; }> = await this.dataSource.query( `SELECT bcu.container_number AS "containerNumber", COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods, @@ -3710,7 +3712,8 @@ export class WarehouseInventoryService { b.reference AS "bookingReference", b.contract_id AS "contractId", (b.last_mile_delivery_address IS NOT NULL) AS "hasLastMile", - COALESCE(inv.status = 'DELIVERED', false) AS delivered + COALESCE(inv.status = 'DELIVERED', false) AS delivered, + inv.inspection_status AS "inspectionStatus" FROM freight.booking_container_units bcu JOIN freight.booking_container bc ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL @@ -3762,6 +3765,7 @@ export class WarehouseInventoryService { bookingReference: r.bookingReference, contractId: r.contractId, hasLastMile: r.hasLastMile, + inspectionStatus: r.inspectionStatus, handoverSigned, })); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx new file mode 100644 index 000000000..1a77632ec --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx @@ -0,0 +1,301 @@ +import { useMemo, useState } from "react"; +import { useQueries, useQuery } from "@tanstack/react-query"; +import { Badge, Button, Center, Group, Loader, SimpleGrid, Stack, Table, Text } from "@mantine/core"; +import { Coins, Truck } from "lucide-react"; + +import { api } from "@/services/api"; +import { warehouseService } from "@/services/warehouse.service"; +import { lastMileService } from "@/services/last-mile.service"; +import { FeePreviewModal } from "@/components/warehouses/FeePreviewModal"; +import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal"; + +import { SectionCard } from "./SectionCard"; +import { MetricTile } from "./MetricTile"; + +const money = (amount: number, currency: string) => + `${Number(amount).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`; + +const fmt = (iso: string | null | undefined) => (iso ? new Date(iso).toLocaleString() : "—"); + +function inspectionLabel(status: string | null | undefined): { text: string; color: string } { + if (!status) return { text: "Pending", color: "gray" }; + if (status === "PASSED") return { text: "Passed", color: "edr-green" }; + if (status === "FAILED") return { text: "Failed", color: "red" }; + return { text: status, color: "gray" }; +} + +interface TruckRow { + key: string; + plate: string; + driver: string | null; + truckType: string | null; + containers: string[]; + warehouseArrived: string | null; + warehouseDeparted: string | null; + destinationArrived: string | null; + returned: string | null; + detentionOpen: boolean; + detentionDays: number | null; + detentionAmount: number | null; + hasDetentionRule: boolean; + inspection: { text: string; color: string }; +} + +/** + * Every truck tied to a booking's last mile — EDR-dispatched or customer + * self-haul (a booking only ever uses one), each with its own warehouse-gate + * and destination-detention clocks, plus the booking's cargo-side cost totals + * (storage/demurrage/double handling — billed per row internally, always + * shown here as one booking-level total). Detention stays EDR-only; customer + * self-haul rows show "—" since EDR only bills detention on its own fleet. + */ +export function BookingTrucksPanel({ bookingId }: { bookingId: string }) { + const [feeModalOpen, setFeeModalOpen] = useState(false); + const [detentionModalOpen, setDetentionModalOpen] = useState(false); + + const inventoryQuery = useQuery( + api.warehouses.listInventory.queryOptions({ input: { filter: { bookingId } } }), + ); + const inventoryItems = inventoryQuery.data ?? []; + const latestInventory = inventoryItems[0] ?? null; + + const edrTrucksQuery = useQuery({ + queryKey: ["booking-edr-trucks", bookingId], + queryFn: () => warehouseService.getLastMileTrucks(bookingId), + }); + const edrTrucks = edrTrucksQuery.data ?? []; + + const customerTrucksQuery = useQuery({ + queryKey: ["booking-customer-trucks", bookingId], + queryFn: () => warehouseService.getCustomerTrucks(bookingId), + enabled: edrTrucksQuery.isSuccess && edrTrucks.length === 0, + }); + const customerTrucks = customerTrucksQuery.data ?? []; + + const mode: "EDR" | "CUSTOMER" | "NONE" = + edrTrucks.length > 0 ? "EDR" : customerTrucks.length > 0 ? "CUSTOMER" : "NONE"; + + const containerItemsQuery = useQuery({ + queryKey: ["booking-container-items-for-trucks", bookingId], + queryFn: () => warehouseService.getContainerItems(bookingId), + }); + const inspectionByContainer = new Map( + (containerItemsQuery.data ?? []).map((c) => [c.containerNumber, c.inspectionStatus]), + ); + + const lastMileId = edrTrucks[0]?.lastMileId ?? null; + + const detentionPreviewQuery = useQuery({ + queryKey: ["truck-detention-preview-for-trucks-tab", lastMileId], + queryFn: () => lastMileService.truckDetentionPreview(lastMileId as string).then((r) => r.data), + enabled: Boolean(lastMileId), + }); + const detentionPreview = detentionPreviewQuery.data; + const detentionByVehicle = new Map( + (detentionPreview?.groups ?? []).map((g) => [g.vehicleId ?? "", g]), + ); + + const lastMileRecordQuery = useQuery({ + queryKey: ["last-mile-record-for-trucks-tab", lastMileId], + queryFn: () => lastMileService.getById(lastMileId as string).then((r) => r.data), + enabled: Boolean(lastMileId), + }); + + // Booking-level cost strip: same per-row fee preview the accrual dashboard + // and FeePreviewModal already use, summed across every inventory row on + // this booking rather than duplicated per row. + const feeQueries = useQueries({ + queries: inventoryItems.map((item) => + api.warehouses.feePreview.queryOptions({ input: { inventoryId: item.id, billingCurrency: "USD" } }), + ), + }); + const allFees = feeQueries.flatMap((q) => q.data ?? []); + const feeCurrency = allFees[0]?.currency ?? "USD"; + const sumByType = (type: string) => + allFees.filter((f) => f.ruleType === type).reduce((sum, f) => sum + Number(f.amount || 0), 0); + + const rows: TruckRow[] = useMemo(() => { + if (mode === "EDR") { + return edrTrucks.map((t) => { + const g = detentionByVehicle.get(t.vehicleId); + return { + key: t.vehicleId, + plate: [t.truckPlateNumber, t.trailerPlateNumber].filter(Boolean).join(" + ") || "—", + driver: t.driverName, + truckType: t.truckType, + containers: t.containerNumber ? [t.containerNumber] : [], + warehouseArrived: t.arrivedAt, + warehouseDeparted: t.departedAt, + destinationArrived: g?.startDate ?? null, + returned: g?.endIsOpen ? null : g?.endDate ?? null, + detentionOpen: Boolean(g?.endIsOpen), + detentionDays: g?.chargeableDays ?? null, + detentionAmount: g?.amount ?? null, + hasDetentionRule: Boolean(g?.ruleId), + inspection: inspectionLabel(t.containerNumber ? inspectionByContainer.get(t.containerNumber) : undefined), + }; + }); + } + if (mode === "CUSTOMER") { + return customerTrucks.map((t) => { + const containers = (t.containers ?? []).map((c) => c.containerNumber); + const statuses = new Set(containers.map((cn) => inspectionByContainer.get(cn) ?? null)); + const inspection = + containers.length === 0 + ? inspectionLabel(undefined) + : statuses.size > 1 + ? { text: "Mixed", color: "yellow" } + : inspectionLabel([...statuses][0]); + return { + key: t.id, + plate: t.plateNumber, + driver: t.driverName, + truckType: t.truckType, + containers, + warehouseArrived: t.arrivedAt ?? null, + warehouseDeparted: t.departedAt ?? null, + destinationArrived: null, + returned: null, + detentionOpen: false, + detentionDays: null, + detentionAmount: null, + hasDetentionRule: false, + inspection, + }; + }); + } + return []; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mode, edrTrucks, customerTrucks, inspectionByContainer, detentionByVehicle]); + + if (inventoryQuery.isLoading || edrTrucksQuery.isLoading) { + return ( +
+ + + Loading trucks… + +
+ ); + } + + return ( + + setFeeModalOpen(true)}> + View breakdown + + ) + } + > + + + + + + + + setDetentionModalOpen(true)}> + Detention times + + ) + } + > + {rows.length === 0 ? ( + + No trucks assigned to this booking's last mile yet. + + ) : ( + + + + + Plate + Driver + Type + Container(s) + Wh. arrived + Wh. departed + Dest. arrived + Returned + Detention + Inspection + + + + {rows.map((r) => ( + + {r.plate} + {r.driver ?? "—"} + {r.truckType ?? "—"} + {r.containers.length ? r.containers.join(", ") : "—"} + {fmt(r.warehouseArrived)} + {fmt(r.warehouseDeparted)} + {fmt(r.destinationArrived)} + + {r.detentionOpen ? ( + + still out + + ) : ( + fmt(r.returned) + )} + + + {mode !== "EDR" || r.detentionDays == null ? ( + "—" + ) : ( + <> + {r.detentionDays}d · {money(r.detentionAmount ?? 0, detentionPreview?.currency ?? "USD")} + {!r.hasDetentionRule && ( + + {" "} + · no rule + + )} + + )} + + + + {r.inspection.text} + + + + ))} + +
+
+ )} +
+ + setFeeModalOpen(false)} + inventoryId={latestInventory?.id ?? null} + /> + {mode === "EDR" && ( + setDetentionModalOpen(false)} + record={lastMileRecordQuery.data ?? null} + /> + )} +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts index f4a677991..ecbb0488e 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts @@ -2,6 +2,7 @@ export * from "./booking-detail.styles"; export * from "./SectionCard"; export * from "./ClearanceReviewSection"; export * from "./BookingDocumentsPanel"; +export * from "./BookingTrucksPanel"; export * from "./ContractOrdersPanel"; export * from "./MetricTile"; export * from "./BookingDetailToolbar"; diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx index fbd851d73..657bcc0ae 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -6,6 +6,7 @@ import { LayoutGrid, Milestone, Package, + Truck, } from "lucide-react"; import { Container, @@ -36,6 +37,7 @@ import { BookingContractSummaryCard, BookingContainerUnitsCard, BookingDocumentsPanel, + BookingTrucksPanel, ContractOrdersPanel, } from "@/components/bookings/detail"; import { WarehouseInfoCard } from "@/components/warehouses"; @@ -141,7 +143,9 @@ export default function BookingRequestDetailPage() { ? "orders" : requestedTab === "documents" ? "documents" - : "overview"; + : requestedTab === "trucks" + ? "trucks" + : "overview"; const setActiveTab = (tab: string | null) => { const next = new URLSearchParams(searchParams); if (tab && tab !== "overview") next.set("tab", tab); @@ -207,6 +211,9 @@ export default function BookingRequestDetailPage() { > Documents + }> + Trucks + @@ -223,6 +230,9 @@ export default function BookingRequestDetailPage() { + + + diff --git a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts index f32195b69..858526a4f 100644 --- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts @@ -91,6 +91,7 @@ export interface ContainerItem { contractId: string | null; hasLastMile: boolean; handoverSigned: boolean; + inspectionStatus: string | null; } /** A pre-dispatch EXPORT train that has inventory waiting to be loaded. */ @@ -136,6 +137,8 @@ const cleanParams = (params: object) => /** An assigned EDR last-mile truck, shaped for the arrival/exit weighing prefill. */ export interface LastMileArrivalTruck { + /** The last-mile leg this truck belongs to — feed straight into lastMileService.truckDetentionPreview(lastMileId). */ + lastMileId: string; vehicleId: string; truckPlateNumber: string | null; trailerPlateNumber: string | null;