mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 01:48:12 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user