mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- PhoneActionCell: kebab always visible (SMS + WhatsApp), Call removed from menu, phone number always brand-colored regardless of telephony state - LeadTable: replaced actions kebab column with eye icon (first column) for view activity, phone column now uses PhoneActionCell - Worklist: React Aria Tabs replaced with custom pill buttons matching All Leads pattern (bg-brand-solid on selected), search lifted to call-desk.tsx header - Appointments: underline tabs replaced with custom pills, phone in patient cell uses PhoneActionCell, group/row added to rows - Patients: removed redundant HamburgerMenu column, group/row on rows - Call Desk: search input in header row, cleaned up duplicate imports Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
335 lines
18 KiB
TypeScript
335 lines
18 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
import { faSidebarFlip, faSidebar, faPhone, faXmark, faDeleteLeft, faFlask, faMagnifyingGlass, faCircleInfo } from '@fortawesome/pro-duotone-svg-icons';
|
|
import { useSetAtom } from 'jotai';
|
|
import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom, sipCallDurationAtom } from '@/state/sip-state';
|
|
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 { WorklistSelection } from '@/components/call-desk/worklist-panel';
|
|
import { ContextPanel } from '@/components/call-desk/context-panel';
|
|
import type { ContextPanelSubject } from '@/components/call-desk/context-panel';
|
|
import { ActiveCallCard } from '@/components/call-desk/active-call-card';
|
|
import { Input } from '@/components/base/input/input';
|
|
import { faIcon } from '@/lib/icon-wrapper';
|
|
|
|
import { apiClient } from '@/lib/api-client';
|
|
import { notify } from '@/lib/toast';
|
|
import { cx } from '@/utils/cx';
|
|
|
|
const SearchLg = faIcon(faMagnifyingGlass);
|
|
|
|
export const CallDeskPage = () => {
|
|
const { user } = useAuth();
|
|
const { leadActivities, calls, followUps: dataFollowUps, patients, appointments } = useData();
|
|
const { callState, callerNumber, callUcid, dialOutbound, isRegistered } = useSip();
|
|
const { missedCalls, followUps, marketingLeads, loading } = useWorklist();
|
|
const [selectedLead, setSelectedLead] = useState<ContextPanelSubject | null>(null);
|
|
const [selectedItemId, setSelectedItemId] = useState<string | 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 [search, setSearch] = useState('');
|
|
|
|
// DEV: simulate incoming call
|
|
const setSimCallState = useSetAtom(sipCallStateAtom);
|
|
const setSimCallerNumber = useSetAtom(sipCallerNumberAtom);
|
|
const setSimCallUcid = useSetAtom(sipCallUcidAtom);
|
|
const setSimDuration = useSetAtom(sipCallDurationAtom);
|
|
const simTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
|
|
const startSimCall = useCallback(() => {
|
|
setSimCallerNumber('+919959966676');
|
|
setSimCallUcid(`SIM-${Date.now()}`);
|
|
setSimDuration(0);
|
|
setSimCallState('active');
|
|
simTimerRef.current = setInterval(() => setSimDuration((d) => d + 1), 1000);
|
|
}, [setSimCallState, setSimCallerNumber, setSimCallUcid, setSimDuration]);
|
|
|
|
const endSimCall = useCallback(() => {
|
|
if (simTimerRef.current) { clearInterval(simTimerRef.current); simTimerRef.current = null; }
|
|
setSimCallState('idle');
|
|
setSimCallerNumber(null);
|
|
setSimCallUcid(null);
|
|
setSimDuration(0);
|
|
}, [setSimCallState, setSimCallerNumber, setSimCallUcid, setSimDuration]);
|
|
|
|
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', 'No existing records found for this number');
|
|
}
|
|
})
|
|
.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.
|
|
// The resolver is the authoritative source for patientId (it just joined
|
|
// lead↔patient by phone), so overlay it on top of any worklist row that
|
|
// pre-dates the linkage. Without this, the Book Appt pills can't find
|
|
// a returning caller's prior appointments because the frontend loses
|
|
// sight of which patient they are.
|
|
const workLead = resolvedCaller ? marketingLeads.find((l) => l.id === resolvedCaller.leadId) : null;
|
|
const callerLead = resolvedCaller
|
|
? workLead
|
|
? { ...workLead, patientId: (workLead as any).patientId ?? resolvedCaller.patientId }
|
|
: {
|
|
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;
|
|
|
|
// Handle selection from any worklist row type. Leads use the lead
|
|
// object directly; missed calls and follow-ups build a synthetic
|
|
// lead-like object from their phone/patientId so the P360 context
|
|
// panel can render for any row type.
|
|
const handleSelectItem = useCallback((selection: WorklistSelection) => {
|
|
setSelectedItemId(selection.rowId);
|
|
|
|
if (selection.lead) {
|
|
// Lead row — use the full lead object as before
|
|
setSelectedLead(selection.lead);
|
|
return;
|
|
}
|
|
|
|
// Non-lead row (missed call, follow-up, callback) — build a
|
|
// ContextPanelSubject from the row's available data. The panel
|
|
// uses contactPhone for call-history matching and patientId for
|
|
// appointment/follow-up lookups. No type cast needed — the
|
|
// ContextPanelSubject type accepts these optional fields.
|
|
const phone = selection.phoneRaw ? selection.phoneRaw.replace(/\D/g, '').slice(-10) : '';
|
|
const subject: ContextPanelSubject = {
|
|
id: selection.leadId ?? selection.rowId,
|
|
contactName: { firstName: selection.name.split(' ')[0] || '', lastName: selection.name.split(' ').slice(1).join(' ') || '' },
|
|
contactPhone: phone ? [{ number: phone, callingCode: '+91' }] : [],
|
|
patientId: selection.patientId,
|
|
};
|
|
setSelectedLead(subject);
|
|
}, []);
|
|
|
|
return (
|
|
<div className="flex flex-1 flex-col overflow-hidden">
|
|
{/* Header — matches PageHeader visual pattern */}
|
|
<div className="flex shrink-0 items-center justify-between px-6 py-3">
|
|
<div className="flex items-center gap-2">
|
|
<h1 className="text-lg font-bold text-primary">Call Desk</h1>
|
|
<span className="text-sm text-tertiary ml-1">{user.name}</span>
|
|
<span className="flex size-5 items-center justify-center text-fg-quaternary" title="Your active worklist — missed calls, leads, and follow-ups prioritised by SLA.">
|
|
<FontAwesomeIcon icon={faCircleInfo} className="size-4" />
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{!isInCall && (
|
|
<div className="w-52">
|
|
<Input
|
|
placeholder="Search worklist..."
|
|
icon={SearchLg}
|
|
size="sm"
|
|
value={search}
|
|
onChange={setSearch}
|
|
aria-label="Search worklist"
|
|
/>
|
|
</div>
|
|
)}
|
|
{import.meta.env.DEV && (!isInCall ? (
|
|
<button
|
|
onClick={startSimCall}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-warning-secondary text-warning-primary hover:bg-warning-primary hover:text-white transition duration-100 ease-linear"
|
|
>
|
|
<FontAwesomeIcon icon={faFlask} className="size-3" />
|
|
Sim Call
|
|
</button>
|
|
) : callUcid?.startsWith('SIM-') && (
|
|
<button
|
|
onClick={endSimCall}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-error-solid text-white hover:bg-error-solid_hover transition duration-100 ease-linear"
|
|
>
|
|
<FontAwesomeIcon icon={faFlask} className="size-3" />
|
|
End Sim
|
|
</button>
|
|
))}
|
|
{!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={!isRegistered || 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...' : !isRegistered ? 'Telephony unavailable' : 'Call'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
<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="flex flex-col flex-1 min-h-0 overflow-hidden 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}
|
|
onSelectItem={handleSelectItem}
|
|
selectedItemId={selectedItemId}
|
|
onDialMissedCall={(id) => setActiveMissedCallId(id)}
|
|
search={search}
|
|
/>
|
|
)}
|
|
</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>
|
|
);
|
|
};
|