feat: overhaul all 12 sector extensions with full CRUD, validation, and tests

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>
This commit is contained in:
Emil
2026-02-21 15:21:49 +01:00
co-authored by Claude Opus 4.6
parent 026497ed75
commit 6a5b2b7792
52 changed files with 14130 additions and 222 deletions
@@ -0,0 +1,206 @@
import { describe, it, expect } from 'vitest'
import {
calculateProjectBillingStats,
calculateAggregateStats,
type BillingEntry,
type CostEntry,
} from '../billing-calculator'
describe('calculateProjectBillingStats', () => {
const makeBilling = (overrides: Partial<BillingEntry> = {}): BillingEntry => ({
projectId: 'proj-1',
amount: 10000,
date: '2025-03-15',
invoiced: true,
...overrides,
})
const makeCost = (overrides: Partial<CostEntry> = {}): CostEntry => ({
projectId: 'proj-1',
amount: 5000,
date: '2025-03-10',
category: 'labor',
...overrides,
})
it('calculates CORRECT margin as (revenue - costs) / revenue * 100', () => {
const billings = [makeBilling({ amount: 100000 })]
const costs = [makeCost({ amount: 60000 })]
const result = calculateProjectBillingStats(billings, costs, 200000)
// Margin = (100000 - 60000) / 100000 * 100 = 40%
expect(result.margin).toBe(40)
// NOT the buggy formula: (budget - billed) / budget = (200000 - 100000) / 200000 = 50%
expect(result.margin).not.toBe(50)
})
it('returns 100% margin when there are no costs', () => {
const billings = [makeBilling({ amount: 80000 })]
const costs: CostEntry[] = []
const result = calculateProjectBillingStats(billings, costs, 100000)
// (80000 - 0) / 80000 * 100 = 100%
expect(result.margin).toBe(100)
expect(result.totalCosts).toBe(0)
})
it('returns negative margin when costs exceed revenue', () => {
const billings = [makeBilling({ amount: 50000 })]
const costs = [makeCost({ amount: 75000 })]
const result = calculateProjectBillingStats(billings, costs, 100000)
// (50000 - 75000) / 50000 * 100 = -50%
expect(result.margin).toBe(-50)
})
it('returns 0% margin when revenue is zero', () => {
const billings: BillingEntry[] = []
const costs = [makeCost({ amount: 10000 })]
const result = calculateProjectBillingStats(billings, costs, 50000)
expect(result.margin).toBe(0)
expect(result.totalBilled).toBe(0)
expect(result.totalCosts).toBe(10000)
})
it('calculates budget used as billed / budget * 100', () => {
const billings = [makeBilling({ amount: 60000 })]
const costs: CostEntry[] = []
const result = calculateProjectBillingStats(billings, costs, 120000)
// 60000 / 120000 * 100 = 50%
expect(result.budgetUsed).toBe(50)
})
it('caps budget used at 100% when billed exceeds budget', () => {
const billings = [makeBilling({ amount: 150000 })]
const costs: CostEntry[] = []
const result = calculateProjectBillingStats(billings, costs, 100000)
expect(result.budgetUsed).toBe(100)
})
it('calculates budget remaining as max(budget - billed, 0)', () => {
const billings = [makeBilling({ amount: 70000 })]
const costs: CostEntry[] = []
const result = calculateProjectBillingStats(billings, costs, 100000)
expect(result.budgetRemaining).toBe(30000)
})
it('never returns negative budget remaining', () => {
const billings = [makeBilling({ amount: 120000 })]
const costs: CostEntry[] = []
const result = calculateProjectBillingStats(billings, costs, 100000)
expect(result.budgetRemaining).toBe(0)
})
it('sums uninvoiced entries correctly', () => {
const billings = [
makeBilling({ amount: 30000, invoiced: true }),
makeBilling({ amount: 20000, invoiced: false }),
makeBilling({ amount: 15000, invoiced: false }),
makeBilling({ amount: 10000, invoiced: true }),
]
const costs: CostEntry[] = []
const result = calculateProjectBillingStats(billings, costs, 100000)
expect(result.uninvoicedAmount).toBe(35000)
expect(result.totalBilled).toBe(75000)
})
it('returns 0 uninvoiced when all entries are invoiced', () => {
const billings = [
makeBilling({ amount: 25000, invoiced: true }),
makeBilling({ amount: 15000, invoiced: true }),
]
const costs: CostEntry[] = []
const result = calculateProjectBillingStats(billings, costs, 50000)
expect(result.uninvoicedAmount).toBe(0)
})
it('returns all zeros for empty billings and costs', () => {
const result = calculateProjectBillingStats([], [], 100000)
expect(result.totalBilled).toBe(0)
expect(result.totalCosts).toBe(0)
expect(result.margin).toBe(0)
expect(result.budgetUsed).toBe(0)
expect(result.budgetRemaining).toBe(100000)
expect(result.uninvoicedAmount).toBe(0)
})
it('rounds monetary values correctly with floating point edge cases', () => {
const billings = [
makeBilling({ amount: 33333.33 }),
makeBilling({ amount: 33333.33 }),
makeBilling({ amount: 33333.34 }),
]
const costs = [
makeCost({ amount: 11111.11 }),
makeCost({ amount: 11111.11 }),
makeCost({ amount: 11111.11 }),
]
const result = calculateProjectBillingStats(billings, costs, 150000)
// totalBilled = 100000.00, totalCosts = 33333.33
expect(result.totalBilled).toBe(100000)
expect(result.totalCosts).toBe(33333.33)
// margin = (100000 - 33333.33) / 100000 * 100 = 66.6667 -> 66.67
expect(result.margin).toBe(66.67)
})
})
describe('calculateAggregateStats', () => {
it('aggregates stats across multiple projects', () => {
const stats = [
{
totalBilled: 100000,
totalCosts: 60000,
margin: 40,
budgetUsed: 80,
budgetRemaining: 25000,
uninvoicedAmount: 10000,
},
{
totalBilled: 50000,
totalCosts: 20000,
margin: 60,
budgetUsed: 50,
budgetRemaining: 50000,
uninvoicedAmount: 5000,
},
]
const result = calculateAggregateStats(stats)
expect(result.totalBilled).toBe(150000)
expect(result.totalCosts).toBe(80000)
expect(result.avgMargin).toBe(50) // (40 + 60) / 2
expect(result.totalUninvoiced).toBe(15000)
expect(result.totalBudgetRemaining).toBe(75000)
})
it('returns all zeros for empty project stats', () => {
const result = calculateAggregateStats([])
expect(result.totalBilled).toBe(0)
expect(result.totalCosts).toBe(0)
expect(result.avgMargin).toBe(0)
expect(result.totalUninvoiced).toBe(0)
expect(result.totalBudgetRemaining).toBe(0)
})
})
@@ -0,0 +1,107 @@
/**
* 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,
}
}