Files
khata-ui/src/Reports/Report.tsx

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