major refactor of the dashboard and react-openapi integration #1

Merged
aetos merged 44 commits from period-selection into main 2026-05-07 11:00:54 +00:00
10 changed files with 138 additions and 350 deletions
Showing only changes of commit 15f76eb5f0 - Show all commits

View File

@@ -8,10 +8,21 @@ import {
import ConfigurableDashboard from "./components/Dashboard"; import ConfigurableDashboard from "./components/Dashboard";
import { configuration } from "./dashboard-config"; import { configuration } from "./dashboard-config";
import { useDashboardData } from "./features/dashboard"; import {
useReport,
prepareReport,
} from "./features/report";
export default function Dashboard() { export default function Dashboard() {
const { data, isLoading, error } = useDashboardData(); const report = useReport({
rolling: true,
include_transactions: true,
group_by: ["payee"],
})
const isLoading = report.isLoading;
const error = report.error;
if (isLoading) { if (isLoading) {
return ( return (
@@ -29,10 +40,11 @@ export default function Dashboard() {
); );
} }
if (!data) { if (!report) {
return null; return null;
} }
const data = prepareReport(report.data?.data);
return ( return (
<ConfigurableDashboard <ConfigurableDashboard
config={configuration} config={configuration}

View File

@@ -1,4 +1,7 @@
import * as React from "react"; import * as React from "react";
import {
ReportData
} from "../../features/report";
export type DashboardMode = "expense" | "income"; export type DashboardMode = "expense" | "income";
export type DashboardPeriodType = "rolling" | "calendar"; export type DashboardPeriodType = "rolling" | "calendar";
@@ -45,9 +48,5 @@ export interface DashboardConfig {
export interface DashboardProps { export interface DashboardProps {
config: DashboardConfig; config: DashboardConfig;
data: any; data: ReportData;
toggleMode: () => void;
togglePeriodType: () => void;
setSelectedPeriodId: (id: string | null) => void;
toggleComparison: () => void;
} }

View File

@@ -13,6 +13,10 @@ import { DashboardProps, DashboardState } from "./Dashboard.models";
interface ViewProps extends DashboardProps { interface ViewProps extends DashboardProps {
state: DashboardState; state: DashboardState;
setState: React.Dispatch<React.SetStateAction<DashboardState>>; setState: React.Dispatch<React.SetStateAction<DashboardState>>;
toggleMode: () => void;
togglePeriodType: () => void;
setSelectedPeriodId: (id: string | null) => void;
toggleComparison: () => void;
} }
export default function DashboardView({ export default function DashboardView({
@@ -101,63 +105,26 @@ export default function DashboardView({
</Box> </Box>
)} )}
{section.isList ? ( <Component
<Box> {...section.settings}
{section.title && ( header={section.title}
<Box sx={{ mb: 2 }}> summary={section.summary}
<Typography variant="h6" fontWeight={700}> data={data}
{section.title} title={section.title}
</Typography> accentColor={colors.primary}
</Box> colorScheme={colors}
)}
<Grid container spacing={2}>
{(data[section.dataKey || ""] || []).map((item: any, idx: number) => (
<Grid key={idx} size={{ xs: 12, sm: 6, md: 2.4 }}>
<Component
{...section.settings}
header={item.payeeName || item.name}
progressAmount={item.amount}
totalAmount={data.totalAmount}
accentColor={colors.primary}
colorScheme={colors}
// State management // State management
mode={mode} mode={mode}
periodType={periodType} periodType={periodType}
comparison={comparison} comparison={comparison}
selectedPeriodId={selectedPeriodId} selectedPeriodId={selectedPeriodId}
togglePeriodType={togglePeriodType} togglePeriodType={togglePeriodType}
toggleComparison={toggleComparison} toggleComparison={toggleComparison}
setSelectedPeriodId={setSelectedPeriodId} setSelectedPeriodId={setSelectedPeriodId}
/> />
</Grid>
))}
</Grid>
</Box>
) : (
<Component
{...section.settings}
header={section.title}
summary={section.summary}
data={data[mode][section.dataKey]}
title={section.title}
accentColor={colors.primary}
colorScheme={colors}
// State management
mode={mode}
periodType={periodType}
comparison={comparison}
selectedPeriodId={selectedPeriodId}
togglePeriodType={togglePeriodType}
toggleComparison={toggleComparison}
setSelectedPeriodId={setSelectedPeriodId}
/>
)}
</Grid> </Grid>
); );
})} })}

View File

@@ -1,191 +0,0 @@
import * as React from "react";
import MonetizationOnIcon from "@mui/icons-material/MonetizationOn";
import { LatestItem } from "../../components/LatestItems";
import {
ChartData,
ChartDataPoint,
} from "../../components/HistoryChart";
const DEFAULT_ICON = React.createElement(MonetizationOnIcon, {
sx: { color: "#388e3c" }
});
type ReportBucket = any;
export function mapToLatestItems(
items: any[],
type: "expense" | "income"
): LatestItem[] {
const isValid = (amt: number) =>
type === "expense" ? amt < 0 : amt > 0;
return items
.filter((item: any) => isValid(Number(item.amount) || 0))
.slice(0, 5)
.map((exp: any, index: number) => {
const time = new Date(exp.occurred_at).getTime();
const diffDays = Math.floor(
Math.abs(Date.now() - time) / (1000 * 60 * 60 * 24)
);
return {
id: exp.id || index,
icon: DEFAULT_ICON,
iconBgColor:
type === "expense" ? "#ffebee" : "#e8f5e9",
title: exp.payee.name,
subtitle: exp.account.name,
amount: `Rs ${Math.abs(exp.amount || 0)}`,
timeAgo: diffDays === 0 ? "Today" : `${diffDays} days ago`
};
});
}
const sumBucket = (bucket: ReportBucket, flow: "expenses" | "incomes") =>
bucket.groups.reduce(
(acc: number, g: any) => acc + (g?.[flow]?.sum || 0),
0
);
const toLabel = (start: string, end: string, type: "weekly" | "monthly" | "yearly" | "fyly" | "full") => {
const s = new Date(start);
const e = new Date(end);
if (type === "monthly") {
return s.toLocaleString("default", { month: "short" });
}
return `${s.getDate()}${e.getDate()} ${e.toLocaleString("default", {
month: "short",
})}`;
};
const getWeekOfMonth = (date: Date) => {
const firstDay = new Date(date.getFullYear(), date.getMonth(), 1);
return Math.ceil((date.getDate() + firstDay.getDay()) / 7);
};
const findCompareBucket = (
current: ReportBucket,
buckets: ReportBucket[],
type: "weekly" | "monthly" | "yearly" | "fyly" | "full",
): ReportBucket | undefined => {
const start = new Date(current.start);
if (type === "monthly") {
const targetYear = start.getFullYear() - 1;
const targetMonth = start.getMonth();
return buckets.find(b => {
const d = new Date(b.start);
return (
d.getFullYear() === targetYear &&
d.getMonth() === targetMonth
);
});
}
if (type === "weekly") {
const weekIndex = getWeekOfMonth(start); // you must define this
const target = new Date(start);
target.setMonth(target.getMonth() - 1);
return buckets.find(b => {
const d = new Date(b.start);
return (
d.getFullYear() === target.getFullYear() &&
d.getMonth() === target.getMonth() &&
getWeekOfMonth(d) === weekIndex
);
});
}
return undefined;
};
const toPoints = (
buckets: ReportBucket[],
type: "weekly" | "monthly" | "yearly" | "fyly" | "full",
flow: "expenses" | "incomes"
): ChartDataPoint[] => {
return buckets.map((b) => {
const amount = sumBucket(b, flow);
const prev = findCompareBucket(b, buckets, type);
return {
id: toLabel(b.start, b.end, type),
amount,
compare: prev
? {
id: toLabel(prev.start, prev.end, type),
amount: sumBucket(prev, flow),
}
: undefined,
};
});
};
export function mapReportToDashboard(
weekly: ReportBucket[],
monthly: ReportBucket[],
payeeBuckets: ReportBucket[],
type: "expense" | "income"
) {
const flow = type === "expense" ? "expenses" : "incomes";
const chartData: ChartData = {
weekly: {
rolling: toPoints(weekly, "weekly", flow),
calendar: toPoints(weekly, "weekly", flow),
},
monthly: {
rolling: toPoints(monthly, "monthly", flow),
calendar: toPoints(monthly, "monthly", flow),
},
// yearly: {
// rolling: toPoints(yearly, "yearly", flow),
// calendar: toPoints(yearly, "yearly", flow),
// },
//
// fyly: {
// rolling: toPoints(fyly, "fyly", flow),
// calendar: toPoints(fyly, "fyly", flow),
// },
//
// full: {
// rolling: toPoints(full, "full", flow),
// calendar: toPoints(full, "full", flow),
// },
};
const totalAmount = weekly.reduce(
(acc, b) => acc + sumBucket(b, flow),
0
);
const payeeMap: Record<string, number> = {};
const sourceForPayees = (payeeBuckets && payeeBuckets.length > 0) ? payeeBuckets : weekly;
for (const b of sourceForPayees) {
for (const g of b.groups) {
const key = g.group_key || "Unknown";
const amt = g?.[flow]?.sum || 0;
payeeMap[key] = (payeeMap[key] || 0) + amt;
}
}
const topPayees = Object.entries(payeeMap)
// .filter(([name]) => name !== "Unknown")
.map(([payeeName, amount]) => ({ payeeName, amount }))
.sort((a, b) => b.amount - a.amount)
.slice(0, 5);
return {
chartData,
totalAmount,
topPayees,
};
}

View File

@@ -1,3 +0,0 @@
export {
useDashboardData
} from './useDashboardData'

View File

@@ -1,44 +0,0 @@
import { useReport } from "../report";
import { mapReportToDashboard } from "./dashboard.mapper";
export function useDashboardData() {
// Fetch reports for aggregation
const weeklyReport = useReport({ period: "weekly", rolling: true });
const monthlyReport = useReport({ period: "monthly", rolling: true });
const payeeReport = useReport({ period: "full", rolling: true, group_by: ["payee"] });
const isLoading =
weeklyReport.isLoading ||
monthlyReport.isLoading ||
payeeReport.isLoading;
const error =
weeklyReport.error ||
monthlyReport.error ||
payeeReport.error;
const aggregatedData = {
expense: weeklyReport.data?.data && monthlyReport.data?.data && payeeReport.data?.data
? mapReportToDashboard(
(weeklyReport.data.data as any).buckets,
(monthlyReport.data.data as any).buckets,
(payeeReport.data.data as any).buckets,
"expense"
)
: null,
income: weeklyReport.data?.data && monthlyReport.data?.data && payeeReport.data?.data
? mapReportToDashboard(
(weeklyReport.data.data as any).buckets,
(monthlyReport.data.data as any).buckets,
(payeeReport.data.data as any).buckets,
"income"
)
: null,
}
return {
data: aggregatedData,
isLoading,
error,
};
}

View File

@@ -3,9 +3,6 @@ export {
} from './useReport' } from './useReport'
export type { export type {
Transaction, Transaction,
PeriodData,
PeriodGroup,
Period,
ReportData, ReportData,
} from './report.models' } from './report.models'
export { export {

View File

@@ -18,44 +18,72 @@ export interface Tag {
} }
export interface Transaction { export interface Transaction {
payor: Payor; payor: Payor;
payee: Payee; payee: Payee;
amount: number; amount: number;
account: Account; account: Account;
tags: Tag[] tags: Tag[];
occurred_at: Date occurred_at: Date;
} }
export interface _PeriodData { // -----------------------------
// Metrics
// -----------------------------
export interface ReportMetric {
sum: number; sum: number;
count: number; count: number;
average: number; average: number;
txns: Transaction[]; transactions?: Transaction[];
} }
export interface PeriodData extends _PeriodData { // -----------------------------
compare?: _PeriodData; // Period
} // -----------------------------
export interface PeriodGroup { export interface ReportPeriod {
group_key: string[];
expenses: PeriodData[];
incomes: PeriodData[];
}
export interface Period {
id: string;
label: string;
start: Date; start: Date;
end: Date; end: Date;
groups: PeriodGroup[];
expenses: ReportMetric;
incomes: ReportMetric;
} }
export interface ReportData { // -----------------------------
period: "weekly" | "monthly" | "yearly" | "fyly" | "full"; // Group (bucket)
rolling?: boolean; // -----------------------------
report_date?: string;
group_by?: ("payee" | "tags")[]; export type GroupKey = {
ignore_self?: boolean; payee?: string[];
buckets: Period[]; tags?: string[];
flow?: string[];
};
export interface ReportBucket {
group_key: GroupKey;
periods: {
weekly?: ReportPeriod[];
monthly?: ReportPeriod[];
yearly?: ReportPeriod[];
fyly?: ReportPeriod[];
};
}
// -----------------------------
// Final Report
// -----------------------------
export interface ReportData {
periods: ("weekly" | "monthly" | "yearly" | "fyly")[];
rolling: boolean;
report_date?: string;
group_by: ("payee" | "tags")[];
ignore_self: boolean;
include_transactions: boolean;
buckets: ReportBucket[];
} }

View File

@@ -1,4 +1,7 @@
import { ReportData } from "./report.models"; import {
ReportData,
ReportPeriod
} from "./report.models";
/* ---------- ID BUILDING ---------- */ /* ---------- ID BUILDING ---------- */
@@ -10,7 +13,7 @@ function formatDate(d: Date): string {
} }
function buildPeriodId( function buildPeriodId(
type: ReportData["period"], type: "weekly" | "monthly" | "yearly" | "fyly" | "full",
start: Date, start: Date,
end: Date end: Date
): string { ): string {
@@ -65,7 +68,7 @@ function sameMonth(a: Date, b: Date) {
} }
function buildLabel( function buildLabel(
type: ReportData["period"], type: "weekly" | "monthly" | "yearly" | "fyly" | "full",
start: Date, start: Date,
end: Date end: Date
): string { ): string {
@@ -94,9 +97,6 @@ function buildLabel(
return `FY ${startY}${String(endY).slice(-2)}`; return `FY ${startY}${String(endY).slice(-2)}`;
} }
case "full":
return `${monthFmt.format(start)} ${yearFmt.format(start)} - ${monthFmt.format(end)} ${yearFmt.format(end)}`;
default: default:
return `${monthDayFmt.format(start)} - ${monthDayFmt.format(end)}`; return `${monthDayFmt.format(start)} - ${monthDayFmt.format(end)}`;
} }
@@ -104,13 +104,34 @@ function buildLabel(
/* ---------- MAIN ---------- */ /* ---------- MAIN ---------- */
function decoratePeriods(
type: "weekly" | "monthly" | "yearly" | "fyly" | "full",
periods: ReportPeriod[]
): (ReportPeriod & { id: string; label: string })[] {
return periods.map((p) => ({
...p,
id: buildPeriodId(type, new Date(p.start), new Date(p.end)),
label: buildLabel(type, new Date(p.start), new Date(p.end)),
}));
}
export function prepareReport(reportData: ReportData): ReportData { export function prepareReport(reportData: ReportData): ReportData {
return { return {
...reportData, ...reportData,
buckets: reportData.buckets.map((p) => ({ buckets: reportData.buckets.map((bucket) => {
...p, const newPeriods: typeof bucket.periods = {};
id: buildPeriodId(reportData.period, p.start, p.end),
label: buildLabel(reportData.period, p.start, p.end), for (const type of reportData.periods) {
})), const arr = bucket.periods[type];
if (arr) {
newPeriods[type] = decoratePeriods(type, arr);
}
}
return {
...bucket,
periods: newPeriods,
};
}),
}; };
} }

View File

@@ -1,18 +1,20 @@
import { useResourceByName } from "../../../react-openapi"; import { useResourceByName } from "../../../react-openapi";
export interface ReportParams { export interface ReportParams {
period: "weekly" | "monthly" | "yearly" | "fyly" | "full"; periods?: ("weekly" | "monthly" | "yearly" | "fyly" | "full")[];
rolling?: boolean; rolling?: boolean;
report_date?: string; report_date?: string;
group_by?: ("payee" | "tags")[]; group_by?: ("payee" | "tags")[];
ignore_self?: boolean; ignore_self?: boolean;
include_transactions?: boolean;
} }
export function useReport(params: ReportParams) { export function useReport(params: ReportParams) {
const { useList } = useResourceByName("reports"); const { useList } = useResourceByName("reports");
if (params.group_by) {
// @ts-ignore return useList({
params.group_by = params.group_by[0] ...params,
} periods: params.periods,
return useList(params); group_by: params.group_by,
});
} }