Private
Public Access
1
0

Implement Phase 4: reports & investments frontend

Add interactive reports dashboard with 4 chart types (spending by
category donut, income vs expense bar, net worth area, cash flow bar)
using ApexCharts, date range presets, and account filtering. Add
investment portfolio view with holdings table, allocation chart, and
gain/loss color coding. Includes TypeScript types and API services.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Catherine Renelle
2026-02-08 13:02:14 -05:00
parent 23d73736c9
commit 6ae90c0c38
5 changed files with 818 additions and 12 deletions
+10
View File
@@ -0,0 +1,10 @@
import api from './api'
import type { InvestmentHolding, InvestmentPerformance } from '@/types'
export const investmentsApi = {
getHoldings: (accountId: string) =>
api.get<InvestmentHolding[]>(`/investments/${accountId}/holdings`),
getPerformance: (accountId: string) =>
api.get<InvestmentPerformance>(`/investments/${accountId}/performance`),
}
+24
View File
@@ -0,0 +1,24 @@
import api from './api'
import type { SpendingByCategoryReport, IncomeVsExpenseReport, NetWorthReport, CashFlowReport } from '@/types'
export const reportsApi = {
spendingByCategory: (startDate: string, endDate: string, accountId?: string) =>
api.get<SpendingByCategoryReport>('/reports/spending-by-category', {
params: { startDate, endDate, accountId },
}),
incomeVsExpense: (startDate: string, endDate: string) =>
api.get<IncomeVsExpenseReport>('/reports/income-vs-expense', {
params: { startDate, endDate },
}),
netWorth: (startDate: string, endDate: string) =>
api.get<NetWorthReport>('/reports/net-worth', {
params: { startDate, endDate },
}),
cashFlow: (startDate: string, endDate: string) =>
api.get<CashFlowReport>('/reports/cash-flow', {
params: { startDate, endDate },
}),
}
+71
View File
@@ -261,3 +261,74 @@ export interface DuplicateResolution {
export interface ResolveDuplicatesRequest {
resolutions: DuplicateResolution[]
}
// Reports
export interface SpendingByCategoryReport {
startDate: string
endDate: string
totalSpending: number
categories: CategorySpending[]
}
export interface IncomeVsExpenseReport {
startDate: string
endDate: string
months: MonthlyIncomeExpense[]
}
export interface MonthlyIncomeExpense {
year: number
month: number
income: number
expenses: number
net: number
}
export interface NetWorthReport {
entries: NetWorthEntry[]
}
export interface NetWorthEntry {
date: string
assets: number
liabilities: number
netWorth: number
}
export interface CashFlowReport {
startDate: string
endDate: string
totalInflow: number
totalOutflow: number
netCashFlow: number
entries: CashFlowEntry[]
}
export interface CashFlowEntry {
date: string
inflow: number
outflow: number
net: number
}
// Investments
export interface InvestmentHolding {
id: string
symbol: string
securityName: string
shares: number
costBasis: number
currentPrice: number
marketValue: number
gainLoss: number
gainLossPercent: number
asOfDate: string
}
export interface InvestmentPerformance {
totalMarketValue: number
totalCostBasis: number
totalGainLoss: number
totalGainLossPercent: number
holdings: InvestmentHolding[]
}
@@ -1,6 +1,217 @@
<template>
<div>
<h1 class="text-h4 mb-4">Investments</h1>
<v-card><v-card-text><p class="text-medium-emphasis">Investment portfolio tracking coming in Phase 5. Track 401k holdings, performance, and allocation.</p></v-card-text></v-card>
<!-- No investment accounts -->
<v-card v-if="investmentAccounts.length === 0 && !accountsStore.loading" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1" class="mb-4">mdi-finance</v-icon>
<h3 class="text-h6 mb-2">No Investment Accounts</h3>
<p class="text-medium-emphasis">Add a 401(k), IRA, or Brokerage account to track your investments.</p>
</v-card>
<template v-if="investmentAccounts.length > 0">
<!-- Account Selector -->
<v-card class="mb-4">
<v-card-text>
<v-select
v-model="selectedAccountId"
:items="investmentAccounts"
item-title="label"
item-value="id"
label="Select Investment Account"
density="compact"
hide-details
prepend-inner-icon="mdi-finance"
/>
</v-card-text>
</v-card>
<v-progress-linear v-if="loading" indeterminate color="primary" class="mb-4" />
<template v-if="performance && !loading">
<!-- Portfolio Summary -->
<v-row class="mb-4">
<v-col cols="12" sm="6" md="3">
<v-card variant="tonal">
<v-card-text class="text-center">
<div class="text-caption">Market Value</div>
<div class="text-h6">{{ formatCurrency(performance.totalMarketValue) }}</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card variant="tonal">
<v-card-text class="text-center">
<div class="text-caption">Cost Basis</div>
<div class="text-h6">{{ formatCurrency(performance.totalCostBasis) }}</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card variant="tonal" :color="performance.totalGainLoss >= 0 ? 'success' : 'error'">
<v-card-text class="text-center">
<div class="text-caption">Total Gain/Loss</div>
<div class="text-h6">{{ formatCurrency(performance.totalGainLoss) }}</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card variant="tonal" :color="performance.totalGainLossPercent >= 0 ? 'success' : 'error'">
<v-card-text class="text-center">
<div class="text-caption">Return</div>
<div class="text-h6">{{ formatPercent(performance.totalGainLossPercent) }}</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Allocation Chart + Holdings Table -->
<v-row>
<v-col v-if="performance.holdings.length > 0" cols="12" md="4">
<v-card>
<v-card-title>Allocation</v-card-title>
<v-card-text>
<apexchart
type="donut"
height="300"
:options="allocationChartOptions"
:series="allocationChartSeries"
/>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" :md="performance.holdings.length > 0 ? 8 : 12">
<v-card>
<v-card-title>Holdings</v-card-title>
<v-card-text v-if="performance.holdings.length > 0">
<v-data-table
:headers="holdingsHeaders"
:items="performance.holdings"
density="compact"
:items-per-page="-1"
hide-default-footer
>
<template #item.shares="{ item }">{{ item.shares.toFixed(4) }}</template>
<template #item.costBasis="{ item }">{{ formatCurrency(item.costBasis) }}</template>
<template #item.currentPrice="{ item }">{{ formatCurrency(item.currentPrice) }}</template>
<template #item.marketValue="{ item }">{{ formatCurrency(item.marketValue) }}</template>
<template #item.gainLoss="{ item }">
<span :class="item.gainLoss >= 0 ? 'text-success' : 'text-error'">
{{ formatCurrency(item.gainLoss) }}
</span>
</template>
<template #item.gainLossPercent="{ item }">
<span :class="item.gainLossPercent >= 0 ? 'text-success' : 'text-error'">
{{ formatPercent(item.gainLossPercent) }}
</span>
</template>
</v-data-table>
</v-card-text>
<v-card-text v-else class="text-center pa-8 text-medium-emphasis">
No holdings found for this account. Add investment holdings to track your portfolio.
</v-card-text>
</v-card>
</v-col>
</v-row>
</template>
<v-card v-if="!loading && !performance && selectedAccountId" class="text-center pa-8">
<v-icon size="48" color="grey-lighten-1" class="mb-4">mdi-chart-line</v-icon>
<p class="text-medium-emphasis">Select an account above to view your portfolio.</p>
</v-card>
</template>
<v-snackbar v-model="snackbar" color="error" :timeout="3000">{{ snackbarText }}</v-snackbar>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import VueApexCharts from 'vue3-apexcharts'
import { investmentsApi } from '@/services/investments'
import { useAccountsStore } from '@/stores/accounts'
import { formatCurrency, formatPercent, accountTypeLabel, accountTypeIcon } from '@/utils/formatters'
import type { InvestmentPerformance } from '@/types'
const apexchart = VueApexCharts
const accountsStore = useAccountsStore()
const selectedAccountId = ref<string | null>(null)
const performance = ref<InvestmentPerformance | null>(null)
const loading = ref(false)
const snackbar = ref(false)
const snackbarText = ref('')
const investmentAccountTypes = ['Retirement401k', 'IRA', 'Brokerage']
const investmentAccounts = computed(() =>
accountsStore.accounts
.filter(a => investmentAccountTypes.includes(a.type))
.map(a => ({
id: a.id,
label: `${a.name} (${accountTypeLabel(a.type)})`,
name: a.name,
type: a.type,
balance: a.balance,
icon: accountTypeIcon(a.type),
}))
)
// --- Allocation Chart ---
const allocationChartSeries = computed(() =>
performance.value?.holdings.map(h => h.marketValue) ?? []
)
const allocationChartOptions = computed(() => ({
chart: { type: 'donut' as const },
labels: performance.value?.holdings.map(h => `${h.symbol} - ${h.securityName}`) ?? [],
legend: { position: 'bottom' as const },
tooltip: {
y: {
formatter: (val: number) => formatCurrency(val),
},
},
}))
// --- Holdings Table ---
const holdingsHeaders = [
{ title: 'Symbol', key: 'symbol' },
{ title: 'Name', key: 'securityName' },
{ title: 'Shares', key: 'shares', align: 'end' as const },
{ title: 'Cost Basis', key: 'costBasis', align: 'end' as const },
{ title: 'Price', key: 'currentPrice', align: 'end' as const },
{ title: 'Market Value', key: 'marketValue', align: 'end' as const },
{ title: 'Gain/Loss', key: 'gainLoss', align: 'end' as const },
{ title: 'Return', key: 'gainLossPercent', align: 'end' as const },
]
async function loadPerformance() {
if (!selectedAccountId.value) {
performance.value = null
return
}
loading.value = true
try {
const { data } = await investmentsApi.getPerformance(selectedAccountId.value)
performance.value = data
} catch {
snackbarText.value = 'Failed to load investment performance'
snackbar.value = true
performance.value = null
} finally {
loading.value = false
}
}
watch(selectedAccountId, () => loadPerformance())
onMounted(async () => {
if (accountsStore.accounts.length === 0) {
await accountsStore.fetchAccounts()
}
if (investmentAccounts.value.length > 0) {
selectedAccountId.value = investmentAccounts.value[0].id
}
})
</script>
+501 -11
View File
@@ -1,22 +1,512 @@
<template>
<div>
<h1 class="text-h4 mb-4">Reports</h1>
<v-row>
<v-col v-for="report in reports" :key="report.title" cols="12" md="6">
<!-- Date Range Selector -->
<v-card class="mb-4">
<v-card-text>
<v-row align="center">
<v-col cols="auto">
<v-btn-toggle v-model="preset" mandatory color="primary" density="compact">
<v-btn value="thisMonth" size="small">This Month</v-btn>
<v-btn value="last3" size="small">Last 3 Months</v-btn>
<v-btn value="last6" size="small">Last 6 Months</v-btn>
<v-btn value="thisYear" size="small">This Year</v-btn>
<v-btn value="lastYear" size="small">Last Year</v-btn>
<v-btn value="custom" size="small">Custom</v-btn>
</v-btn-toggle>
</v-col>
<v-col v-if="preset === 'custom'" cols="auto">
<v-text-field
v-model="customStart"
type="date"
label="Start Date"
density="compact"
hide-details
style="max-width: 180px"
class="d-inline-flex mr-2"
/>
</v-col>
<v-col v-if="preset === 'custom'" cols="auto">
<v-text-field
v-model="customEnd"
type="date"
label="End Date"
density="compact"
hide-details
style="max-width: 180px"
/>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- Tab Navigation -->
<v-tabs v-model="activeTab" color="primary" class="mb-4">
<v-tab value="spending"><v-icon start>mdi-chart-pie</v-icon>Spending by Category</v-tab>
<v-tab value="income"><v-icon start>mdi-chart-bar</v-icon>Income vs Expense</v-tab>
<v-tab value="networth"><v-icon start>mdi-chart-line</v-icon>Net Worth</v-tab>
<v-tab value="cashflow"><v-icon start>mdi-chart-timeline-variant</v-icon>Cash Flow</v-tab>
</v-tabs>
<v-progress-linear v-if="loading" indeterminate color="primary" class="mb-4" />
<!-- Spending by Category -->
<v-window v-model="activeTab">
<v-window-item value="spending">
<v-card>
<v-card-title><v-icon class="mr-2">{{ report.icon }}</v-icon>{{ report.title }}</v-card-title>
<v-card-text>{{ report.description }}</v-card-text>
<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-text v-if="spending">
<v-row>
<v-col cols="12" md="5">
<div class="text-h5 mb-2">{{ formatCurrency(spending.totalSpending) }}</div>
<p class="text-medium-emphasis mb-4">Total spending</p>
<apexchart
v-if="spending.categories.length > 0"
type="donut"
height="300"
:options="spendingChartOptions"
:series="spendingChartSeries"
/>
<div v-else class="text-center pa-8 text-medium-emphasis">No spending data for this period</div>
</v-col>
<v-col cols="12" md="7">
<v-data-table
:headers="spendingHeaders"
:items="spending.categories"
density="compact"
:items-per-page="-1"
hide-default-footer
:sort-by="[{ key: 'amount', order: 'desc' }]"
>
<template #item.amount="{ item }">{{ formatCurrency(item.amount) }}</template>
<template #item.percentage="{ item }">{{ item.percentage.toFixed(1) }}%</template>
</v-data-table>
</v-col>
</v-row>
</v-card-text>
<v-card-text v-else-if="!loading" class="text-center pa-8 text-medium-emphasis">
No spending data available for the selected period.
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-window-item>
<!-- Income vs Expense -->
<v-window-item value="income">
<v-card>
<v-card-title>Income vs Expense</v-card-title>
<v-card-text v-if="incomeExpense && incomeExpense.months.length > 0">
<apexchart
type="bar"
height="350"
:options="incomeChartOptions"
:series="incomeChartSeries"
/>
<v-data-table
:headers="incomeHeaders"
:items="incomeTableItems"
density="compact"
:items-per-page="-1"
hide-default-footer
class="mt-4"
>
<template #item.income="{ item }">{{ formatCurrency(item.income) }}</template>
<template #item.expenses="{ item }">{{ formatCurrency(item.expenses) }}</template>
<template #item.net="{ item }">
<span :class="item.net >= 0 ? 'text-success' : 'text-error'">
{{ formatCurrency(item.net) }}
</span>
</template>
</v-data-table>
</v-card-text>
<v-card-text v-else-if="!loading" class="text-center pa-8 text-medium-emphasis">
No income/expense data available for the selected period.
</v-card-text>
</v-card>
</v-window-item>
<!-- Net Worth -->
<v-window-item value="networth">
<v-card>
<v-card-title>Net Worth Over Time</v-card-title>
<v-card-text v-if="netWorth && netWorth.entries.length > 0">
<apexchart
type="area"
height="350"
:options="netWorthChartOptions"
:series="netWorthChartSeries"
/>
<v-data-table
:headers="netWorthHeaders"
:items="netWorthTableItems"
density="compact"
:items-per-page="-1"
hide-default-footer
class="mt-4"
>
<template #item.assets="{ item }">{{ formatCurrency(item.assets) }}</template>
<template #item.liabilities="{ item }">{{ formatCurrency(item.liabilities) }}</template>
<template #item.netWorth="{ item }">
<span :class="item.netWorth >= 0 ? 'text-success' : 'text-error'">
{{ formatCurrency(item.netWorth) }}
</span>
</template>
</v-data-table>
</v-card-text>
<v-card-text v-else-if="!loading" class="text-center pa-8 text-medium-emphasis">
No net worth data available for the selected period.
</v-card-text>
</v-card>
</v-window-item>
<!-- Cash Flow -->
<v-window-item value="cashflow">
<v-card>
<v-card-title>Cash Flow</v-card-title>
<v-card-text v-if="cashFlow">
<v-row class="mb-4">
<v-col cols="12" sm="4">
<v-card variant="tonal" color="success">
<v-card-text class="text-center">
<div class="text-caption">Total Inflow</div>
<div class="text-h6">{{ formatCurrency(cashFlow.totalInflow) }}</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" sm="4">
<v-card variant="tonal" color="error">
<v-card-text class="text-center">
<div class="text-caption">Total Outflow</div>
<div class="text-h6">{{ formatCurrency(cashFlow.totalOutflow) }}</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" sm="4">
<v-card variant="tonal" :color="cashFlow.netCashFlow >= 0 ? 'success' : 'error'">
<v-card-text class="text-center">
<div class="text-caption">Net Cash Flow</div>
<div class="text-h6">{{ formatCurrency(cashFlow.netCashFlow) }}</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<apexchart
v-if="cashFlow.entries.length > 0"
type="bar"
height="350"
:options="cashFlowChartOptions"
:series="cashFlowChartSeries"
/>
<v-data-table
v-if="cashFlow.entries.length > 0"
:headers="cashFlowHeaders"
:items="cashFlowTableItems"
density="compact"
:items-per-page="-1"
hide-default-footer
class="mt-4"
>
<template #item.inflow="{ item }">{{ formatCurrency(item.inflow) }}</template>
<template #item.outflow="{ item }">{{ formatCurrency(item.outflow) }}</template>
<template #item.net="{ item }">
<span :class="item.net >= 0 ? 'text-success' : 'text-error'">
{{ formatCurrency(item.net) }}
</span>
</template>
</v-data-table>
</v-card-text>
<v-card-text v-else-if="!loading" class="text-center pa-8 text-medium-emphasis">
No cash flow data available for the selected period.
</v-card-text>
</v-card>
</v-window-item>
</v-window>
<v-snackbar v-model="snackbar" color="error" :timeout="3000">{{ snackbarText }}</v-snackbar>
</div>
</template>
<script setup lang="ts">
const reports = [
{ title: 'Spending by Category', icon: 'mdi-chart-pie', description: 'See where your money goes by category' },
{ title: 'Income vs Expense', icon: 'mdi-chart-bar', description: 'Monthly income compared to expenses' },
{ title: 'Net Worth', icon: 'mdi-chart-line', description: 'Track your net worth over time' },
{ title: 'Cash Flow', icon: 'mdi-chart-timeline-variant', description: 'Daily and monthly cash flow analysis' },
import { ref, computed, onMounted, watch } from 'vue'
import VueApexCharts from 'vue3-apexcharts'
import { reportsApi } from '@/services/reports'
import { useAccountsStore } from '@/stores/accounts'
import { formatCurrency, formatDate } from '@/utils/formatters'
import type {
SpendingByCategoryReport,
IncomeVsExpenseReport,
NetWorthReport,
CashFlowReport,
} from '@/types'
const apexchart = VueApexCharts
const accountsStore = useAccountsStore()
const activeTab = ref('spending')
const preset = ref('last3')
const customStart = ref('')
const customEnd = ref('')
const selectedAccountId = ref<string | undefined>(undefined)
const loading = ref(false)
const snackbar = ref(false)
const snackbarText = ref('')
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()
let start: Date
let end: Date = new Date(now.getFullYear(), now.getMonth(), now.getDate())
switch (preset.value) {
case 'thisMonth':
start = new Date(now.getFullYear(), now.getMonth(), 1)
break
case 'last3':
start = new Date(now.getFullYear(), now.getMonth() - 3, 1)
break
case 'last6':
start = new Date(now.getFullYear(), now.getMonth() - 6, 1)
break
case 'thisYear':
start = new Date(now.getFullYear(), 0, 1)
break
case 'lastYear':
start = new Date(now.getFullYear() - 1, 0, 1)
end = new Date(now.getFullYear() - 1, 11, 31)
break
case 'custom':
start = customStart.value ? new Date(customStart.value) : new Date(now.getFullYear(), now.getMonth() - 3, 1)
end = customEnd.value ? new Date(customEnd.value) : now
break
default:
start = new Date(now.getFullYear(), now.getMonth() - 3, 1)
}
return {
startDate: start.toISOString().split('T')[0],
endDate: end.toISOString().split('T')[0],
}
})
// --- Spending by Category ---
const spendingHeaders = [
{ title: 'Category', key: 'categoryName' },
{ title: 'Amount', key: 'amount', align: 'end' as const },
{ title: 'Percentage', key: 'percentage', align: 'end' as const },
]
const spendingChartSeries = computed(() =>
spending.value?.categories.map(c => c.amount) ?? []
)
const spendingChartOptions = computed(() => ({
chart: { type: 'donut' as const },
labels: spending.value?.categories.map(c => c.categoryName) ?? [],
legend: { position: 'bottom' as const },
tooltip: {
y: {
formatter: (val: number) => formatCurrency(val),
},
},
}))
// --- Income vs Expense ---
const incomeHeaders = [
{ title: 'Month', key: 'label' },
{ title: 'Income', key: 'income', align: 'end' as const },
{ title: 'Expenses', key: 'expenses', align: 'end' as const },
{ title: 'Net', key: 'net', align: 'end' as const },
]
const monthLabels = computed(() =>
incomeExpense.value?.months.map(m => {
const d = new Date(m.year, m.month - 1)
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short' })
}) ?? []
)
const incomeTableItems = computed(() =>
incomeExpense.value?.months.map(m => ({
label: new Date(m.year, m.month - 1).toLocaleDateString('en-US', { year: 'numeric', month: 'long' }),
income: m.income,
expenses: m.expenses,
net: m.net,
})) ?? []
)
const incomeChartSeries = computed(() => [
{ name: 'Income', data: incomeExpense.value?.months.map(m => m.income) ?? [] },
{ name: 'Expenses', data: incomeExpense.value?.months.map(m => m.expenses) ?? [] },
{ name: 'Net', type: 'line', data: incomeExpense.value?.months.map(m => m.net) ?? [] },
])
const incomeChartOptions = computed(() => ({
chart: { type: 'bar' as const },
plotOptions: { bar: { columnWidth: '60%' } },
stroke: { width: [0, 0, 3] },
xaxis: { categories: monthLabels.value },
yaxis: {
labels: { formatter: (val: number) => formatCurrency(val) },
},
colors: ['#4CAF50', '#F44336', '#2196F3'],
legend: { position: 'top' as const },
tooltip: {
y: { formatter: (val: number) => formatCurrency(val) },
},
}))
// --- Net Worth ---
const netWorthHeaders = [
{ title: 'Date', key: 'date' },
{ title: 'Assets', key: 'assets', align: 'end' as const },
{ title: 'Liabilities', key: 'liabilities', align: 'end' as const },
{ title: 'Net Worth', key: 'netWorth', align: 'end' as const },
]
const netWorthTableItems = computed(() =>
netWorth.value?.entries.map(e => ({
date: formatDate(e.date),
assets: e.assets,
liabilities: e.liabilities,
netWorth: e.netWorth,
})) ?? []
)
const netWorthChartSeries = computed(() => [
{ name: 'Assets', data: netWorth.value?.entries.map(e => e.assets) ?? [] },
{ name: 'Liabilities', data: netWorth.value?.entries.map(e => e.liabilities) ?? [] },
{ name: 'Net Worth', data: netWorth.value?.entries.map(e => e.netWorth) ?? [] },
])
const netWorthChartOptions = computed(() => ({
chart: { type: 'area' as const },
xaxis: {
categories: netWorth.value?.entries.map(e => formatDate(e.date)) ?? [],
},
yaxis: {
labels: { formatter: (val: number) => formatCurrency(val) },
},
colors: ['#4CAF50', '#F44336', '#2196F3'],
stroke: { curve: 'smooth' as const, width: 2 },
fill: { type: 'gradient', gradient: { opacityFrom: 0.3, opacityTo: 0.05 } },
legend: { position: 'top' as const },
tooltip: {
y: { formatter: (val: number) => formatCurrency(val) },
},
}))
// --- Cash Flow ---
const cashFlowHeaders = [
{ title: 'Date', key: 'date' },
{ title: 'Inflow', key: 'inflow', align: 'end' as const },
{ title: 'Outflow', key: 'outflow', align: 'end' as const },
{ title: 'Net', key: 'net', align: 'end' as const },
]
const cashFlowTableItems = computed(() =>
cashFlow.value?.entries.map(e => ({
date: formatDate(e.date),
inflow: e.inflow,
outflow: e.outflow,
net: e.net,
})) ?? []
)
const cashFlowChartSeries = computed(() => [
{ name: 'Inflow', data: cashFlow.value?.entries.map(e => e.inflow) ?? [] },
{ name: 'Outflow', data: cashFlow.value?.entries.map(e => e.outflow) ?? [] },
])
const cashFlowChartOptions = computed(() => ({
chart: { type: 'bar' as const },
plotOptions: { bar: { columnWidth: '60%' } },
xaxis: {
categories: cashFlow.value?.entries.map(e => formatDate(e.date)) ?? [],
},
yaxis: {
labels: { formatter: (val: number) => formatCurrency(val) },
},
colors: ['#4CAF50', '#F44336'],
legend: { position: 'top' as const },
tooltip: {
y: { formatter: (val: number) => formatCurrency(val) },
},
}))
// --- Data loading ---
async function loadReports() {
loading.value = true
const { startDate, endDate } = dateRange.value
try {
const results = await Promise.allSettled([
reportsApi.spendingByCategory(startDate, endDate, selectedAccountId.value),
reportsApi.incomeVsExpense(startDate, endDate),
reportsApi.netWorth(startDate, endDate),
reportsApi.cashFlow(startDate, endDate),
])
spending.value = results[0].status === 'fulfilled' ? results[0].value.data : null
incomeExpense.value = results[1].status === 'fulfilled' ? results[1].value.data : null
netWorth.value = results[2].status === 'fulfilled' ? results[2].value.data : null
cashFlow.value = results[3].status === 'fulfilled' ? results[3].value.data : null
const failed = results.filter(r => r.status === 'rejected')
if (failed.length > 0) {
showError('Some reports failed to load')
}
} catch {
showError('Failed to load reports')
} finally {
loading.value = false
}
}
async function loadSpending() {
const { startDate, endDate } = dateRange.value
try {
const { data } = await reportsApi.spendingByCategory(startDate, endDate, selectedAccountId.value)
spending.value = data
} catch {
showError('Failed to load spending report')
}
}
function showError(text: string) {
snackbarText.value = text
snackbar.value = true
}
watch(dateRange, () => loadReports())
watch(selectedAccountId, () => loadSpending())
onMounted(async () => {
if (accountsStore.accounts.length === 0) {
await accountsStore.fetchAccounts()
}
await loadReports()
})
</script>