import { defineStore } from "pinia"; export const useBudgetStore = defineStore( "budget", () => { // Schema version for persistence const schemaVersion = "2.0"; // Canonical categories from WizardRevenueStep - matches the wizard exactly const revenueCategories = ref([ 'Games & Products', 'Services & Contracts', 'Grants & Funding', 'Community Support', 'Partnerships', 'Investment Income', 'In-Kind Contributions' ]); // Revenue subcategories by main category (for reference and grouping) const revenueSubcategories = ref({ 'Games & Products': ['Direct sales', 'Platform revenue share', 'DLC/expansions', 'Merchandise'], 'Services & Contracts': ['Contract development', 'Consulting', 'Workshops/teaching', 'Technical services'], 'Grants & Funding': ['Government funding', 'Arts council grants', 'Foundation support', 'Research grants'], 'Community Support': ['Patreon/subscriptions', 'Crowdfunding', 'Donations', 'Mutual aid received'], 'Partnerships': ['Corporate partnerships', 'Academic partnerships', 'Sponsorships'], 'Investment Income': ['Impact investment', 'Venture capital', 'Loans'], 'In-Kind Contributions': ['Office space', 'Equipment/hardware', 'Software licenses', 'Professional services', 'Marketing/PR services', 'Legal services'] }); const expenseCategories = ref([ 'Salaries & Benefits', 'Development Costs', 'Equipment & Technology', 'Marketing & Outreach', 'Office & Operations', 'Legal & Professional', 'Other Expenses' ]); // NEW: Budget worksheet structure (starts empty, populated from wizard data) const budgetWorksheet = ref({ revenue: [], expenses: [] }); // Track if worksheet has been initialized from wizard data const isInitialized = ref(false); // LEGACY: Keep for backward compatibility const budgetLines = ref({}); const overheadCosts = ref([]); const productionCosts = ref([]); // Computed grouped data for category headers const groupedRevenue = computed(() => { const groups = {}; revenueCategories.value.forEach(category => { groups[category] = budgetWorksheet.value.revenue.filter(item => item.mainCategory === category); }); return groups; }); const groupedExpenses = computed(() => { const groups = {}; expenseCategories.value.forEach(category => { groups[category] = budgetWorksheet.value.expenses.filter(item => item.mainCategory === category); }); return groups; }); // Computed totals for budget worksheet (legacy - keep for backward compatibility) const budgetTotals = computed(() => { const years = ['year1', 'year2', 'year3']; const scenarios = ['best', 'worst', 'mostLikely']; const totals = {}; years.forEach(year => { totals[year] = {}; scenarios.forEach(scenario => { // Calculate revenue total const revenueTotal = budgetWorksheet.value.revenue.reduce((sum, item) => { return sum + (item.values?.[year]?.[scenario] || 0); }, 0); // Calculate expenses total const expensesTotal = budgetWorksheet.value.expenses.reduce((sum, item) => { return sum + (item.values?.[year]?.[scenario] || 0); }, 0); // Calculate net income const netIncome = revenueTotal - expensesTotal; totals[year][scenario] = { revenue: revenueTotal, expenses: expensesTotal, net: netIncome }; }); }); return totals; }); // Monthly totals computation const monthlyTotals = computed(() => { const totals = {}; // Generate month keys for next 12 months const today = new Date(); for (let i = 0; i < 12; i++) { const date = new Date(today.getFullYear(), today.getMonth() + i, 1); const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; // Calculate revenue total for this month const revenueTotal = budgetWorksheet.value.revenue.reduce((sum, item) => { return sum + (item.monthlyValues?.[monthKey] || 0); }, 0); // Calculate expenses total for this month const expensesTotal = budgetWorksheet.value.expenses.reduce((sum, item) => { return sum + (item.monthlyValues?.[monthKey] || 0); }, 0); // Calculate net income const netIncome = revenueTotal - expensesTotal; totals[monthKey] = { revenue: revenueTotal, expenses: expensesTotal, net: netIncome }; } return totals; }); // LEGACY: Keep for backward compatibility const currentDate = new Date(); const currentYear = currentDate.getFullYear(); const currentMonth = String(currentDate.getMonth() + 1).padStart(2, '0'); const currentPeriod = ref(`${currentYear}-${currentMonth}`); const currentBudget = computed(() => { return ( budgetLines.value[currentPeriod.value] || { period: currentPeriod.value, revenueByStream: {}, payrollCosts: { memberHours: [], oncostApplied: 0 }, overheadCosts: [], productionCosts: [], savingsChange: 0, net: 0, } ); }); // Actions function setBudgetLine(period, budgetData) { budgetLines.value[period] = { period, ...budgetData, }; } function updateRevenue(period, streamId, type, amount) { if (!budgetLines.value[period]) { budgetLines.value[period] = { period, revenueByStream: {} }; } if (!budgetLines.value[period].revenueByStream[streamId]) { budgetLines.value[period].revenueByStream[streamId] = {}; } budgetLines.value[period].revenueByStream[streamId][type] = amount; } // Wizard-required actions function addOverheadLine(cost) { // Allow creating a blank line so the user can fill it out in the UI const safeName = cost?.name ?? ""; const safeAmountMonthly = typeof cost?.amountMonthly === "number" && !Number.isNaN(cost.amountMonthly) ? cost.amountMonthly : 0; overheadCosts.value.push({ id: Date.now().toString(), name: safeName, amount: safeAmountMonthly, category: cost?.category || "Operations", recurring: cost?.recurring ?? true, ...cost, }); } function removeOverheadLine(id) { const index = overheadCosts.value.findIndex((c) => c.id === id); if (index > -1) { overheadCosts.value.splice(index, 1); } } function addOverheadCost(cost) { addOverheadLine({ name: cost.name, amountMonthly: cost.amount, ...cost }); } function addProductionCost(cost) { productionCosts.value.push({ id: Date.now().toString(), name: cost.name, amount: cost.amount, category: cost.category || "Production", period: cost.period, ...cost, }); } function setCurrentPeriod(period) { currentPeriod.value = period; } // Initialize worksheet from wizard data async function initializeFromWizardData() { if (isInitialized.value && budgetWorksheet.value.revenue.length > 0) { console.log('Already initialized with data, skipping...'); return; } console.log('Initializing budget from wizard data...'); // Import stores dynamically to avoid circular deps const { useStreamsStore } = await import('./streams'); const { useMembersStore } = await import('./members'); const { usePoliciesStore } = await import('./policies'); const streamsStore = useStreamsStore(); const membersStore = useMembersStore(); const policiesStore = usePoliciesStore(); console.log('Streams:', streamsStore.streams.length, 'streams'); console.log('Members capacity:', membersStore.capacityTotals); console.log('Policies wage:', policiesStore.equalHourlyWage); // Clear existing data budgetWorksheet.value.revenue = []; budgetWorksheet.value.expenses = []; // Add revenue streams from wizard if (streamsStore.streams.length === 0) { console.log('No wizard streams found, adding sample data'); // Initialize with minimal demo if no wizard data exists await streamsStore.initializeWithFixtures(); } streamsStore.streams.forEach(stream => { const monthlyAmount = stream.targetMonthlyAmount || 0; console.log('Adding stream:', stream.name, 'category:', stream.category, 'subcategory:', stream.subcategory, 'amount:', monthlyAmount); console.log('Full stream object:', stream); // Simple category mapping - just map the key categories we know exist let mappedCategory = 'Games & Products'; // Default const categoryLower = (stream.category || '').toLowerCase(); if (categoryLower === 'games' || categoryLower === 'product') mappedCategory = 'Games & Products'; else if (categoryLower === 'services' || categoryLower === 'service') mappedCategory = 'Services & Contracts'; else if (categoryLower === 'grants' || categoryLower === 'grant') mappedCategory = 'Grants & Funding'; else if (categoryLower === 'community') mappedCategory = 'Community Support'; else if (categoryLower === 'partnerships' || categoryLower === 'partnership') mappedCategory = 'Partnerships'; else if (categoryLower === 'investment') mappedCategory = 'Investment Income'; console.log('Mapped category from', stream.category, 'to', mappedCategory); // Create monthly values - split the annual target evenly across 12 months const monthlyValues = {}; const today = new Date(); for (let i = 0; i < 12; i++) { const date = new Date(today.getFullYear(), today.getMonth() + i, 1); const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; monthlyValues[monthKey] = monthlyAmount; } console.log('Created monthly values for', stream.name, ':', monthlyValues); budgetWorksheet.value.revenue.push({ id: `revenue-${stream.id}`, name: stream.name, mainCategory: mappedCategory, subcategory: stream.subcategory || 'Direct sales', // Use actual subcategory from stream source: 'wizard', monthlyValues, values: { year1: { best: monthlyAmount * 12, worst: monthlyAmount * 6, mostLikely: monthlyAmount * 10 }, year2: { best: monthlyAmount * 15, worst: monthlyAmount * 8, mostLikely: monthlyAmount * 12 }, year3: { best: monthlyAmount * 18, worst: monthlyAmount * 10, mostLikely: monthlyAmount * 15 } } }); }); // Add payroll from wizard data const totalHours = membersStore.capacityTotals.targetHours || 0; const hourlyWage = policiesStore.equalHourlyWage || 0; const oncostPct = policiesStore.payrollOncostPct || 0; if (totalHours > 0 && hourlyWage > 0) { const monthlyPayroll = totalHours * hourlyWage * (1 + oncostPct / 100); // Create monthly values for payroll const monthlyValues = {}; const today = new Date(); for (let i = 0; i < 12; i++) { const date = new Date(today.getFullYear(), today.getMonth() + i, 1); const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; monthlyValues[monthKey] = monthlyPayroll; } budgetWorksheet.value.expenses.push({ id: 'expense-payroll', name: 'Payroll', mainCategory: 'Salaries & Benefits', subcategory: 'Base wages and benefits', source: 'wizard', monthlyValues, values: { year1: { best: monthlyPayroll * 12, worst: monthlyPayroll * 8, mostLikely: monthlyPayroll * 12 }, year2: { best: monthlyPayroll * 14, worst: monthlyPayroll * 10, mostLikely: monthlyPayroll * 13 }, year3: { best: monthlyPayroll * 16, worst: monthlyPayroll * 12, mostLikely: monthlyPayroll * 15 } } }); } // Add overhead costs from wizard overheadCosts.value.forEach(cost => { if (cost.amount > 0) { const annualAmount = cost.amount * 12; // Map overhead cost categories to expense categories let expenseCategory = 'Other Expenses'; // Default if (cost.category === 'Operations') expenseCategory = 'Office & Operations'; else if (cost.category === 'Technology') expenseCategory = 'Equipment & Technology'; else if (cost.category === 'Legal') expenseCategory = 'Legal & Professional'; else if (cost.category === 'Marketing') expenseCategory = 'Marketing & Outreach'; // Create monthly values for overhead costs const monthlyValues = {}; const today = new Date(); for (let i = 0; i < 12; i++) { const date = new Date(today.getFullYear(), today.getMonth() + i, 1); const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; monthlyValues[monthKey] = cost.amount; } budgetWorksheet.value.expenses.push({ id: `expense-${cost.id}`, name: cost.name, mainCategory: expenseCategory, subcategory: cost.name, // Use the cost name as subcategory source: 'wizard', monthlyValues, values: { year1: { best: annualAmount, worst: annualAmount * 0.8, mostLikely: annualAmount }, year2: { best: annualAmount * 1.1, worst: annualAmount * 0.9, mostLikely: annualAmount * 1.05 }, year3: { best: annualAmount * 1.2, worst: annualAmount, mostLikely: annualAmount * 1.1 } } }); } }); // Add production costs from wizard productionCosts.value.forEach(cost => { if (cost.amount > 0) { const annualAmount = cost.amount * 12; // Create monthly values for production costs const monthlyValues = {}; const today = new Date(); for (let i = 0; i < 12; i++) { const date = new Date(today.getFullYear(), today.getMonth() + i, 1); const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; monthlyValues[monthKey] = cost.amount; } budgetWorksheet.value.expenses.push({ id: `expense-${cost.id}`, name: cost.name, mainCategory: 'Development Costs', subcategory: cost.name, // Use the cost name as subcategory source: 'wizard', monthlyValues, values: { year1: { best: annualAmount, worst: annualAmount * 0.7, mostLikely: annualAmount * 0.9 }, year2: { best: annualAmount * 1.2, worst: annualAmount * 0.8, mostLikely: annualAmount }, year3: { best: annualAmount * 1.3, worst: annualAmount * 0.9, mostLikely: annualAmount * 1.1 } } }); } }); // If still no data after initialization, add a sample row if (budgetWorksheet.value.revenue.length === 0) { console.log('Adding sample revenue line'); // Create monthly values for sample revenue const monthlyValues = {}; const today = new Date(); for (let i = 0; i < 12; i++) { const date = new Date(today.getFullYear(), today.getMonth() + i, 1); const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; monthlyValues[monthKey] = 667; // ~8000/12 } budgetWorksheet.value.revenue.push({ id: 'revenue-sample', name: 'Sample Revenue', mainCategory: 'Games & Products', subcategory: 'Direct sales', source: 'user', monthlyValues, values: { year1: { best: 10000, worst: 5000, mostLikely: 8000 }, year2: { best: 12000, worst: 6000, mostLikely: 10000 }, year3: { best: 15000, worst: 8000, mostLikely: 12000 } } }); } if (budgetWorksheet.value.expenses.length === 0) { console.log('Adding sample expense line'); // Create monthly values for sample expense const monthlyValues = {}; const today = new Date(); for (let i = 0; i < 12; i++) { const date = new Date(today.getFullYear(), today.getMonth() + i, 1); const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; monthlyValues[monthKey] = 67; // ~800/12 } budgetWorksheet.value.expenses.push({ id: 'expense-sample', name: 'Sample Expense', mainCategory: 'Other Expenses', subcategory: 'Miscellaneous', source: 'user', monthlyValues, values: { year1: { best: 1000, worst: 500, mostLikely: 800 }, year2: { best: 1200, worst: 600, mostLikely: 1000 }, year3: { best: 1500, worst: 800, mostLikely: 1200 } } }); } // Debug: Log all revenue items and their categories console.log('Final revenue items:'); budgetWorksheet.value.revenue.forEach(item => { console.log(`- ${item.name}: ${item.mainCategory} > ${item.subcategory} (${item.values.year1.mostLikely})`); }); console.log('Final expense items:'); budgetWorksheet.value.expenses.forEach(item => { console.log(`- ${item.name}: ${item.mainCategory} > ${item.subcategory} (${item.values.year1.mostLikely})`); }); // Ensure all items have monthlyValues and new structure - migrate existing items [...budgetWorksheet.value.revenue, ...budgetWorksheet.value.expenses].forEach(item => { // Migrate to new structure if needed if (item.category && !item.mainCategory) { console.log('Migrating item structure for:', item.name); item.mainCategory = item.category; // Old category becomes mainCategory // Set appropriate subcategory based on the main category and item name if (item.category === 'Games & Products') { const gameSubcategories = ['Direct sales', 'Platform revenue share', 'DLC/expansions', 'Merchandise']; item.subcategory = gameSubcategories.includes(item.name) ? item.name : 'Direct sales'; } else if (item.category === 'Services & Contracts') { const serviceSubcategories = ['Contract development', 'Consulting', 'Workshops/teaching', 'Technical services']; item.subcategory = serviceSubcategories.includes(item.name) ? item.name : 'Contract development'; } else if (item.category === 'Investment Income') { const investmentSubcategories = ['Impact investment', 'Venture capital', 'Loans']; item.subcategory = investmentSubcategories.includes(item.name) ? item.name : 'Impact investment'; } else if (item.category === 'Salaries & Benefits') { item.subcategory = 'Base wages and benefits'; } else if (item.category === 'Office & Operations') { // Map specific office tools to appropriate subcategories if (item.name.toLowerCase().includes('rent') || item.name.toLowerCase().includes('office')) { item.subcategory = 'Rent'; } else if (item.name.toLowerCase().includes('util')) { item.subcategory = 'Utilities'; } else if (item.name.toLowerCase().includes('insurance')) { item.subcategory = 'Insurance'; } else { item.subcategory = 'Office supplies'; } } else { // For other categories, use appropriate default item.subcategory = 'Miscellaneous'; } delete item.category; // Remove old property } if (!item.monthlyValues) { console.log('Migrating item to monthly values:', item.name); item.monthlyValues = {}; const today = new Date(); for (let i = 0; i < 12; i++) { const date = new Date(today.getFullYear(), today.getMonth() + i, 1); const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; // Try to use most likely value divided by 12, or default to 0 const yearlyValue = item.values?.year1?.mostLikely || 0; item.monthlyValues[monthKey] = Math.round(yearlyValue / 12); } console.log('Added monthly values to', item.name, ':', item.monthlyValues); } }); console.log('Initialization complete. Revenue items:', budgetWorksheet.value.revenue.length, 'Expense items:', budgetWorksheet.value.expenses.length); isInitialized.value = true; } // NEW: Budget worksheet functions function updateBudgetValue(category, itemId, year, scenario, value) { const items = budgetWorksheet.value[category]; const item = items.find(i => i.id === itemId); if (item) { item.values[year][scenario] = Number(value) || 0; } } function updateMonthlyValue(category, itemId, monthKey, value) { const items = budgetWorksheet.value[category]; const item = items.find(i => i.id === itemId); if (item) { if (!item.monthlyValues) { item.monthlyValues = {}; } item.monthlyValues[monthKey] = Number(value) || 0; } } function addBudgetItem(category, name, selectedCategory = '') { const id = `${category}-${Date.now()}`; // Create empty monthly values for next 12 months const monthlyValues = {}; const today = new Date(); for (let i = 0; i < 12; i++) { const date = new Date(today.getFullYear(), today.getMonth() + i, 1); const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; monthlyValues[monthKey] = 0; } const newItem = { id, name, mainCategory: selectedCategory || (category === 'revenue' ? 'Games & Products' : 'Other Expenses'), subcategory: '', // Will be set by user via dropdown source: 'user', monthlyValues, values: { year1: { best: 0, worst: 0, mostLikely: 0 }, year2: { best: 0, worst: 0, mostLikely: 0 }, year3: { best: 0, worst: 0, mostLikely: 0 } } }; budgetWorksheet.value[category].push(newItem); return id; } function removeBudgetItem(category, itemId) { const items = budgetWorksheet.value[category]; const index = items.findIndex(i => i.id === itemId); if (index > -1) { items.splice(index, 1); } } function renameBudgetItem(category, itemId, newName) { const items = budgetWorksheet.value[category]; const item = items.find(i => i.id === itemId); if (item) { item.name = newName; } } function updateBudgetCategory(category, itemId, newCategory) { const items = budgetWorksheet.value[category]; const item = items.find(i => i.id === itemId); if (item) { item.category = newCategory; } } function addCustomCategory(type, categoryName) { if (type === 'revenue' && !revenueCategories.value.includes(categoryName)) { revenueCategories.value.push(categoryName); } else if (type === 'expenses' && !expenseCategories.value.includes(categoryName)) { expenseCategories.value.push(categoryName); } } // Reset function function resetBudgetOverhead() { overheadCosts.value = []; productionCosts.value = []; } function resetBudgetWorksheet() { // Reset all values to 0 but keep the structure [...budgetWorksheet.value.revenue, ...budgetWorksheet.value.expenses].forEach(item => { Object.keys(item.values).forEach(year => { Object.keys(item.values[year]).forEach(scenario => { item.values[year][scenario] = 0; }); }); }); } return { // NEW: Budget worksheet budgetWorksheet, budgetTotals, monthlyTotals, revenueCategories, expenseCategories, revenueSubcategories, groupedRevenue, groupedExpenses, isInitialized, initializeFromWizardData, updateBudgetValue, updateMonthlyValue, addBudgetItem, removeBudgetItem, renameBudgetItem, updateBudgetCategory, addCustomCategory, resetBudgetWorksheet, // LEGACY: Keep for backward compatibility budgetLines, overheadCosts, productionCosts, currentPeriod: readonly(currentPeriod), currentBudget, schemaVersion, setBudgetLine, updateRevenue, // Wizard actions addOverheadLine, removeOverheadLine, resetBudgetOverhead, // Legacy actions addOverheadCost, addProductionCost, setCurrentPeriod, }; }, { persist: { key: "urgent-tools-budget", paths: ["budgetWorksheet", "revenueCategories", "expenseCategories", "isInitialized", "overheadCosts", "productionCosts", "currentPeriod"], }, } );