352 lines
10 KiB
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>
|