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} + + + + ); + })} + + +
+ +