From 37ef0f9cac5e7039fd050ca154aa6ead8de56c09 Mon Sep 17 00:00:00 2001 From: marshal Date: Tue, 8 Sep 2026 22:34:56 +0000 Subject: [PATCH] changes --- .../bookings/bookings.repository.spec.ts | 62 +++++++++++++++++++ .../modules/bookings/bookings.repository.ts | 31 ++++++++++ .../src/modules/bookings/bookings.service.ts | 20 ++++++ .../bookings/BookingRequestDetailPage.tsx | 13 ++++ .../contracts/ClearanceDocumentsPage.tsx | 23 +++++++ .../backoffice/src/types/booking.ts | 9 +++ 6 files changed, 158 insertions(+) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.spec.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.spec.ts index 5e3987de5..d0d051965 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.spec.ts @@ -141,4 +141,66 @@ describe('BookingsRepository', () => { expect(result.map((r) => r.booking.reference)).toEqual(['BK-2026-001116']); expect(result[0].ft20Quantity).toBe(1); }); + + describe('findLatestDocumentSubmissionDates', () => { + function mockGroupedQueryBuilder(rows: unknown[]) { + return { + innerJoin: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue(rows), + }; + } + + it('maps each booking to its newest document upload', async () => { + const latest = new Date('2026-09-08T14:22:00.000Z'); + const qb = mockGroupedQueryBuilder([ + { bookingId: 'b1', submittedAt: latest }, + { bookingId: 'b2', submittedAt: new Date('2026-09-06T09:10:00.000Z') }, + ]); + dataSource.getRepository.mockReturnValue({ + createQueryBuilder: () => qb, + }); + + const result = await bookingsRepository.findLatestDocumentSubmissionDates([ + 'b1', + 'b2', + ]); + + expect(result.get('b1')).toEqual(latest); + expect(result.size).toBe(2); + // Soft-deleted review rows must not resurrect a superseded date. + expect( + qb.andWhere.mock.calls.some(([clause]) => + String(clause).includes('deleted_at IS NULL'), + ), + ).toBe(true); + }); + + it('omits bookings whose review rows have no backing file', async () => { + // A required slot the customer never filled has a review row but no file, + // so the aggregate is null — such a booking has submitted nothing and + // must not appear with a bogus date. + const qb = mockGroupedQueryBuilder([{ bookingId: 'b1', submittedAt: null }]); + dataSource.getRepository.mockReturnValue({ + createQueryBuilder: () => qb, + }); + + const result = await bookingsRepository.findLatestDocumentSubmissionDates([ + 'b1', + ]); + + expect(result.has('b1')).toBe(false); + }); + + it('does not query when there are no bookings', async () => { + const result = await bookingsRepository.findLatestDocumentSubmissionDates([]); + + expect(result.size).toBe(0); + expect(dataSource.getRepository).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 0947dc3ae..f4300483f 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -822,6 +822,37 @@ export class BookingsRepository extends BaseRepository { return new Set(rows.map((r) => r.bookingId)); } + /** + * Latest customer clearance-document submission per booking, for a whole + * queue page in one query. Every customer upload (fixed slot or ad-hoc + * `custom_*`) writes a review row pointing at its stored file, and GL's own + * output documents never take that path — so the newest backing file behind a + * review row IS the customer's newest submission. A re-upload answering a + * query supersedes the file, moving the date forward with it. + */ + async findLatestDocumentSubmissionDates( + bookingIds: string[], + ): Promise> { + if (bookingIds.length === 0) return new Map(); + const rows = (await this.dataSource + .getRepository(BookingDocumentReview) + .createQueryBuilder('r') + .innerJoin(FileRecord, 'f', 'f.id = r.file_record_id') + .select('r.booking_id', 'bookingId') + .addSelect('MAX(f.created_at)', 'submittedAt') + .where('r.booking_id IN (:...bookingIds)', { bookingIds }) + .andWhere('r.deleted_at IS NULL') + .groupBy('r.booking_id') + .getRawMany()) as Array<{ bookingId: string; submittedAt: Date | null }>; + return new Map( + rows + .filter((r): r is { bookingId: string; submittedAt: Date } => + Boolean(r.submittedAt), + ) + .map((r) => [r.bookingId, r.submittedAt]), + ); + } + /** * Bookings among `bookingIds` that hold a redeemable wagon-cancellation * credit — the cut is settled (CREDIT_AVAILABLE), the credit is worth 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 4f82b98da..ea332e8c7 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -76,6 +76,8 @@ export interface TrainScheduleSummary { id: string; reference: string | null; trainNumber: string | null; + /** Voyage (sailing) number for THIS departure — the schedule's own, not the train's. */ + voyageNumber: string | null; status: string | null; scheduledDepartureDate: string | null; scheduledArrivalDate: string | null; @@ -2010,6 +2012,23 @@ ${footer} } this.attachPaymentDrainEnds(bookings); await this.attachShippingLineCompanies(bookings); + await this.attachDocumentSubmissionDates(bookings); + } + + /** + * Stamp each row with the newest customer clearance-document submission, for + * the "Documents submitted" column on the clearance worklists. Null on a + * booking whose customer has not uploaded anything yet. + */ + private async attachDocumentSubmissionDates(bookings: Booking[]): Promise { + const ids = bookings.map((b) => b.id); + if (!ids.length) return; + const byBooking = + await this.bookingsRepository.findLatestDocumentSubmissionDates(ids); + for (const b of bookings) { + (b as Booking & { documentsSubmittedAt?: string | null }) + .documentsSubmittedAt = byBooking.get(b.id)?.toISOString() ?? null; + } } /** @@ -2558,6 +2577,7 @@ ${footer} id: schedule.id, reference: schedule.reference ?? null, trainNumber: schedule.trainNumber ?? null, + voyageNumber: schedule.voyageNumber ?? null, status: schedule.status ?? null, scheduledDepartureDate: schedule.scheduledDepartureDate?.toISOString() ?? null, scheduledArrivalDate: schedule.scheduledArrivalDate?.toISOString() ?? null, 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 579c8f290..56c401ac6 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -38,6 +38,7 @@ import { Stack, Tabs, Text, + Title, } from "@mantine/core"; import { PageContainer, PageHeader, KpiStrip } from "@/components/page"; @@ -285,6 +286,11 @@ export default function BookingRequestDetailPage() { const hasSignableContract = booking.isGovernment && booking.contractSummary; + // The voyage (sailing) number of the schedule this booking rides. Null until + // a train is allocated (or requested at day-commit), and on legacy schedules + // created before voyage numbers were captured. + const voyageNumber = booking.trainScheduleSummary?.voyageNumber ?? null; + return ( + {voyageNumber ? ( + // Sits at title weight beside the reference: yards and customs + // quote the voyage, so it is the second thing staff look for. + + · {voyageNumber} + + ) : null} {booking.schedulingStatus ? ( diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx index 9cbc0e5e0..26ce30f89 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx @@ -16,6 +16,7 @@ import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink"; import { bookingTable } from "@/components/bookings/booking-ui.styles"; import { PageContainer, PageHeader } from "@/components/page"; +import { formatDateTime } from "@/lib/format"; import { bookingsService, type BookingListFilter } from "@/services/bookings.service"; import type { BookingDetail } from "@/types/booking"; import { @@ -210,6 +211,28 @@ export default function ClearanceDocumentsPage() { ); }, }, + { + id: "documentsSubmittedAt", + header: () => ( + Documents submitted + ), + cell: ({ row }) => { + const submittedAt = row.original.documentsSubmittedAt; + return submittedAt ? ( + {formatDateTime(submittedAt)} + ) : ( + + Not submitted + + ); + }, + // The table caps cells at 100px; a date needs its own width or it wraps + // to three lines. + meta: { + headerClassName: "min-w-[9rem]", + cellClassName: "min-w-[9rem]", + }, + }, { id: "status", size: 200, diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts index d536734ef..ca014c66f 100644 --- a/apps/edr-freight-web/backoffice/src/types/booking.ts +++ b/apps/edr-freight-web/backoffice/src/types/booking.ts @@ -139,6 +139,8 @@ export interface BookingTrainScheduleSummary { id: string; reference: string | null; trainNumber: string | null; + /** Voyage (sailing) number for THIS departure — the schedule's own, not the train's. */ + voyageNumber?: string | null; status: string | null; scheduledDepartureDate: string | null; scheduledArrivalDate: string | null; @@ -252,6 +254,13 @@ export interface BookingDetail { allDocsApproved?: boolean; /** ET clearance queue: a customer document is PENDING or QUERIED. */ hasDocumentsAwaitingReview?: boolean; + /** + * Newest customer clearance-document upload on this booking, attached by the + * LIST endpoint for the clearance worklists' "Documents submitted" column. A + * re-upload answering a query moves it forward; null until the customer has + * submitted anything. + */ + documentsSubmittedAt?: string | null; /** * ET clearance queue: id of an unspent wagon-cancellation credit on this * booking (CREDIT_AVAILABLE, worth > 0, not yet rebooked). Null when there is