Merge branch 'dev' into freight/nati-2

This commit is contained in:
Nathnael
2026-08-12 08:03:10 +00:00
95 changed files with 7755 additions and 859 deletions

View File

@@ -41,9 +41,9 @@ import MyProfilePage from "./pages/dashboard/MyProfilePage";
import OverviewPage from "./pages/dashboard/OverviewPage";
import ReportsHubPage from "./pages/reports/ReportsHubPage";
import ReportPage from "./pages/reports/ReportPage";
import AuditLogsPage from "./pages/AuditLogsPage";
import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage";
import PaymentsPage from "./pages/payments/PaymentsPage";
import AuditLogsPage from "./pages/audit/AuditLogsPage";
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
import { RequirePermission } from "./components/auth/RequirePermission";
import { FREIGHT_PERMS } from "./lib/permissions";
@@ -205,6 +205,7 @@ const App = () => {
<Route path="overview" element={<RequirePermission permission={FREIGHT_PERMS.overview.view}><OverviewPage /></RequirePermission>} />
<Route path="reports" element={<RequirePermission permission={FREIGHT_PERMS.reports.view}><ReportsHubPage /></RequirePermission>} />
<Route path="reports/:reportKey" element={<RequirePermission permission={FREIGHT_PERMS.reports.view}><ReportPage /></RequirePermission>} />
<Route path="audit-logs" element={<RequirePermission permission={FREIGHT_PERMS.auditLog.view}><AuditLogsPage /></RequirePermission>} />
{/* Dev/testing page for the mock AI booking assistant. */}
<Route
path="ai-booking-mock-test"
@@ -803,14 +804,6 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="audit-logs"
element={
<RequirePermission permission={FREIGHT_PERMS.audit.view}>
<AuditLogsPage />
</RequirePermission>
}
/>
<Route
path="contract-templates"
element={

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

@@ -184,13 +184,6 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
subtitle: "Manage dropdown options used across the platform",
},
},
{
prefix: "/dashboard/audit-logs",
meta: {
title: "Audit Logs",
subtitle: "Request and entity-level activity recorded across the freight API",
},
},
{
prefix: "/dashboard/configuration/contract-validity-periods",
meta: {

View File

@@ -517,7 +517,7 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
label: "Audit logs",
href: "/dashboard/audit-logs",
icon: <History />,
permission: FREIGHT_PERMS.audit.view,
permission: FREIGHT_PERMS.auditLog.view,
},
{
label: "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

View File

@@ -57,6 +57,10 @@ export const URL_CONSTANTS = {
BASE: "/exchange-settings",
},
AUDIT_LOGS: {
BASE: "/audit",
},
DROPDOWN_SETTINGS: {
BASE: "/dropdown-settings",
BY_ID: (id: string) => `/api/dropdown-settings/${id}`,
@@ -321,10 +325,6 @@ export const URL_CONSTANTS = {
SUMMARY: "/payments/summary",
},
AUDIT: {
LOGS: "/audit/logs",
},
LOCOMOTIVES: {
BASE: "/locomotives",
BY_ID: (id: string) => `/locomotives/${id}`,
@@ -359,6 +359,9 @@ export const URL_CONSTANTS = {
`/train-scheduling/schedules/${id}/window-rule`,
SCHEDULE_DATE: (id: string) =>
`/train-scheduling/schedules/${id}/schedule-date`,
MERGE_PREVIEW: (id: string, targetTrainId: string) =>
`/train-scheduling/schedules/${id}/merge-preview/${targetTrainId}`,
MERGE_TRAIN: (id: string) => `/train-scheduling/schedules/${id}/merge`,
CONTRACT_BOOKING_WINDOWS: (contractId: string) =>
`/train-scheduling/contracts/${contractId}/booking-windows`,
MARK_BOOKING_PAID: (bookingId: string) =>

View File

@@ -105,6 +105,7 @@ export const FREIGHT_PERMS = {
dispatch: "edr_freight_app:train_scheduling:dispatch",
markPaid: "edr_freight_app:train_scheduling:mark_paid",
expireBooking: "edr_freight_app:train_scheduling:expire_booking",
editTrainNumber: "edr_freight_app:train_scheduling:edit_train_number",
},
fleet: {
view: "edr_freight_app:fleet:view",
@@ -188,6 +189,11 @@ export const FREIGHT_PERMS = {
update: "edr_freight_app:trains:update",
delete: "edr_freight_app:trains:delete",
assignWagons: "edr_freight_app:trains:assign_wagons",
/** Train-builder detail Actions menu — each item its own grant. */
changeLocomotives: "edr_freight_app:trains:change_locomotives",
changeYard: "edr_freight_app:trains:change_yard",
toggleActive: "edr_freight_app:trains:toggle_active",
disband: "edr_freight_app:trains:disband",
},
routes: {
view: "edr_freight_app:routes:view",
@@ -306,6 +312,13 @@ export const FREIGHT_PERMS = {
cancel: "edr_freight_app:warehouse_fee_invoices:cancel",
pay: "edr_freight_app:warehouse_fee_invoices:pay",
},
/**
* Audit trail. View-only — the API exposes no write routes for audit rows,
* so there is no manage/delete counterpart to grant.
*/
auditLog: {
view: "edr_freight_app:audit_log:view",
},
settings: {
fileUpload: {
view: "edr_freight_app:settings:file_upload:view",
@@ -343,9 +356,6 @@ export const FREIGHT_PERMS = {
manage: "edr_freight_app:settings:support_content:manage",
},
},
audit: {
view: "edr_freight_app:audit:view",
},
staff: {
roles: {
view: "edr_freight_app:staff:roles:view",

View File

@@ -0,0 +1,340 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Badge,
Card,
Code,
Group,
Loader,
Modal,
Select,
Stack,
Table,
Text,
Tooltip,
} from "@mantine/core";
import {
usePagination,
type OnChangeFn,
type PaginationState,
} from "@edr/ui-common";
import { PageContainer, PageHeader } from "@/components/page";
import ListControls from "@/components/common/ListControls";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import {
AUDIT_METHODS,
auditLogsService,
type AuditLog,
type AuditMethod,
} from "@/services/auditLogs.service";
/** Method → badge colour. Destructive actions read as the loudest. */
const METHOD_COLORS: Record<AuditMethod, string> = {
POST: "green",
PUT: "blue",
PATCH: "yellow",
DELETE: "red",
};
const OUTCOME_OPTIONS = [
{ value: "true", label: "Succeeded" },
{ value: "false", label: "Failed" },
];
/** `YYYY-MM-DD` → inclusive ISO bounds, so a single day covers its full range. */
const startOfDay = (date: string) => `${date}T00:00:00.000Z`;
const endOfDay = (date: string) => `${date}T23:59:59.999Z`;
const formatTimestamp = (value: string) => new Date(value).toLocaleString();
const AuditLogsPage = () => {
// Server-side filters. Unlike most freight lists (which filter an
// already-fetched array via useListControls), audit_logs is append-only and
// grows without bound, so filtering and paging both happen in the API.
const [search, setSearch] = useState("");
const [dateFrom, setDateFrom] = useState<string | null>(null);
const [dateTo, setDateTo] = useState<string | null>(null);
const [type, setType] = useState<string | null>(null);
const [method, setMethod] = useState<string | null>(null);
const [outcome, setOutcome] = useState<string | null>(null);
const [selected, setSelected] = useState<AuditLog | null>(null);
const { pagination, setPagination } = usePagination({ pageIndex: 0, pageSize: 25 });
const query = useMemo(
() => ({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
type: type ?? undefined,
method: (method as AuditMethod | null) ?? undefined,
isSuccess: outcome === null ? undefined : outcome === "true",
// The API filters by record id; the search box is the natural place to
// paste one when tracing what happened to a specific contract/booking.
resourceId: search.trim() || undefined,
from: dateFrom ? startOfDay(dateFrom) : undefined,
to: dateTo ? endOfDay(dateTo) : undefined,
}),
[pagination, type, method, outcome, search, dateFrom, dateTo],
);
const logsQuery = useQuery({
queryKey: ["audit-logs", query],
queryFn: () => auditLogsService.list(query),
});
const typesQuery = useQuery({
queryKey: ["audit-logs", "types"],
queryFn: () => auditLogsService.types(),
});
const rows = logsQuery.data?.items ?? [];
const totalCount = logsQuery.data?.meta.total ?? 0;
const pageCount = logsQuery.data?.meta.totalPages ?? 0;
const hasFilters = Boolean(
search || dateFrom || dateTo || type || method || outcome,
);
const resetFilters = () => {
setSearch("");
setDateFrom(null);
setDateTo(null);
setType(null);
setMethod(null);
setOutcome(null);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
};
/** Any filter change must return to page 1, or the view can land out of range. */
const onFilterChange = <T,>(setter: (value: T) => void) => (value: T) => {
setter(value);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
};
// `OnChangeFn` may hand back either a new value or an updater, so both forms
// are resolved before storing.
const handlePaginationChange: OnChangeFn<PaginationState> = (updater) => {
setPagination((prev) =>
typeof updater === "function" ? updater(prev) : updater,
);
};
// `PageContainer` gives the page the same horizontal inset and vertical
// rhythm as every other dashboard screen; `fluid` lifts the max-width cap
// because the log table is wide.
return (
<PageContainer fluid>
<PageHeader
title="Audit logs"
subtitle="Every state-changing action taken by backoffice staff. Read-only — entries cannot be edited or removed."
/>
<Card withBorder padding="md">
<Stack gap="md">
<ListControls
search={search}
onSearchChange={onFilterChange(setSearch)}
searchPlaceholder="Filter by record id…"
dateFrom={dateFrom}
onDateFromChange={onFilterChange(setDateFrom)}
dateTo={dateTo}
onDateToChange={onFilterChange(setDateTo)}
dateLabel="Action date"
hasFilters={hasFilters}
onReset={resetFilters}
>
<Select
label="Entity"
placeholder="All entities"
data={typesQuery.data ?? []}
value={type}
onChange={onFilterChange(setType)}
clearable
searchable
w={200}
/>
<Select
label="Method"
placeholder="All methods"
data={[...AUDIT_METHODS]}
value={method}
onChange={onFilterChange(setMethod)}
clearable
w={150}
/>
<Select
label="Outcome"
placeholder="Any outcome"
data={OUTCOME_OPTIONS}
value={outcome}
onChange={onFilterChange(setOutcome)}
clearable
w={160}
/>
</ListControls>
{logsQuery.isLoading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : logsQuery.isError ? (
<Text c="red" ta="center" py="xl">
Could not load audit logs.
</Text>
) : rows.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
No audit entries match these filters.
</Text>
) : (
<Table.ScrollContainer minWidth={900}>
<Table highlightOnHover striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Action</Table.Th>
<Table.Th>Entity</Table.Th>
<Table.Th>Method</Table.Th>
<Table.Th>User</Table.Th>
<Table.Th>Outcome</Table.Th>
<Table.Th>When</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((log) => (
<Table.Tr
key={log.id}
onClick={() => setSelected(log)}
style={{ cursor: "pointer" }}
>
<Table.Td maw={340}>
<Text size="sm" lineClamp={2}>
{log.title}
</Text>
</Table.Td>
<Table.Td>
<Badge variant="light">{log.type}</Badge>
</Table.Td>
<Table.Td>
<Badge color={METHOD_COLORS[log.method]} variant="light">
{log.method}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm">{log.userName ?? "—"}</Text>
{log.userRole ? (
<Text size="xs" c="dimmed">
{log.userRole}
</Text>
) : null}
</Table.Td>
<Table.Td>
{log.isSuccess ? (
<Badge color="green" variant="light">
Success
</Badge>
) : (
// The status code separates "denied" (403) from
// "broke" (500) — both are simply a failure here.
<Tooltip
label={log.errorMessage ?? "Failed"}
multiline
w={280}
disabled={!log.errorMessage}
>
<Badge color="red" variant="light">
Failed{log.statusCode ? ` · ${log.statusCode}` : ""}
</Badge>
</Tooltip>
)}
</Table.Td>
<Table.Td>
<Text size="sm">{formatTimestamp(log.createdAt)}</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
<RuleEngineListFooter
pagination={pagination}
pageCount={pageCount}
totalCount={totalCount}
itemLabel="entries"
onPaginationChange={handlePaginationChange}
/>
</Stack>
</Card>
<Modal
opened={selected !== null}
onClose={() => setSelected(null)}
title="Audit entry"
size="lg"
>
{selected ? (
<Stack gap="sm">
<DetailRow label="Action" value={selected.title} />
<DetailRow label="Entity" value={selected.type} />
<DetailRow label="Record id" value={selected.resourceId} />
<DetailRow label="Method" value={selected.method} />
<DetailRow label="URL" value={selected.url} />
<DetailRow label="Route" value={selected.routePath} />
<DetailRow
label="Outcome"
value={
selected.isSuccess
? `Success${selected.statusCode ? ` (${selected.statusCode})` : ""}`
: `Failed${selected.statusCode ? ` (${selected.statusCode})` : ""}`
}
/>
{selected.errorMessage ? (
<DetailRow label="Error" value={selected.errorMessage} />
) : null}
<DetailRow label="User" value={selected.userName} />
<DetailRow label="Role" value={selected.userRole} />
<DetailRow label="User id" value={selected.userId} />
<DetailRow label="IP address" value={selected.ipAddress} />
<DetailRow label="Request id" value={selected.requestId} />
<DetailRow
label="Duration"
value={selected.durationMs === null ? null : `${selected.durationMs} ms`}
/>
<DetailRow label="When" value={formatTimestamp(selected.createdAt)} />
<div>
<Text size="sm" fw={600} mb={4}>
Request payload
</Text>
{selected.request ? (
// Secrets are already redacted and uploads reduced to
// descriptors by the API before storage.
<Code block style={{ maxHeight: 320, overflow: "auto" }}>
{JSON.stringify(selected.request, null, 2)}
</Code>
) : (
<Text size="sm" c="dimmed">
No payload recorded.
</Text>
)}
</div>
</Stack>
) : null}
</Modal>
</PageContainer>
);
};
const DetailRow = ({ label, value }: { label: string; value: string | null }) => (
<Group gap="xs" wrap="nowrap" align="flex-start">
<Text size="sm" fw={600} w={120} style={{ flexShrink: 0 }}>
{label}
</Text>
<Text size="sm" style={{ wordBreak: "break-all" }}>
{value ?? "—"}
</Text>
</Group>
);
export default AuditLogsPage;

View File

@@ -1,190 +0,0 @@
import { Badge, Box, Card, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
import type {
AuditLogRow,
AuditQueryMethod,
AuditUser,
LocalizedText,
} from "@/services/audit.service";
import {
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
const ACTION_LABELS: Record<AuditQueryMethod, string> = {
INSERT: "Created",
UPDATE: "Updated",
DELETE: "Deleted",
INSERT_CHILD: "Linked child",
DELETE_CHILD: "Unlinked child",
};
const ACTION_COLORS: Record<AuditQueryMethod, string> = {
INSERT: "edr-green",
UPDATE: "yellow",
DELETE: "red",
INSERT_CHILD: "indigo",
DELETE_CHILD: "gray",
};
function formatDateTime(iso: string): string {
const d = new Date(iso);
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
// See LocalizedText: `name`/`title` lifted from a raw audited entity can be
// a plain string or IAM's { am, en } — never render either directly.
// "undefined undefined" is the producer's own broken template when no user
// was attached at all (unauthenticated/customer flows, e.g. Fayda
// verification) — filtered out here rather than shown as raw garbage.
function localize(value: LocalizedText | null | undefined): string | undefined {
if (!value) return undefined;
if (typeof value === "object") return value.en ?? value.am ?? undefined;
if (/^undefined(\s+undefined)?$/.test(value.trim())) return undefined;
return value;
}
function formatUser(user: AuditUser | null | undefined): string {
return localize(user?.name) ?? user?.id ?? "—";
}
function summarize(row: AuditLogRow): string {
if (row.changes?.length) {
return row.changes
.slice(0, 2)
.map((c) => c.field)
.join(", ") + (row.changes.length > 2 ? `, +${row.changes.length - 2} more` : "");
}
if (row.payload) {
return (
localize(row.payload.name) ?? localize(row.payload.title) ?? row.payload.id ?? "—"
);
}
return "—";
}
const tableHeader =
"text-xs font-semibold uppercase tracking-wide text-muted-foreground";
export default function AuditLogsPage() {
const { pagination, setPagination } = usePagination({ pageSize: 20 });
const filter = {
skip: pagination.pageIndex * pagination.pageSize,
take: pagination.pageSize,
};
const { data, isLoading, isError } = useQuery(
api.audit.list.queryOptions({ input: { filter } }),
);
const rows = data?.items ?? [];
const total = data?.count ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const columns: ColumnDef<AuditLogRow>[] = [
{
id: "time",
header: () => <span className={tableHeader}>Time</span>,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{formatDateTime(row.original.createdAt)}
</span>
),
},
{
id: "action",
header: () => <span className={tableHeader}>Action</span>,
cell: ({ row }) => (
<Badge
color={ACTION_COLORS[row.original.queryMethod] ?? "gray"}
variant="light"
radius="sm"
>
{ACTION_LABELS[row.original.queryMethod] ?? row.original.queryMethod}
</Badge>
),
},
{
id: "entity",
header: () => <span className={tableHeader}>Entity</span>,
cell: ({ row }) => (
<span className="font-mono text-sm text-foreground">
{row.original.entityName}
</span>
),
},
{
id: "user",
header: () => <span className={tableHeader}>User</span>,
cell: ({ row }) => (
<span className="text-sm text-foreground">
{formatUser(row.original.auditLog?.user)}
</span>
),
},
{
id: "summary",
header: () => <span className={tableHeader}>Summary</span>,
cell: ({ row }) => (
<span className="truncate text-sm text-muted-foreground">
{summarize(row.original)}
</span>
),
},
];
return (
<PageContainer>
<PageHeader
title="Audit Logs"
subtitle="Request and entity-level activity recorded across the freight API."
/>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Box>
<Box style={{ overflowX: "auto" }} w="100%">
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
</Stack>
</Card>
</PageContainer>
);
}

View File

@@ -247,7 +247,6 @@ export default function BookingRequestDetailPage() {
<Box style={{ position: "sticky", top: 24 }}>
<Stack gap="lg">
<BookingCompanyCard booking={booking} />
<BookingSchedulingWindowCard booking={booking} />
<BookingPricingSummary booking={booking} />
<Box id="warehouse-payments">
<WarehouseInfoCard
@@ -257,6 +256,11 @@ export default function BookingRequestDetailPage() {
tradeDirection={booking.tradeDirection}
/>
</Box>
{/* Sits directly above the staff actions: reviewing an operation
request means approving the booking onto a specific train, so
that train and its clock must be readable before the approve
button. */}
<BookingSchedulingWindowCard booking={booking} />
<BookingActionsToolbar
booking={booking}
mutations={mutations}

View File

@@ -10,6 +10,7 @@ import {
Progress,
Stack,
Text,
Textarea,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { isAxiosError } from "axios";
@@ -47,7 +48,7 @@ import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompo
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
import { useAuth } from "@/auth/useAuth";
import { canFleetAction, FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { useToast } from "@/hooks/use-toast";
import type { TrainCompositionWagon } from "@/services/trainBuilder.service";
@@ -82,10 +83,19 @@ export default function TrainBuilderDetailPage() {
const [deactivateOpen, setDeactivateOpen] = useState(false);
const [maintenanceTarget, setMaintenanceTarget] =
useState<TrainCompositionWagon | null>(null);
const [maintenanceNote, setMaintenanceNote] = useState("");
// Clearing the note with the target stops one wagon's reason being carried
// over onto the next wagon sent to maintenance.
const closeMaintenance = () => {
setMaintenanceTarget(null);
setMaintenanceNote("");
};
const { user } = useAuth();
const canUpdate = canFleetAction(user, "trains", "update");
const canDelete = canFleetAction(user, "trains", "delete");
const canAssign = hasPermission(user, FREIGHT_PERMS.trains.assignWagons);
const canChangeLocomotives = hasPermission(user, FREIGHT_PERMS.trains.changeLocomotives);
const canChangeYard = hasPermission(user, FREIGHT_PERMS.trains.changeYard);
const canToggleActive = hasPermission(user, FREIGHT_PERMS.trains.toggleActive);
const canDisband = hasPermission(user, FREIGHT_PERMS.trains.disband);
const compositionQuery = useQuery(
api.trainBuilder.composition.queryOptions({ input: { id }, enabled: Boolean(id) }),
@@ -101,6 +111,19 @@ export default function TrainBuilderDetailPage() {
const activate = useMutation(api.trainBuilder.activate.mutationOptions());
const composition = compositionQuery.data;
// 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 =
[
composition?.exportTrainNumber?.trim()
? `export ${composition.exportTrainNumber.trim()}`
: null,
composition?.importTrainNumber?.trim()
? `import ${composition.importTrainNumber.trim()}`
: null,
]
.filter(Boolean)
.join(" / ") || composition?.code;
const busy =
assignWagons.isPending ||
removeWagon.isPending ||
@@ -172,7 +195,7 @@ export default function TrainBuilderDetailPage() {
</Group>
}
action={
canUpdate || canDelete ? (
canChangeLocomotives || canChangeYard || canToggleActive || canDisband ? (
<Menu position="bottom-end" withinPortal shadow="md" width={220}>
<Menu.Target>
<Button variant="default" rightSection={<MoreHorizontal size={16} />}>
@@ -180,47 +203,49 @@ export default function TrainBuilderDetailPage() {
</Button>
</Menu.Target>
<Menu.Dropdown>
{canUpdate ? (
<>
<Menu.Item
leftSection={<Replace size={15} />}
disabled={!composition.editable}
onClick={() => setLocoModalOpen(true)}
>
Change locomotives
</Menu.Item>
<Menu.Item
leftSection={<MapPin size={15} />}
disabled={!composition.editable}
onClick={() => setYardModalOpen(true)}
>
Change yard
</Menu.Item>
{composition.status === "DEACTIVATED" ? (
<Menu.Item
leftSection={<Power size={15} />}
disabled={blockingLocomotives.length > 0}
onClick={() =>
void withToast(async () => {
await activate.mutateAsync(composition.id);
toast({ title: `Train ${composition.code} reactivated` });
}, "Could not reactivate train")
}
>
Reactivate train
</Menu.Item>
) : (
<Menu.Item
leftSection={<PowerOff size={15} />}
disabled={composition.activeSchedules.length > 0}
onClick={() => setDeactivateOpen(true)}
>
Deactivate train
</Menu.Item>
)}
</>
{canChangeLocomotives ? (
<Menu.Item
leftSection={<Replace size={15} />}
disabled={!composition.editable}
onClick={() => setLocoModalOpen(true)}
>
Change locomotives
</Menu.Item>
) : null}
{canDelete ? (
{canChangeYard ? (
<Menu.Item
leftSection={<MapPin size={15} />}
disabled={!composition.editable}
onClick={() => setYardModalOpen(true)}
>
Change yard
</Menu.Item>
) : null}
{canToggleActive ? (
composition.status === "DEACTIVATED" ? (
<Menu.Item
leftSection={<Power size={15} />}
disabled={blockingLocomotives.length > 0}
onClick={() =>
void withToast(async () => {
await activate.mutateAsync(composition.id);
toast({ title: `Train ${composition.code} reactivated` });
}, "Could not reactivate train")
}
>
Reactivate train
</Menu.Item>
) : (
<Menu.Item
leftSection={<PowerOff size={15} />}
disabled={composition.activeSchedules.length > 0}
onClick={() => setDeactivateOpen(true)}
>
Deactivate train
</Menu.Item>
)
) : null}
{canDisband ? (
<Menu.Item
color="red"
leftSection={<Trash2 size={15} />}
@@ -277,7 +302,7 @@ export default function TrainBuilderDetailPage() {
.
</Text>
<Group gap="xs">
{canUpdate ? (
{canChangeLocomotives ? (
<Button
size="compact-sm"
variant="light"
@@ -459,7 +484,7 @@ export default function TrainBuilderDetailPage() {
<Modal
opened={Boolean(maintenanceTarget)}
onClose={() => setMaintenanceTarget(null)}
onClose={closeMaintenance}
title={<Text fw={600}>Send wagon to maintenance?</Text>}
radius="lg"
centered
@@ -472,14 +497,22 @@ export default function TrainBuilderDetailPage() {
</Text>{" "}
is detached from train{" "}
<Text span fw={700} c="dark">
{composition.code}
{trainRunLabel}
</Text>{" "}
and set to MAINTENANCE it stays out of the available pool until it
clears. The detach is stamped with the time and this train number in
the wagon's history.
clears. The detach is stamped with the time and this train's run
numbers in the wagon's history.
</Text>
<Textarea
label="Note"
placeholder="Optional note (e.g. reason for maintenance)"
value={maintenanceNote}
onChange={(e) => setMaintenanceNote(e.currentTarget.value)}
autosize
minRows={2}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setMaintenanceTarget(null)}>
<Button variant="default" onClick={closeMaintenance}>
Keep in consist
</Button>
<Button
@@ -491,11 +524,12 @@ export default function TrainBuilderDetailPage() {
await maintenanceWagon.mutateAsync({
id: composition.id,
wagonId: maintenanceTarget!.id,
note: maintenanceNote.trim() || undefined,
});
toast({
title: `Wagon ${maintenanceTarget!.wagonNumber} sent to maintenance`,
});
setMaintenanceTarget(null);
closeMaintenance();
}, "Could not send wagon to maintenance")
}
>

View File

@@ -23,6 +23,7 @@ import {
CalendarClock,
CheckCircle2,
Clock,
Merge,
Container as ContainerIcon,
Eye,
FileText,
@@ -54,6 +55,7 @@ import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPl
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel";
import MergeScheduleTrainModal from "@/components/trainScheduling/MergeScheduleTrainModal";
import ScheduleHistoryPanel from "@/components/trainScheduling/ScheduleHistoryPanel";
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
@@ -114,6 +116,7 @@ export default function TrainScheduleV2DetailPage() {
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
const [maintenanceOpen, setMaintenanceOpen] = useState(false);
const [windowSettingsOpen, setWindowSettingsOpen] = useState(false);
const [mergeModalOpen, setMergeModalOpen] = useState(false);
const [gatepassSecuredAt, setGatepassSecuredAt] = useState("");
const [gatepassReference, setGatepassReference] = useState("");
const [gatepassFileUrl, setGatepassFileUrl] = useState("");
@@ -400,7 +403,6 @@ export default function TrainScheduleV2DetailPage() {
const canDispatch =
schedule.status === "SCHEDULED" &&
hasPermission(authUser, FREIGHT_PERMS.trainScheduling.dispatch);
// Dispatch readiness: bookings with no wagon, and wagon-loaded bookings whose
// cargo staff never marked loaded. Both are warnings, not blockers — staff can
// still dispatch after confirming.
@@ -667,6 +669,7 @@ export default function TrainScheduleV2DetailPage() {
assignedBookings={(schedule.bookings ?? []).map((b) => ({
id: b.id,
reference: b.reference ?? b.id.slice(0, 8),
customer: b.customer,
weightTons: b.weightTons,
isGovernment: b.isGovernment,
wagonsRequired: b.wagonsRequired,
@@ -940,7 +943,7 @@ export default function TrainScheduleV2DetailPage() {
{schedule.trainNumber ? (
<Box>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
Voyage No.
Train No.
</Text>
<Text
ff="monospace"
@@ -952,6 +955,33 @@ export default function TrainScheduleV2DetailPage() {
</Text>
</Box>
) : null}
{schedule.voyageNumber ? (
<Box>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
Voyage No.
</Text>
<Text
ff="monospace"
fw={800}
lh={1.1}
style={{ fontSize: 32, color: "#0f172a" }}
>
{schedule.voyageNumber}
</Text>
</Box>
) : null}
{/* Merging rewrites the consist, so it is offered only while
the departure can still be edited. */}
{canEditBookings ? (
<Button
variant="light"
size="compact-sm"
leftSection={<Merge size={14} />}
onClick={() => setMergeModalOpen(true)}
>
Merge
</Button>
) : null}
{schedule.direction ? (
<Box>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
@@ -1369,6 +1399,15 @@ export default function TrainScheduleV2DetailPage() {
onSaved={() => void detailQuery.refetch()}
/>
<MergeScheduleTrainModal
scheduleId={scheduleId ?? null}
currentTrainId={schedule.trainSet?.trainId ?? null}
scheduleReference={schedule.reference ?? null}
opened={mergeModalOpen}
onClose={() => setMergeModalOpen(false)}
onMerged={() => void detailQuery.refetch()}
/>
<SwitchGovernmentBookingModal
key={switchTarget?.id ?? "none"}
opened={Boolean(switchTarget)}

View File

@@ -134,6 +134,9 @@ export default function TrainScheduleV2ListPage() {
// confirmation.
const [dispatchTarget, setDispatchTarget] =
useState<TrainScheduleListItem | null>(null);
// Cancelling is likewise irreversible — confirmed before the mutation fires.
const [cancelTarget, setCancelTarget] =
useState<TrainScheduleListItem | null>(null);
const [editDateSchedule, setEditDateSchedule] =
useState<TrainScheduleListItem | null>(null);
const [routeId, setRouteId] = useState("");
@@ -424,9 +427,7 @@ export default function TrainScheduleV2ListPage() {
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<MetricChip value={row.original.bookingsCount} label="bkg" />
{/* Wagon SLOTS this schedule's bookings occupy — not the coupled
consist. A built train shows 0 here until bookings are allocated. */}
<MetricChip value={row.original.wagonCount} label="wgn used" />
<WagonChips schedule={row.original} />
<MetricChip value={`${row.original.totalWeightTons}T`} label="" subtle />
</Group>
),
@@ -503,21 +504,7 @@ export default function TrainScheduleV2ListPage() {
<Menu.Item
color="red"
leftSection={<Ban size={15} />}
onClick={async () => {
try {
await cancel.mutateAsync({
id: schedule.id,
freightType: schedule.freightType ?? "CONTAINER",
});
toast({ title: "Schedule cancelled" });
} catch (err) {
toast({
title: "Cancel failed",
description: parseError(err, "Could not cancel"),
variant: "destructive",
});
}
}}
onClick={() => setCancelTarget(schedule)}
>
Cancel schedule
</Menu.Item>
@@ -969,10 +956,118 @@ export default function TrainScheduleV2ListPage() {
</Group>
</Stack>
</Modal>
{/* Cancelling a schedule is destructive and cannot be undone, so it is
confirmed here rather than firing straight from the row menu. */}
<Modal
opened={cancelTarget != null}
onClose={() => setCancelTarget(null)}
title="Cancel this schedule?"
centered
radius="md"
>
<Stack gap="md">
<Text size="sm" c="dimmed">
<Text span fw={600} c="dark">
{cancelTarget?.trainNumber ?? cancelTarget?.reference ?? "This train"}
</Text>{" "}
will be cancelled and removed from the active schedule board. This
cannot be undone.
</Text>
{cancelTarget?.bookingsCount ? (
<Text size="sm" c="red.7" fw={500}>
{cancelTarget.bookingsCount} booking
{cancelTarget.bookingsCount === 1 ? "" : "s"} on this train will
need to be moved to another schedule.
</Text>
) : null}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setCancelTarget(null)}>
Keep schedule
</Button>
<Button
color="red"
leftSection={<Ban size={16} />}
loading={cancel.isPending}
onClick={async () => {
if (!cancelTarget) return;
try {
await cancel.mutateAsync({
id: cancelTarget.id,
freightType: cancelTarget.freightType ?? "CONTAINER",
});
toast({ title: "Schedule cancelled" });
setCancelTarget(null);
void schedulesQuery.refetch();
} catch (err) {
toast({
title: "Cancel failed",
description: parseError(err, "Could not cancel"),
variant: "destructive",
});
}
}}
>
Cancel schedule
</Button>
</Group>
</Stack>
</Modal>
</PageContainer>
);
}
/**
* The row's wagon chips, matching the detail page's wagon plan: used is slots
* carrying a booking allocation (never the coupled consist size), and remaining
* excludes wagons reserved by bookings that have not paid yet — that space is
* claimed, so it is not bookable.
*
* Schedules whose train set has not been built yet have no consist to measure,
* so both figures fall back to the schedule's planned `maxWagons` ceiling.
* Without that fallback an unbuilt 37-wagon schedule reads "0 bookable" even
* though every one of its wagons is still free.
*/
function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) {
// Pre-deploy API rows carry only wagonCount; fall back so the chip still
// renders rather than reading 0 used on every train.
const total = schedule.wagonsTotal ?? schedule.wagonCount;
const used = schedule.wagonsUsed;
const reserved = schedule.wagonsReserved ?? 0;
// Until the train set is built there is no consist to measure against, so
// `wagonsRemaining` (consist minus claimed) is 0 on every unbuilt schedule —
// which reads as "fully booked" when in fact nothing is booked at all. Before
// a consist exists, capacity is the planned ceiling minus what bookings have
// already claimed.
const planCeiling = schedule.maxWagons ?? 0;
const remaining =
total === 0 && planCeiling > 0
? Math.max(0, planCeiling - Math.max(used ?? 0, reserved))
: schedule.wagonsRemaining;
if (used == null) {
return <MetricChip value={total} label="wgn" subtle />;
}
return (
<>
{/* An unbuilt consist has no "used out of coupled" to show; the plan
ceiling is the only meaningful denominator at that point. */}
<MetricChip
value={total === 0 && planCeiling > 0 ? `${used}/${planCeiling}` : `${used}/${total}`}
label={total === 0 && planCeiling > 0 ? "wgn planned" : "wgn used"}
/>
{reserved > used ? (
<MetricChip value={reserved} label="reserved" subtle />
) : null}
{remaining != null ? (
<MetricChip value={remaining} label="bookable" subtle />
) : null}
</>
);
}
function MetricChip({
value,
label,
@@ -1078,7 +1173,7 @@ function ScheduleCard({
</Group>
<Group gap={6} wrap="nowrap">
<MetricChip value={schedule.bookingsCount} label="bkg" />
<MetricChip value={schedule.wagonCount} label="wgn used" />
<WagonChips schedule={schedule} />
<MetricChip value={`${schedule.totalWeightTons}T`} label="" subtle />
</Group>
</Group>

View File

@@ -2,19 +2,18 @@ import { Freight } from "@edr/types";
import {
Alert,
Button,
Checkbox,
Group,
Loader,
Modal,
ScrollArea,
Stack,
Text,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { AlertTriangle, ArrowRight, PackageCheck } from "lucide-react";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import WagonPicker from "@/components/wagons/WagonPicker";
import { api } from "@/services/api";
import type { WagonTransferRequest } from "@/services/wagon.service";
@@ -59,23 +58,21 @@ export function TransferFulfillModal({
const fulfill = useMutation(api.wagonTransferRequests.fulfill.mutationOptions());
const canTake = useMemo(
() => Math.min(outstanding, wagons.length),
[outstanding, wagons.length],
);
/** The requester's picks that are still in this yard and still available. */
const preferredHere = useMemo(() => {
const asked = new Set(request?.preferredWagonIds ?? []);
return asked.size ? wagons.filter((w) => asked.has(w.id)) : [];
}, [request, wagons]);
const toggle = (id: string) =>
setPicked((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
// Never let staff pick more than is still owed — the API rejects it too.
else if (next.size >= outstanding) return prev;
else next.add(id);
return next;
});
const missingPreferred =
(request?.preferredWagonIds?.length ?? 0) - preferredHere.length;
const takeAllAvailable = () =>
setPicked(new Set(wagons.slice(0, canTake).map((w) => w.id)));
// Open on what the requester asked for: OCC confirms rather than re-picks.
// Re-runs when the wagon list arrives, and is capped at what is still owed.
useEffect(() => {
if (!request) return;
setPicked(new Set(preferredHere.slice(0, outstanding).map((w) => w.id)));
}, [request, preferredHere, outstanding]);
const close = () => {
setPicked(new Set());
@@ -120,27 +117,16 @@ export function TransferFulfillModal({
>
{!request ? null : (
<Stack gap="sm">
<Group justify="space-between" wrap="wrap">
<Text size="sm">
<Text span fw={700}>
{outstanding}
</Text>{" "}
wagon(s) still owed ·{" "}
<Text span fw={700}>
{wagons.length}
</Text>{" "}
available in {yardLabel(request.fromYard)}
</Text>
<Button
variant="light"
size="xs"
radius="md"
disabled={canTake === 0}
onClick={takeAllAvailable}
>
Select {canTake}
</Button>
</Group>
<Text size="sm">
<Text span fw={700}>
{outstanding}
</Text>{" "}
wagon(s) still owed ·{" "}
<Text span fw={700}>
{wagons.length}
</Text>{" "}
available in {yardLabel(request.fromYard)}
</Text>
{wagons.length < outstanding ? (
<Alert color="yellow" radius="md" icon={<AlertTriangle size={15} />}>
@@ -150,27 +136,39 @@ export function TransferFulfillModal({
</Alert>
) : null}
{preferredHere.length > 0 ? (
<Alert color="teal" radius="md" icon={<PackageCheck size={15} />}>
The requester named{" "}
<Text span fw={700}>
{preferredHere.length}
</Text>{" "}
specific wagon(s) pre-selected below. You can change the
selection freely; the request is a count, not a reservation.
</Alert>
) : null}
{missingPreferred > 0 ? (
<Alert color="orange" radius="md" icon={<AlertTriangle size={15} />}>
{missingPreferred} of the wagon(s) the requester named{" "}
{missingPreferred === 1 ? "is" : "are"} no longer available in this
yard. Pick replacements below.
</Alert>
) : null}
{isLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" />
</Group>
) : wagons.length === 0 ? (
<Text c="dimmed" size="sm" py="md">
No available wagons of this type in the source yard right now.
</Text>
) : (
<ScrollArea.Autosize mah={320}>
<Stack gap={4}>
{wagons.map((w) => (
<Checkbox
key={w.id}
checked={picked.has(w.id)}
onChange={() => toggle(w.id)}
label={w.wagonNumber}
/>
))}
</Stack>
</ScrollArea.Autosize>
<WagonPicker
wagons={wagons}
selected={picked}
onChange={setPicked}
// Never let staff pick more than is still owed — the API rejects it too.
max={outstanding}
emptyMessage="No available wagons of this type in the source yard right now."
maxHeight={300}
/>
)}
<Group justify="flex-end" gap="sm">

View File

@@ -15,6 +15,7 @@ import { AlertTriangle, Send, XCircle } from "lucide-react";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import WagonPicker from "@/components/wagons/WagonPicker";
import { api } from "@/services/api";
import type { WagonTransferRequest } from "@/services/wagon.service";
@@ -45,9 +46,11 @@ function useTransferOptions(enabled: boolean) {
/**
* Wagons the source yard can hand over right now — AVAILABLE and not coupled to
* a built train. Mirrors `countAvailable` on the API, which rejects any request
* asking for more than this, so the field must not let one be filed.
* asking for more than this, so the field must not let one be filed. Returns
* the wagons themselves so the form can also offer them for picking; `count` is
* null until both a yard and a type are chosen.
*/
function useAvailableCount(
function useAvailableWagons(
enabled: boolean,
fromYardId: string | null,
wagonTypeId: string | null,
@@ -56,14 +59,15 @@ function useAvailableCount(
...api.wagons.list.queryOptions({ input: {} }),
enabled: enabled && Boolean(fromYardId && wagonTypeId),
});
if (!fromYardId || !wagonTypeId) return null;
return wagons.filter(
if (!fromYardId || !wagonTypeId) return { wagons: [], count: null };
const inYard = wagons.filter(
(w) =>
w.currentYardId === fromYardId &&
w.wagonTypeId === wagonTypeId &&
w.status === Freight.WagonStatus.Available &&
!w.trainId,
).length;
);
return { wagons: inYard, count: inYard.length };
}
export interface TransferRequestFormModalProps {
@@ -95,6 +99,8 @@ export function TransferRequestFormModal({
const [wagonTypeId, setWagonTypeId] = useState<string | null>(null);
const [quantity, setQuantity] = useState<number | string>(1);
const [reason, setReason] = useState("");
/** Specific wagons the requester named — optional; empty means "any N". */
const [picked, setPicked] = useState<Set<string>>(new Set());
// Re-seed on every open so a carry-over never leaks into the next request.
useEffect(() => {
@@ -104,11 +110,27 @@ export function TransferRequestFormModal({
setWagonTypeId(prefillFrom?.wagonTypeId ?? null);
setQuantity(prefillFrom ? outstandingOn(prefillFrom) : 1);
setReason(prefillFrom?.reason ?? "");
setPicked(new Set());
}, [opened, prefillFrom]);
const create = useMutation(api.wagonTransferRequests.create.mutationOptions());
const available = useAvailableCount(opened, fromYardId, wagonTypeId);
const { wagons: availableWagons, count: available } = useAvailableWagons(
opened,
fromYardId,
wagonTypeId,
);
// The picks belong to one yard+type pair; changing either invalidates them.
useEffect(() => {
setPicked(new Set());
}, [fromYardId, wagonTypeId]);
// Naming wagons IS the ask, so the count follows the picks.
const handlePick = (next: Set<string>) => {
setPicked(next);
if (next.size > 0) setQuantity(next.size);
};
// A prefilled outstanding count (or a count typed before the yard was picked)
// can exceed what the chosen source yard actually has — pull it back down so
@@ -118,6 +140,16 @@ export function TransferRequestFormModal({
setQuantity((q) => (Number(q) > available ? available : q));
}, [available]);
// Asking for fewer than were picked would send a selection the API rejects
// (picks may not exceed quantity) — drop the extras, keeping pick order.
const handleQuantityChange = (value: number | string) => {
setQuantity(value);
const n = Number(value);
if (Number.isFinite(n) && picked.size > n) {
setPicked(new Set([...picked].slice(0, Math.max(0, n))));
}
};
const sameYard = Boolean(fromYardId && fromYardId === toYardId);
const overAvailable = available != null && Number(quantity) > available;
const valid =
@@ -136,6 +168,7 @@ export function TransferRequestFormModal({
wagonTypeId: wagonTypeId!,
quantity: Number(quantity),
reason: reason.trim(),
...(picked.size > 0 ? { preferredWagonIds: [...picked] } : {}),
});
toast.success("Transfer request filed");
onClose();
@@ -203,7 +236,7 @@ export function TransferRequestFormModal({
clampBehavior={available == null ? "none" : "strict"}
allowNegative={false}
value={quantity}
onChange={setQuantity}
onChange={handleQuantityChange}
disabled={available === 0}
error={
available === 0
@@ -214,6 +247,34 @@ export function TransferRequestFormModal({
}
required
/>
{/* Optional: name the exact wagons. Leaving this empty files a plain
count request and OCC picks whatever is free. */}
{availableWagons.length > 0 ? (
<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">
{picked.size > 0
? `${picked.size} named — OCC will prioritise these`
: "Leave empty and OCC picks any available"}
</Text>
</Group>
<WagonPicker
wagons={availableWagons}
selected={picked}
onChange={handlePick}
max={available ?? undefined}
maxHeight={200}
/>
</div>
) : null}
<Textarea
label="Reason"
placeholder="Why the wagons are needed"

View File

@@ -52,6 +52,7 @@ import {
TransferRequestFormModal,
} from "./TransferRequestModals";
import {
PreferredWagonChips,
TransferProgress,
TransferStatusBadge,
fmtDateTime,
@@ -195,6 +196,11 @@ export default function WagonTransfersPage() {
<Text size="sm">{wagonTypeLabel(row.original.wagonType)}</Text>
),
},
{
id: "wagons",
header: () => <span>Wagons requested</span>,
cell: ({ row }) => <PreferredWagonChips request={row.original} />,
},
{
id: "progress",
header: () => <span>Delivered</span>,
@@ -564,6 +570,17 @@ export default function WagonTransfersPage() {
{wagonTypeLabel(viewingReason.wagonType)} ·{" "}
{viewingReason.quantity} wagon(s)
</Text>
{viewingReason.preferredWagons?.length ? (
<div>
<Text size="xs" c="dimmed" mb={4}>
Wagons requested
</Text>
<PreferredWagonChips
request={viewingReason}
limit={viewingReason.preferredWagons.length}
/>
</div>
) : null}
<Box
className="text-sm [&_p]:my-2 [&_ol]:list-decimal [&_ul]:list-disc [&_ol]:pl-5 [&_ul]:pl-5"
dangerouslySetInnerHTML={{

View File

@@ -3,6 +3,65 @@ import { Badge, Box, Group, Progress, Text, Tooltip } from "@mantine/core";
import type { WagonTransferRequest } from "@/services/wagon.service";
/** How many wagon chips fit a table cell before the rest are rolled up. */
const CHIP_LIMIT = 4;
/**
* The wagons the requester actually named, when they picked any. Rendered as
* chips so a glance down the column separates "send me these 3" from a plain
* count request — the two are fulfilled differently.
*/
export function PreferredWagonChips({
request,
limit = CHIP_LIMIT,
}: {
request: WagonTransferRequest;
limit?: number;
}) {
const wagons = request.preferredWagons ?? [];
if (wagons.length === 0) {
return (
<Tooltip label="No specific wagons named — OCC picks any available" withArrow>
<Text size="xs" c="dimmed">
Any {request.quantity}
</Text>
</Tooltip>
);
}
const shown = wagons.slice(0, limit);
const rest = wagons.length - shown.length;
return (
<Tooltip
withArrow
multiline
maw={280}
label={`Requested: ${wagons.map((w) => w.wagonNumber).join(", ")}`}
>
<Group gap={4} wrap="wrap" maw={220}>
{shown.map((w) => (
<Badge
key={w.id}
size="sm"
variant="light"
color="edr-green"
radius="sm"
style={{ fontVariantNumeric: "tabular-nums" }}
>
{w.wagonNumber}
</Badge>
))}
{rest > 0 ? (
<Badge size="sm" variant="outline" color="gray" radius="sm">
+{rest}
</Badge>
) : null}
</Group>
</Tooltip>
);
}
/** Reason/note fields come from a rich-text editor and store HTML — this
* gives a plain-text preview for list/table contexts (full formatting is
* shown via `sanitizeHtml` + `dangerouslySetInnerHTML` where there's room). */

View File

@@ -67,6 +67,7 @@ import type {
PinWagonsPayload,
RecordCheckpointPayload,
StaffBookingWindow,
ScheduleMergePreview,
TrainScheduleDetail,
TrainScheduleFilters,
TrainScheduleListFilters,
@@ -166,11 +167,6 @@ import {
type SaveLocomotivePayload,
} from "./locomotives.service";
import { overviewService } from "./overview.service";
import {
auditService,
type AuditLogListFilter,
type PaginatedAuditLogs,
} from "./audit.service";
import { reportsService } from "./reports.service";
import type { ReportQueryInput, ReportResult } from "@/types/reports";
import {
@@ -601,6 +597,36 @@ export const api = {
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
previewScheduleMerge: endpoint<
{ id: string; targetTrainId: string },
ScheduleMergePreview
>(
"train-scheduling",
"merge-preview",
({ id, targetTrainId }) =>
trainSchedulingService.previewScheduleMerge(id, targetTrainId),
({ id, targetTrainId }) => [
"train-scheduling",
"merge-preview",
id,
targetTrainId,
],
),
mergeScheduleTrain: endpoint<
{ id: string; targetTrainId: string; reason?: string },
TrainScheduleDetail
>(
"train-scheduling",
"merge-train",
({ id, ...payload }) =>
trainSchedulingService.mergeScheduleTrain(id, payload),
undefined,
// Wagons and bookings move between trains and schedules, so the wagon and
// train caches are stale too — not just the scheduling ones.
() => [...TRAIN_SCHEDULING_INVALIDATIONS, ["wagons"], ["trains"]],
),
markBookingPaid: endpoint<string, void>(
"train-scheduling",
"mark-booking-paid",
@@ -2045,11 +2071,14 @@ export const api = {
() => TRAIN_BUILDER_INVALIDATIONS,
),
sendWagonToMaintenance: endpoint<{ id: string; wagonId: string }, TrainComposition>(
sendWagonToMaintenance: endpoint<
{ id: string; wagonId: string; note?: string },
TrainComposition
>(
"train-builder",
"sendWagonToMaintenance",
({ id, wagonId }) =>
trainBuilderService.sendWagonToMaintenance(id, wagonId).then((r) => r.data),
({ id, wagonId, note }) =>
trainBuilderService.sendWagonToMaintenance(id, wagonId, note).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
),
@@ -2153,15 +2182,6 @@ export const api = {
),
},
audit: {
list: endpoint<{ filter?: AuditLogListFilter }, PaginatedAuditLogs>(
"audit",
"list",
({ filter }) => auditService.list(filter),
({ filter }) => ["audit", "list", filter ?? {}],
),
},
signatures: {
mySignature: endpoint<void, SavedSignature | null>(
"me",

View File

@@ -1,70 +0,0 @@
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
const A = URL_CONSTANTS.AUDIT;
// Shape from @tria-plc/auditlog's AuditLogCommandController — see
// local-packages/FRONTEND_GUIDE.md.
export type AuditQueryMethod =
| "INSERT"
| "UPDATE"
| "DELETE"
| "INSERT_CHILD"
| "DELETE_CHILD";
export interface AuditFieldChange {
field: string;
from: unknown;
to: unknown;
}
// IAM entities (users, orgs, positions, ...) name themselves bilingually —
// see edr-org.seeder.ts. Any `name`/`title` field lifted from a raw audited
// entity (auditLog.user, payload) can come back as either a plain string or
// this shape; both `name` fields below reflect that.
export type LocalizedText = string | { am?: string; en?: string };
// The vendored interceptor's own broken template produces a plain string
// ("undefined undefined") when no user was attached at all (unauthenticated/
// customer flows) — that's the non-bilingual string case for `name` here.
export interface AuditUser {
id?: string;
name?: LocalizedText;
organizationId?: string;
organizationName?: string;
[key: string]: unknown;
}
export interface AuditLogRow {
id?: string;
createdAt: string;
deletedAt?: string | null;
entityName: string;
queryMethod: AuditQueryMethod;
changes?: AuditFieldChange[] | null;
payload?: { name?: LocalizedText; title?: LocalizedText; id?: string } | null;
auditLog?: { id?: string; user?: AuditUser | null };
}
export interface AuditLogListFilter {
skip?: number;
take?: number;
}
export interface PaginatedAuditLogs {
items: AuditLogRow[];
count: number;
}
export const auditService = {
list: async (filter?: AuditLogListFilter): Promise<PaginatedAuditLogs> => {
const params: Record<string, number | undefined> = {
skip: filter?.skip,
take: filter?.take,
};
const response = await client.get<PaginatedAuditLogs>(A.LOGS, { params });
const data = unwrap(response.data) as PaginatedAuditLogs;
return { items: data.items ?? [], count: data.count ?? 0 };
},
};

View File

@@ -0,0 +1,97 @@
import type { PaginatedResponse } from "@edr/types";
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
import type { ApiResponse } from "@/types/apiResponse";
const BASE = URL_CONSTANTS.AUDIT_LOGS.BASE;
/** Methods the audit trail records. Reads are never audited. */
export const AUDIT_METHODS = ["POST", "PUT", "PATCH", "DELETE"] as const;
export type AuditMethod = (typeof AUDIT_METHODS)[number];
/**
* One recorded backoffice action.
*
* Mirrors `AuditLog` in the freight API. `userName` / `userRole` are snapshots
* taken when the action happened, not live lookups — an old row keeps the name
* and role the actor had at the time.
*/
export interface AuditLog {
id: string;
/** Readable action, e.g. "Approve contract". */
title: string;
method: AuditMethod;
/** URL as called, query string included (secrets already redacted server-side). */
url: string;
/** Route template, e.g. `/api/contracts/:id/cancel`. */
routePath: string | null;
/** Entity the action touched, e.g. "Contract". */
type: string;
isSuccess: boolean;
statusCode: number | null;
errorMessage: string | null;
userId: string | null;
userName: string | null;
userRole: string | null;
resourceId: string | null;
/** Sanitized request body; files appear as `__file` descriptors. */
request: Record<string, unknown> | null;
ipAddress: string | null;
userAgent: string | null;
requestId: string | null;
durationMs: number | null;
createdAt: string;
}
export interface AuditLogQuery {
page?: number;
pageSize?: number;
type?: string;
userId?: string;
method?: AuditMethod;
resourceId?: string;
/** Omit for "any outcome". */
isSuccess?: boolean;
/** Inclusive ISO 8601 bounds. */
from?: string;
to?: string;
}
/**
* Drop empty filters so the request carries only what the user actually set —
* an empty string would otherwise be sent and fail the API's validation.
*/
function toParams(query: AuditLogQuery): Record<string, string | number> {
const params: Record<string, string | number> = {};
if (query.page) params.page = query.page;
if (query.pageSize) params.pageSize = query.pageSize;
if (query.type) params.type = query.type;
if (query.userId) params.userId = query.userId;
if (query.method) params.method = query.method;
if (query.resourceId) params.resourceId = query.resourceId;
if (query.isSuccess !== undefined) params.isSuccess = String(query.isSuccess);
if (query.from) params.from = query.from;
if (query.to) params.to = query.to;
return params;
}
export const auditLogsService = {
/** Paginated audit history, newest first. */
list: async (query: AuditLogQuery = {}): Promise<PaginatedResponse<AuditLog>> => {
const response = await client.get<ApiResponse<PaginatedResponse<AuditLog>>>(
`${BASE}/logs`,
{ params: toParams(query) },
);
return unwrap(response.data);
},
/** Distinct entity types present, for the filter dropdown. */
types: async (): Promise<string[]> => {
const response = await client.get<ApiResponse<string[]>>(`${BASE}/types`);
return unwrap(response.data);
},
};

View File

@@ -423,11 +423,11 @@ export const bookingsService = {
uploadDeliveryOrder: async (
id: string,
file: File,
files: File[],
dates: { vesselArrivalDate: string; doCollectedDate: string },
): Promise<BookingDetail> => {
const form = new FormData();
form.append("file", file);
files.forEach((file) => form.append("files", file));
form.append("vesselArrivalDate", dates.vesselArrivalDate);
form.append("doCollectedDate", dates.doCollectedDate);
const response = await client.post(B.CLEARANCE_DELIVERY_ORDER(id), form, {
@@ -438,11 +438,11 @@ export const bookingsService = {
uploadReleaseOrder: async (
id: string,
file: File,
files: File[],
vesselDepartureDate: string,
): Promise<{ hold?: boolean; holdReason?: string }> => {
const form = new FormData();
form.append("file", file);
files.forEach((file) => form.append("files", file));
form.append("vesselDepartureDate", vesselDepartureDate);
const response = await client.post(B.CLEARANCE_RELEASE_ORDER(id), form, {
headers: { "Content-Type": "multipart/form-data" },

View File

@@ -471,11 +471,11 @@ export const contractsService = {
uploadDeliveryOrder: async (
id: string,
file: File,
files: File[],
dates: { vesselArrivalDate: string; doCollectedDate: string },
): Promise<Freight.IContract> => {
const form = new FormData();
form.append("file", file);
files.forEach((file) => form.append("files", file));
form.append("vesselArrivalDate", dates.vesselArrivalDate);
form.append("doCollectedDate", dates.doCollectedDate);
const response = await client.post(C.CLEARANCE_DELIVERY_ORDER(id), form, {
@@ -486,11 +486,11 @@ export const contractsService = {
uploadReleaseOrder: async (
id: string,
file: File,
files: File[],
vesselDepartureDate: string,
): Promise<{ contract: Freight.IContract; hold: boolean; holdReason?: string }> => {
const form = new FormData();
form.append("file", file);
files.forEach((file) => form.append("files", file));
form.append("vesselDepartureDate", vesselDepartureDate);
const response = await client.post(C.CLEARANCE_RELEASE_ORDER(id), form, {
headers: { "Content-Type": "multipart/form-data" },

View File

@@ -309,8 +309,11 @@ export const trainBuilderService = {
removeWagon: (id: string, wagonId: string) =>
apiClient.delete<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}`),
/** Detach a wagon and move it to MAINTENANCE status. */
sendWagonToMaintenance: (id: string, wagonId: string) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}/maintenance`),
/** `note` is the maintenance reason — recorded with the train it came off. */
sendWagonToMaintenance: (id: string, wagonId: string, note?: string) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}/maintenance`, {
note,
}),
reorderWagons: (id: string, wagonIds: string[]) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/reorder-wagons`, { wagonIds }),
/** Park the train indefinitely — only allowed with no active schedule. */

View File

@@ -29,6 +29,7 @@ import type {
PinWagonsPayload,
RecordCheckpointPayload,
StaffBookingWindow,
ScheduleMergePreview,
TrainScheduleDetail,
UpdateScheduleWindowRulePayload,
TrainScheduleFilters,
@@ -294,6 +295,29 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
/** What a merge would do — drives the confirmation modal. Read-only. */
previewScheduleMerge: async (
scheduleId: string,
targetTrainId: string,
): Promise<ScheduleMergePreview> => {
const response = await client.get<ScheduleMergePreview>(
URL_CONSTANTS.TRAIN_SCHEDULING.MERGE_PREVIEW(scheduleId, targetTrainId),
);
return unwrap(response.data);
},
/** Merge another train into this schedule. This schedule always survives. */
mergeScheduleTrain: async (
scheduleId: string,
payload: { targetTrainId: string; reason?: string },
): Promise<TrainScheduleDetail> => {
const response = await client.post<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.MERGE_TRAIN(scheduleId),
payload,
);
return unwrap(response.data);
},
markBookingPaid: async (bookingId: string): Promise<void> => {
await client.post(
URL_CONSTANTS.TRAIN_SCHEDULING.MARK_BOOKING_PAID(bookingId),

View File

@@ -168,6 +168,10 @@ export interface WagonTransferRequest {
closedShortByUserId?: string | null;
/** Why the wagons are needed — required for new requests, shown on the queue. */
reason?: string | null;
/** The wagons the requester hand-picked, if any. A preference, not a reservation. */
preferredWagonIds?: string[] | null;
/** Those same picks resolved to wagon numbers by the API, for display. */
preferredWagons?: Array<{ id: string; wagonNumber: string }>;
note: string | null;
fromYard?: { id: string; label?: string; code?: string } | null;
toYard?: { id: string; label?: string; code?: string } | null;
@@ -182,6 +186,8 @@ export interface CreateTransferRequestPayload {
quantity: number;
/** Mandatory: why the wagons are needed. */
reason: string;
/** Specific wagons the requester wants — at most `quantity` of them. */
preferredWagonIds?: string[];
note?: string;
}

View File

@@ -143,6 +143,12 @@ export interface BookingTrainScheduleSummary {
actualArrivalAt: string | null;
windowPhase: string | null;
paymentPhaseEndsAt: string | null;
/**
* True when this is the train the customer requested at day-commit rather
* than a confirmed allocation — what staff see while reviewing an operation
* request, before accepting puts the booking into the batch pool.
*/
isRequested?: boolean;
}
export interface BookingDetail {

View File

@@ -217,7 +217,28 @@ export interface TrainScheduleListItem {
name?: string | null;
currentYardId?: string | null;
}>;
/** Coupled consist size. NOT the used count — see `wagonsUsed`. */
wagonCount: number;
/**
* Wagon slots actually carrying a booking allocation — the same figure the
* detail page's wagon plan shows. Optional until every API deploy carries it.
*/
wagonsUsed?: number;
/** Coupled consist size; the denominator of `wagonsUsed`. */
wagonsTotal?: number;
/** Wagons claimed by bookings (including unpaid) — not bookable. */
wagonsReserved?: number;
/** Wagons still bookable: consist minus what bookings have claimed. */
wagonsRemaining?: number;
/**
* Planned wagon ceiling for the departure, set when the schedule is created.
* Independent of the coupled consist — a schedule can plan 37 wagons before a
* single one is coupled, which is why this is the bookable figure until the
* train set is built.
*/
maxWagons?: number;
/** Plan ceiling minus the coupled consist — room left to couple. */
remainingWagons?: number;
totalWeightTons: number;
totalLengthMeters: number;
bookingsCount: number;
@@ -571,6 +592,35 @@ export interface UpdateScheduleWindowRulePayload {
exportBookingLeadHours?: number;
}
/** One schedule touched by a merge, as summarised for the confirmation modal. */
export interface MergeAffectedSchedule {
id: string;
reference: string | null;
scheduledDepartureDate: string;
status: string;
}
/**
* What merging a train into a schedule would do, computed server-side so the
* modal shows exactly what the commit will perform.
*/
export interface ScheduleMergePreview {
canMerge: boolean;
/** Why the merge is refused. Empty when `canMerge` is true. */
blockers: string[];
targetTrain: { id: string; code: string; trainNumber: string | null };
wagons: { current: number; incoming: number; merged: number };
/** The same-day schedule whose bookings move here; it is then removed. */
absorbedSchedule:
| (MergeAffectedSchedule & { bookingsMoving: number })
| null;
/** Other draft/scheduled schedules on the target — they gain wagons only. */
affectedSchedules: MergeAffectedSchedule[];
/** On the target train but left alone (dispatched, cancelled, …). */
untouchedSchedules: MergeAffectedSchedule[];
sourceTrainWillDeactivate: boolean;
}
export interface TrainScheduleDetail {
id: string;
reference?: string | null;
@@ -578,6 +628,8 @@ export interface TrainScheduleDetail {
deferredBookings?: DeferredBookingRow[];
freightType?: FreightType | null;
trainNumber?: string | null;
/** Voyage (sailing) number for this departure — editable until dispatch. */
voyageNumber?: string | null;
/** Wagon cap for this departure (built-train consist size or configured limit). */
maxWagons?: number | null;
/** Built train (Train Builder) behind this departure, when scheduled by train. */
@@ -621,6 +673,8 @@ export interface TrainScheduleDetail {
trainSet?: {
id: string;
status: string;
/** The built train this set runs on — null when it has none yet. */
trainId?: string | null;
wagonCount: number;
totalWeightTons: number;
totalLengthMeters: number;

View File

@@ -33,6 +33,8 @@ import { contractsService } from "@/services/contracts.service";
import { api } from "@/services/api";
import { extractApiError } from "@/utils/result";
import "./contract-sign-bar.css";
const CONSENT_TEXT = "I have read the entire contract and agree to its terms.";
/**
@@ -225,7 +227,7 @@ export default function ContractViewPage() {
return (
<Box
p={{ base: "md", md: "xl" }}
pb={data.canSignCustomer ? 120 : undefined}
pb={data.canSignCustomer ? { base: 220, sm: 160, md: 120 } : undefined}
>
<Box maw={920} mx="auto">
<Group justify="space-between" wrap="wrap" gap="sm" mb="md">
@@ -294,16 +296,19 @@ export default function ContractViewPage() {
style={{
position: "fixed",
bottom: 0,
left: 0,
left: "var(--sign-bar-left, 0px)",
right: 0,
zIndex: 100,
borderTop: "1px solid var(--mantine-color-gray-3)",
background: "var(--mantine-color-body)",
paddingBottom: 32,
paddingBottom: "max(env(safe-area-inset-bottom, 0px), 16px)",
maxHeight: "80vh",
overflowY: "auto",
}}
className="contract-sign-bar"
>
<Box maw={920} mx="auto">
<Stack gap="sm">
<Group justify="flex-start" align="flex-start" wrap="wrap" gap="sm">
<Checkbox
checked={agreedToTerms}
onChange={(e) => setAgreedToTerms(e.currentTarget.checked)}
@@ -315,17 +320,15 @@ export default function ContractViewPage() {
: "Read the full contract above before you can agree and sign."
}
/>
<Group justify="flex-end">
<Button
color="edr-green"
leftSection={<FileSignature size={16} />}
disabled={!canProceedToSign}
onClick={openSign}
>
{usingSaved ? "Approve & sign" : "Sign contract"}
</Button>
</Group>
</Stack>
<Button
color="edr-green"
leftSection={<FileSignature size={16} />}
disabled={!canProceedToSign}
onClick={openSign}
>
{usingSaved ? "Approve & sign" : "Sign contract"}
</Button>
</Group>
</Box>
</Paper>
)}

View File

@@ -87,6 +87,8 @@ export default function NewShipmentRequestPage() {
contract.contractKind === "GENERAL" &&
(contract.serviceType?.includesCustoms ?? contract.customsClearingEnabled);
const isIntercity = contract.tradeDirection === "DOMESTIC";
// Export shipments are invoiced in ETB only — USD is not offered.
const isExport = contract.tradeDirection === "EXPORT";
// Only the container sizes the contract was scoped for (20ft, 40ft, or both).
const SIZE_ORDER = ["20ft", "40ft"];
@@ -115,7 +117,7 @@ export default function NewShipmentRequestPage() {
const dto: Freight.CreateBookingRequestDto = {
contractRouteId: route?.id,
scheduledDate: hasCustoms ? undefined : scheduledDate || undefined,
paymentCurrency: isIntercity ? "ETB" : paymentCurrency,
paymentCurrency: isIntercity || isExport ? "ETB" : paymentCurrency,
notes: notes.trim() || undefined,
};
@@ -246,16 +248,22 @@ export default function NewShipmentRequestPage() {
<Text size="xs" c="dimmed" mb={8}>
{isIntercity
? "Intercity shipments are invoiced in ETB."
: "Your contract is quoted in USD. Global Logistics will book this shipment and invoice you in the currency you pick here."}
: isExport
? "Export shipments are invoiced in ETB."
: "Your contract is quoted in USD. Global Logistics will book this shipment and invoice you in the currency you pick here."}
</Text>
<SegmentedControl
value={isIntercity ? "ETB" : paymentCurrency}
value={isIntercity || isExport ? "ETB" : paymentCurrency}
onChange={(v) => setPaymentCurrency(v as "USD" | "ETB")}
disabled={isIntercity}
data={[
{ label: "USD", value: "USD" },
{ label: "ETB", value: "ETB" },
]}
disabled={isIntercity || isExport}
data={
isExport
? [{ label: "ETB", value: "ETB" }]
: [
{ label: "USD", value: "USD" },
{ label: "ETB", value: "ETB" },
]
}
color="teal"
radius={10}
/>

View File

@@ -0,0 +1,11 @@
/* Keeps the fixed sign bar confined to the content area (right of the
navbar) instead of spanning the full viewport and drifting off-center. */
.contract-sign-bar {
--sign-bar-left: 0px;
}
@media (min-width: 48em) {
.contract-sign-bar {
--sign-bar-left: 260px;
}
}