mirror of https://github.com/Lukibeg/OmniBoard.git
feat|refactor: Controle de agentes implementados e refatoração no dashboard.
This commit is contained in:
parent
c13d9df470
commit
a29fdf8c03
|
|
@ -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
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\Agent;
|
||||||
use App\Models\Queue;
|
use App\Models\Queue;
|
||||||
use App\Models\WaitingList;
|
use App\Models\WaitingList;
|
||||||
use App\Models\DailyMetric;
|
use App\Models\DailyMetric;
|
||||||
|
|
@ -25,16 +26,33 @@ public function handle(Request $request)
|
||||||
$data = $request->all();
|
$data = $request->all();
|
||||||
$eventName = $data['Event'] ?? null;
|
$eventName = $data['Event'] ?? null;
|
||||||
$queueNumber = $data['Queue'] ?? null;
|
$queueNumber = $data['Queue'] ?? null;
|
||||||
|
$interface = $data['Interface'] ?? $data['MemberName'] ?? null; // Tenta achar quem é o agente
|
||||||
|
|
||||||
|
if ($queueNumber) {
|
||||||
$this->saveQueues($queueNumber, $tenant);
|
$this->saveQueues($queueNumber, $tenant);
|
||||||
|
}
|
||||||
|
if ($interface) {
|
||||||
|
$this->saveAgent($interface, $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
if (!$eventName || !$queueNumber) {
|
if (!$eventName || !$queueNumber) {
|
||||||
return response()->json(['status' => 'ignored']);
|
return response()->json(['status' => 'ignored']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$queue = null;
|
||||||
|
if ($queueNumber) {
|
||||||
$queue = Queue::where('tenant_id', $tenant->id)
|
$queue = Queue::where('tenant_id', $tenant->id)
|
||||||
->where('source_id', $queueNumber)
|
->where('source_id', $queueNumber)
|
||||||
->first();;
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recupera o Agente (se aplicável)
|
||||||
|
$agent = null;
|
||||||
|
if ($interface) {
|
||||||
|
$agent = Agent::where('tenant_id', $tenant->id)
|
||||||
|
->where('interface', $interface)
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
if (!$queue) {
|
if (!$queue) {
|
||||||
return response()->json(['error' => 'Fila não encontrada'], 404);
|
return response()->json(['error' => 'Fila não encontrada'], 404);
|
||||||
|
|
@ -54,6 +72,14 @@ public function handle(Request $request)
|
||||||
case 'QueueCallerAbandon':
|
case 'QueueCallerAbandon':
|
||||||
$this->handleAbandon($queue, $data);
|
$this->handleAbandon($queue, $data);
|
||||||
break;
|
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']);
|
return response()->json(['status' => 'success']);
|
||||||
|
|
@ -76,23 +102,36 @@ private function handleJoin($queue, $data)
|
||||||
|
|
||||||
private function handleConnect($queue, $data)
|
private function handleConnect($queue, $data)
|
||||||
{
|
{
|
||||||
|
// 1. Remove da Lista de Espera (Já existente)
|
||||||
WaitingList::where('queue_id', $queue->id)
|
WaitingList::where('queue_id', $queue->id)
|
||||||
->where('caller_number', $data['CallerIDNum'])
|
->where('caller_number', $data['CallerIDNum'])
|
||||||
->delete();
|
->delete();
|
||||||
|
|
||||||
|
// 2. Atualiza Métricas da Fila (Já existente)
|
||||||
$metric = $this->getTodayMetric($queue);
|
$metric = $this->getTodayMetric($queue);
|
||||||
$holdTime = intval($data['HoldTime'] ?? 0);
|
$holdTime = intval($data['HoldTime'] ?? 0);
|
||||||
|
|
||||||
$newAvg = (($metric->avg_wait_time * $metric->answered_count) + $holdTime) / ($metric->answered_count + 1);
|
$newAvg = (($metric->avg_wait_time * $metric->answered_count) + $holdTime) / ($metric->answered_count + 1);
|
||||||
|
|
||||||
$metric->avg_wait_time = $newAvg;
|
$metric->avg_wait_time = $newAvg;
|
||||||
$metric->answered_count += 1;
|
$metric->answered_count += 1;
|
||||||
$metric->save();
|
$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);
|
$this->broadcastUpdate($queue);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function handleComplete($queue, $data)
|
private function handleComplete($queue, $data)
|
||||||
{
|
{
|
||||||
|
// 1. Atualiza Métricas da Fila (Já existente)
|
||||||
$talkTime = intval($data['TalkTime'] ?? 0);
|
$talkTime = intval($data['TalkTime'] ?? 0);
|
||||||
$metric = $this->getTodayMetric($queue);
|
$metric = $this->getTodayMetric($queue);
|
||||||
|
|
||||||
|
|
@ -101,6 +140,19 @@ private function handleComplete($queue, $data)
|
||||||
$metric->avg_talk_time = $newAvg;
|
$metric->avg_talk_time = $newAvg;
|
||||||
$metric->save();
|
$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);
|
$this->broadcastUpdate($queue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,6 +166,43 @@ private function handleAbandon($queue, $data)
|
||||||
$this->broadcastUpdate($queue);
|
$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)
|
private function getTodayMetric($queue)
|
||||||
{
|
{
|
||||||
return DailyMetric::firstOrCreate(
|
return DailyMetric::firstOrCreate(
|
||||||
|
|
@ -136,6 +225,14 @@ private function updateMetric($queue, $field, $value)
|
||||||
$metric->increment($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)
|
private function saveQueues($queue, $tenant)
|
||||||
{
|
{
|
||||||
$existingQueue = Queue::where('tenant_id', $tenant->id)
|
$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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Queue;
|
use App\Models\Queue;
|
||||||
|
use App\Models\Agent; // <--- Importante: Adicionamos o Model de Agente
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
|
@ -14,23 +15,32 @@ public function index()
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$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)
|
$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
|
// Aplica o filtro de setor do supervisor, se existir
|
||||||
// Se o usuário tiver algo escrito em 'allowed_sector', filtramos.
|
|
||||||
// Se for null, ele pula esse if e traz tudo.
|
|
||||||
if (!empty($user->allowed_sector)) {
|
if (!empty($user->allowed_sector)) {
|
||||||
$query->where('sector', $user->allowed_sector);
|
$query->where('sector', $user->allowed_sector);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Executa a query
|
|
||||||
$queues = $query->get();
|
$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', [
|
return Inertia::render('Dashboard', [
|
||||||
'queues' => $queues
|
'queues' => $queues,
|
||||||
|
'agents' => $agents // <--- Agora o Dashboard recebe os agentes
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -5,9 +5,31 @@
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use App\Models\Queue;
|
use App\Models\Queue;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class QueueController extends Controller
|
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)
|
public function setQueueName(Request $request)
|
||||||
{
|
{
|
||||||
if (Auth::user()->role !== "supervisor" && Auth::user()->role !== "admin") {
|
if (Auth::user()->role !== "supervisor" && Auth::user()->role !== "admin") {
|
||||||
|
|
|
||||||
|
|
@ -4,27 +4,24 @@
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use App\Traits\BelongsToTenant;
|
|
||||||
|
|
||||||
|
|
||||||
class Agent extends Model
|
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 = [
|
protected $casts = [
|
||||||
'last_status_change' => 'datetime',
|
'last_status_change' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function tenant()
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Tenant::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function calls()
|
|
||||||
{
|
|
||||||
return $this->hasMany(Call::class);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -32,10 +32,27 @@ public function up()
|
||||||
Schema::create('agents', function (Blueprint $table) {
|
Schema::create('agents', function (Blueprint $table) {
|
||||||
$table->id();
|
$table->id();
|
||||||
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||||
$table->string('name');
|
|
||||||
$table->string('extension')->nullable();
|
$table->string('name'); // Ex: "Lucas Gomes"
|
||||||
$table->enum('status', ['available', 'paused', 'talking', 'offline'])->default('offline');
|
|
||||||
|
// 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();
|
$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();
|
$table->timestamps();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.8 MiB |
|
|
@ -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 →
|
||||||
|
</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>
|
||||||
|
|
@ -28,8 +28,8 @@ const openName = () => nameModalRef.value?.openModal();
|
||||||
const menuItems = [
|
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: '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: '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: '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: '#', 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: '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>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -1,356 +1,198 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
|
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
|
||||||
import { Head, router, usePage } from '@inertiajs/vue3';
|
import { Head, router, usePage } from '@inertiajs/vue3';
|
||||||
import { onMounted, onUnmounted, computed, reactive } from 'vue';
|
import { onMounted, onUnmounted, computed, reactive } from 'vue';
|
||||||
|
import AgentListCompact from '@/Components/AgentListCompact.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
queues: Array
|
queues: Array,
|
||||||
});
|
agents: Array
|
||||||
|
});
|
||||||
|
|
||||||
// === LÓGICA DO ACORDEÃO ===
|
// === LÓGICA DE FILAS (SETORES) ===
|
||||||
// Usamos um Set para guardar os nomes dos setores que estão FECHADOS.
|
const collapsedSectors = reactive(new Set());
|
||||||
// Por padrão começa vazio (tudo aberto).
|
const toggleSector = (name) => collapsedSectors.has(name) ? collapsedSectors.delete(name) : collapsedSectors.add(name);
|
||||||
const collapsedSectors = reactive(new Set());
|
const isExpanded = (name) => !collapsedSectors.has(name);
|
||||||
|
|
||||||
const toggleSector = (sectorName) => {
|
const queuesBySector = computed(() => {
|
||||||
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 = {};
|
const groups = {};
|
||||||
|
props.queues.forEach(q => {
|
||||||
props.queues.forEach(queue => {
|
const sector = (q.sector || 'Geral').toUpperCase();
|
||||||
// Normaliza o nome do setor (maiúsculo para ficar bonito no cabeçalho)
|
if (!groups[sector]) groups[sector] = [];
|
||||||
const rawSector = queue.sector || 'GERAL / SEM SETOR';
|
groups[sector].push(q);
|
||||||
const sectorName = rawSector.toUpperCase();
|
});
|
||||||
|
return Object.keys(groups).sort().reduce((obj, key) => { obj[key] = groups[key]; return obj; }, {});
|
||||||
if (!groups[sectorName]) {
|
|
||||||
groups[sectorName] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
groups[sectorName].push(queue);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// TRUQUE DE MESTRE: Ordena as chaves (Setores) alfabeticamente
|
// === KPIs ===
|
||||||
// Isso garante que 'COMERCIAL' venha antes de 'SUPORTE'
|
const kpis = computed(() => {
|
||||||
return Object.keys(groups).sort().reduce((obj, key) => {
|
let waiting = 0, answered = 0, abandoned = 0;
|
||||||
obj[key] = groups[key];
|
props.queues.forEach(q => {
|
||||||
return obj;
|
waiting += q.waiting_list?.length || 0;
|
||||||
}, {});
|
const m = q.daily_metrics[0] || {};
|
||||||
});
|
answered += m.answered_count || 0;
|
||||||
|
abandoned += m.abandoned_count || 0;
|
||||||
// =================================================================
|
});
|
||||||
// 2. CÁLCULO DE TOTAIS (Mantido igual, lógica perfeita)
|
const sla = (answered + abandoned) > 0 ? Math.round((answered / (answered + abandoned)) * 100) : 100;
|
||||||
// =================================================================
|
return { waiting, answered, abandoned, sla };
|
||||||
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);
|
// === WEBSOCKET ===
|
||||||
|
const page = usePage();
|
||||||
|
const userTenantId = page.props.auth.user.tenant_id;
|
||||||
|
|
||||||
return {
|
onMounted(() => {
|
||||||
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) {
|
if (userTenantId && window.Echo) {
|
||||||
console.log(`🔌 Desconectando...`);
|
window.Echo.private(`dashboard.${userTenantId}`)
|
||||||
window.Echo.leave(`dashboard.${userTenantId}`);
|
.listen('.metrics.updated', (e) => {
|
||||||
|
router.reload({ only: ['queues', 'agents'], preserveScroll: true });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// =================================================================
|
onUnmounted(() => {
|
||||||
// 4. HELPERS VISUAIS
|
if (userTenantId) window.Echo.leave(`dashboard.${userTenantId}`);
|
||||||
// =================================================================
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
const formatDuration = (seconds) => {
|
<template>
|
||||||
if (!seconds || isNaN(seconds)) return '00:00:00';
|
<Head title="Monitoramento" />
|
||||||
|
|
||||||
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>
|
<AuthenticatedLayout>
|
||||||
<template #header>
|
<template #header>
|
||||||
<h2 class="text-xl font-bold text-gray-800 leading-tight">
|
<div class="flex justify-between items-end">
|
||||||
Monitoramento em Tempo Real
|
<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>
|
</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>
|
</template>
|
||||||
|
|
||||||
<div class="py-8 bg-gray-50 min-h-screen">
|
<div class="py-8 max-w-[1600px] mx-auto px-4 sm:px-6 lg:px-8 space-y-8">
|
||||||
<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="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<div class="overflow-x-auto">
|
<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">
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<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>
|
||||||
|
|
||||||
<thead>
|
<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">
|
||||||
<tr class="bg-gray-50 text-gray-500 text-xs uppercase tracking-wider font-bold">
|
<p class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-1">Atendidas</p>
|
||||||
<th scope="col" class="px-6 py-3 text-left w-1/3">Fila / Canal</th>
|
<p class="text-4xl font-extrabold text-green-600">{{ kpis.answered }}</p>
|
||||||
<th scope="col" class="px-6 py-3 text-center w-1/6">Total (Dia)</th>
|
<div class="absolute right-0 top-0 h-full w-1 bg-green-500 opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||||
<th scope="col" class="px-6 py-3 text-center w-1/6">Na Fila</th>
|
</div>
|
||||||
<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">
|
<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>
|
||||||
|
|
||||||
<tr class="bg-indigo-50 border-b-2 border-indigo-100">
|
<div class="bg-white p-5 rounded-xl shadow-[0_2px_10px_-3px_rgba(6,81,237,0.1)] border relative overflow-hidden group">
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<p class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-1">Taxa SLA</p>
|
||||||
<div class="flex items-center">
|
<p class="text-4xl font-extrabold text-indigo-600">{{ kpis.sla }}%</p>
|
||||||
<div
|
<div class="absolute right-0 top-0 h-full w-1 bg-indigo-500 opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||||
class="flex-shrink-0 h-10 w-10 flex items-center justify-center rounded-full bg-indigo-600 text-white shadow-md">
|
</div>
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor"
|
</div>
|
||||||
viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
<div class="grid grid-cols-1 xl:grid-cols-4 gap-6 items-start">
|
||||||
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">
|
<div class="xl:col-span-3 space-y-6">
|
||||||
</path>
|
|
||||||
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-4">
|
|
||||||
<div class="text-sm font-black text-indigo-900 uppercase">TOTAL GERAL
|
<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>
|
||||||
<div class="text-xs text-indigo-500 font-medium">Todas as operações
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
|
||||||
|
|
||||||
<td class="px-6 py-4 text-center">
|
<div class="flex items-center justify-between w-full md:w-auto gap-8">
|
||||||
<span class="text-lg font-black text-gray-800">{{ totalStats.received }}</span>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td class="px-6 py-4 text-center">
|
<div class="text-center min-w-[80px]">
|
||||||
<div v-if="totalStats.waiting > 0"
|
<span class="block text-3xl font-extrabold leading-none transition-colors duration-300"
|
||||||
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold bg-red-600 text-white shadow animate-pulse">
|
:class="(queue.waiting_list?.length || 0) > 0 ? 'text-red-500 scale-110' : 'text-gray-200'">
|
||||||
{{ totalStats.waiting }}
|
{{ queue.waiting_list?.length || 0 }}
|
||||||
</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>
|
||||||
<span class="text-[10px] bg-gray-200 px-1.5 rounded text-gray-600 mt-1">
|
<span class="text-[10px] font-bold text-gray-400 uppercase tracking-wide">Esperando</span>
|
||||||
{{ 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>
|
</div>
|
||||||
|
|
||||||
<svg class="w-4 h-4 text-indigo-500" fill="none" stroke="currentColor"
|
<div class="grid grid-cols-3 gap-4 border-l border-gray-100 pl-6">
|
||||||
viewBox="0 0 24 24">
|
<div class="text-center">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
<span class="block text-lg font-bold text-gray-700">
|
||||||
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 }}
|
{{ queue.daily_metrics[0]?.received_count || 0 }}
|
||||||
</td>
|
</span>
|
||||||
|
<span class="text-[9px] text-gray-400 uppercase font-semibold">Total</span>
|
||||||
<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>
|
</div>
|
||||||
<span v-else class="text-gray-300 text-xs">-</span>
|
<div class="text-center">
|
||||||
</td>
|
<span class="block text-lg font-bold text-green-600">
|
||||||
|
{{ queue.daily_metrics[0]?.answered_count || 0 }}
|
||||||
<td class="px-6 py-3 text-center text-sm font-mono text-gray-600">
|
</span>
|
||||||
{{ formatDuration(queue.daily_metrics[0]?.avg_wait_time) }}
|
<span class="text-[9px] text-gray-400 uppercase font-semibold">Atendidas</span>
|
||||||
</td>
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
<td class="px-6 py-3 text-center text-sm font-mono text-gray-600">
|
<span class="block text-lg font-bold text-red-400">
|
||||||
{{ 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 }}
|
{{ queue.daily_metrics[0]?.abandoned_count || 0 }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
<span class="text-[9px] text-gray-400 uppercase font-semibold">Abandonadas</span>
|
||||||
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</AuthenticatedLayout>
|
</AuthenticatedLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -8,7 +8,8 @@
|
||||||
use App\Http\Controllers\QueueController;
|
use App\Http\Controllers\QueueController;
|
||||||
use App\Http\Controllers\Admin\TenantController;
|
use App\Http\Controllers\Admin\TenantController;
|
||||||
use App\Http\Controllers\Admin\UserController;
|
use App\Http\Controllers\Admin\UserController;
|
||||||
|
use App\Http\Controllers\AgentController;
|
||||||
|
use App\Models\Queue;
|
||||||
|
|
||||||
Route::get('/', function () {
|
Route::get('/', function () {
|
||||||
return Inertia::render('Welcome', [
|
return Inertia::render('Welcome', [
|
||||||
|
|
@ -25,6 +26,8 @@
|
||||||
->middleware('auth');
|
->middleware('auth');
|
||||||
Route::post('Admin/tenants', [TenantController::class, 'store'])->name('tenants.store')->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::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');
|
Route::get('/dashboard', [DashboardController::class, 'index'])->middleware(['auth'])->name('dashboard');
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue