Add shared components (ConfirmDeleteDialog, EditEntryDialog, validation utils), enhance all 12 extension workspaces with edit/delete dialogs, input validation, period comparisons, and new analytics features. Fix critical bugs in ProjectBilling margin calculation and EarningsPerLiter revenue allocation. Add pure calculation modules with 183 new tests across all extensions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
108 lines
3.0 KiB
TypeScript
108 lines
3.0 KiB
TypeScript
/**
|
|
* Calculate project billing statistics.
|
|
*
|
|
* IMPORTANT: Margin is calculated as (revenue - costs) / revenue * 100.
|
|
* This is the correct gross margin formula. A previous implementation
|
|
* incorrectly used (budget - billed) / budget which conflates budget
|
|
* utilization with profitability.
|
|
*/
|
|
|
|
export interface BillingEntry {
|
|
projectId: string
|
|
amount: number
|
|
date: string
|
|
invoiced: boolean
|
|
}
|
|
|
|
export interface CostEntry {
|
|
projectId: string
|
|
amount: number
|
|
date: string
|
|
category: string
|
|
}
|
|
|
|
export interface ProjectBillingStats {
|
|
totalBilled: number
|
|
totalCosts: number
|
|
margin: number
|
|
budgetUsed: number
|
|
budgetRemaining: number
|
|
uninvoicedAmount: number
|
|
}
|
|
|
|
export interface AggregateStats {
|
|
totalBilled: number
|
|
totalCosts: number
|
|
avgMargin: number
|
|
totalUninvoiced: number
|
|
totalBudgetRemaining: number
|
|
}
|
|
|
|
export function calculateProjectBillingStats(
|
|
billings: BillingEntry[],
|
|
costs: CostEntry[],
|
|
budget: number
|
|
): ProjectBillingStats {
|
|
const totalBilled = billings.reduce((sum, b) => sum + b.amount, 0)
|
|
const roundedBilled = Math.round(totalBilled * 100) / 100
|
|
|
|
const totalCosts = costs.reduce((sum, c) => sum + c.amount, 0)
|
|
const roundedCosts = Math.round(totalCosts * 100) / 100
|
|
|
|
// CORRECT margin formula: (revenue - costs) / revenue * 100
|
|
// Revenue = totalBilled. Returns 0 when there is no revenue.
|
|
const margin = roundedBilled > 0
|
|
? Math.round(((roundedBilled - roundedCosts) / roundedBilled) * 10000) / 100
|
|
: 0
|
|
|
|
const budgetUsedRaw = budget > 0
|
|
? Math.round((roundedBilled / budget) * 10000) / 100
|
|
: 0
|
|
const budgetUsed = Math.min(budgetUsedRaw, 100)
|
|
|
|
const budgetRemaining = Math.round(Math.max(budget - roundedBilled, 0) * 100) / 100
|
|
|
|
const uninvoicedAmount = billings
|
|
.filter(b => !b.invoiced)
|
|
.reduce((sum, b) => sum + b.amount, 0)
|
|
const roundedUninvoiced = Math.round(uninvoicedAmount * 100) / 100
|
|
|
|
return {
|
|
totalBilled: roundedBilled,
|
|
totalCosts: roundedCosts,
|
|
margin,
|
|
budgetUsed,
|
|
budgetRemaining,
|
|
uninvoicedAmount: roundedUninvoiced,
|
|
}
|
|
}
|
|
|
|
export function calculateAggregateStats(
|
|
projectStats: ProjectBillingStats[]
|
|
): AggregateStats {
|
|
if (projectStats.length === 0) {
|
|
return {
|
|
totalBilled: 0,
|
|
totalCosts: 0,
|
|
avgMargin: 0,
|
|
totalUninvoiced: 0,
|
|
totalBudgetRemaining: 0,
|
|
}
|
|
}
|
|
|
|
const totalBilled = projectStats.reduce((sum, s) => sum + s.totalBilled, 0)
|
|
const totalCosts = projectStats.reduce((sum, s) => sum + s.totalCosts, 0)
|
|
const totalUninvoiced = projectStats.reduce((sum, s) => sum + s.uninvoicedAmount, 0)
|
|
const totalBudgetRemaining = projectStats.reduce((sum, s) => sum + s.budgetRemaining, 0)
|
|
|
|
const avgMargin = projectStats.reduce((sum, s) => sum + s.margin, 0) / projectStats.length
|
|
|
|
return {
|
|
totalBilled: Math.round(totalBilled * 100) / 100,
|
|
totalCosts: Math.round(totalCosts * 100) / 100,
|
|
avgMargin: Math.round(avgMargin * 100) / 100,
|
|
totalUninvoiced: Math.round(totalUninvoiced * 100) / 100,
|
|
totalBudgetRemaining: Math.round(totalBudgetRemaining * 100) / 100,
|
|
}
|
|
}
|