Private
Public Access
1
0
Files
Purrse/frontend/src/views/loans/LoansView.vue
T
Catherine Renelle e2b69b41f4 Show current month's payment in amortization schedule
Filter from start of current month instead of today so the current
month's entry is always visible even mid-month.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 22:35:53 -05:00

474 lines
19 KiB
Vue

<template>
<div>
<!-- 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 text-red">{{ formatCurrency(Math.abs(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 text-red">{{ formatCurrency(Math.abs(loanDetail.currentBalance)) }}</div>
</v-col>
<v-col v-if="payoffAmount != null" cols="6" md="3">
<div class="text-medium-emphasis text-caption">Est. Payoff Amount</div>
<div class="text-h6 text-red">{{ formatCurrency(payoffAmount) }}</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">First Payment 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>
<v-alert v-if="loanDetail" type="info" variant="tonal" density="compact" class="mb-4">
Amounts shown are estimates. Lines of credit and auto loans use daily interest accrual; mortgages and personal loans use standard monthly compounding. Actual values may differ due to payment timing, your lender's rounding practices, extra payments, or rate changes.
</v-alert>
<!-- Amortization Schedule -->
<v-card v-if="loanDetail" class="mb-4">
<v-card-title class="d-flex align-center">
Amortization Schedule
<v-spacer />
<v-btn size="small" variant="text" @click="showPastPayments = !showPastPayments">
{{ showPastPayments ? 'Hide' : 'Show' }} past payments
</v-btn>
</v-card-title>
<v-data-table
:headers="amortHeaders"
:items="filteredSchedule"
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-alert v-if="prePopulated && !editingLoan" type="info" variant="tonal" density="compact" class="mb-4">
Some fields have been pre-populated from your bank sync data. Please review and adjust as needed.
</v-alert>
<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.monthlyPayment" label="Monthly Payment" type="number" prefix="$" />
<v-text-field v-model="loanForm.originationDate" label="First Payment Date" type="date" />
<v-text-field v-model="loanForm.maturityDate" label="Maturity 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">
import { ref, computed, nextTick, 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 { LoanDefaults, 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 prePopulated = ref(false)
const snackbar = ref(false)
const snackbarText = ref('')
const snackbarColor = ref('success')
const showPastPayments = ref(false)
const loanAccountTypes = ['AutoLoan', 'PersonalLoan', 'Mortgage', 'LineOfCredit']
const loanAccounts = computed(() =>
accountsStore.accounts.filter(a => a.hasLoanDetail || loanAccountTypes.includes(a.type))
)
const filteredSchedule = computed(() => {
if (!loanDetail.value) return []
if (showPastPayments.value) return loanDetail.value.amortizationSchedule
const now = new Date()
const monthStart = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`
return loanDetail.value.amortizationSchedule.filter(e => e.paymentDate.split('T')[0] >= monthStart)
})
const payoffAmount = computed(() => {
if (!loanDetail.value || !props.id) return null
const balance = Math.abs(loanDetail.value.currentBalance)
if (balance === 0) return 0
const rate = loanDetail.value.interestRate / 100
const today = new Date()
const todayStr = today.toISOString().split('T')[0]
const schedule = loanDetail.value.amortizationSchedule
const lastPast = [...schedule].reverse().find(e => e.paymentDate.split('T')[0] <= todayStr)
if (!lastPast) return balance
const lastDate = new Date(lastPast.paymentDate.split('T')[0] + 'T00:00:00')
const todayMidnight = new Date(todayStr + 'T00:00:00')
const days = Math.round((todayMidnight.getTime() - lastDate.getTime()) / 86400000)
if (days <= 0) return balance
// Payoff quotes always use daily per-diem interest regardless of loan type
const accruedInterest = balance * rate / 365 * days
return balance + accruedInterest
})
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,
monthlyPayment: 0,
originationDate: '',
maturityDate: '',
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
}
}
async function openSetupDialog() {
editingLoan.value = false
prePopulated.value = false
loanForm.value = {
originalBalance: 0,
interestRate: 0,
monthlyPayment: 0,
originationDate: new Date().toISOString().split('T')[0],
maturityDate: '',
escrowAmount: null,
extraPayment: null,
}
if (props.id) {
try {
const { data } = await loansApi.getLoanDefaults(props.id)
if (data.originalBalance > 0) loanForm.value.originalBalance = data.originalBalance
if (data.interestRate > 0) loanForm.value.interestRate = data.interestRate
prePopulated.value = data.hasSyncData
} catch {
// Fall back to zero defaults
}
}
showLoanDialog.value = true
}
function openEditDialog() {
if (!loanDetail.value) return
editingLoan.value = true
loanForm.value = {
originalBalance: loanDetail.value.originalBalance,
interestRate: loanDetail.value.interestRate,
monthlyPayment: loanDetail.value.monthlyPayment,
originationDate: loanDetail.value.originationDate.split('T')[0],
maturityDate: loanDetail.value.maturityDate.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 [sY, sM, sD] = loanForm.value.originationDate.split('-').map(Number)
const [eY, eM, eD] = loanForm.value.maturityDate.split('-').map(Number)
const termMonths = (eY - sY) * 12 + (eM - sM) + 1
const payload = {
originalBalance: loanForm.value.originalBalance,
interestRate: loanForm.value.interestRate,
termMonths,
monthlyPayment: loanForm.value.monthlyPayment,
originationDate: new Date(Date.UTC(sY, sM - 1, sD)).toISOString(),
maturityDate: new Date(Date.UTC(eY, eM - 1, eD)).toISOString(),
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')
}
} catch {
showSnackbar('Failed to save loan details', 'error')
} finally {
saving.value = false
showLoanDialog.value = false
await nextTick()
await loadLoanDetail()
}
}
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>