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:
@@ -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 },
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user