feat(loading): notify loaded and left-behind containers, surface the CAS

A booking is routinely loaded in parts, and nothing told the customer which
containers boarded and which stayed behind. The carriage acceptance sheet was
the only record, and it both totalled up cargo still sitting in the yard and
lived inside a Warehouse documents bundle that direct truck-to-train cargo
has no business in.

The sheet now marks each wagon Loaded or Not loaded and totals only the loaded
ones. On load, the customer gets an in-app, SMS and email notice carrying the
train number, route, departure time and both container lists — capped to a
summary on SMS and email, complete in the inbox. Anything left behind also
raises a warehouse-desk notice so somebody owns finding it space.

That desk is addressed by a new warehouse_inventory:get_notification
permission: a recipient selector, not a route guard, so ops can assign who
gets pinged without granting access to anything.

The GRN notice went out over SMS alone, to whatever phone number the gate
clerk typed. Where the receive carries a booking it now resolves the company
and delivers in-app, SMS and email, skipping the typed phone so the customer
is not texted twice; manual and backlog receives keep the old path.
This commit is contained in:
Hagernesh
2026-08-29 08:47:00 +00:00
parent abf856547a
commit fd6f0d03b6
7 changed files with 351 additions and 17 deletions

View File

@@ -114,6 +114,8 @@ interface CarriageAcceptanceWagonRow {
arrivalAt: string | null; arrivalAt: string | null;
containerNumbers: string | null; containerNumbers: string | null;
sealNumbers: string | null; sealNumbers: string | null;
/** Allocation status — LOADED/DEPARTED means EDR has the cargo. */
status: string | null;
} }
/** A received-but-not-yet-marshalled export line, standing in for a wagon row. */ /** A received-but-not-yet-marshalled export line, standing in for a wagon row. */
@@ -263,9 +265,13 @@ export class BookingsService {
/** /**
* Carriage acceptance sheet — one per booking, listing every wagon the booking * Carriage acceptance sheet — one per booking, listing every wagon the booking
* occupies. Handed to the customer when EDR accepts the cargo (export) and when * occupies. A booking is routinely loaded in parts (some containers go, the
* the wagons are allocated before marshalling (import), so it is only available * rest wait for the next train), so each row carries a Status of Loaded or
* once the booking has wagon allocations. * Not loaded and the totals count only the loaded ones: the customer sees the
* whole plan on one page without the sheet overstating what EDR has taken.
*
* Handed to the customer when EDR accepts the cargo (export) and when the
* wagons are allocated before marshalling (import).
*/ */
async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> { async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
const booking = await this.findById(bookingId); const booking = await this.findById(bookingId);
@@ -285,6 +291,7 @@ export class BookingsService {
s.scheduled_departure_date AS "departureAt", s.scheduled_departure_date AS "departureAt",
so.label AS "marshalledAt", so.label AS "marshalledAt",
sd.label AS "arrivalAt", sd.label AS "arrivalAt",
a.status AS "status",
string_agg(DISTINCT ci.container_number, ', ') AS "containerNumbers", string_agg(DISTINCT ci.container_number, ', ') AS "containerNumbers",
string_agg(DISTINCT ci.seal_number, ', ') AS "sealNumbers" string_agg(DISTINCT ci.seal_number, ', ') AS "sealNumbers"
FROM freight.wagon_booking_allocations a FROM freight.wagon_booking_allocations a
@@ -299,7 +306,7 @@ export class BookingsService {
LEFT JOIN freight.wagon_allocation_container_items ci LEFT JOIN freight.wagon_allocation_container_items ci
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
WHERE a.booking_id = $1 AND a.deleted_at IS NULL WHERE a.booking_id = $1 AND a.deleted_at IS NULL
GROUP BY tsw.id, a.id, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons, GROUP BY tsw.id, a.id, a.status, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons,
s.train_number, s.scheduled_departure_date, so.label, sd.label s.train_number, s.scheduled_departure_date, so.label, sd.label
ORDER BY tsw.sequence_no`, ORDER BY tsw.sequence_no`,
[bookingId], [bookingId],
@@ -383,6 +390,8 @@ export class BookingsService {
arrivalAt: null, arrivalAt: null,
containerNumbers: row.containerNumbers, containerNumbers: row.containerNumbers,
sealNumbers: row.sealNumbers ?? null, sealNumbers: row.sealNumbers ?? null,
// A received line has no allocation; it is cargo EDR already holds.
status: null,
})); }));
} }
@@ -497,7 +506,17 @@ export class BookingsService {
const header = wagons[0]; const header = wagons[0];
const sheetDate = header.departureAt ? new Date(header.departureAt) : new Date(); const sheetDate = header.departureAt ? new Date(header.departureAt) : new Date();
const totals = wagons.reduce( // Loaded = EDR has the cargo. A booking is routinely loaded in parts, so the
// totals count only those: the sheet shows the whole plan, but must never
// total up cargo still sitting in the yard. A received-line sheet
// (pendingWagons) has no allocation status, and every line on it is cargo
// already accepted, so it counts in full.
const isLoaded = (w: CarriageAcceptanceWagonRow) =>
pendingWagons || w.status === 'LOADED' || w.status === 'DEPARTED';
const loadedWagons = wagons.filter(isLoaded);
const notLoadedCount = wagons.length - loadedWagons.length;
const totals = loadedWagons.reduce(
(acc, w) => ({ (acc, w) => ({
tare: acc.tare + (Number(w.tareWeightTons) || 0), tare: acc.tare + (Number(w.tareWeightTons) || 0),
capacity: acc.capacity + (Number(w.loadCapacityTons) || 0), capacity: acc.capacity + (Number(w.loadCapacityTons) || 0),
@@ -507,7 +526,7 @@ export class BookingsService {
{ tare: 0, capacity: 0, load: 0, length: 0 }, { tare: 0, capacity: 0, load: 0, length: 0 },
); );
// A wagon carrying no weight and no container is running empty under this booking. // A wagon carrying no weight and no container is running empty under this booking.
const fullWagons = wagons.filter( const fullWagons = loadedWagons.filter(
(w) => (Number(w.allocatedWeightTons) || 0) > 0 || Boolean(w.containerNumbers), (w) => (Number(w.allocatedWeightTons) || 0) > 0 || Boolean(w.containerNumbers),
).length; ).length;
@@ -525,6 +544,9 @@ export class BookingsService {
<td>${esc(departureStation)}</td> <td>${esc(departureStation)}</td>
<td>${esc(w.containerNumbers)}</td> <td>${esc(w.containerNumbers)}</td>
<td>${esc(w.sealNumbers)}</td> <td>${esc(w.sealNumbers)}</td>
<td class="${isLoaded(w) ? 'loaded' : 'pending'}">${
pendingWagons ? 'Accepted' : isLoaded(w) ? 'Loaded' : 'Not loaded'
}</td>
<td class="num">${money(prices[i])}</td> <td class="num">${money(prices[i])}</td>
</tr>`, </tr>`,
) )
@@ -535,11 +557,11 @@ export class BookingsService {
// figure from the printed sheet. // figure from the printed sheet.
const totalsRow = `<tr class="totals"> const totalsRow = `<tr class="totals">
<td>TOT</td> <td>TOT</td>
<td>${wagons.length} ${pendingWagons ? 'received lines' : 'wagons'}</td> <td>${loadedWagons.length} ${pendingWagons ? 'received lines' : 'wagons loaded'}</td>
<td>${ <td>${
pendingWagons pendingWagons
? 'pending marshalling' ? 'pending marshalling'
: `full ${fullWagons} / empty ${wagons.length - fullWagons}` : `full ${fullWagons} / empty ${loadedWagons.length - fullWagons}`
}</td> }</td>
<td class="num">${num(totals.tare, 2)}</td> <td class="num">${num(totals.tare, 2)}</td>
<td class="num">${num(totals.length)}</td> <td class="num">${num(totals.length)}</td>
@@ -549,6 +571,7 @@ export class BookingsService {
<td></td> <td></td>
<td></td> <td></td>
<td></td> <td></td>
<td>${notLoadedCount > 0 ? `loaded only (${notLoadedCount} not loaded)` : ''}</td>
<td class="num">${money(totalAmount)}</td> <td class="num">${money(totalAmount)}</td>
</tr>`; </tr>`;
@@ -575,6 +598,8 @@ export class BookingsService {
th { background: #f8fafc; color: #475569; text-align: left; } th { background: #f8fafc; color: #475569; text-align: left; }
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; } th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
.num { text-align: right; } .num { text-align: right; }
.loaded { color: #0f766e; font-weight: 700; }
.pending { color: #b45309; font-weight: 700; }
tr.totals td { background: #f8fafc; font-weight: 700; } tr.totals td { background: #f8fafc; font-weight: 700; }
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; } .notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; } .signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
@@ -618,6 +643,7 @@ export class BookingsService {
<th>Departure Station</th> <th>Departure Station</th>
<th>Container No.</th> <th>Container No.</th>
<th>Seal No.</th> <th>Seal No.</th>
<th>Status</th>
<th class="num">Price (${esc(currency)})</th> <th class="num">Price (${esc(currency)})</th>
</tr> </tr>
</thead> </thead>

View File

@@ -83,3 +83,184 @@ export async function notifyCarriageAcceptanceReady(
logger.warn(`Carriage acceptance ready notify failed for ${bookingId}: ${(err as Error).message}`); logger.warn(`Carriage acceptance ready notify failed for ${bookingId}: ${(err as Error).message}`);
} }
} }
/** One container line on the load manifest notice. */
interface LoadManifestLists {
reference: string;
companyId: string | null;
trainNumber: string | null;
originStation: string | null;
destinationStation: string | null;
departureAt: Date | null;
loaded: string[];
leftBehind: string[];
}
/** At most `max` numbers, then "+N more" — an SMS must not carry 44 of them. */
function summarizeNumbers(numbers: string[], max = 5): string {
if (numbers.length === 0) return 'none';
const shown = numbers.slice(0, max).join(', ');
const rest = numbers.length - max;
return rest > 0 ? `${shown} +${rest} more` : shown;
}
/**
* Read what actually went on the train and what did not. Left behind = every
* container the customer declared minus the ones sitting on a LOADED/DEPARTED
* wagon, so a booking loaded in parts reports honestly on both halves.
*/
export async function loadManifestLists(
dataSource: DataSource,
bookingId: string,
trainScheduleId: string,
): Promise<LoadManifestLists | null> {
const [booking]: Array<{ reference: string; companyId: string | null }> =
await dataSource.query(
`SELECT reference, company_id AS "companyId"
FROM freight.bookings
WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
if (!booking) return null;
const [train]: Array<{
trainNumber: string | null;
originStation: string | null;
destinationStation: string | null;
departureAt: Date | null;
}> = await dataSource.query(
`SELECT s.train_number AS "trainNumber",
so.label AS "originStation",
sd.label AS "destinationStation",
s.scheduled_departure_date AS "departureAt"
FROM freight.train_schedules s
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id
WHERE s.id = $1 AND s.deleted_at IS NULL`,
[trainScheduleId],
);
const loadedRows: Array<{ containerNumber: string | null }> = await dataSource.query(
`SELECT DISTINCT ci.container_number AS "containerNumber"
FROM freight.wagon_allocation_container_items ci
JOIN freight.wagon_booking_allocations a
ON a.id = ci.wagon_booking_allocation_id AND a.deleted_at IS NULL
WHERE a.booking_id = $1
AND ci.deleted_at IS NULL
AND a.status IN ('LOADED', 'DEPARTED')
ORDER BY 1`,
[bookingId],
);
const declaredRows: Array<{ containerNumber: string | null }> = await dataSource.query(
`SELECT DISTINCT u.container_number AS "containerNumber"
FROM freight.booking_container_units u
JOIN freight.booking_container l
ON l.id = u.booking_container_id AND l.deleted_at IS NULL
WHERE l.booking_id = $1 AND u.deleted_at IS NULL
ORDER BY 1`,
[bookingId],
);
const loaded = loadedRows.map((r) => r.containerNumber).filter(Boolean) as string[];
const loadedSet = new Set(loaded);
const leftBehind = (declaredRows.map((r) => r.containerNumber).filter(Boolean) as string[]).filter(
(n) => !loadedSet.has(n),
);
return {
reference: booking.reference,
companyId: booking.companyId,
trainNumber: train?.trainNumber ?? null,
originStation: train?.originStation ?? null,
destinationStation: train?.destinationStation ?? null,
departureAt: train?.departureAt ?? null,
loaded,
leftBehind,
};
}
/**
* Tell the customer what boarded the train and what did not, over in-app + SMS
* + email, and raise a warehouse-desk notice for anything left behind so
* somebody owns finding it space. A booking is routinely loaded in parts, and
* before this the customer learnt about it only by reading the sheet.
*
* Best-effort throughout: loading must never roll back because a provider is
* down.
*/
export async function notifyLoadManifest(
dataSource: DataSource,
notifications: NotificationsService,
inbox: NotificationInboxService,
bookingId: string,
trainScheduleId: string,
warehouseNotificationPermission: string,
logger: Logger,
): Promise<void> {
try {
const m = await loadManifestLists(dataSource, bookingId, trainScheduleId);
if (!m) return;
const route =
m.originStation && m.destinationStation
? ` ${m.originStation}${m.destinationStation}`
: '';
const departs = m.departureAt
? `, departs ${new Date(m.departureAt).toLocaleString('en-GB')}`
: '';
const train = m.trainNumber ? `train ${m.trainNumber}` : 'the train';
const headline =
`Booking ${m.reference}: ${m.loaded.length} container(s) loaded on ${train}` +
`${route}${departs}.`;
const loadedLine = m.loaded.length > 0 ? ` Loaded: ${summarizeNumbers(m.loaded)}.` : '';
const leftLine =
m.leftBehind.length > 0
? ` Not loaded (${m.leftBehind.length}): ${summarizeNumbers(m.leftBehind)}.` +
' These stay with EDR — once a warehouse is assigned you will receive the GRN.'
: '';
const body = headline + loadedLine + leftLine;
if (m.companyId) {
await inbox.notify({
recipients: { companyId: m.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.BOOKING_STATUS,
title: m.leftBehind.length > 0 ? 'Cargo partly loaded' : 'Cargo loaded',
// The in-app copy carries every number; SMS and email get the summary.
body:
headline +
(m.loaded.length > 0 ? `\nLoaded: ${m.loaded.join(', ')}` : '') +
(m.leftBehind.length > 0
? `\nNot loaded: ${m.leftBehind.join(', ')}\nThese stay with EDR — once a warehouse is assigned you will receive the GRN.`
: ''),
link: `/bookings/${bookingId}`,
data: {
bookingId,
reference: m.reference,
trainNumber: m.trainNumber,
loaded: m.loaded,
leftBehind: m.leftBehind,
},
});
await sendCompanyChannels(dataSource, notifications, m.companyId, body);
}
// Nothing left behind is nothing for the warehouse desk to place.
if (m.leftBehind.length > 0) {
await inbox.notify({
recipients: { permissionKeys: [warehouseNotificationPermission] },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.REQUEST_SUBMITTED,
title: `${m.leftBehind.length} container(s) left behind — ${m.reference}`,
body:
`${train} departed without ${m.leftBehind.length} container(s) of booking ${m.reference}: ` +
`${m.leftBehind.join(', ')}. Assign warehouse space and raise the GRN.`,
link: `/dashboard/booking-requests/${bookingId}`,
data: { bookingId, reference: m.reference, leftBehind: m.leftBehind },
});
}
} catch (err) {
logger.warn(`Load manifest notify failed for ${bookingId}: ${(err as Error).message}`);
}
}

View File

@@ -31,7 +31,11 @@ import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate'; import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { notifyCarriageAcceptanceReady } from '../notifications/notify-company.util'; import {
notifyCarriageAcceptanceReady,
notifyLoadManifest,
} from '../notifications/notify-company.util';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
/** /**
* Per-booking journey along a train's corridor — for EVERY trade direction. * Per-booking journey along a train's corridor — for EVERY trade direction.
@@ -267,6 +271,20 @@ export class BookingJourneyService {
}); });
}); });
// What actually boarded, and what did not. A booking is routinely loaded in
// parts; the customer is told both halves, and the warehouse desk is told
// about the leftovers so somebody owns placing them. After the transaction:
// the lists are read back from the allocation statuses it just wrote.
void notifyLoadManifest(
this.dataSource,
this.notifications,
this.inbox,
bookingId,
scheduleId,
FREIGHT_PERMS.warehouseInventory.getNotification,
this.logger,
);
// Customer tracking: cargo is on the train — loading milestones plus the // Customer tracking: cargo is on the train — loading milestones plus the
// direction's "departed" handoff. Doc-trigger path no-ops non-customs // direction's "departed" handoff. Doc-trigger path no-ops non-customs
// bookings (intercity) and already-completed codes. // bookings (intercity) and already-completed codes.

View File

@@ -1509,6 +1509,7 @@ export class WarehouseInventoryService {
grnNumber: string; grnNumber: string;
direction?: string | null; direction?: string | null;
warehouseId?: string | null; warehouseId?: string | null;
bookingId?: string | null;
}; };
booking: { booking: {
companyId?: string | null; companyId?: string | null;
@@ -1730,6 +1731,7 @@ export class WarehouseInventoryService {
grnNumber, grnNumber,
direction: dto.direction, direction: dto.direction,
warehouseId: dto.warehouseId, warehouseId: dto.warehouseId,
bookingId,
}, },
booking, booking,
bookingId, bookingId,
@@ -3107,6 +3109,7 @@ export class WarehouseInventoryService {
grnNumber, grnNumber,
direction: bookingDirection, direction: bookingDirection,
warehouseId: dto.warehouseId, warehouseId: dto.warehouseId,
bookingId: dto.bookingId ?? null,
}); });
return saved.id; return saved.id;
@@ -6614,10 +6617,9 @@ export class WarehouseInventoryService {
grnNumber: string; grnNumber: string;
direction?: string | null; direction?: string | null;
warehouseId?: string | null; warehouseId?: string | null;
/** Resolves the company, which unlocks in-app + email alongside the SMS. */
bookingId?: string | null;
}): Promise<void> { }): Promise<void> {
const phone = params.phone?.trim();
if (!phone) return;
const ownerName = params.ownerName?.trim() || 'Customer'; const ownerName = params.ownerName?.trim() || 'Customer';
const bookingReference = params.bookingReference?.trim(); const bookingReference = params.bookingReference?.trim();
const message = const message =
@@ -6627,6 +6629,47 @@ export class WarehouseInventoryService {
(params.direction ? `Direction: ${params.direction}. ` : '') + (params.direction ? `Direction: ${params.direction}. ` : '') +
`Thank you.`; `Thank you.`;
// A booking gives us the company, and with it the customer's inbox and
// email — not just whatever phone number the gate clerk typed. Without one
// (manual or backlog receive) the typed phone is all there is, so the
// original SMS-only path stands.
let companyId: string | null = null;
if (params.bookingId) {
try {
const [row]: Array<{ companyId: string | null }> = await this.dataSource.query(
`SELECT company_id AS "companyId"
FROM freight.bookings
WHERE id = $1 AND deleted_at IS NULL`,
[params.bookingId],
);
companyId = row?.companyId ?? null;
} catch (error) {
this.logger.warn(`GRN ${params.grnNumber}: company lookup failed: ${String(error)}`);
}
}
if (companyId) {
try {
await this.inbox.notify({
recipients: { companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.DOCUMENT_ACTION,
title: 'Cargo received — GRN issued',
body: message,
link: params.bookingId ? `/bookings/${params.bookingId}` : undefined,
data: { grnNumber: params.grnNumber, bookingId: params.bookingId ?? null },
});
// Sends SMS *and* email to the company's own contacts, so the typed
// phone below is skipped to avoid texting the customer twice.
await sendCompanyChannels(this.dataSource, this.notifications, companyId, message);
return;
} catch (error) {
this.logger.error(`Failed to notify company for GRN ${params.grnNumber}: ${String(error)}`);
}
}
const phone = params.phone?.trim();
if (!phone) return;
try { try {
await this.notifications.directSend('sms', phone, message); await this.notifications.directSend('sms', phone, message);
} catch (error) { } catch (error) {

View File

@@ -1909,6 +1909,11 @@ export const NOTIFICATION_PERMISSIONS: FreightPermissionSeed[] = [
"edr_freight_app:additional_charges:get_notification", "edr_freight_app:additional_charges:get_notification",
"Receive additional charge notifications", "Receive additional charge notifications",
), ),
perm(
"f3a00001-0001-4000-8000-00000000000a",
"edr_freight_app:warehouse_inventory:get_notification",
"Receive warehouse desk notifications (containers left behind at loading)",
),
]; ];
export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [
@@ -2375,6 +2380,12 @@ export const FREIGHT_PERMS = {
release: "edr_freight_app:warehouse_inventory:release", release: "edr_freight_app:warehouse_inventory:release",
deliver: "edr_freight_app:warehouse_inventory:deliver", deliver: "edr_freight_app:warehouse_inventory:deliver",
inspect: "edr_freight_app:warehouse_inventory:inspect", inspect: "edr_freight_app:warehouse_inventory:inspect",
/**
* Notification selector, not a route guard — who gets pinged when cargo is
* left behind at loading and needs warehouse space. Assign it to whichever
* desk owns that; it grants access to nothing.
*/
getNotification: "edr_freight_app:warehouse_inventory:get_notification",
}, },
interchangeDocuments: { interchangeDocuments: {
view: "edr_freight_app:interchange_documents:view", view: "edr_freight_app:interchange_documents:view",

View File

@@ -40,6 +40,7 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { PageContainer, PageHeader, KpiStrip } from "@/components/page"; import { PageContainer, PageHeader, KpiStrip } from "@/components/page";
import { extractDownloadErrorMessage } from "@/components/warehouses/options";
import type { KpiItem } from "@/components/page"; import type { KpiItem } from "@/components/page";
import { EntityLink } from "@/components/detail"; import { EntityLink } from "@/components/detail";
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar"; import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
@@ -374,11 +375,9 @@ export default function BookingRequestDetailPage() {
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} catch (error) { } catch (error) {
toast.error( // Blob response: the JSON reason is inside the Blob, so
error instanceof Error // the sync path would show only "status code 400".
? error.message toast.error(await extractDownloadErrorMessage(error));
: "Carriage acceptance sheet is not available yet",
);
} }
}} }}
> >

View File

@@ -27,6 +27,25 @@ import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/Clearanc
import toast from "react-hot-toast"; import toast from "react-hot-toast";
/**
* A `responseType: "blob"` request delivers its JSON error body as a Blob, so
* reading `error.message` gives "Request failed with status code 400" instead
* of the reason. Read the blob back before falling back.
*/
async function downloadErrorMessage(error: unknown, fallback: string): Promise<string> {
const data = (error as { response?: { data?: unknown } })?.response?.data;
if (data instanceof Blob) {
try {
const parsed = JSON.parse(await data.text()) as { message?: unknown };
if (parsed?.message) return String(parsed.message);
} catch {
/* not JSON — fall through */
}
}
return error instanceof Error ? error.message : fallback;
}
import { bookingsService } from "@/services/bookings.service"; import { bookingsService } from "@/services/bookings.service";
import type { EmptyContainerReturn } from "@/services/bookings.service"; import type { EmptyContainerReturn } from "@/services/bookings.service";
import { saveBlob } from "@/utils/download"; import { saveBlob } from "@/utils/download";
@@ -308,6 +327,25 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
// One-click warehouse-document bundle: GRN + gate clearance + handover. // One-click warehouse-document bundle: GRN + gate clearance + handover.
const [bundleBusy, setBundleBusy] = useState(false); const [bundleBusy, setBundleBusy] = useState(false);
// The carriage acceptance sheet is its own document, not warehouse paperwork:
// direct truck-to-train cargo never sees a warehouse, and this sheet IS its
// handover record. Hiding it inside the warehouse bundle made it unfindable.
const [casBusy, setCasBusy] = useState(false);
const downloadCarriageAcceptance = async () => {
setCasBusy(true);
const ref = booking.reference ?? booking.id;
try {
const blob = await bookingsService.downloadCarriageAcceptanceSheet(booking.id);
saveBlob(blob, `carriage-acceptance-${ref}.pdf`);
} catch (error) {
toast.error(
await downloadErrorMessage(error, "Carriage acceptance sheet is not available yet."),
);
} finally {
setCasBusy(false);
}
};
const downloadWarehouseDocuments = async () => { const downloadWarehouseDocuments = async () => {
setBundleBusy(true); setBundleBusy(true);
const ref = booking.reference ?? booking.id; const ref = booking.reference ?? booking.id;
@@ -654,6 +692,24 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
</SectionCard> </SectionCard>
)} )}
{/* ── Carriage acceptance sheet (its own document) ────────────────── */}
<SectionCard>
<CardTitle>Carriage acceptance sheet</CardTitle>
<Text fz="12.5px" c="dimmed" mt={4} mb="sm">
The record of the cargo EDR has accepted for carriage, listing each wagon and the
containers on it, and marking which have been loaded.
</Text>
<Button
leftSection={<Download size={16} />}
color="edr-green"
variant="light"
loading={casBusy}
onClick={downloadCarriageAcceptance}
>
Download sheet
</Button>
</SectionCard>
{/* ── Warehouse documents (one-click bundle) ──────────────────────── */} {/* ── Warehouse documents (one-click bundle) ──────────────────────── */}
<SectionCard> <SectionCard>
<CardTitle>Warehouse documents</CardTitle> <CardTitle>Warehouse documents</CardTitle>