app/pages/budget.vue

878 lines
27 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">
<button
@click="openHelperForItem(item)"
class="text-left hover:underline focus:outline-none focus:underline w-full"
>
<div class="font-medium">{{ item.name }}</div>
<div class="text-xs text-gray-600">
{{ item.subcategory }}
</div>
</button>
</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">
<button
@click="openHelperForItem(item)"
class="text-left hover:underline focus:outline-none focus:underline w-full"
>
<div class="font-medium">{{ item.name }}</div>
<div class="text-xs text-gray-600">
{{ item.subcategory }}
</div>
</button>
</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"
:options="revenueCategories"
placeholder="Select a category"
/>
</UFormGroup>
<UFormGroup label="Revenue Name" required>
<UInput
v-model="newRevenue.name"
type="text"
placeholder="e.g., Monthly Subscription"
/>
</UFormGroup>
<UFormGroup
label="Initial Monthly Amount"
description="Starting amount for each month"
>
<UInput
v-model.number="newRevenue.initialAmount"
type="number"
placeholder="0.00"
>
<template #leading>
<span class="text-gray-500">$</span>
</template>
</UInput>
</UFormGroup>
</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>
<!-- Helper Modal -->
<UModal
v-model:open="showHelperModal"
:ui="{ wrapper: 'sm:max-w-lg', footer: 'justify-end' }"
title="Quick Entry Tools"
:description="selectedItemDetails?.label || 'Budget item'"
>
<template #body>
<div class="isolate">
<UTabs v-model="activeHelperTab" :items="helperTabs" class="w-full">
<template #content="{ item }">
<!-- Annual Distribution Content -->
<div v-if="item.key === 'annual'" class="pt-4 space-y-4">
<UFormGroup label="Annual Total Amount">
<UInput
v-model.number="helperConfig.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 the amount equally across all 12 months
</p>
</div>
<!-- Monthly Amount Content -->
<div v-else-if="item.key === 'monthly'" class="pt-4 space-y-4">
<UFormGroup label="Monthly Amount">
<UInput
v-model.number="helperConfig.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 the same value for all months
</p>
</div>
</template>
</UTabs>
</div>
</template>
<template #footer="{ close }">
<UButton @click="close" variant="outline" color="neutral"> Cancel </UButton>
<UButton
v-if="activeHelperTab === 0"
@click="distributeAnnualAmount"
:disabled="!helperConfig.annualAmount || helperConfig.annualAmount <= 0"
color="primary"
>
Distribute Amount
</UButton>
<UButton
v-else
@click="setAllMonths"
:disabled="!helperConfig.monthlyAmount || helperConfig.monthlyAmount <= 0"
color="primary"
>
Apply to All Months
</UButton>
</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"
:options="expenseCategories"
placeholder="Select a category"
/>
</UFormGroup>
<UFormGroup label="Expense Name" required>
<UInput
v-model="newExpense.name"
type="text"
placeholder="e.g., Office Rent"
/>
</UFormGroup>
<UFormGroup
label="Initial Monthly Amount"
description="Starting amount for each month"
>
<UInput
v-model.number="newExpense.initialAmount"
type="number"
placeholder="0.00"
>
<template #leading>
<span class="text-gray-500">$</span>
</template>
</UInput>
</UFormGroup>
</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 showHelperModal = ref(false);
const activeTab = ref(0);
const highlightedItemId = ref<string | null>(null);
// New item forms
const newRevenue = ref({
category: "",
subcategory: "",
name: "",
initialAmount: 0,
});
const newExpense = ref({
category: "",
subcategory: "",
name: "",
initialAmount: 0,
});
// Helper config
const helperConfig = ref({
selectedItem: null as string | null,
annualAmount: 0,
monthlyAmount: 0,
startAmount: 0,
percentChange: 0,
baseAmount: 0,
});
// Selected item details for helper modal
const selectedItemDetails = computed(() => {
if (!helperConfig.value.selectedItem) return null;
return allBudgetItems.value.find((item) => item.id === helperConfig.value.selectedItem);
});
// Helper tabs configuration
const activeHelperTab = ref(0); // UTabs uses index, not key
const helperTabs = [
{
key: "annual",
label: "Annual Distribution",
icon: "i-heroicons-calendar",
},
{
key: "monthly",
label: "Set All Months",
icon: "i-heroicons-squares-2x2",
},
];
// Data from store
const revenueCategories = computed(() => budgetStore.revenueCategories);
const expenseCategories = computed(() => budgetStore.expenseCategories);
// 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);
// All budget items for helper dropdown
const allBudgetItems = computed(() => {
const items: Array<{
id: string;
label: string;
type: "revenue" | "expenses";
data: any;
}> = [];
budgetStore.budgetWorksheet.revenue.forEach((item: any) => {
items.push({
id: item.id,
label: `[Revenue] ${item.name}`,
type: "revenue",
data: item,
});
});
budgetStore.budgetWorksheet.expenses.forEach((item: any) => {
items.push({
id: item.id,
label: `[Expense] ${item.name}`,
type: "expenses",
data: item,
});
});
return items;
});
// Initialize on mount
onMounted(async () => {
try {
// Always re-initialize to get latest wizard data
budgetStore.isInitialized = false;
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
);
// Set initial amount for all months if provided
if (newRevenue.value.initialAmount > 0) {
monthlyHeaders.value.forEach((month) => {
budgetStore.updateMonthlyValue(
"revenue",
id,
month.key,
newRevenue.value.initialAmount.toString()
);
});
}
// Reset form and close modal
newRevenue.value = {
category: "",
subcategory: "",
name: "",
initialAmount: 0,
};
showAddRevenueModal.value = false;
}
// Add expense item
function addExpenseItem() {
const id = budgetStore.addBudgetItem(
"expenses",
newExpense.value.name,
newExpense.value.category
);
// Set initial amount for all months if provided
if (newExpense.value.initialAmount > 0) {
monthlyHeaders.value.forEach((month) => {
budgetStore.updateMonthlyValue(
"expenses",
id,
month.key,
newExpense.value.initialAmount.toString()
);
});
}
// Reset form and close modal
newExpense.value = {
category: "",
subcategory: "",
name: "",
initialAmount: 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();
}
// Open helper modal for specific item
function openHelperForItem(item: any) {
helperConfig.value.selectedItem = item.id;
helperConfig.value.annualAmount = 0;
helperConfig.value.monthlyAmount = 0;
activeTab.value = 0; // Reset to first tab
showHelperModal.value = true;
}
// Highlight row after changes
function highlightRow(itemId: string) {
highlightedItemId.value = itemId;
setTimeout(() => {
highlightedItemId.value = null;
}, 1500);
}
// Helper functions
function distributeAnnualAmount() {
if (!helperConfig.value.selectedItem || !helperConfig.value.annualAmount) return;
const item = allBudgetItems.value.find((i) => i.id === helperConfig.value.selectedItem);
if (!item) return;
const monthlyAmount = Math.round(helperConfig.value.annualAmount / 12);
monthlyHeaders.value.forEach((month) => {
budgetStore.updateMonthlyValue(
item.type,
item.id,
month.key,
monthlyAmount.toString()
);
});
helperConfig.value.annualAmount = 0;
highlightRow(item.id);
showHelperModal.value = false;
}
function setAllMonths() {
if (!helperConfig.value.selectedItem || !helperConfig.value.monthlyAmount) return;
const item = allBudgetItems.value.find((i) => i.id === helperConfig.value.selectedItem);
if (!item) return;
monthlyHeaders.value.forEach((month) => {
budgetStore.updateMonthlyValue(
item.type,
item.id,
month.key,
helperConfig.value.monthlyAmount.toString()
);
});
helperConfig.value.monthlyAmount = 0;
highlightRow(item.id);
showHelperModal.value = false;
}
// 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>