Implement Phase 5: reconciliation, SignalR notifications, payees, splits & plugins
Add full frontend UI for five backend features: bank reconciliation (3-step stepper workflow), payee management (CRUD with alias chips), transaction splits (expandable rows + split editor), plugins (card grid), and real-time SignalR notifications (bell dropdown with badge). Fix backend PayeeResponse to expose alias IDs for deletion. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="d-flex align-center mb-4">
|
||||
<h1 class="text-h4">Payees</h1>
|
||||
<v-spacer />
|
||||
<v-btn color="primary" prepend-icon="mdi-plus" @click="openAddDialog">Add Payee</v-btn>
|
||||
</div>
|
||||
|
||||
<v-card class="mb-4">
|
||||
<v-card-text>
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field v-model="search" label="Search" prepend-icon="mdi-magnify" clearable density="compact" />
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-select v-model="statusFilter" label="Status" :items="['All', 'Active', 'Inactive']" density="compact" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-card>
|
||||
<v-data-table :headers="headers" :items="filteredPayees" :loading="loading" density="compact" :items-per-page="25">
|
||||
<template #item.defaultCategoryName="{ item }">
|
||||
{{ item.defaultCategoryName || '—' }}
|
||||
</template>
|
||||
<template #item.aliases="{ item }">
|
||||
<v-chip
|
||||
v-for="a in item.aliases"
|
||||
:key="a.id"
|
||||
size="small"
|
||||
closable
|
||||
class="mr-1 mb-1"
|
||||
@click:close="removeAlias(item.id, a.id)"
|
||||
>
|
||||
{{ a.alias }}
|
||||
</v-chip>
|
||||
<v-btn
|
||||
icon
|
||||
size="x-small"
|
||||
variant="text"
|
||||
color="primary"
|
||||
@click="openAliasDialog(item)"
|
||||
>
|
||||
<v-icon size="small">mdi-plus</v-icon>
|
||||
</v-btn>
|
||||
</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)">
|
||||
<v-icon size="small">mdi-pencil</v-icon>
|
||||
</v-btn>
|
||||
<v-btn icon size="x-small" variant="text" color="error" @click="confirmDelete(item)">
|
||||
<v-icon size="small">mdi-delete</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
|
||||
<!-- Add/Edit Payee Dialog -->
|
||||
<v-dialog v-model="showDialog" max-width="600" persistent>
|
||||
<v-card>
|
||||
<v-card-title>{{ editingId ? 'Edit Payee' : 'Add Payee' }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field v-model="form.name" label="Name" />
|
||||
<v-select
|
||||
v-model="form.defaultCategoryId"
|
||||
label="Default Category"
|
||||
:items="categories"
|
||||
item-title="name"
|
||||
item-value="id"
|
||||
clearable
|
||||
/>
|
||||
<v-switch
|
||||
v-if="editingId"
|
||||
v-model="form.isActive"
|
||||
label="Active"
|
||||
color="primary"
|
||||
/>
|
||||
<div v-if="!editingId">
|
||||
<div class="text-subtitle-2 mb-2">Aliases</div>
|
||||
<div v-for="(alias, index) in form.aliases" :key="index" class="d-flex align-center mb-2">
|
||||
<v-text-field
|
||||
v-model="form.aliases[index]"
|
||||
label="Alias"
|
||||
density="compact"
|
||||
class="mr-2"
|
||||
/>
|
||||
<v-btn icon size="small" variant="text" color="error" @click="form.aliases.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="form.aliases.push('')">
|
||||
Add Alias
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn @click="showDialog = false">Cancel</v-btn>
|
||||
<v-btn color="primary" :loading="saving" @click="savePayee">Save</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Add Alias Dialog -->
|
||||
<v-dialog v-model="showAliasDialog" max-width="400">
|
||||
<v-card>
|
||||
<v-card-title>Add Alias to {{ aliasPayeeName }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field v-model="newAlias" label="Alias" />
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn @click="showAliasDialog = false">Cancel</v-btn>
|
||||
<v-btn color="primary" :loading="saving" @click="addAlias">Add</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 Payee</v-card-title>
|
||||
<v-card-text>Are you sure you want to delete "{{ deletingName }}"? 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="deletePayee">Delete</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-snackbar v-model="snackbar" :color="snackbarColor" :timeout="3000">{{ snackbarText }}</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { payeesApi } from '@/services/payees'
|
||||
import { categoriesApi } from '@/services/categories'
|
||||
import type { Payee, Category } from '@/types'
|
||||
|
||||
const payees = ref<Payee[]>([])
|
||||
const categories = ref<Category[]>([])
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const search = ref('')
|
||||
const statusFilter = ref('All')
|
||||
const showDialog = ref(false)
|
||||
const showAliasDialog = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const editingId = ref('')
|
||||
const deletingId = ref('')
|
||||
const deletingName = ref('')
|
||||
const aliasPayeeId = ref('')
|
||||
const aliasPayeeName = ref('')
|
||||
const newAlias = ref('')
|
||||
const snackbar = ref(false)
|
||||
const snackbarText = ref('')
|
||||
const snackbarColor = ref('success')
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
defaultCategoryId: null as string | null,
|
||||
isActive: true,
|
||||
aliases: [] as string[]
|
||||
})
|
||||
|
||||
const headers = [
|
||||
{ title: 'Name', key: 'name' },
|
||||
{ title: 'Default Category', key: 'defaultCategoryName' },
|
||||
{ title: 'Aliases', key: 'aliases', sortable: false },
|
||||
{ title: 'Status', key: 'isActive', width: '90px' },
|
||||
{ title: 'Actions', key: 'actions', sortable: false, width: '90px' },
|
||||
]
|
||||
|
||||
const filteredPayees = computed(() => {
|
||||
let result = payees.value
|
||||
if (statusFilter.value === 'Active') result = result.filter(p => p.isActive)
|
||||
else if (statusFilter.value === 'Inactive') result = result.filter(p => !p.isActive)
|
||||
if (search.value) {
|
||||
const s = search.value.toLowerCase()
|
||||
result = result.filter(p =>
|
||||
p.name.toLowerCase().includes(s) ||
|
||||
p.aliases.some(a => a.alias.toLowerCase().includes(s))
|
||||
)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
async function loadPayees() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await payeesApi.getAll()
|
||||
payees.value = data
|
||||
} catch {
|
||||
showSnackbar('Failed to load payees', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCategories() {
|
||||
try {
|
||||
const { data } = await categoriesApi.getAll()
|
||||
categories.value = data
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function openAddDialog() {
|
||||
editingId.value = ''
|
||||
form.value = { name: '', defaultCategoryId: null, isActive: true, aliases: [] }
|
||||
showDialog.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(payee: Payee) {
|
||||
editingId.value = payee.id
|
||||
form.value = {
|
||||
name: payee.name,
|
||||
defaultCategoryId: payee.defaultCategoryId,
|
||||
isActive: payee.isActive,
|
||||
aliases: []
|
||||
}
|
||||
showDialog.value = true
|
||||
}
|
||||
|
||||
async function savePayee() {
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await payeesApi.update(editingId.value, {
|
||||
name: form.value.name,
|
||||
defaultCategoryId: form.value.defaultCategoryId,
|
||||
isActive: form.value.isActive
|
||||
})
|
||||
showSnackbar('Payee updated')
|
||||
} else {
|
||||
const aliases = form.value.aliases.filter(a => a.trim())
|
||||
await payeesApi.create({
|
||||
name: form.value.name,
|
||||
defaultCategoryId: form.value.defaultCategoryId,
|
||||
aliases: aliases.length ? aliases : undefined
|
||||
})
|
||||
showSnackbar('Payee created')
|
||||
}
|
||||
showDialog.value = false
|
||||
await loadPayees()
|
||||
} catch {
|
||||
showSnackbar('Failed to save payee', 'error')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openAliasDialog(payee: Payee) {
|
||||
aliasPayeeId.value = payee.id
|
||||
aliasPayeeName.value = payee.name
|
||||
newAlias.value = ''
|
||||
showAliasDialog.value = true
|
||||
}
|
||||
|
||||
async function addAlias() {
|
||||
if (!newAlias.value.trim()) return
|
||||
saving.value = true
|
||||
try {
|
||||
await payeesApi.addAlias(aliasPayeeId.value, newAlias.value.trim())
|
||||
showAliasDialog.value = false
|
||||
showSnackbar('Alias added')
|
||||
await loadPayees()
|
||||
} catch {
|
||||
showSnackbar('Failed to add alias', 'error')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAlias(payeeId: string, aliasId: string) {
|
||||
try {
|
||||
await payeesApi.removeAlias(payeeId, aliasId)
|
||||
showSnackbar('Alias removed')
|
||||
await loadPayees()
|
||||
} catch {
|
||||
showSnackbar('Failed to remove alias', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(payee: Payee) {
|
||||
deletingId.value = payee.id
|
||||
deletingName.value = payee.name
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
async function deletePayee() {
|
||||
saving.value = true
|
||||
try {
|
||||
await payeesApi.delete(deletingId.value)
|
||||
showDeleteDialog.value = false
|
||||
showSnackbar('Payee deleted')
|
||||
await loadPayees()
|
||||
} catch {
|
||||
showSnackbar('Failed to delete payee', 'error')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function showSnackbar(text: string, color = 'success') {
|
||||
snackbarText.value = text
|
||||
snackbarColor.value = color
|
||||
snackbar.value = true
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadPayees(), loadCategories()])
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user