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' import type { SpendingByCategoryReport, IncomeVsExpenseReport, NetWorthReport, CashFlowReport } from '@/types'
export const reportsApi = { export const reportsApi = {
spendingByCategory: (startDate: string, endDate: string, accountId?: string) => spendingByCategory: (startDate: string, endDate: string, accountIds?: string[]) =>
api.get<SpendingByCategoryReport>('/reports/spending-by-category', { 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', { 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', { 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', { 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" style="max-width: 180px"
/> />
</v-col> </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-row>
</v-card-text> </v-card-text>
</v-card> </v-card>
@@ -56,21 +72,7 @@
<v-window v-model="activeTab"> <v-window v-model="activeTab">
<v-window-item value="spending"> <v-window-item value="spending">
<v-card> <v-card>
<v-card-title class="d-flex align-center"> <v-card-title>Spending by Category</v-card-title>
<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-text v-if="spending"> <v-card-text v-if="spending">
<v-row> <v-row>
<v-col cols="12" md="5"> <v-col cols="12" md="5">
@@ -78,6 +80,7 @@
<p class="text-medium-emphasis mb-4">Total spending</p> <p class="text-medium-emphasis mb-4">Total spending</p>
<apexchart <apexchart
v-if="spending.categories.length > 0" v-if="spending.categories.length > 0"
ref="spendingChartRef"
type="donut" type="donut"
height="300" height="300"
:options="spendingChartOptions" :options="spendingChartOptions"
@@ -93,6 +96,7 @@
:items-per-page="-1" :items-per-page="-1"
hide-default-footer hide-default-footer
:sort-by="[{ key: 'amount', order: 'desc' }]" :sort-by="[{ key: 'amount', order: 'desc' }]"
:row-props="spendingRowProps"
> >
<template #item.amount="{ item }">{{ formatCurrency(item.amount) }}</template> <template #item.amount="{ item }">{{ formatCurrency(item.amount) }}</template>
<template #item.percentage="{ item }">{{ item.percentage.toFixed(1) }}%</template> <template #item.percentage="{ item }">{{ item.percentage.toFixed(1) }}%</template>
@@ -266,19 +270,20 @@ const activeTab = ref('spending')
const preset = ref('thisMonth') const preset = ref('thisMonth')
const customStart = ref('') const customStart = ref('')
const customEnd = ref('') const customEnd = ref('')
const selectedAccountId = ref<string | undefined>(undefined) const selectedAccountIds = ref<string[]>([])
const loading = ref(false) const loading = ref(false)
const snackbar = ref(false) const snackbar = ref(false)
const snackbarText = ref('') const snackbarText = ref('')
const spendingChartRef = ref()
const highlightedCategory = ref<string | null>(null)
let selectedChartIndex = -1
const spending = ref<SpendingByCategoryReport | null>(null) const spending = ref<SpendingByCategoryReport | null>(null)
const incomeExpense = ref<IncomeVsExpenseReport | null>(null) const incomeExpense = ref<IncomeVsExpenseReport | null>(null)
const netWorth = ref<NetWorthReport | null>(null) const netWorth = ref<NetWorthReport | null>(null)
const cashFlow = ref<CashFlowReport | 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 dateRange = computed(() => {
const now = new Date() const now = new Date()
@@ -332,10 +337,21 @@ const spendingChartSeries = computed(() =>
) )
const spendingChartOptions = 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) ?? [], labels: spending.value?.categories.map(c => c.categoryName) ?? [],
legend: { position: 'bottom' as const, labels: { colors: labelColor.value } }, 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: { tooltip: {
y: { y: {
formatter: (val: number) => formatCurrency(val), 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 --- // --- Income vs Expense ---
const incomeHeaders = [ const incomeHeaders = [
{ title: 'Month', key: 'label' }, { title: 'Month', key: 'label' },
@@ -488,11 +535,12 @@ async function loadReports() {
loading.value = true loading.value = true
const { startDate, endDate } = dateRange.value const { startDate, endDate } = dateRange.value
const accts = selectedAccountIds.value.length ? selectedAccountIds.value : undefined
const [s, ie, nw, cf] = await Promise.all([ const [s, ie, nw, cf] = await Promise.all([
fetchReport(reportsApi.spendingByCategory(startDate, endDate, selectedAccountId.value)), fetchReport(reportsApi.spendingByCategory(startDate, endDate, accts)),
fetchReport(reportsApi.incomeVsExpense(startDate, endDate)), fetchReport(reportsApi.incomeVsExpense(startDate, endDate, accts)),
fetchReport(reportsApi.netWorth(startDate, endDate)), fetchReport(reportsApi.netWorth(startDate, endDate, accts)),
fetchReport(reportsApi.cashFlow(startDate, endDate)), fetchReport(reportsApi.cashFlow(startDate, endDate, accts)),
]) ])
spending.value = s spending.value = s
@@ -502,18 +550,13 @@ async function loadReports() {
loading.value = false 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) { function showError(text: string) {
snackbarText.value = text snackbarText.value = text
snackbar.value = true snackbar.value = true
} }
watch(dateRange, () => loadReports()) watch(dateRange, () => loadReports())
watch(selectedAccountId, () => loadSpending()) watch(selectedAccountIds, () => loadReports(), { deep: true })
onMounted(async () => { onMounted(async () => {
if (accountsStore.accounts.length === 0) { if (accountsStore.accounts.length === 0) {
@@ -522,3 +565,10 @@ onMounted(async () => {
await loadReports() await loadReports()
}) })
</script> </script>
<style scoped>
:deep(.spending-row-highlight) {
background-color: rgba(var(--v-theme-primary), 0.15);
transition: background-color 0.15s;
}
</style>
@@ -17,20 +17,20 @@ public class ReportsController : ControllerBase
public ReportsController(IReportService reportService) => _reportService = reportService; public ReportsController(IReportService reportService) => _reportService = reportService;
[HttpGet("spending-by-category")] [HttpGet("spending-by-category")]
public async Task<ActionResult<SpendingByCategoryReport>> SpendingByCategory([FromQuery] DateTime startDate, [FromQuery] DateTime endDate, [FromQuery] Guid? accountId) public async Task<ActionResult<SpendingByCategoryReport>> SpendingByCategory([FromQuery] DateTime startDate, [FromQuery] DateTime endDate, [FromQuery] Guid[]? accountIds)
=> Ok(await _reportService.GetSpendingByCategoryAsync(UserId, Utc(startDate), Utc(endDate), accountId)); => Ok(await _reportService.GetSpendingByCategoryAsync(UserId, Utc(startDate), Utc(endDate), accountIds));
[HttpGet("income-vs-expense")] [HttpGet("income-vs-expense")]
public async Task<ActionResult<IncomeVsExpenseReport>> IncomeVsExpense([FromQuery] DateTime startDate, [FromQuery] DateTime endDate) public async Task<ActionResult<IncomeVsExpenseReport>> IncomeVsExpense([FromQuery] DateTime startDate, [FromQuery] DateTime endDate, [FromQuery] Guid[]? accountIds)
=> Ok(await _reportService.GetIncomeVsExpenseAsync(UserId, Utc(startDate), Utc(endDate))); => Ok(await _reportService.GetIncomeVsExpenseAsync(UserId, Utc(startDate), Utc(endDate), accountIds));
[HttpGet("net-worth")] [HttpGet("net-worth")]
public async Task<ActionResult<NetWorthReport>> NetWorth([FromQuery] DateTime startDate, [FromQuery] DateTime endDate) public async Task<ActionResult<NetWorthReport>> NetWorth([FromQuery] DateTime startDate, [FromQuery] DateTime endDate, [FromQuery] Guid[]? accountIds)
=> Ok(await _reportService.GetNetWorthAsync(UserId, Utc(startDate), Utc(endDate))); => Ok(await _reportService.GetNetWorthAsync(UserId, Utc(startDate), Utc(endDate), accountIds));
[HttpGet("cash-flow")] [HttpGet("cash-flow")]
public async Task<ActionResult<CashFlowReport>> CashFlow([FromQuery] DateTime startDate, [FromQuery] DateTime endDate) public async Task<ActionResult<CashFlowReport>> CashFlow([FromQuery] DateTime startDate, [FromQuery] DateTime endDate, [FromQuery] Guid[]? accountIds)
=> Ok(await _reportService.GetCashFlowAsync(UserId, Utc(startDate), Utc(endDate))); => Ok(await _reportService.GetCashFlowAsync(UserId, Utc(startDate), Utc(endDate), accountIds));
private static DateTime Utc(DateTime dt) => private static DateTime Utc(DateTime dt) =>
dt.Kind == DateTimeKind.Unspecified ? DateTime.SpecifyKind(dt, DateTimeKind.Utc) : dt; dt.Kind == DateTimeKind.Unspecified ? DateTime.SpecifyKind(dt, DateTimeKind.Utc) : dt;
+23 -17
View File
@@ -12,15 +12,15 @@ public class ReportService : IReportService
public ReportService(PurrseDbContext db) => _db = db; public ReportService(PurrseDbContext db) => _db = db;
public async Task<SpendingByCategoryReport> GetSpendingByCategoryAsync(Guid userId, DateTime startDate, DateTime endDate, Guid? accountId = null) public async Task<SpendingByCategoryReport> GetSpendingByCategoryAsync(Guid userId, DateTime startDate, DateTime endDate, Guid[]? accountIds = null)
{ {
var query = _db.Transactions var query = _db.Transactions
.Include(t => t.Category).ThenInclude(c => c!.Parent) .Include(t => t.Category).ThenInclude(c => c!.Parent)
.Where(t => t.Account.UserId == userId && t.Date >= startDate && t.Date <= endDate .Where(t => t.Account.UserId == userId && t.Date >= startDate && t.Date <= endDate
&& t.Amount < 0 && !t.IsVoid && t.CategoryId.HasValue); && t.Amount < 0 && !t.IsVoid && t.CategoryId.HasValue);
if (accountId.HasValue) if (accountIds is { Length: > 0 })
query = query.Where(t => t.AccountId == accountId.Value); query = query.Where(t => accountIds.Contains(t.AccountId));
var groups = await query var groups = await query
.GroupBy(t => t.CategoryId) .GroupBy(t => t.CategoryId)
@@ -45,11 +45,13 @@ public class ReportService : IReportService
return new SpendingByCategoryReport(startDate, endDate, total, categories); return new SpendingByCategoryReport(startDate, endDate, total, categories);
} }
public async Task<IncomeVsExpenseReport> GetIncomeVsExpenseAsync(Guid userId, DateTime startDate, DateTime endDate) public async Task<IncomeVsExpenseReport> GetIncomeVsExpenseAsync(Guid userId, DateTime startDate, DateTime endDate, Guid[]? accountIds = null)
{ {
var transactions = await _db.Transactions var query = _db.Transactions
.Where(t => t.Account.UserId == userId && t.Date >= startDate && t.Date <= endDate && !t.IsVoid && t.Type != TransactionType.Transfer) .Where(t => t.Account.UserId == userId && t.Date >= startDate && t.Date <= endDate && !t.IsVoid && t.Type != TransactionType.Transfer);
.ToListAsync(); if (accountIds is { Length: > 0 })
query = query.Where(t => accountIds.Contains(t.AccountId));
var transactions = await query.ToListAsync();
var months = transactions var months = transactions
.GroupBy(t => new { t.Date.Year, t.Date.Month }) .GroupBy(t => new { t.Date.Year, t.Date.Month })
@@ -65,15 +67,18 @@ public class ReportService : IReportService
return new IncomeVsExpenseReport(startDate, endDate, months); return new IncomeVsExpenseReport(startDate, endDate, months);
} }
public async Task<NetWorthReport> GetNetWorthAsync(Guid userId, DateTime startDate, DateTime endDate) public async Task<NetWorthReport> GetNetWorthAsync(Guid userId, DateTime startDate, DateTime endDate, Guid[]? accountIds = null)
{ {
var accounts = await _db.Accounts.Where(a => a.UserId == userId && a.IsActive).ToListAsync(); var accounts = await _db.Accounts.Where(a => a.UserId == userId && a.IsActive).ToListAsync();
if (accountIds is { Length: > 0 })
accounts = accounts.Where(a => accountIds.Contains(a.Id)).ToList();
var assetTypes = new[] { AccountType.Checking, AccountType.Savings, AccountType.Cash, AccountType.Brokerage, AccountType.Retirement401k, AccountType.IRA }; var assetTypes = new[] { AccountType.Checking, AccountType.Savings, AccountType.Cash, AccountType.Brokerage, AccountType.Retirement401k, AccountType.IRA };
var transactions = await _db.Transactions var txQuery = _db.Transactions
.Where(t => t.Account.UserId == userId && t.Date >= startDate && t.Date <= endDate && !t.IsVoid) .Where(t => t.Account.UserId == userId && t.Date >= startDate && t.Date <= endDate && !t.IsVoid);
.OrderBy(t => t.Date) if (accountIds is { Length: > 0 })
.ToListAsync(); txQuery = txQuery.Where(t => accountIds.Contains(t.AccountId));
var transactions = await txQuery.OrderBy(t => t.Date).ToListAsync();
var entries = new List<NetWorthEntry>(); var entries = new List<NetWorthEntry>();
var currentDate = startDate; var currentDate = startDate;
@@ -94,12 +99,13 @@ public class ReportService : IReportService
return new NetWorthReport(entries); return new NetWorthReport(entries);
} }
public async Task<CashFlowReport> GetCashFlowAsync(Guid userId, DateTime startDate, DateTime endDate) public async Task<CashFlowReport> GetCashFlowAsync(Guid userId, DateTime startDate, DateTime endDate, Guid[]? accountIds = null)
{ {
var transactions = await _db.Transactions var query = _db.Transactions
.Where(t => t.Account.UserId == userId && t.Date >= startDate && t.Date <= endDate && !t.IsVoid && t.Type != TransactionType.Transfer) .Where(t => t.Account.UserId == userId && t.Date >= startDate && t.Date <= endDate && !t.IsVoid && t.Type != TransactionType.Transfer);
.OrderBy(t => t.Date) if (accountIds is { Length: > 0 })
.ToListAsync(); query = query.Where(t => accountIds.Contains(t.AccountId));
var transactions = await query.OrderBy(t => t.Date).ToListAsync();
var groups = transactions.GroupBy(t => t.Date.Date) var groups = transactions.GroupBy(t => t.Date.Date)
.Select(g => .Select(g =>
@@ -4,8 +4,8 @@ namespace Purrse.Core.Interfaces.Services;
public interface IReportService public interface IReportService
{ {
Task<SpendingByCategoryReport> GetSpendingByCategoryAsync(Guid userId, DateTime startDate, DateTime endDate, Guid? accountId = null); Task<SpendingByCategoryReport> GetSpendingByCategoryAsync(Guid userId, DateTime startDate, DateTime endDate, Guid[]? accountIds = null);
Task<IncomeVsExpenseReport> GetIncomeVsExpenseAsync(Guid userId, DateTime startDate, DateTime endDate); Task<IncomeVsExpenseReport> GetIncomeVsExpenseAsync(Guid userId, DateTime startDate, DateTime endDate, Guid[]? accountIds = null);
Task<NetWorthReport> GetNetWorthAsync(Guid userId, DateTime startDate, DateTime endDate); Task<NetWorthReport> GetNetWorthAsync(Guid userId, DateTime startDate, DateTime endDate, Guid[]? accountIds = null);
Task<CashFlowReport> GetCashFlowAsync(Guid userId, DateTime startDate, DateTime endDate); Task<CashFlowReport> GetCashFlowAsync(Guid userId, DateTime startDate, DateTime endDate, Guid[]? accountIds = null);
} }