feat: add wagon usage computation and maintenance logging features

- Implemented  utility to calculate wagon usage metrics for train schedules.
- Created  for sending wagons to maintenance with optional notes.
- Added unit tests for train builder maintenance functionalities, including formatting train run labels and building maintenance notes.
- Developed  component for merging train schedules with detailed previews and reasons for merging.
- Introduced  component for selecting wagons with search functionality and selection limits.
- Created  for displaying and filtering audit logs, including detailed views of individual log entries.
- Added  for handling API interactions related to audit logs, including fetching logs and entity types.
This commit is contained in:
marshalyordanos
2026-08-12 09:36:50 +03:00
parent 35e5404b41
commit 5da36eb128
77 changed files with 6275 additions and 296 deletions

View File

@@ -142,6 +142,21 @@ export function BookingSchedulingWindowCard({
schedule?.reference ??
(schedule ? "Assigned train" : null);
// Before the batch engine allocates, the only train on the booking is the one
// the customer picked at day-commit — staff review that during Operation
// Review, so it is labelled as a request, not as a confirmed allocation.
const isRequested = schedule?.isRequested === true;
const trainRowLabel = isRequested ? "Requested train" : "Scheduled on train";
// A train that has already left (or is past its planned departure) can no
// longer carry this booking, so accepting onto it would be wrong. Called out
// here because this card sits above the staff-actions toolbar.
const departureIso =
schedule?.actualDepartureAt ?? schedule?.scheduledDepartureDate ?? null;
const hasDeparted = schedule?.actualDepartureAt
? true
: departureIso !== null && new Date(departureIso).getTime() <= nowMs;
return (
<SectionCard
icon={CalendarClock}
@@ -153,7 +168,7 @@ export function BookingSchedulingWindowCard({
<Stack gap="sm">
{trainLabel ? (
<Row
label="Scheduled on train"
label={trainRowLabel}
value={trainLabel}
hint={
schedule?.reference && schedule.reference !== trainLabel
@@ -163,13 +178,21 @@ export function BookingSchedulingWindowCard({
/>
) : (
<Row
label="Scheduled on train"
label={trainRowLabel}
value="Not yet allocated"
tone="muted"
hint="The booking has not been placed on a train schedule"
/>
)}
{isRequested && trainLabel ? (
<Text size="xs" c="dimmed">
{hasDeparted
? "This train has already departed — accepting the operation will not place the booking on it."
: "Picked by the customer at day-commit. Accepting the operation releases the booking to the batch pool for this train."}
</Text>
) : null}
{schedule?.status ? (
<Group justify="space-between" wrap="nowrap">
<Text size="sm" c="dimmed">
@@ -231,10 +254,15 @@ export function BookingSchedulingWindowCard({
formatStamp(schedule.scheduledDepartureDate) ??
"—"
}
tone={isRequested && hasDeparted ? "danger" : undefined}
hint={
schedule.actualDepartureAt
? `Actual · planned ${formatStamp(schedule.scheduledDepartureDate) ?? "—"}`
: "Planned"
: departureIso && !hasDeparted
? `Planned · departs ${formatRelative(departureIso, nowMs)}`
: hasDeparted
? "Planned — already past"
: "Planned"
}
/>
<Row

View File

@@ -1006,7 +1006,7 @@ export function ReleaseOrderCard({
clearance: ClearanceViewLike & { vesselDepartureDate?: string | null };
onChanged?: () => void;
}) {
const [file, setFile] = useState<File | null>(null);
const [files, setFiles] = useState<File[]>([]);
const [vesselDate, setVesselDate] = useState<Date | null>(
clearance.vesselDepartureDate ? new Date(clearance.vesselDepartureDate) : null,
);
@@ -1020,7 +1020,14 @@ export function ReleaseOrderCard({
Release Order
</Text>
<Stack gap="sm">
<FileInput label="Release Order" value={file} onChange={setFile} size="sm" />
<FileInput
label="Release Order"
placeholder="Select one or more files"
multiple
value={files}
onChange={setFiles}
size="sm"
/>
<DateInput
label="Vessel departure date"
value={vesselDate}
@@ -1032,15 +1039,15 @@ export function ReleaseOrderCard({
<Button
color="edr-green"
loading={loading}
disabled={!file || !vesselDate}
disabled={files.length === 0 || !vesselDate}
onClick={async () => {
if (!file || !vesselDate) return;
if (files.length === 0 || !vesselDate) return;
setLoading(true);
try {
const iso = vesselDate.toISOString().slice(0, 10);
const result = isBooking
? await bookingsService.uploadReleaseOrder(entityId, file, iso)
: await contractsService.uploadReleaseOrder(entityId, file, iso);
? await bookingsService.uploadReleaseOrder(entityId, files, iso)
: await contractsService.uploadReleaseOrder(entityId, files, iso);
if (result.hold) {
toast.error(result.holdReason ?? "Vessel date too soon");
} else {

View File

@@ -10,11 +10,10 @@ import {
toIsoDate,
useDoCollectionDates,
} from "@/components/contracts/DoCollectionDateFields";
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
import { PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { contractsService } from "@/services/contracts.service";
import { bookingsService } from "@/services/bookings.service";
import type { Freight } from "@edr/types";
import { isDeliveryOrderFileCode, isReleaseOrderFileCode, type Freight } from "@edr/types";
export type GlClearanceUploadKind = "do" | "ro";
@@ -44,9 +43,8 @@ export function GlClearanceUploadModal({
vesselArrivalDate,
doCollectedDate,
onSuccess,
onPreview,
}: GlClearanceUploadModalProps) {
const [file, setFile] = useState<File | null>(null);
const [files, setFiles] = useState<File[]>([]);
const [vesselDate, setVesselDate] = useState<Date | null>(
vesselDepartureDate ? new Date(vesselDepartureDate) : null,
);
@@ -66,16 +64,16 @@ export function GlClearanceUploadModal({
const isDo = kind === "do";
const isRo = kind === "ro";
const replaceMode = isDo
? Boolean(findWorkflowFile(workflowFiles, "delivery_order"))
: Boolean(findWorkflowFile(workflowFiles, "release_order"));
? workflowFiles.some((f) => isDeliveryOrderFileCode(f.code) && f.file)
: workflowFiles.some((f) => isReleaseOrderFileCode(f.code) && f.file);
const close = () => {
setFile(null);
setFiles([]);
onClose();
};
const submit = async () => {
if (!file || !kind) return;
if (files.length === 0 || !kind) return;
if (isRo && !vesselDate) {
toast.error("Vessel departure date is required.");
return;
@@ -93,23 +91,23 @@ export function GlClearanceUploadModal({
doCollectedDate: toIsoDate(doDates.doCollected)!,
};
if (isBooking) {
await bookingsService.uploadDeliveryOrder(entityId, file, dates);
await bookingsService.uploadDeliveryOrder(entityId, files, dates);
} else {
await contractsService.uploadDeliveryOrder(entityId, file, dates);
await contractsService.uploadDeliveryOrder(entityId, files, dates);
}
toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded");
} else {
const iso = vesselDate!.toISOString().slice(0, 10);
const result = isBooking
? await bookingsService.uploadReleaseOrder(entityId, file, iso)
: await contractsService.uploadReleaseOrder(entityId, file, iso);
? await bookingsService.uploadReleaseOrder(entityId, files, iso)
: await contractsService.uploadReleaseOrder(entityId, files, iso);
if (result.hold) {
toast.error(result.holdReason ?? "Vessel date too soon");
} else {
toast.success(replaceMode ? "Release Order updated" : "Release Order uploaded");
}
}
setFile(null);
setFiles([]);
onSuccess?.();
close();
} catch (e) {
@@ -152,14 +150,15 @@ export function GlClearanceUploadModal({
<DoCollectionDateFields value={doDates} onChange={setDoDates} />
)}
<PhasedFileDropzone
label={isDo ? "Delivery Order file" : "Release Order file"}
description={isDo ? "Any file type." : "PDF or image."}
<PhasedMultiFileDropzone
label={isDo ? "Delivery Order files" : "Release Order files"}
description={
isDo ? "Any file type. Add as many files as needed." : "PDF or image. Add as many files as needed."
}
accept={isDo ? "*/*" : undefined}
value={file}
onChange={setFile}
value={files}
onChange={setFiles}
replaceMode={replaceMode}
onPreview={onPreview}
/>
<Group justify="flex-end" gap="sm">
@@ -170,7 +169,7 @@ export function GlClearanceUploadModal({
color="edr-green"
loading={loading}
disabled={
!file ||
files.length === 0 ||
(isRo && !vesselDate) ||
(isDo && !doDatesComplete(doDates))
}

View File

@@ -34,12 +34,15 @@ import {
Truck,
Upload,
} from "lucide-react";
import type { Freight } from "@edr/types";
import {
deliveryOrderFileLabel,
isDeliveryOrderFileCode,
type Freight,
} from "@edr/types";
import toast from "react-hot-toast";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { ExportClearanceStepper } from "@/components/contracts/ExportClearanceStepper";
import { PhasedDocumentUploadField } from "@/components/contracts/PhasedDocumentUploadField";
import {
DoCollectionDateFields,
doDatesComplete,
@@ -2133,56 +2136,82 @@ function DeliveryOrderStep({
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
}) {
const [files, setFiles] = useState<Record<string, File | null>>({
delivery_order: null,
});
const [files, setFiles] = useState<File[]>([]);
const [doDates, setDoDates] = useDoCollectionDates({
vesselArrivalDate,
doCollectedDate,
});
const [loading, setLoading] = useState(false);
const hasFile = Boolean(files.delivery_order);
const submit = async () => {
if (files.length === 0 || !doDatesComplete(doDates)) return;
const dates = {
vesselArrivalDate: toIsoDate(doDates.vesselArrival)!,
doCollectedDate: toIsoDate(doDates.doCollected)!,
};
setLoading(true);
try {
if (isBooking) {
await bookingsService.uploadDeliveryOrder(entityId, files, dates);
} else {
await contractsService.uploadDeliveryOrder(entityId, files, dates);
}
setFiles([]);
toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setLoading(false);
}
};
return (
<PhasedDocumentUploadField
fields={[{ key: "delivery_order", label: "Delivery Order" }]}
files={files}
onChange={(key, file) => setFiles((prev) => ({ ...prev, [key]: file }))}
workflowFiles={workflowFiles}
replaceMode={replaceMode}
loading={loading}
disabled={!hasFile || !doDatesComplete(doDates)}
helperText="Upload the Djibouti Delivery Order (DO) and record when the vessel arrived and when the DO was collected."
submitLabel={replaceMode ? "Replace DO" : "Upload DO"}
extraFields={
<DoCollectionDateFields value={doDates} onChange={setDoDates} />
}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
onSubmit={async () => {
const file = files.delivery_order;
if (!file || !doDatesComplete(doDates)) return;
const dates = {
vesselArrivalDate: toIsoDate(doDates.vesselArrival)!,
doCollectedDate: toIsoDate(doDates.doCollected)!,
};
setLoading(true);
try {
if (isBooking) {
await bookingsService.uploadDeliveryOrder(entityId, file, dates);
} else {
await contractsService.uploadDeliveryOrder(entityId, file, dates);
}
setFiles({ delivery_order: null });
toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setLoading(false);
<Stack gap="sm">
<Text size="sm" c="dimmed">
Upload the Djibouti Delivery Order (DO) and record when the vessel arrived and when the
DO was collected. Add as many files as needed.
</Text>
{workflowFiles
.filter((wf) => isDeliveryOrderFileCode(wf.code) && wf.file)
.map((wf, index) => (
<PhasedUploadedFileRow
key={wf.code}
label={deliveryOrderFileLabel(wf.code, index)}
file={wf.file!}
onView={onViewFile}
onDownload={onDownloadFile}
/>
))}
<DoCollectionDateFields value={doDates} onChange={setDoDates} />
<PhasedMultiFileDropzone
label="Delivery Order files"
description={
replaceMode
? "Replace the DO — upload one or more files (any file type)."
: "Upload one or more Delivery Order files (any file type)."
}
}}
/>
accept="*/*"
value={files}
onChange={setFiles}
replaceMode={replaceMode}
disabled={loading}
/>
<Button
color="edr-green"
loading={loading}
disabled={files.length === 0 || !doDatesComplete(doDates)}
leftSection={<Upload size={16} />}
fullWidth
onClick={() => void submit()}
>
{replaceMode ? "Replace DO" : "Upload DO"}
</Button>
</Stack>
);
}

View File

@@ -8,6 +8,7 @@ import {
FileSignature,
FileText,
Hammer,
History,
Landmark,
LayoutDashboard,
LayoutGrid,
@@ -500,6 +501,12 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
FREIGHT_PERMS.settings.supportContent.manage,
],
},
{
label: "Audit logs",
href: "/dashboard/audit-logs",
icon: <History />,
permission: FREIGHT_PERMS.auditLog.view,
},
{
label: "Configuration",
href: "/dashboard/configuration",

View File

@@ -88,7 +88,7 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
const handleBuild = async () => {
if (!trainName.trim()) {
toast({
title: "Enter the vogue number",
title: "Enter the voyage number",
variant: "destructive",
});
return;
@@ -150,8 +150,8 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
wagons are attached on the next screen.
</Text>
<TextInput
label="Vogue number"
placeholder="Enter vogue number"
label="Voyage number"
placeholder="Enter voyage number"
value={trainName}
onChange={(e) => setTrainName(e.currentTarget.value)}
maxLength={100}

View File

@@ -1,7 +1,9 @@
import { useMemo } from "react";
import { Link } from "react-router-dom";
import { ArrowRight, Landmark, Package } from "lucide-react";
import {
Accordion,
Anchor,
Badge,
Button,
Checkbox,
@@ -50,9 +52,20 @@ function EligibleBookingRow({
<Stack gap={4} style={{ flex: 1 }}>
<Group gap="xs" wrap="wrap">
<Package size={14} />
<Text fw={600} size="sm">
{/* Opens the booking in a new tab: the row is a selection control in
an allocation flow, so navigating away would lose staff's picks. */}
<Anchor
component={Link}
to={`/dashboard/booking-requests/${booking.id}`}
target="_blank"
fw={600}
size="sm"
c="edr-green.8"
underline="hover"
onClick={(e) => e.stopPropagation()}
>
{booking.reference}
</Text>
</Anchor>
{resolvedFreightType ? (
<Badge variant="outline" size="xs">
{resolvedFreightType}

View File

@@ -0,0 +1,344 @@
import {
Alert,
Badge,
Box,
Button,
Card,
Group,
Loader,
Modal,
Stack,
Text,
Textarea,
TextInput,
ThemeIcon,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import {
ArrowRight,
Ban,
CircleAlert,
Merge,
Search,
TriangleAlert,
} from "lucide-react";
import { useMemo, useState } from "react";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
function parseError(error: unknown, fallback: string): string {
if (isAxiosError(error)) {
const message = error.response?.data?.message;
if (Array.isArray(message)) return message.join(", ");
if (typeof message === "string") return message;
}
return fallback;
}
const fmtDate = (iso: string) =>
new Date(iso).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
});
export interface MergeScheduleTrainModalProps {
scheduleId: string | null;
/** This schedule's current train — excluded from the picker. */
currentTrainId: string | null;
scheduleReference?: string | null;
opened: boolean;
onClose: () => void;
onMerged?: () => void;
}
/**
* Merge another train into this schedule.
*
* This schedule always survives: its train set is repointed at the chosen
* train, that train's wagons join this consist, and the emptied train is
* deactivated. When the chosen train also runs a schedule on the SAME DAY, that
* schedule's bookings move here and it is removed — its other-day schedules
* gain the wagons only. The server computes all of that in `previewMerge`, so
* the summary below is exactly what the commit will perform.
*/
export default function MergeScheduleTrainModal({
scheduleId,
currentTrainId,
scheduleReference,
opened,
onClose,
onMerged,
}: MergeScheduleTrainModalProps) {
const { toast } = useToast();
const [selectedTrainId, setSelectedTrainId] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [reason, setReason] = useState("");
const { data: trains = [], isLoading: trainsLoading } = useQuery({
...api.trains.list.queryOptions(),
enabled: opened,
});
// The schedule's own train cannot be merged into itself.
const options = useMemo(() => {
const q = search.trim().toLowerCase();
return trains
.filter((t) => t.id !== currentTrainId)
.filter((t) =>
q
? `${t.code} ${t.trainNumber ?? ""} ${t.trainName ?? ""}`
.toLowerCase()
.includes(q)
: true,
);
}, [trains, currentTrainId, search]);
const { data: preview, isFetching: previewLoading } = useQuery({
...api.trainScheduling.previewScheduleMerge.queryOptions({
input: { id: scheduleId ?? "", targetTrainId: selectedTrainId ?? "" },
}),
enabled: opened && Boolean(scheduleId && selectedTrainId),
});
const merge = useMutation(api.trainScheduling.mergeScheduleTrain.mutationOptions());
const close = () => {
setSelectedTrainId(null);
setSearch("");
setReason("");
onClose();
};
const submit = async () => {
if (!scheduleId || !selectedTrainId || !preview?.canMerge) return;
try {
await merge.mutateAsync({
id: scheduleId,
targetTrainId: selectedTrainId,
...(reason.trim() ? { reason: reason.trim() } : {}),
});
toast({ title: "Trains merged" });
onMerged?.();
close();
} catch (err) {
toast({
title: "Merge failed",
description: parseError(err, "Could not merge the trains"),
variant: "destructive",
});
}
};
return (
<Modal
opened={opened}
onClose={close}
centered
size="lg"
radius="lg"
title={
<Group gap="sm">
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
<Merge size={18} />
</ThemeIcon>
<Box>
<Text fw={600} lh={1.2}>
Merge another train into this one
</Text>
<Text size="xs" c="dimmed" lh={1.2}>
{scheduleReference ?? "This departure survives the merge"}
</Text>
</Box>
</Group>
}
>
<Stack gap="md">
<TextInput
placeholder="Search train code or number…"
leftSection={<Search size={15} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
radius="md"
/>
{trainsLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" />
</Group>
) : options.length === 0 ? (
<Text size="sm" c="dimmed" py="sm">
No other trains available to merge.
</Text>
) : (
<Box
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))",
gap: 8,
maxHeight: 190,
overflowY: "auto",
}}
>
{options.map((t) => {
const on = t.id === selectedTrainId;
return (
<Card
key={t.id}
withBorder
radius="md"
padding="xs"
onClick={() => setSelectedTrainId(t.id)}
style={{
cursor: "pointer",
borderColor: on
? "var(--mantine-color-edr-green-5)"
: undefined,
background: on
? "var(--mantine-color-edr-green-0)"
: undefined,
}}
>
<Text size="sm" fw={on ? 700 : 600} truncate>
{t.code}
</Text>
<Text size="xs" c="dimmed" truncate>
{t.trainNumber ? `No. ${t.trainNumber}` : "—"}
</Text>
</Card>
);
})}
</Box>
)}
{selectedTrainId && previewLoading ? (
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
) : null}
{selectedTrainId && preview && !previewLoading ? (
<Stack gap="sm">
{preview.blockers.length ? (
<Alert
variant="light"
color="red"
icon={<CircleAlert size={16} />}
title="This merge is blocked"
>
<Stack gap={4}>
{preview.blockers.map((b) => (
<Text size="sm" key={b}>
{b}
</Text>
))}
</Stack>
</Alert>
) : (
<Alert
variant="light"
color="orange"
icon={<TriangleAlert size={16} />}
>
This cannot be undone. Wagons are appended last reorder them
afterwards in the train builder.
</Alert>
)}
<Card withBorder radius="md" padding="sm">
<Group gap={8} wrap="nowrap" mb={8}>
<Text size="sm" fw={700}>
{preview.wagons.current} wagons
</Text>
<ArrowRight size={15} />
<Text size="sm" fw={700} c="edr-green.8">
{preview.wagons.merged} wagons
</Text>
<Badge variant="light" color="edr-green" radius="sm">
+{preview.wagons.incoming} from {preview.targetTrain.code}
</Badge>
</Group>
{preview.absorbedSchedule ? (
<Group gap={6} wrap="nowrap" mb={6}>
<Badge size="sm" color="grape" variant="light" radius="sm">
{fmtDate(preview.absorbedSchedule.scheduledDepartureDate)}
</Badge>
<Text size="sm">
{preview.absorbedSchedule.reference ?? "Same-day schedule"} {" "}
<Text span fw={700}>
{preview.absorbedSchedule.bookingsMoving} booking(s)
</Text>{" "}
move here, then it is removed
</Text>
</Group>
) : null}
{preview.affectedSchedules.map((s) => (
<Group gap={6} wrap="nowrap" mb={4} key={s.id}>
<Badge size="sm" color="blue" variant="light" radius="sm">
{fmtDate(s.scheduledDepartureDate)}
</Badge>
<Text size="sm" c="dimmed">
{s.reference ?? s.id.slice(0, 8)} gains the wagons, keeps
its own bookings
</Text>
</Group>
))}
{preview.untouchedSchedules.map((s) => (
<Group gap={6} wrap="nowrap" mb={4} key={s.id}>
<Badge size="sm" color="gray" variant="light" radius="sm">
{fmtDate(s.scheduledDepartureDate)}
</Badge>
<Text size="sm" c="dimmed">
{s.reference ?? s.id.slice(0, 8)} {s.status.toLowerCase()},
not affected
</Text>
</Group>
))}
{preview.sourceTrainWillDeactivate ? (
<Group gap={6} mt={6} wrap="nowrap">
<Ban size={14} color="var(--mantine-color-red-6)" />
<Text size="sm" c="red.7">
This schedule&apos;s current train is emptied and deactivated.
</Text>
</Group>
) : null}
</Card>
{preview.canMerge ? (
<Textarea
label="Reason"
placeholder="Why the trains are being merged (kept on the audit trail)"
maxLength={500}
autosize
minRows={2}
value={reason}
onChange={(e) => setReason(e.currentTarget.value)}
/>
) : null}
</Stack>
) : null}
<Group justify="flex-end" mt="xs">
<Button variant="default" onClick={close}>
Cancel
</Button>
<Button
color="edr-green"
leftSection={<Merge size={16} />}
loading={merge.isPending}
disabled={!preview?.canMerge || previewLoading}
onClick={() => void submit()}
>
Merge trains
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -1,4 +1,5 @@
import {
Anchor,
Badge,
Button,
Group,
@@ -7,7 +8,16 @@ import {
Tabs,
Text,
} from "@mantine/core";
import { ArrowRight, FileText, Landmark, MapPin, Package, Train } from "lucide-react";
import { Link } from "react-router-dom";
import {
ArrowRight,
Building2,
FileText,
Landmark,
MapPin,
Package,
Train,
} from "lucide-react";
import type { EligibleContainerBooking, FreightType } from "@/types/trainScheduling";
@@ -16,6 +26,8 @@ import { EligibleBookingsPanel } from "./EligibleBookingsPanel";
export type AssignedBookingRow = {
id: string;
reference: string;
/** Who booked it — staff scan this list by customer, not by reference. */
customer?: string | null;
weightTons?: number;
isGovernment?: boolean;
wagonsRequired?: number | null;
@@ -90,9 +102,19 @@ export function ScheduleBookingsStep({
>
<Stack gap={4}>
<Group gap="xs">
<Text fw={600} size="sm">
{/* Only the reference is the link — the row also carries a
Remove button, so an interactive control must not nest
inside the anchor. */}
<Anchor
component={Link}
to={`/dashboard/booking-requests/${booking.id}`}
fw={600}
size="sm"
c="edr-green.8"
underline="hover"
>
{booking.reference}
</Text>
</Anchor>
{booking.weightTons != null ? (
<Badge variant="outline" size="xs" color="edr-green">
{booking.weightTons}T
@@ -119,6 +141,14 @@ export function ScheduleBookingsStep({
</Badge>
) : null}
</Group>
{booking.customer ? (
<Group gap={4}>
<Building2 size={12} />
<Text size="xs" c="dimmed">
{booking.customer}
</Text>
</Group>
) : null}
{booking.contractReference || booking.origin || booking.destination ? (
<Group gap="xs">
{booking.contractReference ? (

View File

@@ -0,0 +1,209 @@
import {
Badge,
Box,
Button,
Checkbox,
Group,
ScrollArea,
Stack,
Text,
TextInput,
ThemeIcon,
UnstyledButton,
} from "@mantine/core";
import { Check, Search, Train, X } from "lucide-react";
import { useMemo, useState } from "react";
import type { Wagon } from "@/services/wagon.service";
export interface WagonPickerProps {
/** The wagons on offer — already filtered to what the yard can hand over. */
wagons: Wagon[];
selected: Set<string>;
onChange: (next: Set<string>) => void;
/** Hard ceiling on the selection; further picks are refused once reached. */
max?: number;
emptyMessage?: string;
maxHeight?: number;
}
/**
* Searchable multi-select over a list of wagons, used wherever staff name the
* physical wagons rather than a count. Search matches the wagon number as typed
* (case-insensitively, ignoring the dashes and spaces operators leave out) so
* "1042" finds "GON-1042".
*/
export function WagonPicker({
wagons,
selected,
onChange,
max,
emptyMessage = "No wagons available here right now.",
maxHeight = 260,
}: WagonPickerProps) {
const [query, setQuery] = useState("");
const normalise = (s: string) => s.toLowerCase().replace(/[\s-]/g, "");
const shown = useMemo(() => {
const q = normalise(query.trim());
if (!q) return wagons;
return wagons.filter((w) => normalise(w.wagonNumber).includes(q));
}, [wagons, query]);
const ceiling = max ?? Number.MAX_SAFE_INTEGER;
const full = selected.size >= ceiling;
const toggle = (id: string) => {
const next = new Set(selected);
if (next.has(id)) next.delete(id);
else if (next.size >= ceiling) return; // at the cap — ignore the click
else next.add(id);
onChange(next);
};
/** Select as many of the currently-visible wagons as the cap allows. */
const selectShown = () => {
const next = new Set(selected);
for (const w of shown) {
if (next.size >= ceiling) break;
next.add(w.id);
}
onChange(next);
};
return (
<Stack gap="xs">
<Group gap="xs" wrap="nowrap">
<TextInput
style={{ flex: 1 }}
placeholder="Search wagon number…"
leftSection={<Search size={15} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
rightSection={
query ? (
<UnstyledButton onClick={() => setQuery("")} aria-label="Clear search">
<X size={14} />
</UnstyledButton>
) : null
}
radius="md"
size="sm"
/>
<Button
size="compact-sm"
variant="light"
radius="md"
onClick={selectShown}
disabled={shown.length === 0 || full}
>
Select {query ? "matches" : "all"}
</Button>
{selected.size > 0 ? (
<Button
size="compact-sm"
variant="subtle"
color="gray"
radius="md"
onClick={() => onChange(new Set())}
>
Clear
</Button>
) : null}
</Group>
<Group gap="xs" justify="space-between">
<Text size="xs" c="dimmed">
{shown.length} of {wagons.length} shown
</Text>
<Badge
variant="light"
color={full ? "orange" : selected.size > 0 ? "teal" : "gray"}
radius="sm"
>
{selected.size} selected{max != null ? ` / ${max}` : ""}
</Badge>
</Group>
{wagons.length === 0 ? (
<Text size="sm" c="dimmed" py="sm">
{emptyMessage}
</Text>
) : shown.length === 0 ? (
<Text size="sm" c="dimmed" py="sm">
No wagon matches {query}.
</Text>
) : (
<ScrollArea.Autosize mah={maxHeight} type="auto">
<Box
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))",
gap: 8,
paddingRight: 4,
}}
>
{shown.map((w) => {
const on = selected.has(w.id);
// At the cap, unpicked wagons stop responding — grey them out so
// the dead click is explained before it happens.
const blocked = !on && full;
return (
<UnstyledButton
key={w.id}
onClick={() => toggle(w.id)}
disabled={blocked}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 10px",
borderRadius: 8,
border: `1px solid var(--mantine-color-${
on ? "edr-green-5" : "gray-3"
})`,
background: on
? "var(--mantine-color-edr-green-0)"
: "var(--mantine-color-body)",
opacity: blocked ? 0.45 : 1,
cursor: blocked ? "not-allowed" : "pointer",
transition: "border-color 120ms, background 120ms",
}}
>
<Checkbox
checked={on}
onChange={() => toggle(w.id)}
disabled={blocked}
size="xs"
color="edr-green"
tabIndex={-1}
styles={{ input: { cursor: blocked ? "not-allowed" : "pointer" } }}
/>
<ThemeIcon
variant="light"
color={on ? "edr-green" : "gray"}
size="sm"
radius="sm"
>
{on ? <Check size={12} /> : <Train size={12} />}
</ThemeIcon>
<Text
size="sm"
fw={on ? 700 : 500}
style={{ fontVariantNumeric: "tabular-nums" }}
truncate
>
{w.wagonNumber}
</Text>
</UnstyledButton>
);
})}
</Box>
</ScrollArea.Autosize>
)}
</Stack>
);
}
export default WagonPicker;

View File

@@ -26,6 +26,8 @@ import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type { Wagon } from "@/services/wagon.service";
import WagonPicker from "./WagonPicker";
const stripHtml = (html: string) => html.replace(/<[^>]*>/g, "").trim();
export interface WagonYardWorkspaceModalProps {
@@ -140,6 +142,8 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
const [transferYardId, setTransferYardId] = useState<string | null>(null);
const [transferQty, setTransferQty] = useState(0);
const [transferReason, setTransferReason] = useState("");
/** Specific wagons the requester named — optional; empty means "any N". */
const [pickedWagons, setPickedWagons] = useState<Set<string>>(new Set());
const createRequest = useMutation(
api.wagonTransferRequests.create.mutationOptions(),
@@ -240,8 +244,25 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
setTransferYardId(null);
setTransferQty(0);
setTransferReason("");
setPickedWagons(new Set());
}, [yardId, typeId]);
// Naming wagons IS the ask: the count follows the picks so the two can never
// disagree. Lowering the count by hand (below) trims the selection instead.
const handlePick = (next: Set<string>) => {
setPickedWagons(next);
if (next.size > 0) setTransferQty(next.size);
};
const handleQtyChange = (n: number) => {
setTransferQty(n);
// Asking for fewer than were picked would send a selection the API rejects
// (picks may not exceed quantity) — drop the extras, keeping pick order.
if (pickedWagons.size > n) {
setPickedWagons(new Set([...pickedWagons].slice(0, n)));
}
};
// Reset the whole workspace when closed.
useEffect(() => {
if (!opened) {
@@ -274,16 +295,23 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
wagonTypeId: typeId,
quantity: transferQty,
reason: transferReason,
...(pickedWagons.size > 0
? { preferredWagonIds: [...pickedWagons] }
: {}),
});
toast({
title: `Requested ${transferQty} ${typeInfo.code(typeId)} wagon(s) · ${yardName(
yardId,
)}${yardName(transferYardId)}`,
description: "OCC will pick the wagons and complete the move.",
description:
pickedWagons.size > 0
? `OCC will send the ${pickedWagons.size} wagon(s) you named where it can.`
: "OCC will pick the wagons and complete the move.",
});
setTransferQty(0);
setTransferYardId(null);
setTransferReason("");
setPickedWagons(new Set());
} catch (err) {
showError(err, "Request failed");
}
@@ -438,10 +466,36 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
ask for more than the yard can hand over. */}
<QuantityField
value={transferQty}
onChange={setTransferQty}
onChange={handleQtyChange}
max={availableCount}
/>
</div>
{/* Optional: name the exact wagons. Leaving this empty files
a plain count request and OCC picks whatever is free. */}
<div>
<Group justify="space-between" mb={4} wrap="wrap" gap={4}>
<Text size="sm" fw={500}>
Which wagons{" "}
<Text span size="xs" c="dimmed" fw={400}>
(optional)
</Text>
</Text>
<Text size="xs" c="dimmed">
{pickedWagons.size > 0
? `${pickedWagons.size} named — OCC will prioritise these`
: "Leave empty and OCC picks any available"}
</Text>
</Group>
<WagonPicker
wagons={availableWagons}
selected={pickedWagons}
onChange={handlePick}
max={availableCount}
emptyMessage="No available wagons of this type in this yard right now."
/>
</div>
<Select
label="Destination yard"
placeholder="Select destination"
@@ -464,23 +518,42 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
</div>
{transferYardId && transferQty > 0 ? (
<Card bg="var(--mantine-color-gray-0)" radius="md" padding="sm" withBorder>
<Group gap={8} wrap="nowrap">
<Text size="sm" fw={600}>
{yardName(yardId!)} {total}
<Text span c="red.6" fw={700}>
{" "}
{transferQty}
<Stack gap={6}>
<Group gap={8} wrap="nowrap">
<Text size="sm" fw={600}>
{yardName(yardId!)} {total}
<Text span c="red.6" fw={700}>
{" "}
{transferQty}
</Text>
</Text>
</Text>
<ArrowRight size={16} />
<Text size="sm" fw={600}>
{yardName(transferYardId)}
<Text span c="teal.7" fw={700}>
{" "}
+{transferQty}
<ArrowRight size={16} />
<Text size="sm" fw={600}>
{yardName(transferYardId)}
<Text span c="teal.7" fw={700}>
{" "}
+{transferQty}
</Text>
</Text>
</Text>
</Group>
</Group>
{pickedWagons.size > 0 ? (
<Group gap={4} wrap="wrap">
{availableWagons
.filter((w) => pickedWagons.has(w.id))
.map((w) => (
<Badge
key={w.id}
size="sm"
variant="light"
color="edr-green"
radius="sm"
>
{w.wagonNumber}
</Badge>
))}
</Group>
) : null}
</Stack>
</Card>
) : null}
<Button