app/pages/budget.vue

869 lines
28 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div>
<section class="py-8 space-y-6">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-bold">Budget Worksheet</h2>
<div class="flex items-center gap-2">
<UButton
@click="exportBudget"
variant="outline"
size="sm"
:ui="{
base:
'border-2 border-black hover:bg-black hover:text-white transition-none',
}"
>
Export
</UButton>
<UButton
@click="resetWorksheet"
variant="outline"
size="sm"
:ui="{
base:
'border-2 border-black hover:bg-black hover:text-white transition-none',
}"
>
Reset
</UButton>
</div>
</div>
<!-- Budget Table -->
<div class="border-4 border-black bg-white shadow-[8px_8px_0px_0px_rgba(0,0,0,1)]">
<div class="overflow-x-auto">
<table class="w-full border-collapse text-sm">
<thead>
<tr class="border-b-2 border-black">
<th
class="border-r-2 border-black px-4 py-3 text-left font-bold min-w-[250px] sticky left-0 bg-white z-10"
>
Item
</th>
<th
v-for="month in monthlyHeaders"
:key="month.key"
class="border-r border-gray-400 px-2 py-3 text-center font-medium min-w-[80px] last:border-r-0"
>
{{ month.label }}
</th>
</tr>
</thead>
<tbody>
<!-- REVENUE SECTION -->
<tr class="bg-black text-white">
<td class="px-4 py-2 font-bold sticky left-0 bg-black z-10">
<div class="flex items-center justify-between">
<span>REVENUE</span>
<UButton
@click="showAddRevenueModal = true"
size="xs"
:ui="{
base: 'bg-white text-black hover:bg-gray-200 transition-none',
}"
>
+ Add
</UButton>
</div>
</td>
<td class="px-2 py-2" :colspan="monthlyHeaders.length"></td>
</tr>
<!-- Revenue by Category -->
<template
v-for="(items, categoryName) in groupedRevenue"
:key="`revenue-${categoryName}`"
>
<tr v-if="items.length > 0" class="border-t border-gray-300">
<td
class="px-4 py-1 font-semibold sticky left-0 bg-gray-100 z-10 border-r-2 border-black"
:colspan="monthlyHeaders.length + 1"
>
{{ categoryName }}
</td>
</tr>
<tr
v-for="item in items"
:key="item.id"
:class="[
'border-t border-gray-200 hover:bg-gray-50 transition-all duration-300',
highlightedItemId === item.id && 'bg-yellow-100 animate-pulse',
]"
>
<td
class="border-r-2 border-black px-4 py-2 sticky left-0 bg-white z-10"
>
<div class="flex items-center justify-between group">
<div class="flex-1">
<div class="text-left w-full">
<div class="font-medium">{{ item.name }}</div>
<div class="text-xs text-gray-600">
{{ item.subcategory }}
</div>
</div>
</div>
<UButton
@click="removeItem('revenue', item.id)"
size="xs"
variant="ghost"
:ui="{
base:
'text-red-600 hover:bg-red-100 opacity-0 group-hover:opacity-100 transition-none',
}"
>
×
</UButton>
</div>
</td>
<td
v-for="month in monthlyHeaders"
:key="month.key"
class="border-r border-gray-200 px-1 py-1 last:border-r-0"
>
<input
type="text"
:value="formatValue(item.monthlyValues?.[month.key] || 0)"
@focus="handleFocus($event)"
@blur="handleBlur($event, 'revenue', item.id, month.key)"
@keydown.enter="handleEnter($event)"
class="w-full text-right px-1 py-0.5 border-2 border-transparent hover:border-gray-400 focus:border-black focus:outline-none transition-none"
:class="{
'bg-gray-50': !item.monthlyValues?.[month.key],
}"
/>
</td>
</tr>
</template>
<!-- Total Revenue Row -->
<tr class="border-t-2 border-black font-bold bg-gray-100">
<td
class="border-r-2 border-black px-4 py-2 sticky left-0 bg-gray-100 z-10"
>
TOTAL REVENUE
</td>
<td
v-for="month in monthlyHeaders"
:key="month.key"
class="border-r border-gray-400 px-2 py-2 text-right last:border-r-0"
>
{{ formatCurrency(monthlyTotals[month.key]?.revenue || 0) }}
</td>
</tr>
<!-- Spacer -->
<tr>
<td colspan="100" class="h-4"></td>
</tr>
<!-- EXPENSES SECTION -->
<tr class="bg-black text-white">
<td class="px-4 py-2 font-bold sticky left-0 bg-black z-10">
<div class="flex items-center justify-between">
<span>EXPENSES</span>
<UButton
@click="showAddExpenseModal = true"
size="xs"
:ui="{
base: 'bg-white text-black hover:bg-gray-200 transition-none',
}"
>
+ Add
</UButton>
</div>
</td>
<td class="px-2 py-2" :colspan="monthlyHeaders.length"></td>
</tr>
<!-- Expenses by Category -->
<template
v-for="(items, categoryName) in groupedExpenses"
:key="`expense-${categoryName}`"
>
<tr v-if="items.length > 0" class="border-t border-gray-300">
<td
class="px-4 py-1 font-semibold sticky left-0 bg-gray-100 z-10 border-r-2 border-black"
:colspan="monthlyHeaders.length + 1"
>
{{ categoryName }}
</td>
</tr>
<tr
v-for="item in items"
:key="item.id"
:class="[
'border-t border-gray-200 hover:bg-gray-50 transition-all duration-300',
highlightedItemId === item.id && 'bg-yellow-100 animate-pulse',
]"
>
<td
class="border-r-2 border-black px-4 py-2 sticky left-0 bg-white z-10"
>
<div class="flex items-center justify-between group">
<div class="flex-1">
<div class="text-left w-full">
<div class="font-medium">{{ item.name }}</div>
<div class="text-xs text-gray-600">
{{ item.subcategory }}
</div>
</div>
</div>
<UButton
@click="removeItem('expenses', item.id)"
size="xs"
variant="ghost"
:ui="{
base:
'text-red-600 hover:bg-red-100 opacity-0 group-hover:opacity-100 transition-none',
}"
>
×
</UButton>
</div>
</td>
<td
v-for="month in monthlyHeaders"
:key="month.key"
class="border-r border-gray-200 px-1 py-1 last:border-r-0"
>
<input
type="text"
:value="formatValue(item.monthlyValues?.[month.key] || 0)"
@focus="handleFocus($event)"
@blur="handleBlur($event, 'expenses', item.id, month.key)"
@keydown.enter="handleEnter($event)"
class="w-full text-right px-1 py-0.5 border-2 border-transparent hover:border-gray-400 focus:border-black focus:outline-none transition-none"
:class="{
'bg-gray-50': !item.monthlyValues?.[month.key],
}"
/>
</td>
</tr>
</template>
<!-- Total Expenses Row -->
<tr class="border-t-2 border-black font-bold bg-gray-100">
<td
class="border-r-2 border-black px-4 py-2 sticky left-0 bg-gray-100 z-10"
>
TOTAL EXPENSES
</td>
<td
v-for="month in monthlyHeaders"
:key="month.key"
class="border-r border-gray-400 px-2 py-2 text-right last:border-r-0"
>
{{ formatCurrency(monthlyTotals[month.key]?.expenses || 0) }}
</td>
</tr>
<!-- Net Income Row -->
<tr class="border-t-2 border-black font-bold text-lg">
<td class="border-r-2 border-black px-4 py-3 sticky left-0 bg-white z-10">
NET INCOME
</td>
<td
v-for="month in monthlyHeaders"
:key="month.key"
class="border-r border-gray-400 px-2 py-3 text-right last:border-r-0"
:class="getNetIncomeClass(monthlyTotals[month.key]?.net || 0)"
>
{{ formatCurrency(monthlyTotals[month.key]?.net || 0) }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- Add Revenue Modal -->
<UModal v-model:open="showAddRevenueModal">
<template #header>
<div class="flex items-center justify-between border-b-4 border-black pb-4">
<div>
<h3 class="text-xl font-bold text-black">Add Revenue Source</h3>
<p class="mt-1 text-sm text-gray-600">
Create a new revenue stream for your budget
</p>
</div>
<UButton
@click="showAddRevenueModal = false"
variant="ghost"
size="sm"
icon="i-heroicons-x-mark"
class="text-gray-500 hover:text-black"
/>
</div>
</template>
<template #body>
<div class="space-y-5 py-4">
<UFormGroup label="Category" required>
<USelectMenu
v-model="newRevenue.category"
:items="revenueCategories"
placeholder="Select a category"
/>
</UFormGroup>
<UFormGroup label="Subcategory" :required="false">
<USelectMenu
v-model="newRevenue.subcategory"
:items="revenueSubcategories"
placeholder="Select a subcategory"
:disabled="!newRevenue.category"
/>
</UFormGroup>
<UFormGroup label="Revenue Name" required>
<UInput
v-model="newRevenue.name"
type="text"
placeholder="e.g., Monthly Subscription"
/>
</UFormGroup>
<div class="border-t-2 border-gray-200 pt-5">
<h4 class="text-sm font-semibold text-gray-700 mb-3">Initial Values</h4>
<UTabs v-model="revenueInitialTab" :items="revenueHelperTabs" class="w-full">
<template #content="{ item }">
<!-- Annual Distribution -->
<div v-if="item.key === 'annual'" class="pt-4 space-y-4">
<UFormGroup label="Annual Total Amount">
<UInput
v-model.number="newRevenue.annualAmount"
type="number"
placeholder="Enter annual amount (e.g., 12000)"
size="lg"
>
<template #leading>
<span class="text-gray-500">$</span>
</template>
</UInput>
</UFormGroup>
<p class="text-sm text-gray-600">
This will divide ${{ newRevenue.annualAmount || 0 }} equally across all 12 months
(${{ newRevenue.annualAmount ? Math.round(newRevenue.annualAmount / 12) : 0 }} per month)
</p>
</div>
<!-- Monthly Amount -->
<div v-else-if="item.key === 'monthly'" class="pt-4 space-y-4">
<UFormGroup label="Monthly Amount">
<UInput
v-model.number="newRevenue.monthlyAmount"
type="number"
placeholder="Enter monthly amount (e.g., 1000)"
size="lg"
>
<template #leading>
<span class="text-gray-500">$</span>
</template>
</UInput>
</UFormGroup>
<p class="text-sm text-gray-600">
This will set ${{ newRevenue.monthlyAmount || 0 }} for all months
</p>
</div>
<!-- Start Empty -->
<div v-else class="pt-4">
<p class="text-sm text-gray-600">
The revenue item will be created with no initial values. You can fill them in later.
</p>
</div>
</template>
</UTabs>
</div>
</div>
</template>
<template #footer>
<div class="flex justify-end gap-3 border-t-2 border-gray-200 pt-4">
<UButton @click="showAddRevenueModal = false" variant="outline" size="md">
Cancel
</UButton>
<UButton
@click="addRevenueItem"
size="md"
:disabled="!newRevenue.name || !newRevenue.category"
color="primary"
>
<UIcon name="i-heroicons-plus" class="mr-1" />
Add Revenue
</UButton>
</div>
</template>
</UModal>
<!-- Add Expense Modal -->
<UModal v-model:open="showAddExpenseModal">
<template #header>
<div class="flex items-center justify-between border-b-4 border-black pb-4">
<div>
<h3 class="text-xl font-bold text-black">Add Expense Item</h3>
<p class="mt-1 text-sm text-gray-600">Create a new expense for your budget</p>
</div>
<UButton
@click="showAddExpenseModal = false"
variant="ghost"
size="sm"
icon="i-heroicons-x-mark"
class="text-gray-500 hover:text-black"
/>
</div>
</template>
<template #body>
<div class="space-y-5 py-4">
<UFormGroup label="Category" required>
<USelectMenu
v-model="newExpense.category"
:items="expenseCategories"
placeholder="Select a category"
/>
</UFormGroup>
<UFormGroup label="Expense Name" required>
<UInput
v-model="newExpense.name"
type="text"
placeholder="e.g., Office Rent"
/>
</UFormGroup>
<div class="border-t-2 border-gray-200 pt-5">
<h4 class="text-sm font-semibold text-gray-700 mb-3">Initial Values</h4>
<UTabs v-model="expenseInitialTab" :items="expenseHelperTabs" class="w-full">
<template #content="{ item }">
<!-- Annual Distribution -->
<div v-if="item.key === 'annual'" class="pt-4 space-y-4">
<UFormGroup label="Annual Total Amount">
<UInput
v-model.number="newExpense.annualAmount"
type="number"
placeholder="Enter annual amount (e.g., 12000)"
size="lg"
>
<template #leading>
<span class="text-gray-500">$</span>
</template>
</UInput>
</UFormGroup>
<p class="text-sm text-gray-600">
This will divide ${{ newExpense.annualAmount || 0 }} equally across all 12 months
(${{ newExpense.annualAmount ? Math.round(newExpense.annualAmount / 12) : 0 }} per month)
</p>
</div>
<!-- Monthly Amount -->
<div v-else-if="item.key === 'monthly'" class="pt-4 space-y-4">
<UFormGroup label="Monthly Amount">
<UInput
v-model.number="newExpense.monthlyAmount"
type="number"
placeholder="Enter monthly amount (e.g., 1000)"
size="lg"
>
<template #leading>
<span class="text-gray-500">$</span>
</template>
</UInput>
</UFormGroup>
<p class="text-sm text-gray-600">
This will set ${{ newExpense.monthlyAmount || 0 }} for all months
</p>
</div>
<!-- Start Empty -->
<div v-else class="pt-4">
<p class="text-sm text-gray-600">
The expense item will be created with no initial values. You can fill them in later.
</p>
</div>
</template>
</UTabs>
</div>
</div>
</template>
<template #footer>
<div class="flex justify-end gap-3 border-t-2 border-gray-200 pt-4">
<UButton @click="showAddExpenseModal = false" variant="outline" size="md">
Cancel
</UButton>
<UButton
@click="addExpenseItem"
size="md"
:disabled="!newExpense.name || !newExpense.category"
color="primary"
>
<UIcon name="i-heroicons-plus" class="mr-1" />
Add Expense
</UButton>
</div>
</template>
</UModal>
</div>
</template>
<script setup lang="ts">
// Stores
const budgetStore = useBudgetStore();
const streamsStore = useStreamsStore();
const membersStore = useMembersStore();
const policiesStore = usePoliciesStore();
// State
const showAddRevenueModal = ref(false);
const showAddExpenseModal = ref(false);
const activeTab = ref(0);
const highlightedItemId = ref<string | null>(null);
// New item forms
const newRevenue = ref({
category: "",
subcategory: "",
name: "",
initialAmount: 0,
annualAmount: 0,
monthlyAmount: 0,
});
const newExpense = ref({
category: "",
subcategory: "",
name: "",
initialAmount: 0,
annualAmount: 0,
monthlyAmount: 0,
});
// New modal helper tabs
const revenueInitialTab = ref(0);
const revenueHelperTabs = [
{
key: "annual",
label: "Annual Distribution",
icon: "i-heroicons-calendar",
},
{
key: "monthly",
label: "Set All Months",
icon: "i-heroicons-squares-2x2",
},
{
key: "empty",
label: "Start Empty",
icon: "i-heroicons-minus",
},
];
const expenseInitialTab = ref(0);
const expenseHelperTabs = [
{
key: "annual",
label: "Annual Distribution",
icon: "i-heroicons-calendar",
},
{
key: "monthly",
label: "Set All Months",
icon: "i-heroicons-squares-2x2",
},
{
key: "empty",
label: "Start Empty",
icon: "i-heroicons-minus",
},
];
// Data from store - just use the string arrays directly
const revenueCategories = computed(() => budgetStore.revenueCategories);
const expenseCategories = computed(() => budgetStore.expenseCategories);
// Revenue subcategories based on selected category
const revenueSubcategories = computed(() => {
if (!newRevenue.value.category) return [];
return budgetStore.revenueSubcategories[newRevenue.value.category] || [];
});
// Clear subcategory when category changes
watch(() => newRevenue.value.category, (newCategory, oldCategory) => {
if (newCategory !== oldCategory) {
newRevenue.value.subcategory = "";
}
});
// Generate monthly headers
const monthlyHeaders = computed(() => {
const headers = [];
const today = new Date();
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const monthName = date.toLocaleString("default", { month: "short" });
const year = date.getFullYear().toString().slice(-2);
headers.push({
key: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`,
label: `${monthName} '${year}`,
});
}
return headers;
});
// Grouped data
const groupedRevenue = computed(() => budgetStore.groupedRevenue);
const groupedExpenses = computed(() => budgetStore.groupedExpenses);
const monthlyTotals = computed(() => budgetStore.monthlyTotals);
// Initialize on mount
onMounted(async () => {
try {
// Only initialize if not already done (preserve persisted data)
await budgetStore.initializeFromWizardData();
} catch (error) {
console.error("Error initializing budget page:", error);
}
});
// Add revenue item
function addRevenueItem() {
const id = budgetStore.addBudgetItem(
"revenue",
newRevenue.value.name,
newRevenue.value.category,
newRevenue.value.subcategory
);
// Apply helper values based on selected tab
const activeTab = revenueHelperTabs[revenueInitialTab.value];
if (activeTab?.key === 'annual' && Number(newRevenue.value.annualAmount) > 0) {
// Annual distribution
const monthlyAmount = Math.round(newRevenue.value.annualAmount / 12);
monthlyHeaders.value.forEach((month) => {
budgetStore.updateMonthlyValue(
"revenue",
id,
month.key,
monthlyAmount.toString()
);
});
} else if (activeTab?.key === 'monthly' && Number(newRevenue.value.monthlyAmount) > 0) {
// Set all months
monthlyHeaders.value.forEach((month) => {
budgetStore.updateMonthlyValue(
"revenue",
id,
month.key,
newRevenue.value.monthlyAmount.toString()
);
});
}
// If tab === 2 (empty), don't set any values
// Reset form and close modal
newRevenue.value = {
category: "",
subcategory: "",
name: "",
initialAmount: 0,
annualAmount: 0,
monthlyAmount: 0,
};
revenueInitialTab.value = 0;
showAddRevenueModal.value = false;
}
// Add expense item
function addExpenseItem() {
const id = budgetStore.addBudgetItem(
"expenses",
newExpense.value.name,
newExpense.value.category
);
// Apply helper values based on selected tab
const activeExpenseTab = expenseHelperTabs[expenseInitialTab.value];
if (activeExpenseTab?.key === 'annual' && Number(newExpense.value.annualAmount) > 0) {
// Annual distribution
const monthlyAmount = Math.round(newExpense.value.annualAmount / 12);
monthlyHeaders.value.forEach((month) => {
budgetStore.updateMonthlyValue(
"expenses",
id,
month.key,
monthlyAmount.toString()
);
});
} else if (activeExpenseTab?.key === 'monthly' && Number(newExpense.value.monthlyAmount) > 0) {
// Set all months
monthlyHeaders.value.forEach((month) => {
budgetStore.updateMonthlyValue(
"expenses",
id,
month.key,
newExpense.value.monthlyAmount.toString()
);
});
}
// If tab === 2 (empty), don't set any values
// Reset form and close modal
newExpense.value = {
category: "",
subcategory: "",
name: "",
initialAmount: 0,
annualAmount: 0,
monthlyAmount: 0,
};
expenseInitialTab.value = 0;
showAddExpenseModal.value = false;
}
// Remove item
function removeItem(category: string, itemId: string) {
if (confirm("Are you sure you want to remove this item?")) {
budgetStore.removeBudgetItem(category, itemId);
}
}
// Value entry handlers
function formatValue(value: number): string {
if (value === 0) return "";
return value.toString();
}
function handleFocus(event: FocusEvent) {
const input = event.target as HTMLInputElement;
input.select();
}
function handleBlur(
event: FocusEvent,
category: string,
itemId: string,
monthKey: string
) {
const input = event.target as HTMLInputElement;
const value = input.value.replace(/[^0-9.-]/g, "");
budgetStore.updateMonthlyValue(category, itemId, monthKey, value);
}
function handleEnter(event: KeyboardEvent) {
const input = event.target as HTMLInputElement;
input.blur();
}
// Highlight row after changes
function highlightRow(itemId: string) {
highlightedItemId.value = itemId;
setTimeout(() => {
highlightedItemId.value = null;
}, 1500);
}
// Reset worksheet
function resetWorksheet() {
if (confirm("Are you sure you want to reset all budget data? This cannot be undone.")) {
budgetStore.resetBudgetWorksheet();
budgetStore.isInitialized = false;
nextTick(() => {
budgetStore.initializeFromWizardData();
});
}
}
// Export budget
function exportBudget() {
const data = {
worksheet: budgetStore.budgetWorksheet,
monthlyTotals: budgetStore.monthlyTotals,
exportedAt: new Date().toISOString(),
};
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `budget-worksheet-${new Date().toISOString().split("T")[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// Formatting helpers
function formatCurrency(amount: number): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount || 0);
}
function getNetIncomeClass(amount: number): string {
if (amount > 0) return "text-green-600 font-bold";
if (amount < 0) return "text-red-600 font-bold";
return "text-gray-600";
}
// SEO
useSeoMeta({
title: "Budget Worksheet - Plan Your Co-op's Financial Future",
description:
"Interactive budget planning tool with monthly projections for worker cooperatives.",
});
</script>
<style scoped>
/* Ensure modal content doesn't inherit global styles */
.isolate {
isolation: isolate;
}
.modal-header {
text-decoration: none !important;
border-bottom: none !important;
}
.isolate h1,
.isolate h2,
.isolate h3,
.isolate h4,
.isolate h5,
.isolate h6 {
text-decoration: none !important;
border-bottom: none !important;
}
.isolate p {
text-decoration: none !important;
}
/* Remove number input spinners */
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
}
</style>