123 lines
6.3 KiB
TypeScript
123 lines
6.3 KiB
TypeScript
|
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||
|
|
import { Check, ShieldCheck, X } from 'lucide-react'
|
||
|
|
import { fetchApprovalHistory } from '../../lib/api'
|
||
|
|
import type { Agent, Approval } from '../../types'
|
||
|
|
import { getAgentMeta } from '../../lib/agentMeta'
|
||
|
|
import { Badge } from '../ui/Badge'
|
||
|
|
import { Button } from '../ui/Button'
|
||
|
|
import { cn } from '../../lib/utils'
|
||
|
|
|
||
|
|
type Filter = 'pending' | 'approved' | 'denied' | 'all'
|
||
|
|
|
||
|
|
type Props = {
|
||
|
|
agents: Agent[]
|
||
|
|
livePending: Approval[]
|
||
|
|
onDecide: (id: string, approved: boolean, decidedBy: string, note: string) => Promise<void>
|
||
|
|
}
|
||
|
|
|
||
|
|
export function ApprovalInbox({ agents, livePending, onDecide }: Props) {
|
||
|
|
const [filter, setFilter] = useState<Filter>('pending')
|
||
|
|
const [items, setItems] = useState<Approval[]>([])
|
||
|
|
const [stats, setStats] = useState({ pending: 0, approved: 0, denied: 0, total: 0 })
|
||
|
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||
|
|
const [decider, setDecider] = useState<'mo-commander' | 'bart-commander'>('mo-commander')
|
||
|
|
const [note, setNote] = useState('')
|
||
|
|
const [busy, setBusy] = useState(false)
|
||
|
|
|
||
|
|
const load = useCallback(async () => {
|
||
|
|
const res = await fetchApprovalHistory(filter === 'all' ? 'all' : filter)
|
||
|
|
setItems(res.approvals)
|
||
|
|
if (res.stats) setStats(res.stats)
|
||
|
|
}, [filter])
|
||
|
|
|
||
|
|
useEffect(() => { load() }, [load, livePending])
|
||
|
|
|
||
|
|
const selected = useMemo(() => items.find((a) => a.id === selectedId) || items[0] || null, [items, selectedId])
|
||
|
|
const agentOf = (id: string) => agents.find((a) => a.id === id)
|
||
|
|
|
||
|
|
const handleDecide = async (approved: boolean) => {
|
||
|
|
if (!selected || selected.status !== 'pending') return
|
||
|
|
setBusy(true)
|
||
|
|
try {
|
||
|
|
await onDecide(selected.id, approved, decider, note)
|
||
|
|
setNote('')
|
||
|
|
await load()
|
||
|
|
} finally {
|
||
|
|
setBusy(false)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<section className="panel flex min-h-0 flex-1 flex-col overflow-hidden">
|
||
|
|
<header className="flex shrink-0 items-start justify-between gap-3 border-b border-border px-3 py-2">
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<ShieldCheck className="h-4 w-4 text-docker" />
|
||
|
|
<div>
|
||
|
|
<h2 className="text-sm font-semibold text-foreground">Approval Inbox</h2>
|
||
|
|
<p className="text-[10px] text-foreground-muted">Mo & Bart review mutating agent actions</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="flex gap-1">
|
||
|
|
<Badge variant="warning">{stats.pending} pending</Badge>
|
||
|
|
<Badge variant="success">{stats.approved} ok</Badge>
|
||
|
|
<Badge variant="danger">{stats.denied} denied</Badge>
|
||
|
|
</div>
|
||
|
|
</header>
|
||
|
|
|
||
|
|
<div className="flex shrink-0 gap-1 border-b border-border px-2 py-1">
|
||
|
|
{(['pending', 'approved', 'denied', 'all'] as Filter[]).map((f) => (
|
||
|
|
<button key={f} type="button" onClick={() => setFilter(f)} className={cn('rounded px-2 py-0.5 text-[10px] capitalize', filter === f ? 'bg-docker-light text-docker' : 'text-foreground-muted')}>
|
||
|
|
{f}
|
||
|
|
</button>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="grid min-h-0 flex-1 grid-cols-[minmax(200px,0.9fr)_1.1fr]">
|
||
|
|
<div className="scrollbar-thin overflow-y-auto border-r border-border p-1">
|
||
|
|
{!items.length && <p className="p-4 text-center text-[10px] text-foreground-faint">No {filter} requests.</p>}
|
||
|
|
{items.map((a) => {
|
||
|
|
const ag = agentOf(a.agent_id)
|
||
|
|
const meta = ag ? getAgentMeta(ag.id) : null
|
||
|
|
const Icon = meta?.icon
|
||
|
|
return (
|
||
|
|
<button key={a.id} type="button" onClick={() => setSelectedId(a.id)} className={cn('mb-1 flex w-full gap-2 rounded-lg border p-2 text-left', selected?.id === a.id ? 'border-docker/40 bg-docker-light' : 'border-transparent hover:bg-surface-overlay')}>
|
||
|
|
{Icon && <Icon className="h-4 w-4 shrink-0 text-docker" />}
|
||
|
|
<span className="min-w-0 flex-1">
|
||
|
|
<span className="block truncate text-[11px] font-medium text-foreground">{a.action.slice(0, 80)}</span>
|
||
|
|
<span className="block text-[9px] text-foreground-faint">{ag?.name || a.agent_id}</span>
|
||
|
|
</span>
|
||
|
|
<Badge variant={a.status === 'pending' ? 'warning' : a.status === 'approved' ? 'success' : 'danger'}>{a.status}</Badge>
|
||
|
|
</button>
|
||
|
|
)
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{selected && (
|
||
|
|
<div className="scrollbar-thin overflow-y-auto p-3">
|
||
|
|
<Badge variant={selected.status === 'pending' ? 'warning' : 'success'}>{selected.status}</Badge>
|
||
|
|
<dl className="mt-3 grid grid-cols-2 gap-2 text-[10px]">
|
||
|
|
<div><dt className="text-foreground-faint">Agent</dt><dd className="text-foreground">{agentOf(selected.agent_id)?.name}</dd></div>
|
||
|
|
<div><dt className="text-foreground-faint">Type</dt><dd className="text-foreground">{selected.action_type}</dd></div>
|
||
|
|
<div className="col-span-2"><dt className="text-foreground-faint">Action</dt><dd className="text-foreground-muted">{selected.action}</dd></div>
|
||
|
|
<div className="col-span-2"><dt className="text-foreground-faint">Reason</dt><dd className="text-foreground-muted">{selected.reason}</dd></div>
|
||
|
|
</dl>
|
||
|
|
{selected.status === 'pending' && (
|
||
|
|
<div className="mt-4 space-y-2">
|
||
|
|
<select value={decider} onChange={(e) => setDecider(e.target.value as typeof decider)} className="w-full rounded border border-border bg-surface px-2 py-1 text-[10px] text-foreground-muted">
|
||
|
|
<option value="mo-commander">Decide as Mo</option>
|
||
|
|
<option value="bart-commander">Decide as Bart</option>
|
||
|
|
</select>
|
||
|
|
<input value={note} onChange={(e) => setNote(e.target.value)} placeholder="Note (optional)" className="w-full rounded border border-border bg-surface px-2 py-1 text-[10px] text-foreground-muted" />
|
||
|
|
<div className="flex gap-2">
|
||
|
|
<Button variant="success" className="flex-1" disabled={busy} onClick={() => handleDecide(true)}><Check className="h-3 w-3" /> Approve</Button>
|
||
|
|
<Button variant="danger" className="flex-1" disabled={busy} onClick={() => handleDecide(false)}><X className="h-3 w-3" /> Deny</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
)
|
||
|
|
}
|