184 lines
5.8 KiB
TypeScript
184 lines
5.8 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
import {
|
|
Container,
|
|
Box,
|
|
Paper,
|
|
Typography,
|
|
Alert,
|
|
Accordion,
|
|
AccordionSummary,
|
|
AccordionDetails,
|
|
} from "@mui/material";
|
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
|
import AssessmentIcon from "@mui/icons-material/Assessment";
|
|
import { useResource, useAppContext } from "../../react-openapi";
|
|
import { useToast } from "../ui/Toast";
|
|
import { PageHeader } from "../ui/PageHeader";
|
|
import { EmptyState } from "../ui/EmptyState";
|
|
import { StatCard } from "../common/components/StatCard";
|
|
import { buildTxnFieldConfigs } from "../common/utils/fieldConfigs";
|
|
import { GenerateReportPanel } from "./GenerateReportPanel";
|
|
import { ReportList } from "./ReportList";
|
|
import { ReportViewer } from "./ReportViewer";
|
|
import { apiErrorMessage, buildReportFieldConfigs } from "./types";
|
|
|
|
export default function Report() {
|
|
const { resources } = useAppContext();
|
|
const { showToast } = useToast();
|
|
const { list, loading, error } = useResource("reports");
|
|
const { create, remove } = useResource("reports");
|
|
|
|
const [reports, setReports] = useState<any[] | null>(null);
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
const [viewerVersion, setViewerVersion] = useState(0);
|
|
|
|
const reportFields = useMemo(() => buildReportFieldConfigs(resources), [resources]);
|
|
const txnFields = useMemo(() => buildTxnFieldConfigs(resources), [resources]);
|
|
|
|
const load = useCallback(async () => {
|
|
const res = await list({ limit: 200 });
|
|
setReports(res.items ?? []);
|
|
}, [list]);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, [load]);
|
|
|
|
const handleGenerated = useCallback(
|
|
(created: any) => {
|
|
load();
|
|
if (created?.id) setSelectedId(created.id);
|
|
},
|
|
[load],
|
|
);
|
|
|
|
const handleRegenerate = useCallback(
|
|
async (report: any) => {
|
|
try {
|
|
const created = await create(report.query ?? {});
|
|
showToast("Report regenerated");
|
|
setSelectedId(created?.id ?? report.id);
|
|
setViewerVersion((v) => v + 1);
|
|
load();
|
|
} catch (e: any) {
|
|
showToast(apiErrorMessage(e), "error");
|
|
}
|
|
},
|
|
[create, load, showToast],
|
|
);
|
|
|
|
const handleDelete = useCallback(
|
|
async (id: string) => {
|
|
try {
|
|
await remove(id);
|
|
showToast("Report deleted");
|
|
if (selectedId === id) setSelectedId(null);
|
|
load();
|
|
} catch (e: any) {
|
|
showToast(apiErrorMessage(e), "error");
|
|
}
|
|
},
|
|
[remove, load, selectedId, showToast],
|
|
);
|
|
|
|
const summary = useMemo(() => {
|
|
const rows = reports ?? [];
|
|
const granularities = new Set<string>();
|
|
for (const r of rows) for (const g of r.query?.granularities ?? []) granularities.add(g);
|
|
return { count: rows.length, granularities: [...granularities].join(", ") };
|
|
}, [reports]);
|
|
|
|
return (
|
|
<Container maxWidth="lg" sx={{ py: 4 }}>
|
|
<PageHeader
|
|
crumbs={[{ label: "Home", path: "/" }, { label: "Reports" }]}
|
|
title="Reports"
|
|
subtitle="Build an immutable snapshot cube once, then slice by granularity, period, payee and tag at view time."
|
|
/>
|
|
|
|
{error && (
|
|
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
|
|
Failed to load reports: {error}
|
|
</Alert>
|
|
)}
|
|
|
|
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", mb: 4 }}>
|
|
<StatCard label="Reports" value={summary.count.toLocaleString("en-IN")} />
|
|
<StatCard label="Granularities covered" value={summary.granularities || "—"} />
|
|
</Box>
|
|
|
|
<GenerateReportPanel onGenerated={handleGenerated} />
|
|
|
|
<Accordion
|
|
disableGutters
|
|
defaultExpanded
|
|
sx={{
|
|
border: "1px solid",
|
|
borderColor: "divider",
|
|
borderRadius: 2,
|
|
overflow: "hidden",
|
|
boxShadow: "none",
|
|
backgroundColor: "background.paper",
|
|
"&:before": { display: "none" },
|
|
mb: 4,
|
|
}}
|
|
>
|
|
<AccordionSummary
|
|
expandIcon={<ExpandMoreIcon />}
|
|
sx={{
|
|
px: 2.5,
|
|
py: 1,
|
|
"& .MuiAccordionSummary-content": {
|
|
alignItems: "center",
|
|
gap: 1.5,
|
|
minWidth: 0,
|
|
},
|
|
}}
|
|
>
|
|
<Typography variant="h6" fontWeight={700} sx={{ letterSpacing: "-0.01em" }}>
|
|
Saved reports
|
|
</Typography>
|
|
{reports !== null && (
|
|
<Typography variant="caption" color="text.secondary">
|
|
{reports.length} report{reports.length === 1 ? "" : "s"}
|
|
</Typography>
|
|
)}
|
|
</AccordionSummary>
|
|
<AccordionDetails sx={{ px: 2.5, pb: 2.5, pt: 0 }}>
|
|
{reports !== null && reports.length === 0 ? (
|
|
<Paper variant="outlined" sx={{ borderRadius: 3 }}>
|
|
<EmptyState
|
|
icon={<AssessmentIcon />}
|
|
title="No reports yet"
|
|
description="Generate your first snapshot above — choose granularities and grouping dimensions, then slice the cached cube by period, payee and tag."
|
|
/>
|
|
</Paper>
|
|
) : (
|
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
|
|
<ReportList
|
|
reports={reports ?? []}
|
|
loading={loading}
|
|
fields={reportFields}
|
|
selectedId={selectedId}
|
|
onView={(id) => setSelectedId(id)}
|
|
onRegenerate={handleRegenerate}
|
|
onDelete={handleDelete}
|
|
/>
|
|
</Box>
|
|
)}
|
|
</AccordionDetails>
|
|
</Accordion>
|
|
|
|
{selectedId && (
|
|
<ReportViewer
|
|
key={selectedId}
|
|
id={selectedId}
|
|
version={viewerVersion}
|
|
fields={txnFields}
|
|
onClose={() => setSelectedId(null)}
|
|
onRegenerated={handleRegenerate}
|
|
/>
|
|
)}
|
|
</Container>
|
|
);
|
|
} |