import { allocatePayroll as allocatePayrollImpl, monthlyPayroll, type Member, type PayPolicy } from '~/types/members' import { useCoopBuilderStore } from '~/stores/coopBuilder' export function useCoopBuilder() { // Use the centralized Pinia store const store = useCoopBuilderStore() // Initialize store (but don't auto-load demo data) onMounted(() => { // Give the persistence plugin time to hydrate nextTick(() => { // Just ensure store is initialized but don't load demo data // store.initializeDefaults() is disabled to prevent auto demo data }) }) // Core computed values with error handling const members = computed(() => { try { return store.members || [] } catch (e) { console.warn('Error accessing members:', e) return [] } }) const streams = computed(() => { try { return store.streams || [] } catch (e) { console.warn('Error accessing streams:', e) return [] } }) const policy = computed(() => { try { return store.policy || { relationship: 'equal-pay', roleBands: {} } } catch (e) { console.warn('Error accessing policy:', e) return { relationship: 'equal-pay', roleBands: {} } } }) const operatingMode = computed({ get: () => { try { return store.operatingMode || 'min' } catch (e) { console.warn('Error accessing operating mode:', e) return 'min' as 'min' | 'target' } }, set: (value: 'min' | 'target') => { try { store.setOperatingMode(value) } catch (e) { console.warn('Error setting operating mode:', e) } } }) const currency = computed({ get: () => { try { return store.currency || 'EUR' } catch (e) { console.warn('Error accessing currency:', e) return 'EUR' } }, set: (value: string) => { try { store.setCurrency(value) } catch (e) { console.warn('Error setting currency:', e) } } }) const scenario = computed({ get: () => store.scenario, set: (value) => store.setScenario(value) }) const stress = computed({ get: () => store.stress, set: (value) => store.updateStress(value) }) const milestones = computed(() => store.milestones) // Helper: Get scenario-transformed data function getScenarioData() { const baseMembers = [...members.value] const baseStreams = [...streams.value] switch (scenario.value) { case 'start-production': return { members: baseMembers, streams: baseStreams.map(s => { // Reduce service revenue by 30% if (s.category?.toLowerCase().includes('service') || s.label.toLowerCase().includes('service')) { return { ...s, monthly: (s.monthly || 0) * 0.7 } } return s }) } default: return { members: baseMembers, streams: baseStreams } } } // Helper: Apply stress test to scenario data function getStressedData(baseData?: { members: Member[]; streams: any[] }) { const data = baseData || getScenarioData() const { revenueDelay, costShockPct, grantLost } = stress.value if (revenueDelay === 0 && costShockPct === 0 && !grantLost) { return data } let adjustedStreams = [...data.streams] // Apply revenue delay (reduce revenue by delay percentage) if (revenueDelay > 0) { adjustedStreams = adjustedStreams.map(s => ({ ...s, monthly: (s.monthly || 0) * Math.max(0, 1 - (revenueDelay / 12)) })) } // Grant lost - remove largest grant if (grantLost) { const grantStreams = adjustedStreams.filter(s => s.category?.toLowerCase().includes('grant') || s.label.toLowerCase().includes('grant') ) if (grantStreams.length > 0) { const largestGrant = grantStreams.reduce((prev, current) => (prev.monthly || 0) > (current.monthly || 0) ? prev : current ) adjustedStreams = adjustedStreams.map(s => s.id === largestGrant.id ? { ...s, monthly: 0 } : s ) } } return { members: data.members, streams: adjustedStreams } } // Payroll allocation function allocatePayroll() { const { members: scenarioMembers } = getScenarioData() const payPolicy = policy.value const totalRevenue = streams.value.reduce((sum, s) => sum + (s.monthly || 0), 0) const overheadCosts = store.overheadCosts.reduce((sum, c) => sum + (c.amount || 0), 0) const availableForPayroll = Math.max(0, totalRevenue - overheadCosts) return allocatePayrollImpl(scenarioMembers, payPolicy as PayPolicy, availableForPayroll) } // Coverage calculation for a single member function coverage(member: Member): { coveragePct: number } { const coopPay = member.monthlyPayPlanned || 0 const coveragePct = member.minMonthlyNeeds > 0 ? Math.min(200, (coopPay / member.minMonthlyNeeds) * 100) : 100 return { coveragePct } } // Team coverage statistics function teamCoverageStats() { try { const allocatedMembers = allocatePayroll() || [] const coverages = allocatedMembers.map(m => coverage(m).coveragePct).filter(c => !isNaN(c)) if (coverages.length === 0) { return { median: 0, under100: 0, over100Pct: 0, gini: 0 } } const sorted = [...coverages].sort((a, b) => a - b) const median = sorted[Math.floor(sorted.length / 2)] || 0 const under100 = coverages.filter(c => c < 100).length const over100Pct = coverages.length > 0 ? Math.round(((coverages.length - under100) / coverages.length) * 100) : 0 // Simple Gini coefficient approximation const mean = coverages.reduce((sum, c) => sum + c, 0) / coverages.length const gini = coverages.length > 1 && mean > 0 ? coverages.reduce((sum, c) => sum + Math.abs(c - mean), 0) / (2 * coverages.length * mean) : 0 return { median, under100, over100Pct, gini } } catch (e) { console.warn('Error calculating team coverage stats:', e) return { median: 0, under100: 0, over100Pct: 0, gini: 0 } } } // Revenue mix function revenueMix() { try { const { streams: scenarioStreams } = getStressedData() || { streams: [] } const validStreams = scenarioStreams.filter(s => s && typeof s === 'object') const total = validStreams.reduce((sum, s) => sum + (s.monthly || 0), 0) return validStreams .filter(s => (s.monthly || 0) > 0) .map(s => ({ label: s.label || 'Unnamed Stream', monthly: s.monthly || 0, pct: total > 0 ? (s.monthly || 0) / total : 0 })) .sort((a, b) => b.monthly - a.monthly) } catch (e) { console.warn('Error calculating revenue mix:', e) return [] } } // Concentration percentage (highest stream) function concentrationPct(): number { try { const mix = revenueMix() return mix.length > 0 ? (mix[0].pct || 0) : 0 } catch (e) { console.warn('Error calculating concentration:', e) return 0 } } // Runway calculation function runwayMonths(mode?: 'min' | 'target', opts?: { useStress?: boolean }): number { try { const inputMode = mode || operatingMode.value // Map to internal store format for compatibility const currentMode = inputMode === 'min' ? 'minimum' : inputMode === 'target' ? 'target' : 'minimum' const { members: scenarioMembers, streams: scenarioStreams } = opts?.useStress ? getStressedData() : getScenarioData() // Calculate monthly payroll const payrollCost = monthlyPayroll(scenarioMembers || [], currentMode) || 0 const oncostPct = store.payrollOncostPct || 0 const totalPayroll = payrollCost * (1 + Math.max(0, oncostPct) / 100) // Calculate revenue and costs const totalRevenue = (scenarioStreams || []).reduce((sum, s) => sum + (s.monthly || 0), 0) const overheadCost = (store.overheadCosts || []).reduce((sum, cost) => sum + (cost.amount || 0), 0) // Apply cost shock if in stress mode const adjustedOverhead = opts?.useStress && stress.value.costShockPct > 0 ? overheadCost * (1 + Math.max(0, stress.value.costShockPct) / 100) : overheadCost // Monthly net and burn const monthlyNet = totalRevenue - totalPayroll - adjustedOverhead const monthlyBurn = totalPayroll + adjustedOverhead // Cash reserves with safe defaults const cash = Math.max(0, store.currentCash || 50000) const savings = Math.max(0, store.currentSavings || 15000) const totalLiquid = cash + savings // Runway calculation if (monthlyNet >= 0) { return Infinity // Sustainable } return monthlyBurn > 0 ? totalLiquid / monthlyBurn : 0 } catch (e) { console.warn('Error calculating runway:', e) return 0 } } // Milestone status function milestoneStatus(mode?: 'min' | 'target') { const currentMode = mode || operatingMode.value const runway = runwayMonths(currentMode) const runwayEndDate = new Date() runwayEndDate.setMonth(runwayEndDate.getMonth() + Math.floor(runway)) return milestones.value.map(milestone => ({ ...milestone, willReach: new Date(milestone.date) <= runwayEndDate })) } // Actions function setOperatingMode(mode: 'min' | 'target') { store.setOperatingMode(mode) } function setScenario(newScenario: 'current' | 'quit-jobs' | 'start-production' | 'custom') { store.setScenario(newScenario) } function updateStress(newStress: Partial) { store.updateStress(newStress) } function addMilestone(label: string, date: string) { store.addMilestone(label, date) } function removeMilestone(id: string) { store.removeMilestone(id) } // Testing helpers function clearAll() { store.clearAll() } function loadDefaults() { store.loadDefaultData() } // Reset helper function function reset() { store.clearAll() store.loadDefaultData() } // Watch for policy and operating mode changes to refresh budget payroll // This needs to be after computed values are defined if (typeof window !== 'undefined') { const budgetStore = useBudgetStore() // Watch for policy changes watch(() => [policy.value.relationship, policy.value.roleBands, operatingMode.value, store.equalHourlyWage, store.payrollOncostPct], () => { if (budgetStore.isInitialized) { nextTick(() => { budgetStore.refreshPayrollInBudget() }) } }, { deep: true }) // Watch for member changes watch(() => store.members, () => { if (budgetStore.isInitialized) { nextTick(() => { budgetStore.refreshPayrollInBudget() }) } }, { deep: true }) } return { // State members, streams, policy, operatingMode, currency, scenario, stress, milestones, // Computed allocatePayroll, coverage, teamCoverageStats, revenueMix, concentrationPct, runwayMonths, milestoneStatus, // Actions setOperatingMode, setScenario, updateStress, addMilestone, removeMilestone, upsertMember: (member: any) => store.upsertMember(member), removeMember: (id: string) => store.removeMember(id), upsertStream: (stream: any) => store.upsertStream(stream), removeStream: (id: string) => store.removeStream(id), setPolicy: (relationship: "equal-pay" | "needs-weighted" | "hours-weighted" | "role-banded") => store.setPolicy(relationship), setRoleBands: (bands: Record) => store.setRoleBands(bands), setEqualWage: (wage: number) => store.setEqualWage(wage), setCurrency: (currency: string) => store.setCurrency(currency), // Testing helpers clearAll, loadDefaults, reset } }