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_HOST=127.0.0.1
DB_PORT=3306 DB_PORT=3306
DB_DATABASE=omniboard DB_DATABASE=omniboard
DB_USERNAME=root DB_USERNAME=omniboard_user
DB_PASSWORD= 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_DRIVER=database
SESSION_LIFETIME=120 SESSION_LIFETIME=120
@ -63,3 +70,17 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}" 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\WaitingList;
use App\Models\DailyMetric; use App\Models\DailyMetric;
use Carbon\Carbon; use Carbon\Carbon;
use App\Events\DashboardUpdate;
class AmiEventController extends Controller class AmiEventController extends Controller
{ {
public function handle(Request $request) public function handle(Request $request)
{ {
// 1. Autenticação via Token (Header: X-Tenant-Token)
$token = $request->header('X-Tenant-Token'); $token = $request->header('X-Tenant-Token');
$tenant = Tenant::where('api_key', $token)->first(); $tenant = Tenant::where('api_key', $token)->first();
@ -22,7 +22,6 @@ public function handle(Request $request)
return response()->json(['error' => 'Unauthorized'], 401); return response()->json(['error' => 'Unauthorized'], 401);
} }
// 2. Dados do Evento
$data = $request->all(); $data = $request->all();
$eventName = $data['Event'] ?? null; $eventName = $data['Event'] ?? null;
$queueName = $data['Queue'] ?? null; $queueName = $data['Queue'] ?? null;
@ -31,17 +30,14 @@ public function handle(Request $request)
return response()->json(['status' => 'ignored']); return response()->json(['status' => 'ignored']);
} }
// DEPOIS (Correto)
$queue = Queue::where('tenant_id', $tenant->id) $queue = Queue::where('tenant_id', $tenant->id)
->where('source_id', $queueName) // Procura "08000" na coluna source_id ->where('source_id', $queueName) // Procura "08000" na coluna source_id
->first();; ->first();;
// Se a fila não existe no banco, ignoramos ou criamos automaticamente (opcional)
if (!$queue) { if (!$queue) {
return response()->json(['error' => 'Fila não encontrada'], 404); return response()->json(['error' => 'Fila não encontrada'], 404);
} }
// 4. Processar Lógica de Negócio
switch ($eventName) { switch ($eventName) {
case 'QueueCallerJoin': case 'QueueCallerJoin':
$this->handleJoin($queue, $data); $this->handleJoin($queue, $data);
@ -60,11 +56,9 @@ public function handle(Request $request)
return response()->json(['status' => 'success']); return response()->json(['status' => 'success']);
} }
// LÓGICA DE NEGÓCIO (A mesma do script anterior, mas agora com Eloquent)
private function handleJoin($queue, $data) private function handleJoin($queue, $data)
{ {
// Adiciona na Lista de Espera COM TENANT_ID
WaitingList::create([ WaitingList::create([
'tenant_id' => $queue->tenant_id, // <--- ADICIONE ESTA LINHA 'tenant_id' => $queue->tenant_id, // <--- ADICIONE ESTA LINHA
'queue_id' => $queue->id, 'queue_id' => $queue->id,
@ -73,28 +67,25 @@ private function handleJoin($queue, $data)
'entered_at' => now(), 'entered_at' => now(),
]); ]);
// Incrementa Total do Dia
$this->updateMetric($queue, 'received_count', 1); $this->updateMetric($queue, 'received_count', 1);
$this->broadcastUpdate($queue);
} }
private function handleConnect($queue, $data) private function handleConnect($queue, $data)
{ {
// Remove da Lista de Espera
WaitingList::where('queue_id', $queue->id) WaitingList::where('queue_id', $queue->id)
->where('caller_number', $data['CallerIDNum']) ->where('caller_number', $data['CallerIDNum'])
->delete(); ->delete();
// Atualiza TME (Média Ponderada)
// Nota: Isso é simplificado. Em produção real, calcular média ponderada em SQL é melhor.
$metric = $this->getTodayMetric($queue); $metric = $this->getTodayMetric($queue);
$holdTime = intval($data['HoldTime'] ?? 0); $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); $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();
$this->broadcastUpdate($queue);
} }
private function handleComplete($queue, $data) private function handleComplete($queue, $data)
@ -102,28 +93,24 @@ private function handleComplete($queue, $data)
$talkTime = intval($data['TalkTime'] ?? 0); $talkTime = intval($data['TalkTime'] ?? 0);
$metric = $this->getTodayMetric($queue); $metric = $this->getTodayMetric($queue);
// Evita divisão por zero se o connect falhou
if ($metric->answered_count > 0) { 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; $newAvg = (($metric->avg_talk_time * ($metric->answered_count - 1)) + $talkTime) / $metric->answered_count;
$metric->avg_talk_time = $newAvg; $metric->avg_talk_time = $newAvg;
$metric->save(); $metric->save();
} }
$this->broadcastUpdate($queue);
} }
private function handleAbandon($queue, $data) private function handleAbandon($queue, $data)
{ {
// Remove da Espera
WaitingList::where('queue_id', $queue->id) WaitingList::where('queue_id', $queue->id)
->where('caller_number', $data['CallerIDNum']) ->where('caller_number', $data['CallerIDNum'])
->delete(); ->delete();
// Incrementa Abandonos
$this->updateMetric($queue, 'abandoned_count', 1); $this->updateMetric($queue, 'abandoned_count', 1);
$this->broadcastUpdate($queue);
} }
// Helpers
private function getTodayMetric($queue) private function getTodayMetric($queue)
{ {
return DailyMetric::firstOrCreate( return DailyMetric::firstOrCreate(
@ -132,7 +119,7 @@ private function getTodayMetric($queue)
'date' => Carbon::today() '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, 'received_count' => 0,
'answered_count' => 0, 'answered_count' => 0,
'abandoned_count' => 0 'abandoned_count' => 0
@ -145,4 +132,9 @@ private function updateMetric($queue, $field, $value)
$metric = $this->getTodayMetric($queue); $metric = $this->getTodayMetric($queue);
$metric->increment($field, $value); $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> <script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'; 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 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) => { const formatTime = (seconds) => {
if (!seconds && seconds !== 0) return '-'; if (!seconds && seconds !== 0) return '-';
const m = Math.floor(seconds / 60).toString().padStart(2, '0'); const m = Math.floor(seconds / 60).toString().padStart(2, '0');
@ -14,7 +59,6 @@ const formatTime = (seconds) => {
return `${m}:${s}`; return `${m}:${s}`;
}; };
// Calcula a percentagem com 1 casa decimal
const calculatePercentage = (part, total) => { const calculatePercentage = (part, total) => {
if (!total || total === 0) return '0.0%'; if (!total || total === 0) return '0.0%';
const percent = (part / total) * 100; const percent = (part / total) * 100;

View File

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

View File

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

View File

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

View File

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

View File

@ -2,3 +2,18 @@ import axios from 'axios';
window.axios = axios; window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; 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; use Illuminate\Support\Facades\Broadcast;
Broadcast::channel('App.Models.User.{id}', function ($user, $id) { Broadcast::channel('dashboard.{tenantId}', function ($user, $tenantId) {
return (int) $user->id === (int) $id; // Retorna true se o usuário pertencer ao Tenant que está tentando ouvir
return (int) $user->tenant_id === (int) $tenantId;
}); });