"use client"; import { useEffect, useState } from "react"; import Link from "next/link"; import dynamic from "next/dynamic"; import { EstimateExportFormat } from "@capakraken/shared"; import { clsx } from "clsx"; import type { EstimateWorkspaceView, WorkspaceTab, } from "~/components/estimates/EstimateWorkspace.types.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { usePermissions } from "~/hooks/usePermissions.js"; import { formatDateLong } from "~/lib/format.js"; import { trpc } from "~/lib/trpc/client.js"; const TabSkeleton = () => (
); const EstimateWorkspaceDraftEditor = dynamic( () => import("~/components/estimates/EstimateWorkspaceDraftEditor.js").then((mod) => ({ default: mod.EstimateWorkspaceDraftEditor })), { loading: TabSkeleton }, ); const WeeklyPhasingView = dynamic( () => import("~/components/estimates/WeeklyPhasingView.js").then((mod) => ({ default: mod.WeeklyPhasingView })), { loading: TabSkeleton }, ); const OverviewTab = dynamic( () => import("~/components/estimates/tabs/OverviewTab.js").then((mod) => ({ default: mod.OverviewTab })), { loading: TabSkeleton }, ); const AssumptionsTab = dynamic( () => import("~/components/estimates/tabs/AssumptionsTab.js").then((mod) => ({ default: mod.AssumptionsTab })), { loading: TabSkeleton }, ); const ScopeTab = dynamic( () => import("~/components/estimates/tabs/ScopeTab.js").then((mod) => ({ default: mod.ScopeTab })), { loading: TabSkeleton }, ); const StaffingTab = dynamic( () => import("~/components/estimates/tabs/StaffingTab.js").then((mod) => ({ default: mod.StaffingTab })), { loading: TabSkeleton }, ); const FinancialsTab = dynamic( () => import("~/components/estimates/tabs/FinancialsTab.js").then((mod) => ({ default: mod.FinancialsTab })), { loading: TabSkeleton }, ); const VersionsTab = dynamic( () => import("~/components/estimates/tabs/VersionsTab.js").then((mod) => ({ default: mod.VersionsTab })), { loading: TabSkeleton }, ); const ExportsTab = dynamic( () => import("~/components/estimates/tabs/ExportsTab.js").then((mod) => ({ default: mod.ExportsTab })), { loading: TabSkeleton }, ); const CommentThread = dynamic( () => import("~/components/comments/CommentThread.js").then((mod) => ({ default: mod.CommentThread })), { loading: TabSkeleton }, ); const TABS: Array<{ id: WorkspaceTab; label: string }> = [ { id: "overview", label: "Overview" }, { id: "assumptions", label: "Assumptions" }, { id: "scope", label: "Scope Breakdown" }, { id: "staffing", label: "Staffing" }, { id: "financials", label: "Financials" }, { id: "phasing", label: "Phasing" }, { id: "versions", label: "Versions" }, { id: "exports", label: "Exports" }, { id: "comments", label: "Comments" }, ]; function EmptyState({ children }: { children: React.ReactNode }) { return (
{children}
); } function ActionNotice({ tone, children, }: { tone: "success" | "error"; children: React.ReactNode; }) { return (
{children}
); } export function EstimateWorkspaceClient({ estimateId }: { estimateId: string }) { const [tab, setTab] = useState("overview"); const [isEditing, setIsEditing] = useState(false); const [actionMessage, setActionMessage] = useState(null); const [actionError, setActionError] = useState(null); const { canEdit, canViewCosts } = usePermissions(); const utils = trpc.useUtils(); const detailQuery = trpc.estimate.getById.useQuery( { id: estimateId }, { enabled: canViewCosts, staleTime: 15_000, }, ); const submitVersionMutation = trpc.estimate.submitVersion.useMutation(); const approveVersionMutation = trpc.estimate.approveVersion.useMutation(); const createRevisionMutation = trpc.estimate.createRevision.useMutation(); const createExportMutation = trpc.estimate.createExport.useMutation(); const createPlanningHandoffMutation = trpc.estimate.createPlanningHandoff.useMutation(); const commentCountQuery = trpc.comment.count.useQuery( { entityType: "estimate", entityId: estimateId }, { enabled: canViewCosts, staleTime: 30_000 }, ); const commentCount = commentCountQuery.data ?? 0; const estimate = (detailQuery.data as EstimateWorkspaceView | undefined) ?? null; const hasWorkingVersion = estimate?.versions.some((version) => version.status === "WORKING") ?? false; const editableTab = tab === "overview" || tab === "assumptions" || tab === "scope" || tab === "staffing"; useEffect(() => { setIsEditing(false); setActionError(null); setActionMessage(null); }, [estimateId]); async function invalidateEstimateData() { await Promise.all([ utils.estimate.list.invalidate(), utils.estimate.getById.invalidate({ id: estimateId }), ]); } async function handleSubmitVersion(versionId: string) { setActionError(null); setActionMessage(null); try { await submitVersionMutation.mutateAsync({ estimateId, versionId }); await invalidateEstimateData(); setTab("versions"); setIsEditing(false); setActionMessage("Working version submitted for review."); } catch (error) { setActionError(error instanceof Error ? error.message : "Failed to submit version."); } } async function handleApproveVersion(versionId: string) { setActionError(null); setActionMessage(null); try { await approveVersionMutation.mutateAsync({ estimateId, versionId }); await invalidateEstimateData(); setTab("versions"); setIsEditing(false); setActionMessage("Submitted version approved and locked."); } catch (error) { setActionError(error instanceof Error ? error.message : "Failed to approve version."); } } async function handleCreateRevision(versionId: string) { setActionError(null); setActionMessage(null); try { await createRevisionMutation.mutateAsync({ estimateId, sourceVersionId: versionId }); await invalidateEstimateData(); setTab("versions"); setActionMessage("New working revision created from the selected version."); } catch (error) { setActionError(error instanceof Error ? error.message : "Failed to create revision."); } } async function handleCreateExport(versionId: string, format: EstimateExportFormat) { setActionError(null); setActionMessage(null); try { await createExportMutation.mutateAsync({ estimateId, versionId, format }); await invalidateEstimateData(); setTab("exports"); setActionMessage(`${format} export record created for the current version.`); } catch (error) { setActionError(error instanceof Error ? error.message : "Failed to create export."); } } async function handleCreatePlanningHandoff(versionId: string) { setActionError(null); setActionMessage(null); try { const result = await createPlanningHandoffMutation.mutateAsync({ estimateId, versionId }); await invalidateEstimateData(); setTab("versions"); setActionMessage( `Planning handoff created ${result.createdCount} planning entr${result.createdCount === 1 ? "y" : "ies"} (${result.assignedCount} assigned, ${result.placeholderCount} open demand${result.placeholderCount === 1 ? "" : "s"}).`, ); } catch (error) { setActionError( error instanceof Error ? error.message : "Failed to create planning allocations from the approved estimate.", ); } } return (
Back to Estimates

Estimate Workspace

{estimate?.name ?? "Loading estimate"}

Use the tabs below to inspect the connected estimate structure, version context, and staffing breakdown without relying on spreadsheet tabs.

{estimate && (
{estimate.project ? `${estimate.project.shortCode} - ${estimate.project.name}` : "Standalone estimate"} Updated {formatDateLong(estimate.updatedAt)}
{canEdit && hasWorkingVersion && ( )}
)}
{!canViewCosts ? ( Your role can access the estimate list, but not the detailed financial workspace. ) : detailQuery.isLoading ? ( Loading estimate workspace... ) : detailQuery.error ? ( {detailQuery.error.message} ) : !estimate ? ( Estimate not found. ) : ( <> {actionMessage && {actionMessage}} {actionError && {actionError}}
{TABS.map((item) => ( ))}
{isEditing ? ( setIsEditing(false)} onSaved={() => { setIsEditing(false); }} /> ) : ( <> {tab === "overview" && } {tab === "assumptions" && } {tab === "scope" && } {tab === "staffing" && } {tab === "financials" && } {tab === "phasing" && ( )} {tab === "versions" && ( )} {tab === "exports" && ( )} {tab === "comments" && (

Comments

)} )} )}
); }