mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-31 18:57:38 +00:00
Merge pull request #1456 from Tria-plc/Wrehousechanges
feat(loading): notify loaded and left-behind containers, surface the CAS
This commit is contained in:
@@ -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 {
|
||||
<td>${esc(departureStation)}</td>
|
||||
<td>${esc(w.containerNumbers)}</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>
|
||||
</tr>`,
|
||||
)
|
||||
@@ -535,11 +557,11 @@ export class BookingsService {
|
||||
// figure from the printed sheet.
|
||||
const totalsRow = `<tr class="totals">
|
||||
<td>TOT</td>
|
||||
<td>${wagons.length} ${pendingWagons ? 'received lines' : 'wagons'}</td>
|
||||
<td>${loadedWagons.length} ${pendingWagons ? 'received lines' : 'wagons loaded'}</td>
|
||||
<td>${
|
||||
pendingWagons
|
||||
? 'pending marshalling'
|
||||
: `full ${fullWagons} / empty ${wagons.length - fullWagons}`
|
||||
: `full ${fullWagons} / empty ${loadedWagons.length - fullWagons}`
|
||||
}</td>
|
||||
<td class="num">${num(totals.tare, 2)}</td>
|
||||
<td class="num">${num(totals.length)}</td>
|
||||
@@ -549,6 +571,7 @@ export class BookingsService {
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td>${notLoadedCount > 0 ? `loaded only (${notLoadedCount} not loaded)` : ''}</td>
|
||||
<td class="num">${money(totalAmount)}</td>
|
||||
</tr>`;
|
||||
|
||||
@@ -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 {
|
||||
<th>Departure Station</th>
|
||||
<th>Container No.</th>
|
||||
<th>Seal No.</th>
|
||||
<th>Status</th>
|
||||
<th class="num">Price (${esc(currency)})</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -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<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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<void> {
|
||||
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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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<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 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 }) {
|
||||
</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) ──────────────────────── */}
|
||||
<SectionCard>
|
||||
<CardTitle>Warehouse documents</CardTitle>
|
||||
|
||||
Reference in New Issue
Block a user