sud-life-insurance/src/components/insurance/AIDecisionStream.tsx
2026-07-03 14:52:45 +05:30

193 lines
7.0 KiB
TypeScript

import { useState } from 'react';
import { ChevronDown, FileText, RefreshCw, TriangleAlert } from 'lucide-react';
import { cn } from '../../lib/cn';
import { Avatar } from '../core/Avatar';
import { ReasoningBody } from './ReasoningBody';
import type { Decision } from '../../types';
export interface AIDecisionStreamProps {
decisions: Decision[];
loading?: boolean;
/** Manual refresh handler. Shown as a refresh button in the header. */
onRefresh?: () => void;
/** Autonomy threshold. @default 0.7 */
threshold?: number;
className?: string;
}
/**
* AI DECISION STREAM — the stacked-decision view.
* A single-open accordion: each row is a scannable summary (employee, activity,
* confidence, one-line reasoning); expanding reveals the full reasoning in a
* height-capped, scrollable box so a wall of text never blows out the column.
*/
export function AIDecisionStream({
decisions,
loading = false,
onRefresh,
threshold = 0.7,
className,
}: AIDecisionStreamProps) {
// Single-open accordion; all collapsed by default (-1 = none open).
const [openIdx, setOpenIdx] = useState(-1);
const escalations = decisions.filter((d) => d.confidence < threshold);
return (
<div className={cn('flex flex-col gap-3.5', className)}>
<div className="flex items-center justify-between">
<h3 className="m-0 text-sm font-bold text-strong">AI decision stream</h3>
<div className="flex items-center gap-2">
<span className="text-2xs font-bold uppercase tracking-[0.06em] text-on-navy bg-navy-900 px-2 py-1 rounded-pill">
{decisions.length} action{decisions.length === 1 ? '' : 's'}
</span>
{onRefresh && (
<button
type="button"
onClick={onRefresh}
disabled={loading}
title="Refresh AI decisions"
aria-label="Refresh AI decisions"
className="w-7 h-7 rounded-md border border-border-subtle bg-card cursor-pointer text-muted hover:text-strong disabled:opacity-50 flex items-center justify-center shrink-0"
>
<RefreshCw size={14} className={cn(loading && 'animate-spin')} />
</button>
)}
</div>
</div>
{escalations.length > 0 && (
<div className="bg-escalated-soft border border-amber-300 rounded-md px-3.5 py-3 flex gap-3">
<TriangleAlert size={18} className="text-amber-600 shrink-0 mt-0.5" />
<div>
<div className="text-sm font-bold text-amber-700">
{escalations.length} escalation{escalations.length > 1 ? 's' : ''} need you
</div>
<div className="text-xs text-muted mt-0.5">
{escalations[0].employee} · confidence {Math.round(escalations[0].confidence * 100)}%.
</div>
</div>
</div>
)}
{/* Placeholder only on first load; a manual refresh keeps the list visible
(the header button spins) so the stream never blanks out. */}
{loading && decisions.length === 0 && (
<div className="text-sm text-faint">Loading decisions</div>
)}
{!loading && decisions.length === 0 && (
<div className="text-sm text-faint">No AI decisions for this lead yet.</div>
)}
{decisions.length > 0 && (
<div className="bg-card rounded-lg border border-border-subtle shadow-sm overflow-hidden font-sans divide-y divide-border-subtle">
{decisions.map((d, i) => (
<DecisionRow
key={i}
decision={d}
threshold={threshold}
open={openIdx === i}
onToggle={() => setOpenIdx((cur) => (cur === i ? -1 : i))}
/>
))}
</div>
)}
</div>
);
}
function DecisionRow({
decision: d,
threshold,
open,
onToggle,
}: {
decision: Decision;
threshold: number;
open: boolean;
onToggle: () => void;
}) {
const autonomous = d.confidence >= threshold;
const accent = autonomous ? 'var(--status-autonomous)' : 'var(--status-escalated)';
const accentSoft = autonomous ? 'var(--status-autonomous-soft)' : 'var(--status-escalated-soft)';
const pct = Math.round(d.confidence * 100);
return (
<div className="relative">
{/* accent rail on the open row */}
{open && (
<span
className={cn('absolute top-0 left-0 bottom-0 w-1', autonomous && 'bg-sunrise')}
style={autonomous ? undefined : { background: accent }}
/>
)}
<button
type="button"
onClick={onToggle}
aria-expanded={open}
className={cn(
'w-full flex items-start gap-3 text-left px-3.5 py-3 cursor-pointer bg-transparent border-none transition-colors',
open ? 'bg-sunk/60' : 'hover:bg-sunk/40',
)}
>
<Avatar name={d.employee} ai size={30} className="mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-strong truncate">{d.employee}</span>
{/* confidence chip */}
<span
className="ml-auto shrink-0 inline-flex items-center gap-1 text-2xs font-bold tracking-[0.02em] px-1.5 py-0.5 rounded-pill nums"
style={{ background: accentSoft, color: accent }}
>
<span className="w-1.5 h-1.5 rounded-full" style={{ background: accent }} />
{pct}%
</span>
<ChevronDown
size={15}
className={cn('shrink-0 text-faint transition-transform duration-200', open && 'rotate-180')}
/>
</div>
<div className="text-2xs text-faint mt-0.5">
{d.activity}
{d.time && <span> · {d.time}</span>}
</div>
<p className={cn('m-0 mt-1.5 text-sm leading-snug text-body', !open && 'line-clamp-2')}>
{d.summary}
</p>
</div>
</button>
{open && (d.reasoning || d.citations.length > 0) && (
<div className="px-3.5 pb-4">
{d.reasoning && (
<div className="px-4 py-3.5 bg-sunk rounded-lg">
<ReasoningBody text={d.reasoning} accent={accent} accentSoft={accentSoft} />
</div>
)}
{d.citations.length > 0 && (
<div className="mt-3">
<div className="text-[10px] font-bold uppercase tracking-[0.06em] text-faint mb-[7px]">
Cited knowledge
</div>
<div className="flex flex-wrap gap-[7px]">
{d.citations.map((c, i) => (
<span
key={i}
className="inline-flex items-center gap-1.5 text-xs font-medium text-muted bg-card border border-border-default rounded-sm px-2.5 py-[5px]"
>
<FileText size={12} className="text-sunrise-500" />
{c}
</span>
))}
</div>
</div>
)}
</div>
)}
</div>
);
}