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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user