diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts index 3d9fa4254..ff52ff9d7 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts @@ -48,6 +48,19 @@ export class LastMileRequestsController { return this.requestsService.freeTruckCount().then((count) => ({ count })); } + // Customer-facing like :id — booking detail (portal + backoffice) lists the + // booking's requests to link the stored LM contract. Ownership-checked in + // the service for portal callers. + @Get('by-booking/:bookingId') + @MixedAudience(FREIGHT_PERMS.lastMile.requestView) + @ApiOperation({ summary: "A booking's last-mile requests, newest first — LM contract reference" }) + findForBooking( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @CurrentUser() user: TCurrentUser, + ) { + return this.requestsService.findForBooking(bookingId, user?.id ?? null); + } + @Get(':id/price-estimate') @BookingStaff(FREIGHT_PERMS.lastMile.requestView) @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts index ebdc93746..37612d318 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts @@ -221,6 +221,28 @@ export class LastMileRequestsService { return record; } + /** + * Every request on a booking, newest first — the booking-detail pages + * (portal + backoffice) use this to surface the LM contract later. Portal + * callers pass their userId and are ownership-checked against the booking's + * company, mirroring findById. + */ + async findForBooking(bookingId: string, userId?: string | null): Promise { + if (userId) { + const companyId = await this.bookingsService.resolveCustomerCompanyId(userId); + if (companyId) { + const booking = await this.bookingsRepository.findById(bookingId); + if (booking?.companyId && booking.companyId !== companyId) { + throw new BadRequestException('This booking does not belong to your company'); + } + } + } + return this.requestsRepository.findAll({ + where: { bookingId }, + order: { createdAt: 'DESC' }, + }); + } + /** * Rule-based price estimate for the approval dialog: estimated km (yard GPS → * delivery point, straight-line) × the LIVE last-mile rate rules against the diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index fc9395ffb..6c89c6980 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -3002,6 +3002,9 @@ export class TrainSchedulingService { .map((wagon) => ({ sequenceNo: wagon.sequenceNo, wagonNumber: wagon.physicalWagon?.wagonNumber ?? null, + wagonType: wagon.wagonType?.code ?? wagon.wagonType?.name ?? null, + tareWeightTons: wagon.wagonType?.tareWeightTons ?? null, + equatedLengthM: wagon.wagonType?.equatedLengthM ?? null, allocations: (wagon.allocations ?? []).map((allocation) => ({ bookingId: allocation.bookingId, bookingReference: allocation.booking?.reference ?? null, @@ -3501,26 +3504,37 @@ export class TrainSchedulingService { const allocationRows = loadList.wagons .flatMap((wagon) => { const wagonCells = `${esc(wagon.sequenceNo)} - ${esc(wagon.wagonNumber)}`; + ${esc(wagon.wagonNumber)} + ${esc(wagon.wagonType)} + ${wagon.tareWeightTons == null ? '-' : esc(Number(wagon.tareWeightTons).toFixed(2))} + ${wagon.equatedLengthM == null ? '-' : esc(Number(wagon.equatedLengthM).toFixed(3))} + ${esc(loadList.origin)} + ${esc(loadList.destination)}`; // An empty wagon still runs in the consist, so it still gets a line — see // buildExportLoadListHtml. if (wagon.allocations.length === 0) { return [ ` ${wagonCells} - EMPTY — no cargo allocated + EMPTY — no cargo allocated `, ]; } return wagon.allocations.map( (allocation) => { const companyName = (allocation.booking as unknown as { company?: { name?: string } } | undefined)?.company?.name ?? '-'; + const sealNumbers = (allocation.containerItems ?? []) + .map((item) => item.sealNumber) + .filter(Boolean) + .join(', '); return ` ${wagonCells} ${esc(allocation.bookingReference ?? allocation.bookingId)} ${esc(companyName)} ${esc(allocation.loadType)} ${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')} + ${esc(sealNumbers || '-')} + ${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))} `; }, @@ -3609,15 +3623,22 @@ export class TrainSchedulingService { Seq Wagon + Wagon Type + Tare + Equated + Departure Station + Arrival Station Booking Company Load Container numbers + Seal No + Note Weight T - ${allocationRows || 'No wagons on this train set.'} + ${allocationRows || 'No wagons on this train set.'} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingMileServicesCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingMileServicesCard.tsx index 3236e5cae..0a079bb4d 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingMileServicesCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingMileServicesCard.tsx @@ -1,6 +1,9 @@ -import { Truck } from "lucide-react"; -import { SimpleGrid } from "@mantine/core"; +import { Download, Truck } from "lucide-react"; +import { Button, Group, SimpleGrid, Stack, Text } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; +import { lastMileRequestsService } from "@/services/last-mile-requests.service"; import type { BookingDetail } from "@/types/booking"; import { SectionCard } from "./SectionCard"; @@ -10,12 +13,37 @@ export interface BookingMileServicesCardProps { booking: BookingDetail; } -/** First / last mile addresses. Renders nothing when neither is present. */ +/** + * First / last mile addresses, plus the stored last-mile contract reference + * (signed status + PDF download) for Truck & Machinery once a request on this + * booking is approved. Renders nothing when neither address is present. + */ export function BookingMileServicesCard({ booking }: BookingMileServicesCardProps) { + const { data: requestsResponse } = useQuery({ + queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.list({ bookingId: booking.id }), + queryFn: async () => + (await lastMileRequestsService.list({ bookingId: booking.id })).data, + enabled: Boolean(booking.lastMileDeliveryAddress), + }); + const approvedRequest = (requestsResponse?.data ?? []).find( + (r) => r.status === "APPROVED", + ); + if (!booking.firstMilePickupAddress && !booking.lastMileDeliveryAddress) { return null; } + const downloadContract = async () => { + if (!approvedRequest) return; + const blob = (await lastMileRequestsService.contractDocument(approvedRequest.id)).data; + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `last-mile-contract-${booking.reference ?? booking.id}.pdf`; + a.click(); + URL.revokeObjectURL(url); + }; + return ( @@ -26,6 +54,32 @@ export function BookingMileServicesCard({ booking }: BookingMileServicesCardProp )} + {approvedRequest && ( + + + + Last-mile contract + + + {approvedRequest.customerSignedAt + ? `Signed ${new Date(approvedRequest.customerSignedAt).toLocaleDateString()}${ + approvedRequest.signerDisplayName + ? ` by ${approvedRequest.signerDisplayName}` + : "" + }` + : "Awaiting customer signature"} + + + + + )} ); } diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx index de954742e..25c20c8db 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx @@ -29,6 +29,7 @@ import { // Repeat, // used by the hidden Move (reassign) button Train, TrainFront, + Truck, Weight, X, } from "lucide-react"; @@ -37,6 +38,7 @@ import { CountdownTimer } from "@edr/ui-common"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { api } from "@/services/api"; +import { bookingsService } from "@/services/bookings.service"; import { useToast } from "@/hooks/use-toast"; import type { EligibleContainerBooking, @@ -411,6 +413,31 @@ export function ScheduleWorkspacePanel({ ); }; + // Export cargo that skipped the warehouse (customer truck straight onto the + // wagon) has no GRN and never will — loadBooking's GRN gate would keep + // rejecting it forever. Setting DIRECT_TO_TRAIN tells that gate the carriage + // acceptance sheet is the handover document instead, then loads in one click. + const [truckToTrainPending, setTruckToTrainPending] = useState(null); + const doTruckToTrain = (bookingId: string, ref: string) => { + setTruckToTrainPending(bookingId); + bookingsService + .setExportHandoverMode(bookingId, "DIRECT_TO_TRAIN") + .then(() => loadJourney.mutateAsync({ scheduleId: schedule.id, bookingId })) + .then(() => { + toast({ title: `${ref} loaded — direct truck-to-train handover` }); + onChanged(); + void yardWorkQuery.refetch(); + }) + .catch((error) => + toast({ + title: "Could not load as direct truck-to-train", + description: apiErrorMessage(error, "Please try again."), + variant: "destructive", + }), + ) + .finally(() => setTruckToTrainPending(null)); + }; + const doUnload = (bookingId: string, ref: string) => { unloadJourney .mutateAsync({ scheduleId: schedule.id, bookingId }) @@ -708,6 +735,12 @@ export function ScheduleWorkspacePanel({ const alightHere = trainAtYardId != null && b.destinationYardId === trainAtYardId; const showLoad = canWork && !riding && !done && (journey?.canLoad ?? false); const showUnload = canWork && riding && (journey?.canUnload ?? false); + const showTruckToTrain = + canWork && + !riding && + !done && + boardHere && + b.tradeDirection === "EXPORT"; return ( ) : null} + {showTruckToTrain ? ( + + + + ) : null} {showUnload ? ( `/api/last-mile-requests/by-booking/${bookingId}`, BY_ID: (id: string) => `/api/last-mile-requests/${id}`, SUBMIT: (id: string) => `/api/last-mile-requests/${id}/submit`, CONTRACT_VIEW: (id: string) => `/api/last-mile-requests/${id}/contract/view`, 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 index bfb57529f..6619dbd06 100644 --- 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 @@ -1,5 +1,6 @@ -import { Box, Group, Stack, Text } from "@mantine/core"; +import { Box, Button, Group, Stack, Text } from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; +import { useNavigate } from "react-router-dom"; import type { Freight } from "@edr/types"; @@ -8,6 +9,7 @@ import type { MileLegSummary, MileVehicleSummary, } from "@/services/bookings.service"; +import { lastMileRequestsService } from "@/services/last-mile-requests.service"; import { CardTitle, SectionCard } from "./layout"; @@ -171,12 +173,85 @@ function LegBlock({ ); } +/** + * Reference row for the stored last-mile contract: signed status, open the + * contract page (view / sign), download the PDF. + */ +function LastMileContractRow({ + bookingId, + requestId, + signedAt, + signerDisplayName, +}: { + bookingId: string; + requestId: string; + signedAt?: string | null; + signerDisplayName?: string | null; +}) { + const navigate = useNavigate(); + const download = async () => { + const blob = await lastMileRequestsService.downloadContractDocument(requestId); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "last-mile-contract.pdf"; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( + + + + Last-mile contract + + + {signedAt + ? `Signed ${new Date(signedAt).toLocaleDateString()}${ + signerDisplayName ? ` by ${signerDisplayName}` : "" + }` + : "Awaiting your signature"} + + + + + + + + ); +} + export function MileSummaryCard({ booking }: { booking: Freight.IBooking }) { const { data } = useQuery({ queryKey: ["booking-mile-summary", booking.id], queryFn: () => bookingsService.mileSummary(booking.id), }); + // The stored LM contract lives on the booking's approved last-mile request. + const { data: lmRequests } = useQuery({ + queryKey: ["booking-last-mile-requests", booking.id], + queryFn: () => lastMileRequestsService.listForBooking(booking.id), + enabled: !!booking.lastMileDeliveryAddress, + }); + const approvedRequest = (lmRequests ?? []).find((r) => r.status === "APPROVED"); + const firstLeg = data?.firstMile ?? null; const lastLeg = data?.lastMile ?? null; @@ -202,11 +277,21 @@ export function MileSummaryCard({ booking }: { booking: Freight.IBooking }) { /> )} {showLast && ( - + + + {approvedRequest && ( + + )} + )} diff --git a/apps/edr-freight-web/portal/src/services/last-mile-requests.service.ts b/apps/edr-freight-web/portal/src/services/last-mile-requests.service.ts index 5069eafe3..08133ba08 100644 --- a/apps/edr-freight-web/portal/src/services/last-mile-requests.service.ts +++ b/apps/edr-freight-web/portal/src/services/last-mile-requests.service.ts @@ -12,6 +12,7 @@ export interface LastMileRequest { requestedContainerNumbers?: string[] | null; requestedDeliveryDate?: string | null; customerSignedAt?: string | null; + signerDisplayName?: string | null; rejectionReason?: string | null; createdAt: string; updatedAt: string; @@ -46,6 +47,12 @@ export const lastMileRequestsService = { return data.data ?? data; }, + /** The booking's requests, newest first — links the stored LM contract. */ + listForBooking: async (bookingId: string): Promise => { + const { data } = await client.get(L.BY_BOOKING(bookingId)); + return data.data ?? data; + }, + /** Confirm which containers go via EDR last-mile and the requested delivery date. */ submit: async ( id: string,