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
@@ -0,0 +1,289 @@
<template>
<div>
<h1 class="text-h4 mb-4">Bank Reconciliation</h1>
<v-stepper v-model="step" :items="['Setup', 'Reconcile', 'Complete']" alt-labels>
<!-- Step 1: Setup -->
<template v-slot:item.1>
<v-card flat>
<v-card-text>
<v-row>
<v-col cols="12" md="6">
<v-select
v-model="setupForm.accountId"
label="Account"
:items="activeAccounts"
item-title="name"
item-value="id"
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="setupForm.statementDate"
label="Statement Date"
type="date"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model.number="setupForm.statementBalance"
label="Statement Ending Balance"
type="number"
prefix="$"
/>
</v-col>
</v-row>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
color="primary"
:loading="loading"
:disabled="!setupForm.accountId || !setupForm.statementDate"
@click="startReconciliation"
>
Start Reconciliation
</v-btn>
</v-card-actions>
</v-card>
</template>
<!-- Step 2: Reconcile -->
<template v-slot:item.2>
<v-card flat>
<v-card-text>
<v-row class="mb-4">
<v-col cols="12" md="4">
<v-card variant="outlined">
<v-card-text class="text-center">
<div class="text-caption text-medium-emphasis">Statement Balance</div>
<div class="text-h5">{{ formatCurrency(reconciliation?.statementBalance || 0) }}</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="4">
<v-card variant="outlined">
<v-card-text class="text-center">
<div class="text-caption text-medium-emphasis">Cleared Balance</div>
<div class="text-h5">{{ formatCurrency(clearedBalance) }}</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="4">
<v-card variant="outlined" :color="difference === 0 ? 'success' : undefined">
<v-card-text class="text-center">
<div class="text-caption" :class="difference === 0 ? '' : 'text-medium-emphasis'">Difference</div>
<div class="text-h5">{{ formatCurrency(difference) }}</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<v-data-table
v-model="selectedIds"
:headers="txnHeaders"
:items="unclearedTransactions"
show-select
item-value="id"
density="compact"
:items-per-page="-1"
hide-default-footer
@update:model-value="onSelectionChange"
>
<template #item.date="{ item }">{{ formatDate(item.date) }}</template>
<template #item.amount="{ item }">
<span :class="item.amount >= 0 ? 'text-success' : 'text-error'">
{{ formatCurrency(item.amount) }}
</span>
</template>
<template #item.status="{ item }">
<v-chip :color="item.status === 'Cleared' ? 'info' : 'default'" size="x-small">
{{ item.status }}
</v-chip>
</template>
</v-data-table>
</v-card-text>
<v-card-actions>
<v-btn color="error" variant="text" @click="cancelReconciliation">Cancel</v-btn>
<v-spacer />
<v-btn
color="primary"
:loading="loading"
:disabled="difference !== 0"
@click="completeReconciliation"
>
Complete Reconciliation
</v-btn>
</v-card-actions>
</v-card>
</template>
<!-- Step 3: Complete -->
<template v-slot:item.3>
<v-card flat class="text-center pa-8">
<v-icon size="64" color="success" class="mb-4">mdi-check-circle</v-icon>
<h2 class="text-h5 mb-2">Reconciliation Complete</h2>
<p class="text-medium-emphasis mb-4">All transactions have been reconciled successfully.</p>
<v-btn color="primary" @click="reset">Start New Reconciliation</v-btn>
</v-card>
</template>
</v-stepper>
<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 { reconciliationApi } from '@/services/reconciliation'
import { transactionsApi } from '@/services/transactions'
import { useAccountsStore } from '@/stores/accounts'
import { formatCurrency, formatDate } from '@/utils/formatters'
import type { ReconciliationResponse, Transaction } from '@/types'
const accountsStore = useAccountsStore()
const step = ref(1)
const loading = ref(false)
const reconciliation = ref<ReconciliationResponse | null>(null)
const unclearedTransactions = ref<Transaction[]>([])
const selectedIds = ref<string[]>([])
const snackbar = ref(false)
const snackbarText = ref('')
const snackbarColor = ref('success')
const setupForm = ref({
accountId: '',
statementDate: new Date().toISOString().split('T')[0],
statementBalance: 0
})
const activeAccounts = computed(() =>
accountsStore.accounts.filter(a => a.isActive && !a.isClosed)
)
const txnHeaders = [
{ title: 'Date', key: 'date', width: '100px' },
{ title: 'Payee', key: 'payeeName' },
{ title: 'Memo', key: 'memo' },
{ title: 'Amount', key: 'amount', align: 'end' as const },
{ title: 'Status', key: 'status', width: '100px' },
]
const clearedBalance = computed(() => {
const serverCleared = reconciliation.value?.clearedBalance || 0
const newlyChecked = unclearedTransactions.value
.filter(t => selectedIds.value.includes(t.id) && t.status !== 'Cleared')
.reduce((sum, t) => sum + t.amount, 0)
const uncheckedCleared = unclearedTransactions.value
.filter(t => !selectedIds.value.includes(t.id) && t.status === 'Cleared')
.reduce((sum, t) => sum + t.amount, 0)
return serverCleared + newlyChecked - uncheckedCleared
})
const difference = computed(() => {
if (!reconciliation.value) return 0
return Math.round((reconciliation.value.statementBalance - clearedBalance.value) * 100) / 100
})
onMounted(async () => {
await accountsStore.fetchAccounts()
})
async function startReconciliation() {
loading.value = true
try {
const { data } = await reconciliationApi.start(setupForm.value.accountId, {
statementDate: setupForm.value.statementDate,
statementBalance: setupForm.value.statementBalance
})
reconciliation.value = data
unclearedTransactions.value = data.unclearedTransactions
selectedIds.value = data.unclearedTransactions
.filter(t => t.status === 'Cleared')
.map(t => t.id)
step.value = 2
} catch {
showSnackbar('Failed to start reconciliation', 'error')
} finally {
loading.value = false
}
}
async function onSelectionChange(newSelection: string[]) {
const added = newSelection.filter(id => !selectedIds.value.includes(id))
const removed = selectedIds.value.filter(id => !newSelection.includes(id))
for (const id of added) {
const txn = unclearedTransactions.value.find(t => t.id === id)
if (txn && txn.status !== 'Cleared') {
try {
await transactionsApi.update(id, { ...txn, status: 'Cleared', splits: txn.splits })
txn.status = 'Cleared'
} catch {
showSnackbar('Failed to update transaction status', 'error')
}
}
}
for (const id of removed) {
const txn = unclearedTransactions.value.find(t => t.id === id)
if (txn && txn.status === 'Cleared') {
try {
await transactionsApi.update(id, { ...txn, status: 'Uncleared', splits: txn.splits })
txn.status = 'Uncleared'
} catch {
showSnackbar('Failed to update transaction status', 'error')
}
}
}
selectedIds.value = newSelection
}
async function completeReconciliation() {
if (!reconciliation.value) return
loading.value = true
try {
await reconciliationApi.complete(setupForm.value.accountId, reconciliation.value.id)
step.value = 3
} catch {
showSnackbar('Failed to complete reconciliation', 'error')
} finally {
loading.value = false
}
}
async function cancelReconciliation() {
if (!reconciliation.value) return
loading.value = true
try {
await reconciliationApi.cancel(setupForm.value.accountId, reconciliation.value.id)
showSnackbar('Reconciliation cancelled')
reset()
} catch {
showSnackbar('Failed to cancel reconciliation', 'error')
} finally {
loading.value = false
}
}
function reset() {
step.value = 1
reconciliation.value = null
unclearedTransactions.value = []
selectedIds.value = []
setupForm.value = {
accountId: '',
statementDate: new Date().toISOString().split('T')[0],
statementBalance: 0
}
}
function showSnackbar(text: string, color = 'success') {
snackbarText.value = text
snackbarColor.value = color
snackbar.value = true
}
</script>