mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1268 from Tria-plc/eims-integration
Eims integration
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<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) => {
|
||||
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 (
|
||||
<BookingCard
|
||||
key={b.id}
|
||||
@@ -764,6 +797,24 @@ export function ScheduleWorkspacePanel({
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : 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 ? (
|
||||
<Tooltip
|
||||
label={
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user