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">

View File

@@ -18,6 +18,7 @@ import {
faChartLine,
faFileAudio,
faPhoneMissed,
faSlidersUp,
} from "@fortawesome/pro-duotone-svg-icons";
import { faIcon } from "@/lib/icon-wrapper";
import { useAtom } from "jotai";
@@ -51,6 +52,7 @@ const IconTowerBroadcast = faIcon(faTowerBroadcast);
const IconChartLine = faIcon(faChartLine);
const IconFileAudio = faIcon(faFileAudio);
const IconPhoneMissed = faIcon(faPhoneMissed);
const IconSlidersUp = faIcon(faSlidersUp);
type NavSection = {
label: string;
@@ -76,6 +78,9 @@ const getNavSections = (role: string): NavSection[] => {
{ label: 'Marketing', items: [
{ label: 'Campaigns', href: '/campaigns', icon: IconBullhorn },
]},
{ label: 'Configuration', items: [
{ label: 'Rules Engine', href: '/rules', icon: IconSlidersUp },
]},
{ label: 'Admin', items: [
{ label: 'Settings', href: '/settings', icon: IconGear },
]},

View File

@@ -0,0 +1,55 @@
import { useMemo } from 'react';
import { WeightSliderRow } from './weight-slider-row';
import { CollapsibleSection } from './collapsible-section';
import { useData } from '@/providers/data-provider';
import type { PriorityConfig } from '@/lib/scoring';
interface CampaignWeightsPanelProps {
config: PriorityConfig;
onChange: (config: PriorityConfig) => void;
}
export const CampaignWeightsPanel = ({ config, onChange }: CampaignWeightsPanelProps) => {
const { campaigns } = useData();
const updateCampaign = (campaignId: string, weight: number) => {
onChange({
...config,
campaignWeights: { ...config.campaignWeights, [campaignId]: weight },
});
};
const badge = useMemo(() => {
if (!campaigns || campaigns.length === 0) return 'No campaigns';
const configured = campaigns.filter(c => config.campaignWeights[c.id] != null).length;
return `${campaigns.length} campaigns · ${configured} configured`;
}, [campaigns, config.campaignWeights]);
if (!campaigns || campaigns.length === 0) {
return (
<CollapsibleSection title="Campaign Weights" badge="No campaigns" defaultOpen={false}>
<p className="text-xs text-tertiary py-2">Campaign weights will apply once campaigns are created.</p>
</CollapsibleSection>
);
}
return (
<CollapsibleSection
title="Campaign Weights"
subtitle="Higher-weighted campaigns get their leads called first"
badge={badge}
defaultOpen={false}
>
<div className="divide-y divide-tertiary">
{campaigns.map(campaign => (
<WeightSliderRow
key={campaign.id}
label={campaign.campaignName ?? 'Untitled Campaign'}
weight={config.campaignWeights[campaign.id] ?? 5}
onWeightChange={(w) => updateCampaign(campaign.id, w)}
/>
))}
</div>
</CollapsibleSection>
);
};

View File

@@ -0,0 +1,56 @@
import { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faChevronDown, faChevronRight } from '@fortawesome/pro-duotone-svg-icons';
import { cx } from '@/utils/cx';
interface CollapsibleSectionProps {
title: string;
subtitle?: string;
badge?: string;
badgeColor?: string;
defaultOpen?: boolean;
children: React.ReactNode;
}
export const CollapsibleSection = ({
title,
subtitle,
badge,
badgeColor = 'text-brand-secondary',
defaultOpen = true,
children,
}: CollapsibleSectionProps) => {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="rounded-xl border border-secondary bg-primary overflow-hidden">
<button
onClick={() => setOpen(!open)}
className="flex w-full items-center justify-between px-5 py-3.5 hover:bg-primary_hover transition duration-100 ease-linear"
>
<div className="flex items-center gap-3">
<FontAwesomeIcon
icon={open ? faChevronDown : faChevronRight}
className="size-3 text-fg-quaternary"
/>
<div className="text-left">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-primary">{title}</span>
{badge && (
<span className={cx('text-xs font-medium tabular-nums', badgeColor)}>
{badge}
</span>
)}
</div>
{subtitle && <p className="text-xs text-tertiary mt-0.5">{subtitle}</p>}
</div>
</div>
</button>
{open && (
<div className="border-t border-secondary px-5 pb-4">
{children}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,80 @@
import { useMemo } from 'react';
import { WeightSliderRow } from './weight-slider-row';
import { CollapsibleSection } from './collapsible-section';
import { TASK_TYPE_LABELS } from '@/lib/scoring';
import type { PriorityConfig } from '@/lib/scoring';
interface PriorityConfigPanelProps {
config: PriorityConfig;
onChange: (config: PriorityConfig) => void;
}
const TASK_TYPE_ORDER = ['missed_call', 'follow_up', 'campaign_lead', 'attempt_2', 'attempt_3'];
export const PriorityConfigPanel = ({ config, onChange }: PriorityConfigPanelProps) => {
const updateTaskWeight = (taskType: string, weight: number) => {
onChange({
...config,
taskWeights: {
...config.taskWeights,
[taskType]: { ...config.taskWeights[taskType], weight },
},
});
};
const updateTaskSla = (taskType: string, slaMinutes: number) => {
onChange({
...config,
taskWeights: {
...config.taskWeights,
[taskType]: { ...config.taskWeights[taskType], slaMinutes },
},
});
};
const toggleTask = (taskType: string, enabled: boolean) => {
onChange({
...config,
taskWeights: {
...config.taskWeights,
[taskType]: { ...config.taskWeights[taskType], enabled },
},
});
};
const badge = useMemo(() => {
const entries = Object.values(config.taskWeights).filter(t => t.enabled);
if (entries.length === 0) return 'All disabled';
const avg = entries.reduce((s, t) => s + t.weight, 0) / entries.length;
return `${entries.length} active · Avg ${avg.toFixed(1)}`;
}, [config.taskWeights]);
return (
<CollapsibleSection
title="Task Type Weights"
subtitle="Higher weight = called first"
badge={badge}
defaultOpen
>
<div className="divide-y divide-tertiary">
{TASK_TYPE_ORDER.map(taskType => {
const taskConfig = config.taskWeights[taskType];
if (!taskConfig) return null;
return (
<WeightSliderRow
key={taskType}
label={TASK_TYPE_LABELS[taskType] ?? taskType}
weight={taskConfig.weight}
onWeightChange={(w) => updateTaskWeight(taskType, w)}
enabled={taskConfig.enabled}
onToggle={(e) => toggleTask(taskType, e)}
slaMinutes={taskConfig.slaMinutes}
onSlaChange={(m) => updateTaskSla(taskType, m)}
showSla
/>
);
})}
</div>
</CollapsibleSection>
);
};

View File

@@ -0,0 +1,142 @@
import { useState, useRef, useEffect } from 'react';
import { useChat } from '@ai-sdk/react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPaperPlaneTop, faSparkles, faChevronDown, faChevronUp } from '@fortawesome/pro-duotone-svg-icons';
import type { PriorityConfig } from '@/lib/scoring';
import { cx } from '@/utils/cx';
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
interface RulesAiAssistantProps {
config: PriorityConfig;
}
const QUICK_ACTIONS = [
{ label: 'Explain scoring', prompt: 'How does the priority scoring formula work?' },
{ label: 'Optimize weights', prompt: 'What would you recommend changing to better prioritize urgent cases?' },
{ label: 'SLA best practices', prompt: 'What SLA thresholds are recommended for a hospital call center?' },
{ label: 'Campaign strategy', prompt: 'How should I weight campaigns for IVF vs general health checkups?' },
];
export const RulesAiAssistant = ({ config }: RulesAiAssistantProps) => {
const [expanded, setExpanded] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const token = localStorage.getItem('helix_access_token') ?? '';
const { messages, input, handleSubmit, handleInputChange, isLoading, append } = useChat({
api: `${API_URL}/api/ai/stream`,
streamProtocol: 'text',
headers: {
'Authorization': `Bearer ${token}`,
},
body: {
context: {
type: 'rules-engine',
currentConfig: config,
},
},
});
// Auto-expand when messages arrive
useEffect(() => {
if (messages.length > 0) setExpanded(true);
}, [messages.length]);
useEffect(() => {
const el = messagesEndRef.current;
if (el?.parentElement) {
el.parentElement.scrollTop = el.parentElement.scrollHeight;
}
}, [messages]);
return (
<div className={cx('flex flex-col border-t border-secondary', expanded ? 'flex-1 min-h-0' : '')}>
{/* Collapsible header */}
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center justify-between px-4 py-3 hover:bg-primary_hover transition duration-100 ease-linear"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faSparkles} className="size-3.5 text-fg-brand-primary" />
<span className="text-sm font-semibold text-primary">AI Assistant</span>
{messages.length > 0 && (
<span className="rounded-full bg-brand-primary px-1.5 py-0.5 text-[10px] font-medium text-brand-secondary">
{messages.length}
</span>
)}
</div>
<FontAwesomeIcon
icon={expanded ? faChevronDown : faChevronUp}
className="size-3 text-fg-quaternary"
/>
</button>
{/* Expandable content */}
{expanded && (
<div className="flex flex-1 flex-col min-h-0 px-4 pb-3">
{/* Messages */}
<div className="flex-1 overflow-y-auto min-h-0 space-y-2 mb-3">
{messages.length === 0 && (
<div className="text-center py-3">
<p className="text-[11px] text-tertiary mb-2">
Ask about rule configuration, scoring, or best practices.
</p>
<div className="flex flex-wrap justify-center gap-1">
{QUICK_ACTIONS.map((action) => (
<button
key={action.label}
onClick={() => append({ role: 'user', content: action.prompt })}
disabled={isLoading}
className="rounded-md border border-secondary bg-primary px-2 py-1 text-[10px] font-medium text-secondary transition duration-100 ease-linear hover:bg-secondary hover:text-primary disabled:opacity-50"
>
{action.label}
</button>
))}
</div>
</div>
)}
{messages.map((msg) => (
<div
key={msg.id}
className={msg.role === 'user'
? 'ml-8 rounded-lg bg-brand-solid px-2.5 py-1.5 text-[11px] text-white'
: 'mr-4 rounded-lg bg-primary px-2.5 py-1.5 text-[11px] text-primary leading-relaxed'
}
>
<div className="whitespace-pre-wrap">{msg.content}</div>
</div>
))}
{isLoading && (
<div className="mr-4 rounded-lg bg-primary px-2.5 py-1.5">
<div className="flex gap-1">
<span className="size-1.5 rounded-full bg-tertiary animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="size-1.5 rounded-full bg-tertiary animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="size-1.5 rounded-full bg-tertiary animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<form onSubmit={handleSubmit} className="flex items-center gap-2 shrink-0">
<input
value={input}
onChange={handleInputChange}
placeholder="Ask about rules..."
disabled={isLoading}
className="flex-1 rounded-lg border border-secondary bg-primary px-3 py-2 text-xs text-primary placeholder:text-placeholder focus:border-brand focus:outline-none disabled:opacity-50"
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
className="flex size-8 items-center justify-center rounded-lg bg-brand-solid text-white transition duration-100 ease-linear hover:bg-brand-solid_hover disabled:opacity-50"
>
<FontAwesomeIcon icon={faPaperPlaneTop} className="size-3.5" />
</button>
</form>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,48 @@
import { useMemo } from 'react';
import { WeightSliderRow } from './weight-slider-row';
import { CollapsibleSection } from './collapsible-section';
import { SOURCE_LABELS } from '@/lib/scoring';
import type { PriorityConfig } from '@/lib/scoring';
interface SourceWeightsPanelProps {
config: PriorityConfig;
onChange: (config: PriorityConfig) => void;
}
const SOURCE_ORDER = ['WHATSAPP', 'PHONE', 'FACEBOOK_AD', 'GOOGLE_AD', 'INSTAGRAM', 'WEBSITE', 'REFERRAL', 'WALK_IN', 'OTHER'];
export const SourceWeightsPanel = ({ config, onChange }: SourceWeightsPanelProps) => {
const updateSource = (source: string, weight: number) => {
onChange({
...config,
sourceWeights: { ...config.sourceWeights, [source]: weight },
});
};
const badge = useMemo(() => {
const weights = SOURCE_ORDER.map(s => config.sourceWeights[s] ?? 5);
const avg = weights.reduce((a, b) => a + b, 0) / weights.length;
const highest = SOURCE_ORDER.reduce((best, s) => (config.sourceWeights[s] ?? 5) > (config.sourceWeights[best] ?? 5) ? s : best, SOURCE_ORDER[0]);
return `Avg ${avg.toFixed(1)} · Top: ${SOURCE_LABELS[highest]}`;
}, [config.sourceWeights]);
return (
<CollapsibleSection
title="Source Weights"
subtitle="Leads from higher-weighted sources get priority"
badge={badge}
defaultOpen={false}
>
<div className="divide-y divide-tertiary">
{SOURCE_ORDER.map(source => (
<WeightSliderRow
key={source}
label={SOURCE_LABELS[source] ?? source}
weight={config.sourceWeights[source] ?? 5}
onWeightChange={(w) => updateSource(source, w)}
/>
))}
</div>
</CollapsibleSection>
);
};

View File

@@ -0,0 +1,78 @@
import { Slider } from '@/components/base/slider/slider';
import { Select } from '@/components/base/select/select';
import { Toggle } from '@/components/base/toggle/toggle';
import { cx } from '@/utils/cx';
interface WeightSliderRowProps {
label: string;
weight: number;
onWeightChange: (value: number) => void;
enabled?: boolean;
onToggle?: (enabled: boolean) => void;
slaMinutes?: number;
onSlaChange?: (minutes: number) => void;
showSla?: boolean;
className?: string;
}
const SLA_OPTIONS = [
{ id: '60', label: '1h' },
{ id: '240', label: '4h' },
{ id: '720', label: '12h' },
{ id: '1440', label: '1d' },
{ id: '2880', label: '2d' },
{ id: '4320', label: '3d' },
];
export const WeightSliderRow = ({
label,
weight,
onWeightChange,
enabled = true,
onToggle,
slaMinutes,
onSlaChange,
showSla = false,
className,
}: WeightSliderRowProps) => {
return (
<div className={cx('flex items-center gap-4 py-3', !enabled && 'opacity-40', className)}>
{onToggle && (
<Toggle size="sm" isSelected={enabled} onChange={onToggle} />
)}
<div className="w-36 shrink-0">
<span className="text-sm font-medium text-primary">{label}</span>
</div>
<div className="flex-1 min-w-[140px]">
<Slider
minValue={0}
maxValue={10}
step={1}
value={weight}
onChange={(v) => onWeightChange(v as number)}
isDisabled={!enabled}
formatOptions={{ style: 'decimal', maximumFractionDigits: 0 }}
/>
</div>
<div className="w-8 text-center">
<span className={cx('text-sm font-bold tabular-nums', weight >= 8 ? 'text-error-primary' : weight >= 5 ? 'text-warning-primary' : 'text-tertiary')}>
{weight}
</span>
</div>
{showSla && slaMinutes != null && onSlaChange && (
<div className="w-20 shrink-0">
<Select
size="sm"
placeholder="SLA"
items={SLA_OPTIONS}
selectedKey={String(slaMinutes)}
onSelectionChange={(key) => onSlaChange(Number(key))}
isDisabled={!enabled}
>
{(item) => <Select.Item id={item.id}>{item.label}</Select.Item>}
</Select>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,118 @@
import { useMemo } from 'react';
import { useData } from '@/providers/data-provider';
import { scoreAndRankItems } from '@/lib/scoring';
import type { PriorityConfig, ScoreResult } from '@/lib/scoring';
import { cx } from '@/utils/cx';
interface WorklistPreviewProps {
config: PriorityConfig;
}
const slaColors: Record<string, string> = {
low: 'bg-success-solid',
medium: 'bg-warning-solid',
high: 'bg-error-solid',
critical: 'bg-error-solid animate-pulse',
};
const slaTextColor: Record<string, string> = {
low: 'text-success-primary',
medium: 'text-warning-primary',
high: 'text-error-primary',
critical: 'text-error-primary',
};
const shortType: Record<string, string> = {
missed_call: 'Missed',
follow_up: 'Follow-up',
campaign_lead: 'Campaign',
attempt_2: '2nd Att.',
attempt_3: '3rd Att.',
};
export const WorklistPreview = ({ config }: WorklistPreviewProps) => {
const { calls, leads, followUps } = useData();
const previewItems = useMemo(() => {
const items: any[] = [];
if (calls) {
calls
.filter((c: any) => c.callStatus === 'MISSED')
.slice(0, 5)
.forEach((c: any) => items.push({ ...c, type: 'missed', _label: c.callerNumber?.primaryPhoneNumber ?? c.name ?? 'Unknown' }));
}
if (followUps) {
followUps
.slice(0, 5)
.forEach((f: any) => items.push({ ...f, type: 'follow-up', _label: f.name ?? 'Follow-up' }));
}
if (leads) {
leads
.filter((l: any) => l.campaignId)
.slice(0, 5)
.forEach((l: any) => items.push({
...l,
type: 'lead',
_label: l.contactName ? `${l.contactName.firstName ?? ''} ${l.contactName.lastName ?? ''}`.trim() : l.contactPhone?.primaryPhoneNumber ?? 'Unknown',
}));
}
return items;
}, [calls, leads, followUps]);
const scored = useMemo(() => scoreAndRankItems(previewItems, config), [previewItems, config]);
return (
<div className="flex flex-col min-h-0">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-primary">Live Preview</h3>
<span className="text-xs text-tertiary">{scored.length} items</span>
</div>
<div className="rounded-xl border border-secondary overflow-hidden bg-primary">
{/* Header */}
<div className="flex items-center gap-2 px-3 py-2 bg-secondary text-xs font-medium text-tertiary border-b border-secondary">
<span className="w-3" />
<span className="flex-1 min-w-0">Name</span>
<span className="w-16 text-right">Score</span>
</div>
{/* Rows */}
<div className="divide-y divide-tertiary overflow-y-auto max-h-[320px]">
{scored.map((item: any & ScoreResult, index: number) => (
<div
key={item.id ?? index}
className="flex items-center gap-2 px-3 py-2 hover:bg-primary_hover transition duration-100 ease-linear"
>
<span className={cx('size-2 rounded-full shrink-0', slaColors[item.slaStatus])} />
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-primary truncate">
{item._label ?? item.name ?? 'Item'}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-[10px] text-tertiary">
{shortType[item.taskType] ?? item.taskType}
</span>
<span className="text-quaternary">&middot;</span>
<span className={cx('text-[10px] font-medium', slaTextColor[item.slaStatus])}>
{item.slaElapsedPercent}% SLA
</span>
</div>
</div>
<span className="w-16 text-right text-sm font-bold tabular-nums text-primary shrink-0">
{item.score.toFixed(1)}
</span>
</div>
))}
{scored.length === 0 && (
<div className="px-3 py-6 text-center text-xs text-tertiary">
No worklist items to preview
</div>
)}
</div>
</div>
</div>
);
};

128
src/lib/scoring.ts Normal file
View File

@@ -0,0 +1,128 @@
// Client-side scoring library — mirrors sidecar computation for live preview
export type TaskWeightConfig = {
weight: number;
slaMinutes: number;
enabled: boolean;
};
export type PriorityConfig = {
taskWeights: Record<string, TaskWeightConfig>;
campaignWeights: Record<string, number>;
sourceWeights: Record<string, number>;
};
export type ScoreResult = {
score: number;
baseScore: number;
slaMultiplier: number;
campaignMultiplier: number;
slaElapsedPercent: number;
slaStatus: 'low' | 'medium' | 'high' | 'critical';
taskType: string;
};
export function computeSlaMultiplier(slaElapsedPercent: number): number {
const elapsed = slaElapsedPercent / 100;
if (elapsed > 1) return 1.0 + (elapsed - 1) * 0.5;
return Math.pow(elapsed, 1.6);
}
export function computeSlaStatus(slaElapsedPercent: number): 'low' | 'medium' | 'high' | 'critical' {
if (slaElapsedPercent > 100) return 'critical';
if (slaElapsedPercent >= 80) return 'high';
if (slaElapsedPercent >= 50) return 'medium';
return 'low';
}
export function inferTaskType(item: any): string {
if (item.callStatus === 'MISSED' || item.type === 'missed') return 'missed_call';
if (item.followUpType === 'CALLBACK' || item.type === 'callback' || item.type === 'follow-up') return 'follow_up';
if (item.contactAttempts >= 3) return 'attempt_3';
if (item.contactAttempts >= 2) return 'attempt_2';
return 'campaign_lead';
}
export function scoreItem(item: any, config: PriorityConfig): ScoreResult {
const taskType = inferTaskType(item);
const taskConfig = config.taskWeights[taskType];
if (!taskConfig?.enabled) {
return { score: 0, baseScore: 0, slaMultiplier: 1, campaignMultiplier: 1, slaElapsedPercent: 0, slaStatus: 'low', taskType };
}
const createdAt = item.createdAt ? new Date(item.createdAt).getTime() : Date.now();
const elapsedMinutes = (Date.now() - createdAt) / 60000;
const slaElapsedPercent = Math.round((elapsedMinutes / taskConfig.slaMinutes) * 100);
const baseScore = taskConfig.weight;
const slaMultiplier = computeSlaMultiplier(slaElapsedPercent);
let campaignMultiplier = 1;
if (item.campaignId && config.campaignWeights[item.campaignId]) {
const cw = (config.campaignWeights[item.campaignId] ?? 5) / 10;
const source = item.leadSource ?? item.source ?? 'OTHER';
const sw = (config.sourceWeights[source] ?? 5) / 10;
campaignMultiplier = cw * sw;
}
const score = Math.round(baseScore * slaMultiplier * campaignMultiplier * 100) / 100;
return {
score,
baseScore,
slaMultiplier: Math.round(slaMultiplier * 100) / 100,
campaignMultiplier: Math.round(campaignMultiplier * 100) / 100,
slaElapsedPercent,
slaStatus: computeSlaStatus(slaElapsedPercent),
taskType,
};
}
export function scoreAndRankItems(items: any[], config: PriorityConfig): (any & ScoreResult)[] {
return items
.map(item => ({ ...item, ...scoreItem(item, config) }))
.sort((a, b) => b.score - a.score);
}
export const TASK_TYPE_LABELS: Record<string, string> = {
missed_call: 'Missed Calls',
follow_up: 'Follow-ups',
campaign_lead: 'Campaign Leads',
attempt_2: '2nd Attempt',
attempt_3: '3rd Attempt',
};
export const SOURCE_LABELS: Record<string, string> = {
WHATSAPP: 'WhatsApp',
PHONE: 'Phone',
FACEBOOK_AD: 'Facebook Ad',
GOOGLE_AD: 'Google Ad',
INSTAGRAM: 'Instagram',
WEBSITE: 'Website',
REFERRAL: 'Referral',
WALK_IN: 'Walk-in',
OTHER: 'Other',
};
export const DEFAULT_PRIORITY_CONFIG: PriorityConfig = {
taskWeights: {
missed_call: { weight: 9, slaMinutes: 720, enabled: true },
follow_up: { weight: 8, slaMinutes: 1440, enabled: true },
campaign_lead: { weight: 7, slaMinutes: 2880, enabled: true },
attempt_2: { weight: 6, slaMinutes: 1440, enabled: true },
attempt_3: { weight: 4, slaMinutes: 2880, enabled: true },
},
campaignWeights: {},
sourceWeights: {
WHATSAPP: 9,
PHONE: 8,
FACEBOOK_AD: 7,
GOOGLE_AD: 7,
INSTAGRAM: 5,
WEBSITE: 7,
REFERRAL: 6,
WALK_IN: 5,
OTHER: 5,
},
};

View File

@@ -28,6 +28,7 @@ import { CallRecordingsPage } from "@/pages/call-recordings";
import { MissedCallsPage } from "@/pages/missed-calls";
import { ProfilePage } from "@/pages/profile";
import { AccountSettingsPage } from "@/pages/account-settings";
import { RulesSettingsPage } from "@/pages/rules-settings";
import { AuthProvider } from "@/providers/auth-provider";
import { DataProvider } from "@/providers/data-provider";
import { RouteProvider } from "@/providers/router-provider";
@@ -75,6 +76,7 @@ createRoot(document.getElementById("root")!).render(
<Route path="/patient/:id" element={<Patient360Page />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/account-settings" element={<AccountSettingsPage />} />
<Route path="/rules" element={<RulesSettingsPage />} />
<Route path="*" element={<NotFound />} />
</Route>
</Route>

View File

@@ -0,0 +1,160 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
import { Button } from '@/components/base/buttons/button';
import { PriorityConfigPanel } from '@/components/rules/priority-config-panel';
import { CampaignWeightsPanel } from '@/components/rules/campaign-weights-panel';
import { SourceWeightsPanel } from '@/components/rules/source-weights-panel';
import { WorklistPreview } from '@/components/rules/worklist-preview';
import { RulesAiAssistant } from '@/components/rules/rules-ai-assistant';
import { DEFAULT_PRIORITY_CONFIG } from '@/lib/scoring';
import type { PriorityConfig } from '@/lib/scoring';
const API_BASE = import.meta.env.VITE_SIDECAR_URL ?? 'http://localhost:4100';
const getToken = () => localStorage.getItem('helix_access_token');
export const RulesSettingsPage = () => {
const token = getToken();
const [config, setConfig] = useState<PriorityConfig>(DEFAULT_PRIORITY_CONFIG);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const load = async () => {
try {
const res = await fetch(`${API_BASE}/api/rules/priority-config`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (res.ok) {
const data = await res.json();
setConfig(data);
}
} catch {
// Fallback to defaults
} finally {
setLoading(false);
}
};
load();
}, [token]);
const saveConfig = useCallback(async (newConfig: PriorityConfig) => {
try {
setSaving(true);
await fetch(`${API_BASE}/api/rules/priority-config`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(newConfig),
});
setDirty(false);
} catch {
// Silent fail
} finally {
setSaving(false);
}
}, [token]);
const handleConfigChange = (newConfig: PriorityConfig) => {
setConfig(newConfig);
setDirty(true);
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(() => saveConfig(newConfig), 1000);
};
const applyTemplate = async () => {
try {
const res = await fetch(`${API_BASE}/api/rules/templates/hospital-starter/apply`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
if (res.ok) {
const configRes = await fetch(`${API_BASE}/api/rules/priority-config`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (configRes.ok) {
setConfig(await configRes.json());
setDirty(false);
}
}
} catch {
// Silent fail
}
};
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-sm text-tertiary">Loading rules configuration...</div>
</div>
);
}
return (
<div className="flex h-full flex-col overflow-hidden">
{/* Header */}
<div className="shrink-0 flex items-center justify-between border-b border-secondary px-6 py-4">
<div>
<h1 className="text-lg font-bold text-primary">Rules Engine</h1>
<p className="text-sm text-tertiary">Configure how leads are prioritized and routed in the worklist</p>
</div>
<div className="flex items-center gap-3">
{dirty && <span className="text-xs text-warning-primary">{saving ? 'Saving...' : 'Unsaved changes'}</span>}
<Button size="sm" color="secondary" onClick={applyTemplate}>
Apply Starter Template
</Button>
</div>
</div>
{/* Tabs + Content — fills remaining height */}
<div className="flex flex-1 flex-col min-h-0">
<Tabs aria-label="Rules configuration" className="flex flex-1 flex-col min-h-0">
<div className="shrink-0 border-b border-secondary px-6 pt-2">
<TabList items={[{ id: 'priority', label: 'Priority Rules' }, { id: 'automations', label: 'Automations' }]} type="underline" size="sm">
{(item) => <Tab key={item.id} id={item.id} label={item.label} />}
</TabList>
</div>
<Tabs.Panel id="priority" className="flex flex-1 min-h-0">
<div className="flex flex-1 min-h-0">
{/* Left — config panels, scrollable */}
<div className="flex-1 overflow-y-auto p-6 space-y-8">
<PriorityConfigPanel config={config} onChange={handleConfigChange} />
<CampaignWeightsPanel config={config} onChange={handleConfigChange} />
<SourceWeightsPanel config={config} onChange={handleConfigChange} />
</div>
{/* Right — preview + collapsible AI */}
<div className="w-[400px] shrink-0 border-l border-secondary flex flex-col min-h-0 bg-secondary">
{/* Preview — takes available space */}
<div className="flex-1 overflow-y-auto p-4 min-h-0">
<WorklistPreview config={config} />
</div>
{/* AI Assistant — collapsible at bottom */}
<RulesAiAssistant config={config} />
</div>
</div>
</Tabs.Panel>
<Tabs.Panel id="automations" className="flex flex-1 min-h-0">
<div className="flex items-center justify-center flex-1 p-12">
<div className="text-center">
<h3 className="text-md font-semibold text-primary mb-2">Automation Rules</h3>
<p className="text-sm text-tertiary max-w-md">
Configure rules that automatically assign leads, escalate SLA breaches, and manage lead lifecycle.
This feature is coming soon.
</p>
</div>
</div>
</Tabs.Panel>
</Tabs>
</div>
</div>
);
};