mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(reports): header actions, single export dialog, date-range presets
- ReportPage drops its own PageHeader (and the back arrow); ReportView now optionally renders the header itself (pageHeader prop) with export/refresh as its actions. Embedded ReportSection usage is unaffected (keeps the inline toolbar next to filters). - Replace the two xlsx/pdf icon buttons with one Export button opening a dialog: format as large icon radio cards, fields as checkboxes (select-all toggle), record count (default all, capped per format). Export applies the report's current filters and sort. - Backend: export route accepts fields (whitelisted against the report's own columns) and limit; ReportExportService takes an optional column subset instead of always dumping every column. - Fixed a real bug found while wiring this up: runAll() ignored the caller's sortBy/sortOrder and always used the report's default sort, so exports silently didn't match whatever order was on screen. - Report daterange filters now use DatePickerInput + the shared getDateRangePresets() (Today/Last 7 days/This month/...) instead of two bare DateInputs, matching every other date-range filter in the app. - Removed the reports hub grid page. /dashboard/reports now redirects to the first report the caller has access to, or /dashboard if they have none.
This commit is contained in:
@@ -36,6 +36,7 @@ export class ReportExportService {
|
|||||||
def: ReportDefinition,
|
def: ReportDefinition,
|
||||||
rows: Record<string, unknown>[],
|
rows: Record<string, unknown>[],
|
||||||
kpis: ReportKpi[],
|
kpis: ReportKpi[],
|
||||||
|
columns: ReportColumn[] = def.columns,
|
||||||
): Promise<Buffer> {
|
): Promise<Buffer> {
|
||||||
const workbook = new ExcelJS.Workbook();
|
const workbook = new ExcelJS.Workbook();
|
||||||
const sheet = workbook.addWorksheet(def.title.slice(0, 31));
|
const sheet = workbook.addWorksheet(def.title.slice(0, 31));
|
||||||
@@ -45,14 +46,14 @@ export class ReportExportService {
|
|||||||
sheet.addRow([]);
|
sheet.addRow([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const headerRow = sheet.addRow(def.columns.map((c) => c.label));
|
const headerRow = sheet.addRow(columns.map((c) => c.label));
|
||||||
headerRow.font = { bold: true };
|
headerRow.font = { bold: true };
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
sheet.addRow(def.columns.map((c) => row[c.key] ?? null));
|
sheet.addRow(columns.map((c) => row[c.key] ?? null));
|
||||||
}
|
}
|
||||||
|
|
||||||
def.columns.forEach((col, i) => {
|
columns.forEach((col, i) => {
|
||||||
const format = NUMBER_FORMAT[col.type];
|
const format = NUMBER_FORMAT[col.type];
|
||||||
const excelCol = sheet.getColumn(i + 1);
|
const excelCol = sheet.getColumn(i + 1);
|
||||||
excelCol.width = Math.max(col.label.length + 2, 12);
|
excelCol.width = Math.max(col.label.length + 2, 12);
|
||||||
@@ -67,8 +68,9 @@ export class ReportExportService {
|
|||||||
def: ReportDefinition,
|
def: ReportDefinition,
|
||||||
rows: Record<string, unknown>[],
|
rows: Record<string, unknown>[],
|
||||||
kpis: ReportKpi[],
|
kpis: ReportKpi[],
|
||||||
|
columns: ReportColumn[] = def.columns,
|
||||||
): Promise<Buffer> {
|
): Promise<Buffer> {
|
||||||
const html = this.buildHtml(def, rows, kpis);
|
const html = this.buildHtml(def, rows, kpis, columns);
|
||||||
return this.pdfRender.htmlToPdfBuffer(html, { label: `report:${def.key}`, landscape: true });
|
return this.pdfRender.htmlToPdfBuffer(html, { label: `report:${def.key}`, landscape: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +78,7 @@ export class ReportExportService {
|
|||||||
def: ReportDefinition,
|
def: ReportDefinition,
|
||||||
rows: Record<string, unknown>[],
|
rows: Record<string, unknown>[],
|
||||||
kpis: ReportKpi[],
|
kpis: ReportKpi[],
|
||||||
|
columns: ReportColumn[],
|
||||||
): string {
|
): string {
|
||||||
const esc = (v: unknown) =>
|
const esc = (v: unknown) =>
|
||||||
String(v ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
String(v ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
@@ -89,11 +92,11 @@ export class ReportExportService {
|
|||||||
.join('')}</div>`
|
.join('')}</div>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
const head = def.columns.map((c) => `<th>${esc(c.label)}</th>`).join('');
|
const head = columns.map((c) => `<th>${esc(c.label)}</th>`).join('');
|
||||||
const body = rows
|
const body = rows
|
||||||
.map(
|
.map(
|
||||||
(row) =>
|
(row) =>
|
||||||
`<tr>${def.columns.map((c) => `<td>${esc(formatCell(row[c.key], c.type))}</td>`).join('')}</tr>`,
|
`<tr>${columns.map((c) => `<td>${esc(formatCell(row[c.key], c.type))}</td>`).join('')}</tr>`,
|
||||||
)
|
)
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
|
|||||||
@@ -132,7 +132,9 @@ export class ReportRunnerService {
|
|||||||
const params = coerceParams(def, raw);
|
const params = coerceParams(def, raw);
|
||||||
const ctx = { ds: this.ds, params, directions };
|
const ctx = { ds: this.ds, params, directions };
|
||||||
const qb = def.query(ctx);
|
const qb = def.query(ctx);
|
||||||
const sort = resolveSort(def, undefined, undefined);
|
// Same sort the on-screen table is using, not always the default — an
|
||||||
|
// export is supposed to match what the user is looking at.
|
||||||
|
const sort = resolveSort(def, raw.sortBy, raw.sortOrder);
|
||||||
if (sort) qb.orderBy(sort.expr, sort.dir);
|
if (sort) qb.orderBy(sort.expr, sort.dir);
|
||||||
const items = await qb.limit(limit).getRawMany();
|
const items = await qb.limit(limit).getRawMany();
|
||||||
if (items.length >= limit) {
|
if (items.length >= limit) {
|
||||||
|
|||||||
@@ -53,20 +53,30 @@ export class ReportsController {
|
|||||||
@ApiOperation({ summary: 'Export a report to xlsx or pdf' })
|
@ApiOperation({ summary: 'Export a report to xlsx or pdf' })
|
||||||
async export(
|
async export(
|
||||||
@Param('key') key: string,
|
@Param('key') key: string,
|
||||||
@Query() query: RawReportQuery & { format?: string },
|
@Query() query: RawReportQuery & { format?: string; fields?: string; limit?: string },
|
||||||
@CurrentUser() user: TCurrentUser,
|
@CurrentUser() user: TCurrentUser,
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const def = this.resolve(key, user);
|
const def = this.resolve(key, user);
|
||||||
const directions = await this.userTradeAccessService.resolveAllowedDirections(user);
|
const directions = await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||||
const format = query.format === 'pdf' ? 'pdf' : 'xlsx';
|
const format = query.format === 'pdf' ? 'pdf' : 'xlsx';
|
||||||
const cap = format === 'pdf' ? PDF_ROW_CAP : XLSX_ROW_CAP;
|
const formatCap = format === 'pdf' ? PDF_ROW_CAP : XLSX_ROW_CAP;
|
||||||
|
const requestedLimit = Number(query.limit);
|
||||||
|
const cap = requestedLimit > 0 ? Math.min(requestedLimit, formatCap) : formatCap;
|
||||||
|
|
||||||
|
// Whitelist against the report's own columns — an unknown/empty `fields`
|
||||||
|
// value falls back to every column rather than shipping a blank sheet.
|
||||||
|
const requestedFields = query.fields?.split(',').filter(Boolean);
|
||||||
|
const columns = requestedFields?.length
|
||||||
|
? def.columns.filter((c) => requestedFields.includes(c.key))
|
||||||
|
: def.columns;
|
||||||
|
const exportColumns = columns.length ? columns : def.columns;
|
||||||
|
|
||||||
const { items, kpis } = await this.runner.runAll(def, query, directions, cap);
|
const { items, kpis } = await this.runner.runAll(def, query, directions, cap);
|
||||||
const buffer =
|
const buffer =
|
||||||
format === 'pdf'
|
format === 'pdf'
|
||||||
? await this.exportService.toPdf(def, items, kpis)
|
? await this.exportService.toPdf(def, items, kpis, exportColumns)
|
||||||
: await this.exportService.toXlsx(def, items, kpis);
|
: await this.exportService.toXlsx(def, items, kpis, exportColumns);
|
||||||
|
|
||||||
const filename = `${def.key}.${format === 'pdf' ? 'pdf' : 'xlsx'}`;
|
const filename = `${def.key}.${format === 'pdf' ? 'pdf' : 'xlsx'}`;
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import InvoicesPage from "./pages/invoices/InvoicesPage";
|
|||||||
import UsdPaymentsPage from "./pages/invoices/UsdPaymentsPage";
|
import UsdPaymentsPage from "./pages/invoices/UsdPaymentsPage";
|
||||||
import MyProfilePage from "./pages/dashboard/MyProfilePage";
|
import MyProfilePage from "./pages/dashboard/MyProfilePage";
|
||||||
import OverviewPage from "./pages/dashboard/OverviewPage";
|
import OverviewPage from "./pages/dashboard/OverviewPage";
|
||||||
import ReportsHubPage from "./pages/reports/ReportsHubPage";
|
import ReportsIndexRedirect from "./pages/reports/ReportsIndexRedirect";
|
||||||
import ReportPage from "./pages/reports/ReportPage";
|
import ReportPage from "./pages/reports/ReportPage";
|
||||||
import AuditLogsPage from "./pages/AuditLogsPage";
|
import AuditLogsPage from "./pages/AuditLogsPage";
|
||||||
import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage";
|
import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage";
|
||||||
@@ -214,7 +214,7 @@ const App = () => {
|
|||||||
<Route path="/dashboard" element={<Navigate to={landingPath} replace />} />
|
<Route path="/dashboard" element={<Navigate to={landingPath} replace />} />
|
||||||
<Route path="/dashboard" element={<DashboardShell />}>
|
<Route path="/dashboard" element={<DashboardShell />}>
|
||||||
<Route path="overview" element={<RequirePermission permission={FREIGHT_PERMS.overview.view}><OverviewPage /></RequirePermission>} />
|
<Route path="overview" element={<RequirePermission permission={FREIGHT_PERMS.overview.view}><OverviewPage /></RequirePermission>} />
|
||||||
<Route path="reports" element={<RequirePermission permission={FREIGHT_PERMS.reports.view}><ReportsHubPage /></RequirePermission>} />
|
<Route path="reports" element={<RequirePermission permission={FREIGHT_PERMS.reports.view}><ReportsIndexRedirect /></RequirePermission>} />
|
||||||
<Route path="reports/:reportKey" element={<RequirePermission permission={FREIGHT_PERMS.reports.view}><ReportPage /></RequirePermission>} />
|
<Route path="reports/:reportKey" element={<RequirePermission permission={FREIGHT_PERMS.reports.view}><ReportPage /></RequirePermission>} />
|
||||||
<Route path="audit-logs" element={<RequirePermission permission={FREIGHT_PERMS.auditLog.view}><AuditLogsPage /></RequirePermission>} />
|
<Route path="audit-logs" element={<RequirePermission permission={FREIGHT_PERMS.auditLog.view}><AuditLogsPage /></RequirePermission>} />
|
||||||
{/* Dev/testing page for the mock AI booking assistant. */}
|
{/* Dev/testing page for the mock AI booking assistant. */}
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { Button, Checkbox, Group, Modal, Radio, Select, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||||
|
import { Download, FileSpreadsheet, FileText } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { reportsService } from "@/services/reports.service";
|
||||||
|
import type { ReportCatalogEntry, ReportRunParams } from "@/types/reports";
|
||||||
|
|
||||||
|
interface ReportExportButtonProps {
|
||||||
|
def: ReportCatalogEntry;
|
||||||
|
/** Filters + sort currently applied on screen — no key/page/pageSize. */
|
||||||
|
params: Omit<ReportRunParams, "key" | "page" | "pageSize">;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECORD_OPTIONS = [
|
||||||
|
{ value: "all", label: "All (up to format limit)" },
|
||||||
|
{ value: "100", label: "First 100" },
|
||||||
|
{ value: "500", label: "First 500" },
|
||||||
|
{ value: "1000", label: "First 1,000" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Triggers a browser save for a blob without leaving the SPA. */
|
||||||
|
function saveBlob(blob: Blob, filename: string) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One export button: format, which fields, how many records — applies the
|
||||||
|
* filters/sort already on screen. Record count defaults to all (capped
|
||||||
|
* server-side per format). */
|
||||||
|
export function ReportExportButton({ def, params }: ReportExportButtonProps) {
|
||||||
|
const [opened, setOpened] = useState(false);
|
||||||
|
const [format, setFormat] = useState<"xlsx" | "pdf">("xlsx");
|
||||||
|
const [fields, setFields] = useState<string[]>(def.columns.map((c) => c.key));
|
||||||
|
const [records, setRecords] = useState("all");
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
|
||||||
|
const allSelected = fields.length === def.columns.length;
|
||||||
|
const toggleField = (key: string) =>
|
||||||
|
setFields((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]));
|
||||||
|
const toggleAll = () => setFields(allSelected ? [] : def.columns.map((c) => c.key));
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
const blob = await reportsService.download(def.key, format, {
|
||||||
|
...params,
|
||||||
|
fields: allSelected ? undefined : fields.join(","),
|
||||||
|
limit: records === "all" ? undefined : records,
|
||||||
|
});
|
||||||
|
saveBlob(blob, `${def.key}.${format}`);
|
||||||
|
setOpened(false);
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
radius="md"
|
||||||
|
size="sm"
|
||||||
|
leftSection={<Download size={16} />}
|
||||||
|
onClick={() => setOpened(true)}
|
||||||
|
>
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Modal opened={opened} onClose={() => setOpened(false)} title="Export report" radius="md" size="md">
|
||||||
|
<Stack gap="lg">
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={600} mb="xs">
|
||||||
|
Format
|
||||||
|
</Text>
|
||||||
|
<Radio.Group value={format} onChange={(v) => setFormat(v as "xlsx" | "pdf")}>
|
||||||
|
<SimpleGrid cols={2}>
|
||||||
|
<Radio.Card value="xlsx" radius="md" p="md">
|
||||||
|
<Group wrap="nowrap" gap="sm">
|
||||||
|
<Radio.Indicator />
|
||||||
|
<FileSpreadsheet size={22} />
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
Excel (.xlsx)
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Radio.Card>
|
||||||
|
<Radio.Card value="pdf" radius="md" p="md">
|
||||||
|
<Group wrap="nowrap" gap="sm">
|
||||||
|
<Radio.Indicator />
|
||||||
|
<FileText size={22} />
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
PDF
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Radio.Card>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Radio.Group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Group justify="space-between" mb="xs">
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
Fields
|
||||||
|
</Text>
|
||||||
|
<Button variant="subtle" size="compact-sm" onClick={toggleAll}>
|
||||||
|
{allSelected ? "Clear all" : "Select all"}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
<SimpleGrid cols={2} spacing="xs">
|
||||||
|
{def.columns.map((col) => (
|
||||||
|
<Checkbox
|
||||||
|
key={col.key}
|
||||||
|
label={col.label}
|
||||||
|
checked={fields.includes(col.key)}
|
||||||
|
onChange={() => toggleField(col.key)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Records"
|
||||||
|
value={records}
|
||||||
|
onChange={(v) => setRecords(v ?? "all")}
|
||||||
|
data={RECORD_OPTIONS}
|
||||||
|
allowDeselect={false}
|
||||||
|
radius="md"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Uses the filters and sorting currently applied to the report.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" radius="md" onClick={() => setOpened(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
radius="md"
|
||||||
|
loading={exporting}
|
||||||
|
disabled={!fields.length}
|
||||||
|
leftSection={<Download size={16} />}
|
||||||
|
onClick={() => void handleDownload()}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReportExportButton;
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Group, MultiSelect, Select, TextInput } from "@mantine/core";
|
import { Group, MultiSelect, Select, TextInput } from "@mantine/core";
|
||||||
import { DateInput } from "@mantine/dates";
|
import { DateInput, DatePickerInput } from "@mantine/dates";
|
||||||
import { Search } from "lucide-react";
|
import { Search } from "lucide-react";
|
||||||
|
|
||||||
|
import { getDateRangePresets } from "@/components/common/dateRangePresets";
|
||||||
import type { ReportFilterDef } from "@/types/reports";
|
import type { ReportFilterDef } from "@/types/reports";
|
||||||
|
|
||||||
export interface ReportFilterValues {
|
export interface ReportFilterValues {
|
||||||
@@ -29,26 +30,20 @@ export function ReportFilters({ filters, values, onChange }: ReportFiltersProps)
|
|||||||
switch (filter.type) {
|
switch (filter.type) {
|
||||||
case "daterange":
|
case "daterange":
|
||||||
return (
|
return (
|
||||||
<Group key={filter.key} gap="xs" wrap="nowrap">
|
<DatePickerInput
|
||||||
<DateInput
|
key={filter.key}
|
||||||
placeholder={`${filter.label} from`}
|
type="range"
|
||||||
value={toDate(values[`${filter.key}From`])}
|
placeholder={filter.label}
|
||||||
onChange={(d) => set({ [`${filter.key}From`]: fromDate(d) })}
|
value={[values[`${filter.key}From`] ?? null, values[`${filter.key}To`] ?? null]}
|
||||||
radius="md"
|
onChange={([from, to]) =>
|
||||||
size="sm"
|
set({ [`${filter.key}From`]: fromDate(from), [`${filter.key}To`]: fromDate(to) })
|
||||||
clearable
|
}
|
||||||
w={150}
|
presets={getDateRangePresets()}
|
||||||
/>
|
radius="md"
|
||||||
<DateInput
|
size="sm"
|
||||||
placeholder={`${filter.label} to`}
|
clearable
|
||||||
value={toDate(values[`${filter.key}To`])}
|
w={230}
|
||||||
onChange={(d) => set({ [`${filter.key}To`]: fromDate(d) })}
|
/>
|
||||||
radius="md"
|
|
||||||
size="sm"
|
|
||||||
clearable
|
|
||||||
w={150}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
);
|
);
|
||||||
case "date":
|
case "date":
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,16 +2,17 @@ import { ActionIcon, Alert, Box, Card, Group, SegmentedControl, Stack, Text, Too
|
|||||||
import { useDebouncedValue } from "@mantine/hooks";
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type { Column, SortingState } from "@tanstack/react-table";
|
import type { Column, SortingState } from "@tanstack/react-table";
|
||||||
import { ArrowDown, ArrowUp, ArrowUpDown, FileSpreadsheet, FileText, LayoutGrid, LineChart, RefreshCw } from "lucide-react";
|
import { ArrowDown, ArrowUp, ArrowUpDown, LayoutGrid, LineChart, RefreshCw } from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { PageHeader } from "@/components/page";
|
||||||
import { KpiStrip } from "@/components/page/KpiStrip";
|
import { KpiStrip } from "@/components/page/KpiStrip";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { reportsService } from "@/services/reports.service";
|
|
||||||
import type { ReportRunParams } from "@/types/reports";
|
import type { ReportRunParams } from "@/types/reports";
|
||||||
import { DataTable, DataTableFooter, usePagination, type ColumnDef } from "@edr/ui-common";
|
import { DataTable, DataTableFooter, usePagination, type ColumnDef } from "@edr/ui-common";
|
||||||
|
|
||||||
import { ReportChart } from "./ReportChart";
|
import { ReportChart } from "./ReportChart";
|
||||||
|
import { ReportExportButton } from "./ReportExportButton";
|
||||||
import { ReportFilters, type ReportFilterValues } from "./ReportFilters";
|
import { ReportFilters, type ReportFilterValues } from "./ReportFilters";
|
||||||
import { formatKpiValue, formatReportCell } from "./report-format";
|
import { formatKpiValue, formatReportCell } from "./report-format";
|
||||||
|
|
||||||
@@ -35,24 +36,18 @@ interface ReportViewProps {
|
|||||||
reportKey: string;
|
reportKey: string;
|
||||||
/** Scopes the report to one entity when embedded (e.g. a contract detail page). */
|
/** Scopes the report to one entity when embedded (e.g. a contract detail page). */
|
||||||
idKeyValue?: string;
|
idKeyValue?: string;
|
||||||
}
|
/** Full-page usage: renders the title/description as a PageHeader (no back
|
||||||
|
* arrow) with export/refresh as its actions, instead of inline above the
|
||||||
/** Triggers a browser save for a blob without leaving the SPA. */
|
* table. Off by default for embedded sections. */
|
||||||
function saveBlob(blob: Blob, filename: string) {
|
pageHeader?: boolean;
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = filename;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The report engine: one component renders any report the catalog describes —
|
* The report engine: one component renders any report the catalog describes —
|
||||||
* filters, KPI strip, sortable/paginated table, xlsx/pdf export. Adding a
|
* filters, KPI strip, sortable/paginated table or chart, xlsx/pdf export.
|
||||||
* report never touches this file.
|
* Adding a report never touches this file.
|
||||||
*/
|
*/
|
||||||
export function ReportView({ reportKey, idKeyValue }: ReportViewProps) {
|
export function ReportView({ reportKey, idKeyValue, pageHeader }: ReportViewProps) {
|
||||||
const { data: catalog } = useQuery(api.reports.catalog.queryOptions());
|
const { data: catalog } = useQuery(api.reports.catalog.queryOptions());
|
||||||
const def = catalog?.find((r) => r.key === reportKey);
|
const def = catalog?.find((r) => r.key === reportKey);
|
||||||
|
|
||||||
@@ -60,12 +55,22 @@ export function ReportView({ reportKey, idKeyValue }: ReportViewProps) {
|
|||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
const [filterValues, setFilterValues] = useState<ReportFilterValues>({});
|
const [filterValues, setFilterValues] = useState<ReportFilterValues>({});
|
||||||
const [debouncedFilters] = useDebouncedValue(filterValues, 300);
|
const [debouncedFilters] = useDebouncedValue(filterValues, 300);
|
||||||
const [exporting, setExporting] = useState<"xlsx" | "pdf" | null>(null);
|
|
||||||
const [view, setView] = useState<"table" | "chart">("table");
|
const [view, setView] = useState<"table" | "chart">("table");
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
const appliedParams = useMemo(() => {
|
||||||
|
const sort = sorting[0];
|
||||||
|
return {
|
||||||
|
sortBy: sort?.id,
|
||||||
|
sortOrder: sort ? (sort.desc ? "DESC" as const : "ASC" as const) : undefined,
|
||||||
|
...debouncedFilters,
|
||||||
|
...(def?.idKey && idKeyValue ? { [def.idKey.key]: idKeyValue } : {}),
|
||||||
|
};
|
||||||
|
}, [def, sorting, debouncedFilters, idKeyValue]);
|
||||||
|
|
||||||
const runParams: ReportRunParams | undefined = useMemo(() => {
|
const runParams: ReportRunParams | undefined = useMemo(() => {
|
||||||
if (!def) return undefined;
|
if (!def) return undefined;
|
||||||
const sort = sorting[0];
|
|
||||||
return {
|
return {
|
||||||
key: def.key,
|
key: def.key,
|
||||||
// Chart view isn't paginated on screen — pull the server's max page (100)
|
// Chart view isn't paginated on screen — pull the server's max page (100)
|
||||||
@@ -73,12 +78,9 @@ export function ReportView({ reportKey, idKeyValue }: ReportViewProps) {
|
|||||||
// so the chart doesn't silently plot a fraction of the filtered rows.
|
// so the chart doesn't silently plot a fraction of the filtered rows.
|
||||||
page: view === "chart" ? 1 : pagination.pageIndex + 1,
|
page: view === "chart" ? 1 : pagination.pageIndex + 1,
|
||||||
pageSize: view === "chart" ? 100 : pagination.pageSize,
|
pageSize: view === "chart" ? 100 : pagination.pageSize,
|
||||||
sortBy: sort?.id,
|
...appliedParams,
|
||||||
sortOrder: sort ? (sort.desc ? "DESC" : "ASC") : undefined,
|
|
||||||
...debouncedFilters,
|
|
||||||
...(def.idKey && idKeyValue ? { [def.idKey.key]: idKeyValue } : {}),
|
|
||||||
};
|
};
|
||||||
}, [def, view, pagination, sorting, debouncedFilters, idKeyValue]);
|
}, [def, view, pagination, appliedParams]);
|
||||||
|
|
||||||
const { data, isLoading, isError, isFetching, refetch } = useQuery({
|
const { data, isLoading, isError, isFetching, refetch } = useQuery({
|
||||||
...api.reports.run.queryOptions({ input: runParams as ReportRunParams }),
|
...api.reports.run.queryOptions({ input: runParams as ReportRunParams }),
|
||||||
@@ -106,26 +108,55 @@ export function ReportView({ reportKey, idKeyValue }: ReportViewProps) {
|
|||||||
[def?.columns],
|
[def?.columns],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleExport = async (format: "xlsx" | "pdf") => {
|
|
||||||
if (!def) return;
|
|
||||||
setExporting(format);
|
|
||||||
try {
|
|
||||||
const { key: _key, page: _page, pageSize: _pageSize, ...filters } = runParams ?? {};
|
|
||||||
const blob = await reportsService.download(def.key, format, filters);
|
|
||||||
saveBlob(blob, `${def.key}.${format}`);
|
|
||||||
} finally {
|
|
||||||
setExporting(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!def) {
|
if (!def) {
|
||||||
return catalog ? (
|
return catalog ? (
|
||||||
<Alert color="red">You don't have access to this report.</Alert>
|
<Alert color="red">You don't have access to this report.</Alert>
|
||||||
) : null;
|
) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chartToggle = def.chart ? (
|
||||||
|
<SegmentedControl
|
||||||
|
size="xs"
|
||||||
|
value={view}
|
||||||
|
onChange={(v) => setView(v as "table" | "chart")}
|
||||||
|
data={[
|
||||||
|
{ label: <LayoutGrid size={14} />, value: "table" },
|
||||||
|
{ label: <LineChart size={14} />, value: "chart" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const refreshButton = (
|
||||||
|
<Tooltip label="Refresh">
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
radius="md"
|
||||||
|
loading={isFetching}
|
||||||
|
onClick={() => void refetch()}
|
||||||
|
aria-label="Refresh"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
|
||||||
|
const exportButton = <ReportExportButton def={def} params={appliedParams} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
|
{pageHeader ? (
|
||||||
|
<PageHeader
|
||||||
|
title={def.title}
|
||||||
|
subtitle={def.description}
|
||||||
|
action={
|
||||||
|
<Group gap="xs">
|
||||||
|
{exportButton}
|
||||||
|
{refreshButton}
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{data?.kpis.length ? (
|
{data?.kpis.length ? (
|
||||||
<KpiStrip
|
<KpiStrip
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
@@ -146,50 +177,13 @@ export function ReportView({ reportKey, idKeyValue }: ReportViewProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
{def.chart ? (
|
{chartToggle}
|
||||||
<SegmentedControl
|
{pageHeader ? null : (
|
||||||
size="xs"
|
<>
|
||||||
value={view}
|
{exportButton}
|
||||||
onChange={(v) => setView(v as "table" | "chart")}
|
{refreshButton}
|
||||||
data={[
|
</>
|
||||||
{ label: <LayoutGrid size={14} />, value: "table" },
|
)}
|
||||||
{ label: <LineChart size={14} />, value: "chart" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<Tooltip label="Export to Excel">
|
|
||||||
<ActionIcon
|
|
||||||
variant="default"
|
|
||||||
radius="md"
|
|
||||||
loading={exporting === "xlsx"}
|
|
||||||
onClick={() => void handleExport("xlsx")}
|
|
||||||
aria-label="Export to Excel"
|
|
||||||
>
|
|
||||||
<FileSpreadsheet size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip label="Export to PDF">
|
|
||||||
<ActionIcon
|
|
||||||
variant="default"
|
|
||||||
radius="md"
|
|
||||||
loading={exporting === "pdf"}
|
|
||||||
onClick={() => void handleExport("pdf")}
|
|
||||||
aria-label="Export to PDF"
|
|
||||||
>
|
|
||||||
<FileText size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip label="Refresh">
|
|
||||||
<ActionIcon
|
|
||||||
variant="default"
|
|
||||||
radius="md"
|
|
||||||
loading={isFetching}
|
|
||||||
onClick={() => void refetch()}
|
|
||||||
aria-label="Refresh"
|
|
||||||
>
|
|
||||||
<RefreshCw size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,23 +1,14 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
|
|
||||||
import { ReportView } from "@/components/reports/ReportView";
|
import { ReportView } from "@/components/reports/ReportView";
|
||||||
import { PageContainer, PageHeader } from "@/components/page";
|
import { PageContainer } from "@/components/page";
|
||||||
import { api } from "@/services/api";
|
|
||||||
|
|
||||||
export default function ReportPage() {
|
export default function ReportPage() {
|
||||||
const { reportKey = "" } = useParams<{ reportKey: string }>();
|
const { reportKey = "" } = useParams<{ reportKey: string }>();
|
||||||
const { data: catalog } = useQuery(api.reports.catalog.queryOptions());
|
|
||||||
const def = catalog?.find((r) => r.key === reportKey);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<ReportView reportKey={reportKey} pageHeader />
|
||||||
title={def?.title ?? "Report"}
|
|
||||||
subtitle={def?.description}
|
|
||||||
backTo="/dashboard/reports"
|
|
||||||
/>
|
|
||||||
<ReportView reportKey={reportKey} />
|
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
import { Alert, Card, SimpleGrid, Skeleton, Stack, Text, Title } from "@mantine/core";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
import { PageContainer, PageHeader } from "@/components/page";
|
|
||||||
import { api } from "@/services/api";
|
|
||||||
import type { ReportCatalogEntry } from "@/types/reports";
|
|
||||||
|
|
||||||
const GROUP_ORDER: ReportCatalogEntry["group"][] = ["Commercial", "Operations", "Finance"];
|
|
||||||
|
|
||||||
export default function ReportsHubPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { data: catalog, isLoading, isError } = useQuery(api.reports.catalog.queryOptions());
|
|
||||||
|
|
||||||
const groups = GROUP_ORDER.map((group) => ({
|
|
||||||
group,
|
|
||||||
reports: (catalog ?? []).filter((r) => r.group === group),
|
|
||||||
})).filter((g) => g.reports.length);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContainer>
|
|
||||||
<PageHeader title="Reports" subtitle="Every report you have access to, grouped by area." />
|
|
||||||
|
|
||||||
{isError ? <Alert color="red">Failed to load the report catalog.</Alert> : null}
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
|
||||||
<Skeleton key={i} height={96} radius="md" />
|
|
||||||
))}
|
|
||||||
</SimpleGrid>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!isLoading && !isError && !groups.length ? (
|
|
||||||
<Text c="dimmed">You don't have access to any reports yet.</Text>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{groups.map(({ group, reports }) => (
|
|
||||||
<Stack key={group} gap="sm">
|
|
||||||
<Title order={4}>{group}</Title>
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
|
||||||
{reports.map((report) => (
|
|
||||||
<Card
|
|
||||||
key={report.key}
|
|
||||||
withBorder
|
|
||||||
radius="md"
|
|
||||||
p="md"
|
|
||||||
className="cursor-pointer transition-colors hover:bg-gray-50"
|
|
||||||
onClick={() => navigate(`/dashboard/reports/${report.key}`)}
|
|
||||||
>
|
|
||||||
<Text fw={600} c="edr-text">
|
|
||||||
{report.title}
|
|
||||||
</Text>
|
|
||||||
<Text size="sm" c="dimmed" mt={4}>
|
|
||||||
{report.description}
|
|
||||||
</Text>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</SimpleGrid>
|
|
||||||
</Stack>
|
|
||||||
))}
|
|
||||||
</PageContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Navigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/dashboard/reports` has no page of its own — it forwards to the first
|
||||||
|
* report the caller has access to (catalog order = registration order,
|
||||||
|
* already permission-filtered server-side), or home if they have none.
|
||||||
|
*/
|
||||||
|
export default function ReportsIndexRedirect() {
|
||||||
|
const { data: catalog, isLoading } = useQuery(api.reports.catalog.queryOptions());
|
||||||
|
|
||||||
|
if (isLoading) return null;
|
||||||
|
const first = catalog?.[0];
|
||||||
|
return <Navigate to={first ? `/dashboard/reports/${first.key}` : "/dashboard"} replace />;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user