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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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}`),
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import api from './api'
|
||||
import type { Plugin } from '@/types'
|
||||
|
||||
export const pluginsApi = {
|
||||
getAll: () => api.get<Plugin[]>('/plugins'),
|
||||
}
|
||||
@@ -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 } }),
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user