320 lines
13 KiB
Vue
320 lines
13 KiB
Vue
<template>
|
||
<section class="py-8 space-y-6">
|
||
<div class="flex items-center justify-between">
|
||
<h2 class="text-2xl font-semibold">Budget Worksheet</h2>
|
||
<div class="flex items-center gap-4">
|
||
<UButton @click="forceReinitialize" variant="outline" size="sm" color="orange">Force Re-init</UButton>
|
||
<UButton @click="resetWorksheet" variant="outline" size="sm">Reset All</UButton>
|
||
<UButton @click="exportBudget" variant="outline" size="sm">Export</UButton>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Budget Worksheet Table -->
|
||
<UCard>
|
||
<div class="overflow-x-auto">
|
||
<table class="w-full border-collapse border border-gray-300 text-sm">
|
||
<thead>
|
||
<tr class="bg-gray-50">
|
||
<th class="border border-gray-300 px-3 py-2 text-left min-w-40 sticky left-0 bg-gray-50 z-10">Category</th>
|
||
<!-- Monthly columns -->
|
||
<th v-for="month in monthlyHeaders" :key="month.key" class="border border-gray-300 px-2 py-2 text-center min-w-20">{{ month.label }}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<!-- Revenue Section -->
|
||
<tr class="bg-blue-50 font-medium">
|
||
<td class="border border-gray-300 px-3 py-2 sticky left-0 bg-blue-50 z-10">
|
||
<div class="flex items-center justify-between">
|
||
<span>Revenue</span>
|
||
<UButton @click="addRevenueLine" size="xs" variant="soft">+</UButton>
|
||
</div>
|
||
</td>
|
||
<td class="border border-gray-300 px-2 py-2" :colspan="monthlyHeaders.length"></td>
|
||
</tr>
|
||
|
||
<!-- Revenue by Category -->
|
||
<template v-for="(category, categoryName) in budgetStore.groupedRevenue" :key="`revenue-${categoryName}`">
|
||
<tr v-if="category.length > 0" class="bg-blue-100 font-medium">
|
||
<td class="border border-gray-300 px-4 py-1 sticky left-0 bg-blue-100 z-10 text-sm text-blue-700">
|
||
{{ categoryName }} ({{ category.length }} items)
|
||
</td>
|
||
<td class="border border-gray-300 px-2 py-1" :colspan="monthlyHeaders.length"></td>
|
||
</tr>
|
||
<tr v-for="item in category" :key="item.id">
|
||
<td class="border border-gray-300 px-3 py-2 sticky left-0 bg-white z-10">
|
||
<div class="space-y-2">
|
||
<div class="flex items-center justify-between">
|
||
<input
|
||
v-model="item.name"
|
||
@blur="saveWorksheet"
|
||
class="bg-transparent border-none outline-none w-full font-medium"
|
||
:class="{ 'italic text-gray-500': item.name === 'New Revenue Item' }"
|
||
/>
|
||
<UButton @click="removeItem('revenue', item.id)" size="xs" variant="ghost" color="error">×</UButton>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<BudgetCategorySelector
|
||
v-model="item.subcategory"
|
||
type="revenue"
|
||
:main-category="item.mainCategory"
|
||
placeholder="Subcategory"
|
||
@update:model-value="saveWorksheet"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<!-- Monthly columns -->
|
||
<td v-for="month in monthlyHeaders" :key="month.key" class="border border-gray-300 px-1 py-1">
|
||
<input
|
||
type="number"
|
||
:value="item.monthlyValues?.[month.key] || 0"
|
||
@input="updateMonthlyValue('revenue', item.id, month.key, $event.target.value)"
|
||
class="w-full text-right border-none outline-none bg-transparent"
|
||
placeholder="0"
|
||
/>
|
||
</td>
|
||
</tr>
|
||
</template>
|
||
|
||
<!-- Total Revenue Row -->
|
||
<tr class="bg-blue-100 font-bold">
|
||
<td class="border border-gray-300 px-3 py-2 sticky left-0 bg-blue-100 z-10">Total Revenue</td>
|
||
<td v-for="month in monthlyHeaders" :key="month.key" class="border border-gray-300 px-2 py-2 text-right">
|
||
{{ formatCurrency(budgetStore.monthlyTotals[month.key]?.revenue || 0) }}
|
||
</td>
|
||
</tr>
|
||
|
||
<!-- Expenses Section -->
|
||
<tr class="bg-red-50 font-medium">
|
||
<td class="border border-gray-300 px-3 py-2 sticky left-0 bg-red-50 z-10">
|
||
<div class="flex items-center justify-between">
|
||
<span>Expenses</span>
|
||
<UButton @click="addExpenseLine" size="xs" variant="soft">+</UButton>
|
||
</div>
|
||
</td>
|
||
<td class="border border-gray-300 px-2 py-2" :colspan="monthlyHeaders.length"></td>
|
||
</tr>
|
||
|
||
<!-- Expenses by Category -->
|
||
<template v-for="(category, categoryName) in budgetStore.groupedExpenses" :key="`expense-${categoryName}`">
|
||
<tr v-if="category.length > 0" class="bg-red-100 font-medium">
|
||
<td class="border border-gray-300 px-4 py-1 sticky left-0 bg-red-100 z-10 text-sm text-red-700">
|
||
{{ categoryName }}
|
||
</td>
|
||
<td class="border border-gray-300 px-2 py-1" :colspan="monthlyHeaders.length"></td>
|
||
</tr>
|
||
<tr v-for="item in category" :key="item.id">
|
||
<td class="border border-gray-300 px-3 py-2 sticky left-0 bg-white z-10">
|
||
<div class="space-y-2">
|
||
<div class="flex items-center justify-between">
|
||
<input
|
||
v-model="item.name"
|
||
@blur="saveWorksheet"
|
||
class="bg-transparent border-none outline-none w-full font-medium"
|
||
:class="{ 'italic text-gray-500': item.name === 'New Expense Item' }"
|
||
/>
|
||
<UButton @click="removeItem('expenses', item.id)" size="xs" variant="ghost" color="error">×</UButton>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<BudgetCategorySelector
|
||
v-model="item.subcategory"
|
||
type="expenses"
|
||
:main-category="item.mainCategory"
|
||
placeholder="Subcategory"
|
||
@update:model-value="saveWorksheet"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<!-- Monthly columns -->
|
||
<td v-for="month in monthlyHeaders" :key="month.key" class="border border-gray-300 px-1 py-1">
|
||
<input
|
||
type="number"
|
||
:value="item.monthlyValues?.[month.key] || 0"
|
||
@input="updateMonthlyValue('expenses', item.id, month.key, $event.target.value)"
|
||
class="w-full text-right border-none outline-none bg-transparent"
|
||
placeholder="0"
|
||
/>
|
||
</td>
|
||
</tr>
|
||
</template>
|
||
|
||
<!-- Total Expenses Row -->
|
||
<tr class="bg-red-100 font-bold">
|
||
<td class="border border-gray-300 px-3 py-2 sticky left-0 bg-red-100 z-10">Total Expenses</td>
|
||
<td v-for="month in monthlyHeaders" :key="month.key" class="border border-gray-300 px-2 py-2 text-right">
|
||
{{ formatCurrency(budgetStore.monthlyTotals[month.key]?.expenses || 0) }}
|
||
</td>
|
||
</tr>
|
||
|
||
<!-- Net Income Row -->
|
||
<tr class="bg-green-100 font-bold text-lg">
|
||
<td class="border border-gray-300 px-3 py-2 sticky left-0 bg-green-100 z-10">Net Income</td>
|
||
<td v-for="month in monthlyHeaders" :key="month.key" class="border border-gray-300 px-2 py-2 text-right"
|
||
:class="getNetIncomeClass(budgetStore.monthlyTotals[month.key]?.net || 0)">
|
||
{{ formatCurrency(budgetStore.monthlyTotals[month.key]?.net || 0) }}
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</UCard>
|
||
</section>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
// Import components explicitly
|
||
import BudgetCategorySelector from '~/components/BudgetCategorySelector.vue';
|
||
|
||
// Use budget worksheet store
|
||
const budgetStore = useBudgetStore();
|
||
|
||
// Generate monthly headers for the next 12 months
|
||
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();
|
||
|
||
headers.push({
|
||
key: `${year}-${String(date.getMonth() + 1).padStart(2, '0')}`,
|
||
label: `${monthName} ${year}`
|
||
});
|
||
}
|
||
|
||
return headers;
|
||
});
|
||
|
||
// Initialize from wizard data on first load
|
||
onMounted(async () => {
|
||
console.log('Budget page mounted, initializing...');
|
||
if (!budgetStore.isInitialized) {
|
||
await budgetStore.initializeFromWizardData();
|
||
}
|
||
console.log('Budget worksheet:', budgetStore.budgetWorksheet);
|
||
console.log('Grouped revenue:', budgetStore.groupedRevenue);
|
||
console.log('Grouped expenses:', budgetStore.groupedExpenses);
|
||
});
|
||
|
||
// Budget worksheet functions
|
||
function updateValue(category: string, itemId: string, year: string, scenario: string, value: string) {
|
||
budgetStore.updateBudgetValue(category, itemId, year, scenario, value);
|
||
}
|
||
|
||
function updateMonthlyValue(category: string, itemId: string, monthKey: string, value: string) {
|
||
budgetStore.updateMonthlyValue(category, itemId, monthKey, value);
|
||
}
|
||
|
||
function addRevenueLine() {
|
||
console.log('Adding revenue line...');
|
||
budgetStore.addBudgetItem('revenue', 'New Revenue Item');
|
||
}
|
||
|
||
function addExpenseLine() {
|
||
console.log('Adding expense line...');
|
||
budgetStore.addBudgetItem('expenses', 'New Expense Item');
|
||
}
|
||
|
||
function removeItem(category: string, itemId: string) {
|
||
budgetStore.removeBudgetItem(category, itemId);
|
||
}
|
||
|
||
function saveWorksheet() {
|
||
// Auto-save is handled by the store persistence
|
||
console.log('Worksheet saved');
|
||
}
|
||
|
||
function resetWorksheet() {
|
||
if (confirm('Are you sure you want to reset all budget data? This cannot be undone.')) {
|
||
budgetStore.resetBudgetWorksheet();
|
||
// Force re-initialization
|
||
budgetStore.isInitialized = false;
|
||
budgetStore.initializeFromWizardData();
|
||
}
|
||
}
|
||
|
||
async function forceReinitialize() {
|
||
console.log('Force re-initializing budget...');
|
||
// Clear all persistent data
|
||
localStorage.removeItem('urgent-tools-budget');
|
||
localStorage.removeItem('urgent-tools-streams');
|
||
localStorage.removeItem('urgent-tools-members');
|
||
localStorage.removeItem('urgent-tools-policies');
|
||
|
||
// Reset the store state completely
|
||
budgetStore.isInitialized = false;
|
||
budgetStore.budgetWorksheet.revenue = [];
|
||
budgetStore.budgetWorksheet.expenses = [];
|
||
|
||
// Reset categories to defaults
|
||
budgetStore.revenueCategories = [
|
||
'Games & Products',
|
||
'Services & Contracts',
|
||
'Grants & Funding',
|
||
'Community Support',
|
||
'Partnerships',
|
||
'Investment Income',
|
||
'In-Kind Contributions'
|
||
];
|
||
|
||
budgetStore.expenseCategories = [
|
||
'Salaries & Benefits',
|
||
'Development Costs',
|
||
'Equipment & Technology',
|
||
'Marketing & Outreach',
|
||
'Office & Operations',
|
||
'Legal & Professional',
|
||
'Other Expenses'
|
||
];
|
||
|
||
// Force re-initialization
|
||
await budgetStore.initializeFromWizardData();
|
||
|
||
console.log('Re-initialization complete');
|
||
}
|
||
|
||
function exportBudget() {
|
||
const data = {
|
||
worksheet: budgetStore.budgetWorksheet,
|
||
totals: budgetStore.budgetTotals,
|
||
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);
|
||
}
|
||
|
||
// Helper functions
|
||
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';
|
||
if (amount < 0) return 'text-red-600';
|
||
return 'text-gray-600';
|
||
}
|
||
|
||
// SEO
|
||
useSeoMeta({
|
||
title: "Budget Worksheet - Plan Your Co-op's Financial Future",
|
||
description: "Interactive budget planning tool with multiple scenarios and multi-year projections for worker cooperatives.",
|
||
});
|
||
</script>
|