mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +00:00
fix issue
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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(", ");
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 maker–checker 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 maker–checker
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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), {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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" },
|
||||
|
||||
54
apps/edr-freight-web/backoffice/src/types/exports.ts
Normal file
54
apps/edr-freight-web/backoffice/src/types/exports.ts
Normal 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>;
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) ────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user