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;
|
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 {
|
export class ApiError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
detail: unknown;
|
detail: unknown;
|
||||||
|
|||||||
@@ -25,7 +25,18 @@ import {
|
|||||||
YAxis,
|
YAxis,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { cn } from '../../lib/utils';
|
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';
|
import { LoadingState } from '../../components/ui/FeedbackStates';
|
||||||
|
|
||||||
const emptyChartData = [
|
const emptyChartData = [
|
||||||
@@ -59,6 +70,8 @@ export const SupervisorView: React.FC = () => {
|
|||||||
const [agents, setAgents] = React.useState<BackendAgentState[]>([]);
|
const [agents, setAgents] = React.useState<BackendAgentState[]>([]);
|
||||||
const [volumeTimeseries, setVolumeTimeseries] = React.useState<BackendTimeseries | null>(null);
|
const [volumeTimeseries, setVolumeTimeseries] = React.useState<BackendTimeseries | null>(null);
|
||||||
const [abandonedTimeseries, setAbandonedTimeseries] = 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 [isLoading, setIsLoading] = React.useState(true);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
@@ -80,6 +93,8 @@ export const SupervisorView: React.FC = () => {
|
|||||||
apiRequest<BackendAgentState[]>('supervisor', 'supervisor/agents'),
|
apiRequest<BackendAgentState[]>('supervisor', 'supervisor/agents'),
|
||||||
apiRequest<BackendTimeseries>('reporting', `reports/timeseries?${windowQuery.toString()}&metric=total`),
|
apiRequest<BackendTimeseries>('reporting', `reports/timeseries?${windowQuery.toString()}&metric=total`),
|
||||||
apiRequest<BackendTimeseries>('reporting', `reports/timeseries?${windowQuery.toString()}&metric=abandoned`),
|
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);
|
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[3].status === 'fulfilled') setAgents(results[3].value);
|
||||||
if (results[4].status === 'fulfilled') setVolumeTimeseries(results[4].value);
|
if (results[4].status === 'fulfilled') setVolumeTimeseries(results[4].value);
|
||||||
if (results[5].status === 'fulfilled') setAbandonedTimeseries(results[5].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');
|
const rejected = results.find((result) => result.status === 'rejected');
|
||||||
if (rejected) {
|
if (rejected) {
|
||||||
@@ -256,12 +273,113 @@ export const SupervisorView: React.FC = () => {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</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 }> = ({
|
const KPIItem: React.FC<{ title: string; value: string; trend: string; positive: boolean; icon: any }> = ({
|
||||||
title,
|
title,
|
||||||
value,
|
value,
|
||||||
|
|||||||
Reference in New Issue
Block a user