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,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>
|
||||
Reference in New Issue
Block a user