app/pages/budget.vue

1036 lines
35 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">
<div class="flex items-center gap-4">
<h2 class="text-2xl font-bold">Budget Worksheet</h2>
<div class="flex border-2 border-black bg-white">
<button
@click="activeView = 'monthly'"
:class="[
'px-4 py-2 font-medium transition-none',
activeView === 'monthly' ? 'bg-black text-white' : 'bg-white text-black hover:bg-gray-100'
]"
>
Monthly
</button>
<button
@click="activeView = 'annual'"
:class="[
'px-4 py-2 font-medium border-l-2 border-black transition-none',
activeView === 'annual' ? 'bg-black text-white' : 'bg-white text-black hover:bg-gray-100'
]"
>
Annual
</button>
</div>
</div>
<div class="flex items-center gap-2">
<NuxtLink
to="/help"
class="inline-flex items-center px-3 py-2 text-sm font-medium text-blue-600 hover:text-blue-800 border-2 border-blue-200 hover:border-blue-300 bg-blue-50 hover:bg-blue-100 transition-colors"
>
Help & Guides
</NuxtLink>
<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>
<!-- Empty State Message -->
<div v-if="activeView === 'monthly' && budgetWorksheet.revenue.length === 0 && budgetWorksheet.expenses.length === 0" class="border-4 border-black bg-white shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] p-12 text-center">
<div class="max-w-md mx-auto space-y-6">
<div class="text-6xl">📊</div>
<h3 class="text-xl font-bold text-black">No budget data found</h3>
<p class="text-gray-600">
Your budget is empty. Complete the setup wizard to add your revenue streams, team members, and expenses.
</p>
<div class="flex justify-center">
<NuxtLink
to="/coop-builder"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-black hover:bg-gray-800 border-2 border-black transition-colors"
>
Complete Setup Wizard
</NuxtLink>
</div>
</div>
</div>
<!-- Monthly View -->
<div v-else-if="activeView === 'monthly'" 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>
<!-- Special settings gear for payroll oncosts -->
<div v-if="item.id === 'expense-payroll-oncosts'" class="flex items-center gap-1">
<UButton
@click="showPayrollOncostModal = true"
size="xs"
variant="ghost"
icon="i-heroicons-cog-6-tooth"
:ui="{
base: 'text-gray-500 hover:bg-gray-100 opacity-0 group-hover:opacity-100 transition-all',
}"
/>
<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>
<!-- Regular delete button for non-payroll items -->
<UButton
v-else
@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>
<!-- Annual View -->
<div v-if="activeView === 'annual'">
<AnnualBudget
:orgId="'default'"
:year="new Date().getFullYear()"
/>
</div>
</section>
<!-- Add Revenue Modal -->
<UModal v-model:open="showAddRevenueModal">
<template #header>
<div class="flex items-center justify-between 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-6 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 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 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-6 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 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>
<!-- Payroll Oncost Settings Modal -->
<PayrollOncostModal
v-model:open="showPayrollOncostModal"
@save="handlePayrollOncostUpdate"
/>
</div>
</template>
<script setup lang="ts">
// Stores and synchronization
const budgetStore = useBudgetStore();
const streamsStore = useStreamsStore();
const membersStore = useMembersStore();
const policiesStore = usePoliciesStore();
const coopBuilderStore = useCoopBuilderStore();
const { initSync, getStreams, getMembers, unifiedStreams, unifiedMembers } = useStoreSync();
// Initialize synchronization and budget data
const initializeBudgetPage = async () => {
console.log('📊 Budget Page: Starting initialization');
// First, sync stores to ensure data is available (now async)
await initSync();
// Additional wait to ensure all reactive updates have propagated
await nextTick();
// Now check if we need to initialize budget data
const hasCoopData = coopBuilderStore.streams.length > 0 || coopBuilderStore.members.length > 0;
const hasBudgetData = budgetStore.budgetWorksheet.revenue.length > 0 || budgetStore.budgetWorksheet.expenses.length > 0;
console.log('📊 Budget Page: After sync - hasCoopData:', hasCoopData, 'hasBudgetData:', hasBudgetData);
console.log('📊 Budget Page: Coop streams:', coopBuilderStore.streams);
console.log('📊 Budget Page: Coop members:', coopBuilderStore.members);
if (hasCoopData && !hasBudgetData) {
console.log('📊 Budget Page: Initializing budget from coop data');
// Force initialization since we have coop data but no budget data
await budgetStore.forceInitializeFromWizardData();
// Refresh the page data after initialization
await nextTick();
} else if (!hasCoopData && !hasBudgetData) {
console.log('📊 Budget Page: No data found in any store');
// Try one more time to get the data after a delay
await new Promise(resolve => setTimeout(resolve, 100));
const retryCoopData = coopBuilderStore.streams.length > 0 || coopBuilderStore.members.length > 0;
if (retryCoopData) {
console.log('📊 Budget Page: Found data on retry, initializing');
await budgetStore.forceInitializeFromWizardData();
}
} else if (hasBudgetData) {
console.log('📊 Budget Page: Budget data already exists, using existing data');
}
};
// Initialize on mount
onMounted(async () => {
// Always force reinitialize when navigating to budget page
// This ensures changes from setup are reflected
await budgetStore.forceInitializeFromWizardData();
// Mark initial load as complete after initialization
await nextTick();
initialLoadComplete = true;
});
// Track if initial load is complete
let initialLoadComplete = false;
// Re-initialize when coop data changes (but not on initial load)
watch([() => coopBuilderStore.streams, () => coopBuilderStore.members], async (newVal, oldVal) => {
// Skip the initial trigger
if (!initialLoadComplete) {
return;
}
// Only reinitialize if we actually have new data
const hasNewStreams = coopBuilderStore.streams.length > 0;
const hasNewMembers = coopBuilderStore.members.length > 0;
if (hasNewStreams || hasNewMembers) {
console.log('📊 Budget Page: Coop data changed, reinitializing');
await nextTick();
await initializeBudgetPage();
}
}, { deep: true });
// Use reactive synchronized data
const syncedStreams = unifiedStreams;
const syncedMembers = unifiedMembers;
// State
const activeView = ref('monthly');
const showAddRevenueModal = ref(false);
const showAddExpenseModal = ref(false);
const showPayrollOncostModal = 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 with safe fallbacks
const budgetWorksheet = computed(() => budgetStore.budgetWorksheet || { revenue: [], expenses: [] });
const groupedRevenue = computed(() => budgetStore.groupedRevenue);
const groupedExpenses = computed(() => budgetStore.groupedExpenses);
const monthlyTotals = computed(() => budgetStore.monthlyTotals);
// Initialize on mount
// Removed duplicate onMounted - initialization is now handled above
// 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";
}
// Payroll oncost handling
function handlePayrollOncostUpdate(newPercentage: number) {
// Update the coop store
coopBuilderStore.payrollOncostPct = newPercentage;
// Refresh the budget to reflect the new oncost percentage
budgetStore.refreshPayrollInBudget();
}
// 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>