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
@@ -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>