From 2add23efe782f4077fe9e7209a8e1bcf93e51700 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Fri, 21 Aug 2026 14:16:35 +0000 Subject: [PATCH] =?UTF-8?q?Reports=20frontend=20=E2=80=94=20spec-driven=20?= =?UTF-8?q?generate=20form,=20sliceable=20viewer,=20dimension=20bars=20(#1?= =?UTF-8?q?6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # MR: Reports frontend — spec-driven generate form, sliceable viewer, dimension bars **Branch:** `cached-reporting` → **Target:** `main` ## Summary Reports UI rebuilt to treat the backend OpenAPI spec as the source of truth: the generate-report form renders generically from `ReportQuery`, and the viewer displays API-computed metrics verbatim instead of recomputing them client-side. ## Changes ### react-openapi (library) - `MultiEnumField` renderer for array-of-enum fields; dispatch branch in `FormFieldRenderer` - `useFkFieldOptions` hook + `FKFieldConfig.value` (FK options keyed by a non-PK field — accounts resolve by `name`) - `FieldConfig.defaultValue` seeding in `extractFields`; enum lift for `items.enum` arrays; sanitize-payload bypasses FK resolution when `fk.value` is set ### Reports page - **GenerateReportPanel**: rewritten (~275 → ~110 lines) — fields extracted from the served `ReportQuery` schema (labels, order, defaults, multi-enum, account picker); server-side validation only - **ReportViewer**: - Period groups built from cube buckets keyed by canonical `period_id`; buckets merged per period (disjoint slices), txn ids deduped - Cards/bars/metric strips read `metrics` verbatim (outflows/inflows/sum/count/ avg/min/max/cadence/frequency); Net = one subtraction at render - Dimension-switchable bar strip (Period / Payees / Tags): segmented pill control with sliding indicator; pills disabled when the report has ≤1 distinct value; top-10 bars in a fixed view paged by chevrons (hidden scrollbar) - Active dim requests the full distribution via `payee=*` / `tags=*` when its own filter is unselected; explicit selections always slice normally - **TransactionList**: shared component with optional `groups` prop (API periods + metrics) used by the viewer; Expense page keeps the client-side fallback path; group strip shows Sum · Avg · Min · Max · Cadence (or Frequency /day when cadence < 1) ### Fixes - Strict DD-MM-YYYY parsing (`parseOccurredAt`) in period merges and date ordering — `new Date()` mis-read dates as MM-DD-YYYY and corrupted merged cadence (Aug Zomato: 12.89 days displayed vs 0.76 actual) - Bar widths magnitude-based so all-outflow slices render at proper width Reviewed-on: https://git.aetoskia.com/apps/khata-ui/pulls/16 Co-authored-by: Vishesh 'ironeagle' Bangotra Co-committed-by: Vishesh 'ironeagle' Bangotra --- react-openapi/index.ts | 5 + .../components/fields/FormFieldRenderer.tsx | 12 + react-openapi/src/components/fields/index.ts | 1 + .../fields/renderers/MultiEnumField.tsx | 59 ++ react-openapi/src/hooks/useApi.ts | 19 + react-openapi/src/hooks/useFkFieldOptions.ts | 82 +++ .../src/transformers/field-config.ts | 5 +- react-openapi/src/types.ts | 4 + react-openapi/src/utils/sanitize-payload.ts | 4 +- src/{Expense => }/Expense.tsx | 71 +-- src/Expense/types.ts | 65 --- src/Header.tsx | 1 + src/Reports/GenerateReportPanel.tsx | 126 +++++ src/Reports/Report.tsx | 184 +++++++ src/Reports/ReportList.tsx | 116 ++++ src/Reports/ReportViewer.tsx | 502 ++++++++++++++++++ src/Reports/types.ts | 288 ++++++++++ src/common/components/StatCard.tsx | 26 + .../components/TransactionList.tsx} | 262 ++++----- src/common/components/TransactionRow.tsx | 71 +++ src/common/types.ts | 21 + src/common/utils/dates.ts | 71 +++ src/common/utils/fieldConfigs.ts | 26 + src/common/utils/transactions.ts | 161 ++++++ src/main.jsx | 4 +- 25 files changed, 1932 insertions(+), 254 deletions(-) create mode 100644 react-openapi/src/components/fields/renderers/MultiEnumField.tsx create mode 100644 react-openapi/src/hooks/useFkFieldOptions.ts rename src/{Expense => }/Expense.tsx (62%) delete mode 100644 src/Expense/types.ts create mode 100644 src/Reports/GenerateReportPanel.tsx create mode 100644 src/Reports/Report.tsx create mode 100644 src/Reports/ReportList.tsx create mode 100644 src/Reports/ReportViewer.tsx create mode 100644 src/Reports/types.ts create mode 100644 src/common/components/StatCard.tsx rename src/{Expense/ExpenseList.tsx => common/components/TransactionList.tsx} (55%) create mode 100644 src/common/components/TransactionRow.tsx create mode 100644 src/common/types.ts create mode 100644 src/common/utils/dates.ts create mode 100644 src/common/utils/fieldConfigs.ts create mode 100644 src/common/utils/transactions.ts diff --git a/react-openapi/index.ts b/react-openapi/index.ts index 0f1a3ac..0dd032d 100644 --- a/react-openapi/index.ts +++ b/react-openapi/index.ts @@ -5,7 +5,12 @@ export { useAppContext } from "./src/context/AppContext"; export { useResource } from "./src/context/useResource"; export { ListCellRenderer, DetailFieldRenderer, applyDisplayFormat } from "./src/components/fields"; export { FormFieldRenderer } from "./src/components/fields/FormFieldRenderer"; +export { MultiEnumField } from "./src/components/fields/renderers/MultiEnumField"; +export { extractFields } from "./src/transformers/field-config"; +export { useFkFieldOptions } from "./src/hooks/useFkFieldOptions"; +export type { FkOption } from "./src/hooks/useFkFieldOptions"; export { CurrencyField, formatCurrency } from "./src/components/fields/renderers/CurrencyField"; +export { FkMultiSelectField } from "./src/components/fields/renderers/FkMultiSelectField"; export { SseStreamView } from "./src/components/SseStreamView"; export { SseConnectionStatus } from "./src/components/SseConnectionStatus"; export { getApi } from "./src/hooks/useApi"; diff --git a/react-openapi/src/components/fields/FormFieldRenderer.tsx b/react-openapi/src/components/fields/FormFieldRenderer.tsx index 4b21f66..eb6a896 100644 --- a/react-openapi/src/components/fields/FormFieldRenderer.tsx +++ b/react-openapi/src/components/fields/FormFieldRenderer.tsx @@ -6,6 +6,7 @@ import { NumberField } from "./renderers/NumberField"; import { DateField } from "./renderers/DateField"; import { BooleanField } from "./renderers/BooleanField"; import { EnumField } from "./renderers/EnumField"; +import { MultiEnumField } from "./renderers/MultiEnumField"; import { FkSelectField } from "./renderers/FkSelectField"; import { FkMultiSelectField } from "./renderers/FkMultiSelectField"; import { FileUploadField } from "./renderers/FileUploadField"; @@ -81,6 +82,17 @@ export function FormFieldRenderer({ field, value, onChange, error, fkOptions, fk ); } + if (field.isArray && field.enumValues) { + return ( + + ); + } + if (field.enumValues) { return ( void; + error?: string; +} + +export function MultiEnumField({ field, value, onChange, error }: Props) { + const selected: string[] = Array.isArray(value) ? value : []; + + return ( + onChange(newVal)} + renderOption={(props, option, { selected: isSelected }) => { + const { key, ...rest } = props as any; + return ( +
  • + {isSelected ? ( + + ) : ( + + )} + {option} +
  • + ); + }} + renderTags={(tagValue, getTagProps) => + tagValue.map((tag, index) => { + const { key, ...tagProps } = getTagProps({ index }); + return ; + }) + } + renderInput={(params) => ( + + )} + sx={{ + "& .MuiAutocomplete-popupIndicator, & .MuiAutocomplete-clearIndicator": { width: 20, height: 20, fontSize: 16 }, + }} + disabled={field.readOnly} + /> + ); +} diff --git a/react-openapi/src/hooks/useApi.ts b/react-openapi/src/hooks/useApi.ts index 7d7b5b4..c201b5b 100644 --- a/react-openapi/src/hooks/useApi.ts +++ b/react-openapi/src/hooks/useApi.ts @@ -3,6 +3,24 @@ import axios, { AxiosInstance } from "axios"; let apiClient: AxiosInstance | null = null; let _onUnauthorized: (() => void) | undefined; +function serializeParams(params: Record): string { + const searchParams = new URLSearchParams(); + for (const [key, value] of Object.entries(params ?? {})) { + if (value === undefined || value === null) continue; + if (Array.isArray(value)) { + for (const item of value) searchParams.append(key, String(item)); + } else if (typeof value === "object") { + for (const [nestedKey, nestedValue] of Object.entries(value)) { + if (nestedValue === undefined || nestedValue === null) continue; + searchParams.append(`${key}[${nestedKey}]`, String(nestedValue)); + } + } else { + searchParams.append(key, String(value)); + } + } + return searchParams.toString(); +} + export function initApi(baseUrl: string, getToken?: () => string | null, onUnauthorized?: () => void): AxiosInstance { if (apiClient && apiClient.defaults.baseURL === baseUrl) { _onUnauthorized = onUnauthorized; @@ -14,6 +32,7 @@ export function initApi(baseUrl: string, getToken?: () => string | null, onUnaut apiClient = axios.create({ baseURL: baseUrl, headers: { "Content-Type": "application/json" }, + paramsSerializer: serializeParams, }); apiClient.interceptors.request.use((config) => { diff --git a/react-openapi/src/hooks/useFkFieldOptions.ts b/react-openapi/src/hooks/useFkFieldOptions.ts new file mode 100644 index 0000000..47497a7 --- /dev/null +++ b/react-openapi/src/hooks/useFkFieldOptions.ts @@ -0,0 +1,82 @@ +import { useCallback, useEffect, useState } from "react"; +import type { FieldConfig, FKFieldConfig } from "../types"; +import { useAppContext } from "../context/AppContext"; +import { getApi } from "./useApi"; + +export interface FkOption { + value: any; + label: string; +} + +function applyFormat(obj: any, format: string): string { + if (!obj || typeof obj !== "object") return String(obj ?? ""); + return format.replace(/\{(\w+)\}/g, (_, key) => String(obj[key] ?? "")); +} + +/** + * Loads select options for FK-annotated fields of a field set. + * Option values bind to `fk.value` when set (e.g. account names), + * otherwise to the target resource's primary key. + */ +export function useFkFieldOptions(fields: FieldConfig[]) { + const { resources } = useAppContext(); + const [fkOptions, setFkOptions] = useState>({}); + const [fkLoading, setFkLoading] = useState>({}); + + const loadFkOptions = useCallback( + async (fieldName: string, fk: FKFieldConfig) => { + setFkLoading((prev) => ({ ...prev, [fieldName]: true })); + try { + const targetRes = resources.find((r) => r.name === fk.resource); + if (!targetRes) return; + + const api = getApi(); + const params: Record = {}; + if (targetRes.pagination) params.limit = 0; + const res = await api.get(targetRes.path, { params }); + + let items: any[]; + if (targetRes.pagination) { + if (!res.data || typeof res.data !== "object" || !Array.isArray(res.data.items)) { + throw new Error(`Expected paginated response from ${targetRes.path}`); + } + items = res.data.items; + } else { + if (!Array.isArray(res.data)) { + throw new Error(`Expected array response from ${targetRes.path}`); + } + items = res.data; + } + + const opts: FkOption[] = items.map((item: any) => ({ + value: item[fk.value ?? targetRes.primaryKey], + label: applyFormat(item, targetRes.displayFormat), + })); + setFkOptions((prev) => ({ ...prev, [fieldName]: opts })); + } catch { + // leave options empty; the field renders without suggestions + } finally { + setFkLoading((prev) => ({ ...prev, [fieldName]: false })); + } + }, + [resources] + ); + + useEffect(() => { + fields.forEach((f) => { + if (f.fk?.prefetch) loadFkOptions(f.name, f.fk); + }); + }, [fields, loadFkOptions]); + + const loadFkOnOpen = useCallback( + (fieldName: string) => { + const field = fields.find((f) => f.name === fieldName); + if (field?.fk && !field.fk.prefetch && !fkOptions[fieldName]) { + loadFkOptions(fieldName, field.fk); + } + }, + [fields, fkOptions, loadFkOptions] + ); + + return { fkOptions, fkLoading, loadFkOnOpen }; +} diff --git a/react-openapi/src/transformers/field-config.ts b/react-openapi/src/transformers/field-config.ts index 87a0e14..cbdb793 100644 --- a/react-openapi/src/transformers/field-config.ts +++ b/react-openapi/src/transformers/field-config.ts @@ -136,7 +136,10 @@ export function extractFields(schemaName: string, schema: any, schemas: Record - - {label} - - - {value} - - {hint && ( - - {hint} - - )} - - ); -} +import { useResource, useAppContext, formatCurrency } from "../react-openapi"; +import { PageHeader } from "./ui/PageHeader"; +import { EmptyState } from "./ui/EmptyState"; +import { StatCard } from "./common/components/StatCard"; +import { TransactionList } from "./common/components/TransactionList"; +import { buildTxnFieldConfigs } from "./common/utils/fieldConfigs"; +import { isExpense } from "./common/utils/transactions"; +import { currentMonthKey, monthKey, monthLabel, parseOccurredAt } from "./common/utils/dates"; +import type { ExpenseItem } from "./common/types"; export default function Expense() { const navigate = useNavigate(); @@ -43,33 +26,7 @@ export default function Expense() { const { resources } = useAppContext(); const [items, setItems] = useState(null); - const fieldConfigs = useMemo(() => { - if (!resource) return null; - const find = (name: string) => resource.fields.find((f) => f.name === name); - const entity = find("entity"); - const amount = find("amount"); - const account = find("account"); - const tags = find("tags"); - const occurredAt = find("occurred_at"); - const entitiesRes = resources.find((r) => r.name === "entities"); - const accountsRes = resources.find((r) => r.name === "accounts"); - const tagsRes = resources.find((r) => r.name === "tags"); - const logo = entitiesRes?.fields.find((f) => f.name === "logo"); - if (!entity || !amount || !account || !tags || !occurredAt || !logo) return null; - return { - entity, - amount, - account, - tags, - occurredAt, - logo, - formats: { - entity: entitiesRes?.displayFormat ?? "{name}", - account: accountsRes?.displayFormat ?? "{name}", - tags: tagsRes?.displayFormat ?? "{name}", - }, - }; - }, [resource, resources]); + const fieldConfigs = useMemo(() => buildTxnFieldConfigs(resources), [resources]); useEffect(() => { let mounted = true; @@ -80,7 +37,7 @@ export default function Expense() { return () => { mounted = false; }; - }, []); + }, [list]); const sorted = useMemo( () => @@ -153,15 +110,15 @@ export default function Expense() { <> - + - {fieldConfigs && } + {fieldConfigs && } )} diff --git a/src/Expense/types.ts b/src/Expense/types.ts deleted file mode 100644 index efe769e..0000000 --- a/src/Expense/types.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { FieldConfig } from "../../react-openapi"; - -export interface ExpenseItem { - id: string; - entity?: { name?: string; type?: string; logo?: string } | null; - amount: number; - account?: { name?: string; number?: string; type?: string; currency?: string } | null; - tags?: { icon?: string; name?: string }[]; - occurred_at?: string; - created_at?: string; - updated_at?: string; -} - -export interface ExpenseFieldConfigs { - entity: FieldConfig; - amount: FieldConfig; - account: FieldConfig; - tags: FieldConfig; - occurredAt: FieldConfig; - logo: FieldConfig; - formats: { - entity: string; - account: string; - tags: string; - }; -} - -export function isExpense(item: ExpenseItem): boolean { - return (item.amount ?? 0) < 0; -} - -export function parseOccurredAt(value?: string): Date { - const m = value?.match(/^(\d{2})-(\d{2})-(\d{4})$/); - if (!m) { - throw new Error(`Expense occurred_at 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 expense occurred_at date: ${value}`); - } - return d; -} - -export function monthKey(value?: string): string { - const d = parseOccurredAt(value); - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; -} - -export function currentMonthKey(): string { - const now = new Date(); - return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; -} - -export function monthLabel(key: string): string { - const [y, m] = key.split("-").map(Number); - if (!y || !m) return key; - const d = new Date(y, m - 1, 1); - return d.toLocaleDateString("en-IN", { month: "long", year: "numeric" }); -} \ No newline at end of file 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..2ebe2de --- /dev/null +++ b/src/Reports/GenerateReportPanel.tsx @@ -0,0 +1,126 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { + Box, + Paper, + Typography, + Button, + Grid, + Alert, +} from "@mui/material"; +import AddIcon from "@mui/icons-material/Add"; +import { + useResource, + useAppContext, + FormFieldRenderer, + extractFields, + useFkFieldOptions, +} from "../../react-openapi"; +import type { FieldConfig } from "../../react-openapi"; +import { useToast } from "../ui/Toast"; +import { apiErrorMessage } from "./types"; + +interface GenerateReportPanelProps { + onGenerated: (report: any) => void; +} + +export function GenerateReportPanel({ onGenerated }: GenerateReportPanelProps) { + const { schemas } = useAppContext(); + const { create, loading: submitting } = useResource("reports"); + const { showToast } = useToast(); + + const [formData, setFormData] = useState>({}); + const [error, setError] = useState(null); + + const fields: FieldConfig[] = useMemo(() => { + const schema = schemas?.ReportQuery; + if (!schema) return []; + return extractFields("ReportQuery", schema, schemas).sort( + (a, b) => a.order - b.order || a.name.localeCompare(b.name) + ); + }, [schemas]); + + const { fkOptions, fkLoading, loadFkOnOpen } = useFkFieldOptions(fields); + + useEffect(() => { + setFormData((prev) => { + const next = { ...prev }; + let changed = false; + for (const f of fields) { + if (next[f.name] === undefined && f.defaultValue !== undefined) { + next[f.name] = Array.isArray(f.defaultValue) ? [...f.defaultValue] : f.defaultValue; + changed = true; + } + } + return changed ? next : prev; + }); + }, [fields]); + + const handleChange = useCallback((name: string, value: any) => { + setFormData((prev) => ({ ...prev, [name]: value })); + setError(null); + }, []); + + const buildPayload = (): Record => { + const payload: Record = {}; + for (const f of fields) { + const v = formData[f.name]; + if (v === undefined || v === null || v === "") continue; + if (Array.isArray(v) && v.length === 0) continue; + payload[f.name] = v; + } + return payload; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + try { + const created = await create(buildPayload()); + showToast(`Generated snapshot ${created?.name ? `“${created.name}”` : ""}`.trim() || "Generated snapshot"); + onGenerated(created); + } catch (e: any) { + setError(apiErrorMessage(e)); + } + }; + + return ( + + + Generate report + + + Define the snapshot's scope. Granularity, payee and tag are sliced at view time — the cube is built once and + every combination stays cheap to read. + + + {error && ( + + {error} + + )} + + + + {fields.map((field) => ( + + handleChange(field.name, val)} + fkOptions={fkOptions[field.name]} + fkLoading={fkLoading[field.name]} + onFkOpen={loadFkOnOpen} + /> + + ))} + + + + + + + + ); +} diff --git a/src/Reports/Report.tsx b/src/Reports/Report.tsx new file mode 100644 index 0000000..0f45bdc --- /dev/null +++ b/src/Reports/Report.tsx @@ -0,0 +1,184 @@ +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(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?.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(); + for (const r of rows) for (const g of r.query?.granularities ?? []) granularities.add(g); + return { count: rows.length, granularities: [...granularities].join(", ") }; + }, [reports]); + + return ( + + + + {error && ( + + Failed to load reports: {error} + + )} + + + + + + + + + + } + sx={{ + px: 2.5, + py: 1, + "& .MuiAccordionSummary-content": { + alignItems: "center", + gap: 1.5, + minWidth: 0, + }, + }} + > + + Saved reports + + {reports !== null && ( + + {reports.length} report{reports.length === 1 ? "" : "s"} + + )} + + + {reports !== null && reports.length === 0 ? ( + + } + 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." + /> + + ) : ( + + setSelectedId(id)} + onRegenerate={handleRegenerate} + onDelete={handleDelete} + /> + + )} + + + + {selectedId && ( + setSelectedId(null)} + onRegenerated={handleRegenerate} + /> + )} + + ); +} \ No newline at end of file diff --git a/src/Reports/ReportList.tsx b/src/Reports/ReportList.tsx new file mode 100644 index 0000000..ad264cf --- /dev/null +++ b/src/Reports/ReportList.tsx @@ -0,0 +1,116 @@ +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 } 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 q = report.query ?? {}; + const range = q.start_date || q.end_date ? `range ${q.start_date || "…"} → ${q.end_date || "…"}` : null; + const dims = Array.isArray(q.group_dims) ? q.group_dims.join(", ") : ""; + const granularities = Array.isArray(q.granularities) ? q.granularities.join(", ") : ""; + const accounts = Array.isArray(q.accounts) ? `${q.accounts.length} account${q.accounts.length === 1 ? "" : "s"}` : "all accounts"; + const amounts = + q.min_amount != null || q.max_amount != null + ? `amount ${q.min_amount ?? "0"} → ${q.max_amount ?? "∞"}` + : null; + return ( + + + + + {report.name || report.id} + + + + {granularities} + + + / + + + {dims} + + {range && ( + + {range} + + )} + + + + {accounts} + + {amounts && ( + + {amounts} + + )} + {fields && ( + + + + )} + + + + + + + + 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..ad151f2 --- /dev/null +++ b/src/Reports/ReportViewer.tsx @@ -0,0 +1,502 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Box, Paper, Typography, Button, IconButton, Alert, Skeleton, Chip, MenuItem, Select, FormControl, InputLabel } from "@mui/material"; +import CloseIcon from "@mui/icons-material/Close"; +import CachedIcon from "@mui/icons-material/Cached"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; +import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; +import { FkMultiSelectField, useResource, formatCurrency } from "../../react-openapi"; +import type { FieldConfig } from "../../react-openapi"; +import { StatCard } from "../common/components/StatCard"; +import { TransactionList } from "../common/components/TransactionList"; +import type { TxnFieldConfigs } from "../common/types"; +import type { ListPeriodGroup } from "../common/utils/transactions"; +import { buildDimensionBars, buildPeriodGroups, sliceSummary, FLOW_OPTIONS, apiErrorMessage } from "./types"; +import type { ReportDim } from "./types"; + +const MAX_VISIBLE_BARS = 10; + +const periodField: FieldConfig = { + name: "period", + label: "Period", + description: "", + type: "string", + order: 0, + hidden: {}, + filterable: true, + sortable: false, + readOnly: false, + required: false, + isArray: true, +}; + +const payeeField: FieldConfig = { + name: "payee", + label: "Payee", + description: "", + type: "string", + order: 0, + hidden: {}, + filterable: true, + sortable: false, + readOnly: false, + required: false, + isArray: true, +}; + +const tagField: FieldConfig = { + name: "tag", + label: "Tag", + description: "", + type: "string", + order: 0, + hidden: {}, + filterable: true, + sortable: false, + readOnly: false, + required: false, + isArray: true, +}; + +interface ReportViewerProps { + id: string; + version: number; + fields: TxnFieldConfigs | null; + onClose: () => void; + onRegenerated: (report: any) => void; +} + +function snapshotGranularities(report: any): string[] { + const fromQuery = report?.query?.granularities; + if (Array.isArray(fromQuery) && fromQuery.length) return fromQuery; + const fromResponse = report?.granularities; + if (Array.isArray(fromResponse) && fromResponse.length) return fromResponse; + const series = report?.buckets?.[0]?.series; + return series ? Object.keys(series) : []; +} + +export function ReportViewer({ id, version, fields, onClose, onRegenerated }: ReportViewerProps) { + 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 [granularity, setGranularity] = useState(null); + const [flow, setFlow] = useState("outflows"); + const [dim, setDim] = useState("period"); + const [selectedPeriods, setSelectedPeriods] = useState([]); + const [selectedPayees, setSelectedPayees] = useState([]); + const [selectedTags, setSelectedTags] = useState([]); + const prevGranularity = useRef(null); + + const params = useMemo(() => { + const p: Record = { flow }; + if (granularity) p.granularity = [granularity]; + if (selectedPeriods.length) p.period_ids = selectedPeriods; + // The active breakdown dim requests every cube value ("*") when its own + // filter is unselected, so bars show the full distribution. + if (selectedPayees.length) p.payee = selectedPayees; + else if (dim === "payee") p.payee = ["*"]; + if (selectedTags.length) p.tags = selectedTags; + else if (dim === "tag") p.tags = ["*"]; + return p; + }, [granularity, flow, dim, selectedPeriods, selectedPayees, selectedTags]); + + useEffect(() => { + let mounted = true; + setLoading(true); + setError(null); + const previous = prevGranularity.current; + get(id, params) + .then((res) => { + if (!mounted) return; + setReport(res); + const options = snapshotGranularities(res); + if (granularity === null && options.length) setGranularity(options[0]); + if (previous !== null && previous !== granularity) setSelectedPeriods([]); + prevGranularity.current = granularity; + }) + .catch((e: any) => { + if (!mounted) return; + setError(apiErrorMessage(e)); + }) + .finally(() => { + if (mounted) setLoading(false); + }); + return () => { + mounted = false; + }; + }, [id, version, reload, params, get, granularity]); + + const granularityOptions = useMemo(() => snapshotGranularities(report), [report]); + + const periodOptions = useMemo( + () => (Array.isArray(report?.period_ids) ? report.period_ids.map((label: string) => ({ value: label, label })) : []), + [report], + ); + const payeeOptions = useMemo( + () => (Array.isArray(report?.payees) ? report.payees.map((label: string) => ({ value: label, label })) : []), + [report], + ); + const tagOptions = useMemo( + () => (Array.isArray(report?.tags) ? report.tags.map((label: string) => ({ value: label, label })) : []), + [report], + ); + + const filter = useMemo( + () => ({ + granularity: granularity ?? granularityOptions[0] ?? "", + periods: selectedPeriods, + payees: selectedPayees, + tags: selectedTags, + }), + [granularity, granularityOptions, selectedPeriods, selectedPayees, selectedTags], + ); + + const periodGroups = useMemo(() => buildPeriodGroups(report?.buckets ?? [], filter), [report, filter]); + const slice = useMemo(() => sliceSummary(periodGroups), [periodGroups]); + const bars = useMemo(() => buildDimensionBars(report?.buckets ?? [], filter, dim), [report, filter, dim]); + const visibleBars = useMemo(() => bars.slice(0, MAX_VISIBLE_BARS), [bars]); + const listGroups = useMemo( + () => + periodGroups.map((g) => ({ + key: g.key, + label: g.key, + items: g.txns, + spent: g.metrics.outflows, + income: g.metrics.inflows, + currency: g.currency, + metrics: { + sum: g.metrics.sum, + count: g.metrics.count, + avg: g.metrics.avg, + min: g.metrics.min, + max: g.metrics.max, + cadence: g.metrics.cadence, + frequency: g.metrics.frequency, + }, + })), + [periodGroups], + ); + + const barsListRef = useRef(null); + const [barScroll, setBarScroll] = useState({ canUp: false, canDown: false, overflow: false }); + + const updateBarScroll = useCallback(() => { + const el = barsListRef.current; + if (!el) return; + const overflow = el.scrollHeight > el.clientHeight + 2; + setBarScroll({ + overflow, + canUp: overflow && el.scrollTop > 2, + canDown: overflow && el.scrollTop < el.scrollHeight - el.clientHeight - 2, + }); + }, []); + + useEffect(() => { + const el = barsListRef.current; + if (el) el.scrollTop = 0; + updateBarScroll(); + }, [dim, visibleBars.length, updateBarScroll]); + + const scrollBarsByPage = useCallback((dir: number) => { + const el = barsListRef.current; + if (!el) return; + el.scrollTo({ top: dir > 0 ? el.scrollHeight : 0, behavior: "smooth" }); + }, []); + + if (loading && !report) { + return ( + + + + + + ); + } + + if (error) { + return ( + + + {error} + + + + ); + } + + if (!report) return null; + + const activeGranularity = granularity ?? report.granularities?.[0] ?? ""; + const maxBar = bars.reduce((m, b) => Math.max(m, Math.abs(b.sum)), 0); + const dimOptions: { id: ReportDim; label: string; count: number }[] = [ + { id: "period", label: "Period", count: report.period_ids?.length ?? 0 }, + { id: "payee", label: "Payees", count: report.payees?.length ?? 0 }, + { id: "tag", label: "Tags", count: report.tags?.length ?? 0 }, + ]; + const activeDimIndex = Math.max( + 0, + dimOptions.findIndex((d) => d.id === dim), + ); + const range = + report.query?.start_date || report.query?.end_date + ? `range ${report.query.start_date || "…"} → ${report.query.end_date || "…"}` + : null; + + return ( + + + + + {report.name} + + + + flow {report.flow} · generated {report.generated_at ?? report.created_at} + + {range && ( + + {range} + + )} + + {report.payees?.length ?? 0} payees · {report.tags?.length ?? 0} tags + + + + onRegenerated(report)}> + + + + + + + + + + + {granularityOptions.map((g: string) => ( + setGranularity(g)} + /> + ))} + + + Flow + + + + + + + + + + + + + + + + {slice.txns.length} transactions · {slice.count} rows + + + + + + + + + + + + + + {dimOptions.map((d) => { + const active = d.id === dim; + return ( + setDim(d.id)} + sx={{ + position: "relative", + zIndex: 1, + flex: 1, + border: "none", + background: "transparent", + px: 3, + py: 1, + borderRadius: "999px", + cursor: d.count <= 1 ? "default" : "pointer", + typography: "body2", + fontWeight: 700, + letterSpacing: "-0.01em", + color: + d.count <= 1 + ? "text.disabled" + : active + ? "primary.contrastText" + : "text.secondary", + whiteSpace: "nowrap", + transition: "color 160ms ease", + "&:focus-visible": { outline: "2px solid", outlineColor: "primary.main" }, + }} + > + {d.label} + + ); + })} + + + {visibleBars.length === 0 ? ( + + + No data for this slice. Try another granularity, period or payer. + + + ) : ( + + + + {visibleBars.map((b) => ( + + + {b.key} + + + + {formatCurrency(b.sum, slice.currency)} + + + {b.count} txn{b.count === 1 ? "" : "s"} + + + ))} + + {barScroll.overflow && ( + + scrollBarsByPage(-1)} + > + + + scrollBarsByPage(1)} + > + + + + )} + + + )} + + + {slice.txns.length === 0 ? null : fields ? ( + + ) : null} + + + ); +} \ No newline at end of file diff --git a/src/Reports/types.ts b/src/Reports/types.ts new file mode 100644 index 0000000..6873573 --- /dev/null +++ b/src/Reports/types.ts @@ -0,0 +1,288 @@ +import type { FieldConfig, ResourceConfig } from "../../react-openapi"; +import { parseOccurredAt } from "../common/utils/dates"; + +export interface PeriodMetricsVM { + outflows: number; + inflows: number; + sum: number; + count: number; + avg: number | null; + min: number | null; + max: number | null; + firstDate: string | null; + lastDate: string | null; + cadence: number | null; + frequency: number | null; +} + +export interface ReportPeriodGroup { + key: string; + metrics: PeriodMetricsVM; + txns: any[]; + currency: string; +} + +export interface SliceSummary { + outflows: number; + inflows: number; + sum: number; + count: number; + avg: number | null; + min: number | null; + max: number | null; + firstDate: string | null; + lastDate: string | null; + txns: any[]; + currency: string; +} + +export interface SliceFilter { + granularity: string; + periods?: string[]; + payees?: string[]; + tags?: string[]; +} + +export interface ReportFieldConfigs { + name: FieldConfig; + generatedAt: FieldConfig; +} + +export interface MetricLabel { + key: string; + label: string; + order: number; +} + +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 const FLOW_OPTIONS = ["both", "inflows", "outflows"]; + +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); +} + +function dateVal(value: string): number { + try { + return parseOccurredAt(value).getTime(); + } catch { + return 0; + } +} + +function bucketMatches(bucket: any, filter: SliceFilter): boolean { + const gk = bucket?.group_key ?? {}; + if (filter.payees?.length && !(gk.payee ?? []).some((p: string) => filter.payees?.includes(p))) return false; + if (filter.tags?.length && !(gk.tag ?? []).some((t: string) => filter.tags?.includes(t))) return false; + return true; +} + +function num(v: any): number | null { + return typeof v === "number" && Number.isFinite(v) ? v : null; +} + +/** + * Groups cube periods by their canonical period_id, merging buckets per + * period. The server returns disjoint slices, so additive metric merge is + * safe; txn ids are deduped defensively. Metrics come verbatim from the API + * except when multiple buckets contribute to one period — then cadence and + * frequency are re-derived from the merged txn dates. + */ +export function buildPeriodGroups(buckets: any[], filter: SliceFilter): ReportPeriodGroup[] { + interface Acc { + metrics: PeriodMetricsVM; + txns: any[]; + sources: number; + apiCadence: number | null; + apiFrequency: number | null; + } + const byPeriod = new Map(); + const seenTxnIds = new Set(); + let currency = "INR"; + + for (const bucket of buckets ?? []) { + if (!bucketMatches(bucket, filter)) continue; + for (const period of bucket.series?.[filter.granularity] ?? []) { + if (filter.periods?.length && !filter.periods.includes(period.period_id)) continue; + const m = period.metrics ?? {}; + let acc = byPeriod.get(period.period_id); + if (!acc) { + acc = { + metrics: { + outflows: 0, + inflows: 0, + sum: 0, + count: 0, + avg: null, + min: null, + max: null, + firstDate: null, + lastDate: null, + cadence: null, + frequency: null, + }, + txns: [], + sources: 0, + apiCadence: num(m.cadence), + apiFrequency: num(m.frequency), + }; + byPeriod.set(period.period_id, acc); + } + acc.sources += 1; + const vm = acc.metrics; + vm.outflows += m.outflows ?? 0; + vm.inflows += m.inflows ?? 0; + vm.sum += m.sum ?? 0; + vm.count += m.count ?? 0; + const mn = num(m.min); + if (mn != null) vm.min = vm.min == null ? mn : Math.min(vm.min, mn); + const mx = num(m.max); + if (mx != null) vm.max = vm.max == null ? mx : Math.max(vm.max, mx); + if (m.first_date && (!vm.firstDate || dateVal(String(m.first_date)) < dateVal(vm.firstDate))) { + vm.firstDate = String(m.first_date); + } + if (m.last_date && (!vm.lastDate || dateVal(String(m.last_date)) > dateVal(vm.lastDate))) { + vm.lastDate = String(m.last_date); + } + for (const txn of period.txns ?? []) { + if (txn?.id != null) { + if (seenTxnIds.has(txn.id)) continue; + seenTxnIds.add(txn.id); + } + acc.txns.push(txn); + const c = txn?.account?.currency; + if (c) currency = c; + } + } + } + + return [...byPeriod.entries()] + .map(([key, acc]) => { + const vm = acc.metrics; + vm.avg = vm.count ? Math.round((vm.sum / vm.count) * 100) / 100 : null; + if (acc.sources > 1) { + const dates = acc.txns + .map((t) => { + try { + return parseOccurredAt(t?.occurred_at).getTime(); + } catch { + return NaN; + } + }) + .filter((t) => !Number.isNaN(t)) + .sort((a, b) => a - b); + if (dates.length >= 2) { + let gapSum = 0; + for (let i = 0; i < dates.length - 1; i += 1) gapSum += (dates[i + 1] - dates[i]) / 86400000; + const cadence = Math.round((gapSum / (dates.length - 1)) * 100) / 100; + vm.cadence = cadence > 0 ? cadence : null; + vm.frequency = cadence > 0 ? Math.round((1 / cadence) * 100) / 100 : null; + } else { + vm.cadence = null; + vm.frequency = null; + } + } else { + vm.cadence = acc.apiCadence; + vm.frequency = acc.apiFrequency; + } + return { key, metrics: vm, txns: acc.txns, currency }; + }) + .sort((a, b) => b.key.localeCompare(a.key)); +} + +export function sliceSummary(groups: ReportPeriodGroup[]): SliceSummary { + const outflows = groups.reduce((s, g) => s + g.metrics.outflows, 0); + const inflows = groups.reduce((s, g) => s + g.metrics.inflows, 0); + const sum = groups.reduce((s, g) => s + g.metrics.sum, 0); + const count = groups.reduce((s, g) => s + g.metrics.count, 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[] = []; + for (const g of groups) { + if (g.metrics.min != null) min = min == null ? g.metrics.min : Math.min(min, g.metrics.min); + if (g.metrics.max != null) max = max == null ? g.metrics.max : Math.max(max, g.metrics.max); + if (g.metrics.firstDate && (!firstDate || dateVal(g.metrics.firstDate) < dateVal(firstDate))) { + firstDate = g.metrics.firstDate; + } + if (g.metrics.lastDate && (!lastDate || dateVal(g.metrics.lastDate) > dateVal(lastDate))) { + lastDate = g.metrics.lastDate; + } + if (g.currency) currency = g.currency; + txns.push(...g.txns); + } + return { outflows, inflows, sum, count, avg: count ? sum / count : null, min, max, firstDate, lastDate, txns, currency }; +} + +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 name = find("name"); + const generatedAt = find("generated_at"); + if (!name || !generatedAt) return null; + return { name, generatedAt }; +} + +export type ReportDim = "period" | "payee" | "tag"; + +export interface DimensionBar { + key: string; + sum: number; + count: number; +} + +/** + * Bar-chart rows for a given dimension. "period" reuses the period groups; + * "payee"/"tag" fold each bucket's metrics into every value listed under + * group_key[dim] — safe because server buckets are disjoint slices. + */ +export function buildDimensionBars(buckets: any[], filter: SliceFilter, dim: ReportDim): DimensionBar[] { + if (dim === "period") { + return buildPeriodGroups(buckets, filter).map((g) => ({ + key: g.key, + sum: g.metrics.sum, + count: g.metrics.count, + })); + } + const acc = new Map(); + for (const bucket of buckets ?? []) { + if (!bucketMatches(bucket, filter)) continue; + const keys: string[] = bucket.group_key?.[dim] ?? []; + for (const period of bucket.series?.[filter.granularity] ?? []) { + if (filter.periods?.length && !filter.periods.includes(period.period_id)) continue; + const m = period.metrics ?? {}; + const sum = typeof m.sum === "number" ? m.sum : 0; + const count = typeof m.count === "number" ? m.count : 0; + for (const k of keys) { + const cur = acc.get(k); + if (cur) { + cur.sum += sum; + cur.count += count; + } else { + acc.set(k, { key: k, sum, count }); + } + } + } + } + return [...acc.values()].sort((a, b) => Math.abs(b.sum) - Math.abs(a.sum)); +} \ No newline at end of file diff --git a/src/common/components/StatCard.tsx b/src/common/components/StatCard.tsx new file mode 100644 index 0000000..3e4cd77 --- /dev/null +++ b/src/common/components/StatCard.tsx @@ -0,0 +1,26 @@ +import { Paper, Typography } from "@mui/material"; + +interface StatCardProps { + label: string; + value: string; + color?: string; + hint?: string; +} + +export function StatCard({ label, value, color, hint }: StatCardProps) { + return ( + + + {label} + + + {value} + + {hint && ( + + {hint} + + )} + + ); +} \ No newline at end of file diff --git a/src/Expense/ExpenseList.tsx b/src/common/components/TransactionList.tsx similarity index 55% rename from src/Expense/ExpenseList.tsx rename to src/common/components/TransactionList.tsx index 5b10561..b242c11 100644 --- a/src/Expense/ExpenseList.tsx +++ b/src/common/components/TransactionList.tsx @@ -12,120 +12,85 @@ import { import { alpha } from "@mui/material/styles"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; -import { ListCellRenderer, CurrencyField, applyDisplayFormat, formatCurrency } from "../../react-openapi"; -import type { ExpenseItem, ExpenseFieldConfigs } from "./types"; -import { isExpense, monthKey, monthLabel, parseOccurredAt } from "./types"; +import { formatCurrency } from "../../../react-openapi"; +import type { ExpenseItem, TxnFieldConfigs } from "../types"; +import { computeTxnMetrics, groupByDate, groupByPeriod } from "../utils/transactions"; +import type { ListGroupMetrics, ListPeriodGroup, PeriodGranularity } from "../utils/transactions"; +import { TransactionRow } from "./TransactionRow"; +import { StatCard } from "./StatCard"; -interface GroupedMonth { - key: string; - items: ExpenseItem[]; - spent: number; - income: number; - currency: string; +interface TransactionListProps { + items?: ExpenseItem[]; + fields: TxnFieldConfigs; + granularity?: PeriodGranularity; + showMetrics?: boolean; + groups?: ListPeriodGroup[]; } -function groupByMonth(items: ExpenseItem[]): GroupedMonth[] { - const map = new Map(); - for (const item of items) { - const key = monthKey(item.occurred_at); - const list = map.get(key) ?? []; - list.push(item); - map.set(key, list); +function cadenceRow(cadenceDays: number | null, frequency: number | null): { label: string; value: string } { + const fmt = (v: number) => (Number.isInteger(v) ? String(v) : v.toFixed(2)); + if (cadenceDays != null && cadenceDays < 1) { + return { label: "Frequency", value: `${frequency == null ? "—" : fmt(frequency)} /day` }; } - return [...map.entries()] - .map(([key, list]) => { - const sorted = [...list].sort( - (a, b) => parseOccurredAt(b.occurred_at).getTime() - parseOccurredAt(a.occurred_at).getTime(), - ); - const currency = sorted.find((it) => it.account?.currency)?.account?.currency ?? "INR"; - const spent = sorted.filter(isExpense).reduce((sum, it) => sum + Math.abs(it.amount ?? 0), 0); - const income = sorted.filter((it) => !isExpense(it)).reduce((sum, it) => sum + (it.amount ?? 0), 0); - return { key, items: sorted, spent, income, currency }; - }) - .sort((a, b) => b.key.localeCompare(a.key)); + return { + label: "Cadence", + value: cadenceDays == null ? "—" : `${fmt(cadenceDays)} days`, + }; } -interface ExpenseRowProps { - item: ExpenseItem; +function txnFallbackMetrics(items: ExpenseItem[]): ListGroupMetrics { + const t = computeTxnMetrics(items); + return { + sum: t.sum, + count: t.count, + avg: t.avg, + min: t.min, + max: t.max, + cadence: t.cadenceDays, + frequency: t.frequency, + }; +} + +function GroupMetrics({ + items, + currency, + metrics, +}: { + items: ExpenseItem[]; currency: string; - fields: ExpenseFieldConfigs; -} - -const ExpenseRow = React.memo(function ExpenseRow({ item, currency, fields }: ExpenseRowProps) { - const itemCurrency = item.account?.currency ?? currency; - + metrics?: ListGroupMetrics; +}) { + const m = metrics ?? txnFallbackMetrics(items); + const rows: { label: string; value: string }[] = [ + { label: "Sum", value: formatCurrency(m.sum, currency) }, + { label: "Avg", value: m.avg == null ? "—" : formatCurrency(m.avg, currency) }, + { label: "Min", value: m.min == null ? "—" : formatCurrency(m.min, currency) }, + { label: "Max", value: m.max == null ? "—" : formatCurrency(m.max, currency) }, + cadenceRow(m.cadence, m.frequency), + ]; return ( - - - {item.entity?.logo ? ( - - ) : ( - - )} - - - - {item.entity ? applyDisplayFormat(item.entity, fields.formats.entity) : "Unknown"} - - - - - - {item.account?.name && ( - - )} - - - - + + {rows.map((row) => ( + + ))} ); -}); - -interface ExpenseListProps { - items: ExpenseItem[]; - fields: ExpenseFieldConfigs; } -export function ExpenseList({ items, fields }: ExpenseListProps) { +export function TransactionList({ + items, + fields, + granularity = "monthly", + showMetrics = false, + groups: externalGroups, +}: TransactionListProps) { const [activeMonth, setActiveMonth] = useState(null); const [openMonth, setOpenMonth] = useState(null); const [menuAnchor, setMenuAnchor] = useState(null); - const groups = useMemo(() => groupByMonth(items), [items]); + const groups = useMemo( + () => externalGroups ?? groupByPeriod(items ?? [], granularity), + [externalGroups, items, granularity], + ); const listRef = useRef(null); const pillRef = useRef(null); const didInitOpenMonth = useRef(false); @@ -233,38 +198,79 @@ export function ExpenseList({ items, fields }: ExpenseListProps) { px: 2, py: 1, "& .MuiAccordionSummary-content": { - alignItems: "center", - gap: 1.5, + flexDirection: "column", + alignItems: "stretch", + gap: 0.75, minWidth: 0, }, }} > - - {monthLabel(group.key)} - - - {group.items.length} transaction{group.items.length === 1 ? "" : "s"} - - - - {formatCurrency(group.spent, group.currency)} - - - / - - - {formatCurrency(group.income, group.currency)} - + + + {group.label} + + + {group.items.length} transaction{group.items.length === 1 ? "" : "s"} + + + + {formatCurrency(group.spent, group.currency)} + + + / + + + {formatCurrency(group.income, group.currency)} + + + {showMetrics && ( + + )} - {group.items.map((item) => ( - + {groupByDate(group.items).map((dateGroup) => ( + + + + {dateGroup.label} + + + + {dateGroup.items.length} transaction{dateGroup.items.length === 1 ? "" : "s"} + + + + {dateGroup.items.map((item) => ( + + ))} + + ))} @@ -319,7 +325,7 @@ export function ExpenseList({ items, fields }: ExpenseListProps) { }} > - {activeMonth ? monthLabel(activeMonth) : ""} + {activeMonth ? (groups.find((g) => g.key === activeMonth)?.label ?? activeMonth) : ""} @@ -339,7 +345,7 @@ export function ExpenseList({ items, fields }: ExpenseListProps) { onClick={() => handleSelectMonth(group.key)} > @@ -347,6 +353,4 @@ export function ExpenseList({ items, fields }: ExpenseListProps) { ); -} - -export { groupByMonth }; \ No newline at end of file +} \ No newline at end of file diff --git a/src/common/components/TransactionRow.tsx b/src/common/components/TransactionRow.tsx new file mode 100644 index 0000000..cddb2c3 --- /dev/null +++ b/src/common/components/TransactionRow.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import { Box, Typography } from "@mui/material"; +import { ListCellRenderer, CurrencyField, applyDisplayFormat } from "../../../react-openapi"; +import type { ExpenseItem, TxnFieldConfigs } from "../types"; + +interface TransactionRowProps { + item: ExpenseItem; + currency: string; + fields: TxnFieldConfigs; +} + +export const TransactionRow = React.memo(function TransactionRow({ item, currency, fields }: TransactionRowProps) { + const itemCurrency = item.account?.currency ?? currency; + + return ( + + + {item.entity?.logo ? ( + + ) : ( + + )} + + + + {item.entity ? applyDisplayFormat(item.entity, fields.formats.entity) : "Unknown"} + + + + + + {item.account?.name && ( + + )} + + + + + + ); +}); \ No newline at end of file diff --git a/src/common/types.ts b/src/common/types.ts new file mode 100644 index 0000000..5b53198 --- /dev/null +++ b/src/common/types.ts @@ -0,0 +1,21 @@ +import type { FieldConfig } from "../../react-openapi"; + +export interface ExpenseItem { + id: string; + entity?: { name?: string; type?: string; logo?: string } | null; + amount: number; + account?: { name?: string; number?: string; type?: string; currency?: string } | null; + tags?: { icon?: string; name?: string }[]; + occurred_at?: string; + created_at?: string; + updated_at?: string; +} + +export interface TxnFieldConfigs { + entity: FieldConfig; + amount: FieldConfig; + account: FieldConfig; + occurredAt: FieldConfig; + logo: FieldConfig; + formats: { entity: string; account: string }; +} \ No newline at end of file diff --git a/src/common/utils/dates.ts b/src/common/utils/dates.ts new file mode 100644 index 0000000..5758b3d --- /dev/null +++ b/src/common/utils/dates.ts @@ -0,0 +1,71 @@ +const DDMMYYYY = /^(\d{2})-(\d{2})-(\d{4})$/; + +export function parseOccurredAt(value?: string): Date { + const m = value?.match(DDMMYYYY); + if (!m) { + throw new Error(`Expense occurred_at 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 expense occurred_at date: ${value}`); + } + return d; +} + +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 monthKey(value?: string): string { + const d = parseOccurredAt(value); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; +} + +export function currentMonthKey(): string { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; +} + +export function monthLabel(key: string): string { + const [y, m] = key.split("-").map(Number); + if (!y || !m) return key; + const d = new Date(y, m - 1, 1); + return d.toLocaleDateString("en-IN", { month: "long", year: "numeric" }); +} + +export function dateLabel(value: string): string { + const d = parseOccurredAt(value); + return d.toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" }); +} \ No newline at end of file diff --git a/src/common/utils/fieldConfigs.ts b/src/common/utils/fieldConfigs.ts new file mode 100644 index 0000000..0522faf --- /dev/null +++ b/src/common/utils/fieldConfigs.ts @@ -0,0 +1,26 @@ +import type { ResourceConfig } from "../../../react-openapi"; +import type { TxnFieldConfigs } from "../types"; + +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}", + }, + }; +} \ No newline at end of file diff --git a/src/common/utils/transactions.ts b/src/common/utils/transactions.ts new file mode 100644 index 0000000..2488f0d --- /dev/null +++ b/src/common/utils/transactions.ts @@ -0,0 +1,161 @@ +import type { ExpenseItem } from "../types"; +import { dateLabel, monthKey, monthLabel, parseOccurredAt } from "./dates"; + +export function isExpense(item: ExpenseItem): boolean { + return (item.amount ?? 0) < 0; +} + +export interface DateGroup { + date: string; + label: string; + items: ExpenseItem[]; +} + +export function groupByDate(items: ExpenseItem[]): DateGroup[] { + const sorted = [...items].sort( + (a, b) => parseOccurredAt(b.occurred_at).getTime() - parseOccurredAt(a.occurred_at).getTime(), + ); + const map = new Map(); + for (const item of sorted) { + const key = item.occurred_at ?? ""; + const list = map.get(key) ?? []; + list.push(item); + map.set(key, list); + } + return [...map.entries()].map(([date, list]) => ({ + date, + label: dateLabel(date), + items: list, + })); +} + +export type PeriodGranularity = "weekly" | "monthly" | "quarterly" | "yearly"; + +export interface PeriodGroup { + key: string; + label: string; + items: ExpenseItem[]; + spent: number; + income: number; + currency: string; +} + +/** Metrics verbatim from the API's ReportMetrics (camelCased). */ +export interface ListGroupMetrics { + sum: number; + count: number; + avg: number | null; + min: number | null; + max: number | null; + cadence: number | null; + frequency: number | null; +} + +export interface ListPeriodGroup extends PeriodGroup { + metrics?: ListGroupMetrics; +} + +function isoWeekKey(d: Date): string { + const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); + const day = date.getUTCDay() || 7; + date.setUTCDate(date.getUTCDate() + 4 - day); + const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1)); + const week = Math.ceil(((date.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); + return `${date.getUTCFullYear()}-W${String(week).padStart(2, "0")}`; +} + +function quarterKey(d: Date): string { + return `${d.getFullYear()}-Q${Math.floor(d.getMonth() / 3) + 1}`; +} + +export function periodKey(value: string | undefined, granularity: PeriodGranularity): string { + const d = parseOccurredAt(value); + switch (granularity) { + case "weekly": + return isoWeekKey(d); + case "quarterly": + return quarterKey(d); + case "yearly": + return String(d.getFullYear()); + default: + return monthKey(value); + } +} + +export function periodLabel(key: string, granularity: PeriodGranularity): string { + switch (granularity) { + case "weekly": + case "yearly": + return key; + case "quarterly": { + const [y, q] = key.split("-Q"); + return `Q${q} ${y}`; + } + default: + return monthLabel(key); + } +} + +export function groupByPeriod(items: ExpenseItem[], granularity: PeriodGranularity = "monthly"): PeriodGroup[] { + const map = new Map(); + for (const item of items) { + const key = periodKey(item.occurred_at, granularity); + const list = map.get(key) ?? []; + list.push(item); + map.set(key, list); + } + return [...map.entries()] + .map(([key, list]) => { + const sorted = [...list].sort( + (a, b) => parseOccurredAt(b.occurred_at).getTime() - parseOccurredAt(a.occurred_at).getTime(), + ); + const currency = sorted.find((it) => it.account?.currency)?.account?.currency ?? "INR"; + const spent = sorted.filter(isExpense).reduce((sum, it) => sum + Math.abs(it.amount ?? 0), 0); + const income = sorted.filter((it) => !isExpense(it)).reduce((sum, it) => sum + (it.amount ?? 0), 0); + return { key, label: periodLabel(key, granularity), items: sorted, spent, income, currency }; + }) + .sort((a, b) => b.key.localeCompare(a.key)); +} + +export interface TxnMetrics { + sum: number; + count: number; + avg: number | null; + min: number | null; + max: number | null; + cadenceDays: number | null; + frequency: number | null; +} + +/** Mirrors backend ReportMetrics.compute_metrics over a list of transactions. */ +export function computeTxnMetrics(items: ExpenseItem[]): TxnMetrics { + const amounts = items.filter((it) => typeof it.amount === "number").map((it) => it.amount as number); + const empty = { sum: 0, count: 0, avg: null, min: null, max: null, cadenceDays: null, frequency: null }; + if (amounts.length === 0) { + return empty; + } + const dates = items + .map((it) => it.occurred_at) + .filter((d): d is string => !!d) + .map((d) => parseOccurredAt(d).getTime()) + .sort((a, b) => a - b); + const sum = amounts.reduce((s, a) => s + a, 0); + let cadenceDays: number | null = null; + if (dates.length >= 2) { + const gaps: number[] = []; + for (let i = 0; i < dates.length - 1; i += 1) { + gaps.push((dates[i + 1] - dates[i]) / 86400000); + } + cadenceDays = Math.round((gaps.reduce((s, g) => s + g, 0) / gaps.length) * 100) / 100; + } + const frequency = cadenceDays != null && cadenceDays > 0 ? Math.round((1 / cadenceDays) * 100) / 100 : null; + return { + sum, + count: amounts.length, + avg: sum / amounts.length, + min: Math.min(...amounts), + max: Math.max(...amounts), + cadenceDays, + frequency, + }; +} \ No newline at end of file diff --git a/src/main.jsx b/src/main.jsx index ad1d0f1..b5fe503 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -17,7 +17,8 @@ import { import Home from './Home'; import FetchRequests from './FetchRequest/FetchRequestCreate'; import FetchRequestDetail from './FetchRequest/FetchRequestDetail'; -import Expense from './Expense/Expense'; +import Expense from './Expense'; +import Reports from './Reports/Report'; 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" }, ];