Overhaul UI with shadcn components and new pages

Add shadcn/ui components (badge, button, card, input, select,
separator). Add company context provider. New pages: Activity,
Approvals, Companies, Costs, Org chart. Restyle existing pages
(Dashboard, Agents, Issues, Goals, Projects) with shadcn components
and dark theme. Update layout, sidebar navigation, and routing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-17 09:07:32 -06:00
parent abadd469bc
commit 22e7930d0b
40 changed files with 1555 additions and 137 deletions

21
ui/components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />

View File

@@ -11,12 +11,19 @@
},
"dependencies": {
"@paperclip/shared": "workspace:*",
"@radix-ui/react-slot": "^1.2.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.574.0",
"radix-ui": "^1.4.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.5"
"react-router-dom": "^7.1.5",
"tailwind-merge": "^3.4.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.7",
"@types/node": "^25.2.3",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.3.4",

View File

@@ -1,20 +1,30 @@
import { Routes, Route } from "react-router-dom";
import { Layout } from "./components/Layout";
import { Dashboard } from "./pages/Dashboard";
import { Companies } from "./pages/Companies";
import { Org } from "./pages/Org";
import { Agents } from "./pages/Agents";
import { Projects } from "./pages/Projects";
import { Issues } from "./pages/Issues";
import { Goals } from "./pages/Goals";
import { Approvals } from "./pages/Approvals";
import { Costs } from "./pages/Costs";
import { Activity } from "./pages/Activity";
export function App() {
return (
<Routes>
<Route element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="companies" element={<Companies />} />
<Route path="org" element={<Org />} />
<Route path="agents" element={<Agents />} />
<Route path="projects" element={<Projects />} />
<Route path="issues" element={<Issues />} />
<Route path="tasks" element={<Issues />} />
<Route path="goals" element={<Goals />} />
<Route path="approvals" element={<Approvals />} />
<Route path="costs" element={<Costs />} />
<Route path="activity" element={<Activity />} />
</Route>
</Routes>
);

6
ui/src/api/activity.ts Normal file
View File

@@ -0,0 +1,6 @@
import type { ActivityEvent } from "@paperclip/shared";
import { api } from "./client";
export const activityApi = {
list: (companyId: string) => api.get<ActivityEvent[]>(`/companies/${companyId}/activity`),
};

View File

@@ -1,10 +1,24 @@
import type { Agent } from "@paperclip/shared";
import type { Agent, AgentKeyCreated, HeartbeatRun } from "@paperclip/shared";
import { api } from "./client";
export interface OrgNode {
id: string;
name: string;
role: string;
status: string;
reports: OrgNode[];
}
export const agentsApi = {
list: () => api.get<Agent[]>("/agents"),
list: (companyId: string) => api.get<Agent[]>(`/companies/${companyId}/agents`),
org: (companyId: string) => api.get<OrgNode[]>(`/companies/${companyId}/org`),
get: (id: string) => api.get<Agent>(`/agents/${id}`),
create: (data: Partial<Agent>) => api.post<Agent>("/agents", data),
update: (id: string, data: Partial<Agent>) => api.patch<Agent>(`/agents/${id}`, data),
remove: (id: string) => api.delete<Agent>(`/agents/${id}`),
create: (companyId: string, data: Record<string, unknown>) =>
api.post<Agent>(`/companies/${companyId}/agents`, data),
update: (id: string, data: Record<string, unknown>) => api.patch<Agent>(`/agents/${id}`, data),
pause: (id: string) => api.post<Agent>(`/agents/${id}/pause`, {}),
resume: (id: string) => api.post<Agent>(`/agents/${id}/resume`, {}),
terminate: (id: string) => api.post<Agent>(`/agents/${id}/terminate`, {}),
createKey: (id: string, name: string) => api.post<AgentKeyCreated>(`/agents/${id}/keys`, { name }),
invoke: (id: string) => api.post<HeartbeatRun>(`/agents/${id}/heartbeat/invoke`, {}),
};

15
ui/src/api/approvals.ts Normal file
View File

@@ -0,0 +1,15 @@
import type { Approval } from "@paperclip/shared";
import { api } from "./client";
export const approvalsApi = {
list: (companyId: string, status?: string) =>
api.get<Approval[]>(
`/companies/${companyId}/approvals${status ? `?status=${encodeURIComponent(status)}` : ""}`,
),
create: (companyId: string, data: Record<string, unknown>) =>
api.post<Approval>(`/companies/${companyId}/approvals`, data),
approve: (id: string, decisionNote?: string) =>
api.post<Approval>(`/approvals/${id}/approve`, { decisionNote }),
reject: (id: string, decisionNote?: string) =>
api.post<Approval>(`/approvals/${id}/reject`, { decisionNote }),
};

14
ui/src/api/companies.ts Normal file
View File

@@ -0,0 +1,14 @@
import type { Company } from "@paperclip/shared";
import { api } from "./client";
export const companiesApi = {
list: () => api.get<Company[]>("/companies"),
get: (companyId: string) => api.get<Company>(`/companies/${companyId}`),
create: (data: { name: string; description?: string | null; budgetMonthlyCents?: number }) =>
api.post<Company>("/companies", data),
update: (
companyId: string,
data: Partial<Pick<Company, "name" | "description" | "status" | "budgetMonthlyCents">>,
) => api.patch<Company>(`/companies/${companyId}`, data),
archive: (companyId: string) => api.post<Company>(`/companies/${companyId}/archive`, {}),
};

18
ui/src/api/costs.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { CostSummary } from "@paperclip/shared";
import { api } from "./client";
export interface CostByEntity {
agentId?: string | null;
projectId?: string | null;
costCents: number;
inputTokens: number;
outputTokens: number;
}
export const costsApi = {
summary: (companyId: string) => api.get<CostSummary>(`/companies/${companyId}/costs/summary`),
byAgent: (companyId: string) =>
api.get<CostByEntity[]>(`/companies/${companyId}/costs/by-agent`),
byProject: (companyId: string) =>
api.get<CostByEntity[]>(`/companies/${companyId}/costs/by-project`),
};

6
ui/src/api/dashboard.ts Normal file
View File

@@ -0,0 +1,6 @@
import type { DashboardSummary } from "@paperclip/shared";
import { api } from "./client";
export const dashboardApi = {
summary: (companyId: string) => api.get<DashboardSummary>(`/companies/${companyId}/dashboard`),
};

View File

@@ -2,9 +2,10 @@ import type { Goal } from "@paperclip/shared";
import { api } from "./client";
export const goalsApi = {
list: () => api.get<Goal[]>("/goals"),
list: (companyId: string) => api.get<Goal[]>(`/companies/${companyId}/goals`),
get: (id: string) => api.get<Goal>(`/goals/${id}`),
create: (data: Partial<Goal>) => api.post<Goal>("/goals", data),
update: (id: string, data: Partial<Goal>) => api.patch<Goal>(`/goals/${id}`, data),
create: (companyId: string, data: Record<string, unknown>) =>
api.post<Goal>(`/companies/${companyId}/goals`, data),
update: (id: string, data: Record<string, unknown>) => api.patch<Goal>(`/goals/${id}`, data),
remove: (id: string) => api.delete<Goal>(`/goals/${id}`),
};

View File

@@ -1,5 +1,10 @@
export { api } from "./client";
export { companiesApi } from "./companies";
export { agentsApi } from "./agents";
export { projectsApi } from "./projects";
export { issuesApi } from "./issues";
export { goalsApi } from "./goals";
export { approvalsApi } from "./approvals";
export { costsApi } from "./costs";
export { activityApi } from "./activity";
export { dashboardApi } from "./dashboard";

View File

@@ -1,10 +1,19 @@
import type { Issue } from "@paperclip/shared";
import type { Issue, IssueComment } from "@paperclip/shared";
import { api } from "./client";
export const issuesApi = {
list: () => api.get<Issue[]>("/issues"),
list: (companyId: string) => api.get<Issue[]>(`/companies/${companyId}/issues`),
get: (id: string) => api.get<Issue>(`/issues/${id}`),
create: (data: Partial<Issue>) => api.post<Issue>("/issues", data),
update: (id: string, data: Partial<Issue>) => api.patch<Issue>(`/issues/${id}`, data),
create: (companyId: string, data: Record<string, unknown>) =>
api.post<Issue>(`/companies/${companyId}/issues`, data),
update: (id: string, data: Record<string, unknown>) => api.patch<Issue>(`/issues/${id}`, data),
remove: (id: string) => api.delete<Issue>(`/issues/${id}`),
checkout: (id: string, agentId: string) =>
api.post<Issue>(`/issues/${id}/checkout`, {
agentId,
expectedStatuses: ["todo", "backlog", "blocked"],
}),
release: (id: string) => api.post<Issue>(`/issues/${id}/release`, {}),
listComments: (id: string) => api.get<IssueComment[]>(`/issues/${id}/comments`),
addComment: (id: string, body: string) => api.post<IssueComment>(`/issues/${id}/comments`, { body }),
};

View File

@@ -2,9 +2,10 @@ import type { Project } from "@paperclip/shared";
import { api } from "./client";
export const projectsApi = {
list: () => api.get<Project[]>("/projects"),
list: (companyId: string) => api.get<Project[]>(`/companies/${companyId}/projects`),
get: (id: string) => api.get<Project>(`/projects/${id}`),
create: (data: Partial<Project>) => api.post<Project>("/projects", data),
update: (id: string, data: Partial<Project>) => api.patch<Project>(`/projects/${id}`, data),
create: (companyId: string, data: Record<string, unknown>) =>
api.post<Project>(`/companies/${companyId}/projects`, data),
update: (id: string, data: Record<string, unknown>) => api.patch<Project>(`/projects/${id}`, data),
remove: (id: string) => api.delete<Project>(`/projects/${id}`),
};

View File

@@ -1,13 +1,43 @@
import { Outlet } from "react-router-dom";
import { Sidebar } from "./Sidebar";
import { useCompany } from "../context/CompanyContext";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export function Layout() {
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
return (
<div className="flex h-screen bg-gray-50 text-gray-900">
<div className="flex h-screen bg-background text-foreground">
<Sidebar />
<main className="flex-1 overflow-auto p-8">
<Outlet />
</main>
<div className="flex-1 overflow-auto">
<header className="bg-card border-b border-border px-8 py-3 flex items-center justify-end">
<label className="text-xs text-muted-foreground mr-2">Company</label>
<Select
value={selectedCompanyId ?? ""}
onValueChange={(value) => setSelectedCompanyId(value)}
>
<SelectTrigger className="w-48 h-8 text-sm">
<SelectValue placeholder="No companies" />
</SelectTrigger>
<SelectContent>
{companies.map((company) => (
<SelectItem key={company.id} value={company.id}>
{company.name}
</SelectItem>
))}
</SelectContent>
</Select>
</header>
<main className="p-8">
<Outlet />
</main>
</div>
</div>
);
}

View File

@@ -3,15 +3,20 @@ import { cn } from "../lib/utils";
const links = [
{ to: "/", label: "Dashboard" },
{ to: "/companies", label: "Companies" },
{ to: "/org", label: "Org" },
{ to: "/agents", label: "Agents" },
{ to: "/tasks", label: "Tasks" },
{ to: "/projects", label: "Projects" },
{ to: "/issues", label: "Issues" },
{ to: "/goals", label: "Goals" },
{ to: "/approvals", label: "Approvals" },
{ to: "/costs", label: "Costs" },
{ to: "/activity", label: "Activity" },
];
export function Sidebar() {
return (
<aside className="w-56 border-r border-gray-200 bg-white p-4 flex flex-col gap-1">
<aside className="w-56 border-r border-border bg-card p-4 flex flex-col gap-1">
<h1 className="text-lg font-bold mb-6 px-3">Paperclip</h1>
<nav className="flex flex-col gap-1">
{links.map((link) => (
@@ -23,8 +28,8 @@ export function Sidebar() {
cn(
"px-3 py-2 rounded-md text-sm font-medium transition-colors",
isActive
? "bg-gray-100 text-gray-900"
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900"
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground"
)
}
>

View File

@@ -1,16 +1,27 @@
import { cn } from "../lib/utils";
const statusColors: Record<string, string> = {
active: "bg-green-100 text-green-800",
idle: "bg-yellow-100 text-yellow-800",
offline: "bg-gray-100 text-gray-600",
error: "bg-red-100 text-red-800",
backlog: "bg-gray-100 text-gray-600",
todo: "bg-blue-100 text-blue-800",
in_progress: "bg-indigo-100 text-indigo-800",
in_review: "bg-purple-100 text-purple-800",
done: "bg-green-100 text-green-800",
cancelled: "bg-gray-100 text-gray-500",
active: "bg-green-900/50 text-green-300",
running: "bg-cyan-900/50 text-cyan-300",
paused: "bg-orange-900/50 text-orange-300",
idle: "bg-yellow-900/50 text-yellow-300",
archived: "bg-neutral-800 text-neutral-400",
planned: "bg-neutral-800 text-neutral-400",
achieved: "bg-green-900/50 text-green-300",
completed: "bg-green-900/50 text-green-300",
failed: "bg-red-900/50 text-red-300",
succeeded: "bg-green-900/50 text-green-300",
error: "bg-red-900/50 text-red-300",
backlog: "bg-neutral-800 text-neutral-400",
todo: "bg-blue-900/50 text-blue-300",
in_progress: "bg-indigo-900/50 text-indigo-300",
in_review: "bg-violet-900/50 text-violet-300",
blocked: "bg-amber-900/50 text-amber-300",
done: "bg-green-900/50 text-green-300",
cancelled: "bg-neutral-800 text-neutral-500",
pending: "bg-yellow-900/50 text-yellow-300",
approved: "bg-green-900/50 text-green-300",
rejected: "bg-red-900/50 text-red-300",
};
export function StatusBadge({ status }: { status: string }) {
@@ -18,7 +29,7 @@ export function StatusBadge({ status }: { status: string }) {
<span
className={cn(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
statusColors[status] ?? "bg-gray-100 text-gray-600"
statusColors[status] ?? "bg-neutral-800 text-neutral-400"
)}
>
{status.replace("_", " ")}

View File

@@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,64 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,188 @@
import * as React from "react"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,124 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import type { Company } from "@paperclip/shared";
import { companiesApi } from "../api/companies";
interface CompanyContextValue {
companies: Company[];
selectedCompanyId: string | null;
selectedCompany: Company | null;
loading: boolean;
error: Error | null;
setSelectedCompanyId: (companyId: string) => void;
reloadCompanies: () => Promise<void>;
createCompany: (data: {
name: string;
description?: string | null;
budgetMonthlyCents?: number;
}) => Promise<Company>;
}
const STORAGE_KEY = "paperclip.selectedCompanyId";
const CompanyContext = createContext<CompanyContextValue | null>(null);
export function CompanyProvider({ children }: { children: ReactNode }) {
const [companies, setCompanies] = useState<Company[]>([]);
const [selectedCompanyId, setSelectedCompanyIdState] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const setSelectedCompanyId = useCallback((companyId: string) => {
setSelectedCompanyIdState(companyId);
localStorage.setItem(STORAGE_KEY, companyId);
}, []);
const reloadCompanies = useCallback(async () => {
setLoading(true);
setError(null);
try {
const rows = await companiesApi.list();
setCompanies(rows);
if (rows.length === 0) {
setSelectedCompanyIdState(null);
return;
}
const stored = localStorage.getItem(STORAGE_KEY);
const next = rows.some((company) => company.id === stored)
? stored
: selectedCompanyId && rows.some((company) => company.id === selectedCompanyId)
? selectedCompanyId
: rows[0]!.id;
if (next) {
setSelectedCompanyIdState(next);
localStorage.setItem(STORAGE_KEY, next);
}
} catch (err) {
setError(err instanceof Error ? err : new Error("Failed to load companies"));
} finally {
setLoading(false);
}
}, [selectedCompanyId]);
useEffect(() => {
void reloadCompanies();
}, [reloadCompanies]);
const createCompany = useCallback(
async (data: { name: string; description?: string | null; budgetMonthlyCents?: number }) => {
const company = await companiesApi.create(data);
await reloadCompanies();
setSelectedCompanyId(company.id);
return company;
},
[reloadCompanies, setSelectedCompanyId],
);
const selectedCompany = useMemo(
() => companies.find((company) => company.id === selectedCompanyId) ?? null,
[companies, selectedCompanyId],
);
const value = useMemo(
() => ({
companies,
selectedCompanyId,
selectedCompany,
loading,
error,
setSelectedCompanyId,
reloadCompanies,
createCompany,
}),
[
companies,
selectedCompanyId,
selectedCompany,
loading,
error,
setSelectedCompanyId,
reloadCompanies,
createCompany,
],
);
return <CompanyContext.Provider value={value}>{children}</CompanyContext.Provider>;
}
export function useCompany() {
const ctx = useContext(CompanyContext);
if (!ctx) {
throw new Error("useCompany must be used within CompanyProvider");
}
return ctx;
}

View File

@@ -2,7 +2,10 @@ import { useCallback } from "react";
import { agentsApi } from "../api/agents";
import { useApi } from "./useApi";
export function useAgents() {
const fetcher = useCallback(() => agentsApi.list(), []);
export function useAgents(companyId: string | null) {
const fetcher = useCallback(() => {
if (!companyId) return Promise.resolve([]);
return agentsApi.list(companyId);
}, [companyId]);
return useApi(fetcher);
}

View File

@@ -1 +1,122 @@
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -1,5 +1,8 @@
export function cn(...classes: (string | false | null | undefined)[]) {
return classes.filter(Boolean).join(" ");
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatCents(cents: number): string {

View File

@@ -2,12 +2,15 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App";
import { CompanyProvider } from "./context/CompanyContext";
import "./index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
<CompanyProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</CompanyProvider>
</StrictMode>
);

49
ui/src/pages/Activity.tsx Normal file
View File

@@ -0,0 +1,49 @@
import { useCallback } from "react";
import { activityApi } from "../api/activity";
import { useCompany } from "../context/CompanyContext";
import { useApi } from "../hooks/useApi";
import { formatDate } from "../lib/utils";
import { Card, CardContent } from "@/components/ui/card";
export function Activity() {
const { selectedCompanyId } = useCompany();
const fetcher = useCallback(() => {
if (!selectedCompanyId) return Promise.resolve([]);
return activityApi.list(selectedCompanyId);
}, [selectedCompanyId]);
const { data, loading, error } = useApi(fetcher);
if (!selectedCompanyId) {
return <p className="text-muted-foreground">Select a company first.</p>;
}
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>}
{data && data.length === 0 && <p className="text-muted-foreground">No activity yet.</p>}
{data && data.length > 0 && (
<div className="space-y-2">
{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>
)}
</div>
);
}

View File

@@ -1,33 +1,91 @@
import { useState } from "react";
import { useAgents } from "../hooks/useAgents";
import { StatusBadge } from "../components/StatusBadge";
import { formatCents } from "../lib/utils";
import { useCompany } from "../context/CompanyContext";
import { agentsApi } from "../api/agents";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
export function Agents() {
const { data: agents, loading, error } = useAgents();
const { selectedCompanyId } = useCompany();
const { data: agents, loading, error, reload } = useAgents(selectedCompanyId);
const [actionError, setActionError] = useState<string | null>(null);
async function invoke(agentId: string) {
setActionError(null);
try {
await agentsApi.invoke(agentId);
reload();
} catch (err) {
setActionError(err instanceof Error ? err.message : "Failed to invoke agent");
}
}
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 (
<div>
<h2 className="text-2xl font-bold mb-4">Agents</h2>
{loading && <p className="text-gray-500">Loading...</p>}
{error && <p className="text-red-600">{error.message}</p>}
{agents && agents.length === 0 && <p className="text-gray-500">No agents yet.</p>}
{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>}
{agents && agents.length > 0 && (
<div className="grid gap-4">
{agents.map((agent) => (
<div key={agent.id} className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold">{agent.name}</h3>
<p className="text-sm text-gray-500">{agent.role}</p>
<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="flex items-center gap-3">
<span className="text-sm text-muted-foreground">
{formatCents(agent.spentMonthlyCents)} / {formatCents(agent.budgetMonthlyCents)}
</span>
<StatusBadge status={agent.status} />
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-500">
{formatCents(agent.spentCents)} / {formatCents(agent.budgetCents)}
</span>
<StatusBadge status={agent.status} />
<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>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}

View File

@@ -0,0 +1,91 @@
import { useCallback, useState } from "react";
import { approvalsApi } from "../api/approvals";
import { useCompany } from "../context/CompanyContext";
import { useApi } from "../hooks/useApi";
import { StatusBadge } from "../components/StatusBadge";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
export function Approvals() {
const { selectedCompanyId } = useCompany();
const [actionError, setActionError] = useState<string | null>(null);
const fetcher = useCallback(() => {
if (!selectedCompanyId) return Promise.resolve([]);
return approvalsApi.list(selectedCompanyId);
}, [selectedCompanyId]);
const { data, loading, error, reload } = useApi(fetcher);
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 <p className="text-muted-foreground">Select a company first.</p>;
}
return (
<div>
<h2 className="text-2xl font-bold mb-4">Approvals</h2>
{loading && <p className="text-muted-foreground">Loading...</p>}
{error && <p className="text-destructive">{error.message}</p>}
{actionError && <p className="text-destructive">{actionError}</p>}
{data && data.length === 0 && <p className="text-muted-foreground">No approvals.</p>}
{data && data.length > 0 && (
<div className="grid gap-3">
{data.map((approval) => (
<Card key={approval.id}>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">{approval.type}</p>
<p className="text-xs text-muted-foreground">{approval.id}</p>
</div>
<StatusBadge status={approval.status} />
</div>
{approval.status === "pending" && (
<div className="flex gap-2 mt-3">
<Button
variant="outline"
size="sm"
className="border-green-700 text-green-400 hover:bg-green-900/50"
onClick={() => approve(approval.id)}
>
Approve
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => reject(approval.id)}
>
Reject
</Button>
</div>
)}
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}

114
ui/src/pages/Companies.tsx Normal file
View File

@@ -0,0 +1,114 @@
import { useState } from "react";
import { useCompany } from "../context/CompanyContext";
import { formatCents } from "../lib/utils";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
export function Companies() {
const {
companies,
selectedCompanyId,
setSelectedCompanyId,
createCompany,
loading,
error,
reloadCompanies,
} = useCompany();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [budget, setBudget] = useState("0");
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
if (!name.trim()) return;
setSubmitting(true);
setSubmitError(null);
try {
await createCompany({
name: name.trim(),
description: description.trim() || null,
budgetMonthlyCents: Number(budget) || 0,
});
setName("");
setDescription("");
setBudget("0");
await reloadCompanies();
} catch (err) {
setSubmitError(err instanceof Error ? err.message : "Failed to create company");
} finally {
setSubmitting(false);
}
}
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold">Companies</h2>
<p className="text-muted-foreground">Create and select the company you are operating.</p>
</div>
<Card>
<CardContent className="p-4 space-y-3">
<h3 className="font-semibold">Create Company</h3>
<form onSubmit={onSubmit} className="space-y-3">
<div className="grid md:grid-cols-3 gap-3">
<Input
placeholder="Company name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Input
placeholder="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<Input
placeholder="Monthly budget (cents)"
value={budget}
onChange={(e) => setBudget(e.target.value.replace(/[^0-9]/g, ""))}
/>
</div>
{submitError && <p className="text-sm text-destructive">{submitError}</p>}
<Button type="submit" disabled={submitting}>
{submitting ? "Creating..." : "Create Company"}
</Button>
</form>
</CardContent>
</Card>
{loading && <p className="text-muted-foreground">Loading companies...</p>}
{error && <p className="text-destructive">{error.message}</p>}
<div className="grid gap-3">
{companies.map((company) => {
const selected = company.id === selectedCompanyId;
return (
<button
key={company.id}
onClick={() => setSelectedCompanyId(company.id)}
className={`text-left bg-card border rounded-lg p-4 ${
selected ? "border-primary ring-1 ring-primary" : "border-border"
}`}
>
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold">{company.name}</h3>
{company.description && (
<p className="text-sm text-muted-foreground mt-1">{company.description}</p>
)}
</div>
<div className="text-sm text-muted-foreground">
{formatCents(company.spentMonthlyCents)} / {formatCents(company.budgetMonthlyCents)}
</div>
</div>
</button>
);
})}
</div>
</div>
);
}

83
ui/src/pages/Costs.tsx Normal file
View File

@@ -0,0 +1,83 @@
import { useCallback } from "react";
import { costsApi } from "../api/costs";
import { useCompany } from "../context/CompanyContext";
import { useApi } from "../hooks/useApi";
import { formatCents } from "../lib/utils";
import { Card, CardContent } from "@/components/ui/card";
export function Costs() {
const { selectedCompanyId } = useCompany();
const fetcher = useCallback(async () => {
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 (
<div className="space-y-5">
<h2 className="text-2xl font-bold">Costs</h2>
{loading && <p className="text-muted-foreground">Loading...</p>}
{error && <p className="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)}
</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>
</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>
))}
{data.byProject.length === 0 && <p className="text-muted-foreground">No project-attributed costs yet.</p>}
</div>
</CardContent>
</Card>
</>
)}
</div>
);
}

View File

@@ -1,8 +1,72 @@
import { useCallback } from "react";
import { dashboardApi } from "../api/dashboard";
import { useCompany } from "../context/CompanyContext";
import { useApi } from "../hooks/useApi";
import { formatCents } from "../lib/utils";
import { Card, CardContent } from "@/components/ui/card";
export function Dashboard() {
const { selectedCompanyId, selectedCompany } = useCompany();
const fetcher = useCallback(() => {
if (!selectedCompanyId) {
return Promise.resolve(null);
}
return dashboardApi.summary(selectedCompanyId);
}, [selectedCompanyId]);
const { data, loading, error } = useApi(fetcher);
if (!selectedCompanyId) {
return <p className="text-muted-foreground">Create or select a company to view the dashboard.</p>;
}
return (
<div>
<h2 className="text-2xl font-bold mb-4">Dashboard</h2>
<p className="text-gray-600">Welcome to Paperclip. Select a section from the sidebar.</p>
<div className="space-y-4">
<div>
<h2 className="text-2xl font-bold">Dashboard</h2>
<p className="text-muted-foreground">{selectedCompany?.name}</p>
</div>
{loading && <p className="text-muted-foreground">Loading...</p>}
{error && <p className="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>
);
}

View File

@@ -1,46 +1,45 @@
import { useCallback } from "react";
import { goalsApi } from "../api/goals";
import { useApi } from "../hooks/useApi";
import { cn } from "../lib/utils";
const levelColors: Record<string, string> = {
company: "bg-purple-100 text-purple-800",
team: "bg-blue-100 text-blue-800",
agent: "bg-indigo-100 text-indigo-800",
task: "bg-gray-100 text-gray-600",
};
import { StatusBadge } from "../components/StatusBadge";
import { useCompany } from "../context/CompanyContext";
import { Card, CardContent } from "@/components/ui/card";
export function Goals() {
const fetcher = useCallback(() => goalsApi.list(), []);
const { selectedCompanyId } = useCompany();
const fetcher = useCallback(() => {
if (!selectedCompanyId) return Promise.resolve([]);
return goalsApi.list(selectedCompanyId);
}, [selectedCompanyId]);
const { data: goals, loading, error } = useApi(fetcher);
if (!selectedCompanyId) {
return <p className="text-muted-foreground">Select a company first.</p>;
}
return (
<div>
<h2 className="text-2xl font-bold mb-4">Goals</h2>
{loading && <p className="text-gray-500">Loading...</p>}
{error && <p className="text-red-600">{error.message}</p>}
{goals && goals.length === 0 && <p className="text-gray-500">No goals yet.</p>}
{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>}
{goals && goals.length > 0 && (
<div className="grid gap-4">
{goals.map((goal) => (
<div key={goal.id} className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold">{goal.title}</h3>
{goal.description && (
<p className="text-sm text-gray-500 mt-1">{goal.description}</p>
)}
<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>
<span
className={cn(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
levelColors[goal.level] ?? "bg-gray-100 text-gray-600"
)}
>
{goal.level}
</span>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}

View File

@@ -3,50 +3,62 @@ 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";
const priorityColors: Record<string, string> = {
critical: "text-red-700 bg-red-50",
high: "text-orange-700 bg-orange-50",
medium: "text-yellow-700 bg-yellow-50",
low: "text-gray-600 bg-gray-50",
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",
};
export function Issues() {
const fetcher = useCallback(() => issuesApi.list(), []);
const { selectedCompanyId } = useCompany();
const fetcher = useCallback(() => {
if (!selectedCompanyId) return Promise.resolve([]);
return issuesApi.list(selectedCompanyId);
}, [selectedCompanyId]);
const { data: issues, loading, error } = useApi(fetcher);
if (!selectedCompanyId) {
return <p className="text-muted-foreground">Select a company first.</p>;
}
return (
<div>
<h2 className="text-2xl font-bold mb-4">Issues</h2>
{loading && <p className="text-gray-500">Loading...</p>}
{error && <p className="text-red-600">{error.message}</p>}
{issues && issues.length === 0 && <p className="text-gray-500">No issues yet.</p>}
<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) => (
<div key={issue.id} className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold">{issue.title}</h3>
{issue.description && (
<p className="text-sm text-gray-500 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-gray-600 bg-gray-50"
<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>
)}
>
{issue.priority}
</span>
<StatusBadge status={issue.status} />
</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>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}

52
ui/src/pages/Org.tsx Normal file
View File

@@ -0,0 +1,52 @@
import { useCallback } from "react";
import { agentsApi, type OrgNode } from "../api/agents";
import { useCompany } from "../context/CompanyContext";
import { useApi } from "../hooks/useApi";
import { StatusBadge } from "../components/StatusBadge";
import { Card, CardContent } from "@/components/ui/card";
function OrgTree({ nodes, depth = 0 }: { nodes: OrgNode[]; depth?: number }) {
return (
<div className="space-y-2">
{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>
))}
</div>
);
}
export function Org() {
const { selectedCompanyId } = useCompany();
const fetcher = useCallback(() => {
if (!selectedCompanyId) return Promise.resolve([] as OrgNode[]);
return agentsApi.org(selectedCompanyId);
}, [selectedCompanyId]);
const { data, loading, error } = useApi(fetcher);
if (!selectedCompanyId) {
return <p className="text-muted-foreground">Select a company first.</p>;
}
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>
);
}

View File

@@ -2,31 +2,49 @@ import { useCallback } from "react";
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";
export function Projects() {
const fetcher = useCallback(() => projectsApi.list(), []);
const { selectedCompanyId } = useCompany();
const fetcher = useCallback(() => {
if (!selectedCompanyId) return Promise.resolve([]);
return projectsApi.list(selectedCompanyId);
}, [selectedCompanyId]);
const { data: projects, loading, error } = useApi(fetcher);
if (!selectedCompanyId) {
return <p className="text-muted-foreground">Select a company first.</p>;
}
return (
<div>
<h2 className="text-2xl font-bold mb-4">Projects</h2>
{loading && <p className="text-gray-500">Loading...</p>}
{error && <p className="text-red-600">{error.message}</p>}
{projects && projects.length === 0 && <p className="text-gray-500">No projects yet.</p>}
{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>}
{projects && projects.length > 0 && (
<div className="grid gap-4">
{projects.map((project) => (
<div key={project.id} className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold">{project.name}</h3>
{project.description && (
<p className="text-sm text-gray-500 mt-1">{project.description}</p>
)}
<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>
<StatusBadge status={project.status} />
</div>
<span className="text-sm text-gray-400">{formatDate(project.createdAt)}</span>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}

View File

@@ -10,7 +10,11 @@
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
"noEmit": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

View File

@@ -1,9 +1,15 @@
import path from "path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
port: 5173,
proxy: {