Add detail pages, property panels, and restyle list pages

New pages: AgentDetail, GoalDetail, IssueDetail, ProjectDetail, Inbox,
MyIssues. New feature components: AgentProperties, GoalProperties,
IssueProperties, ProjectProperties, GoalTree, NewIssueDialog. Add
heartbeats API client. Restyle all list pages (Agents, Issues, Goals,
Projects, Dashboard, Costs, Activity, Org) with EntityRow, FilterBar,
and improved layouts. Add routing for detail views.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-17 09:57:06 -06:00
parent fad1bd27ce
commit f4339668f3
22 changed files with 1833 additions and 293 deletions

View File

@@ -4,12 +4,18 @@ import { Dashboard } from "./pages/Dashboard";
import { Companies } from "./pages/Companies";
import { Org } from "./pages/Org";
import { Agents } from "./pages/Agents";
import { AgentDetail } from "./pages/AgentDetail";
import { Projects } from "./pages/Projects";
import { ProjectDetail } from "./pages/ProjectDetail";
import { Issues } from "./pages/Issues";
import { IssueDetail } from "./pages/IssueDetail";
import { Goals } from "./pages/Goals";
import { GoalDetail } from "./pages/GoalDetail";
import { Approvals } from "./pages/Approvals";
import { Costs } from "./pages/Costs";
import { Activity } from "./pages/Activity";
import { Inbox } from "./pages/Inbox";
import { MyIssues } from "./pages/MyIssues";
export function App() {
return (
@@ -19,12 +25,18 @@ export function App() {
<Route path="companies" element={<Companies />} />
<Route path="org" element={<Org />} />
<Route path="agents" element={<Agents />} />
<Route path="agents/:agentId" element={<AgentDetail />} />
<Route path="projects" element={<Projects />} />
<Route path="projects/:projectId" element={<ProjectDetail />} />
<Route path="tasks" element={<Issues />} />
<Route path="issues/:issueId" element={<IssueDetail />} />
<Route path="goals" element={<Goals />} />
<Route path="goals/:goalId" element={<GoalDetail />} />
<Route path="approvals" element={<Approvals />} />
<Route path="costs" element={<Costs />} />
<Route path="activity" element={<Activity />} />
<Route path="inbox" element={<Inbox />} />
<Route path="my-issues" element={<MyIssues />} />
</Route>
</Routes>
);

9
ui/src/api/heartbeats.ts Normal file
View File

@@ -0,0 +1,9 @@
import type { HeartbeatRun } from "@paperclip/shared";
import { api } from "./client";
export const heartbeatsApi = {
list: (companyId: string, agentId?: string) => {
const params = agentId ? `?agentId=${agentId}` : "";
return api.get<HeartbeatRun[]>(`/companies/${companyId}/heartbeat-runs${params}`);
},
};

View File

@@ -0,0 +1,79 @@
import type { Agent } from "@paperclip/shared";
import { StatusBadge } from "./StatusBadge";
import { formatCents, formatDate } from "../lib/utils";
import { Separator } from "@/components/ui/separator";
interface AgentPropertiesProps {
agent: Agent;
}
function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between py-1.5">
<span className="text-xs text-muted-foreground">{label}</span>
<div className="flex items-center gap-1.5">{children}</div>
</div>
);
}
export function AgentProperties({ agent }: AgentPropertiesProps) {
return (
<div className="space-y-4">
<div className="space-y-1">
<PropertyRow label="Status">
<StatusBadge status={agent.status} />
</PropertyRow>
<PropertyRow label="Role">
<span className="text-sm">{agent.role}</span>
</PropertyRow>
{agent.title && (
<PropertyRow label="Title">
<span className="text-sm">{agent.title}</span>
</PropertyRow>
)}
<PropertyRow label="Adapter">
<span className="text-sm font-mono">{agent.adapterType}</span>
</PropertyRow>
<PropertyRow label="Context">
<span className="text-sm">{agent.contextMode}</span>
</PropertyRow>
</div>
<Separator />
<div className="space-y-1">
<PropertyRow label="Budget">
<span className="text-sm">
{formatCents(agent.spentMonthlyCents)} / {formatCents(agent.budgetMonthlyCents)}
</span>
</PropertyRow>
<PropertyRow label="Utilization">
<span className="text-sm">
{agent.budgetMonthlyCents > 0
? Math.round((agent.spentMonthlyCents / agent.budgetMonthlyCents) * 100)
: 0}
%
</span>
</PropertyRow>
</div>
<Separator />
<div className="space-y-1">
{agent.lastHeartbeatAt && (
<PropertyRow label="Last Heartbeat">
<span className="text-sm">{formatDate(agent.lastHeartbeatAt)}</span>
</PropertyRow>
)}
{agent.reportsTo && (
<PropertyRow label="Reports To">
<span className="text-sm font-mono">{agent.reportsTo.slice(0, 8)}</span>
</PropertyRow>
)}
<PropertyRow label="Created">
<span className="text-sm">{formatDate(agent.createdAt)}</span>
</PropertyRow>
</div>
</div>
);
}

View File

@@ -0,0 +1,53 @@
import type { Goal } from "@paperclip/shared";
import { StatusBadge } from "./StatusBadge";
import { formatDate } from "../lib/utils";
import { Separator } from "@/components/ui/separator";
interface GoalPropertiesProps {
goal: Goal;
}
function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between py-1.5">
<span className="text-xs text-muted-foreground">{label}</span>
<div className="flex items-center gap-1.5">{children}</div>
</div>
);
}
export function GoalProperties({ goal }: GoalPropertiesProps) {
return (
<div className="space-y-4">
<div className="space-y-1">
<PropertyRow label="Status">
<StatusBadge status={goal.status} />
</PropertyRow>
<PropertyRow label="Level">
<span className="text-sm capitalize">{goal.level}</span>
</PropertyRow>
{goal.ownerAgentId && (
<PropertyRow label="Owner">
<span className="text-sm font-mono">{goal.ownerAgentId.slice(0, 8)}</span>
</PropertyRow>
)}
{goal.parentId && (
<PropertyRow label="Parent Goal">
<span className="text-sm font-mono">{goal.parentId.slice(0, 8)}</span>
</PropertyRow>
)}
</div>
<Separator />
<div className="space-y-1">
<PropertyRow label="Created">
<span className="text-sm">{formatDate(goal.createdAt)}</span>
</PropertyRow>
<PropertyRow label="Updated">
<span className="text-sm">{formatDate(goal.updatedAt)}</span>
</PropertyRow>
</div>
</div>
);
}

View File

@@ -0,0 +1,91 @@
import type { Goal } from "@paperclip/shared";
import { StatusBadge } from "./StatusBadge";
import { ChevronRight } from "lucide-react";
import { cn } from "../lib/utils";
import { useState } from "react";
interface GoalTreeProps {
goals: Goal[];
onSelect?: (goal: Goal) => void;
}
interface GoalNodeProps {
goal: Goal;
children: Goal[];
allGoals: Goal[];
depth: number;
onSelect?: (goal: Goal) => void;
}
function GoalNode({ goal, children, allGoals, depth, onSelect }: GoalNodeProps) {
const [expanded, setExpanded] = useState(true);
const hasChildren = children.length > 0;
return (
<div>
<div
className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-md text-sm transition-colors cursor-pointer hover:bg-accent/50",
)}
style={{ paddingLeft: `${depth * 20 + 12}px` }}
onClick={() => onSelect?.(goal)}
>
{hasChildren ? (
<button
className="p-0.5"
onClick={(e) => {
e.stopPropagation();
setExpanded(!expanded);
}}
>
<ChevronRight
className={cn("h-3 w-3 transition-transform", expanded && "rotate-90")}
/>
</button>
) : (
<span className="w-4" />
)}
<span className="text-xs text-muted-foreground capitalize">{goal.level}</span>
<span className="flex-1 truncate">{goal.title}</span>
<StatusBadge status={goal.status} />
</div>
{hasChildren && expanded && (
<div>
{children.map((child) => (
<GoalNode
key={child.id}
goal={child}
children={allGoals.filter((g) => g.parentId === child.id)}
allGoals={allGoals}
depth={depth + 1}
onSelect={onSelect}
/>
))}
</div>
)}
</div>
);
}
export function GoalTree({ goals, onSelect }: GoalTreeProps) {
const roots = goals.filter((g) => !g.parentId);
if (goals.length === 0) {
return <p className="text-sm text-muted-foreground">No goals.</p>;
}
return (
<div className="border border-border rounded-md py-1">
{roots.map((goal) => (
<GoalNode
key={goal.id}
goal={goal}
children={goals.filter((g) => g.parentId === goal.id)}
allGoals={goals}
depth={0}
onSelect={onSelect}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,77 @@
import type { Issue } from "@paperclip/shared";
import { StatusIcon } from "./StatusIcon";
import { PriorityIcon } from "./PriorityIcon";
import { formatDate } from "../lib/utils";
import { Separator } from "@/components/ui/separator";
interface IssuePropertiesProps {
issue: Issue;
onUpdate: (data: Record<string, unknown>) => void;
}
function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between py-1.5">
<span className="text-xs text-muted-foreground">{label}</span>
<div className="flex items-center gap-1.5">{children}</div>
</div>
);
}
function statusLabel(status: string): string {
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
function priorityLabel(priority: string): string {
return priority.charAt(0).toUpperCase() + priority.slice(1);
}
export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
return (
<div className="space-y-4">
<div className="space-y-1">
<PropertyRow label="Status">
<StatusIcon
status={issue.status}
onChange={(status) => onUpdate({ status })}
/>
<span className="text-sm">{statusLabel(issue.status)}</span>
</PropertyRow>
<PropertyRow label="Priority">
<PriorityIcon
priority={issue.priority}
onChange={(priority) => onUpdate({ priority })}
/>
<span className="text-sm">{priorityLabel(issue.priority)}</span>
</PropertyRow>
{issue.assigneeAgentId && (
<PropertyRow label="Assignee">
<span className="text-sm font-mono">{issue.assigneeAgentId.slice(0, 8)}</span>
</PropertyRow>
)}
{issue.projectId && (
<PropertyRow label="Project">
<span className="text-sm font-mono">{issue.projectId.slice(0, 8)}</span>
</PropertyRow>
)}
</div>
<Separator />
<div className="space-y-1">
<PropertyRow label="ID">
<span className="text-sm font-mono">{issue.id.slice(0, 8)}</span>
</PropertyRow>
<PropertyRow label="Created">
<span className="text-sm">{formatDate(issue.createdAt)}</span>
</PropertyRow>
<PropertyRow label="Updated">
<span className="text-sm">{formatDate(issue.updatedAt)}</span>
</PropertyRow>
</div>
</div>
);
}

View File

@@ -0,0 +1,142 @@
import { useState } from "react";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { issuesApi } from "../api/issues";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface NewIssueDialogProps {
onCreated?: () => void;
}
export function NewIssueDialog({ onCreated }: NewIssueDialogProps) {
const { newIssueOpen, newIssueDefaults, closeNewIssue } = useDialog();
const { selectedCompanyId } = useCompany();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState(newIssueDefaults.status ?? "todo");
const [priority, setPriority] = useState(newIssueDefaults.priority ?? "medium");
const [submitting, setSubmitting] = useState(false);
function reset() {
setTitle("");
setDescription("");
setStatus("todo");
setPriority("medium");
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!selectedCompanyId || !title.trim()) return;
setSubmitting(true);
try {
await issuesApi.create(selectedCompanyId, {
title: title.trim(),
description: description.trim() || undefined,
status,
priority,
});
reset();
closeNewIssue();
onCreated?.();
} finally {
setSubmitting(false);
}
}
return (
<Dialog
open={newIssueOpen}
onOpenChange={(open) => {
if (!open) {
reset();
closeNewIssue();
}
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>New Issue</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="issue-title">Title</Label>
<Input
id="issue-title"
placeholder="Issue title"
value={title}
onChange={(e) => setTitle(e.target.value)}
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="issue-desc">Description</Label>
<Textarea
id="issue-desc"
placeholder="Add a description..."
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
/>
</div>
<div className="flex gap-4">
<div className="space-y-2 flex-1">
<Label>Status</Label>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="backlog">Backlog</SelectItem>
<SelectItem value="todo">Todo</SelectItem>
<SelectItem value="in_progress">In Progress</SelectItem>
<SelectItem value="in_review">In Review</SelectItem>
<SelectItem value="done">Done</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2 flex-1">
<Label>Priority</Label>
<Select value={priority} onValueChange={setPriority}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="critical">Critical</SelectItem>
<SelectItem value="high">High</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="low">Low</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={closeNewIssue}>
Cancel
</Button>
<Button type="submit" disabled={!title.trim() || submitting}>
{submitting ? "Creating..." : "Create Issue"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,55 @@
import type { Project } from "@paperclip/shared";
import { StatusBadge } from "./StatusBadge";
import { formatDate } from "../lib/utils";
import { Separator } from "@/components/ui/separator";
interface ProjectPropertiesProps {
project: Project;
}
function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between py-1.5">
<span className="text-xs text-muted-foreground">{label}</span>
<div className="flex items-center gap-1.5">{children}</div>
</div>
);
}
export function ProjectProperties({ project }: ProjectPropertiesProps) {
return (
<div className="space-y-4">
<div className="space-y-1">
<PropertyRow label="Status">
<StatusBadge status={project.status} />
</PropertyRow>
{project.leadAgentId && (
<PropertyRow label="Lead">
<span className="text-sm font-mono">{project.leadAgentId.slice(0, 8)}</span>
</PropertyRow>
)}
{project.goalId && (
<PropertyRow label="Goal">
<span className="text-sm font-mono">{project.goalId.slice(0, 8)}</span>
</PropertyRow>
)}
{project.targetDate && (
<PropertyRow label="Target Date">
<span className="text-sm">{formatDate(project.targetDate)}</span>
</PropertyRow>
)}
</div>
<Separator />
<div className="space-y-1">
<PropertyRow label="Created">
<span className="text-sm">{formatDate(project.createdAt)}</span>
</PropertyRow>
<PropertyRow label="Updated">
<span className="text-sm">{formatDate(project.updatedAt)}</span>
</PropertyRow>
</div>
</div>
);
}

View File

@@ -1,12 +1,20 @@
import { useCallback } from "react";
import { useCallback, useEffect } from "react";
import { activityApi } from "../api/activity";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useApi } from "../hooks/useApi";
import { formatDate } from "../lib/utils";
import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "../components/EmptyState";
import { timeAgo } from "../lib/timeAgo";
import { Badge } from "@/components/ui/badge";
import { History } from "lucide-react";
export function Activity() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
useEffect(() => {
setBreadcrumbs([{ label: "Activity" }]);
}, [setBreadcrumbs]);
const fetcher = useCallback(() => {
if (!selectedCompanyId) return Promise.resolve([]);
@@ -16,31 +24,37 @@ export function Activity() {
const { data, loading, error } = useApi(fetcher);
if (!selectedCompanyId) {
return <p className="text-muted-foreground">Select a company first.</p>;
return <EmptyState icon={History} message="Select a company to view activity." />;
}
return (
<div>
<h2 className="text-2xl font-bold mb-4">Activity</h2>
{loading && <p className="text-muted-foreground">Loading...</p>}
{error && <p className="text-destructive">{error.message}</p>}
<div className="space-y-4">
<h2 className="text-lg font-semibold">Activity</h2>
{data && data.length === 0 && <p className="text-muted-foreground">No activity yet.</p>}
{loading && <p className="text-sm text-muted-foreground">Loading...</p>}
{error && <p className="text-sm text-destructive">{error.message}</p>}
{data && data.length === 0 && (
<EmptyState icon={History} message="No activity yet." />
)}
{data && data.length > 0 && (
<div className="space-y-2">
<div className="border border-border rounded-md divide-y divide-border">
{data.map((event) => (
<Card key={event.id}>
<CardContent className="p-3 text-sm">
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{event.action}</span>
<span className="text-xs text-muted-foreground">{formatDate(event.createdAt)}</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
{event.entityType} {event.entityId}
</p>
</CardContent>
</Card>
<div key={event.id} className="px-4 py-3 flex items-center justify-between gap-4">
<div className="flex items-center gap-3 min-w-0">
<Badge variant="secondary" className="shrink-0">
{event.entityType}
</Badge>
<span className="text-sm font-medium">{event.action}</span>
<span className="text-xs text-muted-foreground font-mono truncate">
{event.entityId.slice(0, 8)}
</span>
</div>
<span className="text-xs text-muted-foreground shrink-0">
{timeAgo(event.createdAt)}
</span>
</div>
))}
</div>
)}

View File

@@ -0,0 +1,217 @@
import { useCallback, useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { agentsApi } from "../api/agents";
import { heartbeatsApi } from "../api/heartbeats";
import { issuesApi } from "../api/issues";
import { useApi } from "../hooks/useApi";
import { usePanel } from "../context/PanelContext";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { AgentProperties } from "../components/AgentProperties";
import { StatusBadge } from "../components/StatusBadge";
import { EntityRow } from "../components/EntityRow";
import { formatCents, formatDate } from "../lib/utils";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import type { Issue, HeartbeatRun } from "@paperclip/shared";
export function AgentDetail() {
const { agentId } = useParams<{ agentId: string }>();
const { selectedCompanyId } = useCompany();
const { openPanel, closePanel } = usePanel();
const { setBreadcrumbs } = useBreadcrumbs();
const [actionError, setActionError] = useState<string | null>(null);
const agentFetcher = useCallback(() => {
if (!agentId) return Promise.reject(new Error("No agent ID"));
return agentsApi.get(agentId);
}, [agentId]);
const heartbeatsFetcher = useCallback(() => {
if (!selectedCompanyId || !agentId) return Promise.resolve([] as HeartbeatRun[]);
return heartbeatsApi.list(selectedCompanyId, agentId);
}, [selectedCompanyId, agentId]);
const issuesFetcher = useCallback(() => {
if (!selectedCompanyId) return Promise.resolve([] as Issue[]);
return issuesApi.list(selectedCompanyId);
}, [selectedCompanyId]);
const { data: agent, loading, error, reload: reloadAgent } = useApi(agentFetcher);
const { data: heartbeats } = useApi(heartbeatsFetcher);
const { data: allIssues } = useApi(issuesFetcher);
const assignedIssues = (allIssues ?? []).filter((i) => i.assigneeAgentId === agentId);
useEffect(() => {
setBreadcrumbs([
{ label: "Agents", href: "/agents" },
{ label: agent?.name ?? agentId ?? "Agent" },
]);
}, [setBreadcrumbs, agent, agentId]);
useEffect(() => {
if (agent) {
openPanel(<AgentProperties agent={agent} />);
}
return () => closePanel();
}, [agent]); // eslint-disable-line react-hooks/exhaustive-deps
async function handleAction(action: "invoke" | "pause" | "resume") {
if (!agentId) return;
setActionError(null);
try {
if (action === "invoke") await agentsApi.invoke(agentId);
else if (action === "pause") await agentsApi.pause(agentId);
else await agentsApi.resume(agentId);
reloadAgent();
} catch (err) {
setActionError(err instanceof Error ? err.message : `Failed to ${action} agent`);
}
}
if (loading) return <p className="text-sm text-muted-foreground">Loading...</p>;
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
if (!agent) return null;
const budgetPct =
agent.budgetMonthlyCents > 0
? Math.round((agent.spentMonthlyCents / agent.budgetMonthlyCents) * 100)
: 0;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold">{agent.name}</h2>
<p className="text-sm text-muted-foreground">
{agent.role}{agent.title ? ` - ${agent.title}` : ""}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => handleAction("invoke")}>
Invoke
</Button>
{agent.status === "active" ? (
<Button variant="outline" size="sm" onClick={() => handleAction("pause")}>
Pause
</Button>
) : (
<Button variant="outline" size="sm" onClick={() => handleAction("resume")}>
Resume
</Button>
)}
<StatusBadge status={agent.status} />
</div>
</div>
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="heartbeats">Heartbeats</TabsTrigger>
<TabsTrigger value="issues">Issues ({assignedIssues.length})</TabsTrigger>
<TabsTrigger value="costs">Costs</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4 mt-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Adapter</span>
<p className="font-mono">{agent.adapterType}</p>
</div>
<div>
<span className="text-muted-foreground">Context Mode</span>
<p>{agent.contextMode}</p>
</div>
{agent.reportsTo && (
<div>
<span className="text-muted-foreground">Reports To</span>
<p className="font-mono">{agent.reportsTo}</p>
</div>
)}
{agent.capabilities && (
<div className="col-span-2">
<span className="text-muted-foreground">Capabilities</span>
<p>{agent.capabilities}</p>
</div>
)}
{agent.lastHeartbeatAt && (
<div>
<span className="text-muted-foreground">Last Heartbeat</span>
<p>{formatDate(agent.lastHeartbeatAt)}</p>
</div>
)}
</div>
</TabsContent>
<TabsContent value="heartbeats" className="mt-4">
{(!heartbeats || heartbeats.length === 0) ? (
<p className="text-sm text-muted-foreground">No heartbeat runs.</p>
) : (
<div className="border border-border rounded-md">
{heartbeats.map((run) => (
<EntityRow
key={run.id}
identifier={run.id.slice(0, 8)}
title={run.invocationSource}
subtitle={run.error ?? undefined}
trailing={
<div className="flex items-center gap-2">
<StatusBadge status={run.status} />
<span className="text-xs text-muted-foreground">
{formatDate(run.createdAt)}
</span>
</div>
}
/>
))}
</div>
)}
</TabsContent>
<TabsContent value="issues" className="mt-4">
{assignedIssues.length === 0 ? (
<p className="text-sm text-muted-foreground">No assigned issues.</p>
) : (
<div className="border border-border rounded-md">
{assignedIssues.map((issue) => (
<EntityRow
key={issue.id}
identifier={issue.id.slice(0, 8)}
title={issue.title}
trailing={<StatusBadge status={issue.status} />}
/>
))}
</div>
)}
</TabsContent>
<TabsContent value="costs" className="mt-4 space-y-4">
<div>
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-muted-foreground">Monthly Budget</span>
<span>
{formatCents(agent.spentMonthlyCents)} / {formatCents(agent.budgetMonthlyCents)}
</span>
</div>
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
budgetPct > 90
? "bg-red-400"
: budgetPct > 70
? "bg-yellow-400"
: "bg-green-400"
}`}
style={{ width: `${Math.min(100, budgetPct)}%` }}
/>
</div>
<p className="text-xs text-muted-foreground mt-1">{budgetPct}% utilized</p>
</div>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -1,18 +1,28 @@
import { useState } from "react";
import { useState, useCallback, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useAgents } from "../hooks/useAgents";
import { StatusBadge } from "../components/StatusBadge";
import { formatCents } from "../lib/utils";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { agentsApi } from "../api/agents";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { StatusBadge } from "../components/StatusBadge";
import { EntityRow } from "../components/EntityRow";
import { EmptyState } from "../components/EmptyState";
import { formatCents } from "../lib/utils";
import { Bot } from "lucide-react";
export function Agents() {
const { selectedCompanyId } = useCompany();
const { data: agents, loading, error, reload } = useAgents(selectedCompanyId);
const { setBreadcrumbs } = useBreadcrumbs();
const navigate = useNavigate();
const [actionError, setActionError] = useState<string | null>(null);
async function invoke(agentId: string) {
useEffect(() => {
setBreadcrumbs([{ label: "Agents" }]);
}, [setBreadcrumbs]);
async function invoke(e: React.MouseEvent, agentId: string) {
e.stopPropagation();
setActionError(null);
try {
await agentsApi.invoke(agentId);
@@ -22,71 +32,76 @@ export function Agents() {
}
}
async function pause(agentId: string) {
setActionError(null);
try {
await agentsApi.pause(agentId);
reload();
} catch (err) {
setActionError(err instanceof Error ? err.message : "Failed to pause agent");
}
}
async function resume(agentId: string) {
setActionError(null);
try {
await agentsApi.resume(agentId);
reload();
} catch (err) {
setActionError(err instanceof Error ? err.message : "Failed to resume agent");
}
}
if (!selectedCompanyId) {
return <p className="text-muted-foreground">Select a company first.</p>;
return <EmptyState icon={Bot} message="Select a company to view agents." />;
}
return (
<div>
<h2 className="text-2xl font-bold mb-4">Agents</h2>
{loading && <p className="text-muted-foreground">Loading...</p>}
{error && <p className="text-destructive">{error.message}</p>}
{actionError && <p className="text-destructive mb-3">{actionError}</p>}
{agents && agents.length === 0 && <p className="text-muted-foreground">No agents yet.</p>}
<div className="space-y-4">
<h2 className="text-lg font-semibold">Agents</h2>
{loading && <p className="text-sm text-muted-foreground">Loading...</p>}
{error && <p className="text-sm text-destructive">{error.message}</p>}
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
{agents && agents.length === 0 && (
<EmptyState icon={Bot} message="No agents yet." />
)}
{agents && agents.length > 0 && (
<div className="grid gap-4">
{agents.map((agent) => (
<Card key={agent.id}>
<CardContent className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold">{agent.name}</h3>
<p className="text-sm text-muted-foreground">
{agent.role}
{agent.title ? ` - ${agent.title}` : ""}
</p>
</div>
<div className="border border-border rounded-md">
{agents.map((agent) => {
const budgetPct =
agent.budgetMonthlyCents > 0
? Math.round((agent.spentMonthlyCents / agent.budgetMonthlyCents) * 100)
: 0;
return (
<EntityRow
key={agent.id}
title={agent.name}
subtitle={`${agent.role}${agent.title ? ` - ${agent.title}` : ""}`}
onClick={() => navigate(`/agents/${agent.id}`)}
leading={
<span className="relative flex h-2.5 w-2.5">
<span
className={`absolute inline-flex h-full w-full rounded-full ${
agent.status === "active"
? "bg-green-400"
: agent.status === "paused"
? "bg-yellow-400"
: agent.status === "error"
? "bg-red-400"
: "bg-neutral-400"
}`}
/>
</span>
}
trailing={
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">
{formatCents(agent.spentMonthlyCents)} / {formatCents(agent.budgetMonthlyCents)}
</span>
<div className="flex items-center gap-1.5">
<div className="w-16 h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${
budgetPct > 90
? "bg-red-400"
: budgetPct > 70
? "bg-yellow-400"
: "bg-green-400"
}`}
style={{ width: `${Math.min(100, budgetPct)}%` }}
/>
</div>
<span className="text-xs text-muted-foreground w-20 text-right">
{formatCents(agent.spentMonthlyCents)} / {formatCents(agent.budgetMonthlyCents)}
</span>
</div>
<StatusBadge status={agent.status} />
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => invoke(agent.id)}>
Invoke
</Button>
<Button variant="outline" size="sm" onClick={() => pause(agent.id)}>
Pause
</Button>
<Button variant="outline" size="sm" onClick={() => resume(agent.id)}>
Resume
</Button>
</div>
</CardContent>
</Card>
))}
}
/>
);
})}
</div>
)}
</div>

View File

@@ -1,81 +1,122 @@
import { useCallback } from "react";
import { useCallback, useEffect } from "react";
import { costsApi } from "../api/costs";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useApi } from "../hooks/useApi";
import { EmptyState } from "../components/EmptyState";
import { formatCents } from "../lib/utils";
import { Card, CardContent } from "@/components/ui/card";
import { DollarSign } from "lucide-react";
export function Costs() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
useEffect(() => {
setBreadcrumbs([{ label: "Costs" }]);
}, [setBreadcrumbs]);
const fetcher = useCallback(async () => {
if (!selectedCompanyId) {
return null;
}
if (!selectedCompanyId) return null;
const [summary, byAgent, byProject] = await Promise.all([
costsApi.summary(selectedCompanyId),
costsApi.byAgent(selectedCompanyId),
costsApi.byProject(selectedCompanyId),
]);
return { summary, byAgent, byProject };
}, [selectedCompanyId]);
const { data, loading, error } = useApi(fetcher);
if (!selectedCompanyId) {
return <p className="text-muted-foreground">Select a company first.</p>;
return <EmptyState icon={DollarSign} message="Select a company to view costs." />;
}
return (
<div className="space-y-5">
<h2 className="text-2xl font-bold">Costs</h2>
<div className="space-y-6">
<h2 className="text-lg font-semibold">Costs</h2>
{loading && <p className="text-muted-foreground">Loading...</p>}
{error && <p className="text-destructive">{error.message}</p>}
{loading && <p className="text-sm text-muted-foreground">Loading...</p>}
{error && <p className="text-sm text-destructive">{error.message}</p>}
{data && (
<>
<Card>
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">Month to Date</p>
<p className="text-lg font-semibold mt-1">
{formatCents(data.summary.monthSpendCents)} / {formatCents(data.summary.monthBudgetCents)}
<CardContent className="p-4 space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">Month to Date</p>
<p className="text-sm text-muted-foreground">
{data.summary.monthUtilizationPercent}% utilized
</p>
</div>
<p className="text-2xl font-bold">
{formatCents(data.summary.monthSpendCents)}{" "}
<span className="text-base font-normal text-muted-foreground">
/ {formatCents(data.summary.monthBudgetCents)}
</span>
</p>
<p className="text-sm text-muted-foreground">Utilization {data.summary.monthUtilizationPercent}%</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<h3 className="font-semibold mb-3">By Agent</h3>
<div className="space-y-2 text-sm">
{data.byAgent.map((row, idx) => (
<div key={`${row.agentId ?? "na"}-${idx}`} className="flex justify-between">
<span>{row.agentId}</span>
<span>{formatCents(row.costCents)}</span>
</div>
))}
{data.byAgent.length === 0 && <p className="text-muted-foreground">No cost events yet.</p>}
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
data.summary.monthUtilizationPercent > 90
? "bg-red-400"
: data.summary.monthUtilizationPercent > 70
? "bg-yellow-400"
: "bg-green-400"
}`}
style={{ width: `${Math.min(100, data.summary.monthUtilizationPercent)}%` }}
/>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<h3 className="font-semibold mb-3">By Project</h3>
<div className="space-y-2 text-sm">
{data.byProject.map((row, idx) => (
<div key={`${row.projectId ?? "na"}-${idx}`} className="flex justify-between">
<span>{row.projectId}</span>
<span>{formatCents(row.costCents)}</span>
<div className="grid md:grid-cols-2 gap-4">
<Card>
<CardContent className="p-4">
<h3 className="text-sm font-semibold mb-3">By Agent</h3>
{data.byAgent.length === 0 ? (
<p className="text-sm text-muted-foreground">No cost events yet.</p>
) : (
<div className="space-y-2">
{data.byAgent.map((row, idx) => (
<div
key={`${row.agentId ?? "na"}-${idx}`}
className="flex items-center justify-between text-sm"
>
<span className="font-mono text-xs truncate">
{row.agentId ?? "Unattributed"}
</span>
<span className="font-medium">{formatCents(row.costCents)}</span>
</div>
))}
</div>
))}
{data.byProject.length === 0 && <p className="text-muted-foreground">No project-attributed costs yet.</p>}
</div>
</CardContent>
</Card>
)}
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<h3 className="text-sm font-semibold mb-3">By Project</h3>
{data.byProject.length === 0 ? (
<p className="text-sm text-muted-foreground">No project-attributed costs yet.</p>
) : (
<div className="space-y-2">
{data.byProject.map((row, idx) => (
<div
key={`${row.projectId ?? "na"}-${idx}`}
className="flex items-center justify-between text-sm"
>
<span className="font-mono text-xs truncate">
{row.projectId ?? "Unattributed"}
</span>
<span className="font-medium">{formatCents(row.costCents)}</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</>
)}
</div>

View File

@@ -1,71 +1,106 @@
import { useCallback } from "react";
import { useCallback, useEffect } from "react";
import { dashboardApi } from "../api/dashboard";
import { activityApi } from "../api/activity";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useApi } from "../hooks/useApi";
import { MetricCard } from "../components/MetricCard";
import { EmptyState } from "../components/EmptyState";
import { timeAgo } from "../lib/timeAgo";
import { formatCents } from "../lib/utils";
import { Card, CardContent } from "@/components/ui/card";
import { Bot, CircleDot, DollarSign, ShieldCheck, LayoutDashboard } from "lucide-react";
export function Dashboard() {
const { selectedCompanyId, selectedCompany } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const fetcher = useCallback(() => {
if (!selectedCompanyId) {
return Promise.resolve(null);
}
useEffect(() => {
setBreadcrumbs([{ label: "Dashboard" }]);
}, [setBreadcrumbs]);
const dashFetcher = useCallback(() => {
if (!selectedCompanyId) return Promise.resolve(null);
return dashboardApi.summary(selectedCompanyId);
}, [selectedCompanyId]);
const { data, loading, error } = useApi(fetcher);
const activityFetcher = useCallback(() => {
if (!selectedCompanyId) return Promise.resolve([]);
return activityApi.list(selectedCompanyId);
}, [selectedCompanyId]);
const { data, loading, error } = useApi(dashFetcher);
const { data: activity } = useApi(activityFetcher);
if (!selectedCompanyId) {
return <p className="text-muted-foreground">Create or select a company to view the dashboard.</p>;
return (
<EmptyState icon={LayoutDashboard} message="Create or select a company to view the dashboard." />
);
}
return (
<div className="space-y-4">
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold">Dashboard</h2>
<p className="text-muted-foreground">{selectedCompany?.name}</p>
<h2 className="text-lg font-semibold">Dashboard</h2>
{selectedCompany && (
<p className="text-sm text-muted-foreground">{selectedCompany.name}</p>
)}
</div>
{loading && <p className="text-muted-foreground">Loading...</p>}
{error && <p className="text-destructive">{error.message}</p>}
{loading && <p className="text-sm text-muted-foreground">Loading...</p>}
{error && <p className="text-sm text-destructive">{error.message}</p>}
{data && (
<div className="grid md:grid-cols-2 xl:grid-cols-4 gap-4">
<Card>
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">Agents</p>
<p className="mt-2 text-sm">Running: {data.agents.running}</p>
<p className="text-sm">Paused: {data.agents.paused}</p>
<p className="text-sm">Error: {data.agents.error}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">Tasks</p>
<p className="mt-2 text-sm">Open: {data.tasks.open}</p>
<p className="text-sm">In Progress: {data.tasks.inProgress}</p>
<p className="text-sm">Blocked: {data.tasks.blocked}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">Costs</p>
<p className="mt-2 text-sm">
{formatCents(data.costs.monthSpendCents)} / {formatCents(data.costs.monthBudgetCents)}
</p>
<p className="text-sm">Utilization: {data.costs.monthUtilizationPercent}%</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">Governance</p>
<p className="mt-2 text-sm">Pending approvals: {data.pendingApprovals}</p>
<p className="text-sm">Stale tasks: {data.staleTasks}</p>
</CardContent>
</Card>
</div>
<>
<div className="grid md:grid-cols-2 xl:grid-cols-4 gap-4">
<MetricCard
icon={Bot}
value={data.agents.running}
label="Agents Running"
description={`${data.agents.paused} paused, ${data.agents.error} errors`}
/>
<MetricCard
icon={CircleDot}
value={data.tasks.inProgress}
label="Tasks In Progress"
description={`${data.tasks.open} open, ${data.tasks.blocked} blocked`}
/>
<MetricCard
icon={DollarSign}
value={formatCents(data.costs.monthSpendCents)}
label="Month Spend"
description={`${data.costs.monthUtilizationPercent}% of ${formatCents(data.costs.monthBudgetCents)} budget`}
/>
<MetricCard
icon={ShieldCheck}
value={data.pendingApprovals}
label="Pending Approvals"
description={`${data.staleTasks} stale tasks`}
/>
</div>
{activity && activity.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
Recent Activity
</h3>
<div className="border border-border rounded-md divide-y divide-border">
{activity.slice(0, 10).map((event) => (
<div key={event.id} className="px-4 py-2 flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<span className="font-medium">{event.action}</span>
<span className="text-muted-foreground">
{event.entityType}
</span>
</div>
<span className="text-xs text-muted-foreground">
{timeAgo(event.createdAt)}
</span>
</div>
))}
</div>
</div>
)}
</>
)}
</div>
);

113
ui/src/pages/GoalDetail.tsx Normal file
View File

@@ -0,0 +1,113 @@
import { useCallback, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { goalsApi } from "../api/goals";
import { projectsApi } from "../api/projects";
import { useApi } from "../hooks/useApi";
import { usePanel } from "../context/PanelContext";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { GoalProperties } from "../components/GoalProperties";
import { GoalTree } from "../components/GoalTree";
import { StatusBadge } from "../components/StatusBadge";
import { EntityRow } from "../components/EntityRow";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { Goal, Project } from "@paperclip/shared";
export function GoalDetail() {
const { goalId } = useParams<{ goalId: string }>();
const { selectedCompanyId } = useCompany();
const { openPanel, closePanel } = usePanel();
const { setBreadcrumbs } = useBreadcrumbs();
const navigate = useNavigate();
const goalFetcher = useCallback(() => {
if (!goalId) return Promise.reject(new Error("No goal ID"));
return goalsApi.get(goalId);
}, [goalId]);
const allGoalsFetcher = useCallback(() => {
if (!selectedCompanyId) return Promise.resolve([] as Goal[]);
return goalsApi.list(selectedCompanyId);
}, [selectedCompanyId]);
const projectsFetcher = useCallback(() => {
if (!selectedCompanyId) return Promise.resolve([] as Project[]);
return projectsApi.list(selectedCompanyId);
}, [selectedCompanyId]);
const { data: goal, loading, error } = useApi(goalFetcher);
const { data: allGoals } = useApi(allGoalsFetcher);
const { data: allProjects } = useApi(projectsFetcher);
const childGoals = (allGoals ?? []).filter((g) => g.parentId === goalId);
const linkedProjects = (allProjects ?? []).filter((p) => p.goalId === goalId);
useEffect(() => {
setBreadcrumbs([
{ label: "Goals", href: "/goals" },
{ label: goal?.title ?? goalId ?? "Goal" },
]);
}, [setBreadcrumbs, goal, goalId]);
useEffect(() => {
if (goal) {
openPanel(<GoalProperties goal={goal} />);
}
return () => closePanel();
}, [goal]); // eslint-disable-line react-hooks/exhaustive-deps
if (loading) return <p className="text-sm text-muted-foreground">Loading...</p>;
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
if (!goal) return null;
return (
<div className="space-y-6">
<div>
<div className="flex items-center gap-2">
<span className="text-xs uppercase text-muted-foreground">{goal.level}</span>
<StatusBadge status={goal.status} />
</div>
<h2 className="text-xl font-bold mt-1">{goal.title}</h2>
{goal.description && (
<p className="text-sm text-muted-foreground mt-1">{goal.description}</p>
)}
</div>
<Tabs defaultValue="children">
<TabsList>
<TabsTrigger value="children">Sub-Goals ({childGoals.length})</TabsTrigger>
<TabsTrigger value="projects">Projects ({linkedProjects.length})</TabsTrigger>
</TabsList>
<TabsContent value="children" className="mt-4">
{childGoals.length === 0 ? (
<p className="text-sm text-muted-foreground">No sub-goals.</p>
) : (
<GoalTree
goals={childGoals}
onSelect={(g) => navigate(`/goals/${g.id}`)}
/>
)}
</TabsContent>
<TabsContent value="projects" className="mt-4">
{linkedProjects.length === 0 ? (
<p className="text-sm text-muted-foreground">No linked projects.</p>
) : (
<div className="border border-border rounded-md">
{linkedProjects.map((project) => (
<EntityRow
key={project.id}
title={project.name}
subtitle={project.description ?? undefined}
onClick={() => navigate(`/projects/${project.id}`)}
trailing={<StatusBadge status={project.status} />}
/>
))}
</div>
)}
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -1,12 +1,21 @@
import { useCallback } from "react";
import { useCallback, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { goalsApi } from "../api/goals";
import { useApi } from "../hooks/useApi";
import { StatusBadge } from "../components/StatusBadge";
import { useCompany } from "../context/CompanyContext";
import { Card, CardContent } from "@/components/ui/card";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { GoalTree } from "../components/GoalTree";
import { EmptyState } from "../components/EmptyState";
import { Target } from "lucide-react";
export function Goals() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const navigate = useNavigate();
useEffect(() => {
setBreadcrumbs([{ label: "Goals" }]);
}, [setBreadcrumbs]);
const fetcher = useCallback(() => {
if (!selectedCompanyId) return Promise.resolve([]);
@@ -16,32 +25,22 @@ export function Goals() {
const { data: goals, loading, error } = useApi(fetcher);
if (!selectedCompanyId) {
return <p className="text-muted-foreground">Select a company first.</p>;
return <EmptyState icon={Target} message="Select a company to view goals." />;
}
return (
<div>
<h2 className="text-2xl font-bold mb-4">Goals</h2>
{loading && <p className="text-muted-foreground">Loading...</p>}
{error && <p className="text-destructive">{error.message}</p>}
{goals && goals.length === 0 && <p className="text-muted-foreground">No goals yet.</p>}
<div className="space-y-4">
<h2 className="text-lg font-semibold">Goals</h2>
{loading && <p className="text-sm text-muted-foreground">Loading...</p>}
{error && <p className="text-sm text-destructive">{error.message}</p>}
{goals && goals.length === 0 && (
<EmptyState icon={Target} message="No goals yet." />
)}
{goals && goals.length > 0 && (
<div className="grid gap-4">
{goals.map((goal) => (
<Card key={goal.id}>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold">{goal.title}</h3>
{goal.description && <p className="text-sm text-muted-foreground mt-1">{goal.description}</p>}
<p className="text-xs text-muted-foreground mt-2">Level: {goal.level}</p>
</div>
<StatusBadge status={goal.status} />
</div>
</CardContent>
</Card>
))}
</div>
<GoalTree goals={goals} onSelect={(goal) => navigate(`/goals/${goal.id}`)} />
)}
</div>
);

132
ui/src/pages/Inbox.tsx Normal file
View File

@@ -0,0 +1,132 @@
import { useCallback, useEffect, useState } from "react";
import { approvalsApi } from "../api/approvals";
import { dashboardApi } from "../api/dashboard";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useApi } from "../hooks/useApi";
import { StatusBadge } from "../components/StatusBadge";
import { EmptyState } from "../components/EmptyState";
import { timeAgo } from "../lib/timeAgo";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Inbox as InboxIcon } from "lucide-react";
export function Inbox() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const [actionError, setActionError] = useState<string | null>(null);
useEffect(() => {
setBreadcrumbs([{ label: "Inbox" }]);
}, [setBreadcrumbs]);
const approvalsFetcher = useCallback(() => {
if (!selectedCompanyId) return Promise.resolve([]);
return approvalsApi.list(selectedCompanyId, "pending");
}, [selectedCompanyId]);
const dashboardFetcher = useCallback(() => {
if (!selectedCompanyId) return Promise.resolve(null);
return dashboardApi.summary(selectedCompanyId);
}, [selectedCompanyId]);
const { data: approvals, loading, error, reload } = useApi(approvalsFetcher);
const { data: dashboard } = useApi(dashboardFetcher);
async function approve(id: string) {
setActionError(null);
try {
await approvalsApi.approve(id);
reload();
} catch (err) {
setActionError(err instanceof Error ? err.message : "Failed to approve");
}
}
async function reject(id: string) {
setActionError(null);
try {
await approvalsApi.reject(id);
reload();
} catch (err) {
setActionError(err instanceof Error ? err.message : "Failed to reject");
}
}
if (!selectedCompanyId) {
return <EmptyState icon={InboxIcon} message="Select a company to view inbox." />;
}
const hasContent = (approvals && approvals.length > 0) || (dashboard && (dashboard.staleTasks > 0));
return (
<div className="space-y-6">
<h2 className="text-lg font-semibold">Inbox</h2>
{loading && <p className="text-sm text-muted-foreground">Loading...</p>}
{error && <p className="text-sm text-destructive">{error.message}</p>}
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
{!loading && !hasContent && (
<EmptyState icon={InboxIcon} message="You're all caught up!" />
)}
{approvals && approvals.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
Pending Approvals ({approvals.length})
</h3>
<div className="border border-border rounded-md divide-y divide-border">
{approvals.map((approval) => (
<div key={approval.id} className="p-4 space-y-2">
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium">{approval.type.replace(/_/g, " ")}</span>
<span className="text-xs text-muted-foreground ml-2">
{timeAgo(approval.createdAt)}
</span>
</div>
<StatusBadge status={approval.status} />
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className="border-green-700 text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20"
onClick={() => approve(approval.id)}
>
Approve
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => reject(approval.id)}
>
Reject
</Button>
</div>
</div>
))}
</div>
</div>
)}
{dashboard && dashboard.staleTasks > 0 && (
<>
<Separator />
<div>
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
Stale Work
</h3>
<div className="border border-border rounded-md p-4">
<p className="text-sm">
<span className="font-medium">{dashboard.staleTasks}</span> tasks have gone stale
and may need attention.
</p>
</div>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,105 @@
import { useCallback, useEffect } from "react";
import { useParams } from "react-router-dom";
import { issuesApi } from "../api/issues";
import { useApi } from "../hooks/useApi";
import { usePanel } from "../context/PanelContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { InlineEditor } from "../components/InlineEditor";
import { CommentThread } from "../components/CommentThread";
import { IssueProperties } from "../components/IssueProperties";
import { StatusIcon } from "../components/StatusIcon";
import { PriorityIcon } from "../components/PriorityIcon";
import type { IssueComment } from "@paperclip/shared";
import { Separator } from "@/components/ui/separator";
export function IssueDetail() {
const { issueId } = useParams<{ issueId: string }>();
const { openPanel, closePanel } = usePanel();
const { setBreadcrumbs } = useBreadcrumbs();
const issueFetcher = useCallback(() => {
if (!issueId) return Promise.reject(new Error("No issue ID"));
return issuesApi.get(issueId);
}, [issueId]);
const commentsFetcher = useCallback(() => {
if (!issueId) return Promise.resolve([] as IssueComment[]);
return issuesApi.listComments(issueId);
}, [issueId]);
const { data: issue, loading, error, reload: reloadIssue } = useApi(issueFetcher);
const { data: comments, reload: reloadComments } = useApi(commentsFetcher);
useEffect(() => {
setBreadcrumbs([
{ label: "Issues", href: "/tasks" },
{ label: issue?.title ?? issueId ?? "Issue" },
]);
}, [setBreadcrumbs, issue, issueId]);
async function handleUpdate(data: Record<string, unknown>) {
if (!issueId) return;
await issuesApi.update(issueId, data);
reloadIssue();
}
async function handleAddComment(body: string) {
if (!issueId) return;
await issuesApi.addComment(issueId, body);
reloadComments();
}
useEffect(() => {
if (issue) {
openPanel(
<IssueProperties issue={issue} onUpdate={handleUpdate} />
);
}
return () => closePanel();
}, [issue]); // eslint-disable-line react-hooks/exhaustive-deps
if (loading) return <p className="text-sm text-muted-foreground">Loading...</p>;
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
if (!issue) return null;
return (
<div className="max-w-2xl space-y-6">
<div className="space-y-3">
<div className="flex items-center gap-2">
<StatusIcon
status={issue.status}
onChange={(status) => handleUpdate({ status })}
/>
<PriorityIcon
priority={issue.priority}
onChange={(priority) => handleUpdate({ priority })}
/>
<span className="text-xs font-mono text-muted-foreground">{issue.id.slice(0, 8)}</span>
</div>
<InlineEditor
value={issue.title}
onSave={(title) => handleUpdate({ title })}
as="h2"
className="text-xl font-bold"
/>
<InlineEditor
value={issue.description ?? ""}
onSave={(description) => handleUpdate({ description })}
as="p"
className="text-sm text-muted-foreground"
placeholder="Add a description..."
multiline
/>
</div>
<Separator />
<CommentThread
comments={comments ?? []}
onAdd={handleAddComment}
/>
</div>
);
}

View File

@@ -1,67 +1,149 @@
import { useCallback } from "react";
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { issuesApi } from "../api/issues";
import { useApi } from "../hooks/useApi";
import { StatusBadge } from "../components/StatusBadge";
import { cn } from "../lib/utils";
import { useCompany } from "../context/CompanyContext";
import { Card, CardContent } from "@/components/ui/card";
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 priorityColors: Record<string, string> = {
critical: "text-red-300 bg-red-900/50",
high: "text-orange-300 bg-orange-900/50",
medium: "text-yellow-300 bg-yellow-900/50",
low: "text-neutral-400 bg-neutral-800",
};
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");
useEffect(() => {
setBreadcrumbs([{ label: "Issues" }]);
}, [setBreadcrumbs]);
const fetcher = useCallback(() => {
if (!selectedCompanyId) return Promise.resolve([]);
return issuesApi.list(selectedCompanyId);
}, [selectedCompanyId]);
const { data: issues, loading, error } = useApi(fetcher);
const { data: issues, loading, error, reload } = useApi(fetcher);
if (!selectedCompanyId) {
return <p className="text-muted-foreground">Select a company first.</p>;
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>
<h2 className="text-2xl font-bold mb-4">Tasks</h2>
{loading && <p className="text-muted-foreground">Loading...</p>}
{error && <p className="text-destructive">{error.message}</p>}
{issues && issues.length === 0 && <p className="text-muted-foreground">No tasks yet.</p>}
{issues && issues.length > 0 && (
<div className="grid gap-4">
{issues.map((issue) => (
<Card key={issue.id}>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold">{issue.title}</h3>
{issue.description && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-1">{issue.description}</p>
)}
</div>
<div className="flex items-center gap-3">
<span
className={cn(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
priorityColors[issue.priority] ?? "text-neutral-400 bg-neutral-800",
)}
>
{issue.priority}
</span>
<StatusBadge status={issue.status} />
</div>
</div>
</CardContent>
</Card>
))}
</div>
<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={
<span className="text-xs text-muted-foreground">
{formatDate(issue.createdAt)}
</span>
}
/>
))}
</div>
</div>
))}
</div>
);
}

75
ui/src/pages/MyIssues.tsx Normal file
View File

@@ -0,0 +1,75 @@
import { useCallback, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { issuesApi } from "../api/issues";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useApi } from "../hooks/useApi";
import { StatusIcon } from "../components/StatusIcon";
import { PriorityIcon } from "../components/PriorityIcon";
import { EntityRow } from "../components/EntityRow";
import { EmptyState } from "../components/EmptyState";
import { formatDate } from "../lib/utils";
import { ListTodo } from "lucide-react";
export function MyIssues() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const navigate = useNavigate();
useEffect(() => {
setBreadcrumbs([{ label: "My Issues" }]);
}, [setBreadcrumbs]);
const fetcher = useCallback(() => {
if (!selectedCompanyId) return Promise.resolve([]);
return issuesApi.list(selectedCompanyId);
}, [selectedCompanyId]);
const { data: issues, loading, error } = useApi(fetcher);
if (!selectedCompanyId) {
return <EmptyState icon={ListTodo} message="Select a company to view your issues." />;
}
// Show issues that are not assigned (user-created or unassigned)
const myIssues = (issues ?? []).filter(
(i) => !i.assigneeAgentId && !["done", "cancelled"].includes(i.status)
);
return (
<div className="space-y-4">
<h2 className="text-lg font-semibold">My Issues</h2>
{loading && <p className="text-sm text-muted-foreground">Loading...</p>}
{error && <p className="text-sm text-destructive">{error.message}</p>}
{!loading && myIssues.length === 0 && (
<EmptyState icon={ListTodo} message="No issues assigned to you." />
)}
{myIssues.length > 0 && (
<div className="border border-border rounded-md">
{myIssues.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} />
</>
}
trailing={
<span className="text-xs text-muted-foreground">
{formatDate(issue.createdAt)}
</span>
}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -1,33 +1,98 @@
import { useCallback } from "react";
import { useCallback, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { agentsApi, type OrgNode } from "../api/agents";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useApi } from "../hooks/useApi";
import { StatusBadge } from "../components/StatusBadge";
import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "../components/EmptyState";
import { ChevronRight, GitBranch } from "lucide-react";
import { cn } from "../lib/utils";
import { useState } from "react";
function OrgTree({ nodes, depth = 0 }: { nodes: OrgNode[]; depth?: number }) {
function OrgTree({
nodes,
depth = 0,
onSelect,
}: {
nodes: OrgNode[];
depth?: number;
onSelect: (id: string) => void;
}) {
return (
<div className="space-y-2">
<div>
{nodes.map((node) => (
<div key={node.id}>
<Card style={{ marginLeft: `${depth * 20}px` }}>
<CardContent className="p-3 flex items-center justify-between">
<div>
<p className="font-medium">{node.name}</p>
<p className="text-xs text-muted-foreground">{node.role}</p>
</div>
<StatusBadge status={node.status} />
</CardContent>
</Card>
{node.reports.length > 0 && <OrgTree nodes={node.reports} depth={depth + 1} />}
</div>
<OrgTreeNode key={node.id} node={node} depth={depth} onSelect={onSelect} />
))}
</div>
);
}
function OrgTreeNode({
node,
depth,
onSelect,
}: {
node: OrgNode;
depth: number;
onSelect: (id: string) => void;
}) {
const [expanded, setExpanded] = useState(true);
const hasChildren = node.reports.length > 0;
return (
<div>
<div
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors cursor-pointer hover:bg-accent/50"
style={{ paddingLeft: `${depth * 24 + 12}px` }}
onClick={() => onSelect(node.id)}
>
{hasChildren ? (
<button
className="p-0.5"
onClick={(e) => {
e.stopPropagation();
setExpanded(!expanded);
}}
>
<ChevronRight
className={cn("h-3 w-3 transition-transform", expanded && "rotate-90")}
/>
</button>
) : (
<span className="w-4" />
)}
<span
className={cn(
"h-2 w-2 rounded-full shrink-0",
node.status === "active"
? "bg-green-400"
: node.status === "paused"
? "bg-yellow-400"
: node.status === "error"
? "bg-red-400"
: "bg-neutral-400"
)}
/>
<span className="font-medium flex-1">{node.name}</span>
<span className="text-xs text-muted-foreground">{node.role}</span>
<StatusBadge status={node.status} />
</div>
{hasChildren && expanded && (
<OrgTree nodes={node.reports} depth={depth + 1} onSelect={onSelect} />
)}
</div>
);
}
export function Org() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const navigate = useNavigate();
useEffect(() => {
setBreadcrumbs([{ label: "Org Chart" }]);
}, [setBreadcrumbs]);
const fetcher = useCallback(() => {
if (!selectedCompanyId) return Promise.resolve([] as OrgNode[]);
@@ -37,16 +102,25 @@ export function Org() {
const { data, loading, error } = useApi(fetcher);
if (!selectedCompanyId) {
return <p className="text-muted-foreground">Select a company first.</p>;
return <EmptyState icon={GitBranch} message="Select a company to view org chart." />;
}
return (
<div>
<h2 className="text-2xl font-bold mb-4">Org Chart</h2>
{loading && <p className="text-muted-foreground">Loading...</p>}
{error && <p className="text-destructive">{error.message}</p>}
{data && data.length === 0 && <p className="text-muted-foreground">No agents in org.</p>}
{data && data.length > 0 && <OrgTree nodes={data} />}
<div className="space-y-4">
<h2 className="text-lg font-semibold">Org Chart</h2>
{loading && <p className="text-sm text-muted-foreground">Loading...</p>}
{error && <p className="text-sm text-destructive">{error.message}</p>}
{data && data.length === 0 && (
<EmptyState icon={GitBranch} message="No agents in the organization." />
)}
{data && data.length > 0 && (
<div className="border border-border rounded-md py-1">
<OrgTree nodes={data} onSelect={(id) => navigate(`/agents/${id}`)} />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,105 @@
import { useCallback, useEffect } from "react";
import { useParams } from "react-router-dom";
import { projectsApi } from "../api/projects";
import { issuesApi } from "../api/issues";
import { useApi } from "../hooks/useApi";
import { usePanel } from "../context/PanelContext";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { ProjectProperties } from "../components/ProjectProperties";
import { StatusBadge } from "../components/StatusBadge";
import { EntityRow } from "../components/EntityRow";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { Issue } from "@paperclip/shared";
export function ProjectDetail() {
const { projectId } = useParams<{ projectId: string }>();
const { selectedCompanyId } = useCompany();
const { openPanel, closePanel } = usePanel();
const { setBreadcrumbs } = useBreadcrumbs();
const projectFetcher = useCallback(() => {
if (!projectId) return Promise.reject(new Error("No project ID"));
return projectsApi.get(projectId);
}, [projectId]);
const issuesFetcher = useCallback(() => {
if (!selectedCompanyId) return Promise.resolve([] as Issue[]);
return issuesApi.list(selectedCompanyId);
}, [selectedCompanyId]);
const { data: project, loading, error } = useApi(projectFetcher);
const { data: allIssues } = useApi(issuesFetcher);
const projectIssues = (allIssues ?? []).filter((i) => i.projectId === projectId);
useEffect(() => {
setBreadcrumbs([
{ label: "Projects", href: "/projects" },
{ label: project?.name ?? projectId ?? "Project" },
]);
}, [setBreadcrumbs, project, projectId]);
useEffect(() => {
if (project) {
openPanel(<ProjectProperties project={project} />);
}
return () => closePanel();
}, [project]); // eslint-disable-line react-hooks/exhaustive-deps
if (loading) return <p className="text-sm text-muted-foreground">Loading...</p>;
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
if (!project) return null;
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-bold">{project.name}</h2>
{project.description && (
<p className="text-sm text-muted-foreground mt-1">{project.description}</p>
)}
</div>
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="issues">Issues ({projectIssues.length})</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="mt-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Status</span>
<div className="mt-1">
<StatusBadge status={project.status} />
</div>
</div>
{project.targetDate && (
<div>
<span className="text-muted-foreground">Target Date</span>
<p>{project.targetDate}</p>
</div>
)}
</div>
</TabsContent>
<TabsContent value="issues" className="mt-4">
{projectIssues.length === 0 ? (
<p className="text-sm text-muted-foreground">No issues in this project.</p>
) : (
<div className="border border-border rounded-md">
{projectIssues.map((issue) => (
<EntityRow
key={issue.id}
identifier={issue.id.slice(0, 8)}
title={issue.title}
trailing={<StatusBadge status={issue.status} />}
/>
))}
</div>
)}
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -1,13 +1,23 @@
import { useCallback } from "react";
import { useCallback, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { projectsApi } from "../api/projects";
import { useApi } from "../hooks/useApi";
import { formatDate } from "../lib/utils";
import { StatusBadge } from "../components/StatusBadge";
import { useCompany } from "../context/CompanyContext";
import { Card, CardContent } from "@/components/ui/card";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { EntityRow } from "../components/EntityRow";
import { StatusBadge } from "../components/StatusBadge";
import { EmptyState } from "../components/EmptyState";
import { formatDate } from "../lib/utils";
import { Hexagon } from "lucide-react";
export function Projects() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const navigate = useNavigate();
useEffect(() => {
setBreadcrumbs([{ label: "Projects" }]);
}, [setBreadcrumbs]);
const fetcher = useCallback(() => {
if (!selectedCompanyId) return Promise.resolve([]);
@@ -17,34 +27,39 @@ export function Projects() {
const { data: projects, loading, error } = useApi(fetcher);
if (!selectedCompanyId) {
return <p className="text-muted-foreground">Select a company first.</p>;
return <EmptyState icon={Hexagon} message="Select a company to view projects." />;
}
return (
<div>
<h2 className="text-2xl font-bold mb-4">Projects</h2>
{loading && <p className="text-muted-foreground">Loading...</p>}
{error && <p className="text-destructive">{error.message}</p>}
{projects && projects.length === 0 && <p className="text-muted-foreground">No projects yet.</p>}
<div className="space-y-4">
<h2 className="text-lg font-semibold">Projects</h2>
{loading && <p className="text-sm text-muted-foreground">Loading...</p>}
{error && <p className="text-sm text-destructive">{error.message}</p>}
{projects && projects.length === 0 && (
<EmptyState icon={Hexagon} message="No projects yet." />
)}
{projects && projects.length > 0 && (
<div className="grid gap-4">
<div className="border border-border rounded-md">
{projects.map((project) => (
<Card key={project.id}>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold">{project.name}</h3>
{project.description && (
<p className="text-sm text-muted-foreground mt-1">{project.description}</p>
)}
{project.targetDate && (
<p className="text-xs text-muted-foreground mt-2">Target: {formatDate(project.targetDate)}</p>
)}
</div>
<EntityRow
key={project.id}
title={project.name}
subtitle={project.description ?? undefined}
onClick={() => navigate(`/projects/${project.id}`)}
trailing={
<div className="flex items-center gap-3">
{project.targetDate && (
<span className="text-xs text-muted-foreground">
{formatDate(project.targetDate)}
</span>
)}
<StatusBadge status={project.status} />
</div>
</CardContent>
</Card>
}
/>
))}
</div>
)}