feat|fix: Timezone em America/Sao_Paulo|Laravel Reverb + Pusher + Echo|Atualizações visuais.

This commit is contained in:
lukibeg 2025-12-15 21:56:42 -03:00
parent 4f24b18dd5
commit 80a4731cd0
11 changed files with 193 additions and 164 deletions

View File

@ -24,8 +24,15 @@ DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=omniboard
DB_USERNAME=root
DB_PASSWORD=
DB_USERNAME=omniboard_user
DB_PASSWORD="*Ingline.Sys#9420%SECURITY#"
REVERB_APP_ID=test
REVERB_APP_KEY=test
REVERB_APP_SECRET=test
REVERB_HOST="localhost"
REVERB_PORT=8081
REVERB_SCHEME=http
SESSION_DRIVER=database
SESSION_LIFETIME=120
@ -63,3 +70,17 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
BROADCAST_CONNECTION=reverb
REVERB_APP_ID=348643
REVERB_APP_KEY=v40nrhrty4rxpsv9mnt1
REVERB_APP_SECRET=vf0mesdcuze3dob3nage
REVERB_HOST="localhost"
REVERB_PORT=8081
REVERB_SCHEME=http
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"

View File

@ -0,0 +1,37 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel; // Importante para segurança
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class DashboardUpdate implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public $tenantId;
// Recebemos o ID da empresa que sofreu alteração
public function __construct($tenantId)
{
$this->tenantId = $tenantId;
}
// Define o canal privado: "dashboard.{tenant_id}"
public function broadcastOn(): array
{
return [
new PrivateChannel('dashboard.' . $this->tenantId),
];
}
// Opcional: Nome do evento no Front (padrão é o nome da classe)
public function broadcastAs()
{
return 'metrics.updated';
}
}

View File

@ -9,12 +9,12 @@
use App\Models\WaitingList;
use App\Models\DailyMetric;
use Carbon\Carbon;
use App\Events\DashboardUpdate;
class AmiEventController extends Controller
{
public function handle(Request $request)
{
// 1. Autenticação via Token (Header: X-Tenant-Token)
$token = $request->header('X-Tenant-Token');
$tenant = Tenant::where('api_key', $token)->first();
@ -22,7 +22,6 @@ public function handle(Request $request)
return response()->json(['error' => 'Unauthorized'], 401);
}
// 2. Dados do Evento
$data = $request->all();
$eventName = $data['Event'] ?? null;
$queueName = $data['Queue'] ?? null;
@ -31,17 +30,14 @@ public function handle(Request $request)
return response()->json(['status' => 'ignored']);
}
// DEPOIS (Correto)
$queue = Queue::where('tenant_id', $tenant->id)
->where('source_id', $queueName) // Procura "08000" na coluna source_id
->first();;
// Se a fila não existe no banco, ignoramos ou criamos automaticamente (opcional)
if (!$queue) {
return response()->json(['error' => 'Fila não encontrada'], 404);
}
// 4. Processar Lógica de Negócio
switch ($eventName) {
case 'QueueCallerJoin':
$this->handleJoin($queue, $data);
@ -60,11 +56,9 @@ public function handle(Request $request)
return response()->json(['status' => 'success']);
}
// LÓGICA DE NEGÓCIO (A mesma do script anterior, mas agora com Eloquent)
private function handleJoin($queue, $data)
{
// Adiciona na Lista de Espera COM TENANT_ID
WaitingList::create([
'tenant_id' => $queue->tenant_id, // <--- ADICIONE ESTA LINHA
'queue_id' => $queue->id,
@ -73,28 +67,25 @@ private function handleJoin($queue, $data)
'entered_at' => now(),
]);
// Incrementa Total do Dia
$this->updateMetric($queue, 'received_count', 1);
$this->broadcastUpdate($queue);
}
private function handleConnect($queue, $data)
{
// Remove da Lista de Espera
WaitingList::where('queue_id', $queue->id)
->where('caller_number', $data['CallerIDNum'])
->delete();
// Atualiza TME (Média Ponderada)
// Nota: Isso é simplificado. Em produção real, calcular média ponderada em SQL é melhor.
$metric = $this->getTodayMetric($queue);
$holdTime = intval($data['HoldTime'] ?? 0);
// Novo TME = ((Atual * Qtd) + Novo) / (Qtd + 1)
$newAvg = (($metric->avg_wait_time * $metric->answered_count) + $holdTime) / ($metric->answered_count + 1);
$metric->avg_wait_time = $newAvg;
$metric->answered_count += 1;
$metric->save();
$this->broadcastUpdate($queue);
}
private function handleComplete($queue, $data)
@ -102,28 +93,24 @@ private function handleComplete($queue, $data)
$talkTime = intval($data['TalkTime'] ?? 0);
$metric = $this->getTodayMetric($queue);
// Evita divisão por zero se o connect falhou
if ($metric->answered_count > 0) {
// Novo TMA = ((Atual * (Qtd-1)) + Novo) / Qtd
// Nota: Usamos Qtd (answered_count) como divisor pois a chamada já foi contada no Connect
$newAvg = (($metric->avg_talk_time * ($metric->answered_count - 1)) + $talkTime) / $metric->answered_count;
$metric->avg_talk_time = $newAvg;
$metric->save();
}
$this->broadcastUpdate($queue);
}
private function handleAbandon($queue, $data)
{
// Remove da Espera
WaitingList::where('queue_id', $queue->id)
->where('caller_number', $data['CallerIDNum'])
->delete();
// Incrementa Abandonos
$this->updateMetric($queue, 'abandoned_count', 1);
$this->broadcastUpdate($queue);
}
// Helpers
private function getTodayMetric($queue)
{
return DailyMetric::firstOrCreate(
@ -132,7 +119,7 @@ private function getTodayMetric($queue)
'date' => Carbon::today()
],
[
'tenant_id' => $queue->tenant_id, // <--- O PULO DO GATO: Herda o ID da Fila
'tenant_id' => $queue->tenant_id,
'received_count' => 0,
'answered_count' => 0,
'abandoned_count' => 0
@ -145,4 +132,9 @@ private function updateMetric($queue, $field, $value)
$metric = $this->getTodayMetric($queue);
$metric->increment($field, $value);
}
private function broadcastUpdate($queue)
{
// Dispara o evento apenas para o Tenant dono da fila
broadcast(new DashboardUpdate($queue->tenant_id));
}
}

View File

@ -65,7 +65,7 @@
|
*/
'timezone' => 'UTC',
'timezone' => env('APP_TIMEZONE', 'UTC'),
/*
|--------------------------------------------------------------------------

View File

@ -1,12 +1,57 @@
<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
import { Head } from '@inertiajs/vue3';
import { Head, router, usePage } from '@inertiajs/vue3';
import { onMounted, onUnmounted } from 'vue';
defineProps({
const props = defineProps({
queues: Array
});
// Formata segundos para MM:SS
const page = usePage();
const userTenantId = page.props.auth.user.tenant_id;
// Variável para guardar a inscrição do canal
let channel = null;
onMounted(() => {
if (userTenantId) {
// Verifica se o Echo já está carregado
if (window.Echo) {
connectToChannel();
} else {
// Se não, espera um pouco e tenta de novo (Fallback simples)
// Ou melhor: escuta o evento de carregamento, mas o setInterval é mais prático aqui
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!", e);
router.reload({ only: ['queues'], preserveScroll: true });
})
.error((err) => {
console.error("❌ Erro de Conexão/Auth:", err);
// Dica: Se o erro for detalhado, ele vai aparecer aqui.
});
};
onUnmounted(() => {
if (userTenantId && window.Echo) {
console.log(`🔌 Desconectando do canal dashboard.${userTenantId}`);
window.Echo.leave(`dashboard.${userTenantId}`);
}
});
const formatTime = (seconds) => {
if (!seconds && seconds !== 0) return '-';
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
@ -14,7 +59,6 @@ const formatTime = (seconds) => {
return `${m}:${s}`;
};
// Calcula a percentagem com 1 casa decimal
const calculatePercentage = (part, total) => {
if (!total || total === 0) return '0.0%';
const percent = (part / total) * 100;

View File

@ -23,7 +23,7 @@ defineProps({
<h2
class="text-xl font-semibold leading-tight text-gray-800"
>
Profile
Meu perfil
</h2>
</template>

View File

@ -42,64 +42,46 @@ const closeModal = () => {
<section class="space-y-6">
<header>
<h2 class="text-lg font-medium text-gray-900">
Delete Account
Excluir conta
</h2>
<p class="mt-1 text-sm text-gray-600">
Once your account is deleted, all of its resources and data will
be permanently deleted. Before deleting your account, please
download any data or information that you wish to retain.
Se você optar por excluir sua conta, todas as informações serão perdidas.
Será necessário entrar em contato com a Ingline Systems para gerar uma nova.
</p>
</header>
<DangerButton @click="confirmUserDeletion">Delete Account</DangerButton>
<DangerButton @click="confirmUserDeletion">Deletar conta</DangerButton>
<Modal :show="confirmingUserDeletion" @close="closeModal">
<div class="p-6">
<h2
class="text-lg font-medium text-gray-900"
>
Are you sure you want to delete your account?
<h2 class="text-lg font-medium text-gray-900">
Você tem certeza que deseja deletar sua conta?
</h2>
<p class="mt-1 text-sm text-gray-600">
Once your account is deleted, all of its resources and data
will be permanently deleted. Please enter your password to
confirm you would like to permanently delete your account.
Após a exclusão da sua conta, todos os seus recursos e dados
serão apagados permanentemente. Digite sua senha para
confirmar que deseja excluir sua conta permanentemente.
</p>
<div class="mt-6">
<InputLabel
for="password"
value="Password"
class="sr-only"
/>
<InputLabel for="password" value="Password" class="sr-only" />
<TextInput
id="password"
ref="passwordInput"
v-model="form.password"
type="password"
class="mt-1 block w-3/4"
placeholder="Password"
@keyup.enter="deleteUser"
/>
<TextInput id="password" ref="passwordInput" v-model="form.password" type="password"
class="mt-1 block w-3/4" placeholder="Digite sua senha" @keyup.enter="deleteUser" />
<InputError :message="form.errors.password" class="mt-2" />
</div>
<div class="mt-6 flex justify-end">
<SecondaryButton @click="closeModal">
Cancel
Cancelar
</SecondaryButton>
<DangerButton
class="ms-3"
:class="{ 'opacity-25': form.processing }"
:disabled="form.processing"
@click="deleteUser"
>
Delete Account
<DangerButton class="ms-3" :class="{ 'opacity-25': form.processing }" :disabled="form.processing"
@click="deleteUser">
Deletar conta
</DangerButton>
</div>
</div>

View File

@ -37,83 +37,50 @@ const updatePassword = () => {
<section>
<header>
<h2 class="text-lg font-medium text-gray-900">
Update Password
Atualizar senha
</h2>
<p class="mt-1 text-sm text-gray-600">
Ensure your account is using a long, random password to stay
secure.
Certifique-se de que sua conta esteja usando uma senha longa e aleatória para permanecer
segura.
</p>
</header>
<form @submit.prevent="updatePassword" class="mt-6 space-y-6">
<div>
<InputLabel for="current_password" value="Current Password" />
<InputLabel for="current_password" value="Credencial atual" />
<TextInput
id="current_password"
ref="currentPasswordInput"
v-model="form.current_password"
type="password"
class="mt-1 block w-full"
autocomplete="current-password"
/>
<TextInput id="current_password" ref="currentPasswordInput" v-model="form.current_password"
type="password" class="mt-1 block w-full" autocomplete="current-password" />
<InputError
:message="form.errors.current_password"
class="mt-2"
/>
<InputError :message="form.errors.current_password" class="mt-2" />
</div>
<div>
<InputLabel for="password" value="New Password" />
<InputLabel for="password" value="Nova credencial" />
<TextInput
id="password"
ref="passwordInput"
v-model="form.password"
type="password"
class="mt-1 block w-full"
autocomplete="new-password"
/>
<TextInput id="password" ref="passwordInput" v-model="form.password" type="password"
class="mt-1 block w-full" autocomplete="new-password" />
<InputError :message="form.errors.password" class="mt-2" />
</div>
<div>
<InputLabel
for="password_confirmation"
value="Confirm Password"
/>
<InputLabel for="password_confirmation" value="Confirme sua credencial nova" />
<TextInput
id="password_confirmation"
v-model="form.password_confirmation"
type="password"
class="mt-1 block w-full"
autocomplete="new-password"
/>
<TextInput id="password_confirmation" v-model="form.password_confirmation" type="password"
class="mt-1 block w-full" autocomplete="new-password" />
<InputError
:message="form.errors.password_confirmation"
class="mt-2"
/>
<InputError :message="form.errors.password_confirmation" class="mt-2" />
</div>
<div class="flex items-center gap-4">
<PrimaryButton :disabled="form.processing">Save</PrimaryButton>
<PrimaryButton :disabled="form.processing">Salvar</PrimaryButton>
<Transition
enter-active-class="transition ease-in-out"
enter-from-class="opacity-0"
leave-active-class="transition ease-in-out"
leave-to-class="opacity-0"
>
<p
v-if="form.recentlySuccessful"
class="text-sm text-gray-600"
>
Saved.
<Transition enter-active-class="transition ease-in-out" enter-from-class="opacity-0"
leave-active-class="transition ease-in-out" leave-to-class="opacity-0">
<p v-if="form.recentlySuccessful" class="text-sm text-gray-600">
Alteração concluída!
</p>
</Transition>
</div>

View File

@ -26,30 +26,20 @@ const form = useForm({
<section>
<header>
<h2 class="text-lg font-medium text-gray-900">
Profile Information
Informação de perfil
</h2>
<p class="mt-1 text-sm text-gray-600">
Update your account's profile information and email address.
Atualize as informações do seu perfil e o seu endereço de e-mail.
</p>
</header>
<form
@submit.prevent="form.patch(route('profile.update'))"
class="mt-6 space-y-6"
>
<form @submit.prevent="form.patch(route('profile.update'))" class="mt-6 space-y-6">
<div>
<InputLabel for="name" value="Name" />
<InputLabel for="name" value="Nome" />
<TextInput
id="name"
type="text"
class="mt-1 block w-full"
v-model="form.name"
required
autofocus
autocomplete="name"
/>
<TextInput id="name" type="text" class="mt-1 block w-full" v-model="form.name" required autofocus
autocomplete="name" />
<InputError class="mt-2" :message="form.errors.name" />
</div>
@ -57,53 +47,33 @@ const form = useForm({
<div>
<InputLabel for="email" value="Email" />
<TextInput
id="email"
type="email"
class="mt-1 block w-full"
v-model="form.email"
required
autocomplete="username"
/>
<TextInput id="email" type="email" class="mt-1 block w-full" v-model="form.email" required
autocomplete="username" />
<InputError class="mt-2" :message="form.errors.email" />
</div>
<div v-if="mustVerifyEmail && user.email_verified_at === null">
<p class="mt-2 text-sm text-gray-800">
Your email address is unverified.
<Link
:href="route('verification.send')"
method="post"
as="button"
class="rounded-md text-sm text-gray-600 underline hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
Click here to re-send the verification email.
Seu e-mail não está verificado.
<Link :href="route('verification.send')" method="post" as="button"
class="rounded-md text-sm text-gray-600 underline hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
Clique aqui para reenviar sua verificação de e-mail.
</Link>
</p>
<div
v-show="status === 'verification-link-sent'"
class="mt-2 text-sm font-medium text-green-600"
>
A new verification link has been sent to your email address.
<div v-show="status === 'verification-link-sent'" class="mt-2 text-sm font-medium text-green-600">
Um novo link de verificação foi enviado com sucesso para seu e-mail.
</div>
</div>
<div class="flex items-center gap-4">
<PrimaryButton :disabled="form.processing">Save</PrimaryButton>
<PrimaryButton :disabled="form.processing">Salvar</PrimaryButton>
<Transition
enter-active-class="transition ease-in-out"
enter-from-class="opacity-0"
leave-active-class="transition ease-in-out"
leave-to-class="opacity-0"
>
<p
v-if="form.recentlySuccessful"
class="text-sm text-gray-600"
>
Saved.
<Transition enter-active-class="transition ease-in-out" enter-from-class="opacity-0"
leave-active-class="transition ease-in-out" leave-to-class="opacity-0">
<p v-if="form.recentlySuccessful" class="text-sm text-gray-600">
Alteração concluída!
</p>
</Transition>
</div>

View File

@ -2,3 +2,18 @@ import axios from 'axios';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
window.Pusher = Pusher;
window.Echo = new Echo({
broadcaster: 'reverb',
key: import.meta.env.VITE_REVERB_APP_KEY,
wsHost: import.meta.env.VITE_REVERB_HOST,
wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
enabledTransports: ['ws', 'wss'],
});

View File

@ -2,6 +2,7 @@
use Illuminate\Support\Facades\Broadcast;
Broadcast::channel('App.Models.User.{id}', function ($user, $id) {
return (int) $user->id === (int) $id;
Broadcast::channel('dashboard.{tenantId}', function ($user, $tenantId) {
// Retorna true se o usuário pertencer ao Tenant que está tentando ouvir
return (int) $user->tenant_id === (int) $tenantId;
});