diff --git a/frontend/src/App.vue b/frontend/src/App.vue index a74573f..abf86f7 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -8,9 +8,21 @@ diff --git a/frontend/src/components/layout/AppLayout.vue b/frontend/src/components/layout/AppLayout.vue index 1d97706..803dd97 100644 --- a/frontend/src/components/layout/AppLayout.vue +++ b/frontend/src/components/layout/AppLayout.vue @@ -52,7 +52,37 @@ :icon="isDark ? 'mdi-weather-sunny' : 'mdi-weather-night'" @click="toggleTheme" /> - + + + + + mdi-bell-outline + + + + + + No notifications + + + + Mark all as read + + + + {{ n.data.fileName }} + + {{ n.data.newTransactions }} new, {{ n.data.possibleDuplicates }} duplicates + + + + + @@ -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') diff --git a/frontend/src/composables/useNotifications.ts b/frontend/src/composables/useNotifications.ts new file mode 100644 index 0000000..4850693 --- /dev/null +++ b/frontend/src/composables/useNotifications.ts @@ -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([]) +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 + } +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 84f936c..f9cda82 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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 } }, diff --git a/frontend/src/services/payees.ts b/frontend/src/services/payees.ts new file mode 100644 index 0000000..a46b591 --- /dev/null +++ b/frontend/src/services/payees.ts @@ -0,0 +1,12 @@ +import api from './api' +import type { Payee, CreatePayeeRequest, UpdatePayeeRequest } from '@/types' + +export const payeesApi = { + getAll: () => api.get('/payees'), + getById: (id: string) => api.get(`/payees/${id}`), + create: (data: CreatePayeeRequest) => api.post('/payees', data), + update: (id: string, data: UpdatePayeeRequest) => api.put(`/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}`), +} diff --git a/frontend/src/services/plugins.ts b/frontend/src/services/plugins.ts new file mode 100644 index 0000000..2c429df --- /dev/null +++ b/frontend/src/services/plugins.ts @@ -0,0 +1,6 @@ +import api from './api' +import type { Plugin } from '@/types' + +export const pluginsApi = { + getAll: () => api.get('/plugins'), +} diff --git a/frontend/src/services/reconciliation.ts b/frontend/src/services/reconciliation.ts new file mode 100644 index 0000000..e99698b --- /dev/null +++ b/frontend/src/services/reconciliation.ts @@ -0,0 +1,11 @@ +import api from './api' +import type { ReconciliationResponse, StartReconciliationRequest } from '@/types' + +export const reconciliationApi = { + start: (accountId: string, data: StartReconciliationRequest) => + api.post(`/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 } }), +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index f745eec..dc6d2d8 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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 +} diff --git a/frontend/src/views/payees/PayeesView.vue b/frontend/src/views/payees/PayeesView.vue new file mode 100644 index 0000000..a60a872 --- /dev/null +++ b/frontend/src/views/payees/PayeesView.vue @@ -0,0 +1,321 @@ + + + + Payees + + Add Payee + + + + + + + + + + + + + + + + + + + {{ item.defaultCategoryName || '—' }} + + + + {{ a.alias }} + + + mdi-plus + + + + + {{ item.isActive ? 'Active' : 'Inactive' }} + + + + + mdi-pencil + + + mdi-delete + + + + + + + + + {{ editingId ? 'Edit Payee' : 'Add Payee' }} + + + + + + Aliases + + + + mdi-close + + + + Add Alias + + + + + + Cancel + Save + + + + + + + + Add Alias to {{ aliasPayeeName }} + + + + + + Cancel + Add + + + + + + + + Delete Payee + Are you sure you want to delete "{{ deletingName }}"? This cannot be undone. + + + Cancel + Delete + + + + + {{ snackbarText }} + + + + diff --git a/frontend/src/views/plugins/PluginsView.vue b/frontend/src/views/plugins/PluginsView.vue index a264c5a..60d932c 100644 --- a/frontend/src/views/plugins/PluginsView.vue +++ b/frontend/src/views/plugins/PluginsView.vue @@ -1,6 +1,47 @@ Plugins - Plugin marketplace coming in Phase 5. Extend Purrse with community-built integrations and features. + + + + + No plugins installed. Plugins extend Purrse with additional features and integrations. + + + + + + + + mdi-puzzle + + {{ plugin.name }} + v{{ plugin.version }} + + {{ plugin.description }} + + + + + diff --git a/frontend/src/views/reconciliation/ReconciliationView.vue b/frontend/src/views/reconciliation/ReconciliationView.vue new file mode 100644 index 0000000..e170b90 --- /dev/null +++ b/frontend/src/views/reconciliation/ReconciliationView.vue @@ -0,0 +1,289 @@ + + + Bank Reconciliation + + + + + + + + + + + + + + + + + + + + + + + + Start Reconciliation + + + + + + + + + + + + + + Statement Balance + {{ formatCurrency(reconciliation?.statementBalance || 0) }} + + + + + + + Cleared Balance + {{ formatCurrency(clearedBalance) }} + + + + + + + Difference + {{ formatCurrency(difference) }} + + + + + + + {{ formatDate(item.date) }} + + + {{ formatCurrency(item.amount) }} + + + + + {{ item.status }} + + + + + + Cancel + + + Complete Reconciliation + + + + + + + + + mdi-check-circle + Reconciliation Complete + All transactions have been reconciled successfully. + Start New Reconciliation + + + + + {{ snackbarText }} + + + + diff --git a/frontend/src/views/transactions/TransactionsView.vue b/frontend/src/views/transactions/TransactionsView.vue index d689e5b..ed0132b 100644 --- a/frontend/src/views/transactions/TransactionsView.vue +++ b/frontend/src/views/transactions/TransactionsView.vue @@ -4,7 +4,7 @@ {{ accountName || 'Transactions' }} Transfer - Add Transaction + Add Transaction @@ -27,14 +27,50 @@ - + {{ formatDate(item.date) }} + + ({{ item.splits.length }} splits) + {{ item.categoryName }} + {{ formatCurrency(item.amount) }} {{ item.status }} + + + {{ isExpanded(internalItem) ? 'mdi-chevron-up' : 'mdi-chevron-down' }} + + + + + + + + {{ split.categoryName || '—' }} + {{ split.memo || '' }} + + {{ formatCurrency(split.amount) }} + + + + mdi-pencil mdi-delete @@ -43,7 +79,7 @@ - + {{ editingTxn ? 'Edit Transaction' : 'Add Transaction' }} @@ -52,13 +88,67 @@ + + + + + + + Split Items + + + Remaining: {{ formatCurrency(splitRemaining) }} + + + + + + + + mdi-close + + + Add Split + Cancel - Save + Save @@ -85,15 +175,17 @@
Plugin marketplace coming in Phase 5. Extend Purrse with community-built integrations and features.
All transactions have been reconciled successfully.