app/types/members.ts

123 lines
No EOL
3.7 KiB
TypeScript

export type PayRelationship =
| 'equal-pay'
| 'needs-weighted'
| 'hours-weighted'
| 'custom-formula';
export interface Member {
id: string
displayName: string
hoursPerWeek?: number
hoursPerMonth?: number
capacity?: {
minHours?: number
targetHours?: number
maxHours?: number
}
// Existing/planned
monthlyPayPlanned?: number
// Simplified - only minimum needs for needs-weighted allocation
minMonthlyNeeds?: number
// Compatibility with existing store
payRelationship?: string
riskBand?: string
externalCoveragePct?: number
privacyNeeds?: string
deferredHours?: number
quarterlyDeferredCap?: number
// UI-only derivations
coveragePct?: number
}
export interface PayPolicy {
relationship: PayRelationship
notes?: string
equalBase?: number
needsWeight?: number
hoursRate?: number
customFormula?: string
}
// Simplified coverage calculation
export function coverage(minNeeds = 0, planned = 0) {
const coveragePct = minNeeds > 0 ? Math.min(200, (planned / minNeeds) * 100) : undefined
return { coveragePct }
}
export function teamCoverageStats(members: Member[]) {
const vals = members
.map(m => coverage(m.minMonthlyNeeds, m.monthlyPayPlanned).coveragePct)
.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
}
// Removed role-banded allocation - no longer supported
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 budgets
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)
}