dashboard fixes

This commit is contained in:
2026-04-04 22:08:39 +05:30
parent 68337ba312
commit 214c0be44e
2 changed files with 232 additions and 155 deletions

View File

@@ -1,26 +1,53 @@
import * as React from "react";
import { Box, Container, Grid, Typography, Avatar, CircularProgress, Alert } from "@mui/material";
import {
Box,
Container,
Grid,
Typography,
CircularProgress,
Alert,
ToggleButton,
ToggleButtonGroup
} from "@mui/material";
import LatestItemsList, { LatestItem } from "./components/LatestItemsList";
import ProgressCard from "./components/ProgressCard";
import HistoryChart from "./components/HistoryChart";
import { fetchLatestExpenses, fetchAggregatedExpenses, AggregatedDashboardData } from "./utils/dashboardLoader";
import {
fetchLatestExpenses,
fetchAggregatedExpenses,
fetchAggregatedIncome,
AggregatedDashboardData
} from "./utils/dashboardLoader";
export default function Dashboard() {
const [latest, setLatest] = React.useState<LatestItem[]>([]);
const [aggregated, setAggregated] = React.useState<AggregatedDashboardData | null>(null);
const [aggregated, setAggregated] =
React.useState<AggregatedDashboardData | null>(null);
const [mode, setMode] =
React.useState<"expense" | "income">("expense");
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
// -------- LOAD DATA --------
React.useEffect(() => {
async function loadData() {
try {
setLoading(true);
const [latestData, aggData] = await Promise.all([
fetchLatestExpenses(),
fetchAggregatedExpenses()
]);
const latestData = await fetchLatestExpenses();
const aggData =
mode === "expense"
? await fetchAggregatedExpenses()
: await fetchAggregatedIncome();
setLatest(latestData);
setAggregated(aggData);
} catch (err: any) {
console.error(err);
setError(err.message || "Failed to load dashboard data");
@@ -28,12 +55,14 @@ export default function Dashboard() {
setLoading(false);
}
}
loadData();
}, []);
loadData();
}, [mode]);
// -------- UI STATES --------
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '60vh' }}>
<Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", height: "60vh" }}>
<CircularProgress />
</Box>
);
@@ -51,57 +80,38 @@ export default function Dashboard() {
return (
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
{/* -------- TOGGLE -------- */}
<Box sx={{ display: "flex", justifyContent: "center", mb: 3 }}>
<ToggleButtonGroup
value={mode}
exclusive
onChange={(_, val) => val && setMode(val)}
>
<ToggleButton value="expense">Expenses</ToggleButton>
<ToggleButton value="income">Income</ToggleButton>
</ToggleButtonGroup>
</Box>
<Grid container spacing={4}>
{/* Column 1: Latest Transactions */}
{/* Column 1 */}
<Grid item xs={12} md={4}>
<LatestItemsList
title="Recent Transactions"
items={latest}
onViewAll={() => {}}
<LatestItemsList
title="Recent Transactions"
items={latest}
onViewAll={() => {}}
/>
</Grid>
{/* Column 2: Breakdown Graph */}
<Grid item xs={12} md={4}>
<Box sx={{ mb: 4, display: 'flex', justifyContent: 'center', visibility: 'hidden' }}>
<Typography variant="h6">Spacer</Typography>
</Box>
{/* Column 2 */}
<Grid item xs={12} md={8}>
<HistoryChart
header="Expense Breakdown"
header={`${mode === "expense" ? "Expense" : "Income"} Breakdown`}
summary="Interactive chronological tracking"
tabs={["Today", "Week", "Month", "Year"]}
data={aggregated?.chartData || {}}
/>
</Grid>
{/* Column 3: Top Payees Progress */}
<Grid item xs={12} md={4}>
<Box sx={{ mb: 4, display: 'flex', justifyContent: 'center' }}>
<Typography variant="h6" fontWeight={700}>Top Analytics</Typography>
</Box>
<Box sx={{ mb: 4 }}>
<ProgressCard
header="Total Expenses"
progressAmount={aggregated?.totalAmount || 0}
totalAmount={aggregated?.totalAmount || 0}
summary={`Rs ${aggregated?.totalAmount || 0}`}
colorTheme="error" // Highlight total in red/error or use info
/>
</Box>
<Typography variant="h6" fontWeight={600} mb={2} px={1}>Top 5 Payees</Typography>
{aggregated?.topPayees.map((payee, idx) => (
<Box sx={{ mb: 2 }} key={payee.payeeName}>
<ProgressCard
header={payee.payeeName}
progressAmount={payee.amount}
totalAmount={aggregated?.totalAmount || 1}
colorTheme={themes[idx % themes.length]}
/>
</Box>
))}
</Grid>
</Grid>
</Container>
);

View File

@@ -4,151 +4,218 @@ import { ChartDataPoint } from "../components/HistoryChart";
import * as React from "react";
import MonetizationOnIcon from "@mui/icons-material/MonetizationOn";
// Helper generic icon
const DEFAULT_ICON = React.createElement(MonetizationOnIcon, { sx: { color: "#388e3c" } });
// ---------------- ICON ----------------
const DEFAULT_ICON = React.createElement(MonetizationOnIcon, {
sx: { color: "#388e3c" }
});
// ---------------- LATEST ----------------
export async function fetchLatestExpenses(): Promise<LatestItem[]> {
const res = await api.get('/expenses', { params: { limit: 10, sort: '-occurred_at' } });
const res = await api.get('/expenses', {
params: { limit: 10, sort: '-occurred_at' }
});
const items = res.data?.items || res.data || [];
return items.map((exp: any, index: number) => {
const time = new Date(exp.occurred_at || exp.created_at || Date.now()).getTime();
const diffTime = Math.abs(Date.now() - time);
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
const timeText = diffDays === 0 ? "Today" : `${diffDays} days ago`;
const time = new Date(
exp.occurred_at || exp.created_at || Date.now()
).getTime();
const diffDays = Math.floor(
Math.abs(Date.now() - time) / (1000 * 60 * 60 * 24)
);
return {
id: exp.id || index,
icon: DEFAULT_ICON,
iconBgColor: "#e8f5e9", // soft green
iconBgColor: "#e8f5e9",
title: exp.payee?.name || exp.payee || "Unknown Payee",
subtitle: exp.category?.name || exp.account?.name || "Expense",
subtitle: exp.category?.name || exp.account?.name || "Transaction",
amount: `Rs ${exp.amount || 0}`,
timeAgo: timeText,
timeAgo: diffDays === 0 ? "Today" : `${diffDays} days ago`
};
});
}
// ---------------- TYPES ----------------
export interface AggregatedDashboardData {
chartData: Record<string, ChartDataPoint[]>;
totalAmount: number;
topPayees: Array<{ payeeName: string; amount: number }>;
}
export async function fetchAggregatedExpenses(): Promise<AggregatedDashboardData> {
// ---------------- GENERIC AGGREGATOR ----------------
export async function fetchAggregatedData(
type: "expense" | "income"
): Promise<AggregatedDashboardData> {
const res = await api.get('/expenses', { params: { limit: 0 } });
const allExpenses: any[] = res.data?.items || res.data || [];
const all: any[] = res.data?.items || res.data || [];
const now = new Date();
let maxTime = 0;
let totalAmount = 0;
const payeeMap: Record<string, number> = {};
// 1. Gather Total Amount, Max Time, and Payee accumulations
for (const exp of allExpenses) {
const isValid = (amt: number) =>
type === "expense" ? amt < 0 : amt > 0;
const amt = Number(exp.amount) || 0;
if (amt >= 0) continue; // skip income / non-expense
const normalize = (amt: number) => Math.abs(amt);
// ---------------- BUCKETS ----------------
const todayBuckets: Record<string, number> = {
"12am":0,"3am":0,"6am":0,"9am":0,
"12pm":0,"3pm":0,"6pm":0,"9pm":0
};
const weekBuckets: Record<string, number> = {
"Mon":0,"Tue":0,"Wed":0,"Thu":0,
"Fri":0,"Sat":0,"Sun":0
};
const monthBuckets: Record<string, { amount: number; range: string }> = {};
const yearBuckets: Record<string, { amount: number; range: string }> = {};
const getStartOfWeek = (d: Date) => {
const date = new Date(d);
const day = date.getDay() || 7;
if (day !== 1) date.setDate(date.getDate() - (day - 1));
date.setHours(0,0,0,0);
return date;
};
const format = (d: Date) =>
`${d.getDate()} ${d.toLocaleString('default', { month: 'short' })}`;
// -------- MONTH (5 rolling weeks) --------
for (let i = 0; i < 5; i++) {
const end = new Date(now);
end.setDate(end.getDate() - i * 7);
const start = new Date(end);
start.setDate(start.getDate() - 6);
const key = `${format(start)} - ${format(end)}`;
monthBuckets[key] = { amount: 0, range: key };
}
// -------- YEAR (12 months rolling) --------
for (let i = 0; i < 12; i++) {
const d = new Date(now);
d.setMonth(d.getMonth() - i);
const start = new Date(d.getFullYear(), d.getMonth(), 1);
const end = new Date(d.getFullYear(), d.getMonth() + 1, 0);
const label = d.toLocaleString('default', { month: 'short' });
const range = `${format(start)} - ${format(end)}`;
yearBuckets[label] = { amount: 0, range };
}
const weekStart = getStartOfWeek(now);
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekStart.getDate() + 6);
// ---------------- LOOP ----------------
for (const item of all) {
const d = new Date(
item.occurred_at || item.created_at || Date.now()
);
const amtRaw = Number(item.amount) || 0;
if (!isValid(amtRaw)) continue;
const amt = normalize(amtRaw);
totalAmount += amt;
const payeeName = exp.payee?.name || exp.payee || "Unknown";
payeeMap[payeeName] = (payeeMap[payeeName] || 0) + amt;
const payee = item.payee?.name || item.payee || "Unknown";
payeeMap[payee] = (payeeMap[payee] || 0) + amt;
const time = new Date(exp.occurred_at || exp.created_at || Date.now()).getTime();
if (time > maxTime) maxTime = time;
// ---- TODAY
if (
d.getDate() === now.getDate() &&
d.getMonth() === now.getMonth() &&
d.getFullYear() === now.getFullYear()
) {
const hr = d.getHours();
let label = "12am";
if (hr >= 3 && hr < 6) label = "3am";
else if (hr >= 6 && hr < 9) label = "6am";
else if (hr >= 9 && hr < 12) label = "9am";
else if (hr >= 12 && hr < 15) label = "12pm";
else if (hr >= 15 && hr < 18) label = "3pm";
else if (hr >= 18 && hr < 21) label = "6pm";
else if (hr >= 21) label = "9pm";
todayBuckets[label] += amt;
}
// ---- WEEK
if (d >= weekStart && d <= weekEnd) {
const day = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"][d.getDay()];
if (weekBuckets[day] !== undefined) {
weekBuckets[day] += amt;
}
}
// ---- MONTH
Object.entries(monthBuckets).forEach(([_, obj]) => {
const [startStr, endStr] = obj.range.split(" - ");
const start = new Date(startStr);
const end = new Date(endStr);
if (d >= start && d <= end) {
obj.amount += amt;
}
});
// ---- YEAR
Object.entries(yearBuckets).forEach(([_, obj]) => {
const [startStr, endStr] = obj.range.split(" - ");
const start = new Date(startStr);
const end = new Date(endStr);
if (d >= start && d <= end) {
obj.amount += amt;
}
});
}
if (maxTime === 0) maxTime = Date.now();
const baseDate = new Date(maxTime);
const toPoints = (b: any): ChartDataPoint[] =>
Object.entries(b).map(([k, v]: any) => ({
id: k,
amount: typeof v === "number" ? v : v.amount,
subLabel: typeof v === "number" ? undefined : v.range
}));
// 2. Chart Groups
const chartData: Record<string, ChartDataPoint[]> = {
today: [],
week: [],
month: [],
year: []
const chartData = {
today: toPoints(todayBuckets),
week: toPoints(weekBuckets),
month: toPoints(monthBuckets),
year: toPoints(yearBuckets)
};
const todayBuckets: Record<string, number> = { "12am":0, "3am":0, "6am":0, "9am":0, "12pm":0, "3pm":0, "6pm":0, "9pm":0 };
const weekBuckets: Record<string, number> = { "Mon":0, "Tue":0, "Wed":0, "Thu":0, "Fri":0, "Sat":0, "Sun":0 };
const monthBuckets: Record<string, number> = { "Week 1":0, "Week 2":0, "Week 3":0, "Week 4":0, "Week 5":0 };
const yearBuckets: Record<string, number> = { "Jan":0, "Feb":0, "Mar":0, "Apr":0, "May":0, "Jun":0, "Jul":0, "Aug":0, "Sep":0, "Oct":0, "Nov":0, "Dec":0 };
// highlight max
Object.values(chartData).forEach(group => {
let max = group[0];
for (const g of group) if (g.amount > max.amount) max = g;
if (max.amount > 0) max.highlighted = true;
});
const getDayName = (dayIdx: number) => ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][dayIdx];
const getMonthName = (monthIdx: number) => ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][monthIdx];
for (const exp of allExpenses) {
const d = new Date(exp.occurred_at || exp.created_at || Date.now());
const amt = Number(exp.amount) || 0;
if (amt >= 0) continue; // skip income / non-expense
// Check Today
if (d.getFullYear() === baseDate.getFullYear() && d.getMonth() === baseDate.getMonth() && d.getDate() === baseDate.getDate()) {
const hr = d.getHours();
let label = "12am";
if (hr >= 3 && hr < 6) label = "3am";
else if (hr >= 6 && hr < 9) label = "6am";
else if (hr >= 9 && hr < 12) label = "9am";
else if (hr >= 12 && hr < 15) label = "12pm";
else if (hr >= 15 && hr < 18) label = "3pm";
else if (hr >= 18 && hr < 21) label = "6pm";
else if (hr >= 21) label = "9pm";
todayBuckets[label] += amt;
}
// Check Week
const diffDays = (baseDate.getTime() - d.getTime()) / (1000 * 3600 * 24);
if (diffDays >= 0 && diffDays < 7) {
weekBuckets[getDayName(d.getDay())] += amt;
}
// Check Month
if (d.getFullYear() === baseDate.getFullYear() && d.getMonth() === baseDate.getMonth()) {
const dNum = d.getDate();
let wLabel = "Week 1";
if (dNum > 7 && dNum <= 14) wLabel = "Week 2";
else if (dNum > 14 && dNum <= 21) wLabel = "Week 3";
else if (dNum > 21 && dNum <= 28) wLabel = "Week 4";
else if (dNum > 28) wLabel = "Week 5";
monthBuckets[wLabel] += amt;
}
// Check Year
if (d.getFullYear() === baseDate.getFullYear()) {
yearBuckets[getMonthName(d.getMonth())] += amt;
}
}
const convertBucket = (b: Record<string, number>): ChartDataPoint[] => {
return Object.keys(b).map(k => ({ id: k, amount: b[k] }));
};
chartData.today = convertBucket(todayBuckets);
chartData.week = convertBucket(weekBuckets);
chartData.month = convertBucket(monthBuckets);
chartData.year = convertBucket(yearBuckets);
for (const group of Object.values(chartData)) {
if(group.length === 0) continue;
let maxObj = group[0];
for (const p of group) {
if (p.amount > maxObj.amount) maxObj = p;
}
if (maxObj.amount > 0) {
maxObj.highlighted = true;
}
}
// 3. Top Payees
const topPayees = Object.entries(payeeMap)
.map(([name, amt]) => ({ payeeName: name, amount: amt }))
.sort((a, b) => b.amount - a.amount)
.slice(0, 3);
.slice(0, 5);
return {
chartData,
totalAmount,
topPayees
};
return { chartData, totalAmount, topPayees };
}
// ---------------- EXPORTS ----------------
export const fetchAggregatedExpenses = () =>
fetchAggregatedData("expense");
export const fetchAggregatedIncome = () =>
fetchAggregatedData("income");