Files
Nexus/apps/web/src/app/(app)/estimates/EstimatesClient.tsx
T
Hartmut 4a5edeef3e
CI / Unit Tests (pull_request) Successful in 5m46s
CI / Lint (pull_request) Failing after 3m49s
CI / E2E Tests (pull_request) Has been skipped
CI / Fresh-Linux Docker Deploy (pull_request) Has been skipped
CI / Assistant Split Regression (pull_request) Failing after 35s
CI / Architecture Guardrails (pull_request) Failing after 2m14s
CI / Typecheck (pull_request) Successful in 4m22s
CI / Build (pull_request) Has been skipped
CI / Release Images (pull_request) Has been skipped
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI
- @capakraken/* → @nexus/* across 12 packages (root + 11 workspaces),
  1551 import lines migrated via codemod
- User-visible brand strings renamed (emails, page titles, PWA
  manifest, mobile header, MFA backup-codes header, tooltips, signin
  page, invite page, weekly digest, install prompt)
- TOTP issuer "CapaKraken" → "Nexus" (existing secrets still valid;
  re-enrollment relabels them in users' authenticator apps)
- Function rename: assertCapaKrakenDbTarget → assertNexusDbTarget
- LocalStorage migration shim in apps/web/src/app/layout.tsx copies
  capakraken_* → nexus_* on first load (guarded by nexus_migrated_v1
  sentinel; runs once per browser, then never again)
- Service-worker cache name capakraken-v2 → nexus-v2 with one-time
  caches.delete('capakraken-v2') from the same shim
- Email-domain fixtures @capakraken.{dev,app} → @nexus.{dev,app} in
  seed data, e2e specs, SMTP default fallback
- Dockerfile.dev / Dockerfile.prod / all .github/workflows/*.yml
  pnpm --filter @capakraken/* → @nexus/*
- README, CLAUDE.md, LEARNINGS.md, all docs/*.md, .env.example,
  tooling/deploy/.env.production.example brand sweep

Phase 1 deliberately leaves untouched (handled in Phase 3 cutover):
- PostgreSQL DB name "capakraken" and POSTGRES_USER "capakraken"
- Volume names capakraken_pgdata etc.
- Compose project name "capakraken" / "capakraken-prod"
- db-target-guard default expectedDatabase
- env-var CAPAKRAKEN_EXPECTED_DB_NAME
- Container DNS names in docker-compose.ci.yml

Quality gates green: pnpm typecheck (7/7), pnpm test:unit (7/7),
pnpm lint (0 errors), check:exports/imports/architecture all pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 15:10:44 +02:00

532 lines
20 KiB
TypeScript

"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { EstimateStatus, type EstimateVersionStatus } from "@nexus/shared";
import { clsx } from "clsx";
import { EstimateWizard } from "~/components/estimates/EstimateWizard.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { usePermissions } from "~/hooks/usePermissions.js";
import { formatDateLong, formatMoney } from "~/lib/format.js";
import { trpc } from "~/lib/trpc/client.js";
type EstimateMetric = {
id: string;
key: string;
label: string;
valueDecimal: number;
valueCents: number | null;
currency: string | null;
};
type EstimateScopeItem = {
id: string;
name: string;
description: string | null;
scopeType: string;
};
type EstimateDemandLine = {
id: string;
name: string;
hours: number;
costTotalCents: number;
priceTotalCents: number;
currency: string;
chapter: string | null;
};
type EstimateVersion = {
versionNumber: number;
label: string | null;
status: EstimateVersionStatus;
notes: string | null;
metrics: EstimateMetric[];
scopeItems: EstimateScopeItem[];
demandLines: EstimateDemandLine[];
};
type EstimateProjectRef = {
shortCode: string;
name: string;
};
type EstimateListItem = {
id: string;
name: string;
status: EstimateStatus;
opportunityId: string | null;
updatedAt: Date | string;
project: EstimateProjectRef | null;
versions: Array<Pick<EstimateVersion, "versionNumber" | "status">>;
};
type EstimateDetail = {
id: string;
name: string;
status: EstimateStatus;
project: EstimateProjectRef | null;
versions: EstimateVersion[];
};
const STATUS_STYLES: Record<EstimateStatus, string> = {
DRAFT: "bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300",
IN_REVIEW: "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",
ARCHIVED: "bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300",
};
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: EstimateMetric) {
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 getLatestVersion(estimate: EstimateDetail | null | undefined) {
if (!estimate) return null;
let latest = estimate.versions[0] ?? null;
for (const version of estimate.versions) {
if (!latest || version.versionNumber > latest.versionNumber) {
latest = version;
}
}
return latest;
}
function EstimateDetailPanel({
estimate,
onClone,
cloning,
}: {
estimate: EstimateDetail;
onClone?: (id: string) => void;
cloning?: boolean;
}) {
const latestVersion = getLatestVersion(estimate);
const latestMetrics = latestVersion?.metrics ?? [];
return (
<aside className="app-surface h-full p-5">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-gray-400">
Estimate detail{" "}
<InfoTooltip content="Pre-project cost and effort calculation. Estimates model staffing demand, scope, and financials before work begins." />
</p>
<h2 className="mt-2 text-xl font-semibold text-gray-900 dark:text-gray-50">
{estimate.name}
</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{estimate.project
? `${estimate.project.shortCode} - ${estimate.project.name}`
: "Standalone estimate"}
</p>
</div>
<span
className={clsx(
"rounded-full px-3 py-1 text-xs font-semibold",
STATUS_STYLES[estimate.status],
)}
>
{estimate.status.replace("_", " ")}
</span>
</div>
<div className="mt-4 flex gap-2">
<Link
href={`/estimates/${estimate.id}`}
className="inline-flex items-center justify-center rounded-2xl border border-brand-200 dark:border-sky-700 bg-brand-50 dark:bg-sky-950/40 px-4 py-2 text-sm font-semibold text-brand-700 dark:text-sky-300 transition hover:border-brand-300 dark:hover:border-sky-600 hover:bg-brand-100 dark:hover:bg-sky-900/40"
>
Open workspace
</Link>
{onClone && (
<button
type="button"
disabled={cloning}
onClick={() => onClone(estimate.id)}
className="inline-flex items-center justify-center rounded-2xl border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-700 transition hover:border-gray-300 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
>
{cloning ? "Cloning..." : "Clone"}
</button>
)}
</div>
{latestVersion ? (
<>
<div className="mt-5 flex items-center gap-2">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Version {latestVersion.versionNumber}
{latestVersion.label ? ` - ${latestVersion.label}` : ""}
</span>
<span
className={clsx(
"rounded-full px-2 py-0.5 text-xs font-medium",
VERSION_STYLES[latestVersion.status],
)}
>
{latestVersion.status}
</span>
</div>
{latestMetrics.length > 0 && (
<div className="mt-5 grid gap-3 sm:grid-cols-2">
{latestMetrics.map((metric) => (
<div
key={metric.id}
className="rounded-2xl border border-gray-100 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-900/70"
>
<p className="text-xs uppercase tracking-wide text-gray-400">{metric.label}</p>
<p className="mt-1 text-lg font-semibold text-gray-900 dark:text-gray-100">
{formatMetricValue(metric)}
</p>
</div>
))}
</div>
)}
{latestVersion.notes && (
<div className="mt-5 rounded-2xl border border-gray-100 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-900/70">
<p className="text-xs uppercase tracking-wide text-gray-400">Version notes</p>
<p className="mt-2 text-sm text-gray-700 dark:text-gray-200">{latestVersion.notes}</p>
</div>
)}
<div className="mt-5 grid gap-5 xl:grid-cols-2">
<section>
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Scope items{" "}
<InfoTooltip content="Deliverables or work packages that define what is included in this estimate." />
</h3>
<span className="text-xs text-gray-400">{latestVersion.scopeItems.length}</span>
</div>
<div className="mt-3 space-y-2">
{latestVersion.scopeItems.length === 0 ? (
<p className="rounded-2xl border border-dashed border-gray-200 dark:border-gray-700 px-4 py-3 text-sm text-gray-400">
No scope rows captured yet.
</p>
) : (
latestVersion.scopeItems.map((item) => (
<div
key={item.id}
className="rounded-2xl border border-gray-100 px-4 py-3 dark:border-gray-800"
>
<div className="flex items-center justify-between gap-3">
<p className="font-medium text-gray-900 dark:text-gray-100">{item.name}</p>
<span className="text-xs text-gray-400">{item.scopeType}</span>
</div>
{item.description && (
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300">
{item.description}
</p>
)}
</div>
))
)}
</div>
</section>
<section>
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Demand lines{" "}
<InfoTooltip content="Staffing demand rows. Each line represents a role or resource with hours, cost rate, and sell rate." />
</h3>
<span className="text-xs text-gray-400">{latestVersion.demandLines.length}</span>
</div>
<div className="mt-3 space-y-2">
{latestVersion.demandLines.length === 0 ? (
<p className="rounded-2xl border border-dashed border-gray-200 dark:border-gray-700 px-4 py-3 text-sm text-gray-400">
No staffing demand captured yet.
</p>
) : (
latestVersion.demandLines.map((line) => (
<div
key={line.id}
className="rounded-2xl border border-gray-100 px-4 py-3 dark:border-gray-800"
>
<div className="flex items-center justify-between gap-3">
<p className="font-medium text-gray-900 dark:text-gray-100">{line.name}</p>
<p className="text-sm font-medium text-gray-600 dark:text-gray-300">
{line.hours.toFixed(1)} h
</p>
</div>
<div className="mt-2 flex flex-wrap gap-2 text-xs text-gray-500 dark:text-gray-400">
<span>{formatMoney(line.costTotalCents, line.currency)} cost</span>
<span>{formatMoney(line.priceTotalCents, line.currency)} sell</span>
{line.chapter && <span>{line.chapter}</span>}
</div>
</div>
))
)}
</div>
</section>
</div>
</>
) : (
<p className="mt-6 rounded-2xl border border-dashed border-gray-200 dark:border-gray-700 px-4 py-6 text-sm text-gray-400">
No versions available for this estimate yet.
</p>
)}
</aside>
);
}
function EstimateCard({
estimate,
active,
onSelect,
canInspect,
}: {
estimate: EstimateListItem;
active: boolean;
onSelect: () => void;
canInspect: boolean;
}) {
const latestVersion = estimate.versions[0];
return (
<button
type="button"
onClick={onSelect}
disabled={!canInspect}
className={clsx(
"w-full rounded-3xl border p-5 text-left transition",
active
? "border-brand-500 bg-brand-50 shadow-sm dark:border-sky-400 dark:bg-sky-950/30"
: "border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm dark:border-gray-700 dark:bg-gray-900 dark:hover:border-gray-600",
!canInspect && "cursor-default",
)}
>
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<span
className={clsx(
"rounded-full px-2.5 py-1 text-xs font-semibold",
STATUS_STYLES[estimate.status],
)}
>
{estimate.status.replace("_", " ")}
</span>
{estimate.project && (
<span className="rounded-full bg-gray-100 dark:bg-gray-800 px-2.5 py-1 text-xs font-medium text-gray-600 dark:text-gray-400">
{estimate.project.shortCode}
</span>
)}
</div>
<h3 className="mt-3 text-lg font-semibold text-gray-900 dark:text-gray-50">
{estimate.name}
</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{estimate.project ? estimate.project.name : "No linked project"}
</p>
</div>
{latestVersion && (
<span
className={clsx(
"rounded-full px-2.5 py-1 text-xs font-medium",
VERSION_STYLES[latestVersion.status],
)}
>
v{latestVersion.versionNumber}
</span>
)}
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-2">
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">
Opportunity{" "}
<InfoTooltip content="External CRM or sales reference ID linking this estimate to a sales opportunity." />
</p>
<p className="mt-1 text-sm text-gray-700 dark:text-gray-200">
{estimate.opportunityId ?? "Not set"}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">
Updated{" "}
<InfoTooltip content="When this estimate or any of its versions was last modified." />
</p>
<p className="mt-1 text-sm text-gray-700 dark:text-gray-200">
{formatDateLong(estimate.updatedAt)}
</p>
</div>
</div>
{!canInspect && (
<p className="mt-4 text-xs text-gray-400 dark:text-gray-500">
Detailed financial breakdown is limited to manager and controller roles.
</p>
)}
</button>
);
}
export function EstimatesClient() {
const [search, setSearch] = useState("");
const [status, setStatus] = useState<EstimateStatus | "">("");
const [wizardOpen, setWizardOpen] = useState(false);
const [selectedEstimateId, setSelectedEstimateId] = useState<string | null>(null);
const { canEdit, canViewCosts } = usePermissions();
const utils = trpc.useUtils();
const cloneMutation = trpc.estimate.clone.useMutation({
onSuccess: (cloned) => {
void utils.estimate.list.invalidate();
setSelectedEstimateId(cloned.id);
},
});
const listQuery = trpc.estimate.list.useQuery(
{
query: search || undefined,
status: status || undefined,
},
{ staleTime: 15_000 },
);
const detailQuery = trpc.estimate.getById.useQuery(
{ id: selectedEstimateId ?? "" },
{
enabled: canViewCosts && !!selectedEstimateId,
staleTime: 15_000,
},
);
const estimates = (listQuery.data ?? []) as unknown as EstimateListItem[];
const selectedEstimate = useMemo(() => {
if (!canViewCosts) return null;
return (detailQuery.data ?? null) as unknown as EstimateDetail | null;
}, [canViewCosts, detailQuery.data]);
return (
<>
<div className="app-page space-y-6">
<div className="app-surface-strong overflow-hidden bg-gradient-to-br from-white via-white to-brand-50 p-6 dark:from-gray-900 dark:via-gray-900 dark:to-gray-900">
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600">
Estimating
</p>
<h1 className="mt-2 font-display text-3xl font-semibold text-gray-900 dark:text-gray-50">
Browser-native estimate workspace
</h1>
<p className="mt-2 max-w-3xl text-sm text-gray-600 dark:text-gray-300">
Build structured estimates from live projects, resources, and role data instead of
maintaining a disconnected spreadsheet.
</p>
</div>
{canEdit && (
<button
type="button"
onClick={() => setWizardOpen(true)}
className="inline-flex items-center justify-center rounded-2xl bg-brand-600 px-5 py-3 text-sm font-semibold text-white transition hover:bg-brand-700"
>
New Estimate
</button>
)}
</div>
<div className="mt-6 grid gap-3 lg:grid-cols-[minmax(0,1fr),220px]">
<input
type="search"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search by estimate or opportunity"
className="app-input rounded-2xl px-4 py-3"
/>
<select
value={status}
onChange={(event) => setStatus(event.target.value as EstimateStatus | "")}
className="app-select w-full rounded-2xl px-4 py-3"
>
<option value="">All statuses</option>
{Object.values(EstimateStatus).map((value) => (
<option key={value} value={value}>
{value.replace("_", " ")}
</option>
))}
</select>
</div>
</div>
{listQuery.isLoading ? (
<div className="app-surface-strong border-dashed px-6 py-14 text-center text-sm text-gray-400">
Loading estimates...
</div>
) : estimates.length === 0 ? (
<div className="app-surface-strong border-dashed px-6 py-14 text-center">
<p className="text-base font-medium text-gray-700 dark:text-gray-100">
No estimates yet
</p>
<p className="mt-2 text-sm text-gray-400 dark:text-gray-500">
Start with the wizard to create a connected estimate from Nexus data.
</p>
</div>
) : (
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.05fr),minmax(320px,0.95fr)]">
<div className="space-y-4">
{estimates.map((estimate) => (
<EstimateCard
key={estimate.id}
estimate={estimate}
active={estimate.id === selectedEstimateId}
canInspect={canViewCosts}
onSelect={() => {
if (!canViewCosts) return;
setSelectedEstimateId((current) =>
current === estimate.id ? current : estimate.id,
);
}}
/>
))}
</div>
<div>
{canViewCosts ? (
selectedEstimate ? (
<EstimateDetailPanel
estimate={selectedEstimate}
{...(canEdit
? {
onClone: (id: string) => cloneMutation.mutate({ sourceEstimateId: id }),
cloning: cloneMutation.isPending,
}
: {})}
/>
) : (
<div className="app-surface-strong border-dashed px-6 py-14 text-center text-sm text-gray-400">
Select an estimate to inspect the current version, demand lines, and summary
metrics.
</div>
)
) : (
<div className="app-surface-strong border-dashed px-6 py-14 text-center text-sm text-gray-400">
Your role can access the estimate list, but not the detailed financial breakdown.
</div>
)}
</div>
</div>
)}
</div>
{wizardOpen && <EstimateWizard onClose={() => setWizardOpen(false)} />}
</>
);
}