mirror of https://github.com/Lukibeg/OmniBoard.git
commit
abb794abe5
|
|
@ -1,4 +1,4 @@
|
|||
APP_NAME=Ominiboard
|
||||
APP_NAME=Omniboard
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
|
|
@ -20,10 +20,10 @@ LOG_STACK=single
|
|||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=mysqli
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=laravel
|
||||
DB_DATABASE=omniboard
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ class User extends Authenticatable
|
|||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'tenant_id',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -45,4 +46,9 @@ protected function casts(): array
|
|||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
|
||||
public function tenant()
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
api: __DIR__.'/../routes/api.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -17,9 +17,17 @@ public function run(): void
|
|||
{
|
||||
// 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([
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
$this->call([OmniBoardSeeder::class]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,196 +3,133 @@ import { ref } from 'vue';
|
|||
import ApplicationLogo from '@/Components/ApplicationLogo.vue';
|
||||
import Dropdown from '@/Components/Dropdown.vue';
|
||||
import DropdownLink from '@/Components/DropdownLink.vue';
|
||||
import NavLink from '@/Components/NavLink.vue';
|
||||
import ResponsiveNavLink from '@/Components/ResponsiveNavLink.vue';
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="min-h-screen bg-gray-100">
|
||||
<nav
|
||||
class="border-b border-gray-100 bg-white"
|
||||
>
|
||||
<!-- Primary Navigation Menu -->
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex h-16 justify-between">
|
||||
<div class="flex">
|
||||
<!-- Logo -->
|
||||
<div class="flex shrink-0 items-center">
|
||||
<Link :href="route('dashboard')">
|
||||
<ApplicationLogo
|
||||
class="block h-9 w-auto fill-current text-gray-800"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<div class="flex h-screen bg-gray-50 overflow-hidden font-sans text-gray-800">
|
||||
|
||||
<!-- Navigation Links -->
|
||||
<div
|
||||
class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex"
|
||||
>
|
||||
<NavLink
|
||||
:href="route('dashboard')"
|
||||
:active="route().current('dashboard')"
|
||||
>
|
||||
Dashboard
|
||||
</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
<aside :class="showingSidebar ? 'translate-x-0 ease-out' : '-translate-x-full ease-in'"
|
||||
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">
|
||||
|
||||
<div class="hidden sm:ms-6 sm:flex sm:items-center">
|
||||
<!-- 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
|
||||
class="-me-0.5 ms-2 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>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<DropdownLink
|
||||
:href="route('profile.edit')"
|
||||
>
|
||||
Profile
|
||||
</DropdownLink>
|
||||
<DropdownLink
|
||||
:href="route('logout')"
|
||||
method="post"
|
||||
as="button"
|
||||
>
|
||||
Log Out
|
||||
</DropdownLink>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</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 class="flex items-center justify-center h-20 bg-white border-b border-gray-100">
|
||||
<Link :href="route('dashboard')" class="flex items-center space-x-2 group">
|
||||
<div class="flex flex-col items-center">
|
||||
<span class="text-2xl font-bold tracking-wide">
|
||||
<span class="text-ingline-800">Ing</span>
|
||||
<span class="text-ingline-500">line</span>
|
||||
</span>
|
||||
<span
|
||||
class="text-[10px] uppercase tracking-[0.2em] text-ingline-black font-semibold">Systems</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</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>
|
||||
<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'">
|
||||
|
||||
<!-- 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>
|
||||
<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>
|
||||
|
||||
<!-- Page Heading -->
|
||||
<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
|
||||
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) }}
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-bold text-gray-800">{{ $page.props.auth.user.name }}</p>
|
||||
<div class="flex items-center">
|
||||
<div class="h-1.5 w-1.5 rounded-full bg-green-500 mr-1.5"></div>
|
||||
<p class="text-xs text-gray-500">Conectado</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="flex-1 flex flex-col overflow-hidden bg-gray-50">
|
||||
|
||||
<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">
|
||||
class="bg-white shadow-sm h-16 flex items-center justify-between px-8 z-10 border-b border-gray-100">
|
||||
|
||||
<button @click="showingSidebar = !showingSidebar"
|
||||
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 stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<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>
|
||||
<DropdownLink :href="route('profile.edit')"> Perfil </DropdownLink>
|
||||
<DropdownLink :href="route('logout')" method="post" as="button"> Sair </DropdownLink>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Page Content -->
|
||||
<main>
|
||||
<slot />
|
||||
<main class="flex-1 overflow-x-hidden overflow-y-auto">
|
||||
<div class="container mx-auto px-6 py-8">
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div v-if="showingSidebar" @click="showingSidebar = false"
|
||||
class="fixed inset-0 z-40 bg-gray-900 opacity-20 md:hidden">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,22 +1,25 @@
|
|||
<script setup>
|
||||
import ApplicationLogo from '@/Components/ApplicationLogo.vue';
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
</script>
|
||||
import ApplicationLogo from '@/Components/ApplicationLogo.vue';
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex min-h-screen flex-col items-center bg-gray-100 pt-6 sm:justify-center sm:pt-0"
|
||||
>
|
||||
<div>
|
||||
<Link href="/">
|
||||
<ApplicationLogo class="h-20 w-20 fill-current text-gray-500" />
|
||||
</Link>
|
||||
</div>
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col sm:justify-center items-center pt-6 sm:pt-0 bg-gray-100">
|
||||
<div>
|
||||
<Link href="/" class="flex flex-col items-center group">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-6 w-full overflow-hidden bg-white px-6 py-4 shadow-md sm:max-w-md sm:rounded-lg"
|
||||
>
|
||||
<slot />
|
||||
<div
|
||||
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 />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
|
@ -26,9 +26,8 @@ const submit = () => {
|
|||
<Head title="Forgot Password" />
|
||||
|
||||
<div class="mb-4 text-sm text-gray-600">
|
||||
Forgot your password? No problem. Just let us know your email
|
||||
address and we will email you a password reset link that will allow
|
||||
you to choose a new one.
|
||||
Esqueceu sua senha? Sem problemas! Apenas nos deixe saber seu endereço de e-mail
|
||||
e nós enviaremos um link de redefinição para sua senha!
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
|
@ -60,7 +59,7 @@ const submit = () => {
|
|||
:class="{ 'opacity-25': form.processing }"
|
||||
:disabled="form.processing"
|
||||
>
|
||||
Email Password Reset Link
|
||||
Enviar e-mail de redefinição de senha!
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -1,100 +1,111 @@
|
|||
<script setup>
|
||||
import Checkbox from '@/Components/Checkbox.vue';
|
||||
import GuestLayout from '@/Layouts/GuestLayout.vue';
|
||||
import InputError from '@/Components/InputError.vue';
|
||||
import InputLabel from '@/Components/InputLabel.vue';
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||
import TextInput from '@/Components/TextInput.vue';
|
||||
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||
import Checkbox from '@/Components/Checkbox.vue';
|
||||
import GuestLayout from '@/Layouts/GuestLayout.vue';
|
||||
import InputError from '@/Components/InputError.vue';
|
||||
import InputLabel from '@/Components/InputLabel.vue';
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||
import TextInput from '@/Components/TextInput.vue';
|
||||
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||
|
||||
defineProps({
|
||||
canResetPassword: {
|
||||
type: Boolean,
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
email: '',
|
||||
password: '',
|
||||
remember: false,
|
||||
});
|
||||
|
||||
const submit = () => {
|
||||
form.post(route('login'), {
|
||||
onFinish: () => form.reset('password'),
|
||||
defineProps({
|
||||
canResetPassword: {
|
||||
type: Boolean,
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<GuestLayout>
|
||||
<Head title="Log in" />
|
||||
const form = useForm({
|
||||
email: '',
|
||||
password: '',
|
||||
remember: false,
|
||||
});
|
||||
|
||||
<div v-if="status" class="mb-4 text-sm font-medium text-green-600">
|
||||
{{ status }}
|
||||
</div>
|
||||
const submit = () => {
|
||||
form.post(route('login'), {
|
||||
onFinish: () => form.reset('password'),
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<form @submit.prevent="submit">
|
||||
<div>
|
||||
<InputLabel for="email" value="Email" />
|
||||
<template>
|
||||
<GuestLayout>
|
||||
<Head title="Acesso ao Sistema" />
|
||||
|
||||
<TextInput
|
||||
id="email"
|
||||
type="email"
|
||||
class="mt-1 block w-full"
|
||||
v-model="form.email"
|
||||
required
|
||||
autofocus
|
||||
autocomplete="username"
|
||||
/>
|
||||
|
||||
<InputError class="mt-2" :message="form.errors.email" />
|
||||
<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 class="mt-4">
|
||||
<InputLabel for="password" value="Password" />
|
||||
|
||||
<TextInput
|
||||
id="password"
|
||||
type="password"
|
||||
class="mt-1 block w-full"
|
||||
v-model="form.password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
|
||||
<InputError class="mt-2" :message="form.errors.password" />
|
||||
<div v-if="status" class="mb-4 text-sm font-medium text-green-600">
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 block">
|
||||
<label class="flex items-center">
|
||||
<Checkbox name="remember" v-model:checked="form.remember" />
|
||||
<span class="ms-2 text-sm text-gray-600"
|
||||
>Remember me</span
|
||||
<form @submit.prevent="submit">
|
||||
<div>
|
||||
<InputLabel for="email" value="E-mail Corporativo" class="text-gray-700" />
|
||||
|
||||
<TextInput
|
||||
id="email"
|
||||
type="email"
|
||||
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"
|
||||
required
|
||||
autofocus
|
||||
autocomplete="username"
|
||||
placeholder="exemplo@empresa.com"
|
||||
/>
|
||||
|
||||
<InputError class="mt-2" :message="form.errors.email" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<InputLabel for="password" value="Senha" class="text-gray-700" />
|
||||
|
||||
<TextInput
|
||||
id="password"
|
||||
type="password"
|
||||
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"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
|
||||
<InputError class="mt-2" :message="form.errors.password" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<label class="flex items-center">
|
||||
<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">Lembrar dispositivo</span>
|
||||
</label>
|
||||
|
||||
<Link
|
||||
v-if="canResetPassword"
|
||||
:href="route('password.request')"
|
||||
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"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
Esqueceu a senha?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-end">
|
||||
<Link
|
||||
v-if="canResetPassword"
|
||||
: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"
|
||||
>
|
||||
Forgot your password?
|
||||
</Link>
|
||||
<div class="mt-6">
|
||||
<PrimaryButton
|
||||
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-75': form.processing }"
|
||||
:disabled="form.processing"
|
||||
>
|
||||
Acessar Plataforma
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
|
||||
<PrimaryButton
|
||||
class="ms-4"
|
||||
:class="{ 'opacity-25': form.processing }"
|
||||
:disabled="form.processing"
|
||||
>
|
||||
Log in
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</GuestLayout>
|
||||
</template>
|
||||
<div class="mt-6 text-center">
|
||||
<p class="text-xs text-gray-400">
|
||||
Protegido por Ingline Systems © 2025
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</GuestLayout>
|
||||
</template>
|
||||
|
|
@ -97,7 +97,7 @@ const submit = () => {
|
|||
: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"
|
||||
>
|
||||
Already registered?
|
||||
Já possui uma conta?
|
||||
</Link>
|
||||
|
||||
<PrimaryButton
|
||||
|
|
@ -105,7 +105,7 @@ const submit = () => {
|
|||
:class="{ 'opacity-25': form.processing }"
|
||||
:disabled="form.processing"
|
||||
>
|
||||
Register
|
||||
Registrar
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ const submit = () => {
|
|||
:class="{ 'opacity-25': form.processing }"
|
||||
:disabled="form.processing"
|
||||
>
|
||||
Reset Password
|
||||
Redefinir senha
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -1,29 +1,154 @@
|
|||
<script setup>
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
|
||||
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>
|
||||
|
||||
<template>
|
||||
|
||||
<Head title="Dashboard" />
|
||||
|
||||
<AuthenticatedLayout>
|
||||
<template #header>
|
||||
<h2
|
||||
class="text-xl font-semibold leading-tight text-gray-800"
|
||||
>
|
||||
<h2 class="text-xl font-bold text-gray-800">
|
||||
Dashboard
|
||||
</h2>
|
||||
</template>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="py-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="p-6 text-gray-900">
|
||||
You're logged in!
|
||||
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col"
|
||||
class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">
|
||||
Fila / Canal
|
||||
</th>
|
||||
<th scope="col"
|
||||
class="px-6 py-4 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">
|
||||
Total (Dia)
|
||||
</th>
|
||||
<th scope="col"
|
||||
class="px-6 py-4 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">
|
||||
Em Fila (Agora)
|
||||
</th>
|
||||
<th scope="col"
|
||||
class="px-6 py-4 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">
|
||||
TME (Espera)
|
||||
</th>
|
||||
<th scope="col"
|
||||
class="px-6 py-4 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">
|
||||
TMA (Conversa)
|
||||
</th>
|
||||
<th scope="col"
|
||||
class="px-6 py-4 text-center text-xs font-bold text-red-600 uppercase tracking-wider">
|
||||
Abandonados
|
||||
</th>
|
||||
</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>
|
||||
</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>
|
||||
</AuthenticatedLayout>
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\DashboardController;
|
||||
use App\Http\Controllers\ProfileController;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
|
@ -14,9 +15,7 @@
|
|||
]);
|
||||
});
|
||||
|
||||
Route::get('/dashboard', function () {
|
||||
return Inertia::render('Dashboard');
|
||||
})->middleware(['auth', 'verified'])->name('dashboard');
|
||||
Route::get('/dashboard', [DashboardController::class, 'index'])->middleware(['auth', 'verified'])->name('dashboard');
|
||||
|
||||
Route::middleware('auth')->group(function () {
|
||||
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
|
||||
|
|
@ -24,4 +23,4 @@
|
|||
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
|
||||
});
|
||||
|
||||
require __DIR__.'/auth.php';
|
||||
require __DIR__ . '/auth.php';
|
||||
|
|
|
|||
|
|
@ -15,6 +15,16 @@ export default {
|
|||
fontFamily: {
|
||||
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
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue