"use client"; import { useMemo, useState } from "react"; import Link from "next/link"; import { EstimateStatus, type EstimateVersionStatus } from "@planarchy/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>; }; type EstimateDetail = { id: string; name: string; status: EstimateStatus; project: EstimateProjectRef | null; versions: EstimateVersion[]; }; const STATUS_STYLES: Record = { 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 = { 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 ( ); } function EstimateCard({ estimate, active, onSelect, canInspect, }: { estimate: EstimateListItem; active: boolean; onSelect: () => void; canInspect: boolean; }) { const latestVersion = estimate.versions[0]; return ( ); } export function EstimatesClient() { const [search, setSearch] = useState(""); const [status, setStatus] = useState(""); const [wizardOpen, setWizardOpen] = useState(false); const [selectedEstimateId, setSelectedEstimateId] = useState(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 ( <>

Estimating

Browser-native estimate workspace

Build structured estimates from live projects, resources, and role data instead of maintaining a disconnected spreadsheet.

{canEdit && ( )}
setSearch(event.target.value)} placeholder="Search by estimate or opportunity" className="app-input rounded-2xl px-4 py-3" />
{listQuery.isLoading ? (
Loading estimates...
) : estimates.length === 0 ? (

No estimates yet

Start with the wizard to create a connected estimate from CapaKraken data.

) : (
{estimates.map((estimate) => ( { if (!canViewCosts) return; setSelectedEstimateId((current) => current === estimate.id ? current : estimate.id, ); }} /> ))}
{canViewCosts ? ( selectedEstimate ? ( cloneMutation.mutate({ sourceEstimateId: id }), cloning: cloneMutation.isPending, } : {})} /> ) : (
Select an estimate to inspect the current version, demand lines, and summary metrics.
) ) : (
Your role can access the estimate list, but not the detailed financial breakdown.
)}
)}
{wizardOpen && setWizardOpen(false)} />} ); }