Merge branch 'dev' into dj-franc

This commit is contained in:
ghost2023
2026-09-04 16:31:21 +03:00
54 changed files with 5330 additions and 271 deletions

View File

@@ -0,0 +1,54 @@
import type { ReactNode } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
export interface DeletePublicationDialogProps {
title: string;
onConfirm?: () => void;
children: ReactNode;
}
export default function DeletePublicationDialog({
title,
onConfirm,
children,
}: DeletePublicationDialogProps) {
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-md rounded-3xl">
<DialogHeader>
<DialogTitle className="text-xl font-bold">Delete publication?</DialogTitle>
<DialogDescription>
This will remove{" "}
<span className="font-semibold text-slate-900">{title}</span> from the
public library. It stops being downloadable immediately.
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-2">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button onClick={onConfirm} className="bg-red-600 text-white hover:bg-red-700">
Delete
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,211 @@
import type { Publication } from "@edr/types";
import { useMutation } from "@tanstack/react-query";
import { Loader2, UploadCloud } from "lucide-react";
import { useRef, useState, type ReactNode } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/services/api";
export interface EditPublicationDialogProps {
mode?: "create" | "edit";
publication?: Publication;
children: ReactNode;
}
const ACCEPT =
".pdf,.md,.markdown,.ppt,.pptx,application/pdf,text/markdown,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation";
export default function EditPublicationDialog({
mode = "create",
publication,
children,
}: EditPublicationDialogProps) {
const isEdit = mode === "edit";
const fileInputRef = useRef<HTMLInputElement>(null);
const [open, setOpen] = useState(false);
const [title, setTitle] = useState(publication?.title ?? "");
const [description, setDescription] = useState(publication?.description ?? "");
const [category, setCategory] = useState(publication?.category ?? "");
const [file, setFile] = useState<File | null>(null);
const [progress, setProgress] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const createMutation = useMutation(api.publications.create.mutationOptions());
const updateMutation = useMutation(api.publications.update.mutationOptions());
const replaceFileMutation = useMutation(api.publications.replaceFile.mutationOptions());
const pending =
createMutation.isPending || updateMutation.isPending || replaceFileMutation.isPending;
const reset = () => {
setTitle(publication?.title ?? "");
setDescription(publication?.description ?? "");
setCategory(publication?.category ?? "");
setFile(null);
setProgress(null);
setError(null);
if (fileInputRef.current) fileInputRef.current.value = "";
};
const handleSubmit = async () => {
setError(null);
if (!title.trim()) {
setError("Title is required.");
return;
}
if (!isEdit && !file) {
setError("Choose a file to upload.");
return;
}
const meta = {
title: title.trim(),
description: description.trim() || undefined,
category: category.trim() || undefined,
};
try {
if (isEdit && publication) {
await updateMutation.mutateAsync({ id: publication.id, dto: meta });
if (file) {
await replaceFileMutation.mutateAsync({
id: publication.id,
file,
onProgress: setProgress,
});
}
} else if (file) {
await createMutation.mutateAsync({ file, meta, onProgress: setProgress });
}
setOpen(false);
if (!isEdit) reset();
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong. Try again.");
} finally {
setProgress(null);
}
};
return (
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) reset();
}}
>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg rounded-3xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">
{isEdit ? "Edit publication" : "New publication"}
</DialogTitle>
<DialogDescription>
{isEdit
? "Update this document's title, description or category, or replace its file."
: "Upload a PDF, Markdown or PowerPoint file for the public library."}
</DialogDescription>
</DialogHeader>
<div className="grid gap-5 py-4">
<div className="space-y-2">
<Label>Title *</Label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. EDR Freight Platform Guide"
/>
</div>
<div className="space-y-2">
<Label>Category</Label>
<Input
value={category}
onChange={(e) => setCategory(e.target.value)}
placeholder="e.g. Guides, Reports"
/>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What this document covers…"
/>
</div>
<div className="space-y-2">
<Label>{isEdit ? "Replace file (optional)" : "File *"}</Label>
<div
onClick={() => fileInputRef.current?.click()}
className="cursor-pointer rounded-xl border-2 border-dashed border-slate-300 px-4 py-6 text-center hover:bg-slate-50"
>
{progress !== null ? (
<p className="text-sm text-slate-500">Uploading {progress}%</p>
) : file ? (
<p className="text-sm font-medium text-slate-700">{file.name}</p>
) : isEdit && publication ? (
<p className="text-sm text-slate-500">
Currently <span className="font-medium">{publication.fileName}</span>
click to replace
</p>
) : (
<div className="flex flex-col items-center gap-1 text-slate-500">
<UploadCloud className="h-6 w-6" />
<span className="text-sm">Click to choose a PDF, Markdown or PowerPoint file</span>
</div>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept={ACCEPT}
hidden
onChange={(e) => setFile(e.currentTarget.files?.[0] ?? null)}
/>
</div>
</div>
{error ? (
<p className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p>
) : null}
<div className="mt-2 flex justify-end gap-3">
<DialogClose asChild>
<Button variant="outline" disabled={pending}>
Cancel
</Button>
</DialogClose>
<Button
type="button"
onClick={() => void handleSubmit()}
disabled={pending}
className="bg-[#10B981] text-white hover:bg-[#10B981]/90 disabled:opacity-60"
>
{pending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isEdit ? (
"Save changes"
) : (
"Upload"
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,173 @@
import type { Publication } from "@edr/types";
import { useQuery, useMutation } from "@tanstack/react-query";
import {
ActionIcon,
Badge,
Button,
Center,
Group,
Loader,
Paper,
Stack,
Switch,
Table,
Text,
Title,
Tooltip,
} from "@mantine/core";
import { FileText, Pencil, Plus, Trash2 } from "lucide-react";
import { PageContainer } from "@/components/page";
import { formatBytes, formatDate } from "@/lib/format";
import { api } from "@/services/api";
import DeletePublicationDialog from "./DeletePublicationDialog";
import EditPublicationDialog from "./EditPublicationDialog";
/** Short label from a mime type, for the file-type badge. */
function fileKindLabel(mime: string): string {
if (mime === "application/pdf") return "PDF";
if (mime.includes("markdown")) return "Markdown";
if (mime.includes("powerpoint") || mime.includes("presentationml")) return "PowerPoint";
return "File";
}
/**
* Backoffice admin for the freight portal's public /publications page —
* upload, edit, reorder-by-hand and unpublish PDFs, Markdown write-ups and
* PowerPoint decks about the platform.
*/
export default function PublicationsPage() {
const { data, isLoading, isError } = useQuery(api.publications.list.queryOptions());
const updateMutation = useMutation(api.publications.update.mutationOptions());
const removeMutation = useMutation(api.publications.remove.mutationOptions());
const publications = [...(data ?? [])].sort((a, b) => a.sortOrder - b.sortOrder);
return (
<PageContainer>
<Stack gap="lg">
<Group justify="space-between" align="flex-start" wrap="wrap">
<Stack gap={4}>
<Title order={2}>Publications</Title>
<Text size="sm" c="dimmed" maw={560}>
PDFs, Markdown write-ups and PowerPoint decks shown on the public
/publications page no login required to view them.
</Text>
</Stack>
<EditPublicationDialog>
<Button leftSection={<Plus size={16} />} color="edr-green">
New publication
</Button>
</EditPublicationDialog>
</Group>
<Paper withBorder radius="lg" p="lg">
{isLoading ? (
<Center py="xl">
<Loader color="edr-green" />
</Center>
) : isError ? (
<Text c="dimmed">Could not load publications.</Text>
) : publications.length === 0 ? (
<Stack align="center" gap="md" py="xl">
<FileText size={32} color="var(--mantine-color-gray-5)" />
<Text c="dimmed">No publications yet.</Text>
<EditPublicationDialog>
<Button variant="light" color="edr-green">
Upload the first one
</Button>
</EditPublicationDialog>
</Stack>
) : (
<Table striped highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Title</Table.Th>
<Table.Th>Category</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Size</Table.Th>
<Table.Th>Published</Table.Th>
<Table.Th>Updated</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{publications.map((pub: Publication) => (
<Table.Tr key={pub.id}>
<Table.Td>
<Text fw={600} size="sm">
{pub.title}
</Text>
{pub.description ? (
<Text size="xs" c="dimmed" lineClamp={1}>
{pub.description}
</Text>
) : null}
</Table.Td>
<Table.Td>
{pub.category ? (
<Badge variant="light" color="gray">
{pub.category}
</Badge>
) : (
<Text size="sm" c="dimmed">
</Text>
)}
</Table.Td>
<Table.Td>
<Badge variant="light" color="edr-green">
{fileKindLabel(pub.fileMimeType)}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm">{formatBytes(pub.fileSizeBytes)}</Text>
</Table.Td>
<Table.Td>
<Tooltip label={pub.published ? "Visible on the public page" : "Hidden from the public page"}>
<Switch
checked={pub.published}
color="edr-green"
onChange={(e) =>
updateMutation.mutate({
id: pub.id,
dto: { published: e.currentTarget.checked },
})
}
/>
</Tooltip>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{formatDate(pub.updatedAt)}
</Text>
</Table.Td>
<Table.Td>
<Group gap={4} justify="flex-end" wrap="nowrap">
<EditPublicationDialog mode="edit" publication={pub}>
<ActionIcon variant="subtle" color="gray" aria-label="Edit">
<Pencil size={16} />
</ActionIcon>
</EditPublicationDialog>
<DeletePublicationDialog
title={pub.title}
onConfirm={() => removeMutation.mutate({ id: pub.id })}
>
<ActionIcon variant="subtle" color="red" aria-label="Delete">
<Trash2 size={16} />
</ActionIcon>
</DeletePublicationDialog>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Paper>
</Stack>
</PageContainer>
);
}

View File

@@ -1,13 +1,10 @@
import { useState } from "react";
import {
Badge,
Button,
Card,
Collapse,
Group,
Stack,
Text,
Textarea,
Tooltip,
} from "@mantine/core";
import type { UseMutationResult } from "@tanstack/react-query";
@@ -27,8 +24,24 @@ const FIELD_LABELS: Record<string, string> = {
cargoTypeId: "Cargo type",
originYardId: "Origin yard",
destinationYardId: "Destination yard",
minKm: "From km",
maxKm: "To km",
baseLiters: "Base liters",
rateType: "Rate type",
};
/**
* A key the backend diffed but the UI has no label for still names a real
* change, so turn "baseLiters" into "Base liters" rather than hiding it.
*/
const labelFor = (field: string): string =>
FIELD_LABELS[field] ??
field
.replace(/([A-Z])/g, " $1")
.replace(/^./, (c) => c.toUpperCase())
.replace(/\bId\b/, "")
.trim();
const fmtDateTime = (iso: string) =>
new Date(iso).toLocaleString("en-GB", {
day: "numeric",
@@ -43,13 +56,16 @@ const fmtValue = (
value: unknown,
labels?: Record<string, string>,
): string => {
if (value === null || value === undefined || value === "") return "—";
// "Not set" reads as a real before-state; a bare em dash on both sides of the
// arrow made a newly-set field look like no change at all.
if (value === null || value === undefined || value === "") return "Not set";
if (field === "rateValue") {
const num = Number(value);
return Number.isNaN(num) ? String(value) : num.toLocaleString();
}
// Yard ids are unreadable — an approver decides on the route, not a UUID.
if (field === "originYardId" || field === "destinationYardId") {
// Any id is unreadable — an approver decides on "Perishable → Truck", not on
// a pair of uuids. Covers yards, cargo types, container types and lines.
if (field.endsWith("Id")) {
return labels?.[String(value)] ?? String(value);
}
return String(value).replace(/_/g, " ");
@@ -66,13 +82,33 @@ const rateSummary = (r: RateChangeRequest): string => {
return parts.join(" · ") || "Rate";
};
/** The headline change, so the queue is scannable without expanding: "100 → 200 USD". */
const headline = (r: RateChangeRequest): string | null => {
if (!("rateValue" in r.payload)) return null;
const currency = String(r.payload.currency ?? r.previousValues.currency ?? (r.rate as Record<string, unknown> | undefined)?.currency ?? "");
const before = fmtValue("rateValue", r.previousValues.rateValue);
const after = fmtValue("rateValue", r.payload.rateValue);
return `${before}${after}${currency ? ` ${currency}` : ""}`;
/**
* Every change in the request, as readable before→after pairs. The queue must
* be scannable without expanding: a cargo or direction change is just as much
* the point as a repricing, so it gets the same one-line treatment as the rate.
*/
const summaryRows = (
r: RateChangeRequest,
labels?: Record<string, string>,
): Array<{ field: string; label: string; before: string; after: string; suffix: string }> => {
const currency = String(
r.payload.currency ??
r.previousValues.currency ??
(r.rate as Record<string, unknown> | undefined)?.currency ??
"",
);
// Rate first — it is what most changes are about — then the rest in a stable
// order so the same edit always reads the same way.
const fields = Object.keys(r.payload).sort((a, b) =>
a === "rateValue" ? -1 : b === "rateValue" ? 1 : a.localeCompare(b),
);
return fields.map((field) => ({
field,
label: labelFor(field),
before: fmtValue(field, r.previousValues[field], labels),
after: fmtValue(field, r.payload[field], labels),
suffix: field === "rateValue" && currency ? ` ${currency}` : "",
}));
};
type Decide = UseMutationResult<
@@ -87,8 +123,9 @@ interface RateApprovalsSectionProps {
canDecide: boolean;
approve: Decide;
reject: Decide;
/** yardId → label, so a re-routed rate reads as yards, not UUIDs. */
yardLabels?: Record<string, string>;
/** id → label for every reference a diff can name (yards, cargo/container
* types, shipping lines), so a change reads as names, not UUIDs. */
refLabels?: Record<string, string>;
}
/**
@@ -101,11 +138,8 @@ const RateApprovalsSection = ({
canDecide,
approve,
reject,
yardLabels,
refLabels,
}: RateApprovalsSectionProps) => {
const [openId, setOpenId] = useState<string | null>(null);
const [notes, setNotes] = useState<Record<string, string>>({});
if (requests.length === 0) return null;
const decidingId = approve.variables?.id ?? reject.variables?.id ?? null;
@@ -125,9 +159,8 @@ const RateApprovalsSection = ({
<Stack gap={8}>
{requests.map((r) => {
const isOpen = openId === r.id;
const fields = Object.keys(r.payload);
const summaryLine = headline(r);
const rows = summaryRows(r, refLabels);
// Only the row being decided shows a spinner — the mutation's
// isPending is shared across every row.
const busy = decidingId === r.id;
@@ -145,39 +178,26 @@ const RateApprovalsSection = ({
</Text>
</Group>
{summaryLine ? (
<Group gap={6} wrap="nowrap">
{rows.map((row) => (
<Group key={row.field} gap={6} wrap="wrap" align="center">
<Text size="xs" c="dimmed">
{row.label}
</Text>
<Text size="sm" c="dimmed" td="line-through">
{fmtValue("rateValue", r.previousValues.rateValue)}
{row.before}
</Text>
<ArrowRight size={13} />
<ArrowRight size={13} style={{ flexShrink: 0 }} />
<Text size="sm" fw={700} c="edr-green">
{fmtValue("rateValue", r.payload.rateValue)}
</Text>
<Text size="sm" c="dimmed">
{String(
r.payload.currency ??
r.previousValues.currency ??
(r.rate as Record<string, unknown> | undefined)?.currency ??
"",
)}
{row.after}
{row.suffix}
</Text>
</Group>
) : null}
))}
<Group gap={6}>
<Text size="xs" c="dimmed">
Submitted {fmtDateTime(r.createdAt)} · {fields.length}{" "}
{fields.length === 1 ? "field" : "fields"} changed
</Text>
<Button
size="compact-xs"
variant="subtle"
onClick={() => setOpenId(isOpen ? null : r.id)}
>
{isOpen ? "Hide details" : "See all changes"}
</Button>
</Group>
<Text size="xs" c="dimmed">
Submitted {fmtDateTime(r.createdAt)} · {fields.length}{" "}
{fields.length === 1 ? "field" : "fields"} changed
</Text>
</Stack>
{canDecide ? (
@@ -190,7 +210,7 @@ const RateApprovalsSection = ({
loading={busy && reject.isPending}
disabled={busy && approve.isPending}
onClick={() =>
reject.mutate({ id: r.id, decisionNote: notes[r.id] || undefined })
reject.mutate({ id: r.id })
}
>
Reject
@@ -202,7 +222,7 @@ const RateApprovalsSection = ({
loading={busy && approve.isPending}
disabled={busy && reject.isPending}
onClick={() =>
approve.mutate({ id: r.id, decisionNote: notes[r.id] || undefined })
approve.mutate({ id: r.id })
}
>
Approve &amp; apply
@@ -217,38 +237,6 @@ const RateApprovalsSection = ({
)}
</Group>
<Collapse in={isOpen}>
<Stack gap={6} mt="sm" pt="sm" style={{ borderTop: "1px solid var(--mantine-color-default-border)" }}>
{fields.map((field) => (
<Group key={field} gap={8} wrap="nowrap">
<Text size="xs" c="dimmed" w={110} style={{ flexShrink: 0 }}>
{FIELD_LABELS[field] ?? field}
</Text>
<Text size="sm" c="dimmed" td="line-through">
{fmtValue(field, r.previousValues[field], yardLabels)}
</Text>
<ArrowRight size={13} />
<Text size="sm" fw={600}>
{fmtValue(field, r.payload[field], yardLabels)}
</Text>
</Group>
))}
{canDecide ? (
<Textarea
mt={4}
size="xs"
autosize
minRows={2}
label="Decision note (optional)"
placeholder="Shown to the requester with your decision"
value={notes[r.id] ?? ""}
onChange={(e) =>
setNotes((prev) => ({ ...prev, [r.id]: e.currentTarget.value }))
}
/>
) : null}
</Stack>
</Collapse>
</Card>
);
})}

View File

@@ -322,9 +322,22 @@ const RuleEngineResourcePage = () => {
);
const { data: yardOptions, isLoading: yardOptionsLoading } =
useYardOptions(usesYardField);
const yardLabelById = useMemo(
() => Object.fromEntries((yardOptions ?? []).map((y) => [y.value, y.label])),
[yardOptions],
/**
* Every id a rate diff can name, in one map. A pending change that swaps the
* cargo type or the container size stores raw uuids, so without this the
* approver reads "a1b2… → c3d4…" instead of "Perishable → Truck".
*/
const rateRefLabelById = useMemo(
() =>
Object.fromEntries(
[
...(yardOptions ?? []),
...(cargoLeafOptions ?? []),
...(containerTypeOptions ?? []),
...(shippingLineOptions ?? []),
].map((o) => [o.value, o.label]),
),
[yardOptions, cargoLeafOptions, containerTypeOptions, shippingLineOptions],
);
const usesApprovalRoleField = Boolean(
config?.formFields.some(
@@ -868,7 +881,7 @@ const RuleEngineResourcePage = () => {
canDecide={canApproveRates}
approve={rateChangeWorkflow.approve}
reject={rateChangeWorkflow.reject}
yardLabels={yardLabelById}
refLabels={rateRefLabelById}
/>
) : null}

View File

@@ -29,6 +29,7 @@ import {
Merge,
Container as ContainerIcon,
Eye,
FileSpreadsheet,
FileText,
History as HistoryIcon,
LayoutGrid,
@@ -102,6 +103,7 @@ import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWind
import { api } from "@/services/api";
import { trainSchedulingService } from "@/services/trainScheduling.service";
import { useToast } from "@/hooks/use-toast";
import { extractDownloadErrorMessage } from "@/components/warehouses/options";
import type {
ContainerPlacement,
EligibleContainerBooking,
@@ -125,6 +127,7 @@ export default function TrainScheduleV2DetailPage() {
const { user: authUser } = useAuth();
const { scheduleId } = useParams<{ scheduleId: string }>();
const { toast } = useToast();
const [exportingWagons, setExportingWagons] = useState(false);
const [activeStep, setActiveStep] = useState(0);
const [selectedBookingIds, setSelectedBookingIds] = useState<string[]>([]);
const [forceAssign, setForceAssign] = useState(false);
@@ -314,6 +317,31 @@ export default function TrainScheduleV2DetailPage() {
);
const marshallingStops = marshallingStopsQuery.data ?? [];
/** Wagon list (one row per container) as an .xlsx download. */
const handleExportWagons = useCallback(async () => {
if (!scheduleId) return;
setExportingWagons(true);
try {
const blob =
await trainSchedulingService.downloadScheduleWagonsWorkbook(scheduleId);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `wagon-list-${schedule?.reference ?? scheduleId}.xlsx`;
a.click();
URL.revokeObjectURL(url);
} catch (error) {
// Blob response: the JSON reason rides inside the Blob, so the sync
// decoder would surface only "Request failed with status code 400".
toast({
title: await extractDownloadErrorMessage(error),
variant: "destructive",
});
} finally {
setExportingWagons(false);
}
}, [scheduleId, schedule?.reference, toast]);
useEffect(() => {
const operation = gatepassQuery.data;
if (!operation) return;
@@ -1323,6 +1351,18 @@ export default function TrainScheduleV2DetailPage() {
Load Empty Container
</Button>
) : null}
{(schedule.trainSet?.wagons?.length ?? 0) > 0 ? (
<Button
variant="light"
color="edr-green"
size="compact-sm"
leftSection={<FileSpreadsheet size={14} />}
loading={exportingWagons}
onClick={() => void handleExportWagons()}
>
Export wagons
</Button>
) : null}
{(schedule.trainSet?.wagons?.length ?? 0) > 0 ? (
<Button
variant="gradient"

View File

@@ -0,0 +1,50 @@
import { ActionIcon, Tooltip } from "@mantine/core";
import { Download } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
export interface SectionExportButtonProps {
/** What this button downloads, e.g. "wagon list" — used in the tooltip and toast. */
label: string;
/** Runs the download; false means there was nothing to write. */
onExport: () => boolean;
disabled?: boolean;
}
/**
* Excel download for one section of the wagon performance report. Sits in the
* section's own header, so what it exports is unambiguous — the block it is
* attached to, exactly as filtered on screen.
*/
export function SectionExportButton({
label,
onExport,
disabled,
}: SectionExportButtonProps) {
const { toast } = useToast();
return (
<Tooltip label={`Download ${label} as Excel`}>
<ActionIcon
variant="subtle"
color="gray"
size="md"
aria-label={`Download ${label} as Excel`}
disabled={disabled}
onClick={(e) => {
// The row underneath may navigate; a download must not trigger it.
e.stopPropagation();
const wrote = onExport();
if (!wrote) {
toast({
title: "Nothing to export",
description: `There are no ${label} rows to download yet.`,
});
}
}}
>
<Download size={16} />
</ActionIcon>
</Tooltip>
);
}

View File

@@ -0,0 +1,80 @@
import * as XLSX from "xlsx";
/**
* Excel download for one section of the wagon performance report.
*
* Each section on the page exports exactly what is on screen — the same rows,
* in the same order, honouring the same filters and date window — so a figure
* in the spreadsheet always reconciles with the figure the CEO just read.
*
* Built client-side from data already in the browser: the report holds the
* whole fleet in memory (see WagonPerformancePage), so there is nothing to
* re-fetch and no server round-trip.
*/
/** A sheet's worth of rows: ordered column headers plus plain-value records. */
export interface SheetSpec {
/** Sheet tab name. Excel caps these at 31 chars and forbids : \ / ? * [ ]. */
name: string;
rows: Array<Record<string, string | number | null>>;
}
/** Excel rejects these in a sheet name, and silently truncates past 31 chars. */
const safeSheetName = (name: string): string =>
name.replace(/[:\\/?*[\]]/g, "-").slice(0, 31) || "Sheet1";
/** Widen each column to its longest cell, so nothing opens as ####. */
function fitColumns(
rows: Array<Record<string, unknown>>,
): Array<{ wch: number }> {
const headers = Object.keys(rows[0] ?? {});
return headers.map((h) => {
const longest = rows.reduce((max, row) => {
const cell = row[h];
const len = cell == null ? 0 : String(cell).length;
return len > max ? len : max;
}, h.length);
// Cap the width so one long note cannot push a column off the screen.
return { wch: Math.min(Math.max(longest + 2, 10), 60) };
});
}
/** Timestamp suffix so repeated downloads don't overwrite each other. */
const stamp = (): string => {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
};
/**
* Download one or more sheets as a single .xlsx.
*
* `filenameBase` gets the timestamp and extension appended. Sheets with no
* rows are skipped; if that leaves nothing, the download is skipped entirely
* and the function returns false so the caller can say so.
*/
export function downloadSheets(
filenameBase: string,
sheets: SheetSpec[],
): boolean {
const populated = sheets.filter((s) => s.rows.length > 0);
if (!populated.length) return false;
const workbook = XLSX.utils.book_new();
for (const spec of populated) {
const sheet = XLSX.utils.json_to_sheet(spec.rows);
sheet["!cols"] = fitColumns(spec.rows);
XLSX.utils.book_append_sheet(workbook, sheet, safeSheetName(spec.name));
}
XLSX.writeFile(workbook, `${filenameBase}-${stamp()}.xlsx`);
return true;
}
/** Single-sheet convenience wrapper — the shape most sections need. */
export function downloadSheet(
filenameBase: string,
sheetName: string,
rows: Array<Record<string, string | number | null>>,
): boolean {
return downloadSheets(filenameBase, [{ name: sheetName, rows }]);
}

View File

@@ -0,0 +1,237 @@
/**
* Derived wagon performance figures for the CEO's wagon report.
*
* Nothing here is stored: every number is computed in the browser from the
* ledgers the API already returns — `wagon_movements` (relocations),
* `wagon_status_logs` (roster flips) and `wagon_events` (unified history).
* Keeping the derivation in one place means the report and the wagon record
* can never disagree about what "idle" or "utilisation" means.
*
* This report is READ-ONLY and lives beside the Overview dashboard. It does
* not replace the Fleet Management wagons desk, which owns wagon CRUD.
*/
import { Freight } from "@edr/types";
import type {
Wagon,
WagonMovementRecord,
WagonStatusLog,
} from "@/services/wagon.service";
const DAY_MS = 24 * 60 * 60 * 1000;
/** Days past which a parked wagon is treated as stranded. */
export const IDLE_THRESHOLD_DAYS = 21;
/** Days off the roster past which a repair is treated as overdue. */
export const DOWN_THRESHOLD_DAYS = 30;
/** Statuses that take a wagon off the earning roster. */
export const OFF_ROSTER_STATUSES: Freight.WagonStatus[] = [
Freight.WagonStatus.Maintenance,
Freight.WagonStatus.Detained,
Freight.WagonStatus.OutOfService,
];
export const isOffRoster = (status: Freight.WagonStatus): boolean =>
OFF_ROSTER_STATUSES.includes(status);
/** Whole days between `iso` and now; null when the timestamp is missing. */
export function daysSince(iso: string | null | undefined): number | null {
if (!iso) return null;
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return null;
return Math.max(0, Math.floor((Date.now() - t) / DAY_MS));
}
/** Fractional days between two timestamps; `to` null means "still open". */
export function daysBetween(
from: string | null | undefined,
to: string | null | undefined,
): number | null {
if (!from) return null;
const a = new Date(from).getTime();
if (Number.isNaN(a)) return null;
const b = to ? new Date(to).getTime() : Date.now();
if (Number.isNaN(b)) return null;
return Math.max(0, (b - a) / DAY_MS);
}
export interface WagonPerformance {
/** Days since the wagon last arrived anywhere — the idle clock. */
idleDays: number | null;
/** Days in the current off-roster spell; null while in service. */
downDays: number | null;
loads: number;
moves: number;
emptyMoves: number;
manualMoves: number;
/** Share of moves that carried cargo, 0100; null when nothing moved. */
loadedShare: number | null;
lastMovement: WagonMovementRecord | null;
/** Off-roster spells overlapping the window. */
spells: number;
/** Days off roster inside the window. */
downDaysInWindow: number;
/** Share of the window spent on the roster, 0100. */
availability: number;
}
/**
* Roll one wagon's ledgers up into the figures the report shows.
*
* `windowDays` bounds loads, moves and downtime. Idle days and the current
* down spell are "how long has this been true right now" — never windowed.
*/
export function computeWagonPerformance(
wagon: Pick<Wagon, "status" | "lastMaintenanceAt" | "lastAvailableAt">,
movements: WagonMovementRecord[],
statusLogs: WagonStatusLog[],
windowDays: number,
): WagonPerformance {
const since = Date.now() - windowDays * DAY_MS;
// Movements arrive newest-first from the API; don't rely on it.
const ordered = [...movements].sort(
(a, b) =>
new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(),
);
const lastMovement = ordered[0] ?? null;
const inWindow = ordered.filter((m) => {
const t = new Date(m.occurredAt).getTime();
return !Number.isNaN(t) && t >= since;
});
const loads = inWindow.filter(
(m) => m.kind === Freight.WagonMovementKind.Loaded,
).length;
const emptyMoves = inWindow.filter(
(m) => m.kind === Freight.WagonMovementKind.EmptyReposition,
).length;
const manualMoves = inWindow.filter(
(m) => m.kind === Freight.WagonMovementKind.Manual,
).length;
const moves = inWindow.length;
const idleDays = daysSince(lastMovement?.occurredAt ?? null);
// Newest first, so a flip's "until" is the log entry before it in the array.
const logs = [...statusLogs].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
let downDays: number | null = null;
if (isOffRoster(wagon.status)) {
const entered = logs.find((l) => l.toStatus === wagon.status);
downDays = daysSince(entered?.createdAt ?? wagon.lastMaintenanceAt ?? null);
}
// Downtime inside the window: walk each off-roster entry to the flip that
// ended it, clamping both ends to the window.
let downDaysInWindow = 0;
let spells = 0;
logs.forEach((log, i) => {
if (!isOffRoster(log.toStatus)) return;
const start = new Date(log.createdAt).getTime();
if (Number.isNaN(start)) return;
const closed = logs[i - 1];
const end = closed ? new Date(closed.createdAt).getTime() : Date.now();
const from = Math.max(start, since);
const to = Math.min(end, Date.now());
if (to <= from) return;
downDaysInWindow += (to - from) / DAY_MS;
spells += 1;
});
const availability =
windowDays > 0
? Math.max(
0,
Math.min(
100,
Math.round(((windowDays - downDaysInWindow) / windowDays) * 100),
),
)
: 100;
return {
idleDays,
downDays,
loads,
moves,
emptyMoves,
manualMoves,
loadedShare: moves > 0 ? Math.round((loads / moves) * 100) : null,
lastMovement,
spells,
downDaysInWindow: Math.round(downDaysInWindow),
availability,
};
}
/** Mantine colour per wagon status. */
export function statusColor(status: Freight.WagonStatus): string {
switch (status) {
case Freight.WagonStatus.Available:
return "edr-green";
case Freight.WagonStatus.Assigned:
case Freight.WagonStatus.ImportReady:
return "blue";
case Freight.WagonStatus.ExportReady:
return "teal";
case Freight.WagonStatus.Maintenance:
return "yellow";
case Freight.WagonStatus.Detained:
return "red";
case Freight.WagonStatus.OutOfService:
default:
return "gray";
}
}
/** Mantine colour per movement kind. */
export function movementKindColor(kind: Freight.WagonMovementKind): string {
switch (kind) {
case Freight.WagonMovementKind.Loaded:
return "edr-green";
case Freight.WagonMovementKind.EmptyReposition:
return "teal";
case Freight.WagonMovementKind.Maintenance:
return "yellow";
case Freight.WagonMovementKind.Manual:
default:
return "gray";
}
}
/** Mantine colour per history-event category. */
export function eventCategoryColor(
category: Freight.WagonEventCategory,
): string {
switch (category) {
case Freight.WagonEventCategory.Yard:
return "yellow";
case Freight.WagonEventCategory.Train:
return "blue";
case Freight.WagonEventCategory.Schedule:
return "indigo";
case Freight.WagonEventCategory.Cargo:
return "edr-green";
case Freight.WagonEventCategory.Status:
return "orange";
case Freight.WagonEventCategory.Lifecycle:
default:
return "gray";
}
}
/** Idle banding shared by the table and the distribution chart. */
export function idleBand(
idleDays: number | null,
): "ok" | "watch" | "stranded" | "unknown" {
if (idleDays == null) return "unknown";
if (idleDays > IDLE_THRESHOLD_DAYS) return "stranded";
if (idleDays > Math.round(IDLE_THRESHOLD_DAYS / 2)) return "watch";
return "ok";
}