feat: rules engine — priority config UI + worklist scoring

- Rules engine spec v2 (priority vs automation rules distinction)
- Priority Rules settings page with weight sliders, SLA config, campaign/source weights
- Collapsible config sections with dynamic headers
- Live worklist preview panel with client-side scoring
- AI assistant panel (collapsible) with rules-engine-specific system prompt
- Worklist panel: score display with SLA status dots, sort by score
- Scoring library (scoring.ts) for client-side preview computation
- Sidebar: Rules Engine nav item under Configuration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-01 16:51:29 +05:30
parent 462601d0dc
commit b90740e009
14 changed files with 1680 additions and 514 deletions

View File

@@ -85,6 +85,11 @@ type WorklistRow = {
source: string | null;
lastDisposition: string | null;
missedCallId: string | null;
// Rules engine scoring (from sidecar)
score?: number;
scoreBreakdown?: { baseScore: number; slaMultiplier: number; campaignMultiplier: number; rulesApplied: string[] };
slaStatus?: 'low' | 'medium' | 'high' | 'critical';
slaElapsedPercent?: number;
};
const priorityConfig: Record<string, { color: 'error' | 'warning' | 'brand' | 'gray'; label: string; sort: number }> = {
@@ -228,7 +233,9 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
// Remove rows without a phone number — agent can't act on them
const actionableRows = rows.filter(r => r.phoneRaw);
// Sort by rules engine score if available, otherwise by priority + createdAt
actionableRows.sort((a, b) => {
if (a.score != null && b.score != null) return b.score - a.score;
const pa = priorityConfig[a.priority]?.sort ?? 2;
const pb = priorityConfig[b.priority]?.sort ?? 2;
if (pa !== pb) return pa - pb;
@@ -280,6 +287,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
rows = [...rows].sort((a, b) => {
switch (sortDescriptor.column) {
case 'priority': {
if (a.score != null && b.score != null) return (a.score - b.score) * dir;
const pa = priorityConfig[a.priority]?.sort ?? 2;
const pb = priorityConfig[b.priority]?.sort ?? 2;
return (pa - pb) * dir;
@@ -404,7 +412,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-2 pt-3">
<Table size="sm" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}>
<Table.Header>
<Table.Head id="priority" label="PRIORITY" className="w-20" isRowHeader allowsSorting />
<Table.Head id="priority" label="SCORE" className="w-20" isRowHeader allowsSorting />
<Table.Head id="name" label="PATIENT" allowsSorting />
<Table.Head label="PHONE" />
<Table.Head label={tab === 'missed' ? 'BRANCH' : 'SOURCE'} className="w-28" />
@@ -433,9 +441,22 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
}}
>
<Table.Cell>
<Badge size="sm" color={priority.color} type="pill-color">
{priority.label}
</Badge>
{row.score != null ? (
<div className="flex items-center gap-2" title={row.scoreBreakdown ? `${row.scoreBreakdown.rulesApplied.join(', ')}\nSLA: ×${row.scoreBreakdown.slaMultiplier}\nCampaign: ×${row.scoreBreakdown.campaignMultiplier}` : undefined}>
<span className={cx(
'size-2.5 rounded-full shrink-0',
row.slaStatus === 'low' && 'bg-success-solid',
row.slaStatus === 'medium' && 'bg-warning-solid',
row.slaStatus === 'high' && 'bg-error-solid',
row.slaStatus === 'critical' && 'bg-error-solid animate-pulse',
)} />
<span className="text-xs font-bold tabular-nums text-primary">{row.score.toFixed(1)}</span>
</div>
) : (
<Badge size="sm" color={priority.color} type="pill-color">
{priority.label}
</Badge>
)}
</Table.Cell>
<Table.Cell>
<div className="flex items-center gap-2">