Files
paperclip/ui/src/pages/Companies.tsx
T
Forgotten ea60e4800f UI: task sessions in agent detail, ApprovalCard extraction, and company settings page
Show task sessions list in AgentDetail with per-session reset. Extract
ApprovalCard into standalone component from Approvals and Inbox pages,
reducing duplication. Add CompanySettings page with issuePrefix configuration.
Fix Sidebar active state for settings route. Display sessionDisplayId
in agent properties. Various cleanups to Approvals and Inbox pages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 14:02:29 -06:00

299 lines
11 KiB
TypeScript

import { useState, useEffect } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { companiesApi } from "../api/companies";
import { queryKeys } from "../lib/queryKeys";
import { formatCents, relativeTime } from "../lib/utils";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Pencil,
Check,
X,
Plus,
MoreHorizontal,
Trash2,
Users,
CircleDot,
DollarSign,
Calendar,
} from "lucide-react";
export function Companies() {
const {
companies,
selectedCompanyId,
setSelectedCompanyId,
loading,
error,
} = useCompany();
const { openOnboarding } = useDialog();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const { data: stats } = useQuery({
queryKey: queryKeys.companies.stats,
queryFn: () => companiesApi.stats(),
});
// Inline edit state
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const editMutation = useMutation({
mutationFn: ({ id, newName }: { id: string; newName: string }) =>
companiesApi.update(id, { name: newName }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
setEditingId(null);
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => companiesApi.remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
queryClient.invalidateQueries({ queryKey: queryKeys.companies.stats });
setConfirmDeleteId(null);
},
});
useEffect(() => {
setBreadcrumbs([{ label: "Companies" }]);
}, [setBreadcrumbs]);
function startEdit(companyId: string, currentName: string) {
setEditingId(companyId);
setEditName(currentName);
}
function saveEdit() {
if (!editingId || !editName.trim()) return;
editMutation.mutate({ id: editingId, newName: editName.trim() });
}
function cancelEdit() {
setEditingId(null);
setEditName("");
}
return (
<div className="space-y-6">
<div className="flex items-center justify-end">
<Button size="sm" onClick={openOnboarding}>
<Plus className="h-3.5 w-3.5 mr-1.5" />
New Company
</Button>
</div>
<div className="h-6">
{loading && <p className="text-sm text-muted-foreground">Loading companies...</p>}
{error && <p className="text-sm text-destructive">{error.message}</p>}
</div>
<div className="grid gap-4">
{companies.map((company) => {
const selected = company.id === selectedCompanyId;
const isEditing = editingId === company.id;
const isConfirmingDelete = confirmDeleteId === company.id;
const companyStats = stats?.[company.id];
const agentCount = companyStats?.agentCount ?? 0;
const issueCount = companyStats?.issueCount ?? 0;
const budgetPct =
company.budgetMonthlyCents > 0
? Math.round(
(company.spentMonthlyCents / company.budgetMonthlyCents) * 100,
)
: 0;
return (
<div
key={company.id}
role="button"
tabIndex={0}
onClick={() => setSelectedCompanyId(company.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setSelectedCompanyId(company.id);
}
}}
className={`group text-left bg-card border rounded-lg p-5 transition-colors cursor-pointer ${
selected
? "border-primary ring-1 ring-primary"
: "border-border hover:border-muted-foreground/30"
}`}
>
{/* Header row: name + menu */}
<div className="flex items-start justify-between gap-3">
<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={editMutation.isPending}
>
<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 text-base">{company.name}</h3>
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium ${
company.status === "active"
? "bg-green-500/10 text-green-600 dark:text-green-400"
: company.status === "paused"
? "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400"
: "bg-muted text-muted-foreground"
}`}
>
{company.status}
</span>
<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 line-clamp-2">
{company.description}
</p>
)}
</div>
{/* Three-dot menu */}
<div onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
className="text-muted-foreground opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => startEdit(company.id, company.name)}
>
<Pencil className="h-3.5 w-3.5" />
Rename
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => setConfirmDeleteId(company.id)}
>
<Trash2 className="h-3.5 w-3.5" />
Delete Company
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Stats row */}
<div className="flex items-center gap-5 mt-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1.5">
<Users className="h-3.5 w-3.5" />
<span>
{agentCount} {agentCount === 1 ? "agent" : "agents"}
</span>
</div>
<div className="flex items-center gap-1.5">
<CircleDot className="h-3.5 w-3.5" />
<span>
{issueCount} {issueCount === 1 ? "issue" : "issues"}
</span>
</div>
<div className="flex items-center gap-1.5">
<DollarSign className="h-3.5 w-3.5" />
<span>
{formatCents(company.spentMonthlyCents)} /{" "}
{formatCents(company.budgetMonthlyCents)}
</span>
{company.budgetMonthlyCents > 0 && (
<span className="text-xs">({budgetPct}%)</span>
)}
</div>
<div className="flex items-center gap-1.5 ml-auto">
<Calendar className="h-3.5 w-3.5" />
<span>Created {relativeTime(company.createdAt)}</span>
</div>
</div>
{/* Delete confirmation */}
{isConfirmingDelete && (
<div
className="mt-4 flex items-center justify-between bg-destructive/5 border border-destructive/20 rounded-md px-4 py-3"
onClick={(e) => e.stopPropagation()}
>
<p className="text-sm text-destructive font-medium">
Delete this company and all its data? This cannot be undone.
</p>
<div className="flex items-center gap-2 ml-4 shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => setConfirmDeleteId(null)}
disabled={deleteMutation.isPending}
>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => deleteMutation.mutate(company.id)}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? "Deleting..." : "Delete"}
</Button>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
}