fix issue

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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