diff --git a/frontend/src/services/reports.ts b/frontend/src/services/reports.ts index a64a9fe..8cfd46c 100644 --- a/frontend/src/services/reports.ts +++ b/frontend/src/services/reports.ts @@ -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('/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('/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('/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('/reports/cash-flow', { - params: { startDate, endDate }, + params: { startDate, endDate, accountIds }, + paramsSerializer: { indexes: null }, }), } diff --git a/frontend/src/views/reports/ReportsView.vue b/frontend/src/views/reports/ReportsView.vue index f4c4f3d..6674c3a 100644 --- a/frontend/src/views/reports/ReportsView.vue +++ b/frontend/src/views/reports/ReportsView.vue @@ -38,6 +38,22 @@ style="max-width: 180px" /> + + + + @@ -56,21 +72,7 @@ - - Spending by Category - - - + Spending by Category @@ -78,6 +80,7 @@

Total spending

@@ -266,19 +270,20 @@ const activeTab = ref('spending') const preset = ref('thisMonth') const customStart = ref('') const customEnd = ref('') -const selectedAccountId = ref(undefined) +const selectedAccountIds = ref([]) const loading = ref(false) const snackbar = ref(false) const snackbarText = ref('') +const spendingChartRef = ref() +const highlightedCategory = ref(null) +let selectedChartIndex = -1 + const spending = ref(null) const incomeExpense = ref(null) const netWorth = ref(null) const cashFlow = ref(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() }) + + diff --git a/src/Purrse.Api/Controllers/ReportsController.cs b/src/Purrse.Api/Controllers/ReportsController.cs index bee1877..0528dde 100644 --- a/src/Purrse.Api/Controllers/ReportsController.cs +++ b/src/Purrse.Api/Controllers/ReportsController.cs @@ -17,20 +17,20 @@ public class ReportsController : ControllerBase public ReportsController(IReportService reportService) => _reportService = reportService; [HttpGet("spending-by-category")] - public async Task> SpendingByCategory([FromQuery] DateTime startDate, [FromQuery] DateTime endDate, [FromQuery] Guid? accountId) - => Ok(await _reportService.GetSpendingByCategoryAsync(UserId, Utc(startDate), Utc(endDate), accountId)); + public async Task> 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> IncomeVsExpense([FromQuery] DateTime startDate, [FromQuery] DateTime endDate) - => Ok(await _reportService.GetIncomeVsExpenseAsync(UserId, Utc(startDate), Utc(endDate))); + public async Task> 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> NetWorth([FromQuery] DateTime startDate, [FromQuery] DateTime endDate) - => Ok(await _reportService.GetNetWorthAsync(UserId, Utc(startDate), Utc(endDate))); + public async Task> 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> CashFlow([FromQuery] DateTime startDate, [FromQuery] DateTime endDate) - => Ok(await _reportService.GetCashFlowAsync(UserId, Utc(startDate), Utc(endDate))); + public async Task> 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; diff --git a/src/Purrse.Api/Services/ReportService.cs b/src/Purrse.Api/Services/ReportService.cs index 9ff055c..f6b5a27 100644 --- a/src/Purrse.Api/Services/ReportService.cs +++ b/src/Purrse.Api/Services/ReportService.cs @@ -12,15 +12,15 @@ public class ReportService : IReportService public ReportService(PurrseDbContext db) => _db = db; - public async Task GetSpendingByCategoryAsync(Guid userId, DateTime startDate, DateTime endDate, Guid? accountId = null) + public async Task 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 GetIncomeVsExpenseAsync(Guid userId, DateTime startDate, DateTime endDate) + public async Task 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 GetNetWorthAsync(Guid userId, DateTime startDate, DateTime endDate) + public async Task 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(); var currentDate = startDate; @@ -94,12 +99,13 @@ public class ReportService : IReportService return new NetWorthReport(entries); } - public async Task GetCashFlowAsync(Guid userId, DateTime startDate, DateTime endDate) + public async Task 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 => diff --git a/src/Purrse.Core/Interfaces/Services/IReportService.cs b/src/Purrse.Core/Interfaces/Services/IReportService.cs index c8cf9ca..e1408b7 100644 --- a/src/Purrse.Core/Interfaces/Services/IReportService.cs +++ b/src/Purrse.Core/Interfaces/Services/IReportService.cs @@ -4,8 +4,8 @@ namespace Purrse.Core.Interfaces.Services; public interface IReportService { - Task GetSpendingByCategoryAsync(Guid userId, DateTime startDate, DateTime endDate, Guid? accountId = null); - Task GetIncomeVsExpenseAsync(Guid userId, DateTime startDate, DateTime endDate); - Task GetNetWorthAsync(Guid userId, DateTime startDate, DateTime endDate); - Task GetCashFlowAsync(Guid userId, DateTime startDate, DateTime endDate); + Task GetSpendingByCategoryAsync(Guid userId, DateTime startDate, DateTime endDate, Guid[]? accountIds = null); + Task GetIncomeVsExpenseAsync(Guid userId, DateTime startDate, DateTime endDate, Guid[]? accountIds = null); + Task GetNetWorthAsync(Guid userId, DateTime startDate, DateTime endDate, Guid[]? accountIds = null); + Task GetCashFlowAsync(Guid userId, DateTime startDate, DateTime endDate, Guid[]? accountIds = null); }