app/components/OneOffEventEditor.vue

352 lines
10 KiB
Vue

<template>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-neutral-900 dark:text-white">
One-Off Transactions
</h3>
<p class="text-sm text-neutral-600 dark:text-neutral-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-neutral-400 mb-4" />
<h4 class="text-lg font-medium text-neutral-900 dark:text-white mb-2">
No transactions yet
</h4>
<p class="text-neutral-600 dark:text-neutral-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-neutral-200 dark:border-neutral-700">
<h4 class="font-medium text-neutral-900 dark:text-white">
{{ monthGroup.monthName }}
</h4>
<div class="flex items-center gap-3">
<UBadge variant="subtle" color="neutral">
{{ 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-neutral-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="neutral"
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-neutral-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>