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,193 @@
import { describe, it, expect } from 'vitest'
import {
calculateUtilization,
calculateProjectTimeStats,
buildWeeklyGrid,
calculateMonthlySummary,
getEffectiveRate,
type TimeEntry,
} from '../billable-hours-calculator'
describe('calculateUtilization', () => {
it('calculates basic utilization KPIs', () => {
const entries: TimeEntry[] = [
{ date: '2025-03-03', projectId: 'p1', hours: 6, billable: true },
{ date: '2025-03-03', projectId: 'p1', hours: 2, billable: false },
]
const result = calculateUtilization(entries, 1000)
expect(result.totalHours).toBe(8)
expect(result.billableHours).toBe(6)
expect(result.nonBillableHours).toBe(2)
expect(result.utilization).toBe(75)
expect(result.revenue).toBe(6000)
expect(result.effectiveRate).toBe(750)
})
it('returns all zeros for empty entries', () => {
const result = calculateUtilization([], 1500)
expect(result.totalHours).toBe(0)
expect(result.billableHours).toBe(0)
expect(result.nonBillableHours).toBe(0)
expect(result.utilization).toBe(0)
expect(result.effectiveRate).toBe(0)
expect(result.revenue).toBe(0)
})
it('returns 100% utilization when all hours are billable', () => {
const entries: TimeEntry[] = [
{ date: '2025-03-03', projectId: 'p1', hours: 4, billable: true },
{ date: '2025-03-04', projectId: 'p2', hours: 4, billable: true },
]
const result = calculateUtilization(entries, 800)
expect(result.utilization).toBe(100)
expect(result.billableHours).toBe(8)
expect(result.nonBillableHours).toBe(0)
expect(result.revenue).toBe(6400)
})
it('returns 0% utilization and zero revenue when all hours are non-billable', () => {
const entries: TimeEntry[] = [
{ date: '2025-03-03', projectId: 'p1', hours: 3, billable: false },
{ date: '2025-03-04', projectId: 'p1', hours: 5, billable: false },
]
const result = calculateUtilization(entries, 1200)
expect(result.utilization).toBe(0)
expect(result.billableHours).toBe(0)
expect(result.nonBillableHours).toBe(8)
expect(result.revenue).toBe(0)
expect(result.effectiveRate).toBe(0)
})
it('calculates revenue as billable hours times rate', () => {
const entries: TimeEntry[] = [
{ date: '2025-03-03', projectId: 'p1', hours: 10, billable: true },
{ date: '2025-03-04', projectId: 'p1', hours: 5, billable: false },
]
const result = calculateUtilization(entries, 950)
expect(result.revenue).toBe(9500)
// effectiveRate = 9500 / 15 = 633.33
expect(result.effectiveRate).toBe(633.33)
})
})
describe('calculateProjectTimeStats', () => {
it('calculates per-project stats for multiple projects', () => {
const entries: TimeEntry[] = [
{ date: '2025-03-03', projectId: 'alpha', hours: 4, billable: true },
{ date: '2025-03-03', projectId: 'alpha', hours: 1, billable: false },
{ date: '2025-03-03', projectId: 'beta', hours: 3, billable: true },
{ date: '2025-03-04', projectId: 'beta', hours: 2, billable: true },
]
const result = calculateProjectTimeStats(entries)
expect(result).toHaveLength(2)
const alpha = result.find(p => p.projectId === 'alpha')!
expect(alpha.totalHours).toBe(5)
expect(alpha.billableHours).toBe(4)
expect(alpha.utilization).toBe(80)
const beta = result.find(p => p.projectId === 'beta')!
expect(beta.totalHours).toBe(5)
expect(beta.billableHours).toBe(5)
expect(beta.utilization).toBe(100)
})
it('returns empty array for empty entries', () => {
const result = calculateProjectTimeStats([])
expect(result).toEqual([])
})
})
describe('buildWeeklyGrid', () => {
const weekDates = [
'2025-03-03', // Mon
'2025-03-04', // Tue
'2025-03-05', // Wed
'2025-03-06', // Thu
'2025-03-07', // Fri
'2025-03-08', // Sat
'2025-03-09', // Sun
]
it('builds grid with entries on different days', () => {
const entries: TimeEntry[] = [
{ date: '2025-03-03', projectId: 'p1', hours: 4, billable: true },
{ date: '2025-03-04', projectId: 'p1', hours: 6, billable: true },
{ date: '2025-03-03', projectId: 'p2', hours: 2, billable: true },
{ date: '2025-03-05', projectId: 'p2', hours: 3, billable: false },
]
const result = buildWeeklyGrid(entries, weekDates, ['p1', 'p2'])
expect(result.projects).toHaveLength(2)
const p1 = result.projects.find(p => p.projectId === 'p1')!
expect(p1.days).toEqual([4, 6, 0, 0, 0, 0, 0])
const p2 = result.projects.find(p => p.projectId === 'p2')!
expect(p2.days).toEqual([2, 0, 3, 0, 0, 0, 0])
expect(result.dayTotals).toEqual([6, 6, 3, 0, 0, 0, 0])
})
it('returns zeros for an empty week', () => {
const result = buildWeeklyGrid([], weekDates, ['p1'])
expect(result.projects).toHaveLength(1)
expect(result.projects[0].days).toEqual([0, 0, 0, 0, 0, 0, 0])
expect(result.dayTotals).toEqual([0, 0, 0, 0, 0, 0, 0])
})
})
describe('calculateMonthlySummary', () => {
it('groups entries into monthly summaries across two months', () => {
const entries: TimeEntry[] = [
{ date: '2025-01-10', projectId: 'p1', hours: 40, billable: true },
{ date: '2025-01-15', projectId: 'p1', hours: 8, billable: false },
{ date: '2025-02-05', projectId: 'p1', hours: 32, billable: true },
{ date: '2025-02-10', projectId: 'p1', hours: 4, billable: false },
]
const result = calculateMonthlySummary(entries, 1000)
expect(result).toHaveLength(2)
expect(result[0].month).toBe('2025-01')
expect(result[0].billableHours).toBe(40)
expect(result[0].nonBillableHours).toBe(8)
expect(result[0].totalHours).toBe(48)
expect(result[0].revenue).toBe(40000)
expect(result[1].month).toBe('2025-02')
expect(result[1].billableHours).toBe(32)
expect(result[1].nonBillableHours).toBe(4)
expect(result[1].totalHours).toBe(36)
expect(result[1].revenue).toBe(32000)
})
it('returns empty array for no entries', () => {
const result = calculateMonthlySummary([], 1000)
expect(result).toEqual([])
})
})
describe('getEffectiveRate', () => {
it('returns project rate when defined', () => {
expect(getEffectiveRate(1500, 1000)).toBe(1500)
})
it('falls back to global rate when project rate is undefined', () => {
expect(getEffectiveRate(undefined, 1000)).toBe(1000)
})
})
@@ -0,0 +1,190 @@
/**
* Pure calculation functions for billable hours tracking.
* No side effects, no database calls — just math on time entries.
*/
export interface TimeEntry {
date: string
projectId: string
hours: number
billable: boolean
}
export interface UtilizationKPIs {
totalHours: number
billableHours: number
nonBillableHours: number
utilization: number // billable / total * 100, or 0
effectiveRate: number // (billableHours * hourlyRate) / totalHours, or 0
revenue: number // billableHours * hourlyRate
}
export interface ProjectTimeStats {
projectId: string
totalHours: number
billableHours: number
utilization: number
}
export interface WeeklyGrid {
projects: { projectId: string; days: number[] }[] // days[0..6] = Mon..Sun
dayTotals: number[] // 7 day totals
}
export interface MonthlyPeriodSummary {
month: string
billableHours: number
nonBillableHours: number
totalHours: number
revenue: number
}
/**
* Calculate utilization KPIs for a set of time entries.
*/
export function calculateUtilization(
entries: TimeEntry[],
hourlyRate: number
): UtilizationKPIs {
const totalHours = Math.round(
entries.reduce((sum, e) => sum + e.hours, 0) * 100
) / 100
const billableHours = Math.round(
entries.filter(e => e.billable).reduce((sum, e) => sum + e.hours, 0) * 100
) / 100
const nonBillableHours = Math.round((totalHours - billableHours) * 100) / 100
const utilization = totalHours > 0
? Math.round((billableHours / totalHours) * 10000) / 100
: 0
const revenue = Math.round(billableHours * hourlyRate * 100) / 100
const effectiveRate = totalHours > 0
? Math.round((revenue / totalHours) * 100) / 100
: 0
return {
totalHours,
billableHours,
nonBillableHours,
utilization,
effectiveRate,
revenue,
}
}
/**
* Calculate per-project time statistics.
*/
export function calculateProjectTimeStats(
entries: TimeEntry[]
): ProjectTimeStats[] {
const projectMap = new Map<string, { total: number; billable: number }>()
for (const entry of entries) {
const existing = projectMap.get(entry.projectId) ?? { total: 0, billable: 0 }
existing.total += entry.hours
if (entry.billable) {
existing.billable += entry.hours
}
projectMap.set(entry.projectId, existing)
}
return Array.from(projectMap.entries()).map(([projectId, stats]) => {
const totalHours = Math.round(stats.total * 100) / 100
const billableHours = Math.round(stats.billable * 100) / 100
const utilization = totalHours > 0
? Math.round((billableHours / totalHours) * 10000) / 100
: 0
return { projectId, totalHours, billableHours, utilization }
})
}
/**
* Build a weekly timesheet grid for the given week dates (7 date strings,
* Mon-Sun) and project IDs. Each project row has 7 day values. dayTotals
* sums all projects per day.
*/
export function buildWeeklyGrid(
entries: TimeEntry[],
weekDates: string[],
projectIds: string[]
): WeeklyGrid {
const dateIndex = new Map<string, number>()
for (let i = 0; i < weekDates.length; i++) {
dateIndex.set(weekDates[i], i)
}
const projects = projectIds.map(projectId => {
const days = [0, 0, 0, 0, 0, 0, 0]
for (const entry of entries) {
if (entry.projectId !== projectId) continue
const idx = dateIndex.get(entry.date)
if (idx !== undefined) {
days[idx] = Math.round((days[idx] + entry.hours) * 100) / 100
}
}
return { projectId, days }
})
const dayTotals = [0, 0, 0, 0, 0, 0, 0]
for (const project of projects) {
for (let i = 0; i < 7; i++) {
dayTotals[i] = Math.round((dayTotals[i] + project.days[i]) * 100) / 100
}
}
return { projects, dayTotals }
}
/**
* Calculate monthly period summaries, grouped by YYYY-MM.
*/
export function calculateMonthlySummary(
entries: TimeEntry[],
hourlyRate: number
): MonthlyPeriodSummary[] {
const monthMap = new Map<string, { billable: number; nonBillable: number }>()
for (const entry of entries) {
// Extract YYYY-MM from the date string
const month = entry.date.substring(0, 7)
const existing = monthMap.get(month) ?? { billable: 0, nonBillable: 0 }
if (entry.billable) {
existing.billable += entry.hours
} else {
existing.nonBillable += entry.hours
}
monthMap.set(month, existing)
}
return Array.from(monthMap.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([month, stats]) => {
const billableHours = Math.round(stats.billable * 100) / 100
const nonBillableHours = Math.round(stats.nonBillable * 100) / 100
const totalHours = Math.round((billableHours + nonBillableHours) * 100) / 100
const revenue = Math.round(billableHours * hourlyRate * 100) / 100
return { month, billableHours, nonBillableHours, totalHours, revenue }
})
}
/**
* Get the effective rate for a project. A project-specific rate overrides
* the global rate. If the project rate is undefined, fall back to the global rate.
*/
export function getEffectiveRate(
projectRate: number | undefined,
globalRate: number
): number {
return projectRate !== undefined ? projectRate : globalRate
}
@@ -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,
}
}