|
@@ -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>
|