// WallboardTab.jsx — Real-time Supervisor Wallboard // Light theme — App design system (#00478d primary, #f0f4f8 bg) // Layout: Canlı Çağrı İzleme > KPI Tiles > Kuyruk > Temsilciler (function () { const { useState, useEffect, useCallback, useRef, useMemo } = React; const REFRESH_INTERVAL = 5; /* ── Helpers ─────────────────────────────────────────────────────────────────── */ function fmtDur(seconds) { if (!seconds && seconds !== 0) return '--:--'; const s = Math.floor(seconds); const m = Math.floor(s / 60); const h = Math.floor(m / 60); if (h > 0) return `${h}:${String(m % 60).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`; return `${String(m).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`; } function fmtShort(seconds) { if (!seconds && seconds !== 0) return '--'; const s = Math.round(seconds); if (s < 60) return `${s}s`; const m = Math.floor(s / 60); if (m < 60) return `${m}dk ${s % 60}s`; return `${Math.floor(m / 60)}sa ${m % 60}dk`; } function padTwo(n) { return String(n).padStart(2, '0'); } function fmtTime(d) { return `${padTwo(d.getHours())}:${padTwo(d.getMinutes())}:${padTwo(d.getSeconds())}`; } function fmtDate(d) { const days = ['Pazar','Pazartesi','Salı','Çarşamba','Perşembe','Cuma','Cumartesi']; const months = ['Ocak','Şubat','Mart','Nisan','Mayıs','Haziran','Temmuz','Ağustos','Eylül','Ekim','Kasım','Aralık']; return `${days[d.getDay()]}, ${d.getDate()} ${months[d.getMonth()]}`; } function slColor(pct) { if (pct === null || pct === undefined) return '#64748b'; if (pct >= 80) return '#10b981'; if (pct >= 60) return '#f59e0b'; return '#ef4444'; } function pctColor(pct, invert) { if (pct === null || pct === undefined) return '#64748b'; if (invert) { if (pct <= 3) return '#10b981'; if (pct <= 8) return '#f59e0b'; return '#ef4444'; } if (pct >= 80) return '#10b981'; if (pct >= 60) return '#f59e0b'; return '#ef4444'; } const STATUS_COLORS = { 'Müsait': '#10b981', 'available': '#10b981', 'Görüşmede': '#3b82f6', 'busy': '#3b82f6', 'Çevrimdışı': '#6b7280', 'offline': '#6b7280', 'Mola': '#f59e0b', 'break': '#f59e0b', 'Yemek': '#f59e0b', 'Toplantı': '#8b5cf6', 'acw': '#f97316', 'ACW': '#f97316', }; function getStatusColor(status, apiColor) { return STATUS_COLORS[status] || apiColor || '#6b7280'; } /* ── KPI Tile ────────────────────────────────────────────────────────────────── */ function KpiTile({ label, value, sub, color, alert }) { return (
{label}
{value}
{sub &&
{sub}
}
); } /* ── Live Timer Hook ─────────────────────────────────────────────────────────── */ function useLiveTimer(baseSec) { const [elapsed, setElapsed] = useState(baseSec || 0); useEffect(() => { setElapsed(baseSec || 0); const id = setInterval(() => setElapsed(e => e + 1), 1000); return () => clearInterval(id); }, [baseSec]); return elapsed; } /* ── Active Call Row ─────────────────────────────────────────────────────────── */ function ActiveCallRow({ call, supervisorExt, onAction, actionLoading }) { const elapsed = useLiveTimer(call.duration_sec); const btnStyle = (bg, fg) => ({ border: 'none', borderRadius: 6, cursor: actionLoading ? 'not-allowed' : 'pointer', fontSize: 12, fontWeight: 600, padding: '5px 10px', display: 'flex', alignItems: 'center', gap: 4, whiteSpace: 'nowrap', background: bg, color: fg, opacity: actionLoading ? 0.5 : 1, transition: 'opacity .15s', }); return ( {call.phone_number || '--'} {call.agent_name || '--'} {call.agent_extension && ({call.agent_extension})} {fmtDur(elapsed)}
); } /* ── Session Row ─────────────────────────────────────────────────────────────── */ function SessionRow({ session, onStop, stopLoading }) { const modeLabels = { listen: 'Dinleme', whisper: 'Fısıldama', barge: 'Katılma' }; const modeColors = { listen: '#1d4ed8', whisper: '#6d28d9', barge: '#854d0e' }; const mode = session.mode || ''; const mc = modeColors[mode] || '#5b7a92'; return (
Çağrı UUID
{session.call_uuid || '--'}
Süpervizör
{session.supervisor_ext || '--'}
{modeLabels[mode] || mode || '--'}
); } /* ── Table styles ────────────────────────────────────────────────────────────── */ const cellStyle = { padding: '10px 14px', fontSize: 13, color: '#1a3047', whiteSpace: 'nowrap' }; const thStyle = { padding: '10px 14px', fontSize: 11, fontWeight: 600, color: '#5b7a92', textTransform: 'uppercase', letterSpacing: '.04em', textAlign: 'left', borderBottom: '1px solid #dce6ef', whiteSpace: 'nowrap', }; /* ═══════════════════════════════════════════════════════════════════════════════ MAIN: WallboardTab ═══════════════════════════════════════════════════════════════════════════════ */ function WallboardTab({ toast }) { const [agents, setAgents] = useState([]); const [queues, setQueues] = useState([]); const [perfMap, setPerfMap] = useState({}); const [now, setNow] = useState(new Date()); const [countdown, setCountdown] = useState(REFRESH_INTERVAL); const [loading, setLoading] = useState(true); const [liveCalls, setLiveCalls] = useState([]); const [activeCalls, setActiveCalls] = useState([]); const [monitoringSessions, setMonitoringSessions] = useState([]); const [supervisorExt, setSupervisorExt] = useState(''); const [actionLoadingMap, setActionLoadingMap] = useState({}); const [stopLoadingMap, setStopLoadingMap] = useState({}); const [filterQueue, setFilterQueue] = useState(''); const [filterTeam, setFilterTeam] = useState(''); const countdownRef = useRef(REFRESH_INTERVAL); const refreshTimerRef = useRef(null); /* ── Data fetch ──────────────────────────────────────────────────────────────── */ const fetchData = useCallback(async () => { try { const [agR, quR, pfR, lcR, acR, ssR] = await Promise.all([ authFetch('/api/agents/overview'), authFetch('/api/queues/wallboard-stats'), authFetch('/api/human-agents/perf-stats'), authFetch('/api/live-calls'), authFetch('/api/monitoring/active-calls'), authFetch('/api/monitoring/sessions'), ]); const [agD, quD, pfD, lcD, acD, ssD] = await Promise.all([ agR.json().catch(() => []), quR.json().catch(() => []), pfR.json().catch(() => []), lcR.json().catch(() => []), acR.json().catch(() => []), ssR.json().catch(() => []), ]); const agentsList = agD && agD.agents ? agD.agents : (Array.isArray(agD) ? agD : []); setAgents(agentsList); setQueues(Array.isArray(quD) ? quD : []); const m = {}; (Array.isArray(pfD) ? pfD : []).forEach(p => { m[p.id] = p; }); setPerfMap(m); setLiveCalls(Array.isArray(lcD) ? lcD : []); setActiveCalls(Array.isArray(acD) ? acD : []); setMonitoringSessions(Array.isArray(ssD) ? ssD : []); } catch (e) { console.error('Wallboard fetch error:', e); } finally { setLoading(false); } }, []); useEffect(() => { fetchData(); const tick = () => { countdownRef.current -= 1; setCountdown(countdownRef.current); if (countdownRef.current <= 0) { countdownRef.current = REFRESH_INTERVAL; setCountdown(REFRESH_INTERVAL); fetchData(); } }; refreshTimerRef.current = setInterval(tick, 1000); return () => clearInterval(refreshTimerRef.current); }, [fetchData]); useEffect(() => { const id = setInterval(() => setNow(new Date()), 1000); return () => clearInterval(id); }, []); /* ── Computed KPIs ────────────────────────────────────────────────────────────── */ const kpi = useMemo(() => { const totalWaiting = queues.reduce((s, q) => s + (q.waiting_count || 0), 0); const totalActive = queues.reduce((s, q) => s + (q.active_count || 0), 0); const totalAnswered = queues.reduce((s, q) => s + (q.today_answered || 0), 0); const totalOffered = queues.reduce((s, q) => s + (q.today_total || 0), 0); const totalAbandoned= queues.reduce((s, q) => s + (q.today_abandoned || 0), 0); const longestWait = queues.reduce((mx, q) => Math.max(mx, q.longest_wait_sec || 0), 0); const avgWait = queues.length > 0 ? queues.reduce((s, q) => s + (q.avg_wait_sec || 0), 0) / queues.length : 0; const slPct = totalOffered > 0 ? Math.round(queues.reduce((s, q) => s + (q.sl_pct || 0) * (q.today_total || 0), 0) / totalOffered) : null; const abandonPct = totalOffered > 0 ? ((totalAbandoned / totalOffered) * 100).toFixed(1) : '0.0'; const agentsOnline = agents.filter(a => a.status !== 'Çevrimdışı' && a.status !== 'offline').length; const agentsAvail = agents.filter(a => a.status === 'Müsait' || a.status === 'available' || a.is_routable).length; const agentsInCall = agents.filter(a => a.in_call || a.active_call).length; const agentsBreak = agents.filter(a => ['Mola','Yemek','break'].includes(a.status)).length; return { totalWaiting, totalActive, totalAnswered, totalOffered, totalAbandoned, longestWait, avgWait, slPct, abandonPct, agentsOnline, agentsAvail, agentsInCall, agentsBreak, }; }, [agents, queues]); /* ── Monitor actions ──────────────────────────────────────────────────────────── */ const handleMonitorAction = useCallback(async (actionType, callUuid) => { if (!supervisorExt) { alert('Lütfen önce Süpervizör Dahilisi girin.'); return; } const ep = { listen: '/api/monitoring/listen', whisper: '/api/monitoring/whisper', barge: '/api/monitoring/barge' }; if (!ep[actionType]) return; setActionLoadingMap(prev => ({ ...prev, [callUuid]: true })); try { const res = await authFetch(ep[actionType], { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ call_uuid: callUuid, supervisor_extension: supervisorExt }), }); if (res.ok) { const labels = { listen: 'Dinleme', whisper: 'Fısıldama', barge: 'Katılma' }; if (toast) toast(`${labels[actionType]} başlatıldı.`, 'success'); try { const sr = await authFetch('/api/monitoring/sessions'); const sd = await sr.json().catch(() => []); setMonitoringSessions(Array.isArray(sd) ? sd : []); } catch (_) {} } else { const err = await res.json().catch(() => ({})); if (toast) toast(err.detail || 'İşlem başarısız.', 'error'); } } catch (e) { if (toast) toast('Bağlantı hatası.', 'error'); } finally { setActionLoadingMap(prev => { const n = { ...prev }; delete n[callUuid]; return n; }); } }, [supervisorExt, toast]); const handleStopSession = useCallback(async (sessionId) => { if (!sessionId) return; setStopLoadingMap(prev => ({ ...prev, [sessionId]: true })); try { const res = await authFetch(`/api/monitoring/stop/${sessionId}`, { method: 'POST' }); if (res.ok) { if (toast) toast('Oturum durduruldu.', 'success'); setMonitoringSessions(prev => prev.filter(s => s.session_id !== sessionId)); } else { const err = await res.json().catch(() => ({})); if (toast) toast(err.detail || 'Durdurma başarısız.', 'error'); } } catch (e) { if (toast) toast('Bağlantı hatası.', 'error'); } finally { setStopLoadingMap(prev => { const n = { ...prev }; delete n[sessionId]; return n; }); } }, [toast]); const handleFullscreen = () => { if (!document.fullscreenElement) document.documentElement.requestFullscreen().catch(() => {}); else document.exitFullscreen().catch(() => {}); }; /* ══════════════════════════════ RENDER ══════════════════════════════ */ return (
{/* ── HEADER ── */}
Supervisor Wallboard
{fmtTime(now)}
{fmtDate(now)}
Yenileme {countdown}s
{loading ? (
Veriler yükleniyor...
) : (
{/* ═══════════ 1. CANLI ÇAĞRI İZLEME ═══════════ */} {(() => { const ringing = liveCalls.filter(c => c.status === 'ringing'); const ivr = liveCalls.filter(c => c.status === 'ivr'); const queue = liveCalls.filter(c => c.status === 'queue'); const active = liveCalls.filter(c => c.status === 'active'); const elapsedSec = (c) => { const d = parseUTC(c.started_at || c.start_time); if (!d) return 0; return Math.max(0, Math.floor((Date.now() - d.getTime()) / 1000)); }; const liveStatusBadge = (status) => { const map = { ringing: { label: 'Çalıyor', dot: '#f59e0b', bg: 'rgba(245,158,11,.1)', color: '#92400e' }, ivr: { label: 'IVR / AI', dot: '#8b5cf6', bg: 'rgba(139,92,246,.1)', color: '#5b21b6' }, queue: { label: 'Kuyrukta', dot: '#f97316', bg: 'rgba(249,115,22,.1)', color: '#9a3412' }, active: { label: 'Aktif', dot: '#10b981', bg: 'rgba(16,185,129,.1)', color: '#065f46' }, }; const s = map[status] || { label: status, dot: '#8fafc5', bg: '#f5f8fb', color: '#5b7a92' }; return ( {s.label} ); }; const counterBox = (dotColor, label, count, valueColor, isLast) => (
0 ? dotColor : '#cbd5e1', flexShrink: 0, }} />
0 ? valueColor : '#94a3b8', lineHeight: 1 }}>{count}
{label}
); const queueOrAgent = (c) => { if (c.status === 'active') return c.agent_name || '—'; if (c.status === 'queue') return c.queue_name || '—'; if (c.status === 'ivr') return c.agent_name || 'AI'; return '—'; }; const monitorCallMap = {}; activeCalls.forEach(ac => { monitorCallMap[ac.call_uuid] = ac; }); return (
{/* Başlık */}
Canlı Çağrı İzleme Canlı
setSupervisorExt(e.target.value.trim())} placeholder="9999" maxLength={10} style={{ background: '#f0f4f8', border: '1px solid #dce6ef', borderRadius: 6, color: '#00478d', fontSize: 13, fontFamily: 'monospace', fontWeight: 600, padding: '4px 8px', width: 80, outline: 'none', }} />
{/* Sayaçlar */}
{counterBox('#f59e0b', 'Çalıyor', ringing.length, '#92400e', false)} {counterBox('#8b5cf6', 'IVR / AI', ivr.length, '#5b21b6', false)} {counterBox('#f97316', 'Kuyrukta', queue.length, '#9a3412', false)} {counterBox('#10b981', 'Aktif', active.length, '#065f46', true)}
{/* Tablo */} {liveCalls.length === 0 ? (
Şu an aktif çağrı yok
) : ( {liveCalls.map((c, i) => { const sec = elapsedSec(c); const isActive = c.status === 'active'; return ( ); })}
Arayan Durum Süre Kuyruk / Temsilci İşlem
{c.caller_number || c.phone_number || '—'} {liveStatusBadge(c.status)} {fmtDur(sec)} {queueOrAgent(c)} {isActive && c.call_uuid ? (
{[ { act: 'listen', label: 'Dinle', bg: '#eff6ff', fg: '#1d4ed8' }, { act: 'whisper', label: 'Fısılda', bg: '#f5f3ff', fg: '#6d28d9' }, { act: 'barge', label: 'Katıl', bg: '#fef9c3', fg: '#854d0e' }, ].map(({ act, label, bg, fg }) => ( ))}
) : ( )}
)} {/* Aktif izleme oturumları */} {monitoringSessions.length > 0 && (
Aktif İzleme Oturumları ({monitoringSessions.length})
{monitoringSessions.map(s => ( ))}
)}
); })()} {/* ═══════════ 2. KPI TILES ═══════════ */}
0 ? '#ef4444' : '#10b981'} alert={kpi.totalWaiting > 3} /> 8} /> 0 ? fmtDur(kpi.longestWait) : '--'} color={kpi.longestWait > 60 ? '#ef4444' : kpi.longestWait > 30 ? '#f59e0b' : '#10b981'} alert={kpi.longestWait > 60} /> 0 ? '#3b82f6' : '#94a3b8'} /> 0 ? '#ef4444' : '#10b981'} sub={`${kpi.agentsOnline} çevrimiçi`} alert={kpi.agentsAvail === 0 && kpi.agentsOnline > 0} /> 0 ? fmtDur(Math.round(kpi.avgWait)) : '--'} color={kpi.avgWait > 30 ? '#f59e0b' : '#10b981'} />
{/* ═══════════ 3. KUYRUK TABLOSU ═══════════ */} {queues.length > 0 && (
Kuyruk Performansı {queues.length} kuyruk
{queues.map((q, i) => { const w = q.waiting_count || 0; const offered = q.today_total || 0; const abandoned = q.today_abandoned || 0; const abPct = offered > 0 ? ((abandoned / offered) * 100).toFixed(1) : '0.0'; const sl = q.sl_pct; const lwait = q.longest_wait_sec || 0; return ( 5 ? 'rgba(239,68,68,.04)' : i % 2 === 0 ? 'transparent' : '#fafcfe', }}> ); })}
Kuyruk Bekleyen En Uzun Bekleme SL% Toplam Cevaplanan Terk Terk% Ort. Bekleme Aktif
{q.name}
Dahili: {q.extension}
0 ? (w > 5 ? '#ef4444' : '#f59e0b') : '#94a3b8', }}>{w} 60 ? '#ef4444' : lwait > 30 ? '#f59e0b' : '#5b7a92', }}>{lwait > 0 ? fmtDur(lwait) : '--'} {sl !== null && sl !== undefined ? `${sl}%` : '--'} {offered} {q.today_answered || 0} 0 ? '#ef4444' : '#5b7a92' }}>{abandoned} %{abPct} {q.avg_wait_sec > 0 ? fmtDur(Math.round(q.avg_wait_sec)) : '--'} 0 ? '#3b82f6' : '#94a3b8' }}> {q.active_count || 0}
)} {/* ═══════════ 4. TEMSİLCİ TABLOSU ═══════════ */} {(() => { const allQueueNames = [...new Set(agents.flatMap(a => a.queues || []).map(q => typeof q === 'string' ? q : q.name))].sort(); const allTeamNames = [...new Set(agents.filter(a => a.team_name).map(a => a.team_name))].sort(); let filteredAgents = agents; if (filterQueue) filteredAgents = filteredAgents.filter(a => (a.queues || []).some(q => (typeof q === 'string' ? q : q.name) === filterQueue)); if (filterTeam) filteredAgents = filteredAgents.filter(a => a.team_name === filterTeam); const fAvail = filteredAgents.filter(a => a.status === 'Müsait' || a.status === 'available' || a.is_routable).length; const fInCall = filteredAgents.filter(a => a.in_call || a.active_call).length; const fBreak = filteredAgents.filter(a => ['Mola','Yemek','break'].includes(a.status)).length; const selectStyle = { background: '#f0f4f8', border: '1px solid #dce6ef', borderRadius: 6, color: '#1a3047', fontSize: 12, fontWeight: 500, padding: '4px 10px', outline: 'none', cursor: 'pointer', }; return (
Temsilci Durumu {allTeamNames.length > 0 && ( )} {(filterQueue || filterTeam) && ( )}
Müsait: {fAvail} Çağrıda: {fInCall} Molada: {fBreak} Toplam: {filteredAgents.length}
{filteredAgents.length === 0 ? ( ) : filteredAgents.map((a, i) => { const color = getStatusColor(a.status, a.status_color); const perf = perfMap[a.id] || {}; const hasCall = a.in_call || a.active_call; let stateSeconds = a.status_duration_sec || 0; if (!stateSeconds && a.status_changed_at) { const sc = parseUTC(a.status_changed_at); if (sc) stateSeconds = Math.floor((Date.now() - sc.getTime()) / 1000); } if (hasCall && a.active_call && a.active_call.duration_sec) { stateSeconds = a.active_call.duration_sec; } let durColor = '#5b7a92'; if (a.status === 'Mola' || a.status === 'Yemek' || a.status === 'break') { if (stateSeconds > 900) durColor = '#ef4444'; else if (stateSeconds > 600) durColor = '#f59e0b'; } else if (a.status === 'acw' || a.status === 'ACW') { if (stateSeconds > 120) durColor = '#ef4444'; else if (stateSeconds > 60) durColor = '#f59e0b'; } return ( ); })}
Temsilci Dahili Durum Durum Süresi Bugün Çağrı Ort. Görüşme Takım Kuyruklar
{filterQueue || filterTeam ? 'Filtreye uygun temsilci bulunamadı' : 'Temsilci bulunamadı'}
{a.name} {hasCall && ( {a.active_call?.caller_number || 'Çağrıda'} )}
{a.extension}
{a.status_label || a.status || '--'}
{stateSeconds > 0 ? fmtDur(stateSeconds) : '--'} {perf.today_calls ?? 0} {perf.avg_talk_sec ? fmtShort(perf.avg_talk_sec) : '--'} {a.team_name ? ( {a.team_name} ) : ( -- )}
{a.queues && a.queues.length > 0 ? a.queues.map((q, qi) => { const qName = typeof q === 'string' ? q : q.name; const isFiltered = filterQueue && qName === filterQueue; return ( {qName} ); }) : -- }
); })()}
)} {/* ── FOOTER ── */}
{[ { label: 'Çevrimiçi', value: kpi.agentsOnline, color: '#86efac' }, { label: 'Müsait', value: kpi.agentsAvail, color: '#86efac' }, { label: 'Çağrıda', value: kpi.agentsInCall, color: '#93c5fd' }, { label: 'Bekleyen', value: kpi.totalWaiting, color: kpi.totalWaiting > 0 ? '#fca5a5' : 'rgba(255,255,255,.6)' }, { label: 'Aktif', value: kpi.totalActive, color: '#93c5fd' }, { label: 'SL%', value: kpi.slPct !== null ? `${kpi.slPct}%` : '--', color: slColor(kpi.slPct) === '#10b981' ? '#86efac' : slColor(kpi.slPct) === '#f59e0b' ? '#fcd34d' : '#fca5a5' }, ].map(item => (
{item.label}: {item.value}
))}
); } window.WallboardTab = WallboardTab; })();