nexus/ui/src/pages/Issues.tsx
Forgotten d912670f72 Polish UI: enhance dialogs, command palette, and page layouts
Expand NewIssueDialog with richer form fields. Add NewProjectDialog.
Enhance CommandPalette with more actions and search. Improve
CompanySwitcher, EmptyState, and IssueProperties. Flesh out Activity,
Companies, Dashboard, and Inbox pages with real content and layouts.
Refine sidebar, routing, and dialog context. CSS tweaks for dark theme.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 10:53:20 -06:00

163 lines
5.8 KiB
TypeScript

import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { issuesApi } from "../api/issues";
import { useApi } from "../hooks/useApi";
import { useAgents } from "../hooks/useAgents";
import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { groupBy } from "../lib/groupBy";
import { StatusIcon } from "../components/StatusIcon";
import { PriorityIcon } from "../components/PriorityIcon";
import { EntityRow } from "../components/EntityRow";
import { EmptyState } from "../components/EmptyState";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { CircleDot, Plus } from "lucide-react";
import { formatDate } from "../lib/utils";
import type { Issue } from "@paperclip/shared";
const statusOrder = ["in_progress", "todo", "backlog", "in_review", "blocked", "done", "cancelled"];
function statusLabel(status: string): string {
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
type TabFilter = "all" | "active" | "backlog" | "done";
function filterIssues(issues: Issue[], tab: TabFilter): Issue[] {
switch (tab) {
case "active":
return issues.filter((i) => ["todo", "in_progress", "in_review", "blocked"].includes(i.status));
case "backlog":
return issues.filter((i) => i.status === "backlog");
case "done":
return issues.filter((i) => ["done", "cancelled"].includes(i.status));
default:
return issues;
}
}
export function Issues() {
const { selectedCompanyId } = useCompany();
const { openNewIssue } = useDialog();
const { setBreadcrumbs } = useBreadcrumbs();
const navigate = useNavigate();
const [tab, setTab] = useState<TabFilter>("all");
const { data: agents } = useAgents(selectedCompanyId);
useEffect(() => {
setBreadcrumbs([{ label: "Issues" }]);
}, [setBreadcrumbs]);
const fetcher = useCallback(() => {
if (!selectedCompanyId) return Promise.resolve([]);
return issuesApi.list(selectedCompanyId);
}, [selectedCompanyId]);
const { data: issues, loading, error, reload } = useApi(fetcher);
const agentName = (id: string | null) => {
if (!id || !agents) return null;
return agents.find((a) => a.id === id)?.name ?? null;
};
async function handleStatusChange(issue: Issue, status: string) {
await issuesApi.update(issue.id, { status });
reload();
}
if (!selectedCompanyId) {
return <EmptyState icon={CircleDot} message="Select a company to view issues." />;
}
const filtered = filterIssues(issues ?? [], tab);
const grouped = groupBy(filtered, (i) => i.status);
const orderedGroups = statusOrder
.filter((s) => grouped[s]?.length)
.map((s) => ({ status: s, items: grouped[s]! }));
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Issues</h2>
<Button size="sm" onClick={() => openNewIssue()}>
<Plus className="h-4 w-4 mr-1" />
New Issue
</Button>
</div>
<Tabs value={tab} onValueChange={(v) => setTab(v as TabFilter)}>
<TabsList>
<TabsTrigger value="all">All Issues</TabsTrigger>
<TabsTrigger value="active">Active</TabsTrigger>
<TabsTrigger value="backlog">Backlog</TabsTrigger>
<TabsTrigger value="done">Done</TabsTrigger>
</TabsList>
</Tabs>
{loading && <p className="text-sm text-muted-foreground">Loading...</p>}
{error && <p className="text-sm text-destructive">{error.message}</p>}
{issues && filtered.length === 0 && (
<EmptyState
icon={CircleDot}
message="No issues found."
action="Create Issue"
onAction={() => openNewIssue()}
/>
)}
{orderedGroups.map(({ status, items }) => (
<div key={status}>
<div className="flex items-center gap-2 px-4 py-2 bg-muted/50 rounded-t-md">
<StatusIcon status={status} />
<span className="text-xs font-semibold uppercase tracking-wide">
{statusLabel(status)}
</span>
<span className="text-xs text-muted-foreground">{items.length}</span>
<Button
variant="ghost"
size="icon-xs"
className="ml-auto text-muted-foreground"
onClick={() => openNewIssue({ status })}
>
<Plus className="h-3 w-3" />
</Button>
</div>
<div className="border border-border rounded-b-md">
{items.map((issue) => (
<EntityRow
key={issue.id}
identifier={issue.id.slice(0, 8)}
title={issue.title}
onClick={() => navigate(`/issues/${issue.id}`)}
leading={
<>
<PriorityIcon priority={issue.priority} />
<StatusIcon
status={issue.status}
onChange={(s) => handleStatusChange(issue, s)}
/>
</>
}
trailing={
<div className="flex items-center gap-3">
{issue.assigneeAgentId && (
<span className="text-xs text-muted-foreground">
{agentName(issue.assigneeAgentId) ?? issue.assigneeAgentId.slice(0, 8)}
</span>
)}
<span className="text-xs text-muted-foreground">
{formatDate(issue.createdAt)}
</span>
</div>
}
/>
))}
</div>
</div>
))}
</div>
);
}