import { useCallback, 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, type ProjectConfigFieldKey, type ProjectFieldSaveState } 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 [fieldSaveStates, setFieldSaveStates] = useState>>({}); const fieldSaveRequestIds = useRef>>({}); const fieldSaveTimers = useRef>>>({}); 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]); useEffect(() => { return () => { Object.values(fieldSaveTimers.current).forEach((timer) => { if (timer) clearTimeout(timer); }); }; }, []); const setFieldState = useCallback((field: ProjectConfigFieldKey, state: ProjectFieldSaveState) => { setFieldSaveStates((current) => ({ ...current, [field]: state })); }, []); const scheduleFieldReset = useCallback((field: ProjectConfigFieldKey, delayMs: number) => { const existing = fieldSaveTimers.current[field]; if (existing) clearTimeout(existing); fieldSaveTimers.current[field] = setTimeout(() => { setFieldSaveStates((current) => { const next = { ...current }; delete next[field]; return next; }); delete fieldSaveTimers.current[field]; }, delayMs); }, []); const updateProjectField = useCallback(async (field: ProjectConfigFieldKey, data: Record) => { const requestId = (fieldSaveRequestIds.current[field] ?? 0) + 1; fieldSaveRequestIds.current[field] = requestId; setFieldState(field, "saving"); try { await projectsApi.update(projectLookupRef, data, resolvedCompanyId ?? lookupCompanyId); invalidateProject(); if (fieldSaveRequestIds.current[field] !== requestId) return; setFieldState(field, "saved"); scheduleFieldReset(field, 1800); } catch (error) { if (fieldSaveRequestIds.current[field] !== requestId) return; setFieldState(field, "error"); scheduleFieldReset(field, 3000); throw error; } }, [invalidateProject, lookupCompanyId, projectLookupRef, resolvedCompanyId, scheduleFieldReset, setFieldState]); // 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)} onFieldUpdate={updateProjectField} getFieldSaveState={(field) => fieldSaveStates[field] ?? "idle"} />
)}
); }