mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -48,6 +48,19 @@ export class LastMileRequestsController {
|
|||||||
return this.requestsService.freeTruckCount().then((count) => ({ count }));
|
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')
|
@Get(':id/price-estimate')
|
||||||
@BookingStaff(FREIGHT_PERMS.lastMile.requestView)
|
@BookingStaff(FREIGHT_PERMS.lastMile.requestView)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -221,6 +221,28 @@ export class LastMileRequestsService {
|
|||||||
return record;
|
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 →
|
* Rule-based price estimate for the approval dialog: estimated km (yard GPS →
|
||||||
* delivery point, straight-line) × the LIVE last-mile rate rules against the
|
* delivery point, straight-line) × the LIVE last-mile rate rules against the
|
||||||
|
|||||||
@@ -3002,6 +3002,9 @@ export class TrainSchedulingService {
|
|||||||
.map((wagon) => ({
|
.map((wagon) => ({
|
||||||
sequenceNo: wagon.sequenceNo,
|
sequenceNo: wagon.sequenceNo,
|
||||||
wagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
|
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) => ({
|
allocations: (wagon.allocations ?? []).map((allocation) => ({
|
||||||
bookingId: allocation.bookingId,
|
bookingId: allocation.bookingId,
|
||||||
bookingReference: allocation.booking?.reference ?? null,
|
bookingReference: allocation.booking?.reference ?? null,
|
||||||
@@ -3501,26 +3504,37 @@ export class TrainSchedulingService {
|
|||||||
const allocationRows = loadList.wagons
|
const allocationRows = loadList.wagons
|
||||||
.flatMap((wagon) => {
|
.flatMap((wagon) => {
|
||||||
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
|
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
|
// An empty wagon still runs in the consist, so it still gets a line — see
|
||||||
// buildExportLoadListHtml.
|
// buildExportLoadListHtml.
|
||||||
if (wagon.allocations.length === 0) {
|
if (wagon.allocations.length === 0) {
|
||||||
return [
|
return [
|
||||||
`<tr class="empty">
|
`<tr class="empty">
|
||||||
${wagonCells}
|
${wagonCells}
|
||||||
<td colspan="4">EMPTY — no cargo allocated</td>
|
<td colspan="7">EMPTY — no cargo allocated</td>
|
||||||
</tr>`,
|
</tr>`,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
return wagon.allocations.map(
|
return wagon.allocations.map(
|
||||||
(allocation) => {
|
(allocation) => {
|
||||||
const companyName = (allocation.booking as unknown as { company?: { name?: string } } | undefined)?.company?.name ?? '-';
|
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>
|
return `<tr>
|
||||||
${wagonCells}
|
${wagonCells}
|
||||||
<td>${esc(allocation.bookingReference ?? allocation.bookingId)}</td>
|
<td>${esc(allocation.bookingReference ?? allocation.bookingId)}</td>
|
||||||
<td>${esc(companyName)}</td>
|
<td>${esc(companyName)}</td>
|
||||||
<td>${esc(allocation.loadType)}</td>
|
<td>${esc(allocation.loadType)}</td>
|
||||||
<td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</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>
|
<td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
},
|
},
|
||||||
@@ -3609,15 +3623,22 @@ export class TrainSchedulingService {
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Seq</th>
|
<th>Seq</th>
|
||||||
<th>Wagon</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>Booking</th>
|
||||||
<th>Company</th>
|
<th>Company</th>
|
||||||
<th>Load</th>
|
<th>Load</th>
|
||||||
<th>Container numbers</th>
|
<th>Container numbers</th>
|
||||||
|
<th>Seal No</th>
|
||||||
|
<th>Note</th>
|
||||||
<th class="num">Weight T</th>
|
<th class="num">Weight T</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { Truck } from "lucide-react";
|
import { Download, Truck } from "lucide-react";
|
||||||
import { SimpleGrid } from "@mantine/core";
|
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 type { BookingDetail } from "@/types/booking";
|
||||||
|
|
||||||
import { SectionCard } from "./SectionCard";
|
import { SectionCard } from "./SectionCard";
|
||||||
@@ -10,12 +13,37 @@ export interface BookingMileServicesCardProps {
|
|||||||
booking: BookingDetail;
|
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) {
|
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) {
|
if (!booking.firstMilePickupAddress && !booking.lastMileDeliveryAddress) {
|
||||||
return null;
|
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 (
|
return (
|
||||||
<SectionCard icon={Truck} title="Mile services" accent="grape">
|
<SectionCard icon={Truck} title="Mile services" accent="grape">
|
||||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm">
|
<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} />
|
<MetricTile label="Last mile delivery" value={booking.lastMileDeliveryAddress} />
|
||||||
)}
|
)}
|
||||||
</SimpleGrid>
|
</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>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
// Repeat, // used by the hidden Move (reassign) button
|
// Repeat, // used by the hidden Move (reassign) button
|
||||||
Train,
|
Train,
|
||||||
TrainFront,
|
TrainFront,
|
||||||
|
Truck,
|
||||||
Weight,
|
Weight,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -37,6 +38,7 @@ import { CountdownTimer } from "@edr/ui-common";
|
|||||||
|
|
||||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import type {
|
import type {
|
||||||
EligibleContainerBooking,
|
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<string | null>(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) => {
|
const doUnload = (bookingId: string, ref: string) => {
|
||||||
unloadJourney
|
unloadJourney
|
||||||
.mutateAsync({ scheduleId: schedule.id, bookingId })
|
.mutateAsync({ scheduleId: schedule.id, bookingId })
|
||||||
@@ -708,6 +735,12 @@ export function ScheduleWorkspacePanel({
|
|||||||
const alightHere = trainAtYardId != null && b.destinationYardId === trainAtYardId;
|
const alightHere = trainAtYardId != null && b.destinationYardId === trainAtYardId;
|
||||||
const showLoad = canWork && !riding && !done && (journey?.canLoad ?? false);
|
const showLoad = canWork && !riding && !done && (journey?.canLoad ?? false);
|
||||||
const showUnload = canWork && riding && (journey?.canUnload ?? false);
|
const showUnload = canWork && riding && (journey?.canUnload ?? false);
|
||||||
|
const showTruckToTrain =
|
||||||
|
canWork &&
|
||||||
|
!riding &&
|
||||||
|
!done &&
|
||||||
|
boardHere &&
|
||||||
|
b.tradeDirection === "EXPORT";
|
||||||
return (
|
return (
|
||||||
<BookingCard
|
<BookingCard
|
||||||
key={b.id}
|
key={b.id}
|
||||||
@@ -764,6 +797,24 @@ export function ScheduleWorkspacePanel({
|
|||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
|
{showTruckToTrain ? (
|
||||||
|
<Tooltip
|
||||||
|
label="Customer truck loaded straight onto the wagon — no warehouse receipt, no GRN. Sets direct truck-to-train handover and loads."
|
||||||
|
withArrow
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
variant="light"
|
||||||
|
color="blue"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Truck size={13} />}
|
||||||
|
loading={truckToTrainPending === b.id}
|
||||||
|
onClick={() => doTruckToTrain(b.id, ref)}
|
||||||
|
>
|
||||||
|
Truck to Train
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
{showUnload ? (
|
{showUnload ? (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
label={
|
label={
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ export const URL_CONSTANTS = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
LAST_MILE_REQUESTS: {
|
LAST_MILE_REQUESTS: {
|
||||||
|
BY_BOOKING: (bookingId: string) => `/api/last-mile-requests/by-booking/${bookingId}`,
|
||||||
BY_ID: (id: string) => `/api/last-mile-requests/${id}`,
|
BY_ID: (id: string) => `/api/last-mile-requests/${id}`,
|
||||||
SUBMIT: (id: string) => `/api/last-mile-requests/${id}/submit`,
|
SUBMIT: (id: string) => `/api/last-mile-requests/${id}/submit`,
|
||||||
CONTRACT_VIEW: (id: string) => `/api/last-mile-requests/${id}/contract/view`,
|
CONTRACT_VIEW: (id: string) => `/api/last-mile-requests/${id}/contract/view`,
|
||||||
|
|||||||
@@ -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 { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
@@ -8,6 +9,7 @@ import type {
|
|||||||
MileLegSummary,
|
MileLegSummary,
|
||||||
MileVehicleSummary,
|
MileVehicleSummary,
|
||||||
} from "@/services/bookings.service";
|
} from "@/services/bookings.service";
|
||||||
|
import { lastMileRequestsService } from "@/services/last-mile-requests.service";
|
||||||
|
|
||||||
import { CardTitle, SectionCard } from "./layout";
|
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 }) {
|
export function MileSummaryCard({ booking }: { booking: Freight.IBooking }) {
|
||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
queryKey: ["booking-mile-summary", booking.id],
|
queryKey: ["booking-mile-summary", booking.id],
|
||||||
queryFn: () => bookingsService.mileSummary(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 firstLeg = data?.firstMile ?? null;
|
||||||
const lastLeg = data?.lastMile ?? null;
|
const lastLeg = data?.lastMile ?? null;
|
||||||
|
|
||||||
@@ -202,11 +277,21 @@ export function MileSummaryCard({ booking }: { booking: Freight.IBooking }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showLast && (
|
{showLast && (
|
||||||
<LegBlock
|
<Box>
|
||||||
title="Last mile"
|
<LegBlock
|
||||||
leg={lastLeg}
|
title="Last mile"
|
||||||
address={booking.lastMileDeliveryAddress}
|
leg={lastLeg}
|
||||||
/>
|
address={booking.lastMileDeliveryAddress}
|
||||||
|
/>
|
||||||
|
{approvedRequest && (
|
||||||
|
<LastMileContractRow
|
||||||
|
bookingId={booking.id}
|
||||||
|
requestId={approvedRequest.id}
|
||||||
|
signedAt={approvedRequest.customerSignedAt}
|
||||||
|
signerDisplayName={approvedRequest.signerDisplayName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface LastMileRequest {
|
|||||||
requestedContainerNumbers?: string[] | null;
|
requestedContainerNumbers?: string[] | null;
|
||||||
requestedDeliveryDate?: string | null;
|
requestedDeliveryDate?: string | null;
|
||||||
customerSignedAt?: string | null;
|
customerSignedAt?: string | null;
|
||||||
|
signerDisplayName?: string | null;
|
||||||
rejectionReason?: string | null;
|
rejectionReason?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -46,6 +47,12 @@ export const lastMileRequestsService = {
|
|||||||
return data.data ?? data;
|
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. */
|
/** Confirm which containers go via EDR last-mile and the requested delivery date. */
|
||||||
submit: async (
|
submit: async (
|
||||||
id: string,
|
id: string,
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { buildSeatSummary } from './booking-sms.utils';
|
||||||
|
|
||||||
|
const seat = (passengerName: string, seatNumber: string, leg = 1, coachType = 'VIP Bed') => ({
|
||||||
|
passengerName,
|
||||||
|
leg,
|
||||||
|
seat: { seatNumber, coach: { number: 'VIP-0001 (DJ)', coachType: { name: coachType } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildSeatSummary', () => {
|
||||||
|
it('greets a solo traveller by name and omits the name from the seat line', () => {
|
||||||
|
const { passengerName, trainSeatLines } = buildSeatSummary([seat('Yanet', '9')], 'ONE_WAY');
|
||||||
|
|
||||||
|
expect(passengerName).toBe('Yanet');
|
||||||
|
expect(trainSeatLines).toBe('VIP-0001 (DJ) VIP Bed, seat no. 9');
|
||||||
|
expect(trainSeatLines).not.toContain('Train/Seat');
|
||||||
|
expect(trainSeatLines).not.toContain('Yanet');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('greets a group collectively and names each seat', () => {
|
||||||
|
const { passengerName, trainSeatLines } = buildSeatSummary(
|
||||||
|
[seat('Yanet', '4'), seat('Abebe', '6'), seat('Sara', '9'), seat('Helen', '10')],
|
||||||
|
'ONE_WAY',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(passengerName).toBe('Passengers');
|
||||||
|
expect(trainSeatLines).toBe(
|
||||||
|
[
|
||||||
|
'Yanet, VIP-0001 (DJ) VIP Bed, seat no. 4',
|
||||||
|
'Abebe, VIP-0001 (DJ) VIP Bed, seat no. 6',
|
||||||
|
'Sara, VIP-0001 (DJ) VIP Bed, seat no. 9',
|
||||||
|
'Helen, VIP-0001 (DJ) VIP Bed, seat no. 10',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The reported bug: the seats query had no orderBy, so Postgres heap order put the LAST
|
||||||
|
// passenger first and the SMS greeted them while texting the first passenger's phone.
|
||||||
|
it('is immune to seat rows arriving in an arbitrary order', () => {
|
||||||
|
const rows = [seat('Yanet', '9'), seat('Helen', '10'), seat('Abebe', '6'), seat('Sara', '4')];
|
||||||
|
|
||||||
|
const { passengerName, trainSeatLines } = buildSeatSummary(rows, 'ONE_WAY');
|
||||||
|
|
||||||
|
expect(passengerName).toBe('Passengers');
|
||||||
|
// Every line pairs the right person with their own seat, regardless of input order.
|
||||||
|
expect(trainSeatLines).toBe(
|
||||||
|
[
|
||||||
|
'Sara, VIP-0001 (DJ) VIP Bed, seat no. 4',
|
||||||
|
'Abebe, VIP-0001 (DJ) VIP Bed, seat no. 6',
|
||||||
|
'Yanet, VIP-0001 (DJ) VIP Bed, seat no. 9',
|
||||||
|
'Helen, VIP-0001 (DJ) VIP Bed, seat no. 10',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts seat numbers numerically, not lexicographically', () => {
|
||||||
|
const { trainSeatLines } = buildSeatSummary(
|
||||||
|
[seat('A', '9'), seat('B', '10'), seat('C', '6'), seat('D', '4')],
|
||||||
|
'ONE_WAY',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(trainSeatLines.match(/seat no\. \d+/g)).toEqual([
|
||||||
|
'seat no. 4',
|
||||||
|
'seat no. 6',
|
||||||
|
'seat no. 9',
|
||||||
|
'seat no. 10',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('labels round-trip legs as Outbound/Return, listing each passenger once per leg', () => {
|
||||||
|
const { passengerName, trainSeatLines } = buildSeatSummary(
|
||||||
|
[seat('Yanet', '9', 1), seat('Abebe', '10', 1), seat('Yanet', '3', 2), seat('Abebe', '4', 2)],
|
||||||
|
'ROUND_TRIP',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(passengerName).toBe('Passengers');
|
||||||
|
expect(trainSeatLines).toBe(
|
||||||
|
[
|
||||||
|
'Outbound:',
|
||||||
|
'Yanet, VIP-0001 (DJ) VIP Bed, seat no. 9',
|
||||||
|
'Abebe, VIP-0001 (DJ) VIP Bed, seat no. 10',
|
||||||
|
'Return:',
|
||||||
|
'Yanet, VIP-0001 (DJ) VIP Bed, seat no. 3',
|
||||||
|
'Abebe, VIP-0001 (DJ) VIP Bed, seat no. 4',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// TRANSIT leg 2 is a connecting segment of the same outbound journey — never a return.
|
||||||
|
it('labels transit legs as Leg 1/Leg 2, never Return', () => {
|
||||||
|
const { trainSeatLines } = buildSeatSummary(
|
||||||
|
[seat('Yanet', '9', 1), seat('Abebe', '10', 1), seat('Yanet', '3', 2), seat('Abebe', '4', 2)],
|
||||||
|
'TRANSIT',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(trainSeatLines).toContain('Leg 1:');
|
||||||
|
expect(trainSeatLines).toContain('Leg 2:');
|
||||||
|
expect(trainSeatLines).not.toContain('Return');
|
||||||
|
expect(trainSeatLines).not.toContain('Outbound');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('labels all four round-trip-transit legs', () => {
|
||||||
|
const { trainSeatLines } = buildSeatSummary(
|
||||||
|
[1, 2, 3, 4].map((leg) => seat('Yanet', String(leg), leg)),
|
||||||
|
'ROUND_TRIP_TRANSIT',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(trainSeatLines).toBe(
|
||||||
|
[
|
||||||
|
'Outbound leg 1:',
|
||||||
|
'VIP-0001 (DJ) VIP Bed, seat no. 1',
|
||||||
|
'Outbound leg 2:',
|
||||||
|
'VIP-0001 (DJ) VIP Bed, seat no. 2',
|
||||||
|
'Return leg 1:',
|
||||||
|
'VIP-0001 (DJ) VIP Bed, seat no. 3',
|
||||||
|
'Return leg 2:',
|
||||||
|
'VIP-0001 (DJ) VIP Bed, seat no. 4',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('greets a solo round-trip traveller by name (same person on both legs)', () => {
|
||||||
|
const { passengerName } = buildSeatSummary(
|
||||||
|
[seat('Yanet', '9', 1), seat('Yanet', '3', 2)],
|
||||||
|
'ROUND_TRIP',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(passengerName).toBe('Yanet');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims a trailing space on the coach type instead of emitting "Bed , seat"', () => {
|
||||||
|
const { trainSeatLines } = buildSeatSummary([seat('Yanet', '9', 1, 'VIP Bed ')], 'ONE_WAY');
|
||||||
|
|
||||||
|
expect(trainSeatLines).toBe('VIP-0001 (DJ) VIP Bed, seat no. 9');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back safely on empty or malformed input', () => {
|
||||||
|
expect(buildSeatSummary([], 'ONE_WAY')).toEqual({ passengerName: 'Passenger', trainSeatLines: '' });
|
||||||
|
|
||||||
|
const { passengerName, trainSeatLines } = buildSeatSummary([{ leg: 1 }], 'ONE_WAY');
|
||||||
|
expect(passengerName).toBe('Passenger');
|
||||||
|
expect(trainSeatLines).toBe('-, seat no. -');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a generic leg heading for an unknown booking type', () => {
|
||||||
|
const { trainSeatLines } = buildSeatSummary(
|
||||||
|
[seat('Yanet', '9', 1), seat('Yanet', '3', 2)],
|
||||||
|
'SOMETHING_NEW',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(trainSeatLines).toContain('Leg 1:');
|
||||||
|
expect(trainSeatLines).toContain('Leg 2:');
|
||||||
|
});
|
||||||
|
});
|
||||||
115
apps/edr-passenger-api/src/common/utils/booking-sms.utils.ts
Normal file
115
apps/edr-passenger-api/src/common/utils/booking-sms.utils.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* Builds the two passenger-facing values the `booking.created` SMS/email template needs:
|
||||||
|
* the `{{passengerName}}` salutation and the `{{trainSeatLines}}` block.
|
||||||
|
*
|
||||||
|
* Why this is a shared pure helper rather than inline logic: the salutation used to be
|
||||||
|
* `seats[0]?.passengerName`, and the query loading those seats had no `orderBy`. Postgres
|
||||||
|
* returns heap order for an unordered SELECT, and an UPDATE relocates a row to the end of
|
||||||
|
* the heap — so a group booking regularly greeted the LAST passenger while texting the
|
||||||
|
* first one's phone. Deriving both values from the whole seat set, sorted deterministically,
|
||||||
|
* removes the dependency on row order entirely, and keeps the formatting unit-testable
|
||||||
|
* without a Nest testing module.
|
||||||
|
*
|
||||||
|
* Group bookings send ONE SMS to Booking.contactPhone by design — BookingSeat has no
|
||||||
|
* phone/email column, so there is no per-passenger recipient. Hence 2+ passengers are
|
||||||
|
* greeted collectively and each seat line names its own occupant.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SeatSummary {
|
||||||
|
/** Salutation: the traveller's name when solo, otherwise 'Passengers'. */
|
||||||
|
passengerName: string;
|
||||||
|
/** One line per booked seat, newline-joined, with a heading per leg on multi-leg bookings. */
|
||||||
|
trainSeatLines: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seat numbers are stored as strings of digits (Seat.seatNumber), so they must be compared
|
||||||
|
* numerically — a plain string compare orders '10' before '9'. Non-numeric labels sort last,
|
||||||
|
* then alphabetically among themselves.
|
||||||
|
*/
|
||||||
|
function compareSeatNumber(a: string, b: string): number {
|
||||||
|
const na = Number.parseInt(a, 10);
|
||||||
|
const nb = Number.parseInt(b, 10);
|
||||||
|
const aNum = Number.isNaN(na);
|
||||||
|
const bNum = Number.isNaN(nb);
|
||||||
|
if (aNum && bNum) return a.localeCompare(b);
|
||||||
|
if (aNum) return 1;
|
||||||
|
if (bNum) return -1;
|
||||||
|
return na - nb || a.localeCompare(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : v == null ? '' : String(v).trim());
|
||||||
|
|
||||||
|
const ROUND_TRIP_TRANSIT_LEGS: Record<number, string> = {
|
||||||
|
1: 'Outbound leg 1',
|
||||||
|
2: 'Outbound leg 2',
|
||||||
|
3: 'Return leg 1',
|
||||||
|
4: 'Return leg 2',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leg numbering means different things per booking type — see the enum documented on
|
||||||
|
* TicketsController.validate. TRANSIT's leg 2 is a connecting segment of the SAME outbound
|
||||||
|
* journey, so it must never be labelled 'Return'.
|
||||||
|
*/
|
||||||
|
function legLabel(bookingType: string | undefined, leg: number): string {
|
||||||
|
switch (bookingType) {
|
||||||
|
case 'ROUND_TRIP':
|
||||||
|
return leg === 1 ? 'Outbound' : leg === 2 ? 'Return' : `Leg ${leg}`;
|
||||||
|
case 'TRANSIT':
|
||||||
|
return `Leg ${leg}`;
|
||||||
|
case 'ROUND_TRIP_TRANSIT':
|
||||||
|
return ROUND_TRIP_TRANSIT_LEGS[leg] ?? `Leg ${leg}`;
|
||||||
|
default:
|
||||||
|
// Unknown or newly added booking type — degrade to a generic heading rather than guessing.
|
||||||
|
return `Leg ${leg}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSeatSummary(seats: any[], bookingType?: string): SeatSummary {
|
||||||
|
const rows = [...(seats ?? [])].sort(
|
||||||
|
(a, b) =>
|
||||||
|
(a?.leg ?? 1) - (b?.leg ?? 1) ||
|
||||||
|
str(a?.seat?.coach?.number).localeCompare(str(b?.seat?.coach?.number)) ||
|
||||||
|
compareSeatNumber(str(a?.seat?.seatNumber), str(b?.seat?.seatNumber)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Distinct travellers. A round-trip/transit booking has one row per passenger PER LEG, so
|
||||||
|
// the same name legitimately repeats — count people, not rows.
|
||||||
|
const names: string[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const name = str(row?.passengerName);
|
||||||
|
if (name && !names.includes(name)) names.push(name);
|
||||||
|
}
|
||||||
|
const isGroup = names.length > 1;
|
||||||
|
|
||||||
|
const line = (row: any): string => {
|
||||||
|
const coach = str(row?.seat?.coach?.number) || '-';
|
||||||
|
const coachType = str(row?.seat?.coach?.coachType?.name);
|
||||||
|
const seatNo = str(row?.seat?.seatNumber) || '-';
|
||||||
|
// Trim each part before joining: the coach-type name carries a trailing space in some
|
||||||
|
// records, which a `.replace(/ +/g, ' ')` collapse cannot remove (it shrinks runs of
|
||||||
|
// spaces but leaves a single one), and it surfaced as 'VIP Bed , seat no. 9'.
|
||||||
|
const where = [coach, coachType].filter(Boolean).join(' ');
|
||||||
|
const who = isGroup ? `${str(row?.passengerName) || 'Passenger'}, ` : '';
|
||||||
|
return `${who}${where}, seat no. ${seatNo}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const legs = [...new Set(rows.map((row) => row?.leg ?? 1))];
|
||||||
|
const trainSeatLines =
|
||||||
|
legs.length > 1
|
||||||
|
? legs
|
||||||
|
.map((leg) =>
|
||||||
|
[
|
||||||
|
`${legLabel(bookingType, leg)}:`,
|
||||||
|
...rows.filter((row) => (row?.leg ?? 1) === leg).map(line),
|
||||||
|
].join('\n'),
|
||||||
|
)
|
||||||
|
.join('\n')
|
||||||
|
: rows.map(line).join('\n');
|
||||||
|
|
||||||
|
return {
|
||||||
|
passengerName: isGroup ? 'Passengers' : (names[0] || 'Passenger'),
|
||||||
|
trainSeatLines,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { EmailClientService } from './email-client.service';
|
|||||||
import { SmsClientService } from './sms-client.service';
|
import { SmsClientService } from './sms-client.service';
|
||||||
import { CreateTemplateDto, UpdateTemplateDto } from './notifications.dto';
|
import { CreateTemplateDto, UpdateTemplateDto } from './notifications.dto';
|
||||||
import { resolveBookingSegment } from '../../common/utils/segment-resolver.utils';
|
import { resolveBookingSegment } from '../../common/utils/segment-resolver.utils';
|
||||||
|
import { buildSeatSummary } from '../../common/utils/booking-sms.utils';
|
||||||
|
|
||||||
export type NotificationChannelType = 'EMAIL' | 'SMS' | 'PUSH' | 'IN_APP';
|
export type NotificationChannelType = 'EMAIL' | 'SMS' | 'PUSH' | 'IN_APP';
|
||||||
|
|
||||||
@@ -305,7 +306,7 @@ export class NotificationsService {
|
|||||||
where: { id: bookingId },
|
where: { id: bookingId },
|
||||||
include: {
|
include: {
|
||||||
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
||||||
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
|
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } }, orderBy: { leg: 'asc' } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -367,30 +368,19 @@ export class NotificationsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the interpolation context for the `booking.created` template. `trainSeatLines` is a
|
* Builds the interpolation context for the `booking.created` template. `passengerName` and
|
||||||
* pre-joined block of one "Train/Seat: …" line per booked seat (multi-passenger bookings get
|
* `trainSeatLines` both come from buildSeatSummary — a solo booking is greeted by name with
|
||||||
* several lines).
|
* bare "coach, seat no." lines, while a group is greeted as "Passengers" and each line names
|
||||||
|
* its own occupant (one SMS goes to Booking.contactPhone for the whole party).
|
||||||
*/
|
*/
|
||||||
private buildBookingCreatedContext(booking: any, ref: string): Record<string, unknown> {
|
private buildBookingCreatedContext(booking: any, ref: string): Record<string, unknown> {
|
||||||
const s = booking?.schedule ?? {};
|
const s = booking?.schedule ?? {};
|
||||||
const trainName = s.train?.name ?? s.train?.number ?? '';
|
|
||||||
const fmtDate = (d: any) =>
|
const fmtDate = (d: any) =>
|
||||||
d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' }) : 'TBD';
|
d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' }) : 'TBD';
|
||||||
const fmtTime = (d: any) =>
|
const fmtTime = (d: any) =>
|
||||||
d ? new Date(d).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true }) : 'TBD';
|
d ? new Date(d).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true }) : 'TBD';
|
||||||
|
|
||||||
const seats = booking?.seats ?? [];
|
const { passengerName, trainSeatLines } = buildSeatSummary(booking?.seats ?? [], booking?.bookingType);
|
||||||
const trainSeatLines = seats
|
|
||||||
.map((bs: any) => {
|
|
||||||
const coach = bs.seat?.coach?.number ?? '-';
|
|
||||||
const cls = bs.seat?.coach?.coachType?.name ?? '';
|
|
||||||
const seatNo = bs.seat?.seatNumber ?? '-';
|
|
||||||
return `Train/Seat: Train ${trainName}, ${coach} ${cls}, seat no. ${seatNo}`.replace(/ +/g, ' ').trim();
|
|
||||||
})
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
// Lead passenger (leg-1 seat). Booking has no contactName; the traveller name lives on the seat.
|
|
||||||
const passengerName = seats[0]?.passengerName ?? 'Passenger';
|
|
||||||
const payLink = `${process.env.PORTAL_URL ?? 'http://localhost:5174'}/booking/detail?ref=${ref}`;
|
const payLink = `${process.env.PORTAL_URL ?? 'http://localhost:5174'}/booking/detail?ref=${ref}`;
|
||||||
const segment = resolveBookingSegment(s, booking?.originStationId, booking?.destinationStationId);
|
const segment = resolveBookingSegment(s, booking?.originStationId, booking?.destinationStationId);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user