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
+12
View File
@@ -8,9 +8,21 @@
</template>
<script setup lang="ts">
import { watch } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useNotifications } from '@/composables/useNotifications'
import AppLayout from '@/components/layout/AppLayout.vue'
const authStore = useAuthStore()
authStore.initialize()
const { connect, disconnect } = useNotifications()
watch(() => authStore.isAuthenticated, (authenticated) => {
if (authenticated) {
connect()
} else {
disconnect()
}
}, { immediate: true })
</script>
+42 -1
View File
@@ -52,7 +52,37 @@
:icon="isDark ? 'mdi-weather-sunny' : 'mdi-weather-night'"
@click="toggleTheme"
/>
<v-btn icon="mdi-bell-outline" />
<v-menu>
<template v-slot:activator="{ props: bellProps }">
<v-btn icon v-bind="bellProps">
<v-badge :content="unreadCount" :model-value="unreadCount > 0" color="error" floating>
<v-icon>mdi-bell-outline</v-icon>
</v-badge>
</v-btn>
</template>
<v-list density="compact" min-width="320" max-height="400">
<v-list-item v-if="notifications.length === 0">
<v-list-item-title class="text-medium-emphasis text-center">No notifications</v-list-item-title>
</v-list-item>
<template v-else>
<v-list-item class="d-flex justify-end">
<v-btn variant="text" size="small" @click="markAllAsRead">Mark all as read</v-btn>
</v-list-item>
<v-divider />
<v-list-item
v-for="n in notifications"
:key="n.id"
:class="{ 'bg-blue-lighten-5': !n.read }"
@click="handleNotificationClick(n)"
>
<v-list-item-title>{{ n.data.fileName }}</v-list-item-title>
<v-list-item-subtitle>
{{ n.data.newTransactions }} new, {{ n.data.possibleDuplicates }} duplicates
</v-list-item-subtitle>
</v-list-item>
</template>
</v-list>
</v-menu>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn icon v-bind="props">
@@ -82,6 +112,8 @@ import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTheme } from 'vuetify'
import { useAuthStore } from '@/stores/auth'
import { useNotifications } from '@/composables/useNotifications'
import type { AppNotification } from '@/types'
const drawer = ref(true)
const rail = ref(false)
@@ -90,6 +122,8 @@ const router = useRouter()
const theme = useTheme()
const authStore = useAuthStore()
const { notifications, unreadCount, markAllAsRead, markAsRead } = useNotifications()
const isDark = computed(() => theme.global.current.value.dark)
const navItems = [
@@ -97,8 +131,10 @@ const navItems = [
{ title: 'Accounts', icon: 'mdi-bank', route: '/accounts' },
{ title: 'Transactions', icon: 'mdi-swap-horizontal', route: '/transactions' },
{ title: 'Categories', icon: 'mdi-tag-multiple', route: '/categories' },
{ title: 'Payees', icon: 'mdi-account-group', route: '/payees' },
{ title: 'Budgets', icon: 'mdi-calculator', route: '/budgets' },
{ title: 'Scheduled', icon: 'mdi-clock-outline', route: '/scheduled' },
{ title: 'Reconciliation', icon: 'mdi-scale-balance', route: '/reconciliation' },
{ title: 'Imports', icon: 'mdi-file-upload', route: '/imports' },
{ title: 'Loans', icon: 'mdi-home-city', route: '/loans' },
{ title: 'Reports', icon: 'mdi-chart-bar', route: '/reports' },
@@ -119,6 +155,11 @@ function toggleTheme() {
theme.global.name.value = isDark.value ? 'light' : 'dark'
}
function handleNotificationClick(n: AppNotification) {
markAsRead(n.id)
router.push('/imports')
}
function handleLogout() {
authStore.logout()
router.push('/login')
@@ -0,0 +1,81 @@
import { ref, computed } from 'vue'
import { HubConnectionBuilder, HubConnection, LogLevel } from '@microsoft/signalr'
import { useAuthStore } from '@/stores/auth'
import type { AppNotification, FileImportNotification } from '@/types'
const notifications = ref<AppNotification[]>([])
const isConnected = ref(false)
let connection: HubConnection | null = null
const unreadCount = computed(() => notifications.value.filter(n => !n.read).length)
function connect() {
if (connection) return
const authStore = useAuthStore()
connection = new HubConnectionBuilder()
.withUrl('/hubs/notifications', {
accessTokenFactory: () => authStore.token || ''
})
.withAutomaticReconnect()
.configureLogging(LogLevel.Warning)
.build()
connection.on('FileImportReady', (data: FileImportNotification) => {
notifications.value.unshift({
id: crypto.randomUUID(),
type: 'FileImportReady',
data,
timestamp: new Date().toISOString(),
read: false
})
})
connection.onclose(() => {
isConnected.value = false
})
connection.onreconnected(() => {
isConnected.value = true
})
connection.start()
.then(() => { isConnected.value = true })
.catch(() => { isConnected.value = false })
}
function disconnect() {
if (connection) {
connection.stop()
connection = null
}
isConnected.value = false
notifications.value = []
}
function markAsRead(id: string) {
const notification = notifications.value.find(n => n.id === id)
if (notification) notification.read = true
}
function markAllAsRead() {
notifications.value.forEach(n => { n.read = true })
}
function clearAll() {
notifications.value = []
}
export function useNotifications() {
return {
notifications,
unreadCount,
isConnected,
connect,
disconnect,
markAsRead,
markAllAsRead,
clearAll
}
}
+2
View File
@@ -9,8 +9,10 @@ const routes = [
{ path: '/accounts/:id/transactions', name: 'account-transactions', component: () => import('@/views/transactions/TransactionsView.vue'), meta: { auth: true }, props: true },
{ path: '/transactions', name: 'transactions', component: () => import('@/views/transactions/TransactionsView.vue'), meta: { auth: true } },
{ path: '/categories', name: 'categories', component: () => import('@/views/categories/CategoriesView.vue'), meta: { auth: true } },
{ path: '/payees', name: 'payees', component: () => import('@/views/payees/PayeesView.vue'), meta: { auth: true } },
{ path: '/budgets', name: 'budgets', component: () => import('@/views/budgets/BudgetsView.vue'), meta: { auth: true } },
{ path: '/scheduled', name: 'scheduled', component: () => import('@/views/scheduled/ScheduledView.vue'), meta: { auth: true } },
{ path: '/reconciliation', name: 'reconciliation', component: () => import('@/views/reconciliation/ReconciliationView.vue'), meta: { auth: true } },
{ path: '/imports', name: 'imports', component: () => import('@/views/imports/ImportsView.vue'), meta: { auth: true } },
{ path: '/loans/:id?', name: 'loans', component: () => import('@/views/loans/LoansView.vue'), meta: { auth: true }, props: true },
{ path: '/reports', name: 'reports', component: () => import('@/views/reports/ReportsView.vue'), meta: { auth: true } },
+12
View File
@@ -0,0 +1,12 @@
import api from './api'
import type { Payee, CreatePayeeRequest, UpdatePayeeRequest } from '@/types'
export const payeesApi = {
getAll: () => api.get<Payee[]>('/payees'),
getById: (id: string) => api.get<Payee>(`/payees/${id}`),
create: (data: CreatePayeeRequest) => api.post<Payee>('/payees', data),
update: (id: string, data: UpdatePayeeRequest) => api.put<Payee>(`/payees/${id}`, data),
delete: (id: string) => api.delete(`/payees/${id}`),
addAlias: (payeeId: string, alias: string) => api.post(`/payees/${payeeId}/aliases`, { alias }),
removeAlias: (payeeId: string, aliasId: string) => api.delete(`/payees/${payeeId}/aliases/${aliasId}`),
}
+6
View File
@@ -0,0 +1,6 @@
import api from './api'
import type { Plugin } from '@/types'
export const pluginsApi = {
getAll: () => api.get<Plugin[]>('/plugins'),
}
+11
View File
@@ -0,0 +1,11 @@
import api from './api'
import type { ReconciliationResponse, StartReconciliationRequest } from '@/types'
export const reconciliationApi = {
start: (accountId: string, data: StartReconciliationRequest) =>
api.post<ReconciliationResponse>(`/reconciliation/${accountId}/start`, data),
complete: (accountId: string, reconciliationId: string) =>
api.post(`/reconciliation/${accountId}/complete`, null, { params: { reconciliationId } }),
cancel: (accountId: string, reconciliationId: string) =>
api.post(`/reconciliation/${accountId}/cancel`, null, { params: { reconciliationId } }),
}
+70 -1
View File
@@ -89,13 +89,18 @@ export interface CategoryTree {
export type CategoryType = 'Income' | 'Expense' | 'Transfer'
export interface PayeeAlias {
id: string
alias: string
}
export interface Payee {
id: string
name: string
defaultCategoryId: string | null
defaultCategoryName: string | null
isActive: boolean
aliases: string[]
aliases: PayeeAlias[]
}
export interface Budget {
@@ -332,3 +337,67 @@ export interface InvestmentPerformance {
totalGainLossPercent: number
holdings: InvestmentHolding[]
}
// Reconciliation
export interface ReconciliationResponse {
id: string
accountId: string
statementDate: string
statementBalance: number
clearedBalance: number
difference: number
isCompleted: boolean
unclearedTransactions: Transaction[]
}
export interface StartReconciliationRequest {
statementDate: string
statementBalance: number
}
// Payee requests
export interface CreatePayeeRequest {
name: string
defaultCategoryId?: string | null
aliases?: string[]
}
export interface UpdatePayeeRequest {
name: string
defaultCategoryId?: string | null
isActive: boolean
}
// Plugins
export interface Plugin {
id: string
name: string
version: string
description: string
}
// SignalR
export interface FileImportNotification {
batchId: string
fileName: string
accountId: string
accountName: string
totalTransactions: number
newTransactions: number
possibleDuplicates: number
}
export interface AppNotification {
id: string
type: 'FileImportReady'
data: FileImportNotification
timestamp: string
read: boolean
}
// Split form helper
export interface SplitFormItem {
categoryId: string | null
amount: number
memo: string
}
+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>
+42 -1
View File
@@ -1,6 +1,47 @@
<template>
<div>
<h1 class="text-h4 mb-4">Plugins</h1>
<v-card><v-card-text><p class="text-medium-emphasis">Plugin marketplace coming in Phase 5. Extend Purrse with community-built integrations and features.</p></v-card-text></v-card>
<v-progress-linear v-if="loading" indeterminate color="primary" class="mb-4" />
<v-alert v-if="!loading && plugins.length === 0" type="info" variant="tonal" class="mb-4">
No plugins installed. Plugins extend Purrse with additional features and integrations.
</v-alert>
<v-row v-if="plugins.length > 0">
<v-col v-for="plugin in plugins" :key="plugin.id" cols="12" sm="6" md="4">
<v-card>
<v-card-item>
<template v-slot:prepend>
<v-icon size="large" color="primary">mdi-puzzle</v-icon>
</template>
<v-card-title>{{ plugin.name }}</v-card-title>
<v-card-subtitle>v{{ plugin.version }}</v-card-subtitle>
</v-card-item>
<v-card-text>{{ plugin.description }}</v-card-text>
</v-card>
</v-col>
</v-row>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { pluginsApi } from '@/services/plugins'
import type { Plugin } from '@/types'
const plugins = ref<Plugin[]>([])
const loading = ref(false)
onMounted(async () => {
loading.value = true
try {
const { data } = await pluginsApi.getAll()
plugins.value = data
} catch {
// silently handle - empty state shown
} finally {
loading.value = false
}
})
</script>
@@ -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>
@@ -4,7 +4,7 @@
<h1 class="text-h4">{{ accountName || 'Transactions' }}</h1>
<v-spacer />
<v-btn color="secondary" prepend-icon="mdi-swap-horizontal" class="mr-2" @click="showTransferDialog = true">Transfer</v-btn>
<v-btn color="primary" prepend-icon="mdi-plus" @click="showAddDialog = true">Add Transaction</v-btn>
<v-btn color="primary" prepend-icon="mdi-plus" @click="openAddDialog">Add Transaction</v-btn>
</div>
<v-card class="mb-4">
@@ -27,14 +27,50 @@
</v-card>
<v-card>
<v-data-table :headers="headers" :items="transactions" :loading="loading" :items-per-page="50" density="compact">
<v-data-table
:headers="headers"
:items="transactions"
:loading="loading"
:items-per-page="50"
density="compact"
show-expand
:expand-on-click="false"
>
<template #item.date="{ item }">{{ formatDate(item.date) }}</template>
<template #item.categoryName="{ item }">
<span v-if="item.splits && item.splits.length > 1">({{ item.splits.length }} splits)</span>
<span v-else>{{ item.categoryName }}</span>
</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 === 'Reconciled' ? 'success' : item.status === 'Cleared' ? 'info' : 'default'" size="x-small">{{ item.status }}</v-chip>
</template>
<template #item.data-table-expand="{ item, internalItem, toggleExpand, isExpanded }">
<v-btn
v-if="item.splits && item.splits.length > 1"
icon
size="x-small"
variant="text"
@click="toggleExpand(internalItem)"
>
<v-icon size="small">{{ isExpanded(internalItem) ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
</v-btn>
</template>
<template #expanded-row="{ columns, item }">
<tr v-for="split in item.splits" :key="split.id" class="bg-grey-lighten-5">
<td></td>
<td></td>
<td></td>
<td>{{ split.categoryName || '—' }}</td>
<td>{{ split.memo || '' }}</td>
<td class="text-end">
<span :class="split.amount >= 0 ? 'text-success' : 'text-error'">{{ formatCurrency(split.amount) }}</span>
</td>
<td :colspan="columns.length - 6"></td>
</tr>
</template>
<template #item.actions="{ item }">
<v-btn icon size="x-small" variant="text" @click="editTransaction(item)"><v-icon size="small">mdi-pencil</v-icon></v-btn>
<v-btn icon size="x-small" variant="text" color="error" @click="deleteTransaction(item.id)"><v-icon size="small">mdi-delete</v-icon></v-btn>
@@ -43,7 +79,7 @@
</v-card>
<!-- Add/Edit Transaction Dialog -->
<v-dialog v-model="showAddDialog" max-width="600">
<v-dialog v-model="showAddDialog" max-width="700">
<v-card>
<v-card-title>{{ editingTxn ? 'Edit Transaction' : 'Add Transaction' }}</v-card-title>
<v-card-text>
@@ -52,13 +88,67 @@
<v-text-field v-model.number="txnForm.amount" label="Amount" type="number" prefix="$" />
<v-select v-model="txnForm.type" label="Type" :items="['Debit','Credit']" />
<v-text-field v-model="txnForm.payeeName" label="Payee" />
<v-select
v-if="!useSplits"
v-model="txnForm.categoryId"
label="Category"
:items="categories"
item-title="name"
item-value="id"
clearable
/>
<v-text-field v-model="txnForm.memo" label="Memo" />
<v-text-field v-model="txnForm.checkNumber" label="Check #" />
<v-switch v-model="useSplits" label="Split across categories" color="primary" class="mt-2" />
<div v-if="useSplits">
<div class="d-flex align-center mb-2">
<span class="text-subtitle-2">Split Items</span>
<v-spacer />
<span class="text-caption" :class="splitRemaining === 0 ? 'text-success' : 'text-error'">
Remaining: {{ formatCurrency(splitRemaining) }}
</span>
</div>
<div v-for="(split, index) in splits" :key="index" class="d-flex align-center mb-2">
<v-select
v-model="split.categoryId"
label="Category"
:items="categories"
item-title="name"
item-value="id"
density="compact"
clearable
class="mr-2"
style="flex: 2"
/>
<v-text-field
v-model.number="split.amount"
label="Amount"
type="number"
prefix="$"
density="compact"
class="mr-2"
style="flex: 1"
/>
<v-text-field
v-model="split.memo"
label="Memo"
density="compact"
class="mr-2"
style="flex: 1"
/>
<v-btn icon size="small" variant="text" color="error" @click="splits.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="addSplit">Add Split</v-btn>
</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="showAddDialog = false">Cancel</v-btn>
<v-btn color="primary" @click="saveTxn">Save</v-btn>
<v-btn color="primary" :disabled="useSplits && splitRemaining !== 0" @click="saveTxn">Save</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
@@ -85,15 +175,17 @@
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { transactionsApi } from '@/services/transactions'
import { categoriesApi } from '@/services/categories'
import { useAccountsStore } from '@/stores/accounts'
import { formatCurrency, formatDate } from '@/utils/formatters'
import type { Transaction } from '@/types'
import type { Transaction, Category, SplitFormItem } from '@/types'
const props = defineProps<{ id?: string }>()
const accountsStore = useAccountsStore()
const transactions = ref<Transaction[]>([])
const categories = ref<Category[]>([])
const loading = ref(false)
const accountName = ref('')
const search = ref('')
@@ -104,6 +196,8 @@ const showAddDialog = ref(false)
const showTransferDialog = ref(false)
const editingTxn = ref(false)
const editingTxnId = ref('')
const useSplits = ref(false)
const splits = ref<SplitFormItem[]>([])
const headers = [
{ title: 'Date', key: 'date', width: '100px' },
@@ -115,11 +209,28 @@ const headers = [
{ title: '', key: 'actions', sortable: false, width: '80px' },
]
const txnForm = ref({ accountId: '', date: new Date().toISOString().split('T')[0], amount: 0, type: 'Debit', payeeName: '', memo: '', checkNumber: '' })
const txnForm = ref({
accountId: '',
date: new Date().toISOString().split('T')[0],
amount: 0,
type: 'Debit',
payeeName: '',
categoryId: null as string | null,
memo: '',
checkNumber: ''
})
const transferForm = ref({ fromAccountId: '', toAccountId: '', date: new Date().toISOString().split('T')[0], amount: 0, memo: '' })
const splitRemaining = computed(() => {
const total = Math.abs(txnForm.value.amount)
const splitTotal = splits.value.reduce((sum, s) => sum + Math.abs(s.amount || 0), 0)
return Math.round((total - splitTotal) * 100) / 100
})
onMounted(async () => {
await accountsStore.fetchAccounts()
loadCategories()
if (props.id) {
txnForm.value.accountId = props.id
const acct = accountsStore.accounts.find(a => a.id === props.id)
@@ -130,6 +241,13 @@ onMounted(async () => {
watch(() => props.id, () => loadTransactions())
async function loadCategories() {
try {
const { data } = await categoriesApi.getAll()
categories.value = data
} catch {}
}
async function loadTransactions() {
loading.value = true
try {
@@ -161,19 +279,80 @@ async function doSearch() {
}
}
function openAddDialog() {
editingTxn.value = false
editingTxnId.value = ''
txnForm.value = {
accountId: props.id || '',
date: new Date().toISOString().split('T')[0],
amount: 0,
type: 'Debit',
payeeName: '',
categoryId: null,
memo: '',
checkNumber: ''
}
useSplits.value = false
splits.value = []
showAddDialog.value = true
}
function editTransaction(txn: Transaction) {
editingTxn.value = true
editingTxnId.value = txn.id
txnForm.value = { accountId: txn.accountId, date: txn.date.split('T')[0], amount: Math.abs(txn.amount), type: txn.type, payeeName: txn.payeeName || '', memo: txn.memo || '', checkNumber: txn.checkNumber || '' }
txnForm.value = {
accountId: txn.accountId,
date: txn.date.split('T')[0],
amount: Math.abs(txn.amount),
type: txn.type,
payeeName: txn.payeeName || '',
categoryId: txn.categoryId,
memo: txn.memo || '',
checkNumber: txn.checkNumber || ''
}
if (txn.splits && txn.splits.length > 1) {
useSplits.value = true
splits.value = txn.splits.map(s => ({
categoryId: s.categoryId,
amount: Math.abs(s.amount),
memo: s.memo || ''
}))
} else {
useSplits.value = false
splits.value = []
}
showAddDialog.value = true
}
function addSplit() {
splits.value.push({ categoryId: null, amount: 0, memo: '' })
}
async function saveTxn() {
const amount = txnForm.value.type === 'Debit' ? -Math.abs(txnForm.value.amount) : Math.abs(txnForm.value.amount)
const payload: any = {
...txnForm.value,
amount,
status: 'Uncleared',
splits: null
}
if (useSplits.value && splits.value.length > 0) {
payload.categoryId = null
payload.splits = splits.value
.filter(s => s.categoryId && s.amount)
.map(s => ({
categoryId: s.categoryId,
amount: txnForm.value.type === 'Debit' ? -Math.abs(s.amount) : Math.abs(s.amount),
memo: s.memo || null
}))
}
if (editingTxn.value) {
await transactionsApi.update(editingTxnId.value, { ...txnForm.value, amount, status: 'Uncleared', splits: null })
await transactionsApi.update(editingTxnId.value, payload)
} else {
await transactionsApi.create({ ...txnForm.value, amount, accountId: txnForm.value.accountId || props.id })
payload.accountId = txnForm.value.accountId || props.id
await transactionsApi.create(payload)
}
showAddDialog.value = false
editingTxn.value = false
+4 -4
View File
@@ -22,7 +22,7 @@ public class PayeeService : IPayeeService
.Include(p => p.Aliases).Include(p => p.DefaultCategory)
.Where(p => p.UserId == userId)
.OrderBy(p => p.Name)
.Select(p => new PayeeResponse(p.Id, p.Name, p.DefaultCategoryId, p.DefaultCategory != null ? p.DefaultCategory.Name : null, p.IsActive, p.Aliases.Select(a => a.Alias).ToList()))
.Select(p => new PayeeResponse(p.Id, p.Name, p.DefaultCategoryId, p.DefaultCategory != null ? p.DefaultCategory.Name : null, p.IsActive, p.Aliases.Select(a => new PayeeAliasResponse(a.Id, a.Alias)).ToList()))
.ToListAsync();
}
@@ -30,7 +30,7 @@ public class PayeeService : IPayeeService
{
var p = await _db.Payees.Include(p => p.Aliases).Include(p => p.DefaultCategory)
.FirstOrDefaultAsync(p => p.Id == payeeId && p.UserId == userId);
return p == null ? null : new PayeeResponse(p.Id, p.Name, p.DefaultCategoryId, p.DefaultCategory?.Name, p.IsActive, p.Aliases.Select(a => a.Alias).ToList());
return p == null ? null : new PayeeResponse(p.Id, p.Name, p.DefaultCategoryId, p.DefaultCategory?.Name, p.IsActive, p.Aliases.Select(a => new PayeeAliasResponse(a.Id, a.Alias)).ToList());
}
public async Task<PayeeResponse> CreateAsync(Guid userId, CreatePayeeRequest request)
@@ -51,7 +51,7 @@ public class PayeeService : IPayeeService
_db.Payees.Add(payee);
await _db.SaveChangesAsync();
return new PayeeResponse(payee.Id, payee.Name, payee.DefaultCategoryId, null, true, request.Aliases ?? new());
return new PayeeResponse(payee.Id, payee.Name, payee.DefaultCategoryId, null, true, payee.Aliases.Select(a => new PayeeAliasResponse(a.Id, a.Alias)).ToList());
}
public async Task<PayeeResponse> UpdateAsync(Guid userId, Guid payeeId, UpdatePayeeRequest request)
@@ -65,7 +65,7 @@ public class PayeeService : IPayeeService
payee.IsActive = request.IsActive;
await _db.SaveChangesAsync();
return new PayeeResponse(payee.Id, payee.Name, payee.DefaultCategoryId, null, payee.IsActive, payee.Aliases.Select(a => a.Alias).ToList());
return new PayeeResponse(payee.Id, payee.Name, payee.DefaultCategoryId, null, payee.IsActive, payee.Aliases.Select(a => new PayeeAliasResponse(a.Id, a.Alias)).ToList());
}
public async Task DeleteAsync(Guid userId, Guid payeeId)
+3 -1
View File
@@ -3,13 +3,15 @@ namespace Purrse.Core.DTOs;
public record CreatePayeeRequest(string Name, Guid? DefaultCategoryId, List<string>? Aliases);
public record UpdatePayeeRequest(string Name, Guid? DefaultCategoryId, bool IsActive);
public record PayeeAliasResponse(Guid Id, string Alias);
public record PayeeResponse(
Guid Id,
string Name,
Guid? DefaultCategoryId,
string? DefaultCategoryName,
bool IsActive,
List<string> Aliases
List<PayeeAliasResponse> Aliases
);
public record CreatePayeeAliasRequest(string Alias);