mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-14 12:12:23 +00:00
feat(frontend): barge controls component — connect, mode tabs, hangup
BargeControls component with 4 states: idle → connecting → connected → ended. Connected state shows Listen/Whisper/Barge mode tabs (DTMF 4/5/6), live duration counter, hang up button. Auto-cleanup on unmount. Mode changes notify sidecar for agent SSE events. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
225
src/components/call-desk/barge-controls.tsx
Normal file
225
src/components/call-desk/barge-controls.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faPhoneHangup, faHeadset, faCommentDots, faUsers } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { faIcon } from '@/lib/icon-wrapper';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { supervisorSip } from '@/lib/supervisor-sip-client';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
const HangupIcon = faIcon(faPhoneHangup);
|
||||
const HeadsetIcon = faIcon(faHeadset);
|
||||
|
||||
type BargeStatus = 'idle' | 'connecting' | 'connected' | 'ended';
|
||||
type BargeMode = 'listen' | 'whisper' | 'barge';
|
||||
|
||||
const MODE_DTMF: Record<BargeMode, string> = { listen: '4', whisper: '5', barge: '6' };
|
||||
|
||||
const MODE_CONFIG: Record<BargeMode, {
|
||||
label: string;
|
||||
description: string;
|
||||
icon: any;
|
||||
activeClass: string;
|
||||
}> = {
|
||||
listen: {
|
||||
label: 'Listen',
|
||||
description: 'Silent monitoring — nobody knows you are here',
|
||||
icon: faHeadset,
|
||||
activeClass: 'border-secondary bg-secondary',
|
||||
},
|
||||
whisper: {
|
||||
label: 'Whisper',
|
||||
description: 'Only the agent can hear you',
|
||||
icon: faCommentDots,
|
||||
activeClass: 'border-brand bg-brand-primary',
|
||||
},
|
||||
barge: {
|
||||
label: 'Barge',
|
||||
description: 'Both agent and patient can hear you',
|
||||
icon: faUsers,
|
||||
activeClass: 'border-error bg-error-primary',
|
||||
},
|
||||
};
|
||||
|
||||
type BargeControlsProps = {
|
||||
ucid: string;
|
||||
agentId: string;
|
||||
agentNumber: string;
|
||||
agentName: string;
|
||||
onDisconnected?: () => void;
|
||||
};
|
||||
|
||||
export const BargeControls = ({ ucid, agentId, agentNumber, agentName, onDisconnected }: BargeControlsProps) => {
|
||||
const [status, setStatus] = useState<BargeStatus>('idle');
|
||||
const [mode, setMode] = useState<BargeMode>('listen');
|
||||
const [duration, setDuration] = useState(0);
|
||||
const connectedAtRef = useRef<number | null>(null);
|
||||
|
||||
// Duration counter
|
||||
useEffect(() => {
|
||||
if (status !== 'connected') return;
|
||||
connectedAtRef.current = Date.now();
|
||||
const interval = setInterval(() => {
|
||||
setDuration(Math.floor((Date.now() - (connectedAtRef.current ?? Date.now())) / 1000));
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [status]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (supervisorSip.isCallActive()) {
|
||||
supervisorSip.close();
|
||||
apiClient.post('/api/supervisor/barge/end', { agentId }, { silent: true }).catch(() => {});
|
||||
}
|
||||
};
|
||||
}, [agentId]);
|
||||
|
||||
const handleConnect = async () => {
|
||||
setStatus('connecting');
|
||||
setMode('listen');
|
||||
setDuration(0);
|
||||
|
||||
try {
|
||||
const result = await apiClient.post<{
|
||||
sipNumber: string;
|
||||
sipPassword: string;
|
||||
sipDomain: string;
|
||||
sipPort: string;
|
||||
}>('/api/supervisor/barge', { ucid, agentId, agentNumber });
|
||||
|
||||
supervisorSip.on('registered', () => {
|
||||
// Ozonetel will send incoming call after SIP registration
|
||||
});
|
||||
|
||||
supervisorSip.on('callConnected', () => {
|
||||
setStatus('connected');
|
||||
supervisorSip.sendDTMF('4'); // default: listen mode
|
||||
notify.success('Connected', `Monitoring ${agentName}'s call`);
|
||||
});
|
||||
|
||||
supervisorSip.on('callEnded', () => {
|
||||
setStatus('ended');
|
||||
apiClient.post('/api/supervisor/barge/end', { agentId }, { silent: true }).catch(() => {});
|
||||
onDisconnected?.();
|
||||
});
|
||||
|
||||
supervisorSip.on('callFailed', (cause: string) => {
|
||||
setStatus('ended');
|
||||
notify.error('Connection Failed', cause ?? 'Could not connect to call');
|
||||
apiClient.post('/api/supervisor/barge/end', { agentId }, { silent: true }).catch(() => {});
|
||||
});
|
||||
|
||||
supervisorSip.on('registrationFailed', (cause: string) => {
|
||||
setStatus('ended');
|
||||
notify.error('SIP Registration Failed', cause ?? 'Could not register');
|
||||
});
|
||||
|
||||
supervisorSip.init({
|
||||
domain: result.sipDomain,
|
||||
port: result.sipPort,
|
||||
number: result.sipNumber,
|
||||
password: result.sipPassword,
|
||||
});
|
||||
supervisorSip.register();
|
||||
} catch (err: any) {
|
||||
setStatus('idle');
|
||||
notify.error('Barge Failed', err.message ?? 'Could not initiate barge');
|
||||
}
|
||||
};
|
||||
|
||||
const handleModeChange = (newMode: BargeMode) => {
|
||||
if (newMode === mode) return;
|
||||
supervisorSip.sendDTMF(MODE_DTMF[newMode]);
|
||||
setMode(newMode);
|
||||
apiClient.post('/api/supervisor/barge/mode', { agentId, mode: newMode }, { silent: true }).catch(() => {});
|
||||
};
|
||||
|
||||
const handleHangup = () => {
|
||||
supervisorSip.close();
|
||||
setStatus('ended');
|
||||
apiClient.post('/api/supervisor/barge/end', { agentId }, { silent: true }).catch(() => {});
|
||||
onDisconnected?.();
|
||||
};
|
||||
|
||||
const formatDuration = (sec: number) => {
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Idle / ended state
|
||||
if (status === 'idle' || status === 'ended') {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 py-6">
|
||||
<FontAwesomeIcon icon={faHeadset} className="size-8 text-fg-quaternary" />
|
||||
<p className="text-sm text-secondary">{status === 'ended' ? 'Session ended' : 'Ready to monitor'}</p>
|
||||
<Button size="sm" color="primary" iconLeading={HeadsetIcon} onClick={handleConnect}>
|
||||
{status === 'ended' ? 'Reconnect' : 'Connect'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Connecting state
|
||||
if (status === 'connecting') {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 py-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="size-2 animate-pulse rounded-full bg-warning-solid" />
|
||||
<span className="text-sm font-medium text-warning-primary">Connecting...</span>
|
||||
</div>
|
||||
<p className="text-xs text-tertiary">Registering SIP and joining call</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Connected state
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Status bar */}
|
||||
<div className="flex items-center justify-between rounded-lg bg-success-primary px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="size-2 rounded-full bg-success-solid" />
|
||||
<span className="text-xs font-semibold text-success-primary">Connected</span>
|
||||
</div>
|
||||
<span className="font-mono text-xs text-success-primary">{formatDuration(duration)}</span>
|
||||
</div>
|
||||
|
||||
{/* Mode tabs */}
|
||||
<div className="flex gap-1">
|
||||
{(['listen', 'whisper', 'barge'] as BargeMode[]).map((m) => {
|
||||
const config = MODE_CONFIG[m];
|
||||
const isActive = mode === m;
|
||||
return (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => handleModeChange(m)}
|
||||
className={cx(
|
||||
'flex flex-1 flex-col items-center gap-1 rounded-lg border-2 px-2 py-2.5 text-center transition duration-100 ease-linear',
|
||||
isActive ? config.activeClass : 'border-secondary hover:bg-primary_hover',
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={config.icon}
|
||||
className={cx('size-4', isActive ? 'text-fg-primary' : 'text-fg-quaternary')}
|
||||
/>
|
||||
<span className={cx('text-xs font-semibold', isActive ? 'text-primary' : 'text-tertiary')}>
|
||||
{config.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Mode description */}
|
||||
<p className="text-center text-xs text-tertiary">{MODE_CONFIG[mode].description}</p>
|
||||
|
||||
{/* Hang up */}
|
||||
<Button size="sm" color="primary-destructive" iconLeading={HangupIcon} onClick={handleHangup} className="w-full">
|
||||
Hang Up
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user