refactor: enhance routing and state management in CoopBuilder, add migration checks on startup, and update Tailwind configuration for improved component styling
This commit is contained in:
parent
848386e3dd
commit
4cea1f71fe
55 changed files with 4053 additions and 1486 deletions
364
composables/useCoopBuilder.ts
Normal file
364
composables/useCoopBuilder.ts
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
import { allocatePayroll as allocatePayrollImpl, monthlyPayroll, type Member, type PayPolicy } from '~/types/members'
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue