leonardo 2 тижнів тому
батько
коміт
b05b48cb60

+ 29 - 0
src/api/atendimentos.js

@@ -56,6 +56,35 @@ export function listarSetoresAvaliacoes() {
   return apiFetch("/api/atendimentos/avaliacoes/setores");
 }
 
+export function rankingAtendentesAvaliacoes({ setor, dataInicio, dataFim } = {}) {
+  const query = new URLSearchParams();
+  if (setor) query.set("setor", setor);
+  if (dataInicio) query.set("dataInicio", dataInicio);
+  if (dataFim) query.set("dataFim", dataFim);
+  const qs = query.toString();
+  return apiFetch(`/api/atendimentos/avaliacoes/ranking-atendentes${qs ? `?${qs}` : ""}`);
+}
+
+export async function baixarRelatorioRankingAtendentes({ setor, dataInicio, dataFim } = {}) {
+  const query = new URLSearchParams();
+  if (setor) query.set("setor", setor);
+  if (dataInicio) query.set("dataInicio", dataInicio);
+  if (dataFim) query.set("dataFim", dataFim);
+
+  const reqUrl = `${apiBaseUrl}/api/atendimentos/avaliacoes/ranking-atendentes/relatorio?${query.toString()}`;
+
+  const res = await fetchAutenticado(reqUrl);
+
+  if (!res.ok) {
+    const text = await res.text().catch(() => "");
+    let payload = null;
+    try { payload = JSON.parse(text); } catch {}
+    throw new Error(payload?.error || text || `http_error:${res.status}`);
+  }
+
+  baixarBlob(await res.blob(), `ranking-atendentes-${new Date().toISOString().slice(0, 10)}.pdf`);
+}
+
 export function listarGoldenSet({ status, setor } = {}) {
   const query = new URLSearchParams();
   if (status) query.set("status", status);

+ 0 - 5
src/components/atendimentos/ItemAvaliacao.vue

@@ -8,8 +8,6 @@ import { formatarData } from "../../utils/data.js";
 import {
   cancelamentoLabel,
   cancelamentoVariant,
-  CONTEXTO_IXC_ERRO_LABEL,
-  contextoIxcFalhou,
   resolvidoLabel,
   resolvidoVariant,
   scoreVariant,
@@ -66,9 +64,6 @@ defineEmits(["toggle", "verConversa"]);
         >
           {{ cancelamentoLabel[avaliacao.StatusCancelamento] }}
         </BaseBadge>
-        <BaseBadge v-if="contextoIxcFalhou(avaliacao.ContextoIxcStatus)" variant="warning" :title="CONTEXTO_IXC_ERRO_LABEL">
-          Contexto ERP indisponível
-        </BaseBadge>
       </div>
     </template>
 

+ 11 - 0
src/layout/SidebarSistema.vue

@@ -14,6 +14,7 @@ import {
   TriangleAlert,
   CircleCheck,
   SlidersHorizontal,
+  Trophy,
 } from "lucide-vue-next";
 import ListaConversas from "./ListaConversas.vue";
 import BaseIcon from "../components/base/BaseIcon.vue";
@@ -160,6 +161,16 @@ async function novaConversa() {
             <span :class="classeRotulo">Avaliações</span>
           </button>
 
+          <button
+            type="button"
+            :class="classeItemSidebar(rotaAtiva === 'ranking-atendentes')"
+            :title="sidebarRecolhida ? 'Ranking de atendentes' : ''"
+            @click="irPara('ranking-atendentes')"
+          >
+            <BaseIcon :icon="Trophy" :class="classeIcon(rotaAtiva === 'ranking-atendentes')" />
+            <span :class="classeRotulo">Ranking de atendentes</span>
+          </button>
+
           <button
             type="button"
             :class="classeItemSidebar(rotaAtiva === 'atendimentos-nao-resolvidos')"

+ 7 - 0
src/router/index.js

@@ -8,6 +8,7 @@ const ConfiguracoesView = () => import("../views/configuracoes/ConfiguracoesView
 const UsuariosView = () => import("../views/usuarios/UsuariosView.vue");
 const AvaliacoesView = () => import("../views/atendimentos/AvaliacoesView.vue");
 const NaoResolvidosView = () => import("../views/atendimentos/NaoResolvidosView.vue");
+const RankingAtendentesView = () => import("../views/atendimentos/RankingAtendentesView.vue");
 const AtendimentoView = () => import("../views/atendimentos/AtendimentoView.vue");
 const GoldenSetView = () => import("../views/atendimentos/GoldenSetView.vue");
 const ConversasWhatsappView = () => import("../views/conversas/ConversasWhatsappView.vue");
@@ -36,6 +37,12 @@ export const router = createRouter({
       component: NaoResolvidosView,
       meta: { requiresAuth: true, nivelMinimo: NIVEL_ADMIN }
     },
+    {
+      path: "/atendimentos/ranking-atendentes",
+      name: "ranking-atendentes",
+      component: RankingAtendentesView,
+      meta: { requiresAuth: true, nivelMinimo: NIVEL_ADMIN }
+    },
     {
       path: "/atendimentos/:id(\\d+)",
       name: "atendimento",

+ 2 - 10
src/utils/avaliacoes.js

@@ -1,4 +1,3 @@
-// aparência dos badges de avaliação, compartilhada entre a lista e a tela da conversa
 export function scoreVariant(score) {
   if (score === null || score === undefined) return "default";
   if (score >= 8) return "success";
@@ -12,20 +11,13 @@ export const resolvidoLabel = { sim: "Resolvido", parcial: "Parcial", nao: "Não
 export const sentimentoVariant = { positivo: "success", neutro: "default", negativo: "danger", indefinido: "default" };
 export const sentimentoLabel = { positivo: "Positivo", neutro: "Neutro", negativo: "Negativo", indefinido: "Indefinido" };
 export const cancelamentoVariant = { cancelou: "danger", ameacou: "warning" };
-export const cancelamentoLabel = { cancelou: "Cancelou", ameacou: "Quer cancelar" };
+export const cancelamentoLabel = { cancelou: "Cancelou", ameacou: "Quer cancelar" }; 
 
 
-export const statusCancelamentoLabel = { ...cancelamentoLabel, nao: "Não", indefinido: "Indefinido" };
+export const statusCancelamentoLabel = { ...cancelamentoLabel, nao: "Não", indefinido: "Indefinido" }; 
 
 
 export function pct(parte, todo) {
   if (!todo) return "0%";
   return `${Math.round((parte / todo) * 100)}%`;
 }
-
-// só "erro" indica falha real na integração; sem_erp_protocolo/sem_ticket/nao_verificado são
-// desfechos esperados e não devem gerar alerta visual (ver design da change fix-ixc-contexto-falha-silenciosa)
-export const CONTEXTO_IXC_ERRO_LABEL = "Contexto do ERP indisponível nesta avaliação";
-export function contextoIxcFalhou(status) {
-  return status === "erro";
-}

+ 0 - 5
src/views/atendimentos/AtendimentoView.vue

@@ -13,8 +13,6 @@ import { useAuth } from "../../composables/useAuth.js";
 import {
   cancelamentoLabel,
   cancelamentoVariant,
-  CONTEXTO_IXC_ERRO_LABEL,
-  contextoIxcFalhou,
   resolvidoLabel,
   resolvidoVariant,
   scoreVariant,
@@ -288,9 +286,6 @@ watch(() => route.params.id, carregar, { immediate: true });
                 <BaseBadge v-if="cancelamentoLabel[avaliacao.StatusCancelamento]" :variant="cancelamentoVariant[avaliacao.StatusCancelamento]">
                   {{ cancelamentoLabel[avaliacao.StatusCancelamento] }}
                 </BaseBadge>
-                <BaseBadge v-if="contextoIxcFalhou(avaliacao.ContextoIxcStatus)" variant="warning" :title="CONTEXTO_IXC_ERRO_LABEL">
-                  Contexto ERP indisponível
-                </BaseBadge>
               </template>
             </div>
           </div>

+ 422 - 0
src/views/atendimentos/RankingAtendentesView.vue

@@ -0,0 +1,422 @@
+<script setup>
+import { computed, onMounted, ref } from "vue";
+import { useRoute, useRouter } from "vue-router";
+import LayoutSistema from "../../layout/LayoutSistema.vue";
+import BaseIcon from "../../components/base/BaseIcon.vue";
+import StatCard from "../../components/base/StatCard.vue";
+import BaseBadge from "../../components/base/BaseBadge.vue";
+import BaseButton from "../../components/base/BaseButton.vue";
+import EmptyState from "../../components/base/EmptyState.vue";
+import Spinner from "../../components/base/Spinner.vue";
+import { Trophy, Star, Users, CircleCheck, Info, ChevronUp, ChevronDown, Download } from "lucide-vue-next";
+import { rankingAtendentesAvaliacoes, baixarRelatorioRankingAtendentes } from "../../api/atendimentos.js";
+import { useAsyncState } from "../../composables/useAsyncState.js";
+import { useToast } from "../../composables/useToast.js";
+import { queryTexto } from "../../composables/useListaAvaliacoes.js";
+import { scoreVariant } from "../../utils/avaliacoes.js";
+
+
+const COR_BARRA = {
+  success: "bg-emerald-500 dark:bg-emerald-400",
+  info: "bg-blue-500 dark:bg-blue-400",
+  warning: "bg-amber-500 dark:bg-amber-400",
+  danger: "bg-red-500 dark:bg-red-400",
+  default: "bg-gray-400 dark:bg-gray-500"
+};
+function corBarraScore(score) {
+  return COR_BARRA[scoreVariant(score)];
+}
+
+const SETOR_SUPORTE = "SUP";
+
+const SENTIMENTO_LABEL = { positivo: "positivo", neutro: "neutro", negativo: "negativo" };
+const SENTIMENTO_VARIANT = { positivo: "success", neutro: "default", negativo: "danger" };
+
+const route = useRoute();
+const router = useRouter();
+const toast = useToast();
+
+function formatISO(date) {
+  return date.toISOString().slice(0, 10);
+}
+
+function padraoDataInicio() {
+  const d = new Date();
+  d.setDate(d.getDate() - 30);
+  return formatISO(d);
+}
+
+// lê um parâmetro numérico da query string, com fallback quando ausente/inválido
+function queryNumero(route, chave, padrao) {
+  const v = route.query[chave];
+  const n = typeof v === "string" ? Number(v) : NaN;
+  return Number.isFinite(n) ? n : padrao;
+}
+
+function abrirAvaliacoes(nome) {
+  router.push({ name: "avaliacoes", query: { setor: SETOR_SUPORTE, q: nome } });
+}
+
+function percentual(count, total) {
+  return total ? Number(((count / total) * 100).toFixed(1)) : 0;
+}
+
+function sentimentoPredominante(a) {
+  const entradas = Object.entries(a.sentimento ?? {}).filter(([chave, valor]) => chave !== "indefinido" && valor > 0);
+  if (!entradas.length) return null;
+  const [chave, valor] = entradas.reduce((max, atual) => (atual[1] > max[1] ? atual : max));
+  return { chave, percentual: percentual(valor, a.totalAvaliacoes) };
+}
+
+function totalCancelamentos(a) {
+  return (a.cancelamento?.ameacou ?? 0) + (a.cancelamento?.cancelou ?? 0);
+}
+
+const filtroDataInicio = ref(queryTexto(route, "dataInicio", padraoDataInicio()));
+const filtroDataFim = ref(queryTexto(route, "dataFim", formatISO(new Date())));
+const minAvaliacoes = ref(queryNumero(route, "minAvaliacoes", 5));
+
+const { data: ranking, loading: carregando, error: erro, run } = useAsyncState([]);
+const metadados = ref(null);
+const gerandoRelatorio = ref(false);
+
+const rankingFiltrado = computed(() => (ranking.value ?? []).filter((a) => a.totalAvaliacoes >= minAvaliacoes.value));
+
+const ordenacaoColuna = ref("mediaScore");
+const ordenacaoDirecao = ref("desc");
+
+function ordenarPor(coluna) {
+  if (ordenacaoColuna.value === coluna) {
+    ordenacaoDirecao.value = ordenacaoDirecao.value === "desc" ? "asc" : "desc";
+  } else {
+    ordenacaoColuna.value = coluna;
+    ordenacaoDirecao.value = "desc";
+  }
+}
+
+const rankingOrdenado = computed(() => {
+  const lista = [...rankingFiltrado.value];
+  const coluna = ordenacaoColuna.value;
+  const direcao = ordenacaoDirecao.value === "asc" ? 1 : -1;
+  lista.sort((a, b) => {
+    const av = a[coluna];
+    const bv = b[coluna];
+    if (typeof av === "string") return direcao * av.localeCompare(bv);
+    return direcao * ((av ?? -Infinity) - (bv ?? -Infinity));
+  });
+  return lista;
+});
+
+const resumo = computed(() => {
+  const lista = rankingFiltrado.value;
+  const comScore = lista.filter((a) => a.mediaScore !== null);
+  const somaPonderada = comScore.reduce((s, a) => s + a.mediaScore * a.totalAvaliacoes, 0);
+  const totalAvaliacoesComScore = comScore.reduce((s, a) => s + a.totalAvaliacoes, 0);
+  const mediaGeral = totalAvaliacoesComScore ? somaPonderada / totalAvaliacoesComScore : null;
+  return {
+    totalAtendentes: lista.length,
+    mediaGeral: mediaGeral !== null ? Number(mediaGeral.toFixed(2)) : null,
+    top: lista[0] ?? null
+  };
+});
+
+function sincronizarQuery() {
+  const query = {};
+  if (filtroDataInicio.value) query.dataInicio = filtroDataInicio.value;
+  if (filtroDataFim.value) query.dataFim = filtroDataFim.value;
+  if (minAvaliacoes.value !== 5) query.minAvaliacoes = String(minAvaliacoes.value);
+  router.replace({ query });
+}
+
+async function carregar() {
+  sincronizarQuery();
+  try {
+    await run(() =>
+      rankingAtendentesAvaliacoes({
+        setor: SETOR_SUPORTE,
+        dataInicio: filtroDataInicio.value || undefined,
+        dataFim: filtroDataFim.value || undefined
+      }).then((r) => {
+        metadados.value = r.metadados ?? null;
+        return r.results;
+      })
+    );
+  } catch (err) {
+    toast.erro("Falha ao carregar o ranking de atendentes.");
+    console.warn("[RankingAtendentesView] carregar:", err);
+  }
+}
+
+async function baixarRelatorio() {
+  if (gerandoRelatorio.value) return;
+  gerandoRelatorio.value = true;
+  try {
+    await baixarRelatorioRankingAtendentes({
+      setor: SETOR_SUPORTE,
+      dataInicio: filtroDataInicio.value || undefined,
+      dataFim: filtroDataFim.value || undefined
+    });
+  } catch (err) {
+    toast.erro(err?.message ?? "Falha ao gerar o relatório.");
+  } finally {
+    gerandoRelatorio.value = false;
+  }
+}
+
+onMounted(() => {
+  carregar();
+});
+</script>
+
+<template>
+  <LayoutSistema>
+    <div class="min-h-[calc(100vh-var(--layout-header-height))] px-4 pt-8 pb-12">
+      <div class="mx-auto w-full max-w-[960px] space-y-6">
+        <section
+          class="rounded-2xl border border-gray-200 bg-gray-100/50 p-6 shadow-sm backdrop-blur-md dark:border-gray-700 dark:bg-secondary/50"
+        >
+          <div class="flex flex-wrap items-center justify-between gap-4">
+            <div class="flex items-center gap-4">
+              <div
+                class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-amber-500/10 text-amber-600 dark:bg-amber-500/20 dark:text-amber-400"
+              >
+                <BaseIcon :icon="Trophy" class="h-5 w-5" />
+              </div>
+              <div class="min-w-0">
+                <h2 class="text-base font-semibold text-foreground">Ranking de Atendentes — Suporte Interno</h2>
+                <p class="mt-0.5 text-sm text-muted-foreground">
+                  Técnicos do Suporte Interno ordenados pela nota média das avaliações automáticas, por período.
+                </p>
+              </div>
+            </div>
+            <div class="flex items-center gap-2">
+              <input
+                v-model="filtroDataInicio"
+                type="date"
+                class="rounded-lg border border-gray-200 bg-background px-3 py-1.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-primary/30 dark:border-gray-700 dark:text-gray-200"
+                @change="carregar"
+              />
+              <span class="text-sm text-muted-foreground">até</span>
+              <input
+                v-model="filtroDataFim"
+                type="date"
+                class="rounded-lg border border-gray-200 bg-background px-3 py-1.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-primary/30 dark:border-gray-700 dark:text-gray-200"
+                @change="carregar"
+              />
+            </div>
+          </div>
+
+          <div v-if="erro" class="mt-4 rounded-lg bg-red-50 px-4 py-3 text-xs text-red-800 dark:bg-red-900/30 dark:text-red-300">
+            {{ erro }}
+          </div>
+
+          <p v-if="metadados" class="mt-4 text-xs text-muted-foreground">
+            {{ metadados.totalAvaliacoesElegiveis }} avaliações no período/setor ·
+            {{ metadados.descartadasSemAtendente + metadados.descartadasNomeInvalido + metadados.descartadasForaDaEquipe }}
+            sem atendente identificado ou fora da equipe
+          </p>
+        </section>
+
+        <section class="grid grid-cols-2 gap-3 md:grid-cols-3">
+          <StatCard label="Atendentes no ranking" :value="resumo.totalAtendentes" variant="default">
+            <template #icon>
+              <BaseIcon :icon="Users" class="h-4 w-4" />
+            </template>
+          </StatCard>
+          <StatCard label="Média geral" :value="resumo.mediaGeral ?? '—'" suffix=" / 10" variant="info">
+            <template #icon>
+              <BaseIcon :icon="Star" class="h-4 w-4" />
+            </template>
+          </StatCard>
+          <StatCard label="Top atendente" :value="resumo.top?.atendente ?? '—'" variant="success">
+            <template #icon>
+              <BaseIcon :icon="Trophy" class="h-4 w-4" />
+            </template>
+            <span v-if="resumo.top">Nota {{ resumo.top.mediaScore }} em {{ resumo.top.totalAvaliacoes }} avaliações</span>
+          </StatCard>
+        </section>
+
+        <section class="rounded-2xl border border-gray-200 bg-background/70 shadow-sm dark:border-gray-700 dark:bg-background/20">
+          <div class="flex flex-wrap items-center gap-3 border-b border-gray-200 p-4 dark:border-gray-700">
+            <label class="flex shrink-0 items-center gap-2 text-sm text-muted-foreground">
+              Mínimo de avaliações
+              <input
+                v-model.number="minAvaliacoes"
+                type="number"
+                min="0"
+                class="w-16 rounded-lg border border-gray-200 bg-background px-2 py-1 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-primary/30 dark:border-gray-700 dark:text-gray-200"
+                @change="sincronizarQuery"
+              />
+            </label>
+            <BaseButton
+              variant="secondary"
+              size="sm"
+              :loading="gerandoRelatorio"
+              :disabled="rankingFiltrado.length === 0"
+              title="Baixar PDF com o ranking filtrado"
+              @click="baixarRelatorio"
+            >
+              Exportar PDF
+              <BaseIcon :icon="Download" class="h-3.5 w-3.5" />
+            </BaseButton>
+            <div class="ml-auto text-xs text-muted-foreground">{{ rankingFiltrado.length }} atendentes</div>
+          </div>
+
+          <Spinner v-if="carregando" center label="Carregando ranking..." />
+          <EmptyState
+            v-else-if="rankingFiltrado.length === 0"
+            :icon="CircleCheck"
+            titulo="Nenhum atendente encontrado"
+            descricao="Ajuste os filtros, o período ou o mínimo de avaliações."
+          />
+
+          <div v-else class="overflow-x-auto">
+            <table class="w-full text-sm">
+              <thead>
+                <tr class="border-b border-gray-200 text-left text-xs text-muted-foreground dark:border-gray-700">
+                  <th class="px-4 py-2 font-medium">#</th>
+                  <th class="cursor-pointer select-none px-4 py-2 font-medium" @click="ordenarPor('atendente')">
+                    <span class="inline-flex items-center gap-1">
+                      Atendente
+                      <BaseIcon
+                        v-if="ordenacaoColuna === 'atendente'"
+                        :icon="ordenacaoDirecao === 'asc' ? ChevronUp : ChevronDown"
+                        class="h-3.5 w-3.5"
+                      />
+                    </span>
+                  </th>
+                  <th class="cursor-pointer select-none px-4 py-2 font-medium" @click="ordenarPor('mediaScore')">
+                    <span class="group relative inline-flex items-center gap-1">
+                      Média
+                      <BaseIcon
+                        v-if="ordenacaoColuna === 'mediaScore'"
+                        :icon="ordenacaoDirecao === 'asc' ? ChevronUp : ChevronDown"
+                        class="h-3.5 w-3.5"
+                      />
+                      <BaseIcon :icon="Info" class="h-3.5 w-3.5 text-gray-400" />
+                      <span
+                        class="pointer-events-none absolute left-1/2 top-full z-10 mt-1.5 w-56 -translate-x-1/2 rounded-lg bg-gray-900 px-2.5 py-1.5 text-xs font-normal normal-case text-white opacity-0 shadow-lg transition-opacity duration-100 group-hover:opacity-100 dark:bg-gray-700"
+                      >
+                        Média das notas (1 a 10) dadas pela IA-juiz nas avaliações do atendente. Avaliações sem nota não entram na conta.
+                      </span>
+                    </span>
+                  </th>
+                  <th class="cursor-pointer select-none px-4 py-2 font-medium" @click="ordenarPor('totalAvaliacoes')">
+                    <span class="group relative inline-flex items-center gap-1">
+                      Avaliações
+                      <BaseIcon
+                        v-if="ordenacaoColuna === 'totalAvaliacoes'"
+                        :icon="ordenacaoDirecao === 'asc' ? ChevronUp : ChevronDown"
+                        class="h-3.5 w-3.5"
+                      />
+                      <BaseIcon :icon="Info" class="h-3.5 w-3.5 text-gray-400" />
+                      <span
+                        class="pointer-events-none absolute left-1/2 top-full z-10 mt-1.5 w-56 -translate-x-1/2 rounded-lg bg-gray-900 px-2.5 py-1.5 text-xs font-normal normal-case text-white opacity-0 shadow-lg transition-opacity duration-100 group-hover:opacity-100 dark:bg-gray-700"
+                      >
+                        Quantidade de avaliações no período em que o nome do atendente foi identificado. Quando uma avaliação cita mais de um atendente, ela conta para cada um deles.
+                      </span>
+                    </span>
+                  </th>
+                  <th class="cursor-pointer select-none px-4 py-2 font-medium" @click="ordenarPor('percentualResolvido')">
+                    <span class="group relative inline-flex items-center gap-1">
+                      % Resolvido
+                      <BaseIcon
+                        v-if="ordenacaoColuna === 'percentualResolvido'"
+                        :icon="ordenacaoDirecao === 'asc' ? ChevronUp : ChevronDown"
+                        class="h-3.5 w-3.5"
+                      />
+                      <BaseIcon :icon="Info" class="h-3.5 w-3.5 text-gray-400" />
+                      <span
+                        class="pointer-events-none absolute right-0 top-full z-10 mt-1.5 w-56 rounded-lg bg-gray-900 px-2.5 py-1.5 text-xs font-normal normal-case text-white opacity-0 shadow-lg transition-opacity duration-100 group-hover:opacity-100 dark:bg-gray-700"
+                      >
+                        Percentual das avaliações do atendente marcadas como "Resolvido: sim" pela IA-juiz, sobre o total de avaliações dele no período.
+                      </span>
+                    </span>
+                  </th>
+                  <th class="hidden px-4 py-2 font-medium md:table-cell">
+                    <span class="group relative inline-flex items-center gap-1">
+                      Sentimento
+                      <BaseIcon :icon="Info" class="h-3.5 w-3.5 text-gray-400" />
+                      <span
+                        class="pointer-events-none absolute right-0 top-full z-10 mt-1.5 w-56 rounded-lg bg-gray-900 px-2.5 py-1.5 text-xs font-normal normal-case text-white opacity-0 shadow-lg transition-opacity duration-100 group-hover:opacity-100 dark:bg-gray-700"
+                      >
+                        Sentimento predominante do cliente nas avaliações do atendente (positivo, neutro ou negativo), com o percentual sobre o total dele no período.
+                      </span>
+                    </span>
+                  </th>
+                  <th class="hidden px-4 py-2 font-medium md:table-cell">
+                    <span class="group relative inline-flex items-center gap-1">
+                      Cancelamento
+                      <BaseIcon :icon="Info" class="h-3.5 w-3.5 text-gray-400" />
+                      <span
+                        class="pointer-events-none absolute right-0 top-full z-10 mt-1.5 w-56 rounded-lg bg-gray-900 px-2.5 py-1.5 text-xs font-normal normal-case text-white opacity-0 shadow-lg transition-opacity duration-100 group-hover:opacity-100 dark:bg-gray-700"
+                      >
+                        Quantidade de avaliações do atendente em que o cliente ameaçou cancelar ou cancelou o contrato.
+                      </span>
+                    </span>
+                  </th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr
+                  v-for="(a, i) in rankingOrdenado"
+                  :key="a.atendente"
+                  class="border-b border-gray-100 last:border-0 dark:border-gray-800"
+                  :class="i < 3 ? 'bg-amber-50/60 dark:bg-amber-500/10' : ''"
+                >
+                  <td class="px-4 py-2.5 text-muted-foreground">{{ i + 1 }}</td>
+                  <td class="px-4 py-2.5 font-medium text-foreground">
+                    <button
+                      type="button"
+                      class="border-0 bg-transparent p-0 text-left font-medium text-foreground hover:text-primary hover:underline"
+                      title="Ver avaliações deste atendente"
+                      @click="abrirAvaliacoes(a.atendente)"
+                    >
+                      {{ a.atendente }}
+                    </button>
+                  </td>
+                  <td class="px-4 py-2.5">
+                    <div class="flex items-center gap-2">
+                      <div class="h-2 w-24 shrink-0 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
+                        <div
+                          class="h-full rounded-full"
+                          :class="corBarraScore(a.mediaScore)"
+                          :style="{ width: `${((a.mediaScore ?? 0) / 10) * 100}%` }"
+                        />
+                      </div>
+                      <span class="w-16 shrink-0 text-xs text-muted-foreground">
+                        {{ a.mediaScore !== null ? a.mediaScore : "sem nota" }}
+                      </span>
+                    </div>
+                  </td>
+                  <td class="px-4 py-2.5 text-muted-foreground">{{ a.totalAvaliacoes }}</td>
+                  <td class="px-4 py-2.5">
+                    <div class="flex items-center gap-2">
+                      <div class="h-2 w-24 shrink-0 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
+                        <div
+                          class="h-full rounded-full bg-violet-500 dark:bg-violet-400"
+                          :style="{ width: `${a.percentualResolvido}%` }"
+                        />
+                      </div>
+                      <span class="w-10 shrink-0 text-xs text-muted-foreground">{{ a.percentualResolvido }}%</span>
+                    </div>
+                  </td>
+                  <td class="hidden px-4 py-2.5 md:table-cell">
+                    <BaseBadge v-if="sentimentoPredominante(a)" :variant="SENTIMENTO_VARIANT[sentimentoPredominante(a).chave]">
+                      {{ sentimentoPredominante(a).percentual }}% {{ SENTIMENTO_LABEL[sentimentoPredominante(a).chave] }}
+                    </BaseBadge>
+                    <span v-else class="text-muted-foreground">—</span>
+                  </td>
+                  <td class="hidden px-4 py-2.5 md:table-cell">
+                    <BaseBadge v-if="totalCancelamentos(a) > 0" variant="danger">{{ totalCancelamentos(a) }}</BaseBadge>
+                    <span v-else class="text-muted-foreground">—</span>
+                  </td>
+                </tr>
+              </tbody>
+            </table>
+          </div>
+        </section>
+      </div>
+    </div>
+  </LayoutSistema>
+</template>