import { useEffect, useMemo, useState, useRef } from "react"; import { useParams, useNavigate, useLocation, Navigate } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { PROJECT_COLORS, isUuidLike } from "@paperclipai/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"; import { PageSkeleton } from "../components/PageSkeleton"; import { PageTabBar } from "../components/PageTabBar"; import { projectRouteRef, cn } from "../lib/utils"; import { Tabs } from "@/components/ui/tabs"; /* ── Top-level tab types ── */ type ProjectTab = "overview" | "list" | "configuration"; function resolveProjectTab(pathname: string, projectId: string): ProjectTab | null { const segments = pathname.split("/").filter(Boolean); const projectsIdx = segments.indexOf("projects"); if (projectsIdx === -1 || segments[projectsIdx + 1] !== projectId) return null; const tab = segments[projectsIdx + 2]; if (tab === "overview") return "overview"; if (tab === "configuration") return "configuration"; if (tab === "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, companyId }: { projectId: string; companyId: string }) { const queryClient = useQueryClient(); const { data: agents } = useQuery({ queryKey: queryKeys.agents.list(companyId), queryFn: () => agentsApi.list(companyId), enabled: !!companyId, }); const { data: liveRuns } = useQuery({ queryKey: queryKeys.liveRuns(companyId), queryFn: () => heartbeatsApi.liveRunsForCompany(companyId), enabled: !!companyId, 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(companyId, projectId), queryFn: () => issuesApi.list(companyId, { projectId }), enabled: !!companyId, }); const updateIssue = useMutation({ mutationFn: ({ id, data }: { id: string; data: Record }) => issuesApi.update(id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) }); }, }); return ( updateIssue.mutate({ id, data })} /> ); } /* ── Main project page ── */ export function ProjectDetail() { const { companyPrefix, projectId, filter } = useParams<{ companyPrefix?: string; projectId: string; filter?: string; }>(); const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany(); const { closePanel } = usePanel(); const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); const navigate = useNavigate(); const location = useLocation(); const routeProjectRef = projectId ?? ""; const routeCompanyId = useMemo(() => { if (!companyPrefix) return null; const requestedPrefix = companyPrefix.toUpperCase(); return companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix)?.id ?? null; }, [companies, companyPrefix]); const lookupCompanyId = routeCompanyId ?? selectedCompanyId ?? undefined; const canFetchProject = routeProjectRef.length > 0 && (isUuidLike(routeProjectRef) || Boolean(lookupCompanyId)); const activeTab = routeProjectRef ? resolveProjectTab(location.pathname, routeProjectRef) : null; const { data: project, isLoading, error } = useQuery({ queryKey: [...queryKeys.projects.detail(routeProjectRef), lookupCompanyId ?? null], queryFn: () => projectsApi.get(routeProjectRef, lookupCompanyId), enabled: canFetchProject, }); const canonicalProjectRef = project ? projectRouteRef(project) : routeProjectRef; const projectLookupRef = project?.id ?? routeProjectRef; const resolvedCompanyId = project?.companyId ?? selectedCompanyId; useEffect(() => { if (!project?.companyId || project.companyId === selectedCompanyId) return; setSelectedCompanyId(project.companyId, { source: "route_sync" }); }, [project?.companyId, selectedCompanyId, setSelectedCompanyId]); const invalidateProject = () => { queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(routeProjectRef) }); queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectLookupRef) }); if (resolvedCompanyId) { queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(resolvedCompanyId) }); } }; const updateProject = useMutation({ mutationFn: (data: Record) => projectsApi.update(projectLookupRef, data, resolvedCompanyId ?? lookupCompanyId), onSuccess: invalidateProject, }); const uploadImage = useMutation({ mutationFn: async (file: File) => { if (!resolvedCompanyId) throw new Error("No company selected"); return assetsApi.uploadImage(resolvedCompanyId, file, `projects/${projectLookupRef || "draft"}`); }, }); useEffect(() => { setBreadcrumbs([ { label: "Projects", href: "/projects" }, { label: project?.name ?? routeProjectRef ?? "Project" }, ]); }, [setBreadcrumbs, project, routeProjectRef]); useEffect(() => { if (!project) return; if (routeProjectRef === canonicalProjectRef) return; if (activeTab === "overview") { navigate(`/projects/${canonicalProjectRef}/overview`, { replace: true }); return; } if (activeTab === "configuration") { navigate(`/projects/${canonicalProjectRef}/configuration`, { replace: true }); return; } if (activeTab === "list") { if (filter) { navigate(`/projects/${canonicalProjectRef}/issues/${filter}`, { replace: true }); return; } navigate(`/projects/${canonicalProjectRef}/issues`, { replace: true }); return; } navigate(`/projects/${canonicalProjectRef}`, { replace: true }); }, [project, routeProjectRef, canonicalProjectRef, activeTab, filter, navigate]); useEffect(() => { closePanel(); return () => closePanel(); }, [closePanel]); // Redirect bare /projects/:id to /projects/:id/issues if (routeProjectRef && activeTab === null) { return ; } if (isLoading) return ; if (error) return

{error.message}

; if (!project) return null; const handleTabChange = (tab: ProjectTab) => { if (tab === "overview") { navigate(`/projects/${canonicalProjectRef}/overview`); } else if (tab === "configuration") { navigate(`/projects/${canonicalProjectRef}/configuration`); } else { navigate(`/projects/${canonicalProjectRef}/issues`); } }; return (
updateProject.mutate({ color })} />
updateProject.mutate({ name })} as="h2" className="text-xl font-bold" />
handleTabChange(value as ProjectTab)}> handleTabChange(value as ProjectTab)} /> {activeTab === "overview" && ( updateProject.mutate(data)} imageUploadHandler={async (file) => { const asset = await uploadImage.mutateAsync(file); return asset.contentPath; }} /> )} {activeTab === "list" && project?.id && resolvedCompanyId && ( )} {activeTab === "configuration" && ( updateProject.mutate(data)} /> )}
); }