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,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>
|
||||
Reference in New Issue
Block a user