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:
@@ -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`),
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -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`),
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user