ea60e4800f
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>
299 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|