377 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|