refactor(nexus): rewrite Projects list with hero-stat cards (phase 11)

Replaces the EntityRow table with the spec §7.1 hero-stat card grid:
full-width cards on <1024px, 2-up on lg+, 16px gap. Each card uses
the new ProjectCard component (72px Inter Black volt hero, status
dot, progress bar, sub-line, footer).

Adds the spec's empty state — full-bleed 96px Inter Black volt
"NO PROJECTS YET" with a forest-green "⊕ START YOUR FIRST PROJECT"
CTA. The regular top-right "⊕ NEW PROJECT" CTA is also forest-green.
Both CTAs wire to the existing openNewProject dialog.

Data gaps: the card passes progress=null, phase=null, nextGateName=
null, costBurnedCents=null because the shared Project type doesn't
carry those fields yet. ProjectCard renders em-dash placeholders
gracefully. Status is mapped from the existing ProjectStatus enum
(paused → waiting, active/running → working, else idle).

Also fixes an unrelated test fixture that used the stale "active"
ProjectStatus literal which tsc rejects.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nexus Dev 2026-04-11 12:23:14 +00:00
parent 06c6317c25
commit c3689f11b1
2 changed files with 125 additions and 43 deletions

View file

@ -10,7 +10,7 @@ import { OverviewTab, type OverviewTabProps } from "./OverviewTab";
function baseProps(overrides: Partial<OverviewTabProps> = {}): OverviewTabProps { function baseProps(overrides: Partial<OverviewTabProps> = {}): OverviewTabProps {
return { return {
project: { name: "nexus-design-migration", status: "active" }, project: { name: "nexus-design-migration", status: "in_progress" },
progress: 47, progress: 47,
activeAgentsCount: 4, activeAgentsCount: 4,
milestones: null, milestones: null,

View file

@ -1,23 +1,45 @@
// [nexus] Phase 11 — Projects list page (hero-stat cards).
//
// Replaces the old Paperclip EntityRow table with the spec §7.1
// hero-stat card grid. Cards stack full-width on <1024px and 2-up on
// >=1024px. A forest-green "+ NEW PROJECT" CTA sits top-right. When
// the project list is empty we render the 96px "NO PROJECTS YET"
// empty state canvas.
//
// Data gaps (Phase 11): the shared Project type has no
// milestoneProgress, nextGate, costBurned, lastActivity, or per-
// project agent counts. Each card renders "—" placeholders where
// data is missing. See the Phase 11 report for the full gap list.
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { VOCAB } from "@paperclipai/branding"; import { VOCAB } from "@paperclipai/branding";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "@/lib/router";
import { projectsApi } from "../api/projects"; import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext"; import { useDialog } from "../context/DialogContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { EntityRow } from "../components/EntityRow";
import { StatusBadge } from "../components/StatusBadge";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton"; import { PageSkeleton } from "../components/PageSkeleton";
import { formatDate, projectUrl } from "../lib/utils"; import { EmptyState } from "../components/EmptyState";
import { Button } from "@/components/ui/button"; import { Hexagon } from "lucide-react";
import { Hexagon, Plus } from "lucide-react"; import { projectUrl } from "../lib/utils";
import { cn } from "@/lib/utils";
import { ProjectCard, type ProjectCardStatus } from "../components/projects/ProjectCard";
function deriveStatus(status: string): ProjectCardStatus {
// Waiting > working > idle priority. For Phase 11 we only have the
// coarse ProjectStatus enum to work with; the finer-grained "has
// pending gate" / "has working agent" checks are data gaps.
if (status === "paused" || status === "awaiting_input") return "waiting";
if (status === "active" || status === "running") return "working";
return "idle";
}
export function Projects() { export function Projects() {
const { selectedCompanyId } = useCompany(); const { selectedCompanyId } = useCompany();
const { openNewProject } = useDialog(); const { openNewProject } = useDialog();
const { setBreadcrumbs } = useBreadcrumbs(); const { setBreadcrumbs } = useBreadcrumbs();
const navigate = useNavigate();
useEffect(() => { useEffect(() => {
setBreadcrumbs([{ label: "Projects" }]); setBreadcrumbs([{ label: "Projects" }]);
@ -28,61 +50,121 @@ export function Projects() {
queryFn: () => projectsApi.list(selectedCompanyId!), queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId, enabled: !!selectedCompanyId,
}); });
const projects = useMemo( const projects = useMemo(
() => (allProjects ?? []).filter((p) => !p.archivedAt), () => (allProjects ?? []).filter((p) => !p.archivedAt),
[allProjects], [allProjects],
); );
if (!selectedCompanyId) { if (!selectedCompanyId) {
return <EmptyState icon={Hexagon} message={`Select a ${VOCAB.company.toLowerCase()} to view projects.`} />; return (
<EmptyState
icon={Hexagon}
message={`Select a ${VOCAB.company.toLowerCase()} to view projects.`}
/>
);
} }
if (isLoading) { if (isLoading) {
return <PageSkeleton variant="list" />; return <PageSkeleton variant="list" />;
} }
// Empty state — full-canvas 96px volt hero.
if (projects.length === 0) {
return (
<div
data-testid="projects-empty-state"
className="flex min-h-[60vh] flex-col items-start justify-center gap-8"
>
<h2
data-testid="projects-empty-hero"
className="font-black uppercase text-[96px] leading-none text-primary"
>
No projects yet
</h2>
<button
type="button"
onClick={openNewProject}
data-testid="projects-empty-cta"
className={cn(
"inline-flex items-center gap-2 rounded-md px-5 py-3",
"bg-[#166534] text-white",
"text-[14px] font-semibold uppercase tracking-[0.1em]",
"hover:bg-[#166534]/90 transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
>
<span aria-hidden="true"></span> Start your first project
</button>
</div>
);
}
return ( return (
<div className="space-y-4"> <div data-testid="projects-page" className="space-y-6">
<div className="flex items-center justify-end"> {/* Title row: section heading + new project CTA */}
<Button size="sm" variant="outline" onClick={openNewProject}> <div className="flex items-center justify-between">
<Plus className="h-4 w-4 mr-1" /> <h2 className="text-[14px] font-bold uppercase tracking-[0.14em] text-muted-foreground">
Add Project Projects
</Button> </h2>
<button
type="button"
onClick={openNewProject}
data-testid="projects-new-project-cta"
className={cn(
"inline-flex items-center gap-2 rounded-md px-4 py-2",
"bg-[#166534] text-white",
"text-[12px] font-semibold uppercase tracking-[0.12em]",
"hover:bg-[#166534]/90 transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
>
<span aria-hidden="true"></span> New project
</button>
</div> </div>
{error && <p className="text-sm text-destructive">{error.message}</p>} {error && <p className="text-sm text-destructive">{error.message}</p>}
{!isLoading && projects.length === 0 && ( {/* Card grid: 1 col <1024px, 2 cols >=1024px, 16px gap */}
<EmptyState <div
icon={Hexagon} data-testid="projects-grid"
message="No projects yet." className="grid grid-cols-1 gap-4 lg:grid-cols-2"
action="Add Project" >
onAction={openNewProject} {projects.map((project) => {
/> const slug = (project.urlKey ?? project.name).toUpperCase();
)} const lastActivity = project.updatedAt
? (() => {
{projects.length > 0 && ( const d = typeof project.updatedAt === "string"
<div className="border border-border"> ? new Date(project.updatedAt)
{projects.map((project) => ( : project.updatedAt;
<EntityRow const ms = Date.now() - d.getTime();
const mins = Math.floor(ms / 60_000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
return `${days}d ago`;
})()
: null;
return (
<ProjectCard
key={project.id} key={project.id}
title={project.name} slug={slug}
subtitle={project.description ?? undefined} status={deriveStatus(project.status)}
to={projectUrl(project)} // Data gap: no milestoneProgress on the shared Project type
trailing={ // yet — ProjectCard renders "—%" when null.
<div className="flex items-center gap-3"> progress={null}
{project.targetDate && ( phase={null}
<span className="text-xs text-muted-foreground"> nextGateName={null}
{formatDate(project.targetDate)} costBurnedCents={null}
</span> lastActivity={lastActivity}
)} ariaLabel={`Open project ${slug}`}
<StatusBadge status={project.status} /> onClick={() => navigate(projectUrl(project))}
</div>
}
/> />
))} );
</div> })}
)} </div>
</div> </div>
); );
} }