From 58f88b74f84f1f2415a876f7c29f60167e735a97 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 23 Aug 2026 05:00:43 +0000 Subject: [PATCH] add contract link --- .../consolidation-approval.service.spec.ts | 64 ++- .../consolidation-approval.service.ts | 38 +- .../pages/bookings/BookingRequestsPage.tsx | 435 +++++++++++------- .../bookings/ConsolidationApprovalsPage.tsx | 50 +- .../src/services/bookings.service.ts | 5 + 5 files changed, 419 insertions(+), 173 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.spec.ts index 971f1d831..e94ddd0b3 100644 --- a/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.spec.ts @@ -28,6 +28,8 @@ describe("ConsolidationApprovalService", () => { approvals?: Partial>; bookingsRepository?: Partial>; bookingsService?: Partial>; + /** Contract rows the id→reference lookup should return. */ + contracts?: { id: string; reference: string }[]; /** Yard ids the caller is scoped to; null = unrestricted. */ yardScope?: string[] | null; } = {}, @@ -68,8 +70,14 @@ describe("ConsolidationApprovalService", () => { consolidationRejectedToStaff: jest.fn(), operationRequestedToStaff: jest.fn(), }; + const contractRepo = { + find: jest + .fn() + .mockResolvedValue(overrides.contracts ?? []), + }; const dataSource = { transaction: jest.fn(async (cb: () => Promise) => cb()), + getRepository: jest.fn(() => contractRepo), }; const yardScope = { getScopedYardIds: jest @@ -85,7 +93,14 @@ describe("ConsolidationApprovalService", () => { dataSource as never, yardScope as never, ); - return { service, approvals, bookingsRepository, notifier, yardScope }; + return { + service, + approvals, + bookingsRepository, + notifier, + yardScope, + contractRepo, + }; } it("holds BOTH halves at the gate when a pairing is created", async () => { @@ -416,4 +431,51 @@ describe("ConsolidationApprovalService", () => { status: "OPERATION_REQUEST_PENDING", }); }); + + it("attaches each half's contract reference for the queue link", async () => { + // Booking has no contract relation (contract–booking split), so the + // references are batch-loaded by id — one query for the whole page. + const { service, contractRepo } = makeService({ + approvals: { + findQueuePage: jest.fn().mockResolvedValue({ + items: [ + { + ...PENDING, + booking: { id: "b-1", contractId: "c-1" }, + partnerBooking: { id: "b-2", contractId: "c-2" }, + }, + ], + total: 1, + }), + }, + contracts: [ + { id: "c-1", reference: "CT-001" }, + { id: "c-2", reference: "CT-002" }, + ], + }); + + const { items } = await service.queue(); + + expect(items[0].contractReference).toBe("CT-001"); + expect(items[0].partnerContractReference).toBe("CT-002"); + expect(contractRepo.find).toHaveBeenCalledTimes(1); + }); + + it("leaves the contract reference null when a half has no contract", async () => { + const { service, contractRepo } = makeService({ + approvals: { + findQueuePage: jest.fn().mockResolvedValue({ + items: [{ ...PENDING, booking: { id: "b-1" }, partnerBooking: null }], + total: 1, + }), + }, + }); + + const { items } = await service.queue(); + + expect(items[0].contractReference).toBeNull(); + expect(items[0].partnerContractReference).toBeNull(); + // Nothing to look up — no query at all. + expect(contractRepo.find).not.toHaveBeenCalled(); + }); }); diff --git a/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.ts b/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.ts index 9851b86ff..dcc499966 100644 --- a/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/consolidation-approval.service.ts @@ -8,7 +8,7 @@ import { NotFoundException, forwardRef, } from "@nestjs/common"; -import { DataSource } from "typeorm"; +import { DataSource, In } from "typeorm"; import { Booking } from "./entities/booking.entity"; import { @@ -20,6 +20,7 @@ import { BookingsRepository } from "./bookings.repository"; import { BookingsService } from "./bookings.service"; import { BookingLifecycleNotifierService } from "./booking-lifecycle-notifier.service"; import { YardScopeService } from "../rule-engine/services/yard-scope.service"; +import { Contract } from "../contracts/entities/contract.entity"; /** Where a rejected pair goes back to, so GL can fix and resubmit. */ const REJECTED_STATUS = "OPERATION_CHANGES_REQUESTED"; @@ -31,6 +32,9 @@ export const CONSOLIDATION_APPROVAL_PENDING = "CONSOLIDATION_APPROVAL_PENDING"; export type ConsolidationApprovalView = ConsolidationApproval & { requestedByName: string | null; decidedByName: string | null; + /** Contract the booking half was created under — reviewers work by contract. */ + contractReference: string | null; + partnerContractReference: string | null; }; /** @@ -281,12 +285,18 @@ export class ConsolidationApprovalService { const names = await this.bookingsRepository.resolveStaffNames( rows.flatMap((r) => [r.requestedBy, r.decidedBy]), ); + const contractRefs = await this.contractReferences(rows); + const refOf = (contractId?: string | null) => + contractId ? (contractRefs.get(contractId) ?? null) : null; + const items = rows.map((row) => ({ ...row, requestedByName: row.requestedBy ? (names.get(row.requestedBy) ?? null) : null, decidedByName: row.decidedBy ? (names.get(row.decidedBy) ?? null) : null, + contractReference: refOf(row.booking?.contractId), + partnerContractReference: refOf(row.partnerBooking?.contractId), })); const totalPages = Math.ceil(total / pageSize); @@ -305,6 +315,32 @@ export class ConsolidationApprovalService { }; } + /** + * Contract id → reference for the bookings on this page. + * + * Booking has no contract relation (contract–booking split), so the + * references are batch-loaded by id rather than joined — one query per page, + * not one per row. + */ + private async contractReferences( + rows: ConsolidationApproval[], + ): Promise> { + const ids = [ + ...new Set( + rows + .flatMap((r) => [r.booking?.contractId, r.partnerBooking?.contractId]) + .filter((id): id is string => !!id), + ), + ]; + if (!ids.length) return new Map(); + + const contracts = await this.dataSource.getRepository(Contract).find({ + where: { id: In(ids) }, + select: { id: true, reference: true }, + }); + return new Map(contracts.map((c) => [c.id, c.reference])); + } + /** * Yard ids the caller may see, or undefined for unrestricted. * diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index 6d2787d4f..758f8a7bb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -1,19 +1,12 @@ import { useMyTradeAccess } from "@/hooks/useMyTradeAccess"; -import { - Box, - Button, - Card, - Group, - Modal, - Stack, - Text, -} from "@mantine/core"; +import { Box, Button, Card, Group, Modal, Stack, Text } from "@mantine/core"; import { AlertTriangle, ArrowRight, Calendar, CheckCircle2, Clock, + FileText, LayoutList, Link2, Package, @@ -23,13 +16,19 @@ import { User, } from "lucide-react"; import { useCallback, useMemo, useRef, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu"; import { ExportButton } from "@/components/export/ExportButton"; import { formatDate, humanize } from "@/lib/format"; -import { FilterBar, dateRangeParams, routeParams, useFilters, type FilterDef } from "@/components/filters"; +import { + FilterBar, + dateRangeParams, + routeParams, + useFilters, + type FilterDef, +} from "@/components/filters"; import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; // BookingStatusTabs / Operations* queues removed — replaced by booking-kind tabs. @@ -150,36 +149,97 @@ export default function BookingRequestsPage() { // split), so a deep link can never land behind "More filters" unseen. const bookingFilterDefs: FilterDef[] = useMemo( () => [ - { key: "customerKind", label: "Booked by", type: "enum", multiple: false, options: CUSTOMER_KIND_OPTIONS }, - { key: "bookingType", label: "Kind", type: "enum", multiple: false, options: BOOKING_KIND_OPTIONS }, - { key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS }, { - key: "tradeDirection", label: "Direction", type: "enum", multiple: false, + key: "customerKind", + label: "Booked by", + type: "enum", + multiple: false, + options: CUSTOMER_KIND_OPTIONS, + }, + { + key: "bookingType", + label: "Kind", + type: "enum", + multiple: false, + options: BOOKING_KIND_OPTIONS, + }, + { + key: "statuses", + label: "Status", + type: "enum", + options: STATUS_OPTIONS, + }, + { + key: "tradeDirection", + label: "Direction", + type: "enum", + multiple: false, options: filterOptions(TRADE_DIRECTION_OPTIONS), }, - { key: "freightType", label: "Freight", type: "enum", multiple: false, options: FREIGHT_TYPE_OPTIONS }, - { key: "serviceTypeId", label: "Service", type: "enum", multiple: false, options: serviceTypeOptions }, - { key: "paymentStatus", label: "Payment", type: "enum", multiple: false, options: PAYMENT_STATUS_OPTIONS, secondary: true }, + { + key: "freightType", + label: "Freight", + type: "enum", + multiple: false, + options: FREIGHT_TYPE_OPTIONS, + }, + { + key: "serviceTypeId", + label: "Service", + type: "enum", + multiple: false, + options: serviceTypeOptions, + }, + { + key: "paymentStatus", + label: "Payment", + type: "enum", + multiple: false, + options: PAYMENT_STATUS_OPTIONS, + secondary: true, + }, { // Wins over the `paymentStatus` filter above — the queue is by // definition PAID — because it's later in this array: toApiParams // merges defs in order, so a later toParams overwrites an earlier one. - key: "paidUnallocated", label: "Allocation", type: "boolean", secondary: true, + key: "paidUnallocated", + label: "Allocation", + type: "boolean", + secondary: true, trueLabel: "Paid, not allocated", - toParams: (v) => (v.v[0] === "true" ? { paymentStatus: "PAID", assignedToSchedule: "false" } : {}), + toParams: (v) => + v.v[0] === "true" + ? { paymentStatus: "PAID", assignedToSchedule: "false" } + : {}, }, - { key: "isGovernment", label: "Ownership", type: "enum", multiple: false, options: OWNERSHIP_OPTIONS, secondary: true }, { - key: "route", label: "Route", type: "route", options: yardOptions, + key: "isGovernment", + label: "Ownership", + type: "enum", + multiple: false, + options: OWNERSHIP_OPTIONS, + secondary: true, + }, + { + key: "route", + label: "Route", + type: "route", + options: yardOptions, toParams: routeParams("originYardId", "destinationYardId"), }, { - key: "created", label: "Created", type: "date", secondary: true, + key: "created", + label: "Created", + type: "date", + secondary: true, operators: ["between", "before", "after"], toParams: dateRangeParams("createdFrom", "createdTo"), }, { - key: "scheduled", label: "Scheduled", type: "date", secondary: true, + key: "scheduled", + label: "Scheduled", + type: "date", + secondary: true, operators: ["between", "before", "after"], toParams: dateRangeParams("scheduledFrom", "scheduledTo"), }, @@ -187,19 +247,24 @@ export default function BookingRequestsPage() { [filterOptions, yardOptions, serviceTypeOptions], ); - const controls = useFilters(bookingFilterDefs, { defaultSort: "createdAt:DESC", pageSize: 10 }); + const controls = useFilters(bookingFilterDefs, { + defaultSort: "createdAt:DESC", + pageSize: 10, + }); const filter: BookingListFilter = useMemo( () => ({ ...(controls.params as unknown as BookingListFilter), // React Query cache key per kind selection ("ALL" when unfiltered) — // kept as a param the API ignores, matching the pre-migration cache key. - tab: (controls.values.bookingType?.v[0] as BookingKind | undefined) ?? "ALL", + tab: + (controls.values.bookingType?.v[0] as BookingKind | undefined) ?? "ALL", }), [controls.params, controls.values.bookingType], ); - const { data, isLoading, isError, refetch, isFetching } = useBookingList(filter); + const { data, isLoading, isError, refetch, isFetching } = + useBookingList(filter); const primaryAllocateId = allocateIds[0]; const { data: allocateBooking } = useBookingDetail( allocateOpen ? primaryAllocateId : undefined, @@ -262,8 +327,9 @@ export default function BookingRequestsPage() { async (row: BookingListRow) => { setAllocatingId(row.id); try { - const candidates = - await trainSchedulingService.getAllocationCandidates(row.id); + const candidates = await trainSchedulingService.getAllocationCandidates( + row.id, + ); if (candidates.sameDay.length > 0) { const target = candidates.sameDay[0]; await trainSchedulingService.allocatePaidBooking(row.id, target.id); @@ -330,7 +396,9 @@ export default function BookingRequestsPage() {
-

{b.reference}

+

+ {b.reference} +

+ {b.contractReference ? ( +

+ + {b.contractId ? ( + e.stopPropagation()} + className="truncate text-blue-600 hover:underline" + > + {b.contractReference} + + ) : ( + + {b.contractReference} + + )} +

+ ) : null}

{b.isShippingLine ? ( @@ -346,7 +434,10 @@ export default function BookingRequestsPage() { )} {b.customerLabel} {b.isShippingLine ? ( - + Shipping line ) : null} @@ -382,7 +473,9 @@ export default function BookingRequestsPage() {

{b.originLabel} - {b.destinationLabel} + + {b.destinationLabel} +
{ const b = row.original; - const needsAllocation = b.paymentStatus === "PAID" && !b.trainScheduleId; + const needsAllocation = + b.paymentStatus === "PAID" && !b.trainScheduleId; return ( {needsAllocation ? ( @@ -474,61 +568,61 @@ export default function BookingRequestsPage() { return ( - - - - - } - /> + + + + + } + /> - + - {/* Status tabs replaced by booking-kind tabs (one-time / general). The + {/* Status tabs replaced by booking-kind tabs (one-time / general). The old BookingStatusTabs is commented out — status is now a filter select. */} - - - - - - + + + + + + + + + {showEmpty ? ( + + - - {showEmpty ? ( - - - - ) : ( - - - - )} - - - - - setOtherDayModal(null)} - title="Allocate to another date" - centered - > - - - No train on {otherDayModal ? formatDate(otherDayModal.booking.scheduledDate) : "the booking's day"}{" "} - fits booking {otherDayModal?.booking.reference}. These trains on - other dates do — the customer will be notified of the date change. - - {otherDayModal?.candidates.map((c) => ( - -
- - {c.reference ?? "Train"} - - - Departs {formatDate(c.scheduledDepartureDate)} - {c.direction ? ` · ${c.direction}` : ""} - -
- -
- ))} + ) : ( + + + + )}
-
+
+
- {allocateBooking ? ( - { - setAllocateOpen(false); - setAllocateIds([]); - void refetch(); - }} - initialBookingIds={allocateIds} - /> - ) : null} + setOtherDayModal(null)} + title="Allocate to another date" + centered + > + + + No train on{" "} + {otherDayModal + ? formatDate(otherDayModal.booking.scheduledDate) + : "the booking's day"}{" "} + fits booking {otherDayModal?.booking.reference}. These trains on + other dates do — the customer will be notified of the date change. + + {otherDayModal?.candidates.map((c) => ( + +
+ + {c.reference ?? "Train"} + + + Departs {formatDate(c.scheduledDepartureDate)} + {c.direction ? ` · ${c.direction}` : ""} + +
+ +
+ ))} +
+
+ + {allocateBooking ? ( + { + setAllocateOpen(false); + setAllocateIds([]); + void refetch(); + }} + initialBookingIds={allocateIds} + /> + ) : null}
); } diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/ConsolidationApprovalsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/ConsolidationApprovalsPage.tsx index 7b3ea1780..ac1c120a2 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/ConsolidationApprovalsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/ConsolidationApprovalsPage.tsx @@ -18,7 +18,15 @@ import { Textarea, ThemeIcon, } from "@mantine/core"; -import { AlertCircle, Check, Clock, Link2, User, X } from "lucide-react"; +import { + AlertCircle, + Check, + Clock, + FileText, + Link2, + User, + X, +} from "lucide-react"; import toast from "react-hot-toast"; import { PageContainer, PageHeader } from "@/components/page"; @@ -234,6 +242,8 @@ export default function ConsolidationApprovalsPage() { row.booking?.reference ?? row.bookingReference } company={row.booking?.company?.name} + contractReference={row.contractReference} + contractId={row.booking?.contractId} />
@@ -417,15 +429,23 @@ export default function ConsolidationApprovalsPage() { ); } -/** One half of the wagon: its reference (linked) and whose cargo it is. */ +/** + * One half of the wagon: its booking reference, the contract it was raised + * under, and whose cargo it is. Both references link out — a reviewer deciding + * a pairing usually wants the contract, not just the shipment. + */ function BookingSide({ id, reference, company, + contractReference, + contractId, }: { id: string; reference?: string | null; company?: string | null; + contractReference?: string | null; + contractId?: string | null; }) { return ( @@ -439,6 +459,32 @@ function BookingSide({ > {reference ?? "—"} + + {contractReference && ( + + + {contractId ? ( + + {contractReference} + + ) : ( + + {contractReference} + + )} + + )} + {company ?? "—"} diff --git a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts index 4fbb36b19..f6768de29 100644 --- a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts @@ -26,14 +26,19 @@ export interface ConsolidationApprovalRow { scheduledDate?: string | null; bookingReference?: string | null; partnerBookingReference?: string | null; + /** Contract each half was created under — reviewers work by contract. */ + contractReference?: string | null; + partnerContractReference?: string | null; booking?: { id: string; reference?: string; + contractId?: string | null; company?: { name?: string } | null; } | null; partnerBooking?: { id: string; reference?: string; + contractId?: string | null; company?: { name?: string } | null; } | null; }