Private
Public Access
1
0

Implement Phase 3: financial features frontend (budgets, scheduled transactions, loans)

Add API service files and replace stub views with full implementations:
- Budgets: month navigator, budget items table with progress bars, create/edit/delete
- Scheduled Transactions: data table with skip/post-now actions, add/edit dialog
- Loans: loan list, detail view with amortization schedule, payoff scenario calculator

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Catherine Renelle
2026-02-08 12:14:43 -05:00
parent 2ace39c544
commit 23d73736c9
6 changed files with 1062 additions and 16 deletions
+398 -3
View File
@@ -1,10 +1,405 @@
<template>
<div>
<h1 class="text-h4 mb-4">Loan Details</h1>
<v-card><v-card-text><p class="text-medium-emphasis">Loan amortization schedules and payoff scenario calculator coming in Phase 3.</p></v-card-text></v-card>
<!-- Loan List (no id selected) -->
<template v-if="!props.id">
<div class="d-flex align-center mb-4">
<h1 class="text-h4">Loans</h1>
</div>
<v-progress-linear v-if="accountsStore.loading" indeterminate color="primary" class="mb-4" />
<v-row v-if="loanAccounts.length > 0">
<v-col v-for="account in loanAccounts" :key="account.id" cols="12" md="6" lg="4">
<v-card class="cursor-pointer" hover @click="navigateToLoan(account.id)">
<v-card-title class="d-flex align-center">
<v-icon class="mr-2">{{ accountTypeIcon(account.type) }}</v-icon>
{{ account.name }}
</v-card-title>
<v-card-text>
<div class="d-flex justify-space-between mb-1">
<span class="text-medium-emphasis">Balance</span>
<span class="font-weight-bold">{{ formatCurrency(account.balance) }}</span>
</div>
<div v-if="account.interestRate" class="d-flex justify-space-between mb-1">
<span class="text-medium-emphasis">Interest Rate</span>
<span>{{ account.interestRate }}%</span>
</div>
<v-chip size="x-small" :color="account.hasLoanDetail ? 'success' : 'grey'" class="mt-2">
{{ account.hasLoanDetail ? 'Loan details configured' : 'No loan details' }}
</v-chip>
</v-card-text>
</v-card>
</v-col>
</v-row>
<v-card v-else-if="!accountsStore.loading" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1" class="mb-4">mdi-bank-off</v-icon>
<h3 class="text-h6 mb-2">No loan accounts found</h3>
<p class="text-medium-emphasis">Create an Auto Loan, Personal Loan, or Mortgage account to track loan details.</p>
</v-card>
</template>
<!-- Loan Detail (id selected) -->
<template v-else>
<div class="d-flex align-center mb-4">
<v-btn icon variant="text" @click="router.push('/loans')"><v-icon>mdi-arrow-left</v-icon></v-btn>
<h1 class="text-h4 ml-2">{{ loanDetail?.accountName || 'Loan Details' }}</h1>
<v-spacer />
<v-btn v-if="loanDetail" color="primary" variant="text" prepend-icon="mdi-pencil" @click="openEditDialog">Edit</v-btn>
</div>
<v-progress-linear v-if="loading" indeterminate color="primary" class="mb-4" />
<!-- Setup prompt when no loan detail -->
<v-card v-if="!loading && !loanDetail && showSetup" class="text-center pa-8 mb-4">
<v-icon size="64" color="grey-lighten-1" class="mb-4">mdi-file-document-edit</v-icon>
<h3 class="text-h6 mb-2">Set Up Loan Details</h3>
<p class="text-medium-emphasis mb-4">Configure loan parameters to view amortization schedule and run payoff scenarios.</p>
<v-btn color="primary" prepend-icon="mdi-plus" @click="openSetupDialog">Set Up Loan Details</v-btn>
</v-card>
<!-- Loan Summary Card -->
<v-card v-if="loanDetail" class="mb-4">
<v-card-title>Loan Summary</v-card-title>
<v-card-text>
<v-row>
<v-col cols="6" md="3">
<div class="text-medium-emphasis text-caption">Original Balance</div>
<div class="text-h6">{{ formatCurrency(loanDetail.originalBalance) }}</div>
</v-col>
<v-col cols="6" md="3">
<div class="text-medium-emphasis text-caption">Current Balance</div>
<div class="text-h6">{{ formatCurrency(loanDetail.currentBalance) }}</div>
</v-col>
<v-col cols="6" md="3">
<div class="text-medium-emphasis text-caption">Interest Rate</div>
<div class="text-h6">{{ loanDetail.interestRate }}%</div>
</v-col>
<v-col cols="6" md="3">
<div class="text-medium-emphasis text-caption">Monthly Payment</div>
<div class="text-h6">{{ formatCurrency(loanDetail.monthlyPayment) }}</div>
</v-col>
<v-col cols="6" md="3">
<div class="text-medium-emphasis text-caption">Term</div>
<div>{{ loanDetail.termMonths }} months</div>
</v-col>
<v-col cols="6" md="3">
<div class="text-medium-emphasis text-caption">Origination Date</div>
<div>{{ formatDate(loanDetail.originationDate) }}</div>
</v-col>
<v-col cols="6" md="3">
<div class="text-medium-emphasis text-caption">Maturity Date</div>
<div>{{ formatDate(loanDetail.maturityDate) }}</div>
</v-col>
<v-col v-if="loanDetail.escrowAmount" cols="6" md="3">
<div class="text-medium-emphasis text-caption">Escrow</div>
<div>{{ formatCurrency(loanDetail.escrowAmount) }}</div>
</v-col>
<v-col v-if="loanDetail.extraPayment" cols="6" md="3">
<div class="text-medium-emphasis text-caption">Extra Payment</div>
<div>{{ formatCurrency(loanDetail.extraPayment) }}</div>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- Amortization Schedule -->
<v-card v-if="loanDetail" class="mb-4">
<v-card-title>Amortization Schedule</v-card-title>
<v-data-table
:headers="amortHeaders"
:items="loanDetail.amortizationSchedule"
density="compact"
:items-per-page="25"
>
<template #item.paymentDate="{ item }">{{ formatDate(item.paymentDate) }}</template>
<template #item.paymentAmount="{ item }">{{ formatCurrency(item.paymentAmount) }}</template>
<template #item.principalAmount="{ item }">{{ formatCurrency(item.principalAmount) }}</template>
<template #item.interestAmount="{ item }">{{ formatCurrency(item.interestAmount) }}</template>
<template #item.escrowAmount="{ item }">{{ item.escrowAmount != null ? formatCurrency(item.escrowAmount) : '-' }}</template>
<template #item.remainingBalance="{ item }">{{ formatCurrency(item.remainingBalance) }}</template>
</v-data-table>
</v-card>
<!-- Payoff Scenario Calculator -->
<v-card v-if="loanDetail" class="mb-4">
<v-card-title>Payoff Scenario Calculator</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12" md="4">
<v-text-field
v-model.number="payoffForm.extraMonthlyPayment"
label="Extra Monthly Payment"
type="number"
prefix="$"
min="0"
/>
</v-col>
<v-col cols="12" md="4">
<v-text-field
v-model.number="payoffForm.lumpSumPayment"
label="Lump Sum Payment"
type="number"
prefix="$"
min="0"
/>
</v-col>
<v-col cols="12" md="4">
<v-text-field
v-model="payoffForm.lumpSumDate"
label="Lump Sum Date"
type="date"
:disabled="!payoffForm.lumpSumPayment"
/>
</v-col>
</v-row>
<v-btn color="primary" :loading="calculatingPayoff" @click="calculatePayoff">Calculate</v-btn>
<!-- Payoff Results -->
<div v-if="payoffResult" class="mt-6">
<v-row>
<v-col cols="12" md="4">
<v-card variant="outlined" color="success">
<v-card-text class="text-center">
<div class="text-caption text-medium-emphasis">New Payoff Date</div>
<div class="text-h6">{{ formatDate(payoffResult.newPayoffDate) }}</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="4">
<v-card variant="outlined" color="success">
<v-card-text class="text-center">
<div class="text-caption text-medium-emphasis">Interest Saved</div>
<div class="text-h6 text-success">{{ formatCurrency(payoffResult.interestSaved) }}</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="4">
<v-card variant="outlined" color="success">
<v-card-text class="text-center">
<div class="text-caption text-medium-emphasis">Months Saved</div>
<div class="text-h6 text-success">{{ payoffResult.monthsSaved }}</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<v-row class="mt-2">
<v-col cols="12" md="6">
<div class="text-caption text-medium-emphasis">Original Total Interest</div>
<div>{{ formatCurrency(payoffResult.totalInterestOriginal) }}</div>
</v-col>
<v-col cols="12" md="6">
<div class="text-caption text-medium-emphasis">New Total Interest</div>
<div>{{ formatCurrency(payoffResult.totalInterestNew) }}</div>
</v-col>
</v-row>
<v-row class="mt-2">
<v-col cols="12" md="6">
<div class="text-caption text-medium-emphasis">Original Payoff Date</div>
<div>{{ formatDate(payoffResult.originalPayoffDate) }}</div>
</v-col>
<v-col cols="12" md="6">
<div class="text-caption text-medium-emphasis">New Payoff Date</div>
<div>{{ formatDate(payoffResult.newPayoffDate) }}</div>
</v-col>
</v-row>
</div>
</v-card-text>
</v-card>
</template>
<!-- Create/Edit Loan Detail Dialog -->
<v-dialog v-model="showLoanDialog" max-width="600" persistent>
<v-card>
<v-card-title>{{ editingLoan ? 'Edit' : 'Set Up' }} Loan Details</v-card-title>
<v-card-text>
<v-text-field v-model.number="loanForm.originalBalance" label="Original Balance" type="number" prefix="$" />
<v-text-field v-model.number="loanForm.interestRate" label="Interest Rate (%)" type="number" suffix="%" step="0.125" />
<v-text-field v-model.number="loanForm.termMonths" label="Term (months)" type="number" />
<v-text-field v-model.number="loanForm.monthlyPayment" label="Monthly Payment" type="number" prefix="$" />
<v-text-field v-model="loanForm.originationDate" label="Origination Date" type="date" />
<v-text-field v-model.number="loanForm.escrowAmount" label="Escrow Amount (optional)" type="number" prefix="$" />
<v-text-field v-model.number="loanForm.extraPayment" label="Extra Payment (optional)" type="number" prefix="$" />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="showLoanDialog = false">Cancel</v-btn>
<v-btn color="primary" :loading="saving" @click="saveLoanDetail">Save</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Snackbar -->
<v-snackbar v-model="snackbar" :color="snackbarColor" :timeout="3000">{{ snackbarText }}</v-snackbar>
</div>
</template>
<script setup lang="ts">
defineProps<{ id?: string }>()
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { loansApi } from '@/services/loans'
import { useAccountsStore } from '@/stores/accounts'
import { formatCurrency, formatDate, accountTypeIcon } from '@/utils/formatters'
import type { LoanDetail, PayoffScenario } from '@/types'
const props = defineProps<{ id?: string }>()
const router = useRouter()
const accountsStore = useAccountsStore()
const loanDetail = ref<LoanDetail | null>(null)
const payoffResult = ref<PayoffScenario | null>(null)
const loading = ref(false)
const saving = ref(false)
const calculatingPayoff = ref(false)
const showLoanDialog = ref(false)
const showSetup = ref(false)
const editingLoan = ref(false)
const snackbar = ref(false)
const snackbarText = ref('')
const snackbarColor = ref('success')
const loanAccountTypes = ['AutoLoan', 'PersonalLoan', 'Mortgage']
const loanAccounts = computed(() =>
accountsStore.accounts.filter(a => a.hasLoanDetail || loanAccountTypes.includes(a.type))
)
const amortHeaders = [
{ title: '#', key: 'paymentNumber', width: '60px' },
{ title: 'Date', key: 'paymentDate', width: '110px' },
{ title: 'Payment', key: 'paymentAmount', align: 'end' as const },
{ title: 'Principal', key: 'principalAmount', align: 'end' as const },
{ title: 'Interest', key: 'interestAmount', align: 'end' as const },
{ title: 'Escrow', key: 'escrowAmount', align: 'end' as const },
{ title: 'Balance', key: 'remainingBalance', align: 'end' as const },
]
const payoffForm = ref({
extraMonthlyPayment: 0,
lumpSumPayment: 0,
lumpSumDate: '',
})
const loanForm = ref({
originalBalance: 0,
interestRate: 0,
termMonths: 360,
monthlyPayment: 0,
originationDate: '',
escrowAmount: null as number | null,
extraPayment: null as number | null,
})
function navigateToLoan(accountId: string) {
router.push(`/loans/${accountId}`)
}
async function loadLoanDetail() {
if (!props.id) return
loading.value = true
loanDetail.value = null
payoffResult.value = null
showSetup.value = false
try {
const { data } = await loansApi.getLoanDetail(props.id)
loanDetail.value = data
} catch (err: any) {
if (err.response?.status === 404) {
showSetup.value = true
} else {
showSnackbar('Failed to load loan details', 'error')
}
} finally {
loading.value = false
}
}
function openSetupDialog() {
editingLoan.value = false
loanForm.value = {
originalBalance: 0,
interestRate: 0,
termMonths: 360,
monthlyPayment: 0,
originationDate: new Date().toISOString().split('T')[0],
escrowAmount: null,
extraPayment: null,
}
showLoanDialog.value = true
}
function openEditDialog() {
if (!loanDetail.value) return
editingLoan.value = true
loanForm.value = {
originalBalance: loanDetail.value.originalBalance,
interestRate: loanDetail.value.interestRate,
termMonths: loanDetail.value.termMonths,
monthlyPayment: loanDetail.value.monthlyPayment,
originationDate: loanDetail.value.originationDate.split('T')[0],
escrowAmount: loanDetail.value.escrowAmount,
extraPayment: loanDetail.value.extraPayment,
}
showLoanDialog.value = true
}
async function saveLoanDetail() {
if (!props.id) return
saving.value = true
try {
const payload = {
...loanForm.value,
escrowAmount: loanForm.value.escrowAmount || undefined,
extraPayment: loanForm.value.extraPayment || undefined,
}
if (editingLoan.value) {
await loansApi.updateLoanDetail(props.id, payload)
showSnackbar('Loan details updated')
} else {
await loansApi.createLoanDetail(props.id, payload)
showSnackbar('Loan details created')
}
showLoanDialog.value = false
await loadLoanDetail()
} catch {
showSnackbar('Failed to save loan details', 'error')
} finally {
saving.value = false
}
}
async function calculatePayoff() {
if (!props.id) return
calculatingPayoff.value = true
payoffResult.value = null
try {
const { data } = await loansApi.calculatePayoff(props.id, {
extraMonthlyPayment: payoffForm.value.extraMonthlyPayment || undefined,
lumpSumPayment: payoffForm.value.lumpSumPayment || undefined,
lumpSumDate: payoffForm.value.lumpSumDate || undefined,
})
payoffResult.value = data
} catch {
showSnackbar('Failed to calculate payoff scenario', 'error')
} finally {
calculatingPayoff.value = false
}
}
function showSnackbar(text: string, color = 'success') {
snackbarText.value = text
snackbarColor.value = color
snackbar.value = true
}
watch(() => props.id, () => {
if (props.id) loadLoanDetail()
})
onMounted(async () => {
await accountsStore.fetchAccounts()
if (props.id) await loadLoanDetail()
})
</script>