app/pages/budget.vue

1213 lines
39 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-zinc-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-zinc-100',
]">
Annual
</button>
</div>
</div>
<div class="flex items-center gap-2">
<UButton @click="exportBudget" variant="ghost" 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-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-zinc-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 border border-black bg-white">
<div class="relative overflow-x-auto">
<table class="relative w-full border-collapse text-sm bg-white dark:bg-neutral-950">
<thead>
<tr class="border-b-2 border-black">
<th
class="border-r-1 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-zinc-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-zinc-100 z-10 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-zinc-50 transition-all duration-300',
highlightedItemId === item.id &&
'bg-yellow-100 animate-pulse',
]">
<td
class="border-r-1 border-zinc-200 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)"
@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-gray-400 focus:border-black focus:outline-none transition-none"
:class="{
'bg-zinc-50': !item.monthlyValues?.[month.key],
}" />
</td>
</tr>
</template>
<!-- Total Revenue Row -->
<tr
class="border-t-1 border-black border-b-1 font-bold bg-zinc-100">
<td
class="border-r-1 border-black px-4 py-2 sticky left-0 bg-zinc-100 z-10">
TOTAL REVENUE
</td>
<td
v-for="month in monthlyHeaders"
:key="month.key"
class="border-r border-black px-2 py-2 text-right last:border-r-0">
{{ formatCurrency(monthlyTotals[month.key]?.revenue || 0) }}
</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-zinc-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-zinc-100 z-10"
:colspan="monthlyHeaders.length + 1">
{{ categoryName }}
</td>
</tr>
<tr
v-for="item in items"
:key="item.id"
:class="[
'border-t border-gray-200 hover:bg-zinc-50 transition-all duration-300',
highlightedItemId === item.id &&
'bg-yellow-100 animate-pulse',
]">
<td
class="border-r-1 border-zinc-200 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 flex items-start gap-2">
<UTooltip
v-if="isPayrollItem(item.id)"
text="Calculated from compensation settings"
:content="{ side: 'top', align: 'start' }">
<span class="cursor-help">{{ item.name }}</span>
</UTooltip>
<span v-else>{{ 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-blue-100 text-blue-800">
Auto
</span>
</div>
<div class="text-xs text-gray-600" v-if="!isPayrollItem(item.id)">
{{ item.subcategory }}
</div>
</div>
</div>
<!-- Delete button for non-payroll items only -->
<UButton
v-if="!isPayrollItem(item.id)"
@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)"
: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"
:class="{
'bg-zinc-50':
!item.monthlyValues?.[month.key] &&
!isPayrollItem(item.id),
'bg-zinc-50 border-none cursor-not-allowed text-zinc-500':
isPayrollItem(item.id),
'border-transparent hover:border-zinc-400 focus:border-black 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 font-bold bg-zinc-100">
<td
class="border-r-1 border-black px-4 py-2 sticky left-0 bg-zinc-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-1 border-black font-bold text-lg">
<td
class="border-r-1 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>
</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-gray-500 font-medium">$</span>
</template>
</UInput>
</UFormField>
<div class="bg-white rounded-lg p-4 border border-gray-200">
<div class="mb-2">
<span class="text-sm font-medium text-gray-700"
>Distribution Preview</span
>
</div>
<p class="text-sm text-gray-600">
This will divide
<span class="font-semibold text-gray-900"
>${{ newRevenue.annualAmount || 0 }}</span
>
equally across all 12 months (<span
class="font-semibold text-green-600"
>${{
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-gray-500 font-medium">$</span>
</template>
</UInput>
</UFormField>
<div class="bg-white rounded-lg p-4 border border-gray-200">
<div class="mb-2">
<span class="text-sm font-medium text-gray-700"
>Monthly Preview</span
>
</div>
<p class="text-sm text-gray-600">
This will set
<span class="font-semibold text-green-600"
>${{ 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-gray-200 text-center">
<p class="text-sm text-gray-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-gray-500 font-medium">$</span>
</template>
</UInput>
</UFormField>
<div class="bg-white rounded-lg p-4 border border-gray-200">
<div class="mb-2">
<span class="text-sm font-medium text-gray-700"
>Distribution Preview</span
>
</div>
<p class="text-sm text-gray-600">
This will divide
<span class="font-semibold text-gray-900"
>${{ newExpense.annualAmount || 0 }}</span
>
equally across all 12 months (<span
class="font-semibold text-red-600"
>${{
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-gray-500 font-medium">$</span>
</template>
</UInput>
</UFormField>
<div class="bg-white rounded-lg p-4 border border-gray-200">
<div class="mb-2">
<span class="text-sm font-medium text-gray-700"
>Monthly Preview</span
>
</div>
<p class="text-sm text-gray-600">
This will set
<span class="font-semibold text-red-600"
>${{ 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-gray-200 text-center">
<p class="text-sm text-gray-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>
<!-- Payroll Oncost Settings Modal -->
<PayrollOncostModal
v-model:open="showPayrollOncostModal"
@save="handlePayrollOncostUpdate" />
</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 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("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);
// 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;
}
// 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 === 0) return "";
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 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>
.dither-shadow {
background: black;
background-image: radial-gradient(white 1px, transparent 1px);
background-size: 2px 2px;
}
@media (prefers-color-scheme: dark) {
.dither-shadow {
background: white;
background-image: radial-gradient(black 1px, transparent 1px);
}
}
:global(.dark) .dither-shadow {
background: white;
background-image: radial-gradient(black 1px, transparent 1px);
}
/* 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>