mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' into freight/nati-2
This commit is contained in:
@@ -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={
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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",
|
||||
|
||||
340
apps/edr-freight-web/backoffice/src/pages/AuditLogsPage.tsx
Normal file
340
apps/edr-freight-web/backoffice/src/pages/AuditLogsPage.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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" },
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user