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