refactor: update routing paths in app.vue, enhance AnnualBudget component layout, and streamline dashboard and budget pages for improved user experience
This commit is contained in:
parent
09d8794d72
commit
864a81065c
23 changed files with 3211 additions and 1978 deletions
305
components/OneOffEventEditor.vue
Normal file
305
components/OneOffEventEditor.vue
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
<template>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
One-Off Transactions
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Add one-time income or expense transactions with expected dates.
|
||||
</p>
|
||||
</div>
|
||||
<UButton @click="addEvent" size="sm" icon="i-heroicons-plus">
|
||||
Add Transaction
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="sortedEvents.length === 0" class="text-center py-8">
|
||||
<UIcon name="i-heroicons-banknotes" class="w-12 h-12 mx-auto text-gray-400 mb-4" />
|
||||
<h4 class="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
No transactions yet
|
||||
</h4>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Add one-off income or expense transactions.
|
||||
</p>
|
||||
<UButton @click="addEvent" color="primary">
|
||||
Add Your First Transaction
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<!-- Events list -->
|
||||
<div v-else class="space-y-6">
|
||||
<!-- Month grouping -->
|
||||
<div
|
||||
v-for="monthGroup in eventsByMonth"
|
||||
:key="monthGroup.month"
|
||||
class="space-y-3"
|
||||
>
|
||||
<!-- Month header -->
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<h4 class="font-medium text-gray-900 dark:text-white">
|
||||
{{ monthGroup.monthName }}
|
||||
</h4>
|
||||
<div class="flex items-center gap-3">
|
||||
<UBadge variant="subtle" color="gray">
|
||||
{{ monthGroup.events.length }} transaction{{ monthGroup.events.length !== 1 ? 's' : '' }}
|
||||
</UBadge>
|
||||
<div class="text-sm font-medium" :class="monthGroup.netAmount >= 0 ? 'text-green-600' : 'text-red-600'">
|
||||
{{ monthGroup.netAmount >= 0 ? '+' : '' }}{{ formatCurrency(monthGroup.netAmount) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Events in this month -->
|
||||
<div class="space-y-3">
|
||||
<UCard
|
||||
v-for="event in monthGroup.events"
|
||||
:key="event.id"
|
||||
:ui="{
|
||||
background: event.type === 'income' ? 'bg-green-50 dark:bg-green-900/20' : 'bg-red-50 dark:bg-red-900/20',
|
||||
ring: event.type === 'income' ? 'ring-green-200 dark:ring-green-800' : 'ring-red-200 dark:ring-red-800'
|
||||
}"
|
||||
>
|
||||
<UForm :state="event" @submit="() => {}">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<!-- Category -->
|
||||
<UFormField label="Category" name="category" required>
|
||||
<USelect
|
||||
v-model="event.category"
|
||||
:options="categoryOptions"
|
||||
@update:model-value="updateEvent(event.id, { category: $event })"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<!-- Name -->
|
||||
<UFormField label="Name" name="name" required>
|
||||
<UInput
|
||||
v-model="event.name"
|
||||
placeholder="e.g., Equipment purchase"
|
||||
@update:model-value="updateEvent(event.id, { name: $event })"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<!-- Type -->
|
||||
<UFormField label="Type" name="type" required>
|
||||
<USelect
|
||||
v-model="event.type"
|
||||
:options="typeOptions"
|
||||
@update:model-value="updateEvent(event.id, { type: $event })"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<!-- Amount -->
|
||||
<UFormField label="Amount" name="amount" required>
|
||||
<UInput
|
||||
v-model="event.amount"
|
||||
type="number"
|
||||
placeholder="5000"
|
||||
@update:model-value="updateEvent(event.id, { amount: Number($event) })"
|
||||
>
|
||||
<template #leading>
|
||||
<span class="text-gray-500">$</span>
|
||||
</template>
|
||||
</UInput>
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<!-- Date Expected -->
|
||||
<div class="mt-4">
|
||||
<UFormField label="Date Expected" name="dateExpected" required>
|
||||
<UInput
|
||||
v-model="event.dateExpected"
|
||||
type="date"
|
||||
@update:model-value="updateEventWithDate(event.id, $event)"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
</UForm>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-between">
|
||||
<UButton
|
||||
variant="ghost"
|
||||
color="red"
|
||||
size="sm"
|
||||
icon="i-heroicons-trash"
|
||||
@click="removeEvent(event.id)"
|
||||
>
|
||||
Delete
|
||||
</UButton>
|
||||
<UDropdown :items="getEventActions(event)">
|
||||
<UButton variant="ghost" color="gray" size="sm" icon="i-heroicons-ellipsis-horizontal" />
|
||||
</UDropdown>
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<UCard>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
Total {{ sortedEvents.length }} transaction{{ sortedEvents.length !== 1 ? 's' : '' }}
|
||||
</span>
|
||||
<span class="text-lg font-bold" :class="totalAnnualImpact >= 0 ? 'text-green-600' : 'text-red-600'">
|
||||
{{ totalAnnualImpact >= 0 ? '+' : '' }}{{ formatCurrency(totalAnnualImpact) }}
|
||||
</span>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { OneOffEvent } from '~/types/cash'
|
||||
|
||||
const cashStore = useCashStore()
|
||||
|
||||
// Constants
|
||||
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December']
|
||||
|
||||
const typeOptions = [
|
||||
{ label: 'Income', value: 'income' },
|
||||
{ label: 'Expense', value: 'expense' }
|
||||
]
|
||||
|
||||
const categoryOptions = [
|
||||
{ label: 'Equipment', value: 'Equipment' },
|
||||
{ label: 'Marketing', value: 'Marketing' },
|
||||
{ label: 'Legal', value: 'Legal' },
|
||||
{ label: 'Contractors', value: 'Contractors' },
|
||||
{ label: 'Office', value: 'Office' },
|
||||
{ label: 'Development', value: 'Development' },
|
||||
{ label: 'Other', value: 'Other' }
|
||||
]
|
||||
|
||||
// Computed
|
||||
const { oneOffEvents } = storeToRefs(cashStore)
|
||||
|
||||
const sortedEvents = computed(() => {
|
||||
return oneOffEvents.value
|
||||
.slice()
|
||||
.sort((a, b) => a.month - b.month || a.name.localeCompare(b.name))
|
||||
})
|
||||
|
||||
const eventsByMonth = computed(() => {
|
||||
const groups: Record<number, OneOffEvent[]> = {}
|
||||
|
||||
sortedEvents.value.forEach(event => {
|
||||
if (!groups[event.month]) {
|
||||
groups[event.month] = []
|
||||
}
|
||||
groups[event.month].push(event)
|
||||
})
|
||||
|
||||
return Object.entries(groups).map(([month, events]) => {
|
||||
const monthNum = parseInt(month)
|
||||
const netAmount = events.reduce((sum, event) => {
|
||||
return sum + (event.type === 'income' ? event.amount : -event.amount)
|
||||
}, 0)
|
||||
|
||||
return {
|
||||
month: monthNum,
|
||||
monthName: monthNames[monthNum],
|
||||
events,
|
||||
netAmount
|
||||
}
|
||||
}).sort((a, b) => a.month - b.month)
|
||||
})
|
||||
|
||||
const totalIncome = computed(() => {
|
||||
return oneOffEvents.value
|
||||
.filter(e => e.type === 'income')
|
||||
.reduce((sum, e) => sum + e.amount, 0)
|
||||
})
|
||||
|
||||
const totalExpenses = computed(() => {
|
||||
return oneOffEvents.value
|
||||
.filter(e => e.type === 'expense')
|
||||
.reduce((sum, e) => sum + e.amount, 0)
|
||||
})
|
||||
|
||||
const totalAnnualImpact = computed(() => totalIncome.value - totalExpenses.value)
|
||||
|
||||
// Methods
|
||||
function addEvent() {
|
||||
const currentMonth = new Date().getMonth()
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
cashStore.addOneOffEvent({
|
||||
name: '',
|
||||
type: 'income',
|
||||
amount: 0,
|
||||
category: 'Other',
|
||||
dateExpected: today
|
||||
})
|
||||
}
|
||||
|
||||
function updateEvent(eventId: string, updates: Partial<OneOffEvent>) {
|
||||
cashStore.updateOneOffEvent(eventId, updates)
|
||||
}
|
||||
|
||||
function updateEventWithDate(eventId: string, dateExpected: string) {
|
||||
const eventDate = new Date(dateExpected)
|
||||
const month = eventDate.getMonth()
|
||||
cashStore.updateOneOffEvent(eventId, { dateExpected, month })
|
||||
}
|
||||
|
||||
function removeEvent(eventId: string) {
|
||||
cashStore.removeOneOffEvent(eventId)
|
||||
}
|
||||
|
||||
function getEventActions(event: OneOffEvent) {
|
||||
return [
|
||||
[{
|
||||
label: 'Move to Different Month',
|
||||
icon: 'i-heroicons-arrow-right',
|
||||
click: () => moveToMonth(event.id)
|
||||
}],
|
||||
[{
|
||||
label: 'Duplicate Event',
|
||||
icon: 'i-heroicons-document-duplicate',
|
||||
click: () => duplicateEvent(event)
|
||||
}]
|
||||
]
|
||||
}
|
||||
|
||||
function moveToMonth(eventId: string) {
|
||||
// This could open a month selector modal
|
||||
// For now, just move to next month
|
||||
const event = oneOffEvents.value.find(e => e.id === eventId)
|
||||
if (event) {
|
||||
const newMonth = (event.month + 1) % 12
|
||||
updateEvent(eventId, { month: newMonth })
|
||||
}
|
||||
}
|
||||
|
||||
function duplicateEvent(event: OneOffEvent) {
|
||||
cashStore.addOneOffEvent({
|
||||
name: `${event.name} (Copy)`,
|
||||
type: event.type,
|
||||
amount: event.amount,
|
||||
category: event.category,
|
||||
dateExpected: event.dateExpected
|
||||
})
|
||||
}
|
||||
|
||||
function getDateValue(dateExpected: string | undefined): string {
|
||||
if (!dateExpected) {
|
||||
return new Date().toISOString().split('T')[0]
|
||||
}
|
||||
return dateExpected
|
||||
}
|
||||
|
||||
function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0
|
||||
}).format(Math.abs(amount))
|
||||
}
|
||||
</script>
|
||||
Loading…
Add table
Add a link
Reference in a new issue