305 lines
No EOL
9.5 KiB
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> |