diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
index 71107650f..5cdb0d6a4 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
@@ -114,6 +114,8 @@ interface CarriageAcceptanceWagonRow {
arrivalAt: string | null;
containerNumbers: 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. */
@@ -263,9 +265,13 @@ export class BookingsService {
/**
* Carriage acceptance sheet — one per booking, listing every wagon the booking
- * occupies. Handed to the customer when EDR accepts the cargo (export) and when
- * the wagons are allocated before marshalling (import), so it is only available
- * once the booking has wagon allocations.
+ * occupies. A booking is routinely loaded in parts (some containers go, the
+ * rest wait for the next train), so each row carries a Status of Loaded or
+ * 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 }> {
const booking = await this.findById(bookingId);
@@ -285,6 +291,7 @@ export class BookingsService {
s.scheduled_departure_date AS "departureAt",
so.label AS "marshalledAt",
sd.label AS "arrivalAt",
+ a.status AS "status",
string_agg(DISTINCT ci.container_number, ', ') AS "containerNumbers",
string_agg(DISTINCT ci.seal_number, ', ') AS "sealNumbers"
FROM freight.wagon_booking_allocations a
@@ -299,7 +306,7 @@ export class BookingsService {
LEFT JOIN freight.wagon_allocation_container_items ci
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
- 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
ORDER BY tsw.sequence_no`,
[bookingId],
@@ -383,6 +390,8 @@ export class BookingsService {
arrivalAt: null,
containerNumbers: row.containerNumbers,
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 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) => ({
tare: acc.tare + (Number(w.tareWeightTons) || 0),
capacity: acc.capacity + (Number(w.loadCapacityTons) || 0),
@@ -507,7 +526,7 @@ export class BookingsService {
{ tare: 0, capacity: 0, load: 0, length: 0 },
);
// 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),
).length;
@@ -525,6 +544,9 @@ export class BookingsService {
${esc(departureStation)}
${esc(w.containerNumbers)}
${esc(w.sealNumbers)}
+ ${
+ pendingWagons ? 'Accepted' : isLoaded(w) ? 'Loaded' : 'Not loaded'
+ }
${money(prices[i])}
`,
)
@@ -535,11 +557,11 @@ export class BookingsService {
// figure from the printed sheet.
const totalsRow = `
TOT
- ${wagons.length} ${pendingWagons ? 'received lines' : 'wagons'}
+ ${loadedWagons.length} ${pendingWagons ? 'received lines' : 'wagons loaded'}
${
pendingWagons
? 'pending marshalling'
- : `full ${fullWagons} / empty ${wagons.length - fullWagons}`
+ : `full ${fullWagons} / empty ${loadedWagons.length - fullWagons}`
}
${num(totals.tare, 2)}
${num(totals.length)}
@@ -549,6 +571,7 @@ export class BookingsService {
+ ${notLoadedCount > 0 ? `loaded only (${notLoadedCount} not loaded)` : ''}
${money(totalAmount)}
`;
@@ -575,6 +598,8 @@ export class BookingsService {
th { background: #f8fafc; color: #475569; text-align: left; }
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
.num { text-align: right; }
+ .loaded { color: #0f766e; font-weight: 700; }
+ .pending { color: #b45309; 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; }
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
@@ -618,6 +643,7 @@ export class BookingsService {
Departure Station
Container No.
Seal No.
+ Status
Price (${esc(currency)})
diff --git a/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts b/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts
index 467b172ba..1dd8ad9ca 100644
--- a/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts
+++ b/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts
@@ -83,3 +83,184 @@ export async function notifyCarriageAcceptanceReady(
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 {
+ 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 {
+ 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}`);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts
index 07de05853..46cbdb2f6 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts
@@ -31,7 +31,11 @@ import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate';
import { NotificationsService } from '../notifications/notifications.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.
@@ -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
// direction's "departed" handoff. Doc-trigger path no-ops non-customs
// bookings (intercity) and already-completed codes.
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts
index aa032c17d..43e7d7ae8 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts
@@ -1509,6 +1509,7 @@ export class WarehouseInventoryService {
grnNumber: string;
direction?: string | null;
warehouseId?: string | null;
+ bookingId?: string | null;
};
booking: {
companyId?: string | null;
@@ -1730,6 +1731,7 @@ export class WarehouseInventoryService {
grnNumber,
direction: dto.direction,
warehouseId: dto.warehouseId,
+ bookingId,
},
booking,
bookingId,
@@ -3107,6 +3109,7 @@ export class WarehouseInventoryService {
grnNumber,
direction: bookingDirection,
warehouseId: dto.warehouseId,
+ bookingId: dto.bookingId ?? null,
});
return saved.id;
@@ -6614,10 +6617,9 @@ export class WarehouseInventoryService {
grnNumber: string;
direction?: string | null;
warehouseId?: string | null;
+ /** Resolves the company, which unlocks in-app + email alongside the SMS. */
+ bookingId?: string | null;
}): Promise {
- const phone = params.phone?.trim();
- if (!phone) return;
-
const ownerName = params.ownerName?.trim() || 'Customer';
const bookingReference = params.bookingReference?.trim();
const message =
@@ -6627,6 +6629,47 @@ export class WarehouseInventoryService {
(params.direction ? `Direction: ${params.direction}. ` : '') +
`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 {
await this.notifications.directSend('sms', phone, message);
} catch (error) {
diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts
index 90a7310c8..4dad70ee1 100644
--- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts
+++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts
@@ -1909,6 +1909,11 @@ export const NOTIFICATION_PERMISSIONS: FreightPermissionSeed[] = [
"edr_freight_app:additional_charges:get_notification",
"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[] = [
@@ -2375,6 +2380,12 @@ export const FREIGHT_PERMS = {
release: "edr_freight_app:warehouse_inventory:release",
deliver: "edr_freight_app:warehouse_inventory:deliver",
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: {
view: "edr_freight_app:interchange_documents:view",
diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx
index 88ac1218d..c372f5829 100644
--- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx
@@ -40,6 +40,7 @@ import {
} from "@mantine/core";
import { PageContainer, PageHeader, KpiStrip } from "@/components/page";
+import { extractDownloadErrorMessage } from "@/components/warehouses/options";
import type { KpiItem } from "@/components/page";
import { EntityLink } from "@/components/detail";
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
@@ -374,11 +375,9 @@ export default function BookingRequestDetailPage() {
a.click();
URL.revokeObjectURL(url);
} catch (error) {
- toast.error(
- error instanceof Error
- ? error.message
- : "Carriage acceptance sheet is not available yet",
- );
+ // Blob response: the JSON reason is inside the Blob, so
+ // the sync path would show only "status code 400".
+ toast.error(await extractDownloadErrorMessage(error));
}
}}
>
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/DocumentsTab.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/DocumentsTab.tsx
index 46c19ea7a..25f601628 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/DocumentsTab.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/DocumentsTab.tsx
@@ -27,6 +27,25 @@ import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/Clearanc
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 {
+ 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 type { EmptyContainerReturn } from "@/services/bookings.service";
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.
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 () => {
setBundleBusy(true);
const ref = booking.reference ?? booking.id;
@@ -654,6 +692,24 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
)}
+ {/* ── Carriage acceptance sheet (its own document) ────────────────── */}
+
+ Carriage acceptance sheet
+
+ The record of the cargo EDR has accepted for carriage, listing each wagon and the
+ containers on it, and marking which have been loaded.
+
+ }
+ color="edr-green"
+ variant="light"
+ loading={casBusy}
+ onClick={downloadCarriageAcceptance}
+ >
+ Download sheet
+
+
+
{/* ── Warehouse documents (one-click bundle) ──────────────────────── */}
Warehouse documents