Private
Public Access
1
0

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:
Catherine Renelle
2026-02-08 08:56:46 -05:00
commit 6520ebf221
169 changed files with 7180 additions and 0 deletions
+11
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+13
View File
@@ -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>
+26
View File
@@ -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;
}
}
+30
View File
@@ -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"
}
}
+16
View File
@@ -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>
+12
View File
@@ -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')
+37
View File
@@ -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
+12
View File
@@ -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 } }),
}
+36
View File
@@ -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
+10
View File
@@ -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}`),
}
+14
View File
@@ -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`),
}
+39
View File
@@ -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 }
})
+60
View File
@@ -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 }
})
+237
View File
@@ -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
}
+51
View File
@@ -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>
+51
View File
@@ -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>
+59
View File
@@ -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>
+10
View File
@@ -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>
+20
View File
@@ -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" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+25
View File
@@ -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
}
}
}
})