From fb8a77a53b6c7daf6f33a9ad40886a02450c5995 Mon Sep 17 00:00:00 2001 From: Forgotten Date: Tue, 17 Feb 2026 20:14:05 -0600 Subject: [PATCH] Upgrade Companies page: stats, delete, status badge, dropdown menu Server: - companyService.stats() returns per-company agent/issue counts in one query pair - companyService.remove() cascades deletes across all child tables in dependency order - GET /companies/stats endpoint (board-accessible) - DELETE /companies/:companyId endpoint (board-only) UI: - Companies page shows agent count, issue count, spend/budget, and created-at per card - Company status shown as a colored badge (active/paused/archived) - Three-dot dropdown menu with Rename and Delete Company actions - Inline delete confirmation to prevent accidental data loss - 'New Company' button opens onboarding wizard instead of inline form Co-Authored-By: Claude Sonnet 4.6 --- server/src/routes/companies.ts | 16 +++ server/src/services/companies.ts | 67 +++++++++++- ui/src/api/companies.ts | 4 + ui/src/lib/queryKeys.ts | 1 + ui/src/pages/Companies.tsx | 174 ++++++++++++++++++++++++++++--- 5 files changed, 248 insertions(+), 14 deletions(-) diff --git a/server/src/routes/companies.ts b/server/src/routes/companies.ts index 0ef8cd09..d5af7e20 100644 --- a/server/src/routes/companies.ts +++ b/server/src/routes/companies.ts @@ -14,6 +14,11 @@ export function companyRoutes(db: Db) { res.json(result); }); + router.get("/stats", async (_req, res) => { + const stats = await svc.stats(); + res.json(stats); + }); + router.get("/:companyId", async (req, res) => { const companyId = req.params.companyId as string; const company = await svc.getById(companyId); @@ -78,5 +83,16 @@ export function companyRoutes(db: Db) { res.json(company); }); + router.delete("/:companyId", async (req, res) => { + assertBoard(req); + const companyId = req.params.companyId as string; + const company = await svc.remove(companyId); + if (!company) { + res.status(404).json({ error: "Company not found" }); + return; + } + res.json({ ok: true }); + }); + return router; } diff --git a/server/src/services/companies.ts b/server/src/services/companies.ts index 469c493f..240ae1db 100644 --- a/server/src/services/companies.ts +++ b/server/src/services/companies.ts @@ -1,6 +1,21 @@ -import { eq } from "drizzle-orm"; +import { eq, sql, count } from "drizzle-orm"; import type { Db } from "@paperclip/db"; -import { companies } from "@paperclip/db"; +import { + companies, + agents, + agentApiKeys, + agentRuntimeState, + agentWakeupRequests, + issues, + issueComments, + projects, + goals, + heartbeatRuns, + heartbeatRunEvents, + costEvents, + approvals, + activityLog, +} from "@paperclip/db"; export function companyService(db: Db) { return { @@ -35,5 +50,53 @@ export function companyService(db: Db) { .where(eq(companies.id, id)) .returning() .then((rows) => rows[0] ?? null), + + remove: (id: string) => + db.transaction(async (tx) => { + // Delete from child tables in dependency order + await tx.delete(heartbeatRunEvents).where(eq(heartbeatRunEvents.companyId, id)); + await tx.delete(heartbeatRuns).where(eq(heartbeatRuns.companyId, id)); + await tx.delete(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, id)); + await tx.delete(agentApiKeys).where(eq(agentApiKeys.companyId, id)); + await tx.delete(agentRuntimeState).where(eq(agentRuntimeState.companyId, id)); + await tx.delete(issueComments).where(eq(issueComments.companyId, id)); + await tx.delete(costEvents).where(eq(costEvents.companyId, id)); + await tx.delete(approvals).where(eq(approvals.companyId, id)); + await tx.delete(issues).where(eq(issues.companyId, id)); + await tx.delete(goals).where(eq(goals.companyId, id)); + await tx.delete(projects).where(eq(projects.companyId, id)); + await tx.delete(agents).where(eq(agents.companyId, id)); + await tx.delete(activityLog).where(eq(activityLog.companyId, id)); + const rows = await tx + .delete(companies) + .where(eq(companies.id, id)) + .returning(); + return rows[0] ?? null; + }), + + stats: () => + Promise.all([ + db + .select({ companyId: agents.companyId, count: count() }) + .from(agents) + .groupBy(agents.companyId), + db + .select({ companyId: issues.companyId, count: count() }) + .from(issues) + .groupBy(issues.companyId), + ]).then(([agentRows, issueRows]) => { + const result: Record = {}; + for (const row of agentRows) { + result[row.companyId] = { agentCount: row.count, issueCount: 0 }; + } + for (const row of issueRows) { + if (result[row.companyId]) { + result[row.companyId].issueCount = row.count; + } else { + result[row.companyId] = { agentCount: 0, issueCount: row.count }; + } + } + return result; + }), }; } diff --git a/ui/src/api/companies.ts b/ui/src/api/companies.ts index 78b071b3..e14c828a 100644 --- a/ui/src/api/companies.ts +++ b/ui/src/api/companies.ts @@ -1,9 +1,12 @@ import type { Company } from "@paperclip/shared"; import { api } from "./client"; +export type CompanyStats = Record; + export const companiesApi = { list: () => api.get("/companies"), get: (companyId: string) => api.get(`/companies/${companyId}`), + stats: () => api.get("/companies/stats"), create: (data: { name: string; description?: string | null; budgetMonthlyCents?: number }) => api.post("/companies", data), update: ( @@ -11,4 +14,5 @@ export const companiesApi = { data: Partial>, ) => api.patch(`/companies/${companyId}`, data), archive: (companyId: string) => api.post(`/companies/${companyId}/archive`, {}), + remove: (companyId: string) => api.delete<{ ok: true }>(`/companies/${companyId}`), }; diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 22377210..e9978c13 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -2,6 +2,7 @@ export const queryKeys = { companies: { all: ["companies"] as const, detail: (id: string) => ["companies", id] as const, + stats: ["companies", "stats"] as const, }, agents: { list: (companyId: string) => ["agents", companyId] as const, diff --git a/ui/src/pages/Companies.tsx b/ui/src/pages/Companies.tsx index 6f1c083c..a252473e 100644 --- a/ui/src/pages/Companies.tsx +++ b/ui/src/pages/Companies.tsx @@ -1,14 +1,32 @@ import { useState, useEffect } from "react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +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 } from "../lib/utils"; +import { formatCents, relativeTime } from "../lib/utils"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; -import { Pencil, Check, X, Plus } from "lucide-react"; +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 { @@ -22,9 +40,15 @@ export function Companies() { const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); + const { data: stats } = useQuery({ + queryKey: queryKeys.companies.stats, + queryFn: () => companiesApi.stats(), + }); + // Inline edit state const [editingId, setEditingId] = useState(null); const [editName, setEditName] = useState(""); + const [confirmDeleteId, setConfirmDeleteId] = useState(null); const editMutation = useMutation({ mutationFn: ({ id, newName }: { id: string; newName: string }) => @@ -35,6 +59,15 @@ export function Companies() { }, }); + 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]); @@ -72,10 +105,20 @@ export function Companies() { {error &&

{error.message}

} -
+
{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 (
-
+ {/* Header row: name + menu */} +
{isEditing ? ( -
e.stopPropagation()}> +
e.stopPropagation()} + > setEditName(e.target.value)} @@ -121,7 +170,18 @@ export function Companies() {
) : (
-

{company.name}

+

{company.name}

+ + {company.status} +
-
- {formatCents(company.spentMonthlyCents)} / {formatCents(company.budgetMonthlyCents)} + + {/* Three-dot menu */} +
e.stopPropagation()}> + + + + + + startEdit(company.id, company.name)} + > + + Rename + + + setConfirmDeleteId(company.id)} + > + + Delete Company + + +
+ + {/* Stats row */} +
+
+ + + {agentCount} {agentCount === 1 ? "agent" : "agents"} + +
+
+ + + {issueCount} {issueCount === 1 ? "issue" : "issues"} + +
+
+ + + {formatCents(company.spentMonthlyCents)} /{" "} + {formatCents(company.budgetMonthlyCents)} + + {company.budgetMonthlyCents > 0 && ( + ({budgetPct}%) + )} +
+
+ + Created {relativeTime(company.createdAt)} +
+
+ + {/* Delete confirmation */} + {isConfirmingDelete && ( +
e.stopPropagation()} + > +

+ Delete this company and all its data? This cannot be undone. +

+
+ + +
+
+ )}
); })}