Browse Source

feat: implementa endpoints de dashboard executivo e agentes

EduLascala 3 weeks ago
parent
commit
6dd20cbeb1

+ 51 - 0
controllers/AgentEscalationController.php

@@ -0,0 +1,51 @@
+<?php
+
+namespace Controllers;
+
+use Libs\ResponseLib;
+use Models\AgentsModel;
+use Models\UserModel;
+use Psr\Http\Message\ServerRequestInterface;
+
+class AgentEscalationController
+{
+    private UserModel $userModel;
+    private AgentsModel $agentsModel;
+
+    public function __construct()
+    {
+        $this->userModel = new UserModel();
+        $this->agentsModel = new AgentsModel();
+    }
+
+    public function __invoke(ServerRequestInterface $request)
+    {
+        $userId = (int) ($request->getAttribute('user_id') ?? 0);
+        $body = json_decode((string) $request->getBody(), true) ?: [];
+        $agentId = (int) ($body['id'] ?? 0);
+
+        if ($userId <= 0) {
+            return ResponseLib::sendFail('Unauthorized: Missing authenticated user', [], 'E_VALIDATE')->withStatus(401);
+        }
+
+        if ($agentId <= 0) {
+            return ResponseLib::sendFail('Missing or invalid id', [], 'E_VALIDATE')->withStatus(400);
+        }
+
+        try {
+            $companyId = $this->userModel->getCompanyIdByUserId($userId);
+            if ($companyId === null) {
+                return ResponseLib::sendFail('User not found', [], 'E_NOT_FOUND')->withStatus(404);
+            }
+
+            $agent = $this->agentsModel->toggleAgentEscalation($companyId, $agentId);
+            if ($agent === null) {
+                return ResponseLib::sendFail('Agent not found', [], 'E_NOT_FOUND')->withStatus(404);
+            }
+
+            return ResponseLib::sendOk($agent);
+        } catch (\Throwable $e) {
+            return ResponseLib::sendFail('Failed to update agent escalation', [], 'E_GENERIC')->withStatus(500);
+        }
+    }
+}

+ 46 - 0
controllers/AgentSaveController.php

@@ -0,0 +1,46 @@
+<?php
+
+namespace Controllers;
+
+use Libs\ResponseLib;
+use Models\AgentsModel;
+use Models\UserModel;
+use Psr\Http\Message\ServerRequestInterface;
+
+class AgentSaveController
+{
+    private UserModel $userModel;
+    private AgentsModel $agentsModel;
+
+    public function __construct()
+    {
+        $this->userModel = new UserModel();
+        $this->agentsModel = new AgentsModel();
+    }
+
+    public function __invoke(ServerRequestInterface $request)
+    {
+        $userId = (int) ($request->getAttribute('user_id') ?? 0);
+        $body = json_decode((string) $request->getBody(), true) ?: [];
+
+        if ($userId <= 0) {
+            return ResponseLib::sendFail('Unauthorized: Missing authenticated user', [], 'E_VALIDATE')->withStatus(401);
+        }
+
+        try {
+            $companyId = $this->userModel->getCompanyIdByUserId($userId);
+            if ($companyId === null) {
+                return ResponseLib::sendFail('User not found', [], 'E_NOT_FOUND')->withStatus(404);
+            }
+
+            $agent = $this->agentsModel->saveAgent($companyId, $body);
+            if ($agent === null) {
+                return ResponseLib::sendFail('Invalid payload, duplicated email, or agent not found', [], 'E_VALIDATE')->withStatus(400);
+            }
+
+            return ResponseLib::sendOk($agent, 'S_CREATED');
+        } catch (\Throwable $e) {
+            return ResponseLib::sendFail('Failed to save agent', [], 'E_GENERIC')->withStatus(500);
+        }
+    }
+}

+ 51 - 0
controllers/AgentStatusController.php

@@ -0,0 +1,51 @@
+<?php
+
+namespace Controllers;
+
+use Libs\ResponseLib;
+use Models\AgentsModel;
+use Models\UserModel;
+use Psr\Http\Message\ServerRequestInterface;
+
+class AgentStatusController
+{
+    private UserModel $userModel;
+    private AgentsModel $agentsModel;
+
+    public function __construct()
+    {
+        $this->userModel = new UserModel();
+        $this->agentsModel = new AgentsModel();
+    }
+
+    public function __invoke(ServerRequestInterface $request)
+    {
+        $userId = (int) ($request->getAttribute('user_id') ?? 0);
+        $body = json_decode((string) $request->getBody(), true) ?: [];
+        $agentId = (int) ($body['id'] ?? 0);
+
+        if ($userId <= 0) {
+            return ResponseLib::sendFail('Unauthorized: Missing authenticated user', [], 'E_VALIDATE')->withStatus(401);
+        }
+
+        if ($agentId <= 0) {
+            return ResponseLib::sendFail('Missing or invalid id', [], 'E_VALIDATE')->withStatus(400);
+        }
+
+        try {
+            $companyId = $this->userModel->getCompanyIdByUserId($userId);
+            if ($companyId === null) {
+                return ResponseLib::sendFail('User not found', [], 'E_NOT_FOUND')->withStatus(404);
+            }
+
+            $agent = $this->agentsModel->toggleAgentStatus($companyId, $agentId);
+            if ($agent === null) {
+                return ResponseLib::sendFail('Agent not found', [], 'E_NOT_FOUND')->withStatus(404);
+            }
+
+            return ResponseLib::sendOk($agent);
+        } catch (\Throwable $e) {
+            return ResponseLib::sendFail('Failed to update agent status', [], 'E_GENERIC')->withStatus(500);
+        }
+    }
+}

+ 42 - 0
controllers/AgentsController.php

@@ -0,0 +1,42 @@
+<?php
+
+namespace Controllers;
+
+use Libs\ResponseLib;
+use Models\AgentsModel;
+use Models\UserModel;
+use Psr\Http\Message\ServerRequestInterface;
+
+class AgentsController
+{
+    private UserModel $userModel;
+    private AgentsModel $agentsModel;
+
+    public function __construct()
+    {
+        $this->userModel = new UserModel();
+        $this->agentsModel = new AgentsModel();
+    }
+
+    public function __invoke(ServerRequestInterface $request)
+    {
+        $userId = (int) ($request->getAttribute('user_id') ?? 0);
+
+        if ($userId <= 0) {
+            return ResponseLib::sendFail('Unauthorized: Missing authenticated user', [], 'E_VALIDATE')->withStatus(401);
+        }
+
+        try {
+            $companyId = $this->userModel->getCompanyIdByUserId($userId);
+            if ($companyId === null) {
+                return ResponseLib::sendFail('User not found', [], 'E_NOT_FOUND')->withStatus(404);
+            }
+
+            return ResponseLib::sendOk(
+                $this->agentsModel->getAgentsData($companyId, $request->getQueryParams())
+            );
+        } catch (\Throwable $e) {
+            return ResponseLib::sendFail('Failed to load agents', [], 'E_GENERIC')->withStatus(500);
+        }
+    }
+}

+ 42 - 0
controllers/EvolutionOverviewController.php

@@ -0,0 +1,42 @@
+<?php
+
+namespace Controllers;
+
+use Libs\ResponseLib;
+use Models\EvolutionOverviewModel;
+use Models\UserModel;
+use Psr\Http\Message\ServerRequestInterface;
+
+class EvolutionOverviewController
+{
+    private UserModel $userModel;
+    private EvolutionOverviewModel $evolutionOverviewModel;
+
+    public function __construct()
+    {
+        $this->userModel = new UserModel();
+        $this->evolutionOverviewModel = new EvolutionOverviewModel();
+    }
+
+    public function __invoke(ServerRequestInterface $request)
+    {
+        $userId = (int) ($request->getAttribute('user_id') ?? 0);
+
+        if ($userId <= 0) {
+            return ResponseLib::sendFail('Unauthorized: Missing authenticated user', [], 'E_VALIDATE')->withStatus(401);
+        }
+
+        try {
+            $companyId = $this->userModel->getCompanyIdByUserId($userId);
+            if ($companyId === null) {
+                return ResponseLib::sendFail('User not found', [], 'E_NOT_FOUND')->withStatus(404);
+            }
+
+            return ResponseLib::sendOk(
+                $this->evolutionOverviewModel->getOverviewData($companyId, $request->getQueryParams())
+            );
+        } catch (\Throwable $e) {
+            return ResponseLib::sendFail('Failed to load evolution overview', [], 'E_GENERIC')->withStatus(500);
+        }
+    }
+}

+ 42 - 0
controllers/ExecutiveDashboardController.php

@@ -0,0 +1,42 @@
+<?php
+
+namespace Controllers;
+
+use Libs\ResponseLib;
+use Models\ExecutiveDashboardModel;
+use Models\UserModel;
+use Psr\Http\Message\ServerRequestInterface;
+
+class ExecutiveDashboardController
+{
+    private UserModel $userModel;
+    private ExecutiveDashboardModel $executiveDashboardModel;
+
+    public function __construct()
+    {
+        $this->userModel = new UserModel();
+        $this->executiveDashboardModel = new ExecutiveDashboardModel();
+    }
+
+    public function __invoke(ServerRequestInterface $request)
+    {
+        $userId = (int) ($request->getAttribute('user_id') ?? 0);
+
+        if ($userId <= 0) {
+            return ResponseLib::sendFail('Unauthorized: Missing authenticated user', [], 'E_VALIDATE')->withStatus(401);
+        }
+
+        try {
+            $companyId = $this->userModel->getCompanyIdByUserId($userId);
+            if ($companyId === null) {
+                return ResponseLib::sendFail('User not found', [], 'E_NOT_FOUND')->withStatus(404);
+            }
+
+            return ResponseLib::sendOk(
+                $this->executiveDashboardModel->getDashboardData($companyId, $request->getQueryParams())
+            );
+        } catch (\Throwable $e) {
+            return ResponseLib::sendFail('Failed to load executive dashboard', [], 'E_GENERIC')->withStatus(500);
+        }
+    }
+}

+ 579 - 0
models/AgentsModel.php

@@ -0,0 +1,579 @@
+<?php
+
+namespace Models;
+
+use Libs\Database;
+
+class AgentsModel
+{
+    private \PDO $pdo;
+
+    public function __construct()
+    {
+        $this->pdo = Database::pdo();
+    }
+
+    public function getAgentsData(int $companyId, array $queryParams = []): array
+    {
+        $anchorDate = $this->getAnchorDate($companyId);
+        $filters = $this->normalizeFilters($queryParams);
+        $items = $this->getItems($companyId, $anchorDate, $filters);
+
+        return [
+            'items' => $items,
+            'stats' => $this->getStats($companyId),
+        ];
+    }
+
+    public function saveAgent(int $companyId, array $payload): ?array
+    {
+        $data = $this->normalizePayload($payload);
+        if ($data === null) {
+            return null;
+        }
+
+        if ($this->emailExists($companyId, $data['email'], $data['id'])) {
+            return null;
+        }
+
+        if ($data['id'] !== null) {
+            if (!$this->agentExists($companyId, $data['id'])) {
+                return null;
+            }
+
+            $stmt = $this->pdo->prepare(
+                "UPDATE operator
+                SET operator_name = :name,
+                    operator_initials = :initials,
+                    operator_email = :email,
+                    operator_phone = :phone,
+                    operator_department = :department,
+                    operator_status = :status,
+                    operator_available_for_escalation = :available_for_escalation
+                WHERE company_id = :company_id
+                  AND operator_id = :operator_id
+                  AND operator_deleted_at = 'infinity'"
+            );
+            $stmt->execute([
+                'name' => $data['name'],
+                'initials' => $data['initials'],
+                'email' => $data['email'],
+                'phone' => $data['phone'],
+                'department' => $data['department'],
+                'status' => $data['status'],
+                'available_for_escalation' => $data['available_for_escalation'],
+                'company_id' => $companyId,
+                'operator_id' => $data['id'],
+            ]);
+
+            $operatorId = $data['id'];
+        } else {
+            $stmt = $this->pdo->prepare(
+                "INSERT INTO operator (
+                    company_id,
+                    operator_name,
+                    operator_initials,
+                    operator_email,
+                    operator_phone,
+                    operator_department,
+                    operator_status,
+                    operator_available_for_escalation
+                ) VALUES (
+                    :company_id,
+                    :name,
+                    :initials,
+                    :email,
+                    :phone,
+                    :department,
+                    :status,
+                    :available_for_escalation
+                ) RETURNING operator_id"
+            );
+            $stmt->execute([
+                'company_id' => $companyId,
+                'name' => $data['name'],
+                'initials' => $data['initials'],
+                'email' => $data['email'],
+                'phone' => $data['phone'],
+                'department' => $data['department'],
+                'status' => $data['status'],
+                'available_for_escalation' => $data['available_for_escalation'],
+            ]);
+            $operatorId = (int) $stmt->fetchColumn();
+        }
+
+        $this->replaceChannels($operatorId, $data['channels']);
+
+        return $this->getAgentItemById($companyId, $operatorId, $this->getAnchorDate($companyId));
+    }
+
+    public function toggleAgentStatus(int $companyId, int $agentId): ?array
+    {
+        $agent = $this->getOperatorRow($companyId, $agentId);
+        if ($agent === null) {
+            return null;
+        }
+
+        $currentStatus = (string) ($agent['operator_status'] ?? 'Inativo');
+        $nextStatus = $this->normalizeStatus($currentStatus) === 'Inativo' ? 'Ativo' : 'Inativo';
+
+        $stmt = $this->pdo->prepare(
+            "UPDATE operator
+            SET operator_status = :status
+            WHERE company_id = :company_id
+              AND operator_id = :operator_id
+              AND operator_deleted_at = 'infinity'"
+        );
+        $stmt->execute([
+            'status' => $nextStatus,
+            'company_id' => $companyId,
+            'operator_id' => $agentId,
+        ]);
+
+        return $this->getAgentItemById($companyId, $agentId, $this->getAnchorDate($companyId));
+    }
+
+    public function toggleAgentEscalation(int $companyId, int $agentId): ?array
+    {
+        $agent = $this->getOperatorRow($companyId, $agentId);
+        if ($agent === null) {
+            return null;
+        }
+
+        $stmt = $this->pdo->prepare(
+            "UPDATE operator
+            SET operator_available_for_escalation = :available_for_escalation
+            WHERE company_id = :company_id
+              AND operator_id = :operator_id
+              AND operator_deleted_at = 'infinity'"
+        );
+        $stmt->execute([
+            'available_for_escalation' => !((bool) ($agent['operator_available_for_escalation'] ?? false)),
+            'company_id' => $companyId,
+            'operator_id' => $agentId,
+        ]);
+
+        return $this->getAgentItemById($companyId, $agentId, $this->getAnchorDate($companyId));
+    }
+
+    private function getAnchorDate(int $companyId): \DateTimeImmutable
+    {
+        $stmt = $this->pdo->prepare(
+            "SELECT MAX(operator_stat_date)
+            FROM operator_daily_stats
+            WHERE company_id = :company_id"
+        );
+        $stmt->execute(['company_id' => $companyId]);
+        $date = $stmt->fetchColumn();
+
+        if (!is_string($date) || $date === '') {
+            return new \DateTimeImmutable('today');
+        }
+
+        return new \DateTimeImmutable($date);
+    }
+
+    private function normalizeFilters(array $queryParams): array
+    {
+        $search = trim((string) ($queryParams['search'] ?? ''));
+        $department = trim((string) ($queryParams['department'] ?? ''));
+        $channel = mb_strtolower(trim((string) ($queryParams['channel'] ?? '')));
+        $status = trim((string) ($queryParams['status'] ?? ''));
+
+        return [
+            'search' => $search,
+            'department' => $department,
+            'channel' => in_array($channel, ['whatsapp', 'instagram'], true) ? $channel : '',
+            'status' => $status,
+        ];
+    }
+
+    private function getItems(int $companyId, \DateTimeImmutable $anchorDate, array $filters): array
+    {
+        $sql = "SELECT
+                o.operator_id,
+                o.operator_name,
+                o.operator_initials,
+                o.operator_email,
+                o.operator_department,
+                o.operator_status,
+                o.operator_available_for_escalation,
+                COALESCE(curr.operator_attendances_count, 0) AS today_attendances,
+                curr.operator_avg_response_seconds AS avg_response_seconds,
+                prev.operator_avg_response_seconds AS prev_avg_response_seconds,
+                COALESCE(curr.operator_sla_compliance_pct, 0) AS sla_pct
+            FROM operator o
+            LEFT JOIN operator_daily_stats curr
+                ON curr.operator_id = o.operator_id
+               AND curr.operator_stat_date = :anchor_date
+            LEFT JOIN operator_daily_stats prev
+                ON prev.operator_id = o.operator_id
+               AND prev.operator_stat_date = :previous_date
+            WHERE o.company_id = :company_id
+              AND o.operator_deleted_at = 'infinity'";
+
+        $params = [
+            'company_id' => $companyId,
+            'anchor_date' => $anchorDate->format('Y-m-d'),
+            'previous_date' => $anchorDate->modify('-1 day')->format('Y-m-d'),
+        ];
+
+        if ($filters['search'] !== '') {
+            $sql .= ' AND (o.operator_name ILIKE :search OR o.operator_email ILIKE :search)';
+            $params['search'] = '%' . $filters['search'] . '%';
+        }
+
+        if ($filters['department'] !== '') {
+            $sql .= ' AND o.operator_department = :department';
+            $params['department'] = $filters['department'];
+        }
+
+        if ($filters['status'] !== '') {
+            if ($filters['status'] === 'Disponível para Escalonamento') {
+                $sql .= ' AND o.operator_available_for_escalation = TRUE';
+            } else {
+                $sql .= ' AND o.operator_status = :status';
+                $params['status'] = $filters['status'];
+            }
+        }
+
+        if ($filters['channel'] !== '') {
+            $sql .= "
+              AND EXISTS (
+                    SELECT 1
+                    FROM operator_channel oc_filter
+                    WHERE oc_filter.operator_id = o.operator_id
+                      AND oc_filter.operator_channel_deleted_at = 'infinity'
+                      AND lower(oc_filter.operator_channel) = :channel
+              )";
+            $params['channel'] = $filters['channel'];
+        }
+
+        $sql .= ' ORDER BY o.operator_name ASC, o.operator_id ASC';
+
+        $stmt = $this->pdo->prepare($sql);
+        $stmt->execute($params);
+        $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
+
+        $channelsByOperator = $this->getChannelsByOperator($companyId);
+        $items = [];
+        foreach ($rows as $row) {
+            $operatorId = (int) ($row['operator_id'] ?? 0);
+            $items[] = $this->formatAgentItem($row, $channelsByOperator[$operatorId] ?? []);
+        }
+
+        return $items;
+    }
+
+    private function getStats(int $companyId): array
+    {
+        $stmt = $this->pdo->prepare(
+            "SELECT
+                COUNT(*) AS total,
+                COUNT(*) FILTER (
+                    WHERE operator_status IN ('Ativo', 'Em Atendimento', 'Disponível')
+                ) AS active,
+                COUNT(*) FILTER (
+                    WHERE operator_status = 'Em Atendimento'
+                ) AS in_attendance,
+                COUNT(*) FILTER (
+                    WHERE operator_available_for_escalation = TRUE
+                ) AS available_for_escalation
+            FROM operator
+            WHERE company_id = :company_id
+              AND operator_deleted_at = 'infinity'"
+        );
+        $stmt->execute(['company_id' => $companyId]);
+        $row = $stmt->fetch(\PDO::FETCH_ASSOC) ?: [];
+
+        return [
+            'total' => (int) ($row['total'] ?? 0),
+            'active' => (int) ($row['active'] ?? 0),
+            'inAttendance' => (int) ($row['in_attendance'] ?? 0),
+            'availableForEscalation' => (int) ($row['available_for_escalation'] ?? 0),
+        ];
+    }
+
+    private function getChannelsByOperator(int $companyId): array
+    {
+        $stmt = $this->pdo->prepare(
+            "SELECT
+                o.operator_id,
+                oc.operator_channel
+            FROM operator o
+            LEFT JOIN operator_channel oc
+                ON oc.operator_id = o.operator_id
+               AND oc.operator_channel_deleted_at = 'infinity'
+            WHERE o.company_id = :company_id
+              AND o.operator_deleted_at = 'infinity'
+            ORDER BY o.operator_id ASC, oc.operator_channel ASC"
+        );
+        $stmt->execute(['company_id' => $companyId]);
+        $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
+
+        $grouped = [];
+        foreach ($rows as $row) {
+            $operatorId = (int) ($row['operator_id'] ?? 0);
+            $channel = trim((string) ($row['operator_channel'] ?? ''));
+            if ($operatorId <= 0 || $channel === '') {
+                continue;
+            }
+
+            if (!isset($grouped[$operatorId])) {
+                $grouped[$operatorId] = [];
+            }
+
+            $grouped[$operatorId][] = mb_strtolower($channel);
+        }
+
+        return $grouped;
+    }
+
+    private function formatAgentItem(array $row, array $channels): array
+    {
+        $currentResponseSeconds = isset($row['avg_response_seconds']) ? (int) $row['avg_response_seconds'] : null;
+        $previousResponseSeconds = isset($row['prev_avg_response_seconds']) ? (int) $row['prev_avg_response_seconds'] : null;
+
+        return [
+            'id' => (int) ($row['operator_id'] ?? 0),
+            'name' => $row['operator_name'] ?? '',
+            'email' => $row['operator_email'] ?? '',
+            'initials' => $row['operator_initials'] ?? '',
+            'department' => $row['operator_department'] ?? '',
+            'channels' => array_values(array_unique($channels)),
+            'status' => $this->normalizeStatus((string) ($row['operator_status'] ?? 'Inativo')),
+            'availableForEscalation' => (bool) ($row['operator_available_for_escalation'] ?? false),
+            'todayAttendances' => (int) ($row['today_attendances'] ?? 0),
+            'avgResponseTime' => $this->formatDuration($currentResponseSeconds),
+            'responseTimeTrend' => $this->resolveResponseTimeTrend($currentResponseSeconds, $previousResponseSeconds),
+            'slaPct' => (int) round((float) ($row['sla_pct'] ?? 0)),
+        ];
+    }
+
+    private function normalizePayload(array $payload): ?array
+    {
+        $id = isset($payload['id']) ? (int) $payload['id'] : null;
+        if ($id !== null && $id <= 0) {
+            $id = null;
+        }
+
+        $name = trim((string) ($payload['name'] ?? ''));
+        $email = mb_strtolower(trim((string) ($payload['email'] ?? '')));
+        $department = trim((string) ($payload['department'] ?? ''));
+        $status = $this->normalizeStatus((string) ($payload['status'] ?? 'Ativo'));
+        $availableForEscalation = (bool) ($payload['availableForEscalation'] ?? false);
+        $channels = array_values(array_filter(array_map(static function ($channel): string {
+            return mb_strtolower(trim((string) $channel));
+        }, is_array($payload['channels'] ?? null) ? $payload['channels'] : []), static function (string $channel): bool {
+            return in_array($channel, ['whatsapp', 'instagram'], true);
+        }));
+
+        if ($name === '' || $email === '' || $department === '') {
+            return null;
+        }
+
+        return [
+            'id' => $id,
+            'name' => $name,
+            'email' => $email,
+            'department' => $department,
+            'status' => $status,
+            'available_for_escalation' => $availableForEscalation,
+            'channels' => array_values(array_unique($channels)),
+            'initials' => $this->buildInitials($name),
+            'phone' => trim((string) ($payload['phone'] ?? '')),
+        ];
+    }
+
+    private function emailExists(int $companyId, string $email, ?int $ignoreId): bool
+    {
+        $sql = "SELECT 1
+            FROM operator
+            WHERE company_id = :company_id
+              AND operator_deleted_at = 'infinity'
+              AND lower(operator_email) = :email";
+        $params = [
+            'company_id' => $companyId,
+            'email' => $email,
+        ];
+
+        if ($ignoreId !== null) {
+            $sql .= ' AND operator_id <> :ignore_id';
+            $params['ignore_id'] = $ignoreId;
+        }
+
+        $sql .= ' LIMIT 1';
+
+        $stmt = $this->pdo->prepare($sql);
+        $stmt->execute($params);
+
+        return (bool) $stmt->fetchColumn();
+    }
+
+    private function agentExists(int $companyId, int $agentId): bool
+    {
+        $stmt = $this->pdo->prepare(
+            "SELECT 1
+            FROM operator
+            WHERE company_id = :company_id
+              AND operator_id = :operator_id
+              AND operator_deleted_at = 'infinity'
+            LIMIT 1"
+        );
+        $stmt->execute([
+            'company_id' => $companyId,
+            'operator_id' => $agentId,
+        ]);
+
+        return (bool) $stmt->fetchColumn();
+    }
+
+    private function replaceChannels(int $operatorId, array $channels): void
+    {
+        $stmt = $this->pdo->prepare(
+            "UPDATE operator_channel
+            SET operator_channel_deleted_at = NOW()
+            WHERE operator_id = :operator_id
+              AND operator_channel_deleted_at = 'infinity'"
+        );
+        $stmt->execute(['operator_id' => $operatorId]);
+
+        if ($channels === []) {
+            return;
+        }
+
+        $insert = $this->pdo->prepare(
+            "INSERT INTO operator_channel (operator_id, operator_channel)
+            VALUES (:operator_id, :channel)"
+        );
+        foreach ($channels as $channel) {
+            $insert->execute([
+                'operator_id' => $operatorId,
+                'channel' => $channel,
+            ]);
+        }
+    }
+
+    private function getOperatorRow(int $companyId, int $agentId): ?array
+    {
+        $stmt = $this->pdo->prepare(
+            "SELECT *
+            FROM operator
+            WHERE company_id = :company_id
+              AND operator_id = :operator_id
+              AND operator_deleted_at = 'infinity'
+            LIMIT 1"
+        );
+        $stmt->execute([
+            'company_id' => $companyId,
+            'operator_id' => $agentId,
+        ]);
+        $row = $stmt->fetch(\PDO::FETCH_ASSOC);
+
+        return $row === false ? null : $row;
+    }
+
+    private function getAgentItemById(int $companyId, int $agentId, \DateTimeImmutable $anchorDate): ?array
+    {
+        $sql = "SELECT
+                o.operator_id,
+                o.operator_name,
+                o.operator_initials,
+                o.operator_email,
+                o.operator_department,
+                o.operator_status,
+                o.operator_available_for_escalation,
+                COALESCE(curr.operator_attendances_count, 0) AS today_attendances,
+                curr.operator_avg_response_seconds AS avg_response_seconds,
+                prev.operator_avg_response_seconds AS prev_avg_response_seconds,
+                COALESCE(curr.operator_sla_compliance_pct, 0) AS sla_pct
+            FROM operator o
+            LEFT JOIN operator_daily_stats curr
+                ON curr.operator_id = o.operator_id
+               AND curr.operator_stat_date = :anchor_date
+            LEFT JOIN operator_daily_stats prev
+                ON prev.operator_id = o.operator_id
+               AND prev.operator_stat_date = :previous_date
+            WHERE o.company_id = :company_id
+              AND o.operator_id = :operator_id
+              AND o.operator_deleted_at = 'infinity'
+            LIMIT 1";
+
+        $stmt = $this->pdo->prepare($sql);
+        $stmt->execute([
+            'company_id' => $companyId,
+            'operator_id' => $agentId,
+            'anchor_date' => $anchorDate->format('Y-m-d'),
+            'previous_date' => $anchorDate->modify('-1 day')->format('Y-m-d'),
+        ]);
+        $row = $stmt->fetch(\PDO::FETCH_ASSOC);
+        if ($row === false) {
+            return null;
+        }
+
+        $channelsByOperator = $this->getChannelsByOperator($companyId);
+
+        return $this->formatAgentItem($row, $channelsByOperator[$agentId] ?? []);
+    }
+
+    private function buildInitials(string $name): string
+    {
+        $parts = preg_split('/\s+/', trim($name)) ?: [];
+        $letters = array_slice(array_filter($parts), 0, 2);
+        $initials = '';
+
+        foreach ($letters as $part) {
+            $initials .= mb_strtoupper(mb_substr($part, 0, 1));
+        }
+
+        return $initials !== '' ? $initials : 'AG';
+    }
+
+    private function normalizeStatus(string $status): string
+    {
+        $normalized = mb_strtolower(trim($status));
+
+        if (in_array($normalized, ['ativo', 'active'], true)) {
+            return 'Ativo';
+        }
+
+        if (in_array($normalized, ['em atendimento', 'attending'], true)) {
+            return 'Em Atendimento';
+        }
+
+        if (in_array($normalized, ['disponível', 'disponivel', 'available'], true)) {
+            return 'Disponível';
+        }
+
+        return 'Inativo';
+    }
+
+    private function formatDuration(?int $seconds): string
+    {
+        if ($seconds === null || $seconds <= 0) {
+            return '—';
+        }
+
+        $minutes = intdiv($seconds, 60);
+        $remainingSeconds = $seconds % 60;
+
+        return sprintf('%dm %02ds', $minutes, $remainingSeconds);
+    }
+
+    private function resolveResponseTimeTrend(?int $currentSeconds, ?int $previousSeconds): string
+    {
+        if ($currentSeconds === null || $currentSeconds <= 0 || $previousSeconds === null || $previousSeconds <= 0) {
+            return 'stable';
+        }
+
+        if ($currentSeconds < $previousSeconds) {
+            return 'down';
+        }
+
+        if ($currentSeconds > $previousSeconds) {
+            return 'up';
+        }
+
+        return 'stable';
+    }
+}

+ 262 - 0
models/EvolutionOverviewModel.php

@@ -0,0 +1,262 @@
+<?php
+
+namespace Models;
+
+use Libs\Database;
+
+class EvolutionOverviewModel
+{
+    private \PDO $pdo;
+
+    public function __construct()
+    {
+        $this->pdo = Database::pdo();
+    }
+
+    public function getOverviewData(int $companyId, array $queryParams = []): array
+    {
+        $anchorDate = $this->getAnchorDate($companyId);
+        $range = $this->resolveRange($anchorDate);
+        $sentimentSeries = $this->getSentimentSeries($companyId, $range);
+        $playbooksSeries = $this->getPlaybooksSeries($companyId, $range);
+        $playbooksTotals = $this->buildPlaybooksTotals($playbooksSeries);
+        $kpiSnapshots = $this->getRecentKpiSnapshots($companyId, $anchorDate);
+        $recentSentimentScores = $this->getRecentSentimentScores($companyId, $anchorDate);
+
+        return [
+            'kpis' => $this->buildKpis($kpiSnapshots, $recentSentimentScores, $playbooksTotals),
+            'sentimentSeries' => $sentimentSeries,
+            'playbooksSeries' => $playbooksSeries,
+            'playbooksTotals' => $playbooksTotals,
+        ];
+    }
+
+    private function getAnchorDate(int $companyId): \DateTimeImmutable
+    {
+        $stmt = $this->pdo->prepare(
+            "SELECT MAX(snapshot_date)
+            FROM (
+                SELECT MAX(evolution_snapshot_date)::date AS snapshot_date
+                FROM sentiment_evolution
+                WHERE company_id = :company_id
+
+                UNION ALL
+
+                SELECT MAX(playbook_snapshot_date)::date AS snapshot_date
+                FROM playbooks_monitor
+                WHERE company_id = :company_id
+
+                UNION ALL
+
+                SELECT MAX(kpi_snapshot_date)::date AS snapshot_date
+                FROM kpi_snapshot
+                WHERE company_id = :company_id
+            ) snapshots"
+        );
+        $stmt->execute(['company_id' => $companyId]);
+        $anchorDate = $stmt->fetchColumn();
+
+        if (!is_string($anchorDate) || $anchorDate === '') {
+            return new \DateTimeImmutable('today');
+        }
+
+        return new \DateTimeImmutable($anchorDate);
+    }
+
+    private function resolveRange(\DateTimeImmutable $anchorDate): array
+    {
+        $startDate = $anchorDate->modify('-6 days');
+
+        return [
+            'start_date' => $startDate->format('Y-m-d'),
+            'end_date' => $anchorDate->format('Y-m-d'),
+        ];
+    }
+
+    private function getSentimentSeries(int $companyId, array $range): array
+    {
+        $stmt = $this->pdo->prepare(
+            "SELECT
+                evolution_snapshot_date,
+                evolution_sentiment_score
+            FROM sentiment_evolution
+            WHERE company_id = :company_id
+              AND evolution_snapshot_date >= :start_date
+              AND evolution_snapshot_date <= :end_date
+            ORDER BY evolution_snapshot_date ASC"
+        );
+        $stmt->execute([
+            'company_id' => $companyId,
+            'start_date' => $range['start_date'],
+            'end_date' => $range['end_date'],
+        ]);
+
+        $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
+        $mapped = [];
+        foreach ($rows as $row) {
+            $mapped[$row['evolution_snapshot_date']] = (float) ($row['evolution_sentiment_score'] ?? 0);
+        }
+
+        $items = [];
+        $current = new \DateTimeImmutable($range['start_date']);
+        $end = new \DateTimeImmutable($range['end_date']);
+
+        while ($current <= $end) {
+            $date = $current->format('Y-m-d');
+            $items[] = [
+                'date' => $date,
+                'value' => (float) ($mapped[$date] ?? 0),
+            ];
+            $current = $current->modify('+1 day');
+        }
+
+        return $items;
+    }
+
+    private function getPlaybooksSeries(int $companyId, array $range): array
+    {
+        $stmt = $this->pdo->prepare(
+            "SELECT
+                playbook_snapshot_date,
+                playbook_new_detected,
+                playbook_converted
+            FROM playbooks_monitor
+            WHERE company_id = :company_id
+              AND playbook_snapshot_date >= :start_date
+              AND playbook_snapshot_date <= :end_date
+            ORDER BY playbook_snapshot_date ASC"
+        );
+        $stmt->execute([
+            'company_id' => $companyId,
+            'start_date' => $range['start_date'],
+            'end_date' => $range['end_date'],
+        ]);
+
+        $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
+        $mapped = [];
+        foreach ($rows as $row) {
+            $mapped[$row['playbook_snapshot_date']] = [
+                'novos' => (int) ($row['playbook_new_detected'] ?? 0),
+                'convertidos' => (int) ($row['playbook_converted'] ?? 0),
+            ];
+        }
+
+        $items = [];
+        $current = new \DateTimeImmutable($range['start_date']);
+        $end = new \DateTimeImmutable($range['end_date']);
+
+        while ($current <= $end) {
+            $date = $current->format('Y-m-d');
+            $items[] = [
+                'date' => $date,
+                'novos' => (int) (($mapped[$date]['novos'] ?? 0)),
+                'convertidos' => (int) (($mapped[$date]['convertidos'] ?? 0)),
+            ];
+            $current = $current->modify('+1 day');
+        }
+
+        return $items;
+    }
+
+    private function buildPlaybooksTotals(array $playbooksSeries): array
+    {
+        $novos = 0;
+        $convertidos = 0;
+
+        foreach ($playbooksSeries as $item) {
+            $novos += (int) ($item['novos'] ?? 0);
+            $convertidos += (int) ($item['convertidos'] ?? 0);
+        }
+
+        return [
+            'novos' => $novos,
+            'convertidos' => $convertidos,
+        ];
+    }
+
+    private function getRecentKpiSnapshots(int $companyId, \DateTimeImmutable $anchorDate): array
+    {
+        $stmt = $this->pdo->prepare(
+            "SELECT
+                kpi_snapshot_date,
+                kpi_current_sales,
+                kpi_avg_ticket,
+                kpi_lifetime_at_risk
+            FROM kpi_snapshot
+            WHERE company_id = :company_id
+              AND kpi_snapshot_date <= :anchor_date
+            ORDER BY kpi_snapshot_date DESC
+            LIMIT 2"
+        );
+        $stmt->execute([
+            'company_id' => $companyId,
+            'anchor_date' => $anchorDate->format('Y-m-d'),
+        ]);
+
+        return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
+    }
+
+    private function getRecentSentimentScores(int $companyId, \DateTimeImmutable $anchorDate): array
+    {
+        $stmt = $this->pdo->prepare(
+            "SELECT
+                evolution_snapshot_date,
+                evolution_sentiment_score
+            FROM sentiment_evolution
+            WHERE company_id = :company_id
+              AND evolution_snapshot_date <= :anchor_date
+            ORDER BY evolution_snapshot_date DESC
+            LIMIT 2"
+        );
+        $stmt->execute([
+            'company_id' => $companyId,
+            'anchor_date' => $anchorDate->format('Y-m-d'),
+        ]);
+
+        return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
+    }
+
+    private function buildKpis(array $kpiSnapshots, array $recentSentimentScores, array $playbooksTotals): array
+    {
+        $latestKpi = $kpiSnapshots[0] ?? [];
+        $previousKpi = $kpiSnapshots[1] ?? $latestKpi;
+
+        $latestAtRisk = (float) ($latestKpi['kpi_lifetime_at_risk'] ?? 0);
+        $previousAtRisk = (float) ($previousKpi['kpi_lifetime_at_risk'] ?? $latestAtRisk);
+        $currentSales = (float) ($latestKpi['kpi_current_sales'] ?? 0);
+
+        $latestSentiment = isset($recentSentimentScores[0]['evolution_sentiment_score'])
+            ? (float) $recentSentimentScores[0]['evolution_sentiment_score']
+            : 0.0;
+        $previousSentiment = isset($recentSentimentScores[1]['evolution_sentiment_score'])
+            ? (float) $recentSentimentScores[1]['evolution_sentiment_score']
+            : $latestSentiment;
+
+        $latestScore = $this->normalizeSentimentScore($latestSentiment);
+        $previousScore = $this->normalizeSentimentScore($previousSentiment);
+
+        if ($previousScore === 0.0) {
+            $evolutionRate = $latestScore > 0 ? 100.0 : 0.0;
+        } else {
+            $evolutionRate = (($latestScore - $previousScore) / abs($previousScore)) * 100;
+        }
+
+        $novos = (int) ($playbooksTotals['novos'] ?? 0);
+        $convertidos = (int) ($playbooksTotals['convertidos'] ?? 0);
+
+        return [
+            'churnEvitado' => round(max(0, $previousAtRisk - $latestAtRisk), 2),
+            'roiUpsell' => round($latestAtRisk > 0 ? ($currentSales / $latestAtRisk) : 0, 1),
+            'scoreMedio' => (int) round($latestScore),
+            'taxaEvolucao' => round($evolutionRate, 1),
+            'conversaoEmocao' => $novos > 0 ? (int) round(($convertidos / $novos) * 100) : 0,
+        ];
+    }
+
+    private function normalizeSentimentScore(float $score): float
+    {
+        $normalized = (($score + 1) / 2) * 100;
+
+        return max(0, min(100, $normalized));
+    }
+}

+ 487 - 0
models/ExecutiveDashboardModel.php

@@ -0,0 +1,487 @@
+<?php
+
+namespace Models;
+
+use Libs\Database;
+
+class ExecutiveDashboardModel
+{
+    private \PDO $pdo;
+
+    public function __construct()
+    {
+        $this->pdo = Database::pdo();
+    }
+
+    public function getDashboardData(int $companyId, array $queryParams = []): array
+    {
+        $anchorDate = $this->getAnchorDate($companyId);
+        $latestKpi = $this->getLatestKpiSnapshot($companyId, $anchorDate);
+        $previousKpi = $this->getPreviousKpiSnapshot($companyId, $anchorDate);
+        $latestEmotion = $this->getLatestEmotionSnapshot($companyId, $anchorDate);
+        $previousEmotion = $this->getPreviousEmotionSnapshot($companyId, $anchorDate);
+
+        return [
+            'topKpis' => $this->buildTopKpis($latestKpi, $previousKpi),
+            'churnDistribution' => $this->buildChurnDistribution($latestKpi),
+            'ltvRisk' => $this->buildLtvRisk($latestKpi, $previousKpi),
+            'sla' => $this->buildSla($companyId, $latestKpi, $anchorDate),
+            'emotions' => $this->buildEmotions($latestEmotion),
+            'quickAccess' => $this->buildQuickAccess($companyId, $latestKpi, $latestEmotion, $previousEmotion, $anchorDate),
+        ];
+    }
+
+    private function getAnchorDate(int $companyId): \DateTimeImmutable
+    {
+        $stmt = $this->pdo->prepare(
+            "SELECT MAX(snapshot_date)
+            FROM (
+                SELECT MAX(kpi_snapshot_date)::date AS snapshot_date
+                FROM kpi_snapshot
+                WHERE company_id = :company_id
+
+                UNION ALL
+
+                SELECT MAX(emotion_snapshot_date)::date AS snapshot_date
+                FROM emotion_snapshot
+                WHERE company_id = :company_id
+
+                UNION ALL
+
+                SELECT MAX(operator_stat_date)::date AS snapshot_date
+                FROM operator_daily_stats
+                WHERE company_id = :company_id
+            ) snapshots"
+        );
+        $stmt->execute(['company_id' => $companyId]);
+        $anchorDate = $stmt->fetchColumn();
+
+        if (!is_string($anchorDate) || $anchorDate === '') {
+            return new \DateTimeImmutable('today');
+        }
+
+        return new \DateTimeImmutable($anchorDate);
+    }
+
+    private function getLatestKpiSnapshot(int $companyId, \DateTimeImmutable $anchorDate): array
+    {
+        $stmt = $this->pdo->prepare(
+            "SELECT *
+            FROM kpi_snapshot
+            WHERE company_id = :company_id
+              AND kpi_snapshot_date <= :anchor_date
+            ORDER BY kpi_snapshot_date DESC
+            LIMIT 1"
+        );
+        $stmt->execute([
+            'company_id' => $companyId,
+            'anchor_date' => $anchorDate->format('Y-m-d'),
+        ]);
+
+        return $stmt->fetch(\PDO::FETCH_ASSOC) ?: [];
+    }
+
+    private function getPreviousKpiSnapshot(int $companyId, \DateTimeImmutable $anchorDate): array
+    {
+        $stmt = $this->pdo->prepare(
+            "SELECT *
+            FROM kpi_snapshot
+            WHERE company_id = :company_id
+              AND kpi_snapshot_date < :anchor_date
+            ORDER BY kpi_snapshot_date DESC
+            LIMIT 1"
+        );
+        $stmt->execute([
+            'company_id' => $companyId,
+            'anchor_date' => $anchorDate->format('Y-m-d'),
+        ]);
+
+        return $stmt->fetch(\PDO::FETCH_ASSOC) ?: [];
+    }
+
+    private function getLatestEmotionSnapshot(int $companyId, \DateTimeImmutable $anchorDate): array
+    {
+        $stmt = $this->pdo->prepare(
+            "SELECT *
+            FROM emotion_snapshot
+            WHERE company_id = :company_id
+              AND emotion_snapshot_date <= :anchor_date
+            ORDER BY emotion_snapshot_date DESC
+            LIMIT 1"
+        );
+        $stmt->execute([
+            'company_id' => $companyId,
+            'anchor_date' => $anchorDate->format('Y-m-d'),
+        ]);
+
+        return $stmt->fetch(\PDO::FETCH_ASSOC) ?: [];
+    }
+
+    private function getPreviousEmotionSnapshot(int $companyId, \DateTimeImmutable $anchorDate): array
+    {
+        $stmt = $this->pdo->prepare(
+            "SELECT *
+            FROM emotion_snapshot
+            WHERE company_id = :company_id
+              AND emotion_snapshot_date < :anchor_date
+            ORDER BY emotion_snapshot_date DESC
+            LIMIT 1"
+        );
+        $stmt->execute([
+            'company_id' => $companyId,
+            'anchor_date' => $anchorDate->format('Y-m-d'),
+        ]);
+
+        return $stmt->fetch(\PDO::FETCH_ASSOC) ?: [];
+    }
+
+    private function buildTopKpis(array $latestKpi, array $previousKpi): array
+    {
+        $currentSales = (float) ($latestKpi['kpi_current_sales'] ?? 0);
+        $previousSales = (float) ($previousKpi['kpi_current_sales'] ?? $currentSales);
+        $avgTicket = (float) ($latestKpi['kpi_avg_ticket'] ?? 0);
+        $previousAvgTicket = (float) ($previousKpi['kpi_avg_ticket'] ?? $avgTicket);
+        $ltvAtRisk = (float) ($latestKpi['kpi_ltv_at_risk'] ?? 0);
+        $previousLtvAtRisk = (float) ($previousKpi['kpi_ltv_at_risk'] ?? $ltvAtRisk);
+        $criticalClients = (int) ($latestKpi['kpi_critical_risk_clients'] ?? 0);
+        $previousCriticalClients = (int) ($previousKpi['kpi_critical_risk_clients'] ?? $criticalClients);
+
+        return [
+            [
+                'title' => 'Venda Atual',
+                'value' => $this->formatCurrency($currentSales),
+                'trendLabel' => $this->formatTrendLabel($currentSales, $previousSales, 'vs ontem', false),
+                'danger' => false,
+            ],
+            [
+                'title' => 'Ticket Médio',
+                'value' => $this->formatCurrency($avgTicket),
+                'trendLabel' => $this->formatTrendLabel($avgTicket, $previousAvgTicket, 'vs ontem', false),
+                'danger' => false,
+            ],
+            [
+                'title' => 'Lifetime em Risco',
+                'value' => $this->formatCurrency($ltvAtRisk),
+                'trendLabel' => $this->formatTrendLabel($ltvAtRisk, $previousLtvAtRisk, 'vs ontem', true),
+                'danger' => $ltvAtRisk > 0,
+            ],
+            [
+                'title' => 'Clientes em Risco Crítico',
+                'value' => (string) $criticalClients,
+                'trendLabel' => $this->formatAbsoluteTrendLabel($criticalClients - $previousCriticalClients, 'desde ontem', true),
+                'danger' => $criticalClients > 0,
+            ],
+        ];
+    }
+
+    private function buildChurnDistribution(array $latestKpi): array
+    {
+        return [
+            [
+                'label' => 'Baixo',
+                'value' => (int) round((float) ($latestKpi['kpi_churn_low_pct'] ?? 0)),
+                'color' => '#10b981',
+            ],
+            [
+                'label' => 'Moderado',
+                'value' => (int) round((float) ($latestKpi['kpi_churn_moderate_pct'] ?? 0)),
+                'color' => '#f59e0b',
+            ],
+            [
+                'label' => 'Alto',
+                'value' => (int) round((float) ($latestKpi['kpi_churn_high_pct'] ?? 0)),
+                'color' => '#f97316',
+            ],
+            [
+                'label' => 'Crítico',
+                'value' => (int) round((float) ($latestKpi['kpi_churn_critical_pct'] ?? 0)),
+                'color' => '#ef4444',
+            ],
+        ];
+    }
+
+    private function buildLtvRisk(array $latestKpi, array $previousKpi): array
+    {
+        $ltvTotal = (float) ($latestKpi['kpi_ltv_total'] ?? 0);
+        $ltvAtRisk = (float) ($latestKpi['kpi_ltv_at_risk'] ?? 0);
+        $criticalClients = (int) ($latestKpi['kpi_critical_risk_clients'] ?? 0);
+        $previousCriticalClients = (int) ($previousKpi['kpi_critical_risk_clients'] ?? $criticalClients);
+        $delta = $criticalClients - $previousCriticalClients;
+
+        return [
+            'ltvTotal' => round($ltvTotal, 2),
+            'ltvAtRisk' => round($ltvAtRisk, 2),
+            'ltvRiskPct' => $ltvTotal > 0 ? (int) round(($ltvAtRisk / $ltvTotal) * 100) : 0,
+            'criticalClients' => $criticalClients,
+            'avgTicket' => (int) round((float) ($latestKpi['kpi_avg_ticket'] ?? 0)),
+            'trendText' => $this->formatCriticalTrendText($delta),
+        ];
+    }
+
+    private function buildSla(int $companyId, array $latestKpi, \DateTimeImmutable $anchorDate): array
+    {
+        return [
+            'withinPct' => (int) round((float) ($latestKpi['kpi_sla_compliance_pct'] ?? 0)),
+            'breachPct' => max(0, 100 - (int) round((float) ($latestKpi['kpi_sla_compliance_pct'] ?? 0))),
+            'byDepartment' => $this->getSlaByDepartment($companyId, $anchorDate),
+        ];
+    }
+
+    private function getSlaByDepartment(int $companyId, \DateTimeImmutable $anchorDate): array
+    {
+        $stmt = $this->pdo->prepare(
+            "SELECT
+                o.operator_department AS department,
+                ROUND(AVG(ods.operator_sla_compliance_pct))::int AS value
+            FROM operator_daily_stats ods
+            INNER JOIN operator o
+                ON o.operator_id = ods.operator_id
+               AND o.operator_deleted_at = 'infinity'
+            WHERE ods.company_id = :company_id
+              AND ods.operator_stat_date = :anchor_date
+            GROUP BY o.operator_department
+            ORDER BY o.operator_department ASC"
+        );
+        $stmt->execute([
+            'company_id' => $companyId,
+            'anchor_date' => $anchorDate->format('Y-m-d'),
+        ]);
+        $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
+
+        return array_map(static function (array $row): array {
+            return [
+                'department' => $row['department'] ?? '',
+                'value' => (int) ($row['value'] ?? 0),
+            ];
+        }, $rows);
+    }
+
+    private function buildEmotions(array $latestEmotion): array
+    {
+        $totalAnalyzed = (int) ($latestEmotion['emotion_total_analyzed'] ?? 0);
+        $emotionMap = [
+            ['label' => 'Alegria', 'column' => 'emotion_happiness', 'color' => '#10b981'],
+            ['label' => 'Confiança', 'column' => 'emotion_confidence', 'color' => '#6366f1'],
+            ['label' => 'Antecipação', 'column' => 'emotion_anticipation', 'color' => '#8b5cf6'],
+            ['label' => 'Surpresa', 'column' => 'emotion_surprise', 'color' => '#f59e0b'],
+            ['label' => 'Medo', 'column' => 'emotion_fear', 'color' => '#f97316'],
+            ['label' => 'Raiva', 'column' => 'emotion_anger', 'color' => '#ef4444'],
+            ['label' => 'Tristeza', 'column' => 'emotion_sadness', 'color' => '#64748b'],
+        ];
+
+        $items = [];
+        foreach ($emotionMap as $emotion) {
+            $value = (float) ($latestEmotion[$emotion['column']] ?? 0);
+            $items[] = [
+                'label' => $emotion['label'],
+                'value' => (int) round($value),
+                'count' => (int) round(($totalAnalyzed * $value) / 100),
+                'color' => $emotion['color'],
+            ];
+        }
+
+        usort($items, static function (array $left, array $right): int {
+            return $right['value'] <=> $left['value'];
+        });
+
+        return [
+            'items' => $items,
+            'avgSentimentScore' => round($this->calculateEmotionSentimentScore($latestEmotion), 2),
+        ];
+    }
+
+    private function buildQuickAccess(int $companyId, array $latestKpi, array $latestEmotion, array $previousEmotion, \DateTimeImmutable $anchorDate): array
+    {
+        return [
+            'conversationsToday' => $this->getConversationsToday($companyId, $anchorDate),
+            'activePersonas' => $this->getActivePersonas($companyId),
+            'activeAgents' => $this->getActiveAgents($companyId, $latestKpi),
+            'activePlaybooks' => $this->getActivePlaybooks($companyId),
+            'pendingSettings' => $this->getPendingSettings($companyId),
+            'evolutionDelta' => $this->formatSignedPercent($this->calculateEmotionDeltaPercent($latestEmotion, $previousEmotion)),
+        ];
+    }
+
+    private function getConversationsToday(int $companyId, \DateTimeImmutable $anchorDate): int
+    {
+        $stmt = $this->pdo->prepare(
+            "SELECT COALESCE(SUM(operator_attendances_count), 0)
+            FROM operator_daily_stats
+            WHERE company_id = :company_id
+              AND operator_stat_date = :anchor_date"
+        );
+        $stmt->execute([
+            'company_id' => $companyId,
+            'anchor_date' => $anchorDate->format('Y-m-d'),
+        ]);
+
+        return (int) $stmt->fetchColumn();
+    }
+
+    private function getActivePersonas(int $companyId): int
+    {
+        $stmt = $this->pdo->prepare(
+            "SELECT COUNT(*)
+            FROM persona
+            WHERE company_id = :company_id
+              AND persona_deleted_at = 'infinity'"
+        );
+        $stmt->execute(['company_id' => $companyId]);
+
+        return (int) $stmt->fetchColumn();
+    }
+
+    private function getActiveAgents(int $companyId, array $latestKpi): int
+    {
+        $count = (int) ($latestKpi['kpi_total_active_operators'] ?? 0);
+        if ($count > 0) {
+            return $count;
+        }
+
+        $stmt = $this->pdo->prepare(
+            "SELECT COUNT(*)
+            FROM operator
+            WHERE company_id = :company_id
+              AND operator_deleted_at = 'infinity'
+              AND lower(operator_status) <> 'inativo'"
+        );
+        $stmt->execute(['company_id' => $companyId]);
+
+        return (int) $stmt->fetchColumn();
+    }
+
+    private function getActivePlaybooks(int $companyId): int
+    {
+        $stmt = $this->pdo->prepare(
+            "SELECT COUNT(*)
+            FROM ai_action
+            WHERE company_id = :company_id
+              AND ai_action_deleted_at = 'infinity'
+              AND ai_action_created_at >= NOW() - INTERVAL '30 days'"
+        );
+        $stmt->execute(['company_id' => $companyId]);
+
+        return (int) $stmt->fetchColumn();
+    }
+
+    private function getPendingSettings(int $companyId): int
+    {
+        $departmentsStmt = $this->pdo->prepare(
+            "SELECT COUNT(DISTINCT lower(operator_department))
+            FROM operator
+            WHERE company_id = :company_id
+              AND operator_deleted_at = 'infinity'"
+        );
+        $departmentsStmt->execute(['company_id' => $companyId]);
+        $departmentCount = (int) $departmentsStmt->fetchColumn();
+
+        $configsStmt = $this->pdo->prepare(
+            "SELECT COUNT(DISTINCT lower(sla_config_department))
+            FROM sla_config
+            WHERE company_id = :company_id
+              AND sla_config_deleted_at = 'infinity'"
+        );
+        $configsStmt->execute(['company_id' => $companyId]);
+        $configCount = (int) $configsStmt->fetchColumn();
+
+        return max(0, $departmentCount - $configCount);
+    }
+
+    private function calculateEmotionSentimentScore(array $emotionSnapshot): float
+    {
+        $weights = [
+            'emotion_happiness' => 0.9,
+            'emotion_confidence' => 0.6,
+            'emotion_anticipation' => 0.3,
+            'emotion_surprise' => 0.1,
+            'emotion_fear' => -0.6,
+            'emotion_anger' => -1.0,
+            'emotion_sadness' => -0.7,
+        ];
+
+        $score = 0.0;
+        foreach ($weights as $column => $weight) {
+            $score += ((float) ($emotionSnapshot[$column] ?? 0) / 100) * $weight;
+        }
+
+        return max(-1, min(1, $score));
+    }
+
+    private function calculateEmotionDeltaPercent(array $latestEmotion, array $previousEmotion): float
+    {
+        $latest = $this->calculateEmotionSentimentScore($latestEmotion);
+        $previous = $this->calculateEmotionSentimentScore($previousEmotion);
+
+        if (abs($previous) < 0.00001) {
+            return $latest > 0 ? 100.0 : 0.0;
+        }
+
+        return (($latest - $previous) / abs($previous)) * 100;
+    }
+
+    private function formatCurrency(float $value): string
+    {
+        return 'R$ ' . number_format($value, 2, ',', '.');
+    }
+
+    private function formatTrendLabel(float $current, float $previous, string $suffix, bool $dangerOnIncrease): string
+    {
+        if (abs($current - $previous) < 0.00001) {
+            return '→ estável';
+        }
+
+        $deltaPct = $this->calculateDeltaPercent($current, $previous);
+        $arrow = $deltaPct >= 0 ? '↑' : '↓';
+        $prefix = $deltaPct >= 0 ? '+' : '';
+
+        return sprintf('%s %s%s%% %s', $arrow, $prefix, round($deltaPct), $suffix);
+    }
+
+    private function formatAbsoluteTrendLabel(int $delta, string $suffix, bool $dangerOnIncrease): string
+    {
+        if ($delta === 0) {
+            return '→ estável';
+        }
+
+        $arrow = $delta > 0 ? '↑' : '↓';
+        $prefix = $delta > 0 ? '+' : '';
+
+        return sprintf('%s %s%d %s', $arrow, $prefix, $delta, $suffix);
+    }
+
+    private function formatCriticalTrendText(int $delta): string
+    {
+        if ($delta > 0) {
+            return sprintf('+%d clientes entraram em risco crítico desde ontem', $delta);
+        }
+
+        if ($delta < 0) {
+            return sprintf('%d clientes saíram do risco crítico desde ontem', abs($delta));
+        }
+
+        return 'Nenhuma mudança no risco crítico desde ontem';
+    }
+
+    private function formatSignedPercent(float $value): string
+    {
+        $rounded = round($value);
+        if ($rounded > 0) {
+            return '+' . $rounded . '%';
+        }
+
+        if ($rounded < 0) {
+            return (string) $rounded . '%';
+        }
+
+        return '0%';
+    }
+
+    private function calculateDeltaPercent(float $current, float $previous): float
+    {
+        if (abs($previous) < 0.00001) {
+            return $current > 0 ? 100.0 : 0.0;
+        }
+
+        return (($current - $previous) / abs($previous)) * 100;
+    }
+}

+ 7 - 1
public/index.php

@@ -31,8 +31,14 @@ $app->get('/interactions', $authJwt,\Controllers\InteractionsController::class);
 $app->get('/interactions/details', $authJwt,\Controllers\InteractionDetailsController::class);
 $app->get('/analytics/sentiment/dashboard', $authJwt,\Controllers\AnalyticsSentimentDashboardController::class);
 $app->get('/personas/overview', $authJwt,\Controllers\PersonasOverviewController::class);
+$app->get('/evolution/overview', $authJwt,\Controllers\EvolutionOverviewController::class);
+$app->get('/executive/dashboard', $authJwt,\Controllers\ExecutiveDashboardController::class);
+$app->get('/agents', $authJwt,\Controllers\AgentsController::class);
 
 $app->post('/login', \Controllers\LoginController::class);
 $app->post('/register', \Controllers\RegisterController::class);
+$app->post('/agents', $authJwt, \Controllers\AgentSaveController::class);
+$app->post('/agents/status', $authJwt, \Controllers\AgentStatusController::class);
+$app->post('/agents/escalation', $authJwt, \Controllers\AgentEscalationController::class);
 
-$app->run();
+$app->run();