import { useEffect, useMemo, useState, useRef } from "react"; import { useParams, useNavigate, useLocation, Navigate } from "react-router-dom"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { PROJECT_COLORS } from "@paperclip/shared"; import { projectsApi } from "../api/projects"; import { issuesApi } from "../api/issues"; import { agentsApi } from "../api/agents"; import { heartbeatsApi } from "../api/heartbeats"; import { assetsApi } from "../api/assets"; import { usePanel } from "../context/PanelContext"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; import { ProjectProperties } from "../components/ProjectProperties"; import { InlineEditor } from "../components/InlineEditor"; import { StatusBadge } from "../components/StatusBadge"; import { IssuesList } from "../components/IssuesList"; /* ── Top-level tab types ── */ type ProjectTab = "overview" | "list"; function resolveProjectTab(pathname: string, projectId: string): ProjectTab | null { const prefix = `/projects/${projectId}`; if (pathname === `${prefix}/overview`) return "overview"; if (pathname.startsWith(`${prefix}/issues`)) return "list"; return null; } /* ── Overview tab content ── */ function OverviewContent({ project, onUpdate, imageUploadHandler, }: { project: { description: string | null; status: string; targetDate: string | null }; onUpdate: (data: Record) => void; imageUploadHandler?: (file: File) => Promise; }) { return (
onUpdate({ description })} as="p" className="text-sm text-muted-foreground" placeholder="Add a description..." multiline imageUploadHandler={imageUploadHandler} />
Status
{project.targetDate && (
Target Date

{project.targetDate}

)}
); } /* ── Color picker popover ── */ function ColorPicker({ currentColor, onSelect, }: { currentColor: string; onSelect: (color: string) => void; }) { const [open, setOpen] = useState(false); const ref = useRef(null); useEffect(() => { if (!open) return; function handleClick(e: MouseEvent) { if (ref.current && !ref.current.contains(e.target as Node)) { setOpen(false); } } document.addEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick); }, [open]); return (
)} ); } /* ── List (issues) tab content ── */ function ProjectIssuesList({ projectId }: { projectId: string }) { const { selectedCompanyId } = useCompany(); const queryClient = useQueryClient(); const { data: agents } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), queryFn: () => agentsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); const { data: liveRuns } = useQuery({ queryKey: queryKeys.liveRuns(selectedCompanyId!), queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!), enabled: !!selectedCompanyId, refetchInterval: 5000, }); const liveIssueIds = useMemo(() => { const ids = new Set(); for (const run of liveRuns ?? []) { if (run.issueId) ids.add(run.issueId); } return ids; }, [liveRuns]); const { data: issues, isLoading, error } = useQuery({ queryKey: queryKeys.issues.listByProject(selectedCompanyId!, projectId), queryFn: () => issuesApi.list(selectedCompanyId!, { projectId }), enabled: !!selectedCompanyId, }); const updateIssue = useMutation({ mutationFn: ({ id, data }: { id: string; data: Record }) => issuesApi.update(id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(selectedCompanyId!, projectId) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId!) }); }, }); return ( updateIssue.mutate({ id, data })} /> ); } /* ── Main project page ── */ export function ProjectDetail() { const { projectId } = useParams<{ projectId: string }>(); const { selectedCompanyId } = useCompany(); const { openPanel, closePanel } = usePanel(); const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); const navigate = useNavigate(); const location = useLocation(); const activeTab = projectId ? resolveProjectTab(location.pathname, projectId) : null; const { data: project, isLoading, error } = useQuery({ queryKey: queryKeys.projects.detail(projectId!), queryFn: () => projectsApi.get(projectId!), enabled: !!projectId, }); const invalidateProject = () => { queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId!) }); if (selectedCompanyId) { queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(selectedCompanyId) }); } }; const updateProject = useMutation({ mutationFn: (data: Record) => projectsApi.update(projectId!, data), onSuccess: invalidateProject, }); const uploadImage = useMutation({ mutationFn: async (file: File) => { if (!selectedCompanyId) throw new Error("No company selected"); return assetsApi.uploadImage(selectedCompanyId, file, `projects/${projectId ?? "draft"}`); }, }); useEffect(() => { setBreadcrumbs([ { label: "Projects", href: "/projects" }, { label: project?.name ?? projectId ?? "Project" }, ]); }, [setBreadcrumbs, project, projectId]); useEffect(() => { if (project) { openPanel( updateProject.mutate(data)} />); } return () => closePanel(); }, [project]); // eslint-disable-line react-hooks/exhaustive-deps // Redirect bare /projects/:id to /projects/:id/issues if (projectId && activeTab === null) { return ; } if (isLoading) return

Loading...

; if (error) return

{error.message}

; if (!project) return null; const handleTabChange = (tab: ProjectTab) => { if (tab === "overview") { navigate(`/projects/${projectId}/overview`); } else { navigate(`/projects/${projectId}/issues`); } }; return (
updateProject.mutate({ color })} />
updateProject.mutate({ name })} as="h2" className="text-xl font-bold" />
{/* Top-level project tabs */}
{/* Tab content */} {activeTab === "overview" && ( updateProject.mutate(data)} imageUploadHandler={async (file) => { const asset = await uploadImage.mutateAsync(file); return asset.contentPath; }} /> )} {activeTab === "list" && projectId && ( )}
); }