Files
kandev/apps/web/app/office/projects/projects-page-client.tsx
T
2026-06-18 18:36:48 +01:00

104 lines
3.3 KiB
TypeScript

"use client";
import { useCallback, useEffect, useState } from "react";
import { IconPlus } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { useAppStore } from "@/components/state-provider";
import { useOfficeRefetch } from "@/hooks/use-office-refetch";
import { listProjects } from "@/lib/api/domains/office-api";
import { agentProfileId as toAgentProfileId } from "@/lib/types/ids";
import type { Project } from "@/lib/state/slices/office/types";
import { ProjectCard } from "./project-card";
import { CreateProjectDialog } from "./create-project-dialog";
import { EmptyState } from "../components/shared/empty-state";
type ProjectsPageClientProps = {
initialProjects: Project[];
};
export function ProjectsPageClient({ initialProjects }: ProjectsPageClientProps) {
const projects = useAppStore((s) => s.office.projects);
const agents = useAppStore((s) => s.office.agentProfiles);
const setProjects = useAppStore((s) => s.setProjects);
const activeWorkspaceId = useAppStore((s) => s.workspaces.activeId);
const [dialogOpen, setDialogOpen] = useState(false);
// Hydrate from SSR; subsequent updates flow through the WS-driven
// refetch below. Skipping the unconditional mount fetch removes a
// redundant round-trip when SSR data is already in the store
// (Stream G of office optimization).
useEffect(() => {
if (initialProjects.length > 0) {
setProjects(initialProjects);
}
}, [initialProjects, setProjects]);
const loadProjects = useCallback(async () => {
if (!activeWorkspaceId) return;
try {
const res = await listProjects(activeWorkspaceId);
setProjects(res?.projects ?? []);
} catch {
// Silently handle fetch errors
}
}, [activeWorkspaceId, setProjects]);
useEffect(() => {
void loadProjects();
}, [loadProjects]);
useOfficeRefetch("projects", loadProjects);
const agentNameMap = new Map(agents.map((a) => [a.id, a.name]));
return (
<div className="space-y-4 p-6">
<div className="flex justify-end">
<Button size="sm" onClick={() => setDialogOpen(true)} className="cursor-pointer">
<IconPlus className="h-4 w-4 mr-1" />
New Project
</Button>
</div>
{projects.length === 0 ? (
<EmptyState
message="No projects yet."
description="Projects group related tasks and repositories together."
action={
<Button
variant="outline"
onClick={() => setDialogOpen(true)}
className="cursor-pointer"
>
<IconPlus className="h-4 w-4 mr-1" />
Create your first project
</Button>
}
/>
) : (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{projects.map((project) => (
<ProjectCard
key={project.id}
project={project}
leadAgentName={
project.leadAgentProfileId
? agentNameMap.get(toAgentProfileId(project.leadAgentProfileId))
: undefined
}
/>
))}
</div>
)}
{activeWorkspaceId && (
<CreateProjectDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
workspaceId={activeWorkspaceId}
/>
)}
</div>
);
}