// [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 { VOCAB } from "@paperclipai/branding"; import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "@/lib/router"; import { projectsApi } from "../api/projects"; import { useCompany } from "../context/CompanyContext"; import { useDialog } from "../context/DialogContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; import { PageSkeleton } from "../components/PageSkeleton"; import { EmptyState } from "../components/EmptyState"; import { Hexagon } 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() { const { selectedCompanyId } = useCompany(); const { openNewProject } = useDialog(); const { setBreadcrumbs } = useBreadcrumbs(); const navigate = useNavigate(); useEffect(() => { setBreadcrumbs([{ label: "Projects" }]); }, [setBreadcrumbs]); const { data: allProjects, isLoading, error } = useQuery({ queryKey: queryKeys.projects.list(selectedCompanyId!), queryFn: () => projectsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); const projects = useMemo( () => (allProjects ?? []).filter((p) => !p.archivedAt), [allProjects], ); if (!selectedCompanyId) { return ( ); } if (isLoading) { return ; } // Empty state — full-canvas 96px volt hero. if (projects.length === 0) { return (

No projects yet

); } return (
{/* Title row: section heading + new project CTA */}

Projects

{error &&

{error.message}

} {/* Card grid: 1 col <1024px, 2 cols >=1024px, 16px gap */}
{projects.map((project) => { const slug = (project.urlKey ?? project.name).toUpperCase(); const lastActivity = project.updatedAt ? (() => { const d = typeof project.updatedAt === "string" ? new Date(project.updatedAt) : project.updatedAt; 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 ( navigate(projectUrl(project))} /> ); })}
); }