Compare commits
6 Commits
ffa41825dd
...
b07de2b03c
| Author | SHA1 | Date | |
|---|---|---|---|
| b07de2b03c | |||
| 4eca3b7124 | |||
| 6abed4e72a | |||
| 214c0be44e | |||
| 68337ba312 | |||
| 84059a84b5 |
4
react-openapi/index.ts
Normal file
4
react-openapi/index.ts
Normal 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
140
src/Dashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -99,6 +99,13 @@ export default function Header({
|
|||||||
mr: 2,
|
mr: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Button
|
||||||
|
color="inherit"
|
||||||
|
onClick={() => navigate("/dashboard")}
|
||||||
|
sx={{ textTransform: "none", fontWeight: 500 }}
|
||||||
|
>
|
||||||
|
Dashboard
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="inherit"
|
color="inherit"
|
||||||
onClick={() => navigate("/admin/profile")}
|
onClick={() => navigate("/admin/profile")}
|
||||||
|
|||||||
130
src/components/HistoryChart.tsx
Normal file
130
src/components/HistoryChart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
src/components/LatestItemsList.tsx
Normal file
110
src/components/LatestItemsList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
src/components/ProgressCard.tsx
Normal file
80
src/components/ProgressCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Toolbar
|
Toolbar
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import Home from './Home';
|
import Home from './Home';
|
||||||
|
import Dashboard from './Dashboard';
|
||||||
import { Admin, initializeApiClients } from '../react-openapi';
|
import { Admin, initializeApiClients } from '../react-openapi';
|
||||||
import { configuration, profileConfiguration } from './openapi-config';
|
import { configuration, profileConfiguration } from './openapi-config';
|
||||||
import { Buffer } from 'buffer';
|
import { Buffer } from 'buffer';
|
||||||
@@ -35,6 +36,7 @@ initializeApiClients(API_BASE, AUTH_BASE);
|
|||||||
const routerMapping = [
|
const routerMapping = [
|
||||||
{ path: "/", component: Home, headerTitle: "Home" },
|
{ path: "/", component: Home, headerTitle: "Home" },
|
||||||
{ path: "/home", component: Home, headerTitle: "Home" },
|
{ path: "/home", component: Home, headerTitle: "Home" },
|
||||||
|
{ path: "/dashboard", component: Dashboard, headerTitle: "Dashboard" },
|
||||||
{ path: "/admin/*", component: Admin, headerTitle: "Admin" },
|
{ path: "/admin/*", component: Admin, headerTitle: "Admin" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
223
src/utils/dashboardLoader.ts
Normal file
223
src/utils/dashboardLoader.ts
Normal 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");
|
||||||
Reference in New Issue
Block a user