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:
79
ui/src/components/AgentProperties.tsx
Normal file
79
ui/src/components/AgentProperties.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
ui/src/components/GoalProperties.tsx
Normal file
53
ui/src/components/GoalProperties.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
ui/src/components/GoalTree.tsx
Normal file
91
ui/src/components/GoalTree.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
ui/src/components/IssueProperties.tsx
Normal file
77
ui/src/components/IssueProperties.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
142
ui/src/components/NewIssueDialog.tsx
Normal file
142
ui/src/components/NewIssueDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
ui/src/components/ProjectProperties.tsx
Normal file
55
ui/src/components/ProjectProperties.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user