feat(export-ui): field-picker export dialog, mounted on bookings

A Stripe-style export dialog over the /exports catalog: searchable field
picker grouped by related entity, format choice, row scope, saved presets,
and a live row count.

The picker is what makes 77 fields usable. Groups auto-expand only when they
already hold a selection, so the dialog opens showing the default columns and
their groups rather than a wall of checkboxes; searching force-expands so a
match can't hide inside a collapsed group. Group headers carry a tri-state
checkbox and an n/total badge.

The row count comes from /exports/:key/count with the page's own filters, so
the button reads 'Export 223 rows' before anything is downloaded, and turns
into a cap warning with a one-click 'export the first N' escape when the
result is too large for the chosen format.

ExportButton takes plain params rather than a UseFilters instance —
four of the pages that need this haven't migrated to FilterBar yet, and
coupling to the hook would have blocked them. Pagination keys are stripped in
one place instead of at every call site. It renders nothing when the catalog
omits the dataset, so the catalog's permission filtering IS the UI gate.

Presets reuse useSavedViews unchanged by encoding the preset as a query
string; a preset naming a field the catalog no longer offers is dropped on
load rather than 400ing the download. Download errors go through
extractDownloadErrorMessage, without which the server's row-cap message
degrades to 'Request failed with status code 400'.
This commit is contained in:
Nathnael
2026-08-20 06:44:29 +00:00
parent 62f7b91315
commit 42b9f30057
7 changed files with 630 additions and 1 deletions

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,420 @@
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);
const [format, setFormat] = useState<ExportFormat>("csv");
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]);
// Searching force-expands so matches aren't hidden inside collapsed groups.
// Otherwise open only groups that already have something selected, which is
// what keeps 77 fields tractable on open.
const openGroups = search.trim()
? [...visibleByGroup.keys()]
: dataset.groups
.filter((g) => dataset.fields.some((f) => f.group === g.id && selectedSet.has(f.key)))
.map((g) => g.id);
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} 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">
<Icon size={18} />
<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
{format !== "csv" ? ", 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

@@ -172,6 +172,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",

View File

@@ -26,6 +26,7 @@ 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 { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
@@ -512,7 +513,9 @@ export default function BookingRequestsPage() {
controls={controls}
searchPlaceholder="Search booking, contract, customer or shipping line…"
viewId="booking-requests"
/>
>
<ExportButton datasetKey="bookings" params={controls.params} />
</FilterBar>
</Box>
{showEmpty ? (

View File

@@ -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,
@@ -3279,6 +3285,18 @@ export const api = {
),
},
exports: {
catalog: endpoint<void, ExportDatasetEntry[]>("exports", "catalog", () =>
exportsService.catalog(),
),
count: endpoint<{ key: string; params: ExportParams }, ExportCountResult>(
"exports",
"count",
({ key, params }) => exportsService.count(key, params),
({ key, params }) => ["exports", key, "count", params],
),
},
reports: {
catalog: endpoint<void, ReportCatalogEntry[]>("reports", "catalog", () =>
reportsService.catalog(),

View File

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

View File

@@ -0,0 +1,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>;
}