Files
helix-engage/src/components/call-desk/worklist-panel.tsx
saridsa2 ca482e731e feat: Contacts page + P360 for all tabs + dynamic column toggle + slot flicker fix
Contacts page:
 - New /contacts route — shows leads with source=PHONE/WALK_IN/REFERRAL
 - Leads page now excludes those sources (campaign-sourced only)
 - Sidebar: Contacts nav item added for all roles; Leads added for cc-agent
 - Same LeadTable + pagination + CSV export pattern as All Leads

P360 context panel for all worklist tabs (#6-10):
 - WorklistPanel: onSelectLead → onSelectItem (generic WorklistSelection)
 - call-desk: handleSelectItem builds ContextPanelSubject for any row type
 - ContextPanelSubject type replaces (lead as any).patientId casts
 - Highlight tracks row.id (mc-*/fu-*/lead-*) not lead.id

Dynamic column toggle (blank-screen fix):
 - missed-calls + call-recordings refactored to React Aria dynamic
   collections API (Table.Header columns={} + Table.Row columns={})
 - Fixes "Cell count must match column count" crash on column hide
 - Row-header column metadata in columnDefs instead of hardcoded JSX

Slot flickering fix (#2):
 - Removed clinic + timeSlot from slot-fetch useEffect deps (circular
   loop: effect sets clinic → clinic in deps → re-fires)
 - Memoized timeSlotSelectItems

Other:
 - GlobalSearch hidden (stale appointment state on navigation)
 - Branch column: shows campaign name from relation, falls back to DID
 - formatSource maps PHONE → "Phone"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 16:55:57 +05:30

642 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
patientId?: string | null;
patientName?: string;
patientPhone?: string;
};
type MissedCall = {
id: string;
createdAt: string;
callDirection: string | null;
callerNumber: { number: string; callingCode: string }[] | null;
startedAt: string | null;
leadId: string | null;
leadName: string | null;
disposition: string | null;
callbackStatus: string | null;
callSourceNumber: string | null;
missedCallCount: number | null;
callbackAttemptedAt: string | null;
campaign?: { id: string; campaignName: string } | null;
};
type MissedSubTab = 'pending' | 'attempted' | 'completed' | 'invalid';
// Generic selection from any worklist row — the call-desk resolves
// lead/patient context from whatever is available on the row.
export type WorklistSelection = {
rowId: string;
type: 'missed' | 'callback' | 'follow-up' | 'lead';
lead: WorklistLead | null;
phoneRaw: string | null;
patientId: string | null;
leadId: string | null;
name: string;
};
interface WorklistPanelProps {
missedCalls: MissedCall[];
followUps: WorklistFollowUp[];
leads: WorklistLead[];
loading: boolean;
onSelectItem: (selection: WorklistSelection) => void;
selectedItemId: 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;
patientId: 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',
};
// SLA for reactive work — missed calls / unanswered leads. Measures time
// elapsed since the trigger: longer wait = worse SLA.
const computeReactiveSla = (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' };
};
// SLA for scheduled work — follow-ups / callbacks. Measures time remaining
// until the scheduled slot. Green when comfortably ahead, warning when
// due soon, error when overdue.
const computeScheduledSla = (scheduledAt: string): { label: string; color: 'success' | 'warning' | 'error' } => {
const minutes = Math.round((new Date(scheduledAt).getTime() - Date.now()) / 60000);
if (minutes < 0) {
const overdueMins = -minutes;
if (overdueMins < 60) return { label: `Overdue ${overdueMins}m`, color: 'error' };
const overdueHrs = Math.floor(overdueMins / 60);
if (overdueHrs < 24) return { label: `Overdue ${overdueHrs}h`, color: 'error' };
return { label: `Overdue ${Math.floor(overdueHrs / 24)}d`, color: 'error' };
}
if (minutes < 60) return { label: `Due in ${minutes}m`, color: 'warning' };
const hours = Math.floor(minutes / 60);
if (hours < 24) return { label: `Due in ${hours}h`, color: hours < 4 ? 'warning' : 'success' };
return { label: `Due in ${Math.floor(hours / 24)}d`, color: 'success' };
};
const computeSla = (
row: Pick<WorklistRow, 'type' | 'lastContactedAt' | 'createdAt'>,
): { label: string; color: 'success' | 'warning' | 'error' } => {
if (row.type === 'follow-up' || row.type === 'callback') {
// scheduledAt was written into lastContactedAt during row construction.
return computeScheduledSla(row.lastContactedAt ?? row.createdAt);
}
return computeReactiveSla(row.lastContactedAt ?? row.createdAt);
};
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',
PHONE: 'Phone',
OTHER: 'Other',
};
return map[source] ?? source.replace(/_/g, ' ');
};
// Resolve a DID (e.g. "918041763265") to a friendly branch/campaign name.
// The DID is the phone number the caller dialed — it identifies which
// hospital branch or campaign the call came through. Falls back to the
// last 10 digits if no mapping is found.
const formatDid = (did: string): string => {
if (!did) return '—';
// Known DIDs — loaded from the sidecar theme tokens at boot (via
// the ThemeTokenProvider). For now, hardcode nothing — strip country
// code and show the DID as a short number so it's at least readable.
const digits = did.replace(/\D/g, '');
// Strip leading 91 (India) for display
const short = digits.length > 10 && digits.startsWith('91') ? digits.slice(2) : digits;
return short;
};
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: (call.leadName || (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,
patientId: (call as any).patientId ?? null,
originalLead: null,
lastContactedAt: call.callbackAttemptedAt ?? call.startedAt ?? call.createdAt,
contactAttempts: 0,
// Branch column: prefer the campaign name (e.g. "Cervical Cancer
// Screening Drive") over the raw DID. Falls back to formatted DID
// for organic calls with no campaign.
source: call.campaign?.campaignName ?? (call.callSourceNumber ? formatDid(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';
// Sidecar enriches follow-ups with patient name/phone when a
// patientId is linked. Fall back to the generic type label when
// no patient is attached.
const displayName = fu.patientName?.trim() || label;
const phoneFormatted = fu.patientPhone
? formatPhone({ number: fu.patientPhone, callingCode: '+91' })
: '';
rows.push({
id: `fu-${fu.id}`,
type: fu.followUpType === 'CALLBACK' ? 'callback' : 'follow-up',
priority: (fu.priority as WorklistRow['priority']) ?? (isOverdue ? 'HIGH' : 'NORMAL'),
name: displayName,
phone: phoneFormatted,
phoneRaw: fu.patientPhone ?? '',
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,
patientId: fu.patientId ?? 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,
patientId: (lead as any).patientId ?? null,
originalLead: lead,
lastContactedAt: lead.lastContacted ?? null,
contactAttempts: lead.contactAttempts ?? 0,
source: lead.leadSource ?? lead.utmCampaign ?? null,
lastDisposition: null,
missedCallId: null,
});
}
// Keep all rows — follow-ups may have no phone and still need to be visible.
// The PhoneActionCell renders a "No phone" placeholder when phoneRaw is empty.
const actionableRows = rows;
// 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, onSelectItem, selectedItemId, onDialMissedCall }: WorklistPanelProps) => {
const [tab, setTab] = useState<TabKey>('all');
const [search, setSearch] = useState('');
// Missed tab always shows PENDING callbacks. Attempted/Completed/Invalid
// sub-tabs were removed per QA feedback — pending callbacks are the only
// ones agents need to act on from the worklist.
const missedSubTab: MissedSubTab = 'pending';
// Default SLA sort is ascending — the bucket-sorted result puts the
// most-urgent rows at the top (overdue → oldest reactive → soonest due).
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ column: 'sla', direction: 'ascending' });
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' || r.type === 'callback');
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': {
// Mixed SLA sort: SLA means different things by row type
// (elapsed for reactive, remaining for scheduled). Bucket
// rows by urgency, then sort within bucket — Overdue
// first, then reactive (oldest-first), then scheduled
// (soonest-due first). `dir` flips the whole ordering
// so the user can still toggle ascending/descending.
const urgencyBucket = (row: WorklistRow): number => {
const isScheduled = row.type === 'follow-up' || row.type === 'callback';
if (isScheduled) {
const t = new Date(row.lastContactedAt ?? row.createdAt).getTime();
return t < Date.now() ? 0 : 2; // 0 = overdue, 2 = upcoming
}
return 1; // reactive (missed / lead)
};
const ba = urgencyBucket(a);
const bb = urgencyBucket(b);
if (ba !== bb) return (ba - bb) * dir;
const ta = new Date(a.lastContactedAt ?? a.createdAt).getTime();
const tb = new Date(b.lastContactedAt ?? b.createdAt).getTime();
// Within a bucket, ascending time = most urgent first
// (oldest overdue, oldest reactive, soonest upcoming).
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' || r.type === 'callback').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 sub-tabs removed per QA feedback — the Missed tab
now only shows pending callbacks. Attempted is redundant once
the worklist is the single source of truth. */}
{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);
const isSelected = row.id === selectedItemId;
// 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={() => {
onSelectItem({
rowId: row.id,
type: row.type,
lead: row.originalLead,
phoneRaw: row.phoneRaw || null,
patientId: row.patientId,
leadId: row.leadId,
name: row.name,
});
}}
>
<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 };