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
137
types/members.ts
Normal file
137
types/members.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
export type PayRelationship =
|
||||
| 'equal-pay'
|
||||
| 'needs-weighted'
|
||||
| 'role-banded'
|
||||
| 'hours-weighted'
|
||||
| 'custom-formula';
|
||||
|
||||
export interface Member {
|
||||
id: string
|
||||
displayName: string
|
||||
roleFocus?: string
|
||||
role?: string
|
||||
hoursPerWeek?: number
|
||||
hoursPerMonth?: number
|
||||
capacity?: {
|
||||
minHours?: number
|
||||
targetHours?: number
|
||||
maxHours?: number
|
||||
}
|
||||
|
||||
// Existing/planned
|
||||
monthlyPayPlanned?: number
|
||||
|
||||
// NEW - early-stage friendly, defaults-safe
|
||||
minMonthlyNeeds?: number
|
||||
targetMonthlyPay?: number
|
||||
externalMonthlyIncome?: number
|
||||
|
||||
// Compatibility with existing store
|
||||
payRelationship?: string
|
||||
riskBand?: string
|
||||
externalCoveragePct?: number
|
||||
privacyNeeds?: string
|
||||
deferredHours?: number
|
||||
quarterlyDeferredCap?: number
|
||||
|
||||
// UI-only derivations
|
||||
coverageMinPct?: number
|
||||
coverageTargetPct?: number
|
||||
}
|
||||
|
||||
export interface PayPolicy {
|
||||
relationship: PayRelationship
|
||||
notes?: string
|
||||
equalBase?: number
|
||||
needsWeight?: number
|
||||
roleBands?: Record<string, number>
|
||||
hoursRate?: number
|
||||
customFormula?: string
|
||||
}
|
||||
|
||||
// Coverage calculation helpers
|
||||
export function coverage(minNeeds = 0, target = 0, planned = 0, external = 0) {
|
||||
const base = planned + external
|
||||
const min = minNeeds > 0 ? Math.min(200, (base / minNeeds) * 100) : undefined
|
||||
const tgt = target > 0 ? Math.min(200, (base / target) * 100) : undefined
|
||||
return { minPct: min, targetPct: tgt }
|
||||
}
|
||||
|
||||
export function teamCoverageStats(members: Member[]) {
|
||||
const vals = members
|
||||
.map(m => coverage(m.minMonthlyNeeds, m.targetMonthlyPay, m.monthlyPayPlanned, m.externalMonthlyIncome).minPct)
|
||||
.filter((v): v is number => typeof v === 'number')
|
||||
|
||||
if (!vals.length) return { under100: 0, median: undefined, range: undefined, gini: undefined }
|
||||
|
||||
const sorted = [...vals].sort((a, b) => a - b)
|
||||
const median = sorted[Math.floor(sorted.length / 2)]
|
||||
const range = { min: sorted[0], max: sorted[sorted.length - 1] }
|
||||
|
||||
// quick Gini on coverage (0 = equal, 1 = unequal)
|
||||
const mean = vals.reduce((a, b) => a + b, 0) / vals.length
|
||||
let gini = 0
|
||||
if (mean > 0) {
|
||||
let diffSum = 0
|
||||
for (let i = 0; i < vals.length; i++)
|
||||
for (let j = 0; j < vals.length; j++)
|
||||
diffSum += Math.abs(vals[i] - vals[j])
|
||||
gini = diffSum / (2 * vals.length * vals.length * mean)
|
||||
}
|
||||
|
||||
const under100 = vals.filter(v => v < 100).length
|
||||
|
||||
return { under100, median, range, gini }
|
||||
}
|
||||
|
||||
// Payroll allocation based on policy
|
||||
export function allocatePayroll(members: Member[], policy: PayPolicy, payrollBudget: number): Member[] {
|
||||
const result = JSON.parse(JSON.stringify(members)) // Safe deep clone
|
||||
|
||||
if (policy.relationship === 'equal-pay') {
|
||||
const each = payrollBudget / result.length
|
||||
result.forEach(m => m.monthlyPayPlanned = Math.max(0, each))
|
||||
return result
|
||||
}
|
||||
|
||||
if (policy.relationship === 'needs-weighted') {
|
||||
const weights = result.map(m => m.minMonthlyNeeds ?? 0)
|
||||
const sum = weights.reduce((a, b) => a + b, 0) || 1
|
||||
result.forEach((m, i) => {
|
||||
const w = weights[i] / sum
|
||||
m.monthlyPayPlanned = Math.max(0, payrollBudget * w)
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
if (policy.relationship === 'role-banded' && policy.roleBands) {
|
||||
const bands = result.map(m => policy.roleBands![m.role ?? ''] ?? 0)
|
||||
const sum = bands.reduce((a, b) => a + b, 0) || 1
|
||||
result.forEach((m, i) => m.monthlyPayPlanned = payrollBudget * (bands[i] / sum))
|
||||
return result
|
||||
}
|
||||
|
||||
if (policy.relationship === 'hours-weighted') {
|
||||
const hours = result.map(m => m.hoursPerMonth ?? (m.hoursPerWeek ? m.hoursPerWeek * 4 : 0) ?? (m.capacity?.targetHours ?? 0))
|
||||
const sum = hours.reduce((a, b) => a + b, 0) || 1
|
||||
result.forEach((m, i) => m.monthlyPayPlanned = payrollBudget * (hours[i] / sum))
|
||||
return result
|
||||
}
|
||||
|
||||
// fallback: equal
|
||||
const each = payrollBudget / result.length
|
||||
result.forEach(m => m.monthlyPayPlanned = Math.max(0, each))
|
||||
return result
|
||||
}
|
||||
|
||||
// Monthly payroll calculation for runway and cashflow
|
||||
export function monthlyPayroll(members: Member[], mode: 'minimum' | 'target' = 'minimum'): number {
|
||||
return members.reduce((sum, m) => {
|
||||
const planned = m.monthlyPayPlanned ?? 0
|
||||
// In "minimum" mode cap at min needs to show a lean runway scenario
|
||||
if (mode === 'minimum' && m.minMonthlyNeeds) {
|
||||
return sum + Math.min(planned, m.minMonthlyNeeds)
|
||||
}
|
||||
return sum + planned
|
||||
}, 0)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue