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:
@@ -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
9
ui/src/api/heartbeats.ts
Normal 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}`);
|
||||
},
|
||||
};
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
217
ui/src/pages/AgentDetail.tsx
Normal file
217
ui/src/pages/AgentDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
113
ui/src/pages/GoalDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
132
ui/src/pages/Inbox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
ui/src/pages/IssueDetail.tsx
Normal file
105
ui/src/pages/IssueDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
75
ui/src/pages/MyIssues.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
105
ui/src/pages/ProjectDetail.tsx
Normal file
105
ui/src/pages/ProjectDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user