Private
Public Access
1
0

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:
Catherine Renelle
2026-02-08 14:14:23 -05:00
parent d0c3e3913c
commit eb49147080
14 changed files with 1084 additions and 18 deletions
+321
View File
@@ -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>