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>>; approvals?: Partial<Record<string, jest.Mock>>;
bookingsRepository?: Partial<Record<string, jest.Mock>>; bookingsRepository?: Partial<Record<string, jest.Mock>>;
bookingsService?: 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. */ /** Yard ids the caller is scoped to; null = unrestricted. */
yardScope?: string[] | null; yardScope?: string[] | null;
} = {}, } = {},
@@ -68,8 +70,14 @@ describe("ConsolidationApprovalService", () => {
consolidationRejectedToStaff: jest.fn(), consolidationRejectedToStaff: jest.fn(),
operationRequestedToStaff: jest.fn(), operationRequestedToStaff: jest.fn(),
}; };
const contractRepo = {
find: jest
.fn()
.mockResolvedValue(overrides.contracts ?? []),
};
const dataSource = { const dataSource = {
transaction: jest.fn(async (cb: () => Promise<unknown>) => cb()), transaction: jest.fn(async (cb: () => Promise<unknown>) => cb()),
getRepository: jest.fn(() => contractRepo),
}; };
const yardScope = { const yardScope = {
getScopedYardIds: jest getScopedYardIds: jest
@@ -85,7 +93,14 @@ describe("ConsolidationApprovalService", () => {
dataSource as never, dataSource as never,
yardScope 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 () => { it("holds BOTH halves at the gate when a pairing is created", async () => {
@@ -416,4 +431,51 @@ describe("ConsolidationApprovalService", () => {
status: "OPERATION_REQUEST_PENDING", 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, NotFoundException,
forwardRef, forwardRef,
} from "@nestjs/common"; } from "@nestjs/common";
import { DataSource } from "typeorm"; import { DataSource, In } from "typeorm";
import { Booking } from "./entities/booking.entity"; import { Booking } from "./entities/booking.entity";
import { import {
@@ -20,6 +20,7 @@ import { BookingsRepository } from "./bookings.repository";
import { BookingsService } from "./bookings.service"; import { BookingsService } from "./bookings.service";
import { BookingLifecycleNotifierService } from "./booking-lifecycle-notifier.service"; import { BookingLifecycleNotifierService } from "./booking-lifecycle-notifier.service";
import { YardScopeService } from "../rule-engine/services/yard-scope.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. */ /** Where a rejected pair goes back to, so GL can fix and resubmit. */
const REJECTED_STATUS = "OPERATION_CHANGES_REQUESTED"; const REJECTED_STATUS = "OPERATION_CHANGES_REQUESTED";
@@ -31,6 +32,9 @@ export const CONSOLIDATION_APPROVAL_PENDING = "CONSOLIDATION_APPROVAL_PENDING";
export type ConsolidationApprovalView = ConsolidationApproval & { export type ConsolidationApprovalView = ConsolidationApproval & {
requestedByName: string | null; requestedByName: string | null;
decidedByName: 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( const names = await this.bookingsRepository.resolveStaffNames(
rows.flatMap((r) => [r.requestedBy, r.decidedBy]), 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) => ({ const items = rows.map((row) => ({
...row, ...row,
requestedByName: row.requestedBy requestedByName: row.requestedBy
? (names.get(row.requestedBy) ?? null) ? (names.get(row.requestedBy) ?? null)
: null, : null,
decidedByName: row.decidedBy ? (names.get(row.decidedBy) ?? 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); 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. * Yard ids the caller may see, or undefined for unrestricted.
* *

View File

@@ -1,19 +1,12 @@
import { useMyTradeAccess } from "@/hooks/useMyTradeAccess"; import { useMyTradeAccess } from "@/hooks/useMyTradeAccess";
import { import { Box, Button, Card, Group, Modal, Stack, Text } from "@mantine/core";
Box,
Button,
Card,
Group,
Modal,
Stack,
Text,
} from "@mantine/core";
import { import {
AlertTriangle, AlertTriangle,
ArrowRight, ArrowRight,
Calendar, Calendar,
CheckCircle2, CheckCircle2,
Clock, Clock,
FileText,
LayoutList, LayoutList,
Link2, Link2,
Package, Package,
@@ -23,13 +16,19 @@ import {
User, User,
} from "lucide-react"; } from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "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 { useQuery } from "@tanstack/react-query";
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu"; import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
import { ExportButton } from "@/components/export/ExportButton"; import { ExportButton } from "@/components/export/ExportButton";
import { formatDate, humanize } from "@/lib/format"; 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 { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
// BookingStatusTabs / Operations* queues removed — replaced by booking-kind tabs. // 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. // split), so a deep link can never land behind "More filters" unseen.
const bookingFilterDefs: FilterDef[] = useMemo( 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), 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: "freightType",
{ key: "paymentStatus", label: "Payment", type: "enum", multiple: false, options: PAYMENT_STATUS_OPTIONS, secondary: true }, 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 // Wins over the `paymentStatus` filter above — the queue is by
// definition PAID — because it's later in this array: toApiParams // definition PAID — because it's later in this array: toApiParams
// merges defs in order, so a later toParams overwrites an earlier one. // 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", 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"), toParams: routeParams("originYardId", "destinationYardId"),
}, },
{ {
key: "created", label: "Created", type: "date", secondary: true, key: "created",
label: "Created",
type: "date",
secondary: true,
operators: ["between", "before", "after"], operators: ["between", "before", "after"],
toParams: dateRangeParams("createdFrom", "createdTo"), toParams: dateRangeParams("createdFrom", "createdTo"),
}, },
{ {
key: "scheduled", label: "Scheduled", type: "date", secondary: true, key: "scheduled",
label: "Scheduled",
type: "date",
secondary: true,
operators: ["between", "before", "after"], operators: ["between", "before", "after"],
toParams: dateRangeParams("scheduledFrom", "scheduledTo"), toParams: dateRangeParams("scheduledFrom", "scheduledTo"),
}, },
@@ -187,19 +247,24 @@ export default function BookingRequestsPage() {
[filterOptions, yardOptions, serviceTypeOptions], [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( const filter: BookingListFilter = useMemo(
() => ({ () => ({
...(controls.params as unknown as BookingListFilter), ...(controls.params as unknown as BookingListFilter),
// React Query cache key per kind selection ("ALL" when unfiltered) — // React Query cache key per kind selection ("ALL" when unfiltered) —
// kept as a param the API ignores, matching the pre-migration cache key. // 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], [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 primaryAllocateId = allocateIds[0];
const { data: allocateBooking } = useBookingDetail( const { data: allocateBooking } = useBookingDetail(
allocateOpen ? primaryAllocateId : undefined, allocateOpen ? primaryAllocateId : undefined,
@@ -262,8 +327,9 @@ export default function BookingRequestsPage() {
async (row: BookingListRow) => { async (row: BookingListRow) => {
setAllocatingId(row.id); setAllocatingId(row.id);
try { try {
const candidates = const candidates = await trainSchedulingService.getAllocationCandidates(
await trainSchedulingService.getAllocationCandidates(row.id); row.id,
);
if (candidates.sameDay.length > 0) { if (candidates.sameDay.length > 0) {
const target = candidates.sameDay[0]; const target = candidates.sameDay[0];
await trainSchedulingService.allocatePaidBooking(row.id, target.id); await trainSchedulingService.allocatePaidBooking(row.id, target.id);
@@ -330,7 +396,9 @@ export default function BookingRequestsPage() {
</div> </div>
<div className="min-w-0 max-w-[220px]"> <div className="min-w-0 max-w-[220px]">
<div className="flex items-center gap-1.5"> <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 <Badge
variant={isGeneral ? "secondary" : "outline"} variant={isGeneral ? "secondary" : "outline"}
className="h-5 shrink-0 px-1.5 text-[10px] font-medium" className="h-5 shrink-0 px-1.5 text-[10px] font-medium"
@@ -338,6 +406,26 @@ export default function BookingRequestsPage() {
{isGeneral ? "General" : "One-time"} {isGeneral ? "General" : "One-time"}
</Badge> </Badge>
</div> </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"> <p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
{b.isShippingLine ? ( {b.isShippingLine ? (
<Ship className="size-3 shrink-0 opacity-70" /> <Ship className="size-3 shrink-0 opacity-70" />
@@ -346,7 +434,10 @@ export default function BookingRequestsPage() {
)} )}
{b.customerLabel} {b.customerLabel}
{b.isShippingLine ? ( {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 Shipping line
</Badge> </Badge>
) : null} ) : null}
@@ -382,7 +473,9 @@ export default function BookingRequestsPage() {
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground"> <div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
<span className="max-w-[8rem] truncate">{b.originLabel}</span> <span className="max-w-[8rem] truncate">{b.originLabel}</span>
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" /> <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>
<div className="flex gap-1.5"> <div className="flex gap-1.5">
<Badge <Badge
@@ -443,7 +536,8 @@ export default function BookingRequestsPage() {
size: 140, size: 140,
cell: ({ row }) => { cell: ({ row }) => {
const b = row.original; const b = row.original;
const needsAllocation = b.paymentStatus === "PAID" && !b.trainScheduleId; const needsAllocation =
b.paymentStatus === "PAID" && !b.trainScheduleId;
return ( return (
<Group gap="xs" wrap="nowrap"> <Group gap="xs" wrap="nowrap">
{needsAllocation ? ( {needsAllocation ? (
@@ -474,61 +568,61 @@ export default function BookingRequestsPage() {
return ( return (
<PageContainer> <PageContainer>
<Stack gap="lg"> <Stack gap="lg">
<PageHeader <PageHeader
title="Booking requests" title="Booking requests"
subtitle="Review, approve, and schedule freight booking requests." subtitle="Review, approve, and schedule freight booking requests."
action={ action={
<> <>
<Button <Button
color="edr-green" color="edr-green"
leftSection={<Plus size={18} />} leftSection={<Plus size={18} />}
onClick={() => navigate("/dashboard/booking-requests/new")} onClick={() => navigate("/dashboard/booking-requests/new")}
> >
Create booking Create booking
</Button> </Button>
<Button <Button
variant="default" variant="default"
leftSection={<RefreshCw size={16} />} leftSection={<RefreshCw size={16} />}
loading={isFetching} loading={isFetching}
onClick={handleRefresh} onClick={handleRefresh}
> >
Refresh Refresh
</Button> </Button>
</> </>
} }
/> />
<KpiStrip <KpiStrip
loading={summaryLoading} loading={summaryLoading}
items={[ items={[
{ {
label: "In queue", label: "In queue",
value: metrics?.inQueue ?? 0, value: metrics?.inQueue ?? 0,
icon: LayoutList, icon: LayoutList,
color: "edr-green", color: "edr-green",
}, },
{ {
label: "Needs action", label: "Needs action",
value: metrics?.needsAction ?? 0, value: metrics?.needsAction ?? 0,
icon: Clock, icon: Clock,
color: "yellow", color: "yellow",
}, },
{ {
label: "Urgent", label: "Urgent",
value: metrics?.urgent ?? 0, value: metrics?.urgent ?? 0,
icon: AlertTriangle, icon: AlertTriangle,
color: "red", color: "red",
}, },
{ {
label: "Completed", label: "Completed",
value: tabCounts?.completed ?? 0, value: tabCounts?.completed ?? 0,
icon: CheckCircle2, icon: CheckCircle2,
color: "edr-green", color: "edr-green",
}, },
]} ]}
/> />
{/* 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. old BookingStatusTabs is commented out — status is now a filter select.
<BookingStatusTabs <BookingStatusTabs
active={activeTab} active={activeTab}
@@ -540,92 +634,95 @@ export default function BookingRequestsPage() {
/> />
*/} */}
<Card p={0}> <Card p={0}>
<Stack gap={0}> <Stack gap={0}>
<Box px="md" pt="sm" pb="xs" w="100%"> <Box px="md" pt="sm" pb="xs" w="100%">
<FilterBar <FilterBar
defs={bookingFilterDefs} defs={bookingFilterDefs}
controls={controls} controls={controls}
searchPlaceholder="Search booking, contract, customer or shipping line…" searchPlaceholder="Search booking, contract, customer or shipping line…"
viewId="booking-requests" viewId="booking-requests"
> >
<ExportButton datasetKey="bookings" params={controls.params} /> <ExportButton datasetKey="bookings" params={controls.params} />
</FilterBar> </FilterBar>
</Box>
{showEmpty ? (
<Box px="md" pb="md">
<BookingTableEmpty
isError={isError}
hasSearch={hasSearch}
onRetry={handleRefresh}
/>
</Box> </Box>
) : (
{showEmpty ? ( <Box style={{ overflowX: "auto" }} w="100%">
<Box px="md" pb="md"> <DataTable
<BookingTableEmpty columns={columns}
isError={isError} data={rows}
hasSearch={hasSearch} status={isLoading ? "loading" : isError ? "error" : "success"}
onRetry={handleRefresh} onRowClick={handleRowClick}
/> {...controls.tableProps(total)}
</Box> containerClassName="border-0 shadow-none bg-transparent"
) : ( footer={DataTableFooter}
<Box style={{ overflowX: "auto" }} w="100%"> />
<DataTable </Box>
columns={columns} )}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={handleRowClick}
{...controls.tableProps(total)}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
)}
</Stack>
</Card>
</Stack>
<Modal
opened={otherDayModal !== null}
onClose={() => setOtherDayModal(null)}
title="Allocate to another date"
centered
>
<Stack gap="sm">
<Text size="sm" c="dimmed">
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>
{otherDayModal?.candidates.map((c) => (
<Group key={c.id} justify="space-between" wrap="nowrap">
<div>
<Text size="sm" fw={500}>
{c.reference ?? "Train"}
</Text>
<Text size="xs" c="dimmed">
Departs {formatDate(c.scheduledDepartureDate)}
{c.direction ? ` · ${c.direction}` : ""}
</Text>
</div>
<Button
size="compact-sm"
color="edr-green"
loading={allocatingId === otherDayModal.booking.id}
onClick={() => void handleAllocateOtherDay(c)}
>
Allocate
</Button>
</Group>
))}
</Stack> </Stack>
</Modal> </Card>
</Stack>
{allocateBooking ? ( <Modal
<AllocateBookingWizard opened={otherDayModal !== null}
booking={allocateBooking} onClose={() => setOtherDayModal(null)}
opened={allocateOpen} title="Allocate to another date"
onClose={() => { centered
setAllocateOpen(false); >
setAllocateIds([]); <Stack gap="sm">
void refetch(); <Text size="sm" c="dimmed">
}} No train on{" "}
initialBookingIds={allocateIds} {otherDayModal
/> ? formatDate(otherDayModal.booking.scheduledDate)
) : null} : "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>
{otherDayModal?.candidates.map((c) => (
<Group key={c.id} justify="space-between" wrap="nowrap">
<div>
<Text size="sm" fw={500}>
{c.reference ?? "Train"}
</Text>
<Text size="xs" c="dimmed">
Departs {formatDate(c.scheduledDepartureDate)}
{c.direction ? ` · ${c.direction}` : ""}
</Text>
</div>
<Button
size="compact-sm"
color="edr-green"
loading={allocatingId === otherDayModal.booking.id}
onClick={() => void handleAllocateOtherDay(c)}
>
Allocate
</Button>
</Group>
))}
</Stack>
</Modal>
{allocateBooking ? (
<AllocateBookingWizard
booking={allocateBooking}
opened={allocateOpen}
onClose={() => {
setAllocateOpen(false);
setAllocateIds([]);
void refetch();
}}
initialBookingIds={allocateIds}
/>
) : null}
</PageContainer> </PageContainer>
); );
} }

View File

@@ -18,7 +18,15 @@ import {
Textarea, Textarea,
ThemeIcon, ThemeIcon,
} from "@mantine/core"; } 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 toast from "react-hot-toast";
import { PageContainer, PageHeader } from "@/components/page"; import { PageContainer, PageHeader } from "@/components/page";
@@ -234,6 +242,8 @@ export default function ConsolidationApprovalsPage() {
row.booking?.reference ?? row.bookingReference row.booking?.reference ?? row.bookingReference
} }
company={row.booking?.company?.name} company={row.booking?.company?.name}
contractReference={row.contractReference}
contractId={row.booking?.contractId}
/> />
<BookingSide <BookingSide
id={row.partnerBookingId} id={row.partnerBookingId}
@@ -242,6 +252,8 @@ export default function ConsolidationApprovalsPage() {
row.partnerBookingReference row.partnerBookingReference
} }
company={row.partnerBooking?.company?.name} company={row.partnerBooking?.company?.name}
contractReference={row.partnerContractReference}
contractId={row.partnerBooking?.contractId}
/> />
</Group> </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({ function BookingSide({
id, id,
reference, reference,
company, company,
contractReference,
contractId,
}: { }: {
id: string; id: string;
reference?: string | null; reference?: string | null;
company?: string | null; company?: string | null;
contractReference?: string | null;
contractId?: string | null;
}) { }) {
return ( return (
<Box style={{ minWidth: 0 }}> <Box style={{ minWidth: 0 }}>
@@ -439,6 +459,32 @@ function BookingSide({
> >
{reference ?? "—"} {reference ?? "—"}
</Text> </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"> <Text fz={12.5} c="dimmed">
{company ?? "—"} {company ?? "—"}
</Text> </Text>

View File

@@ -26,14 +26,19 @@ export interface ConsolidationApprovalRow {
scheduledDate?: string | null; scheduledDate?: string | null;
bookingReference?: string | null; bookingReference?: string | null;
partnerBookingReference?: string | null; partnerBookingReference?: string | null;
/** Contract each half was created under — reviewers work by contract. */
contractReference?: string | null;
partnerContractReference?: string | null;
booking?: { booking?: {
id: string; id: string;
reference?: string; reference?: string;
contractId?: string | null;
company?: { name?: string } | null; company?: { name?: string } | null;
} | null; } | null;
partnerBooking?: { partnerBooking?: {
id: string; id: string;
reference?: string; reference?: string;
contractId?: string | null;
company?: { name?: string } | null; company?: { name?: string } | null;
} | null; } | null;
} }