From fd6f0d03b6ec307cf314d6d7bbbe51242c29e8d4 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 29 Aug 2026 08:47:00 +0000 Subject: [PATCH 1/8] feat(loading): notify loaded and left-behind containers, surface the CAS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/modules/bookings/bookings.service.ts | 42 +++- .../notifications/notify-company.util.ts | 181 ++++++++++++++++++ .../booking-journey.service.ts | 20 +- .../warehouses/warehouse-inventory.service.ts | 49 ++++- .../src/seed/freight-permissions.registry.ts | 11 ++ .../bookings/BookingRequestDetailPage.tsx | 9 +- .../components/DocumentsTab.tsx | 56 ++++++ 7 files changed, 351 insertions(+), 17 deletions(-) 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. + + + + {/* ── Warehouse documents (one-click bundle) ──────────────────────── */} Warehouse documents From 80a057dde899cf1b3f7aee5cfe9554ad8d1f7991 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 29 Aug 2026 09:21:17 +0000 Subject: [PATCH 2/8] fix(train-scheduling): drop not-yet-boarded wagons from every marshalling doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leg-slot wagons planned to couple at a LATER stop were being counted into the Wagons/Allocations/Total containers tiles on the ORIGIN marshalling document (Import Load List, export load list) — inflating the departing count from 54 to 64 on a real train, plus a 'TO BE LOADED AT X' / 'TO LOAD AT X' row and a separate 'To load en route' tally to work around it. Filters those slots out of buildImportLoadListHtml / buildExportLoadListHtml entirely instead: a wagon not part of the departing consist gets no row and no count on this document, full stop. Its own coupling shows up on THAT stop's own numbered marshalling document once it actually happens (intercityOnBoardView already filtered correctly there, unaffected). Deletes the now-dead pendingBoardYardLabelBySlot special- casing, loadsHere guards, and the 'to load en route' tile. Co-Authored-By: Claude Sonnet 5 --- .../services/train-scheduling.service.spec.ts | 20 +++--- .../services/train-scheduling.service.ts | 72 ++++++++----------- 2 files changed, 41 insertions(+), 51 deletions(-) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts index d6e778216..05f216a7b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts @@ -1134,7 +1134,7 @@ describe('TrainSchedulingService', () => { expect(html).toContain('2 (1 empty)'); }); - it('marks a leg slot on the import document as TO BE LOADED and keeps it out of the loaded tallies', () => { + it('drops a leg slot entirely from the import document — not part of the departing consist', () => { const loadList = { generatedAt: '2026-07-17T08:00:00.000Z', trainScheduleId: 'schedule-1', @@ -1176,15 +1176,16 @@ describe('TrainSchedulingService', () => { buildImportLoadListHtml: (l: unknown) => string; }).buildImportLoadListHtml(loadList); - expect(html).toContain('TO BE LOADED AT DIRE DAWA PORT'); - // Departure station of the leg slot is its board yard, not the origin. - expect(html).toContain('Dire Dawa Port'); - // Only the origin-loaded container counts; the leg slot's tallies separately. + // The leg slot (W-ICY, boards later at Dire Dawa) gets no row at all — + // it isn't on the departing consist. Only W-IMP appears. + expect(html).not.toContain('W-ICY'); + expect(html).not.toContain('ICY-001'); + expect(html).toContain('W-IMP'); + expect(html).toContain('Wagons1'); expect(html).toContain('Total containers1'); - expect(html).toContain('To load en route1 containers'); }); - it('marks a leg slot on the export document as TO LOAD AT its board yard and keeps it out of the tallies', () => { + it('drops a leg slot entirely from the export document — not part of the departing consist', () => { const sizedAllocation = { ...loadedAllocation, containerItems: [{ containerNumber: 'CONT-001', containerType: { sizeFt: 20 } }], @@ -1204,9 +1205,10 @@ describe('TrainSchedulingService', () => { pendingBoardYardLabelBySlot: new Map([['slot-leg', 'Dire Dawa Port']]), }); - expect(html).toContain('TO LOAD AT DIRE DAWA PORT'); + // The leg slot (W-LEG, boards later at Dire Dawa) gets no row at all. + expect(html).not.toContain('W-LEG'); + expect(html).toContain('Wagons1'); expect(html).toContain('Total containers1'); - expect(html).toContain('To load en route1 containers'); }); it('prints the consist-changes table for this stop, and omits it when there are none', () => { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 19140d822..d20cc8dcf 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -3866,10 +3866,15 @@ export class TrainSchedulingService { const time = (value: unknown) => (value ? new Date(value as string | Date).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : '-'); const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking])); // The document is checked against the physical train, so it has to run in - // consist order — the relation comes back unordered. - const wagons = [...(opts?.wagons ?? schedule.trainSet?.wagons ?? [])].sort( - (a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0), - ); + // consist order — the relation comes back unordered. Slots planned to + // couple at a LATER stop (pendingBoardYardLabelBySlot, origin docs only — + // intercity calls never pass it, their wagons list is already on-board + // only) are dropped here, not just tallied around: they are not part of + // the departing consist, so they get no row and no count on this document. + // Their own coupling shows up on THAT stop's own marshalling document. + const wagons = [...(opts?.wagons ?? schedule.trainSet?.wagons ?? [])] + .filter((wagon) => !opts?.pendingBoardYardLabelBySlot?.get(wagon.id)) + .sort((a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0)); // Empties sit on wagons that carry no booking allocation, keyed by the wagon // slot recorded when they were loaded. const emptiesByWagon = new Map(); @@ -3916,7 +3921,6 @@ export class TrainSchedulingService { `, ]; } - const pendingAt = opts?.pendingBoardYardLabelBySlot?.get(wagon.id); return allocations.map((allocation) => { const booking = allocation.booking ?? bookingById.get(allocation.bookingId); const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType; @@ -3928,7 +3932,7 @@ export class TrainSchedulingService { const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', '); return ` ${wagonCells} - ${pendingAt ? `TO LOAD AT ${esc(pendingAt).toUpperCase()} — ` : ''}${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)} + ${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)} ${esc(companyName)} ${esc(containerNumbers || firstContainer?.containerNumber)} ${esc(chassisNumbers)} @@ -3965,27 +3969,20 @@ export class TrainSchedulingService { (wagon.allocations ?? []).length === 0 && !emptiesByWagon.get(Number(wagon.sequenceNo))?.length, ).length; - const loadsHere = (wagon: TrainSetWagon) => !opts?.pendingBoardYardLabelBySlot?.get(wagon.id); const totalWeight = wagons.reduce( (sum, wagon) => - sum + - (loadsHere(wagon) - ? (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0) - : 0), + sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0), 0, ); // Container count summary (40ft, 20ft) — empties returning to Djibouti are - // physically on the train, so they count, and are called out on their own tile. - // Cargo boarding downstream is not on this train yet — it tallies separately. - let count40ft = 0, count20ft = 0, pendingContainers = 0; + // physically on the train, so they count, and are called out on their own + // tile. Cargo boarding downstream never enters this loop — `wagons` above + // already excludes those slots. + let count40ft = 0, count20ft = 0; wagons.forEach((wagon) => { (wagon.allocations ?? []).forEach((allocation) => { (allocation.containerItems ?? []).forEach((item) => { - if (!loadsHere(wagon)) { - pendingContainers++; - return; - } const size = this.resolveContainerItemSize(item); if (size === 40) count40ft++; else if (size === 20) count20ft++; @@ -4053,7 +4050,6 @@ export class TrainSchedulingService {
Containers 40ft${esc(count40ft)}
Containers 20ft${esc(count20ft)}
Total containers${esc(count40ft + count20ft)}
- ${pendingContainers ? `
To load en route${esc(pendingContainers)} containers
` : ''} ${emptyContainers.length ? `
Empty containers${esc(emptyContainers.length)}
` : ''}
Prepared person${esc(schedule.preparedByUserId)}
Check person${esc(schedule.checkedByUserId)}
@@ -4253,30 +4249,23 @@ export class TrainSchedulingService { .replace(/'/g, '''); const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleString('en-GB') : '-'); const status = loadList.operation.status; - // A leg slot (boardYard set) couples mid-corridor — its cargo is NOT on the - // physical train this Djibouti-side document is checked against, so it must - // stay out of the loaded tallies or the gate count stops matching. - const loadsHere = (wagon: (typeof loadList.wagons)[number]) => !wagon.boardYard; - const totalAllocations = loadList.wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0); - const totalWeight = loadList.wagons.reduce( - (sum, wagon) => - sum + - (loadsHere(wagon) - ? wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0) - : 0), + // A leg slot (boardYard set) couples mid-corridor — it is not part of the + // consist this Djibouti-side document is checked against yet, so it gets + // no row and no count here at all. Its own coupling shows up on THAT + // stop's own marshalling document once it actually happens. + const wagons = loadList.wagons.filter((wagon) => !wagon.boardYard); + const totalAllocations = wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0); + const totalWeight = wagons.reduce( + (sum, wagon) => sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0), 0, ); - const emptyWagons = loadList.wagons.filter((wagon) => wagon.allocations.length === 0).length; + const emptyWagons = wagons.filter((wagon) => wagon.allocations.length === 0).length; - // Container count summary (40ft, 20ft) — loaded at origin vs. en route - let count40ft = 0, count20ft = 0, pendingContainers = 0; - loadList.wagons.forEach((wagon) => { + // Container count summary (40ft, 20ft) + let count40ft = 0, count20ft = 0; + wagons.forEach((wagon) => { wagon.allocations.forEach((allocation) => { (allocation.containerItems ?? []).forEach((item) => { - if (!loadsHere(wagon)) { - pendingContainers++; - return; - } const size = this.resolveContainerItemSize(item); if (size === 40) count40ft++; else if (size === 20) count20ft++; @@ -4284,7 +4273,7 @@ export class TrainSchedulingService { }); }); - const allocationRows = loadList.wagons + const allocationRows = wagons .flatMap((wagon) => { const wagonCells = `${esc(wagon.sequenceNo)} ${esc(wagon.wagonNumber)} @@ -4317,7 +4306,7 @@ export class TrainSchedulingService { ${esc(allocation.loadType)} ${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')} ${esc(sealNumbers || '-')} - ${wagon.boardYard ? `TO BE LOADED AT ${esc(wagon.boardYard).toUpperCase()}` : ''} + ${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))} `; }, @@ -4384,13 +4373,12 @@ export class TrainSchedulingService {
Origin${esc(loadList.origin)}
Destination${esc(loadList.destination)}
Total bookings${esc(loadList.totalBookings)}
-
Wagons${esc(loadList.wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}
+
Wagons${esc(wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}
Allocations${esc(totalAllocations)}
Total weight${esc(totalWeight.toFixed(3))} T
Containers 40ft${esc(count40ft)}
Containers 20ft${esc(count20ft)}
Total containers${esc(count40ft + count20ft)}
- ${pendingContainers ? `
To load en route${esc(pendingContainers)} containers
` : ''}
Gatepass granted${esc(date(loadList.operation.gatepassGrantedAt))}
From 6551660e5f14b43db35246ef7f34b7f2968416dc Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Sat, 29 Aug 2026 12:28:42 +0300 Subject: [PATCH 3/8] feat: ( bookings ) add authenticated My Bookings history to the passenger portal --- .../modules/bookings/bookings.controller.ts | 13 +- .../src/modules/bookings/bookings.service.ts | 79 +++- .../portal/src/app/booking/lookup/page.tsx | 10 +- .../portal/src/app/bookings/page.tsx | 62 +++ .../portal/src/app/profile/page.tsx | 117 +----- .../portal/src/components/AppSidebar.tsx | 9 +- .../portal/src/components/BottomTabBar.tsx | 9 +- .../portal/src/components/MyBookingsTable.tsx | 379 ++++++++++++++++++ .../portal/src/lib/api/bookings.ts | 94 +++++ .../portal/src/middleware.ts | 3 + 10 files changed, 645 insertions(+), 130 deletions(-) create mode 100644 apps/edr-passenger-web/portal/src/app/bookings/page.tsx create mode 100644 apps/edr-passenger-web/portal/src/components/MyBookingsTable.tsx create mode 100644 apps/edr-passenger-web/portal/src/lib/api/bookings.ts diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index ecd3b4043..36f2b7250 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -22,7 +22,7 @@ import { ApiBody, } from "@nestjs/swagger"; import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator"; -import { BookingsService } from "./bookings.service"; +import { BookingsService, BookingScope } from "./bookings.service"; import { GuestBookingService } from "./guest-booking.service"; import { CreateBookingDto, @@ -38,6 +38,8 @@ import { PassengerAdmin, PassengerStaff, PassengerStaffStrict } from "../../comm import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; import { SeatsService } from "../seats/seats.service"; +const BOOKING_SCOPES: BookingScope[] = ["upcoming", "past", "cancelled", "all"]; + @ApiTags("Booking") @Controller("bookings") // @Throttle({ strict: { limit: 20, ttl: 60_000 } }) @@ -66,6 +68,13 @@ export class BookingsController { required: false, description: "Filter by booking status", }) + @ApiQuery({ + name: "scope", + required: false, + enum: ["upcoming", "past", "cancelled", "all"], + description: + "Which slice of the history to return. 'upcoming' and 'past' split on the schedule's departure and exclude cancelled/refunded bookings; 'cancelled' returns only those. Defaults to 'all'.", + }) @ApiQuery({ name: "page", required: false, description: "Page number" }) @ApiQuery({ name: "pageSize", @@ -80,6 +89,7 @@ export class BookingsController { @Req() req: any, @Query("search") search?: string, @Query("status") status?: string, + @Query("scope") scope?: BookingScope, @Query("page") page?: string, @Query("pageSize") pageSize?: string, ) { @@ -88,6 +98,7 @@ export class BookingsController { return this.service.findByIamUserId(iamUserId, { search, status, + scope: BOOKING_SCOPES.includes(scope as BookingScope) ? scope : "all", page: page ? parseInt(page) : 1, pageSize: pageSize ? parseInt(pageSize) : 20, }); diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 7a447690a..b627d64ba 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -15,7 +15,7 @@ import { FareEngineService } from '../fare-engine/fare-engine.service'; import { PaymentsService } from '../payments/payments.service'; import { MAX_PAYMENT_HOURS } from '../../common/utils/payment-deadline.utils'; import { normalizePhoneVariants } from '../../common/utils/phone.utils'; -import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client'; +import { BookingStatus, Currency, PassengerCategory, IdDocumentType } from '@prisma/client'; import { JourneyDirection } from '../seats/seats.dto'; import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto'; import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; @@ -67,8 +67,15 @@ interface BookingFilters { dateTo?: string; page?: number; pageSize?: number; + /** Portal "My bookings" tabs. Only honoured by findByPassengerId. */ + scope?: BookingScope; } +export type BookingScope = 'upcoming' | 'past' | 'cancelled' | 'all'; + +/** Statuses that mean the reservation is off — used by the `cancelled` scope. */ +const CLOSED_BOOKING_STATUSES: BookingStatus[] = ['CANCELLED', 'REFUNDED']; + @Injectable() export class BookingsService { private readonly logger = new Logger(BookingsService.name); @@ -87,16 +94,34 @@ export class BookingsService { ) {} async findByIamUserId(iamUserId: string, filters: BookingFilters = {}) { - const passenger = await this.prisma.passenger.findUniqueOrThrow({ where: { iamUserId }, select: { id: true } }); + // An IAM user with no Passenger row is normal, not an error: a freshly registered + // account that has never booked, or a staff account. findUniqueOrThrow raised P2025 + // here, which surfaced as a 500 on the portal's "My bookings" page. Empty page instead. + const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId }, select: { id: true } }); + if (!passenger) { + const page = filters.page ?? 1; + const pageSize = filters.pageSize ?? 20; + return { items: [], meta: { page, pageSize, total: 0, totalPages: 0 } }; + } return this.findByPassengerId(passenger.id, filters); } + /** + * The portal's authenticated "My bookings" history (GET /bookings/my). + * + * `scope` drives the Upcoming / Past / Cancelled tabs server-side so each tab paginates + * correctly, rather than the client filtering one page at a time. Note it filters on + * `schedule.departureAt` — the schedule's own origin departure — while each item's + * displayed `departureAt` comes from resolveBookingSegment, i.e. the passenger's own + * boarding stop. They differ by the run time to that stop; that is close enough for a + * tab filter and avoids a correlated stopTimes query per row. + */ async findByPassengerId(passengerId: string, filters: BookingFilters = {}) { - const { search, status, page = 1, pageSize = 20 } = filters; + const { search, status, scope = 'all', page = 1, pageSize = 20 } = filters; const skip = (page - 1) * pageSize; - + const where: any = { passengerId }; - + if (search) { where.OR = [ { bookingRef: { contains: search, mode: 'insensitive' } }, @@ -104,22 +129,41 @@ export class BookingsService { { schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } }, ]; } - - if (status) { + + // `status` used to be forwarded raw, so an unrecognised value threw a Prisma + // validation error (a 500) rather than being ignored. Only accept real enum members. + if (status && (Object.values(BookingStatus) as string[]).includes(status)) { where.status = status; } - + + const now = new Date(); + let orderBy: any = { createdAt: 'desc' }; + if (scope === 'cancelled') { + where.status = { in: CLOSED_BOOKING_STATUSES }; + } else if (scope === 'upcoming' || scope === 'past') { + // Don't clobber an explicit `status` filter — intersect with it. + if (!where.status) where.status = { notIn: CLOSED_BOOKING_STATUSES }; + where.schedule = { + ...(where.schedule ?? {}), + departureAt: scope === 'upcoming' ? { gte: now } : { lt: now }, + }; + orderBy = { schedule: { departureAt: scope === 'upcoming' ? 'asc' : 'desc' } }; + } + const [items, total] = await Promise.all([ this.prisma.booking.findMany({ where, skip, take: pageSize, - orderBy: { createdAt: 'desc' }, + orderBy, include: { schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, paymentIntent: true, - seats: { include: { seat: true } }, + seats: { include: { seat: { include: { coach: { select: { number: true } } } } } }, priceTier: { select: { priceMinor: true } }, + // A rescheduled booking stays CONFIRMED — there is no RESCHEDULED status — so the + // portal needs this to show a "Rescheduled" chip alongside the real status. + reschedules: { where: { status: 'APPLIED' }, select: { id: true } }, }, }), this.prisma.booking.count({ where }), @@ -150,6 +194,21 @@ export class BookingsService { }, paymentIntent: booking.paymentIntent, seatCount: booking.seats.length, + // Seat/coach per passenger, so the history table can show a Seat / Coach column + // without a round trip to GET /bookings/:ref for every row. `leg` disambiguates + // outbound (1) from return (2) on a round trip. + seats: booking.seats.map((bs: any) => ({ + leg: bs.leg ?? 1, + passengerName: bs.passengerName, + seatNumber: bs.seat?.seatNumber ?? null, + coachNumber: bs.seat?.coach?.number ?? null, + })), + rescheduled: ((booking as any).reschedules?.length ?? 0) > 0, + // These three let the portal apply the same coarse reschedule gate the booking + // detail page uses, without fetching each booking in full. + outboundBoardedAt: (booking as any).outboundBoardedAt ?? null, + isPackageBooking: !!(booking as any).packageId, + contactPhone: (booking as any).contactPhone ?? null, }; }), meta: { diff --git a/apps/edr-passenger-web/portal/src/app/booking/lookup/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/lookup/page.tsx index 123cd5710..1fbe4080e 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/lookup/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/lookup/page.tsx @@ -6,6 +6,7 @@ import { useState } from "react"; import { apiClient } from "@/lib/api-client"; import { format } from "date-fns"; import { toZonedDate } from "@/utils/format"; +import { STATUS_LABELS } from "@/lib/api/bookings"; type SearchMode = "pnr" | "phone"; @@ -30,15 +31,6 @@ interface BookingListItem { seatCount: number; } -const STATUS_LABELS: Record = { - CONFIRMED: { label: "Confirmed", className: "bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300" }, - PENDING_PAYMENT: { label: "Pending Payment", className: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300" }, - CANCELLED: { label: "Cancelled", className: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300" }, - BOARDED: { label: "Boarded", className: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300" }, - NO_SHOW: { label: "No Show", className: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300" }, - REFUNDED: { label: "Refunded", className: "bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300" }, -}; - export default function BookingLookupPage() { const router = useRouter(); const [mode, setMode] = useState("pnr"); diff --git a/apps/edr-passenger-web/portal/src/app/bookings/page.tsx b/apps/edr-passenger-web/portal/src/app/bookings/page.tsx new file mode 100644 index 000000000..6092e4cb2 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/bookings/page.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { Loader2, Search } from 'lucide-react'; +import { useAuthStore } from '@/lib/auth-store'; +import MyBookingsTable from '@/components/MyBookingsTable'; + +/** + * "My Bookings" for a signed-in customer — every booking on the account, without the + * BRN / phone lookup a guest has to go through at /booking/lookup. + * + * The portal's middleware does no auth gating (it only sets the CSP nonce), so pages + * self-check. Same shape as /profile and /booking/reschedule. + */ +export default function MyBookingsPage() { + const router = useRouter(); + const { isAuthenticated, isInitialized, initialize } = useAuthStore(); + + useEffect(() => { + initialize(); + }, [initialize]); + + useEffect(() => { + if (isInitialized && !isAuthenticated) { + router.push('/login?redirect=/bookings'); + } + }, [isInitialized, isAuthenticated, router]); + + if (!isInitialized || !isAuthenticated) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+

My Bookings

+

+ Every trip booked on this account. +

+
+ {/* A customer can still hold a booking made under a different phone number as a + guest — that one is only reachable by reference, so keep the door open. */} + + + Look up another booking + +
+ + +
+ ); +} diff --git a/apps/edr-passenger-web/portal/src/app/profile/page.tsx b/apps/edr-passenger-web/portal/src/app/profile/page.tsx index 37ffdabfb..ef21631b1 100644 --- a/apps/edr-passenger-web/portal/src/app/profile/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/profile/page.tsx @@ -4,32 +4,19 @@ import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { useAuthStore } from '@/lib/auth-store'; import { useTheme } from '@/components/ThemeProvider'; -import { - User, Settings, Ticket, Calendar, MapPin, - Download, Trash2, Lock, Bell, CreditCard, +import { + User, Settings, Ticket, + Download, Trash2, Lock, Bell, MapPinned, Palette, CheckCircle, - Eye, Edit, LogOut, X + Edit, LogOut, X } from 'lucide-react'; import { apiClient } from '@/lib/api-client'; -import { useQuery, useMutation } from '@tanstack/react-query'; +import { useMutation } from '@tanstack/react-query'; import CustomModal from '@/components/CustomModal'; +import MyBookingsTable from '@/components/MyBookingsTable'; type Tab = 'bookings' | 'profile' | 'settings'; -interface Booking { - id: string; - pnr: string; - status: string; - totalMinor: number; - createdAt: string; - trip?: { - trainNumber: string; - departureAt: string; - origin?: { name: string }; - destination?: { name: string }; - }; -} - export default function ProfilePage() { const router = useRouter(); const { user, isAuthenticated, logout, initialize, updateUser, isInitialized, fetchProfile } = useAuthStore(); @@ -82,18 +69,6 @@ export default function ProfilePage() { } }, [isInitialized, isAuthenticated, user, router, fetchProfile]); - const { data: bookings, isLoading: loadingBookings } = useQuery({ - queryKey: ['user-bookings'], - queryFn: async () => { - try { - return await apiClient.get('/bookings/my-bookings'); - } catch { - return []; - } - }, - enabled: isAuthenticated && activeTab === 'bookings', - }); - const updateProfileMutation = useMutation({ mutationFn: (data: any) => apiClient.patch('/auth/profile', data), onSuccess: (response) => { @@ -229,16 +204,6 @@ export default function ProfilePage() { setShowModal(true); }; - const getStatusBadge = (status: string) => { - const styles = { - CONFIRMED: 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300', - PENDING: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300', - CANCELLED: 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300', - COMPLETED: 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300', - }; - return styles[status as keyof typeof styles] || styles.PENDING; - }; - if (!isInitialized || !user) { return (
@@ -322,71 +287,11 @@ export default function ProfilePage() { {activeTab === 'bookings' && (

Bookings

- - {loadingBookings ? ( -
-
-

Loading bookings...

-
- ) : bookings && Array.isArray(bookings) && bookings.length > 0 ? ( - bookings.map((booking: Booking) => ( -
-
-
-
- - {booking.status} - - - PNR: {booking.pnr} - -
- -
-
- - - {booking.trip?.departureAt - ? new Date(booking.trip.departureAt).toLocaleDateString('en-US', { timeZone: 'Africa/Addis_Ababa' }) - : 'N/A'} - -
-
- - - {booking.trip?.origin?.name} → {booking.trip?.destination?.name} - -
-
- - - ETB {((booking.totalMinor || 0) / 100).toFixed(2)} - -
-
-
- -
- -
-
-
- )) - ) : ( -
- -

No bookings yet

- -
- )} + {/* Same component as /bookings, so the two never drift. It replaces a card + list that called GET /bookings/my-bookings — a route that does not exist + (the real one is GET /bookings/my), whose 404 was swallowed, so this tab + always read "No bookings yet". */} +
)} diff --git a/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx b/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx index a621c5d83..7565712ba 100644 --- a/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx +++ b/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx @@ -39,9 +39,12 @@ const BOOKING_STEP_MAP: Record = { '/booking/confirmation': 'confirmation', }; -const NAV_LINKS = [ +// "My Bookings" resolves differently by session: a signed-in customer gets their own +// account history at /bookings, a guest gets the BRN / phone lookup form. Same label +// either way, because it is the same intent. +const navLinks = (isAuthenticated: boolean) => [ { href: '/', label: 'Home', icon: Home }, - { href: '/booking/lookup', label: 'My Bookings', icon: Ticket }, + { href: isAuthenticated ? '/bookings' : '/booking/lookup', label: 'My Bookings', icon: Ticket }, { href: '/contact', label: 'Contact', icon: Phone }, { href: '/help', label: 'Help', icon: HelpCircle }, ]; @@ -83,7 +86,7 @@ export default function AppSidebar() {