e2b69b41f4
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>
474 lines
19 KiB
Vue
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>
|