feat|refactor: Controle de agentes implementados e refatoração no dashboard.

This commit is contained in:
lukibeg 2025-12-19 21:26:16 -03:00
parent c13d9df470
commit a29fdf8c03
15 changed files with 1111 additions and 388 deletions

View File

@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers;
use Inertia\Inertia;
use App\Models\Agent;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class AgentController extends Controller
{
public function index()
{
// Pega o tenant do usuário logado (assumindo que o user tem tenant_id)
// Se o user for admin global, talvez precise de lógica diferente,
// mas aqui vamos pegar os agentes do tenant do usuário.
$user = Auth::user();
// Ajuste esta query conforme sua estrutura de User x Tenant
// Se o usuário 'admin' não tem tenant_id direto, você pode listar tudo ou pegar o primeiro.
// Aqui assumo que você quer listar todos os agentes cadastrados no sistema para o Admin.
$agents = Agent::orderBy('name')
->get()
->map(function ($agent) {
return [
'id' => $agent->id,
'name' => $agent->name,
'interface' => $agent->interface,
'status' => $agent->status, // available, paused, offline
'pause_reason' => $agent->pause_reason,
'calls_answered' => $agent->total_calls_answered,
'calls_missed' => $agent->total_ring_no_answer,
// Calcula há quanto tempo está nesse status
'status_duration' => $agent->last_status_change
? $agent->last_status_change->diffForHumans(null, true)
: 'N/A',
];
});
return Inertia::render('Agents/Index', [
'agents' => $agents
]);
}
}

View File

@ -5,6 +5,7 @@
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Tenant;
use App\Models\Agent;
use App\Models\Queue;
use App\Models\WaitingList;
use App\Models\DailyMetric;
@ -25,16 +26,33 @@ public function handle(Request $request)
$data = $request->all();
$eventName = $data['Event'] ?? null;
$queueNumber = $data['Queue'] ?? null;
$interface = $data['Interface'] ?? $data['MemberName'] ?? null; // Tenta achar quem é o agente
$this->saveQueues($queueNumber, $tenant);
if ($queueNumber) {
$this->saveQueues($queueNumber, $tenant);
}
if ($interface) {
$this->saveAgent($interface, $tenant);
}
if (!$eventName || !$queueNumber) {
return response()->json(['status' => 'ignored']);
}
$queue = Queue::where('tenant_id', $tenant->id)
->where('source_id', $queueNumber)
->first();;
$queue = null;
if ($queueNumber) {
$queue = Queue::where('tenant_id', $tenant->id)
->where('source_id', $queueNumber)
->first();
}
// Recupera o Agente (se aplicável)
$agent = null;
if ($interface) {
$agent = Agent::where('tenant_id', $tenant->id)
->where('interface', $interface)
->first();
}
if (!$queue) {
return response()->json(['error' => 'Fila não encontrada'], 404);
@ -54,6 +72,14 @@ public function handle(Request $request)
case 'QueueCallerAbandon':
$this->handleAbandon($queue, $data);
break;
case 'QueueMemberPause':
if ($agent) $this->handlePause($agent, $data, $tenant->id);
break;
case 'AgentRingNoAnswer':
if ($agent) $this->handleRingNoAnswer($agent, $tenant->id);
break;
}
return response()->json(['status' => 'success']);
@ -76,23 +102,36 @@ private function handleJoin($queue, $data)
private function handleConnect($queue, $data)
{
// 1. Remove da Lista de Espera (Já existente)
WaitingList::where('queue_id', $queue->id)
->where('caller_number', $data['CallerIDNum'])
->delete();
// 2. Atualiza Métricas da Fila (Já existente)
$metric = $this->getTodayMetric($queue);
$holdTime = intval($data['HoldTime'] ?? 0);
$newAvg = (($metric->avg_wait_time * $metric->answered_count) + $holdTime) / ($metric->answered_count + 1);
$metric->avg_wait_time = $newAvg;
$metric->answered_count += 1;
$metric->save();
// --- NOVO: Atualiza o Agente ---
$agent = $this->findAgent($queue->tenant_id, $data);
if ($agent) {
$agent->status = 'talking'; // Muda cor para Azul
$agent->last_status_change = now();
$agent->increment('total_calls_answered'); // KPI pessoal do agente
$agent->save();
}
// -------------------------------
$this->broadcastUpdate($queue);
}
private function handleComplete($queue, $data)
{
// 1. Atualiza Métricas da Fila (Já existente)
$talkTime = intval($data['TalkTime'] ?? 0);
$metric = $this->getTodayMetric($queue);
@ -101,6 +140,19 @@ private function handleComplete($queue, $data)
$metric->avg_talk_time = $newAvg;
$metric->save();
}
// --- NOVO: Libera o Agente ---
$agent = $this->findAgent($queue->tenant_id, $data);
if ($agent) {
// Só volta para available se ele não estiver pausado (fluxo simples)
// O ideal seria checar se ele pausou DURANTE a chamada, mas
// geralmente ao desligar ele volta a ficar livre.
$agent->status = 'available'; // Muda cor para Verde
$agent->last_status_change = now();
$agent->save();
}
// -----------------------------
$this->broadcastUpdate($queue);
}
@ -114,6 +166,43 @@ private function handleAbandon($queue, $data)
$this->broadcastUpdate($queue);
}
private function handlePause($agent, $data, $tenantId)
{
$isPaused = isset($data['Paused']) && $data['Paused'] == '1';
// Atualiza o Status ENUM e o Motivo
$agent->status = $isPaused ? 'paused' : 'available'; // Define se está pausado ou disponível
$agent->pause_reason = $isPaused ? ($data['Reason'] ?? 'Pausa') : null;
$agent->last_status_change = now(); // Atualiza o relógio
$agent->save();
broadcast(new DashboardUpdate($tenantId));
}
private function saveAgent($interface, $tenant)
{
$exists = Agent::where('tenant_id', $tenant->id)
->where('interface', $interface)
->exists();
if (!$exists) {
// Tenta limpar o nome. Ex: PJSIP/200 -> 200
$name = $interface;
if (strpos($interface, '/') !== false) {
$parts = explode('/', $interface);
$name = end($parts);
}
Agent::create([
'tenant_id' => $tenant->id,
'name' => "Agente $name",
'interface' => $interface,
'status' => 'available' // Assume disponível ao ser descoberto
]);
}
}
private function getTodayMetric($queue)
{
return DailyMetric::firstOrCreate(
@ -136,6 +225,14 @@ private function updateMetric($queue, $field, $value)
$metric->increment($field, $value);
}
private function handleRingNoAnswer($agent, $tenantId)
{
// Incrementa contador de chamadas perdidas pelo agente
$agent->increment('total_ring_no_answer');
broadcast(new DashboardUpdate($tenantId));
}
private function saveQueues($queue, $tenant)
{
$existingQueue = Queue::where('tenant_id', $tenant->id)
@ -152,8 +249,22 @@ private function saveQueues($queue, $tenant)
]);
}
}
private function broadcastUpdate($queue)
// --- COLOQUE AQUI NO FINAL, JUNTO COM OS OUTROS PRIVATES ---
private function findAgent($tenantId, $data)
{
broadcast(new DashboardUpdate($queue->tenant_id));
// Tenta achar o identificador do agente no evento
$interface = $data['Interface'] ?? $data['MemberName'] ?? null;
if (!$interface) return null;
return Agent::where('tenant_id', $tenantId)
->where('interface', $interface)
->first();
}
private function broadcastUpdate($queueOrTenantId)
{
$tenantId = is_object($queueOrTenantId) ? $queueOrTenantId->tenant_id : $queueOrTenantId;
broadcast(new DashboardUpdate($tenantId));
}
}

View File

@ -5,6 +5,7 @@
use Inertia\Inertia;
use App\Models\User;
use App\Models\Queue;
use App\Models\Agent; // <--- Importante: Adicionamos o Model de Agente
use Illuminate\Support\Facades\Auth;
use Carbon\Carbon;
@ -14,23 +15,32 @@ public function index()
{
$user = Auth::user();
// 1. Começa a query base (apenas filas do Tenant do usuário)
// --- 1. BUSCA DE FILAS (MANTENDO SUA LÓGICA) ---
$query = Queue::where('tenant_id', $user->tenant_id)
->with(['dailyMetrics', 'waitingList']); // Carrega relacionamentos
->with(['waitingList', 'dailyMetrics' => function($q) {
// Otimização: Traz apenas as métricas de HOJE para não carregar histórico antigo
$q->whereDate('date', Carbon::today());
}]);
// 2. APLICAR O FILTRO DE SETOR
// Se o usuário tiver algo escrito em 'allowed_sector', filtramos.
// Se for null, ele pula esse if e traz tudo.
// Aplica o filtro de setor do supervisor, se existir
if (!empty($user->allowed_sector)) {
$query->where('sector', $user->allowed_sector);
}
// 3. Executa a query
$queues = $query->get();
// 4. Entrega para o Vue
// --- 2. BUSCA DE AGENTES (NOVO) ---
// Aqui pegamos todos os agentes do Tenant.
// Usamos orderByRaw para mostrar quem está FALANDO primeiro, depois PAUSADO.
$agents = Agent::where('tenant_id', $user->tenant_id)
->orderByRaw("FIELD(status, 'talking', 'paused', 'available', 'offline')")
->orderBy('name')
->get();
// 3. Entrega tudo para o Vue
return Inertia::render('Dashboard', [
'queues' => $queues
'queues' => $queues,
'agents' => $agents // <--- Agora o Dashboard recebe os agentes
]);
}
}

View File

@ -5,9 +5,31 @@
use Illuminate\Http\Request;
use App\Models\Queue;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
use Carbon\Carbon;
class QueueController extends Controller
{
public function index()
{
$user = Auth::user();
$query = Queue::where('tenant_id', $user->tenant_id)
->with(['dailyMetrics' => function ($q) {
$q->whereDate('date', Carbon::today());
}, 'waitingList']);
if (!empty($user->allowed_sector)) {
$query->where('sector', $user->allowed_sector);
}
return Inertia::render('Queues/Index', [
'queues' => $query->get()
]);
}
public function setQueueName(Request $request)
{
if (Auth::user()->role !== "supervisor" && Auth::user()->role !== "admin") {

View File

@ -4,27 +4,24 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Traits\BelongsToTenant;
class Agent extends Model
{
use HasFactory, BelongsToTenant;
use HasFactory;
protected $fillable = ['tenant_id', 'name', 'extension', 'status', 'last_status_change'];
protected $fillable = [
'tenant_id',
'name',
'interface', // Mudamos de extension para interface
'status',
'pause_reason',
'last_status_change',
'total_calls_answered',
'total_ring_no_answer'
];
// O campo 'last_status_change' deve ser tratado como data
// Cast para garantir que last_status_change seja tratado como data Carbon
protected $casts = [
'last_status_change' => 'datetime',
];
public function tenant()
{
return $this->belongsTo(Tenant::class);
}
public function calls()
{
return $this->hasMany(Call::class);
}
}

View File

@ -32,10 +32,27 @@ public function up()
Schema::create('agents', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->string('extension')->nullable();
$table->enum('status', ['available', 'paused', 'talking', 'offline'])->default('offline');
$table->string('name'); // Ex: "Lucas Gomes"
// MUDANÇA 1: De 'extension' para 'interface'
// Armazena "PJSIP/200". É a chave que liga o AMI ao Banco.
$table->string('interface')->index();
// Status atual
$table->enum('status', ['available', 'paused', 'talking', 'offline'])
->default('offline');
// MUDANÇA 2: Motivo da pausa (vindo do AMI)
$table->string('pause_reason')->nullable();
// Monitoramento de tempo
$table->timestamp('last_status_change')->useCurrent();
// MUDANÇA 3: Métricas acumuladas simples (KPIs)
$table->integer('total_calls_answered')->default(0);
$table->integer('total_ring_no_answer')->default(0);
$table->timestamps();
});

View File

@ -0,0 +1,53 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('agents', function (Blueprint $table) {
$table->id();
// Vínculo com o Tenant (Multi-tenancy)
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
// Nome de exibição (Ex: "Lucas Gomes")
$table->string('name');
// MUDANÇA 1: Identificador técnico no Asterisk
// Armazena "PJSIP/200" ou "SIP/200". Indexado para busca rápida na API.
$table->string('interface')->index();
// Status atual do agente
$table->enum('status', ['available', 'paused', 'talking', 'offline'])
->default('offline');
// MUDANÇA 2: Motivo da pausa (vindo do AMI)
// Ex: "Almoco", "Banheiro", "Reuniao"
$table->string('pause_reason')->nullable();
// Momento da última alteração de status (útil para calcular tempo de pausa)
$table->timestamp('last_status_change')->useCurrent();
// MUDANÇA 3: Métricas acumuladas (KPIs rápidos)
$table->integer('total_calls_answered')->default(0);
$table->integer('total_ring_no_answer')->default(0);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('agents');
}
};

View File

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

View File

@ -0,0 +1,115 @@
<script setup>
import { computed, ref } from 'vue';
import { Link } from '@inertiajs/vue3';
const props = defineProps({
agents: Array
});
const search = ref('');
// Estatísticas Rápidas
const stats = computed(() => {
const total = props.agents.length;
if (total === 0) return { online: 0, talking: 0, paused: 0 };
return {
online: props.agents.filter(a => a.status !== 'offline').length,
talking: props.agents.filter(a => a.status === 'talking').length,
paused: props.agents.filter(a => a.status === 'paused').length
};
});
// Filtro e Ordenação
const filteredAgents = computed(() => {
const term = search.value.toLowerCase();
// 1. Filtra por nome ou ramal
let list = props.agents.filter(a =>
a.name.toLowerCase().includes(term) ||
a.interface.toLowerCase().includes(term)
);
// 2. Ordena: Falando > Pausado > Livre > Offline
const priority = { talking: 1, paused: 2, available: 3, offline: 4 };
return list.sort((a, b) => (priority[a.status] || 99) - (priority[b.status] || 99));
});
const getStatusColor = (status) => {
switch(status) {
case 'available': return 'text-green-600 bg-green-50 border-green-100';
case 'paused': return 'text-yellow-600 bg-yellow-50 border-yellow-100';
case 'talking': return 'text-blue-600 bg-blue-50 border-blue-100';
default: return 'text-gray-400 bg-gray-50 border-gray-100';
}
};
const getStatusDot = (status) => {
switch(status) {
case 'available': return 'bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.6)]';
case 'paused': return 'bg-yellow-500';
case 'talking': return 'bg-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.6)]';
default: return 'bg-gray-300';
}
};
</script>
<template>
<div class="bg-white rounded-xl shadow-sm border border-gray-100 flex flex-col h-full max-h-[calc(100vh-200px)]">
<div class="p-4 border-b border-gray-50">
<div class="flex justify-between items-center mb-3">
<h3 class="font-bold text-gray-800">Equipe</h3>
<Link :href="route('agents.index')" class="text-xs text-indigo-600 hover:underline">
Ver Detalhes &rarr;
</Link>
</div>
<div class="flex gap-1 h-1.5 mb-3 rounded-full overflow-hidden bg-gray-100">
<div class="bg-blue-500 transition-all duration-500" :style="{ width: (stats.talking / agents.length * 100) + '%' }"></div>
<div class="bg-yellow-500 transition-all duration-500" :style="{ width: (stats.paused / agents.length * 100) + '%' }"></div>
<div class="bg-green-500 transition-all duration-500" :style="{ width: ((stats.online - stats.talking - stats.paused) / agents.length * 100) + '%' }"></div>
</div>
<div class="relative">
<input v-model="search" type="text" placeholder="Buscar agente..."
class="w-full pl-8 pr-3 py-1.5 text-xs border border-gray-200 rounded-lg focus:ring-indigo-500 focus:border-indigo-500 bg-gray-50 transition focus:bg-white" />
<svg class="w-3.5 h-3.5 text-gray-400 absolute left-2.5 top-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg>
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-1">
<div v-for="agent in filteredAgents" :key="agent.id"
class="group p-2.5 rounded-lg hover:bg-gray-50 transition flex items-center justify-between border border-transparent hover:border-gray-100">
<div class="flex items-center gap-3 overflow-hidden">
<div :class="`flex-shrink-0 h-2.5 w-2.5 rounded-full ${getStatusDot(agent.status)} transition-colors`"></div>
<div class="min-w-0">
<p class="text-sm font-semibold text-gray-700 truncate group-hover:text-indigo-600 transition-colors">
{{ agent.name }}
</p>
<p class="text-[10px] text-gray-400 font-mono truncate">{{ agent.interface }}</p>
</div>
</div>
<div v-if="agent.status !== 'offline'" class="flex-shrink-0">
<span :class="`px-2 py-0.5 rounded text-[9px] font-bold uppercase border ${getStatusColor(agent.status)}`">
<span v-if="agent.status === 'paused'">{{ agent.pause_reason || 'Pausa' }}</span>
<span v-else-if="agent.status === 'talking'">Falando</span>
<span v-else>Livre</span>
</span>
</div>
</div>
<div v-if="filteredAgents.length === 0" class="text-center py-6">
<p class="text-xs text-gray-400">Ninguém encontrado.</p>
</div>
</div>
</div>
</template>
<style scoped>
/* Scrollbar fina e elegante */
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
.custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
.custom-scrollbar::-webkit-scrollbar-thumb { background-color: #e5e7eb; border-radius: 20px; }
</style>

View File

@ -28,8 +28,8 @@ const openName = () => nameModalRef.value?.openModal();
const menuItems = [
{ name: 'Dashboard', route: 'dashboard', icon: 'M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z' },
{ name: 'Monitoramento', route: '#', icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' },
{ name: 'Filas', route: '#', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' },
{ name: 'Agentes', route: '#', icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z' },
{ name: 'Filas', route: 'queues.index', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' },
{ name: 'Agentes', route: 'agents.index', icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z' },
];
</script>

View File

@ -0,0 +1,151 @@
<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
import { Head, router } from '@inertiajs/vue3';
import { onMounted, onUnmounted } from 'vue';
const props = defineProps({
agents: Array,
auth: Object
});
// Configuração do WebSocket para atualização Real-Time
onMounted(() => {
const tenantChannelId = props.auth.user.tenant_id || null;
if (tenantChannelId === null) {
console.error('Tenant ID não encontrado para WebSocket');
return;
}
console.log(`📡 Conectando agentes ao canal: dashboard.${tenantChannelId}`);
window.Echo.private(`dashboard.${tenantChannelId}`)
// CORREÇÃO AQUI: Usar o mesmo nome definido no broadcastAs()
// O ponto '.' no início previne que o Laravel adicione o namespace App\Events
.listen('.metrics.updated', (e) => {
console.log("🔔 Atualização de Agentes recebida!", e);
// Recarrega apenas a prop 'agents' sem piscar a tela
router.reload({ only: ['agents'], preserveScroll: true });
})
.error((error) => {
console.error('❌ Erro no WebSocket de Agentes:', error);
});
});
onUnmounted(() => {
const tenantChannelId = props.auth.user.tenant_id;
if (tenantChannelId) {
window.Echo.leave(`dashboard.${tenantChannelId}`);
}
});
// Função auxiliar para cores de status
const getStatusColor = (status) => {
switch (status) {
case 'available': return 'bg-green-100 text-green-800 border-green-200';
case 'paused': return 'bg-yellow-100 text-yellow-800 border-yellow-200';
case 'talking': return 'bg-blue-100 text-blue-800 border-blue-200';
default: return 'bg-gray-100 text-gray-800 border-gray-200'; // offline
}
};
const getStatusDot = (status) => {
switch (status) {
case 'available': return 'bg-green-500';
case 'paused': return 'bg-yellow-500';
case 'talking': return 'bg-blue-500';
default: return 'bg-gray-400';
}
};
const translateStatus = (status) => {
switch (status) {
case 'available': return 'Disponível';
case 'paused': return 'Em Pausa';
case 'talking': return 'Em Chamada';
case 'offline': return 'Offline';
default: return status;
}
}
</script>
<template>
<Head title="Agentes" />
<AuthenticatedLayout>
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
Monitoramento de Agentes
</h2>
</template>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div v-if="agents.length > 0"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<div v-for="agent in agents" :key="agent.id"
class="bg-white overflow-hidden shadow-sm sm:rounded-lg border border-gray-100 transition hover:shadow-md">
<div class="p-6">
<div class="flex justify-between items-start mb-4">
<div>
<h3 class="text-lg font-bold text-gray-800 truncate" :title="agent.name">
{{ agent.name }}
</h3>
<p class="text-xs text-gray-400 font-mono">{{ agent.interface }}</p>
</div>
<div :class="`h-3 w-3 rounded-full ${getStatusDot(agent.status)} animate-pulse`"></div>
</div>
<div class="flex justify-center mb-4">
<span
:class="`px-3 py-1 rounded-full text-xs font-semibold border ${getStatusColor(agent.status)}`">
{{ translateStatus(agent.status) }}
<span v-if="agent.status === 'paused' && agent.pause_reason"
class="block text-[10px] text-center font-normal pt-0.5 uppercase">
{{ agent.pause_reason }}
</span>
</span>
</div>
<div class="text-center text-xs text-gray-400 mb-4">
{{ agent.status === 'offline' ? 'Visto por último:' : 'Neste status há:' }} {{
agent.status_duration }}
</div>
<div class="grid grid-cols-2 gap-2 pt-4 border-t border-gray-50">
<div class="text-center">
<span class="block text-xl font-bold text-gray-700">{{ agent.calls_answered
}}</span>
<span class="text-[10px] text-gray-400 uppercase tracking-wider">Atendidas</span>
</div>
<div class="text-center border-l border-gray-50">
<span class="block text-xl font-bold text-red-500">{{ agent.calls_missed }}</span>
<span class="text-[10px] text-gray-400 uppercase tracking-wider">Perdidas</span>
</div>
</div>
</div>
</div>
</div>
<div v-else
class="flex flex-col items-center justify-center p-12 bg-white rounded-lg shadow-sm border border-dashed border-gray-300">
<svg class="w-12 h-12 text-gray-300 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z">
</path>
</svg>
<p class="text-gray-500 text-lg">Nenhum agente detectado ainda.</p>
<p class="text-gray-400 text-sm">Faça login nos ramais ou aguarde eventos do Asterisk.</p>
</div>
</div>
</div>
</AuthenticatedLayout>
</template>

View File

@ -1,356 +1,198 @@
<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
import { Head, router, usePage } from '@inertiajs/vue3';
import { onMounted, onUnmounted, computed, reactive } from 'vue';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
import { Head, router, usePage } from '@inertiajs/vue3';
import { onMounted, onUnmounted, computed, reactive } from 'vue';
import AgentListCompact from '@/Components/AgentListCompact.vue';
const props = defineProps({
queues: Array
});
// === LÓGICA DO ACORDEÃO ===
// Usamos um Set para guardar os nomes dos setores que estão FECHADOS.
// Por padrão começa vazio (tudo aberto).
const collapsedSectors = reactive(new Set());
const toggleSector = (sectorName) => {
if (collapsedSectors.has(sectorName)) {
collapsedSectors.delete(sectorName); // Se tá fechado, abre
} else {
collapsedSectors.add(sectorName); // Se tá aberto, fecha
}
};
// Helper para saber se está aberto (para usar no ícone seta)
const isExpanded = (sectorName) => !collapsedSectors.has(sectorName);
// =================================================================
// 1. LÓGICA DE AGRUPAMENTO (Core do novo Layout)
// =================================================================
const queuesBySector = computed(() => {
const groups = {};
props.queues.forEach(queue => {
// Normaliza o nome do setor (maiúsculo para ficar bonito no cabeçalho)
const rawSector = queue.sector || 'GERAL / SEM SETOR';
const sectorName = rawSector.toUpperCase();
if (!groups[sectorName]) {
groups[sectorName] = [];
}
groups[sectorName].push(queue);
const props = defineProps({
queues: Array,
agents: Array
});
// TRUQUE DE MESTRE: Ordena as chaves (Setores) alfabeticamente
// Isso garante que 'COMERCIAL' venha antes de 'SUPORTE'
return Object.keys(groups).sort().reduce((obj, key) => {
obj[key] = groups[key];
return obj;
}, {});
});
// === LÓGICA DE FILAS (SETORES) ===
const collapsedSectors = reactive(new Set());
const toggleSector = (name) => collapsedSectors.has(name) ? collapsedSectors.delete(name) : collapsedSectors.add(name);
const isExpanded = (name) => !collapsedSectors.has(name);
// =================================================================
// 2. CÁLCULO DE TOTAIS (Mantido igual, lógica perfeita)
// =================================================================
const totalStats = computed(() => {
let stats = {
received: 0,
waiting: 0,
abandoned: 0,
total_wait_time_seconds: 0,
total_talk_time_seconds: 0
};
props.queues.forEach(queue => {
const metrics = queue.daily_metrics[0] || {};
const received = metrics.received_count || 0;
const waiting = queue.waiting_list?.length || 0; // Adicionei ?. por segurança
const abandoned = metrics.abandoned_count || 0;
const tme = metrics.avg_wait_time || 0;
const tma = metrics.avg_talk_time || 0;
stats.received += received;
stats.waiting += waiting;
stats.abandoned += abandoned;
// Média Ponderada
stats.total_wait_time_seconds += (tme * received);
const answered = Math.max(0, received - abandoned);
stats.total_talk_time_seconds += (tma * answered);
});
const answeredTotal = Math.max(0, stats.received - stats.abandoned);
return {
received: stats.received,
waiting: stats.waiting,
abandoned: stats.abandoned,
avg_wait_time: stats.received > 0 ? (stats.total_wait_time_seconds / stats.received) : 0,
avg_talk_time: answeredTotal > 0 ? (stats.total_talk_time_seconds / answeredTotal) : 0
};
});
// =================================================================
// 3. WEBSOCKET / REALTIME
// =================================================================
const page = usePage();
const userTenantId = page.props.auth.user.tenant_id;
let channel = null;
onMounted(() => {
if (userTenantId) {
if (window.Echo) {
connectToChannel();
} else {
const checkEcho = setInterval(() => {
if (window.Echo) {
clearInterval(checkEcho);
connectToChannel();
}
}, 100);
}
}
});
const connectToChannel = () => {
console.log(`📡 Conectando ao canal dashboard.${userTenantId}...`);
channel = window.Echo.private(`dashboard.${userTenantId}`)
.listen('.metrics.updated', (e) => {
console.log("🔔 Evento recebido! Atualizando...", e);
// O reload atualiza as props.queues, o que dispara
// automaticamente os computed (queuesBySector e totalStats)
router.reload({ only: ['queues'], preserveScroll: true });
})
.error((err) => {
console.error("❌ Erro de Conexão/Auth:", err);
const queuesBySector = computed(() => {
const groups = {};
props.queues.forEach(q => {
const sector = (q.sector || 'Geral').toUpperCase();
if (!groups[sector]) groups[sector] = [];
groups[sector].push(q);
});
};
return Object.keys(groups).sort().reduce((obj, key) => { obj[key] = groups[key]; return obj; }, {});
});
onUnmounted(() => {
if (userTenantId && window.Echo) {
console.log(`🔌 Desconectando...`);
window.Echo.leave(`dashboard.${userTenantId}`);
}
});
// === KPIs ===
const kpis = computed(() => {
let waiting = 0, answered = 0, abandoned = 0;
props.queues.forEach(q => {
waiting += q.waiting_list?.length || 0;
const m = q.daily_metrics[0] || {};
answered += m.answered_count || 0;
abandoned += m.abandoned_count || 0;
});
const sla = (answered + abandoned) > 0 ? Math.round((answered / (answered + abandoned)) * 100) : 100;
return { waiting, answered, abandoned, sla };
});
// =================================================================
// 4. HELPERS VISUAIS
// =================================================================
// === WEBSOCKET ===
const page = usePage();
const userTenantId = page.props.auth.user.tenant_id;
const formatDuration = (seconds) => {
if (!seconds || isNaN(seconds)) return '00:00:00';
onMounted(() => {
if (userTenantId && window.Echo) {
window.Echo.private(`dashboard.${userTenantId}`)
.listen('.metrics.updated', (e) => {
router.reload({ only: ['queues', 'agents'], preserveScroll: true });
});
}
});
const totalSeconds = Math.floor(seconds);
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
const s = totalSeconds % 60;
onUnmounted(() => {
if (userTenantId) window.Echo.leave(`dashboard.${userTenantId}`);
});
</script>
const hh = h.toString().padStart(2, '0');
const mm = m.toString().padStart(2, '0');
const ss = s.toString().padStart(2, '0');
<template>
<Head title="Monitoramento" />
return `${hh}:${mm}:${ss}`;
};
const calculatePercentage = (part, total) => {
if (!total || total === 0) return '0.0%';
const percent = (part / total) * 100;
return `${percent.toFixed(1)}%`;
};
</script>
<template>
<Head title="Dashboard" />
<AuthenticatedLayout>
<template #header>
<h2 class="text-xl font-bold text-gray-800 leading-tight">
Monitoramento em Tempo Real
</h2>
</template>
<div class="py-8 bg-gray-50 min-h-screen">
<div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="bg-white shadow-xl sm:rounded-lg overflow-hidden border border-gray-200">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead>
<tr class="bg-gray-50 text-gray-500 text-xs uppercase tracking-wider font-bold">
<th scope="col" class="px-6 py-3 text-left w-1/3">Fila / Canal</th>
<th scope="col" class="px-6 py-3 text-center w-1/6">Total (Dia)</th>
<th scope="col" class="px-6 py-3 text-center w-1/6">Na Fila</th>
<th scope="col" class="px-6 py-3 text-center w-1/6">TME (Espera)</th>
<th scope="col" class="px-6 py-3 text-center w-1/6">TMA (Conversa)</th>
<th scope="col" class="px-6 py-3 text-center w-1/6 text-red-600">Abandonos</th>
</tr>
</thead>
<tbody class="bg-white">
<tr class="bg-indigo-50 border-b-2 border-indigo-100">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div
class="flex-shrink-0 h-10 w-10 flex items-center justify-center rounded-full bg-indigo-600 text-white shadow-md">
<svg class="w-6 h-6" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 012 2h2a2 2 0 012-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z">
</path>
</svg>
</div>
<div class="ml-4">
<div class="text-sm font-black text-indigo-900 uppercase">TOTAL GERAL
</div>
<div class="text-xs text-indigo-500 font-medium">Todas as operações
</div>
</div>
</div>
</td>
<td class="px-6 py-4 text-center">
<span class="text-lg font-black text-gray-800">{{ totalStats.received }}</span>
</td>
<td class="px-6 py-4 text-center">
<div v-if="totalStats.waiting > 0"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold bg-red-600 text-white shadow animate-pulse">
{{ totalStats.waiting }}
</div>
<span v-else class="text-gray-400">-</span>
</td>
<td class="px-6 py-4 text-center font-mono text-sm font-bold text-gray-700">
{{ formatDuration(totalStats.avg_wait_time) }}
</td>
<td class="px-6 py-4 text-center font-mono text-sm font-bold text-gray-700">
{{ formatDuration(totalStats.avg_talk_time) }}
</td>
<td class="px-6 py-4 text-center">
<div class="flex flex-col items-center">
<span class="font-bold"
:class="totalStats.abandoned > 0 ? 'text-red-600' : 'text-gray-400'">
{{ totalStats.abandoned }}
</span>
<span class="text-[10px] bg-gray-200 px-1.5 rounded text-gray-600 mt-1">
{{ calculatePercentage(totalStats.abandoned, totalStats.received) }}
</span>
</div>
</td>
</tr>
<template v-for="(sectorQueues, sectorName) in queuesBySector" :key="sectorName">
<tr @click="toggleSector(sectorName)"
class="bg-gray-100 border-t border-b border-gray-200 cursor-pointer hover:bg-gray-200 transition select-none">
<td colspan="6" class="px-6 py-2 text-left">
<div class="flex items-center gap-2">
<div class="transition-transform duration-200"
:class="{ 'rotate-180': !isExpanded(sectorName) }">
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</div>
<svg class="w-4 h-4 text-indigo-500" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2"
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z">
</path>
</svg>
<span
class="text-xs font-bold text-gray-700 uppercase tracking-wider">{{
sectorName }}</span>
<span
class="ml-auto text-[10px] font-semibold bg-white border border-gray-300 px-2 py-0.5 rounded-full text-gray-500">
{{ sectorQueues.length }} filas
</span>
</div>
</td>
</tr>
<tr v-for="queue in sectorQueues" :key="queue.id" v-show="isExpanded(sectorName)"
class="hover:bg-blue-50 transition duration-150 border-b border-gray-50 last:border-0 group">
<td class="px-6 py-3 whitespace-nowrap pl-12 relative">
<div
class="absolute left-6 top-0 bottom-0 w-px bg-gray-200 group-hover:bg-blue-200">
</div>
<div class="flex items-center">
<div class="mr-3 text-gray-400 group-hover:text-blue-500">
<svg v-if="queue.type === 'voice'" class="w-4 h-4" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2"
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z">
</path>
</svg>
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z">
</path>
</svg>
</div>
<span
class="text-sm font-medium text-gray-700 group-hover:text-blue-700">
{{ queue.name }}
</span>
</div>
</td>
<td class="px-6 py-3 text-center text-sm text-gray-600 font-medium">
{{ queue.daily_metrics[0]?.received_count || 0 }}
</td>
<td class="px-6 py-3 text-center">
<div v-if="(queue.waiting_list?.length || 0) > 0"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold bg-red-100 text-red-700 border border-red-200">
{{ queue.waiting_list.length }}
</div>
<span v-else class="text-gray-300 text-xs">-</span>
</td>
<td class="px-6 py-3 text-center text-sm font-mono text-gray-600">
{{ formatDuration(queue.daily_metrics[0]?.avg_wait_time) }}
</td>
<td class="px-6 py-3 text-center text-sm font-mono text-gray-600">
{{ formatDuration(queue.daily_metrics[0]?.avg_talk_time) }}
</td>
<td class="px-6 py-3 text-center">
<span class="text-sm font-bold"
:class="(queue.daily_metrics[0]?.abandoned_count || 0) > 0 ? 'text-red-500' : 'text-gray-300'">
{{ queue.daily_metrics[0]?.abandoned_count || 0 }}
</span>
</td>
</tr>
</template>
</tbody>
</table>
<AuthenticatedLayout>
<template #header>
<div class="flex justify-between items-end">
<div>
<h2 class="font-bold text-2xl text-gray-800 leading-tight tracking-tight">
Dashboard
<p class="text-sm text-gray-500 mt-1">Visão operacional em tempo real</p>
</h2>
</div>
<div class="flex items-center gap-2 px-3 py-1 bg-green-50 text-green-700 rounded-full border border-green-100 shadow-sm mb-3.5">
<span class="relative flex h-2.5 w-2.5">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500"></span>
</span>
<span class="text-xs font-bold uppercase tracking-wide">Live</span>
</div>
</div>
</template>
<div class="py-8 max-w-[1600px] mx-auto px-4 sm:px-6 lg:px-8 space-y-8">
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="bg-white p-5 rounded-xl shadow-[0_2px_10px_-3px_rgba(6,81,237,0.1)] border border-gray-100 relative overflow-hidden group">
<div class="relative z-10">
<p class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-1">Em Espera</p>
<p class="text-4xl font-extrabold" :class="kpis.waiting > 0 ? 'text-red-500 animate-pulse' : 'text-gray-800'">
{{ kpis.waiting }}
</p>
</div>
<div class="absolute right-0 top-0 h-full w-1 bg-red-500 opacity-0 group-hover:opacity-100 transition-opacity"></div>
</div>
<div class="bg-white p-5 rounded-xl shadow-[0_2px_10px_-3px_rgba(6,81,237,0.1)] border border-gray-100 relative overflow-hidden group">
<p class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-1">Atendidas</p>
<p class="text-4xl font-extrabold text-green-600">{{ kpis.answered }}</p>
<div class="absolute right-0 top-0 h-full w-1 bg-green-500 opacity-0 group-hover:opacity-100 transition-opacity"></div>
</div>
<div class="bg-white p-5 rounded-xl shadow-[0_2px_10px_-3px_rgba(6,81,237,0.1)] border relative overflow-hidden group">
<p class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-1">Abandonadas</p>
<p class="text-4xl font-extrabold text-gray-700">{{ kpis.abandoned }}</p>
<div class="absolute right-0 top-0 h-full w-1 bg-gray-500 opacity-0 group-hover:opacity-100 transition-opacity"></div>
</div>
<div class="bg-white p-5 rounded-xl shadow-[0_2px_10px_-3px_rgba(6,81,237,0.1)] border relative overflow-hidden group">
<p class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-1">Taxa SLA</p>
<p class="text-4xl font-extrabold text-indigo-600">{{ kpis.sla }}%</p>
<div class="absolute right-0 top-0 h-full w-1 bg-indigo-500 opacity-0 group-hover:opacity-100 transition-opacity"></div>
</div>
</div>
<div class="grid grid-cols-1 xl:grid-cols-4 gap-6 items-start">
<div class="xl:col-span-3 space-y-6">
<div v-for="(queues, sector) in queuesBySector" :key="sector"
class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div @click="toggleSector(sector)"
class="bg-gray-50/50 px-5 py-3 border-b border-gray-100 cursor-pointer flex justify-between items-center hover:bg-gray-100 transition select-none group">
<div class="flex items-center gap-3">
<span class="h-2 w-2 rounded-full bg-indigo-500 group-hover:scale-125 transition-transform"></span>
<h3 class="font-bold text-gray-700">{{ sector }}</h3>
</div>
<svg class="w-5 h-5 text-gray-400 transform transition-transform duration-200"
:class="{ 'rotate-180': isExpanded(sector) }"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
<div v-show="isExpanded(sector)" class="divide-y divide-gray-50">
<div v-for="queue in queues" :key="queue.id"
class="p-5 hover:bg-indigo-50/30 transition-colors flex flex-col md:flex-row justify-between items-center gap-4">
<div class="flex-1 flex items-center gap-4 w-full">
<div class="h-10 w-10 rounded-lg bg-indigo-100 text-indigo-600 flex items-center justify-center font-bold text-sm shadow-sm">
{{ queue.source_id }}
</div>
<div>
<h4 class="font-bold text-gray-800 text-lg">{{ queue.name }}</h4>
<div class="flex gap-2 text-xs mt-1">
<span class="text-gray-400">Voz</span>
<span class="text-gray-300"></span>
<span class="text-gray-400">Prioridade Normal</span>
</div>
</div>
</div>
<div class="flex items-center justify-between w-full md:w-auto gap-8">
<div class="text-center min-w-[80px]">
<span class="block text-3xl font-extrabold leading-none transition-colors duration-300"
:class="(queue.waiting_list?.length || 0) > 0 ? 'text-red-500 scale-110' : 'text-gray-200'">
{{ queue.waiting_list?.length || 0 }}
</span>
<span class="text-[10px] font-bold text-gray-400 uppercase tracking-wide">Esperando</span>
</div>
<div class="grid grid-cols-3 gap-4 border-l border-gray-100 pl-6">
<div class="text-center">
<span class="block text-lg font-bold text-gray-700">
{{ queue.daily_metrics[0]?.received_count || 0 }}
</span>
<span class="text-[9px] text-gray-400 uppercase font-semibold">Total</span>
</div>
<div class="text-center">
<span class="block text-lg font-bold text-green-600">
{{ queue.daily_metrics[0]?.answered_count || 0 }}
</span>
<span class="text-[9px] text-gray-400 uppercase font-semibold">Atendidas</span>
</div>
<div class="text-center">
<span class="block text-lg font-bold text-red-400">
{{ queue.daily_metrics[0]?.abandoned_count || 0 }}
</span>
<span class="text-[9px] text-gray-400 uppercase font-semibold">Abandonadas</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="props.queues.length === 0" class="text-center py-12 bg-white rounded-xl border border-dashed border-gray-300">
<p class="text-gray-400">Nenhuma fila configurada neste tenant.</p>
</div>
</div>
<div class="xl:col-span-1 sticky top-6">
<AgentListCompact :agents="agents" />
</div>
</div>
</div>
</div>
</AuthenticatedLayout>
</template>
</AuthenticatedLayout>
</template>

View File

@ -0,0 +1,356 @@
<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
import { Head, router, usePage } from '@inertiajs/vue3';
import { onMounted, onUnmounted, computed, reactive } from 'vue';
const props = defineProps({
queues: Array
});
// === LÓGICA DO ACORDEÃO ===
// Usamos um Set para guardar os nomes dos setores que estão FECHADOS.
// Por padrão começa vazio (tudo aberto).
const collapsedSectors = reactive(new Set());
const toggleSector = (sectorName) => {
if (collapsedSectors.has(sectorName)) {
collapsedSectors.delete(sectorName); // Se tá fechado, abre
} else {
collapsedSectors.add(sectorName); // Se tá aberto, fecha
}
};
// Helper para saber se está aberto (para usar no ícone seta)
const isExpanded = (sectorName) => !collapsedSectors.has(sectorName);
// =================================================================
// 1. LÓGICA DE AGRUPAMENTO (Core do novo Layout)
// =================================================================
const queuesBySector = computed(() => {
const groups = {};
props.queues.forEach(queue => {
// Normaliza o nome do setor (maiúsculo para ficar bonito no cabeçalho)
const rawSector = queue.sector || 'GERAL / SEM SETOR';
const sectorName = rawSector.toUpperCase();
if (!groups[sectorName]) {
groups[sectorName] = [];
}
groups[sectorName].push(queue);
});
// TRUQUE DE MESTRE: Ordena as chaves (Setores) alfabeticamente
// Isso garante que 'COMERCIAL' venha antes de 'SUPORTE'
return Object.keys(groups).sort().reduce((obj, key) => {
obj[key] = groups[key];
return obj;
}, {});
});
// =================================================================
// 2. CÁLCULO DE TOTAIS (Mantido igual, lógica perfeita)
// =================================================================
const totalStats = computed(() => {
let stats = {
received: 0,
waiting: 0,
abandoned: 0,
total_wait_time_seconds: 0,
total_talk_time_seconds: 0
};
props.queues.forEach(queue => {
const metrics = queue.daily_metrics[0] || {};
const received = metrics.received_count || 0;
const waiting = queue.waiting_list?.length || 0; // Adicionei ?. por segurança
const abandoned = metrics.abandoned_count || 0;
const tme = metrics.avg_wait_time || 0;
const tma = metrics.avg_talk_time || 0;
stats.received += received;
stats.waiting += waiting;
stats.abandoned += abandoned;
// Média Ponderada
stats.total_wait_time_seconds += (tme * received);
const answered = Math.max(0, received - abandoned);
stats.total_talk_time_seconds += (tma * answered);
});
const answeredTotal = Math.max(0, stats.received - stats.abandoned);
return {
received: stats.received,
waiting: stats.waiting,
abandoned: stats.abandoned,
avg_wait_time: stats.received > 0 ? (stats.total_wait_time_seconds / stats.received) : 0,
avg_talk_time: answeredTotal > 0 ? (stats.total_talk_time_seconds / answeredTotal) : 0
};
});
// =================================================================
// 3. WEBSOCKET / REALTIME
// =================================================================
const page = usePage();
const userTenantId = page.props.auth.user.tenant_id;
let channel = null;
onMounted(() => {
if (userTenantId) {
if (window.Echo) {
connectToChannel();
} else {
const checkEcho = setInterval(() => {
if (window.Echo) {
clearInterval(checkEcho);
connectToChannel();
}
}, 100);
}
}
});
const connectToChannel = () => {
console.log(`📡 Conectando ao canal dashboard.${userTenantId}...`);
channel = window.Echo.private(`dashboard.${userTenantId}`)
.listen('.metrics.updated', (e) => {
console.log("🔔 Evento recebido! Atualizando...", e);
// O reload atualiza as props.queues, o que dispara
// automaticamente os computed (queuesBySector e totalStats)
router.reload({ only: ['queues'], preserveScroll: true });
})
.error((err) => {
console.error("❌ Erro de Conexão/Auth:", err);
});
};
onUnmounted(() => {
if (userTenantId && window.Echo) {
console.log(`🔌 Desconectando...`);
window.Echo.leave(`dashboard.${userTenantId}`);
}
});
// =================================================================
// 4. HELPERS VISUAIS
// =================================================================
const formatDuration = (seconds) => {
if (!seconds || isNaN(seconds)) return '00:00:00';
const totalSeconds = Math.floor(seconds);
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
const s = totalSeconds % 60;
const hh = h.toString().padStart(2, '0');
const mm = m.toString().padStart(2, '0');
const ss = s.toString().padStart(2, '0');
return `${hh}:${mm}:${ss}`;
};
const calculatePercentage = (part, total) => {
if (!total || total === 0) return '0.0%';
const percent = (part / total) * 100;
return `${percent.toFixed(1)}%`;
};
</script>
<template>
<Head title="Dashboard" />
<AuthenticatedLayout>
<template #header>
<h2 class="text-xl font-bold text-gray-800 leading-tight">
Monitoramento em Tempo Real
</h2>
</template>
<div class="py-8 bg-gray-50 min-h-screen">
<div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="bg-white shadow-xl sm:rounded-lg overflow-hidden border border-gray-200">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead>
<tr class="bg-gray-50 text-gray-500 text-xs uppercase tracking-wider font-bold">
<th scope="col" class="px-6 py-3 text-left w-1/3">Fila / Canal</th>
<th scope="col" class="px-6 py-3 text-center w-1/6">Total (Dia)</th>
<th scope="col" class="px-6 py-3 text-center w-1/6">Na Fila</th>
<th scope="col" class="px-6 py-3 text-center w-1/6">TME (Espera)</th>
<th scope="col" class="px-6 py-3 text-center w-1/6">TMA (Conversa)</th>
<th scope="col" class="px-6 py-3 text-center w-1/6 text-red-600">Abandonos</th>
</tr>
</thead>
<tbody class="bg-white">
<tr class="bg-indigo-50 border-b-2 border-indigo-100">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div
class="flex-shrink-0 h-10 w-10 flex items-center justify-center rounded-full bg-indigo-600 text-white shadow-md">
<svg class="w-6 h-6" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 012 2h2a2 2 0 012-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z">
</path>
</svg>
</div>
<div class="ml-4">
<div class="text-sm font-black text-indigo-900 uppercase">TOTAL GERAL
</div>
<div class="text-xs text-indigo-500 font-medium">Todas as operações
</div>
</div>
</div>
</td>
<td class="px-6 py-4 text-center">
<span class="text-lg font-black text-gray-800">{{ totalStats.received }}</span>
</td>
<td class="px-6 py-4 text-center">
<div v-if="totalStats.waiting > 0"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold bg-red-600 text-white shadow animate-pulse">
{{ totalStats.waiting }}
</div>
<span v-else class="text-gray-400">-</span>
</td>
<td class="px-6 py-4 text-center font-mono text-sm font-bold text-gray-700">
{{ formatDuration(totalStats.avg_wait_time) }}
</td>
<td class="px-6 py-4 text-center font-mono text-sm font-bold text-gray-700">
{{ formatDuration(totalStats.avg_talk_time) }}
</td>
<td class="px-6 py-4 text-center">
<div class="flex flex-col items-center">
<span class="font-bold"
:class="totalStats.abandoned > 0 ? 'text-red-600' : 'text-gray-400'">
{{ totalStats.abandoned }}
</span>
<span class="text-[10px] bg-gray-200 px-1.5 rounded text-gray-600 mt-1">
{{ calculatePercentage(totalStats.abandoned, totalStats.received) }}
</span>
</div>
</td>
</tr>
<template v-for="(sectorQueues, sectorName) in queuesBySector" :key="sectorName">
<tr @click="toggleSector(sectorName)"
class="bg-gray-100 border-t border-b border-gray-200 cursor-pointer hover:bg-gray-200 transition select-none">
<td colspan="6" class="px-6 py-2 text-left">
<div class="flex items-center gap-2">
<div class="transition-transform duration-200"
:class="{ 'rotate-180': !isExpanded(sectorName) }">
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</div>
<svg class="w-4 h-4 text-indigo-500" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2"
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z">
</path>
</svg>
<span
class="text-xs font-bold text-gray-700 uppercase tracking-wider">{{
sectorName }}</span>
<span
class="ml-auto text-[10px] font-semibold bg-white border border-gray-300 px-2 py-0.5 rounded-full text-gray-500">
{{ sectorQueues.length }} filas
</span>
</div>
</td>
</tr>
<tr v-for="queue in sectorQueues" :key="queue.id" v-show="isExpanded(sectorName)"
class="hover:bg-blue-50 transition duration-150 border-b border-gray-50 last:border-0 group">
<td class="px-6 py-3 whitespace-nowrap pl-12 relative">
<div
class="absolute left-6 top-0 bottom-0 w-px bg-gray-200 group-hover:bg-blue-200">
</div>
<div class="flex items-center">
<div class="mr-3 text-gray-400 group-hover:text-blue-500">
<svg v-if="queue.type === 'voice'" class="w-4 h-4" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2"
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z">
</path>
</svg>
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z">
</path>
</svg>
</div>
<span
class="text-sm font-medium text-gray-700 group-hover:text-blue-700">
{{ queue.name }}
</span>
</div>
</td>
<td class="px-6 py-3 text-center text-sm text-gray-600 font-medium">
{{ queue.daily_metrics[0]?.received_count || 0 }}
</td>
<td class="px-6 py-3 text-center">
<div v-if="(queue.waiting_list?.length || 0) > 0"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold bg-red-100 text-red-700 border border-red-200">
{{ queue.waiting_list.length }}
</div>
<span v-else class="text-gray-300 text-xs">-</span>
</td>
<td class="px-6 py-3 text-center text-sm font-mono text-gray-600">
{{ formatDuration(queue.daily_metrics[0]?.avg_wait_time) }}
</td>
<td class="px-6 py-3 text-center text-sm font-mono text-gray-600">
{{ formatDuration(queue.daily_metrics[0]?.avg_talk_time) }}
</td>
<td class="px-6 py-3 text-center">
<span class="text-sm font-bold"
:class="(queue.daily_metrics[0]?.abandoned_count || 0) > 0 ? 'text-red-500' : 'text-gray-300'">
{{ queue.daily_metrics[0]?.abandoned_count || 0 }}
</span>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</div>
</AuthenticatedLayout>
</template>

View File

@ -8,7 +8,8 @@
use App\Http\Controllers\QueueController;
use App\Http\Controllers\Admin\TenantController;
use App\Http\Controllers\Admin\UserController;
use App\Http\Controllers\AgentController;
use App\Models\Queue;
Route::get('/', function () {
return Inertia::render('Welcome', [
@ -25,6 +26,8 @@
->middleware('auth');
Route::post('Admin/tenants', [TenantController::class, 'store'])->name('tenants.store')->middleware('auth');
Route::post('Admin/users', [UserController::class, 'store'])->name('users.store')->middleware('auth');
Route::get('/agents', [AgentController::class, 'index'])->name('agents.index')->middleware('auth');
Route::get('/queues', [QueueController::class, 'index'])->name('queues.index')->middleware('auth');
Route::get('/dashboard', [DashboardController::class, 'index'])->middleware(['auth'])->name('dashboard');