Compare commits

...

2 Commits

Author SHA1 Message Date
lukidev cefc446221
feat: Org. Setores|Divisão Filas
feat: Org. Setores|Divisão Filas
2025-12-19 14:13:36 -03:00
lukibeg 8c22eddf56 feat: Org. Setores|Divisão Filas 2025-12-19 14:12:53 -03:00
10 changed files with 470 additions and 116 deletions

View File

@ -138,13 +138,19 @@ private function updateMetric($queue, $field, $value)
private function saveQueues($queue, $tenant)
{
$existingQueues = Queue::where('source_id', $queue)->exists();
$existingQueue = Queue::where('tenant_id', $tenant->id)
->where('source_id', $queue)
->exists();
if (!$existingQueues) {
Queue::create(['tenant_id' => $tenant->id, 'type' => 'voice', 'source_id' => $queue]);
exit;
if (!$existingQueue) {
Queue::create([
'tenant_id' => $tenant->id,
'type' => 'voice',
'source_id' => $queue,
'name' => "Fila $queue", // Nome padrão provisório
'sector' => null // Começa sem setor
]);
}
}
private function broadcastUpdate($queue)
{

View File

@ -3,37 +3,34 @@
namespace App\Http\Controllers;
use Inertia\Inertia;
use App\Models\User;
use App\Models\Queue;
use Illuminate\Support\Facades\Auth;
use Carbon\Carbon;
class DashboardController extends Controller
{
public function index()
{
/**
* Graças à Trait 'BelongsToTenant' no Model Queue,
* o Laravel aplica automaticamente: WHERE tenant_id = ID_DO_USUARIO_LOGADO
*/
$user = Auth::user();
$queues = Queue::with([
// 1. Relacionamento com Métricas Diárias
'dailyMetrics' => function ($query) {
// Filtra para pegar apenas os dados de HOJE
$query->whereDate('date', Carbon::today());
},
// 1. Começa a query base (apenas filas do Tenant do usuário)
$query = Queue::where('tenant_id', $user->tenant_id)
->with(['dailyMetrics', 'waitingList']); // Carrega relacionamentos
// 2. Relacionamento com Lista de Espera (Ao Vivo)
'waitingList' => function ($query) {
// Ordena: Quem chegou primeiro aparece no topo da lista interna (se formos exibir detalhes)
$query->orderBy('entered_at', 'asc');
}
])
// Ordena as filas por nome para ficarem sempre na mesma posição no Dashboard
->orderBy('name', 'asc')
->get();
// 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.
if (!empty($user->allowed_sector)) {
$query->where('sector', $user->allowed_sector);
}
// 3. Executa a query
$queues = $query->get();
// 4. Entrega para o Vue
return Inertia::render('Dashboard', [
'queues' => $queues,
'queues' => $queues
]);
}
}

View File

@ -27,4 +27,26 @@ public function setQueueName(Request $request)
return back()->with('message', 'Nome da fila atualizado com sucesso!');
}
public function updateSectors(Request $request)
{
$data = $request->validate([
'queues' => 'required|array',
'queues.*.id' => 'required|exists:queues,id', // Garante que a fila existe
'queues.*.sector' => 'nullable|string|max:50', // Valida o nome do setor
]);
foreach ($data['queues'] as $item) {
// Encontra a fila pelo ID e atualiza apenas a coluna 'sector'
// Sugestão: Se usar multi-tenancy, adicione ->where('tenant_id', ...) aqui
$queue = Queue::find($item['id']);
if ($queue) {
$queue->sector = $item['sector'];
$queue->save();
}
}
return back()->with('message', 'Setores organizados com sucesso!');
}
}

View File

@ -10,7 +10,7 @@ class Queue extends Model
{
use HasFactory, BelongsToTenant;
protected $fillable = ['tenant_id', 'name', 'type', 'source_id', 'sla_threshold'];
protected $fillable = ['tenant_id', 'name', 'type', 'source_id', 'sla_threshold', 'sector'];
public function tenant()
{

View File

@ -0,0 +1,28 @@
<?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()
{
Schema::table('queues', function (Blueprint $table) {
$table->string('sector')->nullable()->after('name'); // Campo Setor
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('queues', function (Blueprint $table) {
//
});
}
};

View File

@ -0,0 +1,26 @@
<?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()
{
Schema::table('users', function (Blueprint $table) {
// Se for NULL, vê tudo. Se tiver valor (ex: "Financeiro"), só vê esse setor.
$table->string('allowed_sector')->nullable()->after('email');
});
}
public function down()
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('allowed_sector');
});
}
};

View File

@ -0,0 +1,105 @@
<script setup>
import { ref } from 'vue';
import { useForm, usePage } from '@inertiajs/vue3';
import Modal from '@/Components/Modal.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import TextInput from '@/Components/TextInput.vue';
import InputLabel from '@/Components/InputLabel.vue';
const showModal = ref(false);
const page = usePage();
// Inicializamos o form vazio
const form = useForm({
queues: []
});
const openModal = () => {
// Pegamos as filas disponíveis na página (assumindo que o Dashboard passa 'queues')
// Mapeamos para o formato que o form precisa editar
const currentQueues = page.props.queues || [];
form.queues = currentQueues.map(queue => ({
id: queue.id,
name: queue.name, // Apenas para mostrar na tela (leitura)
sector: queue.sector || '' // O valor que vamos editar
}));
showModal.value = true;
};
const submit = () => {
form.put(route('queues.update-sectors'), {
preserveScroll: true,
onSuccess: () => {
showModal.value = false;
form.reset();
},
});
};
const closeModal = () => {
showModal.value = false;
form.reset();
};
</script>
<template>
<div>
<button @click.prevent.stop="openModal"
class="block w-full px-4 py-2 text-start text-sm leading-5 text-gray-700 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 transition duration-150 ease-in-out">
Organizar Setores
</button>
<Modal :show="showModal" @close="closeModal">
<div class="p-6">
<h2 class="text-lg font-medium text-gray-900 mb-2">
Organizar Setores
</h2>
<p class="text-sm text-gray-500 mb-6">
Defina em qual setor (pasta) cada fila deve aparecer no Dashboard.
Deixe em branco para "Geral".
</p>
<div v-if="form.queues.length > 0" class="max-h-[60vh] overflow-y-auto pr-2 flex flex-col gap-3">
<div v-for="(queue, index) in form.queues" :key="queue.id"
class="flex items-center justify-between p-3 bg-gray-50 rounded border border-gray-200">
<div class="flex flex-col w-1/2 pr-4">
<span class="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Fila /
Canal</span>
<span class="text-sm font-semibold text-gray-800 truncate" :title="queue.name">
{{ queue.name }}
</span>
</div>
<div class="w-1/2">
<InputLabel :for="'sector-' + index" value="Nome do Setor" class="sr-only" />
<TextInput :id="'sector-' + index" v-model="queue.sector" type="text"
class="mt-1 block w-full py-1.5 text-sm" placeholder="Ex: Financeiro"
@keyup.enter="submit" />
</div>
</div>
</div>
<div v-else class="text-center py-8 text-gray-400 italic">
Nenhuma fila carregada nesta página.
</div>
<div class="mt-6 flex justify-end gap-3">
<SecondaryButton @click="closeModal">
Cancelar
</SecondaryButton>
<PrimaryButton @click="submit" class="bg-indigo-600 hover:bg-indigo-700"
:class="{ 'opacity-25': form.processing }" :disabled="form.processing">
Salvar Setores
</PrimaryButton>
</div>
</div>
</Modal>
</div>
</template>

View File

@ -5,6 +5,7 @@ import Dropdown from '@/Components/Dropdown.vue';
import DropdownLink from '@/Components/DropdownLink.vue';
import { Link } from '@inertiajs/vue3';
import SetQueueNameForm from '@/Components/SetQueueNameForm.vue';
import SetQueueSectorForm from '@/Components/SetQueueSectorForm.vue';
const showingSidebar = ref(false);
@ -112,9 +113,13 @@ const menuItems = [
</button>
</template>
<template #content>
<DropdownLink :href="route('profile.edit')"> Perfil </DropdownLink>
<DropdownLink :href="route('logout')" method="post" as="button"> Sair </DropdownLink>
<SetQueueNameForm />
<SetQueueSectorForm />
<div class="border-t border-gray-100 my-1"></div>
<DropdownLink :href="route('logout')" method="post" as="button"> Sair </DropdownLink>
</template>
</Dropdown>
</div>

View File

@ -1,15 +1,102 @@
<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
import { Head, router, usePage } from '@inertiajs/vue3';
import { onMounted, onUnmounted } from 'vue';
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(() => {
@ -32,7 +119,9 @@ const connectToChannel = () => {
channel = window.Echo.private(`dashboard.${userTenantId}`)
.listen('.metrics.updated', (e) => {
console.log("🔔 Evento recebido!", 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) => {
@ -42,16 +131,28 @@ const connectToChannel = () => {
onUnmounted(() => {
if (userTenantId && window.Echo) {
console.log(`🔌 Desconectando do canal dashboard.${userTenantId}`);
console.log(`🔌 Desconectando...`);
window.Echo.leave(`dashboard.${userTenantId}`);
}
});
const formatTime = (seconds) => {
if (!seconds && seconds !== 0) return '-';
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
const s = (seconds % 60).toString().padStart(2, '0');
return `${m}:${s}`;
// =================================================================
// 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) => {
@ -67,127 +168,188 @@ const calculatePercentage = (part, total) => {
<AuthenticatedLayout>
<template #header>
<h2 class="text-xl font-bold text-gray-800">
Dashboard
<h2 class="text-xl font-bold text-gray-800 leading-tight">
Monitoramento em Tempo Real
</h2>
</template>
<div class="py-8">
<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 overflow-hidden shadow-sm sm:rounded-lg">
<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 class="bg-gray-50">
<tr>
<th scope="col"
class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">
Fila / Canal
</th>
<th scope="col"
class="px-6 py-4 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">
Total (Dia)
</th>
<th scope="col"
class="px-6 py-4 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">
Em Fila (Agora)
</th>
<th scope="col"
class="px-6 py-4 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">
TME (Espera)
</th>
<th scope="col"
class="px-6 py-4 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">
TMA (Conversa)
</th>
<th scope="col"
class="px-6 py-4 text-center text-xs font-bold text-red-600 uppercase tracking-wider">
Abandonados
</th>
<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 divide-y divide-gray-200">
<tr v-for="queue in queues" :key="queue.id"
class="hover:bg-gray-50 transition duration-150">
<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-8 w-8 flex items-center justify-center rounded-full"
:class="queue.type === 'voice' ? 'bg-blue-100 text-blue-600' : 'bg-green-100 text-green-600'">
<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"
<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="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">
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-3">
<div class="text-sm font-semibold text-gray-900">{{ queue.name }}</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 whitespace-nowrap text-center text-sm font-bold text-gray-900">
{{ queue.daily_metrics[0]?.received_count || 0 }}
<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 whitespace-nowrap text-center">
<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">
<div v-if="queue.waiting_list.length > 0"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 mb-1">
<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="w-2 h-2 mr-1.5 bg-red-500 rounded-full animate-pulse"></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>
<div v-else class="text-sm text-gray-400 mb-1">-</div>
<span v-else class="text-gray-300 text-xs">-</span>
</td>
<span
v-if="queue.daily_metrics[0]?.received_count > 0 && queue.waiting_list.length > 0"
class="text-[10px] text-gray-400">
{{ calculatePercentage(queue.waiting_list.length,
queue.daily_metrics[0]?.received_count) }} do vol.
</span>
</div>
</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-4 whitespace-nowrap text-center text-sm text-gray-600">
{{ formatTime(queue.daily_metrics[0]?.avg_wait_time || 0) }}
</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-4 whitespace-nowrap text-center text-sm text-gray-600">
{{ formatTime(queue.daily_metrics[0]?.avg_talk_time || 0) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<div class="flex flex-col items-center justify-center">
<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-600' : 'text-gray-400'">
:class="(queue.daily_metrics[0]?.abandoned_count || 0) > 0 ? 'text-red-500' : 'text-gray-300'">
{{ queue.daily_metrics[0]?.abandoned_count || 0 }}
</span>
</td>
<span
class="text-xs font-medium mt-0.5 px-2 py-0.5 rounded bg-gray-100 text-gray-500">
{{ calculatePercentage(queue.daily_metrics[0]?.abandoned_count || 0,
queue.daily_metrics[0]?.received_count || 0) }}
</span>
</div>
</td>
</tr>
</template>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</AuthenticatedLayout>

View File

@ -17,6 +17,9 @@
});
Route::post('/queues', [QueueController::class, 'setQueueName'])->middleware(['auth'])->name('queues.store');
Route::put('/queues/sectors', [QueueController::class, 'updateSectors'])
->name('queues.update-sectors')
->middleware('auth');
Route::get('/dashboard', [DashboardController::class, 'index'])->middleware(['auth'])->name('dashboard');