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:
21
ui/components.json
Normal file
21
ui/components.json
Normal 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"
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" class="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|||||||
@@ -11,12 +11,19 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@paperclip/shared": "workspace:*",
|
"@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": "^19.0.0",
|
||||||
"react-dom": "^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": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.7",
|
"@tailwindcss/vite": "^4.0.7",
|
||||||
|
"@types/node": "^25.2.3",
|
||||||
"@types/react": "^19.0.8",
|
"@types/react": "^19.0.8",
|
||||||
"@types/react-dom": "^19.0.3",
|
"@types/react-dom": "^19.0.3",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
|||||||
@@ -1,20 +1,30 @@
|
|||||||
import { Routes, Route } from "react-router-dom";
|
import { Routes, Route } from "react-router-dom";
|
||||||
import { Layout } from "./components/Layout";
|
import { Layout } from "./components/Layout";
|
||||||
import { Dashboard } from "./pages/Dashboard";
|
import { Dashboard } from "./pages/Dashboard";
|
||||||
|
import { Companies } from "./pages/Companies";
|
||||||
|
import { Org } from "./pages/Org";
|
||||||
import { Agents } from "./pages/Agents";
|
import { Agents } from "./pages/Agents";
|
||||||
import { Projects } from "./pages/Projects";
|
import { Projects } from "./pages/Projects";
|
||||||
import { Issues } from "./pages/Issues";
|
import { Issues } from "./pages/Issues";
|
||||||
import { Goals } from "./pages/Goals";
|
import { Goals } from "./pages/Goals";
|
||||||
|
import { Approvals } from "./pages/Approvals";
|
||||||
|
import { Costs } from "./pages/Costs";
|
||||||
|
import { Activity } from "./pages/Activity";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
<Route index element={<Dashboard />} />
|
<Route index element={<Dashboard />} />
|
||||||
|
<Route path="companies" element={<Companies />} />
|
||||||
|
<Route path="org" element={<Org />} />
|
||||||
<Route path="agents" element={<Agents />} />
|
<Route path="agents" element={<Agents />} />
|
||||||
<Route path="projects" element={<Projects />} />
|
<Route path="projects" element={<Projects />} />
|
||||||
<Route path="issues" element={<Issues />} />
|
<Route path="tasks" element={<Issues />} />
|
||||||
<Route path="goals" element={<Goals />} />
|
<Route path="goals" element={<Goals />} />
|
||||||
|
<Route path="approvals" element={<Approvals />} />
|
||||||
|
<Route path="costs" element={<Costs />} />
|
||||||
|
<Route path="activity" element={<Activity />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
6
ui/src/api/activity.ts
Normal file
6
ui/src/api/activity.ts
Normal 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`),
|
||||||
|
};
|
||||||
@@ -1,10 +1,24 @@
|
|||||||
import type { Agent } from "@paperclip/shared";
|
import type { Agent, AgentKeyCreated, HeartbeatRun } from "@paperclip/shared";
|
||||||
import { api } from "./client";
|
import { api } from "./client";
|
||||||
|
|
||||||
|
export interface OrgNode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
status: string;
|
||||||
|
reports: OrgNode[];
|
||||||
|
}
|
||||||
|
|
||||||
export const agentsApi = {
|
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}`),
|
get: (id: string) => api.get<Agent>(`/agents/${id}`),
|
||||||
create: (data: Partial<Agent>) => api.post<Agent>("/agents", data),
|
create: (companyId: string, data: Record<string, unknown>) =>
|
||||||
update: (id: string, data: Partial<Agent>) => api.patch<Agent>(`/agents/${id}`, data),
|
api.post<Agent>(`/companies/${companyId}/agents`, data),
|
||||||
remove: (id: string) => api.delete<Agent>(`/agents/${id}`),
|
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
15
ui/src/api/approvals.ts
Normal 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
14
ui/src/api/companies.ts
Normal 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
18
ui/src/api/costs.ts
Normal 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
6
ui/src/api/dashboard.ts
Normal 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`),
|
||||||
|
};
|
||||||
@@ -2,9 +2,10 @@ import type { Goal } from "@paperclip/shared";
|
|||||||
import { api } from "./client";
|
import { api } from "./client";
|
||||||
|
|
||||||
export const goalsApi = {
|
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}`),
|
get: (id: string) => api.get<Goal>(`/goals/${id}`),
|
||||||
create: (data: Partial<Goal>) => api.post<Goal>("/goals", data),
|
create: (companyId: string, data: Record<string, unknown>) =>
|
||||||
update: (id: string, data: Partial<Goal>) => api.patch<Goal>(`/goals/${id}`, data),
|
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}`),
|
remove: (id: string) => api.delete<Goal>(`/goals/${id}`),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
export { api } from "./client";
|
export { api } from "./client";
|
||||||
|
export { companiesApi } from "./companies";
|
||||||
export { agentsApi } from "./agents";
|
export { agentsApi } from "./agents";
|
||||||
export { projectsApi } from "./projects";
|
export { projectsApi } from "./projects";
|
||||||
export { issuesApi } from "./issues";
|
export { issuesApi } from "./issues";
|
||||||
export { goalsApi } from "./goals";
|
export { goalsApi } from "./goals";
|
||||||
|
export { approvalsApi } from "./approvals";
|
||||||
|
export { costsApi } from "./costs";
|
||||||
|
export { activityApi } from "./activity";
|
||||||
|
export { dashboardApi } from "./dashboard";
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import type { Issue } from "@paperclip/shared";
|
import type { Issue, IssueComment } from "@paperclip/shared";
|
||||||
import { api } from "./client";
|
import { api } from "./client";
|
||||||
|
|
||||||
export const issuesApi = {
|
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}`),
|
get: (id: string) => api.get<Issue>(`/issues/${id}`),
|
||||||
create: (data: Partial<Issue>) => api.post<Issue>("/issues", data),
|
create: (companyId: string, data: Record<string, unknown>) =>
|
||||||
update: (id: string, data: Partial<Issue>) => api.patch<Issue>(`/issues/${id}`, data),
|
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}`),
|
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 }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import type { Project } from "@paperclip/shared";
|
|||||||
import { api } from "./client";
|
import { api } from "./client";
|
||||||
|
|
||||||
export const projectsApi = {
|
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}`),
|
get: (id: string) => api.get<Project>(`/projects/${id}`),
|
||||||
create: (data: Partial<Project>) => api.post<Project>("/projects", data),
|
create: (companyId: string, data: Record<string, unknown>) =>
|
||||||
update: (id: string, data: Partial<Project>) => api.patch<Project>(`/projects/${id}`, data),
|
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}`),
|
remove: (id: string) => api.delete<Project>(`/projects/${id}`),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,43 @@
|
|||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
import { Sidebar } from "./Sidebar";
|
import { Sidebar } from "./Sidebar";
|
||||||
|
import { useCompany } from "../context/CompanyContext";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
|
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-gray-50 text-gray-900">
|
<div className="flex h-screen bg-background text-foreground">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="flex-1 overflow-auto p-8">
|
<div className="flex-1 overflow-auto">
|
||||||
<Outlet />
|
<header className="bg-card border-b border-border px-8 py-3 flex items-center justify-end">
|
||||||
</main>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,20 @@ import { cn } from "../lib/utils";
|
|||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
{ to: "/", label: "Dashboard" },
|
{ to: "/", label: "Dashboard" },
|
||||||
|
{ to: "/companies", label: "Companies" },
|
||||||
|
{ to: "/org", label: "Org" },
|
||||||
{ to: "/agents", label: "Agents" },
|
{ to: "/agents", label: "Agents" },
|
||||||
|
{ to: "/tasks", label: "Tasks" },
|
||||||
{ to: "/projects", label: "Projects" },
|
{ to: "/projects", label: "Projects" },
|
||||||
{ to: "/issues", label: "Issues" },
|
|
||||||
{ to: "/goals", label: "Goals" },
|
{ to: "/goals", label: "Goals" },
|
||||||
|
{ to: "/approvals", label: "Approvals" },
|
||||||
|
{ to: "/costs", label: "Costs" },
|
||||||
|
{ to: "/activity", label: "Activity" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
return (
|
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>
|
<h1 className="text-lg font-bold mb-6 px-3">Paperclip</h1>
|
||||||
<nav className="flex flex-col gap-1">
|
<nav className="flex flex-col gap-1">
|
||||||
{links.map((link) => (
|
{links.map((link) => (
|
||||||
@@ -23,8 +28,8 @@ export function Sidebar() {
|
|||||||
cn(
|
cn(
|
||||||
"px-3 py-2 rounded-md text-sm font-medium transition-colors",
|
"px-3 py-2 rounded-md text-sm font-medium transition-colors",
|
||||||
isActive
|
isActive
|
||||||
? "bg-gray-100 text-gray-900"
|
? "bg-accent text-accent-foreground"
|
||||||
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900"
|
: "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,16 +1,27 @@
|
|||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
const statusColors: Record<string, string> = {
|
||||||
active: "bg-green-100 text-green-800",
|
active: "bg-green-900/50 text-green-300",
|
||||||
idle: "bg-yellow-100 text-yellow-800",
|
running: "bg-cyan-900/50 text-cyan-300",
|
||||||
offline: "bg-gray-100 text-gray-600",
|
paused: "bg-orange-900/50 text-orange-300",
|
||||||
error: "bg-red-100 text-red-800",
|
idle: "bg-yellow-900/50 text-yellow-300",
|
||||||
backlog: "bg-gray-100 text-gray-600",
|
archived: "bg-neutral-800 text-neutral-400",
|
||||||
todo: "bg-blue-100 text-blue-800",
|
planned: "bg-neutral-800 text-neutral-400",
|
||||||
in_progress: "bg-indigo-100 text-indigo-800",
|
achieved: "bg-green-900/50 text-green-300",
|
||||||
in_review: "bg-purple-100 text-purple-800",
|
completed: "bg-green-900/50 text-green-300",
|
||||||
done: "bg-green-100 text-green-800",
|
failed: "bg-red-900/50 text-red-300",
|
||||||
cancelled: "bg-gray-100 text-gray-500",
|
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 }) {
|
export function StatusBadge({ status }: { status: string }) {
|
||||||
@@ -18,7 +29,7 @@ export function StatusBadge({ status }: { status: string }) {
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
|
"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("_", " ")}
|
{status.replace("_", " ")}
|
||||||
|
|||||||
48
ui/src/components/ui/badge.tsx
Normal file
48
ui/src/components/ui/badge.tsx
Normal 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 }
|
||||||
64
ui/src/components/ui/button.tsx
Normal file
64
ui/src/components/ui/button.tsx
Normal 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 }
|
||||||
92
ui/src/components/ui/card.tsx
Normal file
92
ui/src/components/ui/card.tsx
Normal 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,
|
||||||
|
}
|
||||||
21
ui/src/components/ui/input.tsx
Normal file
21
ui/src/components/ui/input.tsx
Normal 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 }
|
||||||
188
ui/src/components/ui/select.tsx
Normal file
188
ui/src/components/ui/select.tsx
Normal 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,
|
||||||
|
}
|
||||||
28
ui/src/components/ui/separator.tsx
Normal file
28
ui/src/components/ui/separator.tsx
Normal 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 }
|
||||||
124
ui/src/context/CompanyContext.tsx
Normal file
124
ui/src/context/CompanyContext.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -2,7 +2,10 @@ import { useCallback } from "react";
|
|||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
import { useApi } from "./useApi";
|
import { useApi } from "./useApi";
|
||||||
|
|
||||||
export function useAgents() {
|
export function useAgents(companyId: string | null) {
|
||||||
const fetcher = useCallback(() => agentsApi.list(), []);
|
const fetcher = useCallback(() => {
|
||||||
|
if (!companyId) return Promise.resolve([]);
|
||||||
|
return agentsApi.list(companyId);
|
||||||
|
}, [companyId]);
|
||||||
return useApi(fetcher);
|
return useApi(fetcher);
|
||||||
}
|
}
|
||||||
|
|||||||
121
ui/src/index.css
121
ui/src/index.css
@@ -1 +1,122 @@
|
|||||||
@import "tailwindcss";
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
export function cn(...classes: (string | false | null | undefined)[]) {
|
import { type ClassValue, clsx } from "clsx";
|
||||||
return classes.filter(Boolean).join(" ");
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatCents(cents: number): string {
|
export function formatCents(cents: number): string {
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import { StrictMode } from "react";
|
|||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
|
import { CompanyProvider } from "./context/CompanyContext";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<CompanyProvider>
|
||||||
<App />
|
<BrowserRouter>
|
||||||
</BrowserRouter>
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</CompanyProvider>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
49
ui/src/pages/Activity.tsx
Normal file
49
ui/src/pages/Activity.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,33 +1,91 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { useAgents } from "../hooks/useAgents";
|
import { useAgents } from "../hooks/useAgents";
|
||||||
import { StatusBadge } from "../components/StatusBadge";
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
import { formatCents } from "../lib/utils";
|
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() {
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold mb-4">Agents</h2>
|
<h2 className="text-2xl font-bold mb-4">Agents</h2>
|
||||||
{loading && <p className="text-gray-500">Loading...</p>}
|
{loading && <p className="text-muted-foreground">Loading...</p>}
|
||||||
{error && <p className="text-red-600">{error.message}</p>}
|
{error && <p className="text-destructive">{error.message}</p>}
|
||||||
{agents && agents.length === 0 && <p className="text-gray-500">No agents yet.</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 && (
|
{agents && agents.length > 0 && (
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
{agents.map((agent) => (
|
{agents.map((agent) => (
|
||||||
<div key={agent.id} className="bg-white rounded-lg border border-gray-200 p-4">
|
<Card key={agent.id}>
|
||||||
<div className="flex items-center justify-between">
|
<CardContent className="p-4 space-y-3">
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="font-semibold">{agent.name}</h3>
|
<div>
|
||||||
<p className="text-sm text-gray-500">{agent.role}</p>
|
<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>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex gap-2">
|
||||||
<span className="text-sm text-gray-500">
|
<Button variant="outline" size="sm" onClick={() => invoke(agent.id)}>
|
||||||
{formatCents(agent.spentCents)} / {formatCents(agent.budgetCents)}
|
Invoke
|
||||||
</span>
|
</Button>
|
||||||
<StatusBadge status={agent.status} />
|
<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>
|
||||||
</div>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
91
ui/src/pages/Approvals.tsx
Normal file
91
ui/src/pages/Approvals.tsx
Normal 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
114
ui/src/pages/Companies.tsx
Normal 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
83
ui/src/pages/Costs.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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() {
|
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 (
|
return (
|
||||||
<div>
|
<div className="space-y-4">
|
||||||
<h2 className="text-2xl font-bold mb-4">Dashboard</h2>
|
<div>
|
||||||
<p className="text-gray-600">Welcome to Paperclip. Select a section from the sidebar.</p>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,45 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { goalsApi } from "../api/goals";
|
import { goalsApi } from "../api/goals";
|
||||||
import { useApi } from "../hooks/useApi";
|
import { useApi } from "../hooks/useApi";
|
||||||
import { cn } from "../lib/utils";
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
|
import { useCompany } from "../context/CompanyContext";
|
||||||
const levelColors: Record<string, string> = {
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
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",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function Goals() {
|
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);
|
const { data: goals, loading, error } = useApi(fetcher);
|
||||||
|
|
||||||
|
if (!selectedCompanyId) {
|
||||||
|
return <p className="text-muted-foreground">Select a company first.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold mb-4">Goals</h2>
|
<h2 className="text-2xl font-bold mb-4">Goals</h2>
|
||||||
{loading && <p className="text-gray-500">Loading...</p>}
|
{loading && <p className="text-muted-foreground">Loading...</p>}
|
||||||
{error && <p className="text-red-600">{error.message}</p>}
|
{error && <p className="text-destructive">{error.message}</p>}
|
||||||
{goals && goals.length === 0 && <p className="text-gray-500">No goals yet.</p>}
|
{goals && goals.length === 0 && <p className="text-muted-foreground">No goals yet.</p>}
|
||||||
{goals && goals.length > 0 && (
|
{goals && goals.length > 0 && (
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
{goals.map((goal) => (
|
{goals.map((goal) => (
|
||||||
<div key={goal.id} className="bg-white rounded-lg border border-gray-200 p-4">
|
<Card key={goal.id}>
|
||||||
<div className="flex items-center justify-between">
|
<CardContent className="p-4">
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="font-semibold">{goal.title}</h3>
|
<div>
|
||||||
{goal.description && (
|
<h3 className="font-semibold">{goal.title}</h3>
|
||||||
<p className="text-sm text-gray-500 mt-1">{goal.description}</p>
|
{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>
|
</div>
|
||||||
<span
|
</CardContent>
|
||||||
className={cn(
|
</Card>
|
||||||
"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>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,50 +3,62 @@ import { issuesApi } from "../api/issues";
|
|||||||
import { useApi } from "../hooks/useApi";
|
import { useApi } from "../hooks/useApi";
|
||||||
import { StatusBadge } from "../components/StatusBadge";
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
import { useCompany } from "../context/CompanyContext";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
const priorityColors: Record<string, string> = {
|
const priorityColors: Record<string, string> = {
|
||||||
critical: "text-red-700 bg-red-50",
|
critical: "text-red-300 bg-red-900/50",
|
||||||
high: "text-orange-700 bg-orange-50",
|
high: "text-orange-300 bg-orange-900/50",
|
||||||
medium: "text-yellow-700 bg-yellow-50",
|
medium: "text-yellow-300 bg-yellow-900/50",
|
||||||
low: "text-gray-600 bg-gray-50",
|
low: "text-neutral-400 bg-neutral-800",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Issues() {
|
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);
|
const { data: issues, loading, error } = useApi(fetcher);
|
||||||
|
|
||||||
|
if (!selectedCompanyId) {
|
||||||
|
return <p className="text-muted-foreground">Select a company first.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold mb-4">Issues</h2>
|
<h2 className="text-2xl font-bold mb-4">Tasks</h2>
|
||||||
{loading && <p className="text-gray-500">Loading...</p>}
|
{loading && <p className="text-muted-foreground">Loading...</p>}
|
||||||
{error && <p className="text-red-600">{error.message}</p>}
|
{error && <p className="text-destructive">{error.message}</p>}
|
||||||
{issues && issues.length === 0 && <p className="text-gray-500">No issues yet.</p>}
|
{issues && issues.length === 0 && <p className="text-muted-foreground">No tasks yet.</p>}
|
||||||
{issues && issues.length > 0 && (
|
{issues && issues.length > 0 && (
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
{issues.map((issue) => (
|
{issues.map((issue) => (
|
||||||
<div key={issue.id} className="bg-white rounded-lg border border-gray-200 p-4">
|
<Card key={issue.id}>
|
||||||
<div className="flex items-center justify-between">
|
<CardContent className="p-4">
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="font-semibold">{issue.title}</h3>
|
<div>
|
||||||
{issue.description && (
|
<h3 className="font-semibold">{issue.title}</h3>
|
||||||
<p className="text-sm text-gray-500 mt-1 line-clamp-1">
|
{issue.description && (
|
||||||
{issue.description}
|
<p className="text-sm text-muted-foreground mt-1 line-clamp-1">{issue.description}</p>
|
||||||
</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"
|
|
||||||
)}
|
)}
|
||||||
>
|
</div>
|
||||||
{issue.priority}
|
<div className="flex items-center gap-3">
|
||||||
</span>
|
<span
|
||||||
<StatusBadge status={issue.status} />
|
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>
|
||||||
</div>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
52
ui/src/pages/Org.tsx
Normal file
52
ui/src/pages/Org.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,31 +2,49 @@ import { useCallback } from "react";
|
|||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
import { useApi } from "../hooks/useApi";
|
import { useApi } from "../hooks/useApi";
|
||||||
import { formatDate } from "../lib/utils";
|
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() {
|
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);
|
const { data: projects, loading, error } = useApi(fetcher);
|
||||||
|
|
||||||
|
if (!selectedCompanyId) {
|
||||||
|
return <p className="text-muted-foreground">Select a company first.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold mb-4">Projects</h2>
|
<h2 className="text-2xl font-bold mb-4">Projects</h2>
|
||||||
{loading && <p className="text-gray-500">Loading...</p>}
|
{loading && <p className="text-muted-foreground">Loading...</p>}
|
||||||
{error && <p className="text-red-600">{error.message}</p>}
|
{error && <p className="text-destructive">{error.message}</p>}
|
||||||
{projects && projects.length === 0 && <p className="text-gray-500">No projects yet.</p>}
|
{projects && projects.length === 0 && <p className="text-muted-foreground">No projects yet.</p>}
|
||||||
{projects && projects.length > 0 && (
|
{projects && projects.length > 0 && (
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
{projects.map((project) => (
|
{projects.map((project) => (
|
||||||
<div key={project.id} className="bg-white rounded-lg border border-gray-200 p-4">
|
<Card key={project.id}>
|
||||||
<div className="flex items-center justify-between">
|
<CardContent className="p-4">
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="font-semibold">{project.name}</h3>
|
<div>
|
||||||
{project.description && (
|
<h3 className="font-semibold">{project.name}</h3>
|
||||||
<p className="text-sm text-gray-500 mt-1">{project.description}</p>
|
{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>
|
</div>
|
||||||
<span className="text-sm text-gray-400">{formatDate(project.createdAt)}</span>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -10,7 +10,11 @@
|
|||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true
|
"noEmit": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
|
import path from "path";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
Reference in New Issue
Block a user