fix issue

This commit is contained in:
Marshal
2026-08-20 18:10:29 +00:00
183 changed files with 14241 additions and 1265 deletions

View File

@@ -45,7 +45,7 @@ import MyProfilePage from "./pages/dashboard/MyProfilePage";
import OverviewPage from "./pages/dashboard/OverviewPage";
import OverviewDomainPage from "./pages/dashboard/OverviewDomainPage";
import { OVERVIEW_DOMAINS } from "./components/overview/overview-domains.config";
import ReportsIndexRedirect from "./pages/reports/ReportsIndexRedirect";
import ReportsLandingPage from "./pages/reports/ReportsLandingPage";
import ReportPage from "./pages/reports/ReportPage";
import AuditLogsPage from "./pages/AuditLogsPage";
import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage";
@@ -86,6 +86,7 @@ import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2De
import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage";
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
import TradeAccessPage from "./pages/configuration/TradeAccessPage";
import OperationsStandardsPage from "./pages/settings/OperationsStandardsPage";
import ExchangeRateSettingsCard from "./pages/settings/ExchangeRateSettingsCard";
import ManualPaymentSettingsCard from "./pages/settings/ManualPaymentSettingsCard";
import FirstMilePage from "./pages/operations/FirstMilePage";
@@ -248,7 +249,7 @@ const App = () => {
path="reports"
element={
<RequirePermission permission={FREIGHT_PERMS.reports.view}>
<ReportsIndexRedirect />
<ReportsLandingPage />
</RequirePermission>
}
/>
@@ -1202,6 +1203,16 @@ const App = () => {
</RequirePermission>
}
/> */}
<Route
path="configuration/operations-standards"
element={
<RequirePermission
permission={FREIGHT_PERMS.settings.operationsStandards.view}
>
<OperationsStandardsPage />
</RequirePermission>
}
/>
<Route path="configuration/cargo-types" element={<CargoTypesPage />} />
<Route
path="configuration/cargo-types/:id"

View File

@@ -0,0 +1,397 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Badge,
Box,
Button,
FileButton,
Group,
Loader,
Modal,
NumberInput,
Paper,
Select,
Stack,
Text,
Textarea,
Tooltip,
} from "@mantine/core";
import {
Ban,
Download,
Eye,
FileText,
Plus,
Receipt,
Send,
Upload,
} from "lucide-react";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common";
import { bookingsService } from "@/services/bookings.service";
import { downloadBookingFile, fetchViewableFile } from "@/services/files.service";
import { formatDateTime } from "@/lib/format";
import { extractErrorMessage } from "@/utils/errorExtractor";
const CURRENCIES = ["ETB", "USD"];
const STATUS_META: Record<Freight.AdditionalChargeStatus, { label: string; color: string }> = {
DRAFT: { label: "Draft", color: "gray" },
SENT: { label: "Sent — unpaid", color: "orange" },
PAID: { label: "Paid", color: "edr-green" },
CANCELLED: { label: "Cancelled", color: "red" },
};
export interface AdditionalPaymentsTabProps {
bookingId: string;
onViewFile: (file: { name: string; url: string }) => void;
}
/**
* Ad-hoc extra charges finance raises against a booking — any number, free-text
* reason. Draft until sent; sending issues the payable invoice and notifies the
* customer (in-app + SMS + email). Settles the same way every invoice does.
*/
export function AdditionalPaymentsTab({ bookingId, onViewFile }: AdditionalPaymentsTabProps) {
const qc = useQueryClient();
const [modalOpen, setModalOpen] = useState(false);
const { data: charges, isLoading } = useQuery({
queryKey: ["additional-charges", bookingId],
queryFn: () => bookingsService.getAdditionalCharges(bookingId),
});
const refresh = (next: Freight.AdditionalCharge[]) =>
qc.setQueryData(["additional-charges", bookingId], next);
const onError = (e: unknown) =>
toast.error(extractErrorMessage(e, "Could not update the charge"));
const create = useMutation({
mutationFn: (p: {
reason: string;
amount: number;
currency: string;
action: "draft" | "send";
file?: File | null;
}) => bookingsService.createAdditionalCharge(bookingId, p),
onSuccess: (next, p) => {
toast.success(p.action === "send" ? "Charge sent to the customer" : "Draft saved");
refresh(next);
setModalOpen(false);
},
onError,
});
const send = useMutation({
mutationFn: (chargeId: string) => bookingsService.sendAdditionalCharge(bookingId, chargeId),
onSuccess: (next) => {
toast.success("Charge sent to the customer");
refresh(next);
},
onError,
});
const cancel = useMutation({
mutationFn: (chargeId: string) => bookingsService.cancelAdditionalCharge(bookingId, chargeId),
onSuccess: (next) => {
toast.success("Charge cancelled");
refresh(next);
},
onError,
});
if (isLoading) {
return (
<Group justify="center" py="xl" gap={10}>
<Loader size="sm" color="edr-green" />
<Text c="dimmed">Loading additional charges</Text>
</Group>
);
}
const rows = charges ?? [];
const busy = send.isPending || cancel.isPending;
return (
<Stack gap="md" maw={860}>
<Group justify="space-between">
<Text fz="13px" fw={700} c="edr-text">
Additional charges
</Text>
<Button
size="compact-sm"
color="edr-green"
radius="md"
leftSection={<Plus size={14} />}
onClick={() => setModalOpen(true)}
>
Add charge
</Button>
</Group>
{rows.length === 0 && (
<Text fz="12.5px" c="dimmed">
No additional charges raised on this booking yet.
</Text>
)}
{rows.map((charge) => (
<ChargeCard
key={charge.id}
charge={charge}
busy={busy}
onViewFile={onViewFile}
onSend={() => send.mutate(charge.id)}
onCancel={() => cancel.mutate(charge.id)}
/>
))}
<AddChargeModal
opened={modalOpen}
onClose={() => setModalOpen(false)}
busy={create.isPending}
onSubmit={(p) => create.mutate(p)}
/>
</Stack>
);
}
function ChargeCard({
charge,
busy,
onViewFile,
onSend,
onCancel,
}: {
charge: Freight.AdditionalCharge;
busy: boolean;
onViewFile: (file: { name: string; url: string }) => void;
onSend: () => void;
onCancel: () => void;
}) {
const meta = STATUS_META[charge.status];
return (
<Paper withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Group gap={10} wrap="nowrap" align="flex-start">
<Receipt size={18} color="var(--mantine-color-edr-green-6)" />
<Box>
<Text fz="14px" fw={700} c="edr-text">
{charge.reason}
</Text>
<Text fz="11.5px" c="dimmed">
Raised{charge.createdByName ? ` by ${charge.createdByName}` : ""} ·{" "}
{formatDateTime(charge.createdAt)}
</Text>
{charge.sentAt && (
<Text fz="11.5px" c="dimmed">
Sent{charge.sentByName ? ` by ${charge.sentByName}` : ""} ·{" "}
{formatDateTime(charge.sentAt)}
{charge.paymentReference ? ` · ref ${charge.paymentReference}` : ""}
</Text>
)}
{charge.paidAt && (
<Text fz="11.5px" c="edr-green.8" fw={600}>
Paid · {formatDateTime(charge.paidAt)}
{charge.invoiceNumber ? ` (invoice ${charge.invoiceNumber})` : ""}
</Text>
)}
{charge.cancelledAt && (
<Text fz="11.5px" c="red.7">
Cancelled · {formatDateTime(charge.cancelledAt)}
{charge.cancelReason ? `${charge.cancelReason}` : ""}
</Text>
)}
</Box>
</Group>
<Group gap={8} wrap="nowrap">
<Text fz="14px" fw={800} c="edr-text">
{charge.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "}
{charge.currency}
</Text>
<Badge variant="light" color={meta.color} radius="sm">
{meta.label}
</Badge>
</Group>
</Group>
{charge.file && (
<Group gap={8} mt="sm" wrap="nowrap">
<FileText size={15} color="var(--mantine-color-edr-green-6)" />
<Text fz="12.5px" c="edr-text" truncate style={{ minWidth: 0 }}>
{charge.file.name}
</Text>
{isViewable({ name: charge.file.name, url: "" }) && (
<Tooltip label="View">
<Box
component="button"
type="button"
onClick={() =>
void fetchViewableFile(charge.file!.id, charge.file!.name).then(onViewFile)
}
c="edr-green"
style={{ display: "flex", background: "transparent", border: "none", cursor: "pointer" }}
>
<Eye size={15} />
</Box>
</Tooltip>
)}
<Tooltip label="Download">
<Box
component="button"
type="button"
onClick={() => void downloadBookingFile(charge.file!.id, charge.file!.name)}
c="edr-green"
style={{ display: "flex", background: "transparent", border: "none", cursor: "pointer" }}
>
<Download size={15} />
</Box>
</Tooltip>
</Group>
)}
{(charge.status === "DRAFT" || charge.status === "SENT") && (
<Group mt="sm" gap={8} justify="flex-end">
<Button
size="compact-sm"
variant="light"
color="red"
radius="md"
leftSection={<Ban size={14} />}
disabled={busy}
onClick={onCancel}
>
Cancel
</Button>
{charge.status === "DRAFT" && (
<Button
size="compact-sm"
color="edr-green"
radius="md"
leftSection={<Send size={14} />}
disabled={busy}
onClick={onSend}
>
Send to customer
</Button>
)}
</Group>
)}
</Paper>
);
}
function AddChargeModal({
opened,
onClose,
busy,
onSubmit,
}: {
opened: boolean;
onClose: () => void;
busy: boolean;
onSubmit: (p: {
reason: string;
amount: number;
currency: string;
action: "draft" | "send";
file?: File | null;
}) => void;
}) {
const [reason, setReason] = useState("");
const [amount, setAmount] = useState<number | string>("");
const [currency, setCurrency] = useState("ETB");
const [file, setFile] = useState<File | null>(null);
const valid = reason.trim().length > 0 && Number(amount) > 0;
const reset = () => {
setReason("");
setAmount("");
setCurrency("ETB");
setFile(null);
};
const submit = (action: "draft" | "send") => {
if (!valid) return;
onSubmit({ reason: reason.trim(), amount: Number(amount), currency, action, file });
};
return (
<Modal
opened={opened}
onClose={() => {
onClose();
reset();
}}
title="Add additional charge"
radius="md"
centered
>
<Stack gap="sm">
<Textarea
label="Reason for charge"
placeholder="e.g. Re-weighing fee at Mojo dry port"
autosize
minRows={2}
value={reason}
onChange={(e) => setReason(e.currentTarget.value)}
/>
<Group gap={8} align="flex-end">
<NumberInput
label="Amount"
min={0.01}
decimalScale={2}
value={amount}
onChange={setAmount}
style={{ flex: 1 }}
/>
<Select
label="Currency"
data={CURRENCIES}
value={currency}
onChange={(v) => v && setCurrency(v)}
w={100}
/>
</Group>
<FileButton onChange={setFile} accept="application/pdf,image/*">
{(props) => (
<Button
{...props}
variant="light"
color="edr-green"
radius="md"
leftSection={<Upload size={14} />}
>
{file ? file.name : "Attach a document (optional)"}
</Button>
)}
</FileButton>
<Group justify="flex-end" mt="sm" gap={8}>
<Button
variant="light"
color="gray"
radius="md"
disabled={busy || !valid}
loading={busy}
onClick={() => submit("draft")}
>
Save draft
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<Send size={14} />}
disabled={busy || !valid}
loading={busy}
onClick={() => submit("send")}
>
Send to customer
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -1,10 +1,11 @@
import { useNavigate } from "react-router-dom";
import { ExternalLink, MoreHorizontal } from "lucide-react";
import { ExternalLink, MoreHorizontal, Receipt } from "lucide-react";
import { Button, Menu, ActionIcon, Group, Text } from "@mantine/core";
import { BookingConfirmDialog } from "./BookingConfirmDialog";
import { useBookingActionDialog } from "./useBookingActionDialog";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
import {
isAllocateAction,
isClearanceNavAction,
@@ -50,6 +51,14 @@ export function BookingActionsMenu({
const goToClearanceTab = () =>
navigate(`/dashboard/booking-requests/${row.id}?tab=clearance`);
const goToAdditionalCharges = () =>
navigate(`/dashboard/booking-requests/${row.id}?tab=additional-charges`);
const canSeeAdditionalCharges = hasFreightPermission(
user,
FREIGHT_PERMS.additionalCharges.view,
);
const handleAction = (action: (typeof actions)[number]) => {
onSuppressRowClick?.();
if (isContractNavAction(action.id)) {
@@ -144,6 +153,17 @@ export function BookingActionsMenu({
);
})}
{actions.length > 0 && <Menu.Divider />}
{canSeeAdditionalCharges && (
<Menu.Item
leftSection={<Receipt size={15} />}
onClick={() => {
onSuppressRowClick?.();
goToAdditionalCharges();
}}
>
Additional charges
</Menu.Item>
)}
<Menu.Item
leftSection={<ExternalLink size={15} />}
onClick={() => {

View File

@@ -0,0 +1,86 @@
import { useMemo, useState } from "react";
import { Button, Tooltip } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { Download } from "lucide-react";
import { api } from "@/services/api";
import type { ExportParams } from "@/types/exports";
import { ExportDialog } from "./ExportDialog";
/**
* Pagination is a screen concern, never an export one — stripped here, once,
* rather than at each of the pages that mount this.
*/
const PAGINATION_KEYS = ["page", "pageSize", "skip", "take"];
export interface ExportButtonProps {
/** Catalog dataset key, e.g. "bookings". */
datasetKey: string;
/**
* The page's current filters — `useFilters().params` verbatim, or a
* non-migrated page's hand-built filter object. Deliberately not typed as
* `UseFilters`: four of the pages that need this haven't migrated yet.
*/
params?: Record<string, unknown>;
label?: string;
size?: "xs" | "sm";
}
/**
* Opens the export dialog for one dataset. Renders nothing when the caller
* lacks permission for that dataset — the catalog only returns what they may
* export, so an absent entry IS the permission check.
*/
export function ExportButton({
datasetKey,
params,
label = "Export",
size = "xs",
}: ExportButtonProps) {
const [opened, setOpened] = useState(false);
const { data: catalog, isLoading } = useQuery(
api.exports.catalog.queryOptions({ staleTime: 5 * 60_000 }),
);
const dataset = catalog?.find((d) => d.key === datasetKey);
const exportParams = useMemo<ExportParams>(() => {
const out: ExportParams = {};
for (const [key, value] of Object.entries(params ?? {})) {
if (PAGINATION_KEYS.includes(key)) continue;
if (value === undefined || value === null || value === "") continue;
out[key] = value as string | number;
}
return out;
}, [params]);
if (isLoading || !dataset) return null;
return (
<>
<Tooltip label={dataset.description} openDelay={500}>
<Button
variant="default"
radius="md"
size={size}
leftSection={<Download size={14} />}
onClick={() => setOpened(true)}
>
{label}
</Button>
</Tooltip>
{opened && (
<ExportDialog
opened={opened}
onClose={() => setOpened(false)}
dataset={dataset}
params={exportParams}
/>
)}
</>
);
}
export default ExportButton;

View File

@@ -0,0 +1,447 @@
import { useMemo, useState } from "react";
import {
Accordion,
Alert,
Anchor,
Badge,
Button,
Checkbox,
Chip,
Divider,
Group,
Loader,
Modal,
Popover,
Radio,
ScrollArea,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { Download, FileSpreadsheet, FileText, Search, Table, TriangleAlert, X } from "lucide-react";
import { extractDownloadErrorMessage } from "@/components/warehouses/options";
import { saveBlob } from "@/components/warehouses/pdf";
import { useSavedViews } from "@/components/filters";
import { useToast } from "@/hooks/use-toast";
import { api } from "@/services/api";
import { exportsService } from "@/services/exports.service";
import type {
ExportDatasetEntry,
ExportFormat,
ExportParams,
} from "@/types/exports";
const FORMAT_META: Record<ExportFormat, { label: string; Icon: typeof FileText; hint: string }> = {
csv: { label: "CSV", Icon: Table, hint: "Best for many columns" },
xlsx: { label: "Excel", Icon: FileSpreadsheet, hint: "Typed number columns" },
pdf: { label: "PDF", Icon: FileText, hint: "Few columns only" },
};
const ROW_SCOPES = [
{ value: "all", label: "All matching filters" },
{ value: "100", label: "First 100" },
{ value: "1000", label: "First 1,000" },
{ value: "5000", label: "First 5,000" },
];
/** Beyond this a PDF's columns are too narrow to read; we warn, the server allows it. */
const PDF_FIELD_WARN = 12;
export interface ExportDialogProps {
opened: boolean;
onClose: () => void;
dataset: ExportDatasetEntry;
/** The page's current filters. Pagination keys are stripped by ExportButton. */
params: ExportParams;
}
export function ExportDialog({ opened, onClose, dataset, params }: ExportDialogProps) {
const { toast } = useToast();
const defaultKeys = useMemo(
() => dataset.fields.filter((f) => f.default).map((f) => f.key),
[dataset.fields],
);
const [selected, setSelected] = useState<string[]>(defaultKeys);
// xlsx by default: typed number and date columns, so a spreadsheet opens it
// without the "is this text?" pass CSV needs. Falls back to whatever the
// dataset does offer rather than presetting a format it would reject.
const [format, setFormat] = useState<ExportFormat>(
() => (dataset.formats.includes("xlsx") ? "xlsx" : dataset.formats[0]),
);
const [scope, setScope] = useState("all");
const [search, setSearch] = useState("");
const [exporting, setExporting] = useState(false);
const [presetName, setPresetName] = useState("");
const [savePresetOpen, setSavePresetOpen] = useState(false);
// A preset is stored as a query string so the existing saved-views hook can
// hold it unchanged — see useExportPresets note below.
const presets = useSavedViews(`export:${dataset.key}`);
const { data: countData, isLoading: countLoading } = useQuery({
...api.exports.count.queryOptions({ input: { key: dataset.key, params } }),
enabled: opened,
staleTime: 30_000,
});
const total = countData?.total;
const cap = dataset.caps[format];
const limit = scope === "all" ? undefined : Number(scope);
const rowsToExport = total === undefined ? undefined : Math.min(total, limit ?? total);
const overCap = total !== undefined && limit === undefined && total > cap;
const selectedSet = useMemo(() => new Set(selected), [selected]);
const fieldKeys = useMemo(() => new Set(dataset.fields.map((f) => f.key)), [dataset.fields]);
const visibleByGroup = useMemo(() => {
const q = search.trim().toLowerCase();
const out = new Map<string, typeof dataset.fields>();
for (const group of dataset.groups) {
const fields = dataset.fields.filter(
(f) => f.group === group.id && (!q || f.label.toLowerCase().includes(q)),
);
if (fields.length) out.set(group.id, fields);
}
return out;
}, [dataset.fields, dataset.groups, search]);
// Which groups are expanded. Real state, NOT derived from the selection:
// deriving it made the accordion fully controlled with no way to change it,
// so clicking a group that had nothing selected re-collapsed on the next
// render and the group could only be opened by selecting a field in it.
// Seeded from `default` (not the live selection) so clearing every field
// doesn't slam the open groups shut underneath the user.
const [expanded, setExpanded] = useState<string[]>(() =>
dataset.groups
.filter((g) => dataset.fields.some((f) => f.group === g.id && f.default))
.map((g) => g.id),
);
// Searching force-opens every group holding a match, so a hit can't hide
// inside a collapsed section. It only overrides what is displayed — the
// user's own expand state is untouched and returns when the search clears.
const openGroups = search.trim() ? [...visibleByGroup.keys()] : expanded;
const toggleField = (key: string) =>
setSelected((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]));
const toggleGroup = (groupId: string) => {
const keys = dataset.fields.filter((f) => f.group === groupId).map((f) => f.key);
const allOn = keys.every((k) => selectedSet.has(k));
setSelected((prev) =>
allOn ? prev.filter((k) => !keys.includes(k)) : [...new Set([...prev, ...keys])],
);
};
const applyPreset = (query: string) => {
const p = new URLSearchParams(query);
// Drop any key the catalog no longer offers — a stale preset must not 400
// the download by asking for a field that has since been removed.
const keys = (p.get("fields") ?? "").split(",").filter((k) => fieldKeys.has(k));
if (keys.length) setSelected(keys);
const f = p.get("format") as ExportFormat | null;
if (f && dataset.formats.includes(f)) setFormat(f);
};
const savePreset = () => {
const name = presetName.trim();
if (!name) return;
presets.save(
new URLSearchParams({ name, format, fields: selected.join(",") }).toString(),
);
setPresetName("");
setSavePresetOpen(false);
};
const handleDownload = async () => {
setExporting(true);
try {
const blob = await exportsService.download(dataset.key, format, selected, {
...params,
...(limit ? { limit } : {}),
});
saveBlob(blob, `${dataset.key}-${new Date().toISOString().slice(0, 10)}.${format}`);
onClose();
} catch (error) {
// Blob error bodies need the async decoder, or the server's row-cap
// message degrades to "Request failed with status code 400".
toast({
variant: "destructive",
title: "Export failed",
description: await extractDownloadErrorMessage(error),
});
} finally {
setExporting(false);
}
};
return (
<Modal
opened={opened}
onClose={onClose}
title={`Export ${dataset.title.toLowerCase()}`}
size="62rem"
radius="md"
>
<Stack gap="md">
{/* Presets */}
<Group gap="xs" wrap="wrap">
<Chip size="xs" checked={false} onClick={() => setSelected(defaultKeys)}>
Default columns
</Chip>
<Chip
size="xs"
checked={false}
onClick={() => setSelected(dataset.fields.map((f) => f.key))}
>
All columns
</Chip>
{presets.views.map((view) => {
const name = new URLSearchParams(view.query).get("name") ?? "Preset";
return (
<Chip
key={view.id}
size="xs"
checked={false}
onClick={() => applyPreset(view.query)}
>
<Group gap={4} wrap="nowrap">
{name}
<X
size={12}
onClick={(e) => {
e.stopPropagation();
presets.remove(view.id);
}}
/>
</Group>
</Chip>
);
})}
<Popover opened={savePresetOpen} onChange={setSavePresetOpen} position="bottom-start">
<Popover.Target>
<Button
variant="subtle"
size="compact-xs"
disabled={!selected.length}
onClick={() => setSavePresetOpen((o) => !o)}
>
Save preset
</Button>
</Popover.Target>
<Popover.Dropdown p="xs">
<Group gap="xs" wrap="nowrap">
<TextInput
size="xs"
placeholder="Preset name"
value={presetName}
onChange={(e) => setPresetName(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && savePreset()}
autoFocus
/>
<Button size="compact-xs" onClick={savePreset} disabled={!presetName.trim()}>
Save
</Button>
</Group>
</Popover.Dropdown>
</Popover>
</Group>
<Divider />
{/* Pick the data on the left, configure the file on the right. Stacks
on a phone, where neither column has room to sit beside the other. */}
<div className="flex flex-col gap-6 sm:flex-row">
{/* Fields */}
<div className="min-w-0 flex-[7]">
<Stack gap="xs">
<TextInput
size="xs"
placeholder="Search fields…"
leftSection={<Search size={14} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<Group justify="space-between">
<Text size="xs" c="dimmed">
{selected.length} of {dataset.fields.length} fields selected
</Text>
<Button variant="subtle" size="compact-xs" onClick={() => setSelected(defaultKeys)}>
Reset
</Button>
</Group>
<ScrollArea.Autosize mah={420} type="auto">
<Accordion
multiple
value={openGroups}
onChange={setExpanded}
chevronPosition="left"
variant="contained"
>
{dataset.groups.map((group) => {
const fields = visibleByGroup.get(group.id);
if (!fields) return null;
const groupKeys = dataset.fields
.filter((f) => f.group === group.id)
.map((f) => f.key);
const on = groupKeys.filter((k) => selectedSet.has(k)).length;
return (
<Accordion.Item key={group.id} value={group.id}>
<Accordion.Control>
<Group gap="xs" wrap="nowrap">
<Checkbox
size="xs"
checked={on === groupKeys.length}
indeterminate={on > 0 && on < groupKeys.length}
onClick={(e) => {
e.stopPropagation();
toggleGroup(group.id);
}}
onChange={() => undefined}
/>
<Text size="sm" fw={500}>
{group.label}
</Text>
<Badge size="xs" variant="light" color={on ? "edr-green" : "gray"}>
{on}/{groupKeys.length}
</Badge>
</Group>
</Accordion.Control>
<Accordion.Panel>
<Stack gap={2}>
{fields.map((field) => (
<Checkbox
key={field.key}
size="xs"
label={field.label}
checked={selectedSet.has(field.key)}
onChange={() => toggleField(field.key)}
/>
))}
</Stack>
</Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion>
</ScrollArea.Autosize>
</Stack>
</div>
{/* Options */}
<div className="min-w-0 flex-[5]">
<Stack gap="md">
<div>
<Text size="sm" fw={600} mb="xs">
Format
</Text>
<Radio.Group value={format} onChange={(v) => setFormat(v as ExportFormat)}>
<SimpleGrid cols={dataset.formats.length} spacing="xs">
{dataset.formats.map((f) => {
const { label, Icon } = FORMAT_META[f];
return (
<Radio.Card key={f} value={f} radius="md" p="xs">
<Stack gap={4} align="center">
{/* Radio.Card's own checked state is a border tint
and nothing else, which reads as unselected at
this size. The Indicator is what actually says
which format is picked, as the report export
dialog's cards already do. */}
<Group gap={6} wrap="nowrap">
<Radio.Indicator size="xs" />
<Icon size={18} />
</Group>
<Text size="xs" fw={500}>
{label}
</Text>
</Stack>
</Radio.Card>
);
})}
</SimpleGrid>
</Radio.Group>
</div>
<Select
label="Rows"
size="sm"
radius="md"
value={scope}
onChange={(v) => setScope(v ?? "all")}
data={ROW_SCOPES}
allowDeselect={false}
/>
<div>
<Text size="xs" c="dimmed">
Matching rows
</Text>
<Group gap="xs">
{countLoading ? (
<Loader size="xs" />
) : (
<Text size="lg" fw={600}>
{total?.toLocaleString() ?? "—"}
</Text>
)}
</Group>
</div>
{overCap && (
<Alert color="red" icon={<TriangleAlert size={16} />} p="xs">
<Text size="xs">
{total?.toLocaleString()} rows exceeds the {cap.toLocaleString()}-row{" "}
{FORMAT_META[format].label} limit. Narrow the filters
{dataset.caps.csv > cap ? ", switch to CSV," : ""} or{" "}
<Anchor size="xs" onClick={() => setScope(String(cap))}>
export the first {cap.toLocaleString()}
</Anchor>
.
</Text>
</Alert>
)}
{format === "pdf" && selected.length > PDF_FIELD_WARN && (
<Alert color="yellow" icon={<TriangleAlert size={16} />} p="xs">
<Text size="xs">
{selected.length} columns is more than a PDF can show legibly. CSV or Excel
keeps them readable.
</Text>
</Alert>
)}
<Text size="xs" c="dimmed">
Uses the filters currently applied on this page.
</Text>
</Stack>
</div>
</div>
<Group justify="flex-end">
<Button variant="default" radius="md" onClick={onClose}>
Cancel
</Button>
<Button
radius="md"
loading={exporting}
disabled={!selected.length || overCap}
leftSection={<Download size={16} />}
onClick={() => void handleDownload()}
>
{rowsToExport === undefined
? "Export"
: `Export ${rowsToExport.toLocaleString()} ${rowsToExport === 1 ? "row" : "rows"}`}
</Button>
</Group>
</Stack>
</Modal>
);
}
export default ExportDialog;

View File

@@ -21,8 +21,9 @@ const BODIES: Record<FilterDef["type"], React.ComponentType<any>> = {
};
// Most bodies fit a narrow popover; a date range needs room for the presets
// sidebar next to the calendar, so it gets a wider minimum.
const DROPDOWN_WIDTH: Partial<Record<FilterDef["type"], number>> = { date: 340 };
// sidebar next to the calendar, and a route needs room for two multi-selects'
// worth of yard chips, so both get a wider minimum.
const DROPDOWN_WIDTH: Partial<Record<FilterDef["type"], number>> = { date: 340, route: 320 };
export interface FilterPillProps {
def: FilterDef;

View File

@@ -4,7 +4,7 @@ import { DatePickerInput } from "@mantine/dates";
import { CalendarDays } from "lucide-react";
import { getDateRangePresets } from "@/components/common/dateRangePresets";
import { startOfDayIso, endOfDayIso, parseDateStr } from "../dates";
import { startOfDayIso, endOfDayIso, isoToLocalDateStr, parseDateStr } from "../dates";
import { DEFAULT_OP } from "../types";
import type { DateFilterDef, Operator } from "../types";
import { OperatorSelect } from "../OperatorSelect";
@@ -15,10 +15,19 @@ import type { FilterBodyProps } from "./TextBody";
// i18n.language !== "en" branch here when this body is first wired into a
// record-management page (Phase 4 of the filter-bar rollout).
export function DateBody({ def, value, onChange, onClose }: FilterBodyProps<DateFilterDef>) {
const [op, setOp] = useState<Operator>(value?.op ?? DEFAULT_OP.date);
// Mantine 9's date inputs speak `YYYY-MM-DD` strings, not Date objects.
const [from, setFrom] = useState<string | null>(value?.v[0]?.slice(0, 10) ?? null);
const [to, setTo] = useState<string | null>(value?.v[1]?.slice(0, 10) ?? null);
// DEFAULT_OP.date is always "between" — a def restricted to a single
// non-default operator (e.g. `operators: ["before"]` for an exact-date
// filter) would otherwise open on the range UI with no way to switch off
// it, since OperatorSelect hides itself when there's only one choice.
const [op, setOp] = useState<Operator>(value?.op ?? def.operators?.[0] ?? DEFAULT_OP.date);
// Mantine 9's date inputs speak `YYYY-MM-DD` strings, not Date objects — and
// the stored values are UTC instants, so they must come back through
// `isoToLocalDateStr`, not a slice (see its comment: a slice reopens the
// "from" side a day early east of UTC).
const [from, setFrom] = useState<string | null>(
value?.v[0] ? isoToLocalDateStr(value.v[0]) : null,
);
const [to, setTo] = useState<string | null>(value?.v[1] ? isoToLocalDateStr(value.v[1]) : null);
const apply = () => {
if (op === "between") {

View File

@@ -11,19 +11,29 @@ import type { FilterBodyProps } from "./TextBody";
const SEARCH_THRESHOLD = 8;
/**
* Stretches the Checkbox/Radio's native <label> across the full popover
* width and pads it, so the clickable/tappable area is the whole row —
* not just the ~14px input square — plus a hover cue. `body`/`labelWrapper`
* are Mantine's part names for this; `cursor: pointer` on the row (not just
* the input) makes the affordance visible before you even click.
* Whole-row hit target. The only element that toggles a Mantine Checkbox /
* Radio is its native <label>, and that label wraps its own text and nothing
* else — the row's padding and the gutter beside the input square lie
* OUTSIDE it. Styling those on `root` therefore bought a hover cue over an
* area that swallowed the click.
*
* The fix is a `::before` stretched over the (relatively positioned) root:
* that pseudo-element is part of the label's own box, so a click anywhere in
* the row hits the label and toggles the input. `cursor: pointer` goes on the
* root for the same reason — the affordance must cover what is clickable.
*/
const ROW_STYLES = {
root: { padding: "10px 10px", borderRadius: 6 },
root: { position: "relative" as const, padding: "10px 10px", borderRadius: 6, cursor: "pointer" },
body: { alignItems: "center" as const },
labelWrapper: { flex: 1 },
label: { cursor: "pointer", paddingLeft: 8 },
};
const ROW_CLASSES = {
root: "hover:bg-gray-100 transition-colors",
label: "before:absolute before:inset-0 before:content-['']",
};
function OptionLabel({ label, count }: { label: string; count?: number }) {
return (
<Group justify="space-between" wrap="nowrap" gap="sm">
@@ -96,7 +106,7 @@ export function EnumBody({ def, value, onChange, onClose }: FilterBodyProps<Enum
// just the tiny checkbox square) toggles the option too.
label={<OptionLabel label={o.label} count={def.counts?.[o.value]} />}
styles={ROW_STYLES}
classNames={{ root: "hover:bg-gray-100 transition-colors" }}
classNames={ROW_CLASSES}
/>
))}
</Stack>
@@ -115,7 +125,7 @@ export function EnumBody({ def, value, onChange, onClose }: FilterBodyProps<Enum
size="sm"
label={<OptionLabel label={o.label} count={def.counts?.[o.value]} />}
styles={ROW_STYLES}
classNames={{ root: "hover:bg-gray-100 transition-colors" }}
classNames={ROW_CLASSES}
/>
))}
</Stack>

View File

@@ -1,59 +1,127 @@
import { useState } from "react";
import { Button, Select, Stack } from "@mantine/core";
import { ArrowRight } from "lucide-react";
import { ActionIcon, Button, Group, MultiSelect, Stack, Text, Tooltip } from "@mantine/core";
import { ArrowDown, ArrowUpDown } from "lucide-react";
import type { RouteFilterDef } from "../types";
import { decodeRouteValue, encodeRouteValue, type RouteSelection } from "../route";
import type { FilterBodyProps } from "./TextBody";
type Side = keyof RouteSelection;
/**
* Origin + destination picked together, each a searchable `Select` over the
* page's yard list — typing filters by yard name, same as any Mantine
* Select. No `OperatorSelect`: a route pair has exactly one operator ("is"),
* which is why DEFAULT_OP.route is the only entry the generic bar needs.
* Origin and destination as two independent multi-selects, either of which may
* be left empty. That is the whole point: "everything leaving Nagad" and
* "everything arriving at Gelan" are real questions an operator asks, and the
* previous body — two single Selects behind an Apply gated on
* `origin && destination` — could only ask the third one.
*
* Semantics are OR inside a side, AND across the two, which the hint line
* below spells out in words rather than making the user infer it from a
* checkbox list.
*
* No `OperatorSelect`: a route still has exactly one operator ("is"), which is
* why DEFAULT_OP.route is the only entry the generic bar needs.
*/
export function RouteBody({ def, value, onChange, onClose }: FilterBodyProps<RouteFilterDef>) {
const [origin, setOrigin] = useState<string | null>(value?.v[0] ?? null);
const [destination, setDestination] = useState<string | null>(value?.v[1] ?? null);
const [sel, setSel] = useState<RouteSelection>(() => decodeRouteValue(value?.v ?? []));
const [search, setSearch] = useState<Record<Side, string>>({ origins: "", destinations: "" });
const label = (id: string) => def.options.find((o) => o.value === id)?.label ?? id;
const list = (ids: string[]) => ids.map(label).join(" or ");
// Spelled out, because "OR within a side, AND across sides" is not something
// two stacked pickers communicate on their own.
const hint = !sel.origins.length && !sel.destinations.length
? "Type to search stations. Pick an origin, a destination, or both."
: !sel.destinations.length
? `Everything leaving ${list(sel.origins)}.`
: !sel.origins.length
? `Everything arriving at ${list(sel.destinations)}.`
: `From ${list(sel.origins)} to ${list(sel.destinations)}.`;
const apply = () => {
onChange(origin && destination ? { op: "is", v: [origin, destination] } : undefined);
onChange(encodeRouteValue(sel));
onClose();
};
// This popover already lives inside FilterPill's own Popover. A Select's
// dropdown portals separately by default, so a click on an option registers
// as "outside" the outer Popover and closes the whole filter before a pick
// This popover already lives inside FilterPill's own Popover. A dropdown
// portals separately by default, so a click on an option registers as
// "outside" the outer Popover and closes the whole filter before a pick
// lands — same nested-portal bug DateBody had. Un-portalling keeps it
// inside the outer popover's DOM subtree instead.
const comboboxProps = { withinPortal: false } as const;
/**
* The list stays shut until there is something typed. Two open triggers have
* to be neutralised for that, not one: `openOnFocus={false}` handles the
* focus, but MultiSelect's PillsInput root ALSO calls `openDropdown()` on
* every click, ungated — so the only reliable lever is driving
* `dropdownOpened` ourselves off the search text.
*
* Consequence worth knowing: Mantine clears the search on each pick
* (`clearSearchOnChange`, default true), so the list closes after one is
* chosen and typing reopens it. That is the intended resting state — the
* popover opens showing what is already selected, not a wall of stations.
*/
const sideProps = (side: Side) => ({
data: def.options,
placeholder: sel[side].length ? "Add another" : "Any",
value: sel[side],
onChange: (next: string[]) => setSel((s) => ({ ...s, [side]: next })),
searchValue: search[side],
onSearchChange: (q: string) => setSearch((s) => ({ ...s, [side]: q })),
dropdownOpened: search[side].trim().length > 0,
openOnFocus: false,
comboboxProps,
searchable: true,
clearable: true,
hidePickedOptions: true,
maxDropdownHeight: 200,
nothingFoundMessage: "No station matches",
});
return (
<Stack gap="xs" w={240}>
<Select
label="Origin"
placeholder="Any"
data={def.options}
value={origin}
onChange={setOrigin}
comboboxProps={comboboxProps}
searchable
clearable
autoFocus
/>
<ArrowRight size={14} className="text-gray-400" style={{ alignSelf: "center" }} />
<Select
label="Destination"
placeholder="Any"
data={def.options}
value={destination}
onChange={setDestination}
comboboxProps={comboboxProps}
searchable
clearable
/>
<Button size="sm" onClick={apply} disabled={!(origin && destination)}>
Apply
</Button>
<Stack gap={6} w={300}>
<MultiSelect label="From" autoFocus {...sideProps("origins")} />
<Group justify="center" gap={6} wrap="nowrap">
<ArrowDown size={14} className="text-gray-400" />
<Tooltip label="Swap origin and destination" withinPortal={false}>
<ActionIcon
size="sm"
radius="xl"
variant="subtle"
color="gray"
aria-label="Swap origin and destination"
disabled={!sel.origins.length && !sel.destinations.length}
onClick={() => setSel((s) => ({ origins: s.destinations, destinations: s.origins }))}
>
<ArrowUpDown size={13} />
</ActionIcon>
</Tooltip>
</Group>
<MultiSelect label="To" {...sideProps("destinations")} />
<Text size="xs" c="dimmed" mt={2}>
{hint}
</Text>
<Group gap="xs" grow mt={2}>
<Button
size="sm"
variant="default"
disabled={!sel.origins.length && !sel.destinations.length}
onClick={() => setSel({ origins: [], destinations: [] })}
>
Clear
</Button>
{/* Enabled even when empty: applying nothing removes the filter, which
is how every other body's Apply behaves. */}
<Button size="sm" onClick={apply}>
Apply
</Button>
</Group>
</Stack>
);
}

View File

@@ -32,6 +32,23 @@ export function parseDateStr(dateStr: string): Date {
return new Date(y, (m || 1) - 1, d || 1);
}
/**
* Inverse of `parseDateStr` + `startOfDayIso`/`endOfDayIso`: the LOCAL
* `YYYY-MM-DD` an ISO instant falls on.
*
* `iso.slice(0, 10)` is the tempting version and it is wrong. Those instants
* came out of `toISOString()`, so they are UTC — for Ethiopia (UTC+3) a local
* end-of-day is `…T20:59:59.999Z` on the SAME day but a local start-of-day is
* `…T21:00:00.000Z` on the PREVIOUS one. Slicing therefore reopens the picker
* (and printed the pill) a day early on the "from" side only.
*/
export function isoToLocalDateStr(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso.slice(0, 10);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
/**
* `toParams` for a date `FilterDef` widened to `["between", "before", "after"]`
* operators. `DateBody` always emits a single-element `v` for before/after —

View File

@@ -1,5 +1,7 @@
import { formatDate } from "@/lib/format";
import { parseFilters } from "./url";
import type { FilterDef, FilterValue } from "./types";
import { decodeRouteValue } from "./route";
import { OPERATOR_LABELS, type FilterDef, type FilterValue } from "./types";
/** Human-readable text for one filter's current value — same text a
* FilterPill shows, and what a saved view's auto-generated label is built
@@ -10,12 +12,29 @@ export function formatFilterValue(def: FilterDef, value: FilterValue): string {
const labels = value.v.map((v) => def.options.find((o) => o.value === v)?.label ?? v);
return labels.join(", ");
}
if (def.type === "date" && value.v.length === 2) {
return `${value.v[0].slice(0, 10)}${value.v[1].slice(0, 10)}`;
if (def.type === "date") {
// `v` holds UTC instants (startOfDayIso/endOfDayIso call toISOString), so
// slicing the first 10 characters printed the UTC calendar day — one day
// EARLIER than the one picked, for anyone east of UTC. `formatDate` reads
// the instant back in local time, which is the day the user actually chose.
// Single-sided ops carry their operator, since "Created | Aug 20" alone
// doesn't say whether that's a floor or a ceiling.
const days = value.v.map(formatDate);
if (value.op === "between" && days.length === 2) return `${days[0]}${days[1]}`;
return `${OPERATOR_LABELS[value.op]} ${days[0]}`;
}
if (def.type === "route" && value.v.length === 2) {
if (def.type === "route") {
const label = (id: string) => def.options.find((o) => o.value === id)?.label ?? id;
return `${label(value.v[0])}${label(value.v[1])}`;
// "Any" reads as an unconstrained end; a long side collapses to "first +N"
// so the pill can't grow past the rest of the bar.
const side = (ids: string[]) =>
ids.length === 0
? "Any"
: ids.length <= 2
? ids.map(label).join(", ")
: `${label(ids[0])} +${ids.length - 1}`;
const { origins, destinations } = decodeRouteValue(value.v);
return `${side(origins)}${side(destinations)}`;
}
return value.v.join(", ");
}

View File

@@ -1,6 +1,7 @@
export * from "./types";
export * from "./url";
export * from "./dates";
export * from "./route";
export * from "./format";
export * from "./clientFilter";
export * from "./ruleEngineFooterProps";

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import type { FilterDef } from "./types";
import { decodeRouteValue, encodeRouteValue, routeParams } from "./route";
import { decodeFilterValue, encodeFilterValue, toApiParams } from "./url";
import { formatFilterValue } from "./format";
const NAGAD = "11111111-1111-1111-1111-111111111111";
const DMP = "22222222-2222-2222-2222-222222222222";
const GELAN = "33333333-3333-3333-3333-333333333333";
const ROUTE: FilterDef = {
key: "route",
label: "Route",
type: "route",
options: [
{ value: NAGAD, label: "Nagad" },
{ value: DMP, label: "DMP" },
{ value: GELAN, label: "Gelan" },
],
toParams: routeParams("originYardId", "destinationYardId"),
};
describe("route filter value", () => {
it("round-trips each side independently, through the URL codec", () => {
const cases = [
{ origins: [NAGAD], destinations: [] },
{ origins: [], destinations: [GELAN] },
{ origins: [NAGAD, DMP], destinations: [GELAN] },
];
for (const sel of cases) {
const value = encodeRouteValue(sel)!;
const raw = encodeFilterValue("route", value);
expect(decodeRouteValue(decodeFilterValue("route", raw)!.v)).toEqual(sel);
}
});
it("is no filter at all when both sides are empty", () => {
expect(encodeRouteValue({ origins: [], destinations: [] })).toBeUndefined();
});
it("omits an unconstrained side rather than sending an empty param", () => {
const value = encodeRouteValue({ origins: [NAGAD, DMP], destinations: [] })!;
expect(toApiParams([ROUTE], { route: value })).toEqual({
originYardId: `${NAGAD},${DMP}`,
destinationYardId: undefined,
});
});
it("still reads the legacy untagged `route=<origin>,<destination>` pair", () => {
expect(decodeRouteValue([NAGAD, GELAN])).toEqual({
origins: [NAGAD],
destinations: [GELAN],
});
});
it("labels an empty side 'Any' in the pill", () => {
const value = encodeRouteValue({ origins: [], destinations: [GELAN] })!;
expect(formatFilterValue(ROUTE, value)).toBe("Any → Gelan");
});
});

View File

@@ -0,0 +1,67 @@
import type { FilterValue } from "./types";
const ORIGIN = "o:";
const DEST = "d:";
export interface RouteSelection {
origins: string[];
destinations: string[];
}
/**
* A route filter's `v` is one flat, TAGGED list — `["o:<yardId>", "d:<yardId>", …]`.
*
* It has to be flat because `url.ts` knows exactly one encoding for a filter
* value: comma-split inside a single query param. The tags are what buy back
* the two sides, and with them the three things the old fixed
* `[origin, destination]` pair could not express:
*
* - origin only — "everything leaving Nagad"
* - destination only — "everything arriving at Gelan"
* - several yards per side — "leaving Nagad OR DMP, arriving Gelan OR Indode"
*
* Semantics: OR within a side, AND across the two. An empty side is not a
* filter at all (see {@link routeParams}), never "matches nothing".
*/
export function decodeRouteValue(v: string[]): RouteSelection {
const origins: string[] = [];
const destinations: string[] = [];
for (const entry of v) {
if (entry.startsWith(ORIGIN)) origins.push(entry.slice(ORIGIN.length));
else if (entry.startsWith(DEST)) destinations.push(entry.slice(DEST.length));
}
// Legacy `?route=<originId>,<destinationId>`: deep links and saved views
// written before the tags existed. Untagged, and always exactly the pair.
if (!origins.length && !destinations.length && v.length === 2) {
return { origins: [v[0]], destinations: [v[1]] };
}
return { origins, destinations };
}
/** Inverse of {@link decodeRouteValue}. `undefined` when both sides are empty — that is "no filter". */
export function encodeRouteValue(sel: RouteSelection): FilterValue | undefined {
const v = [
...sel.origins.map((id) => `${ORIGIN}${id}`),
...sel.destinations.map((id) => `${DEST}${id}`),
];
return v.length ? { op: "is", v } : undefined;
}
/**
* `toParams` for a route filter — each side onto its own comma-separated API
* param, mirroring `dateRangeParams`. An empty side maps to `undefined` so
* `cleanParams` drops the param entirely; sending `originYardId=` instead
* would have the server filter on an empty list.
*/
export function routeParams(
originKey: string,
destinationKey: string,
): (v: FilterValue) => Record<string, string | undefined> {
return (value) => {
const { origins, destinations } = decodeRouteValue(value.v);
return {
[originKey]: origins.join(",") || undefined,
[destinationKey]: destinations.join(",") || undefined,
};
};
}

View File

@@ -89,11 +89,11 @@ export interface BooleanFilterDef extends FilterDefBase {
}
/**
* Origin + destination picked together as one pill — `v` is always the
* 2-slot pair `[originYardId, destinationYardId]`, never partial (the body's
* Apply button stays disabled until both sides are chosen, same rule
* `DateBody` uses for a `between` range). One shared `options` list drives
* both selects.
* Origin + destination as one pill, each side holding any number of yards and
* either side allowed to be empty. `v` is the tagged flat list described in
* `route.ts` — use `decodeRouteValue` / `encodeRouteValue` to read or write it,
* and `routeParams(originKey, destinationKey)` as the def's `toParams`. One
* shared `options` list drives both sides.
*/
export interface RouteFilterDef extends FilterDefBase {
type: "route";

View File

@@ -571,6 +571,11 @@ export const buildSidebarSections = (
href: "/dashboard/configuration/exchange-rate",
permission: FREIGHT_PERMS.settings.exchangeRate.view,
},
{
label: "Operating standards",
href: "/dashboard/configuration/operations-standards",
permission: FREIGHT_PERMS.settings.operationsStandards.view,
},
{
label: "Manual payments",
href: "/dashboard/configuration/manual-payments",

View File

@@ -4,44 +4,93 @@ import { getPositionKeys } from "@/lib/permissions";
/** One overview composition. Every backoffice user lands on exactly one of these. */
export type OverviewLayoutKey =
| "executive"
| "operations"
| "operation"
| "occ"
| "marketing"
| "marketer"
| "finance"
| "clearance";
export const OVERVIEW_LAYOUT_LABEL: Record<OverviewLayoutKey, string> = {
executive: "Executive dashboard",
operations: "Operations dashboard",
operation: "Operations dashboard",
occ: "Control centre dashboard",
marketing: "Marketing dashboard",
marketer: "Marketing dashboard",
finance: "Finance dashboard",
clearance: "Clearance & logistics dashboard",
};
/**
* Role/position key → layout, in match priority order: a user holding several
* Position/role key → layout, in match priority order: a user holding several
* of these keys gets the first match, so the specific operational view wins
* over the broad executive one. Position keys are matched too because the IAM
* payload models the GL desks as positions (`ethiopian_gl`) on some accounts
* and as roles (`edr_gl_ethiopia`) on others — see `getPositionKeys`.
* over the broad executive one. Roles are matched alongside positions because
* the IAM payload models the GL desks as positions (`ethiopian_gl`) on some
* accounts and as roles (`edr_gl_ethiopia`) on others — see `getPositionKeys`.
*
* The `edr_freight_app/…` keys are the org's real position keys (root desks and
* their sub-positions) as configured under Unit → Departments. They are typed
* by hand in the Add/Edit Department form, so a new sub-position appears here
* only once someone adds it — unmapped keys fall through to `executive`.
*/
const ROLE_LAYOUTS: Array<[key: string, layout: OverviewLayoutKey]> = [
["edr_operations_officer", "operations"],
["truck_machinery_chief", "operations"],
["edr_line_staff", "occ"],
["edr_gl_ethiopia", "clearance"],
["edr_gl_djibouti", "clearance"],
// ── Clearance & logistics: both GL desks, root and sub-positions ──────────
["ethiopian_gl", "clearance"],
["edr_freight_app/gl_003", "clearance"], // Ethiopian GL Chief
["edr_freight_app/off_001", "clearance"], // Ethiopian GL Director
["edr_freight_app/off_0056", "clearance"], // Ethiopian GL Officer
["djibouti_gl", "clearance"],
["edr_marketing", "marketing"],
["edr_finance", "finance"],
["edr_director", "executive"],
["edr_ceo", "executive"],
["edr_org_manager", "executive"],
["edr_freight_app/dj_gl_001", "clearance"], // Djibouti GL Director
["edr_freight_app/dj_gl_002", "clearance"], // Djibouti GL Chief
["edr_freight_app/dj_gl_003", "clearance"], // Djibouti GL Officer
["edr_gl_ethiopia", "clearance"], // legacy role form
["edr_gl_djibouti", "clearance"], // legacy role form
// ── Control centre ───────────────────────────────────────────────────────
["edr_freight_app/occ_001", "occ"], // OCC
["edr_freight_app/occ_005", "occ"], // OCC Director
["edr_line_staff", "occ"], // legacy role form
// ── Operations: operations desk, track & machinery, rolling stock ─────────
["edr_freight_app/opn", "operation"], // Operation
["edr_freight_app/opcf", "operation"], // Operation Chief
["edr_freight_app/opdr", "operation"], // Operation Director
["edr_freight_app/opco", "operation"], // Operation Officer
["edr_freight_app/opp_005", "operation"], // Operation Dispatcher
["edr_freight_app/opp_0067", "operation"], // Gelan Operation Director
["edr_freight_app/track_001", "operation"], // Track And Machinery
["edr_freight_app/ttk_001", "operation"], // Track Director
["edr_freight_app/tto_001", "operation"], // Track Operator
["edr_freight_app/rool_001", "operation"], // Rolling Stock
["edr_freight_app/rl_003", "operation"], // Rolling Stock Director
["edr_freight_app/rl_009", "operation"], // Rolling Stock Team Lead
["edr_freight_app/rl_0090", "operation"], // Rolling Stock Dispatcher
["operation", "operation"],
["operations_chief", "operation"],
["dispatcher", "operation"],
["truck_machinery_chief", "operation"],
["edr_operations_officer", "operation"], // legacy role form
// ── Marketing ────────────────────────────────────────────────────────────
["edr_freight_app/edr_test_org_0022", "marketer"], // Commercial Marketing
["edr_freight_app/edr_test_org_00567", "marketer"], // Marketing Director
["edr_freight_app/edr_test_org_0054", "marketer"], // Marketing Chief
["edr_freight_app/edr_test_org_0013", "marketer"], // Marketing Officer
["marketer", "marketer"],
["edr_marketing", "marketer"], // legacy role form
// ── Finance ──────────────────────────────────────────────────────────────
["edr_freight_app/finance", "finance"],
["edr_finance", "finance"], // legacy role form
// ── Executive: org-wide desks with no operational queue of their own ──────
["ceo", "executive"],
["director", "executive"],
["chief", "executive"],
["edr_ceo", "executive"], // legacy role form
["edr_director", "executive"], // legacy role form
["edr_org_manager", "executive"], // legacy role form
];
/** Unmapped roles (superadmin, IAM admins, new roles) keep the executive layout. */
/** Unmapped keys (superadmin, IAM admins, Safety, new positions) keep the executive layout. */
export function resolveOverviewLayout(
user: AuthUser | null | undefined,
): OverviewLayoutKey {

View File

@@ -50,21 +50,15 @@ export function PageHeader({
</ActionIcon>
) : null}
<div style={{ minWidth: 0 }}>
<div style={{ minWidth: 0, maxWidth: 640 }}>
<Group gap="sm" align="center" wrap="nowrap">
<Title
order={2}
fz={24}
fw={600}
className="truncate"
style={{ letterSpacing: "-0.02em" }}
>
<Title order={2} className="truncate" style={{ minWidth: 0 }}>
{title}
</Title>
{meta}
</Group>
{subtitle ? (
<Text c="edr-muted" fz={13} mt={4}>
<Text c="dimmed" size="sm" mt={4} className="truncate">
{subtitle}
</Text>
) : null}

View File

@@ -1,110 +0,0 @@
import { Group, MultiSelect, Select, TextInput } from "@mantine/core";
import { DateInput, DatePickerInput } from "@mantine/dates";
import { Search } from "lucide-react";
import { getDateRangePresets } from "@/components/common/dateRangePresets";
import type { ReportFilterDef } from "@/types/reports";
export interface ReportFilterValues {
[param: string]: string | undefined;
}
interface ReportFiltersProps {
filters: ReportFilterDef[];
values: ReportFilterValues;
onChange: (values: ReportFilterValues) => void;
}
const toDate = (value: string | undefined): Date | null => (value ? new Date(value) : null);
const fromDate = (value: string | null): string | undefined => value ?? undefined;
/** Renders one widget per report-declared filter and reports raw param values back up. */
export function ReportFilters({ filters, values, onChange }: ReportFiltersProps) {
if (!filters.length) return null;
const set = (patch: ReportFilterValues) => onChange({ ...values, ...patch });
return (
<Group gap="sm" wrap="wrap">
{filters.map((filter) => {
switch (filter.type) {
case "daterange":
return (
<DatePickerInput
key={filter.key}
type="range"
placeholder={filter.label}
value={[values[`${filter.key}From`] ?? null, values[`${filter.key}To`] ?? null]}
onChange={([from, to]) =>
set({ [`${filter.key}From`]: fromDate(from), [`${filter.key}To`]: fromDate(to) })
}
presets={getDateRangePresets()}
radius="md"
size="sm"
clearable
w={230}
/>
);
case "date":
return (
<DateInput
key={filter.key}
placeholder={filter.label}
value={toDate(values[filter.key])}
onChange={(d) => set({ [filter.key]: fromDate(d) })}
radius="md"
size="sm"
clearable
w={150}
/>
);
case "select":
return (
<Select
key={filter.key}
placeholder={filter.label}
data={filter.options ?? []}
value={values[filter.key] ?? null}
onChange={(v) => set({ [filter.key]: v ?? undefined })}
radius="md"
size="sm"
clearable
w={170}
/>
);
case "multiselect":
return (
<MultiSelect
key={filter.key}
placeholder={filter.label}
data={filter.options ?? []}
value={values[filter.key]?.split(",").filter(Boolean) ?? []}
onChange={(v) => set({ [filter.key]: v.length ? v.join(",") : undefined })}
radius="md"
size="sm"
clearable
w={200}
/>
);
case "text":
return (
<TextInput
key={filter.key}
placeholder={filter.label}
leftSection={<Search size={16} />}
value={values[filter.key] ?? ""}
onChange={(e) => set({ [filter.key]: e.target.value || undefined })}
radius="md"
size="sm"
w={220}
/>
);
default:
return null;
}
})}
</Group>
);
}
export default ReportFilters;

View File

@@ -9,6 +9,8 @@ interface ReportSectionProps {
reportKey: string;
/** Scopes the report to one entity, e.g. the contract this page is showing. */
idKeyValue?: string;
/** Opens on the chart instead of the table — for dashboard tiles. */
defaultView?: "table" | "chart";
}
/**
@@ -17,7 +19,7 @@ interface ReportSectionProps {
* loading or if the caller lacks the report's permission, so pages can embed
* it unconditionally without their own permission check.
*/
export function ReportSection({ reportKey, idKeyValue }: ReportSectionProps) {
export function ReportSection({ reportKey, idKeyValue, defaultView }: ReportSectionProps) {
const { data: catalog } = useQuery(api.reports.catalog.queryOptions());
const def = catalog?.find((r) => r.key === reportKey);
@@ -31,7 +33,7 @@ export function ReportSection({ reportKey, idKeyValue }: ReportSectionProps) {
{def.description}
</Text>
</div>
<ReportView reportKey={reportKey} idKeyValue={idKeyValue} />
<ReportView reportKey={reportKey} idKeyValue={idKeyValue} defaultView={defaultView} />
</Stack>
);
}

View File

@@ -1,21 +1,55 @@
import { ActionIcon, Alert, Box, Card, Group, SegmentedControl, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useQuery } from "@tanstack/react-query";
import type { Column, SortingState } from "@tanstack/react-table";
import { ArrowDown, ArrowUp, ArrowUpDown, LayoutGrid, LineChart, RefreshCw } from "lucide-react";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { PageHeader } from "@/components/page";
import { KpiStrip } from "@/components/page/KpiStrip";
import { FilterBar, dateRangeParams, useFilters, type FilterDef } from "@/components/filters";
import { api } from "@/services/api";
import type { ReportRunParams } from "@/types/reports";
import type { ReportFilterDef, ReportRunParams } from "@/types/reports";
import { DataTable, DataTableFooter, usePagination, type ColumnDef } from "@edr/ui-common";
import { ReportChart } from "./ReportChart";
import { ReportExportButton } from "./ReportExportButton";
import { ReportFilters, type ReportFilterValues } from "./ReportFilters";
import { formatKpiValue, formatReportCell } from "./report-format";
/**
* Maps the report catalog's own filter vocabulary onto the shared FilterBar's
* `FilterDef`. "search" is skipped — FilterBar already renders its own search
* box wired to the same `search` param, so keeping the catalog's declared
* "search" filter too would just double it up as a redundant pill.
*/
function toFilterDefs(filters: ReportFilterDef[]): FilterDef[] {
return filters
.filter((f) => f.key !== "search")
.map((f): FilterDef => {
switch (f.type) {
case "daterange":
return {
key: f.key,
label: f.label,
type: "date",
operators: ["between", "before", "after"],
toParams: dateRangeParams(`${f.key}From`, `${f.key}To`),
};
case "date":
// Every report's single-date filter (e.g. "as of") is an exact
// cutoff, not a range — one fixed operator keeps DateBody on its
// single-date UI instead of the range picker.
return { key: f.key, label: f.label, type: "date", operators: ["before"] };
case "select":
return { key: f.key, label: f.label, type: "enum", multiple: false, options: f.options ?? [] };
case "multiselect":
return { key: f.key, label: f.label, type: "enum", multiple: true, options: f.options ?? [] };
default:
return { key: f.key, label: f.label, type: "text" };
}
});
}
function SortableHeader({ label, column }: { label: string; column: Column<Record<string, unknown>, unknown> }) {
const sorted = column.getIsSorted();
const Icon = sorted === "asc" ? ArrowUp : sorted === "desc" ? ArrowDown : ArrowUpDown;
@@ -40,6 +74,8 @@ interface ReportViewProps {
* arrow) with export/refresh as its actions, instead of inline above the
* table. Off by default for embedded sections. */
pageHeader?: boolean;
/** Opens on the chart instead of the table — for dashboard tiles. */
defaultView?: "table" | "chart";
}
/**
@@ -47,15 +83,29 @@ interface ReportViewProps {
* filters, KPI strip, sortable/paginated table or chart, xlsx/pdf export.
* Adding a report never touches this file.
*/
export function ReportView({ reportKey, idKeyValue, pageHeader }: ReportViewProps) {
export function ReportView({ reportKey, idKeyValue, pageHeader, defaultView }: ReportViewProps) {
const { data: catalog } = useQuery(api.reports.catalog.queryOptions());
const def = catalog?.find((r) => r.key === reportKey);
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 20 });
const [sorting, setSorting] = useState<SortingState>([]);
const [filterValues, setFilterValues] = useState<ReportFilterValues>({});
const [debouncedFilters] = useDebouncedValue(filterValues, 300);
const [view, setView] = useState<"table" | "chart">("table");
const [view, setView] = useState<"table" | "chart">(defaultView ?? "table");
// FilterBar's own state — reads/writes the URL directly, same as
// BookingRequestsPage, so a drilled-into or shared report URL opens
// already filtered.
const reportFilterDefs = useMemo(() => toFilterDefs(def?.filters ?? []), [def?.filters]);
const controls = useFilters(reportFilterDefs);
// useFilters also tracks its own page/pageSize — unused here, this report
// view paginates itself (and overrides pageSize for the chart view below).
const filterParams = useMemo(() => {
const rest = { ...controls.params };
delete rest.page;
delete rest.pageSize;
return rest;
}, [controls.params]);
// Filters + sort as the user currently has them — independent of the view
// toggle's paging, so export always matches what's on screen either way.
@@ -64,10 +114,18 @@ export function ReportView({ reportKey, idKeyValue, pageHeader }: ReportViewProp
return {
sortBy: sort?.id,
sortOrder: sort ? (sort.desc ? "DESC" as const : "ASC" as const) : undefined,
...debouncedFilters,
...filterParams,
...(def?.idKey && idKeyValue ? { [def.idKey.key]: idKeyValue } : {}),
};
}, [def, sorting, debouncedFilters, idKeyValue]);
}, [def, sorting, filterParams, idKeyValue]);
// A filter change should land back on page 1, same as every other
// FilterBar page — but pagination here is local (not URL-driven via
// controls.tableProps), so it needs an explicit reset.
useEffect(() => {
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filterParams]);
const runParams: ReportRunParams | undefined = useMemo(() => {
if (!def) return undefined;
@@ -87,6 +145,30 @@ export function ReportView({ reportKey, idKeyValue, pageHeader }: ReportViewProp
enabled: Boolean(runParams),
});
/**
* Row click carries this row's values into the target report as filter
* params — the "summary to transaction level" drill-down. Undefined unless
* the report declares `drill`, which is what leaves the row unclickable.
*/
const handleRowClick = useMemo(() => {
const drill = def?.drill;
if (!drill) return undefined;
return (row: Record<string, unknown>) => {
const params = new URLSearchParams();
for (const [column, filterKey] of Object.entries(drill.carry)) {
const value = row[column];
if (value !== null && value !== undefined && value !== "") {
params.set(filterKey, String(value));
}
}
// Carry the filters already applied, so the drill narrows rather than resets.
for (const [key, value] of Object.entries(appliedParams)) {
if (typeof value === "string" && value && !params.has(key)) params.set(key, value);
}
navigate(`/dashboard/reports/${drill.to}?${params.toString()}`);
};
}, [def?.drill, appliedParams, navigate]);
const total = data?.meta.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
@@ -167,25 +249,23 @@ export function ReportView({ reportKey, idKeyValue, pageHeader }: ReportViewProp
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm">
<Group justify="space-between" gap="md" wrap="wrap">
<ReportFilters
filters={def.filters}
values={filterValues}
onChange={(v) => {
setFilterValues(v);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
/>
<Group gap="xs">
{chartToggle}
{pageHeader ? null : (
<>
{exportButton}
{refreshButton}
</>
)}
</Group>
</Group>
<FilterBar
defs={reportFilterDefs}
controls={controls}
// Only a handful of reports actually implement the `search`
// param server-side (see toFilterDefs) — showing the box on
// every report would be a dead control on the rest.
showSearch={def.filters.some((f) => f.key === "search")}
searchPlaceholder="Search…"
>
{chartToggle}
{pageHeader ? null : (
<>
{exportButton}
{refreshButton}
</>
)}
</FilterBar>
</Box>
{view === "chart" && def.chart ? (
@@ -195,6 +275,7 @@ export function ReportView({ reportKey, idKeyValue, pageHeader }: ReportViewProp
<DataTable
columns={columns}
data={data?.items ?? []}
onRowClick={handleRowClick}
status={isLoading ? "loading" : isError ? "error" : "success"}
emptyMessage="No data for the selected filters."
error={isError ? { message: "Failed to load report.", onRetry: () => void refetch() } : undefined}

View File

@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { DateInput } from "@mantine/dates";
import { Loader2, Plus, Trash2 } from "lucide-react";
import {
ActionIcon,
@@ -162,16 +163,6 @@ const inputStyles = {
label: { fontWeight: 600, marginBottom: 6, color: "var(--mantine-color-gray-8)" },
} as const;
const FieldLabel = ({ label, required }: { label: string; required?: boolean }) => (
<Group gap={4} wrap="nowrap">
<span>{label}</span>
{required ? (
<Text component="span" c="red" size="sm">
*
</Text>
) : null}
</Group>
);
const RuleEngineFormDialog = ({
open,
@@ -417,7 +408,11 @@ const RuleEngineFormDialog = ({
);
}
const label = <FieldLabel label={field.label} required={field.required} />;
// A plain string, so Mantine renders the label and its required asterisk
// itself. Passing an element here put a flex box inside the <label>,
// which added a line of dead space above every input and bumped
// Mantine's own asterisk onto a line of its own.
const label = field.label;
if (field.type === "tierList") {
const rows = Array.isArray(values[field.name])
@@ -525,6 +520,7 @@ const RuleEngineFormDialog = ({
<MultiSelect
key={field.name}
label={label}
withAsterisk={field.required}
description={field.description}
placeholder={
selectOptionsLoading
@@ -614,6 +610,31 @@ const RuleEngineFormDialog = ({
);
}
if (field.type === "date") {
const raw = String(values[field.name] ?? "");
return (
<DateInput
key={field.name}
label={label}
description={field.description}
placeholder="Select date"
// Mantine's DateValue accepts a `YYYY-MM-DD` string, which is exactly
// what the API's date columns take — so the value passes straight
// through with no Date round-trip, and none of the UTC-parsing shift
// that `new Date("2026-01-01")` would introduce east of Greenwich.
value={raw || null}
onChange={(v) => setField(field.name, v ?? "")}
disabled={field.disabled || (field.disabledOnEdit && !!initialRecord)}
required={field.required}
error={fieldErrors[field.name] || undefined}
clearable
size="md"
radius="md"
styles={inputStyles}
/>
);
}
const isNumber = field.type === "number";
const computed = field.computeValue ? field.computeValue(values) : undefined;
@@ -622,7 +643,7 @@ const RuleEngineFormDialog = ({
key={field.name}
label={label}
description={field.description}
type={isNumber ? "number" : field.type === "date" ? "date" : "text"}
type={isNumber ? "number" : "text"}
// Every rule-engine number (sizes, capacities, counts, points, rates,
// display order) is a non-negative magnitude — reject negatives outright
// rather than letting a typed "-" reach the API.

View File

@@ -201,16 +201,20 @@ export function StatTile({
/**
* Origin → destination corridor visual: two anchored stops joined by a rail
* line. `variant="compact"` is for dense table rows; `default` for cards.
* `orientation="vertical"` stacks the stops as waypoints, which keeps a long
* yard name off one wide line in a table cell.
*/
export function RouteCorridor({
origin,
destination,
variant = "default",
orientation = "horizontal",
onDark = false,
}: {
origin?: string | null;
destination?: string | null;
variant?: "default" | "compact";
orientation?: "horizontal" | "vertical";
onDark?: boolean;
}) {
const compact = variant === "compact";
@@ -218,6 +222,48 @@ export function RouteCorridor({
const strong = onDark ? "white" : "var(--mantine-color-gray-8)";
const lineColor = onDark ? "rgba(255,255,255,0.4)" : "var(--mantine-color-gray-3)";
const accent = onDark ? "white" : freightBrand.primary;
const dot = compact ? 7 : 9;
if (orientation === "vertical") {
return (
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<Box
w={dot}
h={dot}
style={{
borderRadius: 999,
flexShrink: 0,
border: `2px solid ${accent}`,
background: onDark ? "transparent" : "white",
}}
/>
<Text size="sm" fw={600} c={strong} lh={1.2} truncate>
{origin ?? "—"}
</Text>
</Group>
{/* Rail between the stops, aligned to the dot centres. */}
<Box
ml={dot / 2 - 1}
style={{
width: 0,
height: compact ? 10 : 14,
borderLeft: `2px dashed ${lineColor}`,
}}
/>
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<Box
w={dot}
h={dot}
style={{ borderRadius: 999, flexShrink: 0, background: accent }}
/>
<Text size="sm" fw={600} c={strong} lh={1.2} truncate>
{destination ?? "—"}
</Text>
</Group>
</Stack>
);
}
return (
<Group gap={compact ? 6 : 8} wrap="nowrap" align="center" style={{ minWidth: 0 }}>

View File

@@ -4,7 +4,9 @@ import {
Alert,
Badge,
Button,
Card,
Checkbox,
CopyButton,
Group,
Loader,
Menu,
@@ -12,20 +14,25 @@ import {
NumberInput,
ScrollArea,
Select,
SimpleGrid,
Stack,
Table,
Tabs,
Text,
Textarea,
TextInput,
ThemeIcon,
Tooltip,
} from '@mantine/core';
import {
ArrowRightLeft,
Calendar,
Check,
CheckCheck,
ChevronDown,
ChevronRight,
ClipboardCheck,
Copy,
Eye,
FileText,
History,
@@ -83,6 +90,9 @@ import { StoreInventoryModal } from './StoreInventoryModal';
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions, warehousesAtStation, yardsForBooking } from './options';
import { openPdfBlob } from './pdf';
import ListControls from '@/components/common/ListControls';
import RuleEngineListFooter from '@/components/ruleEngine/RuleEngineListFooter';
import { useListControls } from '@/hooks/useListControls';
import '@/components/overview/overview.css';
type ImportUnloadAssignment = { bookingId: string; warehouseId: string; yardId: string; zoneId: string };
@@ -915,7 +925,10 @@ function EligibleTab({
),
[rows, statusOptions],
);
const selectableRows = statusFilteredRows.filter(canReceiveBooking);
const controls = useListControls(statusFilteredRows, {
searchKeys: ['reference', 'customer', 'origin', 'destination', 'containerNumber', 'cargo', 'cargoDescription'],
});
const selectableRows = controls.filteredRows.filter(canReceiveBooking);
const allSelected = selectableRows.length > 0 && selected.size === selectableRows.length;
const someSelected = selected.size > 0 && !allSelected;
const pendingReceiveRows = useMemo(
@@ -1075,7 +1088,7 @@ function EligibleTab({
<Group justify="space-between">
<Text size="sm" c="dimmed">
Selected: <b>{selected.size}</b> / {statusFilteredRows.length} eligible
Selected: <b>{selected.size}</b> / {controls.filteredRows.length} eligible
</Text>
<Group gap="xs">
<Button
@@ -1118,6 +1131,19 @@ function EligibleTab({
: `No eligible PAID ${direction.toLowerCase()} bookings to receive.`}
</Text>
) : (
<Stack gap="sm">
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Booking, customer, route, container, cargo…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
hasFilters={controls.hasFilters}
onReset={controls.reset}
showDateRange={false}
/>
<Table.ScrollContainer minWidth={1350}>
<Table highlightOnHover verticalSpacing="xs" striped>
<Table.Thead>
@@ -1146,7 +1172,7 @@ function EligibleTab({
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{statusFilteredRows.map((r) => {
{controls.pagedRows.map((r) => {
const canReceive = canReceiveBooking(r);
return (
<Table.Tr key={r.id}>
@@ -1242,6 +1268,14 @@ function EligibleTab({
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="bookings"
onPaginationChange={controls.setPagination}
/>
</Stack>
)}
<Modal
@@ -1340,7 +1374,10 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
const [expandedRow, setExpandedRow] = useState<string | null>(null);
const [inspectId, setInspectId] = useState<string | null>(null);
const pendingRows = rows.filter((r) => r.inspectionStatus !== 'PASSED');
const controls = useListControls(rows, {
searchKeys: ['bookingReference', 'customerName', 'containerNumber', 'cargoType', 'grnNumber', 'origin', 'destination'],
});
const pendingRows = controls.filteredRows.filter((r) => r.inspectionStatus !== 'PASSED');
const allSelected = pendingRows.length > 0 && selected.size === pendingRows.length;
const someSelected = selected.size > 0 && !allSelected;
const toggleAll = () =>
@@ -1404,6 +1441,19 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
No received export items awaiting inspection.
</Text>
) : (
<Stack gap="sm">
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Booking, GRN, customer, container, cargo, route…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
hasFilters={controls.hasFilters}
onReset={controls.reset}
showDateRange={false}
/>
<Table.ScrollContainer minWidth={1350}>
<Table highlightOnHover verticalSpacing="xs" striped>
<Table.Thead>
@@ -1430,7 +1480,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((r: ReadyToLoadRow) => {
{controls.pagedRows.map((r: ReadyToLoadRow) => {
const selectable = r.inspectionStatus !== 'PASSED';
return (
<Fragment key={r.id}>
@@ -1493,6 +1543,14 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="items"
onPaginationChange={controls.setPagination}
/>
</Stack>
)}
<InspectionReportModal
@@ -1517,9 +1575,12 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
const [targetScheduleId, setTargetScheduleId] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const allSelected = rows.length > 0 && selected.size === rows.length;
const controls = useListControls(rows, {
searchKeys: ['bookingReference', 'customerName', 'containerNumber', 'cargoType', 'grnNumber', 'origin', 'destination'],
});
const allSelected = controls.filteredRows.length > 0 && selected.size === controls.filteredRows.length;
const someSelected = selected.size > 0 && !allSelected;
const toggleAll = () => setSelected(allSelected ? new Set() : new Set(rows.map((r) => r.id)));
const toggleAll = () => setSelected(allSelected ? new Set() : new Set(controls.filteredRows.map((r) => r.id)));
const toggleOne = (id: string) =>
setSelected((prev) => {
const next = new Set(prev);
@@ -1589,9 +1650,9 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
<Group justify="space-between">
<Text size="sm" c="dimmed">
{selected.size > 0 ? (
<><b>{selected.size}</b> of {rows.length} selected</>
<><b>{selected.size}</b> of {controls.filteredRows.length} selected</>
) : (
<><b>{rows.length}</b> item{rows.length !== 1 ? 's' : ''} ready to load</>
<><b>{controls.filteredRows.length}</b> item{controls.filteredRows.length !== 1 ? 's' : ''} ready to load</>
)}
</Text>
<Button
@@ -1660,6 +1721,19 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
No EXPORT items with inspection PASSED waiting to be loaded.
</Text>
) : (
<Stack gap="sm">
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Booking, GRN, customer, container, cargo, route…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
hasFilters={controls.hasFilters}
onReset={controls.reset}
showDateRange={false}
/>
<Table.ScrollContainer minWidth={1200}>
<Table highlightOnHover verticalSpacing="xs" striped>
<Table.Thead>
@@ -1685,7 +1759,7 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((r: ReadyToLoadRow) => (
{controls.pagedRows.map((r: ReadyToLoadRow) => (
<Fragment key={r.id}>
<Table.Tr>
<Table.Td>
@@ -1739,6 +1813,14 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="items"
onPaginationChange={controls.setPagination}
/>
</Stack>
)}
</Stack>
);
@@ -1768,9 +1850,12 @@ function LoadedExportTab({
);
const [selected, setSelected] = useState<Set<string>>(new Set());
const allSelected = rows.length > 0 && selected.size === rows.length;
const controls = useListControls(rows, {
searchKeys: ['bookingReference', 'customerName', 'containerNumber', 'cargoType', 'grnNumber', 'origin', 'destination'],
});
const allSelected = controls.filteredRows.length > 0 && selected.size === controls.filteredRows.length;
const someSelected = selected.size > 0 && !allSelected;
const toggleAll = () => setSelected(allSelected ? new Set() : new Set(rows.map((r) => r.id)));
const toggleAll = () => setSelected(allSelected ? new Set() : new Set(controls.filteredRows.map((r) => r.id)));
const toggleOne = (id: string) =>
setSelected((prev) => {
const next = new Set(prev);
@@ -1802,11 +1887,11 @@ function LoadedExportTab({
<Text size="sm" c="dimmed">
{dispatchable ? (
<>
Selected: <b>{selected.size}</b> / {rows.length} loaded
Selected: <b>{selected.size}</b> / {controls.filteredRows.length} loaded
</>
) : (
<>
<b>{rows.length}</b> item{rows.length !== 1 ? 's' : ''} loaded
<b>{controls.filteredRows.length}</b> item{controls.filteredRows.length !== 1 ? 's' : ''} loaded
</>
)}
</Text>
@@ -1858,6 +1943,19 @@ function LoadedExportTab({
No LOADED export items {dispatchable ? 'waiting to dispatch' : 'yet'}.
</Text>
) : (
<Stack gap="sm">
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Booking, GRN, customer, container, cargo, route…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
hasFilters={controls.hasFilters}
onReset={controls.reset}
showDateRange={false}
/>
<Table.ScrollContainer minWidth={1200}>
<Table highlightOnHover verticalSpacing="xs" striped>
<Table.Thead>
@@ -1884,7 +1982,7 @@ function LoadedExportTab({
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((r: ReadyToLoadRow) => (
{controls.pagedRows.map((r: ReadyToLoadRow) => (
<Fragment key={r.id}>
<Table.Tr>
{dispatchable && (
@@ -1935,6 +2033,14 @@ function LoadedExportTab({
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="items"
onPaginationChange={controls.setPagination}
/>
</Stack>
)}
<ConfirmActionModal action={confirmAction} onClose={() => setConfirmAction(null)} />
</Stack>
@@ -2218,6 +2324,11 @@ export function ImportArriveQueueTab({
>({});
const [readyBySchedule, setReadyBySchedule] = useState<Record<string, boolean>>({});
const controls = useListControls(trains, {
searchKeys: ['trainNumber', 'route', 'origin', 'destination', 'status'],
dateKey: 'arrivalTime',
});
const autoUnload = async (train: ImportTrain) => {
const assignments = Object.entries(assignmentsBySchedule[train.scheduleId] ?? {})
.filter((entry): entry is [string, Required<ImportUnloadAssignmentDraft>] =>
@@ -2272,10 +2383,6 @@ export function ImportArriveQueueTab({
return (
<Stack gap="sm" mt="sm">
<Text size="sm" c="dimmed">
<b>{trains.length}</b> arrived import train{trains.length !== 1 ? 's' : ''}
</Text>
{isLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" />
@@ -2285,6 +2392,25 @@ export function ImportArriveQueueTab({
No arrived import trains. Trains appear here once their schedule status is ARRIVED.
</Text>
) : (
<Stack gap="sm">
<Group justify="space-between" align="flex-end" wrap="wrap">
<Text size="sm" c="dimmed">
<b>{controls.totalCount}</b> arrived import train{controls.totalCount !== 1 ? 's' : ''}
</Text>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Train #, route, origin, destination…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Arrival"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/>
</Group>
<Table.ScrollContainer minWidth={1500}>
<Table highlightOnHover verticalSpacing="xs" striped>
<Table.Thead>
@@ -2303,7 +2429,16 @@ export function ImportArriveQueueTab({
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{trains.map((t: ImportTrain) => {
{controls.pagedRows.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={11}>
<Text c="dimmed" ta="center" py="lg" size="sm">
No trains match the current filters.
</Text>
</Table.Td>
</Table.Tr>
) : (
controls.pagedRows.map((t: ImportTrain) => {
const isOpen = openId === t.scheduleId;
const fullyUnloaded = isFullyUnloaded(t);
const unloadedBookings = t.unloadedBookings ?? t.totalBookings - getPendingUnloadBookings(t);
@@ -2311,7 +2446,18 @@ export function ImportArriveQueueTab({
<Fragment key={t.scheduleId}>
<Table.Tr>
<Table.Td>
<Text size="xs" c="dimmed">{t.scheduleId.slice(0, 8)}</Text>
<Group gap={4} wrap="nowrap">
<Text size="xs" c="dimmed">{t.scheduleId.slice(0, 8)}</Text>
<CopyButton value={t.scheduleId}>
{({ copied, copy }) => (
<Tooltip label={copied ? 'Copied' : 'Copy schedule ID'} withArrow>
<ActionIcon size="xs" variant="subtle" color={copied ? 'teal' : 'gray'} onClick={copy}>
{copied ? <CheckCheck size={12} /> : <Copy size={12} />}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
</Group>
</Table.Td>
<Table.Td>
<Text size="sm" fw={600}>{t.trainNumber ?? '—'}</Text>
@@ -2390,10 +2536,19 @@ export function ImportArriveQueueTab({
)}
</Fragment>
);
})}
}))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="trains"
onPaginationChange={controls.setPagination}
/>
</Stack>
)}
<ConfirmActionModal action={confirmAction} onClose={() => setConfirmAction(null)} />
</Stack>
@@ -2872,6 +3027,41 @@ interface WarehouseFlowWorkbenchProps {
focusedBookingLabel?: string;
}
function WarehouseStatCard({
icon,
label,
value,
sub,
color,
}: {
icon: React.ReactNode;
label: string;
value: React.ReactNode;
sub: string;
color: string;
}) {
return (
<Card withBorder radius="md" padding="md">
<Group gap="sm" wrap="nowrap">
<ThemeIcon color={color} variant="light" size={40} radius="md">
{icon}
</ThemeIcon>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={800} fz={22} lh={1.1}>
{value}
</Text>
<Text size="sm" fw={600}>
{label}
</Text>
<Text size="xs" c="dimmed">
{sub}
</Text>
</Stack>
</Group>
</Card>
);
}
function WarehouseQueueTabs<TValue extends string>({
value,
onChange,
@@ -2939,6 +3129,7 @@ function LocateBookingTab({ enabled }: { enabled: boolean }) {
applied.status,
);
const { data: results = [], isFetching } = useInventoryInquiry(applied, enabled && hasSearch);
const controls = useListControls(results);
const normalizeDraft = (): InventoryInquiryFilter => ({
bookingReference: draft.bookingReference?.trim() || undefined,
@@ -3022,7 +3213,16 @@ function LocateBookingTab({ enabled }: { enabled: boolean }) {
No inventory found for the current filters.
</Text>
) : (
<WarehouseInquiryTable results={results} onView={setViewResult} />
<Stack gap="sm">
<WarehouseInquiryTable results={controls.pagedRows} onView={setViewResult} />
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="results"
onPaginationChange={controls.setPagination}
/>
</Stack>
)}
<InventoryInquiryDetailModal
@@ -3039,6 +3239,7 @@ function ImportWarehouseTabs({ enabled, onChanged }: { enabled: boolean; onChang
const { data: arriveRows = [] } = useQuery(api.warehouses.importArriveQueue.queryOptions({ enabled }));
const { data: unloadedRows = [] } = useQuery(api.warehouses.importUnloadedQueue.queryOptions({ enabled }));
const { data: dispatchRows = [] } = useQuery(api.warehouses.importPickupReadyQueue.queryOptions({ enabled }));
const totalBookings = arriveRows.reduce((sum, t) => sum + t.totalBookings, 0);
const tabs: WarehouseQueueTab<ImportWarehouseTab>[] = [
{
value: 'arrive-queue',
@@ -3067,6 +3268,13 @@ function ImportWarehouseTabs({ enabled, onChanged }: { enabled: boolean; onChang
return (
<Stack gap="md">
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="sm">
<WarehouseStatCard icon={<PackageOpen size={18} />} label="Arrived" value={arriveRows.length} sub="Import trains" color="edr-green" />
<WarehouseStatCard icon={<ClipboardCheck size={18} />} label="Unloaded" value={unloadedRows.length} sub="Import trains" color="blue" />
<WarehouseStatCard icon={<Send size={18} />} label="Dispatch Ready" value={dispatchRows.length} sub="Import trains" color="violet" />
<WarehouseStatCard icon={<Calendar size={18} />} label="Total Bookings" value={totalBookings} sub="Across arrived trains" color="orange" />
</SimpleGrid>
<WarehouseQueueTabs value={activeTab} onChange={setActiveTab} tabs={tabs} />
{activeTab === 'arrive-queue' && (
@@ -3152,6 +3360,14 @@ function ExportWarehouseTabs({
return (
<Stack gap="md">
<SimpleGrid cols={{ base: 1, xs: 2, md: 5 }} spacing="sm">
<WarehouseStatCard icon={<Truck size={18} />} label="Eligible" value={exportEligibleCount} sub="Export bookings" color="edr-green" />
<WarehouseStatCard icon={<ClipboardCheck size={18} />} label="Received" value={receivedRows.length} sub="Export bookings" color="blue" />
<WarehouseStatCard icon={<Train size={18} />} label="Ready To Load" value={readyRows.length} sub="Export bookings" color="teal" />
<WarehouseStatCard icon={<PackageCheck size={18} />} label="Loaded" value={loadedRows.length} sub="Export bookings" color="indigo" />
<WarehouseStatCard icon={<Send size={18} />} label="Dispatch Ready" value={loadedRows.length} sub="Export bookings" color="violet" />
</SimpleGrid>
<WarehouseQueueTabs value={activeTab} onChange={setActiveTab} tabs={tabs} />
{activeTab === 'receive-queue' && (

View File

@@ -57,6 +57,10 @@ export const URL_CONSTANTS = {
BASE: "/exchange-settings",
},
OPERATIONS_STANDARDS: {
BASE: "/operations-standards",
},
MANUAL_PAYMENT_SETTINGS: {
BASE: "/payment-settings/manual",
},
@@ -172,6 +176,12 @@ export const URL_CONSTANTS = {
EXPORT: (key: string) => `/reports/${key}/export`,
},
EXPORTS: {
CATALOG: "/exports",
COUNT: (key: string) => `/exports/${key}/count`,
DOWNLOAD: (key: string) => `/exports/${key}/download`,
},
OVERVIEW: {
BASE: "/overview",
BOOKINGS: "/overview/bookings",
@@ -563,6 +573,7 @@ export const URL_CONSTANTS = {
YARD_BY_ID: (id: string) => `/yards/${id}`,
YARD_DISTANCES: "/yard-distances",
OPERATIONS_TARGETS: "/operations-targets",
YARD_DISTANCE_BY_ID: (id: string) => `/yard-distances/${id}`,
SHIPPING_LINES: "/shipping-lines",

View File

@@ -221,6 +221,8 @@ export interface YardOption {
label: string;
value: string;
country: string;
/** The yard's business code — what config keyed on a station stores. */
code: string;
}
/**
@@ -243,6 +245,7 @@ export const useYardOptions = (enabled = true) =>
label: label && code ? `${label} (${code})` : label || code || String(row.id),
value: String(row.id),
country: String(row.country ?? ""),
code,
};
}),
});

View File

@@ -0,0 +1,35 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
operationsStandardsService,
type OperationsStandardsPatch,
} from "@/services/operationsStandards.service";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
const QUERY_KEY = ["operationsStandards"];
export const useOperationsStandardsQuery = () =>
useQuery({
queryKey: QUERY_KEY,
queryFn: () => operationsStandardsService.get(),
});
export const useUpdateOperationsStandards = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
return useMutation({
mutationFn: (patch: OperationsStandardsPatch) =>
operationsStandardsService.update(patch),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
toast.success(
t("operationsStandards.updated", "Operating standards updated"),
);
},
onError: handleError,
});
};

View File

@@ -20,6 +20,7 @@ import type {
SaveWarehousePayload,
SaveYardPayload,
SaveZonePayload,
WarehouseDashboardFilter,
WarehouseFilter,
} from '@/types/warehouse';
@@ -461,10 +462,10 @@ export function useInventoryActivity(id?: string) {
});
}
export function useWarehouseDashboard() {
export function useWarehouseDashboard(filter?: WarehouseDashboardFilter) {
return useQuery({
queryKey: ['warehouses', 'dashboard'],
queryFn: () => warehouseService.dashboard().then((r) => r.data),
queryKey: ['warehouses', 'dashboard', filter ?? {}],
queryFn: () => warehouseService.dashboard(filter).then((r) => r.data),
refetchInterval: DASHBOARD_REFETCH_MS,
});
}

View File

@@ -340,6 +340,12 @@ export const FREIGHT_PERMS = {
cancel: "edr_freight_app:warehouse_fee_invoices:cancel",
pay: "edr_freight_app:warehouse_fee_invoices:pay",
},
additionalCharges: {
view: "edr_freight_app:additional_charges:view",
create: "edr_freight_app:additional_charges:create",
send: "edr_freight_app:additional_charges:send",
cancel: "edr_freight_app:additional_charges:cancel",
},
/**
* Audit trail. View-only — the API exposes no write routes for audit rows,
* so there is no manage/delete counterpart to grant.
@@ -379,6 +385,12 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:settings:exchange_rate:view",
manage: "edr_freight_app:settings:exchange_rate:manage",
},
// Standard station stay, cycle and leg times, and the charged-tonnage
// factors the operations reports measure actual performance against.
operationsStandards: {
view: "edr_freight_app:settings:operations_standards:view",
manage: "edr_freight_app:settings:operations_standards:manage",
},
// Whether Finance may settle invoices by hand, per currency. Finance holds
// `view` (the worklist offers only enabled currencies); `manage` is admin.
manualPayment: {

View File

@@ -13,6 +13,7 @@ import {
Milestone,
MoreHorizontal,
Package,
Receipt,
RefreshCw,
Ship,
Truck,
@@ -64,6 +65,7 @@ import {
ContractOrdersPanel,
} from "@/components/bookings/detail";
import { WarehouseInfoCard } from "@/components/warehouses";
import { AdditionalPaymentsTab } from "@/components/bookings/AdditionalPaymentsTab";
import { getStatusMeta } from "@/features/bookings/booking-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import { formatDateTime, formatMoney } from "@/lib/format";
@@ -74,7 +76,10 @@ import {
useBookingMutations,
} from "@/hooks/bookings/useBookings";
import { useScrollToHash } from "@/hooks/useScrollToHash";
import { useFileViewer } from "@/hooks/useFileViewer";
import { bookingsService } from "@/services/bookings.service";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
export default function BookingRequestDetailPage() {
const { id } = useParams<{ id: string }>();
@@ -82,6 +87,12 @@ export default function BookingRequestDetailPage() {
const [searchParams, setSearchParams] = useSearchParams();
// Deep-link from a warehouse fee invoice → this booking's warehouse section.
useScrollToHash();
const { view, viewer } = useFileViewer();
const { user } = useAuth();
const canSeeAdditionalCharges = hasFreightPermission(
user,
FREIGHT_PERMS.additionalCharges.view,
);
// Consolidated pair: `?booking=<partnerId>` swaps the WHOLE page over to the
// other half of the shared wagon. Everything below — KPIs, stepper, the
@@ -206,7 +217,9 @@ export default function BookingRequestDetailPage() {
? "documents"
: requestedTab === "trucks"
? "trucks"
: "overview";
: requestedTab === "additional-charges"
? "additional-charges"
: "overview";
const setActiveTab = (tab: string | null) => {
const next = new URLSearchParams(searchParams);
if (tab && tab !== "overview") next.set("tab", tab);
@@ -509,6 +522,14 @@ export default function BookingRequestDetailPage() {
<Tabs.Tab value="trucks" leftSection={<Truck size={16} />}>
Trucks
</Tabs.Tab>
{canSeeAdditionalCharges && (
<Tabs.Tab
value="additional-charges"
leftSection={<Receipt size={16} />}
>
Additional payments
</Tabs.Tab>
)}
</Tabs.List>
<Tabs.Panel value="overview">
@@ -528,6 +549,11 @@ export default function BookingRequestDetailPage() {
<Tabs.Panel value="trucks">
<BookingTrucksPanel bookingId={booking.id} />
</Tabs.Panel>
{canSeeAdditionalCharges && (
<Tabs.Panel value="additional-charges">
<AdditionalPaymentsTab bookingId={booking.id} onViewFile={view} />
</Tabs.Panel>
)}
</Tabs>
</Grid.Col>
@@ -562,6 +588,7 @@ export default function BookingRequestDetailPage() {
</Grid.Col>
</Grid>
</Stack>
{viewer}
</PageContainer>
);
}

View File

@@ -27,8 +27,9 @@ import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
import { ExportButton } from "@/components/export/ExportButton";
import { formatDate, humanize } from "@/lib/format";
import { FilterBar, dateRangeParams, useFilters, type FilterDef } from "@/components/filters";
import { FilterBar, dateRangeParams, routeParams, useFilters, type FilterDef } from "@/components/filters";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
// BookingStatusTabs / Operations* queues removed — replaced by booking-kind tabs.
@@ -170,7 +171,7 @@ export default function BookingRequestsPage() {
{ key: "isGovernment", label: "Ownership", type: "enum", multiple: false, options: OWNERSHIP_OPTIONS, secondary: true },
{
key: "route", label: "Route", type: "route", options: yardOptions,
toParams: ({ v }) => ({ originYardId: v[0], destinationYardId: v[1] }),
toParams: routeParams("originYardId", "destinationYardId"),
},
{
key: "created", label: "Created", type: "date", secondary: true,
@@ -547,7 +548,9 @@ export default function BookingRequestsPage() {
controls={controls}
searchPlaceholder="Search booking, contract, customer or shipping line…"
viewId="booking-requests"
/>
>
<ExportButton datasetKey="bookings" params={controls.params} />
</FilterBar>
</Box>
{showEmpty ? (

View File

@@ -52,7 +52,8 @@ import {
DataTableFooter,
type ColumnDef,
} from "@edr/ui-common";
import { FilterBar, dateRangeParams, useFilters, type FilterDef } from "@/components/filters";
import { FilterBar, dateRangeParams, routeParams, useFilters, type FilterDef } from "@/components/filters";
import { ExportButton } from "@/components/export/ExportButton";
/** Every filterable status — the pill tabs are gone, so the select carries them all. */
const STATUS_OPTIONS = CONTRACT_LIST_TABS.flatMap((t) => t.statuses ?? []).map(
@@ -183,7 +184,7 @@ export default function ContractRequestsPage() {
label: "Route",
type: "route",
options: yardOptions,
toParams: ({ v }) => ({ originYardId: v[0], destinationYardId: v[1] }),
toParams: routeParams("originYardId", "destinationYardId"),
},
],
[filterOptions, yardOptions, serviceTypeOptions],
@@ -468,7 +469,9 @@ export default function ContractRequestsPage() {
searchPlaceholder="Search reference or customer…"
sortOptions={SORT_OPTIONS}
viewId="contract-requests"
/>
>
<ExportButton datasetKey="contracts" params={controls.params} />
</FilterBar>
</Box>
{showEmpty ? (

View File

@@ -38,6 +38,7 @@ import type { Company, CompanyStatus } from "@/types/customer";
import { isOnboardingDraft } from "@/types/customer";
import { DataTable, DataTableFooter, type ColumnDef } from "@edr/ui-common";
import { FilterBar, useFilters, type FilterDef } from "@/components/filters";
import { ExportButton } from "@/components/export/ExportButton";
/**
* The list's segmented views. "Pending approval" means submitted-and-awaiting-
@@ -318,6 +319,7 @@ export default function CustomersPage() {
{ label: "Active", value: "active" },
]}
/>
<ExportButton datasetKey="customers" params={controls.params} />
</FilterBar>
</Box>

View File

@@ -24,14 +24,21 @@ import { useOverview } from "@/hooks/useOverview";
import type { OverviewRange } from "@/types/overview";
import "@/components/overview/summary/overview-summary.css";
const RANGE_LABEL: Record<OverviewRange, string> = { "7d": "7d", "30d": "30d", "90d": "90d" };
const RANGE_LABEL: Record<OverviewRange, string> = {
"7d": "7d",
"30d": "30d",
"90d": "90d",
};
/** Which composition each role sees below the hero. */
const LAYOUTS: Record<OverviewLayoutKey, (props: RoleOverviewProps) => ReactElement> = {
const LAYOUTS: Record<
OverviewLayoutKey,
(props: RoleOverviewProps) => ReactElement
> = {
executive: ExecutiveOverview,
operations: OperationsOverview,
operation: OperationsOverview,
occ: OccOverview,
marketing: MarketingOverview,
marketer: MarketingOverview,
finance: FinanceOverview,
clearance: ClearanceOverview,
};
@@ -51,15 +58,17 @@ const OverviewPage = () => {
const [range, setRange] = useState<OverviewRange>("30d");
const queryClient = useQueryClient();
const { user } = useAuth();
const { data, isLoading, isError, error, refetch, isFetching } = useOverview(range);
const { data, isLoading, isError, error, refetch, isFetching } =
useOverview(range);
// Hero, range control and headline KPIs are role-neutral; everything below
// them is chosen by role key.
const layoutKey = resolveOverviewLayout(user);
const RoleLayout = LAYOUTS[layoutKey];
const RoleLayout = layoutKey ? LAYOUTS[layoutKey] : null;
const accessDenied =
(error as { response?: { status?: number } } | null)?.response?.status === 403;
(error as { response?: { status?: number } } | null)?.response?.status ===
403;
const handleRefresh = () => {
void refetch();
@@ -79,7 +88,9 @@ const OverviewPage = () => {
label={OVERVIEW_LAYOUT_LABEL[layoutKey]}
/>
{data ? (
<div style={{ marginTop: -52, paddingInline: 20, position: "relative" }}>
<div
style={{ marginTop: -52, paddingInline: 20, position: "relative" }}
>
<OverviewHeroKpis
kpis={data.kpis}
current={data.current}
@@ -100,7 +111,12 @@ const OverviewPage = () => {
>
<Stack gap="sm" align="flex-start">
<span>Check your connection and try again.</span>
<Button size="xs" variant="light" color="red" onClick={() => void refetch()}>
<Button
size="xs"
variant="light"
color="red"
onClick={() => void refetch()}
>
Retry
</Button>
</Stack>
@@ -117,7 +133,7 @@ const OverviewPage = () => {
<Stack mt="lg">
<OverviewSkeleton />
</Stack>
) : data ? (
) : data && RoleLayout ? (
<RoleLayout data={data} range={range} />
) : null}
</PageContainer>

View File

@@ -42,6 +42,7 @@ import {
} from "@/services/fleet/fleet.service";
import { DataTable, DataTableFooter } from "@edr/ui-common";
import { dateRangeParams, FilterBar, useFilters, type FilterDef, type FilterOption } from "@/components/filters";
import { ExportButton } from "@/components/export/ExportButton";
const DEFAULT_SLUG: FleetResourceSlug = "locomotives";
@@ -621,6 +622,9 @@ const FleetResourcePage = () => {
]}
styles={{ root: { background: "var(--mantine-color-gray-1)" } }}
/>
{config.exportKey ? (
<ExportButton datasetKey={config.exportKey} params={controls.params} />
) : null}
</FilterBar>
</Box>

View File

@@ -119,6 +119,11 @@ export interface FleetResourceConfig {
supportsSearch: boolean;
/** Server-side list filters (e.g. wagon status / readiness). */
listFilters?: FleetListFilterDef[];
/**
* Export dataset key for this resource. Omitted where no dataset exists yet,
* in which case the page renders no export button.
*/
exportKey?: string;
columns: FleetResourceColumn[];
formFields: FleetFormFieldDef[];
emptyValues: Record<string, unknown>;
@@ -182,6 +187,7 @@ const WAGON_EDITABLE_STATUS_OPTIONS = WAGON_STATUS_OPTIONS.filter(
export const FLEET_RESOURCES: FleetResourceConfig[] = [
{
slug: "locomotives",
exportKey: "locomotives",
label: "Locomotives",
subtitle: "Manage locomotive master data used by train scheduling and fleet operations",
basePath: "/dashboard/locomotives",
@@ -249,6 +255,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
},
{
slug: "trains",
exportKey: "trains",
label: "Trains",
subtitle: "Manage train master data independently from train scheduling",
basePath: "/dashboard/trains",
@@ -292,6 +299,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
},
{
slug: "wagons",
exportKey: "wagons",
label: "Wagons",
subtitle: "Manage wagon master data. Operational scheduling uses train schedules separately",
basePath: "/dashboard/wagons",

View File

@@ -1,6 +1,7 @@
import type { Freight } from "@edr/types";
import {
ActionIcon,
Badge,
Box,
Card,
Group,
@@ -11,34 +12,55 @@ import {
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useQuery } from "@tanstack/react-query";
import {
Banknote,
CircleDollarSign,
Landmark,
RefreshCw,
Search,
X,
} from "lucide-react";
import { Banknote, CircleDollarSign, Landmark, RefreshCw, Search, X } from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
InvoiceStatusBadge,
formatDate,
formatMoney,
humanize,
} from "@/components/customers";
import { InvoiceStatusBadge, formatDate, formatMoney, humanize } from "@/components/customers";
import { KpiStrip } from "@/components/page";
import CreditInvoiceActions from "@/components/shipping-lines/CreditInvoiceActions";
import { ExportButton } from "@/components/export/ExportButton";
import { useExchangeSettingsQuery } from "@/hooks/useExchangeSettings";
import { api } from "@/services/api";
import type { Invoice } from "@/types/invoice";
import {
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
import { DataTable, DataTableFooter, usePagination, type ColumnDef } from "@edr/ui-common";
/**
* Which record raised the invoice, not just which subsystem. The source label
* stays (it says how the charge arose); under it sits the reference a human
* actually recognises — booking, GRN, or the shipping line billed. Falls back
* to the bare label when the server resolved nothing.
*/
function InvoiceSourceCell({ invoice }: { invoice: Invoice }) {
const ref = invoice.sourceRef;
const detail = ref?.bookingReference ?? ref?.shippingLineName ?? null;
return (
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
<Text size="sm" c="edr-text" lh={1.2}>
{humanize(invoice.source)}
</Text>
{ref?.tradeDirection ? (
<Badge size="xs" variant="light" color="gray">
{ref.tradeDirection}
</Badge>
) : null}
</Group>
{detail ? (
<Text size="xs" c="dimmed" ff="monospace" lh={1.2} truncate>
{detail}
</Text>
) : null}
{/* GRN only when it adds something the booking reference doesn't. */}
{ref?.grnNumber ? (
<Text size="xs" c="dimmed" lh={1.2} truncate>
{ref.grnNumber}
</Text>
) : null}
</Stack>
);
}
/** Invoices tab body of `FinanceHubPage` — page chrome lives in the parent. */
export default function InvoicesPanel() {
@@ -46,9 +68,7 @@ export default function InvoicesPanel() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>(
"",
);
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>("");
const filter = useMemo(
() => ({
@@ -71,10 +91,7 @@ export default function InvoicesPanel() {
// Shipping-line credit invoices carry makerchecker actions (mark paid /
// cancel). One batched lookup fetches the visible rows' pending requests.
const creditInvoiceIds = useMemo(
() =>
rows
.filter((inv) => inv.source === "shipping_line_credit")
.map((inv) => inv.id),
() => rows.filter((inv) => inv.source === "shipping_line_credit").map((inv) => inv.id),
[rows],
);
const { data: pendingActions } = useQuery(
@@ -119,20 +136,15 @@ export default function InvoicesPanel() {
header: "Billed to",
cell: ({ row }) => (
<Text size="sm" c="edr-text" truncate maw={200}>
{row.original.company?.name ??
row.original.shippingLineCompany?.name ??
"—"}
{row.original.company?.name ?? row.original.shippingLineCompany?.name ?? "—"}
</Text>
),
},
{
id: "source",
header: "Source",
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{humanize(row.original.source)}
</Text>
),
size: 220,
cell: ({ row }) => <InvoiceSourceCell invoice={row.original} />,
},
{
id: "status",
@@ -171,7 +183,6 @@ export default function InvoicesPanel() {
},
{
id: "actions",
header: "Actions",
cell: ({ row }) => {
const inv = row.original;
// Only shipping-line credit invoices have manual makerchecker
@@ -223,99 +234,96 @@ export default function InvoicesPanel() {
/>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search by invoice number…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => setQuery(e.target.value)}
rightSection={
query ? (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
>
<X size={16} />
</ActionIcon>
) : null
}
style={{ flex: 1, minWidth: "240px" }}
radius="lg"
/>
<SegmentedControl
size="sm"
radius="md"
value={statusFilter || "all"}
onChange={(v) => {
setStatusFilter(
v === "all" ? "" : (v as Freight.InvoiceStatus),
);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
data={[
{ label: "All", value: "all" },
{ label: "Pending", value: "PENDING" },
{ label: "Payment processing", value: "PAYMENT_PROCESSING" },
{ label: "Paid", value: "PAID" },
{ label: "Overdue", value: "OVERDUE" },
]}
/>
<ActionIcon
variant="default"
size="lg"
radius="md"
aria-label="Refresh"
loading={isFetching}
onClick={() => void refetch()}
>
<RefreshCw size={16} />
</ActionIcon>
</Group>
</Box>
<Box style={{ overflowX: "auto" }} w="100%">
<Box miw={920}>
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
emptyMessage={
debouncedQuery
? "No invoices match your search."
: "No invoices yet."
}
error={
isError
? {
message: "Failed to load invoices.",
onRetry: () => void refetch(),
}
: undefined
}
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}
/>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search invoice, customer, booking ref, GRN or shipping line…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => setQuery(e.target.value)}
rightSection={
query ? (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
>
<X size={16} />
</ActionIcon>
) : null
}
style={{ flex: 1, minWidth: "240px" }}
radius="lg"
/>
<ExportButton datasetKey="invoices" params={filter} size="sm" />
<SegmentedControl
size="sm"
radius="md"
value={statusFilter || "all"}
onChange={(v) => {
setStatusFilter(v === "all" ? "" : (v as Freight.InvoiceStatus));
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
data={[
{ label: "All", value: "all" },
{ label: "Pending", value: "PENDING" },
{ label: "Payment processing", value: "PAYMENT_PROCESSING" },
{ label: "Paid", value: "PAID" },
{ label: "Overdue", value: "OVERDUE" },
]}
/>
<ActionIcon
variant="default"
size="lg"
radius="md"
aria-label="Refresh"
loading={isFetching}
onClick={() => void refetch()}
>
<RefreshCw size={16} />
</ActionIcon>
</Group>
</Box>
</Box>
</Stack>
<Box style={{ overflowX: "auto" }} w="100%">
<Box miw={920}>
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
emptyMessage={
debouncedQuery ? "No invoices match your search." : "No invoices yet."
}
error={
isError
? {
message: "Failed to load invoices.",
onRetry: () => void refetch(),
}
: undefined
}
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>
</Box>
</Stack>
</Card>
</Stack>
);

View File

@@ -25,6 +25,7 @@ import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { KpiStrip } from "@/components/page";
import { ExportButton } from "@/components/export/ExportButton";
import { formatDate, formatMoney } from "@/lib/format";
import { api } from "@/services/api";
import type { PaymentMethod, PaymentRow } from "@/services/payments.service";
@@ -295,6 +296,7 @@ export default function PaymentsPanel() {
}
style={{ flex: 1, minWidth: "200px" }}
/>
<ExportButton datasetKey="payments" params={filter} size="sm" />
<Select
placeholder="All methods"
clearable

View File

@@ -1,17 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { Navigate } from "react-router-dom";
import { api } from "@/services/api";
/**
* `/dashboard/reports` has no page of its own — it forwards to the first
* report the caller has access to (catalog order = registration order,
* already permission-filtered server-side), or home if they have none.
*/
export default function ReportsIndexRedirect() {
const { data: catalog, isLoading } = useQuery(api.reports.catalog.queryOptions());
if (isLoading) return null;
const first = catalog?.[0];
return <Navigate to={first ? `/dashboard/reports/${first.key}` : "/dashboard"} replace />;
}

View File

@@ -0,0 +1,79 @@
import { SimpleGrid, Stack, Title } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { Navigate } from "react-router-dom";
import { PageContainer, PageHeader } from "@/components/page";
import { ReportSection } from "@/components/reports/ReportSection";
import { api } from "@/services/api";
/**
* The dashboards the reporting specs ask for, assembled from reports that
* already exist rather than a second aggregation API: each tile is a
* `ReportSection` opened on its chart, and each one permission-gates itself by
* rendering nothing when the caller's catalog lacks that report.
*/
const REVENUE_TILES = [
"revenue-by-period",
"revenue-by-category",
"revenue-by-route",
"revenue-top-customers",
];
const OPERATIONS_TILES = [
"cargo-volume-performance",
"teu-performance",
"trainset-performance",
"turnaround-cycle",
];
export default function ReportsLandingPage() {
const { data: catalog, isLoading } = useQuery(api.reports.catalog.queryOptions());
if (isLoading) return null;
const visible = (keys: string[]) =>
keys.filter((key) => catalog?.some((r) => r.key === key));
const revenue = visible(REVENUE_TILES);
const operations = visible(OPERATIONS_TILES);
// No dashboard reports for this user — fall back to the old behaviour and
// send them to the first report they can actually open.
if (!revenue.length && !operations.length) {
const first = catalog?.[0];
return <Navigate to={first ? `/dashboard/reports/${first.key}` : "/dashboard"} replace />;
}
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title="Reports dashboard"
subtitle="Billed rail revenue and operational performance at a glance. Pick any report in the sidebar for the full table, filters and export."
/>
{revenue.length > 0 && (
<Stack gap="sm">
<Title order={3}>Revenue</Title>
<SimpleGrid cols={{ base: 1, xl: 2 }} spacing="lg">
{revenue.map((key) => (
<ReportSection key={key} reportKey={key} defaultView="chart" />
))}
</SimpleGrid>
</Stack>
)}
{operations.length > 0 && (
<Stack gap="sm">
<Title order={3}>Operations</Title>
<SimpleGrid cols={{ base: 1, xl: 2 }} spacing="lg">
{operations.map((key) => (
<ReportSection key={key} reportKey={key} defaultView="chart" />
))}
</SimpleGrid>
</Stack>
)}
</Stack>
</PageContainer>
);
}

View File

@@ -313,7 +313,9 @@ const RuleEngineResourcePage = () => {
(f) =>
f.name === "originYardId" ||
f.name === "fromYardId" ||
f.name === "toYardId",
f.name === "toYardId" ||
// Operational targets pick a station by yard code.
f.name === "dimensionKey",
),
);
const { data: yardOptions, isLoading: yardOptionsLoading } =
@@ -475,6 +477,20 @@ const RuleEngineResourcePage = () => {
.map(({ label, value }) => ({ label, value })),
};
}
// An operational target's key is a category, a container class, or a
// station's YARD CODE — never a yard id, because the reports match it
// against what their classification CASE emits.
if (field.name === "dimensionKey") {
const staticOptions = field.optionsFromValues;
return {
...field,
type: "select" as const,
optionsFromValues: (values: Record<string, unknown>) =>
String(values.dimension ?? "") === "station"
? (yardOptions ?? []).map(({ label, code }) => ({ label, value: code }))
: (staticOptions?.(values) ?? []),
};
}
if (field.name === "originYardId" || field.name === "destinationYardId") {
const end = field.name === "originYardId" ? "origin" : "destination";
return {

View File

@@ -142,6 +142,37 @@ const TRADE_DIRECTIONS = [
{ label: "Both", value: "BOTH" },
];
/**
* The cargo categories and container classes an operational target may be
* keyed on.
*
* Mirrors CARGO_CATEGORIES / CONTAINER_CLASSES in the API's
* `modules/reports/operations-classification.ts`, which is the source of truth:
* a report matches a target by this exact key, so a value here that the API
* does not emit is a plan the report will never find. The API spec
* `operations-classification.spec.ts` guards the API side of the pair.
*/
export const OPERATIONS_CARGO_CATEGORIES = [
{ label: "Multimodal container import", value: "CONTAINER_IMPORT_MULTIMODAL" },
{ label: "Unimodal container import", value: "CONTAINER_IMPORT_UNIMODAL" },
{ label: "Export container", value: "CONTAINER_EXPORT" },
{ label: "Empty container", value: "EMPTY_CONTAINER" },
{ label: "Fertilizer", value: "FERTILIZER" },
{ label: "RoRo", value: "RORO" },
{ label: "Break bulk", value: "BREAK_BULK" },
{ label: "Sand", value: "SAND" },
{ label: "Bulk", value: "BULK" },
{ label: "Other imports", value: "OTHER_IMPORT" },
{ label: "Other export cargo", value: "OTHER_EXPORT" },
];
export const OPERATIONS_CONTAINER_CLASSES = [
{ label: "Multimodal container import", value: "CONTAINER_IMPORT_MULTIMODAL" },
{ label: "Unimodal container import", value: "CONTAINER_IMPORT_UNIMODAL" },
{ label: "Full export container", value: "CONTAINER_EXPORT" },
{ label: "Empty container return", value: "EMPTY_CONTAINER_RETURN" },
];
// Mirrors the YardCountry enum in @edr/types — the only two countries on the line.
const YARD_COUNTRIES = [
{ label: "Ethiopia", value: "Ethiopia" },
@@ -478,6 +509,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
optional: true,
placeholder: "Select parent cargo type (optional)",
},
{
name: "fullTrainsetWagons",
label: "Wagons in a full trainset",
type: "number",
optional: true,
description:
"What the Trainset Performance report divides loaded wagons by — 37 for vehicles, 22 for sand. Leave blank to use the default in Operating standards.",
},
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
{ name: "hasLashing", label: "Charge lashing fee", type: "boolean" },
{
@@ -639,6 +678,108 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ name: "isActive", label: "Active", type: "boolean", description: "Off suspends the officer regardless of the validity window" },
],
},
{
slug: "operations-targets",
label: "Operational Targets",
category: "configuration",
subtitle:
"Planned TEU, trainsets and tonnage per period — the Plan column in the operations reports",
searchPlaceholder: "Search by category, station or note...",
supportsSearch: true,
cardTitleKey: "appliesToLabel",
cardSubtitleKey: "periodStart",
columns: [
// The *Label columns are readable twins the API sends alongside the stored
// codes (see OperationsTargetsService.toRow) — the codes themselves are
// enums the reports join on and stay out of the grid.
{ id: "periodStart", header: "Period start", accessorKey: "periodStart", format: "date" },
{ id: "periodLabel", header: "Period", accessorKey: "periodLabel" },
{ id: "metricLabel", header: "Metric", accessorKey: "metricLabel" },
{ id: "dimensionLabel", header: "Plan by", accessorKey: "dimensionLabel" },
{ id: "appliesToLabel", header: "Applies to", accessorKey: "appliesToLabel" },
{ id: "cargoCategoryLabel", header: "Cargo category", accessorKey: "cargoCategoryLabel" },
{ id: "plannedValue", header: "Plan", accessorKey: "plannedValue", format: "number" },
],
formFields: [
{
name: "metric",
label: "Metric",
type: "select",
required: true,
options: [
{ label: "TEU", value: "TEU" },
{ label: "Trainsets", value: "TRAINSET" },
{ label: "Volume (tons)", value: "VOLUME_TONS" },
],
},
{
name: "periodType",
label: "Period",
type: "select",
required: true,
options: [
{ label: "Weekly", value: "week" },
{ label: "Monthly", value: "month" },
{ label: "Quarterly", value: "quarter" },
{ label: "Yearly", value: "year" },
],
},
{
name: "periodStart",
label: "Period start",
type: "date",
required: true,
description: "Any date inside the period — snapped to its start on save.",
},
{
name: "dimension",
label: "Plan by",
type: "select",
required: true,
options: [
{ label: "Cargo category", value: "cargo_category" },
{ label: "Station", value: "station" },
{ label: "Container class", value: "container_class" },
],
},
{
name: "dimensionKey",
label: "Applies to",
type: "select",
required: true,
placeholder: "Select",
// The valid keys depend on the chosen dimension, and must match what the
// reports emit exactly — a mismatch here is a target the report never
// finds. Station options are the live yard codes, injected by
// RuleEngineResourcePage.
optionsFromValues: (values) => {
const dimension = String(values.dimension ?? "");
if (dimension === "container_class") return OPERATIONS_CONTAINER_CLASSES;
if (dimension === "station") return [];
return OPERATIONS_CARGO_CATEGORIES;
},
},
{
name: "cargoCategory",
label: "Cargo category",
type: "select",
required: true,
// A station's plan is per station AND per cargo type — the OCC report
// plans Nagad-Mojo container and Nagad-Mojo fertilizer separately. The
// other two dimensions already carry the category in the key above.
showWhen: { field: "dimension", equals: ["station"] },
options: OPERATIONS_CARGO_CATEGORIES,
},
{
name: "plannedValue",
label: "Planned value",
type: "number",
required: true,
description: "TEU, trainsets or tonnes — whichever the metric above is.",
},
{ name: "note", label: "Note", type: "text", optional: true },
],
},
{
slug: "yard-distances",
label: "Yard Distances",
@@ -652,6 +793,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ id: "fromYardLabel", header: "From yard", accessorKey: "fromYardLabel" },
{ id: "toYardLabel", header: "To yard", accessorKey: "toYardLabel" },
{ id: "distanceKm", header: "Distance (km)", accessorKey: "distanceKm", format: "number" },
{ id: "standardHours", header: "Standard (hrs)", accessorKey: "standardHours", format: "number" },
],
formFields: [
// Options injected at render from useYardOptions (RuleEngineResourcePage).
@@ -665,6 +807,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
description:
"Symmetric — one entry covers both directions. Route segments between these yards use this value.",
},
{
name: "standardHours",
label: "Standard running time (hrs)",
type: "number",
optional: true,
description:
"What the Train Delays report judges this leg against — 21h Negad to GMP, 20h to Adama, 20.5h to Modjo, 22h to Sebeta. Leave blank to use the default in Operating standards.",
},
],
},
{

View File

@@ -0,0 +1,282 @@
import { useState } from "react";
import { Save } from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import { Input } from "@/shared/common/ui/input";
import { Button } from "@/shared/common/ui/button";
import {
useOperationsStandardsQuery,
useUpdateOperationsStandards,
} from "@/hooks/useOperationsStandards";
import type { OperationsStandards } from "@/services/operationsStandards.service";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { useAuth } from "@/auth/useAuth";
type Field = {
name: keyof Omit<OperationsStandards, "id" | "updatedAt">;
label: string;
hint: string;
unit: string;
integer?: boolean;
};
type Section = { title: string; description: string; fields: Field[] };
/**
* Grouped the way the reporting spec reads, so an operator changing "the
* Djibouti standard" finds it next to the Ethiopian one rather than hunting a
* flat list of fifteen numbers.
*/
const SECTIONS: Section[] = [
{
title: "Station staying time",
description:
"How long a train may stand at a station before the stop needs a reason. Used by Station Staying Time.",
fields: [
{
name: "stationStandardHoursEthiopia",
label: "Ethiopian stations",
hint: "Standard stop on the Ethiopian side",
unit: "hrs",
},
{
name: "stationStandardHoursDjibouti",
label: "Djibouti stations",
hint: "Standard stop on the Djibouti side",
unit: "hrs",
},
],
},
{
title: "Turnaround cycle",
description:
"The full out-and-back a train is expected to complete in. Used by Turnaround Cycle.",
fields: [
{
name: "cycleStandardHoursContainer",
label: "Container",
hint: "10 + 21 + 13 + 21",
unit: "hrs",
},
{
name: "cycleStandardHoursBulkDmp",
label: "Bulk via DMP",
hint: "13 + 21 + 33 + 21",
unit: "hrs",
},
{
name: "cycleStandardHoursBulkNagad",
label: "Bulk via Negad",
hint: "13 + 21 + 41 + 21",
unit: "hrs",
},
{
name: "cycleStandardHoursBulkBcc",
label: "Bulk via BCC",
hint: "13 + 21 + 41 + 21",
unit: "hrs",
},
],
},
{
title: "Delay",
description:
"Used by Train Delays when a yard pair has no standard of its own. Per-corridor times live on Yard Distances.",
fields: [
{
name: "defaultLegStandardHours",
label: "Default leg standard",
hint: "Negad to GMP is 21 hours",
unit: "hrs",
},
{
name: "delayToleranceMinutes",
label: "Tolerance",
hint: "Grace before a leg counts as delayed",
unit: "min",
integer: true,
},
],
},
{
title: "Charged volume",
description:
"The standard weight capacity cargo is charged on, as opposed to what was weighed. Used by Charged and Actual Volumes.",
fields: [
{
name: "chargedTonsFull20ft",
label: "Laden 20ft container",
hint: "Per container",
unit: "t",
},
{
name: "chargedTonsFull40ft",
label: "Laden 40ft container",
hint: "Per container",
unit: "t",
},
{
name: "chargedTonsEmpty20ft",
label: "Empty 20ft container",
hint: "Per container",
unit: "t",
},
{
name: "chargedTonsEmpty40ft",
label: "Empty 40ft container",
hint: "Per container",
unit: "t",
},
{
name: "chargedTonsPerWagonGeneral",
label: "Wagon of steel, fertilizer, rice, sugar",
hint: "Per wagon",
unit: "t",
},
{
name: "chargedTonsPerWagonPerishable",
label: "Wagon of vegetables, milk, meat, livestock",
hint: "Per wagon",
unit: "t",
},
],
},
{
title: "Trainset",
description:
"Used by Trainset Performance when a cargo type has no wagon count of its own — set those on Cargo Types.",
fields: [
{
name: "defaultFullTrainsetWagons",
label: "Wagons in a full trainset",
hint: "37 for vehicles and 22 for sand are set per cargo type",
unit: "wagons",
integer: true,
},
],
},
];
const ALL_FIELDS = SECTIONS.flatMap((s) => s.fields);
/**
* The operating standards the operations reports measure against.
*
* A single settings row rather than constants in the code, because the business
* treats these as tunable — the corridor standard is explicitly described as
* flexible. Every value here changes what a report calls on-time, encouraging,
* or on plan, so the page shows what each one drives.
*/
export default function OperationsStandardsPage() {
const { user } = useAuth();
const { data, isLoading } = useOperationsStandardsQuery();
const update = useUpdateOperationsStandards();
const [draft, setDraft] = useState<Record<string, string>>({});
const canEdit =
hasPermission(user, FREIGHT_PERMS.settings.operationsStandards.manage) ||
hasPermission(user, FREIGHT_PERMS.admin);
const valueOf = (field: Field): string =>
draft[field.name] ?? (data ? String(data[field.name] ?? "") : "");
const invalid = (field: Field): boolean => {
const raw = draft[field.name];
if (raw === undefined) return false;
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) return true;
return field.integer ? !Number.isInteger(parsed) : false;
};
const anyInvalid = ALL_FIELDS.some(invalid);
const dirty = Object.keys(draft).length > 0;
const handleSave = async () => {
if (anyInvalid || !dirty) return;
const patch = Object.fromEntries(
Object.entries(draft).map(([key, value]) => [key, Number(value)]),
);
await update.mutateAsync(patch);
setDraft({});
};
return (
<div className="p-4 space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold">Operating standards</h1>
<p className="text-sm text-muted-foreground max-w-3xl">
The figures every operations report measures actual performance
against. Changing one changes what the reports call on time, over
standard, or on plan it does not change any charge a customer
pays.
</p>
</div>
<Button
onClick={handleSave}
disabled={!canEdit || !dirty || anyInvalid || update.isPending}
>
<Save className="h-4 w-4 mr-2" />
{update.isPending ? "Saving..." : "Save changes"}
</Button>
</div>
{SECTIONS.map((section) => (
<Card key={section.title}>
<CardHeader>
<CardTitle>{section.title}</CardTitle>
<CardDescription>{section.description}</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{section.fields.map((field) => (
<div key={field.name} className="space-y-1">
<label
className="text-sm font-medium"
htmlFor={`standard-${field.name}`}
>
{field.label}
</label>
<div className="flex items-center gap-2">
<Input
id={`standard-${field.name}`}
type="number"
step={field.integer ? 1 : 0.01}
min={field.integer ? 1 : 0.01}
value={valueOf(field)}
disabled={isLoading || !canEdit}
aria-invalid={invalid(field)}
onChange={(e) =>
setDraft((d) => ({ ...d, [field.name]: e.target.value }))
}
/>
<span className="text-sm text-muted-foreground w-16">
{field.unit}
</span>
</div>
<p className="text-xs text-muted-foreground">
{invalid(field)
? field.integer
? "Must be a whole number above zero"
: "Must be above zero"
: field.hint}
</p>
</div>
))}
</CardContent>
</Card>
))}
{!canEdit && (
<p className="text-sm text-muted-foreground">
You can view these standards but not change them.
</p>
)}
</div>
);
}

View File

@@ -5,11 +5,13 @@ import {
Box,
Button,
Card,
Center,
Checkbox,
Divider,
Group,
Menu,
Modal,
SegmentedControl,
Select,
SimpleGrid,
Stack,
@@ -19,7 +21,6 @@ import {
ThemeIcon,
} from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import { useDebouncedValue } from "@mantine/hooks";
import { isAxiosError } from "axios";
import {
ArrowRight,
@@ -27,27 +28,33 @@ import {
CalendarClock,
Clock,
Eye,
LayoutGrid,
MoreHorizontal,
Navigation,
Pencil,
Play,
Send,
Table2,
Train,
Weight,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import {
FilterBar,
routeParams,
toRuleEngineFooterProps,
useFilters,
type FilterDef,
type SortOption,
} from "@/components/filters";
import { useFleetViewMode, type FleetViewMode } from "@/components/fleet/useFleetViewMode";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import {
directionColor,
directionRowStyle,
} from "@/components/trainBuilder/trainStatus";
import { directionColor, directionRowStyle } from "@/components/trainBuilder/trainStatus";
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
import CreateScheduleWindowFields, {
buildWindowRulePayload,
@@ -55,10 +62,8 @@ import CreateScheduleWindowFields, {
} from "@/components/trainScheduling/CreateScheduleWindowFields";
import EditScheduleDateModal from "@/components/trainScheduling/EditScheduleDateModal";
import { showScheduleWarnings } from "@/components/trainScheduling/locomotiveOptions";
import {
RouteCorridor,
StatusPill,
} from "@/components/trainScheduling/scheduleVisuals";
import { ExportButton } from "@/components/export/ExportButton";
import { RouteCorridor, StatusPill } from "@/components/trainScheduling/scheduleVisuals";
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { formatRouteLabel } from "@/services/routes.service";
@@ -67,12 +72,34 @@ import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, canCreateSchedule, hasPermission } from "@/lib/permissions";
import type {
CreateScheduleWindowRulePayload,
FreightType,
TrainScheduleListFilters,
TrainScheduleListItem,
TrainScheduleStatus,
} from "@/types/trainScheduling";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
import { DataTable, DataTableFooter } from "@edr/ui-common";
const SCHEDULE_STATUS_OPTIONS = [
{ value: "DRAFT", label: "Draft" },
{ value: "SCHEDULED", label: "Scheduled" },
{ value: "DISPATCHED", label: "Dispatched" },
{ value: "ARRIVED", label: "Arrived" },
{ value: "CANCELLED", label: "Cancelled" },
];
const FREIGHT_TYPE_OPTIONS = [
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
{ value: "MIXED", label: "Mixed" },
];
/** Server sort fields (TRAIN_SCHEDULE_SORT_FIELDS) in the shared "field:DIR" form. */
const SORT_OPTIONS: SortOption[] = [
{ value: "createdAt:DESC", label: "Newest created" },
{ value: "createdAt:ASC", label: "Oldest created" },
{ value: "scheduledDepartureDate:DESC", label: "Departure ↓" },
{ value: "scheduledDepartureDate:ASC", label: "Departure ↑" },
{ value: "reference:ASC", label: "Reference ↑" },
{ value: "reference:DESC", label: "Reference ↓" },
];
/** `min` for a `datetime-local` input: now, in the browser's local zone. */
const nowLocalDateTime = () => {
@@ -114,34 +141,16 @@ export default function TrainScheduleV2ListPage() {
const canCreate = canCreateSchedule(user);
const canDispatch = hasPermission(user, FREIGHT_PERMS.trainScheduling.dispatch);
const { viewMode, setViewMode } = useFleetViewMode("train-scheduling-v2");
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 300);
const [statusFilter, setStatusFilter] = useState<"ALL" | TrainScheduleStatus>("ALL");
const [freightFilter, setFreightFilter] = useState<"ALL" | FreightType>("ALL");
// Origin/destination hold yard IDs ("ALL" = no filter); the server matches
// the schedule's origin_station_id / destination_station_id exactly.
const [originFilter, setOriginFilter] = useState("ALL");
const [destinationFilter, setDestinationFilter] = useState("ALL");
// Default: newest-created first, matching the API's default order. Values
// are the server sort fields (see TRAIN_SCHEDULE_SORT_FIELDS).
const [sortBy, setSortBy] = useState<
"createdAt" | "scheduledDepartureDate" | "reference"
>("createdAt");
const [sortDir, setSortDir] = useState<"desc" | "asc">("desc");
const [createOpen, setCreateOpen] = useState(false);
const [windowSettingsId, setWindowSettingsId] = useState<string | null>(null);
// Dispatch is irreversible from this screen, so it goes through an explicit
// confirmation.
const [dispatchTarget, setDispatchTarget] =
useState<TrainScheduleListItem | null>(null);
const [dispatchTarget, setDispatchTarget] = useState<TrainScheduleListItem | null>(null);
// Actual departure — defaults to now when the dialog opens; past is fine.
const [dispatchAt, setDispatchAt] = useState<Date | 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 [cancelTarget, setCancelTarget] = useState<TrainScheduleListItem | null>(null);
const [editDateSchedule, setEditDateSchedule] = useState<TrainScheduleListItem | null>(null);
const [routeId, setRouteId] = useState("");
const [scheduleDate, setScheduleDate] = useState("");
const [trainId, setTrainId] = useState("");
@@ -155,51 +164,59 @@ export default function TrainScheduleV2ListPage() {
const [windowForm, setWindowForm] = useState<WindowFormState | null>(null);
// Recomputed each time the create modal opens so a long-lived tab can't keep
// offering a stale "now" as the earliest selectable departure.
const minScheduleDate = useMemo(
() => (createOpen ? nowLocalDateTime() : ""),
[createOpen],
const minScheduleDate = useMemo(() => (createOpen ? nowLocalDateTime() : ""), [createOpen]);
// Yard options for the origin/destination filters (shared routes reference
// list, so the choices don't shrink to whatever the current page shows).
const yardsQuery = useQuery(api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }));
const yardOptions = useMemo(
() =>
(yardsQuery.data ?? []).map((y) => ({
value: y.id,
label: y.label ?? y.code,
})),
[yardsQuery.data],
);
const resetPage = useCallback(() => {
setPagination((prev) =>
prev.pageIndex === 0 ? prev : { ...prev, pageIndex: 0 },
);
}, [setPagination]);
// One Route pill covering both ends. It is the paired `route` type — which
// no longer forces both sides to be filled — so filtering by origin alone,
// by destination alone, or by several yards per side all still work, and the
// two ends read as the one thing an operator is actually picking.
const scheduleFilterDefs: FilterDef[] = useMemo(
() => [
{
key: "status",
label: "Status",
type: "enum",
multiple: false,
options: SCHEDULE_STATUS_OPTIONS,
},
{
key: "freightType",
label: "Freight",
type: "enum",
multiple: false,
options: FREIGHT_TYPE_OPTIONS,
},
{
key: "route",
label: "Route",
type: "route",
options: yardOptions,
toParams: routeParams("originStationId", "destinationStationId"),
},
],
[yardOptions],
);
// Search resets the page only once the debounced value settles — resetting
// per keystroke would refetch page 1 mid-typing.
useEffect(() => {
resetPage();
}, [debouncedSearch, resetPage]);
const controls = useFilters(scheduleFilterDefs, {
defaultSort: "createdAt:DESC",
pageSize: 10,
});
// Fully server-driven list: pagination, search, filters, and sort all travel
// as query params; the response envelope carries the page + totals.
const filters = useMemo<TrainScheduleListFilters>(
() => ({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}),
...(statusFilter !== "ALL" ? { status: statusFilter } : {}),
...(freightFilter !== "ALL" ? { freightType: freightFilter } : {}),
...(originFilter !== "ALL" ? { originStationId: originFilter } : {}),
...(destinationFilter !== "ALL"
? { destinationStationId: destinationFilter }
: {}),
sortBy,
sortOrder: sortDir === "asc" ? "ASC" : "DESC",
}),
[
pagination.pageIndex,
pagination.pageSize,
debouncedSearch,
statusFilter,
freightFilter,
originFilter,
destinationFilter,
sortBy,
sortDir,
],
);
const filters = controls.params as unknown as TrainScheduleListFilters;
const schedulesQuery = useQuery(
api.trainScheduling.scheduleList.queryOptions({
@@ -211,14 +228,7 @@ export default function TrainScheduleV2ListPage() {
staleTime: 30_000,
}),
);
// Yard options for the origin/destination filters (shared routes reference
// list, so the choices don't shrink to whatever the current page shows).
const yardsQuery = useQuery(
api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }),
);
const routesQuery = useQuery(
api.routes.list.queryOptions({ input: { status: "AVAILABLE" } }),
);
const routesQuery = useQuery(api.routes.list.queryOptions({ input: { status: "AVAILABLE" } }));
const trainsQuery = useQuery(
api.trainScheduling.availableTrains.queryOptions({
input: { routeId },
@@ -235,9 +245,7 @@ export default function TrainScheduleV2ListPage() {
}),
);
const create = useMutation(api.trainScheduling.createSchedule.mutationOptions());
const dispatchSchedule = useMutation(
api.trainScheduling.dispatchSchedule.mutationOptions(),
);
const dispatchSchedule = useMutation(api.trainScheduling.dispatchSchedule.mutationOptions());
const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions());
// Intercity (same-country / DOMESTIC) routes cannot be scheduled yet — the
@@ -252,9 +260,7 @@ export default function TrainScheduleV2ListPage() {
const trainYardHint = useMemo(() => {
if (!selectedRoute) return "Select a route first";
const originLabel =
selectedRoute.originYard?.label ??
selectedRoute.originYard?.code ??
"the route origin yard";
selectedRoute.originYard?.label ?? selectedRoute.originYard?.code ?? "the route origin yard";
return `All schedulable built trains are shown — those not yet at ${originLabel} or already on future schedules are flagged`;
}, [selectedRoute]);
@@ -266,7 +272,6 @@ export default function TrainScheduleV2ListPage() {
// current page, and the meta envelope carries the totals.
const schedules = schedulesQuery.data?.items ?? [];
const totalSchedules = schedulesQuery.data?.meta.total ?? 0;
const pageCount = Math.max(1, schedulesQuery.data?.meta.totalPages ?? 1);
// Status/weight tiles count the visible page only — board-wide numbers would
// need a dedicated summary endpoint now that the list is server-paginated.
@@ -286,92 +291,62 @@ export default function TrainScheduleV2ListPage() {
return base;
}, [schedules]);
// Corridor filter options: every yard from the shared reference list, sent
// to the server as origin/destination station IDs.
const yardOptions = useMemo(
() =>
(yardsQuery.data ?? []).map((y) => ({
value: y.id,
label: y.label ?? y.code,
})),
[yardsQuery.data],
);
const columns = useMemo((): ColumnDef<TrainScheduleListItem>[] => {
const headerClassName = ruleEngineTable.headerCell;
const cellClassName = ruleEngineTable.bodyCell;
return [
{
id: "reference",
header: "Ref",
// Train, reference and status share one identity column — three
// stacked lines cost the width of the widest, not three columns.
id: "train",
header: "Train",
size: 170,
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Text size="sm" fw={600} ff="monospace" c="edr-green.8">
{row.original.reference ?? "—"}
</Text>
),
cell: ({ row }) => <TrainIdentityCell schedule={row.original} />,
},
{
id: "date",
header: "Departure",
size: 110,
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const { day, time } = splitDate(row.original.scheduleDate);
return (
<Group gap="sm" wrap="nowrap">
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 34,
height: 34,
borderRadius: 9,
background: "var(--mantine-color-edr-green-0)",
color: "var(--mantine-color-edr-green-7)",
flexShrink: 0,
}}
>
<CalendarClock size={16} />
</Box>
<Stack gap={0}>
<Text size="sm" fw={600} lh={1.2}>
{day}
</Text>
<Text size="xs" c="dimmed" lh={1.2}>
{time || "—"}
</Text>
</Stack>
</Group>
<Stack gap={0}>
<Text size="sm" fw={600} lh={1.2}>
{day}
</Text>
<Text size="xs" c="dimmed" lh={1.2}>
{time || "—"}
</Text>
</Stack>
);
},
},
{
id: "route",
header: "Route",
size: 280,
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Stack gap={4}>
<Stack gap={6}>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600} lh={1.2}>
{row.original.routeName ?? "—"}
</Text>
{row.original.direction ? (
<Badge
size="xs"
variant="light"
color={directionColor(row.original.direction)}
>
<Badge size="xs" variant="light" color={directionColor(row.original.direction)}>
{row.original.direction}
</Badge>
) : null}
<ShippingLineBadge schedule={row.original} />
</Group>
<Box maw={220}>
<Box maw={260}>
<RouteCorridor
origin={row.original.origin}
destination={row.original.destination}
variant="compact"
orientation="vertical"
/>
</Box>
</Stack>
@@ -383,59 +358,6 @@ export default function TrainScheduleV2ListPage() {
meta: { headerClassName, cellClassName },
cell: ({ row }) => <FreightTypeBadge freightType={row.original.freightType} />,
},
{
id: "train",
header: "Train",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
// Schedules created from the Train Builder show the direction-matched
// run number first (falling back to the train code); legacy rows fall
// back to their locomotive set.
if (row.original.train) {
const subtitle = [row.original.trainNumber ? row.original.train.code : null,
row.original.train.trainName]
.filter(Boolean)
.join(" · ");
return (
<Group gap={6} wrap="nowrap">
<Train size={14} color="var(--mantine-color-gray-5)" />
<Stack gap={0}>
<Text size="sm" fw={600} ff="monospace" lh={1.2}>
{row.original.trainNumber ?? row.original.train.code}
</Text>
{subtitle ? (
<Text size="xs" c="dimmed" lh={1.2}>
{subtitle}
</Text>
) : null}
</Stack>
</Group>
);
}
const locos =
row.original.locomotives && row.original.locomotives.length > 0
? row.original.locomotives
: row.original.locomotive
? [row.original.locomotive]
: [];
if (!locos.length) {
return (
<Text size="sm" c="dimmed">
</Text>
);
}
return (
<Group gap={6} wrap="nowrap">
<Train size={14} color="var(--mantine-color-gray-5)" />
<Text size="sm" fw={500}>
{locos[0].code}
{locos.length > 1 ? ` +${locos.length - 1}` : ""}
</Text>
</Group>
);
},
},
{
id: "metrics",
header: "Load",
@@ -448,15 +370,9 @@ export default function TrainScheduleV2ListPage() {
</Group>
),
},
{
id: "status",
header: "Status",
meta: { headerClassName, cellClassName },
cell: ({ row }) => <StatusPill status={row.original.status} />,
},
{
id: "actions",
size:32,
size: 32,
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
cell: ({ row }) => {
const schedule = row.original;
@@ -481,9 +397,7 @@ export default function TrainScheduleV2ListPage() {
<Menu.Item
leftSection={<Navigation size={15} />}
onClick={() =>
navigate(
`/dashboard/operations/train-scheduling-v2/${schedule.id}/track`,
)
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}/track`)
}
>
Track
@@ -560,10 +474,7 @@ export default function TrainScheduleV2ListPage() {
toast({ title: "Booking window settings are still loading", variant: "destructive" });
return;
}
const built = buildWindowRulePayload(
windowForm,
selectedRoute?.direction === "EXPORT",
);
const built = buildWindowRulePayload(windowForm, selectedRoute?.direction === "EXPORT");
if ("error" in built) {
toast({ title: built.error, variant: "destructive" });
return;
@@ -631,114 +542,42 @@ export default function TrainScheduleV2ListPage() {
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<FleetToolbar
search={search}
onSearchChange={setSearch}
<FilterBar
defs={scheduleFilterDefs}
controls={controls}
searchPlaceholder="Search schedules…"
viewMode={viewMode}
onViewModeChange={setViewMode}
filters={
<>
<Select
size="sm"
radius="lg"
value={statusFilter}
onChange={(v) => {
if (!v) return;
setStatusFilter(v as "ALL" | TrainScheduleStatus);
resetPage();
}}
data={[
{ value: "ALL", label: "All statuses" },
{ value: "DRAFT", label: "Draft" },
{ value: "SCHEDULED", label: "Scheduled" },
{ value: "DISPATCHED", label: "Dispatched" },
{ value: "ARRIVED", label: "Arrived" },
{ value: "CANCELLED", label: "Cancelled" },
]}
w={150}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<Select
size="sm"
radius="lg"
value={freightFilter}
onChange={(v) => {
if (!v) return;
setFreightFilter(v as "ALL" | FreightType);
resetPage();
}}
data={[
{ value: "ALL", label: "All freight" },
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
{ value: "MIXED", label: "Mixed" },
]}
w={140}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<Select
size="sm"
radius="lg"
placeholder="Origin"
searchable
value={originFilter}
onChange={(v) => {
setOriginFilter(v ?? "ALL");
resetPage();
}}
data={[
{ value: "ALL", label: "All origins" },
...yardOptions,
]}
w={160}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<Select
size="sm"
radius="lg"
placeholder="Destination"
searchable
value={destinationFilter}
onChange={(v) => {
setDestinationFilter(v ?? "ALL");
resetPage();
}}
data={[
{ value: "ALL", label: "All destinations" },
...yardOptions,
]}
w={170}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<Select
size="sm"
radius="lg"
value={`${sortBy}:${sortDir}`}
onChange={(v) => {
if (!v) return;
const [by, dir] = v.split(":") as [
typeof sortBy,
typeof sortDir,
];
setSortBy(by);
setSortDir(dir);
resetPage();
}}
data={[
{ value: "createdAt:desc", label: "Newest created" },
{ value: "createdAt:asc", label: "Oldest created" },
{ value: "scheduledDepartureDate:desc", label: "Departure ↓" },
{ value: "scheduledDepartureDate:asc", label: "Departure ↑" },
{ value: "reference:asc", label: "Reference ↑" },
{ value: "reference:desc", label: "Reference ↓" },
]}
w={170}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
</>
}
/>
sortOptions={SORT_OPTIONS}
viewId="train-schedules"
>
<Group gap="sm" wrap="nowrap">
<SegmentedControl
size="xs"
radius="lg"
value={viewMode}
onChange={(v) => setViewMode(v as FleetViewMode)}
data={[
{
value: "table",
label: (
<Center>
<Table2 size={14} />
</Center>
),
},
{
value: "cards",
label: (
<Center>
<LayoutGrid size={14} />
</Center>
),
},
]}
aria-label="View mode"
/>
<ExportButton datasetKey="train-schedules" params={controls.params} size="sm" />
</Group>
</FilterBar>
</Box>
{viewMode === "table" ? (
@@ -763,18 +602,7 @@ export default function TrainScheduleV2ListPage() {
: undefined
}
emptyMessage="No train schedules found"
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: totalSchedules,
}}
tableOptions={{
manualPagination: true,
pageCount,
state: { pagination },
onPaginationChange: setPagination,
}}
{...controls.tableProps(totalSchedules)}
containerClassName="border-0 shadow-none bg-transparent"
footer={({ table, pagination: footerPagination }) => (
<DataTableFooter
@@ -797,25 +625,18 @@ export default function TrainScheduleV2ListPage() {
key={schedule.id}
schedule={schedule}
onOpen={() =>
navigate(
`/dashboard/operations/train-scheduling-v2/${schedule.id}`,
)
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}`)
}
onTrack={() =>
navigate(
`/dashboard/operations/train-scheduling-v2/${schedule.id}/track`,
)
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}/track`)
}
/>
))}
</SimpleGrid>
)}
<RuleEngineListFooter
pagination={pagination}
pageCount={pageCount}
totalCount={totalSchedules}
{...toRuleEngineFooterProps(controls, totalSchedules)}
itemLabel="schedules"
onPaginationChange={setPagination}
/>
</Stack>
)}
@@ -957,13 +778,12 @@ export default function TrainScheduleV2ListPage() {
{dispatchTarget?.trainNumber ?? dispatchTarget?.reference ?? "This train"}
</Text>{" "}
departs {dispatchTarget?.origin ?? "its origin"} for{" "}
{dispatchTarget?.destination ?? "its destination"} and its booking
window closes. This cannot be undone.
{dispatchTarget?.destination ?? "its destination"} and its booking window closes. This
cannot be undone.
</Text>
<Text size="xs" c="dimmed">
Open the schedule detail first if you want to check for unassigned
wagons or cargo not yet marked loaded those warnings are shown
there, not here.
Open the schedule detail first if you want to check for unassigned wagons or cargo not
yet marked loaded those warnings are shown there, not here.
</Text>
<DateTimePicker
label="Actual departure"
@@ -988,9 +808,7 @@ export default function TrainScheduleV2ListPage() {
try {
await dispatchSchedule.mutateAsync({
id: dispatchTarget.id,
payload: dispatchAt
? { actualDepartureAt: dispatchAt.toISOString() }
: {},
payload: dispatchAt ? { actualDepartureAt: dispatchAt.toISOString() } : {},
});
toast({ title: "Train dispatched" });
setDispatchTarget(null);
@@ -1024,14 +842,13 @@ export default function TrainScheduleV2ListPage() {
<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.
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.
{cancelTarget.bookingsCount === 1 ? "" : "s"} on this train will need to be moved to
another schedule.
</Text>
) : null}
<Group justify="flex-end" gap="sm">
@@ -1083,6 +900,56 @@ const SHIPPING_LINE_ROW_STYLE = {
backgroundColor: "var(--mantine-color-edr-green-0)",
} as const;
/**
* The row's identity: which train is running, under what reference, in what
* state. Stacked into one column so the three read as a unit and cost one
* column's width between them.
*/
function TrainIdentityCell({ schedule }: { schedule: TrainScheduleListItem }) {
// Schedules created from the Train Builder show the direction-matched run
// number first (falling back to the train code); legacy rows fall back to
// their locomotive set.
const locos =
schedule.locomotives && schedule.locomotives.length > 0
? schedule.locomotives
: schedule.locomotive
? [schedule.locomotive]
: [];
let title = "—";
let subtitle = "";
if (schedule.train) {
title = schedule.trainNumber ?? schedule.train.code;
subtitle = [schedule.trainNumber ? schedule.train.code : null, schedule.train.trainName]
.filter(Boolean)
.join(" · ");
} else if (locos.length) {
title = `${locos[0].code}${locos.length > 1 ? ` +${locos.length - 1}` : ""}`;
}
return (
<Stack gap={3} style={{ minWidth: 0 }}>
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
<Train size={14} color="var(--mantine-color-gray-5)" style={{ flexShrink: 0 }} />
<Text size="sm" fw={600} ff="monospace" lh={1.2} truncate>
{title}
</Text>
</Group>
{subtitle ? (
<Text size="xs" c="dimmed" lh={1.2} truncate>
{subtitle}
</Text>
) : null}
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<Text size="xs" fw={600} ff="monospace" c="edr-green.8" lh={1.2} truncate>
{schedule.reference ?? "—"}
</Text>
<StatusPill status={schedule.status} />
</Group>
</Stack>
);
}
function ShippingLineBadge({ schedule }: { schedule: TrainScheduleListItem }) {
if (!schedule.shippingLineCompanyId) return null;
return (
@@ -1110,12 +977,8 @@ function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) {
value={`${used}/${total}`}
label={schedule.wagonCount === 0 ? "wgn planned" : "wgn used"}
/>
{reserved > used ? (
<MetricChip value={reserved} label="reserved" subtle />
) : null}
{remaining != null ? (
<MetricChip value={remaining} label="bookable" subtle />
) : null}
{reserved > used ? <MetricChip value={reserved} label="reserved" subtle /> : null}
{remaining != null ? <MetricChip value={remaining} label="bookable" subtle /> : null}
</>
);
}
@@ -1136,9 +999,7 @@ function MetricChip({
style={{
padding: "2px 8px",
borderRadius: 8,
background: subtle
? "var(--mantine-color-gray-1)"
: "var(--mantine-color-edr-green-0)",
background: subtle ? "var(--mantine-color-gray-1)" : "var(--mantine-color-edr-green-0)",
border: `1px solid ${
subtle ? "var(--mantine-color-gray-2)" : "var(--mantine-color-edr-green-1)"
}`,
@@ -1215,11 +1076,7 @@ function ScheduleCard({
<Group gap={6} wrap="nowrap">
<FreightTypeBadge freightType={schedule.freightType} />
{schedule.direction ? (
<Badge
size="xs"
variant="light"
color={directionColor(schedule.direction)}
>
<Badge size="xs" variant="light" color={directionColor(schedule.direction)}>
{schedule.direction}
</Badge>
) : null}

View File

@@ -1,21 +1,23 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Badge, Card, Center, Divider, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
import { Badge, Card, Center, Divider, Group, Loader, Select, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
import { DatePickerInput } from '@mantine/dates';
import {
ClipboardCheck,
ClipboardList,
ShieldCheck,
PackageCheck,
PackageOpen,
PackagePlus,
PackageSearch,
CircleCheck,
Send,
Train,
Truck,
Warehouse as WarehouseIcon,
Boxes,
Layers,
} from 'lucide-react';
import { PageContainer, PageHeader } from '@/components/page';
import { getDateRangePresets } from '@/components/common/dateRangePresets';
import {
AccrualDashboard,
CycleTimeCard,
@@ -25,7 +27,7 @@ import {
WarehouseOpsKpiStrip,
ZoneOccupancyHeatmap,
} from '@/components/warehouses';
import { useWarehouseDashboard } from '@/hooks/useWarehouses';
import { useWarehouseDashboard, useWarehouses } from '@/hooks/useWarehouses';
import type { WarehouseDashboard } from '@/types/warehouse';
function SectionTitle({ children }: { children: React.ReactNode }) {
@@ -51,21 +53,37 @@ const GREEN = '#084b21';
const METRICS: Metric[] = [
{ key: 'totalWarehouses', label: 'Total Warehouses', icon: <WarehouseIcon size={22} />, to: '/dashboard/warehouses', theme: ORANGE },
{ key: 'totalInventory', label: 'Total Inventory', icon: <Boxes size={22} />, to: '/dashboard/warehouse-inventory', theme: GREEN },
{ key: 'receivedToday', label: 'Received Today', icon: <PackagePlus size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: ORANGE },
{ key: 'received', label: 'Received Today', icon: <PackagePlus size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: ORANGE },
{ key: 'awaitingInspection', label: 'Awaiting Inspection', icon: <ClipboardList size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: GREEN },
{ key: 'inspected', label: 'Inspected', icon: <ShieldCheck size={22} />, to: '/dashboard/warehouse-inventory', theme: ORANGE },
{ key: 'stored', label: 'Stored', icon: <Layers size={22} />, to: '/dashboard/warehouse-inventory?status=STORED', theme: GREEN },
{ key: 'reserved', label: 'Reserved', icon: <ClipboardCheck size={22} />, to: '/dashboard/warehouse-inventory?status=RESERVED', theme: ORANGE },
{ key: 'readyForLoading', label: 'Ready For Loading', icon: <PackageCheck size={22} />, to: '/dashboard/loading-queue', theme: GREEN },
{ key: 'loaded', label: 'Loaded', icon: <Truck size={22} />, to: '/dashboard/loaded-inventory', theme: ORANGE },
{ key: 'dispatched', label: 'Dispatched', icon: <Send size={22} />, to: '/dashboard/dispatch-queue', theme: GREEN },
{ key: 'readyForPickup', label: 'Ready For Pickup', icon: <PackageSearch size={22} />, to: '/dashboard/warehouse-inventory?status=READY_FOR_PICKUP', theme: ORANGE },
{ key: 'emptyContainers', label: 'Empty Containers', icon: <PackageOpen size={22} />, to: '/dashboard/containers', theme: ORANGE },
{ key: 'importTrains', label: 'Import Trains', icon: <Train size={22} />, to: '/dashboard/import-warehouse', theme: GREEN },
{ key: 'exportTrains', label: 'Export Trains', icon: <Train size={22} />, to: '/dashboard/export-warehouse', theme: ORANGE },
{ key: 'loaded', label: 'Loaded', icon: <Truck size={22} />, to: '/dashboard/loaded-inventory', theme: GREEN },
{ key: 'dispatched', label: 'Dispatched', icon: <Send size={22} />, to: '/dashboard/dispatch-queue', theme: ORANGE },
{ key: 'readyForPickup', label: 'Ready For Pickup', icon: <PackageSearch size={22} />, to: '/dashboard/warehouse-inventory?status=READY_FOR_PICKUP', theme: GREEN },
{ key: 'readyForLoading', label: 'Ready For Loading', icon: <PackageCheck size={22} />, to: '/dashboard/loading-queue', theme: ORANGE },
{ key: 'delivered', label: 'Delivered', icon: <CircleCheck size={22} />, to: '/dashboard/warehouse-inventory?status=DELIVERED', theme: GREEN },
];
export default function WarehouseDashboardPage() {
const navigate = useNavigate();
const { data, isError, isLoading } = useWarehouseDashboard();
// Both null → the API defaults `received` to "today", matching the page's original behaviour.
const [dateRange, setDateRange] = useState<[string | null, string | null]>([null, null]);
const [warehouseId, setWarehouseId] = useState<string | null>(null);
const [dateFrom, dateTo] = dateRange;
const hasCustomRange = Boolean(dateFrom || dateTo);
const warehousesQuery = useWarehouses();
const warehouseOptions = useMemo(
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
[warehousesQuery.data],
);
const { data, isError, isLoading } = useWarehouseDashboard({
dateFrom: dateFrom ?? undefined,
dateTo: dateTo ?? undefined,
warehouseId: warehouseId ?? undefined,
});
return (
<PageContainer>
@@ -73,27 +91,58 @@ export default function WarehouseDashboardPage() {
title="Warehouse Dashboard"
subtitle="Live overview of warehouse capacity and inventory lifecycle."
action={
<Badge
color="edr-green"
variant="light"
size="lg"
leftSection={
<span
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
background: 'var(--mantine-color-edr-green-6)',
}}
/>
}
>
Live · updates every 60s
</Badge>
<Group gap="sm" wrap="wrap" justify="flex-end">
<Select
placeholder="All warehouses"
clearable
searchable
data={warehouseOptions}
value={warehouseId}
onChange={setWarehouseId}
w={220}
/>
<DatePickerInput
type="range"
placeholder="Received: today"
value={dateRange}
onChange={setDateRange}
presets={getDateRangePresets()}
clearable
w={230}
/>
<Badge
color="edr-green"
variant="light"
size="lg"
leftSection={
<span
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
background: 'var(--mantine-color-edr-green-6)',
}}
/>
}
>
Live · updates every 60s
</Badge>
</Group>
}
/>
{(warehouseId || hasCustomRange) && (
<Text size="xs" c="dimmed" mt={-8}>
Scoped to{' '}
{warehouseId ? warehouseOptions.find((o) => o.value === warehouseId)?.label ?? 'selected warehouse' : 'all warehouses'}
{hasCustomRange
? ` · Received counts ${dateFrom ?? '…'} to ${dateTo ?? '…'}`
: ' · Received counts: today'}
. Status-backlog and fleet counters are always current regardless of the date range.
</Text>
)}
{isLoading ? (
<Center py="xl">
<Loader />
@@ -124,7 +173,7 @@ export default function WarehouseDashboardPage() {
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Text size="xs" c="edr-muted" tt="uppercase" fw={700} style={{ letterSpacing: 0.4 }}>
{metric.label}
{metric.key === 'received' && hasCustomRange ? 'Received' : metric.label}
</Text>
<Text fw={800} fz={32} mt={8} c="edr-text" lh={1.1}>
{data ? data[metric.key] : 0}

View File

@@ -192,7 +192,13 @@ import {
type SaveLocomotivePayload,
} from "./locomotives.service";
import { overviewService } from "./overview.service";
import { exportsService } from "./exports.service";
import { reportsService } from "./reports.service";
import type {
ExportCountResult,
ExportDatasetEntry,
ExportParams,
} from "@/types/exports";
import type { ReportCatalogEntry, ReportRunParams, ReportRunResult } from "@/types/reports";
import {
paymentsService,
@@ -3354,6 +3360,18 @@ export const api = {
),
},
exports: {
catalog: endpoint<void, ExportDatasetEntry[]>("exports", "catalog", () =>
exportsService.catalog(),
),
count: endpoint<{ key: string; params: ExportParams }, ExportCountResult>(
"exports",
"count",
({ key, params }) => exportsService.count(key, params),
({ key, params }) => ["exports", key, "count", params],
),
},
reports: {
catalog: endpoint<void, ReportCatalogEntry[]>("reports", "catalog", () =>
reportsService.catalog(),

View File

@@ -509,6 +509,53 @@ export const bookingsService = {
return unwrap(response.data) as Freight.ClearanceCharge[];
},
// ── Additional charges (ad-hoc finance billing) ──
getAdditionalCharges: async (id: string): Promise<Freight.AdditionalCharge[]> => {
const response = await client.get(`/bookings/${id}/additional-charges`);
return unwrap(response.data) as Freight.AdditionalCharge[];
},
/** Finance raises a new charge — 'draft' just saves it, 'send' also issues the invoice and notifies the customer. */
createAdditionalCharge: async (
id: string,
payload: { reason: string; amount: number; currency: string; action: "draft" | "send"; file?: File | null },
): Promise<Freight.AdditionalCharge[]> => {
const form = new FormData();
form.append("reason", payload.reason);
form.append("amount", String(payload.amount));
form.append("currency", payload.currency);
form.append("action", payload.action);
if (payload.file) form.append("file", payload.file);
const response = await client.post(`/bookings/${id}/additional-charges`, form, {
headers: { "Content-Type": "multipart/form-data" },
});
return unwrap(response.data) as Freight.AdditionalCharge[];
},
/** Issues the draft charge's payable invoice and notifies the customer. */
sendAdditionalCharge: async (
id: string,
chargeId: string,
): Promise<Freight.AdditionalCharge[]> => {
const response = await client.post(
`/bookings/${id}/additional-charges/${chargeId}/send`,
);
return unwrap(response.data) as Freight.AdditionalCharge[];
},
/** Withdraws a draft or unpaid additional charge. */
cancelAdditionalCharge: async (
id: string,
chargeId: string,
reason?: string,
): Promise<Freight.AdditionalCharge[]> => {
const response = await client.post(
`/bookings/${id}/additional-charges/${chargeId}/cancel`,
{ reason },
);
return unwrap(response.data) as Freight.AdditionalCharge[];
},
/** GL ET asks Djibouti to name the officer handling the shipment in transit. */
requestTransitAssignee: (id: string, note?: string) =>
postBooking<BookingDetail>(B.CLEARANCE_TRANSIT_ASSIGNEE_REQUEST(id), {

View File

@@ -0,0 +1,42 @@
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
import type {
ExportCountResult,
ExportDatasetEntry,
ExportFormat,
ExportParams,
} from "@/types/exports";
const E = URL_CONSTANTS.EXPORTS;
export const exportsService = {
catalog: async (): Promise<ExportDatasetEntry[]> => {
const response = await client.get(E.CATALOG);
return unwrap(response.data);
},
/** Exact row count for the current filters, plus the per-format caps. */
count: async (key: string, params: ExportParams): Promise<ExportCountResult> => {
const response = await client.get(E.COUNT(key), { params });
return unwrap(response.data);
},
/**
* Streams the export file as a blob — caller triggers the browser save.
* A failure here arrives with a Blob body, so the catch must use
* `extractDownloadErrorMessage`, not the synchronous decoder.
*/
download: async (
key: string,
format: ExportFormat,
fields: string[],
params: ExportParams,
): Promise<Blob> => {
const response = await client.get(E.DOWNLOAD(key), {
params: { ...params, format, fields: fields.join(",") },
responseType: "blob",
});
return response.data as Blob;
},
};

View File

@@ -0,0 +1,51 @@
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.OPERATIONS_STANDARDS.BASE;
/**
* The railway's operating standards — the numbers the operations reports
* measure actual performance against. One row, edited here.
*/
export interface OperationsStandards {
id: string;
stationStandardHoursEthiopia: number;
stationStandardHoursDjibouti: number;
cycleStandardHoursContainer: number;
cycleStandardHoursBulkDmp: number;
cycleStandardHoursBulkNagad: number;
cycleStandardHoursBulkBcc: number;
defaultLegStandardHours: number;
delayToleranceMinutes: number;
chargedTonsFull20ft: number;
chargedTonsFull40ft: number;
chargedTonsEmpty20ft: number;
chargedTonsEmpty40ft: number;
chargedTonsPerWagonGeneral: number;
chargedTonsPerWagonPerishable: number;
defaultFullTrainsetWagons: number;
updatedAt?: string;
}
export type OperationsStandardsPatch = Partial<
Omit<OperationsStandards, "id" | "updatedAt">
>;
export const operationsStandardsService = {
get: async (): Promise<OperationsStandards> => {
const response = await client.get<ApiResponse<OperationsStandards>>(BASE);
return unwrap(response.data);
},
update: async (
patch: OperationsStandardsPatch,
): Promise<OperationsStandards> => {
const response = await client.patch<ApiResponse<OperationsStandards>>(
BASE,
patch,
);
return unwrap(response.data);
},
};

View File

@@ -96,6 +96,7 @@ const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
"weight-limit-rules": URL_CONSTANTS.RULE_ENGINE.WEIGHT_LIMIT_RULES,
yards: URL_CONSTANTS.RULE_ENGINE.YARDS,
"yard-distances": URL_CONSTANTS.RULE_ENGINE.YARD_DISTANCES,
"operations-targets": URL_CONSTANTS.RULE_ENGINE.OPERATIONS_TARGETS,
"shipping-lines": URL_CONSTANTS.RULE_ENGINE.SHIPPING_LINES,
rates: URL_CONSTANTS.RULE_ENGINE.RATES,
"approval-rules": URL_CONSTANTS.RULE_ENGINE.APPROVAL_RULES,

View File

@@ -64,6 +64,7 @@ import type {
Warehouse,
WarehouseActivityLog,
WarehouseDashboard,
WarehouseDashboardFilter,
WarehouseFacility,
WarehouseFilter,
WarehouseInventoryItem,
@@ -245,7 +246,10 @@ export const warehouseService = {
apiClient.get<Warehouse[]>(URL_CONSTANTS.WAREHOUSES.BASE, {
params: cleanParams(filter ?? {}),
}),
dashboard: () => apiClient.get<WarehouseDashboard>(URL_CONSTANTS.WAREHOUSES.DASHBOARD),
dashboard: (filter?: WarehouseDashboardFilter) =>
apiClient.get<WarehouseDashboard>(URL_CONSTANTS.WAREHOUSES.DASHBOARD, {
params: cleanParams(filter ?? {}),
}),
getDashboardSummary: (_filter?: InventoryFilter) =>
apiClient.get<WarehouseDashboard>(URL_CONSTANTS.WAREHOUSES.DASHBOARD),
getById: (id: string) => apiClient.get<Warehouse>(URL_CONSTANTS.WAREHOUSES.BY_ID(id)),

View File

@@ -86,7 +86,7 @@ export const freightMantineTheme = createTheme({
black: "#10202F",
fontFamily:
'"Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
'"Space Grotesk", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
defaultRadius: "md",
@@ -123,7 +123,7 @@ export const freightMantineTheme = createTheme({
},
headings: {
fontFamily: '"Space Grotesk", "Inter", var(--mantine-font-family)',
fontFamily: '"Space Grotesk", var(--mantine-font-family)',
fontWeight: "700",
sizes: {
h1: { fontSize: "36px", lineHeight: "1.1", fontWeight: "800" },

View File

@@ -0,0 +1,54 @@
/** Mirrors the API's ExportCatalogEntry — see modules/exports/export.types.ts. */
export type ExportFormat = "csv" | "xlsx" | "pdf";
export type ExportFieldType =
| "string"
| "number"
| "money"
| "tons"
| "percent"
| "date"
| "datetime"
| "boolean";
export interface ExportFieldGroup {
id: string;
label: string;
}
export interface ExportField {
key: string;
label: string;
type: ExportFieldType;
/** References an ExportFieldGroup id. */
group: string;
/** In the "Default columns" preset. */
default?: boolean;
}
export interface ExportDatasetEntry {
key: string;
title: string;
description: string;
group: string;
groups: ExportFieldGroup[];
fields: ExportField[];
formats: ExportFormat[];
caps: Record<ExportFormat, number>;
/**
* The dataset's own filter declarations. The dialog does NOT render these —
* the page's FilterBar already owns filtering, and a second filter UI inside
* the dialog would diverge from it. Kept only so callers can introspect.
*/
filters: unknown[];
defaultSort?: { key: string; dir: "ASC" | "DESC" };
}
/** Flat filter params, straight off `useFilters().params` or a page's own object. */
export type ExportParams = Record<string, string | number | undefined>;
export interface ExportCountResult {
total: number;
caps: Record<ExportFormat, number>;
}

View File

@@ -1,9 +1,27 @@
import type { Freight } from "@edr/types";
/**
* What an invoice's `sourceId` points at, resolved server-side for display.
* `source` names the subsystem, `sourceId` is a raw UUID — this is the part a
* human recognises. Null when nothing resolved (EIMS self-test rows, records
* since deleted). See `InvoiceSourceRef` in the API's billing service.
*/
export interface InvoiceSourceRef {
bookingId: string | null;
bookingReference: string | null;
tradeDirection: string | null;
/** Warehouse-sourced rows: the GRN the fees were raised against. */
grnNumber: string | null;
/** Shipping-line credit rows: the line billed, not a single record. */
shippingLineName: string | null;
}
/** Mirrors backend `Invoice` (the shared `Freight.IInvoice` omits a couple of raw entity columns). */
export interface Invoice extends Freight.IInvoice {
subtotalAmount: number;
taxAmount: number;
/** Present on list reads (`findAllPaginated`), absent on a single-invoice fetch. */
sourceRef?: InvoiceSourceRef | null;
}
/** Query parameters for the invoice list. */

View File

@@ -46,6 +46,16 @@ export interface ReportChartDef {
y: string[];
}
/**
* Makes a summary row clickable: the row's values are carried into another
* report as filter params. Keys are this report's column keys; values are the
* target report's filter keys.
*/
export interface ReportDrillDef {
to: string;
carry: Record<string, string>;
}
/** Mirrors the backend's ReportCatalogEntry — one entry per GET /reports item. */
export interface ReportCatalogEntry {
key: string;
@@ -58,6 +68,7 @@ export interface ReportCatalogEntry {
defaultSort?: { key: string; dir: "ASC" | "DESC" };
hasSummary: boolean;
chart?: ReportChartDef;
drill?: ReportDrillDef;
}
export interface ReportPageMeta {

View File

@@ -11,7 +11,8 @@ export type RuleEngineResourceSlug =
| "shipping-lines"
| "rates"
| "approval-rules"
| "transit-agents";
| "transit-agents"
| "operations-targets";
/**
* Mirrors the API's shared `PaginationMeta` (@edr/types). The `has*` flags are

View File

@@ -286,10 +286,18 @@ export interface WarehouseActivityLog {
createdAt: string;
}
/** Both dates omitted → `received` defaults to "today" (the original behaviour). */
export interface WarehouseDashboardFilter {
dateFrom?: string;
dateTo?: string;
warehouseId?: string;
}
export interface WarehouseDashboard {
totalWarehouses: number;
totalInventory: number;
receivedToday: number;
/** Items received in the requested range — "today" when no range is set. */
received: number;
awaitingInspection: number;
inspected: number;
stored: number;
@@ -299,6 +307,9 @@ export interface WarehouseDashboard {
dispatched: number;
readyForPickup: number;
delivered: number;
emptyContainers: number;
importTrains: number;
exportTrains: number;
}
// ── Loading (Batch 3) ────────────────────────────────────────────────────────