- Create PolicyList with utilization bars, edit/delete, empty state - Create PolicyFormModal with scope selector, dollar amount, warn/hard-stop toggles - Create IncidentList with status filtering and manual resolve action - Add tabbed navigation (Cost Entries | Policies | Incidents) to costs section - Export new components from execution barrel file
139 lines
6.0 KiB
TypeScript
139 lines
6.0 KiB
TypeScript
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<string, string> = {
|
|
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<string, string> = {
|
|
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<BudgetIncidentRecord[]>([]);
|
|
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 (
|
|
<div className="flex h-full flex-col overflow-hidden">
|
|
<div className="flex items-center justify-between px-4 py-3">
|
|
<h3 className="text-[15px] font-semibold text-[color:var(--text)]">Budget Incidents</h3>
|
|
<div className="flex items-center gap-1 rounded-[6px] bg-[color:var(--panel-soft)] p-0.5">
|
|
{(["open", "all"] as const).map((f) => (
|
|
<button
|
|
key={f}
|
|
type="button"
|
|
onClick={() => setFilter(f)}
|
|
className={`rounded-[5px] px-3 py-1 text-[12px] font-medium transition ${
|
|
filter === f
|
|
? "bg-[color:var(--panel)] text-[color:var(--text)] shadow-sm"
|
|
: "text-[color:var(--muted)] hover:text-[color:var(--text)]"
|
|
}`}
|
|
>
|
|
{f === "open" ? "Open" : "All"}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto px-4 pb-4">
|
|
{incidents.length === 0 ? (
|
|
<EmptyState
|
|
title="No budget incidents"
|
|
detail={filter === "open" ? "No open incidents. All spending is within limits." : "No incidents recorded yet."}
|
|
/>
|
|
) : (
|
|
<ListFrame>
|
|
{incidents.map((incident) => {
|
|
const isWarn = incident.thresholdType === "warn_threshold";
|
|
return (
|
|
<ListRow key={incident.id}>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.1em] ${
|
|
isWarn
|
|
? "text-[color:var(--warn)] bg-[color:var(--warn-soft)]"
|
|
: "text-[color:var(--danger)] bg-[color:var(--danger-soft)]"
|
|
}`}
|
|
>
|
|
{isWarn ? <Warning size={10} weight="bold" /> : null}
|
|
{isWarn ? "Warning" : "Hard Stop"}
|
|
</span>
|
|
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.1em] ${STATUS_STYLES[incident.status] ?? STATUS_STYLES.open}`}>
|
|
{STATUS_LABELS[incident.status] ?? incident.status}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="mt-1.5 flex items-center gap-2 text-[12px] text-[color:var(--muted-strong)]">
|
|
<span className="capitalize">{incident.scopeType}</span>
|
|
<span className="text-[color:var(--muted)]">·</span>
|
|
<span className="font-medium text-[color:var(--text)]">{getScopeName(incident.scopeType, incident.scopeId)}</span>
|
|
</div>
|
|
|
|
<div className="mt-1 flex items-center gap-3 text-[11px] text-[color:var(--muted)]">
|
|
<span className="font-[family-name:var(--font-mono)]" style={{ fontVariantNumeric: "tabular-nums" }}>
|
|
${(incident.amountObserved / 100).toFixed(2)} / ${(incident.amountLimit / 100).toFixed(2)}
|
|
</span>
|
|
<span>{formatTime(incident.windowStart)} - {formatTime(incident.windowEnd)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{incident.status === "open" ? (
|
|
<ActionButton
|
|
label="Resolve"
|
|
icon={CheckCircle}
|
|
onClick={() => void handleResolve(incident.id)}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
</ListRow>
|
|
);
|
|
})}
|
|
</ListFrame>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|