mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
feat(notifications): poll real PerformanceAlert rows from sidecar
usePerformanceAlerts now fetches /api/supervisor/performance-alerts every 60s instead of computing client-side. Dismiss + dismiss-all hit the sidecar so state survives reload. Toast fires when new alerts arrive. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -91,7 +91,7 @@ export const NotificationBell = () => {
|
||||
<p className="text-sm font-medium text-primary">{alert.agent}</p>
|
||||
<p className="text-xs text-tertiary">{alert.type}</p>
|
||||
</div>
|
||||
<Badge size="sm" color={alert.severity} type="pill-color">{alert.value}</Badge>
|
||||
<Badge size="sm" color={alert.severity === 'error' ? 'error' : alert.severity === 'warning' ? 'warning' : 'gray'} type="pill-color">{alert.value}</Badge>
|
||||
<button
|
||||
onClick={() => dismiss(alert.id)}
|
||||
className="text-fg-quaternary hover:text-fg-secondary shrink-0 transition duration-100 ease-linear"
|
||||
|
||||
@@ -1,106 +1,101 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import { notify } from '@/lib/toast';
|
||||
|
||||
export type PerformanceAlert = {
|
||||
id: string;
|
||||
agent: string;
|
||||
type: 'Excessive Idle Time' | 'Low NPS' | 'Low Conversion';
|
||||
agentId: string | null;
|
||||
type: string;
|
||||
value: string;
|
||||
severity: 'error' | 'warning';
|
||||
severity: 'error' | 'warning' | 'info';
|
||||
message?: string | null;
|
||||
firedAt?: string;
|
||||
dismissed: boolean;
|
||||
};
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
||||
const POLL_INTERVAL_MS = 60_000;
|
||||
|
||||
const sevToFront = (s: string): 'error' | 'warning' | 'info' => {
|
||||
const v = (s ?? '').toLowerCase();
|
||||
if (v === 'critical') return 'error';
|
||||
if (v === 'warning') return 'warning';
|
||||
return 'info';
|
||||
};
|
||||
|
||||
export const usePerformanceAlerts = () => {
|
||||
const { isAdmin } = useAuth();
|
||||
const { calls, leads } = useData();
|
||||
const [alerts, setAlerts] = useState<PerformanceAlert[]>([]);
|
||||
const [teamPerf, setTeamPerf] = useState<any>(null);
|
||||
const toastsFiredRef = useRef(false);
|
||||
const lastSeenIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Fetch team performance data from sidecar (same as team-performance page)
|
||||
useEffect(() => {
|
||||
const load = useCallback(async () => {
|
||||
if (!isAdmin) return;
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const token = localStorage.getItem('helix_access_token') ?? '';
|
||||
fetch(`${API_URL}/api/supervisor/team-performance?date=${today}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => setTeamPerf(data))
|
||||
.catch(() => {});
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/supervisor/performance-alerts`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const json = await res.json();
|
||||
const list: PerformanceAlert[] = (json?.alerts ?? []).map((a: any) => ({
|
||||
id: a.id,
|
||||
agent: a.agent,
|
||||
agentId: a.agentId ?? null,
|
||||
type: a.type,
|
||||
value: a.value ?? '',
|
||||
severity: sevToFront(a.severity),
|
||||
message: a.message,
|
||||
firedAt: a.firedAt,
|
||||
dismissed: false,
|
||||
}));
|
||||
setAlerts(list);
|
||||
|
||||
// Toast for newly arrived alerts
|
||||
const fresh = list.filter((a) => !lastSeenIdsRef.current.has(a.id));
|
||||
if (fresh.length > 0 && lastSeenIdsRef.current.size > 0) {
|
||||
notify.error('Performance Alerts', `${fresh.length} new alert(s)`);
|
||||
}
|
||||
lastSeenIdsRef.current = new Set(list.map((a) => a.id));
|
||||
} catch {
|
||||
// Silent — sidecar may be temporarily down
|
||||
}
|
||||
}, [isAdmin]);
|
||||
|
||||
// Compute alerts from team performance + entity data
|
||||
useMemo(() => {
|
||||
if (!isAdmin || !teamPerf?.agents) return;
|
||||
|
||||
const parseTime = (t: string): number => {
|
||||
const parts = t.split(':').map(Number);
|
||||
return (parts[0] ?? 0) * 3600 + (parts[1] ?? 0) * 60 + (parts[2] ?? 0);
|
||||
};
|
||||
|
||||
const list: PerformanceAlert[] = [];
|
||||
let idx = 0;
|
||||
|
||||
for (const agent of teamPerf.agents) {
|
||||
const agentCalls = calls.filter((c) => {
|
||||
if (c.agentId && agent.id && c.agentId === agent.id) return true;
|
||||
if (!c.agentId && (c.agentName === agent.name || c.agentName === agent.ozonetelAgentId)) return true;
|
||||
return false;
|
||||
});
|
||||
const totalCalls = agentCalls.length;
|
||||
const agentAppts = agentCalls.filter((c: any) => c.disposition === 'APPOINTMENT_BOOKED').length;
|
||||
const convPercent = totalCalls > 0 ? Math.round((agentAppts / totalCalls) * 100) : 0;
|
||||
|
||||
const tb = agent.timeBreakdown;
|
||||
const idleMinutes = tb ? Math.round(parseTime(tb.totalIdleTime ?? '0:0:0') / 60) : 0;
|
||||
|
||||
if (agent.maxIdleMinutes && idleMinutes > agent.maxIdleMinutes) {
|
||||
list.push({ id: `idle-${idx++}`, agent: agent.name ?? agent.ozonetelAgentId, type: 'Excessive Idle Time', value: `${idleMinutes}m`, severity: 'error', dismissed: false });
|
||||
}
|
||||
if (agent.minNpsThreshold && (agent.npsScore ?? 100) < agent.minNpsThreshold) {
|
||||
list.push({ id: `nps-${idx++}`, agent: agent.name ?? agent.ozonetelAgentId, type: 'Low NPS', value: String(agent.npsScore ?? 0), severity: 'warning', dismissed: false });
|
||||
}
|
||||
if (agent.minConversionPercent && convPercent < agent.minConversionPercent) {
|
||||
list.push({ id: `conv-${idx++}`, agent: agent.name ?? agent.ozonetelAgentId, type: 'Low Conversion', value: `${convPercent}%`, severity: 'warning', dismissed: false });
|
||||
}
|
||||
}
|
||||
|
||||
setAlerts(list);
|
||||
}, [isAdmin, teamPerf, calls, leads]);
|
||||
|
||||
// Fire toasts once when alerts first load
|
||||
useEffect(() => {
|
||||
if (toastsFiredRef.current || alerts.length === 0) return;
|
||||
toastsFiredRef.current = true;
|
||||
if (!isAdmin) return;
|
||||
load();
|
||||
const id = setInterval(load, POLL_INTERVAL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [isAdmin, load]);
|
||||
|
||||
const idleCount = alerts.filter(a => a.type === 'Excessive Idle Time').length;
|
||||
const npsCount = alerts.filter(a => a.type === 'Low NPS').length;
|
||||
const convCount = alerts.filter(a => a.type === 'Low Conversion').length;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (idleCount > 0) parts.push(`${idleCount} excessive idle`);
|
||||
if (npsCount > 0) parts.push(`${npsCount} low NPS`);
|
||||
if (convCount > 0) parts.push(`${convCount} low conversion`);
|
||||
|
||||
if (parts.length > 0) {
|
||||
notify.error('Performance Alerts', `${alerts.length} alert(s): ${parts.join(', ')}`);
|
||||
const dismiss = useCallback(async (id: string) => {
|
||||
// Optimistic
|
||||
setAlerts((prev) => prev.filter((a) => a.id !== id));
|
||||
const token = localStorage.getItem('helix_access_token') ?? '';
|
||||
try {
|
||||
await fetch(`${API_URL}/api/supervisor/performance-alerts/${id}/dismiss`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
} catch {
|
||||
// Reload on failure to restore truth
|
||||
load();
|
||||
}
|
||||
}, [alerts]);
|
||||
}, [load]);
|
||||
|
||||
const dismiss = (id: string) => {
|
||||
setAlerts(prev => prev.map(a => a.id === id ? { ...a, dismissed: true } : a));
|
||||
};
|
||||
const dismissAll = useCallback(async () => {
|
||||
setAlerts([]);
|
||||
const token = localStorage.getItem('helix_access_token') ?? '';
|
||||
try {
|
||||
await fetch(`${API_URL}/api/supervisor/performance-alerts/dismiss-all`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
} catch {
|
||||
load();
|
||||
}
|
||||
}, [load]);
|
||||
|
||||
const dismissAll = () => {
|
||||
setAlerts(prev => prev.map(a => ({ ...a, dismissed: true })));
|
||||
};
|
||||
|
||||
const activeAlerts = alerts.filter(a => !a.dismissed);
|
||||
|
||||
return { alerts: activeAlerts, allAlerts: alerts, dismiss, dismissAll };
|
||||
return { alerts, allAlerts: alerts, dismiss, dismissAll };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user