mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 10:23:27 +00:00
- 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>
552 lines
26 KiB
TypeScript
552 lines
26 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import { faPhoneArrowDown, faPhoneArrowUp, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons';
|
||
import type { SortDescriptor } from 'react-aria-components';
|
||
import { faIcon } from '@/lib/icon-wrapper';
|
||
import { Table } from '@/components/application/table/table';
|
||
|
||
const SearchLg = faIcon(faMagnifyingGlass);
|
||
import { Badge } from '@/components/base/badges/badges';
|
||
import { Input } from '@/components/base/input/input';
|
||
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
|
||
import { PhoneActionCell } from './phone-action-cell';
|
||
import { formatPhone, formatTimeOnly, formatShortDate } from '@/lib/format';
|
||
import { notify } from '@/lib/toast';
|
||
import { cx } from '@/utils/cx';
|
||
|
||
type WorklistLead = {
|
||
id: string;
|
||
createdAt: string;
|
||
contactName: { firstName: string; lastName: string } | null;
|
||
contactPhone: { number: string; callingCode: string }[] | null;
|
||
leadSource: string | null;
|
||
leadStatus: string | null;
|
||
interestedService: string | null;
|
||
aiSummary: string | null;
|
||
aiSuggestedAction: string | null;
|
||
lastContacted: string | null;
|
||
contactAttempts: number | null;
|
||
utmCampaign: string | null;
|
||
campaignId: string | null;
|
||
};
|
||
|
||
type WorklistFollowUp = {
|
||
id: string;
|
||
createdAt: string | null;
|
||
followUpType: string | null;
|
||
followUpStatus: string | null;
|
||
scheduledAt: string | null;
|
||
priority: string | null;
|
||
};
|
||
|
||
type MissedCall = {
|
||
id: string;
|
||
createdAt: string;
|
||
callDirection: string | null;
|
||
callerNumber: { number: string; callingCode: string }[] | null;
|
||
startedAt: string | null;
|
||
leadId: string | null;
|
||
disposition: string | null;
|
||
callbackstatus: string | null;
|
||
callsourcenumber: string | null;
|
||
missedcallcount: number | null;
|
||
callbackattemptedat: string | null;
|
||
};
|
||
|
||
type MissedSubTab = 'pending' | 'attempted' | 'completed' | 'invalid';
|
||
|
||
interface WorklistPanelProps {
|
||
missedCalls: MissedCall[];
|
||
followUps: WorklistFollowUp[];
|
||
leads: WorklistLead[];
|
||
loading: boolean;
|
||
onSelectLead: (lead: WorklistLead) => void;
|
||
selectedLeadId: string | null;
|
||
onDialMissedCall?: (missedCallId: string) => void;
|
||
}
|
||
|
||
type TabKey = 'all' | 'missed' | 'leads' | 'follow-ups';
|
||
|
||
type WorklistRow = {
|
||
id: string;
|
||
type: 'missed' | 'callback' | 'follow-up' | 'lead';
|
||
priority: 'URGENT' | 'HIGH' | 'NORMAL' | 'LOW';
|
||
name: string;
|
||
phone: string;
|
||
phoneRaw: string;
|
||
direction: 'inbound' | 'outbound' | null;
|
||
typeLabel: string;
|
||
reason: string;
|
||
createdAt: string;
|
||
taskState: 'PENDING' | 'ATTEMPTED' | 'SCHEDULED';
|
||
leadId: string | null;
|
||
originalLead: WorklistLead | null;
|
||
lastContactedAt: string | null;
|
||
contactAttempts: number;
|
||
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 }> = {
|
||
URGENT: { color: 'error', label: 'Urgent', sort: 0 },
|
||
HIGH: { color: 'warning', label: 'High', sort: 1 },
|
||
NORMAL: { color: 'brand', label: 'Normal', sort: 2 },
|
||
LOW: { color: 'gray', label: 'Low', sort: 3 },
|
||
};
|
||
|
||
const followUpLabel: Record<string, string> = {
|
||
CALLBACK: 'Callback',
|
||
APPOINTMENT_REMINDER: 'Appt Reminder',
|
||
POST_VISIT: 'Post-visit',
|
||
MARKETING: 'Marketing',
|
||
REVIEW_REQUEST: 'Review',
|
||
};
|
||
|
||
const computeSla = (dateStr: string): { label: string; color: 'success' | 'warning' | 'error' } => {
|
||
const minutes = Math.max(0, Math.round((Date.now() - new Date(dateStr).getTime()) / 60000));
|
||
if (minutes < 1) return { label: '<1m', color: 'success' };
|
||
if (minutes < 15) return { label: `${minutes}m`, color: 'success' };
|
||
if (minutes < 30) return { label: `${minutes}m`, color: 'warning' };
|
||
if (minutes < 60) return { label: `${minutes}m`, color: 'error' };
|
||
const hours = Math.floor(minutes / 60);
|
||
if (hours < 24) return { label: `${hours}h ${minutes % 60}m`, color: 'error' };
|
||
return { label: `${Math.floor(hours / 24)}d`, color: 'error' };
|
||
};
|
||
|
||
const formatTimeAgo = (dateStr: string): string => {
|
||
const minutes = Math.round((Date.now() - new Date(dateStr).getTime()) / 60000);
|
||
if (minutes < 1) return 'Just now';
|
||
if (minutes < 60) return `${minutes}m ago`;
|
||
const hours = Math.floor(minutes / 60);
|
||
if (hours < 24) return `${hours}h ago`;
|
||
return `${Math.floor(hours / 24)}d ago`;
|
||
};
|
||
|
||
const formatDisposition = (disposition: string): string =>
|
||
disposition.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||
|
||
const formatSource = (source: string): string => {
|
||
const map: Record<string, string> = {
|
||
FACEBOOK_AD: 'Facebook',
|
||
GOOGLE_AD: 'Google',
|
||
WALK_IN: 'Walk-in',
|
||
REFERRAL: 'Referral',
|
||
WEBSITE: 'Website',
|
||
PHONE_INQUIRY: 'Phone',
|
||
};
|
||
return map[source] ?? source.replace(/_/g, ' ');
|
||
};
|
||
|
||
const IconInbound = faIcon(faPhoneArrowDown);
|
||
const IconOutbound = faIcon(faPhoneArrowUp);
|
||
|
||
const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], leads: WorklistLead[]): WorklistRow[] => {
|
||
const rows: WorklistRow[] = [];
|
||
|
||
for (const call of missedCalls) {
|
||
const phone = call.callerNumber?.[0];
|
||
const countBadge = call.missedcallcount && call.missedcallcount > 1 ? ` (${call.missedcallcount}x)` : '';
|
||
const sourceSuffix = call.callsourcenumber ? ` • ${call.callsourcenumber}` : '';
|
||
rows.push({
|
||
id: `mc-${call.id}`,
|
||
type: 'missed',
|
||
priority: 'HIGH',
|
||
name: (phone ? formatPhone(phone) : 'Unknown') + countBadge,
|
||
phone: phone ? formatPhone(phone) : '',
|
||
phoneRaw: phone?.number ?? '',
|
||
direction: call.callDirection === 'OUTBOUND' ? 'outbound' : 'inbound',
|
||
typeLabel: 'Missed Call',
|
||
reason: call.startedAt
|
||
? `Missed at ${formatTimeOnly(call.startedAt)}${sourceSuffix}`
|
||
: 'Missed call',
|
||
createdAt: call.createdAt,
|
||
taskState: call.callbackstatus === 'CALLBACK_ATTEMPTED' ? 'ATTEMPTED' : 'PENDING',
|
||
leadId: call.leadId,
|
||
originalLead: null,
|
||
lastContactedAt: call.callbackattemptedat ?? call.startedAt ?? call.createdAt,
|
||
contactAttempts: 0,
|
||
source: call.callsourcenumber ?? null,
|
||
lastDisposition: call.disposition ?? null,
|
||
missedCallId: call.id,
|
||
});
|
||
}
|
||
|
||
for (const fu of followUps) {
|
||
const isOverdue = fu.followUpStatus === 'OVERDUE' || (fu.scheduledAt !== null && new Date(fu.scheduledAt) < new Date());
|
||
const label = followUpLabel[fu.followUpType ?? ''] ?? fu.followUpType ?? 'Follow-up';
|
||
rows.push({
|
||
id: `fu-${fu.id}`,
|
||
type: fu.followUpType === 'CALLBACK' ? 'callback' : 'follow-up',
|
||
priority: (fu.priority as WorklistRow['priority']) ?? (isOverdue ? 'HIGH' : 'NORMAL'),
|
||
name: label,
|
||
phone: '',
|
||
phoneRaw: '',
|
||
direction: null,
|
||
typeLabel: fu.followUpType === 'CALLBACK' ? 'Callback' : 'Follow-up',
|
||
reason: fu.scheduledAt
|
||
? `Scheduled ${formatShortDate(fu.scheduledAt)}`
|
||
: '',
|
||
createdAt: fu.createdAt ?? fu.scheduledAt ?? new Date().toISOString(),
|
||
taskState: isOverdue ? 'PENDING' : (fu.followUpStatus === 'COMPLETED' ? 'ATTEMPTED' : 'SCHEDULED'),
|
||
leadId: null,
|
||
originalLead: null,
|
||
lastContactedAt: fu.scheduledAt ?? fu.createdAt ?? null,
|
||
contactAttempts: 0,
|
||
source: null,
|
||
lastDisposition: null,
|
||
missedCallId: null,
|
||
});
|
||
}
|
||
|
||
for (const lead of leads) {
|
||
const firstName = lead.contactName?.firstName ?? '';
|
||
const lastName = lead.contactName?.lastName ?? '';
|
||
const fullName = `${firstName} ${lastName}`.trim() || 'Unknown';
|
||
const phone = lead.contactPhone?.[0];
|
||
rows.push({
|
||
id: `lead-${lead.id}`,
|
||
type: 'lead',
|
||
priority: 'NORMAL',
|
||
name: fullName,
|
||
phone: phone ? formatPhone(phone) : '',
|
||
phoneRaw: phone?.number ?? '',
|
||
direction: null,
|
||
typeLabel: 'Lead',
|
||
reason: lead.interestedService ?? lead.aiSuggestedAction ?? '',
|
||
createdAt: lead.createdAt,
|
||
taskState: 'PENDING',
|
||
leadId: lead.id,
|
||
originalLead: lead,
|
||
lastContactedAt: lead.lastContacted ?? null,
|
||
contactAttempts: lead.contactAttempts ?? 0,
|
||
source: lead.leadSource ?? lead.utmCampaign ?? null,
|
||
lastDisposition: null,
|
||
missedCallId: null,
|
||
});
|
||
}
|
||
|
||
// 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;
|
||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||
});
|
||
|
||
return actionableRows;
|
||
};
|
||
|
||
export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectLead, selectedLeadId, onDialMissedCall }: WorklistPanelProps) => {
|
||
const [tab, setTab] = useState<TabKey>('all');
|
||
const [search, setSearch] = useState('');
|
||
const [missedSubTab, setMissedSubTab] = useState<MissedSubTab>('pending');
|
||
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ column: 'sla', direction: 'descending' });
|
||
|
||
const missedByStatus = useMemo(() => ({
|
||
pending: missedCalls.filter(c => c.callbackstatus === 'PENDING_CALLBACK' || !c.callbackstatus),
|
||
attempted: missedCalls.filter(c => c.callbackstatus === 'CALLBACK_ATTEMPTED'),
|
||
completed: missedCalls.filter(c => c.callbackstatus === 'CALLBACK_COMPLETED' || c.callbackstatus === 'WRONG_NUMBER'),
|
||
invalid: missedCalls.filter(c => c.callbackstatus === 'INVALID'),
|
||
}), [missedCalls]);
|
||
|
||
const allRows = useMemo(
|
||
() => buildRows(missedCalls, followUps, leads),
|
||
[missedCalls, followUps, leads],
|
||
);
|
||
|
||
// Build rows from sub-tab filtered missed calls when on missed tab
|
||
const missedSubTabRows = useMemo(
|
||
() => buildRows(missedByStatus[missedSubTab], [], []),
|
||
[missedByStatus, missedSubTab],
|
||
);
|
||
|
||
const filteredRows = useMemo(() => {
|
||
let rows = allRows;
|
||
if (tab === 'missed') rows = missedSubTabRows;
|
||
else if (tab === 'leads') rows = rows.filter((r) => r.type === 'lead');
|
||
else if (tab === 'follow-ups') rows = rows.filter((r) => r.type === 'follow-up');
|
||
|
||
if (search.trim()) {
|
||
const q = search.toLowerCase();
|
||
rows = rows.filter(
|
||
(r) => r.name.toLowerCase().includes(q) || r.phone.toLowerCase().includes(q) || r.phoneRaw.includes(q),
|
||
);
|
||
}
|
||
|
||
if (sortDescriptor.column) {
|
||
const dir = sortDescriptor.direction === 'ascending' ? 1 : -1;
|
||
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;
|
||
}
|
||
case 'name':
|
||
return a.name.localeCompare(b.name) * dir;
|
||
case 'sla': {
|
||
const ta = new Date(a.lastContactedAt ?? a.createdAt).getTime();
|
||
const tb = new Date(b.lastContactedAt ?? b.createdAt).getTime();
|
||
return (ta - tb) * dir;
|
||
}
|
||
default:
|
||
return 0;
|
||
}
|
||
});
|
||
}
|
||
|
||
return rows;
|
||
}, [allRows, tab, search, sortDescriptor, missedSubTabRows]);
|
||
|
||
const missedCount = allRows.filter((r) => r.type === 'missed').length;
|
||
const leadCount = allRows.filter((r) => r.type === 'lead').length;
|
||
const followUpCount = allRows.filter((r) => r.type === 'follow-up').length;
|
||
|
||
// Notification for new missed calls
|
||
const prevMissedCount = useRef(missedCount);
|
||
useEffect(() => {
|
||
if (missedCount > prevMissedCount.current && prevMissedCount.current > 0) {
|
||
notify.info('New Missed Call', `${missedCount - prevMissedCount.current} new missed call(s)`);
|
||
}
|
||
prevMissedCount.current = missedCount;
|
||
}, [missedCount]);
|
||
|
||
const PAGE_SIZE = 15;
|
||
const [page, setPage] = useState(1);
|
||
|
||
const handleTabChange = useCallback((key: TabKey) => { setTab(key); setPage(1); }, []);
|
||
const handleSearch = useCallback((value: string) => { setSearch(value); setPage(1); }, []);
|
||
|
||
const totalPages = Math.max(1, Math.ceil(filteredRows.length / PAGE_SIZE));
|
||
const pagedRows = filteredRows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||
|
||
const tabItems = [
|
||
{ id: 'all' as const, label: 'All Tasks', badge: allRows.length > 0 ? String(allRows.length) : undefined },
|
||
{ id: 'missed' as const, label: 'Missed Calls', badge: missedCount > 0 ? String(missedCount) : undefined },
|
||
{ id: 'leads' as const, label: 'Leads', badge: leadCount > 0 ? String(leadCount) : undefined },
|
||
{ id: 'follow-ups' as const, label: 'Follow-ups', badge: followUpCount > 0 ? String(followUpCount) : undefined },
|
||
];
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex items-center justify-center py-12">
|
||
<p className="text-sm text-tertiary">Loading worklist...</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const isEmpty = missedCalls.length === 0 && followUps.length === 0 && leads.length === 0;
|
||
|
||
if (isEmpty) {
|
||
return (
|
||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||
<p className="text-sm font-semibold text-primary">All clear</p>
|
||
<p className="text-xs text-tertiary mt-1">No pending items in your worklist</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||
{/* Filter tabs + search */}
|
||
<div className="flex shrink-0 items-end justify-between border-b border-secondary px-5 pt-3 pb-0.5">
|
||
<Tabs selectedKey={tab} onSelectionChange={(key) => handleTabChange(key as TabKey)}>
|
||
<TabList items={tabItems} type="underline" size="sm">
|
||
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
|
||
</TabList>
|
||
</Tabs>
|
||
<div className="w-44 shrink-0">
|
||
<Input
|
||
placeholder="Search..."
|
||
icon={SearchLg}
|
||
size="sm"
|
||
value={search}
|
||
onChange={handleSearch}
|
||
aria-label="Search worklist"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Missed call status sub-tabs */}
|
||
{tab === 'missed' && (
|
||
<div className="flex shrink-0 gap-1 px-5 py-2 border-b border-secondary">
|
||
{(['pending', 'attempted', 'completed', 'invalid'] as MissedSubTab[]).map(sub => (
|
||
<button
|
||
key={sub}
|
||
onClick={() => { setMissedSubTab(sub); setPage(1); }}
|
||
className={cx(
|
||
'px-3 py-1 text-xs font-medium rounded-md capitalize transition duration-100 ease-linear',
|
||
missedSubTab === sub
|
||
? 'bg-brand-50 text-brand-700 border border-brand-200'
|
||
: 'text-tertiary hover:text-secondary hover:bg-secondary',
|
||
)}
|
||
>
|
||
{sub}
|
||
{sub === 'pending' && missedByStatus.pending.length > 0 && (
|
||
<span className="ml-1.5 bg-error-50 text-error-700 text-xs px-1.5 py-0.5 rounded-full">
|
||
{missedByStatus.pending.length}
|
||
</span>
|
||
)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{filteredRows.length === 0 ? (
|
||
<div className="flex items-center justify-center py-12">
|
||
<p className="text-sm text-quaternary">
|
||
{search ? 'No matching items' : 'No items in this category'}
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<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="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" />
|
||
<Table.Head id="sla" label="SLA" className="w-24" allowsSorting />
|
||
</Table.Header>
|
||
<Table.Body items={pagedRows}>
|
||
{(row) => {
|
||
const priority = priorityConfig[row.priority] ?? priorityConfig.NORMAL;
|
||
const sla = computeSla(row.lastContactedAt ?? row.createdAt);
|
||
const isSelected = row.originalLead !== null && row.originalLead.id === selectedLeadId;
|
||
|
||
// Sub-line: last interaction context
|
||
const subLine = row.lastContactedAt
|
||
? `${formatTimeAgo(row.lastContactedAt)}${row.lastDisposition ? ` — ${formatDisposition(row.lastDisposition)}` : ''}`
|
||
: row.reason || row.typeLabel;
|
||
|
||
return (
|
||
<Table.Row
|
||
id={row.id}
|
||
className={cx(
|
||
'cursor-pointer group/row',
|
||
isSelected && 'bg-brand-primary',
|
||
)}
|
||
onAction={() => {
|
||
if (row.originalLead) onSelectLead(row.originalLead);
|
||
}}
|
||
>
|
||
<Table.Cell>
|
||
{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">
|
||
{row.direction === 'inbound' && (
|
||
<IconInbound className="size-3.5 text-fg-success-secondary shrink-0" />
|
||
)}
|
||
{row.direction === 'outbound' && (
|
||
<IconOutbound className="size-3.5 text-fg-brand-secondary shrink-0" />
|
||
)}
|
||
<div className="min-w-0">
|
||
<span className="text-sm font-medium text-primary truncate block max-w-[180px]">
|
||
{row.name}
|
||
</span>
|
||
<span className="text-xs text-tertiary truncate block max-w-[200px]">
|
||
{subLine}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</Table.Cell>
|
||
<Table.Cell>
|
||
{row.phoneRaw ? (
|
||
<PhoneActionCell
|
||
phoneNumber={row.phoneRaw}
|
||
displayNumber={row.phone}
|
||
leadId={row.leadId ?? undefined}
|
||
onDial={row.missedCallId ? () => onDialMissedCall?.(row.missedCallId!) : undefined}
|
||
/>
|
||
) : (
|
||
<span className="text-xs text-quaternary italic">No phone</span>
|
||
)}
|
||
</Table.Cell>
|
||
<Table.Cell>
|
||
{row.source ? (
|
||
<span className="text-xs text-tertiary truncate block max-w-[100px]">
|
||
{formatSource(row.source)}
|
||
</span>
|
||
) : (
|
||
<span className="text-xs text-quaternary">—</span>
|
||
)}
|
||
</Table.Cell>
|
||
<Table.Cell>
|
||
<Badge size="sm" color={sla.color} type="pill-color">
|
||
{sla.label}
|
||
</Badge>
|
||
</Table.Cell>
|
||
</Table.Row>
|
||
);
|
||
}}
|
||
</Table.Body>
|
||
</Table>
|
||
{totalPages > 1 && (
|
||
<div className="flex shrink-0 items-center justify-between border-t border-secondary px-5 py-3">
|
||
<span className="text-xs text-tertiary">
|
||
Showing {(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, filteredRows.length)} of {filteredRows.length}
|
||
</span>
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
onClick={() => setPage(Math.max(1, page - 1))}
|
||
disabled={page === 1}
|
||
className="px-2 py-1 text-xs font-medium text-secondary rounded-md hover:bg-primary_hover disabled:text-disabled disabled:cursor-not-allowed transition duration-100 ease-linear"
|
||
>
|
||
Previous
|
||
</button>
|
||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
|
||
<button
|
||
key={p}
|
||
onClick={() => setPage(p)}
|
||
className={cx(
|
||
"size-8 text-xs font-medium rounded-lg transition duration-100 ease-linear",
|
||
p === page ? "bg-active text-brand-secondary" : "text-tertiary hover:bg-primary_hover",
|
||
)}
|
||
>
|
||
{p}
|
||
</button>
|
||
))}
|
||
<button
|
||
onClick={() => setPage(Math.min(totalPages, page + 1))}
|
||
disabled={page === totalPages}
|
||
className="px-2 py-1 text-xs font-medium text-secondary rounded-md hover:bg-primary_hover disabled:text-disabled disabled:cursor-not-allowed transition duration-100 ease-linear"
|
||
>
|
||
Next
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export type { WorklistLead };
|