feat(05-03): policy-level BudgetSummary, incident-based BudgetAlertBanner, AgentEditor policy wiring

- BudgetSummary shows policy-level utilization bars when companyId provided, falls back to agent-level
- BudgetAlertBanner loads open incidents for policy-based alerts (warn_threshold/hard_stop), falls back to agent thresholds
- AgentEditor reads budget from budget_policies and writes budget as policy on save (D-11)
- Dashboard passes companyId through to BudgetAlertBanner
This commit is contained in:
ZacharyZhang-NY committed 2026-03-26 21:17:02 -04:00
1 parent 3a8e4c40ad
commit 1c462d571c
5 files changed
+217 -46

No files matched your search

@@ -1,12 +1,13 @@
import { useState, useEffect, useMemo, useCallback } from "react";
import { Warning, X } from "@phosphor-icons/react";
import { AnimatePresence, motion } from "framer-motion";
import type { AgentRecord, SectionId } from "@shared/types";
import type { AgentRecord, BudgetIncidentRecord, SectionId } from "@shared/types";
import { useReducedMotion } from "../ui";
export interface BudgetAlertBannerProps {
agents: AgentRecord[];
onNavigate: (section: SectionId, entityId?: string) => void;
companyId?: string;
}
interface AgentOverThreshold {
@@ -15,10 +16,21 @@ interface AgentOverThreshold {
pct: number;
}
export function BudgetAlertBanner({ agents, onNavigate }: BudgetAlertBannerProps) {
export function BudgetAlertBanner({ agents, onNavigate, companyId }: BudgetAlertBannerProps) {
const reducedMotion = useReducedMotion();
const [dismissed, setDismissed] = useState(false);
const [incidents, setIncidents] = useState<BudgetIncidentRecord[]>([]);
// Load policy-based incidents when companyId is provided
useEffect(() => {
if (!companyId) return;
void (async () => {
const result = await window.agentCompany.listBudgetIncidents({ companyId, status: "open" });
if (result.ok) setIncidents(result.data);
})();
}, [companyId]);
// Fallback: agent-level threshold detection (existing logic)
const agentsOverThreshold = useMemo<AgentOverThreshold[]>(() => {
return agents
.filter((a) => a.budgetMonthlyUsd > 0 && a.spentMonthlyUsd / a.budgetMonthlyUsd >= 0.8)
@@ -30,34 +42,102 @@ export function BudgetAlertBanner({ agents, onNavigate }: BudgetAlertBannerProps
.sort((a, b) => b.pct - a.pct);
}, [agents]);
// Reset dismissed state when the set of agents over threshold changes
const overThresholdKey = useMemo(
() => agentsOverThreshold.map((a) => a.id).sort().join(","),
[agentsOverThreshold],
);
// Determine whether to use policy-based or agent-based alerts
const usePolicyAlerts = companyId && incidents.length > 0;
// Reset dismissed state when the set of alerts changes
const alertKey = useMemo(() => {
if (usePolicyAlerts) {
return incidents.map((i) => i.id).sort().join(",");
}
return agentsOverThreshold.map((a) => a.id).sort().join(",");
}, [usePolicyAlerts, incidents, agentsOverThreshold]);
useEffect(() => {
setDismissed(false);
}, [overThresholdKey]);
}, [alertKey]);
const handleDismiss = useCallback(() => setDismissed(true), []);
const handleViewCosts = useCallback(() => onNavigate("costs"), [onNavigate]);
if (agentsOverThreshold.length === 0) return null;
// Nothing to show
if (!usePolicyAlerts && agentsOverThreshold.length === 0) return null;
if (dismissed) return null;
const content = (
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);
}
return scopeId.slice(0, 8);
}
// Policy-based alert content
const policyContent = usePolicyAlerts ? (
<div className="space-y-2">
{incidents.map((incident) => {
const isHardStop = incident.thresholdType === "hard_stop";
const borderColor = isHardStop ? "border-[color:var(--danger)]" : "border-[color:var(--warn)]";
const bgColor = isHardStop ? "bg-[color:var(--danger-soft)]" : "bg-[color:var(--warn-soft)]";
const textColor = isHardStop ? "text-[color:var(--danger)]" : "text-[color:var(--warn)]";
const pct = incident.amountLimit > 0 ? Math.round((incident.amountObserved / incident.amountLimit) * 100) : 0;
return (
<div
key={incident.id}
role="alert"
aria-live="assertive"
className={`rounded-[8px] border ${borderColor} border-opacity-30 ${bgColor} px-4 py-3`}
>
<div className="flex items-start gap-3">
<div className="mt-0.5 shrink-0">
<Warning size={18} weight="bold" className={textColor} />
</div>
<div className="min-w-0 flex-1">
<div className={`text-[13px] font-semibold ${textColor}`}>
{isHardStop ? "Budget Hard Stop" : "Budget Warning"}
</div>
<div className="mt-0.5 text-[13px] text-[color:var(--muted-strong)]">
{getScopeName(incident.scopeType, incident.scopeId)} — ${(incident.amountObserved / 100).toFixed(2)} / ${(incident.amountLimit / 100).toFixed(2)} ({pct}%)
</div>
<button
type="button"
onClick={handleViewCosts}
className="mt-1.5 text-[11px] font-medium text-[color:var(--accent)] hover:underline cursor-pointer"
>
View Costs
</button>
</div>
</div>
</div>
);
})}
<div className="flex justify-end">
<button
type="button"
onClick={handleDismiss}
title="Dismiss budget alerts"
aria-label="Dismiss budget alerts"
className="shrink-0 rounded p-0.5 text-[color:var(--muted-strong)] transition hover:text-[color:var(--text)]"
>
<X size={14} />
</button>
</div>
</div>
) : null;
// Fallback agent-level alert content (existing logic)
const agentContent = !usePolicyAlerts ? (
<div
role="alert"
aria-live="assertive"
className="rounded-[8px] border border-[color:var(--warn)] border-opacity-30 bg-[color:var(--warn-soft)] px-4 py-3"
>
<div className="flex items-start gap-3">
{/* Icon */}
<div className="mt-0.5 shrink-0">
<Warning size={18} weight="bold" className="text-[color:var(--warn)]" />
</div>
{/* Content */}
<div className="min-w-0 flex-1">
<div className="text-[13px] font-semibold text-[color:var(--warn)]">
Budget Alert
@@ -88,8 +168,6 @@ export function BudgetAlertBanner({ agents, onNavigate }: BudgetAlertBannerProps
View Costs
</button>
</div>
{/* Dismiss button */}
<button
type="button"
onClick={handleDismiss}
@@ -101,9 +179,9 @@ export function BudgetAlertBanner({ agents, onNavigate }: BudgetAlertBannerProps
</button>
</div>
</div>
);
) : null;
if (dismissed) return null;
const content = usePolicyAlerts ? policyContent : agentContent;
if (reducedMotion) {
return content;
@@ -30,6 +30,7 @@ import { BudgetAlertBanner } from "./BudgetAlertBanner";
export interface DashboardProps {
companyName?: string;
companyId?: string;
agents: AgentRecord[];
tasks: TaskRecord[];
runs: RunRecord[];
@@ -44,7 +45,7 @@ export interface DashboardProps {
export function Dashboard(props: DashboardProps) {
const t = useT();
const { companyName, agents, tasks, runs, approvals, activity, costs, messages = [], onNavigate } = props;
const { companyName, companyId, agents, tasks, runs, approvals, activity, costs, messages = [], onNavigate } = props;
const agentMap = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]);
const taskMap = useMemo(() => new Map(tasks.map((t) => [t.id, t])), [tasks]);
@@ -95,7 +96,7 @@ export function Dashboard(props: DashboardProps) {
{/* Conditional banners */}
<RecoveryBanner />
<BudgetAlertBanner agents={agents} onNavigate={onNavigate} />
<BudgetAlertBanner agents={agents} onNavigate={onNavigate} companyId={companyId} />
{/* Key Metrics — 4 cards */}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">