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

@@ -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 (