"use client"; import { useMemo, useState } from "react"; import Link from "next/link"; import type { AppRouter } from "@planarchy/api/router"; import { EstimateStatus, type EstimateVersionStatus } from "@planarchy/shared"; import type { inferRouterOutputs } from "@trpc/server"; import { clsx } from "clsx"; import { EstimateWizard } from "~/components/estimates/EstimateWizard.js"; import { usePermissions } from "~/hooks/usePermissions.js"; import { formatDateLong } from "~/lib/format.js"; import { trpc } from "~/lib/trpc/client.js"; type RouterOutput = inferRouterOutputs; type EstimateListItem = RouterOutput["estimate"]["list"][number]; type EstimateDetail = RouterOutput["estimate"]["getById"]; const STATUS_STYLES: Record = { DRAFT: "bg-slate-100 text-slate-700", IN_REVIEW: "bg-amber-100 text-amber-700", APPROVED: "bg-emerald-100 text-emerald-700", ARCHIVED: "bg-zinc-200 text-zinc-700", }; const VERSION_STYLES: Record = { WORKING: "bg-sky-100 text-sky-700", BASELINE: "bg-violet-100 text-violet-700", SUBMITTED: "bg-amber-100 text-amber-700", APPROVED: "bg-emerald-100 text-emerald-700", SUPERSEDED: "bg-zinc-200 text-zinc-700", }; function formatMoney(cents: number | null | undefined, currency = "EUR") { return new Intl.NumberFormat("de-DE", { style: "currency", currency, maximumFractionDigits: 0, }).format((cents ?? 0) / 100); } function formatMetricValue(metric: EstimateDetail["versions"][number]["metrics"][number]) { 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; return [...estimate.versions].sort((left, right) => right.versionNumber - left.versionNumber)[0] ?? null; } 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 ?? []; const selectedEstimate = useMemo(() => { if (!canViewCosts) return null; return detailQuery.data ?? 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="w-full rounded-2xl border border-gray-300 bg-white px-4 py-3 text-sm text-gray-900 outline-none ring-0 transition focus:border-brand-500 focus:ring-2 focus:ring-brand-100" />
{listQuery.isLoading ? (
Loading estimates...
) : estimates.length === 0 ? (

No estimates yet

Start with the wizard to create a connected estimate from Planarchy 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)} />} ); }