mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
feat: SSE agent state, UCID fix, maint module, QA bug fixes
- Fix outbound disposition: store UCID from dial API response (root cause of silent disposition failure) - SSE agent state: real-time Ozonetel state drives status toggle (ready/break/calling/in-call/acw) - Maint module with OTP-protected endpoints (force-ready, unlock-agent, backfill, fix-timestamps) - Maint OTP modal with PinInput component, keyboard shortcuts (Ctrl+Shift+R/U/B/T) - Force-logout via SSE: admin unlock pushes force-logout to connected browsers - Silence JsSIP debug flood, add structured lifecycle logging ([SIP], [DIAL], [DISPOSE], [AGENT-STATE]) - Centralize date formatting with IST-aware formatters across 11 files - Fix call history: non-overlapping aggregates (completed/missed), correct timestamp display - Auto-dismiss CallWidget ended/failed state after 3 seconds - Remove floating "Helix Phone" idle badge from all pages - Fix dead code in agent-state endpoint (auto-assign was unreachable after return) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
78
src/hooks/use-agent-state.ts
Normal file
78
src/hooks/use-agent-state.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { notify } from '@/lib/toast';
|
||||
|
||||
export type OzonetelState = 'ready' | 'break' | 'training' | 'calling' | 'in-call' | 'acw' | 'offline';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
||||
|
||||
export const useAgentState = (agentId: string | null): OzonetelState => {
|
||||
const [state, setState] = useState<OzonetelState>('offline');
|
||||
const prevStateRef = useRef<OzonetelState>('offline');
|
||||
const esRef = useRef<EventSource | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentId) {
|
||||
setState('offline');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch current state on connect
|
||||
fetch(`${API_URL}/api/supervisor/agent-state?agentId=${agentId}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.state) {
|
||||
console.log(`[SSE] Initial state for ${agentId}: ${data.state}`);
|
||||
prevStateRef.current = data.state;
|
||||
setState(data.state);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Open SSE stream
|
||||
const url = `${API_URL}/api/supervisor/agent-state/stream?agentId=${agentId}`;
|
||||
console.log(`[SSE] Connecting: ${url}`);
|
||||
const es = new EventSource(url);
|
||||
esRef.current = es;
|
||||
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log(`[SSE] State update: ${agentId} → ${data.state}`);
|
||||
|
||||
// Force-logout: only triggered by explicit admin action, not normal Ozonetel logout
|
||||
if (data.state === 'force-logout') {
|
||||
console.log('[SSE] Force-logout received — clearing session');
|
||||
notify.info('Session Ended', 'Your session was ended by an administrator.');
|
||||
es.close();
|
||||
|
||||
localStorage.removeItem('helix_access_token');
|
||||
localStorage.removeItem('helix_refresh_token');
|
||||
localStorage.removeItem('helix_agent_config');
|
||||
localStorage.removeItem('helix_user');
|
||||
|
||||
import('@/state/sip-manager').then(({ disconnectSip }) => disconnectSip()).catch(() => {});
|
||||
|
||||
setTimeout(() => { window.location.href = '/login'; }, 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
prevStateRef.current = data.state;
|
||||
setState(data.state);
|
||||
} catch {
|
||||
console.warn('[SSE] Failed to parse event:', event.data);
|
||||
}
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
console.warn('[SSE] Connection error — will auto-reconnect');
|
||||
};
|
||||
|
||||
return () => {
|
||||
console.log('[SSE] Closing connection');
|
||||
es.close();
|
||||
esRef.current = null;
|
||||
};
|
||||
}, [agentId]);
|
||||
|
||||
return state;
|
||||
};
|
||||
71
src/hooks/use-maint-shortcuts.ts
Normal file
71
src/hooks/use-maint-shortcuts.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
export type MaintAction = {
|
||||
endpoint: string;
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const MAINT_ACTIONS: Record<string, MaintAction> = {
|
||||
forceReady: {
|
||||
endpoint: 'force-ready',
|
||||
label: 'Force Ready',
|
||||
description: 'Logout and re-login the agent to force Ready state on Ozonetel.',
|
||||
},
|
||||
unlockAgent: {
|
||||
endpoint: 'unlock-agent',
|
||||
label: 'Unlock Agent',
|
||||
description: 'Release the Redis session lock so the agent can log in again.',
|
||||
},
|
||||
backfill: {
|
||||
endpoint: 'backfill-missed-calls',
|
||||
label: 'Backfill Missed Calls',
|
||||
description: 'Match existing missed calls with lead records by phone number.',
|
||||
},
|
||||
fixTimestamps: {
|
||||
endpoint: 'fix-timestamps',
|
||||
label: 'Fix Timestamps',
|
||||
description: 'Correct call timestamps that were stored with IST double-offset.',
|
||||
},
|
||||
};
|
||||
|
||||
export const useMaintShortcuts = () => {
|
||||
const [activeAction, setActiveAction] = useState<MaintAction | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const openAction = useCallback((action: MaintAction) => {
|
||||
setActiveAction(action);
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
setActiveAction(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey && e.shiftKey && e.key === 'R') {
|
||||
e.preventDefault();
|
||||
openAction(MAINT_ACTIONS.forceReady);
|
||||
}
|
||||
if (e.ctrlKey && e.shiftKey && e.key === 'U') {
|
||||
e.preventDefault();
|
||||
openAction(MAINT_ACTIONS.unlockAgent);
|
||||
}
|
||||
if (e.ctrlKey && e.shiftKey && e.key === 'B') {
|
||||
e.preventDefault();
|
||||
openAction(MAINT_ACTIONS.backfill);
|
||||
}
|
||||
if (e.ctrlKey && e.shiftKey && e.key === 'T') {
|
||||
e.preventDefault();
|
||||
openAction(MAINT_ACTIONS.fixTimestamps);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [openAction]);
|
||||
|
||||
return { isOpen, activeAction, close };
|
||||
};
|
||||
Reference in New Issue
Block a user