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
+19
View File
@@ -0,0 +1,19 @@
import api from './api'
import type { Budget, BudgetItem } from '@/types'
export const budgetsApi = {
getByMonth: (year: number, month: number) =>
api.get<Budget>(`/budgets/${year}/${month}`),
create: (data: { year: number; month: number; notes?: string; items: { categoryId: string; budgetedAmount: number }[] }) =>
api.post<Budget>('/budgets', data),
update: (id: string, data: { notes?: string; items: { categoryId: string; budgetedAmount: number }[] }) =>
api.put<Budget>(`/budgets/${id}`, data),
delete: (id: string) =>
api.delete(`/budgets/${id}`),
getActuals: (id: string) =>
api.get<BudgetItem[]>(`/budgets/${id}/actual`),
}
+16
View File
@@ -0,0 +1,16 @@
import api from './api'
import type { LoanDetail, PayoffScenario } from '@/types'
export const loansApi = {
getLoanDetail: (accountId: string) =>
api.get<LoanDetail>(`/loans/${accountId}/amortization`),
createLoanDetail: (accountId: string, data: any) =>
api.post<LoanDetail>(`/loans/${accountId}`, data),
updateLoanDetail: (accountId: string, data: any) =>
api.put<LoanDetail>(`/loans/${accountId}`, data),
calculatePayoff: (accountId: string, data: { extraMonthlyPayment?: number; lumpSumPayment?: number; lumpSumDate?: string }) =>
api.post<PayoffScenario>(`/loans/${accountId}/payoff-scenario`, data),
}
+25
View File
@@ -0,0 +1,25 @@
import api from './api'
import type { ScheduledTransaction } from '@/types'
export const scheduledApi = {
getAll: () =>
api.get<ScheduledTransaction[]>('/scheduled-transactions'),
getById: (id: string) =>
api.get<ScheduledTransaction>(`/scheduled-transactions/${id}`),
create: (data: any) =>
api.post<ScheduledTransaction>('/scheduled-transactions', data),
update: (id: string, data: any) =>
api.put<ScheduledTransaction>(`/scheduled-transactions/${id}`, data),
delete: (id: string) =>
api.delete(`/scheduled-transactions/${id}`),
skip: (id: string) =>
api.post<ScheduledTransaction>(`/scheduled-transactions/${id}/skip`),
postNow: (id: string) =>
api.post(`/scheduled-transactions/${id}/post-now`),
}
+308 -11
View File
@@ -1,28 +1,325 @@
<template> <template>
<div> <div>
<h1 class="text-h4 mb-4">Budgets</h1> <div class="d-flex align-center mb-4">
<v-card> <h1 class="text-h4">Budgets</h1>
</div>
<!-- Month Navigator -->
<v-card class="mb-4">
<v-card-text> <v-card-text>
<v-row class="mb-4"> <v-row align="center" justify="center">
<v-col cols="auto"> <v-col cols="auto">
<v-btn icon @click="prevMonth"><v-icon>mdi-chevron-left</v-icon></v-btn> <v-btn icon variant="text" @click="prevMonth"><v-icon>mdi-chevron-left</v-icon></v-btn>
</v-col> </v-col>
<v-col cols="auto"><h2>{{ monthLabel }}</h2></v-col>
<v-col cols="auto"> <v-col cols="auto">
<v-btn icon @click="nextMonth"><v-icon>mdi-chevron-right</v-icon></v-btn> <h2 class="text-h5">{{ monthLabel }}</h2>
</v-col>
<v-col cols="auto">
<v-btn icon variant="text" @click="nextMonth"><v-icon>mdi-chevron-right</v-icon></v-btn>
</v-col> </v-col>
</v-row> </v-row>
<p class="text-medium-emphasis">Budget management coming in Phase 3. Set monthly spending targets per category and track actual vs budgeted amounts.</p>
</v-card-text> </v-card-text>
</v-card> </v-card>
<!-- Loading -->
<v-progress-linear v-if="loading" indeterminate color="primary" class="mb-4" />
<!-- No Budget State -->
<v-card v-if="!loading && !budget" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1" class="mb-4">mdi-calculator-variant</v-icon>
<h3 class="text-h6 mb-2">No budget for {{ monthLabel }}</h3>
<p class="text-medium-emphasis mb-4">Create a budget to track spending against your targets.</p>
<v-btn color="primary" prepend-icon="mdi-plus" @click="openCreateDialog">Create Budget for {{ monthLabel }}</v-btn>
</v-card>
<!-- Budget Table -->
<v-card v-if="!loading && budget">
<v-card-title class="d-flex align-center">
<span>Budget Items</span>
<v-spacer />
<v-btn size="small" variant="text" color="primary" prepend-icon="mdi-pencil" class="mr-2" @click="openEditDialog">Edit</v-btn>
<v-btn size="small" variant="text" color="error" prepend-icon="mdi-delete" @click="confirmDelete">Delete</v-btn>
</v-card-title>
<v-data-table :headers="headers" :items="budgetItems" density="compact" :items-per-page="-1" hide-default-footer>
<template #item.budgetedAmount="{ item }">
{{ formatCurrency(item.budgetedAmount) }}
</template>
<template #item.actualAmount="{ item }">
{{ formatCurrency(item.actualAmount) }}
</template>
<template #item.remaining="{ item }">
<span :class="item.budgetedAmount - item.actualAmount >= 0 ? 'text-success' : 'text-error'">
{{ formatCurrency(item.budgetedAmount - item.actualAmount) }}
</span>
</template>
<template #item.progress="{ item }">
<div class="d-flex align-center" style="min-width: 150px">
<v-progress-linear
:model-value="item.budgetedAmount > 0 ? (item.actualAmount / item.budgetedAmount) * 100 : 0"
:color="getProgressColor(item.budgetedAmount > 0 ? (item.actualAmount / item.budgetedAmount) * 100 : 0)"
height="8"
rounded
class="mr-2"
/>
<span class="text-caption" style="min-width: 40px">
{{ item.budgetedAmount > 0 ? Math.round((item.actualAmount / item.budgetedAmount) * 100) : 0 }}%
</span>
</div>
</template>
<template #body.append>
<tr class="font-weight-bold bg-grey-lighten-4">
<td>Total</td>
<td>{{ formatCurrency(totalBudgeted) }}</td>
<td>{{ formatCurrency(totalActual) }}</td>
<td>
<span :class="totalBudgeted - totalActual >= 0 ? 'text-success' : 'text-error'">
{{ formatCurrency(totalBudgeted - totalActual) }}
</span>
</td>
<td>
<div class="d-flex align-center" style="min-width: 150px">
<v-progress-linear
:model-value="totalBudgeted > 0 ? (totalActual / totalBudgeted) * 100 : 0"
:color="getProgressColor(totalBudgeted > 0 ? (totalActual / totalBudgeted) * 100 : 0)"
height="8"
rounded
class="mr-2"
/>
<span class="text-caption" style="min-width: 40px">
{{ totalBudgeted > 0 ? Math.round((totalActual / totalBudgeted) * 100) : 0 }}%
</span>
</div>
</td>
</tr>
</template>
</v-data-table>
</v-card>
<!-- Create/Edit Budget Dialog -->
<v-dialog v-model="showDialog" max-width="700" persistent>
<v-card>
<v-card-title>{{ editing ? 'Edit Budget' : 'Create Budget' }} - {{ monthLabel }}</v-card-title>
<v-card-text>
<v-text-field v-model="dialogForm.notes" label="Notes (optional)" class="mb-4" />
<div v-for="(item, index) in dialogForm.items" :key="index" class="d-flex align-center mb-2">
<v-select
v-model="item.categoryId"
:items="expenseCategories"
item-title="name"
item-value="id"
label="Category"
density="compact"
class="mr-2"
style="flex: 2"
/>
<v-text-field
v-model.number="item.budgetedAmount"
label="Amount"
type="number"
prefix="$"
density="compact"
style="flex: 1"
class="mr-2"
/>
<v-btn icon size="small" variant="text" color="error" @click="dialogForm.items.splice(index, 1)">
<v-icon size="small">mdi-close</v-icon>
</v-btn>
</div>
<v-btn variant="text" color="primary" prepend-icon="mdi-plus" size="small" @click="addDialogItem">Add Category</v-btn>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="showDialog = false">Cancel</v-btn>
<v-btn color="primary" :loading="saving" @click="saveBudget">Save</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Delete Confirmation -->
<v-dialog v-model="showDeleteDialog" max-width="400">
<v-card>
<v-card-title>Delete Budget</v-card-title>
<v-card-text>Are you sure you want to delete the budget for {{ monthLabel }}? This cannot be undone.</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="showDeleteDialog = false">Cancel</v-btn>
<v-btn color="error" :loading="saving" @click="deleteBudget">Delete</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Snackbar -->
<v-snackbar v-model="snackbar" :color="snackbarColor" :timeout="3000">{{ snackbarText }}</v-snackbar>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { budgetsApi } from '@/services/budgets'
import { categoriesApi } from '@/services/categories'
import { formatCurrency } from '@/utils/formatters'
import type { Budget, BudgetItem, Category } from '@/types'
const year = ref(new Date().getFullYear()) const year = ref(new Date().getFullYear())
const month = ref(new Date().getMonth() + 1) const month = ref(new Date().getMonth() + 1)
const monthLabel = computed(() => new Date(year.value, month.value - 1).toLocaleDateString('en-US', { year: 'numeric', month: 'long' })) const budget = ref<Budget | null>(null)
function prevMonth() { if (month.value === 1) { month.value = 12; year.value-- } else month.value-- } const categories = ref<Category[]>([])
function nextMonth() { if (month.value === 12) { month.value = 1; year.value++ } else month.value++ } const loading = ref(false)
const saving = ref(false)
const showDialog = ref(false)
const showDeleteDialog = ref(false)
const editing = ref(false)
const snackbar = ref(false)
const snackbarText = ref('')
const snackbarColor = ref('success')
const monthLabel = computed(() =>
new Date(year.value, month.value - 1).toLocaleDateString('en-US', { year: 'numeric', month: 'long' })
)
const expenseCategories = computed(() =>
categories.value.filter(c => c.type === 'Expense' && c.isActive)
)
const budgetItems = computed(() => budget.value?.items || [])
const totalBudgeted = computed(() =>
budgetItems.value.reduce((sum, item) => sum + item.budgetedAmount, 0)
)
const totalActual = computed(() =>
budgetItems.value.reduce((sum, item) => sum + item.actualAmount, 0)
)
const headers = [
{ title: 'Category', key: 'categoryName' },
{ title: 'Budgeted', key: 'budgetedAmount', align: 'end' as const },
{ title: 'Actual', key: 'actualAmount', align: 'end' as const },
{ title: 'Remaining', key: 'remaining', align: 'end' as const },
{ title: '% Used', key: 'progress', width: '200px' },
]
const dialogForm = ref<{ notes: string; items: { categoryId: string; budgetedAmount: number }[] }>({
notes: '',
items: [],
})
function prevMonth() {
if (month.value === 1) { month.value = 12; year.value-- }
else month.value--
}
function nextMonth() {
if (month.value === 12) { month.value = 1; year.value++ }
else month.value++
}
function getProgressColor(percent: number): string {
if (percent > 100) return 'error'
if (percent >= 80) return 'warning'
return 'success'
}
async function loadBudget() {
loading.value = true
budget.value = null
try {
const { data } = await budgetsApi.getByMonth(year.value, month.value)
budget.value = data
} catch (err: any) {
if (err.response?.status === 404) {
budget.value = null
} else {
showSnackbar('Failed to load budget', 'error')
}
} finally {
loading.value = false
}
}
async function loadCategories() {
try {
const { data } = await categoriesApi.getAll()
categories.value = data
} catch {
showSnackbar('Failed to load categories', 'error')
}
}
function openCreateDialog() {
editing.value = false
dialogForm.value = {
notes: '',
items: expenseCategories.value.map(c => ({ categoryId: c.id, budgetedAmount: 0 })),
}
showDialog.value = true
}
function openEditDialog() {
if (!budget.value) return
editing.value = true
dialogForm.value = {
notes: budget.value.notes || '',
items: budget.value.items.map(item => ({
categoryId: item.categoryId,
budgetedAmount: item.budgetedAmount,
})),
}
showDialog.value = true
}
function addDialogItem() {
dialogForm.value.items.push({ categoryId: '', budgetedAmount: 0 })
}
async function saveBudget() {
saving.value = true
try {
const items = dialogForm.value.items.filter(i => i.categoryId && i.budgetedAmount > 0)
if (editing.value && budget.value) {
await budgetsApi.update(budget.value.id, { notes: dialogForm.value.notes || undefined, items })
} else {
await budgetsApi.create({ year: year.value, month: month.value, notes: dialogForm.value.notes || undefined, items })
}
showDialog.value = false
showSnackbar(editing.value ? 'Budget updated' : 'Budget created')
await loadBudget()
} catch {
showSnackbar('Failed to save budget', 'error')
} finally {
saving.value = false
}
}
function confirmDelete() {
showDeleteDialog.value = true
}
async function deleteBudget() {
if (!budget.value) return
saving.value = true
try {
await budgetsApi.delete(budget.value.id)
showDeleteDialog.value = false
showSnackbar('Budget deleted')
budget.value = null
} catch {
showSnackbar('Failed to delete budget', 'error')
} finally {
saving.value = false
}
}
function showSnackbar(text: string, color = 'success') {
snackbarText.value = text
snackbarColor.value = color
snackbar.value = true
}
watch([year, month], () => loadBudget())
onMounted(async () => {
await loadCategories()
await loadBudget()
})
</script> </script>
+398 -3
View File
@@ -1,10 +1,405 @@
<template> <template>
<div> <div>
<h1 class="text-h4 mb-4">Loan Details</h1> <!-- Loan List (no id selected) -->
<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> <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> </div>
</template> </template>
<script setup lang="ts"> <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> </script>
+296 -2
View File
@@ -1,6 +1,300 @@
<template> <template>
<div> <div>
<h1 class="text-h4 mb-4">Scheduled Transactions</h1> <div class="d-flex align-center mb-4">
<v-card><v-card-text><p class="text-medium-emphasis">Recurring bill management coming in Phase 3. Set up automatic recurring transactions with reminders.</p></v-card-text></v-card> <h1 class="text-h4">Scheduled Transactions</h1>
<v-spacer />
<v-btn color="primary" prepend-icon="mdi-plus" @click="openAddDialog">Add Scheduled Transaction</v-btn>
</div>
<v-card>
<v-data-table :headers="headers" :items="transactions" :loading="loading" density="compact" :items-per-page="25">
<template #item.nextDueDate="{ item }">{{ formatDate(item.nextDueDate) }}</template>
<template #item.amount="{ item }">
<span :class="item.type === 'Credit' ? 'text-success' : 'text-error'">
{{ formatCurrency(item.amount) }}
</span>
</template>
<template #item.frequency="{ item }">{{ frequencyLabel(item.frequency) }}</template>
<template #item.autoPost="{ item }">
<v-icon :color="item.autoPost ? 'success' : 'grey'" size="small">
{{ item.autoPost ? 'mdi-check-circle' : 'mdi-close-circle' }}
</v-icon>
</template>
<template #item.isActive="{ item }">
<v-chip :color="item.isActive ? 'success' : 'grey'" size="x-small">
{{ item.isActive ? 'Active' : 'Inactive' }}
</v-chip>
</template>
<template #item.actions="{ item }">
<v-btn icon size="x-small" variant="text" @click="openEditDialog(item)" title="Edit">
<v-icon size="small">mdi-pencil</v-icon>
</v-btn>
<v-btn icon size="x-small" variant="text" color="warning" @click="skipTransaction(item.id)" title="Skip Next">
<v-icon size="small">mdi-skip-next</v-icon>
</v-btn>
<v-btn icon size="x-small" variant="text" color="primary" @click="postNow(item.id)" title="Post Now">
<v-icon size="small">mdi-send</v-icon>
</v-btn>
<v-btn icon size="x-small" variant="text" color="error" @click="confirmDelete(item)" title="Delete">
<v-icon size="small">mdi-delete</v-icon>
</v-btn>
</template>
</v-data-table>
</v-card>
<!-- Add/Edit Dialog -->
<v-dialog v-model="showDialog" max-width="600" persistent>
<v-card>
<v-card-title>{{ editingId ? 'Edit' : 'Add' }} Scheduled Transaction</v-card-title>
<v-card-text>
<v-select
v-model="form.accountId"
label="Account"
:items="accountsStore.accounts"
item-title="name"
item-value="id"
/>
<v-row>
<v-col cols="8">
<v-text-field v-model.number="form.amount" label="Amount" type="number" prefix="$" />
</v-col>
<v-col cols="4">
<v-select v-model="form.type" label="Type" :items="['Debit', 'Credit']" />
</v-col>
</v-row>
<v-text-field v-model="form.payeeName" label="Payee" />
<v-select
v-model="form.categoryId"
label="Category"
:items="categories"
item-title="name"
item-value="id"
clearable
/>
<v-text-field v-model="form.memo" label="Memo" />
<v-select v-model="form.frequency" label="Frequency" :items="frequencyOptions" />
<v-row>
<v-col cols="6">
<v-text-field v-model="form.nextDueDate" label="Next Due Date" type="date" />
</v-col>
<v-col cols="6">
<v-text-field v-model="form.endDate" label="End Date (optional)" type="date" clearable />
</v-col>
</v-row>
<v-row>
<v-col cols="6">
<v-switch v-model="form.autoPost" label="Auto-Post" color="primary" />
</v-col>
<v-col cols="6">
<v-text-field v-model.number="form.reminderDaysBefore" label="Reminder Days Before" type="number" min="0" />
</v-col>
</v-row>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="showDialog = false">Cancel</v-btn>
<v-btn color="primary" :loading="saving" @click="saveTransaction">Save</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Delete Confirmation -->
<v-dialog v-model="showDeleteDialog" max-width="400">
<v-card>
<v-card-title>Delete Scheduled Transaction</v-card-title>
<v-card-text>Are you sure you want to delete the scheduled transaction for "{{ deletingName }}"?</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="showDeleteDialog = false">Cancel</v-btn>
<v-btn color="error" :loading="saving" @click="deleteTransaction">Delete</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Snackbar -->
<v-snackbar v-model="snackbar" :color="snackbarColor" :timeout="3000">{{ snackbarText }}</v-snackbar>
</div> </div>
</template> </template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { scheduledApi } from '@/services/scheduled'
import { categoriesApi } from '@/services/categories'
import { useAccountsStore } from '@/stores/accounts'
import { formatCurrency, formatDate } from '@/utils/formatters'
import type { ScheduledTransaction, Category, Frequency } from '@/types'
const accountsStore = useAccountsStore()
const transactions = ref<ScheduledTransaction[]>([])
const categories = ref<Category[]>([])
const loading = ref(false)
const saving = ref(false)
const showDialog = ref(false)
const showDeleteDialog = ref(false)
const editingId = ref('')
const deletingId = ref('')
const deletingName = ref('')
const snackbar = ref(false)
const snackbarText = ref('')
const snackbarColor = ref('success')
const frequencyOptions: Frequency[] = ['Daily', 'Weekly', 'BiWeekly', 'SemiMonthly', 'Monthly', 'Quarterly', 'SemiAnnually', 'Annually']
const headers = [
{ title: 'Next Due', key: 'nextDueDate', width: '110px' },
{ title: 'Payee', key: 'payeeName' },
{ title: 'Amount', key: 'amount', align: 'end' as const },
{ title: 'Account', key: 'accountName' },
{ title: 'Frequency', key: 'frequency' },
{ title: 'Auto-Post', key: 'autoPost', align: 'center' as const, width: '90px' },
{ title: 'Status', key: 'isActive', width: '90px' },
{ title: 'Actions', key: 'actions', sortable: false, width: '160px' },
]
const defaultForm = () => ({
accountId: '',
amount: 0,
type: 'Debit' as 'Debit' | 'Credit',
payeeName: '',
categoryId: null as string | null,
memo: '',
frequency: 'Monthly' as Frequency,
nextDueDate: new Date().toISOString().split('T')[0],
endDate: null as string | null,
autoPost: false,
reminderDaysBefore: 3,
})
const form = ref(defaultForm())
function frequencyLabel(freq: string): string {
const labels: Record<string, string> = {
Daily: 'Daily',
Weekly: 'Weekly',
BiWeekly: 'Bi-Weekly',
SemiMonthly: 'Semi-Monthly',
Monthly: 'Monthly',
Quarterly: 'Quarterly',
SemiAnnually: 'Semi-Annually',
Annually: 'Annually',
}
return labels[freq] || freq
}
async function loadTransactions() {
loading.value = true
try {
const { data } = await scheduledApi.getAll()
transactions.value = data
} catch {
showSnackbar('Failed to load scheduled transactions', 'error')
} finally {
loading.value = false
}
}
async function loadCategories() {
try {
const { data } = await categoriesApi.getAll()
categories.value = data
} catch {
// categories are optional for display
}
}
function openAddDialog() {
editingId.value = ''
form.value = defaultForm()
showDialog.value = true
}
function openEditDialog(txn: ScheduledTransaction) {
editingId.value = txn.id
form.value = {
accountId: txn.accountId,
amount: Math.abs(txn.amount),
type: txn.type === 'Credit' ? 'Credit' : 'Debit',
payeeName: txn.payeeName || '',
categoryId: txn.categoryId,
memo: txn.memo || '',
frequency: txn.frequency,
nextDueDate: txn.nextDueDate.split('T')[0],
endDate: txn.endDate ? txn.endDate.split('T')[0] : null,
autoPost: txn.autoPost,
reminderDaysBefore: txn.reminderDaysBefore,
}
showDialog.value = true
}
async function saveTransaction() {
saving.value = true
try {
const amount = form.value.type === 'Debit' ? -Math.abs(form.value.amount) : Math.abs(form.value.amount)
const payload = { ...form.value, amount, endDate: form.value.endDate || undefined }
if (editingId.value) {
await scheduledApi.update(editingId.value, payload)
showSnackbar('Scheduled transaction updated')
} else {
await scheduledApi.create(payload)
showSnackbar('Scheduled transaction created')
}
showDialog.value = false
await loadTransactions()
} catch {
showSnackbar('Failed to save scheduled transaction', 'error')
} finally {
saving.value = false
}
}
async function skipTransaction(id: string) {
try {
await scheduledApi.skip(id)
showSnackbar('Next occurrence skipped')
await loadTransactions()
} catch {
showSnackbar('Failed to skip transaction', 'error')
}
}
async function postNow(id: string) {
try {
await scheduledApi.postNow(id)
showSnackbar('Transaction posted successfully')
await loadTransactions()
} catch {
showSnackbar('Failed to post transaction', 'error')
}
}
function confirmDelete(txn: ScheduledTransaction) {
deletingId.value = txn.id
deletingName.value = txn.payeeName || 'this transaction'
showDeleteDialog.value = true
}
async function deleteTransaction() {
saving.value = true
try {
await scheduledApi.delete(deletingId.value)
showDeleteDialog.value = false
showSnackbar('Scheduled transaction deleted')
await loadTransactions()
} catch {
showSnackbar('Failed to delete scheduled transaction', 'error')
} finally {
saving.value = false
}
}
function showSnackbar(text: string, color = 'success') {
snackbarText.value = text
snackbarColor.value = color
snackbar.value = true
}
onMounted(async () => {
await accountsStore.fetchAccounts()
await Promise.all([loadTransactions(), loadCategories()])
})
</script>