Create src/renderer/components/ui/icons.ts as the single re-export point
for every Phosphor icon the renderer touches, then rewrite the 95 files
that previously imported directly from "@phosphor-icons/react" to pull
from the relative-path-to-barrel instead. The TitleBar commit earlier
in this series already used ui/icons; this commit extends the pattern
to the rest of the renderer.
Benefits now wired in:
- one location where bundle tree-shaking can be reasoned about,
- typos like Briefcase vs BriefCase show up against a finite list,
- re-theming or swapping icon sets is a one-file edit,
- a lint rule that flags raw @phosphor-icons/react imports can be
added later without mass-rewriting call sites.
The barrel also re-exports the `Icon` type so call sites that type
icon props against the Phosphor component type work unchanged.
Test count stays at 363 passing; typecheck stays at 156 errors.
106 lines
5.4 KiB
TypeScript
106 lines
5.4 KiB
TypeScript
import { useEffect, useState } from "react"
|
|
import { Warning } from "../ui/icons"
|
|
import type { BudgetStatusSummary, ProfileSnapshot } from "@shared/types"
|
|
import { formatMoney } from "../../lib/formatters"
|
|
import { ListFrame, ListRow } from "../ui"
|
|
|
|
interface BudgetSummaryProps {
|
|
agents: ProfileSnapshot["agents"]
|
|
costs: ProfileSnapshot["costs"]
|
|
companyId?: string
|
|
}
|
|
|
|
export function BudgetSummary({ agents, costs, companyId }: BudgetSummaryProps) {
|
|
const [budgetStatus, setBudgetStatus] = useState<BudgetStatusSummary | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (!companyId) return
|
|
void (async () => {
|
|
const result = await window.agentCompany.getBudgetStatus(companyId)
|
|
if (result.ok) setBudgetStatus(result.data)
|
|
})()
|
|
}, [companyId])
|
|
|
|
const knownSpend = costs.reduce((sum, entry) => sum + (entry.amountUsd ?? 0), 0)
|
|
const unattributedCount = costs.filter((entry) => entry.unattributed).length
|
|
|
|
const hasPolicies = budgetStatus && budgetStatus.policies.length > 0
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="border-y border-border">
|
|
<div className="flex items-end justify-between gap-4 py-3">
|
|
<div className="text-[12px] text-muted-foreground">Known spend</div>
|
|
<div className="font-mono text-[22px] tracking-[-0.03em] text-foreground">{formatMoney(knownSpend)}</div>
|
|
</div>
|
|
<div className="flex items-end justify-between gap-4 border-t border-border py-3">
|
|
<div className="text-[12px] text-muted-foreground">Unattributed entries</div>
|
|
<div className="font-mono text-[18px] text-foreground">{String(unattributedCount)}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{hasPolicies ? (
|
|
<ListFrame>
|
|
{budgetStatus.policies.map((status) => {
|
|
const pct = status.amount > 0 ? (status.spent / status.amount) * 100 : 0
|
|
const isWarning = pct > 80 && pct <= 100
|
|
const isDanger = pct > 100
|
|
const barColor = isDanger ? "bg-destructive" : isWarning ? "bg-warning" : "bg-success"
|
|
const nameColor = isDanger ? "text-destructive" : isWarning ? "text-warning" : "text-foreground"
|
|
|
|
const scopeLabel = status.scopeType === "company"
|
|
? "Company"
|
|
: status.scopeType === "agent"
|
|
? (agents.find((a) => a.id === status.scopeId)?.name ?? status.scopeId.slice(0, 8))
|
|
: status.scopeId.slice(0, 8)
|
|
|
|
return (
|
|
<ListRow key={status.policyId}>
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.1em] text-muted-foreground">
|
|
{status.scopeType}
|
|
</span>
|
|
<div className={`text-[14px] font-medium ${nameColor}`}>{scopeLabel}</div>
|
|
{status.hasOpenIncident ? <Warning size={14} weight="fill" className="text-warning" /> : null}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="font-mono text-[12px] text-foreground">{formatMoney(status.spent / 100)} / {formatMoney(status.amount / 100)}</div>
|
|
<span className={`font-mono text-[11px] font-semibold ${isDanger ? "text-destructive" : isWarning ? "text-warning" : "text-muted-foreground"}`}>{Math.round(pct)}%</span>
|
|
</div>
|
|
</div>
|
|
<div className="h-2 rounded-full bg-[color:var(--border)]"><div className={`h-2 rounded-full ${barColor}`} style={{ width: `${Math.min(100, pct)}%` }} /></div>
|
|
</ListRow>
|
|
)
|
|
})}
|
|
</ListFrame>
|
|
) : (
|
|
<ListFrame>
|
|
{agents.map((agent) => {
|
|
const pct = agent.budgetMonthlyUsd > 0 ? (agent.spentMonthlyUsd / agent.budgetMonthlyUsd) * 100 : 0
|
|
const isWarning = pct > 80 && pct <= 100; const isDanger = pct > 100
|
|
const barColor = isDanger ? "bg-destructive" : isWarning ? "bg-warning" : "bg-success"
|
|
const nameColor = isDanger ? "text-destructive" : isWarning ? "text-warning" : "text-foreground"
|
|
return (
|
|
<ListRow key={agent.id}>
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<div className={`text-[14px] font-medium ${nameColor}`}>{agent.name}</div>
|
|
{isDanger ? <Warning size={14} weight="fill" className="text-destructive" /> : null}
|
|
{isWarning ? <Warning size={14} weight="fill" className="text-warning" /> : null}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="font-mono text-[12px] text-foreground">{formatMoney(agent.spentMonthlyUsd)} / {formatMoney(agent.budgetMonthlyUsd)}</div>
|
|
<span className={`font-mono text-[11px] font-semibold ${isDanger ? "text-destructive" : isWarning ? "text-warning" : "text-muted-foreground"}`}>{Math.round(pct)}%</span>
|
|
</div>
|
|
</div>
|
|
<div className="h-2 rounded-full bg-[color:var(--border)]"><div className={`h-2 rounded-full ${barColor}`} style={{ width: `${Math.min(100, pct)}%` }} /></div>
|
|
</ListRow>
|
|
)
|
|
})}
|
|
</ListFrame>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|