Initial commit: Purrse personal finance app
Self-hosted, plugin-extensible personal finance manager built with ASP.NET Core 9.0, Vue 3 + Vuetify 3, and PostgreSQL 17. Backend (8 .NET projects): - Core: 19 domain models, 6 enums, 14 DTOs, 12 service interfaces - Data: EF Core DbContext, 16 entity configurations, category seeder - API: 14 controllers, 15 services, JWT auth, SignalR, middleware - Plugins: Abstractions + OFX/CSV/QIF file parsers - Tests: 28 xUnit tests (fingerprinting, duplicate detection, parsers) Frontend (Vue 3 + Vuetify 3 + TypeScript): - 13 views, Pinia stores, Axios API services with JWT interceptors - Dashboard, accounts, transactions, categories, imports, and more Deployment: - Docker Compose (PostgreSQL 17 + .NET API + nginx frontend) - Auto-migration on startup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Purrse - Personal Finance</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://api:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /hubs/ {
|
||||
proxy_pass http://api:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "purrse-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdi/font": "^7.4.47",
|
||||
"@microsoft/signalr": "^9.0.0",
|
||||
"apexcharts": "^4.3.0",
|
||||
"axios": "^1.7.9",
|
||||
"date-fns": "^4.1.0",
|
||||
"pinia": "^2.3.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0",
|
||||
"vue3-apexcharts": "^1.7.0",
|
||||
"vuetify": "^3.7.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"sass": "^1.83.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.5",
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<v-app>
|
||||
<AppLayout v-if="authStore.isAuthenticated">
|
||||
<router-view />
|
||||
</AppLayout>
|
||||
<router-view v-else />
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
authStore.initialize()
|
||||
</script>
|
||||
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<v-navigation-drawer v-model="drawer" :rail="rail" permanent>
|
||||
<v-list-item
|
||||
prepend-icon="mdi-cat"
|
||||
title="Purrse"
|
||||
subtitle="Personal Finance"
|
||||
nav
|
||||
>
|
||||
<template v-slot:append>
|
||||
<v-btn
|
||||
:icon="rail ? 'mdi-chevron-right' : 'mdi-chevron-left'"
|
||||
variant="text"
|
||||
@click="rail = !rail"
|
||||
/>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-list density="compact" nav>
|
||||
<v-list-item
|
||||
v-for="item in navItems"
|
||||
:key="item.route"
|
||||
:prepend-icon="item.icon"
|
||||
:title="item.title"
|
||||
:to="item.route"
|
||||
:value="item.route"
|
||||
rounded="xl"
|
||||
/>
|
||||
</v-list>
|
||||
|
||||
<template v-slot:append>
|
||||
<v-divider />
|
||||
<v-list density="compact" nav>
|
||||
<v-list-item
|
||||
prepend-icon="mdi-logout"
|
||||
title="Logout"
|
||||
@click="handleLogout"
|
||||
rounded="xl"
|
||||
/>
|
||||
</v-list>
|
||||
</template>
|
||||
</v-navigation-drawer>
|
||||
|
||||
<v-app-bar density="compact" flat>
|
||||
<v-app-bar-title>
|
||||
<span class="text-body-1 font-weight-medium">{{ currentPageTitle }}</span>
|
||||
</v-app-bar-title>
|
||||
|
||||
<template v-slot:append>
|
||||
<v-btn
|
||||
:icon="isDark ? 'mdi-weather-sunny' : 'mdi-weather-night'"
|
||||
@click="toggleTheme"
|
||||
/>
|
||||
<v-btn icon="mdi-bell-outline" />
|
||||
<v-menu>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn icon v-bind="props">
|
||||
<v-avatar color="primary" size="32">
|
||||
<span class="text-body-2">{{ userInitial }}</span>
|
||||
</v-avatar>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item prepend-icon="mdi-account" :title="authStore.username || 'User'" />
|
||||
<v-divider />
|
||||
<v-list-item prepend-icon="mdi-logout" title="Logout" @click="handleLogout" />
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</template>
|
||||
</v-app-bar>
|
||||
|
||||
<v-main>
|
||||
<v-container fluid class="pa-6">
|
||||
<slot />
|
||||
</v-container>
|
||||
</v-main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useTheme } from 'vuetify'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const drawer = ref(true)
|
||||
const rail = ref(false)
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const theme = useTheme()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const isDark = computed(() => theme.global.current.value.dark)
|
||||
|
||||
const navItems = [
|
||||
{ title: 'Dashboard', icon: 'mdi-view-dashboard', route: '/' },
|
||||
{ title: 'Accounts', icon: 'mdi-bank', route: '/accounts' },
|
||||
{ title: 'Transactions', icon: 'mdi-swap-horizontal', route: '/transactions' },
|
||||
{ title: 'Categories', icon: 'mdi-tag-multiple', route: '/categories' },
|
||||
{ title: 'Budgets', icon: 'mdi-calculator', route: '/budgets' },
|
||||
{ title: 'Scheduled', icon: 'mdi-clock-outline', route: '/scheduled' },
|
||||
{ title: 'Imports', icon: 'mdi-file-upload', route: '/imports' },
|
||||
{ title: 'Loans', icon: 'mdi-home-city', route: '/loans' },
|
||||
{ title: 'Reports', icon: 'mdi-chart-bar', route: '/reports' },
|
||||
{ title: 'Investments', icon: 'mdi-finance', route: '/investments' },
|
||||
{ title: 'Plugins', icon: 'mdi-puzzle', route: '/plugins' },
|
||||
]
|
||||
|
||||
const currentPageTitle = computed(() => {
|
||||
const item = navItems.find(n => n.route === route.path)
|
||||
return item?.title || 'Purrse'
|
||||
})
|
||||
|
||||
const userInitial = computed(() => {
|
||||
return authStore.username?.charAt(0).toUpperCase() || 'U'
|
||||
})
|
||||
|
||||
function toggleTheme() {
|
||||
theme.global.name.value = isDark.value ? 'light' : 'dark'
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import vuetify from './plugins/vuetify'
|
||||
import '@mdi/font/css/materialdesignicons.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(vuetify)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const routes = [
|
||||
{ path: '/login', name: 'login', component: () => import('@/views/auth/LoginView.vue'), meta: { guest: true } },
|
||||
{ path: '/register', name: 'register', component: () => import('@/views/auth/RegisterView.vue'), meta: { guest: true } },
|
||||
{ path: '/', name: 'dashboard', component: () => import('@/views/dashboard/DashboardView.vue'), meta: { auth: true } },
|
||||
{ path: '/accounts', name: 'accounts', component: () => import('@/views/accounts/AccountsView.vue'), meta: { auth: 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: '/categories', name: 'categories', component: () => import('@/views/categories/CategoriesView.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: '/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 } },
|
||||
{ path: '/investments', name: 'investments', component: () => import('@/views/investments/InvestmentsView.vue'), meta: { auth: true } },
|
||||
{ path: '/plugins', name: 'plugins', component: () => import('@/views/plugins/PluginsView.vue'), meta: { auth: true } },
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
if (to.meta.auth && !authStore.isAuthenticated) {
|
||||
next({ name: 'login' })
|
||||
} else if (to.meta.guest && authStore.isAuthenticated) {
|
||||
next({ name: 'dashboard' })
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,12 @@
|
||||
import api from './api'
|
||||
import type { Account } from '@/types'
|
||||
|
||||
export const accountsApi = {
|
||||
getAll: () => api.get<Account[]>('/accounts'),
|
||||
getById: (id: string) => api.get<Account>(`/accounts/${id}`),
|
||||
create: (data: any) => api.post<Account>('/accounts', data),
|
||||
update: (id: string, data: any) => api.put<Account>(`/accounts/${id}`, data),
|
||||
delete: (id: string) => api.delete(`/accounts/${id}`),
|
||||
getBalanceHistory: (id: string, startDate: string, endDate: string) =>
|
||||
api.get(`/accounts/${id}/balance-history`, { params: { startDate, endDate } }),
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import axios from 'axios'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import router from '@/router'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
|
||||
api.interceptors.request.use(config => {
|
||||
const authStore = useAuthStore()
|
||||
if (authStore.token) {
|
||||
config.headers.Authorization = `Bearer ${authStore.token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
response => response,
|
||||
async error => {
|
||||
if (error.response?.status === 401) {
|
||||
const authStore = useAuthStore()
|
||||
try {
|
||||
await authStore.refreshToken()
|
||||
error.config.headers.Authorization = `Bearer ${authStore.token}`
|
||||
return api(error.config)
|
||||
} catch {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default api
|
||||
@@ -0,0 +1,10 @@
|
||||
import api from './api'
|
||||
import type { Category, CategoryTree } from '@/types'
|
||||
|
||||
export const categoriesApi = {
|
||||
getAll: () => api.get<Category[]>('/categories'),
|
||||
getTree: () => api.get<CategoryTree[]>('/categories/tree'),
|
||||
create: (data: any) => api.post<Category>('/categories', data),
|
||||
update: (id: string, data: any) => api.put<Category>(`/categories/${id}`, data),
|
||||
delete: (id: string) => api.delete(`/categories/${id}`),
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import api from './api'
|
||||
import type { Transaction, PagedResult } from '@/types'
|
||||
|
||||
export const transactionsApi = {
|
||||
getByAccount: (accountId: string, page = 1, pageSize = 50) =>
|
||||
api.get<PagedResult<Transaction>>(`/transactions/account/${accountId}`, { params: { page, pageSize } }),
|
||||
getById: (id: string) => api.get<Transaction>(`/transactions/${id}`),
|
||||
create: (data: any) => api.post<Transaction>('/transactions', data),
|
||||
update: (id: string, data: any) => api.put<Transaction>(`/transactions/${id}`, data),
|
||||
delete: (id: string) => api.delete(`/transactions/${id}`),
|
||||
search: (data: any) => api.post<PagedResult<Transaction>>('/transactions/search', data),
|
||||
createTransfer: (data: any) => api.post('/transactions/transfer', data),
|
||||
void: (id: string) => api.post(`/transactions/${id}/void`),
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { accountsApi } from '@/services/accounts'
|
||||
import type { Account } from '@/types'
|
||||
|
||||
export const useAccountsStore = defineStore('accounts', () => {
|
||||
const accounts = ref<Account[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchAccounts() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await accountsApi.getAll()
|
||||
accounts.value = data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createAccount(accountData: any) {
|
||||
const { data } = await accountsApi.create(accountData)
|
||||
accounts.value.push(data)
|
||||
return data
|
||||
}
|
||||
|
||||
async function updateAccount(id: string, accountData: any) {
|
||||
const { data } = await accountsApi.update(id, accountData)
|
||||
const index = accounts.value.findIndex(a => a.id === id)
|
||||
if (index >= 0) accounts.value[index] = data
|
||||
return data
|
||||
}
|
||||
|
||||
async function deleteAccount(id: string) {
|
||||
await accountsApi.delete(id)
|
||||
accounts.value = accounts.value.filter(a => a.id !== id)
|
||||
}
|
||||
|
||||
return { accounts, loading, fetchAccounts, createAccount, updateAccount, deleteAccount }
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import api from '@/services/api'
|
||||
import type { AuthResponse } from '@/types'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref<string | null>(null)
|
||||
const refreshTokenValue = ref<string | null>(null)
|
||||
const username = ref<string | null>(null)
|
||||
const expiresAt = ref<string | null>(null)
|
||||
|
||||
const isAuthenticated = computed(() => !!token.value)
|
||||
|
||||
function initialize() {
|
||||
token.value = localStorage.getItem('purrse_token')
|
||||
refreshTokenValue.value = localStorage.getItem('purrse_refresh_token')
|
||||
username.value = localStorage.getItem('purrse_username')
|
||||
expiresAt.value = localStorage.getItem('purrse_expires_at')
|
||||
}
|
||||
|
||||
function setAuth(auth: AuthResponse) {
|
||||
token.value = auth.token
|
||||
refreshTokenValue.value = auth.refreshToken
|
||||
username.value = auth.username
|
||||
expiresAt.value = auth.expiresAt
|
||||
localStorage.setItem('purrse_token', auth.token)
|
||||
localStorage.setItem('purrse_refresh_token', auth.refreshToken)
|
||||
localStorage.setItem('purrse_username', auth.username)
|
||||
localStorage.setItem('purrse_expires_at', auth.expiresAt)
|
||||
}
|
||||
|
||||
async function login(usernameInput: string, password: string) {
|
||||
const { data } = await api.post<AuthResponse>('/auth/login', { username: usernameInput, password })
|
||||
setAuth(data)
|
||||
}
|
||||
|
||||
async function register(usernameInput: string, email: string, password: string) {
|
||||
const { data } = await api.post<AuthResponse>('/auth/register', { username: usernameInput, email, password })
|
||||
setAuth(data)
|
||||
}
|
||||
|
||||
async function refreshToken() {
|
||||
if (!refreshTokenValue.value) throw new Error('No refresh token')
|
||||
const { data } = await api.post<AuthResponse>('/auth/refresh', { refreshToken: refreshTokenValue.value })
|
||||
setAuth(data)
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = null
|
||||
refreshTokenValue.value = null
|
||||
username.value = null
|
||||
expiresAt.value = null
|
||||
localStorage.removeItem('purrse_token')
|
||||
localStorage.removeItem('purrse_refresh_token')
|
||||
localStorage.removeItem('purrse_username')
|
||||
localStorage.removeItem('purrse_expires_at')
|
||||
}
|
||||
|
||||
return { token, username, expiresAt, isAuthenticated, initialize, login, register, refreshToken, logout }
|
||||
})
|
||||
@@ -0,0 +1,237 @@
|
||||
export interface AuthResponse {
|
||||
token: string
|
||||
refreshToken: string
|
||||
expiresAt: string
|
||||
username: string
|
||||
}
|
||||
|
||||
export interface Account {
|
||||
id: string
|
||||
name: string
|
||||
type: AccountType
|
||||
institution: string | null
|
||||
accountNumber: string | null
|
||||
balance: number
|
||||
creditLimit: number | null
|
||||
interestRate: number | null
|
||||
isActive: boolean
|
||||
isClosed: boolean
|
||||
notes: string | null
|
||||
sortOrder: number
|
||||
createdAt: string
|
||||
hasLoanDetail: boolean
|
||||
}
|
||||
|
||||
export interface AccountSummary {
|
||||
id: string
|
||||
name: string
|
||||
type: AccountType
|
||||
institution: string | null
|
||||
balance: number
|
||||
creditLimit: number | null
|
||||
}
|
||||
|
||||
export type AccountType = 'Checking' | 'Savings' | 'CreditCard' | 'AutoLoan' | 'PersonalLoan' | 'Mortgage' | 'LineOfCredit' | 'Retirement401k' | 'IRA' | 'Brokerage' | 'Cash'
|
||||
|
||||
export interface Transaction {
|
||||
id: string
|
||||
accountId: string
|
||||
accountName: string | null
|
||||
date: string
|
||||
amount: number
|
||||
payeeId: string | null
|
||||
payeeName: string | null
|
||||
categoryId: string | null
|
||||
categoryName: string | null
|
||||
memo: string | null
|
||||
referenceNumber: string | null
|
||||
checkNumber: string | null
|
||||
type: TransactionType
|
||||
status: TransactionStatus
|
||||
isVoid: boolean
|
||||
transferTransactionId: string | null
|
||||
importBatchId: string | null
|
||||
splits: TransactionSplit[] | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type TransactionType = 'Debit' | 'Credit' | 'Transfer'
|
||||
export type TransactionStatus = 'Uncleared' | 'Cleared' | 'Reconciled'
|
||||
|
||||
export interface TransactionSplit {
|
||||
id: string
|
||||
categoryId: string | null
|
||||
categoryName: string | null
|
||||
amount: number
|
||||
memo: string | null
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: string
|
||||
name: string
|
||||
type: CategoryType
|
||||
parentId: string | null
|
||||
parentName: string | null
|
||||
sortOrder: number
|
||||
isSystem: boolean
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export interface CategoryTree {
|
||||
id: string
|
||||
name: string
|
||||
type: CategoryType
|
||||
sortOrder: number
|
||||
isSystem: boolean
|
||||
isActive: boolean
|
||||
children: CategoryTree[]
|
||||
}
|
||||
|
||||
export type CategoryType = 'Income' | 'Expense' | 'Transfer'
|
||||
|
||||
export interface Payee {
|
||||
id: string
|
||||
name: string
|
||||
defaultCategoryId: string | null
|
||||
defaultCategoryName: string | null
|
||||
isActive: boolean
|
||||
aliases: string[]
|
||||
}
|
||||
|
||||
export interface Budget {
|
||||
id: string
|
||||
year: number
|
||||
month: number
|
||||
notes: string | null
|
||||
items: BudgetItem[]
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface BudgetItem {
|
||||
id: string
|
||||
categoryId: string
|
||||
categoryName: string
|
||||
budgetedAmount: number
|
||||
actualAmount: number
|
||||
}
|
||||
|
||||
export interface ScheduledTransaction {
|
||||
id: string
|
||||
accountId: string
|
||||
accountName: string
|
||||
amount: number
|
||||
payeeName: string | null
|
||||
categoryId: string | null
|
||||
categoryName: string | null
|
||||
memo: string | null
|
||||
type: TransactionType
|
||||
frequency: Frequency
|
||||
nextDueDate: string
|
||||
endDate: string | null
|
||||
autoPost: boolean
|
||||
reminderDaysBefore: number
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export type Frequency = 'Daily' | 'Weekly' | 'BiWeekly' | 'SemiMonthly' | 'Monthly' | 'Quarterly' | 'SemiAnnually' | 'Annually'
|
||||
|
||||
export interface DashboardData {
|
||||
netWorth: number
|
||||
totalAssets: number
|
||||
totalLiabilities: number
|
||||
accounts: AccountSummary[]
|
||||
recentTransactions: Transaction[]
|
||||
spendingSummary: SpendingSummary
|
||||
upcomingBills: UpcomingBill[]
|
||||
}
|
||||
|
||||
export interface SpendingSummary {
|
||||
thisMonth: number
|
||||
lastMonth: number
|
||||
changePercent: number
|
||||
topCategories: CategorySpending[]
|
||||
}
|
||||
|
||||
export interface CategorySpending {
|
||||
categoryName: string
|
||||
amount: number
|
||||
percentage: number
|
||||
parentCategoryName: string | null
|
||||
}
|
||||
|
||||
export interface UpcomingBill {
|
||||
id: string
|
||||
payeeName: string
|
||||
amount: number
|
||||
dueDate: string
|
||||
accountName: string
|
||||
autoPost: boolean
|
||||
}
|
||||
|
||||
export interface PagedResult<T> {
|
||||
items: T[]
|
||||
totalCount: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export interface LoanDetail {
|
||||
id: string
|
||||
accountId: string
|
||||
accountName: string
|
||||
originalBalance: number
|
||||
currentBalance: number
|
||||
interestRate: number
|
||||
termMonths: number
|
||||
monthlyPayment: number
|
||||
originationDate: string
|
||||
maturityDate: string
|
||||
escrowAmount: number | null
|
||||
extraPayment: number | null
|
||||
amortizationSchedule: AmortizationEntry[]
|
||||
}
|
||||
|
||||
export interface AmortizationEntry {
|
||||
paymentNumber: number
|
||||
paymentDate: string
|
||||
paymentAmount: number
|
||||
principalAmount: number
|
||||
interestAmount: number
|
||||
escrowAmount: number | null
|
||||
remainingBalance: number
|
||||
}
|
||||
|
||||
export interface PayoffScenario {
|
||||
originalPayoffDate: string
|
||||
newPayoffDate: string
|
||||
totalInterestOriginal: number
|
||||
totalInterestNew: number
|
||||
interestSaved: number
|
||||
monthsSaved: number
|
||||
newSchedule: AmortizationEntry[]
|
||||
}
|
||||
|
||||
export interface ImportUploadResponse {
|
||||
batchId: string
|
||||
totalTransactions: number
|
||||
newTransactions: number
|
||||
possibleDuplicates: number
|
||||
previews: ImportPreview[]
|
||||
}
|
||||
|
||||
export interface ImportPreview {
|
||||
index: number
|
||||
date: string
|
||||
amount: number
|
||||
payeeName: string | null
|
||||
memo: string | null
|
||||
fitId: string | null
|
||||
duplicateMatch: DuplicateMatch | null
|
||||
}
|
||||
|
||||
export interface DuplicateMatch {
|
||||
existingTransactionId: string
|
||||
confidence: number
|
||||
matchReason: string
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { format, parseISO } from 'date-fns'
|
||||
|
||||
export function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount)
|
||||
}
|
||||
|
||||
export function formatDate(date: string): string {
|
||||
return format(parseISO(date), 'MM/dd/yyyy')
|
||||
}
|
||||
|
||||
export function formatDateTime(date: string): string {
|
||||
return format(parseISO(date), 'MM/dd/yyyy h:mm a')
|
||||
}
|
||||
|
||||
export function formatPercent(value: number): string {
|
||||
return `${value >= 0 ? '+' : ''}${value.toFixed(1)}%`
|
||||
}
|
||||
|
||||
export function accountTypeLabel(type: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
Checking: 'Checking',
|
||||
Savings: 'Savings',
|
||||
CreditCard: 'Credit Card',
|
||||
AutoLoan: 'Auto Loan',
|
||||
PersonalLoan: 'Personal Loan',
|
||||
Mortgage: 'Mortgage',
|
||||
LineOfCredit: 'Line of Credit',
|
||||
Retirement401k: '401(k)',
|
||||
IRA: 'IRA',
|
||||
Brokerage: 'Brokerage',
|
||||
Cash: 'Cash',
|
||||
}
|
||||
return labels[type] || type
|
||||
}
|
||||
|
||||
export function accountTypeIcon(type: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
Checking: 'mdi-bank',
|
||||
Savings: 'mdi-piggy-bank',
|
||||
CreditCard: 'mdi-credit-card',
|
||||
AutoLoan: 'mdi-car',
|
||||
PersonalLoan: 'mdi-cash-multiple',
|
||||
Mortgage: 'mdi-home',
|
||||
LineOfCredit: 'mdi-credit-card-outline',
|
||||
Retirement401k: 'mdi-chart-line',
|
||||
IRA: 'mdi-chart-areaspline',
|
||||
Brokerage: 'mdi-finance',
|
||||
Cash: 'mdi-cash',
|
||||
}
|
||||
return icons[type] || 'mdi-bank'
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="d-flex align-center mb-4">
|
||||
<h1 class="text-h4">Accounts</h1>
|
||||
<v-spacer />
|
||||
<v-btn color="primary" prepend-icon="mdi-plus" @click="showDialog = true">Add Account</v-btn>
|
||||
</div>
|
||||
|
||||
<v-row>
|
||||
<v-col v-for="account in accountsStore.accounts" :key="account.id" cols="12" md="6" lg="4">
|
||||
<v-card :to="`/accounts/${account.id}/transactions`" hover>
|
||||
<v-card-title>
|
||||
<v-icon class="mr-2">{{ accountTypeIcon(account.type) }}</v-icon>
|
||||
{{ account.name }}
|
||||
</v-card-title>
|
||||
<v-card-subtitle>{{ account.institution }} - {{ accountTypeLabel(account.type) }}</v-card-subtitle>
|
||||
<v-card-text>
|
||||
<div class="text-h4" :class="account.balance >= 0 ? 'text-success' : 'text-error'">
|
||||
{{ formatCurrency(account.balance) }}
|
||||
</div>
|
||||
<div v-if="account.creditLimit" class="text-caption mt-1">
|
||||
Credit Limit: {{ formatCurrency(account.creditLimit) }}
|
||||
({{ ((Math.abs(account.balance) / account.creditLimit) * 100).toFixed(0) }}% used)
|
||||
</div>
|
||||
<div v-if="account.interestRate" class="text-caption">APR: {{ account.interestRate }}%</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn size="small" variant="text" @click.prevent="editAccount(account)">Edit</v-btn>
|
||||
<v-btn v-if="account.hasLoanDetail" size="small" variant="text" color="info" :to="`/loans/${account.id}`" @click.stop>Loan Details</v-btn>
|
||||
<v-spacer />
|
||||
<v-btn size="small" variant="text" color="error" @click.prevent="confirmDelete(account)">Delete</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-dialog v-model="showDialog" max-width="600">
|
||||
<v-card>
|
||||
<v-card-title>{{ editing ? 'Edit Account' : 'Add Account' }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field v-model="form.name" label="Account Name" required />
|
||||
<v-select v-model="form.type" label="Account Type" :items="accountTypes" item-title="label" item-value="value" />
|
||||
<v-text-field v-model="form.institution" label="Institution" />
|
||||
<v-text-field v-model="form.accountNumber" label="Account Number (last 4)" maxlength="4" />
|
||||
<v-text-field v-model.number="form.balance" label="Current Balance" type="number" prefix="$" />
|
||||
<v-text-field v-if="showCreditLimit" v-model.number="form.creditLimit" label="Credit Limit" type="number" prefix="$" />
|
||||
<v-text-field v-if="showInterestRate" v-model.number="form.interestRate" label="Interest Rate (APR)" type="number" suffix="%" />
|
||||
<v-textarea v-model="form.notes" label="Notes" rows="2" />
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn @click="showDialog = false">Cancel</v-btn>
|
||||
<v-btn color="primary" @click="saveAccount">{{ editing ? 'Update' : 'Create' }}</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog v-model="showDeleteDialog" max-width="400">
|
||||
<v-card>
|
||||
<v-card-title>Delete Account</v-card-title>
|
||||
<v-card-text>Are you sure you want to delete "{{ deleteTarget?.name }}"? This will also delete all transactions.</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn @click="showDeleteDialog = false">Cancel</v-btn>
|
||||
<v-btn color="error" @click="doDelete">Delete</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useAccountsStore } from '@/stores/accounts'
|
||||
import { formatCurrency, accountTypeLabel, accountTypeIcon } from '@/utils/formatters'
|
||||
import type { Account } from '@/types'
|
||||
|
||||
const accountsStore = useAccountsStore()
|
||||
const showDialog = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const editing = ref(false)
|
||||
const editingId = ref('')
|
||||
const deleteTarget = ref<Account | null>(null)
|
||||
|
||||
const accountTypes = [
|
||||
{ label: 'Checking', value: 'Checking' }, { label: 'Savings', value: 'Savings' },
|
||||
{ label: 'Credit Card', value: 'CreditCard' }, { label: 'Auto Loan', value: 'AutoLoan' },
|
||||
{ label: 'Personal Loan', value: 'PersonalLoan' }, { label: 'Mortgage', value: 'Mortgage' },
|
||||
{ label: 'Line of Credit', value: 'LineOfCredit' }, { label: '401(k)', value: 'Retirement401k' },
|
||||
{ label: 'IRA', value: 'IRA' }, { label: 'Brokerage', value: 'Brokerage' }, { label: 'Cash', value: 'Cash' },
|
||||
]
|
||||
|
||||
const form = ref({ name: '', type: 'Checking', institution: '', accountNumber: '', balance: 0, creditLimit: null as number | null, interestRate: null as number | null, notes: '' })
|
||||
|
||||
const showCreditLimit = computed(() => ['CreditCard', 'LineOfCredit'].includes(form.value.type))
|
||||
const showInterestRate = computed(() => ['CreditCard', 'AutoLoan', 'PersonalLoan', 'Mortgage', 'LineOfCredit'].includes(form.value.type))
|
||||
|
||||
onMounted(() => accountsStore.fetchAccounts())
|
||||
|
||||
function editAccount(account: Account) {
|
||||
editing.value = true
|
||||
editingId.value = account.id
|
||||
form.value = { name: account.name, type: account.type, institution: account.institution || '', accountNumber: account.accountNumber || '', balance: account.balance, creditLimit: account.creditLimit, interestRate: account.interestRate, notes: account.notes || '' }
|
||||
showDialog.value = true
|
||||
}
|
||||
|
||||
function confirmDelete(account: Account) {
|
||||
deleteTarget.value = account
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
async function saveAccount() {
|
||||
if (editing.value) {
|
||||
await accountsStore.updateAccount(editingId.value, { ...form.value, isActive: true, sortOrder: 0 })
|
||||
} else {
|
||||
await accountsStore.createAccount(form.value)
|
||||
}
|
||||
showDialog.value = false
|
||||
editing.value = false
|
||||
form.value = { name: '', type: 'Checking', institution: '', accountNumber: '', balance: 0, creditLimit: null, interestRate: null, notes: '' }
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
if (deleteTarget.value) {
|
||||
await accountsStore.deleteAccount(deleteTarget.value.id)
|
||||
showDeleteDialog.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<v-container class="fill-height" fluid>
|
||||
<v-row align="center" justify="center">
|
||||
<v-col cols="12" sm="8" md="4">
|
||||
<v-card class="elevation-12">
|
||||
<v-toolbar color="primary">
|
||||
<v-toolbar-title><v-icon class="mr-2">mdi-cat</v-icon>Purrse Login</v-toolbar-title>
|
||||
</v-toolbar>
|
||||
<v-card-text>
|
||||
<v-alert v-if="error" type="error" class="mb-4" closable>{{ error }}</v-alert>
|
||||
<v-form @submit.prevent="handleLogin">
|
||||
<v-text-field v-model="username" label="Username" prepend-icon="mdi-account" required autofocus />
|
||||
<v-text-field v-model="password" label="Password" prepend-icon="mdi-lock" type="password" required />
|
||||
<v-btn type="submit" color="primary" block size="large" :loading="loading" class="mt-4">Login</v-btn>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" to="/register">Create Account</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
async function handleLogin() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await authStore.login(username.value, password.value)
|
||||
router.push('/')
|
||||
} catch (e: any) {
|
||||
error.value = e.response?.data?.error || 'Login failed'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<v-container class="fill-height" fluid>
|
||||
<v-row align="center" justify="center">
|
||||
<v-col cols="12" sm="8" md="4">
|
||||
<v-card class="elevation-12">
|
||||
<v-toolbar color="primary">
|
||||
<v-toolbar-title><v-icon class="mr-2">mdi-cat</v-icon>Create Account</v-toolbar-title>
|
||||
</v-toolbar>
|
||||
<v-card-text>
|
||||
<v-alert v-if="error" type="error" class="mb-4" closable>{{ error }}</v-alert>
|
||||
<v-form @submit.prevent="handleRegister">
|
||||
<v-text-field v-model="username" label="Username" prepend-icon="mdi-account" required />
|
||||
<v-text-field v-model="email" label="Email" prepend-icon="mdi-email" type="email" required />
|
||||
<v-text-field v-model="password" label="Password" prepend-icon="mdi-lock" type="password" required />
|
||||
<v-text-field v-model="confirmPassword" label="Confirm Password" prepend-icon="mdi-lock-check" type="password" required />
|
||||
<v-btn type="submit" color="primary" block size="large" :loading="loading" class="mt-4">Register</v-btn>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" to="/login">Already have an account?</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const username = ref('')
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
async function handleRegister() {
|
||||
if (password.value !== confirmPassword.value) {
|
||||
error.value = 'Passwords do not match'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await authStore.register(username.value, email.value, password.value)
|
||||
router.push('/')
|
||||
} catch (e: any) {
|
||||
error.value = e.response?.data?.error || 'Registration failed'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-h4 mb-4">Budgets</h1>
|
||||
<v-card>
|
||||
<v-card-text>
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="auto">
|
||||
<v-btn icon @click="prevMonth"><v-icon>mdi-chevron-left</v-icon></v-btn>
|
||||
</v-col>
|
||||
<v-col cols="auto"><h2>{{ monthLabel }}</h2></v-col>
|
||||
<v-col cols="auto">
|
||||
<v-btn icon @click="nextMonth"><v-icon>mdi-chevron-right</v-icon></v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<p class="text-medium-emphasis">Budget management coming in Phase 3. Set monthly spending targets per category and track actual vs budgeted amounts.</p>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
const year = ref(new Date().getFullYear())
|
||||
const month = ref(new Date().getMonth() + 1)
|
||||
const monthLabel = computed(() => new Date(year.value, month.value - 1).toLocaleDateString('en-US', { year: 'numeric', month: 'long' }))
|
||||
function prevMonth() { if (month.value === 1) { month.value = 12; year.value-- } else month.value-- }
|
||||
function nextMonth() { if (month.value === 12) { month.value = 1; year.value++ } else month.value++ }
|
||||
</script>
|
||||
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="d-flex align-center mb-4">
|
||||
<h1 class="text-h4">Categories</h1>
|
||||
<v-spacer />
|
||||
<v-btn color="primary" prepend-icon="mdi-plus" @click="showDialog = true">Add Category</v-btn>
|
||||
</div>
|
||||
<v-card>
|
||||
<v-card-text>
|
||||
<v-treeview :items="categoryTree" item-value="id" item-title="name" open-all density="compact">
|
||||
<template #prepend="{ item }">
|
||||
<v-chip :color="item.type === 'Income' ? 'success' : item.type === 'Transfer' ? 'info' : 'error'" size="x-small">{{ item.type }}</v-chip>
|
||||
</template>
|
||||
</v-treeview>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-dialog v-model="showDialog" max-width="400">
|
||||
<v-card>
|
||||
<v-card-title>Add Category</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field v-model="form.name" label="Category Name" />
|
||||
<v-select v-model="form.type" label="Type" :items="['Income','Expense','Transfer']" />
|
||||
<v-select v-model="form.parentId" label="Parent Category" :items="parentOptions" item-title="name" item-value="id" clearable />
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn @click="showDialog = false">Cancel</v-btn>
|
||||
<v-btn color="primary" @click="saveCategory">Create</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { categoriesApi } from '@/services/categories'
|
||||
import type { CategoryTree, Category } from '@/types'
|
||||
|
||||
const categoryTree = ref<CategoryTree[]>([])
|
||||
const allCategories = ref<Category[]>([])
|
||||
const showDialog = ref(false)
|
||||
const form = ref({ name: '', type: 'Expense', parentId: null as string | null })
|
||||
|
||||
const parentOptions = computed(() => allCategories.value.filter(c => !c.parentId))
|
||||
|
||||
onMounted(async () => {
|
||||
const [tree, all] = await Promise.all([categoriesApi.getTree(), categoriesApi.getAll()])
|
||||
categoryTree.value = tree.data
|
||||
allCategories.value = all.data
|
||||
})
|
||||
|
||||
async function saveCategory() {
|
||||
await categoriesApi.create(form.value)
|
||||
const [tree, all] = await Promise.all([categoriesApi.getTree(), categoriesApi.getAll()])
|
||||
categoryTree.value = tree.data
|
||||
allCategories.value = all.data
|
||||
showDialog.value = false
|
||||
form.value = { name: '', type: 'Expense', parentId: null }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-h4 mb-4">Dashboard</h1>
|
||||
<v-row v-if="loading">
|
||||
<v-col cols="12" class="text-center"><v-progress-circular indeterminate color="primary" size="64" /></v-col>
|
||||
</v-row>
|
||||
<template v-else-if="dashboard">
|
||||
<v-row>
|
||||
<v-col cols="12" md="4">
|
||||
<v-card color="primary" variant="tonal">
|
||||
<v-card-text>
|
||||
<div class="text-overline">Net Worth</div>
|
||||
<div class="text-h4">{{ formatCurrency(dashboard.netWorth) }}</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4">
|
||||
<v-card color="success" variant="tonal">
|
||||
<v-card-text>
|
||||
<div class="text-overline">Total Assets</div>
|
||||
<div class="text-h4">{{ formatCurrency(dashboard.totalAssets) }}</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4">
|
||||
<v-card color="error" variant="tonal">
|
||||
<v-card-text>
|
||||
<div class="text-overline">Total Liabilities</div>
|
||||
<div class="text-h4">{{ formatCurrency(dashboard.totalLiabilities) }}</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row class="mt-4">
|
||||
<v-col cols="12" md="6">
|
||||
<v-card>
|
||||
<v-card-title><v-icon class="mr-2">mdi-bank</v-icon>Accounts</v-card-title>
|
||||
<v-list density="compact">
|
||||
<v-list-item v-for="acct in dashboard.accounts" :key="acct.id" :to="`/accounts/${acct.id}/transactions`">
|
||||
<template #prepend><v-icon>{{ accountTypeIcon(acct.type) }}</v-icon></template>
|
||||
<v-list-item-title>{{ acct.name }}</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ acct.institution }}</v-list-item-subtitle>
|
||||
<template #append>
|
||||
<span :class="acct.balance >= 0 ? 'text-success' : 'text-error'">{{ formatCurrency(acct.balance) }}</span>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-card>
|
||||
<v-card-title><v-icon class="mr-2">mdi-chart-pie</v-icon>Spending This Month</v-card-title>
|
||||
<v-card-text>
|
||||
<div class="text-h5 mb-2">{{ formatCurrency(dashboard.spendingSummary.thisMonth) }}</div>
|
||||
<div class="text-caption" :class="dashboard.spendingSummary.changePercent <= 0 ? 'text-success' : 'text-error'">
|
||||
{{ formatPercent(dashboard.spendingSummary.changePercent) }} vs last month
|
||||
</div>
|
||||
<v-list density="compact" class="mt-2">
|
||||
<v-list-item v-for="cat in dashboard.spendingSummary.topCategories" :key="cat.categoryName">
|
||||
<v-list-item-title>{{ cat.categoryName }}</v-list-item-title>
|
||||
<template #append>{{ formatCurrency(cat.amount) }}</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row class="mt-4">
|
||||
<v-col cols="12" md="6">
|
||||
<v-card>
|
||||
<v-card-title><v-icon class="mr-2">mdi-history</v-icon>Recent Transactions</v-card-title>
|
||||
<v-list density="compact">
|
||||
<v-list-item v-for="txn in dashboard.recentTransactions" :key="txn.id">
|
||||
<v-list-item-title>{{ txn.payeeName || 'Unknown' }}</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ formatDate(txn.date) }} - {{ txn.accountName }}</v-list-item-subtitle>
|
||||
<template #append>
|
||||
<span :class="txn.amount >= 0 ? 'text-success' : 'text-error'">{{ formatCurrency(txn.amount) }}</span>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-card>
|
||||
<v-card-title><v-icon class="mr-2">mdi-calendar-clock</v-icon>Upcoming Bills</v-card-title>
|
||||
<v-list density="compact">
|
||||
<v-list-item v-for="bill in dashboard.upcomingBills" :key="bill.id">
|
||||
<v-list-item-title>{{ bill.payeeName }}</v-list-item-title>
|
||||
<v-list-item-subtitle>Due {{ formatDate(bill.dueDate) }} - {{ bill.accountName }}</v-list-item-subtitle>
|
||||
<template #append>
|
||||
<span class="text-error">{{ formatCurrency(bill.amount) }}</span>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="!dashboard.upcomingBills.length">
|
||||
<v-list-item-title class="text-center text-medium-emphasis">No upcoming bills</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import api from '@/services/api'
|
||||
import type { DashboardData } from '@/types'
|
||||
import { formatCurrency, formatDate, formatPercent, accountTypeIcon } from '@/utils/formatters'
|
||||
|
||||
const dashboard = ref<DashboardData | null>(null)
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { data } = await api.get<DashboardData>('/dashboard')
|
||||
dashboard.value = data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-h4 mb-4">Investments</h1>
|
||||
<v-card><v-card-text><p class="text-medium-emphasis">Investment portfolio tracking coming in Phase 5. Track 401k holdings, performance, and allocation.</p></v-card-text></v-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-h4 mb-4">Loan Details</h1>
|
||||
<v-card><v-card-text><p class="text-medium-emphasis">Loan amortization schedules and payoff scenario calculator coming in Phase 3.</p></v-card-text></v-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{ id?: string }>()
|
||||
</script>
|
||||
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-h4 mb-4">Reports</h1>
|
||||
<v-row>
|
||||
<v-col v-for="report in reports" :key="report.title" cols="12" md="6">
|
||||
<v-card>
|
||||
<v-card-title><v-icon class="mr-2">{{ report.icon }}</v-icon>{{ report.title }}</v-card-title>
|
||||
<v-card-text>{{ report.description }}</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const reports = [
|
||||
{ title: 'Spending by Category', icon: 'mdi-chart-pie', description: 'See where your money goes by category' },
|
||||
{ title: 'Income vs Expense', icon: 'mdi-chart-bar', description: 'Monthly income compared to expenses' },
|
||||
{ title: 'Net Worth', icon: 'mdi-chart-line', description: 'Track your net worth over time' },
|
||||
{ title: 'Cash Flow', icon: 'mdi-chart-timeline-variant', description: 'Daily and monthly cash flow analysis' },
|
||||
]
|
||||
</script>
|
||||
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-h4 mb-4">Scheduled Transactions</h1>
|
||||
<v-card><v-card-text><p class="text-medium-emphasis">Recurring bill management coming in Phase 3. Set up automatic recurring transactions with reminders.</p></v-card-text></v-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="d-flex align-center mb-4">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<v-card class="mb-4">
|
||||
<v-card-text>
|
||||
<v-row>
|
||||
<v-col cols="12" md="3">
|
||||
<v-text-field v-model="search" label="Search" prepend-icon="mdi-magnify" clearable density="compact" @update:model-value="doSearch" />
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-text-field v-model="startDate" label="Start Date" type="date" density="compact" @update:model-value="doSearch" />
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-text-field v-model="endDate" label="End Date" type="date" density="compact" @update:model-value="doSearch" />
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-select v-model="statusFilter" label="Status" :items="['All','Uncleared','Cleared','Reconciled']" density="compact" @update:model-value="doSearch" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-card>
|
||||
<v-data-table :headers="headers" :items="transactions" :loading="loading" :items-per-page="50" density="compact">
|
||||
<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 === 'Reconciled' ? 'success' : item.status === 'Cleared' ? 'info' : 'default'" size="x-small">{{ item.status }}</v-chip>
|
||||
</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>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
|
||||
<!-- Add/Edit Transaction Dialog -->
|
||||
<v-dialog v-model="showAddDialog" max-width="600">
|
||||
<v-card>
|
||||
<v-card-title>{{ editingTxn ? 'Edit Transaction' : 'Add Transaction' }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-select v-if="!props.id" v-model="txnForm.accountId" label="Account" :items="accountsStore.accounts" item-title="name" item-value="id" />
|
||||
<v-text-field v-model="txnForm.date" label="Date" type="date" />
|
||||
<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-text-field v-model="txnForm.memo" label="Memo" />
|
||||
<v-text-field v-model="txnForm.checkNumber" label="Check #" />
|
||||
</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-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Transfer Dialog -->
|
||||
<v-dialog v-model="showTransferDialog" max-width="500">
|
||||
<v-card>
|
||||
<v-card-title>Transfer Between Accounts</v-card-title>
|
||||
<v-card-text>
|
||||
<v-select v-model="transferForm.fromAccountId" label="From Account" :items="accountsStore.accounts" item-title="name" item-value="id" />
|
||||
<v-select v-model="transferForm.toAccountId" label="To Account" :items="accountsStore.accounts" item-title="name" item-value="id" />
|
||||
<v-text-field v-model="transferForm.date" label="Date" type="date" />
|
||||
<v-text-field v-model.number="transferForm.amount" label="Amount" type="number" prefix="$" />
|
||||
<v-text-field v-model="transferForm.memo" label="Memo" />
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn @click="showTransferDialog = false">Cancel</v-btn>
|
||||
<v-btn color="primary" @click="createTransfer">Transfer</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { transactionsApi } from '@/services/transactions'
|
||||
import { useAccountsStore } from '@/stores/accounts'
|
||||
import { formatCurrency, formatDate } from '@/utils/formatters'
|
||||
import type { Transaction } from '@/types'
|
||||
|
||||
const props = defineProps<{ id?: string }>()
|
||||
const accountsStore = useAccountsStore()
|
||||
const transactions = ref<Transaction[]>([])
|
||||
const loading = ref(false)
|
||||
const accountName = ref('')
|
||||
const search = ref('')
|
||||
const startDate = ref('')
|
||||
const endDate = ref('')
|
||||
const statusFilter = ref('All')
|
||||
const showAddDialog = ref(false)
|
||||
const showTransferDialog = ref(false)
|
||||
const editingTxn = ref(false)
|
||||
const editingTxnId = ref('')
|
||||
|
||||
const headers = [
|
||||
{ title: 'Date', key: 'date', width: '100px' },
|
||||
{ title: 'Payee', key: 'payeeName' },
|
||||
{ title: 'Category', key: 'categoryName' },
|
||||
{ title: 'Memo', key: 'memo' },
|
||||
{ title: 'Amount', key: 'amount', align: 'end' as const },
|
||||
{ title: 'Status', key: 'status', width: '100px' },
|
||||
{ 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 transferForm = ref({ fromAccountId: '', toAccountId: '', date: new Date().toISOString().split('T')[0], amount: 0, memo: '' })
|
||||
|
||||
onMounted(async () => {
|
||||
await accountsStore.fetchAccounts()
|
||||
if (props.id) {
|
||||
txnForm.value.accountId = props.id
|
||||
const acct = accountsStore.accounts.find(a => a.id === props.id)
|
||||
accountName.value = acct?.name || ''
|
||||
}
|
||||
await loadTransactions()
|
||||
})
|
||||
|
||||
watch(() => props.id, () => loadTransactions())
|
||||
|
||||
async function loadTransactions() {
|
||||
loading.value = true
|
||||
try {
|
||||
if (props.id) {
|
||||
const { data } = await transactionsApi.getByAccount(props.id)
|
||||
transactions.value = data.items
|
||||
} else {
|
||||
const { data } = await transactionsApi.search({})
|
||||
transactions.value = data.items
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doSearch() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await transactionsApi.search({
|
||||
accountId: props.id || undefined,
|
||||
searchText: search.value || undefined,
|
||||
startDate: startDate.value || undefined,
|
||||
endDate: endDate.value || undefined,
|
||||
status: statusFilter.value === 'All' ? undefined : statusFilter.value,
|
||||
})
|
||||
transactions.value = data.items
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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 || '' }
|
||||
showAddDialog.value = true
|
||||
}
|
||||
|
||||
async function saveTxn() {
|
||||
const amount = txnForm.value.type === 'Debit' ? -Math.abs(txnForm.value.amount) : Math.abs(txnForm.value.amount)
|
||||
if (editingTxn.value) {
|
||||
await transactionsApi.update(editingTxnId.value, { ...txnForm.value, amount, status: 'Uncleared', splits: null })
|
||||
} else {
|
||||
await transactionsApi.create({ ...txnForm.value, amount, accountId: txnForm.value.accountId || props.id })
|
||||
}
|
||||
showAddDialog.value = false
|
||||
editingTxn.value = false
|
||||
await loadTransactions()
|
||||
}
|
||||
|
||||
async function deleteTransaction(id: string) {
|
||||
await transactionsApi.delete(id)
|
||||
await loadTransactions()
|
||||
}
|
||||
|
||||
async function createTransfer() {
|
||||
await transactionsApi.createTransfer(transferForm.value)
|
||||
showTransferDialog.value = false
|
||||
await loadTransactions()
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue", "env.d.ts"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:5000',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/hubs': {
|
||||
target: 'http://localhost:5000',
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user