refactor: complete v2 refactoring plan (Phases 1-5)
Phase 1 — Quick Wins: centralize formatMoney/formatCents, extract findUniqueOrThrow helper (19 routers), shared Prisma select constants, useInvalidatePlanningViews hook, status badge consolidation, composite DB indexes. Phase 2 — Timeline Split: extract TimelineContext, TimelineResourcePanel, TimelineProjectPanel; split 28-dep useMemo into 3 focused memos. TimelineView.tsx reduced from 1,903 to 538 lines. Phase 3 — Query Performance: server-side filtering for getEntriesView, remove availability from timeline resource select, SSE event debouncing (50ms batch window). Phase 4 — Estimate Workspace: extract 7 tab components and 3 editor components. EstimateWorkspaceClient 1,298→306 lines, EstimateWorkspaceDraftEditor 1,205→581 lines. Phase 5 — Package Cleanup: split commit-dispo-import-batch (1,112→573 lines), extract shared pagination helper with 11 tests. All tests pass: 209 API, 254 engine, 67 application. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { CommercialTermsEditor } from "~/components/estimates/CommercialTermsEditor.js";
|
||||
import type {
|
||||
EstimateVersionView,
|
||||
EstimateWorkspaceView,
|
||||
} from "~/components/estimates/EstimateWorkspace.types.js";
|
||||
import { formatMoney } from "~/lib/format.js";
|
||||
|
||||
function EmptyState({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-5 py-10 text-center text-sm text-gray-400">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 <EmptyState>No demand lines available to generate financial summaries.</EmptyState>;
|
||||
}
|
||||
|
||||
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<string, { hours: number; costCents: number; priceCents: number; count: number }>();
|
||||
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<string, number> } =>
|
||||
line.monthlySpread != null && Object.keys(line.monthlySpread).length > 0,
|
||||
);
|
||||
const monthlyFinancials = new Map<string, { hours: number; costCents: number; priceCents: number }>();
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* Summary cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Total Cost</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-gray-900">{formatMoney(totals.costCents, estimate.baseCurrency)}</p>
|
||||
<p className="mt-1 text-xs text-gray-500">Avg {formatMoney(Math.round(avgCostRate), estimate.baseCurrency)}/h</p>
|
||||
</div>
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Total Price</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-gray-900">{formatMoney(totals.priceCents, estimate.baseCurrency)}</p>
|
||||
<p className="mt-1 text-xs text-gray-500">Avg {formatMoney(Math.round(avgBillRate), estimate.baseCurrency)}/h</p>
|
||||
</div>
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Margin</p>
|
||||
<p className={clsx("mt-2 text-2xl font-semibold", marginCents >= 0 ? "text-emerald-700" : "text-red-700")}>
|
||||
{formatMoney(marginCents, estimate.baseCurrency)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500">{marginPercent.toFixed(1)}% of price</p>
|
||||
</div>
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Total Hours</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-gray-900">{totals.hours.toFixed(1)} h</p>
|
||||
<p className="mt-1 text-xs text-gray-500">{demandLines.length} demand lines</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Margin waterfall: Cost -> Margin -> Price */}
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<h3 className="mb-4 text-base font-semibold text-gray-900">Cost to price bridge</h3>
|
||||
<div className="flex items-end gap-1 h-32">
|
||||
{(() => {
|
||||
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 (
|
||||
<>
|
||||
<div className="flex-1 flex flex-col items-center gap-1">
|
||||
<div className="w-full rounded-t-xl bg-gray-300" style={{ height: `${costH}%` }} />
|
||||
<span className="text-xs font-medium text-gray-600">Cost</span>
|
||||
<span className="text-xs text-gray-500">{formatMoney(totals.costCents, estimate.baseCurrency)}</span>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center gap-1">
|
||||
<div
|
||||
className={clsx("w-full rounded-t-xl", marginCents >= 0 ? "bg-emerald-400" : "bg-red-400")}
|
||||
style={{ height: `${marginH}%` }}
|
||||
/>
|
||||
<span className="text-xs font-medium text-gray-600">Margin</span>
|
||||
<span className="text-xs text-gray-500">{formatMoney(marginCents, estimate.baseCurrency)}</span>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center gap-1">
|
||||
<div className="w-full rounded-t-xl bg-brand-500" style={{ height: `${priceH}%` }} />
|
||||
<span className="text-xs font-medium text-gray-600">Price</span>
|
||||
<span className="text-xs text-gray-500">{formatMoney(totals.priceCents, estimate.baseCurrency)}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chapter breakdown */}
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<h3 className="mb-3 text-base font-semibold text-gray-900">Breakdown by chapter</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="py-2 pr-3 font-medium">Chapter</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Lines</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Hours</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Cost</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Price</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Margin</th>
|
||||
<th className="pl-3 py-2 text-right font-medium">Margin %</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{chapterBreakdown.map(([chapter, data]) => {
|
||||
const chapterMargin = data.priceCents - data.costCents;
|
||||
const chapterMarginPct = data.priceCents > 0 ? (chapterMargin / data.priceCents) * 100 : 0;
|
||||
return (
|
||||
<tr key={chapter} className="border-b border-gray-100">
|
||||
<td className="py-2 pr-3 font-medium text-gray-900">{chapter}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-600">{data.count}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{data.hours.toFixed(1)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(data.costCents, estimate.baseCurrency)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(data.priceCents, estimate.baseCurrency)}</td>
|
||||
<td className={clsx("px-3 py-2 text-right tabular-nums", chapterMargin >= 0 ? "text-emerald-700" : "text-red-700")}>
|
||||
{formatMoney(chapterMargin, estimate.baseCurrency)}
|
||||
</td>
|
||||
<td className={clsx("pl-3 py-2 text-right tabular-nums", chapterMarginPct >= 0 ? "text-emerald-700" : "text-red-700")}>
|
||||
{chapterMarginPct.toFixed(1)}%
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
<tr className="border-t-2 border-gray-300 font-semibold">
|
||||
<td className="py-2 pr-3 text-gray-900">Total</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-900">{demandLines.length}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-900">{totals.hours.toFixed(1)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-900">{formatMoney(totals.costCents, estimate.baseCurrency)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-900">{formatMoney(totals.priceCents, estimate.baseCurrency)}</td>
|
||||
<td className={clsx("px-3 py-2 text-right tabular-nums", marginCents >= 0 ? "text-emerald-700" : "text-red-700")}>
|
||||
{formatMoney(marginCents, estimate.baseCurrency)}
|
||||
</td>
|
||||
<td className={clsx("pl-3 py-2 text-right tabular-nums", marginPercent >= 0 ? "text-emerald-700" : "text-red-700")}>
|
||||
{marginPercent.toFixed(1)}%
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Monthly cost/price phasing */}
|
||||
{sortedMonths.length > 0 && (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<h3 className="mb-3 text-base font-semibold text-gray-900">Monthly financial phasing</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="py-2 pr-3 font-medium">Month</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Hours</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Cost</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Price</th>
|
||||
<th className="pl-3 py-2 text-right font-medium">Margin</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedMonths.map((month) => {
|
||||
const data = monthlyFinancials.get(month)!;
|
||||
const mMargin = data.priceCents - data.costCents;
|
||||
return (
|
||||
<tr key={month} className="border-b border-gray-100">
|
||||
<td className="py-2 pr-3 font-medium text-gray-900">{month}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{data.hours.toFixed(1)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(data.costCents, estimate.baseCurrency)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(data.priceCents, estimate.baseCurrency)}</td>
|
||||
<td className={clsx("pl-3 py-2 text-right tabular-nums", mMargin >= 0 ? "text-emerald-700" : "text-red-700")}>
|
||||
{formatMoney(mMargin, estimate.baseCurrency)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Commercial Terms */}
|
||||
<CommercialTermsEditor
|
||||
estimateId={estimate.id}
|
||||
baseCostCents={totals.costCents}
|
||||
basePriceCents={totals.priceCents}
|
||||
baseCurrency={estimate.baseCurrency}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user