feat: implement consolidation approval process for shared-wagon bookings

- Add migration for consolidation approvals table and status enum
- Create ConsolidationApprovalService to handle approval logic
- Implement repository for managing consolidation approvals
- Add entity for consolidation approval with necessary fields
- Develop frontend components for displaying and managing consolidation approvals
- Create tests for consolidation approval service to ensure correct behavior
This commit is contained in:
Marshal
2026-08-18 13:17:55 +00:00
parent 22a3fb98ee
commit 40904049cf
36 changed files with 1898 additions and 184 deletions

View File

@@ -19,6 +19,7 @@ import ForgotPasswordPage from "./pages/auth/ForgotPasswordPage";
import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
import ConsolidationApprovalsPage from "./pages/bookings/ConsolidationApprovalsPage";
import NewBookingPage from "./pages/bookings/NewBookingPage";
import WagonCancellationsPage from "./pages/bookings/WagonCancellationsPage";
import ContractRequestsPage from "./pages/contracts/ContractRequestsPage";
@@ -381,6 +382,18 @@ const App = () => {
</RequirePermission>
}
/>
{/* Shared-wagon gate: consolidated pairs wait for a human decision
before either half reaches Operations. */}
<Route
path="consolidation-approvals"
element={
<RequirePermission
permission={FREIGHT_PERMS.bookings.approveConsolidation}
>
<ConsolidationApprovalsPage />
</RequirePermission>
}
/>
<Route
path="booking-requests/:id"
element={

View File

@@ -0,0 +1,82 @@
import { useQuery } from "@tanstack/react-query";
import { Badge, Box, Group, Stack, Text } from "@mantine/core";
import { Link2 } from "lucide-react";
import { bookingsService } from "@/services/bookings.service";
import { formatDateTime } from "@/lib/format";
import { SectionCard } from "./SectionCard";
const STATUS_COLOR: Record<string, string> = {
PENDING: "yellow",
APPROVED: "teal",
REJECTED: "red",
};
/**
* Audit trail for this booking's shared wagon: every approval request against
* it, who decided, when, and why. Rendered only for a booking that is actually
* consolidated — there is nothing to show otherwise.
*/
export function ConsolidationApprovalCard({ bookingId }: { bookingId: string }) {
const { data } = useQuery({
queryKey: ["consolidation-approvals", "history", bookingId],
queryFn: () => bookingsService.consolidationApprovalHistory(bookingId),
enabled: Boolean(bookingId),
});
if (!data?.length) return null;
return (
<SectionCard icon={Link2} title="Shared wagon approval">
<Stack gap="md">
{data.map((row) => (
<Box
key={row.id}
style={{
borderLeft: "3px solid var(--mantine-color-gray-3)",
paddingLeft: 12,
}}
>
<Group gap={8} align="center" wrap="wrap" mb={4}>
<Badge
color={STATUS_COLOR[row.status] ?? "gray"}
variant="light"
radius="sm"
size="sm"
>
{row.status}
</Badge>
<Text fz={13} fw={600}>
{row.bookingReference ?? "—"} + {row.partnerBookingReference ?? "—"}
</Text>
</Group>
<Text fz={12} c="dimmed">
Requested {formatDateTime(row.requestedAt)}
{row.requestedBy ? ` by ${row.requestedBy}` : ""}
</Text>
{row.decidedAt ? (
<Text fz={12} c="dimmed">
{row.status === "APPROVED" ? "Approved" : "Rejected"}{" "}
{formatDateTime(row.decidedAt)}
{row.decidedBy ? ` by ${row.decidedBy}` : ""}
</Text>
) : (
<Text fz={12} c="yellow.8">
Waiting for a decision neither booking reaches Operations until
this is approved.
</Text>
)}
{row.decisionNote ? (
<Text fz={12.5} mt={4} style={{ whiteSpace: "pre-wrap" }}>
{row.decisionNote}
</Text>
) : null}
</Box>
))}
</Stack>
</SectionCard>
);
}

View File

@@ -14,7 +14,7 @@ import {
import { useDebouncedValue } from "@mantine/hooks";
import { useQuery } from "@tanstack/react-query";
import { MapPin, Plus, Search } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { memo, useCallback, useEffect, useMemo, useState } from "react";
const PAGE_SIZE = 20;
@@ -24,7 +24,7 @@ import { api } from "@/services/api";
* AVAILABLE, unassigned wagons from every yard — filtered and paged on the API,
* so the picker never page-walks the whole fleet into the browser.
*/
export default function AvailableWagonsPanel({
function AvailableWagonsPanel({
homeYardId,
onAssign,
assigning,
@@ -36,7 +36,7 @@ export default function AvailableWagonsPanel({
const [typeFilter, setTypeFilter] = useState<string>("ALL");
const [yardFilter, setYardFilter] = useState<string>("ALL");
const [runOnly, setRunOnly] = useState(false);
const [selected, setSelected] = useState<string[]>([]);
const [selected, setSelected] = useState<ReadonlySet<string>>(() => new Set());
const [page, setPage] = useState(1);
// The train's own run, e.g. "8001-8002" — only offered when the train has one.
@@ -60,6 +60,9 @@ export default function AvailableWagonsPanel({
pageSize: PAGE_SIZE,
},
},
// Keep the previous page on screen while the next one loads — otherwise
// paging and typing flash the list to "Loading wagons…" on every stroke.
placeholderData: (prev) => prev,
}),
);
@@ -105,32 +108,32 @@ export default function AvailableWagonsPanel({
[wagonTypesQuery.data],
);
const toggle = (wagonId: string, checked: boolean) => {
setSelected((prev) =>
checked ? [...prev, wagonId] : prev.filter((id) => id !== wagonId),
);
};
const toggle = useCallback((wagonId: string, checked: boolean) => {
setSelected((prev) => {
const next = new Set(prev);
if (checked) next.add(wagonId);
else next.delete(wagonId);
return next;
});
}, []);
// Select-all covers this page only — the rest of the matches are not loaded.
const allSelected = wagons.length > 0 && wagons.every((w) => selected.includes(w.id));
const someSelected = wagons.some((w) => selected.includes(w.id));
const allSelected = wagons.length > 0 && wagons.every((w) => selected.has(w.id));
const someSelected = wagons.some((w) => selected.has(w.id));
const toggleAll = (checked: boolean) => {
setSelected((prev) => {
if (checked) {
const ids = new Set(prev);
wagons.forEach((w) => ids.add(w.id));
return [...ids];
}
const visible = new Set(wagons.map((w) => w.id));
return prev.filter((id) => !visible.has(id));
const next = new Set(prev);
if (checked) wagons.forEach((w) => next.add(w.id));
else wagons.forEach((w) => next.delete(w.id));
return next;
});
};
const handleAssign = () => {
if (!selected.length) return;
onAssign(selected);
setSelected([]);
if (!selected.size) return;
onAssign([...selected]);
setSelected(new Set());
};
return (
@@ -178,16 +181,24 @@ export default function AvailableWagonsPanel({
indeterminate={!allSelected && someSelected}
onChange={(e) => toggleAll(e.currentTarget.checked)}
/>
{selected.length ? (
{selected.size ? (
<Text size="xs" c="dimmed">
{selected.length} selected
{selected.size} selected
</Text>
) : null}
</Group>
) : null}
<ScrollArea.Autosize mah={380} type="auto">
<Stack gap={6}>
{/* Previous results stay put while the next page loads (placeholderData),
so dim them rather than blanking the list. */}
<Stack
gap={6}
style={{
opacity: wagonsQuery.isFetching && !wagonsQuery.isLoading ? 0.55 : 1,
transition: "opacity 120ms ease",
}}
>
{wagonsQuery.isLoading ? (
<Text py="md" ta="center" c="dimmed" size="sm">
Loading wagons
@@ -198,57 +209,14 @@ export default function AvailableWagonsPanel({
</Text>
) : (
wagons.map((wagon) => (
<Group
<WagonOption
key={wagon.id}
gap="sm"
wrap="nowrap"
p="xs"
style={{
border: "1px solid var(--mantine-color-gray-2)",
borderRadius: "var(--mantine-radius-md)",
}}
>
<Checkbox
size="sm"
checked={selected.includes(wagon.id)}
onChange={(e) => toggle(wagon.id, e.currentTarget.checked)}
aria-label={`Select wagon ${wagon.wagonNumber}`}
/>
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600} ff="monospace" truncate>
{wagon.wagonNumber}
</Text>
<Badge
size="xs"
radius="sm"
variant="outline"
color={wagon.currentYardId === homeYardId ? "edr-green" : "gray"}
leftSection={<MapPin size={10} />}
>
{wagon.currentYard?.label ?? wagon.currentYard?.code ?? "No yard"}
</Badge>
{wagon.exportTrainNumber ? (
<Badge
size="xs"
radius="sm"
variant="light"
color={
wagon.exportTrainNumber === exportTrainNumber ? "edr-green" : "gray"
}
>
{wagon.exportTrainNumber}
{wagon.importTrainNumber ? `-${wagon.importTrainNumber}` : ""}
</Badge>
) : null}
</Group>
<Text size="xs" c="dimmed" truncate>
{wagon.wagonType
? `${wagon.wagonType.name} · ${wagon.wagonType.capacityTons ?? "—"}T cap`
: "Unknown type"}
</Text>
</Stack>
</Group>
wagon={wagon}
selected={selected.has(wagon.id)}
homeYardId={homeYardId}
exportTrainNumber={exportTrainNumber}
onToggle={toggle}
/>
))
)}
</Stack>
@@ -265,16 +233,19 @@ export default function AvailableWagonsPanel({
<Button
leftSection={<Plus size={16} />}
disabled={!selected.length}
disabled={!selected.size}
loading={assigning}
onClick={handleAssign}
>
Add {selected.length ? `${selected.length} wagon${selected.length > 1 ? "s" : ""}` : "wagons"} to consist
Add {selected.size ? `${selected.size} wagon${selected.size > 1 ? "s" : ""}` : "wagons"} to consist
</Button>
</Stack>
);
}
/** Memoized: the workspace re-renders on every pending mutation. */
export default memo(AvailableWagonsPanel);
export interface AvailableWagonsPanelProps {
/** The train's own yard — sorted first and highlighted; not a restriction. */
homeYardId: string | null;
@@ -285,3 +256,81 @@ export interface AvailableWagonsPanelProps {
/** This train's even IMPORT run — label only; the export run does the matching. */
importTrainNumber?: string | null;
}
/**
* One selectable wagon row. Memoized: the picker re-renders on every keystroke
* and every selection change, but a row only actually changes when its own
* checkbox flips — so a full page of rows stays untouched.
*/
const WagonOption = memo(function WagonOption({
wagon,
selected,
homeYardId,
exportTrainNumber,
onToggle,
}: {
wagon: {
id: string;
wagonNumber: string;
currentYardId?: string | null;
currentYard?: { label?: string | null; code?: string | null } | null;
exportTrainNumber?: string | null;
importTrainNumber?: string | null;
wagonType?: { name?: string | null; capacityTons?: number | null } | null;
};
selected: boolean;
homeYardId: string | null;
exportTrainNumber?: string | null;
onToggle: (wagonId: string, checked: boolean) => void;
}) {
return (
<Group
gap="sm"
wrap="nowrap"
p="xs"
style={{
border: "1px solid var(--mantine-color-gray-2)",
borderRadius: "var(--mantine-radius-md)",
}}
>
<Checkbox
size="sm"
checked={selected}
onChange={(e) => onToggle(wagon.id, e.currentTarget.checked)}
aria-label={`Select wagon ${wagon.wagonNumber}`}
/>
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600} ff="monospace" truncate>
{wagon.wagonNumber}
</Text>
<Badge
size="xs"
radius="sm"
variant="outline"
color={wagon.currentYardId === homeYardId ? "edr-green" : "gray"}
leftSection={<MapPin size={10} />}
>
{wagon.currentYard?.label ?? wagon.currentYard?.code ?? "No yard"}
</Badge>
{wagon.exportTrainNumber ? (
<Badge
size="xs"
radius="sm"
variant="light"
color={wagon.exportTrainNumber === exportTrainNumber ? "edr-green" : "gray"}
>
{wagon.exportTrainNumber}
{wagon.importTrainNumber ? `-${wagon.importTrainNumber}` : ""}
</Badge>
) : null}
</Group>
<Text size="xs" c="dimmed" truncate>
{wagon.wagonType
? `${wagon.wagonType.name} · ${wagon.wagonType.capacityTons ?? "—"}T cap`
: "Unknown type"}
</Text>
</Stack>
</Group>
);
});

View File

@@ -8,7 +8,7 @@ import {
} from "@hello-pangea/dnd";
import { ActionIcon, Badge, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
import { GripVertical, MapPin, Trash2, Wrench } from "lucide-react";
import { type ReactNode } from "react";
import { memo, useCallback, useMemo, type ReactNode } from "react";
import { createPortal } from "react-dom";
import type { TrainCompositionWagon } from "@/services/trainBuilder.service";
@@ -32,7 +32,7 @@ const PortalAwareRow = ({
* The train's ordered wagon consist. Drag to reorder (persisted on drop),
* trash to detach a wagon back to the yard.
*/
export default function ConsistWagonList({
function ConsistWagonList({
wagons,
editable,
onReorder,
@@ -40,7 +40,7 @@ export default function ConsistWagonList({
onMaintenance,
busy = false,
}: ConsistWagonListProps) {
const onDragEnd = (result: DropResult) => {
const onDragEnd = useCallback((result: DropResult) => {
if (!result.destination) return;
const from = result.source.index;
const to = result.destination.index;
@@ -49,7 +49,18 @@ export default function ConsistWagonList({
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved!);
onReorder(next.map((w) => w.id));
};
}, [wagons, onReorder]);
// Legend of the types actually coupled, in consist order — the colour code is
// only readable if the row tints are keyed somewhere.
const legend = useMemo(
() => [
...new Map(
wagons.filter((w) => w.wagonType).map((w) => [w.wagonType!.code, w.wagonType!]),
).values(),
],
[wagons],
);
if (!wagons.length) {
return (
@@ -59,16 +70,6 @@ export default function ConsistWagonList({
);
}
// Legend of the types actually coupled, in consist order — the colour code is
// only readable if the row tints are keyed somewhere.
const legend = [
...new Map(
wagons
.filter((w) => w.wagonType)
.map((w) => [w.wagonType!.code, w.wagonType!]),
).values(),
];
return (
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="train-consist-wagons" isDropDisabled={!editable || busy}>
@@ -118,6 +119,9 @@ export default function ConsistWagonList({
);
}
/** Memoized: a 40-wagon consist re-renders every row otherwise. */
export default memo(ConsistWagonList);
export interface ConsistWagonListProps {
wagons: TrainCompositionWagon[];
editable: boolean;
@@ -128,7 +132,7 @@ export interface ConsistWagonListProps {
busy?: boolean;
}
function WagonRow({
const WagonRow = memo(function WagonRow({
wagon,
index,
dragProvided,
@@ -232,4 +236,4 @@ function WagonRow({
</Group>
</PortalAwareRow>
);
}
});

View File

@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { memo, useMemo } from "react";
import { Box, Group, Paper, Progress, Stack, Text, Tooltip } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { Box as BoxIcon, Container as ContainerIcon, Fuel, Gauge, TrainFront } from "lucide-react";
@@ -132,7 +132,7 @@ function Coupler() {
);
}
function LocomotiveCar({
const LocomotiveCar = memo(function LocomotiveCar({
code,
name,
maxPullWeightTons,
@@ -260,7 +260,7 @@ function LocomotiveCar({
</Box>
</Tooltip>
);
}
});
const CONTAINER_GRADIENTS = [
"linear-gradient(180deg, var(--mantine-color-cyan-5), var(--mantine-color-cyan-7))",
@@ -271,7 +271,7 @@ const CONTAINER_BORDERS = [
"var(--mantine-color-blue-8)",
];
function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
const WagonCar = memo(function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
// GROSS on both sides: cargo + tare vs rated payload + tare.
const grossTons = round1(wagon.assignedWeightTons + wagon.tareWeightTons);
const maxGrossTons = round1(wagon.capacityTons + wagon.tareWeightTons);
@@ -468,7 +468,7 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
</Box>
</Tooltip>
);
}
});
/** Railway track: two rails over evenly-spaced sleepers. */
function TrackBed() {
@@ -520,7 +520,7 @@ function TrackBed() {
);
}
export function TrainCompositionDiagram({
export const TrainCompositionDiagram = memo(function TrainCompositionDiagram({
locomotive,
locomotives,
wagons,
@@ -818,7 +818,7 @@ export function TrainCompositionDiagram({
</Stack>
</Paper>
);
}
});
function LegendDot({ color, label }: { color: string; label: string }) {
return (

View File

@@ -193,6 +193,15 @@ export const URL_CONSTANTS = {
STAFF_ACCEPT: (id: string) => `/bookings/${id}/staff/accept`,
// Consolidated pair: one staff decision applied to both halves at once.
PAIRED_DECISION: (id: string) => `/bookings/${id}/paired-decision`,
// Shared-wagon approval gate: a consolidated pair waits for a human
// decision before either half reaches Operations.
CONSOLIDATION_APPROVAL_QUEUE: "/bookings/consolidation-approvals/queue",
CONSOLIDATION_APPROVAL_HISTORY: (id: string) =>
`/bookings/${id}/consolidation-approvals`,
CONSOLIDATION_APPROVE: (approvalId: string) =>
`/bookings/consolidation-approvals/${approvalId}/approve`,
CONSOLIDATION_REJECT: (approvalId: string) =>
`/bookings/consolidation-approvals/${approvalId}/reject`,
STAFF_REQUEST_CHANGES: (id: string) =>
`/bookings/${id}/staff/request-changes`,
STAFF_REJECT: (id: string) => `/bookings/${id}/staff/reject`,

View File

@@ -102,6 +102,11 @@ export const BOOKING_STATUS_STYLES: Record<string, StatusStyle> = {
label: "Operation Changes",
color: "bg-orange-50 text-orange-700 border-orange-200",
},
// Shared-wagon gate: held for a human decision before reaching Operations.
CONSOLIDATION_APPROVAL_PENDING: {
label: "Wagon Approval",
color: "bg-amber-50 text-amber-700 border-amber-200",
},
OPERATION_PRICE_PENDING_CONFIRM: {
label: "Price Confirm",
color: "bg-amber-50 text-amber-700 border-amber-200",
@@ -309,6 +314,9 @@ export const BOOKING_LIST_TABS = [
"OPERATION_REQUEST_PENDING",
"OPERATION_CHANGES_REQUESTED",
"OPERATION_PRICE_PENDING_CONFIRM",
// Held at the shared-wagon gate — still an ops-review-stage booking, it
// just needs the pairing signed off before Operations can act on it.
"CONSOLIDATION_APPROVAL_PENDING",
],
},
{

View File

@@ -57,6 +57,7 @@ export const FREIGHT_PERMS = {
wagonCancellationVoid: "edr_freight_app:bookings:wagon_cancellation_void",
wagonCancellationRebook:
"edr_freight_app:bookings:wagon_cancellation_rebook",
approveConsolidation: "edr_freight_app:bookings:approve_consolidation",
},
contracts: {
view: "edr_freight_app:contracts:view",

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { queryClient } from "./queryClient";
/**
* The MutationCache seeds `meta.updates` entries into the cache and then skips
* exactly those keys when running `meta.invalidates`. Getting that skip wrong
* silently reintroduces the refetch it exists to avoid, so it is worth pinning.
*/
const runOnSuccess = (meta: Record<string, unknown>, data: unknown, variables: unknown) => {
const handler = (queryClient.getMutationCache() as unknown as {
config: {
onSuccess?: (
data: unknown,
variables: unknown,
context: unknown,
mutation: { meta?: Record<string, unknown> },
) => void;
};
}).config.onSuccess;
handler?.(data, variables, undefined, { meta });
};
describe("MutationCache updates/invalidates", () => {
it("writes the mutation response into the seeded key and leaves it fresh", () => {
const seededKey = ["train-builder", "composition", "t1"] as const;
const siblingKey = ["train-builder", "list", {}] as const;
queryClient.setQueryData(seededKey, { code: "STALE" });
queryClient.setQueryData(siblingKey, { items: [] });
const response = { code: "FRESH" };
runOnSuccess(
{
updates: (_v: unknown, d: unknown) => [[seededKey, d]],
invalidates: () => [["train-builder"]],
},
response,
{ id: "t1" },
);
// Seeded key holds the response, and was NOT invalidated back to stale.
expect(queryClient.getQueryData(seededKey)).toStrictEqual(response);
expect(queryClient.getQueryState(seededKey)?.isInvalidated).toBe(false);
// Its siblings under the same root still get invalidated.
expect(queryClient.getQueryState(siblingKey)?.isInvalidated).toBe(true);
});
it("invalidates everything when a mutation declares no updates", () => {
const key = ["train-builder", "composition", "t2"] as const;
queryClient.setQueryData(key, { code: "X" });
runOnSuccess({ invalidates: () => [["train-builder"]] }, undefined, undefined);
expect(queryClient.getQueryState(key)?.isInvalidated).toBe(true);
});
});

View File

@@ -1,6 +1,6 @@
import { MutationCache, QueryClient } from "@tanstack/react-query";
import type { InvalidatesMeta } from "@/utils/endpoint";
import type { InvalidatesMeta, UpdatesMeta } from "@/utils/endpoint";
/**
* Single app-wide React Query client (do not nest additional providers).
@@ -14,13 +14,37 @@ import type { InvalidatesMeta } from "@/utils/endpoint";
export const queryClient = new QueryClient({
mutationCache: new MutationCache({
onSuccess: (data, variables, _context, mutation) => {
// Seed first: endpoints that return the entity they just changed write it
// straight into its cache key, so the screen updates from the response
// instead of round-tripping for data it already holds.
const updates = mutation.meta?.updates as UpdatesMeta | undefined;
const seeded: readonly unknown[][] = [];
if (typeof updates === "function") {
for (const [queryKey, value] of updates(variables, data)) {
queryClient.setQueryData(queryKey, value);
seeded.push(queryKey as unknown[]);
}
}
const invalidates = mutation.meta?.invalidates as
| InvalidatesMeta
| undefined;
if (typeof invalidates !== "function") return;
for (const queryKey of invalidates(variables, data)) {
void queryClient.invalidateQueries({ queryKey });
void queryClient.invalidateQueries({
queryKey,
// A seeded key already holds the authoritative value from this very
// response — invalidating it would refetch it right back.
predicate: seeded.length
? (query) =>
!seeded.some(
(key) =>
key.length === query.queryKey.length &&
key.every((part, i) => Object.is(part, query.queryKey[i])),
)
: undefined,
});
}
},
// Mutation failures are surfaced globally by the axios interceptor in

View File

@@ -21,6 +21,7 @@ import {
} from "lucide-react";
import {
ActionIcon,
Alert,
Badge,
Box,
Button,
@@ -48,6 +49,7 @@ import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import { ConsolidationWaitingBanner } from "@/components/bookings/detail/ConsolidationWaitingBanner";
import { ConsolidationApprovalCard } from "@/components/bookings/detail/ConsolidationApprovalCard";
import {
detailStyles,
BookingRouteServiceCard,
@@ -460,6 +462,21 @@ export default function BookingRequestDetailPage() {
<ConsolidationWaitingBanner bookingId={booking.id} />
)}
{booking.status === "CONSOLIDATION_APPROVAL_PENDING" && (
<Alert
color="yellow"
radius="md"
icon={<Link2 size={18} />}
title="Waiting for shared-wagon approval"
>
<Text size="sm">
This booking shares a wagon with another customer&apos;s booking.
Both are held here until the pairing is approved neither reaches
Operations before then.
</Text>
</Alert>
)}
<Grid gap="lg">
{/* LEFT — primary content, split into tabs to keep each view focused.
The Documents tab is always present, so the tab bar always renders. */}
@@ -534,6 +551,8 @@ export default function BookingRequestDetailPage() {
that train and its clock must be readable before the approve
button. */}
<BookingSchedulingWindowCard booking={booking} />
{/* Renders itself only when this booking has a shared wagon. */}
<ConsolidationApprovalCard bookingId={booking.id} />
<BookingActionsToolbar
booking={booking}
mutations={mutations}

View File

@@ -0,0 +1,284 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Alert,
Badge,
Box,
Button,
Center,
Group,
Loader,
Modal,
Paper,
Stack,
Text,
Textarea,
ThemeIcon,
} from "@mantine/core";
import { AlertCircle, Check, Clock, Link2, X } from "lucide-react";
import toast from "react-hot-toast";
import { PageContainer, PageHeader } from "@/components/page";
import {
bookingsService,
type ConsolidationApprovalRow,
} from "@/services/bookings.service";
import { formatDateTime } from "@/lib/format";
import { extractErrorMessage } from "@/utils/errorExtractor";
const QUEUE_KEY = ["consolidation-approvals", "queue"];
/**
* Review queue for shared-wagon pairings.
*
* A booking that fills its own wagons goes straight to Operations. A
* consolidated one waits here: two customers' cargo rides one physical wagon
* under two separate invoices, so a person signs off on the pairing first.
* Approving releases BOTH bookings to Operations; rejecting sends BOTH back to
* GL with the reason.
*/
export default function ConsolidationApprovalsPage() {
const qc = useQueryClient();
const [decision, setDecision] = useState<{
row: ConsolidationApprovalRow;
kind: "approve" | "reject";
} | null>(null);
const [note, setNote] = useState("");
const {
data: rows,
isLoading,
isError,
} = useQuery({
queryKey: QUEUE_KEY,
queryFn: () => bookingsService.consolidationApprovalQueue(),
});
const close = () => {
setDecision(null);
setNote("");
};
const decide = useMutation({
mutationFn: () => {
if (!decision) throw new Error("No pairing selected");
return decision.kind === "approve"
? bookingsService.approveConsolidation(decision.row.id, note.trim() || undefined)
: bookingsService.rejectConsolidation(decision.row.id, note.trim());
},
onSuccess: () => {
toast.success(
decision?.kind === "approve"
? "Shared wagon approved — both bookings sent to Operations"
: "Shared wagon rejected — both bookings returned to GL",
);
void qc.invalidateQueries({ queryKey: QUEUE_KEY });
close();
},
onError: (error) =>
toast.error(extractErrorMessage(error, "Could not record the decision")),
});
// A rejection has to tell GL what to fix, so the reason is mandatory there.
const confirmDisabled =
decide.isPending || (decision?.kind === "reject" && !note.trim());
return (
<PageContainer>
<PageHeader
title="Shared wagon approvals"
subtitle="Two customers' cargo on one wagon — review the pairing before it reaches Operations."
/>
{isLoading ? (
<Center py={80}>
<Loader color="edr-green" />
</Center>
) : isError ? (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
Could not load the approval queue.
</Alert>
) : !rows?.length ? (
<Alert color="gray" radius="md" icon={<Check size={16} />}>
Nothing waiting for approval.
</Alert>
) : (
<Stack gap="md">
{rows.map((row) => (
<Paper
key={row.id}
withBorder
radius="lg"
p="lg"
style={{ borderColor: "#E6ECF2" }}
>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Box style={{ minWidth: 0, flex: 1 }}>
<Group gap={8} align="center" mb={10}>
<ThemeIcon variant="light" color="blue" radius="md" size={30}>
<Link2 size={16} />
</ThemeIcon>
<Text fw={800} fz={15}>
Shared wagon
</Text>
<Badge color="yellow" variant="light" radius="sm">
Awaiting approval
</Badge>
</Group>
<Group gap="xl" wrap="wrap">
<BookingSide
id={row.bookingId}
reference={row.booking?.reference ?? row.bookingReference}
company={row.booking?.company?.name}
/>
<BookingSide
id={row.partnerBookingId}
reference={
row.partnerBooking?.reference ??
row.partnerBookingReference
}
company={row.partnerBooking?.company?.name}
/>
</Group>
<Group gap={6} mt={12} c="dimmed">
<Clock size={13} />
<Text fz={12}>
Requested {formatDateTime(row.requestedAt)}
{row.scheduledDate
? ` · ships ${formatDateTime(row.scheduledDate)}`
: ""}
</Text>
</Group>
</Box>
<Group gap="sm">
<Button
color="edr-green"
radius="md"
leftSection={<Check size={15} />}
onClick={() => {
setDecision({ row, kind: "approve" });
setNote("");
}}
>
Approve
</Button>
<Button
color="red"
variant="light"
radius="md"
leftSection={<X size={15} />}
onClick={() => {
setDecision({ row, kind: "reject" });
setNote("");
}}
>
Reject
</Button>
</Group>
</Group>
</Paper>
))}
</Stack>
)}
<Modal
opened={Boolean(decision)}
onClose={() => {
if (!decide.isPending) close();
}}
centered
radius="lg"
title={
<Text fw={800} fz={16}>
{decision?.kind === "approve"
? "Approve this shared wagon?"
: "Reject this shared wagon?"}
</Text>
}
>
<Stack gap="md">
<Text fz="sm" c="dimmed">
{decision?.kind === "approve"
? "Both bookings leave the gate together and continue to Operations. Each is still invoiced and paid separately."
: "Both bookings go back to GL as “changes requested” with your reason. Neither reaches Operations."}
</Text>
<Textarea
label={
decision?.kind === "approve"
? "Note (optional)"
: "Reason (required)"
}
description={
decision?.kind === "approve"
? "Recorded with the approval for the audit trail."
: "GL sees this on both bookings — say what has to change."
}
placeholder={
decision?.kind === "approve"
? "Anything worth recording…"
: "e.g. the partner's cargo weights are unbalanced for one wagon"
}
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
autosize
minRows={3}
/>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
radius="md"
onClick={close}
disabled={decide.isPending}
>
Cancel
</Button>
<Button
color={decision?.kind === "approve" ? "edr-green" : "red"}
radius="md"
loading={decide.isPending}
disabled={confirmDisabled}
onClick={() => decide.mutate()}
>
{decision?.kind === "approve" ? "Approve both" : "Reject both"}
</Button>
</Group>
</Stack>
</Modal>
</PageContainer>
);
}
/** One half of the wagon: its reference (linked) and whose cargo it is. */
function BookingSide({
id,
reference,
company,
}: {
id: string;
reference?: string | null;
company?: string | null;
}) {
return (
<Box style={{ minWidth: 0 }}>
<Text
component={Link}
to={`/dashboard/booking-requests/${id}`}
fz={14}
fw={700}
c="blue.7"
style={{ textDecoration: "none" }}
>
{reference ?? "—"}
</Text>
<Text fz={12.5} c="dimmed">
{company ?? "—"}
</Text>
</Box>
);
}

View File

@@ -29,7 +29,7 @@ import {
Weight,
Wrench,
} from "lucide-react";
import { useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import AvailableWagonsPanel from "@/components/trainBuilder/AvailableWagonsPanel";
@@ -98,7 +98,14 @@ export default function TrainBuilderDetailPage() {
const canDisband = hasPermission(user, FREIGHT_PERMS.trains.disband);
const compositionQuery = useQuery(
api.trainBuilder.composition.queryOptions({ input: { id }, enabled: Boolean(id) }),
api.trainBuilder.composition.queryOptions({
input: { id },
enabled: Boolean(id),
// Mutations seed this key from their own response (see `seedComposition`
// in services/api.ts), so the cached consist is authoritative — the
// global staleTime of 0 would otherwise refetch it on every remount.
staleTime: 30_000,
}),
);
const assignWagons = useMutation(api.trainBuilder.assignWagons.mutationOptions());
const removeWagon = useMutation(api.trainBuilder.removeWagon.mutationOptions());
@@ -111,6 +118,34 @@ export default function TrainBuilderDetailPage() {
const activate = useMutation(api.trainBuilder.activate.mutationOptions());
const composition = compositionQuery.data;
// The diagram memoizes off its `locomotives`/`wagons` props; building those
// arrays inline in JSX would hand it a new identity on every render and
// re-normalize + repaint every car for each keystroke or pending mutation.
const diagramLocomotives = useMemo(
() =>
(composition?.locomotives ?? []).map((loco) => ({
code: loco.code,
name: loco.name,
maxPullWeightTons: loco.maxPullWeightTons,
})),
[composition?.locomotives],
);
const diagramWagons = useMemo(
() =>
(composition?.wagons ?? []).map((wagon, index) => ({
sequenceNo: wagon.sequenceNumber ?? index + 1,
capacityTons: wagon.wagonType?.capacityTons ?? 0,
// No bookings at build time — wagons ride empty until allocation.
assignedWeightTons: 0,
tareWeightTons: wagon.wagonType?.tareWeightTons ?? 0,
wagonTypeCode: wagon.wagonType?.code ?? null,
physicalWagonNumber: wagon.wagonNumber,
allocations: [],
})),
[composition?.wagons],
);
// Staff identify a train by its operational run numbers, not the internal
// code — mirrors formatTrainRunLabel on the API, which writes the history note.
const trainRunLabel =
@@ -130,17 +165,62 @@ export default function TrainBuilderDetailPage() {
maintenanceWagon.isPending ||
reorderWagons.isPending;
const withToast = async (action: () => Promise<unknown>, failTitle: string) => {
try {
await action();
} catch (err) {
toast({
title: failTitle,
description: parseError(err, "Something went wrong"),
variant: "destructive",
});
}
};
const withToast = useCallback(
async (action: () => Promise<unknown>, failTitle: string) => {
try {
await action();
} catch (err) {
toast({
title: failTitle,
description: parseError(err, "Something went wrong"),
variant: "destructive",
});
}
},
[toast],
);
// Stable handlers: the consist list and wagon picker are memoized, so a new
// closure each render would defeat the memo and re-render every wagon row
// (and re-mount the drag context) on unrelated state changes.
// `trainId` is only absent before the composition loads, and these handlers
// are wired to controls that render after that — the guard keeps the promise
// rather than leaning on a non-null assertion.
const trainId = composition?.id;
const handleAssign = useCallback(
(wagonIds: string[]) => {
if (!trainId) return;
void withToast(
() => assignWagons.mutateAsync({ id: trainId, wagonIds }),
"Could not add wagons",
);
},
[withToast, assignWagons.mutateAsync, trainId],
);
const handleReorder = useCallback(
(wagonIds: string[]) => {
if (!trainId) return;
void withToast(
() => reorderWagons.mutateAsync({ id: trainId, wagonIds }),
"Could not reorder wagons",
);
},
[withToast, reorderWagons.mutateAsync, trainId],
);
const handleRemove = useCallback(
(wagonId: string) => {
if (!trainId) return;
void withToast(
() => removeWagon.mutateAsync({ id: trainId, wagonId }),
"Could not detach wagon",
);
},
[withToast, removeWagon.mutateAsync, trainId],
);
const handleMaintenance = useCallback(
(wagon: TrainCompositionWagon) => setMaintenanceTarget(wagon),
[],
);
if (compositionQuery.isLoading) {
return (
@@ -364,21 +444,8 @@ export default function TrainBuilderDetailPage() {
<Stack gap="sm">
<TrainCompositionDiagram
locomotives={composition.locomotives.map((loco) => ({
code: loco.code,
name: loco.name,
maxPullWeightTons: loco.maxPullWeightTons,
}))}
wagons={composition.wagons.map((wagon, index) => ({
sequenceNo: wagon.sequenceNumber ?? index + 1,
capacityTons: wagon.wagonType?.capacityTons ?? 0,
// No bookings at build time — wagons ride empty until allocation.
assignedWeightTons: 0,
tareWeightTons: wagon.wagonType?.tareWeightTons ?? 0,
wagonTypeCode: wagon.wagonType?.code ?? null,
physicalWagonNumber: wagon.wagonNumber,
allocations: [],
}))}
locomotives={diagramLocomotives}
wagons={diagramWagons}
trainNumber={composition.code}
totalLengthMeters={totals.totalLengthMeters}
/>
@@ -412,12 +479,7 @@ export default function TrainBuilderDetailPage() {
exportTrainNumber={composition.exportTrainNumber}
importTrainNumber={composition.importTrainNumber}
assigning={assignWagons.isPending}
onAssign={(wagonIds) =>
void withToast(
() => assignWagons.mutateAsync({ id: composition.id, wagonIds }),
"Could not add wagons",
)
}
onAssign={handleAssign}
/>
</Stack>
</Card>
@@ -434,19 +496,9 @@ export default function TrainBuilderDetailPage() {
wagons={composition.wagons}
editable={composition.editable && canAssign}
busy={busy}
onReorder={(wagonIds) =>
void withToast(
() => reorderWagons.mutateAsync({ id: composition.id, wagonIds }),
"Could not reorder wagons",
)
}
onRemove={(wagonId) =>
void withToast(
() => removeWagon.mutateAsync({ id: composition.id, wagonId }),
"Could not detach wagon",
)
}
onMaintenance={(wagon) => setMaintenanceTarget(wagon)}
onReorder={handleReorder}
onRemove={handleRemove}
onMaintenance={handleMaintenance}
/>
</Stack>
</Card>

View File

@@ -284,6 +284,32 @@ const TRAIN_BUILDER_INVALIDATIONS: ReadonlyArray<readonly unknown[]> = [
QUERY_KEYS.FLEET.ROOT,
];
/**
* Coupling/uncoupling wagons moves wagons between the available pool and one
* train — it does not touch locomotives, so those roots stay valid. Trimming
* the set keeps a drag-reorder from refetching the whole fleet.
*/
const TRAIN_BUILDER_WAGON_INVALIDATIONS: ReadonlyArray<readonly unknown[]> = [
QUERY_KEYS.TRAIN_BUILDER.ROOT,
QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
["wagons"],
];
/**
* Every train-builder mutation responds with the train's full, fresh
* composition — write it straight into the detail cache so the workspace
* repaints from the response instead of refetching what it was just handed.
*/
const seedComposition = (
input: { id: string } | string,
data: TrainComposition,
): ReadonlyArray<readonly [readonly unknown[], unknown]> => [
[
QUERY_KEYS.TRAIN_BUILDER.composition(typeof input === "string" ? input : input.id),
data,
],
];
export const api = {
trainScheduling: {
// ── Queries ────────────────────────────────────────────────────────────
@@ -2070,6 +2096,7 @@ export const api = {
trainBuilderService.setLocomotives(id, locomotiveIds).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
seedComposition,
),
setYard: endpoint<{ id: string; currentYardId: string }, TrainComposition>(
@@ -2079,6 +2106,7 @@ export const api = {
trainBuilderService.setYard(id, currentYardId).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
seedComposition,
),
updateDetails: endpoint<
@@ -2091,6 +2119,7 @@ export const api = {
trainBuilderService.updateDetails(id, payload).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
seedComposition,
),
assignWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
@@ -2099,7 +2128,8 @@ export const api = {
({ id, wagonIds }) =>
trainBuilderService.assignWagons(id, wagonIds).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
() => TRAIN_BUILDER_WAGON_INVALIDATIONS,
seedComposition,
),
removeWagon: endpoint<{ id: string; wagonId: string }, TrainComposition>(
@@ -2108,7 +2138,8 @@ export const api = {
({ id, wagonId }) =>
trainBuilderService.removeWagon(id, wagonId).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
() => TRAIN_BUILDER_WAGON_INVALIDATIONS,
seedComposition,
),
sendWagonToMaintenance: endpoint<
@@ -2120,7 +2151,8 @@ export const api = {
({ id, wagonId, note }) =>
trainBuilderService.sendWagonToMaintenance(id, wagonId, note).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
() => TRAIN_BUILDER_WAGON_INVALIDATIONS,
seedComposition,
),
reorderWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
@@ -2129,7 +2161,8 @@ export const api = {
({ id, wagonIds }) =>
trainBuilderService.reorderWagons(id, wagonIds).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
() => TRAIN_BUILDER_WAGON_INVALIDATIONS,
seedComposition,
),
deactivate: endpoint<string, TrainComposition>(
@@ -2138,6 +2171,7 @@ export const api = {
(id) => trainBuilderService.deactivate(id).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
seedComposition,
),
activate: endpoint<string, TrainComposition>(
@@ -2146,6 +2180,7 @@ export const api = {
(id) => trainBuilderService.activate(id).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
seedComposition,
),
disband: endpoint<string, void>(

View File

@@ -6,6 +6,35 @@ import type { Freight } from "@edr/types";
const B = URL_CONSTANTS.BOOKINGS;
/**
* One shared-wagon approval. Covers BOTH bookings on the wagon — the pair is
* decided as a unit, never one side at a time.
*/
export interface ConsolidationApprovalRow {
id: string;
bookingId: string;
partnerBookingId: string;
status: "PENDING" | "APPROVED" | "REJECTED";
requestedBy?: string | null;
requestedAt: string;
decidedBy?: string | null;
decidedAt?: string | null;
decisionNote?: string | null;
scheduledDate?: string | null;
bookingReference?: string | null;
partnerBookingReference?: string | null;
booking?: {
id: string;
reference?: string;
company?: { name?: string } | null;
} | null;
partnerBooking?: {
id: string;
reference?: string;
company?: { name?: string } | null;
} | null;
}
export interface BookingListFilter {
status?: string;
/** Comma-separated statuses for grouped tabs */
@@ -323,6 +352,40 @@ export const bookingsService = {
cancel: (id: string, reason: string) =>
postBooking<BookingDetail>(B.CANCEL(id), { reason }),
// ── Shared-wagon approval gate ──────────────────────────────────────────
/** Pairings awaiting a decision, oldest first. */
consolidationApprovalQueue: async (): Promise<ConsolidationApprovalRow[]> => {
const response = await client.get(B.CONSOLIDATION_APPROVAL_QUEUE);
return (unwrap(response.data) ?? []) as ConsolidationApprovalRow[];
},
/** Decision history for one booking's shared wagon — who, when, and why. */
consolidationApprovalHistory: async (
bookingId: string,
): Promise<ConsolidationApprovalRow[]> => {
const response = await client.get(
B.CONSOLIDATION_APPROVAL_HISTORY(bookingId),
);
return (unwrap(response.data) ?? []) as ConsolidationApprovalRow[];
},
/** Approve: both bookings leave the gate and continue to Operations. */
approveConsolidation: async (approvalId: string, note?: string) => {
const response = await client.post(B.CONSOLIDATION_APPROVE(approvalId), {
note,
});
return unwrap(response.data);
},
/** Reject: both bookings go back to GL for changes with the reason. */
rejectConsolidation: async (approvalId: string, reason: string) => {
const response = await client.post(B.CONSOLIDATION_REJECT(approvalId), {
reason,
});
return unwrap(response.data);
},
/**
* Apply one staff decision to BOTH halves of a consolidated pair. The two
* bookings share a wagon, so they advance or cancel together — all-or-nothing

View File

@@ -33,6 +33,27 @@ export type InvalidatesMeta = (
data: unknown,
) => ReadonlyArray<readonly unknown[]>;
/**
* Cache entries a mutation can write DIRECTLY from its own response, skipping
* a refetch. Many endpoints already return the fresh entity they just changed
* (e.g. every train-builder mutation returns the whole `TrainComposition`), so
* re-fetching that same key is a wasted round-trip and a visible flicker.
*
* Returned pairs are written with `setQueryData` by the app-wide MutationCache
* BEFORE the `invalidates` keys are invalidated, and any key seeded here is
* skipped by that invalidation pass — the value just written IS the fresh one.
*/
export type UpdatesFn<TInput, TResponse> = (
input: TInput,
data: TResponse,
) => ReadonlyArray<readonly [readonly unknown[], unknown]>;
/** Shape stored in `mutation.meta.updates` and consumed by the MutationCache. */
export type UpdatesMeta = (
variables: unknown,
data: unknown,
) => ReadonlyArray<readonly [readonly unknown[], unknown]>;
// ---------------------------------------------------------------------------
// Endpoint interfaces
// ---------------------------------------------------------------------------
@@ -67,6 +88,7 @@ export function endpoint<TInput, TResponse>(
execute: (input: TInput) => Promise<TResponse>,
queryKeyBuilder?: (input: TInput) => readonly unknown[],
invalidates?: InvalidatesFn<TInput, TResponse>,
updates?: UpdatesFn<TInput, TResponse>,
) {
const buildKey = (input?: TInput): readonly unknown[] => {
if (queryKeyBuilder && input !== undefined) {
@@ -101,16 +123,30 @@ export function endpoint<TInput, TResponse>(
const mutationOptions = (
config?: Omit<UseMutationOptions<TResponse, Error, TInput>, "mutationFn">,
): UseMutationOptions<TResponse, Error, TInput> => {
const meta = invalidates
? {
...config?.meta,
invalidates: ((variables, data) =>
invalidates(
variables as TInput,
data as TResponse,
)) satisfies InvalidatesMeta,
}
: config?.meta;
const meta =
invalidates || updates
? {
...config?.meta,
...(invalidates
? {
invalidates: ((variables, data) =>
invalidates(
variables as TInput,
data as TResponse,
)) satisfies InvalidatesMeta,
}
: {}),
...(updates
? {
updates: ((variables, data) =>
updates(
variables as TInput,
data as TResponse,
)) satisfies UpdatesMeta,
}
: {}),
}
: config?.meta;
return {
...config,