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, type BudgetPolicySummary } from "@paperclipai/shared"; import { budgetsApi } from "../api/budgets"; 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 { useToast } from "../context/ToastContext"; 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 { BudgetPolicyCard } from "../components/BudgetPolicyCard"; 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"; import { PluginLauncherOutlet } from "@/plugins/launchers"; import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots"; /* ── Top-level tab types ── */ type ProjectBaseTab = "overview" | "list" | "configuration" | "budget"; type ProjectPluginTab = `plugin:${string}`; type ProjectTab = ProjectBaseTab | ProjectPluginTab; function isProjectPluginTab(value: string | null): value is ProjectPluginTab { return typeof value === "string" && value.startsWith("plugin:"); } 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 === "budget") return "budget"; 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 { pushToast } = useToast(); 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 activeRouteTab = routeProjectRef ? resolveProjectTab(location.pathname, routeProjectRef) : null; const pluginTabFromSearch = useMemo(() => { const tab = new URLSearchParams(location.search).get("tab"); return isProjectPluginTab(tab) ? tab : null; }, [location.search]); const activeTab = activeRouteTab ?? pluginTabFromSearch; 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; const { slots: pluginDetailSlots, isLoading: pluginDetailSlotsLoading, } = usePluginSlots({ slotTypes: ["detailTab"], entityType: "project", companyId: resolvedCompanyId, enabled: !!resolvedCompanyId, }); const pluginTabItems = useMemo( () => pluginDetailSlots.map((slot) => ({ value: `plugin:${slot.pluginKey}:${slot.id}` as ProjectPluginTab, label: slot.displayName, slot, })), [pluginDetailSlots], ); const activePluginTab = pluginTabItems.find((item) => item.value === activeTab) ?? null; 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 archiveProject = useMutation({ mutationFn: (archived: boolean) => projectsApi.update( projectLookupRef, { archivedAt: archived ? new Date().toISOString() : null }, resolvedCompanyId ?? lookupCompanyId, ), onSuccess: (updatedProject, archived) => { invalidateProject(); const name = updatedProject?.name ?? project?.name ?? "Project"; if (archived) { pushToast({ title: `"${name}" has been archived`, tone: "success" }); navigate("/dashboard"); } else { pushToast({ title: `"${name}" has been unarchived`, tone: "success" }); } }, onError: (_, archived) => { pushToast({ title: archived ? "Failed to archive project" : "Failed to unarchive project", tone: "error", }); }, }); const uploadImage = useMutation({ mutationFn: async (file: File) => { if (!resolvedCompanyId) throw new Error("No company selected"); return assetsApi.uploadImage(resolvedCompanyId, file, `projects/${projectLookupRef || "draft"}`); }, }); const { data: budgetOverview } = useQuery({ queryKey: queryKeys.budgets.overview(resolvedCompanyId ?? "__none__"), queryFn: () => budgetsApi.overview(resolvedCompanyId!), enabled: !!resolvedCompanyId, refetchInterval: 30_000, staleTime: 5_000, }); useEffect(() => { setBreadcrumbs([ { label: "Projects", href: "/projects" }, { label: project?.name ?? routeProjectRef ?? "Project" }, ]); }, [setBreadcrumbs, project, routeProjectRef]); useEffect(() => { if (!project) return; if (routeProjectRef === canonicalProjectRef) return; if (isProjectPluginTab(activeTab)) { navigate(`/projects/${canonicalProjectRef}?tab=${encodeURIComponent(activeTab)}`, { replace: true }); return; } if (activeTab === "overview") { navigate(`/projects/${canonicalProjectRef}/overview`, { replace: true }); return; } if (activeTab === "configuration") { navigate(`/projects/${canonicalProjectRef}/configuration`, { replace: true }); return; } if (activeTab === "budget") { navigate(`/projects/${canonicalProjectRef}/budget`, { 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]); const projectBudgetSummary = useMemo(() => { const matched = budgetOverview?.policies.find( (policy) => policy.scopeType === "project" && policy.scopeId === (project?.id ?? routeProjectRef), ); if (matched) return matched; return { policyId: "", companyId: resolvedCompanyId ?? "", scopeType: "project", scopeId: project?.id ?? routeProjectRef, scopeName: project?.name ?? "Project", metric: "billed_cents", windowKind: "lifetime", amount: 0, observedAmount: 0, remainingAmount: 0, utilizationPercent: 0, warnPercent: 80, hardStopEnabled: true, notifyEnabled: true, isActive: false, status: "ok", paused: Boolean(project?.pausedAt), pauseReason: project?.pauseReason ?? null, windowStart: new Date(), windowEnd: new Date(), } satisfies BudgetPolicySummary; }, [budgetOverview?.policies, project, resolvedCompanyId, routeProjectRef]); const budgetMutation = useMutation({ mutationFn: (amount: number) => budgetsApi.upsertPolicy(resolvedCompanyId!, { scopeType: "project", scopeId: project?.id ?? routeProjectRef, amount, windowKind: "lifetime", }), onSuccess: () => { if (!resolvedCompanyId) return; queryClient.invalidateQueries({ queryKey: queryKeys.budgets.overview(resolvedCompanyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(routeProjectRef) }); queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectLookupRef) }); queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(resolvedCompanyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(resolvedCompanyId) }); }, }); if (pluginTabFromSearch && !pluginDetailSlotsLoading && !activePluginTab) { return ; } // Redirect bare /projects/:id to cached tab or default /issues if (routeProjectRef && activeTab === null) { let cachedTab: string | null = null; if (project?.id) { try { cachedTab = localStorage.getItem(`paperclip:project-tab:${project.id}`); } catch {} } if (cachedTab === "overview") { return ; } if (cachedTab === "configuration") { return ; } if (cachedTab === "budget") { return ; } if (isProjectPluginTab(cachedTab)) { return ; } return ; } if (isLoading) return ; if (error) return

{error.message}

; if (!project) return null; const handleTabChange = (tab: ProjectTab) => { // Cache the active tab per project if (project?.id) { try { localStorage.setItem(`paperclip:project-tab:${project.id}`, tab); } catch {} } if (isProjectPluginTab(tab)) { navigate(`/projects/${canonicalProjectRef}?tab=${encodeURIComponent(tab)}`); return; } if (tab === "overview") { navigate(`/projects/${canonicalProjectRef}/overview`); } else if (tab === "budget") { navigate(`/projects/${canonicalProjectRef}/budget`); } 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" /> {project.pauseReason === "budget" ? (
Paused by budget hard stop
) : null}
handleTabChange(value as ProjectTab)}> ({ value: item.value, label: item.label, })), ]} align="start" value={activeTab ?? "list"} onValueChange={(value) => 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"} onArchive={(archived) => archiveProject.mutate(archived)} archivePending={archiveProject.isPending} />
)} {activeTab === "budget" && resolvedCompanyId ? (
budgetMutation.mutate(amount)} />
) : null} {activePluginTab && ( )}
); }