refactor(dashboard): consolidate BudgetAlertBanner dual source into helper

This commit is contained in:
ZacharyZhang-NY committed 2026-04-16 23:31:58 -04:00
1 parent c6935ff983
commit 0c9d8269c1
2 files changed
+90 -111

No files matched your search

@@ -3,6 +3,7 @@ import { Warning, X } from "@phosphor-icons/react";
import { AnimatePresence, motion } from "framer-motion";
import type { AgentRecord, BudgetIncidentRecord, SectionId } from "@shared/types";
import { useReducedMotion } from "../ui";
import { computeBudgetAlerts, type BudgetAlert } from "../../lib/budget-alerts";
export interface BudgetAlertBannerProps {
agents: AgentRecord[];
@@ -10,18 +11,11 @@ export interface BudgetAlertBannerProps {
companyId?: string;
}
interface AgentOverThreshold {
id: string;
name: string;
pct: number;
}
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 () => {
@@ -30,28 +24,15 @@ export function BudgetAlertBanner({ agents, onNavigate, companyId }: BudgetAlert
})();
}, [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)
.map((a) => ({
id: a.id,
name: a.name,
pct: Math.round((a.spentMonthlyUsd / a.budgetMonthlyUsd) * 100),
}))
.sort((a, b) => b.pct - a.pct);
}, [agents]);
const alerts = useMemo<BudgetAlert[]>(
() => computeBudgetAlerts(incidents, agents),
[incidents, agents],
);
// 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]);
const alertKey = useMemo(
() => alerts.map((a) => a.id).sort().join(","),
[alerts],
);
useEffect(() => {
setDismissed(false);
@@ -60,46 +41,30 @@ export function BudgetAlertBanner({ agents, onNavigate, companyId }: BudgetAlert
const handleDismiss = useCallback(() => setDismissed(true), []);
const handleViewCosts = useCallback(() => onNavigate("costs"), [onNavigate]);
// Nothing to show
if (!usePolicyAlerts && agentsOverThreshold.length === 0) return null;
if (alerts.length === 0) return null;
if (dismissed) return null;
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 ? (
const content = (
<div className="space-y-2">
{incidents.map((incident) => {
const isHardStop = incident.thresholdType === "hard_stop";
const borderColor = isHardStop ? "border-[color:var(--destructive)]" : "border-[color:var(--destructive)]";
const bgColor = isHardStop ? "bg-destructive-muted" : "bg-warning-muted";
const textColor = isHardStop ? "text-destructive" : "text-warning";
const pct = incident.amountLimit > 0 ? Math.round((incident.amountObserved / incident.amountLimit) * 100) : 0;
{alerts.map((alert) => {
const isDanger = alert.severity === "danger";
const bgColor = isDanger ? "bg-destructive-muted" : "bg-warning-muted";
const textColor = isDanger ? "text-destructive" : "text-warning";
return (
<div
key={incident.id}
key={alert.id}
role="alert"
aria-live="assertive"
className={`rounded-lg border ${borderColor} border-opacity-30 ${bgColor} px-4 py-3`}
className={`rounded-lg border border-[color:var(--destructive)] 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={`text-[13px] font-semibold ${textColor}`}>{alert.title}</div>
<div className="mt-0.5 text-[13px] text-muted-foreground">
{getScopeName(incident.scopeType, incident.scopeId)} — ${(incident.amountObserved / 100).toFixed(2)} / ${(incident.amountLimit / 100).toFixed(2)} ({pct}%)
{alert.scopeLabel} — ${alert.observedUsd.toFixed(2)} / ${alert.limitUsd.toFixed(2)} ({alert.pct}%)
</div>
<button
type="button"
@@ -125,63 +90,7 @@ export function BudgetAlertBanner({ agents, onNavigate, companyId }: BudgetAlert
</button>
</div>
</div>
) : null;
// Fallback agent-level alert content (existing logic)
const agentContent = !usePolicyAlerts ? (
<div
role="alert"
aria-live="assertive"
className="rounded-lg border border-[color:var(--destructive)] border-opacity-30 bg-warning-muted px-4 py-3"
>
<div className="flex items-start gap-3">
<div className="mt-0.5 shrink-0">
<Warning size={18} weight="bold" className="text-warning" />
</div>
<div className="min-w-0 flex-1">
<div className="text-[13px] font-semibold text-warning">
Budget Alert
</div>
<div className="mt-0.5 text-[13px] text-muted-foreground">
{agentsOverThreshold.length} agent(s) approaching budget limit
</div>
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1">
{agentsOverThreshold.map((agent) => (
<span
key={agent.id}
className={`whitespace-nowrap text-[11px] font-[family-name:var(--font-mono)] ${
agent.pct >= 100
? "text-destructive"
: "text-warning"
}`}
style={{ fontVariantNumeric: "tabular-nums" }}
>
{agent.name} ({agent.pct}%)
</span>
))}
</div>
<button
type="button"
onClick={handleViewCosts}
className="mt-1.5 text-[11px] font-medium text-primary hover:underline cursor-pointer"
>
View Costs
</button>
</div>
<button
type="button"
onClick={handleDismiss}
title="Dismiss budget alert"
aria-label="Dismiss budget alert"
className="shrink-0 rounded p-0.5 text-muted-foreground transition hover:text-foreground"
>
<X size={14} />
</button>
</div>
</div>
) : null;
const content = usePolicyAlerts ? policyContent : agentContent;
);
if (reducedMotion) {
return content;
+70
View File
@@ -0,0 +1,70 @@
import type { AgentRecord, BudgetIncidentRecord } from "@shared/types";
export type BudgetAlertSeverity = "warn" | "danger";
export interface BudgetAlert {
id: string;
severity: BudgetAlertSeverity;
title: string;
scopeLabel: string;
pct: number;
observedUsd: number;
limitUsd: number;
}
/**
* Unify policy-based BudgetIncidentRecord alerts and fallback agent-level
* threshold alerts into a single `BudgetAlert[]`.
*
* When `incidents` is non-empty, policy-based alerts are used exclusively.
* Otherwise we derive alerts from agents whose month-to-date spend crosses
* 80% of their monthly budget.
*/
export function computeBudgetAlerts(
incidents: BudgetIncidentRecord[],
agents: AgentRecord[],
): BudgetAlert[] {
if (incidents.length > 0) {
return incidents.map((incident) => {
const pct = incident.amountLimit > 0
? Math.round((incident.amountObserved / incident.amountLimit) * 100)
: 0;
const isHardStop = incident.thresholdType === "hard_stop";
const scopeLabel = getScopeLabel(incident.scopeType, incident.scopeId, agents);
return {
id: incident.id,
severity: isHardStop ? "danger" : "warn",
title: isHardStop ? "Budget Hard Stop" : "Budget Warning",
scopeLabel,
pct,
observedUsd: incident.amountObserved / 100,
limitUsd: incident.amountLimit / 100,
};
});
}
return agents
.filter((a) => a.budgetMonthlyUsd > 0 && a.spentMonthlyUsd / a.budgetMonthlyUsd >= 0.8)
.map((a): BudgetAlert => {
const pct = Math.round((a.spentMonthlyUsd / a.budgetMonthlyUsd) * 100);
return {
id: a.id,
severity: pct >= 100 ? "danger" : "warn",
title: "Budget Alert",
scopeLabel: a.name,
pct,
observedUsd: a.spentMonthlyUsd,
limitUsd: a.budgetMonthlyUsd,
};
})
.sort((a, b) => b.pct - a.pct);
}
function getScopeLabel(scopeType: string, scopeId: string, agents: AgentRecord[]): 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);
}