Files
helix-engage/src/components/call-desk/worklist-panel.tsx
saridsa2 dd3b049253 fix: add isRowHeader to all tables — fixes React error #520 in production
All Table.Head components need at least one isRowHeader prop set.
Fixed in: worklist, agent-table, settings, patients, call-history, agent-detail.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 21:09:56 +05:30

414 lines
18 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, useMemo, useState } from 'react';
import type { FC, HTMLAttributes } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faPhoneArrowDown,
faPhoneArrowUp,
} from '@fortawesome/pro-duotone-svg-icons';
import { SearchLg } from '@untitledui/icons';
import { Table } from '@/components/application/table/table';
import { Badge } from '@/components/base/badges/badges';
import { Input } from '@/components/base/input/input';
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
import { ClickToCallButton } from './click-to-call-button';
import { formatPhone } from '@/lib/format';
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;
};
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;
};
interface WorklistPanelProps {
missedCalls: MissedCall[];
followUps: WorklistFollowUp[];
leads: WorklistLead[];
loading: boolean;
onSelectLead: (lead: WorklistLead) => void;
selectedLeadId: string | null;
}
type TabKey = 'all' | 'missed' | 'callbacks' | 'follow-ups';
// Unified row type for the table
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;
};
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',
};
// Compute SLA: minutes since created, color-coded
const computeSla = (createdAt: string): { label: string; color: 'success' | 'warning' | 'error' } => {
const minutes = Math.max(0, Math.round((Date.now() - new Date(createdAt).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 IconInbound: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) => (
<FontAwesomeIcon icon={faPhoneArrowDown} className={className} />
);
const IconOutbound: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) => (
<FontAwesomeIcon icon={faPhoneArrowUp} className={className} />
);
const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], leads: WorklistLead[]): WorklistRow[] => {
const rows: WorklistRow[] = [];
for (const call of missedCalls) {
const phone = call.callerNumber?.[0];
rows.push({
id: `mc-${call.id}`,
type: 'missed',
priority: 'HIGH',
name: phone ? formatPhone(phone) : 'Unknown',
phone: phone ? formatPhone(phone) : '',
phoneRaw: phone?.number ?? '',
direction: call.callDirection === 'OUTBOUND' ? 'outbound' : 'inbound',
typeLabel: 'Missed Call',
reason: call.startedAt
? `Missed at ${new Date(call.startedAt).toLocaleTimeString('en-IN', { hour: 'numeric', minute: '2-digit', hour12: true })}`
: 'Missed call',
createdAt: call.createdAt,
taskState: 'PENDING',
leadId: call.leadId,
originalLead: null,
});
}
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 ${new Date(fu.scheduledAt).toLocaleString('en-IN', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true })}`
: '',
createdAt: fu.createdAt ?? fu.scheduledAt ?? new Date().toISOString(),
taskState: isOverdue ? 'PENDING' : (fu.followUpStatus === 'COMPLETED' ? 'ATTEMPTED' : 'SCHEDULED'),
leadId: null,
originalLead: 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,
});
}
// Sort by priority (urgent first), then by creation time (oldest first)
rows.sort((a, b) => {
const pa = priorityConfig[a.priority]?.sort ?? 2;
const pb = priorityConfig[b.priority]?.sort ?? 2;
if (pa !== pb) return pa - pb;
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
});
return rows;
};
const typeConfig: Record<WorklistRow['type'], { color: 'error' | 'brand' | 'blue-light' | 'gray' }> = {
missed: { color: 'error' },
callback: { color: 'brand' },
'follow-up': { color: 'blue-light' },
lead: { color: 'gray' },
};
export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectLead, selectedLeadId }: WorklistPanelProps) => {
const [tab, setTab] = useState<TabKey>('all');
const [search, setSearch] = useState('');
const allRows = useMemo(
() => buildRows(missedCalls, followUps, leads),
[missedCalls, followUps, leads],
);
const filteredRows = useMemo(() => {
let rows = allRows;
// Tab filter
if (tab === 'missed') rows = rows.filter((r) => r.type === 'missed');
else if (tab === 'callbacks') rows = rows.filter((r) => r.type === 'callback');
else if (tab === 'follow-ups') rows = rows.filter((r) => r.type === 'follow-up');
// Search filter
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),
);
}
return rows;
}, [allRows, tab, search]);
const missedCount = allRows.filter((r) => r.type === 'missed').length;
const callbackCount = allRows.filter((r) => r.type === 'callback').length;
const followUpCount = allRows.filter((r) => r.type === 'follow-up').length;
const PAGE_SIZE = 15;
const [page, setPage] = useState(1);
// Reset page when filters change
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: 'callbacks' as const, label: 'Callbacks', badge: callbackCount > 0 ? String(callbackCount) : 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">
{/* Filter tabs + search — single row */}
<div className="flex 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>
{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="px-2 pt-3">
<Table size="sm">
<Table.Header>
<Table.Head label="PRIORITY" className="w-20" isRowHeader />
<Table.Head label="PATIENT" />
<Table.Head label="PHONE" />
<Table.Head label="TYPE" />
<Table.Head label="SLA" className="w-20" />
<Table.Head label="ACTIONS" className="w-24" />
</Table.Header>
<Table.Body items={pagedRows}>
{(row) => {
const priority = priorityConfig[row.priority] ?? priorityConfig.NORMAL;
const sla = computeSla(row.createdAt);
const typeCfg = typeConfig[row.type];
const isSelected = row.originalLead !== null && row.originalLead.id === selectedLeadId;
return (
<Table.Row
id={row.id}
className={cx(
'cursor-pointer',
isSelected && 'bg-brand-primary',
)}
onAction={() => {
if (row.originalLead) {
onSelectLead(row.originalLead);
}
}}
>
<Table.Cell>
<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" />
)}
{row.direction === 'outbound' && (
<IconOutbound className="size-3.5 text-fg-brand-secondary" />
)}
<span className="text-sm font-medium text-primary truncate max-w-[140px]">
{row.name}
</span>
</div>
</Table.Cell>
<Table.Cell>
<span className="text-sm text-tertiary whitespace-nowrap">
{row.phone || '\u2014'}
</span>
</Table.Cell>
<Table.Cell>
<Badge size="sm" color={typeCfg.color} type="pill-color">
{row.typeLabel}
</Badge>
</Table.Cell>
<Table.Cell>
<Badge size="sm" color={sla.color} type="pill-color">
{sla.label}
</Badge>
</Table.Cell>
<Table.Cell>
<div className="flex items-center gap-1">
{row.phoneRaw ? (
<ClickToCallButton
phoneNumber={row.phoneRaw}
leadId={row.leadId ?? undefined}
size="sm"
/>
) : (
<span className="text-xs text-quaternary">No phone</span>
)}
</div>
</Table.Cell>
</Table.Row>
);
}}
</Table.Body>
</Table>
{totalPages > 1 && (
<div className="flex 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 };