top tag selection for further drill down
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {
|
import {
|
||||||
ReportData
|
ReportData,
|
||||||
|
GroupKey,
|
||||||
} from "../../features/report";
|
} from "../../features/report";
|
||||||
|
|
||||||
export type DashboardMode = "expense" | "income";
|
export type DashboardMode = "expense" | "income";
|
||||||
@@ -11,6 +12,7 @@ export interface DashboardState {
|
|||||||
mode: DashboardMode;
|
mode: DashboardMode;
|
||||||
periodType: DashboardPeriodType;
|
periodType: DashboardPeriodType;
|
||||||
selectedPeriodId: DashboardSelectedPeriodId;
|
selectedPeriodId: DashboardSelectedPeriodId;
|
||||||
|
selectedGroupKey: GroupKey | null;
|
||||||
comparison: boolean;
|
comparison: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export default function Dashboard(props: DashboardProps) {
|
|||||||
mode: "expense",
|
mode: "expense",
|
||||||
periodType: "rolling",
|
periodType: "rolling",
|
||||||
selectedPeriodId: null,
|
selectedPeriodId: null,
|
||||||
|
selectedGroupKey: null,
|
||||||
comparison: false,
|
comparison: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -35,6 +36,10 @@ export default function Dashboard(props: DashboardProps) {
|
|||||||
setState(prev => ({ ...prev, selectedPeriodId }));
|
setState(prev => ({ ...prev, selectedPeriodId }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setSelectedGroupKey = (groupKey: typeof state.selectedGroupKey) => {
|
||||||
|
setState(prev => ({ ...prev, selectedGroupKey: groupKey }));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardView
|
<DashboardView
|
||||||
{...props}
|
{...props}
|
||||||
@@ -44,6 +49,7 @@ export default function Dashboard(props: DashboardProps) {
|
|||||||
togglePeriodType={togglePeriodType}
|
togglePeriodType={togglePeriodType}
|
||||||
toggleComparison={toggleComparison}
|
toggleComparison={toggleComparison}
|
||||||
setSelectedPeriodId={setSelectedPeriodId}
|
setSelectedPeriodId={setSelectedPeriodId}
|
||||||
|
setSelectedGroupKey={setSelectedGroupKey}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
ToggleButtonGroup
|
ToggleButtonGroup
|
||||||
} 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 { DashboardProps, DashboardState } from "./Dashboard.models";
|
import { DashboardProps, DashboardState } from "./Dashboard.models";
|
||||||
|
|
||||||
interface ViewProps extends DashboardProps {
|
interface ViewProps extends DashboardProps {
|
||||||
@@ -16,6 +17,7 @@ interface ViewProps extends DashboardProps {
|
|||||||
toggleMode: () => void;
|
toggleMode: () => void;
|
||||||
togglePeriodType: () => void;
|
togglePeriodType: () => void;
|
||||||
setSelectedPeriodId: (id: string | null) => void;
|
setSelectedPeriodId: (id: string | null) => void;
|
||||||
|
setSelectedGroupKey: (groupKey: GroupKey | null) => void;
|
||||||
toggleComparison: () => void;
|
toggleComparison: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,10 +30,11 @@ export default function DashboardView({
|
|||||||
togglePeriodType,
|
togglePeriodType,
|
||||||
toggleComparison,
|
toggleComparison,
|
||||||
setSelectedPeriodId,
|
setSelectedPeriodId,
|
||||||
|
setSelectedGroupKey,
|
||||||
}: ViewProps) {
|
}: ViewProps) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const themeMode = theme.palette.mode;
|
const themeMode = theme.palette.mode;
|
||||||
const { mode, periodType, comparison, selectedPeriodId } = state;
|
const { mode, periodType, comparison, selectedPeriodId, selectedGroupKey } = state;
|
||||||
|
|
||||||
// Resolve colors with fallbacks
|
// Resolve colors with fallbacks
|
||||||
const colors = React.useMemo(() => {
|
const colors = React.useMemo(() => {
|
||||||
@@ -120,10 +123,12 @@ export default function DashboardView({
|
|||||||
periodType={periodType}
|
periodType={periodType}
|
||||||
comparison={comparison}
|
comparison={comparison}
|
||||||
selectedPeriodId={selectedPeriodId}
|
selectedPeriodId={selectedPeriodId}
|
||||||
|
selectedGroupKey={selectedGroupKey}
|
||||||
|
|
||||||
togglePeriodType={togglePeriodType}
|
togglePeriodType={togglePeriodType}
|
||||||
toggleComparison={toggleComparison}
|
toggleComparison={toggleComparison}
|
||||||
setSelectedPeriodId={setSelectedPeriodId}
|
setSelectedPeriodId={setSelectedPeriodId}
|
||||||
|
setSelectedGroupKey={setSelectedGroupKey}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { ReportData, Transaction } from "../../features/report";
|
import { ReportData, Transaction, GroupKey } from "../../features/report";
|
||||||
import {
|
import {
|
||||||
mergeBucketPeriods,
|
mergeBucketPeriods,
|
||||||
periodIdToKey,
|
periodIdToKey,
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
|
filterBuckets,
|
||||||
} from "../report.helpers";
|
} from "../report.helpers";
|
||||||
import { LatestItem } from "./LatestItems.models";
|
import { LatestItem } from "./LatestItems.models";
|
||||||
|
|
||||||
@@ -11,11 +12,13 @@ import { LatestItem } from "./LatestItems.models";
|
|||||||
function extractTransactions(
|
function extractTransactions(
|
||||||
reportData: ReportData,
|
reportData: ReportData,
|
||||||
selectedPeriodId: string | null,
|
selectedPeriodId: string | null,
|
||||||
|
selectedGroupKey: GroupKey | null,
|
||||||
mode: "expense" | "income"
|
mode: "expense" | "income"
|
||||||
): Transaction[] {
|
): Transaction[] {
|
||||||
|
const buckets = filterBuckets(reportData.buckets, selectedGroupKey);
|
||||||
if (selectedPeriodId) {
|
if (selectedPeriodId) {
|
||||||
const key = periodIdToKey(selectedPeriodId);
|
const key = periodIdToKey(selectedPeriodId);
|
||||||
const periods = mergeBucketPeriods(reportData.buckets, key);
|
const periods = mergeBucketPeriods(buckets, key);
|
||||||
const selected = periods.find((p) => p.id === selectedPeriodId);
|
const selected = periods.find((p) => p.id === selectedPeriodId);
|
||||||
|
|
||||||
if (!selected) return [];
|
if (!selected) return [];
|
||||||
@@ -25,7 +28,7 @@ function extractTransactions(
|
|||||||
: (selected.incomes.transactions || []);
|
: (selected.incomes.transactions || []);
|
||||||
}
|
}
|
||||||
|
|
||||||
const periods = mergeBucketPeriods(reportData.buckets, "full");
|
const periods = mergeBucketPeriods(buckets, "full");
|
||||||
|
|
||||||
if (!periods.length) return [];
|
if (!periods.length) return [];
|
||||||
|
|
||||||
@@ -41,9 +44,10 @@ function extractTransactions(
|
|||||||
export function buildLatestItems(
|
export function buildLatestItems(
|
||||||
reportData: ReportData,
|
reportData: ReportData,
|
||||||
selectedPeriodId: string | null,
|
selectedPeriodId: string | null,
|
||||||
|
selectedGroupKey: GroupKey | null,
|
||||||
mode: "expense" | "income"
|
mode: "expense" | "income"
|
||||||
): LatestItem[] {
|
): LatestItem[] {
|
||||||
const txns = extractTransactions(reportData, selectedPeriodId, mode);
|
const txns = extractTransactions(reportData, selectedPeriodId, selectedGroupKey, mode);
|
||||||
|
|
||||||
return txns
|
return txns
|
||||||
.filter((t) => (mode === "expense" ? t.amount < 0 : t.amount >= 0))
|
.filter((t) => (mode === "expense" ? t.amount < 0 : t.amount >= 0))
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { ReportData } from "../../features/report";
|
import { ReportData, GroupKey } from "../../features/report";
|
||||||
import { buildLatestItems } from "./LatestItems.adapter";
|
import { buildLatestItems } from "./LatestItems.adapter";
|
||||||
import LatestItemsView from "./LatestItems.view";
|
import LatestItemsView from "./LatestItems.view";
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ type Props = {
|
|||||||
reportData: ReportData;
|
reportData: ReportData;
|
||||||
mode: "expense" | "income";
|
mode: "expense" | "income";
|
||||||
selectedPeriodId: string | null;
|
selectedPeriodId: string | null;
|
||||||
|
selectedGroupKey?: GroupKey | null;
|
||||||
accentColor: string;
|
accentColor: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -14,22 +15,23 @@ export default function LatestItems({
|
|||||||
reportData,
|
reportData,
|
||||||
mode,
|
mode,
|
||||||
selectedPeriodId,
|
selectedPeriodId,
|
||||||
|
selectedGroupKey = null,
|
||||||
accentColor,
|
accentColor,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [visibleCount, setVisibleCount] = React.useState(5);
|
const [visibleCount, setVisibleCount] = React.useState(5);
|
||||||
|
|
||||||
const allItems = React.useMemo(() => {
|
const allItems = React.useMemo(() => {
|
||||||
return buildLatestItems(reportData, selectedPeriodId, mode);
|
return buildLatestItems(reportData, selectedPeriodId, selectedGroupKey, mode);
|
||||||
}, [reportData, selectedPeriodId, mode]);
|
}, [reportData, selectedPeriodId, selectedGroupKey, mode]);
|
||||||
|
|
||||||
const isPeriodSelected = Boolean(selectedPeriodId);
|
const hasSelection = Boolean(selectedPeriodId) || Boolean(selectedGroupKey);
|
||||||
|
|
||||||
const visibleItems = React.useMemo(() => {
|
const visibleItems = React.useMemo(() => {
|
||||||
if (!isPeriodSelected) return allItems.slice(0, 5);
|
if (!hasSelection) return allItems.slice(0, 5);
|
||||||
return allItems.slice(0, visibleCount);
|
return allItems.slice(0, visibleCount);
|
||||||
}, [allItems, isPeriodSelected, visibleCount]);
|
}, [allItems, hasSelection, visibleCount]);
|
||||||
|
|
||||||
const canExpand = isPeriodSelected && visibleCount < allItems.length;
|
const canExpand = hasSelection && visibleCount < allItems.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LatestItemsView
|
<LatestItemsView
|
||||||
|
|||||||
@@ -5,4 +5,6 @@ export interface ProgressCardProps {
|
|||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
colorTheme?: "primary" | "secondary" | "error" | "info" | "success" | "warning";
|
colorTheme?: "primary" | "secondary" | "error" | "info" | "success" | "warning";
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
selected?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export default function ProgressCard(props: ProgressCardProps) {
|
|||||||
formattedProgress={formattedProgress}
|
formattedProgress={formattedProgress}
|
||||||
formattedTotal={formattedTotal}
|
formattedTotal={formattedTotal}
|
||||||
compact={compact}
|
compact={compact}
|
||||||
|
selected={props.selected}
|
||||||
|
onClick={props.onClick}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ interface ViewProps extends ProgressCardProps {
|
|||||||
percentage: number;
|
percentage: number;
|
||||||
formattedProgress: string;
|
formattedProgress: string;
|
||||||
formattedTotal: string;
|
formattedTotal: string;
|
||||||
|
selected?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProgressCardView({
|
export default function ProgressCardView({
|
||||||
@@ -23,6 +25,8 @@ export default function ProgressCardView({
|
|||||||
formattedProgress,
|
formattedProgress,
|
||||||
formattedTotal,
|
formattedTotal,
|
||||||
compact = false,
|
compact = false,
|
||||||
|
selected,
|
||||||
|
onClick,
|
||||||
}: ViewProps) {
|
}: ViewProps) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isDark = theme.palette.mode === "dark";
|
const isDark = theme.palette.mode === "dark";
|
||||||
@@ -30,10 +34,14 @@ export default function ProgressCardView({
|
|||||||
return (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
elevation={compact ? 2 : 4}
|
elevation={compact ? 2 : 4}
|
||||||
|
onClick={onClick}
|
||||||
sx={{
|
sx={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
p: compact ? { xs: 2.5, md: 3 } : { xs: 3, md: 4 },
|
p: compact ? { xs: 2.5, md: 3 } : { xs: 3, md: 4 },
|
||||||
borderRadius: compact ? 3 : 4,
|
borderRadius: compact ? 3 : 4,
|
||||||
|
cursor: onClick ? "pointer" : "default",
|
||||||
|
transform: selected ? "scale(1.02)" : "scale(1)",
|
||||||
|
transition: "transform 0.2s ease, box-shadow 0.2s ease",
|
||||||
background: (theme) => {
|
background: (theme) => {
|
||||||
const baseColor = theme.palette[colorTheme]?.main || theme.palette.primary.main;
|
const baseColor = theme.palette[colorTheme]?.main || theme.palette.primary.main;
|
||||||
const lightColor = theme.palette[colorTheme]?.light || theme.palette.primary.light;
|
const lightColor = theme.palette[colorTheme]?.light || theme.palette.primary.light;
|
||||||
@@ -48,13 +56,19 @@ export default function ProgressCardView({
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
border: isDark ? "1px solid rgba(255,255,255,0.1)" : "none",
|
border: selected
|
||||||
boxShadow: (theme) =>
|
? `2px solid #fff`
|
||||||
`0 ${compact ? 6 : 12}px ${compact ? 12 : 24}px -10px ${
|
: isDark ? "1px solid rgba(255,255,255,0.1)" : "none",
|
||||||
|
boxShadow: (theme) => {
|
||||||
|
const baseShadow = `0 ${compact ? 6 : 12}px ${compact ? 12 : 24}px -10px ${
|
||||||
isDark
|
isDark
|
||||||
? "rgba(0,0,0,0.5)"
|
? "rgba(0,0,0,0.5)"
|
||||||
: theme.palette[colorTheme]?.main || theme.palette.primary.main
|
: theme.palette[colorTheme]?.main || theme.palette.primary.main
|
||||||
}`,
|
}`;
|
||||||
|
return selected
|
||||||
|
? `${baseShadow}, 0 0 0 2px ${theme.palette.background.paper}, 0 0 0 4px ${theme.palette[colorTheme]?.main || theme.palette.primary.main}`
|
||||||
|
: baseShadow;
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography
|
<Typography
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Box } from "@mui/material";
|
import { Box } from "@mui/material";
|
||||||
import { ReportData } from "../../features/report";
|
import { ReportData, GroupKey } from "../../features/report";
|
||||||
import ProgressCard from "./ProgressCard";
|
import ProgressCard from "./ProgressCard";
|
||||||
import { extractTopTags } from "./TopTags.adapter";
|
import { extractTopTags } from "./TopTags.adapter";
|
||||||
|
|
||||||
@@ -8,6 +8,8 @@ type Props = {
|
|||||||
reportData: ReportData;
|
reportData: ReportData;
|
||||||
mode: "expense" | "income";
|
mode: "expense" | "income";
|
||||||
selectedPeriodId?: string | null;
|
selectedPeriodId?: string | null;
|
||||||
|
selectedGroupKey?: GroupKey | null;
|
||||||
|
setSelectedGroupKey?: (key: GroupKey | null) => void;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -15,6 +17,8 @@ export default function TopTags({
|
|||||||
reportData,
|
reportData,
|
||||||
mode,
|
mode,
|
||||||
selectedPeriodId,
|
selectedPeriodId,
|
||||||
|
selectedGroupKey,
|
||||||
|
setSelectedGroupKey,
|
||||||
compact = true,
|
compact = true,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { items, total } = React.useMemo(() => {
|
const { items, total } = React.useMemo(() => {
|
||||||
@@ -33,16 +37,25 @@ export default function TopTags({
|
|||||||
gap: 2,
|
gap: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{items.map((item) => (
|
{items.map((item) => {
|
||||||
<ProgressCard
|
const isSelected = selectedGroupKey?.tags?.includes(item.tag);
|
||||||
key={item.tag}
|
return (
|
||||||
header={item.tag}
|
<ProgressCard
|
||||||
progressAmount={item.amount}
|
key={item.tag}
|
||||||
totalAmount={total}
|
header={item.tag}
|
||||||
compact={compact}
|
progressAmount={item.amount}
|
||||||
colorTheme={mode === "expense" ? "error" : "success"}
|
totalAmount={total}
|
||||||
/>
|
compact={compact}
|
||||||
))}
|
colorTheme={mode === "expense" ? "error" : "success"}
|
||||||
|
selected={isSelected}
|
||||||
|
onClick={() => {
|
||||||
|
if (setSelectedGroupKey) {
|
||||||
|
setSelectedGroupKey(isSelected ? null : { tags: [item.tag] });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
ReportPeriod,
|
ReportPeriod,
|
||||||
ReportBucket,
|
ReportBucket,
|
||||||
|
GroupKey,
|
||||||
} from "../features/report";
|
} from "../features/report";
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────
|
||||||
@@ -112,3 +113,35 @@ export const getPercentage = (progressAmount: number, totalAmount: number) => {
|
|||||||
if (!totalAmount) return 0;
|
if (!totalAmount) return 0;
|
||||||
return Math.min(100, Math.max(0, (progressAmount / totalAmount) * 100));
|
return Math.min(100, Math.max(0, (progressAmount / totalAmount) * 100));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── Group filtering ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a bucket's group_key matches the selected GroupKey.
|
||||||
|
* Every dimension present in `selected` must exist in the bucket
|
||||||
|
* and contain all the selected values.
|
||||||
|
*/
|
||||||
|
export function matchesGroupKey(
|
||||||
|
bucket: ReportBucket,
|
||||||
|
selected: GroupKey
|
||||||
|
): boolean {
|
||||||
|
for (const [dim, values] of Object.entries(selected)) {
|
||||||
|
const bucketValues = bucket.group_key[dim as keyof GroupKey];
|
||||||
|
if (!bucketValues) return false;
|
||||||
|
if (!(values as string[]).every((v) => bucketValues.includes(v)))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return only buckets matching the selected group key,
|
||||||
|
* or all buckets if no selection.
|
||||||
|
*/
|
||||||
|
export function filterBuckets(
|
||||||
|
buckets: ReportBucket[],
|
||||||
|
selectedGroupKey: GroupKey | null
|
||||||
|
): ReportBucket[] {
|
||||||
|
if (!selectedGroupKey) return buckets;
|
||||||
|
return buckets.filter((b) => matchesGroupKey(b, selectedGroupKey));
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export type {
|
|||||||
ReportData,
|
ReportData,
|
||||||
ReportBucket,
|
ReportBucket,
|
||||||
ReportPeriod,
|
ReportPeriod,
|
||||||
|
GroupKey,
|
||||||
} from './report.models'
|
} from './report.models'
|
||||||
export {
|
export {
|
||||||
prepareReport
|
prepareReport
|
||||||
|
|||||||
Reference in New Issue
Block a user