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:
@@ -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