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:
2026-04-15 09:02:20 +05:30
parent 91a1f33d35
commit 180613a2f3
2 changed files with 76 additions and 81 deletions

View File

@@ -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 };
};