mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -0,0 +1,86 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button, Tooltip } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Download } from "lucide-react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { ExportParams } from "@/types/exports";
|
||||
|
||||
import { ExportDialog } from "./ExportDialog";
|
||||
|
||||
/**
|
||||
* Pagination is a screen concern, never an export one — stripped here, once,
|
||||
* rather than at each of the pages that mount this.
|
||||
*/
|
||||
const PAGINATION_KEYS = ["page", "pageSize", "skip", "take"];
|
||||
|
||||
export interface ExportButtonProps {
|
||||
/** Catalog dataset key, e.g. "bookings". */
|
||||
datasetKey: string;
|
||||
/**
|
||||
* The page's current filters — `useFilters().params` verbatim, or a
|
||||
* non-migrated page's hand-built filter object. Deliberately not typed as
|
||||
* `UseFilters`: four of the pages that need this haven't migrated yet.
|
||||
*/
|
||||
params?: Record<string, unknown>;
|
||||
label?: string;
|
||||
size?: "xs" | "sm";
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the export dialog for one dataset. Renders nothing when the caller
|
||||
* lacks permission for that dataset — the catalog only returns what they may
|
||||
* export, so an absent entry IS the permission check.
|
||||
*/
|
||||
export function ExportButton({
|
||||
datasetKey,
|
||||
params,
|
||||
label = "Export",
|
||||
size = "xs",
|
||||
}: ExportButtonProps) {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const { data: catalog, isLoading } = useQuery(
|
||||
api.exports.catalog.queryOptions({ staleTime: 5 * 60_000 }),
|
||||
);
|
||||
|
||||
const dataset = catalog?.find((d) => d.key === datasetKey);
|
||||
|
||||
const exportParams = useMemo<ExportParams>(() => {
|
||||
const out: ExportParams = {};
|
||||
for (const [key, value] of Object.entries(params ?? {})) {
|
||||
if (PAGINATION_KEYS.includes(key)) continue;
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
out[key] = value as string | number;
|
||||
}
|
||||
return out;
|
||||
}, [params]);
|
||||
|
||||
if (isLoading || !dataset) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip label={dataset.description} openDelay={500}>
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
size={size}
|
||||
leftSection={<Download size={14} />}
|
||||
onClick={() => setOpened(true)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
{opened && (
|
||||
<ExportDialog
|
||||
opened={opened}
|
||||
onClose={() => setOpened(false)}
|
||||
dataset={dataset}
|
||||
params={exportParams}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExportButton;
|
||||
@@ -0,0 +1,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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
54
apps/edr-freight-web/backoffice/src/types/exports.ts
Normal file
54
apps/edr-freight-web/backoffice/src/types/exports.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/** Mirrors the API's ExportCatalogEntry — see modules/exports/export.types.ts. */
|
||||
|
||||
export type ExportFormat = "csv" | "xlsx" | "pdf";
|
||||
|
||||
export type ExportFieldType =
|
||||
| "string"
|
||||
| "number"
|
||||
| "money"
|
||||
| "tons"
|
||||
| "percent"
|
||||
| "date"
|
||||
| "datetime"
|
||||
| "boolean";
|
||||
|
||||
export interface ExportFieldGroup {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ExportField {
|
||||
key: string;
|
||||
label: string;
|
||||
type: ExportFieldType;
|
||||
/** References an ExportFieldGroup id. */
|
||||
group: string;
|
||||
/** In the "Default columns" preset. */
|
||||
default?: boolean;
|
||||
}
|
||||
|
||||
export interface ExportDatasetEntry {
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
group: string;
|
||||
groups: ExportFieldGroup[];
|
||||
fields: ExportField[];
|
||||
formats: ExportFormat[];
|
||||
caps: Record<ExportFormat, number>;
|
||||
/**
|
||||
* The dataset's own filter declarations. The dialog does NOT render these —
|
||||
* the page's FilterBar already owns filtering, and a second filter UI inside
|
||||
* the dialog would diverge from it. Kept only so callers can introspect.
|
||||
*/
|
||||
filters: unknown[];
|
||||
defaultSort?: { key: string; dir: "ASC" | "DESC" };
|
||||
}
|
||||
|
||||
/** Flat filter params, straight off `useFilters().params` or a page's own object. */
|
||||
export type ExportParams = Record<string, string | number | undefined>;
|
||||
|
||||
export interface ExportCountResult {
|
||||
total: number;
|
||||
caps: Record<ExportFormat, number>;
|
||||
}
|
||||
Reference in New Issue
Block a user