feat: show L2 agent pool and AI escalations on Supervisor dashboard
deploy / deploy (push) Successful in 51s
deploy / deploy (push) Successful in 51s
Surfaces the routing-service agent pool (/routing/agents) and live escalation feed (/routing/escalations) on the SupervisorView used at aimaq.konturai.kz — this is the actual frontend for the Tele2 Kazgaz number +77476456048's call-center backend.
This commit is contained in:
@@ -227,6 +227,52 @@ export interface BackendOmnichannelInsights {
|
||||
generated_at: string;
|
||||
}
|
||||
|
||||
export type BackendAgentPoolStatus =
|
||||
| 'OFFLINE'
|
||||
| 'AVAILABLE'
|
||||
| 'RESERVED'
|
||||
| 'RINGING'
|
||||
| 'TALKING'
|
||||
| 'AFTER_CALL_WORK'
|
||||
| 'PAUSED';
|
||||
|
||||
export interface BackendAgentPoolItem {
|
||||
agent_id: string;
|
||||
tenant_ids: string[];
|
||||
extension: string;
|
||||
endpoint?: string | null;
|
||||
display_name: string;
|
||||
level: 'L2' | 'L3';
|
||||
skills: string[];
|
||||
status: BackendAgentPoolStatus;
|
||||
current_call_id?: string | null;
|
||||
max_concurrent_calls: number;
|
||||
enabled: boolean;
|
||||
calls_handled_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export type BackendEscalationStatus = 'requested' | 'ringing' | 'completed' | 'failed';
|
||||
|
||||
export interface BackendEscalation {
|
||||
escalation_id: string;
|
||||
call_id: string;
|
||||
tenant_id?: string | null;
|
||||
from_level: string;
|
||||
to_level: string;
|
||||
reason_code: string;
|
||||
required_skills: string[];
|
||||
priority: number;
|
||||
topic?: string | null;
|
||||
summary?: string | null;
|
||||
status: BackendEscalationStatus;
|
||||
assigned_agent_id?: string | null;
|
||||
requested_at: string;
|
||||
connected_at?: string | null;
|
||||
completed_at?: string | null;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
detail: unknown;
|
||||
|
||||
@@ -25,7 +25,18 @@ import {
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { apiRequest, BackendAgentState, BackendKpi, BackendQueue, BackendRealtime, BackendTimeseries } from '../../lib/api';
|
||||
import {
|
||||
apiRequest,
|
||||
BackendAgentPoolItem,
|
||||
BackendAgentPoolStatus,
|
||||
BackendAgentState,
|
||||
BackendEscalation,
|
||||
BackendEscalationStatus,
|
||||
BackendKpi,
|
||||
BackendQueue,
|
||||
BackendRealtime,
|
||||
BackendTimeseries,
|
||||
} from '../../lib/api';
|
||||
import { LoadingState } from '../../components/ui/FeedbackStates';
|
||||
|
||||
const emptyChartData = [
|
||||
@@ -59,6 +70,8 @@ export const SupervisorView: React.FC = () => {
|
||||
const [agents, setAgents] = React.useState<BackendAgentState[]>([]);
|
||||
const [volumeTimeseries, setVolumeTimeseries] = React.useState<BackendTimeseries | null>(null);
|
||||
const [abandonedTimeseries, setAbandonedTimeseries] = React.useState<BackendTimeseries | null>(null);
|
||||
const [callAgents, setCallAgents] = React.useState<BackendAgentPoolItem[]>([]);
|
||||
const [escalations, setEscalations] = React.useState<BackendEscalation[]>([]);
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
@@ -80,6 +93,8 @@ export const SupervisorView: React.FC = () => {
|
||||
apiRequest<BackendAgentState[]>('supervisor', 'supervisor/agents'),
|
||||
apiRequest<BackendTimeseries>('reporting', `reports/timeseries?${windowQuery.toString()}&metric=total`),
|
||||
apiRequest<BackendTimeseries>('reporting', `reports/timeseries?${windowQuery.toString()}&metric=abandoned`),
|
||||
apiRequest<BackendAgentPoolItem[]>('routing', 'agents'),
|
||||
apiRequest<BackendEscalation[]>('routing', 'escalations?limit=30'),
|
||||
]);
|
||||
|
||||
if (results[0].status === 'fulfilled') setRealtime(results[0].value);
|
||||
@@ -88,6 +103,8 @@ export const SupervisorView: React.FC = () => {
|
||||
if (results[3].status === 'fulfilled') setAgents(results[3].value);
|
||||
if (results[4].status === 'fulfilled') setVolumeTimeseries(results[4].value);
|
||||
if (results[5].status === 'fulfilled') setAbandonedTimeseries(results[5].value);
|
||||
if (results[6].status === 'fulfilled') setCallAgents(results[6].value);
|
||||
if (results[7].status === 'fulfilled') setEscalations(results[7].value);
|
||||
|
||||
const rejected = results.find((result) => result.status === 'rejected');
|
||||
if (rejected) {
|
||||
@@ -256,12 +273,113 @@ export const SupervisorView: React.FC = () => {
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100">
|
||||
<h3 className="text-xs font-bold text-slate-900 uppercase tracking-widest">Пул операторов L2 (звонки +7 747 645 6048)</h3>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5 italic">Реальный пул Routing Engine — кого AI переводит на живого оператора</p>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-50">
|
||||
{callAgents.length ? (
|
||||
callAgents.map((agent) => <CallAgentRow key={agent.agent_id} agent={agent} />)
|
||||
) : (
|
||||
<div className="px-6 py-10 text-center text-sm text-slate-400">Пул пуст — операторы ещё не добавлены.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100">
|
||||
<h3 className="text-xs font-bold text-slate-900 uppercase tracking-widest">Эскалации AI → оператор</h3>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5 italic">Живая лента передач звонка с Voice AI на L2</p>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-50 max-h-[420px] overflow-y-auto">
|
||||
{escalations.length ? (
|
||||
escalations.map((escalation) => <EscalationRow key={escalation.escalation_id} escalation={escalation} />)
|
||||
) : (
|
||||
<div className="px-6 py-10 text-center text-sm text-slate-400">Эскалаций пока не было.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const callAgentStatusTone: Record<BackendAgentPoolStatus, string> = {
|
||||
AVAILABLE: 'bg-emerald-50 text-emerald-600',
|
||||
RESERVED: 'bg-amber-50 text-amber-600',
|
||||
RINGING: 'bg-amber-50 text-amber-600',
|
||||
TALKING: 'bg-emerald-50 text-emerald-600',
|
||||
AFTER_CALL_WORK: 'bg-amber-50 text-amber-600',
|
||||
PAUSED: 'bg-slate-100 text-slate-600',
|
||||
OFFLINE: 'bg-rose-50 text-rose-600',
|
||||
};
|
||||
|
||||
const callAgentStatusLabel: Record<BackendAgentPoolStatus, string> = {
|
||||
AVAILABLE: 'Свободен',
|
||||
RESERVED: 'Зарезервирован',
|
||||
RINGING: 'Звонок идёт',
|
||||
TALKING: 'На линии',
|
||||
AFTER_CALL_WORK: 'Постобработка',
|
||||
PAUSED: 'Пауза',
|
||||
OFFLINE: 'Offline',
|
||||
};
|
||||
|
||||
const CallAgentRow: React.FC<{ agent: BackendAgentPoolItem }> = ({ agent }) => (
|
||||
<div className="px-6 py-4 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center shrink-0">
|
||||
<Users className="w-4 h-4 text-slate-400" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-slate-900 truncate">
|
||||
{agent.display_name} <span className="text-slate-400 font-normal">· вн. {agent.extension}</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-400 italic truncate">
|
||||
{agent.level} · принято звонков: {agent.calls_handled_count}
|
||||
{agent.current_call_id ? ` · звонок ${agent.current_call_id}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn('shrink-0 text-[10px] font-bold uppercase tracking-widest px-2 py-1 rounded-full', callAgentStatusTone[agent.status])}>
|
||||
{callAgentStatusLabel[agent.status]}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const escalationStatusTone: Record<BackendEscalationStatus, string> = {
|
||||
requested: 'bg-amber-50 text-amber-600',
|
||||
ringing: 'bg-amber-50 text-amber-600',
|
||||
completed: 'bg-emerald-50 text-emerald-600',
|
||||
failed: 'bg-rose-50 text-rose-600',
|
||||
};
|
||||
|
||||
const escalationStatusLabel: Record<BackendEscalationStatus, string> = {
|
||||
requested: 'Запрошена',
|
||||
ringing: 'Дозвон',
|
||||
completed: 'Завершена',
|
||||
failed: 'Не удалась',
|
||||
};
|
||||
|
||||
const EscalationRow: React.FC<{ escalation: BackendEscalation }> = ({ escalation }) => (
|
||||
<div className="px-6 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-semibold text-slate-900 text-sm">звонок {escalation.call_id}</div>
|
||||
<span className={cn('shrink-0 text-[10px] font-bold uppercase tracking-widest px-2 py-1 rounded-full', escalationStatusTone[escalation.status])}>
|
||||
{escalationStatusLabel[escalation.status]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-400 italic mt-1">
|
||||
{escalation.from_level} → {escalation.to_level} · причина: {escalation.reason_code}
|
||||
{escalation.assigned_agent_id ? ` · оператор: ${escalation.assigned_agent_id}` : ' · оператор не назначен'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const KPIItem: React.FC<{ title: string; value: string; trend: string; positive: boolean; icon: any }> = ({
|
||||
title,
|
||||
value,
|
||||
|
||||
Reference in New Issue
Block a user