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