2da29c8191
#66: Project detail "Open Demands" summary incorrectly counted COMPLETED demands as open. Fix: add `status !== "COMPLETED"` to the activeDemands filter in /projects/[id]/page.tsx. #59/#67: Project creation and edit had two bugs: 1. Both invalidated `project.list` but the page queries `project.listWithCosts` — the list never refreshed after a save. 2. Success toasts were either absent (ProjectModal) or mounted inside the wizard component that unmounts before the toast finishes. Fix: correct invalidation key to listWithCosts; add optional onSuccess prop to both ProjectWizard and ProjectModal; ProjectsClient wires onSuccess to a persistent SuccessToast rendered outside the modals. Co-Authored-By: claude-flow <ruv@ruv.net>
151 lines
8.0 KiB
TypeScript
151 lines
8.0 KiB
TypeScript
import { notFound } from "next/navigation";
|
|
import { formatDate } from "~/lib/format.js";
|
|
import Link from "next/link";
|
|
import { createCaller } from "~/server/trpc.js";
|
|
import { auth } from "~/server/auth.js";
|
|
import { BudgetStatusCard } from "~/components/projects/BudgetStatusCard.js";
|
|
import { ProjectDetailActions } from "~/components/projects/ProjectDetailClient.js";
|
|
import { ProjectDemandsTable } from "~/components/projects/ProjectDemandsTable.js";
|
|
import { ProjectAssignmentsTable } from "~/components/projects/ProjectAssignmentsTable.js";
|
|
import { PROJECT_STATUS_BADGE as STATUS_COLORS, ORDER_TYPE_BADGE as ORDER_TYPE_COLORS } from "~/lib/status-styles.js";
|
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
|
import { CoverArtSection } from "~/components/projects/CoverArtSection.js";
|
|
import { ShoringIndicator } from "~/components/projects/ShoringIndicator.js";
|
|
|
|
const EDIT_ROLES = new Set(["ADMIN", "MANAGER"]);
|
|
|
|
interface ProjectDetailPageProps {
|
|
params: Promise<{ id: string }>;
|
|
}
|
|
|
|
export default async function ProjectDetailPage({ params }: ProjectDetailPageProps) {
|
|
const { id } = await params;
|
|
const [trpc, session] = await Promise.all([createCaller(), auth()]);
|
|
const userRole = (session?.user as { role?: string } | undefined)?.role ?? "USER";
|
|
const canEditProject = EDIT_ROLES.has(userRole);
|
|
|
|
let project: Awaited<ReturnType<typeof trpc.project.getById>>;
|
|
try {
|
|
project = await trpc.project.getById({ id });
|
|
} catch {
|
|
notFound();
|
|
}
|
|
|
|
const activeAssignments = project.assignments.filter((assignment) => assignment.status !== "CANCELLED");
|
|
const activeDemands = project.demands.filter((demand) => demand.status !== "CANCELLED" && demand.status !== "COMPLETED");
|
|
const requestedSeats = activeDemands.reduce((sum, demand) => sum + demand.requestedHeadcount, 0);
|
|
const unfilledSeats = activeDemands.reduce((sum, demand) => sum + demand.unfilledHeadcount, 0);
|
|
|
|
return (
|
|
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
|
{/* Back link */}
|
|
<Link
|
|
href="/projects"
|
|
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-800 transition-colors"
|
|
>
|
|
<svg className="w-4 h-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 Projects
|
|
</Link>
|
|
|
|
{/* Cover Art */}
|
|
<CoverArtSection
|
|
projectId={project.id}
|
|
coverImageUrl={project.coverImageUrl}
|
|
coverFocusY={project.coverFocusY}
|
|
projectColor={project.color}
|
|
projectName={project.name}
|
|
canEdit={canEditProject}
|
|
/>
|
|
|
|
{/* Project header */}
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6 dark:bg-gray-900 dark:border-gray-700">
|
|
<div className="flex items-start justify-between gap-4 mb-4">
|
|
<div>
|
|
<div className="flex items-center gap-3 mb-1">
|
|
<span className="font-mono text-sm font-medium text-gray-500">{project.shortCode}</span>
|
|
<span className={`inline-block px-2 py-0.5 text-xs rounded-full ${STATUS_COLORS[project.status] ?? ""}`}>
|
|
{project.status}
|
|
</span>
|
|
<InfoTooltip content="Project lifecycle status: DRAFT = not yet visible, ACTIVE = in progress, ON_HOLD = paused, COMPLETED = finished, CANCELLED = abandoned." />
|
|
<span className={`inline-block px-2 py-0.5 text-xs rounded-full ${ORDER_TYPE_COLORS[project.orderType] ?? ""}`}>
|
|
{project.orderType}
|
|
</span>
|
|
<InfoTooltip content="BD = Business Development (pre-sales), CHARGEABLE = billable client work, INTERNAL = internal project, OVERHEAD = non-project overhead." />
|
|
</div>
|
|
<h1 className="text-2xl font-bold text-gray-900">{project.name}</h1>
|
|
</div>
|
|
<div className="flex items-start gap-4 flex-shrink-0">
|
|
<div className="text-right text-sm text-gray-500">
|
|
<div className="font-medium text-gray-800">
|
|
{formatDate(project.startDate)}
|
|
{" — "}
|
|
{formatDate(project.endDate)}
|
|
</div>
|
|
<div className="mt-0.5 flex items-center">Win probability: {project.winProbability}%<InfoTooltip content="Likelihood of winning this project (0-100%). Used to calculate weighted pipeline value (budget x probability)." /></div>
|
|
</div>
|
|
<Link
|
|
href={`/projects/${id}/scenario`}
|
|
className="inline-flex items-center gap-2 rounded-lg border border-indigo-300 bg-white px-3 py-2 text-sm font-medium text-indigo-700 shadow-sm hover:bg-indigo-50 transition dark:border-indigo-600 dark:bg-gray-800 dark:text-indigo-300 dark:hover:bg-indigo-900/20"
|
|
title="Open What-If Scenario Planner"
|
|
>
|
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
</svg>
|
|
What-If
|
|
</Link>
|
|
<ProjectDetailActions project={project as never} />
|
|
</div>
|
|
</div>
|
|
|
|
<dl className="grid grid-cols-2 gap-x-8 gap-y-3 sm:grid-cols-4 mt-4 pt-4 border-t border-gray-100">
|
|
<div>
|
|
<dt className="text-xs text-gray-500 flex items-center">Chargecode<InfoTooltip content="Unique project identifier used for time tracking and cost attribution." /></dt>
|
|
<dd className="mt-0.5 text-sm font-mono font-medium text-gray-900">{project.shortCode}</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-xs text-gray-500 flex items-center">Order Type<InfoTooltip content="BD = Business Development, CHARGEABLE = billable client work, INTERNAL = internal project, OVERHEAD = overhead costs." /></dt>
|
|
<dd className="mt-0.5 text-sm text-gray-900">{project.orderType}</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-xs text-gray-500 flex items-center">Allocation Type<InfoTooltip content="INT = staffed with internal resources, EXT = staffed with external contractors." /></dt>
|
|
<dd className="mt-0.5 text-sm text-gray-900">{project.allocationType}</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-xs text-gray-500 flex items-center">Assignments<InfoTooltip content="Number of active resource assignments (confirmed or in-progress allocations) on this project." /></dt>
|
|
<dd className="mt-0.5 text-sm text-gray-900">{activeAssignments.length} active</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-xs text-gray-500 flex items-center">Open Demands<InfoTooltip content="Staffing requirements that still need resources. Unfilled seats are demand positions not yet assigned to a person." /></dt>
|
|
<dd className="mt-0.5 text-sm text-gray-900">
|
|
{activeDemands.length} items · {unfilledSeats}/{requestedSeats} seats unfilled
|
|
</dd>
|
|
</div>
|
|
{project.responsiblePerson && (
|
|
<div className="sm:col-span-2">
|
|
<dt className="text-xs text-gray-500 flex items-center">Responsible Person<InfoTooltip content="The project lead or account manager responsible for this project." /></dt>
|
|
<dd className="mt-0.5 text-sm font-medium text-gray-900">{project.responsiblePerson}</dd>
|
|
</div>
|
|
)}
|
|
</dl>
|
|
</div>
|
|
|
|
{/* Budget status card (client component) */}
|
|
<BudgetStatusCard projectId={project.id} />
|
|
|
|
{/* Nearshore ratio indicator (client component) */}
|
|
<ShoringIndicator projectId={project.id} />
|
|
|
|
{/* Assignments table (client component with delete action) */}
|
|
<ProjectAssignmentsTable assignments={project.assignments as never} />
|
|
|
|
{/* Open demands table (client component with fill action) */}
|
|
<ProjectDemandsTable
|
|
demands={project.demands as never}
|
|
project={{ id: project.id, name: project.name, shortCode: project.shortCode }}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|