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>
|
||||
|
||||
@@ -17,20 +17,20 @@ public class ReportsController : ControllerBase
|
||||
public ReportsController(IReportService reportService) => _reportService = reportService;
|
||||
|
||||
[HttpGet("spending-by-category")]
|
||||
public async Task<ActionResult<SpendingByCategoryReport>> SpendingByCategory([FromQuery] DateTime startDate, [FromQuery] DateTime endDate, [FromQuery] Guid? accountId)
|
||||
=> Ok(await _reportService.GetSpendingByCategoryAsync(UserId, Utc(startDate), Utc(endDate), 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), accountIds));
|
||||
|
||||
[HttpGet("income-vs-expense")]
|
||||
public async Task<ActionResult<IncomeVsExpenseReport>> IncomeVsExpense([FromQuery] DateTime startDate, [FromQuery] DateTime endDate)
|
||||
=> Ok(await _reportService.GetIncomeVsExpenseAsync(UserId, Utc(startDate), Utc(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), accountIds));
|
||||
|
||||
[HttpGet("net-worth")]
|
||||
public async Task<ActionResult<NetWorthReport>> NetWorth([FromQuery] DateTime startDate, [FromQuery] DateTime endDate)
|
||||
=> Ok(await _reportService.GetNetWorthAsync(UserId, Utc(startDate), Utc(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), accountIds));
|
||||
|
||||
[HttpGet("cash-flow")]
|
||||
public async Task<ActionResult<CashFlowReport>> CashFlow([FromQuery] DateTime startDate, [FromQuery] DateTime endDate)
|
||||
=> Ok(await _reportService.GetCashFlowAsync(UserId, Utc(startDate), Utc(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), accountIds));
|
||||
|
||||
private static DateTime Utc(DateTime dt) =>
|
||||
dt.Kind == DateTimeKind.Unspecified ? DateTime.SpecifyKind(dt, DateTimeKind.Utc) : dt;
|
||||
|
||||
@@ -12,15 +12,15 @@ public class ReportService : IReportService
|
||||
|
||||
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
|
||||
.Include(t => t.Category).ThenInclude(c => c!.Parent)
|
||||
.Where(t => t.Account.UserId == userId && t.Date >= startDate && t.Date <= endDate
|
||||
&& t.Amount < 0 && !t.IsVoid && t.CategoryId.HasValue);
|
||||
|
||||
if (accountId.HasValue)
|
||||
query = query.Where(t => t.AccountId == accountId.Value);
|
||||
if (accountIds is { Length: > 0 })
|
||||
query = query.Where(t => accountIds.Contains(t.AccountId));
|
||||
|
||||
var groups = await query
|
||||
.GroupBy(t => t.CategoryId)
|
||||
@@ -45,11 +45,13 @@ public class ReportService : IReportService
|
||||
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
|
||||
.Where(t => t.Account.UserId == userId && t.Date >= startDate && t.Date <= endDate && !t.IsVoid && t.Type != TransactionType.Transfer)
|
||||
.ToListAsync();
|
||||
var query = _db.Transactions
|
||||
.Where(t => t.Account.UserId == userId && t.Date >= startDate && t.Date <= endDate && !t.IsVoid && t.Type != TransactionType.Transfer);
|
||||
if (accountIds is { Length: > 0 })
|
||||
query = query.Where(t => accountIds.Contains(t.AccountId));
|
||||
var transactions = await query.ToListAsync();
|
||||
|
||||
var months = transactions
|
||||
.GroupBy(t => new { t.Date.Year, t.Date.Month })
|
||||
@@ -65,15 +67,18 @@ public class ReportService : IReportService
|
||||
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();
|
||||
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 transactions = await _db.Transactions
|
||||
.Where(t => t.Account.UserId == userId && t.Date >= startDate && t.Date <= endDate && !t.IsVoid)
|
||||
.OrderBy(t => t.Date)
|
||||
.ToListAsync();
|
||||
var txQuery = _db.Transactions
|
||||
.Where(t => t.Account.UserId == userId && t.Date >= startDate && t.Date <= endDate && !t.IsVoid);
|
||||
if (accountIds is { Length: > 0 })
|
||||
txQuery = txQuery.Where(t => accountIds.Contains(t.AccountId));
|
||||
var transactions = await txQuery.OrderBy(t => t.Date).ToListAsync();
|
||||
|
||||
var entries = new List<NetWorthEntry>();
|
||||
var currentDate = startDate;
|
||||
@@ -94,12 +99,13 @@ public class ReportService : IReportService
|
||||
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
|
||||
.Where(t => t.Account.UserId == userId && t.Date >= startDate && t.Date <= endDate && !t.IsVoid && t.Type != TransactionType.Transfer)
|
||||
.OrderBy(t => t.Date)
|
||||
.ToListAsync();
|
||||
var query = _db.Transactions
|
||||
.Where(t => t.Account.UserId == userId && t.Date >= startDate && t.Date <= endDate && !t.IsVoid && t.Type != TransactionType.Transfer);
|
||||
if (accountIds is { Length: > 0 })
|
||||
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)
|
||||
.Select(g =>
|
||||
|
||||
@@ -4,8 +4,8 @@ namespace Purrse.Core.Interfaces.Services;
|
||||
|
||||
public interface IReportService
|
||||
{
|
||||
Task<SpendingByCategoryReport> GetSpendingByCategoryAsync(Guid userId, DateTime startDate, DateTime endDate, Guid? accountId = null);
|
||||
Task<IncomeVsExpenseReport> GetIncomeVsExpenseAsync(Guid userId, DateTime startDate, DateTime endDate);
|
||||
Task<NetWorthReport> GetNetWorthAsync(Guid userId, DateTime startDate, DateTime endDate);
|
||||
Task<CashFlowReport> GetCashFlowAsync(Guid userId, DateTime startDate, DateTime endDate);
|
||||
Task<SpendingByCategoryReport> GetSpendingByCategoryAsync(Guid userId, DateTime startDate, DateTime endDate, Guid[]? accountIds = null);
|
||||
Task<IncomeVsExpenseReport> GetIncomeVsExpenseAsync(Guid userId, DateTime startDate, DateTime endDate, Guid[]? accountIds = null);
|
||||
Task<NetWorthReport> GetNetWorthAsync(Guid userId, DateTime startDate, DateTime endDate, Guid[]? accountIds = null);
|
||||
Task<CashFlowReport> GetCashFlowAsync(Guid userId, DateTime startDate, DateTime endDate, Guid[]? accountIds = null);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user