diff --git a/app/Http/Controllers/AgentController.php b/app/Http/Controllers/AgentController.php new file mode 100644 index 0000000..bd97d4f --- /dev/null +++ b/app/Http/Controllers/AgentController.php @@ -0,0 +1,46 @@ +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 + ]); + } +} diff --git a/app/Http/Controllers/Api/AmiEventController.php b/app/Http/Controllers/Api/AmiEventController.php index e002d36..f1a9bda 100644 --- a/app/Http/Controllers/Api/AmiEventController.php +++ b/app/Http/Controllers/Api/AmiEventController.php @@ -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)); } } diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index f1cbef2..36efea7 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -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 ]); } -} +} \ No newline at end of file diff --git a/app/Http/Controllers/QueueController.php b/app/Http/Controllers/QueueController.php index 71a16ab..dbe00ab 100644 --- a/app/Http/Controllers/QueueController.php +++ b/app/Http/Controllers/QueueController.php @@ -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") { diff --git a/app/Models/Agent.php b/app/Models/Agent.php index 63806ee..1866304 100644 --- a/app/Models/Agent.php +++ b/app/Models/Agent.php @@ -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); - } -} \ No newline at end of file +} diff --git a/database/migrations/2025_12_15_134836_create_omniboard_schema.php b/database/migrations/2025_12_15_134836_create_omniboard_schema.php index a19a993..48fad5d 100644 --- a/database/migrations/2025_12_15_134836_create_omniboard_schema.php +++ b/database/migrations/2025_12_15_134836_create_omniboard_schema.php @@ -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(); }); diff --git a/database/migrations/2025_12_19_195736_create_agents_table.php b/database/migrations/2025_12_19_195736_create_agents_table.php new file mode 100644 index 0000000..9fabaee --- /dev/null +++ b/database/migrations/2025_12_19_195736_create_agents_table.php @@ -0,0 +1,53 @@ +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'); + } +}; diff --git a/public/favicon.ico b/public/favicon.ico deleted file mode 100644 index e69de29..0000000 diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000..a80cfb3 Binary files /dev/null and b/public/favicon.png differ diff --git a/resources/js/Components/AgentListCompact.vue b/resources/js/Components/AgentListCompact.vue new file mode 100644 index 0000000..c38f6b6 --- /dev/null +++ b/resources/js/Components/AgentListCompact.vue @@ -0,0 +1,115 @@ + + + + + \ No newline at end of file diff --git a/resources/js/Layouts/AuthenticatedLayout.vue b/resources/js/Layouts/AuthenticatedLayout.vue index bf254b4..0b54dea 100644 --- a/resources/js/Layouts/AuthenticatedLayout.vue +++ b/resources/js/Layouts/AuthenticatedLayout.vue @@ -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' }, ]; diff --git a/resources/js/Pages/Agents/Index.vue b/resources/js/Pages/Agents/Index.vue new file mode 100644 index 0000000..7a1eb2a --- /dev/null +++ b/resources/js/Pages/Agents/Index.vue @@ -0,0 +1,151 @@ + + + \ No newline at end of file diff --git a/resources/js/Pages/Dashboard.vue b/resources/js/Pages/Dashboard.vue index 08290bd..abfb530 100644 --- a/resources/js/Pages/Dashboard.vue +++ b/resources/js/Pages/Dashboard.vue @@ -1,356 +1,198 @@ - - \ No newline at end of file diff --git a/resources/js/Pages/Queues/Index.vue b/resources/js/Pages/Queues/Index.vue new file mode 100644 index 0000000..d3abf1e --- /dev/null +++ b/resources/js/Pages/Queues/Index.vue @@ -0,0 +1,356 @@ + + + \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 4f368d1..1170885 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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');