app/pages/budget.vue

1910 lines
67 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>
<UTabs
v-model="activeView"
:items="viewTabs"
variant="pill"
class="mb-0 gap-0" />
</div>
<div class="flex items-center gap-2">
<UButton
@click="showBudgetSettingsModal = true"
variant="outline"
size="sm">
Settings
</UButton>
<UButton @click="exportBudget" variant="outline" size="sm">
Export
</UButton>
</div>
</div>
<!-- Empty State Message -->
<div
v-if="
activeView === 'monthly' &&
budgetWorksheet.revenue.length === 0 &&
budgetWorksheet.expenses.length === 0
"
class="border border-black bg-white 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-neutral-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-neutral-800 border-2 border-black transition-colors">
Complete Setup Wizard
</NuxtLink>
</div>
</div>
</div>
<!-- Monthly View -->
<div v-else-if="activeView === 'monthly'" class="relative">
<div class="absolute top-2 left-2 w-full h-full dither-shadow"></div>
<div class="relative bg-white dark:bg-neutral-400">
<div
class="relative overflow-x-auto border border-black dark:border-neutral-600">
<table
class="relative w-full border-collapse text-sm bg-white dark:bg-neutral-950 border-black dark:border-neutral-200">
<thead class="border-black dark:border-neutral-400">
<tr>
<th
class="border-r-1 border-black dark:border-neutral-400 px-4 py-3 text-left font-bold min-w-[250px] sticky left-0 bg-white dark:bg-neutral-950 text-black dark:text-white z-10">
Item
</th>
<th
v-for="month in monthlyHeaders"
:key="month.key"
class="border-r border-neutral-400 dark:border-neutral-600 px-2 py-3 text-center font-medium min-w-[80px] last:border-r-0 text-black dark:text-white">
{{ month.label }}
</th>
</tr>
</thead>
<tbody>
<!-- REVENUE SECTION -->
<tr class="bg-black text-white dark:bg-white dark:text-black">
<td
class="px-4 py-2 font-bold sticky left-0 bg-black dark:bg-white text-white dark:text-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-neutral-200 dark:bg-black dark:text-white dark:hover:bg-neutral-800 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-neutral-300 dark:border-neutral-700">
<td
class="px-4 py-1 font-semibold sticky left-0 bg-neutral-100 dark:bg-neutral-800 text-black dark:text-white z-10 border-black dark:border-neutral-400"
:colspan="monthlyHeaders.length + 1">
{{ categoryName }}
</td>
</tr>
<tr
v-for="item in items"
:key="item.id"
:class="[
'border-t border-neutral-200 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-all duration-300',
highlightedItemId === item.id &&
'bg-yellow-100 dark:bg-yellow-900 animate-pulse',
]">
<td
class="border-r-1 border-neutral-200 dark:border-neutral-700 px-4 py-2 sticky left-0 bg-white dark:bg-neutral-950 z-10">
<div class="flex items-center justify-between group">
<div class="flex-1">
<div class="text-left w-full">
<div class="font-medium text-black dark:text-white">
{{ item.name }}
</div>
<div
class="text-xs text-neutral-600 dark:text-neutral-400">
{{ item.subcategory }}
</div>
</div>
</div>
<div class="flex items-center gap-1">
<UButton
@click="editItem('revenue', item)"
size="xs"
variant="ghost"
:ui="{
base: 'text-blue-600 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-900 opacity-0 group-hover:opacity-100 transition-none',
}">
</UButton>
<UButton
@click="removeItem('revenue', item.id)"
size="xs"
variant="ghost"
:ui="{
base: 'text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900 opacity-0 group-hover:opacity-100 transition-none',
}">
×
</UButton>
</div>
</div>
</td>
<td
v-for="month in monthlyHeaders"
:key="month.key"
class="border-r border-neutral-200 dark:border-neutral-700 px-1 py-1 last:border-r-0">
<input
type="text"
:value="
formatValue(item.monthlyValues?.[month.key] || 0)
"
@focus="handleFocus($event)"
@input="
handleInput($event, 'revenue', item.id, month.key)
"
@blur="
handleBlur($event, 'revenue', item.id, month.key)
"
@keydown.enter="handleEnter($event)"
class="w-full text-right px-1 py-0.5 border border-transparent hover:border-neutral-400 dark:hover:border-neutral-600 focus:border-black dark:focus:border-white focus:outline-none transition-none bg-white dark:bg-neutral-950 text-black dark:text-white font-mono"
:class="{
'bg-neutral-50 dark:bg-neutral-800':
!item.monthlyValues?.[month.key],
}" />
</td>
</tr>
</template>
<!-- Total Revenue Row -->
<tr
class="border-t-1 border-black dark:border-neutral-400 border-b-1 font-bold bg-neutral-100 dark:bg-neutral-800">
<td
class="border-r-1 border-black dark:border-neutral-400 px-4 py-2 sticky left-0 bg-neutral-100 dark:bg-neutral-800 text-black dark:text-white z-10">
TOTAL REVENUE
</td>
<td
v-for="month in monthlyHeaders"
:key="month.key"
class="border-r border-black dark:border-neutral-400 px-2 py-2 text-right last:border-r-0 text-black dark:text-white font-mono">
<span class="font-mono">{{
formatCurrency(monthlyTotals[month.key]?.revenue || 0)
}}</span>
</td>
</tr>
<!-- EXPENSES SECTION -->
<tr class="bg-black text-white dark:bg-white dark:text-black">
<td
class="px-4 py-2 font-bold sticky left-0 bg-black dark:bg-white text-white dark:text-black z-10">
<div class="flex items-center justify-between">
<span>EXPENSES</span>
<div class="flex items-center gap-2">
<UButton
@click="showAddExpenseModal = true"
size="xs"
:ui="{
base: 'bg-white text-black hover:bg-neutral-200 dark:bg-black dark:text-white dark:hover:bg-neutral-800 transition-none',
}">
+ Add
</UButton>
</div>
</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-neutral-300 dark:border-neutral-700">
<td
class="px-4 py-1 font-semibold sticky left-0 bg-neutral-100 dark:bg-neutral-800 text-black dark:text-white z-10"
:colspan="monthlyHeaders.length + 1">
<div class="flex items-center justify-between">
<span>{{ categoryName }}</span>
<UButton
v-if="categoryName === 'Salaries & Benefits'"
@click="showCalculationModal = true"
variant="outline"
size="xs">
How are these calculated?
</UButton>
</div>
</td>
</tr>
<tr
v-for="item in items"
:key="item.id"
:class="[
'border-t border-neutral-200 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-all duration-300',
highlightedItemId === item.id &&
'bg-yellow-100 dark:bg-yellow-900 animate-pulse',
]">
<td
class="border-r-1 border-neutral-200 dark:border-neutral-700 px-4 py-2 sticky left-0 bg-white dark:bg-neutral-950 z-10">
<div
class="flex items-center justify-between group w-full">
<div class="flex-1">
<div class="text-left w-full">
<div
class="font-medium flex items-start justify-between gap-2">
<UTooltip
v-if="isPayrollItem(item.id)"
text="Calculated based on available revenue after overhead costs. This represents realistic, sustainable payroll."
:content="{ side: 'top', align: 'start' }">
<span
class="cursor-help text-black dark:text-white"
>{{ item.name }}</span
>
</UTooltip>
<span v-else class="text-black dark:text-white">{{
item.name
}}</span>
<span
v-if="isPayrollItem(item.id)"
class="inline-flex items-start px-1.5 py-0.5 rounded text-xs font-medium bg-neutral-100 dark:bg-neutral-900 text-neutral-800 dark:text-neutral-200 uppercase">
Auto
</span>
</div>
<div
class="text-xs text-neutral-600 dark:text-neutral-400"
v-if="!isPayrollItem(item.id)">
{{ item.subcategory }}
</div>
<!-- Edit button for payroll oncost items - appears below the text -->
<div
v-if="item.id === 'expense-payroll-oncosts'"
class="mt-2"></div>
</div>
</div>
<!-- Edit and delete buttons for non-payroll items only -->
<div
v-if="!isPayrollItem(item.id)"
class="flex items-center gap-1">
<UButton
@click="editItem('expenses', item)"
size="xs"
variant="ghost"
:ui="{
base: 'text-blue-600 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-900 opacity-0 group-hover:opacity-100 transition-none',
}">
</UButton>
<UButton
@click="removeItem('expenses', item.id)"
size="xs"
variant="ghost"
:ui="{
base: 'text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900 opacity-0 group-hover:opacity-100 transition-none',
}">
×
</UButton>
</div>
</div>
</td>
<td
v-for="month in monthlyHeaders"
:key="month.key"
class="border-r border-neutral-200 dark:border-neutral-700 px-1 py-1 last:border-r-0">
<input
type="text"
:value="
formatValue(item.monthlyValues?.[month.key] || 0)
"
:readonly="isPayrollItem(item.id)"
@focus="handleFocus($event)"
@input="
handleInput($event, 'expenses', item.id, month.key)
"
@blur="
handleBlur($event, 'expenses', item.id, month.key)
"
@keydown.enter="handleEnter($event)"
class="w-full text-right px-1 py-0.5 border-2 transition-none bg-white dark:bg-neutral-950 text-black dark:text-white font-mono"
:class="{
'bg-neutral-50 dark:bg-neutral-800':
!item.monthlyValues?.[month.key] &&
!isPayrollItem(item.id),
'bg-neutral-50 dark:bg-neutral-800 border-none cursor-not-allowed text-neutral-500 dark:text-neutral-400':
isPayrollItem(item.id),
'border-transparent hover:border-neutral-400 dark:hover:border-neutral-600 focus:border-black dark:focus:border-white focus:outline-none':
!isPayrollItem(item.id),
}"
:title="
isPayrollItem(item.id)
? 'Calculated from compensation settings - edit on Compensation page'
: ''
" />
</td>
</tr>
</template>
<!-- Total Expenses Row -->
<tr
class="border-t-1 border-black dark:border-neutral-400 font-bold bg-neutral-100 dark:bg-neutral-800">
<td
class="border-r-1 border-black dark:border-neutral-400 px-4 py-2 sticky left-0 bg-neutral-100 dark:bg-neutral-800 text-black dark:text-white z-10">
TOTAL EXPENSES
</td>
<td
v-for="month in monthlyHeaders"
:key="month.key"
class="border-r border-neutral-400 dark:border-neutral-600 px-2 py-2 text-right last:border-r-0 text-black dark:text-white font-mono">
<span class="font-mono">{{
formatCurrency(monthlyTotals[month.key]?.expenses || 0)
}}</span>
</td>
</tr>
<!-- Net Income Row -->
<tr
class="border-t-1 border-black dark:border-neutral-400 font-bold text-lg">
<td
class="border-r-1 border-black dark:border-neutral-400 px-4 py-3 sticky left-0 bg-white dark:bg-neutral-950 text-black dark:text-white z-10">
NET INCOME
</td>
<td
v-for="month in monthlyHeaders"
:key="month.key"
class="border-r border-neutral-400 dark:border-neutral-600 px-2 py-3 text-right last:border-r-0"
:class="
getNetIncomeClass(monthlyTotals[month.key]?.net || 0)
">
<span class="font-mono">{{
formatCurrency(monthlyTotals[month.key]?.net || 0)
}}</span>
</td>
</tr>
<!-- Cumulative Balance Row -->
<tr
class="border-t-1 border-neutral-400 dark:border-neutral-600 font-bold text-lg bg-blue-50 dark:bg-neutral-950">
<td
class="border-r-1 border-black dark:border-neutral-400 px-4 py-3 sticky left-0 bg-blue-50 dark:bg-neutral-950 text-black dark:text-white z-10">
CUMULATIVE BALANCE
</td>
<td
v-for="month in monthlyHeaders"
:key="month.key"
class="border-r border-neutral-400 dark:border-neutral-600 px-2 py-3 text-right last:border-r-0"
:class="
getCumulativeBalanceClass(
cumulativeBalances[month.key] || 0
)
">
<span class="font-mono">{{
formatCurrency(cumulativeBalances[month.key] || 0)
}}</span>
</td>
</tr>
</tbody>
</table>
</div>
</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"
size="xl"
title="Add Revenue Source"
description="Create a new revenue stream for your budget">
<template #body>
<!-- Basic Information Section -->
<div class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<UFormField label="Category" required>
<USelectMenu
v-model="newRevenue.category"
:items="revenueCategories"
placeholder="Select a category"
class="text-sm font-medium w-full"
size="lg" />
</UFormField>
<UFormField label="Subcategory" :required="false">
<USelectMenu
v-model="newRevenue.subcategory"
:items="revenueSubcategories"
placeholder="Select a subcategory"
:disabled="!newRevenue.category"
class="text-sm font-medium w-full"
size="lg" />
</UFormField>
</div>
<UFormField label="Revenue Name" required>
<UInput
v-model="newRevenue.name"
type="text"
placeholder="e.g., Monthly Subscription"
size="lg"
class="text-sm font-medium w-full" />
</UFormField>
</div>
<!-- Initial Values Section -->
<div class="space-y-6 mt-6">
<UTabs
v-model="revenueInitialTab"
:items="revenueHelperTabs"
class="w-full">
<template #content="{ item }">
<!-- Annual Distribution -->
<div v-if="item.value === 'annual'" class="space-y-6">
<UFormField 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-neutral-500 font-medium font-mono"
>$</span
>
</template>
</UInput>
</UFormField>
<div class="bg-white rounded-lg p-4 border border-neutral-200">
<div class="mb-2">
<span class="text-sm font-medium text-neutral-700"
>Distribution Preview</span
>
</div>
<p class="text-sm text-neutral-600">
This will divide
<span class="font-semibold text-neutral-900 font-mono"
>${{ newRevenue.annualAmount || 0 }}</span
>
equally across all 12 months (<span
class="font-semibold text-green-600 font-mono"
>${{
newRevenue.annualAmount
? Math.round(newRevenue.annualAmount / 12)
: 0
}}</span
>
per month)
</p>
</div>
</div>
<!-- Monthly Amount -->
<div v-else-if="item.value === 'monthly'" class="space-y-6">
<UFormField 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-neutral-500 font-medium font-mono"
>$</span
>
</template>
</UInput>
</UFormField>
<div class="bg-white rounded-lg p-4 border border-neutral-200">
<div class="mb-2">
<span class="text-sm font-medium text-neutral-700"
>Monthly Preview</span
>
</div>
<p class="text-sm text-neutral-600">
This will set
<span class="font-semibold text-green-600 font-mono"
>${{ newRevenue.monthlyAmount || 0 }}</span
>
for all 12 months
</p>
</div>
</div>
<!-- Start Empty -->
<div v-else>
<div
class="bg-white rounded-lg p-6 border border-neutral-200 text-center">
<p class="text-sm text-neutral-600">
The revenue item will be created with no initial values. You
can fill them in later directly in the budget table.
</p>
</div>
</div>
</template>
</UTabs>
</div>
</template>
<template #footer>
<div class="flex justify-between w-full">
<UButton
@click="showAddRevenueModal = false"
variant="outline"
size="md">
Cancel
</UButton>
<UButton
@click="addRevenueItem"
class="self-end"
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"
size="xl"
title="Add Expense Item"
description="Create a new expense for your budget">
<template #body>
<!-- Basic Information Section -->
<div class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<UFormField label="Category" required>
<USelectMenu
v-model="newExpense.category"
:items="expenseCategories"
placeholder="Select a category"
size="lg"
class="text-sm font-medium w-full" />
</UFormField>
<div class="flex items-end">
<UFormField label="Expense Name" required class="flex-1">
<UInput
v-model="newExpense.name"
type="text"
placeholder="e.g., Office Rent"
size="lg"
class="text-sm font-medium w-full" />
</UFormField>
</div>
</div>
</div>
<!-- Initial Values Section -->
<div class="space-y-6 mt-6">
<UTabs
v-model="expenseInitialTab"
:items="expenseHelperTabs"
class="w-full">
<template #content="{ item }">
<!-- Annual Distribution -->
<div v-if="item.value === 'annual'" class="space-y-6">
<UFormField label="Annual Total Amount">
<UInput
v-model.number="newExpense.annualAmount"
type="number"
placeholder="Enter annual amount (e.g., 12000)"
size="lg"
class="text-sm font-medium w-full">
<template #leading>
<span class="text-neutral-500 font-medium font-mono"
>$</span
>
</template>
</UInput>
</UFormField>
<div class="bg-white rounded-lg p-4 border border-neutral-200">
<div class="mb-2">
<span class="text-sm font-medium text-neutral-700"
>Distribution Preview</span
>
</div>
<p class="text-sm text-neutral-600">
This will divide
<span class="font-semibold text-neutral-900 font-mono"
>${{ newExpense.annualAmount || 0 }}</span
>
equally across all 12 months (<span
class="font-semibold text-red-600 font-mono"
>${{
newExpense.annualAmount
? Math.round(newExpense.annualAmount / 12)
: 0
}}</span
>
per month)
</p>
</div>
</div>
<!-- Monthly Amount -->
<div v-else-if="item.value === 'monthly'" class="space-y-6">
<UFormField label="Monthly Amount">
<UInput
v-model.number="newExpense.monthlyAmount"
type="number"
placeholder="Enter monthly amount (e.g., 1000)"
size="lg"
class="text-sm font-medium w-full">
<template #leading>
<span class="text-neutral-500 font-medium font-mono"
>$</span
>
</template>
</UInput>
</UFormField>
<div class="bg-white rounded-lg p-4 border border-neutral-200">
<div class="mb-2">
<span class="text-sm font-medium text-neutral-700"
>Monthly Preview</span
>
</div>
<p class="text-sm text-neutral-600">
This will set
<span class="font-semibold text-red-600 font-mono"
>${{ newExpense.monthlyAmount || 0 }}</span
>
for all 12 months
</p>
</div>
</div>
<!-- Start Empty -->
<div v-else>
<div
class="bg-white rounded-lg p-6 border border-neutral-200 text-center">
<p class="text-sm text-neutral-600">
The expense item will be created with no initial values. You
can fill them in later directly in the budget table.
</p>
</div>
</div>
</template>
</UTabs>
</div>
</template>
<template #footer>
<div class="flex justify-between w-full">
<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>
<!-- Edit Revenue Modal -->
<UModal
v-model:open="showEditRevenueModal"
size="xl"
title="Edit Revenue Source"
description="Update this revenue stream for your budget">
<template #body>
<!-- Basic Information Section -->
<div class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<UFormField label="Category" required>
<USelectMenu
v-model="newRevenue.category"
:items="revenueCategories"
placeholder="Select a category"
class="text-sm font-medium w-full"
size="lg" />
</UFormField>
<UFormField label="Subcategory" :required="false">
<USelectMenu
v-model="newRevenue.subcategory"
:items="revenueSubcategories"
placeholder="Select a subcategory"
:disabled="!newRevenue.category"
class="text-sm font-medium w-full"
size="lg" />
</UFormField>
</div>
<UFormField label="Revenue Name" required>
<UInput
v-model="newRevenue.name"
type="text"
placeholder="e.g., Monthly Subscription"
size="lg"
class="text-sm font-medium w-full" />
</UFormField>
</div>
<!-- Initial Values Section -->
<div class="space-y-6 mt-6">
<UTabs
v-model="revenueInitialTab"
:items="revenueHelperTabs"
class="w-full">
<template #content="{ item }">
<!-- Annual Distribution -->
<div v-if="item.value === 'annual'" class="space-y-6">
<UFormField 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-neutral-500 font-medium font-mono"
>$</span
>
</template>
</UInput>
</UFormField>
<div class="bg-white rounded-lg p-4 border border-neutral-200">
<div class="mb-2">
<span class="text-sm font-medium text-neutral-700"
>Distribution Preview</span
>
</div>
<p class="text-sm text-neutral-600">
This will divide
<span class="font-semibold text-neutral-900 font-mono"
>${{ newRevenue.annualAmount || 0 }}</span
>
equally across all 12 months (<span
class="font-semibold text-green-600 font-mono"
>${{
newRevenue.annualAmount
? Math.round(newRevenue.annualAmount / 12)
: 0
}}</span
>
per month)
</p>
</div>
</div>
<!-- Monthly Amount -->
<div v-else-if="item.value === 'monthly'" class="space-y-6">
<UFormField 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-neutral-500 font-medium font-mono"
>$</span
>
</template>
</UInput>
</UFormField>
<div class="bg-white rounded-lg p-4 border border-neutral-200">
<div class="mb-2">
<span class="text-sm font-medium text-neutral-700"
>Monthly Preview</span
>
</div>
<p class="text-sm text-neutral-600">
This will set
<span class="font-semibold text-green-600 font-mono"
>${{ newRevenue.monthlyAmount || 0 }}</span
>
for all 12 months
</p>
</div>
</div>
<!-- Start Empty -->
<div v-else>
<div
class="bg-white rounded-lg p-6 border border-neutral-200 text-center">
<p class="text-sm text-neutral-600">
The revenue item will not change its monthly values. You can
adjust them manually in the budget table.
</p>
</div>
</div>
</template>
</UTabs>
</div>
</template>
<template #footer>
<div class="flex justify-between w-full">
<UButton
@click="showEditRevenueModal = false"
variant="outline"
size="md">
Cancel
</UButton>
<UButton
@click="editRevenueItem"
class="self-end"
size="md"
:disabled="!newRevenue.name || !newRevenue.category"
color="primary">
<UIcon name="i-heroicons-pencil" class="mr-1" />
Update Revenue
</UButton>
</div>
</template>
</UModal>
<!-- Edit Expense Modal -->
<UModal
v-model:open="showEditExpenseModal"
size="xl"
title="Edit Expense Item"
description="Update this expense for your budget">
<template #body>
<!-- Basic Information Section -->
<div class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<UFormField label="Category" required>
<USelectMenu
v-model="newExpense.category"
:items="expenseCategories"
placeholder="Select a category"
size="lg"
class="text-sm font-medium w-full" />
</UFormField>
<div class="flex items-end">
<UFormField label="Expense Name" required class="flex-1">
<UInput
v-model="newExpense.name"
type="text"
placeholder="e.g., Office Rent"
size="lg"
class="text-sm font-medium w-full" />
</UFormField>
</div>
</div>
</div>
<!-- Initial Values Section -->
<div class="space-y-6 mt-6">
<UTabs
v-model="expenseInitialTab"
:items="expenseHelperTabs"
class="w-full">
<template #content="{ item }">
<!-- Annual Distribution -->
<div v-if="item.value === 'annual'" class="space-y-6">
<UFormField label="Annual Total Amount">
<UInput
v-model.number="newExpense.annualAmount"
type="number"
placeholder="Enter annual amount (e.g., 12000)"
size="lg"
class="text-sm font-medium w-full">
<template #leading>
<span class="text-neutral-500 font-medium font-mono"
>$</span
>
</template>
</UInput>
</UFormField>
<div class="bg-white rounded-lg p-4 border border-neutral-200">
<div class="mb-2">
<span class="text-sm font-medium text-neutral-700"
>Distribution Preview</span
>
</div>
<p class="text-sm text-neutral-600">
This will divide
<span class="font-semibold text-neutral-900 font-mono"
>${{ newExpense.annualAmount || 0 }}</span
>
equally across all 12 months (<span
class="font-semibold text-red-600 font-mono"
>${{
newExpense.annualAmount
? Math.round(newExpense.annualAmount / 12)
: 0
}}</span
>
per month)
</p>
</div>
</div>
<!-- Monthly Amount -->
<div v-else-if="item.value === 'monthly'" class="space-y-6">
<UFormField label="Monthly Amount">
<UInput
v-model.number="newExpense.monthlyAmount"
type="number"
placeholder="Enter monthly amount (e.g., 1000)"
size="lg"
class="text-sm font-medium w-full">
<template #leading>
<span class="text-neutral-500 font-medium font-mono"
>$</span
>
</template>
</UInput>
</UFormField>
<div class="bg-white rounded-lg p-4 border border-neutral-200">
<div class="mb-2">
<span class="text-sm font-medium text-neutral-700"
>Monthly Preview</span
>
</div>
<p class="text-sm text-neutral-600">
This will set
<span class="font-semibold text-red-600 font-mono"
>${{ newExpense.monthlyAmount || 0 }}</span
>
for all 12 months
</p>
</div>
</div>
<!-- Start Empty -->
<div v-else>
<div
class="bg-white rounded-lg p-6 border border-neutral-200 text-center">
<p class="text-sm text-neutral-600">
The expense item will not change its monthly values. You can
adjust them manually in the budget table.
</p>
</div>
</div>
</template>
</UTabs>
</div>
</template>
<template #footer>
<div class="flex justify-between w-full">
<UButton
@click="showEditExpenseModal = false"
variant="outline"
size="md">
Cancel
</UButton>
<UButton
@click="editExpenseItem"
size="md"
:disabled="!newExpense.name || !newExpense.category"
color="primary">
<UIcon name="i-heroicons-pencil" class="mr-1" />
Update Expense
</UButton>
</div>
</template>
</UModal>
<!-- Budget Settings Modal -->
<BudgetSettingsModal
v-model:open="showBudgetSettingsModal"
@settings-updated="handleSettingsUpdate" />
<!-- Calculation Explanation Modal -->
<UModal
v-model:open="showCalculationModal"
title="How Budget Calculations Work">
<template #content>
<div class="space-y-6 max-w-2xl p-6">
<!-- Revenue Section -->
<div>
<h4 class="font-semibold mb-2">Revenue Calculation</h4>
<p class="text-sm text-neutral-600 dark:text-neutral-200 mb-2">
Revenue comes from the streams you set up in the budget builder,
and any manual additions to the budget spreadsheet:
</p>
<ul
class="text-sm text-neutral-600 dark:text-neutral-200 space-y-1 ml-4 list-disc">
<li>Monthly amounts you entered for each revenue stream</li>
<li>Varies by month based on your specific projections</li>
</ul>
</div>
<!-- Payroll Section -->
<div>
<h4 class="font-semibold mb-2">Payroll Calculation</h4>
<p class="text-sm text-neutral-600 dark:text-neutral-200 mb-2">
Payroll uses a <strong>cumulative balance approach</strong> to
make sure members are paid sustainably:
</p>
<div class="bg-neutral-100 dark:bg-neutral-800 p-3 text-sm">
<p class="font-medium mb-2">Step-by-step process:</p>
<ol class="space-y-1 ml-4 list-decimal">
<li>Calculate available funds: Revenue - Other Expenses</li>
<li>
Check if this maintains minimum cash threshold (<span
class="font-mono"
>{{
$format.currency(coopBuilderStore.minCashThreshold || 0)
}}</span
>)
</li>
<li>
Allocate using your chosen policy ({{ getPolicyName() }})
</li>
<li>
Account for payroll taxes (<span class="font-mono"
>{{ coopBuilderStore.payrollOncostPct || 0 }}%</span
>)
</li>
<li>Ensure cumulative balance doesn't fall below threshold</li>
</ol>
</div>
<p class="text-sm text-neutral-600 dark:text-neutral-200 mt-2">
This means payroll varies by month - higher in good cash flow
months, lower when cash is tight.
</p>
</div>
<!-- Cumulative Balance Section -->
<div>
<h4 class="font-semibold mb-2">Cumulative Balance</h4>
<p class="text-sm text-neutral-600 dark:text-neutral-200 mb-2">
Shows your running cash position over time:
</p>
<ul
class="text-sm text-neutral-600 dark:text-neutral-200 space-y-1 ml-4 list-disc">
<li>Starts at 0 (current cash position)</li>
<li>Adds each month's net income (Revenue - All Expenses)</li>
<li>Helps you see when cash might run low</li>
<li>
Payroll is reduced to prevent going below minimum threshold
</li>
</ul>
</div>
<!-- Policy Explanation -->
<div>
<h4 class="font-semibold mb-2">
Pay Policy: {{ getPolicyName() }}
</h4>
<div class="text-sm text-neutral-600 dark:text-neutral-200">
<p v-if="coopBuilderStore.policy?.relationship === 'equal-pay'">
Everyone gets equal hourly wage (<span class="font-mono">{{
$format.currency(coopBuilderStore.equalHourlyWage || 0)
}}</span
>/hour) based on their monthly hours.
</p>
<p
v-else-if="
coopBuilderStore.policy?.relationship === 'needs-weighted'
">
Pay is allocated proportionally based on each member's minimum
monthly needs, ensuring fair coverage.
</p>
<p
v-else-if="
coopBuilderStore.policy?.relationship === 'hours-weighted'
">
Pay is allocated proportionally based on hours worked, with
higher hours getting more pay.
</p>
</div>
</div>
<div
class="bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded p-3">
<p class="text-sm text-neutral-700 dark:text-neutral-200">
This system prioritizes sustainability over
<em>theoretical maximum pay</em>. You might not always get full
theoretical wages, but this method will prevent you from running
out of cash.
</p>
</div>
</div>
</template>
</UModal>
</div>
</template>
<script setup lang="ts">
// Stores and synchronization
const budgetStore = useBudgetStore();
console.log("Budget store initialized:", budgetStore);
console.log("Budget worksheet data:", budgetStore.budgetWorksheet);
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");
// Only use regular initialization (not force) to avoid overwriting user changes
await budgetStore.initializeFromWizardData();
// 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.initializeFromWizardData();
}
} else if (hasBudgetData) {
console.log(
"📊 Budget Page: Budget data already exists, using existing data"
);
}
};
// Initialize on mount
onMounted(async () => {
// Initialize without overwriting existing user data
await initializeBudgetPage();
// 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 (arrays changed in size)
const [newStreams, newMembers] = newVal;
const [oldStreams, oldMembers] = oldVal || [[], []];
const streamsChanged = newStreams.length !== oldStreams.length;
const membersChanged = newMembers.length !== oldMembers.length;
if (streamsChanged || membersChanged) {
console.log(
"📊 Budget Page: Coop data structure changed, checking if reinit needed"
);
// Only reinitialize if budget is truly empty (no user-entered data)
const hasBudgetData =
budgetStore.budgetWorksheet.revenue.length > 0 ||
budgetStore.budgetWorksheet.expenses.length > 0;
if (!hasBudgetData) {
console.log(
"📊 Budget Page: No budget data exists, reinitializing from coop data"
);
await nextTick();
await initializeBudgetPage();
} else {
console.log(
"📊 Budget Page: Budget data exists, preserving user changes"
);
}
}
},
{ deep: true }
);
// Use reactive synchronized data
const syncedStreams = unifiedStreams;
const syncedMembers = unifiedMembers;
// State
const activeView = ref("monthly");
const viewTabs = [
{
value: "monthly",
label: "Monthly",
},
{
value: "annual",
label: "Annual",
},
];
const showAddRevenueModal = ref(false);
const showAddExpenseModal = ref(false);
const showEditRevenueModal = ref(false);
const showEditExpenseModal = ref(false);
const showBudgetSettingsModal = ref(false);
const showCalculationModal = ref(false);
const activeTab = ref(0);
const highlightedItemId = ref<string | null>(null);
const editingItem = ref<any | 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("annual");
const revenueHelperTabs = [
{
value: "annual",
label: "Annual Distribution",
icon: "i-heroicons-calendar",
},
{
value: "monthly",
label: "Set All Months",
icon: "i-heroicons-squares-2x2",
},
{
value: "empty",
label: "Start Empty",
icon: "i-heroicons-minus",
},
];
const expenseInitialTab = ref("annual");
const expenseHelperTabs = [
{
value: "annual",
label: "Annual Distribution",
icon: "i-heroicons-calendar",
},
{
value: "monthly",
label: "Set All Months",
icon: "i-heroicons-squares-2x2",
},
{
value: "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 [];
const subcategories = budgetStore.revenueSubcategories as Record<
string,
string[]
>;
return subcategories[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);
const cumulativeBalances = computed(() => budgetStore.cumulativeBalances);
// 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 activeTabValue = revenueInitialTab.value;
if (
activeTabValue === "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 (
activeTabValue === "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 = "annual";
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 activeExpenseTabValue = expenseInitialTab.value;
if (
activeExpenseTabValue === "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 (
activeExpenseTabValue === "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 = "annual";
showAddExpenseModal.value = false;
}
// Edit revenue item
function editRevenueItem() {
if (!editingItem.value) return;
// Update the existing item's basic properties
const item = editingItem.value;
item.name = newRevenue.value.name;
item.mainCategory = newRevenue.value.category;
item.subcategory = newRevenue.value.subcategory;
// Apply helper values based on selected tab
const activeTabValue = revenueInitialTab.value;
if (
activeTabValue === "annual" &&
Number(newRevenue.value.annualAmount) > 0
) {
// Annual distribution
const monthlyAmount = Math.round(newRevenue.value.annualAmount / 12);
monthlyHeaders.value.forEach((month) => {
budgetStore.updateMonthlyValue(
"revenue",
item.id,
month.key,
monthlyAmount.toString()
);
});
} else if (
activeTabValue === "monthly" &&
Number(newRevenue.value.monthlyAmount) > 0
) {
// Set all months
monthlyHeaders.value.forEach((month) => {
budgetStore.updateMonthlyValue(
"revenue",
item.id,
month.key,
newRevenue.value.monthlyAmount.toString()
);
});
}
// If tab === 2 (empty), don't change values
// Reset form and close modal
newRevenue.value = {
category: "",
subcategory: "",
name: "",
initialAmount: 0,
annualAmount: 0,
monthlyAmount: 0,
};
revenueInitialTab.value = "annual";
editingItem.value = null;
showEditRevenueModal.value = false;
}
// Edit expense item
function editExpenseItem() {
if (!editingItem.value) return;
// Update the existing item's basic properties
const item = editingItem.value;
item.name = newExpense.value.name;
item.mainCategory = newExpense.value.category;
item.subcategory = newExpense.value.subcategory;
// Apply helper values based on selected tab
const activeExpenseTabValue = expenseInitialTab.value;
if (
activeExpenseTabValue === "annual" &&
Number(newExpense.value.annualAmount) > 0
) {
// Annual distribution
const monthlyAmount = Math.round(newExpense.value.annualAmount / 12);
monthlyHeaders.value.forEach((month) => {
budgetStore.updateMonthlyValue(
"expenses",
item.id,
month.key,
monthlyAmount.toString()
);
});
} else if (
activeExpenseTabValue === "monthly" &&
Number(newExpense.value.monthlyAmount) > 0
) {
// Set all months
monthlyHeaders.value.forEach((month) => {
budgetStore.updateMonthlyValue(
"expenses",
item.id,
month.key,
newExpense.value.monthlyAmount.toString()
);
});
}
// If tab === 2 (empty), don't change values
// Reset form and close modal
newExpense.value = {
category: "",
subcategory: "",
name: "",
initialAmount: 0,
annualAmount: 0,
monthlyAmount: 0,
};
expenseInitialTab.value = "annual";
editingItem.value = null;
showEditExpenseModal.value = false;
}
// Edit item
function editItem(category: string, item: any) {
editingItem.value = item;
if (category === "revenue") {
// Populate revenue form with existing data
newRevenue.value = {
category: item.mainCategory,
subcategory: item.subcategory,
name: item.name,
initialAmount: 0,
annualAmount: 0,
monthlyAmount: 0,
};
// Calculate if all months have the same value for monthly tab
const monthlyValues = Object.values(item.monthlyValues || {}) as number[];
const firstValue = monthlyValues[0] || 0;
const allSame = monthlyValues.every((val) => val === firstValue);
if (allSame && firstValue > 0) {
newRevenue.value.monthlyAmount = firstValue;
revenueInitialTab.value = "monthly";
} else {
const annualTotal = monthlyValues.reduce(
(sum: number, val: number) => sum + (val || 0),
0
);
newRevenue.value.annualAmount = annualTotal;
revenueInitialTab.value = "annual";
}
showEditRevenueModal.value = true;
} else if (category === "expenses") {
// Populate expense form with existing data
newExpense.value = {
category: item.mainCategory,
subcategory: item.subcategory,
name: item.name,
initialAmount: 0,
annualAmount: 0,
monthlyAmount: 0,
};
// Calculate if all months have the same value for monthly tab
const monthlyValues = Object.values(item.monthlyValues || {}) as number[];
const firstValue = monthlyValues[0] || 0;
const allSame = monthlyValues.every((val) => val === firstValue);
if (allSame && firstValue > 0) {
newExpense.value.monthlyAmount = firstValue;
expenseInitialTab.value = "monthly";
} else {
const annualTotal = monthlyValues.reduce(
(sum: number, val: number) => sum + (val || 0),
0
);
newExpense.value.annualAmount = annualTotal;
expenseInitialTab.value = "annual";
}
showEditExpenseModal.value = true;
}
}
// Remove item
function removeItem(category: string, itemId: string) {
// Prevent deletion of payroll items - they are managed through compensation settings
if (isPayrollItem(itemId)) {
alert(
"Payroll items are automatically managed through the Compensation page. To modify payroll costs, please use the Compensation page."
);
return;
}
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 || value === 0) return "0";
return value.toString();
}
function isPayrollItem(itemId: string): boolean {
return (
itemId === "expense-payroll-base" ||
itemId === "expense-payroll-oncosts" ||
itemId === "expense-payroll"
);
}
function handleFocus(event: FocusEvent) {
const input = event.target as HTMLInputElement;
input.select();
}
function handleBlur(
event: FocusEvent,
category: string,
itemId: string,
monthKey: string
) {
// Prevent editing payroll items - they are calculated automatically
if (isPayrollItem(itemId)) {
return;
}
const input = event.target as HTMLInputElement;
const rawValue = input.value;
const cleanValue = rawValue.replace(/[^0-9.-]/g, "");
const numericValue = parseFloat(cleanValue) || 0;
console.log("handleBlur called:", {
category,
itemId,
monthKey,
rawValue,
cleanValue,
numericValue,
});
budgetStore.updateMonthlyValue(category, itemId, monthKey, numericValue);
// Ensure the store is marked as initialized to prevent overwrites
if (!budgetStore.isInitialized) {
budgetStore.isInitialized = true;
}
}
function handleInput(
event: Event,
category: string,
itemId: string,
monthKey: string
) {
// Prevent editing payroll items - they are calculated automatically
if (isPayrollItem(itemId)) {
return;
}
const input = event.target as HTMLInputElement;
const rawValue = input.value;
const cleanValue = rawValue.replace(/[^0-9.-]/g, "");
const numericValue = parseFloat(cleanValue) || 0;
console.log("handleInput called:", {
category,
itemId,
monthKey,
rawValue,
cleanValue,
numericValue,
});
budgetStore.updateMonthlyValue(category, itemId, monthKey, numericValue);
// Ensure the store is marked as initialized to prevent overwrites
if (!budgetStore.isInitialized) {
budgetStore.isInitialized = true;
}
}
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 dark:text-green-400 font-bold";
if (amount < 0) return "text-red-600 dark:text-red-400 font-bold";
return "text-neutral-600 dark:text-neutral-400";
}
function getCumulativeBalanceClass(amount: number): string {
if (amount > 50000) return "text-green-700 dark:text-green-300 font-bold"; // Healthy cash position
if (amount > 10000) return "text-green-600 dark:text-green-400 font-bold"; // Good cash position
if (amount > 0) return "text-blue-600 dark:text-blue-400 font-bold"; // Positive but low
if (amount > -10000)
return "text-neutral-600 dark:text-neutral-200 font-bold"; // Concerning
return "text-red-700 dark:text-red-300 font-bold"; // Critical cash position
}
function getPolicyName(): string {
const policyType = coopBuilderStore.policy?.relationship || "equal-pay";
if (policyType === "equal-pay") return "Equal Pay";
if (policyType === "hours-weighted") return "Hours Based";
if (policyType === "needs-weighted") return "Needs Weighted";
return "Equal Pay";
}
// Settings handling
function handleSettingsUpdate() {
// Refresh the budget to reflect the new settings
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>