cd78f72f33
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>
185 lines
8.1 KiB
TypeScript
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>
|
|
);
|
|
}
|