feat(train-scheduling): add wagon type, tare, equated length, station, seal no and note columns to import marshalling doc

Import Load List / Marshalling Document only rendered Seq, Wagon,
Booking, Company, Load, Container numbers, Weight T — missing fields
present on the physical marshaling sheet (wagon type, tare, equated
length, departure/arrival station, seal no) and a blank note column
for yard staff. Export marshalling doc already had most of these;
import doc now matches. Existing columns kept in place, unchanged.
This commit is contained in:
Hagernesh
2026-08-13 08:22:51 +00:00
parent f5e98ae67c
commit 833e62990e
7 changed files with 215 additions and 12 deletions

View File

@@ -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({

View File

@@ -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<LastMileRequest[]> {
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

View File

@@ -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 = `<td>${esc(wagon.sequenceNo)}</td>
<td>${esc(wagon.wagonNumber)}</td>`;
<td>${esc(wagon.wagonNumber)}</td>
<td>${esc(wagon.wagonType)}</td>
<td class="num">${wagon.tareWeightTons == null ? '-' : esc(Number(wagon.tareWeightTons).toFixed(2))}</td>
<td class="num">${wagon.equatedLengthM == null ? '-' : esc(Number(wagon.equatedLengthM).toFixed(3))}</td>
<td>${esc(loadList.origin)}</td>
<td>${esc(loadList.destination)}</td>`;
// An empty wagon still runs in the consist, so it still gets a line — see
// buildExportLoadListHtml.
if (wagon.allocations.length === 0) {
return [
`<tr class="empty">
${wagonCells}
<td colspan="4">EMPTY — no cargo allocated</td>
<td colspan="7">EMPTY — no cargo allocated</td>
</tr>`,
];
}
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 `<tr>
${wagonCells}
<td>${esc(allocation.bookingReference ?? allocation.bookingId)}</td>
<td>${esc(companyName)}</td>
<td>${esc(allocation.loadType)}</td>
<td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td>
<td>${esc(sealNumbers || '-')}</td>
<td></td>
<td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td>
</tr>`;
},
@@ -3609,15 +3623,22 @@ export class TrainSchedulingService {
<tr>
<th>Seq</th>
<th>Wagon</th>
<th>Wagon Type</th>
<th class="num">Tare</th>
<th class="num">Equated</th>
<th>Departure Station</th>
<th>Arrival Station</th>
<th>Booking</th>
<th>Company</th>
<th>Load</th>
<th>Container numbers</th>
<th>Seal No</th>
<th>Note</th>
<th class="num">Weight T</th>
</tr>
</thead>
<tbody>
${allocationRows || '<tr><td colspan="6">No wagons on this train set.</td></tr>'}
${allocationRows || '<tr><td colspan="14">No wagons on this train set.</td></tr>'}
</tbody>
</table>

View File

@@ -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 (
<SectionCard icon={Truck} title="Mile services" accent="grape">
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm">
@@ -26,6 +54,32 @@ export function BookingMileServicesCard({ booking }: BookingMileServicesCardProp
<MetricTile label="Last mile delivery" value={booking.lastMileDeliveryAddress} />
)}
</SimpleGrid>
{approvedRequest && (
<Group justify="space-between" align="center" wrap="wrap" mt="sm">
<Stack gap={0}>
<Text size="sm" fw={600}>
Last-mile contract
</Text>
<Text size="xs" c={approvedRequest.customerSignedAt ? "green.8" : "orange.8"}>
{approvedRequest.customerSignedAt
? `Signed ${new Date(approvedRequest.customerSignedAt).toLocaleDateString()}${
approvedRequest.signerDisplayName
? ` by ${approvedRequest.signerDisplayName}`
: ""
}`
: "Awaiting customer signature"}
</Text>
</Stack>
<Button
size="xs"
variant="light"
leftSection={<Download size={14} />}
onClick={() => void downloadContract()}
>
Download PDF
</Button>
</Group>
)}
</SectionCard>
);
}

View File

@@ -224,6 +224,7 @@ export const URL_CONSTANTS = {
},
LAST_MILE_REQUESTS: {
BY_BOOKING: (bookingId: string) => `/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`,

View File

@@ -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 (
<Group
justify="space-between"
align="center"
wrap="wrap"
pt={8}
mt={4}
style={{ borderTop: "1px solid #F2F5F8" }}
>
<Stack gap={2} style={{ minWidth: 0 }}>
<Text fz="13px" fw={700} c="#10202F">
Last-mile contract
</Text>
<Text fz="12px" c={signedAt ? "#0A6F4D" : "#B45309"}>
{signedAt
? `Signed ${new Date(signedAt).toLocaleDateString()}${
signerDisplayName ? ` by ${signerDisplayName}` : ""
}`
: "Awaiting your signature"}
</Text>
</Stack>
<Group gap={8}>
<Button
size="xs"
variant="light"
onClick={() =>
navigate(`/bookings/${bookingId}/last-mile-contract?requestId=${requestId}`)
}
>
{signedAt ? "View contract" : "View & sign"}
</Button>
<Button size="xs" variant="default" onClick={() => void download()}>
Download PDF
</Button>
</Group>
</Group>
);
}
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 && (
<LegBlock
title="Last mile"
leg={lastLeg}
address={booking.lastMileDeliveryAddress}
/>
<Box>
<LegBlock
title="Last mile"
leg={lastLeg}
address={booking.lastMileDeliveryAddress}
/>
{approvedRequest && (
<LastMileContractRow
bookingId={booking.id}
requestId={approvedRequest.id}
signedAt={approvedRequest.customerSignedAt}
signerDisplayName={approvedRequest.signerDisplayName}
/>
)}
</Box>
)}
</Stack>
</SectionCard>

View File

@@ -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<LastMileRequest[]> => {
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,