Files
CapaKraken/apps/web/src/components/estimates/tabs/VersionsTab.tsx
T
Hartmut cd78f72f33 chore: full technical rename planarchy → capakraken
Complete rename of all technical identifiers across the codebase:

Package names (11 packages):
- @planarchy/* → @capakraken/* in all package.json, tsconfig, imports

Import statements: 277 files, 548 occurrences replaced

Database & Docker:
- PostgreSQL user/db: planarchy → capakraken
- Docker volumes: planarchy_pgdata → capakraken_pgdata
- Connection strings updated in docker-compose, .env, CI

CI/CD:
- GitHub Actions workflow: all filter commands updated
- Test database credentials updated

Infrastructure:
- Redis channel: planarchy:sse → capakraken:sse
- Logger service name: planarchy-api → capakraken-api
- Anonymization seed updated
- Start/stop/restart scripts updated

Test data:
- Seed emails: @planarchy.dev → @capakraken.dev
- E2E test credentials: all 11 spec files updated
- Email defaults: @planarchy.app → @capakraken.app
- localStorage keys: planarchy_* → capakraken_*

Documentation: 30+ .md files updated

Verification:
- pnpm install: workspace resolution works
- TypeScript: only pre-existing TS2589 (no new errors)
- Engine: 310/310 tests pass
- Staffing: 37/37 tests pass

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-27 13:18:09 +01:00

185 lines
8.1 KiB
TypeScript

"use client";
import { EstimateVersionStatus } from "@capakraken/shared";
import { clsx } from "clsx";
import { VersionCompare } from "~/components/estimates/VersionCompare.js";
import type {
EstimateMetricView,
EstimateVersionView,
EstimateWorkspaceView,
} from "~/components/estimates/EstimateWorkspace.types.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { formatDateLong, formatMoney } from "~/lib/format.js";
const VERSION_STYLES: Record<EstimateVersionStatus, string> = {
WORKING: "bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300",
BASELINE: "bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300",
SUBMITTED: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
APPROVED: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300",
SUPERSEDED: "bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300",
};
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 dark:border-gray-700 bg-white dark:bg-gray-800 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">
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<span>Versions are immutable snapshots of the estimate for comparison and audit.</span>
<InfoTooltip content="Each version captures a full copy of scope, assumptions, demand lines, and metrics. WORKING versions can be edited; SUBMITTED and APPROVED versions are locked." />
</div>
{versions.map((version) => (
<div key={version.id} className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 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 dark:text-gray-100">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 dark:text-gray-400">{version.label ?? "Unlabeled version"}</p>
{version.notes && <p className="mt-2 text-sm text-gray-500 dark:text-gray-400">{version.notes}</p>}
</div>
<div className="text-right text-sm text-gray-500 dark:text-gray-400">
<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 dark:bg-gray-800 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 dark:text-amber-300">
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 dark:bg-gray-900 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 dark:text-gray-100">{formatMetricValue(metric)}</p>
</div>
))}
</div>
)}
</div>
))}
{versions.length >= 2 && <VersionCompare versions={versions} />}
</div>
);
}