Compare commits

..

6 Commits

8 changed files with 696 additions and 0 deletions

4
react-openapi/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export { default as Admin } from "./Admin";
export { api, auth, initializeApiClients } from "./api/client";
export { getAppConfig } from "./config";
export type { AppConfig, ResourceConfig, ResourceField } from "./types/config";

140
src/Dashboard.tsx Normal file
View File

@@ -0,0 +1,140 @@
import * as React from "react";
import {
Box,
Container,
Grid,
CircularProgress,
Alert,
ToggleButton,
ToggleButtonGroup
} from "@mui/material";
import LatestItemsList, { LatestItem } from "./components/LatestItemsList";
import HistoryChart from "./components/HistoryChart";
import {
fetchLatestTransactions,
fetchAggregatedExpenses,
fetchAggregatedIncome,
AggregatedDashboardData
} from "./utils/dashboardLoader";
export default function Dashboard() {
const [latest, setLatest] = React.useState<{
expense: LatestItem[];
income: LatestItem[];
}>({
expense: [],
income: []
});
const [aggregated, setAggregated] = React.useState<{
expense: AggregatedDashboardData | null;
income: AggregatedDashboardData | null;
}>({
expense: null,
income: null
});
const [mode, setMode] =
React.useState<"expense" | "income">("expense");
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
// -------- LOAD ONCE --------
React.useEffect(() => {
async function loadData() {
try {
setLoading(true);
const [
latestExpense,
latestIncome,
expenseData,
incomeData
] = await Promise.all([
fetchLatestTransactions("expense"),
fetchLatestTransactions("income"),
fetchAggregatedExpenses(),
fetchAggregatedIncome()
]);
setLatest({
expense: latestExpense,
income: latestIncome
});
setAggregated({
expense: expenseData,
income: incomeData
});
} catch (err: any) {
console.error(err);
setError(err.message || "Failed to load dashboard data");
} finally {
setLoading(false);
}
}
loadData();
}, []);
const currentData = aggregated[mode];
const currentLatest = latest[mode];
// -------- UI STATES --------
if (loading) {
return (
<Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", height: "60vh" }}>
<CircularProgress />
</Box>
);
}
if (error) {
return (
<Container sx={{ mt: 4 }}>
<Alert severity="error">{error}</Alert>
</Container>
);
}
return (
<Container 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} direction="row">
{/* LEFT → 1/3 */}
<Grid size={4}>
<LatestItemsList
title={`Recent ${mode === "expense" ? "Expenses" : "Income"}`}
items={currentLatest}
onViewAll={() => {}}
/>
</Grid>
{/* RIGHT → 2/3 */}
<Grid size={8}>
<HistoryChart
header={`${mode === "expense" ? "Expense" : "Income"} Breakdown`}
summary="Interactive chronological tracking"
tabs={["Week", "Month", "Year"]}
data={currentData?.chartData || {}}
/>
</Grid>
</Grid>
</Container>
);
}

View File

@@ -99,6 +99,13 @@ export default function Header({
mr: 2,
}}
>
<Button
color="inherit"
onClick={() => navigate("/dashboard")}
sx={{ textTransform: "none", fontWeight: 500 }}
>
Dashboard
</Button>
<Button
color="inherit"
onClick={() => navigate("/admin/profile")}

View File

@@ -0,0 +1,130 @@
import * as React from "react";
import { Box, Typography, ToggleButtonGroup, ToggleButton, Paper } from "@mui/material";
export interface ChartDataPoint {
id: string;
amount: number;
count?: number;
highlighted?: boolean;
}
export interface HistoryChartProps {
header: string;
summary?: string;
tabs: string[];
data: Record<string, ChartDataPoint[]>;
}
export default function HistoryChart({
header,
summary,
tabs,
data,
}: HistoryChartProps) {
const [activeTab, setActiveTab] = React.useState<string>(tabs[0] || "");
const handleTabChange = (_: React.MouseEvent<HTMLElement>, newTab: string | null) => {
if (newTab !== null) {
setActiveTab(newTab);
}
};
const activeDataKey = activeTab.toLowerCase();
const currentData = data[activeDataKey] || data[activeTab] || [];
const maxAmount = Math.max(...currentData.map((d) => d.amount), 1);
return (
<Paper sx={{ p: { xs: 2, sm: 4 }, borderRadius: 4, width: "100%", boxShadow: 'none', border: '1px solid', borderColor: 'divider' }}>
<Typography variant="h6" fontWeight={700} gutterBottom>
{header}
</Typography>
{summary && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{summary}
</Typography>
)}
<ToggleButtonGroup
value={activeTab}
exclusive
onChange={handleTabChange}
fullWidth
sx={{
mb: 4,
bgcolor: (theme) => theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.02)',
borderRadius: 8,
p: 0.5,
"& .MuiToggleButton-root": {
border: "none",
borderRadius: 8,
textTransform: "capitalize",
fontWeight: 600,
color: "text.secondary",
"&.Mui-selected": {
bgcolor: (theme) => theme.palette.mode === 'dark' ? 'primary.dark' : 'primary.light',
color: (theme) => theme.palette.mode === 'dark' ? 'primary.contrastText' : 'primary.main',
boxShadow: '0 2px 8px rgba(0,0,0,0.05)',
},
},
}}
>
{tabs.map((tab) => (
<ToggleButton key={tab} value={tab}>
{tab}
</ToggleButton>
))}
</ToggleButtonGroup>
{/* Chart Area */}
{currentData.length > 0 ? (
<Box sx={{ display: "flex", alignItems: "flex-end", height: 200, mt: 4, position: 'relative' }}>
{currentData.map((point) => {
const heightPerc = (point.amount / maxAmount) * 100;
return (
<Box
key={point.id}
sx={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "flex-end",
height: "100%",
}}
>
<Typography variant="caption" sx={{ mb: 1, opacity: 0.7, fontSize: '0.65rem', display: { xs: 'none', sm: 'block' } }}>
{point.amount > 0 ? `Rs ${point.amount}` : ''}
</Typography>
<Box
sx={{
width: "40%",
minWidth: 12,
maxWidth: 32,
height: `${heightPerc}%`,
minHeight: "4px",
bgcolor: point.highlighted ? "error.main" : "grey.300",
borderRadius: 4,
transition: "height 0.5s cubic-bezier(0.4, 0, 0.2, 1)",
...(point.highlighted && {
boxShadow: (theme) => `0 4px 12px ${theme.palette.error.main}40`,
}),
}}
/>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, fontWeight: 500, fontSize: '0.7rem' }}>
{point.id}
</Typography>
</Box>
);
})}
</Box>
) : (
<Box sx={{ height: 200, display: "flex", alignItems: "center", justifyContent: "center" }}>
<Typography color="text.secondary">No Data Available</Typography>
</Box>
)}
</Paper>
);
}

View File

@@ -0,0 +1,110 @@
import * as React from "react";
import {
List,
ListItem,
ListItemAvatar,
ListItemText,
Avatar,
Typography,
Box,
Button,
} from "@mui/material";
export interface LatestItem {
id: string | number;
icon: React.ReactNode;
iconBgColor?: string;
title: string;
subtitle: string;
amount: string;
timeAgo: string;
}
export interface LatestItemsListProps {
title?: string;
items: LatestItem[];
onViewAll?: () => void;
}
export default function LatestItemsList({
title = "Recent Transactions",
items,
onViewAll,
}: LatestItemsListProps) {
return (
<Box sx={{ width: "100%", bgcolor: "background.paper", borderRadius: 4, p: 2 }}>
{/* Header */}
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2, px: 2 }}>
<Typography variant="h6" fontWeight="bold">
{title}
</Typography>
{onViewAll && (
<Button
variant="text"
color="inherit"
size="small"
sx={{ textTransform: "none", color: "text.secondary", fontWeight: "medium" }}
onClick={onViewAll}
>
view all
</Button>
)}
</Box>
{/* List */}
<List disablePadding>
{items.map((item, index) => (
<ListItem
key={item.id}
sx={{
px: { xs: 1, sm: 2 },
py: 2,
mb: index !== items.length - 1 ? 1 : 0,
borderRadius: 3,
"&:hover": { bgcolor: "action.hover" },
transition: "background-color 0.2s ease",
}}
>
<ListItemAvatar>
<Avatar
variant="rounded"
sx={{
bgcolor: item.iconBgColor || "grey.200",
color: "inherit",
width: 48,
height: 48,
borderRadius: 3,
mr: 2,
}}
>
{item.icon}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={
<Typography variant="subtitle1" fontWeight={600} color="text.primary">
{item.title}
</Typography>
}
secondary={
<Typography variant="body2" color="text.secondary">
{item.subtitle}
</Typography>
}
/>
<Box sx={{ textAlign: "right" }}>
<Typography variant="subtitle1" fontWeight={700} color="text.primary">
{item.amount}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
{item.timeAgo}
</Typography>
</Box>
</ListItem>
))}
</List>
</Box>
);
}

View File

@@ -0,0 +1,80 @@
import * as React from "react";
import { Box, Typography, Paper, LinearProgress, linearProgressClasses } from "@mui/material";
export interface ProgressCardProps {
header: string;
summary?: string;
progressAmount: number;
totalAmount: number;
colorTheme?: "primary" | "secondary" | "error" | "info" | "success" | "warning";
}
export default function ProgressCard({
header,
summary,
progressAmount,
totalAmount,
colorTheme = "info",
}: ProgressCardProps) {
const percentage = Math.min(100, Math.max(0, (progressAmount / totalAmount) * 100)) || 0;
const displaySummary = summary ?? `Rs ${progressAmount} / Rs ${totalAmount}`;
const parts = displaySummary.split('/');
const prefixAmount = parts[0]?.trim() || '';
const suffixString = parts.length > 1 ? `/ ${parts.slice(1).join('/').trim()}` : '';
return (
<Paper
elevation={4}
sx={{
width: "100%",
p: { xs: 3, md: 4 },
borderRadius: 4,
background: (theme) =>
colorTheme === "info"
? "linear-gradient(135deg, #0284c7 0%, #06b6d4 100%)"
: `linear-gradient(135deg, ${theme.palette[colorTheme].main} 0%, ${theme.palette[colorTheme].light} 100%)`,
color: "#fff",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
position: 'relative',
overflow: 'hidden',
boxShadow: (theme) => `0 12px 24px -10px ${theme.palette.mode === 'dark' ? '#000' : theme.palette[colorTheme].main}`,
}}
>
<Typography variant="subtitle1" fontWeight={600} sx={{ opacity: 0.9, mb: 1 }}>
{header}
</Typography>
<Typography variant="h3" fontWeight={800} sx={{ mb: 3 }}>
{prefixAmount}{" "}
{suffixString && (
<Typography component="span" variant="subtitle1" sx={{ opacity: 0.7, fontWeight: 500 }}>
{suffixString}
</Typography>
)}
</Typography>
<Box sx={{ width: "85%" }}>
<LinearProgress
variant="determinate"
value={percentage}
sx={{
height: 10,
borderRadius: 5,
[`&.${linearProgressClasses.colorPrimary}`]: {
backgroundColor: "rgba(0, 0, 0, 0.2)",
},
[`& .${linearProgressClasses.bar}`]: {
borderRadius: 5,
backgroundColor: "#fff",
},
}}
/>
</Box>
</Paper>
);
}

View File

@@ -11,6 +11,7 @@ import {
Toolbar
} from "@mui/material";
import Home from './Home';
import Dashboard from './Dashboard';
import { Admin, initializeApiClients } from '../react-openapi';
import { configuration, profileConfiguration } from './openapi-config';
import { Buffer } from 'buffer';
@@ -35,6 +36,7 @@ initializeApiClients(API_BASE, AUTH_BASE);
const routerMapping = [
{ path: "/", component: Home, headerTitle: "Home" },
{ path: "/home", component: Home, headerTitle: "Home" },
{ path: "/dashboard", component: Dashboard, headerTitle: "Dashboard" },
{ path: "/admin/*", component: Admin, headerTitle: "Admin" },
];

View File

@@ -0,0 +1,223 @@
import { api } from "../../react-openapi";
import { LatestItem } from "../components/LatestItemsList";
import { ChartDataPoint } from "../components/HistoryChart";
import * as React from "react";
import MonetizationOnIcon from "@mui/icons-material/MonetizationOn";
// ---------------- ICON ----------------
const DEFAULT_ICON = React.createElement(MonetizationOnIcon, {
sx: { color: "#388e3c" }
});
// ---------------- HELPERS ----------------
const format = (d: Date) =>
`${d.getDate()} ${d.toLocaleString("default", { month: "short" })}`;
const startOfDay = (d: Date) => {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x;
};
const endOfDay = (d: Date) => {
const x = new Date(d);
x.setHours(23, 59, 59, 999);
return x;
};
const getStartOfWeek = (d: Date) => {
const date = new Date(d);
const day = date.getDay() || 7;
if (day !== 1) date.setDate(date.getDate() - (day - 1));
return startOfDay(date);
};
// ---------------- LATEST ----------------
export async function fetchLatestTransactions(
type: "expense" | "income"
): Promise<LatestItem[]> {
const res = await api.get("/expenses", {
params: { limit: 100, sort: "-occurred_at" }
});
const items = res.data?.items || res.data || [];
const isValid = (amt: number) =>
type === "expense" ? amt < 0 : amt > 0;
return items
.filter((item: any) => isValid(Number(item.amount) || 0))
.slice(0, 10)
.map((exp: any, index: number) => {
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:
type === "expense" ? "#ffebee" : "#e8f5e9",
title: exp.payee?.name || exp.payee || "Unknown Payee",
subtitle: exp.category?.name || exp.account?.name || "Transaction",
amount: `Rs ${Math.abs(exp.amount || 0)}`,
timeAgo: diffDays === 0 ? "Today" : `${diffDays} days ago`
};
});
}
// ---------------- TYPES ----------------
export interface AggregatedDashboardData {
chartData: Record<string, ChartDataPoint[]>;
totalAmount: number;
topPayees: Array<{ payeeName: string; amount: number }>;
}
// ---------------- AGGREGATION ----------------
export async function fetchAggregatedData(
type: "expense" | "income"
): Promise<AggregatedDashboardData> {
const res = await api.get("/expenses", { params: { limit: 0 } });
const all: any[] = res.data?.items || res.data || [];
const now = new Date();
let totalAmount = 0;
const payeeMap: Record<string, number> = {};
const isValid = (amt: number) =>
type === "expense" ? amt < 0 : amt > 0;
const normalize = (amt: number) => Math.abs(amt);
// ---------------- WEEK ----------------
const weekBuckets: Record<string, number> = {
Mon: 0, Tue: 0, Wed: 0, Thu: 0,
Fri: 0, Sat: 0, Sun: 0
};
const weekStart = getStartOfWeek(now);
const weekEnd = endOfDay(new Date(weekStart.getTime() + 6 * 86400000));
// ---------------- MONTH (5 rolling weeks) ----------------
const monthBuckets: {
label: string;
start: Date;
end: Date;
amount: number;
}[] = [];
for (let i = 0; i < 5; i++) {
const end = endOfDay(new Date(now.getTime() - i * 7 * 86400000));
const start = startOfDay(new Date(end.getTime() - 6 * 86400000));
monthBuckets.push({
label: `${format(start)} - ${format(end)}`,
start,
end,
amount: 0
});
}
// ---------------- YEAR (12 months) ----------------
const yearBuckets: {
label: string;
start: Date;
end: Date;
amount: number;
}[] = [];
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 = endOfDay(new Date(d.getFullYear(), d.getMonth() + 1, 0));
yearBuckets.push({
label: d.toLocaleString("default", { month: "short" }),
start,
end,
amount: 0
});
}
// ---------------- 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 payee = item.payee?.name || item.payee || "Unknown";
payeeMap[payee] = (payeeMap[payee] || 0) + 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
monthBuckets.forEach(b => {
if (d >= b.start && d <= b.end) {
b.amount += amt;
}
});
// YEAR
yearBuckets.forEach(b => {
if (d >= b.start && d <= b.end) {
b.amount += amt;
}
});
}
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
}));
const chartData = {
week: toPoints(weekBuckets),
month: toPoints(monthBuckets),
year: toPoints(yearBuckets)
};
// 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 topPayees = Object.entries(payeeMap)
.map(([name, amt]) => ({ payeeName: name, amount: amt }))
.sort((a, b) => b.amount - a.amount)
.slice(0, 5);
return { chartData, totalAmount, topPayees };
}
// ---------------- EXPORTS ----------------
export const fetchAggregatedExpenses = () =>
fetchAggregatedData("expense");
export const fetchAggregatedIncome = () =>
fetchAggregatedData("income");