1149 lines
43 KiB
TypeScript
1149 lines
43 KiB
TypeScript
import { defineStore } from "pinia";
|
|
import { allocatePayroll } from "~/types/members";
|
|
|
|
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",
|
|
]);
|
|
|
|
// Define budget item type
|
|
interface BudgetItem {
|
|
id: string;
|
|
name: string;
|
|
mainCategory: string;
|
|
subcategory: string;
|
|
source: string;
|
|
monthlyValues: Record<string, number>;
|
|
values: {
|
|
year1: { best: number; worst: number; mostLikely: number };
|
|
year2: { best: number; worst: number; mostLikely: number };
|
|
year3: { best: number; worst: number; mostLikely: number };
|
|
};
|
|
}
|
|
|
|
// NEW: Budget worksheet structure (starts empty, populated from wizard data)
|
|
const budgetWorksheet = ref<{
|
|
revenue: BudgetItem[];
|
|
expenses: BudgetItem[];
|
|
}>({
|
|
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: Record<string, any[]> = {};
|
|
revenueCategories.value.forEach((category) => {
|
|
groups[category] = budgetWorksheet.value.revenue.filter(
|
|
(item) => item.mainCategory === category
|
|
);
|
|
});
|
|
return groups;
|
|
});
|
|
|
|
const groupedExpenses = computed(() => {
|
|
const groups: Record<string, any[]> = {};
|
|
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: Record<
|
|
string,
|
|
{ revenue: number; expenses: number; net: number }
|
|
> = {};
|
|
|
|
// 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;
|
|
});
|
|
|
|
// Cumulative balance computation (running cash balance)
|
|
const cumulativeBalances = computed(() => {
|
|
const balances: Record<string, number> = {};
|
|
let runningBalance = 0; // Assuming starting balance of 0 - could be configurable
|
|
|
|
// Generate month keys for next 12 months in order
|
|
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")}`;
|
|
|
|
// Add this month's net income to running balance
|
|
const monthlyNet = monthlyTotals.value[monthKey]?.net || 0;
|
|
runningBalance += monthlyNet;
|
|
balances[monthKey] = runningBalance;
|
|
}
|
|
|
|
return balances;
|
|
});
|
|
|
|
// 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;
|
|
|
|
// Refresh payroll to account for revenue changes
|
|
refreshPayrollInBudget();
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
// Helper function to map stream category to budget category
|
|
function mapStreamToBudgetCategory(streamCategory) {
|
|
const categoryLower = (streamCategory || "").toLowerCase();
|
|
|
|
// More comprehensive category mapping
|
|
if (categoryLower.includes("game") || categoryLower.includes("product")) {
|
|
return "Games & Products";
|
|
} else if (categoryLower.includes("service") || categoryLower.includes("consulting")) {
|
|
return "Services & Contracts";
|
|
} else if (categoryLower.includes("grant") || categoryLower.includes("funding")) {
|
|
return "Grants & Funding";
|
|
} else if (categoryLower.includes("community") || categoryLower.includes("donation")) {
|
|
return "Community Support";
|
|
} else if (categoryLower.includes("partnership")) {
|
|
return "Partnerships";
|
|
} else if (categoryLower.includes("investment")) {
|
|
return "Investment Income";
|
|
} else if (categoryLower.includes("other")) {
|
|
return "Services & Contracts"; // Map "Other" to services as fallback
|
|
} else {
|
|
return "Games & Products"; // Default fallback
|
|
}
|
|
}
|
|
|
|
// Refresh payroll in budget when policy or operating mode changes
|
|
function refreshPayrollInBudget() {
|
|
console.log("=== REFRESH PAYROLL CALLED ===");
|
|
if (!isInitialized.value) {
|
|
console.log("Not initialized, skipping payroll refresh");
|
|
return;
|
|
}
|
|
|
|
const coopStore = useCoopBuilderStore();
|
|
const basePayrollIndex = budgetWorksheet.value.expenses.findIndex(item => item.id === "expense-payroll-base");
|
|
const oncostIndex = budgetWorksheet.value.expenses.findIndex(item => item.id === "expense-payroll-oncosts");
|
|
|
|
// If no split payroll entries exist, look for legacy single entry
|
|
const legacyIndex = budgetWorksheet.value.expenses.findIndex(item => item.id === "expense-payroll");
|
|
|
|
if (basePayrollIndex === -1 && legacyIndex === -1) return; // No existing payroll entries
|
|
|
|
const totalHours = coopStore.members.reduce((sum, m) => sum + (m.hoursPerMonth || 0), 0);
|
|
const hourlyWage = coopStore.equalHourlyWage || 0;
|
|
const oncostPct = coopStore.payrollOncostPct || 0;
|
|
|
|
// Keep theoretical maximum for reference
|
|
const theoreticalMaxPayroll = totalHours * hourlyWage;
|
|
|
|
// Policy for allocation
|
|
const payPolicy = {
|
|
relationship: coopStore.policy.relationship,
|
|
roleBands: coopStore.policy.roleBands
|
|
};
|
|
|
|
const membersForAllocation = coopStore.members.map(m => ({
|
|
...m,
|
|
displayName: m.name,
|
|
monthlyPayPlanned: m.monthlyPayPlanned || 0,
|
|
minMonthlyNeeds: m.minMonthlyNeeds || 0,
|
|
hoursPerMonth: m.hoursPerMonth || 0
|
|
}));
|
|
|
|
// Calculate payroll for each month individually using cumulative balance approach
|
|
const refreshToday = new Date();
|
|
let totalAnnualPayroll = 0;
|
|
let totalAnnualOncosts = 0;
|
|
let runningBalance = 0; // Track cumulative cash balance
|
|
|
|
for (let i = 0; i < 12; i++) {
|
|
const date = new Date(refreshToday.getFullYear(), refreshToday.getMonth() + i, 1);
|
|
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
|
|
|
// Get revenue for this specific month
|
|
const monthRevenue = budgetWorksheet.value.revenue.reduce((sum, item) => {
|
|
return sum + (item.monthlyValues?.[monthKey] || 0);
|
|
}, 0);
|
|
|
|
// Get non-payroll expenses for this specific month
|
|
const nonPayrollExpenses = budgetWorksheet.value.expenses.reduce((sum, item) => {
|
|
// Exclude payroll items from the calculation
|
|
if (item.id === "expense-payroll-base" || item.id === "expense-payroll-oncosts" || item.id === "expense-payroll") {
|
|
return sum;
|
|
}
|
|
return sum + (item.monthlyValues?.[monthKey] || 0);
|
|
}, 0);
|
|
|
|
// Calculate normal payroll based on policy and wage (theoretical maximum)
|
|
const theoreticalPayrollBudget = totalHours * hourlyWage;
|
|
|
|
console.log(`Month ${monthKey}: Revenue=${monthRevenue}, NonPayrollExp=${nonPayrollExpenses}, TheoreticalPayroll=${theoreticalPayrollBudget}, RunningBalance=${runningBalance}`);
|
|
|
|
// Only allocate payroll if members exist
|
|
if (coopStore.members.length > 0) {
|
|
// First, calculate payroll using normal policy allocation
|
|
const allocatedMembers = allocatePayroll(membersForAllocation, payPolicy, theoreticalPayrollBudget);
|
|
|
|
// Sum the allocated payroll amounts for this month
|
|
let monthlyAllocatedPayroll = allocatedMembers.reduce((sum, m) => {
|
|
return sum + (m.monthlyPayPlanned || 0);
|
|
}, 0);
|
|
|
|
let monthlyOncostAmount = Math.round(monthlyAllocatedPayroll * (oncostPct / 100));
|
|
|
|
// Calculate projected balance after this month's expenses and payroll
|
|
const totalExpensesWithPayroll = nonPayrollExpenses + monthlyAllocatedPayroll + monthlyOncostAmount;
|
|
const monthlyNetIncome = monthRevenue - totalExpensesWithPayroll;
|
|
const projectedBalance = runningBalance + monthlyNetIncome;
|
|
|
|
// Check if this payroll would push cumulative balance below minimum threshold
|
|
const minThreshold = coopStore.minCashThreshold || 0;
|
|
if (projectedBalance < minThreshold) {
|
|
// Calculate maximum sustainable payroll to maintain minimum cash threshold
|
|
const targetNetIncome = minThreshold - runningBalance;
|
|
const availableForExpenses = monthRevenue - targetNetIncome;
|
|
const maxSustainablePayroll = Math.max(0, (availableForExpenses - nonPayrollExpenses) / (1 + oncostPct / 100));
|
|
|
|
console.log(`Month ${monthKey}: Reducing payroll from ${monthlyAllocatedPayroll} to ${maxSustainablePayroll} to maintain minimum cash threshold of ${minThreshold}`);
|
|
|
|
// Proportionally reduce all member allocations
|
|
if (monthlyAllocatedPayroll > 0) {
|
|
const reductionRatio = maxSustainablePayroll / monthlyAllocatedPayroll;
|
|
allocatedMembers.forEach(m => {
|
|
m.monthlyPayPlanned = (m.monthlyPayPlanned || 0) * reductionRatio;
|
|
});
|
|
}
|
|
|
|
monthlyAllocatedPayroll = maxSustainablePayroll;
|
|
monthlyOncostAmount = Math.round(monthlyAllocatedPayroll * (oncostPct / 100));
|
|
}
|
|
|
|
// Update running balance with actual net income after payroll adjustments
|
|
const actualNetIncome = monthRevenue - (nonPayrollExpenses + monthlyAllocatedPayroll + monthlyOncostAmount);
|
|
runningBalance += actualNetIncome;
|
|
|
|
// Update this specific month's payroll values
|
|
if (basePayrollIndex !== -1) {
|
|
budgetWorksheet.value.expenses[basePayrollIndex].monthlyValues[monthKey] = Math.round(monthlyAllocatedPayroll);
|
|
}
|
|
|
|
if (oncostIndex !== -1) {
|
|
budgetWorksheet.value.expenses[oncostIndex].monthlyValues[monthKey] = Math.round(monthlyOncostAmount);
|
|
}
|
|
|
|
// Handle legacy single payroll entry
|
|
if (legacyIndex !== -1 && basePayrollIndex === -1) {
|
|
const combinedPayroll = Math.round(monthlyAllocatedPayroll * (1 + oncostPct / 100));
|
|
budgetWorksheet.value.expenses[legacyIndex].monthlyValues[monthKey] = Math.round(combinedPayroll);
|
|
}
|
|
|
|
// Accumulate for annual totals
|
|
totalAnnualPayroll += monthlyAllocatedPayroll;
|
|
totalAnnualOncosts += monthlyOncostAmount;
|
|
} else {
|
|
// No members or theoretical payroll is 0 - set payroll to 0
|
|
if (basePayrollIndex !== -1) {
|
|
budgetWorksheet.value.expenses[basePayrollIndex].monthlyValues[monthKey] = 0;
|
|
}
|
|
|
|
if (oncostIndex !== -1) {
|
|
budgetWorksheet.value.expenses[oncostIndex].monthlyValues[monthKey] = 0;
|
|
}
|
|
|
|
if (legacyIndex !== -1 && basePayrollIndex === -1) {
|
|
budgetWorksheet.value.expenses[legacyIndex].monthlyValues[monthKey] = 0;
|
|
}
|
|
|
|
// Update running balance with net income (revenue - non-payroll expenses)
|
|
const actualNetIncome = monthRevenue - nonPayrollExpenses;
|
|
runningBalance += actualNetIncome;
|
|
}
|
|
}
|
|
|
|
// Update annual values based on actual totals
|
|
if (basePayrollIndex !== -1) {
|
|
budgetWorksheet.value.expenses[basePayrollIndex].values = {
|
|
year1: { best: Math.round(totalAnnualPayroll * 1.2), worst: Math.round(totalAnnualPayroll * 0.8), mostLikely: Math.round(totalAnnualPayroll) },
|
|
year2: { best: Math.round(totalAnnualPayroll * 1.3), worst: Math.round(totalAnnualPayroll * 0.9), mostLikely: Math.round(totalAnnualPayroll * 1.1) },
|
|
year3: { best: Math.round(totalAnnualPayroll * 1.5), worst: Math.round(totalAnnualPayroll), mostLikely: Math.round(totalAnnualPayroll * 1.25) }
|
|
};
|
|
}
|
|
|
|
if (oncostIndex !== -1) {
|
|
// Update name with current percentage
|
|
budgetWorksheet.value.expenses[oncostIndex].name = `Payroll Taxes & Benefits (${oncostPct}%)`;
|
|
|
|
budgetWorksheet.value.expenses[oncostIndex].values = {
|
|
year1: { best: Math.round(totalAnnualOncosts * 1.2), worst: Math.round(totalAnnualOncosts * 0.8), mostLikely: Math.round(totalAnnualOncosts) },
|
|
year2: { best: Math.round(totalAnnualOncosts * 1.3), worst: Math.round(totalAnnualOncosts * 0.9), mostLikely: Math.round(totalAnnualOncosts * 1.1) },
|
|
year3: { best: Math.round(totalAnnualOncosts * 1.5), worst: Math.round(totalAnnualOncosts), mostLikely: Math.round(totalAnnualOncosts * 1.25) }
|
|
};
|
|
}
|
|
|
|
// Handle legacy single payroll entry annual values
|
|
if (legacyIndex !== -1 && basePayrollIndex === -1) {
|
|
const totalCombined = totalAnnualPayroll + totalAnnualOncosts;
|
|
budgetWorksheet.value.expenses[legacyIndex].values = {
|
|
year1: { best: Math.round(totalCombined * 1.2), worst: Math.round(totalCombined * 0.8), mostLikely: Math.round(totalCombined) },
|
|
year2: { best: Math.round(totalCombined * 1.3), worst: Math.round(totalCombined * 0.9), mostLikely: Math.round(totalCombined * 1.1) },
|
|
year3: { best: Math.round(totalCombined * 1.5), worst: Math.round(totalCombined), mostLikely: Math.round(totalCombined * 1.25) }
|
|
};
|
|
}
|
|
}
|
|
|
|
// Force reinitialize - always reload from wizard data
|
|
async function forceInitializeFromWizardData() {
|
|
console.log("=== FORCE BUDGET INITIALIZATION ===");
|
|
isInitialized.value = false;
|
|
budgetWorksheet.value.revenue = [];
|
|
budgetWorksheet.value.expenses = [];
|
|
await initializeFromWizardData();
|
|
}
|
|
|
|
// Initialize worksheet from wizard data
|
|
async function initializeFromWizardData() {
|
|
console.log("=== BUDGET INITIALIZATION DEBUG ===");
|
|
console.log("Is already initialized:", isInitialized.value);
|
|
console.log("Current revenue items:", budgetWorksheet.value.revenue.length);
|
|
console.log("Current expense items:", budgetWorksheet.value.expenses.length);
|
|
|
|
// Check if we have actual budget data - prioritize preserving user data
|
|
const hasUserData = budgetWorksheet.value.revenue.length > 0 || budgetWorksheet.value.expenses.length > 0;
|
|
|
|
if (hasUserData) {
|
|
console.log("Budget data already exists with user changes, preserving...");
|
|
isInitialized.value = true; // Mark as initialized to prevent future overwrites
|
|
return;
|
|
}
|
|
|
|
console.log("No existing budget data found, initializing from wizard data...");
|
|
|
|
try {
|
|
// Use the new coopBuilder store instead of the old stores
|
|
const coopStore = useCoopBuilderStore();
|
|
|
|
console.log("CoopStore Data:");
|
|
console.log("- Streams:", coopStore.streams.length, coopStore.streams);
|
|
console.log("- Members:", coopStore.members.length, coopStore.members);
|
|
console.log("- Equal wage:", coopStore.equalHourlyWage || "No wage set");
|
|
console.log("- Overhead costs:", coopStore.overheadCosts.length, coopStore.overheadCosts);
|
|
|
|
// Only clear data if we're truly initializing from scratch
|
|
budgetWorksheet.value.revenue = [];
|
|
budgetWorksheet.value.expenses = [];
|
|
|
|
// Declare date once for the entire function
|
|
const initDate = new Date();
|
|
|
|
// Add revenue streams from wizard (but don't auto-load fixtures)
|
|
// Note: We don't auto-load fixtures anymore, but wizard data should still work
|
|
|
|
coopStore.streams.forEach((stream) => {
|
|
const monthlyAmount = stream.monthly || 0;
|
|
console.log(
|
|
"Adding stream:",
|
|
stream.label,
|
|
"category:",
|
|
stream.category,
|
|
"amount:",
|
|
monthlyAmount
|
|
);
|
|
|
|
// Use the helper function for category mapping
|
|
const mappedCategory = mapStreamToBudgetCategory(stream.category);
|
|
|
|
console.log(
|
|
"Mapped category from",
|
|
stream.category,
|
|
"to",
|
|
mappedCategory
|
|
);
|
|
|
|
// Create monthly values - split the annual target evenly across 12 months
|
|
const monthlyValues: Record<string, number> = {};
|
|
for (let i = 0; i < 12; i++) {
|
|
const date = new Date(initDate.getFullYear(), initDate.getMonth() + i, 1);
|
|
const monthKey = `${date.getFullYear()}-${String(
|
|
date.getMonth() + 1
|
|
).padStart(2, "0")}`;
|
|
monthlyValues[monthKey] = monthlyAmount;
|
|
}
|
|
console.log(
|
|
"Created monthly values for",
|
|
stream.label,
|
|
":",
|
|
monthlyValues
|
|
);
|
|
|
|
budgetWorksheet.value.revenue.push({
|
|
id: `revenue-${stream.id}`,
|
|
name: stream.label,
|
|
mainCategory: mappedCategory,
|
|
subcategory: "Direct sales", // Default subcategory for coopStore streams
|
|
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 using the policy-driven allocation system
|
|
const totalHours = coopStore.members.reduce((sum, m) => sum + (m.hoursPerMonth || 0), 0);
|
|
const hourlyWage = coopStore.equalHourlyWage || 0;
|
|
const oncostPct = coopStore.payrollOncostPct || 0;
|
|
|
|
// Use revenue-constrained payroll budget (same logic as useCoopBuilder)
|
|
const totalRevenue = coopStore.streams.reduce((sum, s) => sum + (s.monthly || 0), 0);
|
|
const overheadCosts = coopStore.overheadCosts.reduce((sum, c) => sum + (c.amount || 0), 0);
|
|
const availableForPayroll = Math.max(0, totalRevenue - overheadCosts);
|
|
|
|
// Keep theoretical maximum for reference
|
|
const theoreticalMaxPayroll = totalHours * hourlyWage;
|
|
|
|
console.log("=== PAYROLL CALCULATION DEBUG ===");
|
|
console.log("Total hours:", totalHours);
|
|
console.log("Hourly wage:", hourlyWage);
|
|
console.log("Oncost %:", oncostPct);
|
|
console.log("Policy relationship:", coopStore.policy.relationship);
|
|
console.log("Total revenue:", totalRevenue);
|
|
console.log("Overhead costs:", overheadCosts);
|
|
console.log("Available for payroll:", availableForPayroll);
|
|
console.log("Theoretical max payroll:", theoreticalMaxPayroll);
|
|
|
|
// Use revenue-constrained budget
|
|
const basePayrollBudget = availableForPayroll;
|
|
console.log("Using payroll budget:", basePayrollBudget);
|
|
|
|
if (basePayrollBudget > 0 && coopStore.members.length > 0) {
|
|
// Use policy-driven allocation to get actual member pay amounts
|
|
const payPolicy = {
|
|
relationship: coopStore.policy.relationship,
|
|
roleBands: coopStore.policy.roleBands
|
|
};
|
|
|
|
// Convert coopStore members to the format expected by allocatePayroll
|
|
const membersForAllocation = coopStore.members.map(m => ({
|
|
...m,
|
|
displayName: m.name,
|
|
// Ensure all required fields exist
|
|
monthlyPayPlanned: m.monthlyPayPlanned || 0,
|
|
minMonthlyNeeds: m.minMonthlyNeeds || 0,
|
|
hoursPerMonth: m.hoursPerMonth || 0
|
|
}));
|
|
|
|
// Allocate payroll based on policy
|
|
const allocatedMembers = allocatePayroll(membersForAllocation, payPolicy, basePayrollBudget);
|
|
console.log("Allocated members:", allocatedMembers.map(m => ({ name: m.name, planned: m.monthlyPayPlanned, needs: m.minMonthlyNeeds })));
|
|
|
|
// Sum the allocated amounts for total payroll
|
|
const totalAllocatedPayroll = allocatedMembers.reduce((sum, m) => {
|
|
const planned = m.monthlyPayPlanned || 0;
|
|
console.log(`Member ${m.name}: planned ${planned}`);
|
|
return sum + planned;
|
|
}, 0);
|
|
|
|
console.log("Total allocated payroll:", totalAllocatedPayroll);
|
|
|
|
// Apply oncosts to the policy-allocated total
|
|
const monthlyPayroll = totalAllocatedPayroll * (1 + oncostPct / 100);
|
|
console.log("Monthly payroll with oncosts:", monthlyPayroll);
|
|
|
|
// Create monthly values for payroll
|
|
const monthlyValues: Record<string, number> = {};
|
|
for (let i = 0; i < 12; i++) {
|
|
const date = new Date(initDate.getFullYear(), initDate.getMonth() + i, 1);
|
|
const monthKey = `${date.getFullYear()}-${String(
|
|
date.getMonth() + 1
|
|
).padStart(2, "0")}`;
|
|
monthlyValues[monthKey] = monthlyPayroll;
|
|
}
|
|
|
|
console.log("Creating split payroll budget items with monthly values:", Object.keys(monthlyValues).length, "months");
|
|
|
|
// Create base payroll monthly values (without oncosts)
|
|
const baseMonthlyValues: Record<string, number> = {};
|
|
const oncostMonthlyValues: Record<string, number> = {};
|
|
// Reuse the initDate variable from above
|
|
for (let i = 0; i < 12; i++) {
|
|
const date = new Date(initDate.getFullYear(), initDate.getMonth() + i, 1);
|
|
const monthKey = `${date.getFullYear()}-${String(
|
|
date.getMonth() + 1
|
|
).padStart(2, "0")}`;
|
|
baseMonthlyValues[monthKey] = totalAllocatedPayroll;
|
|
oncostMonthlyValues[monthKey] = totalAllocatedPayroll * (oncostPct / 100);
|
|
}
|
|
|
|
// Add base payroll item
|
|
budgetWorksheet.value.expenses.push({
|
|
id: "expense-payroll-base",
|
|
name: "Payroll",
|
|
mainCategory: "Salaries & Benefits",
|
|
subcategory: "Base wages and benefits",
|
|
source: "wizard",
|
|
monthlyValues: baseMonthlyValues,
|
|
values: {
|
|
year1: {
|
|
best: totalAllocatedPayroll * 12,
|
|
worst: totalAllocatedPayroll * 8,
|
|
mostLikely: totalAllocatedPayroll * 12,
|
|
},
|
|
year2: {
|
|
best: totalAllocatedPayroll * 14,
|
|
worst: totalAllocatedPayroll * 10,
|
|
mostLikely: totalAllocatedPayroll * 13,
|
|
},
|
|
year3: {
|
|
best: totalAllocatedPayroll * 16,
|
|
worst: totalAllocatedPayroll * 12,
|
|
mostLikely: totalAllocatedPayroll * 15,
|
|
},
|
|
},
|
|
});
|
|
|
|
// Add payroll oncosts item (if oncost percentage > 0)
|
|
if (oncostPct > 0) {
|
|
const oncostAmount = totalAllocatedPayroll * (oncostPct / 100);
|
|
budgetWorksheet.value.expenses.push({
|
|
id: "expense-payroll-oncosts",
|
|
name: `Payroll Taxes & Benefits (${oncostPct}%)`,
|
|
mainCategory: "Salaries & Benefits",
|
|
subcategory: "Payroll taxes and benefits",
|
|
source: "wizard",
|
|
monthlyValues: oncostMonthlyValues,
|
|
values: {
|
|
year1: {
|
|
best: oncostAmount * 12,
|
|
worst: oncostAmount * 8,
|
|
mostLikely: oncostAmount * 12,
|
|
},
|
|
year2: {
|
|
best: oncostAmount * 14,
|
|
worst: oncostAmount * 10,
|
|
mostLikely: oncostAmount * 13,
|
|
},
|
|
year3: {
|
|
best: oncostAmount * 16,
|
|
worst: oncostAmount * 12,
|
|
mostLikely: oncostAmount * 15,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
// Add overhead costs from wizard
|
|
coopStore.overheadCosts.forEach((cost) => {
|
|
if (cost.amount && 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 === "Tools")
|
|
expenseCategory = "Equipment & Technology";
|
|
else if (cost.category === "Professional")
|
|
expenseCategory = "Legal & Professional";
|
|
else if (cost.category === "Marketing")
|
|
expenseCategory = "Marketing & Outreach";
|
|
|
|
// Create monthly values for overhead costs
|
|
const monthlyValues: Record<string, number> = {};
|
|
for (let i = 0; i < 12; i++) {
|
|
const date = new Date(initDate.getFullYear(), initDate.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: Math.round(annualAmount),
|
|
worst: Math.round(annualAmount * 0.8),
|
|
mostLikely: Math.round(annualAmount),
|
|
},
|
|
year2: {
|
|
best: Math.round(annualAmount * 1.1),
|
|
worst: Math.round(annualAmount * 0.9),
|
|
mostLikely: Math.round(annualAmount * 1.05),
|
|
},
|
|
year3: {
|
|
best: Math.round(annualAmount * 1.2),
|
|
worst: Math.round(annualAmount),
|
|
mostLikely: Math.round(annualAmount * 1.1),
|
|
},
|
|
},
|
|
});
|
|
}
|
|
});
|
|
|
|
// Production costs are handled within overhead costs in the new architecture
|
|
|
|
// DISABLED: No sample data - budget should start empty
|
|
// if (budgetWorksheet.value.revenue.length === 0) {
|
|
// console.log("Adding sample revenue line");
|
|
// // ... sample revenue creation code removed
|
|
// }
|
|
|
|
// DISABLED: No sample data - expenses should start empty
|
|
// if (budgetWorksheet.value.expenses.length === 0) {
|
|
// console.log("Adding sample expense line");
|
|
// // ... sample expense creation code removed
|
|
// }
|
|
|
|
// 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 = {};
|
|
for (let i = 0; i < 12; i++) {
|
|
const date = new Date(initDate.getFullYear(), initDate.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;
|
|
|
|
// Trigger payroll refresh after initialization
|
|
console.log("Triggering initial payroll refresh after initialization");
|
|
refreshPayrollInBudget();
|
|
} catch (error) {
|
|
console.error("Error initializing budget from wizard data:", error);
|
|
|
|
// DISABLED: No fallback sample data - budget should remain empty on errors
|
|
// Budget initialization complete (without automatic fallback data)
|
|
|
|
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) {
|
|
console.log('updateMonthlyValue called:', { category, itemId, monthKey, value });
|
|
const items = budgetWorksheet.value[category];
|
|
const item = items.find((i) => i.id === itemId);
|
|
if (item) {
|
|
if (!item.monthlyValues) {
|
|
item.monthlyValues = {};
|
|
}
|
|
const numericValue = Number(value) || 0;
|
|
|
|
// Update directly - Pinia's reactivity will handle persistence
|
|
item.monthlyValues[monthKey] = numericValue;
|
|
|
|
console.log('Updated item.monthlyValues:', item.monthlyValues);
|
|
console.log('Item updated:', item.name);
|
|
|
|
// Refresh payroll when any budget item changes (except payroll items themselves)
|
|
if (!itemId.includes('payroll')) {
|
|
console.log('Triggering payroll refresh for non-payroll item:', itemId);
|
|
refreshPayrollInBudget();
|
|
} else {
|
|
console.log('Skipping payroll refresh for payroll item:', itemId);
|
|
}
|
|
} else {
|
|
console.error('Item not found:', { category, itemId, availableItems: items.map(i => ({id: i.id, name: i.name})) });
|
|
}
|
|
}
|
|
|
|
function addBudgetItem(category, name, selectedCategory = "", subcategory = "") {
|
|
const id = `${category}-${Date.now()}`;
|
|
|
|
// Create empty monthly values for next 12 months
|
|
const monthlyValues = {};
|
|
const addDate = new Date();
|
|
for (let i = 0; i < 12; i++) {
|
|
const date = new Date(addDate.getFullYear(), addDate.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: subcategory || "",
|
|
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,
|
|
cumulativeBalances,
|
|
revenueCategories,
|
|
expenseCategories,
|
|
revenueSubcategories,
|
|
groupedRevenue,
|
|
groupedExpenses,
|
|
isInitialized,
|
|
initializeFromWizardData,
|
|
forceInitializeFromWizardData,
|
|
refreshPayrollInBudget,
|
|
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",
|
|
],
|
|
},
|
|
}
|
|
);
|