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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user