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:
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">
|
||||
|
||||
Reference in new issue
Block a user