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:
@@ -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`),
|
||||
}
|
||||
@@ -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 },
|
||||
}),
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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-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