refactor: extract shared expense/report module into src/common

Collapse the Expenses page into a single src/Expense.tsx file and rename
the Reports page to src/Reports/Report.tsx, extracting their shared
transaction-list UI, date/grouping helpers, field configs, and types
into an expense/report-agnostic src/common module. Update ReportViewer
and GenerateReportPanel to consume the shared components, rewire
main.jsx imports, and remove the old src/Expense/ folder and
src/Reports/Reports.tsx.
This commit is contained in:
2026-08-20 13:29:44 +05:30
parent f08bc72037
commit baa9b296cb
13 changed files with 251 additions and 441 deletions

144
src/Reports/Report.tsx Normal file
View File

@@ -0,0 +1,144 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Container, Box, Paper, Typography, Alert } from "@mui/material";
import AssessmentIcon from "@mui/icons-material/Assessment";
import { useResource, useAppContext, formatCurrency } 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?.[0]?.id) setSelectedId(created[0].id);
},
[load],
);
const handleRegenerate = useCallback(
async (report: any) => {
const payload: Record<string, any> = { groups: report.groups ?? [] };
if (report.start_date) payload.start_date = report.start_date;
if (report.end_date) payload.end_date = report.end_date;
try {
await create(payload);
showToast("Report regenerated");
setSelectedId(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 txnCount = rows.reduce((s, r) => s + (r.txn_count ?? 0), 0);
const total = rows.reduce((s, r) => s + (typeof r.metrics?.sum === "number" ? r.metrics.sum : 0), 0);
return { count: rows.length, txnCount, total };
}, [reports]);
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<PageHeader
crumbs={[{ label: "Home", path: "/" }, { label: "Reports" }]}
title="Reports"
subtitle="Generate period/payee snapshots from the reporting API and slice the cached data."
/>
{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="Transactions covered" value={summary.txnCount.toLocaleString("en-IN")} />
<StatCard label="Total" value={formatCurrency(summary.total, "INR")} />
</Box>
<GenerateReportPanel onGenerated={handleGenerated} />
<Typography variant="h6" fontWeight={700} sx={{ letterSpacing: "-0.01em", mb: 1.5 }}>
Saved reports
</Typography>
{reports !== null && reports.length === 0 ? (
<Paper variant="outlined" sx={{ borderRadius: 3 }}>
<EmptyState
icon={<AssessmentIcon />}
title="No reports yet"
description="Generate your first report above — pick a granularity and payee (a payee-only or wildcard config snapshots weekly, monthly and quarterly)."
/>
</Paper>
) : (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2.5, mb: 4 }}>
<ReportList
reports={reports ?? []}
loading={loading}
fields={reportFields}
selectedId={selectedId}
onView={(id) => setSelectedId(id)}
onRegenerate={handleRegenerate}
onDelete={handleDelete}
/>
</Box>
)}
{selectedId && (
<ReportViewer
key={selectedId}
id={selectedId}
version={viewerVersion}
fields={txnFields}
onClose={() => setSelectedId(null)}
onRegenerated={handleRegenerate}
/>
)}
</Container>
);
}