app/composables/useCoopBuilder.ts
2025-09-04 10:42:03 +01:00

389 lines
No EOL
12 KiB
TypeScript

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 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 'quit-jobs':
return {
members: baseMembers.map(m => ({ ...m, externalMonthlyIncome: 0 })),
streams: baseStreams
}
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): { minPct: number; targetPct: number } {
const totalIncome = (member.monthlyPayPlanned || 0) + (member.externalMonthlyIncome || 0)
const minPct = member.minMonthlyNeeds > 0
? Math.min(200, (totalIncome / member.minMonthlyNeeds) * 100)
: 100
const targetPct = member.targetMonthlyPay > 0
? Math.min(200, (totalIncome / member.targetMonthlyPay) * 100)
: 100
return { minPct, targetPct }
}
// Team coverage statistics
function teamCoverageStats() {
try {
const allocatedMembers = allocatePayroll() || []
const coverages = allocatedMembers.map(m => coverage(m).minPct).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<typeof stress.value>) {
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,
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<string, number>) => store.setRoleBands(bands),
setEqualWage: (wage: number) => store.setEqualWage(wage),
// Testing helpers
clearAll,
loadDefaults,
reset
}
}