feat: MVP para testes.

feat: MVP para testes.
This commit is contained in:
lukidev 2025-12-15 15:47:11 -03:00 committed by GitHub
commit abb794abe5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1249 additions and 313 deletions

View File

@ -1,4 +1,4 @@
APP_NAME=Ominiboard APP_NAME=Omniboard
APP_ENV=local APP_ENV=local
APP_KEY= APP_KEY=
APP_DEBUG=true APP_DEBUG=true
@ -20,10 +20,10 @@ LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug LOG_LEVEL=debug
DB_CONNECTION=mysqli DB_CONNECTION=mysql
DB_HOST=127.0.0.1 DB_HOST=127.0.0.1
DB_PORT=3306 DB_PORT=3306
DB_DATABASE=laravel DB_DATABASE=omniboard
DB_USERNAME=root DB_USERNAME=root
DB_PASSWORD= DB_PASSWORD=

View File

@ -0,0 +1,148 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Tenant;
use App\Models\Queue;
use App\Models\WaitingList;
use App\Models\DailyMetric;
use Carbon\Carbon;
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();
if (!$tenant) {
return response()->json(['error' => 'Unauthorized'], 401);
}
// 2. Dados do Evento
$data = $request->all();
$eventName = $data['Event'] ?? null;
$queueName = $data['Queue'] ?? null;
if (!$eventName || !$queueName) {
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);
break;
case 'AgentConnect':
$this->handleConnect($queue, $data);
break;
case 'AgentComplete': // Ou QueueCallerLeave com TalkTime
$this->handleComplete($queue, $data);
break;
case 'QueueCallerAbandon':
$this->handleAbandon($queue, $data);
break;
}
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,
'caller_number' => $data['CallerIDNum'],
'caller_name' => $data['CallerIDName'] ?? 'Desconhecido',
'entered_at' => now(),
]);
// Incrementa Total do Dia
$this->updateMetric($queue, 'received_count', 1);
}
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();
}
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();
}
}
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);
}
// Helpers
private function getTodayMetric($queue)
{
return DailyMetric::firstOrCreate(
[
'queue_id' => $queue->id,
'date' => Carbon::today()
],
[
'tenant_id' => $queue->tenant_id, // <--- O PULO DO GATO: Herda o ID da Fila
'received_count' => 0,
'answered_count' => 0,
'abandoned_count' => 0
]
);
}
private function updateMetric($queue, $field, $value)
{
$metric = $this->getTodayMetric($queue);
$metric->increment($field, $value);
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers;
use Inertia\Inertia;
use App\Models\Queue;
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
*/
$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());
},
// 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();
return Inertia::render('Dashboard', [
'queues' => $queues,
]);
}
}

29
app/Models/Agent.php Normal file
View File

@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Traits\BelongsToTenant;
class Agent extends Model
{
use HasFactory, BelongsToTenant;
protected $fillable = ['tenant_id', 'name', 'extension', 'status', 'last_status_change'];
// O campo 'last_status_change' deve ser tratado como data
protected $casts = [
'last_status_change' => 'datetime',
];
public function tenant()
{
return $this->belongsTo(Tenant::class);
}
public function calls()
{
return $this->hasMany(Call::class);
}
}

37
app/Models/Call.php Normal file
View File

@ -0,0 +1,37 @@
<?php
namespace App\Models;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Call extends Model
{
use HasFactory, BelongsToTenant;
// Configuração para UUID
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = [
'id', 'tenant_id', 'queue_id', 'agent_id',
'caller_id', 'status', 'wait_time', 'talk_time',
'entered_at', 'finished_at'
];
protected $casts = [
'entered_at' => 'datetime',
'finished_at' => 'datetime',
];
public function queue()
{
return $this->belongsTo(Queue::class);
}
public function agent()
{
return $this->belongsTo(Agent::class);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Models;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class DailyMetric extends Model
{
use HasFactory, BelongsToTenant;
protected $fillable = [
'queue_id', 'date',
'received_count', 'answered_count', 'abandoned_count',
'sla_percentage', 'avg_wait_time', 'avg_talk_time',
'max_wait_time', 'transferred_count',
'tenant_id',
];
protected $casts = [
'date' => 'date',
'sla_percentage' => 'float',
];
public function queue()
{
return $this->belongsTo(Queue::class);
}
}

37
app/Models/Queue.php Normal file
View File

@ -0,0 +1,37 @@
<?php
namespace App\Models;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Queue extends Model
{
use HasFactory, BelongsToTenant;
protected $fillable = ['tenant_id', 'name', 'type', 'source_id', 'sla_threshold'];
public function tenant()
{
return $this->belongsTo(Tenant::class);
}
// Uma fila tem muitas chamadas (histórico)
public function calls()
{
return $this->hasMany(Call::class);
}
// Uma fila tem muitas métricas diárias
public function dailyMetrics()
{
return $this->hasMany(DailyMetric::class);
}
// Uma fila tem itens na lista de espera (ao vivo)
public function waitingList()
{
return $this->hasMany(WaitingList::class);
}
}

31
app/Models/Tenant.php Normal file
View File

@ -0,0 +1,31 @@
<?php
namespace App\Models;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Tenant extends Model
{
use HasFactory, BelongsToTenant;
protected $fillable = ['name', 'api_key'];
// Um Cliente tem muitas Filas
public function queues()
{
return $this->hasMany(Queue::class);
}
// Um Cliente tem muitos Agentes
public function agents()
{
return $this->hasMany(Agent::class);
}
public function users()
{
return $this->hasMany(User::class);
}
}

View File

@ -21,6 +21,7 @@ class User extends Authenticatable
'name', 'name',
'email', 'email',
'password', 'password',
'tenant_id',
]; ];
/** /**
@ -45,4 +46,9 @@ protected function casts(): array
'password' => 'hashed', 'password' => 'hashed',
]; ];
} }
public function tenant()
{
return $this->belongsTo(Tenant::class);
}
} }

View File

@ -0,0 +1,33 @@
<?php
namespace App\Models;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class WaitingList extends Model
{
use HasFactory, BelongsToTenant;
// Define explicitamente o nome da tabela se não for o plural padrão do inglês
protected $table = 'waiting_list';
protected $fillable = [
'tenant_id',
'queue_id',
'caller_name',
'caller_number',
'entered_at',
'attempt_count'
];
protected $casts = [
'entered_at' => 'datetime',
];
public function queue()
{
return $this->belongsTo(Queue::class);
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Traits;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
trait BelongsToTenant
{
// O "Boot" da Trait roda automaticamente sempre que o Model é usado
protected static function bootBelongsToTenant()
{
// Se tiver usuário logado e ele tiver um tenant_id...
if (Auth::user() && Auth::user()->tenant_id) {
// Adiciona um filtro GLOBAL em todas as consultas
static::addGlobalScope('tenant', function (Builder $builder) {
$builder->where('tenant_id', Auth::user()->tenant_id);
});
// Preenche automaticamente o tenant_id ao CRIAR registros
static::creating(function ($model) {
$model->tenant_id = Auth::user()->tenant_id;
});
}
}
public function tenant()
{
return $this->belongsTo(Tenant::class);
}
}

View File

@ -7,6 +7,7 @@
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
web: __DIR__.'/../routes/web.php', web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php', commands: __DIR__.'/../routes/console.php',
health: '/up', health: '/up',
) )

84
config/sanctum.php Normal file
View File

@ -0,0 +1,84 @@
<?php
use Laravel\Sanctum\Sanctum;
return [
/*
|--------------------------------------------------------------------------
| Stateful Domains
|--------------------------------------------------------------------------
|
| Requests from the following domains / hosts will receive stateful API
| authentication cookies. Typically, these should include your local
| and production domains which access your API via a frontend SPA.
|
*/
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
Sanctum::currentApplicationUrlWithPort(),
// Sanctum::currentRequestHost(),
))),
/*
|--------------------------------------------------------------------------
| Sanctum Guards
|--------------------------------------------------------------------------
|
| This array contains the authentication guards that will be checked when
| Sanctum is trying to authenticate a request. If none of these guards
| are able to authenticate the request, Sanctum will use the bearer
| token that's present on an incoming request for authentication.
|
*/
'guard' => ['web'],
/*
|--------------------------------------------------------------------------
| Expiration Minutes
|--------------------------------------------------------------------------
|
| This value controls the number of minutes until an issued token will be
| considered expired. This will override any values set in the token's
| "expires_at" attribute, but first-party sessions are not affected.
|
*/
'expiration' => null,
/*
|--------------------------------------------------------------------------
| Token Prefix
|--------------------------------------------------------------------------
|
| Sanctum can prefix new tokens in order to take advantage of numerous
| security scanning initiatives maintained by open source platforms
| that notify developers if they commit tokens into repositories.
|
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
*/
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
/*
|--------------------------------------------------------------------------
| Sanctum Middleware
|--------------------------------------------------------------------------
|
| When authenticating your first-party SPA with Sanctum you may need to
| customize some of the middleware Sanctum uses while processing the
| request. You may change the middleware listed below as required.
|
*/
'middleware' => [
'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class,
'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
],
];

View File

@ -0,0 +1,100 @@
<?php
// database/migrations/xxxx_create_omniboard_schema.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
// 1. Tenants (Clientes SaaS)
Schema::create('tenants', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('api_key')->unique(); // Token para o Agente Local
$table->timestamps();
});
// 2. Queues (Filas de Voz ou Chat)
Schema::create('queues', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->string('type')->default('voice'); // voice, whatsapp
$table->string('source_id')->nullable(); // ID no Asterisk/Helena
$table->integer('sla_threshold')->default(20); // Meta de SLA em segundos
$table->timestamps();
});
// 3. Agents (Atendentes)
Schema::create('agents', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->string('extension')->nullable();
$table->enum('status', ['available', 'paused', 'talking', 'offline'])->default('offline');
$table->timestamp('last_status_change')->useCurrent();
$table->timestamps();
});
// 4. Calls (Histórico detalhado)
Schema::create('calls', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('queue_id')->constrained()->cascadeOnDelete();
$table->foreignId('agent_id')->nullable()->constrained('agents');
$table->string('caller_id'); // Telefone ou Nome
$table->enum('status', ['waiting', 'answered', 'abandoned']);
$table->integer('wait_time')->default(0); // Segundos na fila
$table->integer('talk_time')->default(0); // Segundos falando
$table->timestamp('entered_at');
$table->timestamp('finished_at')->nullable();
$table->timestamps();
});
// 5. Daily Metrics (Snapshot consolidado para o Dashboard carregar rápido)
Schema::create('daily_metrics', function (Blueprint $table) {
$table->id();
$table->foreignId('queue_id')->constrained()->cascadeOnDelete();
$table->date('date');
// Métricas visíveis na imagem
$table->integer('received_count')->default(0);
$table->integer('answered_count')->default(0);
$table->integer('abandoned_count')->default(0);
$table->decimal('sla_percentage', 5, 2)->default(0); // Ex: 96.50
$table->integer('avg_wait_time')->default(0); // Em segundos
$table->integer('avg_talk_time')->default(0); // Em segundos
$table->integer('max_wait_time')->default(0); // Em segundos
$table->integer('transferred_count')->default(0);
$table->timestamps();
// Garante uma métrica por fila por dia
$table->unique(['queue_id', 'date']);
});
// 6. Waiting List (Barra lateral "Próximos Atendimentos")
Schema::create('waiting_list', function (Blueprint $table) {
$table->id();
$table->foreignId('queue_id')->constrained()->cascadeOnDelete();
$table->string('caller_name')->nullable();
$table->string('caller_number');
$table->timestamp('entered_at');
$table->integer('attempt_count')->default(1);
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('waiting_list');
Schema::dropIfExists('daily_metrics');
Schema::dropIfExists('calls');
Schema::dropIfExists('agents');
Schema::dropIfExists('queues');
Schema::dropIfExists('tenants');
}
};

View File

@ -0,0 +1,33 @@
<?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(): void
{
Schema::create('personal_access_tokens', function (Blueprint $table) {
$table->id();
$table->morphs('tokenable');
$table->text('name');
$table->string('token', 64)->unique();
$table->text('abilities')->nullable();
$table->timestamp('last_used_at')->nullable();
$table->timestamp('expires_at')->nullable()->index();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('personal_access_tokens');
}
};

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('users', function (Blueprint $table) {
// Adiciona a chave estrangeira para a tabela tenants
// nullable() permite que exista um "Super Admin" que não pertence a empresa nenhuma
$table->foreignId('tenant_id')->nullable()->after('id')->constrained('tenants')->nullOnDelete();
});
}
public function down()
{
Schema::table('users', function (Blueprint $table) {
$table->dropForeign(['tenant_id']);
$table->dropColumn('tenant_id');
});
}
};

View File

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up()
{
Schema::table('daily_metrics', function (Blueprint $table) {
$table->foreignId('tenant_id')->nullable()->after('id')->constrained()->cascadeOnDelete();
});
// SCRIPT DE CORREÇÃO: Preenche o tenant_id baseado na fila a que a métrica pertence
// Isso evita que dados antigos fiquem "órfãos"
DB::statement("
UPDATE daily_metrics dm
JOIN queues q ON dm.queue_id = q.id
SET dm.tenant_id = q.tenant_id
");
// Depois de preencher, podemos (opcionalmente) tornar obrigatório,
// mas vamos deixar nullable por segurança agora.
}
public function down()
{
Schema::table('daily_metrics', function (Blueprint $table) {
$table->dropForeign(['tenant_id']);
$table->dropColumn('tenant_id');
});
}
};

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up()
{
Schema::table('waiting_list', function (Blueprint $table) {
$table->foreignId('tenant_id')->nullable()->after('id')->constrained()->cascadeOnDelete();
});
// Popula dados existentes baseado na fila
DB::statement("
UPDATE waiting_list wl
JOIN queues q ON wl.queue_id = q.id
SET wl.tenant_id = q.tenant_id
");
}
public function down()
{
Schema::table('waiting_list', function (Blueprint $table) {
$table->dropForeign(['tenant_id']);
$table->dropColumn('tenant_id');
});
}
};

View File

@ -17,9 +17,17 @@ public function run(): void
{ {
// User::factory(10)->create(); // User::factory(10)->create();
// Cria seu usuário de acesso (Admin)
User::factory()->create([
'name' => 'Admin OmniBoard',
'email' => 'admin@omniboard.com',
'password' => bcrypt('password'), // A senha será 'password'
]);
User::factory()->create([ User::factory()->create([
'name' => 'Test User', 'name' => 'Test User',
'email' => 'test@example.com', 'email' => 'test@example.com',
]); ]);
$this->call([OmniBoardSeeder::class]);
} }
} }

View File

@ -0,0 +1,91 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
use Illuminate\Support\Str;
class OmniBoardSeeder extends Seeder
{
public function run()
{
// 1. Criar o Cliente
$tenantId = DB::table('tenants')->insertGetId([
'name' => 'LinePBX Demo',
'api_key' => Str::random(32),
'created_at' => now(),
'updated_at' => now(),
]);
// 2. Criar as Filas (Baseado na sua imagem)
$queues = [
['name' => 'Central de Atendimento Voz New', 'type' => 'voice'],
['name' => 'Unific Central de Atendimento WEB', 'type' => 'whatsapp'],
['name' => 'Unific Coleta Domiciliar WEB', 'type' => 'whatsapp'],
['name' => 'Unific Autorizacao WEB', 'type' => 'whatsapp'],
['name' => 'Imagem', 'type' => 'voice'],
];
foreach ($queues as $q) {
$queueId = DB::table('queues')->insertGetId([
'tenant_id' => $tenantId,
'name' => $q['name'],
'type' => $q['type'],
'created_at' => now(),
'updated_at' => now(),
]);
// 3. Simular Métricas do Dia (Para preencher os cards)
// Vou gerar números aleatórios mas realistas para cada fila
$received = rand(50, 600);
$answered = intval($received * rand(60, 95) / 100);
$abandoned = $received - $answered;
$sla = rand(20, 98); // SLA variado para testar cores (verde/vermelho)
DB::table('daily_metrics')->insert([
'queue_id' => $queueId,
'date' => Carbon::today(),
'received_count' => $received,
'answered_count' => $answered,
'abandoned_count' => $abandoned,
'sla_percentage' => $sla,
'avg_wait_time' => rand(60, 300), // 1 a 5 min
'avg_talk_time' => rand(180, 600), // 3 a 10 min
'max_wait_time' => rand(600, 3000), // Picos altos
'transferred_count' => rand(0, 10),
'created_at' => now(),
'updated_at' => now(),
]);
// 4. Povoar a Lista de Espera (Sidebar "Próximos Atendimentos")
// Adiciona 3 a 5 pessoas esperando em cada fila
for ($i = 0; $i < rand(3, 5); $i++) {
DB::table('waiting_list')->insert([
'queue_id' => $queueId,
'caller_name' => fake()->firstName() . ' ' . fake()->lastName(),
'caller_number' => fake()->phoneNumber(),
'entered_at' => Carbon::now()->subMinutes(rand(1, 45)), // Entrou entre 1 e 45 min atrás
'attempt_count' => rand(1, 3),
'created_at' => now(),
'updated_at' => now(),
]);
}
}
// 5. Criar Agentes
$statuses = ['available', 'talking', 'paused', 'offline'];
for ($i=0; $i < 10; $i++) {
DB::table('agents')->insert([
'tenant_id' => $tenantId,
'name' => fake()->name(),
'extension' => rand(1000, 9999),
'status' => $statuses[array_rand($statuses)],
'last_status_change' => now()->subMinutes(rand(5, 120)),
'created_at' => now(),
'updated_at' => now(),
]);
}
}
}

View File

@ -3,196 +3,133 @@ import { ref } from 'vue';
import ApplicationLogo from '@/Components/ApplicationLogo.vue'; import ApplicationLogo from '@/Components/ApplicationLogo.vue';
import Dropdown from '@/Components/Dropdown.vue'; import Dropdown from '@/Components/Dropdown.vue';
import DropdownLink from '@/Components/DropdownLink.vue'; import DropdownLink from '@/Components/DropdownLink.vue';
import NavLink from '@/Components/NavLink.vue';
import ResponsiveNavLink from '@/Components/ResponsiveNavLink.vue';
import { Link } from '@inertiajs/vue3'; import { Link } from '@inertiajs/vue3';
const showingNavigationDropdown = ref(false); const showingSidebar = ref(false);
const menuItems = [
{ name: 'Dashboard', route: 'dashboard', icon: 'M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z' },
{ name: 'Monitoramento', route: '#', icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' },
{ name: 'Relatórios', route: '#', icon: 'M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
{ name: 'Filas', route: '#', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' },
{ name: 'Agentes', route: '#', icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z' },
{ name: 'Configurações', route: '#', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z' },
];
</script> </script>
<template> <template>
<div> <div class="flex h-screen bg-gray-50 overflow-hidden font-sans text-gray-800">
<div class="min-h-screen bg-gray-100">
<nav <aside :class="showingSidebar ? 'translate-x-0 ease-out' : '-translate-x-full ease-in'"
class="border-b border-gray-100 bg-white" class="fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg md:shadow-none border-r border-gray-200 transform transition-transform duration-300 md:relative md:translate-x-0">
>
<!-- Primary Navigation Menu --> <div class="flex items-center justify-center h-20 bg-white border-b border-gray-100">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> <Link :href="route('dashboard')" class="flex items-center space-x-2 group">
<div class="flex h-16 justify-between"> <div class="flex flex-col items-center">
<div class="flex"> <span class="text-2xl font-bold tracking-wide">
<!-- Logo --> <span class="text-ingline-800">Ing</span>
<div class="flex shrink-0 items-center"> <span class="text-ingline-500">line</span>
<Link :href="route('dashboard')"> </span>
<ApplicationLogo <span
class="block h-9 w-auto fill-current text-gray-800" class="text-[10px] uppercase tracking-[0.2em] text-ingline-black font-semibold">Systems</span>
/> </div>
</Link> </Link>
</div> </div>
<!-- Navigation Links --> <nav class="mt-6 px-3 space-y-1">
<Link v-for="item in menuItems" :key="item.name" :href="item.route === '#' ? '#' : route(item.route)"
class="group flex items-center px-4 py-3 text-sm font-medium rounded-lg transition-all duration-200"
:class="item.active || route().current(item.route)
? 'bg-ingline-500 text-white shadow-md shadow-blue-200'
: 'text-gray-600 hover:bg-gray-50 hover:text-ingline-600'">
<svg class="mr-3 flex-shrink-0 h-5 w-5 transition-colors duration-200"
:class="item.active || route().current(item.route) ? 'text-white' : 'text-gray-400 group-hover:text-ingline-500'"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="item.icon" />
</svg>
{{ item.name }}
</Link>
</nav>
<div class="absolute bottom-0 w-full p-4 bg-white border-t border-gray-100">
<div class="flex items-center space-x-3">
<div <div
class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex" class="h-9 w-9 rounded-full bg-ingline-50 flex items-center justify-center text-ingline-600 font-bold border border-ingline-100">
> {{ $page.props.auth.user.name.charAt(0) }}
<NavLink </div>
:href="route('dashboard')" <div>
:active="route().current('dashboard')" <p class="text-sm font-bold text-gray-800">{{ $page.props.auth.user.name }}</p>
> <div class="flex items-center">
Dashboard <div class="h-1.5 w-1.5 rounded-full bg-green-500 mr-1.5"></div>
</NavLink> <p class="text-xs text-gray-500">Conectado</p>
</div> </div>
</div> </div>
</div>
</div>
</aside>
<div class="hidden sm:ms-6 sm:flex sm:items-center"> <div class="flex-1 flex flex-col overflow-hidden bg-gray-50">
<!-- Settings Dropdown -->
<div class="relative ms-3">
<Dropdown align="right" width="48">
<template #trigger>
<span class="inline-flex rounded-md">
<button
type="button"
class="inline-flex items-center rounded-md border border-transparent bg-white px-3 py-2 text-sm font-medium leading-4 text-gray-500 transition duration-150 ease-in-out hover:text-gray-700 focus:outline-none"
>
{{ $page.props.auth.user.name }}
<svg <header
class="-me-0.5 ms-2 h-4 w-4" class="bg-white shadow-sm h-16 flex items-center justify-between px-8 z-10 border-b border-gray-100">
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" <button @click="showingSidebar = !showingSidebar"
fill="currentColor" class="md:hidden text-gray-500 hover:text-ingline-500 focus:outline-none">
> <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
fill-rule="evenodd" d="M4 6h16M4 12h16M4 18h16" />
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg> </svg>
</button> </button>
</span>
</template>
<div class="flex-1 ml-4 md:ml-0">
<slot name="header" />
</div>
<div class="flex items-center space-x-4">
<button
class="relative p-2 text-gray-400 hover:text-ingline-500 transition-colors rounded-full hover:bg-gray-50">
<span
class="absolute top-1.5 right-1.5 h-2 w-2 rounded-full bg-red-500 border-2 border-white"></span>
<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="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9">
</path>
</svg>
</button>
<div class="relative">
<Dropdown align="right" width="48">
<template #trigger>
<button type="button"
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-600 bg-white hover:text-ingline-600 focus:outline-none transition ease-in-out duration-150">
Opções
<svg class="ml-2 -mr-0.5 h-4 w-4" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clip-rule="evenodd" />
</svg>
</button>
</template>
<template #content> <template #content>
<DropdownLink <DropdownLink :href="route('profile.edit')"> Perfil </DropdownLink>
:href="route('profile.edit')" <DropdownLink :href="route('logout')" method="post" as="button"> Sair </DropdownLink>
>
Profile
</DropdownLink>
<DropdownLink
:href="route('logout')"
method="post"
as="button"
>
Log Out
</DropdownLink>
</template> </template>
</Dropdown> </Dropdown>
</div> </div>
</div> </div>
<!-- Hamburger -->
<div class="-me-2 flex items-center sm:hidden">
<button
@click="
showingNavigationDropdown =
!showingNavigationDropdown
"
class="inline-flex items-center justify-center rounded-md p-2 text-gray-400 transition duration-150 ease-in-out hover:bg-gray-100 hover:text-gray-500 focus:bg-gray-100 focus:text-gray-500 focus:outline-none"
>
<svg
class="h-6 w-6"
stroke="currentColor"
fill="none"
viewBox="0 0 24 24"
>
<path
:class="{
hidden: showingNavigationDropdown,
'inline-flex':
!showingNavigationDropdown,
}"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
<path
:class="{
hidden: !showingNavigationDropdown,
'inline-flex':
showingNavigationDropdown,
}"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
</div>
<!-- Responsive Navigation Menu -->
<div
:class="{
block: showingNavigationDropdown,
hidden: !showingNavigationDropdown,
}"
class="sm:hidden"
>
<div class="space-y-1 pb-3 pt-2">
<ResponsiveNavLink
:href="route('dashboard')"
:active="route().current('dashboard')"
>
Dashboard
</ResponsiveNavLink>
</div>
<!-- Responsive Settings Options -->
<div
class="border-t border-gray-200 pb-1 pt-4"
>
<div class="px-4">
<div
class="text-base font-medium text-gray-800"
>
{{ $page.props.auth.user.name }}
</div>
<div class="text-sm font-medium text-gray-500">
{{ $page.props.auth.user.email }}
</div>
</div>
<div class="mt-3 space-y-1">
<ResponsiveNavLink :href="route('profile.edit')">
Profile
</ResponsiveNavLink>
<ResponsiveNavLink
:href="route('logout')"
method="post"
as="button"
>
Log Out
</ResponsiveNavLink>
</div>
</div>
</div>
</nav>
<!-- Page Heading -->
<header
class="bg-white shadow"
v-if="$slots.header"
>
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
<slot name="header" />
</div>
</header> </header>
<!-- Page Content --> <main class="flex-1 overflow-x-hidden overflow-y-auto">
<main> <div class="container mx-auto px-6 py-8">
<slot /> <slot />
</div>
</main> </main>
</div> </div>
<div v-if="showingSidebar" @click="showingSidebar = false"
class="fixed inset-0 z-40 bg-gray-900 opacity-20 md:hidden">
</div>
</div> </div>
</template> </template>

View File

@ -1,22 +1,25 @@
<script setup> <script setup>
import ApplicationLogo from '@/Components/ApplicationLogo.vue'; import ApplicationLogo from '@/Components/ApplicationLogo.vue';
import { Link } from '@inertiajs/vue3'; import { Link } from '@inertiajs/vue3';
</script> </script>
<template> <template>
<div <div class="min-h-screen flex flex-col sm:justify-center items-center pt-6 sm:pt-0 bg-gray-100">
class="flex min-h-screen flex-col items-center bg-gray-100 pt-6 sm:justify-center sm:pt-0"
>
<div> <div>
<Link href="/"> <Link href="/" class="flex flex-col items-center group">
<ApplicationLogo class="h-20 w-20 fill-current text-gray-500" /> <div class="flex flex-col items-center">
<span class="text-4xl font-bold tracking-wide">
<span class="text-ingline-800">Ing</span><span class="text-ingline-500">line</span>
</span>
<span class="text-xs uppercase tracking-[0.3em] text-ingline-black font-semibold mt-1">Systems</span>
</div>
</Link> </Link>
</div> </div>
<div <div
class="mt-6 w-full overflow-hidden bg-white px-6 py-4 shadow-md sm:max-w-md sm:rounded-lg" class="w-full sm:max-w-md mt-6 px-6 py-8 bg-white shadow-xl overflow-hidden sm:rounded-lg border-t-4 border-ingline-500"
> >
<slot /> <slot />
</div> </div>
</div> </div>
</template> </template>

View File

@ -26,9 +26,8 @@ const submit = () => {
<Head title="Forgot Password" /> <Head title="Forgot Password" />
<div class="mb-4 text-sm text-gray-600"> <div class="mb-4 text-sm text-gray-600">
Forgot your password? No problem. Just let us know your email Esqueceu sua senha? Sem problemas! Apenas nos deixe saber seu endereço de e-mail
address and we will email you a password reset link that will allow e nós enviaremos um link de redefinição para sua senha!
you to choose a new one.
</div> </div>
<div <div
@ -60,7 +59,7 @@ const submit = () => {
:class="{ 'opacity-25': form.processing }" :class="{ 'opacity-25': form.processing }"
:disabled="form.processing" :disabled="form.processing"
> >
Email Password Reset Link Enviar e-mail de redefinição de senha!
</PrimaryButton> </PrimaryButton>
</div> </div>
</form> </form>

View File

@ -1,37 +1,42 @@
<script setup> <script setup>
import Checkbox from '@/Components/Checkbox.vue'; import Checkbox from '@/Components/Checkbox.vue';
import GuestLayout from '@/Layouts/GuestLayout.vue'; import GuestLayout from '@/Layouts/GuestLayout.vue';
import InputError from '@/Components/InputError.vue'; import InputError from '@/Components/InputError.vue';
import InputLabel from '@/Components/InputLabel.vue'; import InputLabel from '@/Components/InputLabel.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue'; import PrimaryButton from '@/Components/PrimaryButton.vue';
import TextInput from '@/Components/TextInput.vue'; import TextInput from '@/Components/TextInput.vue';
import { Head, Link, useForm } from '@inertiajs/vue3'; import { Head, Link, useForm } from '@inertiajs/vue3';
defineProps({ defineProps({
canResetPassword: { canResetPassword: {
type: Boolean, type: Boolean,
}, },
status: { status: {
type: String, type: String,
}, },
}); });
const form = useForm({ const form = useForm({
email: '', email: '',
password: '', password: '',
remember: false, remember: false,
}); });
const submit = () => { const submit = () => {
form.post(route('login'), { form.post(route('login'), {
onFinish: () => form.reset('password'), onFinish: () => form.reset('password'),
}); });
}; };
</script> </script>
<template> <template>
<GuestLayout> <GuestLayout>
<Head title="Log in" /> <Head title="Acesso ao Sistema" />
<div class="mb-6 text-center">
<h2 class="text-2xl font-bold text-ingline-800">Bem-vindo de volta</h2>
<p class="text-sm text-gray-500 mt-2">Acesse o painel de controle da sua operação</p>
</div>
<div v-if="status" class="mb-4 text-sm font-medium text-green-600"> <div v-if="status" class="mb-4 text-sm font-medium text-green-600">
{{ status }} {{ status }}
@ -39,62 +44,68 @@ const submit = () => {
<form @submit.prevent="submit"> <form @submit.prevent="submit">
<div> <div>
<InputLabel for="email" value="Email" /> <InputLabel for="email" value="E-mail Corporativo" class="text-gray-700" />
<TextInput <TextInput
id="email" id="email"
type="email" type="email"
class="mt-1 block w-full" class="mt-1 block w-full border-gray-300 focus:border-ingline-500 focus:ring-ingline-500 rounded-md shadow-sm"
v-model="form.email" v-model="form.email"
required required
autofocus autofocus
autocomplete="username" autocomplete="username"
placeholder="exemplo@empresa.com"
/> />
<InputError class="mt-2" :message="form.errors.email" /> <InputError class="mt-2" :message="form.errors.email" />
</div> </div>
<div class="mt-4"> <div class="mt-4">
<InputLabel for="password" value="Password" /> <InputLabel for="password" value="Senha" class="text-gray-700" />
<TextInput <TextInput
id="password" id="password"
type="password" type="password"
class="mt-1 block w-full" class="mt-1 block w-full border-gray-300 focus:border-ingline-500 focus:ring-ingline-500 rounded-md shadow-sm"
v-model="form.password" v-model="form.password"
required required
autocomplete="current-password" autocomplete="current-password"
placeholder="••••••••"
/> />
<InputError class="mt-2" :message="form.errors.password" /> <InputError class="mt-2" :message="form.errors.password" />
</div> </div>
<div class="mt-4 block"> <div class="flex items-center justify-between mt-4">
<label class="flex items-center"> <label class="flex items-center">
<Checkbox name="remember" v-model:checked="form.remember" /> <Checkbox name="remember" v-model:checked="form.remember" class="text-ingline-600 focus:ring-ingline-500" />
<span class="ms-2 text-sm text-gray-600" <span class="ms-2 text-sm text-gray-600">Lembrar dispositivo</span>
>Remember me</span
>
</label> </label>
</div>
<div class="mt-4 flex items-center justify-end">
<Link <Link
v-if="canResetPassword" v-if="canResetPassword"
:href="route('password.request')" :href="route('password.request')"
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" class="text-sm text-ingline-500 hover:text-ingline-700 font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-ingline-500"
> >
Forgot your password? Esqueceu a senha?
</Link> </Link>
</div>
<div class="mt-6">
<PrimaryButton <PrimaryButton
class="ms-4" class="w-full justify-center py-3 bg-ingline-500 hover:bg-ingline-600 focus:bg-ingline-700 active:bg-ingline-700 transition duration-150 ease-in-out"
:class="{ 'opacity-25': form.processing }" :class="{ 'opacity-75': form.processing }"
:disabled="form.processing" :disabled="form.processing"
> >
Log in Acessar Plataforma
</PrimaryButton> </PrimaryButton>
</div> </div>
<div class="mt-6 text-center">
<p class="text-xs text-gray-400">
Protegido por Ingline Systems © 2025
</p>
</div>
</form> </form>
</GuestLayout> </GuestLayout>
</template> </template>

View File

@ -97,7 +97,7 @@ const submit = () => {
:href="route('login')" :href="route('login')"
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" 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"
> >
Already registered? possui uma conta?
</Link> </Link>
<PrimaryButton <PrimaryButton
@ -105,7 +105,7 @@ const submit = () => {
:class="{ 'opacity-25': form.processing }" :class="{ 'opacity-25': form.processing }"
:disabled="form.processing" :disabled="form.processing"
> >
Register Registrar
</PrimaryButton> </PrimaryButton>
</div> </div>
</form> </form>

View File

@ -93,7 +93,7 @@ const submit = () => {
:class="{ 'opacity-25': form.processing }" :class="{ 'opacity-25': form.processing }"
:disabled="form.processing" :disabled="form.processing"
> >
Reset Password Redefinir senha
</PrimaryButton> </PrimaryButton>
</div> </div>
</form> </form>

View File

@ -1,29 +1,154 @@
<script setup> <script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'; import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
import { Head } from '@inertiajs/vue3'; import { Head } from '@inertiajs/vue3';
defineProps({
queues: Array
});
// Formata segundos para MM:SS
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}`;
};
// Calcula a percentagem com 1 casa decimal
const calculatePercentage = (part, total) => {
if (!total || total === 0) return '0.0%';
const percent = (part / total) * 100;
return `${percent.toFixed(1)}%`;
};
</script> </script>
<template> <template>
<Head title="Dashboard" /> <Head title="Dashboard" />
<AuthenticatedLayout> <AuthenticatedLayout>
<template #header> <template #header>
<h2 <h2 class="text-xl font-bold text-gray-800">
class="text-xl font-semibold leading-tight text-gray-800"
>
Dashboard Dashboard
</h2> </h2>
</template> </template>
<div class="py-12"> <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="overflow-hidden bg-white shadow-sm sm:rounded-lg" <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
> <div class="overflow-x-auto">
<div class="p-6 text-gray-900"> <table class="min-w-full divide-y divide-gray-200">
You're logged in! <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>
</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">
<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"
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>
<div class="ml-3">
<div class="text-sm font-semibold text-gray-900">{{ queue.name }}</div>
</div> </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>
<td class="px-6 py-4 whitespace-nowrap 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="w-2 h-2 mr-1.5 bg-red-500 rounded-full animate-pulse"></span>
{{ queue.waiting_list.length }}
</div>
<div v-else class="text-sm text-gray-400 mb-1">-</div>
<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-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-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"
:class="(queue.daily_metrics[0]?.abandoned_count || 0) > 0 ? 'text-red-600' : 'text-gray-400'">
{{ queue.daily_metrics[0]?.abandoned_count || 0 }}
</span>
<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>
</tbody>
</table>
</div>
</div>
</div> </div>
</div> </div>
</AuthenticatedLayout> </AuthenticatedLayout>

11
routes/api.php Normal file
View File

@ -0,0 +1,11 @@
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\AmiEventController;
Route::get('/user', function (Request $request) {
return $request->user();
})->middleware('auth:sanctum');
Route::post('/v1/ami-informations', [AmiEventController::class, 'handle']);

View File

@ -1,5 +1,6 @@
<?php <?php
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\ProfileController; use App\Http\Controllers\ProfileController;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@ -14,9 +15,7 @@
]); ]);
}); });
Route::get('/dashboard', function () { Route::get('/dashboard', [DashboardController::class, 'index'])->middleware(['auth', 'verified'])->name('dashboard');
return Inertia::render('Dashboard');
})->middleware(['auth', 'verified'])->name('dashboard');
Route::middleware('auth')->group(function () { Route::middleware('auth')->group(function () {
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
@ -24,4 +23,4 @@
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
}); });
require __DIR__.'/auth.php'; require __DIR__ . '/auth.php';

View File

@ -15,6 +15,16 @@ export default {
fontFamily: { fontFamily: {
sans: ['Figtree', ...defaultTheme.fontFamily.sans], sans: ['Figtree', ...defaultTheme.fontFamily.sans],
}, },
// ADICIONE AQUI AS CORES DA INGLINE
colors: {
ingline: {
900: '#0f172a', // Fundo Sidebar (Quase preto)
800: '#1e293b', // "Ing" (Azul Petróleo)
500: '#3b82f6', // "line" (Azul Vibrante)
400: '#60a5fa', // Azul claro (Hover)
purple: '#9333ea', // O Roxo da logo
}
}
}, },
}, },