import { useState, useMemo, useCallback, useEffect } from "react"; import { Plus, LayoutGrid, Filter, ArrowUpDown, Activity, CheckCircle, AlertCircle, Folder, Inbox, Server } from "lucide-react"; import "./ProjectOverview.css"; import type { ProjectInfo, ProjectHealth, NodeInfo, ProjectInfoWithSource } from "../api"; import type { ProjectStatus } from "@fusion/core"; import { ProjectCard } from "./ProjectCard"; import { ProjectGridSkeleton } from "./ProjectGridSkeleton"; import { useProjectHealth } from "../hooks/useProjectHealth"; export interface ProjectOverviewProps { projects: ProjectInfoWithSource[]; loading?: boolean; onSelectProject: (project: ProjectInfo) => void; onAddProject: () => void; onPauseProject: (project: ProjectInfo) => void; onResumeProject: (project: ProjectInfo) => void; onRemoveProject: (project: ProjectInfo) => void; onViewAllProjects?: () => void; nodes?: NodeInfo[]; } type FilterTab = "all" | "active" | "paused" | "errored"; type SortOption = "name" | "activity" | "status"; interface ProjectWithHealth { project: ProjectInfoWithSource; health: ProjectHealth | null; } /** * ProjectOverview - Multi-project grid view with stats and filtering * * Displays all projects in a responsive grid with: * - Header stats: total projects, active tasks, completed tasks * - Filter tabs: All, Active, Paused, Errored * - Sort dropdown: Name, Last Activity, Status * - Project cards with health indicators * - Empty state when no projects */ export function ProjectOverview({ projects, loading = false, onSelectProject, onAddProject, onPauseProject, onResumeProject, onRemoveProject, nodes = [], }: ProjectOverviewProps) { const [activeFilter, setActiveFilter] = useState("all"); const [activeNodeFilter, setActiveNodeFilter] = useState(null); const [sortBy, setSortBy] = useState("activity"); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc"); // Track recently accessed projects for quick selection useEffect(() => { if (typeof window === "undefined") return; // Load recently accessed from localStorage const recent = localStorage.getItem("kb-dashboard-recent-projects"); if (recent) { try { const parsed = JSON.parse(recent) as string[]; setRecentProjectIds(parsed); } catch { // Ignore parse errors } } }, []); const [recentProjectIds, setRecentProjectIds] = useState([]); // Fetch health for all projects const projectIds = useMemo(() => projects.map((p) => p.id), [projects]); const { healthMap, loading: healthLoading } = useProjectHealth(projectIds); // Combine projects with their health data const projectsWithHealth: ProjectWithHealth[] = useMemo(() => { return projects.map((project) => ({ project, health: healthMap[project.id] || null, })); }, [projects, healthMap]); // Filter projects const filteredProjects = useMemo(() => { let filtered = [...projectsWithHealth]; if (activeFilter !== "all") { filtered = filtered.filter(({ project }) => project.status === activeFilter); } // Filter by node if a node filter is active if (activeNodeFilter !== null) { filtered = filtered.filter(({ project }) => project.nodeId === activeNodeFilter); } return filtered; }, [projectsWithHealth, activeFilter, activeNodeFilter]); // Sort projects const sortedProjects = useMemo(() => { const sorted = [...filteredProjects]; sorted.sort((a, b) => { let comparison = 0; switch (sortBy) { case "name": comparison = a.project.name.localeCompare(b.project.name); break; case "activity": { const aTime = a.project.lastActivityAt || a.health?.lastActivityAt || a.project.updatedAt; const bTime = b.project.lastActivityAt || b.health?.lastActivityAt || b.project.updatedAt; comparison = new Date(bTime).getTime() - new Date(aTime).getTime(); break; } case "status": { const statusOrder: Record = { errored: 0, initializing: 1, paused: 2, active: 3, }; comparison = statusOrder[a.project.status] - statusOrder[b.project.status]; break; } } return sortDirection === "asc" ? comparison : -comparison; }); return sorted; }, [filteredProjects, sortBy, sortDirection]); // Calculate stats const stats = useMemo(() => { const totalProjects = projects.length; const activeProjects = projects.filter((p) => p.status === "active").length; const erroredProjects = projects.filter((p) => p.status === "errored").length; // Count unique nodes with projects (local + remote) const nodesWithProjects = new Set(); projects.forEach((p) => { if (p.nodeId) { nodesWithProjects.add(p.nodeId); } }); const totalNodes = nodesWithProjects.size || (totalProjects > 0 ? 1 : 0); let totalActiveTasks = 0; let totalCompletedTasks = 0; let totalInFlightAgents = 0; Object.values(healthMap).forEach((health) => { if (health) { totalActiveTasks += health.activeTaskCount; totalCompletedTasks += health.totalTasksCompleted; totalInFlightAgents += health.inFlightAgentCount; } }); return { totalProjects, activeProjects, erroredProjects, totalNodes, totalActiveTasks, totalCompletedTasks, totalInFlightAgents, }; }, [projects, healthMap]); // Filter counts const filterCounts = useMemo(() => { return { all: projects.length, active: projects.filter((p) => p.status === "active").length, paused: projects.filter((p) => p.status === "paused").length, errored: projects.filter((p) => p.status === "errored").length, }; }, [projects]); // Node filter options with project counts const nodeFilterOptions = useMemo(() => { const nodeCounts = new Map(); projects.forEach((p) => { const nodeId = p.nodeId; const existing = nodeCounts.get(nodeId); if (existing) { existing.count++; } else { // Get the node name from the nodes list or use _sourceNodeName for remote projects const localNode = nodes.find((n) => n.id === nodeId); const nodeName = localNode?.name ?? p._sourceNodeName ?? "Local"; nodeCounts.set(nodeId, { name: nodeName, count: 1 }); } }); return Array.from(nodeCounts.entries()).map(([nodeId, { name, count }]) => ({ nodeId: nodeId ?? null, name, count, })); }, [projects, nodes]); // Handle project selection const handleSelectProject = useCallback((project: ProjectInfo) => { // Update recent projects in localStorage const updated = [project.id, ...recentProjectIds.filter((id) => id !== project.id)].slice(0, 3); setRecentProjectIds(updated); if (typeof window !== "undefined") { localStorage.setItem("kb-dashboard-recent-projects", JSON.stringify(updated)); } onSelectProject(project); }, [onSelectProject, recentProjectIds]); // Determine if we need to show skeleton // Show skeleton for initial load if: // 1. Projects list is still loading, OR // 2. Projects exist but we haven't fetched health data yet (healthLoading with no data) // Don't show skeleton during background health polling when health data already exists const needsInitialSkeleton = loading || (healthLoading && projects.length > 0 && Object.keys(healthMap).length === 0); // Show skeleton while loading if (needsInitialSkeleton) { return ; } // Empty state when no projects if (projects.length === 0) { return (

No Projects Found

Get started by adding your first project. Projects allow you to organize and track tasks across multiple repositories.

); } return (
{/* Header with stats */}

Projects

{stats.totalProjects} Total
{stats.totalActiveTasks} Active Tasks
{stats.totalCompletedTasks} Completed
{stats.erroredProjects > 0 && (
{stats.erroredProjects} Errored
)} {stats.totalNodes > 1 && (
{stats.totalNodes} Nodes
)}
{/* Filter tabs */}
{/* Node filter dropdown */} {nodeFilterOptions.length > 1 && (
)} {/* Sort dropdown */}
{/* Project grid */}
{sortedProjects.map(({ project, health }) => { const projectNode = nodes.find((node) => node.id === project.nodeId); // Fallback: use server-provided _sourceNodeName for remote projects const nodeNameFallback = !projectNode ? project._sourceNodeName : undefined; return ( ); })}
{/* No results state */} {sortedProjects.length === 0 && (

No projects match the current filter

)}
); }