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:
2026-03-14 23:03:42 +01:00
parent 4dabb9d4ce
commit ad0855902b
65 changed files with 7108 additions and 4740 deletions
@@ -0,0 +1,174 @@
"use client";
import { clsx } from "clsx";
import { EstimateStatus, EstimateVersionStatus } from "@planarchy/shared";
import type {
EstimateMetricView,
EstimateVersionView,
EstimateWorkspaceView,
} from "~/components/estimates/EstimateWorkspace.types.js";
import { formatDateLong, formatMoney } from "~/lib/format.js";
const STATUS_STYLES: Record<EstimateStatus, string> = {
DRAFT: "bg-slate-100 text-slate-700",
IN_REVIEW: "bg-amber-100 text-amber-700",
APPROVED: "bg-emerald-100 text-emerald-700",
ARCHIVED: "bg-zinc-200 text-zinc-700",
};
const VERSION_STYLES: Record<EstimateVersionStatus, string> = {
WORKING: "bg-sky-100 text-sky-700",
BASELINE: "bg-violet-100 text-violet-700",
SUBMITTED: "bg-amber-100 text-amber-700",
APPROVED: "bg-emerald-100 text-emerald-700",
SUPERSEDED: "bg-zinc-200 text-zinc-700",
};
function formatMetricValue(metric: EstimateMetricView) {
if (metric.valueCents != null) {
return formatMoney(metric.valueCents, metric.currency ?? "EUR");
}
if (metric.key === "margin_percent") {
return `${metric.valueDecimal.toFixed(0)}%`;
}
return new Intl.NumberFormat("de-DE", { maximumFractionDigits: 1 }).format(metric.valueDecimal);
}
export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
const versions = estimate.versions as EstimateVersionView[];
const latestVersion = versions[0] ?? null;
const latestMetrics = latestVersion?.metrics ?? [];
return (
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr),340px]">
<section className="space-y-6">
<div className="rounded-3xl border border-gray-200 bg-white p-6 shadow-sm">
<div className="flex flex-wrap items-center gap-2">
<span className={clsx("rounded-full px-3 py-1 text-xs font-semibold", STATUS_STYLES[estimate.status])}>
{estimate.status.replace("_", " ")}
</span>
{estimate.project && (
<span className="rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-600">
{estimate.project.shortCode}
</span>
)}
</div>
<div className="mt-5 grid gap-4 md:grid-cols-2">
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">Opportunity</p>
<p className="mt-1 text-sm text-gray-800">{estimate.opportunityId ?? "Not set"}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">Base currency</p>
<p className="mt-1 text-sm text-gray-800">{estimate.baseCurrency}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">Latest version</p>
<p className="mt-1 text-sm text-gray-800">
{latestVersion ? `v${latestVersion.versionNumber}${latestVersion.label ? ` - ${latestVersion.label}` : ""}` : "No version"}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">Updated</p>
<p className="mt-1 text-sm text-gray-800">{formatDateLong(estimate.updatedAt)}</p>
</div>
</div>
{latestVersion?.notes && (
<div className="mt-5 rounded-2xl border border-gray-100 bg-gray-50 p-4">
<p className="text-xs uppercase tracking-wide text-gray-400">Version notes</p>
<p className="mt-2 text-sm text-gray-700">{latestVersion.notes}</p>
</div>
)}
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-gray-900">Scope items</p>
<span className="text-xs text-gray-400">{latestVersion?.scopeItems.length ?? 0}</span>
</div>
<div className="mt-4 space-y-2">
{(latestVersion?.scopeItems ?? []).slice(0, 4).map((item) => (
<div key={item.id} className="rounded-2xl border border-gray-100 px-4 py-3">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-medium text-gray-900">{item.name}</p>
<span className="text-xs text-gray-400">{item.scopeType}</span>
</div>
</div>
))}
{(latestVersion?.scopeItems.length ?? 0) === 0 && <p className="text-sm text-gray-400">No scope rows captured yet.</p>}
</div>
</div>
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-gray-900">Demand lines</p>
<span className="text-xs text-gray-400">{latestVersion?.demandLines.length ?? 0}</span>
</div>
<div className="mt-4 space-y-2">
{(latestVersion?.demandLines ?? []).slice(0, 4).map((line) => (
<div key={line.id} className="rounded-2xl border border-gray-100 px-4 py-3">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-medium text-gray-900">{line.name}</p>
<span className="text-xs text-gray-500">{line.hours.toFixed(1)} h</span>
</div>
</div>
))}
{(latestVersion?.demandLines.length ?? 0) === 0 && <p className="text-sm text-gray-400">No demand lines captured yet.</p>}
</div>
</div>
</div>
</section>
<aside className="space-y-4">
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="text-sm font-semibold text-gray-900">Summary metrics</p>
<div className="mt-4 space-y-3">
{latestMetrics.length === 0 ? (
<p className="text-sm text-gray-400">No derived metrics available yet.</p>
) : (
latestMetrics.map((metric) => (
<div key={metric.id} className="flex items-center justify-between gap-3 rounded-2xl bg-gray-50 px-4 py-3">
<span className="text-xs uppercase tracking-wide text-gray-400">{metric.label}</span>
<span className="text-sm font-semibold text-gray-900">{formatMetricValue(metric)}</span>
</div>
))
)}
</div>
</div>
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="text-sm font-semibold text-gray-900">Version context</p>
<div className="mt-4 space-y-3">
{latestVersion ? (
<>
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-gray-500">Status</span>
<span className={clsx("rounded-full px-2.5 py-1 text-xs font-medium", VERSION_STYLES[latestVersion.status])}>
{latestVersion.status}
</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-gray-500">Assumptions</span>
<span className="text-sm font-medium text-gray-900">{latestVersion.assumptions.length}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-gray-500">Snapshots</span>
<span className="text-sm font-medium text-gray-900">{latestVersion.resourceSnapshots.length}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-gray-500">Exports</span>
<span className="text-sm font-medium text-gray-900">{latestVersion.exports.length}</span>
</div>
</>
) : (
<p className="text-sm text-gray-400">No version available.</p>
)}
</div>
</div>
</aside>
</div>
);
}