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,179 @@
"use client";
import { EstimateVersionStatus } from "@planarchy/shared";
import { clsx } from "clsx";
import { VersionCompare } from "~/components/estimates/VersionCompare.js";
import type {
EstimateMetricView,
EstimateVersionView,
EstimateWorkspaceView,
} from "~/components/estimates/EstimateWorkspace.types.js";
import { formatDateLong, formatMoney } from "~/lib/format.js";
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);
}
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 interface VersionsTabProps {
estimate: EstimateWorkspaceView;
canEdit: boolean;
hasLinkedProject: boolean;
onSubmitVersion: (versionId: string) => void;
onApproveVersion: (versionId: string) => void;
onCreateRevision: (versionId: string) => void;
onCreatePlanningHandoff: (versionId: string) => void;
isSubmitting: boolean;
isApproving: boolean;
isCreatingRevision: boolean;
isCreatingPlanningHandoff: boolean;
}
export function VersionsTab({
estimate,
canEdit,
hasLinkedProject,
onSubmitVersion,
onApproveVersion,
onCreateRevision,
onCreatePlanningHandoff,
isSubmitting,
isApproving,
isCreatingRevision,
isCreatingPlanningHandoff,
}: VersionsTabProps) {
const versions = estimate.versions as EstimateVersionView[];
const hasWorkingVersion = versions.some(
(version) => version.status === EstimateVersionStatus.WORKING,
);
if (versions.length === 0) {
return <EmptyState>No versions available for this estimate yet.</EmptyState>;
}
return (
<div className="space-y-3">
{versions.map((version) => (
<div key={version.id} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<span className="text-lg font-semibold text-gray-900">v{version.versionNumber}</span>
<span className={clsx("rounded-full px-2.5 py-1 text-xs font-medium", VERSION_STYLES[version.status])}>
{version.status}
</span>
</div>
<p className="mt-2 text-sm text-gray-600">{version.label ?? "Unlabeled version"}</p>
{version.notes && <p className="mt-2 text-sm text-gray-500">{version.notes}</p>}
</div>
<div className="text-right text-sm text-gray-500">
<p>Updated {formatDateLong(version.updatedAt)}</p>
{version.lockedAt && (
<p className="mt-1">Locked {formatDateLong(version.lockedAt)}</p>
)}
<p className="mt-1">{version.demandLines.length} lines</p>
</div>
</div>
{canEdit && (
<div className="mt-4 flex flex-wrap gap-2">
{version.status === EstimateVersionStatus.WORKING && (
<button
type="button"
onClick={() => onSubmitVersion(version.id)}
disabled={isSubmitting || isApproving || isCreatingRevision}
className="rounded-2xl border border-amber-200 bg-amber-50 px-3 py-2 text-sm font-semibold text-amber-800 transition hover:border-amber-300 hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-60"
>
{isSubmitting ? "Submitting..." : "Submit for review"}
</button>
)}
{version.status === EstimateVersionStatus.SUBMITTED && (
<button
type="button"
onClick={() => onApproveVersion(version.id)}
disabled={isSubmitting || isApproving || isCreatingRevision}
className="rounded-2xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-800 transition hover:border-emerald-300 hover:bg-emerald-100 disabled:cursor-not-allowed disabled:opacity-60"
>
{isApproving ? "Approving..." : "Approve version"}
</button>
)}
{version.status !== EstimateVersionStatus.WORKING && !hasWorkingVersion && (
<button
type="button"
onClick={() => onCreateRevision(version.id)}
disabled={isSubmitting || isApproving || isCreatingRevision || isCreatingPlanningHandoff}
className="rounded-2xl border border-brand-200 bg-white px-3 py-2 text-sm font-semibold text-brand-700 transition hover:border-brand-300 hover:bg-brand-50 disabled:cursor-not-allowed disabled:opacity-60"
>
{isCreatingRevision ? "Creating revision..." : "Create working revision"}
</button>
)}
{version.status === EstimateVersionStatus.APPROVED && (
<button
type="button"
onClick={() => onCreatePlanningHandoff(version.id)}
disabled={
isSubmitting ||
isApproving ||
isCreatingRevision ||
isCreatingPlanningHandoff ||
!hasLinkedProject
}
className="rounded-2xl border border-sky-200 bg-sky-50 px-3 py-2 text-sm font-semibold text-sky-800 transition hover:border-sky-300 hover:bg-sky-100 disabled:cursor-not-allowed disabled:opacity-60"
>
{isCreatingPlanningHandoff
? "Creating planning allocations..."
: hasLinkedProject
? "Create planning allocations"
: "Link project to hand off"}
</button>
)}
</div>
)}
{version.status === EstimateVersionStatus.APPROVED && !hasLinkedProject && (
<p className="mt-3 text-sm text-amber-700">
Link this estimate to a project before handing approved demand into planning.
</p>
)}
{version.metrics.length > 0 && (
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-5">
{version.metrics.map((metric) => (
<div key={metric.id} className="rounded-2xl bg-gray-50 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">{metric.label}</p>
<p className="mt-1 text-sm font-semibold text-gray-900">{formatMetricValue(metric)}</p>
</div>
))}
</div>
)}
</div>
))}
{versions.length >= 2 && <VersionCompare versions={versions} />}
</div>
);
}