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:
144
src/Reports/Report.tsx
Normal file
144
src/Reports/Report.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user