diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 3a1a822a..14e3df65 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AnimatePresence, motion } from "framer-motion"; import { CaretUpDown, @@ -60,6 +60,8 @@ import { WorkspaceInspector, WorkspaceList, } from "./components/execution"; +import { PolicyList } from "./components/execution/PolicyList"; +import { IncidentList } from "./components/execution/IncidentList"; import { AgentCreator, AgentEditor, @@ -175,6 +177,8 @@ export function App() { const browserActions = useAppStore((s) => s.browserActions); const setBrowserActions = useAppStore((s) => s.setBrowserActions); + const [costsTab, setCostsTab] = useState<"costs" | "policies" | "incidents">("costs"); + const clearCompanyScopedUiState = useCallback(() => { const store = useAppStore.getState(); store.clearCompanyScopedSelections(); @@ -1108,20 +1112,54 @@ export function App() { ) : null} {section === "costs" ? ( - - - - } - secondary={ - - - - } - /> +
+ {/* Tab bar */} +
+ {(["costs", "policies", "incidents"] as const).map((tab) => ( + + ))} +
+ {/* Tab content */} + {costsTab === "costs" ? ( + + + + } + secondary={ + + + + } + /> + ) : costsTab === "policies" ? ( + ({ id: a.id, name: a.name }))} + projects={companyProjects.map((p) => ({ id: p.id, name: p.name }))} + /> + ) : ( + ({ id: a.id, name: a.name }))} + projects={companyProjects.map((p) => ({ id: p.id, name: p.name }))} + /> + )} +
) : null} {section === "activity" ? ( diff --git a/src/renderer/components/execution.tsx b/src/renderer/components/execution.tsx index 3d5644b9..672814a2 100644 --- a/src/renderer/components/execution.tsx +++ b/src/renderer/components/execution.tsx @@ -13,3 +13,6 @@ export { WorkspaceCreator } from "./execution/WorkspaceCreator" export { SecretCreator } from "./execution/SecretCreator" export { SettingsPane } from "./execution/SettingsPane" export { PendingReviewList } from "./execution/PendingReviewList" +export { PolicyList } from "./execution/PolicyList" +export { PolicyFormModal } from "./execution/PolicyFormModal" +export { IncidentList } from "./execution/IncidentList" diff --git a/src/renderer/components/execution/IncidentList.tsx b/src/renderer/components/execution/IncidentList.tsx new file mode 100644 index 00000000..0e2dbc2b --- /dev/null +++ b/src/renderer/components/execution/IncidentList.tsx @@ -0,0 +1,138 @@ +import { useCallback, useEffect, useState } from "react"; +import { CheckCircle, Warning } from "@phosphor-icons/react"; +import type { BudgetIncidentRecord } from "@shared/types"; +import { ActionButton, EmptyState, ListFrame, ListRow } from "../ui"; +import { formatTime } from "../../lib/formatters"; + +interface IncidentListProps { + companyId: string; + agents: Array<{ id: string; name: string }>; + projects: Array<{ id: string; name: string }>; +} + +const STATUS_STYLES: Record = { + open: "text-[color:var(--danger)] bg-[color:var(--danger-soft)]", + resolved: "text-[color:var(--success)] bg-[color:var(--success-soft)]", + auto_resolved_limit_increase: "text-[color:var(--accent)] bg-[color:var(--accent-soft)]", + auto_closed: "text-[color:var(--muted-strong)] bg-[color:var(--panel-soft)]", +}; + +const STATUS_LABELS: Record = { + open: "Open", + resolved: "Resolved", + auto_resolved_limit_increase: "Auto-resolved", + auto_closed: "Auto-closed", +}; + +export function IncidentList({ companyId, agents, projects }: IncidentListProps) { + const [incidents, setIncidents] = useState([]); + const [filter, setFilter] = useState<"all" | "open">("open"); + + const loadData = useCallback(async () => { + const result = await window.agentCompany.listBudgetIncidents({ companyId, status: filter }); + if (result.ok) setIncidents(result.data); + }, [companyId, filter]); + + useEffect(() => { void loadData(); }, [loadData]); + + function getScopeName(scopeType: string, scopeId: string): string { + if (scopeType === "company") return "Company"; + if (scopeType === "agent") { + const agent = agents.find((a) => a.id === scopeId); + return agent ? agent.name : scopeId.slice(0, 8); + } + if (scopeType === "project") { + const project = projects.find((p) => p.id === scopeId); + return project ? project.name : scopeId.slice(0, 8); + } + return scopeId.slice(0, 8); + } + + async function handleResolve(incidentId: string) { + const result = await window.agentCompany.resolveBudgetIncident({ companyId, incidentId }); + if (result.ok) void loadData(); + } + + return ( +
+
+

Budget Incidents

+
+ {(["open", "all"] as const).map((f) => ( + + ))} +
+
+ +
+ {incidents.length === 0 ? ( + + ) : ( + + {incidents.map((incident) => { + const isWarn = incident.thresholdType === "warn_threshold"; + return ( + +
+
+
+ + {isWarn ? : null} + {isWarn ? "Warning" : "Hard Stop"} + + + {STATUS_LABELS[incident.status] ?? incident.status} + +
+ +
+ {incident.scopeType} + · + {getScopeName(incident.scopeType, incident.scopeId)} +
+ +
+ + ${(incident.amountObserved / 100).toFixed(2)} / ${(incident.amountLimit / 100).toFixed(2)} + + {formatTime(incident.windowStart)} - {formatTime(incident.windowEnd)} +
+
+ + {incident.status === "open" ? ( + void handleResolve(incident.id)} + /> + ) : null} +
+
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/src/renderer/components/execution/PolicyFormModal.tsx b/src/renderer/components/execution/PolicyFormModal.tsx new file mode 100644 index 00000000..ff057057 --- /dev/null +++ b/src/renderer/components/execution/PolicyFormModal.tsx @@ -0,0 +1,172 @@ +import { useEffect, useState } from "react"; +import type { BudgetPolicyRecord } from "@shared/types"; +import { ActionButton, Input, Modal, Select } from "../ui"; + +interface PolicyFormModalProps { + open: boolean; + onClose: () => void; + onSaved: () => void; + companyId: string; + editingPolicy: BudgetPolicyRecord | null; + agents: Array<{ id: string; name: string }>; + projects: Array<{ id: string; name: string }>; +} + +export function PolicyFormModal({ open, onClose, onSaved, companyId, editingPolicy, agents, projects }: PolicyFormModalProps) { + const [scopeType, setScopeType] = useState<"company" | "agent" | "project">("company"); + const [scopeId, setScopeId] = useState(companyId); + const [amountDollars, setAmountDollars] = useState("100"); + const [warnPercent, setWarnPercent] = useState("80"); + const [hardStopEnabled, setHardStopEnabled] = useState(true); + const [notifyEnabled, setNotifyEnabled] = useState(true); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (editingPolicy) { + setScopeType(editingPolicy.scopeType); + setScopeId(editingPolicy.scopeId); + setAmountDollars((editingPolicy.amount / 100).toFixed(2)); + setWarnPercent(String(editingPolicy.warnPercent)); + setHardStopEnabled(editingPolicy.hardStopEnabled); + setNotifyEnabled(editingPolicy.notifyEnabled); + } else { + setScopeType("company"); + setScopeId(companyId); + setAmountDollars("100"); + setWarnPercent("80"); + setHardStopEnabled(true); + setNotifyEnabled(true); + } + }, [editingPolicy, companyId]); + + useEffect(() => { + if (scopeType === "company") { + setScopeId(companyId); + } else if (scopeType === "agent" && agents.length > 0) { + setScopeId(agents[0].id); + } else if (scopeType === "project" && projects.length > 0) { + setScopeId(projects[0].id); + } + }, [scopeType, companyId, agents, projects]); + + async function handleSave() { + setSaving(true); + try { + const amountCents = Math.round(parseFloat(amountDollars) * 100); + if (Number.isNaN(amountCents) || amountCents <= 0) return; + + const wp = parseInt(warnPercent, 10); + if (Number.isNaN(wp) || wp < 1 || wp > 100) return; + + const result = await window.agentCompany.saveBudgetPolicy({ + companyId, + scopeType, + scopeId, + metric: "billed_cents", + windowKind: "calendar_month_utc", + amount: amountCents, + warnPercent: wp, + hardStopEnabled, + notifyEnabled, + isActive: true, + }); + if (result.ok) { + onSaved(); + onClose(); + } + } finally { + setSaving(false); + } + } + + return ( + +
+ ({ value: a.id, label: a.name }))} + /> + ) : null} + + {scopeType === "project" ? ( + + + + + + + + +
+ + void handleSave()} + /> +
+
+
+ ); +} diff --git a/src/renderer/components/execution/PolicyList.tsx b/src/renderer/components/execution/PolicyList.tsx new file mode 100644 index 00000000..96924b93 --- /dev/null +++ b/src/renderer/components/execution/PolicyList.tsx @@ -0,0 +1,161 @@ +import { useCallback, useEffect, useState } from "react"; +import { Plus, ShieldCheck, Trash, Warning } from "@phosphor-icons/react"; +import type { BudgetPolicyRecord, BudgetStatusSummary } from "@shared/types"; +import { ActionButton, EmptyState, ListFrame, ListRow } from "../ui"; +import { PolicyFormModal } from "./PolicyFormModal"; + +interface PolicyListProps { + companyId: string; + agents: Array<{ id: string; name: string }>; + projects: Array<{ id: string; name: string }>; +} + +export function PolicyList({ companyId, agents, projects }: PolicyListProps) { + const [policies, setPolicies] = useState([]); + const [budgetStatus, setBudgetStatus] = useState(null); + const [showForm, setShowForm] = useState(false); + const [editingPolicy, setEditingPolicy] = useState(null); + + const loadData = useCallback(async () => { + const [polRes, statusRes] = await Promise.all([ + window.agentCompany.listBudgetPolicies(companyId), + window.agentCompany.getBudgetStatus(companyId), + ]); + if (polRes.ok) setPolicies(polRes.data); + if (statusRes.ok) setBudgetStatus(statusRes.data); + }, [companyId]); + + useEffect(() => { void loadData(); }, [loadData]); + + function getScopeName(policy: BudgetPolicyRecord): string { + if (policy.scopeType === "company") return "Company"; + if (policy.scopeType === "agent") { + const agent = agents.find((a) => a.id === policy.scopeId); + return agent ? agent.name : policy.scopeId.slice(0, 8); + } + if (policy.scopeType === "project") { + const project = projects.find((p) => p.id === policy.scopeId); + return project ? project.name : policy.scopeId.slice(0, 8); + } + return policy.scopeId.slice(0, 8); + } + + function getUtilization(policyId: string): { spent: number; amount: number; pct: number; hasIncident: boolean } | null { + if (!budgetStatus) return null; + const entry = budgetStatus.policies.find((p) => p.policyId === policyId); + if (!entry) return null; + const pct = entry.amount > 0 ? (entry.spent / entry.amount) * 100 : 0; + return { spent: entry.spent, amount: entry.amount, pct, hasIncident: entry.hasOpenIncident }; + } + + async function handleDelete(policyId: string) { + const result = await window.agentCompany.deleteBudgetPolicy({ companyId, policyId }); + if (result.ok) void loadData(); + } + + function handleEdit(policy: BudgetPolicyRecord) { + setEditingPolicy(policy); + setShowForm(true); + } + + function handleAdd() { + setEditingPolicy(null); + setShowForm(true); + } + + function handleSaved() { + void loadData(); + } + + if (policies.length === 0 && !showForm) { + return ( +
+ } + /> + setShowForm(false)} + onSaved={handleSaved} + companyId={companyId} + editingPolicy={editingPolicy} + agents={agents} + projects={projects} + /> +
+ ); + } + + return ( +
+
+

Budget Policies

+ +
+
+ + {policies.map((policy) => { + const util = getUtilization(policy.id); + const pct = util?.pct ?? 0; + const barColor = pct > 100 ? "bg-[color:var(--danger)]" : pct > 80 ? "bg-[color:var(--warn)]" : "bg-[color:var(--success)]"; + return ( + handleEdit(policy)}> +
+
+
+ + {policy.scopeType} + + {getScopeName(policy)} + {policy.hardStopEnabled ? : null} + {util?.hasIncident ? : null} +
+
+ + ${(policy.amount / 100).toFixed(2)}/mo + + Warn at {policy.warnPercent}% +
+ {util ? ( +
+
+
+
+ + {Math.round(pct)}% + +
+ ) : null} +
+ +
+ + ); + })} + +
+ { setShowForm(false); setEditingPolicy(null); }} + onSaved={handleSaved} + companyId={companyId} + editingPolicy={editingPolicy} + agents={agents} + projects={projects} + /> +
+ ); +}