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:
@@ -41,7 +41,7 @@ import InvoicesPage from "./pages/invoices/InvoicesPage";
|
||||
import UsdPaymentsPage from "./pages/invoices/UsdPaymentsPage";
|
||||
import MyProfilePage from "./pages/dashboard/MyProfilePage";
|
||||
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 AuditLogsPage from "./pages/AuditLogsPage";
|
||||
import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage";
|
||||
@@ -214,7 +214,7 @@ const App = () => {
|
||||
<Route path="/dashboard" element={<Navigate to={landingPath} replace />} />
|
||||
<Route path="/dashboard" element={<DashboardShell />}>
|
||||
<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="audit-logs" element={<RequirePermission permission={FREIGHT_PERMS.auditLog.view}><AuditLogsPage /></RequirePermission>} />
|
||||
{/* 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 { DateInput } from "@mantine/dates";
|
||||
import { DateInput, DatePickerInput } from "@mantine/dates";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
import { getDateRangePresets } from "@/components/common/dateRangePresets";
|
||||
import type { ReportFilterDef } from "@/types/reports";
|
||||
|
||||
export interface ReportFilterValues {
|
||||
@@ -29,26 +30,20 @@ export function ReportFilters({ filters, values, onChange }: ReportFiltersProps)
|
||||
switch (filter.type) {
|
||||
case "daterange":
|
||||
return (
|
||||
<Group key={filter.key} gap="xs" wrap="nowrap">
|
||||
<DateInput
|
||||
placeholder={`${filter.label} from`}
|
||||
value={toDate(values[`${filter.key}From`])}
|
||||
onChange={(d) => set({ [`${filter.key}From`]: fromDate(d) })}
|
||||
radius="md"
|
||||
size="sm"
|
||||
clearable
|
||||
w={150}
|
||||
/>
|
||||
<DateInput
|
||||
placeholder={`${filter.label} to`}
|
||||
value={toDate(values[`${filter.key}To`])}
|
||||
onChange={(d) => set({ [`${filter.key}To`]: fromDate(d) })}
|
||||
radius="md"
|
||||
size="sm"
|
||||
clearable
|
||||
w={150}
|
||||
/>
|
||||
</Group>
|
||||
<DatePickerInput
|
||||
key={filter.key}
|
||||
type="range"
|
||||
placeholder={filter.label}
|
||||
value={[values[`${filter.key}From`] ?? null, values[`${filter.key}To`] ?? null]}
|
||||
onChange={([from, to]) =>
|
||||
set({ [`${filter.key}From`]: fromDate(from), [`${filter.key}To`]: fromDate(to) })
|
||||
}
|
||||
presets={getDateRangePresets()}
|
||||
radius="md"
|
||||
size="sm"
|
||||
clearable
|
||||
w={230}
|
||||
/>
|
||||
);
|
||||
case "date":
|
||||
return (
|
||||
|
||||
@@ -2,16 +2,17 @@ import { ActionIcon, Alert, Box, Card, Group, SegmentedControl, Stack, Text, Too
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
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 { PageHeader } from "@/components/page";
|
||||
import { KpiStrip } from "@/components/page/KpiStrip";
|
||||
import { api } from "@/services/api";
|
||||
import { reportsService } from "@/services/reports.service";
|
||||
import type { ReportRunParams } from "@/types/reports";
|
||||
import { DataTable, DataTableFooter, usePagination, type ColumnDef } from "@edr/ui-common";
|
||||
|
||||
import { ReportChart } from "./ReportChart";
|
||||
import { ReportExportButton } from "./ReportExportButton";
|
||||
import { ReportFilters, type ReportFilterValues } from "./ReportFilters";
|
||||
import { formatKpiValue, formatReportCell } from "./report-format";
|
||||
|
||||
@@ -35,24 +36,18 @@ interface ReportViewProps {
|
||||
reportKey: string;
|
||||
/** Scopes the report to one entity when embedded (e.g. a contract detail page). */
|
||||
idKeyValue?: string;
|
||||
}
|
||||
|
||||
/** 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);
|
||||
/** Full-page usage: renders the title/description as a PageHeader (no back
|
||||
* arrow) with export/refresh as its actions, instead of inline above the
|
||||
* table. Off by default for embedded sections. */
|
||||
pageHeader?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The report engine: one component renders any report the catalog describes —
|
||||
* filters, KPI strip, sortable/paginated table, xlsx/pdf export. Adding a
|
||||
* report never touches this file.
|
||||
* filters, KPI strip, sortable/paginated table or chart, xlsx/pdf export.
|
||||
* 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 def = catalog?.find((r) => r.key === reportKey);
|
||||
|
||||
@@ -60,12 +55,22 @@ export function ReportView({ reportKey, idKeyValue }: ReportViewProps) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [filterValues, setFilterValues] = useState<ReportFilterValues>({});
|
||||
const [debouncedFilters] = useDebouncedValue(filterValues, 300);
|
||||
const [exporting, setExporting] = useState<"xlsx" | "pdf" | null>(null);
|
||||
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(() => {
|
||||
if (!def) return undefined;
|
||||
const sort = sorting[0];
|
||||
return {
|
||||
key: def.key,
|
||||
// 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.
|
||||
page: view === "chart" ? 1 : pagination.pageIndex + 1,
|
||||
pageSize: view === "chart" ? 100 : pagination.pageSize,
|
||||
sortBy: sort?.id,
|
||||
sortOrder: sort ? (sort.desc ? "DESC" : "ASC") : undefined,
|
||||
...debouncedFilters,
|
||||
...(def.idKey && idKeyValue ? { [def.idKey.key]: idKeyValue } : {}),
|
||||
...appliedParams,
|
||||
};
|
||||
}, [def, view, pagination, sorting, debouncedFilters, idKeyValue]);
|
||||
}, [def, view, pagination, appliedParams]);
|
||||
|
||||
const { data, isLoading, isError, isFetching, refetch } = useQuery({
|
||||
...api.reports.run.queryOptions({ input: runParams as ReportRunParams }),
|
||||
@@ -106,26 +108,55 @@ export function ReportView({ reportKey, idKeyValue }: ReportViewProps) {
|
||||
[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) {
|
||||
return catalog ? (
|
||||
<Alert color="red">You don't have access to this report.</Alert>
|
||||
) : 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 (
|
||||
<Stack gap="md">
|
||||
{pageHeader ? (
|
||||
<PageHeader
|
||||
title={def.title}
|
||||
subtitle={def.description}
|
||||
action={
|
||||
<Group gap="xs">
|
||||
{exportButton}
|
||||
{refreshButton}
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{data?.kpis.length ? (
|
||||
<KpiStrip
|
||||
loading={isLoading}
|
||||
@@ -146,50 +177,13 @@ export function ReportView({ reportKey, idKeyValue }: ReportViewProps) {
|
||||
}}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
{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}
|
||||
<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>
|
||||
{chartToggle}
|
||||
{pageHeader ? null : (
|
||||
<>
|
||||
{exportButton}
|
||||
{refreshButton}
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
@@ -1,23 +1,14 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
import { ReportView } from "@/components/reports/ReportView";
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import { api } from "@/services/api";
|
||||
import { PageContainer } from "@/components/page";
|
||||
|
||||
export default function ReportPage() {
|
||||
const { reportKey = "" } = useParams<{ reportKey: string }>();
|
||||
const { data: catalog } = useQuery(api.reports.catalog.queryOptions());
|
||||
const def = catalog?.find((r) => r.key === reportKey);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title={def?.title ?? "Report"}
|
||||
subtitle={def?.description}
|
||||
backTo="/dashboard/reports"
|
||||
/>
|
||||
<ReportView reportKey={reportKey} />
|
||||
<ReportView reportKey={reportKey} pageHeader />
|
||||
</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