mirror of https://github.com/Lukibeg/OmniBoard.git
Compare commits
No commits in common. "cefc4462219548b5f5828dc564dbe1bb34420b82" and "ae264024907a2a28b17172675ab4a246d281ce2d" have entirely different histories.
cefc446221
...
ae26402490
|
|
@ -138,19 +138,13 @@ private function updateMetric($queue, $field, $value)
|
||||||
|
|
||||||
private function saveQueues($queue, $tenant)
|
private function saveQueues($queue, $tenant)
|
||||||
{
|
{
|
||||||
$existingQueue = Queue::where('tenant_id', $tenant->id)
|
$existingQueues = Queue::where('source_id', $queue)->exists();
|
||||||
->where('source_id', $queue)
|
|
||||||
->exists();
|
|
||||||
|
|
||||||
if (!$existingQueue) {
|
if (!$existingQueues) {
|
||||||
Queue::create([
|
Queue::create(['tenant_id' => $tenant->id, 'type' => 'voice', 'source_id' => $queue]);
|
||||||
'tenant_id' => $tenant->id,
|
exit;
|
||||||
'type' => 'voice',
|
|
||||||
'source_id' => $queue,
|
|
||||||
'name' => "Fila $queue", // Nome padrão provisório
|
|
||||||
'sector' => null // Começa sem setor
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
private function broadcastUpdate($queue)
|
private function broadcastUpdate($queue)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -3,34 +3,37 @@
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Queue;
|
use App\Models\Queue;
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
|
|
||||||
class DashboardController extends Controller
|
class DashboardController extends Controller
|
||||||
{
|
{
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
/**
|
||||||
|
* Graças à Trait 'BelongsToTenant' no Model Queue,
|
||||||
|
* o Laravel aplica automaticamente: WHERE tenant_id = ID_DO_USUARIO_LOGADO
|
||||||
|
*/
|
||||||
|
|
||||||
// 1. Começa a query base (apenas filas do Tenant do usuário)
|
$queues = Queue::with([
|
||||||
$query = Queue::where('tenant_id', $user->tenant_id)
|
// 1. Relacionamento com Métricas Diárias
|
||||||
->with(['dailyMetrics', 'waitingList']); // Carrega relacionamentos
|
'dailyMetrics' => function ($query) {
|
||||||
|
// Filtra para pegar apenas os dados de HOJE
|
||||||
|
$query->whereDate('date', Carbon::today());
|
||||||
|
},
|
||||||
|
|
||||||
// 2. APLICAR O FILTRO DE SETOR
|
// 2. Relacionamento com Lista de Espera (Ao Vivo)
|
||||||
// Se o usuário tiver algo escrito em 'allowed_sector', filtramos.
|
'waitingList' => function ($query) {
|
||||||
// Se for null, ele pula esse if e traz tudo.
|
// Ordena: Quem chegou primeiro aparece no topo da lista interna (se formos exibir detalhes)
|
||||||
if (!empty($user->allowed_sector)) {
|
$query->orderBy('entered_at', 'asc');
|
||||||
$query->where('sector', $user->allowed_sector);
|
}
|
||||||
}
|
])
|
||||||
|
// Ordena as filas por nome para ficarem sempre na mesma posição no Dashboard
|
||||||
|
->orderBy('name', 'asc')
|
||||||
|
->get();
|
||||||
|
|
||||||
// 3. Executa a query
|
|
||||||
$queues = $query->get();
|
|
||||||
|
|
||||||
// 4. Entrega para o Vue
|
|
||||||
return Inertia::render('Dashboard', [
|
return Inertia::render('Dashboard', [
|
||||||
'queues' => $queues
|
'queues' => $queues,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,26 +27,4 @@ public function setQueueName(Request $request)
|
||||||
|
|
||||||
return back()->with('message', 'Nome da fila atualizado com sucesso!');
|
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!');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ class Queue extends Model
|
||||||
{
|
{
|
||||||
use HasFactory, BelongsToTenant;
|
use HasFactory, BelongsToTenant;
|
||||||
|
|
||||||
protected $fillable = ['tenant_id', 'name', 'type', 'source_id', 'sla_threshold', 'sector'];
|
protected $fillable = ['tenant_id', 'name', 'type', 'source_id', 'sla_threshold'];
|
||||||
|
|
||||||
public function tenant()
|
public function tenant()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
<?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) {
|
|
||||||
//
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
<?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');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -5,7 +5,6 @@ import Dropdown from '@/Components/Dropdown.vue';
|
||||||
import DropdownLink from '@/Components/DropdownLink.vue';
|
import DropdownLink from '@/Components/DropdownLink.vue';
|
||||||
import { Link } from '@inertiajs/vue3';
|
import { Link } from '@inertiajs/vue3';
|
||||||
import SetQueueNameForm from '@/Components/SetQueueNameForm.vue';
|
import SetQueueNameForm from '@/Components/SetQueueNameForm.vue';
|
||||||
import SetQueueSectorForm from '@/Components/SetQueueSectorForm.vue';
|
|
||||||
|
|
||||||
const showingSidebar = ref(false);
|
const showingSidebar = ref(false);
|
||||||
|
|
||||||
|
|
@ -113,13 +112,9 @@ const menuItems = [
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
|
|
||||||
<DropdownLink :href="route('profile.edit')"> Perfil </DropdownLink>
|
<DropdownLink :href="route('profile.edit')"> Perfil </DropdownLink>
|
||||||
<SetQueueNameForm />
|
|
||||||
<SetQueueSectorForm />
|
|
||||||
<div class="border-t border-gray-100 my-1"></div>
|
|
||||||
<DropdownLink :href="route('logout')" method="post" as="button"> Sair </DropdownLink>
|
<DropdownLink :href="route('logout')" method="post" as="button"> Sair </DropdownLink>
|
||||||
|
<SetQueueNameForm />
|
||||||
</template>
|
</template>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,102 +1,15 @@
|
||||||
<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 } from 'vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
queues: Array
|
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 page = usePage();
|
||||||
const userTenantId = page.props.auth.user.tenant_id;
|
const userTenantId = page.props.auth.user.tenant_id;
|
||||||
|
|
||||||
let channel = null;
|
let channel = null;
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|
@ -119,9 +32,7 @@ const connectToChannel = () => {
|
||||||
|
|
||||||
channel = window.Echo.private(`dashboard.${userTenantId}`)
|
channel = window.Echo.private(`dashboard.${userTenantId}`)
|
||||||
.listen('.metrics.updated', (e) => {
|
.listen('.metrics.updated', (e) => {
|
||||||
console.log("🔔 Evento recebido! Atualizando...", e);
|
console.log("🔔 Evento recebido!", e);
|
||||||
// O reload atualiza as props.queues, o que dispara
|
|
||||||
// automaticamente os computed (queuesBySector e totalStats)
|
|
||||||
router.reload({ only: ['queues'], preserveScroll: true });
|
router.reload({ only: ['queues'], preserveScroll: true });
|
||||||
})
|
})
|
||||||
.error((err) => {
|
.error((err) => {
|
||||||
|
|
@ -131,28 +42,16 @@ const connectToChannel = () => {
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (userTenantId && window.Echo) {
|
if (userTenantId && window.Echo) {
|
||||||
console.log(`🔌 Desconectando...`);
|
console.log(`🔌 Desconectando do canal dashboard.${userTenantId}`);
|
||||||
window.Echo.leave(`dashboard.${userTenantId}`);
|
window.Echo.leave(`dashboard.${userTenantId}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// =================================================================
|
const formatTime = (seconds) => {
|
||||||
// 4. HELPERS VISUAIS
|
if (!seconds && seconds !== 0) return '-';
|
||||||
// =================================================================
|
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
|
||||||
|
const s = (seconds % 60).toString().padStart(2, '0');
|
||||||
const formatDuration = (seconds) => {
|
return `${m}:${s}`;
|
||||||
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) => {
|
const calculatePercentage = (part, total) => {
|
||||||
|
|
@ -168,188 +67,127 @@ const calculatePercentage = (part, total) => {
|
||||||
|
|
||||||
<AuthenticatedLayout>
|
<AuthenticatedLayout>
|
||||||
<template #header>
|
<template #header>
|
||||||
<h2 class="text-xl font-bold text-gray-800 leading-tight">
|
<h2 class="text-xl font-bold text-gray-800">
|
||||||
Monitoramento em Tempo Real
|
Dashboard
|
||||||
</h2>
|
</h2>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="py-8 bg-gray-50 min-h-screen">
|
<div class="py-8">
|
||||||
<div class="mx-auto max-w-7xl sm:px-6 lg:px-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="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
<thead>
|
<tr>
|
||||||
<tr class="bg-gray-50 text-gray-500 text-xs uppercase tracking-wider font-bold">
|
<th scope="col"
|
||||||
<th scope="col" class="px-6 py-3 text-left w-1/3">Fila / Canal</th>
|
class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">
|
||||||
<th scope="col" class="px-6 py-3 text-center w-1/6">Total (Dia)</th>
|
Fila / Canal
|
||||||
<th scope="col" class="px-6 py-3 text-center w-1/6">Na Fila</th>
|
</th>
|
||||||
<th scope="col" class="px-6 py-3 text-center w-1/6">TME (Espera)</th>
|
<th scope="col"
|
||||||
<th scope="col" class="px-6 py-3 text-center w-1/6">TMA (Conversa)</th>
|
class="px-6 py-4 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">
|
||||||
<th scope="col" class="px-6 py-3 text-center w-1/6 text-red-600">Abandonos</th>
|
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>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody class="bg-white">
|
<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">
|
||||||
|
|
||||||
<tr class="bg-indigo-50 border-b-2 border-indigo-100">
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div
|
<div class="flex-shrink-0 h-8 w-8 flex items-center justify-center rounded-full"
|
||||||
class="flex-shrink-0 h-10 w-10 flex items-center justify-center rounded-full bg-indigo-600 text-white shadow-md">
|
:class="queue.type === 'voice' ? 'bg-blue-100 text-blue-600' : 'bg-green-100 text-green-600'">
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor"
|
<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">
|
viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
stroke-width="2"
|
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">
|
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>
|
</path>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-4">
|
<div class="ml-3">
|
||||||
<div class="text-sm font-black text-indigo-900 uppercase">TOTAL GERAL
|
<div class="text-sm font-semibold text-gray-900">{{ queue.name }}</div>
|
||||||
</div>
|
|
||||||
<div class="text-xs text-indigo-500 font-medium">Todas as operações
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td class="px-6 py-4 text-center">
|
<td class="px-6 py-4 whitespace-nowrap text-center text-sm font-bold text-gray-900">
|
||||||
<span class="text-lg font-black text-gray-800">{{ totalStats.received }}</span>
|
{{ queue.daily_metrics[0]?.received_count || 0 }}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td class="px-6 py-4 text-center">
|
<td class="px-6 py-4 whitespace-nowrap 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 class="flex flex-col items-center">
|
||||||
<span class="font-bold"
|
<div v-if="queue.waiting_list.length > 0"
|
||||||
:class="totalStats.abandoned > 0 ? 'text-red-600' : 'text-gray-400'">
|
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">
|
||||||
{{ 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
|
<span
|
||||||
class="text-xs font-bold text-gray-700 uppercase tracking-wider">{{
|
class="w-2 h-2 mr-1.5 bg-red-500 rounded-full animate-pulse"></span>
|
||||||
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 }}
|
{{ queue.waiting_list.length }}
|
||||||
</div>
|
</div>
|
||||||
<span v-else class="text-gray-300 text-xs">-</span>
|
<div v-else class="text-sm text-gray-400 mb-1">-</div>
|
||||||
</td>
|
|
||||||
|
|
||||||
<td class="px-6 py-3 text-center text-sm font-mono text-gray-600">
|
<span
|
||||||
{{ formatDuration(queue.daily_metrics[0]?.avg_wait_time) }}
|
v-if="queue.daily_metrics[0]?.received_count > 0 && queue.waiting_list.length > 0"
|
||||||
</td>
|
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">
|
<td class="px-6 py-4 whitespace-nowrap text-center text-sm text-gray-600">
|
||||||
{{ formatDuration(queue.daily_metrics[0]?.avg_talk_time) }}
|
{{ formatTime(queue.daily_metrics[0]?.avg_wait_time || 0) }}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td class="px-6 py-3 text-center">
|
<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">
|
||||||
<span class="text-sm font-bold"
|
<span class="text-sm font-bold"
|
||||||
:class="(queue.daily_metrics[0]?.abandoned_count || 0) > 0 ? 'text-red-500' : 'text-gray-300'">
|
:class="(queue.daily_metrics[0]?.abandoned_count || 0) > 0 ? 'text-red-600' : 'text-gray-400'">
|
||||||
{{ queue.daily_metrics[0]?.abandoned_count || 0 }}
|
{{ queue.daily_metrics[0]?.abandoned_count || 0 }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
|
||||||
|
|
||||||
</tr>
|
<span
|
||||||
</template>
|
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>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AuthenticatedLayout>
|
</AuthenticatedLayout>
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,6 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::post('/queues', [QueueController::class, 'setQueueName'])->middleware(['auth'])->name('queues.store');
|
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');
|
Route::get('/dashboard', [DashboardController::class, 'index'])->middleware(['auth'])->name('dashboard');
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue