add contract link

This commit is contained in:
Marshal
2026-08-23 05:00:43 +00:00
parent 716444e60a
commit 58f88b74f8
5 changed files with 419 additions and 173 deletions

View File

@@ -28,6 +28,8 @@ describe("ConsolidationApprovalService", () => {
approvals?: Partial<Record<string, jest.Mock>>;
bookingsRepository?: Partial<Record<string, jest.Mock>>;
bookingsService?: Partial<Record<string, jest.Mock>>;
/** 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<unknown>) => 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 (contractbooking 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();
});
});

View File

@@ -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 (contractbooking 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<Map<string, string>> {
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.
*

View File

@@ -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() {
</div>
<div className="min-w-0 max-w-[220px]">
<div className="flex items-center gap-1.5">
<p className="truncate font-medium text-foreground">{b.reference}</p>
<p className="truncate font-medium text-foreground">
{b.reference}
</p>
<Badge
variant={isGeneral ? "secondary" : "outline"}
className="h-5 shrink-0 px-1.5 text-[10px] font-medium"
@@ -338,6 +406,26 @@ export default function BookingRequestsPage() {
{isGeneral ? "General" : "One-time"}
</Badge>
</div>
{b.contractReference ? (
<p className="mt-0.5 flex items-center gap-1 truncate text-xs">
<FileText className="size-3 shrink-0 text-muted-foreground opacity-70" />
{b.contractId ? (
<Link
to={`/dashboard/contract-requests/${b.contractId}/view`}
// The row itself opens the booking — without this the
// contract link would never win the click.
onClick={(e) => e.stopPropagation()}
className="truncate text-blue-600 hover:underline"
>
{b.contractReference}
</Link>
) : (
<span className="truncate text-muted-foreground">
{b.contractReference}
</span>
)}
</p>
) : null}
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
{b.isShippingLine ? (
<Ship className="size-3 shrink-0 opacity-70" />
@@ -346,7 +434,10 @@ export default function BookingRequestsPage() {
)}
{b.customerLabel}
{b.isShippingLine ? (
<Badge variant="secondary" className="h-4 shrink-0 px-1 text-[9px] font-medium">
<Badge
variant="secondary"
className="h-4 shrink-0 px-1 text-[9px] font-medium"
>
Shipping line
</Badge>
) : null}
@@ -382,7 +473,9 @@ export default function BookingRequestsPage() {
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
<span className="max-w-[8rem] truncate">{b.originLabel}</span>
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
<span className="max-w-[8rem] truncate">{b.destinationLabel}</span>
<span className="max-w-[8rem] truncate">
{b.destinationLabel}
</span>
</div>
<div className="flex gap-1.5">
<Badge
@@ -443,7 +536,8 @@ export default function BookingRequestsPage() {
size: 140,
cell: ({ row }) => {
const b = row.original;
const needsAllocation = b.paymentStatus === "PAID" && !b.trainScheduleId;
const needsAllocation =
b.paymentStatus === "PAID" && !b.trainScheduleId;
return (
<Group gap="xs" wrap="nowrap">
{needsAllocation ? (
@@ -586,7 +680,10 @@ export default function BookingRequestsPage() {
>
<Stack gap="sm">
<Text size="sm" c="dimmed">
No train on {otherDayModal ? formatDate(otherDayModal.booking.scheduledDate) : "the booking's day"}{" "}
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.
</Text>

View File

@@ -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}
/>
<BookingSide
id={row.partnerBookingId}
@@ -242,6 +252,8 @@ export default function ConsolidationApprovalsPage() {
row.partnerBookingReference
}
company={row.partnerBooking?.company?.name}
contractReference={row.partnerContractReference}
contractId={row.partnerBooking?.contractId}
/>
</Group>
@@ -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 (
<Box style={{ minWidth: 0 }}>
@@ -439,6 +459,32 @@ function BookingSide({
>
{reference ?? "—"}
</Text>
{contractReference && (
<Group gap={4} wrap="nowrap" mt={2}>
<FileText
size={11}
className="shrink-0"
color="var(--mantine-color-dimmed)"
/>
{contractId ? (
<Text
component={Link}
to={`/dashboard/contract-requests/${contractId}/view`}
fz={12}
c="blue.7"
style={{ textDecoration: "none" }}
>
{contractReference}
</Text>
) : (
<Text fz={12} c="dimmed">
{contractReference}
</Text>
)}
</Group>
)}
<Text fz={12.5} c="dimmed">
{company ?? "—"}
</Text>

View File

@@ -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;
}