Files
CapaKraken/apps/web/src/components/estimates/EstimateWorkspaceClient.tsx
T

377 lines
15 KiB
TypeScript

"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 = () => (
<div className="p-6 space-y-4">
<div className="h-8 w-48 shimmer-skeleton rounded" />
<div className="h-64 shimmer-skeleton rounded-xl" />
</div>
);
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 (
<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>
);
}
function ActionNotice({
tone,
children,
}: {
tone: "success" | "error";
children: React.ReactNode;
}) {
return (
<div
className={clsx(
"rounded-2xl border px-4 py-3 text-sm",
tone === "success"
? "border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-800 dark:bg-emerald-950/50 dark:text-emerald-300"
: "border-rose-200 bg-rose-50 text-rose-800 dark:border-rose-800 dark:bg-rose-950/50 dark:text-rose-300",
)}
>
{children}
</div>
);
}
export function EstimateWorkspaceClient({ estimateId }: { estimateId: string }) {
const [tab, setTab] = useState<WorkspaceTab>("overview");
const [isEditing, setIsEditing] = useState(false);
const [actionMessage, setActionMessage] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(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 (
<div className="mx-auto max-w-7xl space-y-6 p-6">
<Link
href="/estimates"
className="inline-flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400 transition-colors hover:text-gray-800 dark:hover:text-gray-200"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back to Estimates
</Link>
<div className="rounded-[28px] border border-gray-200 dark:border-gray-700 bg-gradient-to-br from-white via-white to-brand-50 dark:from-gray-900 dark:via-gray-900 dark:to-gray-900 p-6 shadow-sm dark:shadow-black/20">
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600 dark:text-sky-400">Estimate Workspace <InfoTooltip content="Central workspace for inspecting and editing an estimate's scope, staffing, financials, and version history." /></p>
<h1 className="mt-2 text-3xl font-semibold text-gray-900 dark:text-gray-50">
{estimate?.name ?? "Loading estimate"}
</h1>
<p className="mt-2 max-w-3xl text-sm text-gray-600 dark:text-gray-300">
Use the tabs below to inspect the connected estimate structure, version context, and staffing breakdown without relying on spreadsheet tabs.
</p>
</div>
{estimate && (
<div className="flex flex-col gap-3 lg:items-end">
<div className="grid gap-2 text-sm text-gray-500 dark:text-gray-400 lg:text-right">
<span>{estimate.project ? `${estimate.project.shortCode} - ${estimate.project.name}` : "Standalone estimate"}</span>
<span>Updated {formatDateLong(estimate.updatedAt)}</span>
</div>
{canEdit && hasWorkingVersion && (
<button
type="button"
onClick={() => {
if (!editableTab && !isEditing) return;
setIsEditing((current) => !current);
}}
className="rounded-2xl border border-brand-200 dark:border-sky-700 bg-white dark:bg-gray-800 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-50 dark:hover:bg-gray-700"
>
{isEditing ? "Close editor" : editableTab ? "Edit working draft" : "Draft editor available in editable tabs"}
</button>
)}
</div>
)}
</div>
</div>
{!canViewCosts ? (
<EmptyState>Your role can access the estimate list, but not the detailed financial workspace.</EmptyState>
) : detailQuery.isLoading ? (
<EmptyState>Loading estimate workspace...</EmptyState>
) : detailQuery.error ? (
<EmptyState>{detailQuery.error.message}</EmptyState>
) : !estimate ? (
<EmptyState>Estimate not found.</EmptyState>
) : (
<>
{actionMessage && <ActionNotice tone="success">{actionMessage}</ActionNotice>}
{actionError && <ActionNotice tone="error">{actionError}</ActionNotice>}
<div className="flex flex-wrap gap-2 border-b border-gray-200 dark:border-gray-700">
{TABS.map((item) => (
<button
key={item.id}
type="button"
onClick={() => setTab(item.id)}
className={clsx(
"rounded-t-2xl border-b-2 px-4 py-3 text-sm font-medium transition-colors",
tab === item.id
? "border-brand-600 text-brand-700 dark:border-sky-400 dark:text-sky-300"
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200",
)}
>
{item.label}
{item.id === "comments" && commentCount > 0 && (
<span className="ml-1.5 inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-brand-100 dark:bg-sky-800 px-1.5 text-xs font-semibold text-brand-700 dark:text-sky-200">
{commentCount}
</span>
)}
</button>
))}
</div>
{isEditing ? (
<EstimateWorkspaceDraftEditor
estimate={estimate}
tab={tab}
onCancel={() => setIsEditing(false)}
onSaved={() => {
setIsEditing(false);
}}
/>
) : (
<>
{tab === "overview" && <OverviewTab estimate={estimate} />}
{tab === "assumptions" && <AssumptionsTab estimate={estimate} />}
{tab === "scope" && <ScopeTab estimate={estimate} />}
{tab === "staffing" && <StaffingTab estimate={estimate} canEdit={canEdit} />}
{tab === "financials" && <FinancialsTab estimate={estimate} canEdit={canEdit} />}
{tab === "phasing" && (
<WeeklyPhasingView estimateId={estimate.id} canEdit={canEdit} />
)}
{tab === "versions" && (
<VersionsTab
estimate={estimate}
canEdit={canEdit}
hasLinkedProject={Boolean(estimate.projectId)}
onSubmitVersion={handleSubmitVersion}
onApproveVersion={handleApproveVersion}
onCreateRevision={handleCreateRevision}
onCreatePlanningHandoff={handleCreatePlanningHandoff}
isSubmitting={submitVersionMutation.isPending}
isApproving={approveVersionMutation.isPending}
isCreatingRevision={createRevisionMutation.isPending}
isCreatingPlanningHandoff={createPlanningHandoffMutation.isPending}
/>
)}
{tab === "exports" && (
<ExportsTab
estimate={estimate}
canEdit={canEdit}
onCreateExport={handleCreateExport}
isCreatingExport={createExportMutation.isPending}
/>
)}
{tab === "comments" && (
<div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6">
<h2 className="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-50">
Comments
</h2>
<CommentThread entityId={estimate.id} />
</div>
)}
</>
)}
</>
)}
</div>
);
}