Files
helix-engage/src/pages/call-desk.tsx
saridsa2 c3c3f4b3d7 feat: worklist sorting, contextual disposition, context panel redesign, notifications
- Worklist default sort descending (newest first), sortable column headers (PRIORITY, PATIENT, SLA) via React Aria
- Contextual disposition: auto-selects based on in-call actions (appointment → APPOINTMENT_BOOKED, enquiry → INFO_PROVIDED, transfer → FOLLOW_UP_SCHEDULED)
- Context panel redesign: collapsible AI Insight, Upcoming (appointments + follow-ups + linked patient), Recent (calls + activities) sections; auto-collapse on AI chat start
- Appointments added to DataProvider with APPOINTMENTS_QUERY, Appointment type, transform
- Notification bell for admin/supervisor: performance alerts (idle time, NPS, conversion thresholds) with toast on load + bell dropdown with dismiss; demo alerts as fallback
- Slideout z-index fix: added z-50 to slideout ModalOverlay matching modal component

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:45:52 +05:30

238 lines
13 KiB
TypeScript

import { useState, useEffect, useRef } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSidebarFlip, faSidebar, faPhone, faXmark, faDeleteLeft } from '@fortawesome/pro-duotone-svg-icons';
import { useAuth } from '@/providers/auth-provider';
import { useData } from '@/providers/data-provider';
import { useWorklist } from '@/hooks/use-worklist';
import { useSip } from '@/providers/sip-provider';
import { WorklistPanel } from '@/components/call-desk/worklist-panel';
import type { WorklistLead } from '@/components/call-desk/worklist-panel';
import { ContextPanel } from '@/components/call-desk/context-panel';
import { ActiveCallCard } from '@/components/call-desk/active-call-card';
import { Badge } from '@/components/base/badges/badges';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
import { cx } from '@/utils/cx';
export const CallDeskPage = () => {
const { user } = useAuth();
const { leadActivities, calls, followUps: dataFollowUps, patients, appointments } = useData();
const { callState, callerNumber, callUcid, dialOutbound } = useSip();
const { missedCalls, followUps, marketingLeads, totalPending, loading } = useWorklist();
const [selectedLead, setSelectedLead] = useState<WorklistLead | null>(null);
const [contextOpen, setContextOpen] = useState(true);
const [activeMissedCallId, setActiveMissedCallId] = useState<string | null>(null);
const [callDismissed, setCallDismissed] = useState(false);
const [diallerOpen, setDiallerOpen] = useState(false);
const [dialNumber, setDialNumber] = useState('');
const [dialling, setDialling] = useState(false);
const handleDial = async () => {
const num = dialNumber.replace(/[^0-9]/g, '');
if (num.length < 10) { notify.error('Enter a valid phone number'); return; }
setDialling(true);
try {
await dialOutbound(num);
setDiallerOpen(false);
setDialNumber('');
} catch {
notify.error('Dial failed');
} finally {
setDialling(false);
}
};
// Reset callDismissed when a new call starts (ringing in or out)
if (callDismissed && (callState === 'ringing-in' || callState === 'ringing-out')) {
setCallDismissed(false);
}
const isInCall = !callDismissed && (callState === 'ringing-in' || callState === 'ringing-out' || callState === 'active' || callState === 'ended' || callState === 'failed');
// Resolve caller identity via sidecar (lookup-or-create lead+patient pair)
const [resolvedCaller, setResolvedCaller] = useState<{
leadId: string; patientId: string; firstName: string; lastName: string; phone: string;
} | null>(null);
const resolveAttemptedRef = useRef<string | null>(null);
useEffect(() => {
if (!callerNumber || !isInCall) return;
if (resolveAttemptedRef.current === callerNumber) return; // already resolving/resolved this number
resolveAttemptedRef.current = callerNumber;
apiClient.post<{
leadId: string; patientId: string; firstName: string; lastName: string; phone: string; isNew: boolean;
}>('/api/caller/resolve', { phone: callerNumber }, { silent: true })
.then((result) => {
setResolvedCaller(result);
if (result.isNew) {
notify.info('New Caller', 'Lead and patient records created');
}
})
.catch((err) => {
console.warn('[RESOLVE] Caller resolution failed:', err);
resolveAttemptedRef.current = null; // allow retry
});
}, [callerNumber, isInCall]);
// Reset resolved caller when call ends
useEffect(() => {
if (!isInCall) {
setResolvedCaller(null);
resolveAttemptedRef.current = null;
}
}, [isInCall]);
// Build activeLead from resolved caller or fallback to client-side match
const callerLead = resolvedCaller
? marketingLeads.find((l) => l.id === resolvedCaller.leadId) ?? {
id: resolvedCaller.leadId,
contactName: { firstName: resolvedCaller.firstName, lastName: resolvedCaller.lastName },
contactPhone: [{ number: resolvedCaller.phone, callingCode: '+91' }],
patientId: resolvedCaller.patientId,
}
: callerNumber
? marketingLeads.find((l) => l.contactPhone?.[0]?.number?.endsWith(callerNumber) || callerNumber.endsWith(l.contactPhone?.[0]?.number ?? '---'))
: null;
// For inbound calls, use resolved/matched lead. For outbound, use selectedLead.
const activeLead = isInCall
? (callerLead ?? (callState === 'ringing-out' ? selectedLead : null))
: selectedLead;
const activeLeadFull = activeLead as any;
return (
<div className="flex flex-1 flex-col overflow-hidden">
{/* Compact header: title + name on left, status + toggle on right */}
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-3">
<div className="flex items-center gap-3">
<h1 className="text-lg font-bold text-primary">Call Desk</h1>
<span className="text-sm text-tertiary">{user.name}</span>
</div>
<div className="flex items-center gap-2">
{!isInCall && (
<div className="relative">
<button
onClick={() => setDiallerOpen(!diallerOpen)}
className={cx(
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition duration-100 ease-linear',
diallerOpen
? 'bg-brand-solid text-white'
: 'bg-secondary text-secondary hover:bg-secondary_hover',
)}
>
<FontAwesomeIcon icon={faPhone} className="size-3" />
Dial
</button>
{diallerOpen && (
<div className="absolute top-full right-0 mt-2 w-72 rounded-xl bg-primary shadow-xl ring-1 ring-secondary p-4 z-50">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-semibold text-primary">Dial</span>
<button onClick={() => setDiallerOpen(false)} className="text-fg-quaternary hover:text-fg-secondary">
<FontAwesomeIcon icon={faXmark} className="size-4" />
</button>
</div>
<div className="flex items-center gap-2 mb-3 px-3 py-2.5 rounded-lg bg-secondary min-h-[40px]">
<input
type="tel"
value={dialNumber}
onChange={e => setDialNumber(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleDial()}
placeholder="Enter number"
autoFocus
className="flex-1 bg-transparent text-lg font-semibold text-primary tracking-wider text-center placeholder:text-placeholder placeholder:font-normal placeholder:text-sm outline-none"
/>
{dialNumber && (
<button onClick={() => setDialNumber(dialNumber.slice(0, -1))} className="text-fg-quaternary hover:text-fg-secondary shrink-0">
<FontAwesomeIcon icon={faDeleteLeft} className="size-4" />
</button>
)}
</div>
<div className="grid grid-cols-3 gap-1.5 mb-3">
{['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#'].map(key => (
<button
key={key}
onClick={() => setDialNumber(prev => prev + key)}
className="flex items-center justify-center h-11 rounded-lg text-sm font-semibold text-primary bg-primary hover:bg-secondary border border-secondary transition duration-100 ease-linear active:scale-95"
>
{key}
</button>
))}
</div>
<button
onClick={handleDial}
disabled={dialling || dialNumber.replace(/[^0-9]/g, '').length < 10}
className="w-full flex items-center justify-center gap-2 rounded-lg bg-success-solid py-2.5 text-sm font-medium text-white hover:opacity-90 disabled:bg-disabled disabled:cursor-not-allowed transition duration-100 ease-linear"
>
<FontAwesomeIcon icon={faPhone} className="size-3.5" />
{dialling ? 'Dialling...' : 'Call'}
</button>
</div>
)}
</div>
)}
{totalPending > 0 && (
<Badge size="sm" color="brand" type="pill-color">{totalPending} pending</Badge>
)}
<button
onClick={() => setContextOpen(!contextOpen)}
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
title={contextOpen ? 'Hide AI panel' : 'Show AI panel'}
>
<FontAwesomeIcon icon={contextOpen ? faSidebarFlip : faSidebar} className="size-4" />
</button>
</div>
</div>
{/* Main content */}
<div className="flex flex-1 overflow-hidden">
{/* Main panel */}
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
{/* Active call */}
{isInCall && (
<div className="p-5">
<ActiveCallCard lead={activeLeadFull} callerPhone={callerNumber ?? ''} missedCallId={activeMissedCallId} onCallComplete={() => { setActiveMissedCallId(null); setCallDismissed(true); }} />
</div>
)}
{/* Worklist — no wrapper, tabs + table fill the space */}
{!isInCall && (
<WorklistPanel
missedCalls={missedCalls}
followUps={followUps}
leads={marketingLeads}
loading={loading}
onSelectLead={(lead) => setSelectedLead(lead)}
selectedLeadId={selectedLead?.id ?? null}
onDialMissedCall={(id) => setActiveMissedCallId(id)}
/>
)}
</div>
{/* Context panel — collapsible with smooth transition */}
<div className={cx(
"shrink-0 border-l border-secondary bg-primary flex flex-col overflow-hidden transition-all duration-200 ease-linear",
contextOpen ? "w-[400px]" : "w-0 border-l-0",
)}>
{contextOpen && (
<ContextPanel
selectedLead={activeLeadFull}
activities={leadActivities}
calls={calls}
followUps={dataFollowUps}
appointments={appointments}
patients={patients}
callerPhone={callerNumber ?? undefined}
isInCall={isInCall}
callUcid={callUcid}
/>
)}
</div>
</div>
</div>
);
};