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
+296 -2
View File
@@ -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>