mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-12 02:38:15 +00:00
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:
55
src/components/rules/campaign-weights-panel.tsx
Normal file
55
src/components/rules/campaign-weights-panel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
56
src/components/rules/collapsible-section.tsx
Normal file
56
src/components/rules/collapsible-section.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
80
src/components/rules/priority-config-panel.tsx
Normal file
80
src/components/rules/priority-config-panel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
142
src/components/rules/rules-ai-assistant.tsx
Normal file
142
src/components/rules/rules-ai-assistant.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
48
src/components/rules/source-weights-panel.tsx
Normal file
48
src/components/rules/source-weights-panel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
78
src/components/rules/weight-slider-row.tsx
Normal file
78
src/components/rules/weight-slider-row.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
118
src/components/rules/worklist-preview.tsx
Normal file
118
src/components/rules/worklist-preview.tsx
Normal 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">·</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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user