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,226 @@
"use client";
import {
type EstimateExportArtifactPayload,
EstimateExportFormat,
} from "@planarchy/shared";
import type {
EstimateExportView,
EstimateVersionView,
EstimateWorkspaceView,
} from "~/components/estimates/EstimateWorkspace.types.js";
import { formatDateLong, formatMoney } from "~/lib/format.js";
const EXPORT_FORMATS: EstimateExportFormat[] = [
EstimateExportFormat.XLSX,
EstimateExportFormat.CSV,
EstimateExportFormat.JSON,
EstimateExportFormat.SAP,
EstimateExportFormat.MMP,
];
function formatBytes(value: number | null | undefined) {
const bytes = value ?? 0;
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function isEstimateExportArtifactPayload(
payload: EstimateExportView["payload"],
): payload is EstimateExportArtifactPayload {
return (
typeof payload === "object" &&
payload !== null &&
typeof payload.content === "string" &&
typeof payload.mimeType === "string" &&
typeof payload.encoding === "string" &&
typeof payload.fileExtension === "string" &&
typeof payload.generatedAt === "string" &&
typeof payload.byteLength === "number" &&
typeof payload.summary === "object" &&
payload.summary !== null
);
}
function decodeBase64(value: string) {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
function downloadEstimateExport(estimateExport: EstimateExportView) {
const payload = estimateExport.payload;
if (!isEstimateExportArtifactPayload(payload)) {
return;
}
const blob =
payload.encoding === "base64"
? new Blob([decodeBase64(payload.content)], { type: payload.mimeType })
: new Blob([payload.content], { type: payload.mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = estimateExport.fileName;
document.body.append(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
export interface ExportsTabProps {
estimate: EstimateWorkspaceView;
canEdit: boolean;
onCreateExport: (versionId: string, format: EstimateExportFormat) => void;
isCreatingExport: boolean;
}
export function ExportsTab({
estimate,
canEdit,
onCreateExport,
isCreatingExport,
}: ExportsTabProps) {
const versions = estimate.versions as EstimateVersionView[];
const latestVersion = versions[0] ?? null;
const exports = latestVersion?.exports ?? [];
return (
<div className="space-y-4">
<div 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>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600">Export delivery</h2>
<p className="mt-2 text-sm text-gray-500">
Generate format-specific artifacts from the current version and download them directly from the stored serializer payload.
</p>
</div>
{latestVersion && canEdit && (
<div className="flex flex-wrap gap-2">
{EXPORT_FORMATS.map((format) => (
<button
key={format}
type="button"
onClick={() => onCreateExport(latestVersion.id, format)}
disabled={isCreatingExport}
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"
>
{isCreatingExport ? "Generating..." : `Create ${format}`}
</button>
))}
</div>
)}
</div>
</div>
<div className="rounded-3xl border border-gray-200 bg-white shadow-sm">
<div className="border-b border-gray-100 px-6 py-4">
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600">Generated exports</h2>
</div>
{exports.length === 0 ? (
<div className="px-6 py-8">
<p className="text-sm text-gray-400">No exports have been generated for the current version yet.</p>
</div>
) : (
<div className="divide-y divide-gray-100">
{exports.map((estimateExport) => {
const payload = isEstimateExportArtifactPayload(estimateExport.payload)
? estimateExport.payload
: null;
return (
<div key={estimateExport.id} className="px-6 py-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium text-gray-900">{estimateExport.fileName}</p>
<span className="rounded-full bg-gray-100 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wide text-gray-600">
{estimateExport.format}
</span>
{payload?.sheetNames?.length ? (
<span className="rounded-full bg-sky-50 px-2.5 py-1 text-[11px] font-semibold text-sky-700">
{payload.sheetNames.length} sheets
</span>
) : null}
</div>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-500">
<span>{formatDateLong(estimateExport.createdAt)}</span>
{payload ? <span>{formatBytes(payload.byteLength)}</span> : null}
{payload?.rowCount != null ? <span>{payload.rowCount} rows</span> : null}
{payload?.lineCount != null ? <span>{payload.lineCount} lines</span> : null}
</div>
{payload ? (
<div className="mt-3 grid gap-2 text-xs text-gray-600 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-2xl bg-gray-50 px-3 py-2">
<p className="uppercase tracking-wide text-gray-400">Hours</p>
<p className="mt-1 font-semibold text-gray-900">
{payload.summary.totalHours.toFixed(1)}
</p>
</div>
<div className="rounded-2xl bg-gray-50 px-3 py-2">
<p className="uppercase tracking-wide text-gray-400">Cost</p>
<p className="mt-1 font-semibold text-gray-900">
{formatMoney(
payload.summary.totalCostCents,
payload.summary.baseCurrency,
)}
</p>
</div>
<div className="rounded-2xl bg-gray-50 px-3 py-2">
<p className="uppercase tracking-wide text-gray-400">Price</p>
<p className="mt-1 font-semibold text-gray-900">
{formatMoney(
payload.summary.totalPriceCents,
payload.summary.baseCurrency,
)}
</p>
</div>
<div className="rounded-2xl bg-gray-50 px-3 py-2">
<p className="uppercase tracking-wide text-gray-400">Margin</p>
<p className="mt-1 font-semibold text-gray-900">
{payload.summary.marginPercent.toFixed(0)}%
</p>
</div>
</div>
) : (
<p className="mt-3 text-xs text-amber-700">
Legacy export record detected. Regenerate it to get downloadable serializer output.
</p>
)}
</div>
{payload ? (
<button
type="button"
onClick={() => downloadEstimateExport(estimateExport)}
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"
>
Download
</button>
) : null}
</div>
{payload?.previewText ? (
<pre className="mt-4 overflow-x-auto rounded-2xl bg-gray-950/95 p-4 text-xs text-gray-100">
{payload.previewText}
</pre>
) : null}
</div>
);
})}
</div>
)}
</div>
</div>
);
}