common component props
This commit is contained in:
@@ -16,6 +16,13 @@ export interface DashboardState {
|
|||||||
comparison: boolean;
|
comparison: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DashboardStateSetters {
|
||||||
|
setSelectedPeriodId: (id: DashboardSelectedPeriodId) => void;
|
||||||
|
setSelectedGroupKey: (groupKey: GroupKey | null) => void;
|
||||||
|
togglePeriodType: () => void;
|
||||||
|
toggleComparison: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DashboardSection {
|
export interface DashboardSection {
|
||||||
id: string;
|
id: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import DashboardView from "./Dashboard.view";
|
import DashboardView from "./Dashboard.view";
|
||||||
import { DashboardProps, DashboardState } from "./Dashboard.models";
|
import { DashboardProps, DashboardState, DashboardStateSetters } from "./Dashboard.models";
|
||||||
|
|
||||||
export default function Dashboard(props: DashboardProps) {
|
export default function Dashboard(props: DashboardProps) {
|
||||||
const [state, setState] = React.useState<DashboardState>({
|
const [state, setState] = React.useState<DashboardState>({
|
||||||
@@ -49,16 +49,20 @@ export default function Dashboard(props: DashboardProps) {
|
|||||||
setState(prev => ({ ...prev, selectedGroupKey: groupKey }));
|
setState(prev => ({ ...prev, selectedGroupKey: groupKey }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const stateSetters: DashboardStateSetters = {
|
||||||
|
togglePeriodType,
|
||||||
|
toggleComparison,
|
||||||
|
setSelectedPeriodId,
|
||||||
|
setSelectedGroupKey,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardView
|
<DashboardView
|
||||||
{...props}
|
{...props}
|
||||||
state={state}
|
state={state}
|
||||||
setState={setState}
|
setState={setState}
|
||||||
toggleFlow={toggleFlow}
|
toggleFlow={toggleFlow}
|
||||||
togglePeriodType={togglePeriodType}
|
stateSetters={stateSetters}
|
||||||
toggleComparison={toggleComparison}
|
|
||||||
setSelectedPeriodId={setSelectedPeriodId}
|
|
||||||
setSelectedGroupKey={setSelectedGroupKey}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,16 +10,13 @@ import {
|
|||||||
} 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";
|
||||||
import { DashboardProps, DashboardState } from "./Dashboard.models";
|
import { DashboardProps, DashboardState, DashboardStateSetters } from "./Dashboard.models";
|
||||||
|
|
||||||
interface ViewProps extends DashboardProps {
|
interface ViewProps extends DashboardProps {
|
||||||
state: DashboardState;
|
state: DashboardState;
|
||||||
setState: React.Dispatch<React.SetStateAction<DashboardState>>;
|
setState: React.Dispatch<React.SetStateAction<DashboardState>>;
|
||||||
toggleFlow: (event: React.MouseEvent<HTMLElement>, newFlow: "outflows" | "inflows" | null) => void;
|
toggleFlow: (event: React.MouseEvent<HTMLElement>, newFlow: "outflows" | "inflows" | null) => void;
|
||||||
togglePeriodType: () => void;
|
stateSetters: DashboardStateSetters;
|
||||||
setSelectedPeriodId: (id: string | null) => void;
|
|
||||||
setSelectedGroupKey: (groupKey: GroupKey | null) => void;
|
|
||||||
toggleComparison: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardView({
|
export default function DashboardView({
|
||||||
@@ -28,14 +25,12 @@ export default function DashboardView({
|
|||||||
state,
|
state,
|
||||||
setState,
|
setState,
|
||||||
toggleFlow,
|
toggleFlow,
|
||||||
togglePeriodType,
|
stateSetters,
|
||||||
toggleComparison,
|
|
||||||
setSelectedPeriodId,
|
|
||||||
setSelectedGroupKey,
|
|
||||||
}: ViewProps) {
|
}: ViewProps) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const themeMode = theme.palette.mode;
|
const themeMode = theme.palette.mode;
|
||||||
const { flow, periodType, comparison, selectedPeriodId, selectedGroupKey } = state;
|
const { flow, selectedGroupKey } = state;
|
||||||
|
const { setSelectedGroupKey } = stateSetters;
|
||||||
|
|
||||||
// Resolve colors with fallbacks
|
// Resolve colors with fallbacks
|
||||||
const colors = React.useMemo(() => {
|
const colors = React.useMemo(() => {
|
||||||
@@ -121,17 +116,8 @@ export default function DashboardView({
|
|||||||
colorScheme={colors}
|
colorScheme={colors}
|
||||||
|
|
||||||
// State management
|
// State management
|
||||||
flow={flow}
|
state={state}
|
||||||
|
stateSetters={stateSetters}
|
||||||
periodType={periodType}
|
|
||||||
comparison={comparison}
|
|
||||||
selectedPeriodId={selectedPeriodId}
|
|
||||||
selectedGroupKey={selectedGroupKey}
|
|
||||||
|
|
||||||
togglePeriodType={togglePeriodType}
|
|
||||||
toggleComparison={toggleComparison}
|
|
||||||
setSelectedPeriodId={setSelectedPeriodId}
|
|
||||||
setSelectedGroupKey={setSelectedGroupKey}
|
|
||||||
isFetching={arguments[0].isFetching}
|
isFetching={arguments[0].isFetching}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import {
|
import { ComponentProps } from "../report.props";
|
||||||
DashboardFlow,
|
|
||||||
DashboardPeriodType,
|
|
||||||
DashboardSelectedPeriodId
|
|
||||||
} from "../Dashboard";
|
|
||||||
import { ReportData } from "../../features/report";
|
|
||||||
|
|
||||||
export interface _ChartDataPoint {
|
export interface _ChartDataPoint {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -16,27 +11,6 @@ export interface ChartDataPoint extends _ChartDataPoint {
|
|||||||
compare?: _ChartDataPoint;
|
compare?: _ChartDataPoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HistoryChartProps {
|
export interface HistoryChartProps extends ComponentProps {
|
||||||
header: string;
|
|
||||||
summary?: string;
|
|
||||||
tabs: string[];
|
tabs: string[];
|
||||||
|
|
||||||
reportData: ReportData;
|
|
||||||
|
|
||||||
colorScheme: {
|
|
||||||
primary: string;
|
|
||||||
light: string;
|
|
||||||
text: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
flow: DashboardFlow;
|
|
||||||
periodType: DashboardPeriodType;
|
|
||||||
selectedPeriodId: DashboardSelectedPeriodId;
|
|
||||||
comparison: boolean;
|
|
||||||
|
|
||||||
togglePeriodType: () => void;
|
|
||||||
setSelectedPeriodId: (id: string | null) => void;
|
|
||||||
toggleComparison: () => void;
|
|
||||||
|
|
||||||
isFetching?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ export default function HistoryChart(props: HistoryChartProps) {
|
|||||||
const {
|
const {
|
||||||
tabs,
|
tabs,
|
||||||
reportData,
|
reportData,
|
||||||
flow,
|
state,
|
||||||
comparison,
|
stateSetters,
|
||||||
selectedPeriodId,
|
|
||||||
setSelectedPeriodId
|
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
const { flow, comparison, selectedPeriodId } = state;
|
||||||
|
const { setSelectedPeriodId } = stateSetters;
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = React.useState<string>(tabs[0] || "");
|
const [activeTab, setActiveTab] = React.useState<string>(tabs[0] || "");
|
||||||
const [startIndex, setStartIndex] = React.useState(0);
|
const [startIndex, setStartIndex] = React.useState(0);
|
||||||
|
|
||||||
|
|||||||
@@ -35,14 +35,8 @@ export default function HistoryChartView(props: ViewProps) {
|
|||||||
tabs,
|
tabs,
|
||||||
colorScheme,
|
colorScheme,
|
||||||
|
|
||||||
flow,
|
state,
|
||||||
periodType,
|
stateSetters,
|
||||||
selectedPeriodId,
|
|
||||||
comparison,
|
|
||||||
|
|
||||||
togglePeriodType,
|
|
||||||
setSelectedPeriodId,
|
|
||||||
toggleComparison,
|
|
||||||
|
|
||||||
activeTab,
|
activeTab,
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
@@ -55,6 +49,9 @@ export default function HistoryChartView(props: ViewProps) {
|
|||||||
activeDataKey,
|
activeDataKey,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
const { flow, periodType, selectedPeriodId, comparison } = state;
|
||||||
|
const { togglePeriodType, setSelectedPeriodId, toggleComparison } = stateSetters;
|
||||||
|
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isDark = theme.palette.mode === "dark";
|
const isDark = theme.palette.mode === "dark";
|
||||||
|
|
||||||
|
|||||||
@@ -1,67 +1,19 @@
|
|||||||
import { ReportData, Transaction, GroupKey } from "../../features/report";
|
import { ReportData, GroupKey } from "../../features/report";
|
||||||
import {
|
import {
|
||||||
mergeBucketPeriods,
|
|
||||||
periodIdToKey,
|
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
filterBuckets,
|
extractFilteredTransactions,
|
||||||
} from "../report.helpers";
|
} from "../report.helpers";
|
||||||
import { LatestItem } from "./LatestItems.models";
|
import { LatestItem } from "./LatestItems.models";
|
||||||
|
|
||||||
// ─── Transaction extraction ─────────────────────────────────
|
|
||||||
|
|
||||||
function extractTransactions(
|
|
||||||
reportData: ReportData,
|
|
||||||
selectedPeriodId: string | null,
|
|
||||||
selectedGroupKey: GroupKey | null,
|
|
||||||
): Transaction[] {
|
|
||||||
// 1. Get raw transactions
|
|
||||||
let rawTxns: Transaction[] = [];
|
|
||||||
|
|
||||||
if (selectedPeriodId) {
|
|
||||||
const key = periodIdToKey(selectedPeriodId);
|
|
||||||
const periods = mergeBucketPeriods(reportData.buckets, key);
|
|
||||||
const selected = periods.find((p) => p.id === selectedPeriodId);
|
|
||||||
rawTxns = selected?.metric.transactions || [];
|
|
||||||
} else {
|
|
||||||
const periods = mergeBucketPeriods(reportData.buckets, "all");
|
|
||||||
if (periods.length > 0) {
|
|
||||||
rawTxns = periods[0].metric.transactions || [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Filter by group key
|
|
||||||
if (selectedGroupKey) {
|
|
||||||
rawTxns = rawTxns.filter(txn => {
|
|
||||||
let match = true;
|
|
||||||
if (selectedGroupKey.tags && selectedGroupKey.tags.length > 0) {
|
|
||||||
if (!txn.tags) match = false;
|
|
||||||
else {
|
|
||||||
const txnTags = txn.tags.map(t => typeof t === "string" ? t : t.name);
|
|
||||||
if (!selectedGroupKey.tags.every(selectedTag => txnTags.includes(selectedTag))) match = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (match && selectedGroupKey.payee && selectedGroupKey.payee.length > 0) {
|
|
||||||
if (!txn.payee || !txn.payee.name) match = false;
|
|
||||||
else {
|
|
||||||
if (!selectedGroupKey.payee.includes(txn.payee.name)) match = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return match;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return rawTxns;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Main adapter ────────────────────────────────────────────
|
// ─── Main adapter ────────────────────────────────────────────
|
||||||
|
|
||||||
export function buildLatestItems(
|
export function buildLatestItems(
|
||||||
reportData: ReportData,
|
reportData: ReportData,
|
||||||
selectedPeriodId: string | null,
|
selectedPeriodId: string | null | undefined,
|
||||||
selectedGroupKey: GroupKey | null,
|
selectedGroupKey: GroupKey | null | undefined,
|
||||||
flow: "outflows" | "inflows"
|
flow: "outflows" | "inflows"
|
||||||
): LatestItem[] {
|
): LatestItem[] {
|
||||||
const txns = extractTransactions(reportData, selectedPeriodId, selectedGroupKey);
|
const txns = extractFilteredTransactions(reportData, selectedPeriodId, selectedGroupKey);
|
||||||
|
|
||||||
return txns
|
return txns
|
||||||
.sort(
|
.sort(
|
||||||
|
|||||||
@@ -1,27 +1,19 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { ReportData, GroupKey } from "../../features/report";
|
import { ComponentProps } from "../report.props";
|
||||||
import { buildLatestItems } from "./LatestItems.adapter";
|
import { buildLatestItems } from "./LatestItems.adapter";
|
||||||
import LatestItemsView from "./LatestItems.view";
|
import LatestItemsView from "./LatestItems.view";
|
||||||
|
|
||||||
type Props = {
|
type Props = ComponentProps;
|
||||||
reportData: ReportData;
|
|
||||||
flow: "outflows" | "inflows";
|
|
||||||
header: string;
|
|
||||||
selectedPeriodId: string | null;
|
|
||||||
selectedGroupKey?: GroupKey | null;
|
|
||||||
accentColor: string;
|
|
||||||
isFetching?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function LatestItems({
|
export default function LatestItems({
|
||||||
reportData,
|
reportData,
|
||||||
flow,
|
state,
|
||||||
|
stateSetters,
|
||||||
header,
|
header,
|
||||||
selectedPeriodId,
|
accentColor = "",
|
||||||
selectedGroupKey = null,
|
|
||||||
accentColor,
|
|
||||||
isFetching,
|
isFetching,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const { flow, selectedPeriodId, selectedGroupKey } = state;
|
||||||
const [visibleCount, setVisibleCount] = React.useState(5);
|
const [visibleCount, setVisibleCount] = React.useState(5);
|
||||||
|
|
||||||
const allItems = React.useMemo(() => {
|
const allItems = React.useMemo(() => {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { mergeBucketPeriods, periodIdToKey } from "../report.helpers";
|
|
||||||
import { GroupKey, ReportData } from "../../features/report";
|
import { GroupKey, ReportData } from "../../features/report";
|
||||||
|
import {
|
||||||
|
extractFilteredTransactions,
|
||||||
|
aggregateTransactions,
|
||||||
|
} from "../report.helpers";
|
||||||
|
|
||||||
export interface PayeeItem {
|
export interface PayeeItem {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -12,54 +15,17 @@ export function extractTopPayees(
|
|||||||
selectedPeriodId?: string | null,
|
selectedPeriodId?: string | null,
|
||||||
selectedGroupKey?: GroupKey | null
|
selectedGroupKey?: GroupKey | null
|
||||||
): { items: PayeeItem[]; total: number } {
|
): { items: PayeeItem[]; total: number } {
|
||||||
const payeeMap = new Map<string, number>();
|
const txns = extractFilteredTransactions(reportData, selectedPeriodId, selectedGroupKey);
|
||||||
|
|
||||||
let targetPeriods = [];
|
const { items, total } = aggregateTransactions(txns, (txn) => {
|
||||||
|
|
||||||
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) {
|
if (txn.payee && txn.payee.name) {
|
||||||
const current = payeeMap.get(txn.payee.name) || 0;
|
return [txn.payee.name];
|
||||||
payeeMap.set(txn.payee.name, current + txn.amount);
|
|
||||||
}
|
}
|
||||||
}
|
return [];
|
||||||
}
|
});
|
||||||
|
|
||||||
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 {
|
return {
|
||||||
items: items.slice(0, 4), // Top 4
|
items,
|
||||||
total,
|
total,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,24 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Box, Paper, Typography } from "@mui/material";
|
import { Box, Paper, Typography } from "@mui/material";
|
||||||
import { ReportData, GroupKey } from "../../features/report";
|
import { ComponentProps } from "../report.props";
|
||||||
import ProgressCard from "./ProgressCard";
|
import ProgressCard from "./ProgressCard";
|
||||||
import { extractTopPayees } from "./TopPayees.adapter";
|
import { extractTopPayees } from "./TopPayees.adapter";
|
||||||
|
|
||||||
type Props = {
|
interface Props extends ComponentProps {
|
||||||
reportData: ReportData;
|
|
||||||
flow: "outflows" | "inflows";
|
|
||||||
header: string;
|
|
||||||
selectedPeriodId?: string | null;
|
|
||||||
selectedGroupKey?: GroupKey | null;
|
|
||||||
setSelectedGroupKey?: (key: GroupKey | null) => void;
|
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
isFetching?: boolean;
|
}
|
||||||
};
|
|
||||||
|
|
||||||
export default function TopPayees({
|
export default function TopPayees({
|
||||||
reportData,
|
reportData,
|
||||||
flow,
|
state,
|
||||||
|
stateSetters,
|
||||||
header,
|
header,
|
||||||
selectedPeriodId,
|
|
||||||
selectedGroupKey,
|
|
||||||
setSelectedGroupKey,
|
|
||||||
compact = true,
|
compact = true,
|
||||||
isFetching,
|
isFetching,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const { flow, selectedPeriodId, selectedGroupKey } = state;
|
||||||
|
const { setSelectedGroupKey } = stateSetters;
|
||||||
|
|
||||||
const { items, total } = React.useMemo(() => {
|
const { items, total } = React.useMemo(() => {
|
||||||
return extractTopPayees(reportData, flow, selectedPeriodId, selectedGroupKey);
|
return extractTopPayees(reportData, flow, selectedPeriodId, selectedGroupKey);
|
||||||
}, [reportData, flow, selectedPeriodId, selectedGroupKey]);
|
}, [reportData, flow, selectedPeriodId, selectedGroupKey]);
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { ReportData } from "../../features/report";
|
import { ReportData, GroupKey } from "../../features/report";
|
||||||
import {
|
import {
|
||||||
mergeBucketPeriods,
|
extractFilteredTransactions,
|
||||||
periodIdToKey,
|
aggregateTransactions,
|
||||||
} 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;
|
||||||
@@ -17,55 +15,17 @@ export function extractTopTags(
|
|||||||
selectedPeriodId?: string | null,
|
selectedPeriodId?: string | null,
|
||||||
selectedGroupKey?: GroupKey | null
|
selectedGroupKey?: GroupKey | null
|
||||||
): { items: TagItem[]; total: number } {
|
): { items: TagItem[]; total: number } {
|
||||||
const tagMap = new Map<string, number>();
|
const txns = extractFilteredTransactions(reportData, selectedPeriodId, selectedGroupKey);
|
||||||
|
|
||||||
let periodKey: ReturnType<typeof periodIdToKey> = "all";
|
const { items, total } = aggregateTransactions(txns, (txn) => {
|
||||||
if (selectedPeriodId) {
|
|
||||||
periodKey = periodIdToKey(selectedPeriodId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const periods = mergeBucketPeriods(reportData.buckets, periodKey);
|
|
||||||
|
|
||||||
let period = periods[0];
|
|
||||||
if (selectedPeriodId) {
|
|
||||||
period = periods.find(p => p.id === selectedPeriodId) || period;
|
|
||||||
} else if (periods.length > 0) {
|
|
||||||
period = periods.reduce((latest, p) =>
|
|
||||||
new Date(p.start).getTime() > new Date(latest.start).getTime()
|
|
||||||
? p
|
|
||||||
: latest
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (period && period.metric && 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) {
|
return txn.tags.map((t) => (typeof t === "string" ? t : t.name));
|
||||||
const tagName = typeof tagObj === "string" ? tagObj : tagObj.name;
|
|
||||||
tagMap.set(tagName, (tagMap.get(tagName) || 0) + txn.amount);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
tagMap.set("Untagged", (tagMap.get("Untagged") || 0) + txn.amount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return ["Untagged"];
|
||||||
|
});
|
||||||
|
|
||||||
const arr = Array.from(tagMap.entries()).map(([tag, amount]) => ({
|
return {
|
||||||
tag,
|
items: items.map((item) => ({ tag: item.name, amount: item.amount })),
|
||||||
amount,
|
total,
|
||||||
}));
|
};
|
||||||
|
|
||||||
arr.sort((a, b) => b.amount - a.amount);
|
|
||||||
|
|
||||||
const top = arr.slice(0, 4);
|
|
||||||
const total = top.reduce((sum, t) => sum + t.amount, 0);
|
|
||||||
|
|
||||||
return { items: top, total };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,24 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Box, Paper, Typography } from "@mui/material";
|
import { Box, Paper, Typography } from "@mui/material";
|
||||||
import { ReportData, GroupKey } from "../../features/report";
|
import { ComponentProps } from "../report.props";
|
||||||
import ProgressCard from "./ProgressCard";
|
import ProgressCard from "./ProgressCard";
|
||||||
import { extractTopTags } from "./TopTags.adapter";
|
import { extractTopTags } from "./TopTags.adapter";
|
||||||
|
|
||||||
type Props = {
|
interface Props extends ComponentProps {
|
||||||
reportData: ReportData;
|
|
||||||
flow: "outflows" | "inflows";
|
|
||||||
header: string;
|
|
||||||
selectedPeriodId?: string | null;
|
|
||||||
selectedGroupKey?: GroupKey | null;
|
|
||||||
setSelectedGroupKey?: (key: GroupKey | null) => void;
|
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
isFetching?: boolean;
|
}
|
||||||
};
|
|
||||||
|
|
||||||
export default function TopTags({
|
export default function TopTags({
|
||||||
reportData,
|
reportData,
|
||||||
flow,
|
state,
|
||||||
|
stateSetters,
|
||||||
header,
|
header,
|
||||||
selectedPeriodId,
|
|
||||||
selectedGroupKey,
|
|
||||||
setSelectedGroupKey,
|
|
||||||
compact = true,
|
compact = true,
|
||||||
isFetching,
|
isFetching,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const { flow, selectedPeriodId, selectedGroupKey } = state;
|
||||||
|
const { setSelectedGroupKey } = stateSetters;
|
||||||
|
|
||||||
const { items, total } = React.useMemo(() => {
|
const { items, total } = React.useMemo(() => {
|
||||||
return extractTopTags(reportData, flow, selectedPeriodId, selectedGroupKey);
|
return extractTopTags(reportData, flow, selectedPeriodId, selectedGroupKey);
|
||||||
}, [reportData, flow, selectedPeriodId, selectedGroupKey]);
|
}, [reportData, flow, selectedPeriodId, selectedGroupKey]);
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import {
|
|||||||
ReportBucket,
|
ReportBucket,
|
||||||
GroupKey,
|
GroupKey,
|
||||||
PeriodType,
|
PeriodType,
|
||||||
|
ReportData,
|
||||||
|
Transaction,
|
||||||
} from "../features/report";
|
} from "../features/report";
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────
|
||||||
@@ -140,3 +142,89 @@ export function filterBuckets(
|
|||||||
if (!selectedGroupKey) return buckets;
|
if (!selectedGroupKey) return buckets;
|
||||||
return buckets.filter((b) => matchesGroupKey(b, selectedGroupKey));
|
return buckets.filter((b) => matchesGroupKey(b, selectedGroupKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function extractFilteredTransactions(
|
||||||
|
reportData: ReportData,
|
||||||
|
selectedPeriodId: string | null | undefined,
|
||||||
|
selectedGroupKey: GroupKey | null | undefined
|
||||||
|
): Transaction[] {
|
||||||
|
let txns: Transaction[] = [];
|
||||||
|
|
||||||
|
if (selectedPeriodId) {
|
||||||
|
const key = periodIdToKey(selectedPeriodId);
|
||||||
|
const periods = mergeBucketPeriods(reportData.buckets, key);
|
||||||
|
const selected = periods.find((p) => p.id === selectedPeriodId);
|
||||||
|
txns = selected?.metric.transactions || [];
|
||||||
|
} else {
|
||||||
|
const periods = mergeBucketPeriods(reportData.buckets, "all");
|
||||||
|
if (periods.length > 0) {
|
||||||
|
const period = periods.reduce((latest, p) =>
|
||||||
|
new Date(p.start).getTime() > new Date(latest.start).getTime()
|
||||||
|
? p
|
||||||
|
: latest
|
||||||
|
, periods[0]);
|
||||||
|
txns = period?.metric.transactions || [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedGroupKey) {
|
||||||
|
txns = txns.filter((txn) => {
|
||||||
|
let match = true;
|
||||||
|
if (selectedGroupKey.tags && selectedGroupKey.tags.length > 0) {
|
||||||
|
if (!txn.tags) {
|
||||||
|
match = false;
|
||||||
|
} else {
|
||||||
|
const txnTags = txn.tags.map((t: any) =>
|
||||||
|
typeof t === "string" ? t : t.name
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!selectedGroupKey.tags.every((selectedTag) =>
|
||||||
|
txnTags.includes(selectedTag)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
match = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (match && selectedGroupKey.payee && selectedGroupKey.payee.length > 0) {
|
||||||
|
if (!txn.payee || !txn.payee.name) {
|
||||||
|
match = false;
|
||||||
|
} else {
|
||||||
|
if (!selectedGroupKey.payee.includes(txn.payee.name)) {
|
||||||
|
match = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return match;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return txns;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aggregateTransactions(
|
||||||
|
transactions: Transaction[],
|
||||||
|
keyExtractor: (txn: Transaction) => string[],
|
||||||
|
limit = 4
|
||||||
|
): { items: { name: string; amount: number }[]; total: number } {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const txn of transactions) {
|
||||||
|
const keys = keyExtractor(txn);
|
||||||
|
for (const key of keys) {
|
||||||
|
map.set(key, (map.get(key) || 0) + txn.amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = Array.from(map.entries()).map(([name, amount]) => ({
|
||||||
|
name,
|
||||||
|
amount,
|
||||||
|
}));
|
||||||
|
|
||||||
|
items.sort((a, b) => b.amount - a.amount);
|
||||||
|
|
||||||
|
const top = items.slice(0, limit);
|
||||||
|
const total = top.reduce((sum, item) => sum + item.amount, 0);
|
||||||
|
|
||||||
|
return { items: top, total };
|
||||||
|
}
|
||||||
|
|||||||
17
src/components/report.props.ts
Normal file
17
src/components/report.props.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { ReportData } from "../features/report";
|
||||||
|
import { DashboardState, DashboardStateSetters } from "./Dashboard";
|
||||||
|
|
||||||
|
export interface ComponentProps {
|
||||||
|
reportData: ReportData;
|
||||||
|
state: DashboardState;
|
||||||
|
stateSetters: DashboardStateSetters;
|
||||||
|
isFetching?: boolean;
|
||||||
|
header: string;
|
||||||
|
summary?: string;
|
||||||
|
accentColor?: string;
|
||||||
|
colorScheme?: {
|
||||||
|
primary: string;
|
||||||
|
light: string;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user