diff --git a/src/Header.tsx b/src/Header.tsx index ff15ae9..144255c 100644 --- a/src/Header.tsx +++ b/src/Header.tsx @@ -34,6 +34,7 @@ interface HeaderProps { const NAV_LINKS = [ { label: "Home", path: "/" }, { label: "Expenses", path: "/expenses" }, + { label: "Reports", path: "/reports" }, { label: "Fetch Requests", path: "/fetch-requests" }, ]; diff --git a/src/Reports/GenerateReportPanel.tsx b/src/Reports/GenerateReportPanel.tsx new file mode 100644 index 0000000..9845649 --- /dev/null +++ b/src/Reports/GenerateReportPanel.tsx @@ -0,0 +1,210 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { + Box, + Paper, + Typography, + TextField, + Button, + IconButton, + MenuItem, + Select, + FormControl, + InputLabel, + Autocomplete, + Alert, +} from "@mui/material"; +import AddIcon from "@mui/icons-material/Add"; +import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline"; +import { useResource, useAppContext, applyDisplayFormat } from "../../react-openapi"; +import { useToast } from "../ui/Toast"; +import { apiErrorMessage, groupTypeEnum, isDdmmyyyy, periodHints } from "./types"; + +interface GroupRow { + id: number; + group_type: string; + group_value: string; +} + +interface GenerateReportPanelProps { + onGenerated: (reports: any[]) => void; +} + +export function GenerateReportPanel({ onGenerated }: GenerateReportPanelProps) { + const { schemas, resources } = useAppContext(); + const { create } = useResource("reports"); + const { list: listEntities } = useResource("entities"); + const { showToast } = useToast(); + + const [rows, setRows] = useState([{ id: 1, group_type: "monthly", group_value: "*" }]); + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [entityNames, setEntityNames] = useState([]); + const [dateErrors, setDateErrors] = useState<{ start?: string; end?: string }>({}); + + const types = useMemo(() => groupTypeEnum(schemas), [schemas]); + const entitiesRes = resources.find((r) => r.name === "entities"); + const entitiesFormat = entitiesRes?.displayFormat ?? "{name}"; + + useEffect(() => { + let mounted = true; + listEntities({ limit: 0 }).then((res) => { + if (!mounted) return; + const names = (res.items ?? []) + .map((it: any) => applyDisplayFormat(it, entitiesFormat)) + .filter((n: string) => n); + setEntityNames([...new Set(names)].sort((a, b) => a.localeCompare(b))); + }); + return () => { + mounted = false; + }; + }, [listEntities, entitiesFormat]); + + const isPayee = (type: string) => type === "payee"; + + const valueOptions = (row: GroupRow): string[] => ["*", ...(isPayee(row.group_type) ? entityNames : [])]; + + const valueHint = (row: GroupRow): string => + isPayee(row.group_type) ? "Entity name, or * for all payees" : `e.g. ${periodHints(row.group_type).join(", ")}, or *`; + + const nextRowId = () => Math.max(0, ...rows.map((r) => r.id)) + 1; + + const addRow = () => setRows((rs) => [...rs, { id: nextRowId(), group_type: "monthly", group_value: "*" }]); + + const removeRow = (id: number) => setRows((rs) => rs.filter((r) => r.id !== id)); + + const updateRow = (id: number, patch: Partial) => + setRows((rs) => rs.map((r) => (r.id === id ? { ...r, ...patch } : r))); + + const validateDates = (): boolean => { + const errs: { start?: string; end?: string } = {}; + if (startDate && !isDdmmyyyy(startDate)) errs.start = "Use DD-MM-YYYY"; + if (endDate && !isDdmmyyyy(endDate)) errs.end = "Use DD-MM-YYYY"; + setDateErrors(errs); + return Object.keys(errs).length === 0; + }; + + const handleSubmit = async () => { + if (!validateDates()) return; + setError(null); + const groups = rows + .filter((r) => r.group_type && r.group_value.trim()) + .map(({ group_type, group_value }) => ({ group_type, group_value: group_value.trim() })); + if (groups.length === 0) { + setError("At least one group is required"); + return; + } + setSubmitting(true); + const payload: Record = { groups }; + if (startDate.trim()) payload.start_date = startDate.trim(); + if (endDate.trim()) payload.end_date = endDate.trim(); + try { + const created = await create(payload); + const list = Array.isArray(created) ? created : created ? [created] : []; + showToast(`Generated ${list.length} report${list.length === 1 ? "" : "s"}`); + onGenerated(list); + } catch (e: any) { + setError(apiErrorMessage(e)); + } finally { + setSubmitting(false); + } + }; + + return ( + + + Generate report + + + Choose the dimensions to snapshot. At least one group is required — with no period dimension the server + snapshots weekly, monthly and quarterly. + + + {error && ( + + {error} + + )} + + + {rows.map((row) => ( + + + Type + + + updateRow(row.id, { group_value: newVal ?? "" })} + renderInput={(params) => ( + + )} + /> + removeRow(row.id)} + sx={{ mt: 0.25 }} + > + + + + ))} + + + + + + setStartDate(e.target.value)} + error={Boolean(dateErrors.start)} + helperText={dateErrors.start ?? "Inclusive start (empty = unbounded)"} + sx={{ width: 200 }} + /> + setEndDate(e.target.value)} + error={Boolean(dateErrors.end)} + helperText={dateErrors.end ?? "Inclusive end (empty = unbounded)"} + sx={{ width: 200 }} + /> + + + + + + + ); +} \ No newline at end of file diff --git a/src/Reports/ReportList.tsx b/src/Reports/ReportList.tsx new file mode 100644 index 0000000..3813d5c --- /dev/null +++ b/src/Reports/ReportList.tsx @@ -0,0 +1,122 @@ +import React from "react"; +import { Box, Paper, Typography, Button, IconButton, Skeleton, Tooltip } from "@mui/material"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; +import VisibilityIcon from "@mui/icons-material/Visibility"; +import CachedIcon from "@mui/icons-material/Cached"; +import { ListCellRenderer, formatCurrency } from "../../react-openapi"; +import type { ReportFieldConfigs } from "./types"; + +interface ReportListProps { + reports: any[]; + loading: boolean; + fields: ReportFieldConfigs | null; + selectedId: string | null; + onView: (id: string) => void; + onRegenerate: (report: any) => void; + onDelete: (id: string) => void; +} + +export function ReportList({ reports, loading, fields, selectedId, onView, onRegenerate, onDelete }: ReportListProps) { + if (loading && reports.length === 0) { + return ( + + {[0, 1, 2].map((i) => ( + + ))} + + ); + } + + return ( + + {reports.map((report) => { + const sum = typeof report.metrics?.sum === "number" ? report.metrics.sum : null; + const range = + report.start_date || report.end_date + ? `range ${report.start_date || "…"} → ${report.end_date || "…"}` + : null; + return ( + + + + + {report.group_label} + + + {fields && ( + + )} + + {report.period_label} + + + / + + + {report.payee} + + {range && ( + + {range} + + )} + + + + {report.entity_count ?? 0} entities + + + {report.txn_count ?? 0} txns + + + {report.expense_count ?? 0} expenses + + {fields && ( + + + + )} + + + + + + {sum != null ? formatCurrency(sum, "INR") : "—"} + + + + + + + + onDelete(report.id)}> + + + + + + + ); + })} + + ); +} \ No newline at end of file diff --git a/src/Reports/ReportViewer.tsx b/src/Reports/ReportViewer.tsx new file mode 100644 index 0000000..db7c5a0 --- /dev/null +++ b/src/Reports/ReportViewer.tsx @@ -0,0 +1,438 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { + Box, + Paper, + Typography, + Button, + IconButton, + Alert, + Skeleton, + TextField, + Autocomplete, + Accordion, + AccordionSummary, + AccordionDetails, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, +} from "@mui/material"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import CloseIcon from "@mui/icons-material/Close"; +import CachedIcon from "@mui/icons-material/Cached"; +import { useAppContext, useResource, ListCellRenderer, CurrencyField, applyDisplayFormat, formatCurrency } from "../../react-openapi"; +import { groupByMonth } from "../Expense/ExpenseList"; +import type { ExpenseItem, ExpenseFieldConfigs } from "../Expense/types"; +import { monthLabel } from "../Expense/types"; +import type { TxnFieldConfigs } from "./types"; +import { aggregateSlice, buildPivot, metricLabels } from "./types"; + +interface ReportViewerProps { + id: string; + version: number; + fields: TxnFieldConfigs | null; + onClose: () => void; + onRegenerated: (report: any) => void; +} + +function StatCard({ label, value, color }: { label: string; value: string; color?: string }) { + return ( + + + {label} + + + {value} + + + ); +} + +const ReportTxnRow = React.memo(function ReportTxnRow({ + item, + currency, + fields, +}: { + item: ExpenseItem; + currency: string; + fields: ExpenseFieldConfigs; +}) { + const itemCurrency = item.account?.currency ?? currency; + + return ( + + + {item.entity?.logo ? ( + + ) : ( + + )} + + + + {item.entity ? applyDisplayFormat(item.entity, fields.formats.entity) : "Unknown"} + + + + + + {item.account?.name && ( + + )} + + + + + + ); +}); + +export function ReportViewer({ id, version, fields, onClose, onRegenerated }: ReportViewerProps) { + const { schemas } = useAppContext(); + const { get } = useResource("reports"); + + const [report, setReport] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [reload, setReload] = useState(0); + const [selectedPeriod, setSelectedPeriod] = useState("*"); + const [selectedPayee, setSelectedPayee] = useState("*"); + const [openMonth, setOpenMonth] = useState(null); + + useEffect(() => { + let mounted = true; + setLoading(true); + setError(null); + get(id) + .then((res) => { + if (!mounted) return; + setReport(res); + setSelectedPeriod("*"); + setSelectedPayee("*"); + }) + .catch((e: any) => { + if (!mounted) return; + setError(e?.response?.data?.detail ?? e?.message ?? "Failed to load report"); + }) + .finally(() => { + if (mounted) setLoading(false); + }); + return () => { + mounted = false; + }; + }, [id, version, reload, get]); + + const data = useMemo(() => (Array.isArray(report?.data) ? report.data : null), [report]); + + const groupOptions = useMemo(() => { + const options = report?.metadata?.group_options; + if (!options || typeof options !== "object") return { period: [], payee: [] }; + return { + period: Array.isArray(options.period) ? options.period.map(String) : [], + payee: Array.isArray(options.payee) ? options.payee.map(String) : [], + }; + }, [report]); + + const slice = useMemo( + () => aggregateSlice(data ?? [], { period: selectedPeriod, payee: selectedPayee }), + [data, selectedPeriod, selectedPayee], + ); + + const months = useMemo(() => groupByMonth(slice.txns), [slice.txns]); + + const pivot = useMemo( + () => buildPivot(data ?? [], groupOptions.period, groupOptions.payee), + [data, groupOptions], + ); + + const metrics = useMemo(() => { + const labels = metricLabels(schemas); + const displayKeys = ["sum", "count", "avg", "min", "max", "first_date", "last_date"]; + return labels.filter((l) => displayKeys.includes(l.key)); + }, [schemas]); + + const metricValue = (key: string): string | null => { + if (key === "sum") return formatCurrency(slice.sum, slice.currency); + if (key === "count") return slice.count.toLocaleString("en-IN"); + if (key === "avg") return slice.avg == null ? null : formatCurrency(slice.avg, slice.currency); + if (key === "min") return slice.min == null ? null : formatCurrency(slice.min, slice.currency); + if (key === "max") return slice.max == null ? null : formatCurrency(slice.max, slice.currency); + if (key === "first_date") return slice.firstDate; + if (key === "last_date") return slice.lastDate; + return null; + }; + + if (loading) { + return ( + + + + + + ); + } + + if (error) { + return ( + + + {error} + + + + ); + } + + if (!report) return null; + + return ( + + + + + {report.group_label} + + + + {report.granularity} · {report.period_label} · {report.payee} + + + {report.entity_count ?? 0} entities · {report.txn_count ?? 0} txns · {report.expense_count ?? 0} expenses + + {report.start_date || report.end_date ? ( + + range {report.start_date || "…"} → {report.end_date || "…"} + + ) : null} + + + + + + + + {data === null ? ( + + + The cached data for this report has expired. Regenerate it to rebuild the snapshot. + + + + ) : ( + + + setSelectedPeriod(v ?? "*")} + disabled={groupOptions.period.length === 0} + renderInput={(params) => } + /> + setSelectedPayee(v ?? "*")} + disabled={groupOptions.payee.length === 0} + renderInput={(params) => } + /> + + Showing {slice.count} transactions across {slice.txns.length} rows + + + + + + + + + + + {metrics.map((m) => { + const value = metricValue(m.key); + if (value == null) return null; + return ( + + + {m.label} + + + {value} + + + ); + })} + + + {selectedPeriod === "*" && selectedPayee === "*" && pivot.rows.length > 0 && ( + + + + + Period + {pivot.payees.map((payee) => ( + + {payee} + + ))} + + Total + + + + + {pivot.rows.map((row) => ( + + {row.period} + {row.cells.map((cell) => ( + + {cell.sum === 0 ? "—" : formatCurrency(cell.sum, slice.currency)} + + ))} + + {formatCurrency(row.periodSum, slice.currency)} + + + ))} + + Total + {pivot.totals.map((t) => ( + + {formatCurrency(t.sum, slice.currency)} + + ))} + + {formatCurrency(pivot.rows.reduce((s, r) => s + r.periodSum, 0), slice.currency)} + + + +
+
+ )} + + {months.length === 0 ? ( + + + No transactions in this slice. + + + ) : ( + + {months.map((group) => ( + setOpenMonth(isExpanded ? group.key : null)} + TransitionProps={{ unmountOnExit: true }} + sx={{ + border: "1px solid", + borderColor: openMonth === group.key ? "primary.main" : "divider", + borderRadius: 2, + overflow: "hidden", + boxShadow: "none", + backgroundColor: "background.paper", + "&:before": { display: "none" }, + "&:hover": { borderColor: "primary.light" }, + }} + > + } + sx={{ + px: 2, + py: 1, + "& .MuiAccordionSummary-content": { alignItems: "center", gap: 1.5, minWidth: 0 }, + }} + > + + {monthLabel(group.key)} + + + {group.items.length} transaction{group.items.length === 1 ? "" : "s"} + + + + {formatCurrency(group.spent, group.currency)} + + + / + + + {formatCurrency(group.income, group.currency)} + + + + + {fields && + group.items.map((item) => ( + + ))} + + + + ))} + + )} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/Reports/Reports.tsx b/src/Reports/Reports.tsx new file mode 100644 index 0000000..61b3e0e --- /dev/null +++ b/src/Reports/Reports.tsx @@ -0,0 +1,160 @@ +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 { GenerateReportPanel } from "./GenerateReportPanel"; +import { ReportList } from "./ReportList"; +import { ReportViewer } from "./ReportViewer"; +import { apiErrorMessage, buildReportFieldConfigs, buildTxnFieldConfigs } from "./types"; + +function StatCard({ label, value, hint }: { label: string; value: string; hint?: string }) { + return ( + + + {label} + + + {value} + + {hint && ( + + {hint} + + )} + + ); +} + +export default function Reports() { + const { resources } = useAppContext(); + const { showToast } = useToast(); + const { list, loading, error } = useResource("reports"); + const { create, remove } = useResource("reports"); + + const [reports, setReports] = useState(null); + const [selectedId, setSelectedId] = useState(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 = { 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 ( + + + + {error && ( + + Failed to load reports: {error} + + )} + + + + + + + + + + + Saved reports + + + {reports !== null && reports.length === 0 ? ( + + } + 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)." + /> + + ) : ( + + setSelectedId(id)} + onRegenerate={handleRegenerate} + onDelete={handleDelete} + /> + + )} + + {selectedId && ( + setSelectedId(null)} + onRegenerated={handleRegenerate} + /> + )} + + ); +} \ No newline at end of file diff --git a/src/Reports/types.ts b/src/Reports/types.ts new file mode 100644 index 0000000..1c5760c --- /dev/null +++ b/src/Reports/types.ts @@ -0,0 +1,302 @@ +import type { FieldConfig, ResourceConfig } from "../../react-openapi"; + +const DDMMYYYY = /^(\d{2})-(\d{2})-(\d{4})$/; + +export interface ParsedGroupKey { + period?: { granularity: string; label: string }; + payee?: { label: string }; + range?: { start?: string; end?: string }; +} + +export interface ReportGroupLike { + key: string; + group_label?: string; + metrics?: Record; + txns?: any[]; +} + +export interface SliceFilter { + period?: string; + payee?: string; +} + +export interface SliceSummary { + sum: number; + count: number; + avg: number | null; + min: number | null; + max: number | null; + firstDate: string | null; + lastDate: string | null; + txns: any[]; + spent: number; + income: number; + currency: string; +} + +export interface PivotCell { + payee: string; + sum: number; + count: number; +} + +export interface PivotRow { + period: string; + cells: PivotCell[]; + periodSum: number; + periodCount: number; +} + +export interface PivotTable { + rows: PivotRow[]; + payees: string[]; + totals: PivotCell[]; +} + +export interface TxnFieldConfigs { + entity: FieldConfig; + amount: FieldConfig; + account: FieldConfig; + occurredAt: FieldConfig; + logo: FieldConfig; + formats: { entity: string; account: string }; +} + +export interface ReportFieldConfigs { + groupLabel: FieldConfig; + granularity: FieldConfig; + periodLabel: FieldConfig; + payee: FieldConfig; + entityCount: FieldConfig; + generatedAt: FieldConfig; +} + +export interface MetricLabel { + key: string; + label: string; + order: number; +} + +/** Split a concrete cache key into its dimension parts, e.g. `period:monthly:2026-Jan|payee:Zepto`. */ +export function parseCacheKey(key: string): ParsedGroupKey { + const out: ParsedGroupKey = {}; + for (const dim of key.split("|")) { + if (!dim) continue; + const [name, ...rest] = dim.split(":"); + if (name === "period" && rest.length >= 2) { + out.period = { granularity: rest[0], label: rest.slice(1).join(":") }; + } else if (name === "payee" && rest.length >= 1) { + out.payee = { label: rest.join(":") }; + } else if (name === "range" && rest.length >= 1) { + out.range = { start: rest[0] || undefined, end: rest[1] || undefined }; + } + } + return out; +} + +export function isDdmmyyyy(value?: string): boolean { + if (!value) return true; + const m = value.match(DDMMYYYY); + if (!m) return false; + const [, dd, mm, yyyy] = m; + const d = new Date(Number(yyyy), Number(mm) - 1, Number(dd)); + return !( + Number.isNaN(d.getTime()) || + d.getDate() !== Number(dd) || + d.getMonth() !== Number(mm) - 1 || + d.getFullYear() !== Number(yyyy) + ); +} + +export function parseDdmmyyyy(value: string): Date { + const m = value.match(DDMMYYYY); + if (!m) throw new Error(`Date is not DD-MM-YYYY: ${value}`); + const [, dd, mm, yyyy] = m; + const d = new Date(Number(yyyy), Number(mm) - 1, Number(dd)); + if ( + Number.isNaN(d.getTime()) || + d.getDate() !== Number(dd) || + d.getMonth() !== Number(mm) - 1 || + d.getFullYear() !== Number(yyyy) + ) { + throw new Error(`Invalid date: ${value}`); + } + return d; +} + +export function apiErrorMessage(e: any): string { + if (e?.response?.data) { + const d = e.response.data; + if (Array.isArray(d)) return d.map((x: any) => x?.msg ?? String(x)).join("; "); + if (typeof d.detail === "string") return d.detail; + if (typeof d.detail?.msg === "string") return d.detail.msg; + if (typeof d === "string") return d; + } + return e?.message ?? "Request failed"; +} + +export function groupTypeEnum(schemas: Record): string[] { + return schemas?.GroupSpec?.properties?.group_type?.enum ?? []; +} + +export function groupValueFk(schemas: Record): { resource?: string; prefetch?: boolean } | null { + const fk = schemas?.GroupSpec?.properties?.group_value?.["x-fk"]; + return fk && typeof fk === "object" ? fk : null; +} + +export function metricLabels(schemas: Record): MetricLabel[] { + const props: Record = schemas?.ReportMetrics?.properties ?? {}; + return Object.entries(props) + .filter(([, p]) => p && typeof p === "object") + .map(([key, p]) => ({ + key, + label: (p as any)["x-label"] ?? key, + order: (p as any)["x-order"] ?? Infinity, + })) + .sort((a, b) => a.order - b.order); +} + +export function periodHints(granularity: string): string[] { + const y = new Date().getFullYear(); + switch (granularity) { + case "weekly": + return [`${y}-W01`, `${y}-W26`]; + case "monthly": + return [`${y}-Jan`, `${y}-Feb`]; + case "quarterly": + return [`${y}-Jan-Mar`, `${y}-Apr-Jun`]; + case "yearly": + return [`${y - 1}`, `${y}`]; + default: + return [`${y}-Jan`, `${y}-W01`, `${y}-Jan-Mar`, `${y}`]; + } +} + +function dateVal(value: string): number { + try { + return parseDdmmyyyy(value).getTime(); + } catch { + return 0; + } +} + +export function groupMatches(group: ReportGroupLike, filter: SliceFilter): boolean { + const key = parseCacheKey(group.key); + if (filter.period && filter.period !== "*") { + if (!key.period || key.period.label !== filter.period) return false; + } + if (filter.payee && filter.payee !== "*") { + if (!key.payee || key.payee.label !== filter.payee) return false; + } + return true; +} + +export function aggregateSlice(groups: ReportGroupLike[], filter: SliceFilter): SliceSummary { + let sum = 0; + let count = 0; + let spent = 0; + let income = 0; + let min: number | null = null; + let max: number | null = null; + let firstDate: string | null = null; + let lastDate: string | null = null; + let currency = "INR"; + const txns: any[] = []; + const seen = new Set(); + + for (const group of groups) { + if (!groupMatches(group, filter)) continue; + const m = group.metrics ?? {}; + if (typeof m.sum === "number") sum += m.sum; + if (typeof m.count === "number") count += m.count; + if (typeof m.min === "number") min = min === null ? m.min : Math.min(min, m.min); + if (typeof m.max === "number") max = max === null ? m.max : Math.max(max, m.max); + if (m.first_date && (!firstDate || dateVal(String(m.first_date)) < dateVal(firstDate))) { + firstDate = String(m.first_date); + } + if (m.last_date && (!lastDate || dateVal(String(m.last_date)) > dateVal(lastDate))) { + lastDate = String(m.last_date); + } + for (const txn of group.txns ?? []) { + if (txn?.id != null) { + if (seen.has(txn.id)) continue; + seen.add(txn.id); + } + txns.push(txn); + const amt = Number(txn?.amount ?? 0); + if (amt < 0) spent += Math.abs(amt); + else income += amt; + const c = txn?.account?.currency; + if (c) currency = c; + } + } + + return { sum, count, avg: count ? sum / count : null, min, max, firstDate, lastDate, txns, spent, income, currency }; +} + +export function buildPivot(groups: ReportGroupLike[], periodOrder: string[], payeeOrder: string[]): PivotTable { + const payees = payeeOrder.filter((payee) => + groups.some((g) => parseCacheKey(g.key).payee?.label === payee), + ); + const totals = payees.map((payee) => ({ payee, sum: 0, count: 0 })); + const rows: PivotRow[] = []; + + for (const period of periodOrder) { + const periodGroups = groups.filter((g) => parseCacheKey(g.key).period?.label === period); + if (periodGroups.length === 0) continue; + const cells = payees.map((payee, i) => { + const cellGroups = periodGroups.filter((g) => parseCacheKey(g.key).payee?.label === payee); + const sum = cellGroups.reduce((s, g) => s + (g.metrics?.sum ?? 0), 0); + const count = cellGroups.reduce((s, g) => s + (g.metrics?.count ?? 0), 0); + totals[i].sum += sum; + totals[i].count += count; + return { payee, sum, count }; + }); + rows.push({ + period, + cells, + periodSum: cells.reduce((s, c) => s + c.sum, 0), + periodCount: cells.reduce((s, c) => s + c.count, 0), + }); + } + + return { rows, payees, totals }; +} + +export function buildTxnFieldConfigs(resources: ResourceConfig[]): TxnFieldConfigs | null { + const expensesRes = resources.find((r) => r.name === "expenses"); + const entitiesRes = resources.find((r) => r.name === "entities"); + const accountsRes = resources.find((r) => r.name === "accounts"); + const find = (res: ResourceConfig | undefined, name: string) => res?.fields.find((f) => f.name === name); + const entity = find(expensesRes, "entity"); + const amount = find(expensesRes, "amount"); + const account = find(expensesRes, "account"); + const occurredAt = find(expensesRes, "occurred_at"); + const logo = find(entitiesRes, "logo"); + if (!entity || !amount || !account || !occurredAt || !logo) return null; + return { + entity, + amount, + account, + occurredAt, + logo, + formats: { + entity: entitiesRes?.displayFormat ?? "{name}", + account: accountsRes?.displayFormat ?? "{name}", + }, + }; +} + +export function buildReportFieldConfigs(resources: ResourceConfig[]): ReportFieldConfigs | null { + const reportsRes = resources.find((r) => r.name === "reports"); + if (!reportsRes) return null; + const find = (name: string) => reportsRes.fields.find((f) => f.name === name); + const groupLabel = find("group_label"); + const granularity = find("granularity"); + const periodLabel = find("period_label"); + const payee = find("payee"); + const entityCount = find("entity_count"); + const generatedAt = find("generated_at"); + if (!groupLabel || !granularity || !periodLabel || !payee || !entityCount || !generatedAt) return null; + return { groupLabel, granularity, periodLabel, payee, entityCount, generatedAt }; +} \ No newline at end of file diff --git a/src/main.jsx b/src/main.jsx index ad1d0f1..7c4d8ea 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -18,6 +18,7 @@ import Home from './Home'; import FetchRequests from './FetchRequest/FetchRequestCreate'; import FetchRequestDetail from './FetchRequest/FetchRequestDetail'; import Expense from './Expense/Expense'; +import Reports from './Reports/Reports'; import { AppProvider, useAppContext, Admin, ProfileRoutes } from '../react-openapi'; import { AuthProvider, AuthPage, useAuth, ProfileCreate, ProfileEdit, ProfileView } from "../react-auth"; import Header from './Header'; @@ -80,6 +81,7 @@ const routerMapping = [ { path: "/fetch-requests", component: FetchRequests, headerTitle: "Fetch Requests" }, { path: "/fetch-requests/:id", component: FetchRequestDetail, headerTitle: "Fetch Request" }, { path: "/expenses", component: Expense, headerTitle: "Expenses" }, + { path: "/reports", component: Reports, headerTitle: "Reports" }, { path: "/admin/*", component: Admin, headerTitle: "Admin" }, { path: "/profile/*", component: ProfileRoutes, headerTitle: "Profile" }, ];