From 42b9f30057d3a94bff7549db62b2ab161e992e81 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 20 Aug 2026 06:44:29 +0000 Subject: [PATCH] feat(export-ui): field-picker export dialog, mounted on bookings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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'. --- .../src/components/export/ExportButton.tsx | 86 ++++ .../src/components/export/ExportDialog.tsx | 420 ++++++++++++++++++ .../backoffice/src/constants/URLS.ts | 6 + .../pages/bookings/BookingRequestsPage.tsx | 5 +- .../backoffice/src/services/api.ts | 18 + .../src/services/exports.service.ts | 42 ++ .../backoffice/src/types/exports.ts | 54 +++ 7 files changed, 630 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/export/ExportButton.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/export/ExportDialog.tsx create mode 100644 apps/edr-freight-web/backoffice/src/services/exports.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/types/exports.ts diff --git a/apps/edr-freight-web/backoffice/src/components/export/ExportButton.tsx b/apps/edr-freight-web/backoffice/src/components/export/ExportButton.tsx new file mode 100644 index 000000000..b79017cdb --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/export/ExportButton.tsx @@ -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; + 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(() => { + 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 ( + <> + + + + + {opened && ( + setOpened(false)} + dataset={dataset} + params={exportParams} + /> + )} + + ); +} + +export default ExportButton; diff --git a/apps/edr-freight-web/backoffice/src/components/export/ExportDialog.tsx b/apps/edr-freight-web/backoffice/src/components/export/ExportDialog.tsx new file mode 100644 index 000000000..195d05f08 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/export/ExportDialog.tsx @@ -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 = { + 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(defaultKeys); + const [format, setFormat] = useState("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(); + 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 ( + + + {/* Presets */} + + setSelected(defaultKeys)}> + Default columns + + setSelected(dataset.fields.map((f) => f.key))} + > + All columns + + {presets.views.map((view) => { + const name = new URLSearchParams(view.query).get("name") ?? "Preset"; + return ( + applyPreset(view.query)} + > + + {name} + { + e.stopPropagation(); + presets.remove(view.id); + }} + /> + + + ); + })} + + + + + + + setPresetName(e.currentTarget.value)} + onKeyDown={(e) => e.key === "Enter" && savePreset()} + autoFocus + /> + + + + + + + + + {/* 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. */} +
+ {/* Fields */} +
+ + } + value={search} + onChange={(e) => setSearch(e.currentTarget.value)} + /> + + + {selected.length} of {dataset.fields.length} fields selected + + + + + + + {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 ( + + + + 0 && on < groupKeys.length} + onClick={(e) => { + e.stopPropagation(); + toggleGroup(group.id); + }} + onChange={() => undefined} + /> + + {group.label} + + + {on}/{groupKeys.length} + + + + + + {fields.map((field) => ( + toggleField(field.key)} + /> + ))} + + + + ); + })} + + + +
+ + {/* Options */} +
+ +
+ + Format + + setFormat(v as ExportFormat)}> + + {dataset.formats.map((f) => { + const { label, Icon } = FORMAT_META[f]; + return ( + + + + + {label} + + + + ); + })} + + +
+ +