Dashboard Refactor: Flow-based Metrics + Unified Data Model #4
@@ -5,7 +5,8 @@ import {
|
|||||||
Grid,
|
Grid,
|
||||||
Typography,
|
Typography,
|
||||||
ToggleButton,
|
ToggleButton,
|
||||||
ToggleButtonGroup
|
ToggleButtonGroup,
|
||||||
|
Button
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { useTheme, alpha } from "@mui/material/styles";
|
import { useTheme, alpha } from "@mui/material/styles";
|
||||||
import { GroupKey } from "../../features/report";
|
import { GroupKey } from "../../features/report";
|
||||||
@@ -69,7 +70,7 @@ export default function DashboardView({
|
|||||||
transition: 'background 0.3s ease'
|
transition: 'background 0.3s ease'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: "flex", justifyContent: "center", mb: 3 }}>
|
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", mb: 3 }}>
|
||||||
<ToggleButtonGroup
|
<ToggleButtonGroup
|
||||||
value={flow}
|
value={flow}
|
||||||
exclusive
|
exclusive
|
||||||
@@ -92,6 +93,16 @@ export default function DashboardView({
|
|||||||
<ToggleButton value="outflows">Outflows</ToggleButton>
|
<ToggleButton value="outflows">Outflows</ToggleButton>
|
||||||
<ToggleButton value="inflows">Inflows</ToggleButton>
|
<ToggleButton value="inflows">Inflows</ToggleButton>
|
||||||
</ToggleButtonGroup>
|
</ToggleButtonGroup>
|
||||||
|
|
||||||
|
{selectedGroupKey && Object.keys(selectedGroupKey).length > 0 && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
sx={{ mt: 1, textTransform: "none" }}
|
||||||
|
onClick={() => setSelectedGroupKey(null)}
|
||||||
|
>
|
||||||
|
Clear Drill-down
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Grid container spacing={4}>
|
<Grid container spacing={4}>
|
||||||
|
|||||||
65
src/components/ProgressCard/TopPayees.adapter.ts
Normal file
65
src/components/ProgressCard/TopPayees.adapter.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { mergeBucketPeriods, periodIdToKey } from "../report.helpers";
|
||||||
|
import { GroupKey, ReportData } from "../../features/report";
|
||||||
|
|
||||||
|
export interface PayeeItem {
|
||||||
|
name: string;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractTopPayees(
|
||||||
|
reportData: ReportData,
|
||||||
|
flow: "outflows" | "inflows",
|
||||||
|
selectedPeriodId?: string | null,
|
||||||
|
selectedGroupKey?: GroupKey | null
|
||||||
|
): { items: PayeeItem[]; total: number } {
|
||||||
|
const payeeMap = new Map<string, number>();
|
||||||
|
|
||||||
|
let targetPeriods = [];
|
||||||
|
|
||||||
|
if (selectedPeriodId) {
|
||||||
|
const key = periodIdToKey(selectedPeriodId);
|
||||||
|
const periods = mergeBucketPeriods(reportData.buckets, key);
|
||||||
|
const selected = periods.find((p) => p.id === selectedPeriodId);
|
||||||
|
if (selected) {
|
||||||
|
targetPeriods.push(selected);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If no specific period is selected, aggregate over the "all" period bucket
|
||||||
|
targetPeriods = mergeBucketPeriods(reportData.buckets, "all");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const p of targetPeriods) {
|
||||||
|
let txns = p.metric.transactions || [];
|
||||||
|
|
||||||
|
if (selectedGroupKey?.tags && selectedGroupKey.tags.length > 0) {
|
||||||
|
txns = txns.filter(txn => {
|
||||||
|
if (!txn.tags) return false;
|
||||||
|
const txnTags = txn.tags.map(t => typeof t === "string" ? t : t.name);
|
||||||
|
return selectedGroupKey.tags!.every(selectedTag => txnTags.includes(selectedTag));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const txn of txns) {
|
||||||
|
if (txn.payee && txn.payee.name) {
|
||||||
|
const current = payeeMap.get(txn.payee.name) || 0;
|
||||||
|
payeeMap.set(txn.payee.name, current + txn.amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let items: PayeeItem[] = [];
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
for (const [name, amount] of payeeMap.entries()) {
|
||||||
|
items.push({ name, amount });
|
||||||
|
total += amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort descending by amount
|
||||||
|
items.sort((a, b) => b.amount - a.amount);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: items.slice(0, 4), // Top 4
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
93
src/components/ProgressCard/TopPayees.tsx
Normal file
93
src/components/ProgressCard/TopPayees.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Box, Paper, Typography } from "@mui/material";
|
||||||
|
import { ReportData, GroupKey } from "../../features/report";
|
||||||
|
import ProgressCard from "./ProgressCard";
|
||||||
|
import { extractTopPayees } from "./TopPayees.adapter";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
reportData: ReportData;
|
||||||
|
flow: "outflows" | "inflows";
|
||||||
|
header: string;
|
||||||
|
selectedPeriodId?: string | null;
|
||||||
|
selectedGroupKey?: GroupKey | null;
|
||||||
|
setSelectedGroupKey?: (key: GroupKey | null) => void;
|
||||||
|
compact?: boolean;
|
||||||
|
isFetching?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TopPayees({
|
||||||
|
reportData,
|
||||||
|
flow,
|
||||||
|
header,
|
||||||
|
selectedPeriodId,
|
||||||
|
selectedGroupKey,
|
||||||
|
setSelectedGroupKey,
|
||||||
|
compact = true,
|
||||||
|
isFetching,
|
||||||
|
}: Props) {
|
||||||
|
const { items, total } = React.useMemo(() => {
|
||||||
|
return extractTopPayees(reportData, flow, selectedPeriodId, selectedGroupKey);
|
||||||
|
}, [reportData, flow, selectedPeriodId, selectedGroupKey]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
sx={{
|
||||||
|
p: { xs: 2.5, sm: 4 },
|
||||||
|
borderRadius: 4,
|
||||||
|
width: "100%",
|
||||||
|
boxShadow: "none",
|
||||||
|
border: "1px solid",
|
||||||
|
borderColor: "divider",
|
||||||
|
bgcolor: "background.paper",
|
||||||
|
opacity: isFetching ? 0.6 : 1,
|
||||||
|
transition: "opacity 0.3s ease",
|
||||||
|
pointerEvents: isFetching ? "none" : "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6" fontWeight={700} gutterBottom>
|
||||||
|
{header}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: {
|
||||||
|
xs: "1fr",
|
||||||
|
sm: "repeat(2, 1fr)",
|
||||||
|
md: "repeat(4, 1fr)",
|
||||||
|
},
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map((item) => {
|
||||||
|
const isSelected = selectedGroupKey?.payee?.includes(item.name);
|
||||||
|
return (
|
||||||
|
<ProgressCard
|
||||||
|
key={item.name}
|
||||||
|
header={item.name}
|
||||||
|
progressAmount={item.amount}
|
||||||
|
totalAmount={total}
|
||||||
|
compact={compact}
|
||||||
|
colorTheme={flow === "outflows" ? "warning" : "info"}
|
||||||
|
selected={isSelected}
|
||||||
|
isFetching={isFetching}
|
||||||
|
onClick={() => {
|
||||||
|
if (setSelectedGroupKey) {
|
||||||
|
let newKey = selectedGroupKey ? { ...selectedGroupKey } : {};
|
||||||
|
|
||||||
|
if (isSelected) {
|
||||||
|
delete newKey.payee;
|
||||||
|
} else {
|
||||||
|
newKey.payee = [item.name];
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedGroupKey(Object.keys(newKey).length ? newKey : null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
periodIdToKey,
|
periodIdToKey,
|
||||||
} from "../report.helpers";
|
} from "../report.helpers";
|
||||||
|
|
||||||
|
import { GroupKey } from "../../features/report";
|
||||||
|
|
||||||
export interface TagItem {
|
export interface TagItem {
|
||||||
tag: string;
|
tag: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
@@ -12,7 +14,8 @@ export interface TagItem {
|
|||||||
export function extractTopTags(
|
export function extractTopTags(
|
||||||
reportData: ReportData,
|
reportData: ReportData,
|
||||||
flow: "outflows" | "inflows",
|
flow: "outflows" | "inflows",
|
||||||
selectedPeriodId?: string | null
|
selectedPeriodId?: string | null,
|
||||||
|
selectedGroupKey?: GroupKey | null
|
||||||
): { items: TagItem[]; total: number } {
|
): { items: TagItem[]; total: number } {
|
||||||
const tagMap = new Map<string, number>();
|
const tagMap = new Map<string, number>();
|
||||||
|
|
||||||
@@ -35,7 +38,14 @@ export function extractTopTags(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (period && period.metric && period.metric.transactions) {
|
if (period && period.metric && period.metric.transactions) {
|
||||||
for (const txn of period.metric.transactions) {
|
let txns = period.metric.transactions;
|
||||||
|
if (selectedGroupKey?.payee && selectedGroupKey.payee.length > 0) {
|
||||||
|
txns = txns.filter(txn =>
|
||||||
|
txn.payee?.name && selectedGroupKey.payee!.includes(txn.payee.name)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const txn of txns) {
|
||||||
if (txn.tags && txn.tags.length > 0) {
|
if (txn.tags && txn.tags.length > 0) {
|
||||||
for (const tagObj of txn.tags) {
|
for (const tagObj of txn.tags) {
|
||||||
const tagName = typeof tagObj === "string" ? tagObj : tagObj.name;
|
const tagName = typeof tagObj === "string" ? tagObj : tagObj.name;
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ export default function TopTags({
|
|||||||
isFetching,
|
isFetching,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { items, total } = React.useMemo(() => {
|
const { items, total } = React.useMemo(() => {
|
||||||
return extractTopTags(reportData, flow, selectedPeriodId);
|
return extractTopTags(reportData, flow, selectedPeriodId, selectedGroupKey);
|
||||||
}, [reportData, flow, selectedPeriodId]);
|
}, [reportData, flow, selectedPeriodId, selectedGroupKey]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
|
|||||||
Reference in New Issue
Block a user