feat(fase3): add Supervisor Dashboard frontend
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
384
frontend/src/pages/SupervisorDashboard.tsx
Normal file
384
frontend/src/pages/SupervisorDashboard.tsx
Normal file
@@ -0,0 +1,384 @@
|
||||
import {
|
||||
Alert,
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
List,
|
||||
Row,
|
||||
Space,
|
||||
Statistic,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
MessageOutlined,
|
||||
ReloadOutlined,
|
||||
TeamOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { apiClient } from '../api/client';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface DashboardData {
|
||||
conversations: {
|
||||
by_status: Record<string, number>;
|
||||
waiting: number;
|
||||
resolved_today: number;
|
||||
};
|
||||
agents: {
|
||||
online: number;
|
||||
total: number;
|
||||
};
|
||||
messages_today: number;
|
||||
avg_csat: number | null;
|
||||
}
|
||||
|
||||
interface AgentStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
status: string;
|
||||
active_conversations: number;
|
||||
waiting_conversations: number;
|
||||
resolved_today: number;
|
||||
}
|
||||
|
||||
interface QueueStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
waiting_conversations: number;
|
||||
active_conversations: number;
|
||||
online_agents: number;
|
||||
total_agents: number;
|
||||
}
|
||||
|
||||
interface CriticalConversation {
|
||||
id: string;
|
||||
contact_name: string;
|
||||
contact_phone: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
last_message_at: string | null;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
online: 'green',
|
||||
offline: 'default',
|
||||
away: 'orange',
|
||||
busy: 'red',
|
||||
};
|
||||
|
||||
function getReasonLabel(reason: string): string {
|
||||
if (reason === 'long_wait') {
|
||||
return 'Espera prolongada';
|
||||
}
|
||||
return 'Alta prioridad';
|
||||
}
|
||||
|
||||
function getReasonColor(reason: string): string {
|
||||
if (reason === 'long_wait') {
|
||||
return 'orange';
|
||||
}
|
||||
return 'red';
|
||||
}
|
||||
|
||||
export default function SupervisorDashboard(): JSX.Element {
|
||||
const { data: dashboard, refetch: refetchDashboard } = useQuery({
|
||||
queryKey: ['supervisor-dashboard'],
|
||||
queryFn: () => apiClient.get<DashboardData>('/api/supervisor/dashboard'),
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
const { data: agents, refetch: refetchAgents } = useQuery({
|
||||
queryKey: ['supervisor-agents'],
|
||||
queryFn: () => apiClient.get<AgentStatus[]>('/api/supervisor/agents'),
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
const { data: queues, refetch: refetchQueues } = useQuery({
|
||||
queryKey: ['supervisor-queues'],
|
||||
queryFn: () => apiClient.get<QueueStatus[]>('/api/supervisor/queues'),
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
const { data: criticalConversations } = useQuery({
|
||||
queryKey: ['critical-conversations'],
|
||||
queryFn: () => apiClient.get<CriticalConversation[]>('/api/supervisor/conversations/critical'),
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
|
||||
function handleRefresh(): void {
|
||||
refetchDashboard();
|
||||
refetchAgents();
|
||||
refetchQueues();
|
||||
}
|
||||
|
||||
const agentColumns = [
|
||||
{
|
||||
title: 'Agente',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (name: string, record: AgentStatus) => (
|
||||
<Space>
|
||||
<Badge status={record.status === 'online' ? 'success' : 'default'} />
|
||||
<Text>{name}</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Estado',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => (
|
||||
<Tag color={STATUS_COLORS[status]}>{status}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Activas',
|
||||
dataIndex: 'active_conversations',
|
||||
key: 'active',
|
||||
render: (count: number) => (
|
||||
<Badge count={count} showZero color={count > 0 ? 'blue' : 'default'} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'En Espera',
|
||||
dataIndex: 'waiting_conversations',
|
||||
key: 'waiting',
|
||||
render: (count: number) => (
|
||||
<Badge count={count} showZero color={count > 0 ? 'orange' : 'default'} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Resueltas Hoy',
|
||||
dataIndex: 'resolved_today',
|
||||
key: 'resolved',
|
||||
},
|
||||
];
|
||||
|
||||
const queueColumns = [
|
||||
{
|
||||
title: 'Cola',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: 'En Espera',
|
||||
dataIndex: 'waiting_conversations',
|
||||
key: 'waiting',
|
||||
render: (count: number) => (
|
||||
<Badge count={count} showZero color={count > 0 ? 'orange' : 'default'} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Activas',
|
||||
dataIndex: 'active_conversations',
|
||||
key: 'active',
|
||||
render: (count: number) => (
|
||||
<Badge count={count} showZero color={count > 0 ? 'blue' : 'default'} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Agentes Online',
|
||||
key: 'agents',
|
||||
render: (_: unknown, record: QueueStatus) => (
|
||||
<Text>{record.online_agents}/{record.total_agents}</Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const hasCriticalConversations = criticalConversations && criticalConversations.length > 0;
|
||||
const conversationsByStatus = dashboard?.conversations.by_status;
|
||||
const waitingCount = dashboard?.conversations.waiting || 0;
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'agents',
|
||||
label: 'Estado de Agentes',
|
||||
children: (
|
||||
<Card>
|
||||
<Table
|
||||
dataSource={agents}
|
||||
columns={agentColumns}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'queues',
|
||||
label: 'Estado de Colas',
|
||||
children: (
|
||||
<Card>
|
||||
<Table
|
||||
dataSource={queues}
|
||||
columns={queueColumns}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'critical',
|
||||
label: (
|
||||
<span>
|
||||
Criticas {hasCriticalConversations && <Badge count={criticalConversations.length} />}
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<Card>
|
||||
{hasCriticalConversations ? (
|
||||
<List
|
||||
dataSource={criticalConversations}
|
||||
renderItem={(conv) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
avatar={<Avatar icon={<UserOutlined />} />}
|
||||
title={conv.contact_name || conv.contact_phone}
|
||||
description={
|
||||
<Space>
|
||||
<Tag color={getReasonColor(conv.reason)}>
|
||||
{getReasonLabel(conv.reason)}
|
||||
</Tag>
|
||||
<Text type="secondary">
|
||||
{conv.last_message_at
|
||||
? dayjs(conv.last_message_at).fromNow()
|
||||
: 'Sin mensajes'}
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Text type="secondary">No hay conversaciones criticas</Text>
|
||||
)}
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Title level={4}>Panel de Supervisor</Title>
|
||||
<Button icon={<ReloadOutlined />} onClick={handleRefresh}>
|
||||
Actualizar
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{hasCriticalConversations && (
|
||||
<Alert
|
||||
message={`${criticalConversations.length} conversaciones requieren atencion`}
|
||||
type="warning"
|
||||
showIcon
|
||||
icon={<ExclamationCircleOutlined />}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="Agentes Online"
|
||||
value={dashboard?.agents.online || 0}
|
||||
suffix={`/ ${dashboard?.agents.total || 0}`}
|
||||
prefix={<TeamOutlined />}
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="En Espera"
|
||||
value={waitingCount}
|
||||
prefix={<ClockCircleOutlined />}
|
||||
valueStyle={{ color: waitingCount > 0 ? '#faad14' : undefined }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="Resueltas Hoy"
|
||||
value={dashboard?.conversations.resolved_today || 0}
|
||||
prefix={<CheckCircleOutlined />}
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="Mensajes Hoy"
|
||||
value={dashboard?.messages_today || 0}
|
||||
prefix={<MessageOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="Bot"
|
||||
value={conversationsByStatus?.bot || 0}
|
||||
valueStyle={{ fontSize: 20, color: '#1890ff' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="Esperando"
|
||||
value={conversationsByStatus?.waiting || 0}
|
||||
valueStyle={{ fontSize: 20, color: '#faad14' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="Activas"
|
||||
value={conversationsByStatus?.active || 0}
|
||||
valueStyle={{ fontSize: 20, color: '#52c41a' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="CSAT Promedio"
|
||||
value={dashboard?.avg_csat || '-'}
|
||||
suffix={dashboard?.avg_csat ? '/ 5' : ''}
|
||||
valueStyle={{ fontSize: 20 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Tabs items={tabItems} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user