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}
+ />
+
+ ))}
+
+
+
+ } disabled={submitting}>
+ {submitting ? "Generating…" : "Generate"}
+
+
+
+
+ );
+}
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 && (
+
+
+
+ )}
+
+
+
+
+ } onClick={() => onView(report.id)}>
+ View
+
+ }
+ onClick={() => onRegenerate(report)}
+ >
+ Regenerate
+
+
+ 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" },
];