diff --git a/src/components/call-desk/barge-controls.tsx b/src/components/call-desk/barge-controls.tsx new file mode 100644 index 0000000..fd8c652 --- /dev/null +++ b/src/components/call-desk/barge-controls.tsx @@ -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 = { listen: '4', whisper: '5', barge: '6' }; + +const MODE_CONFIG: Record = { + 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('idle'); + const [mode, setMode] = useState('listen'); + const [duration, setDuration] = useState(0); + const connectedAtRef = useRef(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 ( +
+ +

{status === 'ended' ? 'Session ended' : 'Ready to monitor'}

+ +
+ ); + } + + // Connecting state + if (status === 'connecting') { + return ( +
+
+ + Connecting... +
+

Registering SIP and joining call

+
+ ); + } + + // Connected state + return ( +
+ {/* Status bar */} +
+
+ + Connected +
+ {formatDuration(duration)} +
+ + {/* Mode tabs */} +
+ {(['listen', 'whisper', 'barge'] as BargeMode[]).map((m) => { + const config = MODE_CONFIG[m]; + const isActive = mode === m; + return ( + + ); + })} +
+ + {/* Mode description */} +

{MODE_CONFIG[mode].description}

+ + {/* Hang up */} + +
+ ); +};