app/components/OneOffEventEditor.vue

305 lines
No EOL
9.5 KiB
Vue

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