389 lines
No EOL
12 KiB
TypeScript
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
|
|
}
|
|
} |