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
|
||||
}
|
||||
}
|
||||
92
composables/useCushionForecast.ts
Normal file
92
composables/useCushionForecast.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { monthlyPayroll } from '~/types/members'
|
||||
|
||||
export function useCushionForecast() {
|
||||
const cashStore = useCashStore()
|
||||
const membersStore = useMembersStore()
|
||||
const policiesStore = usePoliciesStore()
|
||||
const budgetStore = useBudgetStore()
|
||||
|
||||
// Savings progress calculation
|
||||
const savingsProgress = computed(() => {
|
||||
const current = cashStore.currentSavings || 0
|
||||
const targetMonths = policiesStore.savingsTargetMonths || 3
|
||||
const monthlyBurn = getMonthlyBurn()
|
||||
const target = targetMonths * monthlyBurn
|
||||
|
||||
return {
|
||||
current,
|
||||
target,
|
||||
targetMonths,
|
||||
progressPct: target > 0 ? Math.min(100, (current / target) * 100) : 0,
|
||||
gap: Math.max(0, target - current),
|
||||
status: getProgressStatus(current, target)
|
||||
}
|
||||
})
|
||||
|
||||
// 13-week cushion breach forecast
|
||||
const cushionForecast = computed(() => {
|
||||
const minCushion = policiesStore.minCashCushionAmount || 5000
|
||||
const currentBalance = cashStore.currentCash || 0
|
||||
const monthlyBurn = getMonthlyBurn()
|
||||
const weeklyBurn = monthlyBurn / 4.33 // Convert monthly to weekly
|
||||
|
||||
const weeks = []
|
||||
let runningBalance = currentBalance
|
||||
|
||||
for (let week = 1; week <= 13; week++) {
|
||||
// Simple projection: subtract weekly burn
|
||||
runningBalance -= weeklyBurn
|
||||
|
||||
const breachesCushion = runningBalance < minCushion
|
||||
|
||||
weeks.push({
|
||||
week,
|
||||
balance: runningBalance,
|
||||
breachesCushion,
|
||||
cushionAmount: minCushion
|
||||
})
|
||||
}
|
||||
|
||||
const firstBreachWeek = weeks.find(w => w.breachesCushion)?.week
|
||||
const breachesWithin13Weeks = Boolean(firstBreachWeek)
|
||||
|
||||
return {
|
||||
weeks,
|
||||
firstBreachWeek,
|
||||
breachesWithin13Weeks,
|
||||
minCushion,
|
||||
weeksUntilBreach: firstBreachWeek || null
|
||||
}
|
||||
})
|
||||
|
||||
function getMonthlyBurn() {
|
||||
const operatingMode = policiesStore.operatingMode || 'minimum'
|
||||
const payrollCost = monthlyPayroll(membersStore.members, operatingMode)
|
||||
const oncostPct = policiesStore.payrollOncostPct || 0
|
||||
const totalPayroll = payrollCost * (1 + oncostPct / 100)
|
||||
const overheadCost = budgetStore.overheadCosts.reduce((sum, cost) => sum + (cost.amount || 0), 0)
|
||||
|
||||
return totalPayroll + overheadCost
|
||||
}
|
||||
|
||||
function getProgressStatus(current: number, target: number): 'green' | 'yellow' | 'red' {
|
||||
if (target === 0) return 'green'
|
||||
const pct = (current / target) * 100
|
||||
if (pct >= 80) return 'green'
|
||||
if (pct >= 50) return 'yellow'
|
||||
return 'red'
|
||||
}
|
||||
|
||||
// Alert conditions (matching CLAUDE.md)
|
||||
const alerts = computed(() => ({
|
||||
savingsBelowTarget: savingsProgress.value.current < savingsProgress.value.target,
|
||||
cushionBreach: cushionForecast.value.breachesWithin13Weeks,
|
||||
}))
|
||||
|
||||
return {
|
||||
savingsProgress,
|
||||
cushionForecast,
|
||||
alerts,
|
||||
getMonthlyBurn
|
||||
}
|
||||
}
|
||||
|
|
@ -33,10 +33,13 @@ export function useFixtureIO() {
|
|||
payrollOncostPct: policies.payrollOncostPct,
|
||||
savingsTargetMonths: policies.savingsTargetMonths,
|
||||
minCashCushionAmount: policies.minCashCushionAmount,
|
||||
operatingMode: policies.operatingMode,
|
||||
payPolicy: policies.payPolicy,
|
||||
deferredCapHoursPerQtr: policies.deferredCapHoursPerQtr,
|
||||
deferredSunsetMonths: policies.deferredSunsetMonths,
|
||||
surplusOrder: policies.surplusOrder,
|
||||
paymentPriority: policies.paymentPriority
|
||||
paymentPriority: policies.paymentPriority,
|
||||
volunteerScope: policies.volunteerScope
|
||||
},
|
||||
streams: streams.streams,
|
||||
budget: {
|
||||
|
|
@ -66,9 +69,65 @@ export function useFixtureIO() {
|
|||
}
|
||||
|
||||
const importAll = (snapshot: AppSnapshot) => {
|
||||
// TODO: Implement import functionality for all stores
|
||||
// This will patch each store with the snapshot data
|
||||
console.log('Import functionality to be implemented', snapshot)
|
||||
const members = useMembersStore()
|
||||
const policies = usePoliciesStore()
|
||||
const streams = useStreamsStore()
|
||||
const budget = useBudgetStore()
|
||||
const scenarios = useScenariosStore()
|
||||
const cash = useCashStore()
|
||||
const session = useSessionStore()
|
||||
|
||||
try {
|
||||
// Import members
|
||||
if (snapshot.members && Array.isArray(snapshot.members)) {
|
||||
members.$patch({ members: snapshot.members })
|
||||
}
|
||||
|
||||
// Import policies
|
||||
if (snapshot.policies) {
|
||||
policies.$patch(snapshot.policies)
|
||||
}
|
||||
|
||||
// Import streams
|
||||
if (snapshot.streams && Array.isArray(snapshot.streams)) {
|
||||
streams.$patch({ streams: snapshot.streams })
|
||||
}
|
||||
|
||||
// Import budget
|
||||
if (snapshot.budget) {
|
||||
budget.$patch(snapshot.budget)
|
||||
}
|
||||
|
||||
// Import scenarios
|
||||
if (snapshot.scenarios) {
|
||||
scenarios.$patch(snapshot.scenarios)
|
||||
}
|
||||
|
||||
// Import cash
|
||||
if (snapshot.cash) {
|
||||
cash.updateCurrentBalances(
|
||||
snapshot.cash.currentCash || 0,
|
||||
snapshot.cash.currentSavings || 0
|
||||
)
|
||||
// Handle cash events and payment queue if present
|
||||
if (snapshot.cash.cashEvents) {
|
||||
cash.$patch({ cashEvents: snapshot.cash.cashEvents })
|
||||
}
|
||||
if (snapshot.cash.paymentQueue) {
|
||||
cash.$patch({ paymentQueue: snapshot.cash.paymentQueue })
|
||||
}
|
||||
}
|
||||
|
||||
// Import session
|
||||
if (snapshot.session) {
|
||||
session.$patch(snapshot.session)
|
||||
}
|
||||
|
||||
console.log('Successfully imported data snapshot')
|
||||
} catch (error) {
|
||||
console.error('Failed to import snapshot:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return { exportAll, importAll }
|
||||
|
|
|
|||
|
|
@ -1,238 +1,73 @@
|
|||
/**
|
||||
* Composable for loading and managing fixture data
|
||||
* Provides centralized access to demo data for all screens
|
||||
* DISABLED: All fixture data removed to prevent automatic demo data
|
||||
* This composable previously loaded sample data but is now empty
|
||||
*/
|
||||
export const useFixtures = () => {
|
||||
// Load fixture data (in real app, this would come from API or stores)
|
||||
// All sample data functions now return empty data
|
||||
const loadMembers = async () => {
|
||||
// In production, this would fetch from content/fixtures/members.json
|
||||
// For now, return inline data that matches the fixture structure
|
||||
return {
|
||||
members: [
|
||||
{
|
||||
id: 'member-1',
|
||||
displayName: 'Alex Chen',
|
||||
roleFocus: 'Technical Lead',
|
||||
payRelationship: 'Hybrid',
|
||||
capacity: { minHours: 20, targetHours: 120, maxHours: 160 },
|
||||
riskBand: 'Medium',
|
||||
externalCoveragePct: 60,
|
||||
privacyNeeds: 'aggregate_ok',
|
||||
deferredHours: 85,
|
||||
quarterlyDeferredCap: 240
|
||||
},
|
||||
{
|
||||
id: 'member-2',
|
||||
displayName: 'Jordan Silva',
|
||||
roleFocus: 'Design & UX',
|
||||
payRelationship: 'FullyPaid',
|
||||
capacity: { minHours: 30, targetHours: 140, maxHours: 180 },
|
||||
riskBand: 'Low',
|
||||
externalCoveragePct: 20,
|
||||
privacyNeeds: 'aggregate_ok',
|
||||
deferredHours: 0,
|
||||
quarterlyDeferredCap: 240
|
||||
},
|
||||
{
|
||||
id: 'member-3',
|
||||
displayName: 'Sam Rodriguez',
|
||||
roleFocus: 'Operations & Growth',
|
||||
payRelationship: 'Supplemental',
|
||||
capacity: { minHours: 10, targetHours: 60, maxHours: 100 },
|
||||
riskBand: 'High',
|
||||
externalCoveragePct: 85,
|
||||
privacyNeeds: 'steward_only',
|
||||
deferredHours: 32,
|
||||
quarterlyDeferredCap: 120
|
||||
}
|
||||
]
|
||||
members: []
|
||||
}
|
||||
}
|
||||
|
||||
const loadStreams = async () => {
|
||||
return {
|
||||
revenueStreams: [
|
||||
{
|
||||
id: 'stream-1',
|
||||
name: 'Client Services',
|
||||
category: 'Services',
|
||||
subcategory: 'Development',
|
||||
targetPct: 65,
|
||||
targetMonthlyAmount: 13000,
|
||||
certainty: 'Committed',
|
||||
payoutDelayDays: 30,
|
||||
terms: 'Net 30',
|
||||
revenueSharePct: 0,
|
||||
platformFeePct: 0,
|
||||
restrictions: 'General',
|
||||
effortHoursPerMonth: 180
|
||||
},
|
||||
{
|
||||
id: 'stream-2',
|
||||
name: 'Platform Sales',
|
||||
category: 'Product',
|
||||
subcategory: 'Digital Tools',
|
||||
targetPct: 20,
|
||||
targetMonthlyAmount: 4000,
|
||||
certainty: 'Probable',
|
||||
payoutDelayDays: 14,
|
||||
terms: 'Platform payout',
|
||||
revenueSharePct: 0,
|
||||
platformFeePct: 5,
|
||||
restrictions: 'General',
|
||||
effortHoursPerMonth: 40
|
||||
},
|
||||
{
|
||||
id: 'stream-3',
|
||||
name: 'Innovation Grant',
|
||||
category: 'Grant',
|
||||
subcategory: 'Government',
|
||||
targetPct: 10,
|
||||
targetMonthlyAmount: 2000,
|
||||
certainty: 'Committed',
|
||||
payoutDelayDays: 45,
|
||||
terms: 'Quarterly disbursement',
|
||||
revenueSharePct: 0,
|
||||
platformFeePct: 0,
|
||||
restrictions: 'Restricted',
|
||||
effortHoursPerMonth: 8
|
||||
},
|
||||
{
|
||||
id: 'stream-4',
|
||||
name: 'Community Donations',
|
||||
category: 'Donation',
|
||||
subcategory: 'Individual',
|
||||
targetPct: 3,
|
||||
targetMonthlyAmount: 600,
|
||||
certainty: 'Aspirational',
|
||||
payoutDelayDays: 3,
|
||||
terms: 'Immediate',
|
||||
revenueSharePct: 0,
|
||||
platformFeePct: 2.9,
|
||||
restrictions: 'General',
|
||||
effortHoursPerMonth: 5
|
||||
},
|
||||
{
|
||||
id: 'stream-5',
|
||||
name: 'Consulting & Training',
|
||||
category: 'Other',
|
||||
subcategory: 'Professional Services',
|
||||
targetPct: 2,
|
||||
targetMonthlyAmount: 400,
|
||||
certainty: 'Probable',
|
||||
payoutDelayDays: 21,
|
||||
terms: 'Net 21',
|
||||
revenueSharePct: 0,
|
||||
platformFeePct: 0,
|
||||
restrictions: 'General',
|
||||
effortHoursPerMonth: 12
|
||||
}
|
||||
]
|
||||
revenueStreams: []
|
||||
}
|
||||
}
|
||||
|
||||
const loadFinances = async () => {
|
||||
return {
|
||||
currentBalances: {
|
||||
cash: 5000,
|
||||
savings: 8000,
|
||||
totalLiquid: 13000
|
||||
cash: 0,
|
||||
savings: 0,
|
||||
totalLiquid: 0
|
||||
},
|
||||
policies: {
|
||||
equalHourlyWage: 20,
|
||||
payrollOncostPct: 25,
|
||||
savingsTargetMonths: 3,
|
||||
minCashCushionAmount: 3000,
|
||||
deferredCapHoursPerQtr: 240,
|
||||
deferredSunsetMonths: 12
|
||||
equalHourlyWage: 0,
|
||||
payrollOncostPct: 0,
|
||||
savingsTargetMonths: 0,
|
||||
minCashCushionAmount: 0,
|
||||
deferredCapHoursPerQtr: 0,
|
||||
deferredSunsetMonths: 0
|
||||
},
|
||||
deferredLiabilities: {
|
||||
totalDeferred: 2340,
|
||||
byMember: {
|
||||
'member-1': 1700,
|
||||
'member-2': 0,
|
||||
'member-3': 640
|
||||
}
|
||||
totalDeferred: 0,
|
||||
byMember: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loadCosts = async () => {
|
||||
return {
|
||||
overheadCosts: [
|
||||
{
|
||||
id: 'overhead-1',
|
||||
name: 'Coworking Space',
|
||||
amount: 0,
|
||||
category: 'Workspace',
|
||||
recurring: true
|
||||
},
|
||||
{
|
||||
id: 'overhead-2',
|
||||
name: 'Tools & Software',
|
||||
amount: 0,
|
||||
category: 'Technology',
|
||||
recurring: true
|
||||
},
|
||||
{
|
||||
id: 'overhead-3',
|
||||
name: 'Business Insurance',
|
||||
amount: 0,
|
||||
category: 'Legal & Compliance',
|
||||
recurring: true
|
||||
}
|
||||
],
|
||||
productionCosts: [
|
||||
{
|
||||
id: 'production-1',
|
||||
name: 'Development Kits',
|
||||
amount: 0,
|
||||
category: 'Hardware',
|
||||
period: '2024-01'
|
||||
}
|
||||
]
|
||||
overheadCosts: [],
|
||||
productionCosts: []
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate derived metrics from fixture data
|
||||
// Return empty metrics
|
||||
const calculateMetrics = async () => {
|
||||
const [members, streams, finances, costs] = await Promise.all([
|
||||
loadMembers(),
|
||||
loadStreams(),
|
||||
loadFinances(),
|
||||
loadCosts()
|
||||
])
|
||||
|
||||
const totalTargetHours = members.members.reduce((sum, member) =>
|
||||
sum + member.capacity.targetHours, 0
|
||||
)
|
||||
|
||||
const totalTargetRevenue = streams.revenueStreams.reduce((sum, stream) =>
|
||||
sum + stream.targetMonthlyAmount, 0
|
||||
)
|
||||
|
||||
const totalOverheadCosts = costs.overheadCosts.reduce((sum, cost) =>
|
||||
sum + cost.amount, 0
|
||||
)
|
||||
|
||||
const monthlyPayroll = totalTargetHours * finances.policies.equalHourlyWage *
|
||||
(1 + finances.policies.payrollOncostPct / 100)
|
||||
|
||||
const monthlyBurn = monthlyPayroll + totalOverheadCosts +
|
||||
costs.productionCosts.reduce((sum, cost) => sum + cost.amount, 0)
|
||||
|
||||
const runway = finances.currentBalances.totalLiquid / monthlyBurn
|
||||
|
||||
return {
|
||||
totalTargetHours,
|
||||
totalTargetRevenue,
|
||||
monthlyPayroll,
|
||||
monthlyBurn,
|
||||
runway,
|
||||
members: members.members,
|
||||
streams: streams.revenueStreams,
|
||||
finances: finances,
|
||||
costs: costs
|
||||
totalTargetHours: 0,
|
||||
totalTargetRevenue: 0,
|
||||
monthlyPayroll: 0,
|
||||
monthlyBurn: 0,
|
||||
runway: 0,
|
||||
members: [],
|
||||
streams: [],
|
||||
finances: {
|
||||
currentBalances: { cash: 0, savings: 0, totalLiquid: 0 },
|
||||
policies: {
|
||||
equalHourlyWage: 0,
|
||||
payrollOncostPct: 0,
|
||||
savingsTargetMonths: 0,
|
||||
minCashCushionAmount: 0,
|
||||
deferredCapHoursPerQtr: 0,
|
||||
deferredSunsetMonths: 0
|
||||
},
|
||||
deferredLiabilities: { totalDeferred: 0, byMember: {} }
|
||||
},
|
||||
costs: { overheadCosts: [], productionCosts: [] }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
142
composables/useMigrations.ts
Normal file
142
composables/useMigrations.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
export function useMigrations() {
|
||||
const CURRENT_SCHEMA_VERSION = "1.1"
|
||||
const SCHEMA_KEY = "urgent-tools-schema-version"
|
||||
|
||||
// Get stored schema version
|
||||
function getStoredVersion(): string {
|
||||
if (process.client) {
|
||||
return localStorage.getItem(SCHEMA_KEY) || "1.0"
|
||||
}
|
||||
return "1.0"
|
||||
}
|
||||
|
||||
// Set schema version
|
||||
function setVersion(version: string) {
|
||||
if (process.client) {
|
||||
localStorage.setItem(SCHEMA_KEY, version)
|
||||
}
|
||||
}
|
||||
|
||||
// Migration functions
|
||||
const migrations = {
|
||||
"1.0": () => {
|
||||
// Initial schema - no migration needed
|
||||
},
|
||||
|
||||
"1.1": () => {
|
||||
// Add new member needs fields
|
||||
const membersData = localStorage.getItem("urgent-tools-members")
|
||||
if (membersData) {
|
||||
try {
|
||||
const parsed = JSON.parse(membersData)
|
||||
if (Array.isArray(parsed.members)) {
|
||||
// Add default values for new fields
|
||||
parsed.members = parsed.members.map((member: any) => ({
|
||||
...member,
|
||||
minMonthlyNeeds: member.minMonthlyNeeds || 0,
|
||||
targetMonthlyPay: member.targetMonthlyPay || 0,
|
||||
externalMonthlyIncome: member.externalMonthlyIncome || 0,
|
||||
monthlyPayPlanned: member.monthlyPayPlanned || 0,
|
||||
}))
|
||||
localStorage.setItem("urgent-tools-members", JSON.stringify(parsed))
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to migrate members data:", error)
|
||||
}
|
||||
}
|
||||
|
||||
// Add new policy fields
|
||||
const policiesData = localStorage.getItem("urgent-tools-policies")
|
||||
if (policiesData) {
|
||||
try {
|
||||
const parsed = JSON.parse(policiesData)
|
||||
parsed.operatingMode = parsed.operatingMode || 'minimum'
|
||||
parsed.payPolicy = parsed.payPolicy || {
|
||||
relationship: 'equal-pay',
|
||||
roleBands: []
|
||||
}
|
||||
localStorage.setItem("urgent-tools-policies", JSON.stringify(parsed))
|
||||
} catch (error) {
|
||||
console.warn("Failed to migrate policies data:", error)
|
||||
}
|
||||
}
|
||||
|
||||
// DISABLED: No automatic cash balance initialization
|
||||
// The app should start completely empty - no demo data
|
||||
//
|
||||
// Previously this would auto-initialize cash balances, but this
|
||||
// created unwanted demo data. Users must explicitly set up their data.
|
||||
//
|
||||
// const cashData = localStorage.getItem("urgent-tools-cash")
|
||||
// if (!cashData) {
|
||||
// localStorage.setItem("urgent-tools-cash", JSON.stringify({
|
||||
// currentCash: 50000,
|
||||
// currentSavings: 15000,
|
||||
// cashEvents: [],
|
||||
// paymentQueue: []
|
||||
// }))
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
// Run all necessary migrations
|
||||
function migrate() {
|
||||
if (!process.client) return
|
||||
|
||||
const currentVersion = getStoredVersion()
|
||||
|
||||
if (currentVersion === CURRENT_SCHEMA_VERSION) {
|
||||
return // Already up to date
|
||||
}
|
||||
|
||||
console.log(`Migrating from schema version ${currentVersion} to ${CURRENT_SCHEMA_VERSION}`)
|
||||
|
||||
// Run migrations in order
|
||||
const versions = Object.keys(migrations).sort()
|
||||
const currentIndex = versions.indexOf(currentVersion)
|
||||
|
||||
if (currentIndex === -1) {
|
||||
console.warn(`Unknown schema version: ${currentVersion}, running all migrations`)
|
||||
// Run all migrations
|
||||
versions.forEach(version => {
|
||||
try {
|
||||
migrations[version as keyof typeof migrations]()
|
||||
console.log(`Applied migration: ${version}`)
|
||||
} catch (error) {
|
||||
console.error(`Migration ${version} failed:`, error)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// Run migrations from current version + 1 to latest
|
||||
for (let i = currentIndex + 1; i < versions.length; i++) {
|
||||
const version = versions[i]
|
||||
try {
|
||||
migrations[version as keyof typeof migrations]()
|
||||
console.log(`Applied migration: ${version}`)
|
||||
} catch (error) {
|
||||
console.error(`Migration ${version} failed:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update stored version
|
||||
setVersion(CURRENT_SCHEMA_VERSION)
|
||||
console.log(`Migration complete. Schema version: ${CURRENT_SCHEMA_VERSION}`)
|
||||
}
|
||||
|
||||
// Check if migration is needed
|
||||
function needsMigration(): boolean {
|
||||
// Don't run migrations if data was intentionally cleared
|
||||
if (process.client && localStorage.getItem('urgent-tools-cleared-flag') === 'true') {
|
||||
return false
|
||||
}
|
||||
return getStoredVersion() !== CURRENT_SCHEMA_VERSION
|
||||
}
|
||||
|
||||
return {
|
||||
migrate,
|
||||
needsMigration,
|
||||
currentVersion: CURRENT_SCHEMA_VERSION,
|
||||
storedVersion: getStoredVersion()
|
||||
}
|
||||
}
|
||||
52
composables/usePayrollAllocation.ts
Normal file
52
composables/usePayrollAllocation.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { allocatePayroll } from '~/types/members'
|
||||
|
||||
export function usePayrollAllocation() {
|
||||
const membersStore = useMembersStore()
|
||||
const policiesStore = usePoliciesStore()
|
||||
const budgetStore = useBudgetStore()
|
||||
|
||||
const { members, payPolicy } = storeToRefs(membersStore)
|
||||
const { equalHourlyWage, payrollOncostPct } = storeToRefs(policiesStore)
|
||||
const { capacityTotals } = storeToRefs(membersStore)
|
||||
|
||||
// Calculate base payroll budget from hours and wage
|
||||
const basePayrollBudget = computed(() => {
|
||||
const totalHours = capacityTotals.value.targetHours || 0
|
||||
const wage = equalHourlyWage.value || 0
|
||||
return totalHours * wage
|
||||
})
|
||||
|
||||
// Allocate payroll to members based on policy
|
||||
const allocatedMembers = computed(() => {
|
||||
if (members.value.length === 0 || basePayrollBudget.value === 0) {
|
||||
return members.value
|
||||
}
|
||||
|
||||
return allocatePayroll(
|
||||
members.value,
|
||||
payPolicy.value,
|
||||
basePayrollBudget.value
|
||||
)
|
||||
})
|
||||
|
||||
// Total payroll with oncosts
|
||||
const totalPayrollWithOncosts = computed(() => {
|
||||
return basePayrollBudget.value * (1 + payrollOncostPct.value / 100)
|
||||
})
|
||||
|
||||
// Update member planned pay when allocation changes
|
||||
watchEffect(() => {
|
||||
allocatedMembers.value.forEach(member => {
|
||||
membersStore.setPlannedPay(member.id, member.monthlyPayPlanned || 0)
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
basePayrollBudget,
|
||||
allocatedMembers,
|
||||
totalPayrollWithOncosts,
|
||||
payPolicy
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,49 @@
|
|||
import { monthlyPayroll } from '~/types/members'
|
||||
|
||||
/**
|
||||
* Computes months of runway from cash, reserves, and burn rate
|
||||
* Formula: (cash + savings) ÷ average monthly burn in scenario
|
||||
*/
|
||||
export const useRunway = () => {
|
||||
const membersStore = useMembersStore()
|
||||
const policiesStore = usePoliciesStore()
|
||||
const budgetStore = useBudgetStore()
|
||||
|
||||
const calculateRunway = (cash: number, savings: number, monthlyBurn: number): number => {
|
||||
if (monthlyBurn <= 0) return Infinity
|
||||
return (cash + savings) / monthlyBurn
|
||||
}
|
||||
|
||||
// Calculate monthly burn based on operating mode
|
||||
const getMonthlyBurn = (mode?: 'minimum' | 'target') => {
|
||||
const operatingMode = mode || policiesStore.operatingMode || 'minimum'
|
||||
|
||||
// Get payroll costs based on mode
|
||||
const payrollCost = monthlyPayroll(membersStore.members, operatingMode)
|
||||
|
||||
// Add oncosts
|
||||
const oncostPct = policiesStore.payrollOncostPct || 0
|
||||
const totalPayroll = payrollCost * (1 + oncostPct / 100)
|
||||
|
||||
// Add overhead costs
|
||||
const overheadCost = budgetStore.overheadCosts.reduce((sum, cost) => sum + (cost.amount || 0), 0)
|
||||
|
||||
return totalPayroll + overheadCost
|
||||
}
|
||||
|
||||
// Calculate runway for both modes
|
||||
const getDualModeRunway = (cash: number, savings: number) => {
|
||||
const minBurn = getMonthlyBurn('minimum')
|
||||
const targetBurn = getMonthlyBurn('target')
|
||||
|
||||
return {
|
||||
minimum: calculateRunway(cash, savings, minBurn),
|
||||
target: calculateRunway(cash, savings, targetBurn),
|
||||
minBurn,
|
||||
targetBurn
|
||||
}
|
||||
}
|
||||
|
||||
const getRunwayStatus = (months: number): 'green' | 'yellow' | 'red' => {
|
||||
if (months >= 3) return 'green'
|
||||
if (months >= 2) return 'yellow'
|
||||
|
|
@ -22,6 +58,8 @@ export const useRunway = () => {
|
|||
return {
|
||||
calculateRunway,
|
||||
getRunwayStatus,
|
||||
formatRunway
|
||||
formatRunway,
|
||||
getMonthlyBurn,
|
||||
getDualModeRunway
|
||||
}
|
||||
}
|
||||
|
|
|
|||
109
composables/useScenarios.ts
Normal file
109
composables/useScenarios.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { monthlyPayroll } from '~/types/members'
|
||||
|
||||
export function useScenarios() {
|
||||
const membersStore = useMembersStore()
|
||||
const streamsStore = useStreamsStore()
|
||||
const policiesStore = usePoliciesStore()
|
||||
const budgetStore = useBudgetStore()
|
||||
const cashStore = useCashStore()
|
||||
|
||||
// Base runway calculation
|
||||
function calculateScenarioRunway(
|
||||
members: any[],
|
||||
streams: any[],
|
||||
operatingMode: 'minimum' | 'target' = 'minimum'
|
||||
) {
|
||||
// Calculate payroll for scenario
|
||||
const payrollCost = monthlyPayroll(members, operatingMode)
|
||||
const oncostPct = policiesStore.payrollOncostPct || 0
|
||||
const totalPayroll = payrollCost * (1 + oncostPct / 100)
|
||||
|
||||
// Calculate revenue
|
||||
const totalRevenue = streams.reduce((sum, s) => sum + (s.targetMonthlyAmount || 0), 0)
|
||||
|
||||
// Add overhead
|
||||
const overheadCost = budgetStore.overheadCosts.reduce((sum, cost) => sum + (cost.amount || 0), 0)
|
||||
|
||||
// Net monthly
|
||||
const monthlyNet = totalRevenue - totalPayroll - overheadCost
|
||||
|
||||
// Cash + savings
|
||||
const cash = cashStore.currentCash || 50000
|
||||
const savings = cashStore.currentSavings || 15000
|
||||
const totalLiquid = cash + savings
|
||||
|
||||
// Runway calculation
|
||||
const monthlyBurn = totalPayroll + overheadCost
|
||||
const runway = monthlyBurn > 0 ? totalLiquid / monthlyBurn : Infinity
|
||||
|
||||
return {
|
||||
runway: Math.max(0, runway),
|
||||
monthlyNet,
|
||||
monthlyBurn,
|
||||
totalRevenue,
|
||||
totalPayroll
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario transformations per CLAUDE.md
|
||||
const scenarioTransforms = {
|
||||
current: () => ({
|
||||
members: [...membersStore.members],
|
||||
streams: [...streamsStore.streams]
|
||||
}),
|
||||
|
||||
quitJobs: () => ({
|
||||
// Set external income to 0 for members who have day jobs
|
||||
members: membersStore.members.map(m => ({
|
||||
...m,
|
||||
externalMonthlyIncome: 0 // Assume everyone quits their day job
|
||||
})),
|
||||
streams: [...streamsStore.streams]
|
||||
}),
|
||||
|
||||
startProduction: () => ({
|
||||
members: [...membersStore.members],
|
||||
// Reduce service revenue, increase production costs
|
||||
streams: streamsStore.streams.map(s => {
|
||||
// Reduce service contracts by 30%
|
||||
if (s.category?.toLowerCase().includes('service') || s.name.toLowerCase().includes('service')) {
|
||||
return { ...s, targetMonthlyAmount: (s.targetMonthlyAmount || 0) * 0.7 }
|
||||
}
|
||||
return s
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Calculate all scenarios
|
||||
const scenarios = computed(() => {
|
||||
const currentMode = policiesStore.operatingMode || 'minimum'
|
||||
|
||||
const current = scenarioTransforms.current()
|
||||
const quitJobs = scenarioTransforms.quitJobs()
|
||||
const startProduction = scenarioTransforms.startProduction()
|
||||
|
||||
return {
|
||||
current: {
|
||||
name: 'Operate Current',
|
||||
status: 'Active',
|
||||
...calculateScenarioRunway(current.members, current.streams, currentMode)
|
||||
},
|
||||
quitJobs: {
|
||||
name: 'Quit Day Jobs',
|
||||
status: 'Scenario',
|
||||
...calculateScenarioRunway(quitJobs.members, quitJobs.streams, currentMode)
|
||||
},
|
||||
startProduction: {
|
||||
name: 'Start Production',
|
||||
status: 'Scenario',
|
||||
...calculateScenarioRunway(startProduction.members, startProduction.streams, currentMode)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
scenarios,
|
||||
calculateScenarioRunway,
|
||||
scenarioTransforms
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue