Private
Public Access
1
0

Add global multi-account filter to all reports with chart hover interaction

Move account selector to date range bar and apply filtering to all four
report types. Add bidirectional hover highlighting between spending donut
chart and category table. Fix donut data label readability with white text.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Catherine Renelle
2026-02-15 18:15:46 -05:00
parent 63a58b10c0
commit f3211b7ee7
5 changed files with 128 additions and 68 deletions
+12 -8
View File
@@ -2,23 +2,27 @@ import api from './api'
import type { SpendingByCategoryReport, IncomeVsExpenseReport, NetWorthReport, CashFlowReport } from '@/types'
export const reportsApi = {
spendingByCategory: (startDate: string, endDate: string, accountId?: string) =>
spendingByCategory: (startDate: string, endDate: string, accountIds?: string[]) =>
api.get<SpendingByCategoryReport>('/reports/spending-by-category', {
params: { startDate, endDate, accountId },
params: { startDate, endDate, accountIds },
paramsSerializer: { indexes: null },
}),
incomeVsExpense: (startDate: string, endDate: string) =>
incomeVsExpense: (startDate: string, endDate: string, accountIds?: string[]) =>
api.get<IncomeVsExpenseReport>('/reports/income-vs-expense', {
params: { startDate, endDate },
params: { startDate, endDate, accountIds },
paramsSerializer: { indexes: null },
}),
netWorth: (startDate: string, endDate: string) =>
netWorth: (startDate: string, endDate: string, accountIds?: string[]) =>
api.get<NetWorthReport>('/reports/net-worth', {
params: { startDate, endDate },
params: { startDate, endDate, accountIds },
paramsSerializer: { indexes: null },
}),
cashFlow: (startDate: string, endDate: string) =>
cashFlow: (startDate: string, endDate: string, accountIds?: string[]) =>
api.get<CashFlowReport>('/reports/cash-flow', {
params: { startDate, endDate },
params: { startDate, endDate, accountIds },
paramsSerializer: { indexes: null },
}),
}
+81 -31
View File
@@ -38,6 +38,22 @@
style="max-width: 180px"
/>
</v-col>
<v-spacer />
<v-col cols="auto">
<v-select
v-model="selectedAccountIds"
:items="accountsStore.accounts"
item-title="name"
item-value="id"
label="Accounts"
density="compact"
hide-details
clearable
multiple
chips
style="min-width: 250px; max-width: 350px"
/>
</v-col>
</v-row>
</v-card-text>
</v-card>
@@ -56,21 +72,7 @@
<v-window v-model="activeTab">
<v-window-item value="spending">
<v-card>
<v-card-title class="d-flex align-center">
<span>Spending by Category</span>
<v-spacer />
<v-select
v-model="selectedAccountId"
:items="accountOptions"
item-title="name"
item-value="id"
label="Account"
density="compact"
hide-details
clearable
style="max-width: 250px"
/>
</v-card-title>
<v-card-title>Spending by Category</v-card-title>
<v-card-text v-if="spending">
<v-row>
<v-col cols="12" md="5">
@@ -78,6 +80,7 @@
<p class="text-medium-emphasis mb-4">Total spending</p>
<apexchart
v-if="spending.categories.length > 0"
ref="spendingChartRef"
type="donut"
height="300"
:options="spendingChartOptions"
@@ -93,6 +96,7 @@
:items-per-page="-1"
hide-default-footer
:sort-by="[{ key: 'amount', order: 'desc' }]"
:row-props="spendingRowProps"
>
<template #item.amount="{ item }">{{ formatCurrency(item.amount) }}</template>
<template #item.percentage="{ item }">{{ item.percentage.toFixed(1) }}%</template>
@@ -266,19 +270,20 @@ const activeTab = ref('spending')
const preset = ref('thisMonth')
const customStart = ref('')
const customEnd = ref('')
const selectedAccountId = ref<string | undefined>(undefined)
const selectedAccountIds = ref<string[]>([])
const loading = ref(false)
const snackbar = ref(false)
const snackbarText = ref('')
const spendingChartRef = ref()
const highlightedCategory = ref<string | null>(null)
let selectedChartIndex = -1
const spending = ref<SpendingByCategoryReport | null>(null)
const incomeExpense = ref<IncomeVsExpenseReport | null>(null)
const netWorth = ref<NetWorthReport | null>(null)
const cashFlow = ref<CashFlowReport | null>(null)
const accountOptions = computed(() =>
[{ id: undefined as string | undefined, name: 'All Accounts' }, ...accountsStore.accounts.map(a => ({ id: a.id, name: a.name }))]
)
const dateRange = computed(() => {
const now = new Date()
@@ -332,10 +337,21 @@ const spendingChartSeries = computed(() =>
)
const spendingChartOptions = computed(() => ({
chart: { type: 'donut' as const },
chart: {
type: 'donut' as const,
events: {
dataPointMouseEnter: (_e: any, _ctx: any, config: any) => {
const labels = spending.value?.categories.map(c => c.categoryName) ?? []
highlightedCategory.value = labels[config.dataPointIndex] ?? null
},
dataPointMouseLeave: () => {
highlightedCategory.value = null
},
},
},
labels: spending.value?.categories.map(c => c.categoryName) ?? [],
legend: { position: 'bottom' as const, labels: { colors: labelColor.value } },
dataLabels: { style: { colors: undefined }, dropShadow: { enabled: false } },
dataLabels: { style: { colors: ['#FFFFFF'] }, dropShadow: { enabled: true, top: 1, left: 1, blur: 2, opacity: 0.5 } },
tooltip: {
y: {
formatter: (val: number) => formatCurrency(val),
@@ -343,6 +359,37 @@ const spendingChartOptions = computed(() => ({
},
}))
function highlightCategory(categoryName: string) {
highlightedCategory.value = categoryName
const chart = spendingChartRef.value?.chart
if (!chart) return
const labels = spending.value?.categories.map(c => c.categoryName) ?? []
const idx = labels.indexOf(categoryName)
if (idx === -1) return
if (selectedChartIndex >= 0 && selectedChartIndex !== idx) {
chart.toggleDataPointSelection(0, selectedChartIndex)
}
if (selectedChartIndex !== idx) {
chart.toggleDataPointSelection(0, idx)
selectedChartIndex = idx
}
}
function unhighlightCategory() {
highlightedCategory.value = null
const chart = spendingChartRef.value?.chart
if (chart && selectedChartIndex >= 0) {
chart.toggleDataPointSelection(0, selectedChartIndex)
selectedChartIndex = -1
}
}
const spendingRowProps = ({ item }: any) => ({
class: highlightedCategory.value === item.categoryName ? 'spending-row-highlight' : '',
onMouseenter: () => highlightCategory(item.categoryName),
onMouseleave: () => unhighlightCategory(),
})
// --- Income vs Expense ---
const incomeHeaders = [
{ title: 'Month', key: 'label' },
@@ -488,11 +535,12 @@ async function loadReports() {
loading.value = true
const { startDate, endDate } = dateRange.value
const accts = selectedAccountIds.value.length ? selectedAccountIds.value : undefined
const [s, ie, nw, cf] = await Promise.all([
fetchReport(reportsApi.spendingByCategory(startDate, endDate, selectedAccountId.value)),
fetchReport(reportsApi.incomeVsExpense(startDate, endDate)),
fetchReport(reportsApi.netWorth(startDate, endDate)),
fetchReport(reportsApi.cashFlow(startDate, endDate)),
fetchReport(reportsApi.spendingByCategory(startDate, endDate, accts)),
fetchReport(reportsApi.incomeVsExpense(startDate, endDate, accts)),
fetchReport(reportsApi.netWorth(startDate, endDate, accts)),
fetchReport(reportsApi.cashFlow(startDate, endDate, accts)),
])
spending.value = s
@@ -502,18 +550,13 @@ async function loadReports() {
loading.value = false
}
async function loadSpending() {
const { startDate, endDate } = dateRange.value
spending.value = await fetchReport(reportsApi.spendingByCategory(startDate, endDate, selectedAccountId.value))
}
function showError(text: string) {
snackbarText.value = text
snackbar.value = true
}
watch(dateRange, () => loadReports())
watch(selectedAccountId, () => loadSpending())
watch(selectedAccountIds, () => loadReports(), { deep: true })
onMounted(async () => {
if (accountsStore.accounts.length === 0) {
@@ -522,3 +565,10 @@ onMounted(async () => {
await loadReports()
})
</script>
<style scoped>
:deep(.spending-row-highlight) {
background-color: rgba(var(--v-theme-primary), 0.15);
transition: background-color 0.15s;
}
</style>