to payee filtering and updated drill down logic both ways
This commit is contained in:
@@ -5,7 +5,8 @@ import {
|
||||
Grid,
|
||||
Typography,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup
|
||||
ToggleButtonGroup,
|
||||
Button
|
||||
} from "@mui/material";
|
||||
import { useTheme, alpha } from "@mui/material/styles";
|
||||
import { GroupKey } from "../../features/report";
|
||||
@@ -69,7 +70,7 @@ export default function DashboardView({
|
||||
transition: 'background 0.3s ease'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", justifyContent: "center", mb: 3 }}>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", mb: 3 }}>
|
||||
<ToggleButtonGroup
|
||||
value={flow}
|
||||
exclusive
|
||||
@@ -92,6 +93,16 @@ export default function DashboardView({
|
||||
<ToggleButton value="outflows">Outflows</ToggleButton>
|
||||
<ToggleButton value="inflows">Inflows</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
{selectedGroupKey && Object.keys(selectedGroupKey).length > 0 && (
|
||||
<Button
|
||||
size="small"
|
||||
sx={{ mt: 1, textTransform: "none" }}
|
||||
onClick={() => setSelectedGroupKey(null)}
|
||||
>
|
||||
Clear Drill-down
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<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,
|
||||
} from "../report.helpers";
|
||||
|
||||
import { GroupKey } from "../../features/report";
|
||||
|
||||
export interface TagItem {
|
||||
tag: string;
|
||||
amount: number;
|
||||
@@ -12,7 +14,8 @@ export interface TagItem {
|
||||
export function extractTopTags(
|
||||
reportData: ReportData,
|
||||
flow: "outflows" | "inflows",
|
||||
selectedPeriodId?: string | null
|
||||
selectedPeriodId?: string | null,
|
||||
selectedGroupKey?: GroupKey | null
|
||||
): { items: TagItem[]; total: number } {
|
||||
const tagMap = new Map<string, number>();
|
||||
|
||||
@@ -35,7 +38,14 @@ export function extractTopTags(
|
||||
}
|
||||
|
||||
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) {
|
||||
for (const tagObj of txn.tags) {
|
||||
const tagName = typeof tagObj === "string" ? tagObj : tagObj.name;
|
||||
|
||||
@@ -26,8 +26,8 @@ export default function TopTags({
|
||||
isFetching,
|
||||
}: Props) {
|
||||
const { items, total } = React.useMemo(() => {
|
||||
return extractTopTags(reportData, flow, selectedPeriodId);
|
||||
}, [reportData, flow, selectedPeriodId]);
|
||||
return extractTopTags(reportData, flow, selectedPeriodId, selectedGroupKey);
|
||||
}, [reportData, flow, selectedPeriodId, selectedGroupKey]);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
|
||||
Reference in New Issue
Block a user