refactor: remove CashFlowChart and UnifiedCashFlowDashboard components, update routing paths in app.vue, and enhance budget page with cumulative balance calculations and payroll explanation modal for improved user experience

This commit is contained in:
Jennie Robinson Faber 2025-09-10 07:42:56 +01:00
parent 864a81065c
commit f1889b3a70
17 changed files with 922 additions and 1004 deletions

View file

@ -206,6 +206,28 @@ export const useBudgetStore = defineStore(
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();
@ -242,6 +264,9 @@ export const useBudgetStore = defineStore(
budgetLines.value[period].revenueByStream[streamId] = {};
}
budgetLines.value[period].revenueByStream[streamId][type] = amount;
// Refresh payroll to account for revenue changes
refreshPayrollInBudget();
}
// Wizard-required actions
@ -316,7 +341,11 @@ export const useBudgetStore = defineStore(
// Refresh payroll in budget when policy or operating mode changes
function refreshPayrollInBudget() {
if (!isInitialized.value) return;
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");
@ -330,89 +359,163 @@ export const useBudgetStore = defineStore(
const totalHours = coopStore.members.reduce((sum, m) => sum + (m.hoursPerMonth || 0), 0);
const hourlyWage = coopStore.equalHourlyWage || 0;
const oncostPct = coopStore.payrollOncostPct || 0;
const basePayrollBudget = totalHours * hourlyWage;
// Declare today once for the entire function
const today = new Date();
// Keep theoretical maximum for reference
const theoreticalMaxPayroll = totalHours * hourlyWage;
if (basePayrollBudget > 0 && coopStore.members.length > 0) {
// Use policy-driven allocation
const payPolicy = {
relationship: coopStore.policy.relationship,
roleBands: coopStore.policy.roleBands
};
// 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")}`;
const membersForAllocation = coopStore.members.map(m => ({
...m,
displayName: m.name,
monthlyPayPlanned: m.monthlyPayPlanned || 0,
minMonthlyNeeds: m.minMonthlyNeeds || 0,
hoursPerMonth: m.hoursPerMonth || 0
}));
const allocatedMembers = allocatePayroll(membersForAllocation, payPolicy, basePayrollBudget);
// Sum the allocated payroll amounts
const totalAllocatedPayroll = allocatedMembers.reduce((sum, m) => {
return sum + (m.monthlyPayPlanned || 0);
// Get revenue for this specific month
const monthRevenue = budgetWorksheet.value.revenue.reduce((sum, item) => {
return sum + (item.monthlyValues?.[monthKey] || 0);
}, 0);
// Update monthly values for base payroll
// 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);
if (basePayrollIndex !== -1) {
// Update base payroll entry
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")}`;
budgetWorksheet.value.expenses[basePayrollIndex].monthlyValues[monthKey] = totalAllocatedPayroll;
// 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 = 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 = monthlyAllocatedPayroll * (oncostPct / 100);
}
// Update annual values for base payroll
budgetWorksheet.value.expenses[basePayrollIndex].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 }
};
}
if (oncostIndex !== -1) {
// Update oncost entry
const oncostAmount = totalAllocatedPayroll * (oncostPct / 100);
// Update running balance with actual net income after payroll adjustments
const actualNetIncome = monthRevenue - (nonPayrollExpenses + monthlyAllocatedPayroll + monthlyOncostAmount);
runningBalance += actualNetIncome;
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")}`;
budgetWorksheet.value.expenses[oncostIndex].monthlyValues[monthKey] = oncostAmount;
// Update this specific month's payroll values
if (basePayrollIndex !== -1) {
budgetWorksheet.value.expenses[basePayrollIndex].monthlyValues[monthKey] = monthlyAllocatedPayroll;
}
// Update name with current percentage
budgetWorksheet.value.expenses[oncostIndex].name = `Payroll Taxes & Benefits (${oncostPct}%)`;
// Update annual values for oncosts
budgetWorksheet.value.expenses[oncostIndex].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 }
};
}
// Handle legacy single payroll entry (update to combined amount for backwards compatibility)
if (legacyIndex !== -1 && basePayrollIndex === -1) {
const monthlyPayroll = totalAllocatedPayroll * (1 + oncostPct / 100);
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")}`;
budgetWorksheet.value.expenses[legacyIndex].monthlyValues[monthKey] = monthlyPayroll;
if (oncostIndex !== -1) {
budgetWorksheet.value.expenses[oncostIndex].monthlyValues[monthKey] = monthlyOncostAmount;
}
budgetWorksheet.value.expenses[legacyIndex].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 }
};
// Handle legacy single payroll entry
if (legacyIndex !== -1 && basePayrollIndex === -1) {
const combinedPayroll = monthlyAllocatedPayroll * (1 + oncostPct / 100);
budgetWorksheet.value.expenses[legacyIndex].monthlyValues[monthKey] = 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: totalAnnualPayroll * 1.2, worst: totalAnnualPayroll * 0.8, mostLikely: totalAnnualPayroll },
year2: { best: totalAnnualPayroll * 1.3, worst: totalAnnualPayroll * 0.9, mostLikely: totalAnnualPayroll * 1.1 },
year3: { best: totalAnnualPayroll * 1.5, worst: totalAnnualPayroll, mostLikely: 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: totalAnnualOncosts * 1.2, worst: totalAnnualOncosts * 0.8, mostLikely: totalAnnualOncosts },
year2: { best: totalAnnualOncosts * 1.3, worst: totalAnnualOncosts * 0.9, mostLikely: totalAnnualOncosts * 1.1 },
year3: { best: totalAnnualOncosts * 1.5, worst: totalAnnualOncosts, mostLikely: 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: totalCombined * 1.2, worst: totalCombined * 0.8, mostLikely: totalCombined },
year2: { best: totalCombined * 1.3, worst: totalCombined * 0.9, mostLikely: totalCombined * 1.1 },
year3: { best: totalCombined * 1.5, worst: totalCombined, mostLikely: totalCombined * 1.25 }
};
}
}
// Force reinitialize - always reload from wizard data
@ -456,8 +559,8 @@ export const useBudgetStore = defineStore(
budgetWorksheet.value.revenue = [];
budgetWorksheet.value.expenses = [];
// Declare today once for the entire function
const today = new Date();
// 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
@ -486,7 +589,7 @@ export const useBudgetStore = defineStore(
// 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(today.getFullYear(), today.getMonth() + i, 1);
const date = new Date(initDate.getFullYear(), initDate.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(
date.getMonth() + 1
).padStart(2, "0")}`;
@ -530,17 +633,28 @@ export const useBudgetStore = defineStore(
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("Operating mode:", coopStore.operatingMode);
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);
// Calculate total payroll budget using policy allocation
const basePayrollBudget = totalHours * hourlyWage;
console.log("Base payroll budget:", basePayrollBudget);
// 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
@ -579,7 +693,7 @@ export const useBudgetStore = defineStore(
// Create monthly values for payroll
const monthlyValues: Record<string, number> = {};
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const date = new Date(initDate.getFullYear(), initDate.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(
date.getMonth() + 1
).padStart(2, "0")}`;
@ -591,9 +705,9 @@ export const useBudgetStore = defineStore(
// Create base payroll monthly values (without oncosts)
const baseMonthlyValues: Record<string, number> = {};
const oncostMonthlyValues: Record<string, number> = {};
// Reuse the today variable from above
// Reuse the initDate variable from above
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const date = new Date(initDate.getFullYear(), initDate.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(
date.getMonth() + 1
).padStart(2, "0")}`;
@ -677,7 +791,7 @@ export const useBudgetStore = defineStore(
// Create monthly values for overhead costs
const monthlyValues: Record<string, number> = {};
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const date = new Date(initDate.getFullYear(), initDate.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(
date.getMonth() + 1
).padStart(2, "0")}`;
@ -809,7 +923,7 @@ export const useBudgetStore = defineStore(
console.log("Migrating item to monthly values:", item.name);
item.monthlyValues = {};
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const date = new Date(initDate.getFullYear(), initDate.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(
date.getMonth() + 1
).padStart(2, "0")}`;
@ -834,6 +948,10 @@ export const useBudgetStore = defineStore(
);
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);
@ -868,6 +986,14 @@ export const useBudgetStore = defineStore(
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})) });
}
@ -878,9 +1004,9 @@ export const useBudgetStore = defineStore(
// Create empty monthly values for next 12 months
const monthlyValues = {};
const today = new Date();
const addDate = new Date();
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const date = new Date(addDate.getFullYear(), addDate.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(
date.getMonth() + 1
).padStart(2, "0")}`;
@ -969,6 +1095,7 @@ export const useBudgetStore = defineStore(
budgetWorksheet,
budgetTotals,
monthlyTotals,
cumulativeBalances,
revenueCategories,
expenseCategories,
revenueSubcategories,