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:
parent
06c6317c25
commit
c3689f11b1
2 changed files with 125 additions and 43 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue