Polish UI: enhance dialogs, command palette, and page layouts
Expand NewIssueDialog with richer form fields. Add NewProjectDialog. Enhance CommandPalette with more actions and search. Improve CompanySwitcher, EmptyState, and IssueProperties. Flesh out Activity, Companies, Dashboard, and Inbox pages with real content and layouts. Refine sidebar, routing, and dialog context. CSS tweaks for dark theme. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import { Layout } from "./components/Layout";
|
||||
import { Dashboard } from "./pages/Dashboard";
|
||||
import { Companies } from "./pages/Companies";
|
||||
@@ -21,14 +21,15 @@ export function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="companies" element={<Companies />} />
|
||||
<Route path="org" element={<Org />} />
|
||||
<Route path="agents" element={<Agents />} />
|
||||
<Route path="agents/:agentId" element={<AgentDetail />} />
|
||||
<Route path="projects" element={<Projects />} />
|
||||
<Route path="projects/:projectId" element={<ProjectDetail />} />
|
||||
<Route path="tasks" element={<Issues />} />
|
||||
<Route path="issues" element={<Issues />} />
|
||||
<Route path="issues/:issueId" element={<IssueDetail />} />
|
||||
<Route path="goals" element={<Goals />} />
|
||||
<Route path="goals/:goalId" element={<GoalDetail />} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { projectsApi } from "../api/projects";
|
||||
@@ -11,8 +12,21 @@ import {
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@/components/ui/command";
|
||||
import { CircleDot, Bot, Hexagon, Target, LayoutDashboard, Inbox } from "lucide-react";
|
||||
import {
|
||||
CircleDot,
|
||||
Bot,
|
||||
Hexagon,
|
||||
Target,
|
||||
LayoutDashboard,
|
||||
Inbox,
|
||||
DollarSign,
|
||||
History,
|
||||
GitBranch,
|
||||
SquarePen,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import type { Issue, Agent, Project } from "@paperclip/shared";
|
||||
|
||||
export function CommandPalette() {
|
||||
@@ -22,6 +36,7 @@ export function CommandPalette() {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const navigate = useNavigate();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { openNewIssue } = useDialog();
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
@@ -57,6 +72,11 @@ export function CommandPalette() {
|
||||
navigate(path);
|
||||
}
|
||||
|
||||
const agentName = (id: string | null) => {
|
||||
if (!id) return null;
|
||||
return agents.find((a) => a.id === id)?.name ?? null;
|
||||
};
|
||||
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||
<CommandInput placeholder="Search issues, agents, projects..." />
|
||||
@@ -64,7 +84,7 @@ export function CommandPalette() {
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
|
||||
<CommandGroup heading="Pages">
|
||||
<CommandItem onSelect={() => go("/")}>
|
||||
<CommandItem onSelect={() => go("/dashboard")}>
|
||||
<LayoutDashboard className="mr-2 h-4 w-4" />
|
||||
Dashboard
|
||||
</CommandItem>
|
||||
@@ -72,7 +92,7 @@ export function CommandPalette() {
|
||||
<Inbox className="mr-2 h-4 w-4" />
|
||||
Inbox
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => go("/tasks")}>
|
||||
<CommandItem onSelect={() => go("/issues")}>
|
||||
<CircleDot className="mr-2 h-4 w-4" />
|
||||
Issues
|
||||
</CommandItem>
|
||||
@@ -88,42 +108,92 @@ export function CommandPalette() {
|
||||
<Bot className="mr-2 h-4 w-4" />
|
||||
Agents
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => go("/costs")}>
|
||||
<DollarSign className="mr-2 h-4 w-4" />
|
||||
Costs
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => go("/activity")}>
|
||||
<History className="mr-2 h-4 w-4" />
|
||||
Activity
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => go("/org")}>
|
||||
<GitBranch className="mr-2 h-4 w-4" />
|
||||
Org Chart
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandSeparator />
|
||||
|
||||
<CommandGroup heading="Actions">
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
setOpen(false);
|
||||
openNewIssue();
|
||||
}}
|
||||
>
|
||||
<SquarePen className="mr-2 h-4 w-4" />
|
||||
Create new issue
|
||||
<span className="ml-auto text-xs text-muted-foreground">C</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => go("/agents")}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create new agent
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => go("/projects")}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create new project
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
{issues.length > 0 && (
|
||||
<CommandGroup heading="Issues">
|
||||
{issues.slice(0, 10).map((issue) => (
|
||||
<CommandItem key={issue.id} onSelect={() => go(`/issues/${issue.id}`)}>
|
||||
<CircleDot className="mr-2 h-4 w-4" />
|
||||
<span className="text-muted-foreground mr-2 font-mono text-xs">
|
||||
{issue.id.slice(0, 8)}
|
||||
</span>
|
||||
{issue.title}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Issues">
|
||||
{issues.slice(0, 10).map((issue) => (
|
||||
<CommandItem key={issue.id} onSelect={() => go(`/issues/${issue.id}`)}>
|
||||
<CircleDot className="mr-2 h-4 w-4" />
|
||||
<span className="text-muted-foreground mr-2 font-mono text-xs">
|
||||
{issue.id.slice(0, 8)}
|
||||
</span>
|
||||
<span className="flex-1 truncate">{issue.title}</span>
|
||||
{issue.assigneeAgentId && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
{agentName(issue.assigneeAgentId)}
|
||||
</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
{agents.length > 0 && (
|
||||
<CommandGroup heading="Agents">
|
||||
{agents.slice(0, 10).map((agent) => (
|
||||
<CommandItem key={agent.id} onSelect={() => go(`/agents/${agent.id}`)}>
|
||||
<Bot className="mr-2 h-4 w-4" />
|
||||
{agent.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Agents">
|
||||
{agents.slice(0, 10).map((agent) => (
|
||||
<CommandItem key={agent.id} onSelect={() => go(`/agents/${agent.id}`)}>
|
||||
<Bot className="mr-2 h-4 w-4" />
|
||||
{agent.name}
|
||||
<span className="text-xs text-muted-foreground ml-2">{agent.role}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
{projects.length > 0 && (
|
||||
<CommandGroup heading="Projects">
|
||||
{projects.slice(0, 10).map((project) => (
|
||||
<CommandItem key={project.id} onSelect={() => go(`/projects/${project.id}`)}>
|
||||
<Hexagon className="mr-2 h-4 w-4" />
|
||||
{project.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Projects">
|
||||
{projects.slice(0, 10).map((project) => (
|
||||
<CommandItem key={project.id} onSelect={() => go(`/projects/${project.id}`)}>
|
||||
<Hexagon className="mr-2 h-4 w-4" />
|
||||
{project.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ChevronsUpDown, Plus } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -10,23 +11,39 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function statusDotColor(status?: string): string {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "bg-green-400";
|
||||
case "paused":
|
||||
return "bg-yellow-400";
|
||||
case "archived":
|
||||
return "bg-neutral-400";
|
||||
default:
|
||||
return "bg-green-400";
|
||||
}
|
||||
}
|
||||
|
||||
export function CompanySwitcher() {
|
||||
const { companies, selectedCompany, setSelectedCompanyId } = useCompany();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-between px-3 py-2 h-auto text-left"
|
||||
className="w-full justify-between px-2 py-1.5 h-auto text-left"
|
||||
>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-xs text-muted-foreground">Company</span>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{selectedCompany && (
|
||||
<span className={`h-2 w-2 rounded-full shrink-0 ${statusDotColor(selectedCompany.status)}`} />
|
||||
)}
|
||||
<span className="text-sm font-medium truncate">
|
||||
{selectedCompany?.name ?? "Select company"}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<ChevronsUpDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-[220px]">
|
||||
@@ -38,6 +55,7 @@ export function CompanySwitcher() {
|
||||
onClick={() => setSelectedCompanyId(company.id)}
|
||||
className={company.id === selectedCompany?.id ? "bg-accent" : ""}
|
||||
>
|
||||
<span className={`h-2 w-2 rounded-full shrink-0 mr-2 ${statusDotColor(company.status)}`} />
|
||||
<span className="truncate">{company.name}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
@@ -45,11 +63,9 @@ export function CompanySwitcher() {
|
||||
<DropdownMenuItem disabled>No companies</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<a href="/companies" className="flex items-center gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Manage Companies
|
||||
</a>
|
||||
<DropdownMenuItem onClick={() => navigate("/companies")}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Manage Companies
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Plus } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -11,10 +12,13 @@ interface EmptyStateProps {
|
||||
export function EmptyState({ icon: Icon, message, action, onAction }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Icon className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<div className="rounded-xl bg-muted/50 p-4 mb-4">
|
||||
<Icon className="h-10 w-10 text-muted-foreground/50" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4">{message}</p>
|
||||
{action && onAction && (
|
||||
<Button variant="outline" size="sm" onClick={onAction}>
|
||||
<Button onClick={onAction}>
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
{action}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { Issue } from "@paperclip/shared";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useAgents } from "../hooks/useAgents";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import { PriorityIcon } from "./PriorityIcon";
|
||||
import { formatDate } from "../lib/utils";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
interface IssuePropertiesProps {
|
||||
@@ -27,6 +30,15 @@ function priorityLabel(priority: string): string {
|
||||
}
|
||||
|
||||
export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { data: agents } = useAgents(selectedCompanyId);
|
||||
|
||||
const agentName = (id: string | null) => {
|
||||
if (!id || !agents) return null;
|
||||
const agent = agents.find((a) => a.id === id);
|
||||
return agent?.name ?? id.slice(0, 8);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
@@ -46,30 +58,37 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
|
||||
<span className="text-sm">{priorityLabel(issue.priority)}</span>
|
||||
</PropertyRow>
|
||||
|
||||
{issue.assigneeAgentId && (
|
||||
<PropertyRow label="Assignee">
|
||||
<span className="text-sm font-mono">{issue.assigneeAgentId.slice(0, 8)}</span>
|
||||
</PropertyRow>
|
||||
)}
|
||||
<PropertyRow label="Assignee">
|
||||
<span className="text-sm">
|
||||
{issue.assigneeAgentId ? agentName(issue.assigneeAgentId) : "Unassigned"}
|
||||
</span>
|
||||
</PropertyRow>
|
||||
|
||||
{issue.projectId && (
|
||||
<PropertyRow label="Project">
|
||||
<span className="text-sm font-mono">{issue.projectId.slice(0, 8)}</span>
|
||||
</PropertyRow>
|
||||
)}
|
||||
<PropertyRow label="Project">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{issue.projectId ? issue.projectId.slice(0, 8) : "None"}
|
||||
</span>
|
||||
</PropertyRow>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-1">
|
||||
<PropertyRow label="ID">
|
||||
<span className="text-sm font-mono">{issue.id.slice(0, 8)}</span>
|
||||
</PropertyRow>
|
||||
{issue.startedAt && (
|
||||
<PropertyRow label="Started">
|
||||
<span className="text-sm">{formatDate(issue.startedAt)}</span>
|
||||
</PropertyRow>
|
||||
)}
|
||||
{issue.completedAt && (
|
||||
<PropertyRow label="Completed">
|
||||
<span className="text-sm">{formatDate(issue.completedAt)}</span>
|
||||
</PropertyRow>
|
||||
)}
|
||||
<PropertyRow label="Created">
|
||||
<span className="text-sm">{formatDate(issue.createdAt)}</span>
|
||||
</PropertyRow>
|
||||
<PropertyRow label="Updated">
|
||||
<span className="text-sm">{formatDate(issue.updatedAt)}</span>
|
||||
<span className="text-sm">{timeAgo(issue.updatedAt)}</span>
|
||||
</PropertyRow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { BreadcrumbBar } from "./BreadcrumbBar";
|
||||
import { PropertiesPanel } from "./PropertiesPanel";
|
||||
import { CommandPalette } from "./CommandPalette";
|
||||
import { NewIssueDialog } from "./NewIssueDialog";
|
||||
import { NewProjectDialog } from "./NewProjectDialog";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { usePanel } from "../context/PanelContext";
|
||||
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
|
||||
@@ -27,18 +28,18 @@ export function Layout() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background text-foreground">
|
||||
<div className="flex h-screen bg-background text-foreground overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"transition-all duration-200 ease-in-out shrink-0 overflow-hidden",
|
||||
"transition-all duration-200 ease-in-out shrink-0 h-full overflow-hidden",
|
||||
sidebarOpen ? "w-60" : "w-0"
|
||||
)}
|
||||
>
|
||||
<Sidebar />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex-1 flex flex-col min-w-0 h-full">
|
||||
<BreadcrumbBar />
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
@@ -47,6 +48,7 @@ export function Layout() {
|
||||
</div>
|
||||
<CommandPalette />
|
||||
<NewIssueDialog />
|
||||
<NewProjectDialog />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,51 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { useAgents } from "../hooks/useAgents";
|
||||
import { useApi } from "../hooks/useApi";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
MoreHorizontal,
|
||||
CircleDot,
|
||||
Minus,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
AlertTriangle,
|
||||
User,
|
||||
Hexagon,
|
||||
Tag,
|
||||
Calendar,
|
||||
} from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import type { Project, Agent } from "@paperclip/shared";
|
||||
|
||||
const statuses = [
|
||||
{ value: "backlog", label: "Backlog", color: "text-muted-foreground" },
|
||||
{ value: "todo", label: "Todo", color: "text-blue-400" },
|
||||
{ value: "in_progress", label: "In Progress", color: "text-yellow-400" },
|
||||
{ value: "in_review", label: "In Review", color: "text-violet-400" },
|
||||
{ value: "done", label: "Done", color: "text-green-400" },
|
||||
];
|
||||
|
||||
const priorities = [
|
||||
{ value: "critical", label: "Critical", icon: AlertTriangle, color: "text-red-400" },
|
||||
{ value: "high", label: "High", icon: ArrowUp, color: "text-orange-400" },
|
||||
{ value: "medium", label: "Medium", icon: Minus, color: "text-yellow-400" },
|
||||
{ value: "low", label: "Low", icon: ArrowDown, color: "text-blue-400" },
|
||||
];
|
||||
|
||||
interface NewIssueDialogProps {
|
||||
onCreated?: () => void;
|
||||
@@ -27,22 +53,50 @@ interface NewIssueDialogProps {
|
||||
|
||||
export function NewIssueDialog({ onCreated }: NewIssueDialogProps) {
|
||||
const { newIssueOpen, newIssueDefaults, closeNewIssue } = useDialog();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [status, setStatus] = useState(newIssueDefaults.status ?? "todo");
|
||||
const [priority, setPriority] = useState(newIssueDefaults.priority ?? "medium");
|
||||
const [status, setStatus] = useState("todo");
|
||||
const [priority, setPriority] = useState("");
|
||||
const [assigneeId, setAssigneeId] = useState("");
|
||||
const [projectId, setProjectId] = useState("");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Popover states
|
||||
const [statusOpen, setStatusOpen] = useState(false);
|
||||
const [priorityOpen, setPriorityOpen] = useState(false);
|
||||
const [assigneeOpen, setAssigneeOpen] = useState(false);
|
||||
const [projectOpen, setProjectOpen] = useState(false);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
|
||||
const { data: agents } = useAgents(selectedCompanyId);
|
||||
|
||||
const projectsFetcher = useCallback(() => {
|
||||
if (!selectedCompanyId) return Promise.resolve([] as Project[]);
|
||||
return projectsApi.list(selectedCompanyId);
|
||||
}, [selectedCompanyId]);
|
||||
const { data: projects } = useApi(projectsFetcher);
|
||||
|
||||
useEffect(() => {
|
||||
if (newIssueOpen) {
|
||||
setStatus(newIssueDefaults.status ?? "todo");
|
||||
setPriority(newIssueDefaults.priority ?? "");
|
||||
setProjectId(newIssueDefaults.projectId ?? "");
|
||||
}
|
||||
}, [newIssueOpen, newIssueDefaults]);
|
||||
|
||||
function reset() {
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setStatus("todo");
|
||||
setPriority("medium");
|
||||
setPriority("");
|
||||
setAssigneeId("");
|
||||
setProjectId("");
|
||||
setExpanded(false);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
async function handleSubmit() {
|
||||
if (!selectedCompanyId || !title.trim()) return;
|
||||
|
||||
setSubmitting(true);
|
||||
@@ -51,7 +105,9 @@ export function NewIssueDialog({ onCreated }: NewIssueDialogProps) {
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
status,
|
||||
priority,
|
||||
priority: priority || "medium",
|
||||
...(assigneeId ? { assigneeAgentId: assigneeId } : {}),
|
||||
...(projectId ? { projectId } : {}),
|
||||
});
|
||||
reset();
|
||||
closeNewIssue();
|
||||
@@ -61,6 +117,18 @@ export function NewIssueDialog({ onCreated }: NewIssueDialogProps) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
const currentStatus = statuses.find((s) => s.value === status) ?? statuses[1]!;
|
||||
const currentPriority = priorities.find((p) => p.value === priority);
|
||||
const currentAssignee = (agents ?? []).find((a) => a.id === assigneeId);
|
||||
const currentProject = (projects ?? []).find((p) => p.id === projectId);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={newIssueOpen}
|
||||
@@ -71,71 +139,232 @@ export function NewIssueDialog({ onCreated }: NewIssueDialogProps) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Issue</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="issue-title">Title</Label>
|
||||
<Input
|
||||
id="issue-title"
|
||||
placeholder="Issue title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className={cn(
|
||||
"p-0 gap-0",
|
||||
expanded ? "sm:max-w-2xl" : "sm:max-w-lg"
|
||||
)}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Header bar */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
{selectedCompany && (
|
||||
<span className="bg-muted px-1.5 py-0.5 rounded text-xs font-medium">
|
||||
{selectedCompany.name.slice(0, 3).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-muted-foreground/60">›</span>
|
||||
<span>New issue</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="issue-desc">Description</Label>
|
||||
<Textarea
|
||||
id="issue-desc"
|
||||
placeholder="Add a description..."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<div className="space-y-2 flex-1">
|
||||
<Label>Status</Label>
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="backlog">Backlog</SelectItem>
|
||||
<SelectItem value="todo">Todo</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="in_review">In Review</SelectItem>
|
||||
<SelectItem value="done">Done</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2 flex-1">
|
||||
<Label>Priority</Label>
|
||||
<Select value={priority} onValueChange={setPriority}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="critical">Critical</SelectItem>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={closeNewIssue}>
|
||||
Cancel
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
<Button type="submit" disabled={!title.trim() || submitting}>
|
||||
{submitting ? "Creating..." : "Create Issue"}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => { reset(); closeNewIssue(); }}
|
||||
>
|
||||
<span className="text-lg leading-none">×</span>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="px-4 pt-3">
|
||||
<input
|
||||
className="w-full text-base font-medium bg-transparent outline-none placeholder:text-muted-foreground/50"
|
||||
placeholder="Issue title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="px-4 pb-2">
|
||||
<textarea
|
||||
className={cn(
|
||||
"w-full bg-transparent outline-none text-sm text-muted-foreground placeholder:text-muted-foreground/40 resize-none",
|
||||
expanded ? "min-h-[200px]" : "min-h-[60px]"
|
||||
)}
|
||||
placeholder="Add description..."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Property chips bar */}
|
||||
<div className="flex items-center gap-1.5 px-4 py-2 border-t border-border flex-wrap">
|
||||
{/* Status chip */}
|
||||
<Popover open={statusOpen} onOpenChange={setStatusOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors">
|
||||
<CircleDot className={cn("h-3 w-3", currentStatus.color)} />
|
||||
{currentStatus.label}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-36 p-1" align="start">
|
||||
{statuses.map((s) => (
|
||||
<button
|
||||
key={s.value}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
s.value === status && "bg-accent"
|
||||
)}
|
||||
onClick={() => { setStatus(s.value); setStatusOpen(false); }}
|
||||
>
|
||||
<CircleDot className={cn("h-3 w-3", s.color)} />
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Priority chip */}
|
||||
<Popover open={priorityOpen} onOpenChange={setPriorityOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors">
|
||||
{currentPriority ? (
|
||||
<>
|
||||
<currentPriority.icon className={cn("h-3 w-3", currentPriority.color)} />
|
||||
{currentPriority.label}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Minus className="h-3 w-3 text-muted-foreground" />
|
||||
Priority
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-36 p-1" align="start">
|
||||
{priorities.map((p) => (
|
||||
<button
|
||||
key={p.value}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
p.value === priority && "bg-accent"
|
||||
)}
|
||||
onClick={() => { setPriority(p.value); setPriorityOpen(false); }}
|
||||
>
|
||||
<p.icon className={cn("h-3 w-3", p.color)} />
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Assignee chip */}
|
||||
<Popover open={assigneeOpen} onOpenChange={setAssigneeOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors">
|
||||
<User className="h-3 w-3 text-muted-foreground" />
|
||||
{currentAssignee ? currentAssignee.name : "Assignee"}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-44 p-1" align="start">
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
!assigneeId && "bg-accent"
|
||||
)}
|
||||
onClick={() => { setAssigneeId(""); setAssigneeOpen(false); }}
|
||||
>
|
||||
No assignee
|
||||
</button>
|
||||
{(agents ?? []).map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
a.id === assigneeId && "bg-accent"
|
||||
)}
|
||||
onClick={() => { setAssigneeId(a.id); setAssigneeOpen(false); }}
|
||||
>
|
||||
{a.name}
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Project chip */}
|
||||
<Popover open={projectOpen} onOpenChange={setProjectOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors">
|
||||
<Hexagon className="h-3 w-3 text-muted-foreground" />
|
||||
{currentProject ? currentProject.name : "Project"}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-44 p-1" align="start">
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
!projectId && "bg-accent"
|
||||
)}
|
||||
onClick={() => { setProjectId(""); setProjectOpen(false); }}
|
||||
>
|
||||
No project
|
||||
</button>
|
||||
{(projects ?? []).map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
p.id === projectId && "bg-accent"
|
||||
)}
|
||||
onClick={() => { setProjectId(p.id); setProjectOpen(false); }}
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Labels chip (placeholder) */}
|
||||
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors text-muted-foreground">
|
||||
<Tag className="h-3 w-3" />
|
||||
Labels
|
||||
</button>
|
||||
|
||||
{/* More (dates) */}
|
||||
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="inline-flex items-center justify-center rounded-md border border-border p-1 text-xs hover:bg-accent/50 transition-colors text-muted-foreground">
|
||||
<MoreHorizontal className="h-3 w-3" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-44 p-1" align="start">
|
||||
<button className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-muted-foreground">
|
||||
<Calendar className="h-3 w-3" />
|
||||
Start date
|
||||
</button>
|
||||
<button className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-muted-foreground">
|
||||
<Calendar className="h-3 w-3" />
|
||||
Due date
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end px-4 py-2.5 border-t border-border">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!title.trim() || submitting}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{submitting ? "Creating..." : "Create issue"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
251
ui/src/components/NewProjectDialog.tsx
Normal file
251
ui/src/components/NewProjectDialog.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { goalsApi } from "../api/goals";
|
||||
import { useApi } from "../hooks/useApi";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Target,
|
||||
Calendar,
|
||||
} from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
import type { Goal } from "@paperclip/shared";
|
||||
|
||||
const projectStatuses = [
|
||||
{ value: "backlog", label: "Backlog" },
|
||||
{ value: "planned", label: "Planned" },
|
||||
{ value: "in_progress", label: "In Progress" },
|
||||
{ value: "completed", label: "Completed" },
|
||||
{ value: "cancelled", label: "Cancelled" },
|
||||
];
|
||||
|
||||
interface NewProjectDialogProps {
|
||||
onCreated?: () => void;
|
||||
}
|
||||
|
||||
export function NewProjectDialog({ onCreated }: NewProjectDialogProps) {
|
||||
const { newProjectOpen, closeNewProject } = useDialog();
|
||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [status, setStatus] = useState("planned");
|
||||
const [goalId, setGoalId] = useState("");
|
||||
const [targetDate, setTargetDate] = useState("");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const [statusOpen, setStatusOpen] = useState(false);
|
||||
const [goalOpen, setGoalOpen] = useState(false);
|
||||
|
||||
const goalsFetcher = useCallback(() => {
|
||||
if (!selectedCompanyId) return Promise.resolve([] as Goal[]);
|
||||
return goalsApi.list(selectedCompanyId);
|
||||
}, [selectedCompanyId]);
|
||||
const { data: goals } = useApi(goalsFetcher);
|
||||
|
||||
function reset() {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setStatus("planned");
|
||||
setGoalId("");
|
||||
setTargetDate("");
|
||||
setExpanded(false);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!selectedCompanyId || !name.trim()) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await projectsApi.create(selectedCompanyId, {
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
status,
|
||||
...(goalId ? { goalId } : {}),
|
||||
...(targetDate ? { targetDate } : {}),
|
||||
});
|
||||
reset();
|
||||
closeNewProject();
|
||||
onCreated?.();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
const currentGoal = (goals ?? []).find((g) => g.id === goalId);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={newProjectOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
reset();
|
||||
closeNewProject();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className={cn("p-0 gap-0", expanded ? "sm:max-w-2xl" : "sm:max-w-lg")}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
{selectedCompany && (
|
||||
<span className="bg-muted px-1.5 py-0.5 rounded text-xs font-medium">
|
||||
{selectedCompany.name.slice(0, 3).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-muted-foreground/60">›</span>
|
||||
<span>New project</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => { reset(); closeNewProject(); }}
|
||||
>
|
||||
<span className="text-lg leading-none">×</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div className="px-4 pt-3">
|
||||
<input
|
||||
className="w-full text-base font-medium bg-transparent outline-none placeholder:text-muted-foreground/50"
|
||||
placeholder="Project name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="px-4 pb-2">
|
||||
<textarea
|
||||
className={cn(
|
||||
"w-full bg-transparent outline-none text-sm text-muted-foreground placeholder:text-muted-foreground/40 resize-none",
|
||||
expanded ? "min-h-[160px]" : "min-h-[48px]"
|
||||
)}
|
||||
placeholder="Add description..."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Property chips */}
|
||||
<div className="flex items-center gap-1.5 px-4 py-2 border-t border-border flex-wrap">
|
||||
{/* Status */}
|
||||
<Popover open={statusOpen} onOpenChange={setStatusOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors">
|
||||
<StatusBadge status={status} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-40 p-1" align="start">
|
||||
{projectStatuses.map((s) => (
|
||||
<button
|
||||
key={s.value}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
s.value === status && "bg-accent"
|
||||
)}
|
||||
onClick={() => { setStatus(s.value); setStatusOpen(false); }}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Goal */}
|
||||
<Popover open={goalOpen} onOpenChange={setGoalOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors">
|
||||
<Target className="h-3 w-3 text-muted-foreground" />
|
||||
{currentGoal ? currentGoal.title : "Goal"}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-48 p-1" align="start">
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
!goalId && "bg-accent"
|
||||
)}
|
||||
onClick={() => { setGoalId(""); setGoalOpen(false); }}
|
||||
>
|
||||
No goal
|
||||
</button>
|
||||
{(goals ?? []).map((g) => (
|
||||
<button
|
||||
key={g.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 truncate",
|
||||
g.id === goalId && "bg-accent"
|
||||
)}
|
||||
onClick={() => { setGoalId(g.id); setGoalOpen(false); }}
|
||||
>
|
||||
{g.title}
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Target date */}
|
||||
<div className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs">
|
||||
<Calendar className="h-3 w-3 text-muted-foreground" />
|
||||
<input
|
||||
type="date"
|
||||
className="bg-transparent outline-none text-xs w-24"
|
||||
value={targetDate}
|
||||
onChange={(e) => setTargetDate(e.target.value)}
|
||||
placeholder="Target date"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end px-4 py-2.5 border-t border-border">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!name.trim() || submitting}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{submitting ? "Creating..." : "Create project"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
SquarePen,
|
||||
Building2,
|
||||
ListTodo,
|
||||
LayoutList,
|
||||
} from "lucide-react";
|
||||
import { CompanySwitcher } from "./CompanySwitcher";
|
||||
import { SidebarSection } from "./SidebarSection";
|
||||
@@ -29,16 +30,15 @@ export function Sidebar() {
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="w-60 border-r border-border bg-card flex flex-col shrink-0">
|
||||
<div className="p-3">
|
||||
<CompanySwitcher />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 px-3 pb-2">
|
||||
<aside className="w-60 h-full border-r border-border bg-card flex flex-col">
|
||||
<div className="flex items-center gap-1 p-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<CompanySwitcher />
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
className="text-muted-foreground shrink-0"
|
||||
onClick={openSearch}
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
@@ -46,7 +46,7 @@ export function Sidebar() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
className="text-muted-foreground shrink-0"
|
||||
onClick={() => openNewIssue()}
|
||||
>
|
||||
<SquarePen className="h-4 w-4" />
|
||||
@@ -63,13 +63,13 @@ export function Sidebar() {
|
||||
</div>
|
||||
|
||||
<SidebarSection label="Work">
|
||||
<SidebarNavItem to="/tasks" label="Issues" icon={CircleDot} />
|
||||
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
|
||||
<SidebarNavItem to="/projects" label="Projects" icon={Hexagon} />
|
||||
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
|
||||
</SidebarSection>
|
||||
|
||||
<SidebarSection label="Company">
|
||||
<SidebarNavItem to="/" label="Dashboard" icon={LayoutDashboard} end />
|
||||
<SidebarNavItem to="/dashboard" label="Dashboard" icon={LayoutDashboard} />
|
||||
<SidebarNavItem to="/org" label="Org Chart" icon={GitBranch} />
|
||||
<SidebarNavItem to="/agents" label="Agents" icon={Bot} />
|
||||
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
|
||||
|
||||
@@ -37,7 +37,7 @@ function DialogOverlay({
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 duration-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -59,7 +59,7 @@ function DialogContent({
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||
"bg-background 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-[0.97] data-[state=open]:zoom-in-[0.97] data-[state=closed]:slide-out-to-top-[1%] data-[state=open]:slide-in-from-top-[1%] fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-150 ease-[cubic-bezier(0.16,1,0.3,1)] outline-none sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -11,6 +11,9 @@ interface DialogContextValue {
|
||||
newIssueDefaults: NewIssueDefaults;
|
||||
openNewIssue: (defaults?: NewIssueDefaults) => void;
|
||||
closeNewIssue: () => void;
|
||||
newProjectOpen: boolean;
|
||||
openNewProject: () => void;
|
||||
closeNewProject: () => void;
|
||||
}
|
||||
|
||||
const DialogContext = createContext<DialogContextValue | null>(null);
|
||||
@@ -18,6 +21,7 @@ const DialogContext = createContext<DialogContextValue | null>(null);
|
||||
export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
const [newIssueOpen, setNewIssueOpen] = useState(false);
|
||||
const [newIssueDefaults, setNewIssueDefaults] = useState<NewIssueDefaults>({});
|
||||
const [newProjectOpen, setNewProjectOpen] = useState(false);
|
||||
|
||||
const openNewIssue = useCallback((defaults: NewIssueDefaults = {}) => {
|
||||
setNewIssueDefaults(defaults);
|
||||
@@ -29,8 +33,26 @@ export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
setNewIssueDefaults({});
|
||||
}, []);
|
||||
|
||||
const openNewProject = useCallback(() => {
|
||||
setNewProjectOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeNewProject = useCallback(() => {
|
||||
setNewProjectOpen(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DialogContext.Provider value={{ newIssueOpen, newIssueDefaults, openNewIssue, closeNewIssue }}>
|
||||
<DialogContext.Provider
|
||||
value={{
|
||||
newIssueOpen,
|
||||
newIssueDefaults,
|
||||
openNewIssue,
|
||||
closeNewIssue,
|
||||
newProjectOpen,
|
||||
openNewProject,
|
||||
closeNewProject,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</DialogContext.Provider>
|
||||
);
|
||||
|
||||
@@ -120,3 +120,8 @@
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* Expandable dialog transition for max-width changes */
|
||||
[data-slot="dialog-content"] {
|
||||
transition: max-width 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { activityApi } from "../api/activity";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
@@ -6,11 +7,74 @@ import { useApi } from "../hooks/useApi";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { History } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { History, Bot, User, Settings } from "lucide-react";
|
||||
|
||||
function formatAction(action: string, entityType: string, entityId: string): string {
|
||||
const shortId = entityId.slice(0, 8);
|
||||
const actionMap: Record<string, string> = {
|
||||
"company.created": "Company created",
|
||||
"agent.created": `Agent created`,
|
||||
"agent.updated": `Agent updated`,
|
||||
"agent.paused": `Agent paused`,
|
||||
"agent.resumed": `Agent resumed`,
|
||||
"agent.terminated": `Agent terminated`,
|
||||
"agent.key_created": `API key created for agent`,
|
||||
"issue.created": `Issue created`,
|
||||
"issue.updated": `Issue updated`,
|
||||
"issue.checked_out": `Issue checked out`,
|
||||
"issue.released": `Issue released`,
|
||||
"issue.commented": `Comment added to issue`,
|
||||
"heartbeat.invoked": `Heartbeat invoked`,
|
||||
"heartbeat.completed": `Heartbeat completed`,
|
||||
"heartbeat.failed": `Heartbeat failed`,
|
||||
"approval.created": `Approval requested`,
|
||||
"approval.approved": `Approval granted`,
|
||||
"approval.rejected": `Approval rejected`,
|
||||
"project.created": `Project created`,
|
||||
"project.updated": `Project updated`,
|
||||
"goal.created": `Goal created`,
|
||||
"goal.updated": `Goal updated`,
|
||||
"cost.recorded": `Cost recorded`,
|
||||
};
|
||||
return actionMap[action] ?? `${action.replace(/[._]/g, " ")}`;
|
||||
}
|
||||
|
||||
function actorIcon(entityType: string) {
|
||||
if (entityType === "agent") return <Bot className="h-4 w-4 text-muted-foreground" />;
|
||||
if (entityType === "company" || entityType === "approval")
|
||||
return <User className="h-4 w-4 text-muted-foreground" />;
|
||||
return <Settings className="h-4 w-4 text-muted-foreground" />;
|
||||
}
|
||||
|
||||
function entityLink(entityType: string, entityId: string): string | null {
|
||||
switch (entityType) {
|
||||
case "issue":
|
||||
return `/issues/${entityId}`;
|
||||
case "agent":
|
||||
return `/agents/${entityId}`;
|
||||
case "project":
|
||||
return `/projects/${entityId}`;
|
||||
case "goal":
|
||||
return `/goals/${entityId}`;
|
||||
case "approval":
|
||||
return `/approvals/${entityId}`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function Activity() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const navigate = useNavigate();
|
||||
const [filter, setFilter] = useState("all");
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "Activity" }]);
|
||||
@@ -27,35 +91,71 @@ export function Activity() {
|
||||
return <EmptyState icon={History} message="Select a company to view activity." />;
|
||||
}
|
||||
|
||||
const filtered =
|
||||
data && filter !== "all"
|
||||
? data.filter((e) => e.entityType === filter)
|
||||
: data;
|
||||
|
||||
const entityTypes = data
|
||||
? [...new Set(data.map((e) => e.entityType))].sort()
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Activity</h2>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Activity</h2>
|
||||
<Select value={filter} onValueChange={setFilter}>
|
||||
<SelectTrigger className="w-[140px] h-8 text-xs">
|
||||
<SelectValue placeholder="Filter by type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All types</SelectItem>
|
||||
{entityTypes.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type.charAt(0).toUpperCase() + type.slice(1)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{loading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||
|
||||
{data && data.length === 0 && (
|
||||
{filtered && filtered.length === 0 && (
|
||||
<EmptyState icon={History} message="No activity yet." />
|
||||
)}
|
||||
|
||||
{data && data.length > 0 && (
|
||||
{filtered && filtered.length > 0 && (
|
||||
<div className="border border-border rounded-md divide-y divide-border">
|
||||
{data.map((event) => (
|
||||
<div key={event.id} className="px-4 py-3 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
{event.entityType}
|
||||
</Badge>
|
||||
<span className="text-sm font-medium">{event.action}</span>
|
||||
<span className="text-xs text-muted-foreground font-mono truncate">
|
||||
{event.entityId.slice(0, 8)}
|
||||
{filtered.map((event) => {
|
||||
const link = entityLink(event.entityType, event.entityId);
|
||||
return (
|
||||
<div
|
||||
key={event.id}
|
||||
className={`px-4 py-3 flex items-center justify-between gap-4 ${
|
||||
link ? "cursor-pointer hover:bg-accent/50 transition-colors" : ""
|
||||
}`}
|
||||
onClick={link ? () => navigate(link) : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{actorIcon(event.entityType)}
|
||||
<span className="text-sm">
|
||||
{formatAction(event.action, event.entityType, event.entityId)}
|
||||
</span>
|
||||
<Badge variant="secondary" className="shrink-0 text-[10px]">
|
||||
{event.entityType}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground font-mono truncate">
|
||||
{event.entityId.slice(0, 8)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{timeAgo(event.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{timeAgo(event.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAgents } from "../hooks/useAgents";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
@@ -21,17 +21,6 @@ export function Agents() {
|
||||
setBreadcrumbs([{ label: "Agents" }]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
async function invoke(e: React.MouseEvent, agentId: string) {
|
||||
e.stopPropagation();
|
||||
setActionError(null);
|
||||
try {
|
||||
await agentsApi.invoke(agentId);
|
||||
reload();
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : "Failed to invoke agent");
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <EmptyState icon={Bot} message="Select a company to view agents." />;
|
||||
}
|
||||
@@ -45,7 +34,10 @@ export function Agents() {
|
||||
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
|
||||
|
||||
{agents && agents.length === 0 && (
|
||||
<EmptyState icon={Bot} message="No agents yet." />
|
||||
<EmptyState
|
||||
icon={Bot}
|
||||
message="No agents yet. Agents are created via the API or templates."
|
||||
/>
|
||||
)}
|
||||
|
||||
{agents && agents.length > 0 && (
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { companiesApi } from "../api/companies";
|
||||
import { formatCents } from "../lib/utils";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Pencil, Check, X } from "lucide-react";
|
||||
|
||||
export function Companies() {
|
||||
const {
|
||||
@@ -15,12 +18,22 @@ export function Companies() {
|
||||
error,
|
||||
reloadCompanies,
|
||||
} = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
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);
|
||||
|
||||
// Inline edit state
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editSaving, setEditSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "Companies" }]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
@@ -44,16 +57,38 @@ export function Companies() {
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(companyId: string, currentName: string) {
|
||||
setEditingId(companyId);
|
||||
setEditName(currentName);
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editingId || !editName.trim()) return;
|
||||
setEditSaving(true);
|
||||
try {
|
||||
await companiesApi.update(editingId, { name: editName.trim() });
|
||||
await reloadCompanies();
|
||||
setEditingId(null);
|
||||
} finally {
|
||||
setEditSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
setEditingId(null);
|
||||
setEditName("");
|
||||
}
|
||||
|
||||
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>
|
||||
<h2 className="text-lg font-semibold">Companies</h2>
|
||||
<p className="text-sm text-muted-foreground">Create and manage your companies.</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<h3 className="font-semibold">Create Company</h3>
|
||||
<h3 className="text-sm font-semibold">Create Company</h3>
|
||||
<form onSubmit={onSubmit} className="space-y-3">
|
||||
<div className="grid md:grid-cols-3 gap-3">
|
||||
<Input
|
||||
@@ -73,31 +108,72 @@ export function Companies() {
|
||||
/>
|
||||
</div>
|
||||
{submitError && <p className="text-sm text-destructive">{submitError}</p>}
|
||||
<Button type="submit" disabled={submitting}>
|
||||
<Button type="submit" size="sm" 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>}
|
||||
{loading && <p className="text-sm text-muted-foreground">Loading companies...</p>}
|
||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||
|
||||
<div className="grid gap-3">
|
||||
{companies.map((company) => {
|
||||
const selected = company.id === selectedCompanyId;
|
||||
const isEditing = editingId === company.id;
|
||||
|
||||
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"
|
||||
className={`text-left bg-card border rounded-lg p-4 transition-colors ${
|
||||
selected ? "border-primary ring-1 ring-primary" : "border-border hover:border-muted-foreground/30"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold">{company.name}</h3>
|
||||
{company.description && (
|
||||
<div className="flex-1 min-w-0">
|
||||
{isEditing ? (
|
||||
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Input
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
className="h-7 text-sm"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") saveEdit();
|
||||
if (e.key === "Escape") cancelEdit();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={saveEdit}
|
||||
disabled={editSaving}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5 text-green-500" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-xs" onClick={cancelEdit}>
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold">{company.name}</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground opacity-0 group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startEdit(company.id, company.name);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{company.description && !isEditing && (
|
||||
<p className="text-sm text-muted-foreground mt-1">{company.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,62 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { dashboardApi } from "../api/dashboard";
|
||||
import { activityApi } from "../api/activity";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useAgents } from "../hooks/useAgents";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useApi } from "../hooks/useApi";
|
||||
import { MetricCard } from "../components/MetricCard";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { StatusIcon } from "../components/StatusIcon";
|
||||
import { PriorityIcon } from "../components/PriorityIcon";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { formatCents } from "../lib/utils";
|
||||
import { Bot, CircleDot, DollarSign, ShieldCheck, LayoutDashboard } from "lucide-react";
|
||||
import { Bot, CircleDot, DollarSign, ShieldCheck, LayoutDashboard, Clock } from "lucide-react";
|
||||
import type { Issue } from "@paperclip/shared";
|
||||
|
||||
const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function formatAction(action: string): string {
|
||||
const actionMap: Record<string, string> = {
|
||||
"company.created": "Company created",
|
||||
"agent.created": "Agent created",
|
||||
"agent.updated": "Agent updated",
|
||||
"agent.key_created": "API key created",
|
||||
"issue.created": "Issue created",
|
||||
"issue.updated": "Issue updated",
|
||||
"issue.checked_out": "Issue checked out",
|
||||
"issue.released": "Issue released",
|
||||
"issue.commented": "Comment added",
|
||||
"heartbeat.invoked": "Heartbeat invoked",
|
||||
"heartbeat.completed": "Heartbeat completed",
|
||||
"approval.created": "Approval requested",
|
||||
"approval.approved": "Approval granted",
|
||||
"approval.rejected": "Approval rejected",
|
||||
"project.created": "Project created",
|
||||
"goal.created": "Goal created",
|
||||
"cost.recorded": "Cost recorded",
|
||||
};
|
||||
return actionMap[action] ?? action.replace(/[._]/g, " ");
|
||||
}
|
||||
|
||||
function getStaleIssues(issues: Issue[]): Issue[] {
|
||||
const now = Date.now();
|
||||
return issues
|
||||
.filter(
|
||||
(i) =>
|
||||
["in_progress", "todo"].includes(i.status) &&
|
||||
now - new Date(i.updatedAt).getTime() > STALE_THRESHOLD_MS
|
||||
)
|
||||
.sort((a, b) => new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime());
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const navigate = useNavigate();
|
||||
const { data: agents } = useAgents(selectedCompanyId);
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "Dashboard" }]);
|
||||
@@ -28,8 +72,21 @@ export function Dashboard() {
|
||||
return activityApi.list(selectedCompanyId);
|
||||
}, [selectedCompanyId]);
|
||||
|
||||
const issuesFetcher = useCallback(() => {
|
||||
if (!selectedCompanyId) return Promise.resolve([]);
|
||||
return issuesApi.list(selectedCompanyId);
|
||||
}, [selectedCompanyId]);
|
||||
|
||||
const { data, loading, error } = useApi(dashFetcher);
|
||||
const { data: activity } = useApi(activityFetcher);
|
||||
const { data: issues } = useApi(issuesFetcher);
|
||||
|
||||
const staleIssues = issues ? getStaleIssues(issues) : [];
|
||||
|
||||
const agentName = (id: string | null) => {
|
||||
if (!id || !agents) return null;
|
||||
return agents.find((a) => a.id === id)?.name ?? null;
|
||||
};
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return (
|
||||
@@ -78,28 +135,68 @@ export function Dashboard() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{activity && activity.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
||||
Recent Activity
|
||||
</h3>
|
||||
<div className="border border-border rounded-md divide-y divide-border">
|
||||
{activity.slice(0, 10).map((event) => (
|
||||
<div key={event.id} className="px-4 py-2 flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{event.action}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{event.entityType}
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{/* Recent Activity */}
|
||||
{activity && activity.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
||||
Recent Activity
|
||||
</h3>
|
||||
<div className="border border-border rounded-md divide-y divide-border">
|
||||
{activity.slice(0, 10).map((event) => (
|
||||
<div key={event.id} className="px-4 py-2 flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="font-medium truncate">
|
||||
{formatAction(event.action)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground font-mono shrink-0">
|
||||
{event.entityId.slice(0, 8)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground shrink-0 ml-2">
|
||||
{timeAgo(event.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{timeAgo(event.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stale Tasks */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
||||
Stale Tasks
|
||||
</h3>
|
||||
{staleIssues.length === 0 ? (
|
||||
<div className="border border-border rounded-md p-4">
|
||||
<p className="text-sm text-muted-foreground">No stale tasks. All work is up to date.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-border rounded-md divide-y divide-border">
|
||||
{staleIssues.slice(0, 10).map((issue) => (
|
||||
<div
|
||||
key={issue.id}
|
||||
className="px-4 py-2 flex items-center gap-2 text-sm cursor-pointer hover:bg-accent/50 transition-colors"
|
||||
onClick={() => navigate(`/issues/${issue.id}`)}
|
||||
>
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<PriorityIcon priority={issue.priority} />
|
||||
<StatusIcon status={issue.status} />
|
||||
<span className="truncate flex-1">{issue.title}</span>
|
||||
{issue.assigneeAgentId && (
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{agentName(issue.assigneeAgentId) ?? issue.assigneeAgentId.slice(0, 8)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{timeAgo(issue.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -36,7 +36,12 @@ export function Goals() {
|
||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||
|
||||
{goals && goals.length === 0 && (
|
||||
<EmptyState icon={Target} message="No goals yet." />
|
||||
<EmptyState
|
||||
icon={Target}
|
||||
message="No goals yet."
|
||||
action="Add Goal"
|
||||
onAction={() => {/* TODO: goal creation */}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{goals && goals.length > 0 && (
|
||||
|
||||
@@ -1,20 +1,50 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { approvalsApi } from "../api/approvals";
|
||||
import { dashboardApi } from "../api/dashboard";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useAgents } from "../hooks/useAgents";
|
||||
import { useApi } from "../hooks/useApi";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { StatusIcon } from "../components/StatusIcon";
|
||||
import { PriorityIcon } from "../components/PriorityIcon";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Inbox as InboxIcon } from "lucide-react";
|
||||
import {
|
||||
Inbox as InboxIcon,
|
||||
Shield,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
import type { Issue } from "@paperclip/shared";
|
||||
|
||||
const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
function getStaleIssues(issues: Issue[]): Issue[] {
|
||||
const now = Date.now();
|
||||
return issues
|
||||
.filter(
|
||||
(i) =>
|
||||
["in_progress", "todo"].includes(i.status) &&
|
||||
now - new Date(i.updatedAt).getTime() > STALE_THRESHOLD_MS
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
export function Inbox() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const navigate = useNavigate();
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const { data: agents } = useAgents(selectedCompanyId);
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "Inbox" }]);
|
||||
@@ -30,8 +60,22 @@ export function Inbox() {
|
||||
return dashboardApi.summary(selectedCompanyId);
|
||||
}, [selectedCompanyId]);
|
||||
|
||||
const issuesFetcher = useCallback(() => {
|
||||
if (!selectedCompanyId) return Promise.resolve([]);
|
||||
return issuesApi.list(selectedCompanyId);
|
||||
}, [selectedCompanyId]);
|
||||
|
||||
const { data: approvals, loading, error, reload } = useApi(approvalsFetcher);
|
||||
const { data: dashboard } = useApi(dashboardFetcher);
|
||||
const { data: issues } = useApi(issuesFetcher);
|
||||
|
||||
const staleIssues = issues ? getStaleIssues(issues) : [];
|
||||
|
||||
const agentName = (id: string | null) => {
|
||||
if (!id || !agents) return null;
|
||||
const agent = agents.find((a) => a.id === id);
|
||||
return agent?.name ?? null;
|
||||
};
|
||||
|
||||
async function approve(id: string) {
|
||||
setActionError(null);
|
||||
@@ -57,7 +101,13 @@ export function Inbox() {
|
||||
return <EmptyState icon={InboxIcon} message="Select a company to view inbox." />;
|
||||
}
|
||||
|
||||
const hasContent = (approvals && approvals.length > 0) || (dashboard && (dashboard.staleTasks > 0));
|
||||
const hasApprovals = approvals && approvals.length > 0;
|
||||
const hasAlerts =
|
||||
dashboard &&
|
||||
(dashboard.agents.error > 0 ||
|
||||
dashboard.costs.monthUtilizationPercent >= 80);
|
||||
const hasStale = staleIssues.length > 0;
|
||||
const hasContent = hasApprovals || hasAlerts || hasStale;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -71,28 +121,37 @@ export function Inbox() {
|
||||
<EmptyState icon={InboxIcon} message="You're all caught up!" />
|
||||
)}
|
||||
|
||||
{approvals && approvals.length > 0 && (
|
||||
{/* Pending Approvals */}
|
||||
{hasApprovals && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
||||
Pending Approvals ({approvals.length})
|
||||
</h3>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Approvals
|
||||
</h3>
|
||||
<button
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => navigate("/approvals")}
|
||||
>
|
||||
See all approvals <ExternalLink className="inline h-3 w-3 ml-0.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="border border-border rounded-md divide-y divide-border">
|
||||
{approvals.map((approval) => (
|
||||
{approvals!.map((approval) => (
|
||||
<div key={approval.id} className="p-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm font-medium">{approval.type.replace(/_/g, " ")}</span>
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
{timeAgo(approval.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<StatusBadge status={approval.status} />
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4 text-yellow-500 shrink-0" />
|
||||
<span className="text-sm font-medium">
|
||||
{approval.type.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{timeAgo(approval.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-green-700 text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20"
|
||||
className="border-green-700 text-green-500 hover:bg-green-900/20"
|
||||
onClick={() => approve(approval.id)}
|
||||
>
|
||||
Approve
|
||||
@@ -104,6 +163,14 @@ export function Inbox() {
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground ml-auto"
|
||||
onClick={() => navigate(`/approvals/${approval.id}`)}
|
||||
>
|
||||
View details
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -111,18 +178,79 @@ export function Inbox() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dashboard && dashboard.staleTasks > 0 && (
|
||||
{/* Alerts */}
|
||||
{hasAlerts && (
|
||||
<>
|
||||
<Separator />
|
||||
{hasApprovals && <Separator />}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
||||
Alerts
|
||||
</h3>
|
||||
<div className="border border-border rounded-md divide-y divide-border">
|
||||
{dashboard!.agents.error > 0 && (
|
||||
<div
|
||||
className="px-4 py-3 flex items-center gap-3 cursor-pointer hover:bg-accent/50 transition-colors"
|
||||
onClick={() => navigate("/agents")}
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 text-red-400 shrink-0" />
|
||||
<span className="text-sm">
|
||||
<span className="font-medium">{dashboard!.agents.error}</span>{" "}
|
||||
{dashboard!.agents.error === 1 ? "agent has" : "agents have"} errors
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{dashboard!.costs.monthUtilizationPercent >= 80 && (
|
||||
<div
|
||||
className="px-4 py-3 flex items-center gap-3 cursor-pointer hover:bg-accent/50 transition-colors"
|
||||
onClick={() => navigate("/costs")}
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-400 shrink-0" />
|
||||
<span className="text-sm">
|
||||
Budget at{" "}
|
||||
<span className="font-medium">
|
||||
{dashboard!.costs.monthUtilizationPercent}%
|
||||
</span>{" "}
|
||||
utilization this month
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Stale Work */}
|
||||
{hasStale && (
|
||||
<>
|
||||
{(hasApprovals || hasAlerts) && <Separator />}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
||||
Stale Work
|
||||
</h3>
|
||||
<div className="border border-border rounded-md p-4">
|
||||
<p className="text-sm">
|
||||
<span className="font-medium">{dashboard.staleTasks}</span> tasks have gone stale
|
||||
and may need attention.
|
||||
</p>
|
||||
<div className="border border-border rounded-md divide-y divide-border">
|
||||
{staleIssues.map((issue) => (
|
||||
<div
|
||||
key={issue.id}
|
||||
className="px-4 py-3 flex items-center gap-3 cursor-pointer hover:bg-accent/50 transition-colors"
|
||||
onClick={() => navigate(`/issues/${issue.id}`)}
|
||||
>
|
||||
<Clock className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<PriorityIcon priority={issue.priority} />
|
||||
<StatusIcon status={issue.status} />
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
{issue.id.slice(0, 8)}
|
||||
</span>
|
||||
<span className="text-sm truncate flex-1">{issue.title}</span>
|
||||
{issue.assigneeAgentId && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{agentName(issue.assigneeAgentId) ?? issue.assigneeAgentId.slice(0, 8)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
updated {timeAgo(issue.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -32,7 +32,7 @@ export function IssueDetail() {
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: "Issues", href: "/tasks" },
|
||||
{ label: "Issues", href: "/issues" },
|
||||
{ label: issue?.title ?? issueId ?? "Issue" },
|
||||
]);
|
||||
}, [setBreadcrumbs, issue, issueId]);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { useApi } from "../hooks/useApi";
|
||||
import { useAgents } from "../hooks/useAgents";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
@@ -43,6 +44,7 @@ export function Issues() {
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const navigate = useNavigate();
|
||||
const [tab, setTab] = useState<TabFilter>("all");
|
||||
const { data: agents } = useAgents(selectedCompanyId);
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "Issues" }]);
|
||||
@@ -55,6 +57,11 @@ export function Issues() {
|
||||
|
||||
const { data: issues, loading, error, reload } = useApi(fetcher);
|
||||
|
||||
const agentName = (id: string | null) => {
|
||||
if (!id || !agents) return null;
|
||||
return agents.find((a) => a.id === id)?.name ?? null;
|
||||
};
|
||||
|
||||
async function handleStatusChange(issue: Issue, status: string) {
|
||||
await issuesApi.update(issue.id, { status });
|
||||
reload();
|
||||
@@ -135,9 +142,16 @@ export function Issues() {
|
||||
</>
|
||||
}
|
||||
trailing={
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDate(issue.createdAt)}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{issue.assigneeAgentId && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{agentName(issue.assigneeAgentId) ?? issue.assigneeAgentId.slice(0, 8)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDate(issue.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { agentsApi, type OrgNode } from "../api/agents";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
@@ -8,7 +8,6 @@ import { StatusBadge } from "../components/StatusBadge";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { ChevronRight, GitBranch } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useState } from "react";
|
||||
|
||||
function OrgTree({
|
||||
nodes,
|
||||
@@ -113,7 +112,10 @@ export function Org() {
|
||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||
|
||||
{data && data.length === 0 && (
|
||||
<EmptyState icon={GitBranch} message="No agents in the organization." />
|
||||
<EmptyState
|
||||
icon={GitBranch}
|
||||
message="No agents in the organization. Create agents to build your org chart."
|
||||
/>
|
||||
)}
|
||||
|
||||
{data && data.length > 0 && (
|
||||
|
||||
@@ -3,15 +3,18 @@ import { useNavigate } from "react-router-dom";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { useApi } from "../hooks/useApi";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { EntityRow } from "../components/EntityRow";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { formatDate } from "../lib/utils";
|
||||
import { Hexagon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Hexagon, Plus } from "lucide-react";
|
||||
|
||||
export function Projects() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { openNewProject } = useDialog();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -32,13 +35,24 @@ export function Projects() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Projects</h2>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Projects</h2>
|
||||
<Button size="sm" onClick={openNewProject}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Project
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||
|
||||
{projects && projects.length === 0 && (
|
||||
<EmptyState icon={Hexagon} message="No projects yet." />
|
||||
<EmptyState
|
||||
icon={Hexagon}
|
||||
message="No projects yet."
|
||||
action="Add Project"
|
||||
onAction={openNewProject}
|
||||
/>
|
||||
)}
|
||||
|
||||
{projects && projects.length > 0 && (
|
||||
|
||||
Reference in New Issue
Block a user