common component props

This commit is contained in:
2026-05-18 14:18:36 +05:30
parent 8bea3d06f6
commit ceaeca70cc
14 changed files with 191 additions and 259 deletions

View File

@@ -16,6 +16,13 @@ export interface DashboardState {
comparison: boolean;
}
export interface DashboardStateSetters {
setSelectedPeriodId: (id: DashboardSelectedPeriodId) => void;
setSelectedGroupKey: (groupKey: GroupKey | null) => void;
togglePeriodType: () => void;
toggleComparison: () => void;
}
export interface DashboardSection {
id: string;
title?: string;

View File

@@ -1,6 +1,6 @@
import * as React from "react";
import DashboardView from "./Dashboard.view";
import { DashboardProps, DashboardState } from "./Dashboard.models";
import { DashboardProps, DashboardState, DashboardStateSetters } from "./Dashboard.models";
export default function Dashboard(props: DashboardProps) {
const [state, setState] = React.useState<DashboardState>({
@@ -49,16 +49,20 @@ export default function Dashboard(props: DashboardProps) {
setState(prev => ({ ...prev, selectedGroupKey: groupKey }));
};
const stateSetters: DashboardStateSetters = {
togglePeriodType,
toggleComparison,
setSelectedPeriodId,
setSelectedGroupKey,
};
return (
<DashboardView
{...props}
state={state}
setState={setState}
toggleFlow={toggleFlow}
togglePeriodType={togglePeriodType}
toggleComparison={toggleComparison}
setSelectedPeriodId={setSelectedPeriodId}
setSelectedGroupKey={setSelectedGroupKey}
stateSetters={stateSetters}
/>
);
}

View File

@@ -10,16 +10,13 @@ import {
} from "@mui/material";
import { useTheme, alpha } from "@mui/material/styles";
import { GroupKey } from "../../features/report";
import { DashboardProps, DashboardState } from "./Dashboard.models";
import { DashboardProps, DashboardState, DashboardStateSetters } from "./Dashboard.models";
interface ViewProps extends DashboardProps {
state: DashboardState;
setState: React.Dispatch<React.SetStateAction<DashboardState>>;
toggleFlow: (event: React.MouseEvent<HTMLElement>, newFlow: "outflows" | "inflows" | null) => void;
togglePeriodType: () => void;
setSelectedPeriodId: (id: string | null) => void;
setSelectedGroupKey: (groupKey: GroupKey | null) => void;
toggleComparison: () => void;
stateSetters: DashboardStateSetters;
}
export default function DashboardView({
@@ -28,14 +25,12 @@ export default function DashboardView({
state,
setState,
toggleFlow,
togglePeriodType,
toggleComparison,
setSelectedPeriodId,
setSelectedGroupKey,
stateSetters,
}: ViewProps) {
const theme = useTheme();
const themeMode = theme.palette.mode;
const { flow, periodType, comparison, selectedPeriodId, selectedGroupKey } = state;
const { flow, selectedGroupKey } = state;
const { setSelectedGroupKey } = stateSetters;
// Resolve colors with fallbacks
const colors = React.useMemo(() => {
@@ -121,17 +116,8 @@ export default function DashboardView({
colorScheme={colors}
// State management
flow={flow}
periodType={periodType}
comparison={comparison}
selectedPeriodId={selectedPeriodId}
selectedGroupKey={selectedGroupKey}
togglePeriodType={togglePeriodType}
toggleComparison={toggleComparison}
setSelectedPeriodId={setSelectedPeriodId}
setSelectedGroupKey={setSelectedGroupKey}
state={state}
stateSetters={stateSetters}
isFetching={arguments[0].isFetching}
/>
</Grid>

View File

@@ -1,9 +1,4 @@
import {
DashboardFlow,
DashboardPeriodType,
DashboardSelectedPeriodId
} from "../Dashboard";
import { ReportData } from "../../features/report";
import { ComponentProps } from "../report.props";
export interface _ChartDataPoint {
id: string;
@@ -16,27 +11,6 @@ export interface ChartDataPoint extends _ChartDataPoint {
compare?: _ChartDataPoint;
}
export interface HistoryChartProps {
header: string;
summary?: string;
export interface HistoryChartProps extends ComponentProps {
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;
}

View File

@@ -7,12 +7,13 @@ export default function HistoryChart(props: HistoryChartProps) {
const {
tabs,
reportData,
flow,
comparison,
selectedPeriodId,
setSelectedPeriodId
state,
stateSetters,
} = props;
const { flow, comparison, selectedPeriodId } = state;
const { setSelectedPeriodId } = stateSetters;
const [activeTab, setActiveTab] = React.useState<string>(tabs[0] || "");
const [startIndex, setStartIndex] = React.useState(0);

View File

@@ -35,14 +35,8 @@ export default function HistoryChartView(props: ViewProps) {
tabs,
colorScheme,
flow,
periodType,
selectedPeriodId,
comparison,
togglePeriodType,
setSelectedPeriodId,
toggleComparison,
state,
stateSetters,
activeTab,
setActiveTab,
@@ -55,6 +49,9 @@ export default function HistoryChartView(props: ViewProps) {
activeDataKey,
} = props;
const { flow, periodType, selectedPeriodId, comparison } = state;
const { togglePeriodType, setSelectedPeriodId, toggleComparison } = stateSetters;
const theme = useTheme();
const isDark = theme.palette.mode === "dark";

View File

@@ -1,67 +1,19 @@
import { ReportData, Transaction, GroupKey } from "../../features/report";
import { ReportData, GroupKey } from "../../features/report";
import {
mergeBucketPeriods,
periodIdToKey,
formatCurrency,
filterBuckets,
extractFilteredTransactions,
} from "../report.helpers";
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 ────────────────────────────────────────────
export function buildLatestItems(
reportData: ReportData,
selectedPeriodId: string | null,
selectedGroupKey: GroupKey | null,
selectedPeriodId: string | null | undefined,
selectedGroupKey: GroupKey | null | undefined,
flow: "outflows" | "inflows"
): LatestItem[] {
const txns = extractTransactions(reportData, selectedPeriodId, selectedGroupKey);
const txns = extractFilteredTransactions(reportData, selectedPeriodId, selectedGroupKey);
return txns
.sort(

View File

@@ -1,27 +1,19 @@
import * as React from "react";
import { ReportData, GroupKey } from "../../features/report";
import { ComponentProps } from "../report.props";
import { buildLatestItems } from "./LatestItems.adapter";
import LatestItemsView from "./LatestItems.view";
type Props = {
reportData: ReportData;
flow: "outflows" | "inflows";
header: string;
selectedPeriodId: string | null;
selectedGroupKey?: GroupKey | null;
accentColor: string;
isFetching?: boolean;
};
type Props = ComponentProps;
export default function LatestItems({
reportData,
flow,
state,
stateSetters,
header,
selectedPeriodId,
selectedGroupKey = null,
accentColor,
accentColor = "",
isFetching,
}: Props) {
const { flow, selectedPeriodId, selectedGroupKey } = state;
const [visibleCount, setVisibleCount] = React.useState(5);
const allItems = React.useMemo(() => {

View File

@@ -1,5 +1,8 @@
import { mergeBucketPeriods, periodIdToKey } from "../report.helpers";
import { GroupKey, ReportData } from "../../features/report";
import {
extractFilteredTransactions,
aggregateTransactions,
} from "../report.helpers";
export interface PayeeItem {
name: string;
@@ -12,54 +15,17 @@ export function extractTopPayees(
selectedPeriodId?: string | null,
selectedGroupKey?: GroupKey | null
): { items: PayeeItem[]; total: number } {
const payeeMap = new Map<string, number>();
const txns = extractFilteredTransactions(reportData, selectedPeriodId, selectedGroupKey);
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);
const { items, total } = aggregateTransactions(txns, (txn) => {
if (txn.payee && txn.payee.name) {
return [txn.payee.name];
}
} 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 [];
});
return {
items: items.slice(0, 4), // Top 4
items,
total,
};
}

View File

@@ -1,30 +1,24 @@
import * as React from "react";
import { Box, Paper, Typography } from "@mui/material";
import { ReportData, GroupKey } from "../../features/report";
import { ComponentProps } from "../report.props";
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;
interface Props extends ComponentProps {
compact?: boolean;
isFetching?: boolean;
};
}
export default function TopPayees({
reportData,
flow,
state,
stateSetters,
header,
selectedPeriodId,
selectedGroupKey,
setSelectedGroupKey,
compact = true,
isFetching,
}: Props) {
const { flow, selectedPeriodId, selectedGroupKey } = state;
const { setSelectedGroupKey } = stateSetters;
const { items, total } = React.useMemo(() => {
return extractTopPayees(reportData, flow, selectedPeriodId, selectedGroupKey);
}, [reportData, flow, selectedPeriodId, selectedGroupKey]);

View File

@@ -1,11 +1,9 @@
import { ReportData } from "../../features/report";
import { ReportData, GroupKey } from "../../features/report";
import {
mergeBucketPeriods,
periodIdToKey,
extractFilteredTransactions,
aggregateTransactions,
} from "../report.helpers";
import { GroupKey } from "../../features/report";
export interface TagItem {
tag: string;
amount: number;
@@ -17,55 +15,17 @@ export function extractTopTags(
selectedPeriodId?: string | null,
selectedGroupKey?: GroupKey | null
): { items: TagItem[]; total: number } {
const tagMap = new Map<string, number>();
const txns = extractFilteredTransactions(reportData, selectedPeriodId, selectedGroupKey);
let periodKey: ReturnType<typeof periodIdToKey> = "all";
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)
);
const { items, total } = aggregateTransactions(txns, (txn) => {
if (txn.tags && txn.tags.length > 0) {
return txn.tags.map((t) => (typeof t === "string" ? t : t.name));
}
return ["Untagged"];
});
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;
tagMap.set(tagName, (tagMap.get(tagName) || 0) + txn.amount);
}
} else {
tagMap.set("Untagged", (tagMap.get("Untagged") || 0) + txn.amount);
}
}
}
const arr = Array.from(tagMap.entries()).map(([tag, amount]) => ({
tag,
amount,
}));
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 };
return {
items: items.map((item) => ({ tag: item.name, amount: item.amount })),
total,
};
}

View File

@@ -1,30 +1,24 @@
import * as React from "react";
import { Box, Paper, Typography } from "@mui/material";
import { ReportData, GroupKey } from "../../features/report";
import { ComponentProps } from "../report.props";
import ProgressCard from "./ProgressCard";
import { extractTopTags } from "./TopTags.adapter";
type Props = {
reportData: ReportData;
flow: "outflows" | "inflows";
header: string;
selectedPeriodId?: string | null;
selectedGroupKey?: GroupKey | null;
setSelectedGroupKey?: (key: GroupKey | null) => void;
interface Props extends ComponentProps {
compact?: boolean;
isFetching?: boolean;
};
}
export default function TopTags({
reportData,
flow,
state,
stateSetters,
header,
selectedPeriodId,
selectedGroupKey,
setSelectedGroupKey,
compact = true,
isFetching,
}: Props) {
const { flow, selectedPeriodId, selectedGroupKey } = state;
const { setSelectedGroupKey } = stateSetters;
const { items, total } = React.useMemo(() => {
return extractTopTags(reportData, flow, selectedPeriodId, selectedGroupKey);
}, [reportData, flow, selectedPeriodId, selectedGroupKey]);

View File

@@ -3,6 +3,8 @@ import {
ReportBucket,
GroupKey,
PeriodType,
ReportData,
Transaction,
} from "../features/report";
// ─── Types ────────────────────────────────────────────────────
@@ -140,3 +142,89 @@ export function filterBuckets(
if (!selectedGroupKey) return buckets;
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 };
}

View 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;
};
}