"use client"; import { clsx } from "clsx"; import { CommercialTermsEditor } from "~/components/estimates/CommercialTermsEditor.js"; import type { EstimateVersionView, EstimateWorkspaceView, } from "~/components/estimates/EstimateWorkspace.types.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { formatMoney } from "~/lib/format.js"; function EmptyState({ children }: { children: React.ReactNode }) { return (
{children}
); } export function FinancialsTab({ estimate, canEdit }: { estimate: EstimateWorkspaceView; canEdit: boolean }) { const versions = estimate.versions as EstimateVersionView[]; const latestVersion = versions[0] ?? null; const demandLines = latestVersion?.demandLines ?? []; if (demandLines.length === 0) { return No demand lines available to generate financial summaries.; } const totals = demandLines.reduce( (acc, line) => ({ hours: acc.hours + line.hours, costCents: acc.costCents + line.costTotalCents, priceCents: acc.priceCents + line.priceTotalCents, }), { hours: 0, costCents: 0, priceCents: 0 }, ); const marginCents = totals.priceCents - totals.costCents; const marginPercent = totals.priceCents > 0 ? (marginCents / totals.priceCents) * 100 : 0; const avgCostRate = totals.hours > 0 ? totals.costCents / totals.hours : 0; const avgBillRate = totals.hours > 0 ? totals.priceCents / totals.hours : 0; // Group by chapter const chapterMap = new Map(); for (const line of demandLines) { const chapter = line.chapter?.trim() || "Unassigned"; const existing = chapterMap.get(chapter) ?? { hours: 0, costCents: 0, priceCents: 0, count: 0 }; existing.hours += line.hours; existing.costCents += line.costTotalCents; existing.priceCents += line.priceTotalCents; existing.count += 1; chapterMap.set(chapter, existing); } const chapterBreakdown = [...chapterMap.entries()] .sort(([, a], [, b]) => b.priceCents - a.priceCents); // Monthly cost/price phasing const spreads = demandLines.filter( (line): line is typeof line & { monthlySpread: Record } => line.monthlySpread != null && Object.keys(line.monthlySpread).length > 0, ); const monthlyFinancials = new Map(); for (const line of spreads) { const costRate = line.hours > 0 ? line.costTotalCents / line.hours : 0; const billRate = line.hours > 0 ? line.priceTotalCents / line.hours : 0; for (const [month, hours] of Object.entries(line.monthlySpread)) { const existing = monthlyFinancials.get(month) ?? { hours: 0, costCents: 0, priceCents: 0 }; existing.hours += hours; existing.costCents += Math.round(hours * costRate); existing.priceCents += Math.round(hours * billRate); monthlyFinancials.set(month, existing); } } const sortedMonths = [...monthlyFinancials.keys()].sort(); return (
{/* Summary cards */}

Total Cost

{formatMoney(totals.costCents, estimate.baseCurrency)}

Avg {formatMoney(Math.round(avgCostRate), estimate.baseCurrency)}/h

Total Price

{formatMoney(totals.priceCents, estimate.baseCurrency)}

Avg {formatMoney(Math.round(avgBillRate), estimate.baseCurrency)}/h

Margin

= 0 ? "text-emerald-700 dark:text-emerald-400" : "text-red-700 dark:text-red-400")}> {formatMoney(marginCents, estimate.baseCurrency)}

{marginPercent.toFixed(1)}% of price

Total Hours

{totals.hours.toFixed(1)} h

{demandLines.length} demand lines

{/* Margin waterfall: Cost -> Margin -> Price */}

Cost to price bridge

{(() => { const maxVal = Math.max(totals.costCents, totals.priceCents, 1); const costH = (totals.costCents / maxVal) * 100; const marginH = (Math.abs(marginCents) / maxVal) * 100; const priceH = (totals.priceCents / maxVal) * 100; return ( <>
Cost {formatMoney(totals.costCents, estimate.baseCurrency)}
= 0 ? "bg-emerald-400" : "bg-red-400")} style={{ height: `${marginH}%` }} /> Margin {formatMoney(marginCents, estimate.baseCurrency)}
Price {formatMoney(totals.priceCents, estimate.baseCurrency)}
); })()}
{/* Chapter breakdown */}

Breakdown by chapter

{chapterBreakdown.map(([chapter, data]) => { const chapterMargin = data.priceCents - data.costCents; const chapterMarginPct = data.priceCents > 0 ? (chapterMargin / data.priceCents) * 100 : 0; return ( ); })}
Chapter Lines Hours Cost Price Margin Margin %
{chapter} {data.count} {data.hours.toFixed(1)} {formatMoney(data.costCents, estimate.baseCurrency)} {formatMoney(data.priceCents, estimate.baseCurrency)} = 0 ? "text-emerald-700 dark:text-emerald-400" : "text-red-700 dark:text-red-400")}> {formatMoney(chapterMargin, estimate.baseCurrency)} = 0 ? "text-emerald-700 dark:text-emerald-400" : "text-red-700 dark:text-red-400")}> {chapterMarginPct.toFixed(1)}%
Total {demandLines.length} {totals.hours.toFixed(1)} {formatMoney(totals.costCents, estimate.baseCurrency)} {formatMoney(totals.priceCents, estimate.baseCurrency)} = 0 ? "text-emerald-700 dark:text-emerald-400" : "text-red-700 dark:text-red-400")}> {formatMoney(marginCents, estimate.baseCurrency)} = 0 ? "text-emerald-700 dark:text-emerald-400" : "text-red-700 dark:text-red-400")}> {marginPercent.toFixed(1)}%
{/* Monthly cost/price phasing */} {sortedMonths.length > 0 && (

Monthly financial phasing

{sortedMonths.map((month) => { const data = monthlyFinancials.get(month)!; const mMargin = data.priceCents - data.costCents; return ( ); })}
Month Hours Cost Price Margin
{month} {data.hours.toFixed(1)} {formatMoney(data.costCents, estimate.baseCurrency)} {formatMoney(data.priceCents, estimate.baseCurrency)} = 0 ? "text-emerald-700 dark:text-emerald-400" : "text-red-700 dark:text-red-400")}> {formatMoney(mMargin, estimate.baseCurrency)}
)} {/* Commercial Terms */}
); }