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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { watch } from 'vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useNotifications } from '@/composables/useNotifications'
|
||||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
authStore.initialize()
|
authStore.initialize()
|
||||||
|
|
||||||
|
const { connect, disconnect } = useNotifications()
|
||||||
|
|
||||||
|
watch(() => authStore.isAuthenticated, (authenticated) => {
|
||||||
|
if (authenticated) {
|
||||||
|
connect()
|
||||||
|
} else {
|
||||||
|
disconnect()
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -52,7 +52,37 @@
|
|||||||
:icon="isDark ? 'mdi-weather-sunny' : 'mdi-weather-night'"
|
:icon="isDark ? 'mdi-weather-sunny' : 'mdi-weather-night'"
|
||||||
@click="toggleTheme"
|
@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>
|
<v-menu>
|
||||||
<template v-slot:activator="{ props }">
|
<template v-slot:activator="{ props }">
|
||||||
<v-btn icon v-bind="props">
|
<v-btn icon v-bind="props">
|
||||||
@@ -82,6 +112,8 @@ import { ref, computed } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useTheme } from 'vuetify'
|
import { useTheme } from 'vuetify'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useNotifications } from '@/composables/useNotifications'
|
||||||
|
import type { AppNotification } from '@/types'
|
||||||
|
|
||||||
const drawer = ref(true)
|
const drawer = ref(true)
|
||||||
const rail = ref(false)
|
const rail = ref(false)
|
||||||
@@ -90,6 +122,8 @@ const router = useRouter()
|
|||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const { notifications, unreadCount, markAllAsRead, markAsRead } = useNotifications()
|
||||||
|
|
||||||
const isDark = computed(() => theme.global.current.value.dark)
|
const isDark = computed(() => theme.global.current.value.dark)
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
@@ -97,8 +131,10 @@ const navItems = [
|
|||||||
{ title: 'Accounts', icon: 'mdi-bank', route: '/accounts' },
|
{ title: 'Accounts', icon: 'mdi-bank', route: '/accounts' },
|
||||||
{ title: 'Transactions', icon: 'mdi-swap-horizontal', route: '/transactions' },
|
{ title: 'Transactions', icon: 'mdi-swap-horizontal', route: '/transactions' },
|
||||||
{ title: 'Categories', icon: 'mdi-tag-multiple', route: '/categories' },
|
{ title: 'Categories', icon: 'mdi-tag-multiple', route: '/categories' },
|
||||||
|
{ title: 'Payees', icon: 'mdi-account-group', route: '/payees' },
|
||||||
{ title: 'Budgets', icon: 'mdi-calculator', route: '/budgets' },
|
{ title: 'Budgets', icon: 'mdi-calculator', route: '/budgets' },
|
||||||
{ title: 'Scheduled', icon: 'mdi-clock-outline', route: '/scheduled' },
|
{ 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: 'Imports', icon: 'mdi-file-upload', route: '/imports' },
|
||||||
{ title: 'Loans', icon: 'mdi-home-city', route: '/loans' },
|
{ title: 'Loans', icon: 'mdi-home-city', route: '/loans' },
|
||||||
{ title: 'Reports', icon: 'mdi-chart-bar', route: '/reports' },
|
{ title: 'Reports', icon: 'mdi-chart-bar', route: '/reports' },
|
||||||
@@ -119,6 +155,11 @@ function toggleTheme() {
|
|||||||
theme.global.name.value = isDark.value ? 'light' : 'dark'
|
theme.global.name.value = isDark.value ? 'light' : 'dark'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleNotificationClick(n: AppNotification) {
|
||||||
|
markAsRead(n.id)
|
||||||
|
router.push('/imports')
|
||||||
|
}
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
authStore.logout()
|
authStore.logout()
|
||||||
router.push('/login')
|
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: '/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: '/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: '/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: '/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: '/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: '/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: '/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 } },
|
{ 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 type CategoryType = 'Income' | 'Expense' | 'Transfer'
|
||||||
|
|
||||||
|
export interface PayeeAlias {
|
||||||
|
id: string
|
||||||
|
alias: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Payee {
|
export interface Payee {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
defaultCategoryId: string | null
|
defaultCategoryId: string | null
|
||||||
defaultCategoryName: string | null
|
defaultCategoryName: string | null
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
aliases: string[]
|
aliases: PayeeAlias[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Budget {
|
export interface Budget {
|
||||||
@@ -332,3 +337,67 @@ export interface InvestmentPerformance {
|
|||||||
totalGainLossPercent: number
|
totalGainLossPercent: number
|
||||||
holdings: InvestmentHolding[]
|
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>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-h4 mb-4">Plugins</h1>
|
<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>
|
</div>
|
||||||
</template>
|
</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>
|
<h1 class="text-h4">{{ accountName || 'Transactions' }}</h1>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-btn color="secondary" prepend-icon="mdi-swap-horizontal" class="mr-2" @click="showTransferDialog = true">Transfer</v-btn>
|
<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>
|
</div>
|
||||||
|
|
||||||
<v-card class="mb-4">
|
<v-card class="mb-4">
|
||||||
@@ -27,14 +27,50 @@
|
|||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
<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.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 }">
|
<template #item.amount="{ item }">
|
||||||
<span :class="item.amount >= 0 ? 'text-success' : 'text-error'">{{ formatCurrency(item.amount) }}</span>
|
<span :class="item.amount >= 0 ? 'text-success' : 'text-error'">{{ formatCurrency(item.amount) }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template #item.status="{ item }">
|
<template #item.status="{ item }">
|
||||||
<v-chip :color="item.status === 'Reconciled' ? 'success' : item.status === 'Cleared' ? 'info' : 'default'" size="x-small">{{ item.status }}</v-chip>
|
<v-chip :color="item.status === 'Reconciled' ? 'success' : item.status === 'Cleared' ? 'info' : 'default'" size="x-small">{{ item.status }}</v-chip>
|
||||||
</template>
|
</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 }">
|
<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" @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>
|
<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>
|
</v-card>
|
||||||
|
|
||||||
<!-- Add/Edit Transaction Dialog -->
|
<!-- Add/Edit Transaction Dialog -->
|
||||||
<v-dialog v-model="showAddDialog" max-width="600">
|
<v-dialog v-model="showAddDialog" max-width="700">
|
||||||
<v-card>
|
<v-card>
|
||||||
<v-card-title>{{ editingTxn ? 'Edit Transaction' : 'Add Transaction' }}</v-card-title>
|
<v-card-title>{{ editingTxn ? 'Edit Transaction' : 'Add Transaction' }}</v-card-title>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
@@ -52,13 +88,67 @@
|
|||||||
<v-text-field v-model.number="txnForm.amount" label="Amount" type="number" prefix="$" />
|
<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-select v-model="txnForm.type" label="Type" :items="['Debit','Credit']" />
|
||||||
<v-text-field v-model="txnForm.payeeName" label="Payee" />
|
<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.memo" label="Memo" />
|
||||||
<v-text-field v-model="txnForm.checkNumber" label="Check #" />
|
<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-text>
|
||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-btn @click="showAddDialog = false">Cancel</v-btn>
|
<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-actions>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-dialog>
|
</v-dialog>
|
||||||
@@ -85,15 +175,17 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { transactionsApi } from '@/services/transactions'
|
import { transactionsApi } from '@/services/transactions'
|
||||||
|
import { categoriesApi } from '@/services/categories'
|
||||||
import { useAccountsStore } from '@/stores/accounts'
|
import { useAccountsStore } from '@/stores/accounts'
|
||||||
import { formatCurrency, formatDate } from '@/utils/formatters'
|
import { formatCurrency, formatDate } from '@/utils/formatters'
|
||||||
import type { Transaction } from '@/types'
|
import type { Transaction, Category, SplitFormItem } from '@/types'
|
||||||
|
|
||||||
const props = defineProps<{ id?: string }>()
|
const props = defineProps<{ id?: string }>()
|
||||||
const accountsStore = useAccountsStore()
|
const accountsStore = useAccountsStore()
|
||||||
const transactions = ref<Transaction[]>([])
|
const transactions = ref<Transaction[]>([])
|
||||||
|
const categories = ref<Category[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const accountName = ref('')
|
const accountName = ref('')
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
@@ -104,6 +196,8 @@ const showAddDialog = ref(false)
|
|||||||
const showTransferDialog = ref(false)
|
const showTransferDialog = ref(false)
|
||||||
const editingTxn = ref(false)
|
const editingTxn = ref(false)
|
||||||
const editingTxnId = ref('')
|
const editingTxnId = ref('')
|
||||||
|
const useSplits = ref(false)
|
||||||
|
const splits = ref<SplitFormItem[]>([])
|
||||||
|
|
||||||
const headers = [
|
const headers = [
|
||||||
{ title: 'Date', key: 'date', width: '100px' },
|
{ title: 'Date', key: 'date', width: '100px' },
|
||||||
@@ -115,11 +209,28 @@ const headers = [
|
|||||||
{ title: '', key: 'actions', sortable: false, width: '80px' },
|
{ 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 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 () => {
|
onMounted(async () => {
|
||||||
await accountsStore.fetchAccounts()
|
await accountsStore.fetchAccounts()
|
||||||
|
loadCategories()
|
||||||
if (props.id) {
|
if (props.id) {
|
||||||
txnForm.value.accountId = props.id
|
txnForm.value.accountId = props.id
|
||||||
const acct = accountsStore.accounts.find(a => a.id === props.id)
|
const acct = accountsStore.accounts.find(a => a.id === props.id)
|
||||||
@@ -130,6 +241,13 @@ onMounted(async () => {
|
|||||||
|
|
||||||
watch(() => props.id, () => loadTransactions())
|
watch(() => props.id, () => loadTransactions())
|
||||||
|
|
||||||
|
async function loadCategories() {
|
||||||
|
try {
|
||||||
|
const { data } = await categoriesApi.getAll()
|
||||||
|
categories.value = data
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadTransactions() {
|
async function loadTransactions() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
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) {
|
function editTransaction(txn: Transaction) {
|
||||||
editingTxn.value = true
|
editingTxn.value = true
|
||||||
editingTxnId.value = txn.id
|
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
|
showAddDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addSplit() {
|
||||||
|
splits.value.push({ categoryId: null, amount: 0, memo: '' })
|
||||||
|
}
|
||||||
|
|
||||||
async function saveTxn() {
|
async function saveTxn() {
|
||||||
const amount = txnForm.value.type === 'Debit' ? -Math.abs(txnForm.value.amount) : Math.abs(txnForm.value.amount)
|
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) {
|
if (editingTxn.value) {
|
||||||
await transactionsApi.update(editingTxnId.value, { ...txnForm.value, amount, status: 'Uncleared', splits: null })
|
await transactionsApi.update(editingTxnId.value, payload)
|
||||||
} else {
|
} 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
|
showAddDialog.value = false
|
||||||
editingTxn.value = false
|
editingTxn.value = false
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ public class PayeeService : IPayeeService
|
|||||||
.Include(p => p.Aliases).Include(p => p.DefaultCategory)
|
.Include(p => p.Aliases).Include(p => p.DefaultCategory)
|
||||||
.Where(p => p.UserId == userId)
|
.Where(p => p.UserId == userId)
|
||||||
.OrderBy(p => p.Name)
|
.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();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ public class PayeeService : IPayeeService
|
|||||||
{
|
{
|
||||||
var p = await _db.Payees.Include(p => p.Aliases).Include(p => p.DefaultCategory)
|
var p = await _db.Payees.Include(p => p.Aliases).Include(p => p.DefaultCategory)
|
||||||
.FirstOrDefaultAsync(p => p.Id == payeeId && p.UserId == userId);
|
.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)
|
public async Task<PayeeResponse> CreateAsync(Guid userId, CreatePayeeRequest request)
|
||||||
@@ -51,7 +51,7 @@ public class PayeeService : IPayeeService
|
|||||||
|
|
||||||
_db.Payees.Add(payee);
|
_db.Payees.Add(payee);
|
||||||
await _db.SaveChangesAsync();
|
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)
|
public async Task<PayeeResponse> UpdateAsync(Guid userId, Guid payeeId, UpdatePayeeRequest request)
|
||||||
@@ -65,7 +65,7 @@ public class PayeeService : IPayeeService
|
|||||||
payee.IsActive = request.IsActive;
|
payee.IsActive = request.IsActive;
|
||||||
await _db.SaveChangesAsync();
|
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)
|
public async Task DeleteAsync(Guid userId, Guid payeeId)
|
||||||
|
|||||||
@@ -3,13 +3,15 @@ namespace Purrse.Core.DTOs;
|
|||||||
public record CreatePayeeRequest(string Name, Guid? DefaultCategoryId, List<string>? Aliases);
|
public record CreatePayeeRequest(string Name, Guid? DefaultCategoryId, List<string>? Aliases);
|
||||||
public record UpdatePayeeRequest(string Name, Guid? DefaultCategoryId, bool IsActive);
|
public record UpdatePayeeRequest(string Name, Guid? DefaultCategoryId, bool IsActive);
|
||||||
|
|
||||||
|
public record PayeeAliasResponse(Guid Id, string Alias);
|
||||||
|
|
||||||
public record PayeeResponse(
|
public record PayeeResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
string Name,
|
string Name,
|
||||||
Guid? DefaultCategoryId,
|
Guid? DefaultCategoryId,
|
||||||
string? DefaultCategoryName,
|
string? DefaultCategoryName,
|
||||||
bool IsActive,
|
bool IsActive,
|
||||||
List<string> Aliases
|
List<PayeeAliasResponse> Aliases
|
||||||
);
|
);
|
||||||
|
|
||||||
public record CreatePayeeAliasRequest(string Alias);
|
public record CreatePayeeAliasRequest(string Alias);
|
||||||
|
|||||||
Reference in New Issue
Block a user