|
@@ -0,0 +1,313 @@
|
|
|
|
|
+<script setup>
|
|
|
|
|
+import { computed, onMounted, ref } from "vue";
|
|
|
|
|
+import LayoutSistema from "../../layout/LayoutSistema.vue";
|
|
|
|
|
+import BaseBadge from "../../components/base/BaseBadge.vue";
|
|
|
|
|
+import {
|
|
|
|
|
+ avaliarAtendimentosPendentes,
|
|
|
|
|
+ estatisticasAvaliacoes,
|
|
|
|
|
+ listarAvaliacoes
|
|
|
|
|
+} from "../../api/atendimentos.js";
|
|
|
|
|
+
|
|
|
|
|
+const carregando = ref(false);
|
|
|
|
|
+const avaliandoLote = ref(false);
|
|
|
|
|
+const erro = ref("");
|
|
|
|
|
+const loteResultado = ref(null);
|
|
|
|
|
+
|
|
|
|
|
+const stats = ref(null);
|
|
|
|
|
+const avaliacoes = ref([]);
|
|
|
|
|
+const total = ref(0);
|
|
|
|
|
+const page = ref(1);
|
|
|
|
|
+const pageSize = 20;
|
|
|
|
|
+const expandido = ref(null);
|
|
|
|
|
+
|
|
|
|
|
+const filtroResolvido = ref("");
|
|
|
|
|
+const filtroSentimento = ref("");
|
|
|
|
|
+
|
|
|
|
|
+const totalPaginas = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
|
|
|
|
|
+
|
|
|
|
|
+function scoreVariant(score) {
|
|
|
|
|
+ if (score === null || score === undefined) return "default";
|
|
|
|
|
+ if (score >= 8) return "success";
|
|
|
|
|
+ if (score >= 6) return "info";
|
|
|
|
|
+ if (score >= 4) return "warning";
|
|
|
|
|
+ return "danger";
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const resolvidoVariant = { sim: "success", parcial: "warning", nao: "danger", indefinido: "default" };
|
|
|
|
|
+const resolvidoLabel = { sim: "Resolvido", parcial: "Parcial", nao: "Não resolvido", indefinido: "Indefinido" };
|
|
|
|
|
+const sentimentoVariant = { positivo: "success", neutro: "default", negativo: "danger", indefinido: "default" };
|
|
|
|
|
+const sentimentoLabel = { positivo: "Positivo", neutro: "Neutro", negativo: "Negativo", indefinido: "Indefinido" };
|
|
|
|
|
+
|
|
|
|
|
+function formatData(value) {
|
|
|
|
|
+ if (!value) return "";
|
|
|
|
|
+ const d = new Date(value);
|
|
|
|
|
+ if (Number.isNaN(d.getTime())) return "";
|
|
|
|
|
+ return d.toLocaleDateString("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric" });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function pct(parte, todo) {
|
|
|
|
|
+ if (!todo) return "0%";
|
|
|
|
|
+ return `${Math.round((parte / todo) * 100)}%`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function carregarStats() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ stats.value = await estatisticasAvaliacoes();
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ stats.value = null;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function carregarLista() {
|
|
|
|
|
+ carregando.value = true;
|
|
|
|
|
+ erro.value = "";
|
|
|
|
|
+ try {
|
|
|
|
|
+ const r = await listarAvaliacoes({
|
|
|
|
|
+ page: page.value,
|
|
|
|
|
+ pageSize,
|
|
|
|
|
+ resolvido: filtroResolvido.value || undefined,
|
|
|
|
|
+ sentimento: filtroSentimento.value || undefined
|
|
|
|
|
+ });
|
|
|
|
|
+ avaliacoes.value = r.results ?? [];
|
|
|
|
|
+ total.value = r.total ?? 0;
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ erro.value = err?.message ?? "Falha ao carregar avaliações";
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ carregando.value = false;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function aplicarFiltros() {
|
|
|
|
|
+ page.value = 1;
|
|
|
|
|
+ expandido.value = null;
|
|
|
|
|
+ carregarLista();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function irParaPagina(nova) {
|
|
|
|
|
+ if (nova < 1 || nova > totalPaginas.value || nova === page.value) return;
|
|
|
|
|
+ page.value = nova;
|
|
|
|
|
+ expandido.value = null;
|
|
|
|
|
+ carregarLista();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function avaliarPendentes() {
|
|
|
|
|
+ if (avaliandoLote.value) return;
|
|
|
|
|
+ avaliandoLote.value = true;
|
|
|
|
|
+ loteResultado.value = null;
|
|
|
|
|
+ erro.value = "";
|
|
|
|
|
+ try {
|
|
|
|
|
+ loteResultado.value = await avaliarAtendimentosPendentes({ limite: 10 });
|
|
|
|
|
+ await Promise.all([carregarStats(), carregarLista()]);
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ erro.value = err?.message ?? "Falha ao avaliar pendentes";
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ avaliandoLote.value = false;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function alternarExpandido(id) {
|
|
|
|
|
+ expandido.value = expandido.value === id ? null : id;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+onMounted(() => {
|
|
|
|
|
+ carregarStats();
|
|
|
|
|
+ carregarLista();
|
|
|
|
|
+});
|
|
|
|
|
+</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-primary/10 text-primary dark:bg-primary/20"
|
|
|
|
|
+ >
|
|
|
|
|
+ <svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
|
|
|
|
+ <path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.5c.16-.4.88-.4 1.04 0l1.98 4.88 5.25.4c.43.03.6.57.28.85l-4 3.46 1.22 5.13c.1.42-.36.75-.73.52L12 15.98l-4.52 2.76c-.37.23-.83-.1-.73-.52l1.22-5.13-4-3.46c-.33-.28-.15-.82.28-.85l5.25-.4 1.98-4.88Z" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="min-w-0">
|
|
|
|
|
+ <h2 class="text-base font-semibold text-gray-900 dark:text-gray-100">
|
|
|
|
|
+ Avaliações de Atendimento (IA)
|
|
|
|
|
+ </h2>
|
|
|
|
|
+ <p class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">
|
|
|
|
|
+ Score do atendente, resolução, sentimento do cliente e pontos de melhoria gerados automaticamente.
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ class="rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-white shadow-sm hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
|
|
|
|
+ :disabled="avaliandoLote"
|
|
|
|
|
+ @click="avaliarPendentes"
|
|
|
|
|
+ >
|
|
|
|
|
+ {{ avaliandoLote ? "Avaliando..." : `Avaliar pendentes${stats?.pendentes ? ` (${stats.pendentes})` : ""}` }}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div v-if="avaliandoLote" class="mt-4 rounded-lg bg-blue-50 px-4 py-3 text-xs text-blue-800 dark:bg-blue-900/30 dark:text-blue-300">
|
|
|
|
|
+ Avaliando um lote de atendimentos com a IA — isso pode levar alguns minutos...
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div v-if="loteResultado" class="mt-4 rounded-lg bg-green-50 px-4 py-3 text-xs text-green-800 dark:bg-green-900/30 dark:text-green-300">
|
|
|
|
|
+ {{ loteResultado.avaliados }} atendimentos avaliados
|
|
|
|
|
+ <span v-if="loteResultado.semDialogo">, {{ loteResultado.semDialogo }} sem diálogo para avaliar</span>
|
|
|
|
|
+ <span v-if="loteResultado.erros?.length">, {{ loteResultado.erros.length }} com erro</span>.
|
|
|
|
|
+ O restante segue sendo avaliado automaticamente pelo job.
|
|
|
|
|
+ </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>
|
|
|
|
|
+ </section>
|
|
|
|
|
+
|
|
|
|
|
+ <section v-if="stats" class="grid grid-cols-2 gap-3 md:grid-cols-4">
|
|
|
|
|
+ <div class="rounded-2xl border border-gray-200 bg-background/70 p-4 shadow-sm dark:border-gray-700 dark:bg-background/20">
|
|
|
|
|
+ <div class="text-xs text-gray-500 dark:text-gray-400">Score médio</div>
|
|
|
|
|
+ <div class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
|
|
|
|
+ {{ stats.scoreMedio ?? "—" }}<span class="text-sm font-normal text-gray-400"> / 10</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="rounded-2xl border border-gray-200 bg-background/70 p-4 shadow-sm dark:border-gray-700 dark:bg-background/20">
|
|
|
|
|
+ <div class="text-xs text-gray-500 dark:text-gray-400">Avaliados</div>
|
|
|
|
|
+ <div class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100">{{ stats.avaliados }}</div>
|
|
|
|
|
+ <div class="text-xs text-gray-500 dark:text-gray-400">{{ stats.pendentes }} pendentes</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="rounded-2xl border border-gray-200 bg-background/70 p-4 shadow-sm dark:border-gray-700 dark:bg-background/20">
|
|
|
|
|
+ <div class="text-xs text-gray-500 dark:text-gray-400">Problemas resolvidos</div>
|
|
|
|
|
+ <div class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
|
|
|
|
+ {{ pct(stats.resolvido?.sim ?? 0, stats.avaliados) }}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="text-xs text-gray-500 dark:text-gray-400">
|
|
|
|
|
+ {{ stats.resolvido?.sim ?? 0 }} sim · {{ stats.resolvido?.parcial ?? 0 }} parcial · {{ stats.resolvido?.nao ?? 0 }} não
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="rounded-2xl border border-gray-200 bg-background/70 p-4 shadow-sm dark:border-gray-700 dark:bg-background/20">
|
|
|
|
|
+ <div class="text-xs text-gray-500 dark:text-gray-400">Clientes satisfeitos</div>
|
|
|
|
|
+ <div class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
|
|
|
|
+ {{ pct(stats.sentimento?.positivo ?? 0, stats.avaliados) }}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="text-xs text-gray-500 dark:text-gray-400">
|
|
|
|
|
+ {{ stats.sentimento?.negativo ?? 0 }} saíram insatisfeitos
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </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">
|
|
|
|
|
+ <select
|
|
|
|
|
+ v-model="filtroResolvido"
|
|
|
|
|
+ class="rounded-lg border border-gray-300 bg-transparent px-3 py-1.5 text-sm text-gray-700 dark:border-gray-600 dark:bg-transparent dark:text-gray-200 [&>option]:dark:bg-gray-800"
|
|
|
|
|
+ @change="aplicarFiltros"
|
|
|
|
|
+ >
|
|
|
|
|
+ <option value="">Resolução: todas</option>
|
|
|
|
|
+ <option value="sim">Resolvido</option>
|
|
|
|
|
+ <option value="parcial">Parcial</option>
|
|
|
|
|
+ <option value="nao">Não resolvido</option>
|
|
|
|
|
+ <option value="indefinido">Indefinido</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ <select
|
|
|
|
|
+ v-model="filtroSentimento"
|
|
|
|
|
+ class="rounded-lg border border-gray-300 bg-transparent px-3 py-1.5 text-sm text-gray-700 dark:border-gray-600 dark:bg-transparent dark:text-gray-200 [&>option]:dark:bg-gray-800"
|
|
|
|
|
+ @change="aplicarFiltros"
|
|
|
|
|
+ >
|
|
|
|
|
+ <option value="">Sentimento: todos</option>
|
|
|
|
|
+ <option value="positivo">Positivo</option>
|
|
|
|
|
+ <option value="neutro">Neutro</option>
|
|
|
|
|
+ <option value="negativo">Negativo</option>
|
|
|
|
|
+ <option value="indefinido">Indefinido</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ <div class="ml-auto text-xs text-gray-500 dark:text-gray-400">
|
|
|
|
|
+ {{ total }} avaliações
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div v-if="carregando" class="p-6 text-sm text-gray-500 dark:text-gray-400">Carregando...</div>
|
|
|
|
|
+ <div v-else-if="avaliacoes.length === 0" class="p-6 text-sm text-gray-500 dark:text-gray-400">
|
|
|
|
|
+ Nenhuma avaliação encontrada. Use "Avaliar pendentes" ou aguarde o job automático.
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <ul v-else class="divide-y divide-gray-200 dark:divide-gray-700">
|
|
|
|
|
+ <li v-for="a in avaliacoes" :key="a.AtendimentoId">
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ class="flex w-full flex-wrap items-center gap-2 px-4 py-3 text-left hover:bg-black/[0.03] dark:hover:bg-white/[0.04]"
|
|
|
|
|
+ @click="alternarExpandido(a.AtendimentoId)"
|
|
|
|
|
+ >
|
|
|
|
|
+ <div class="min-w-0 flex-1">
|
|
|
|
|
+ <div class="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">
|
|
|
|
|
+ {{ a.Codigo ?? `#${a.AtendimentoId}` }}
|
|
|
|
|
+ <span class="font-normal text-gray-500 dark:text-gray-400"> — {{ a.ClienteNome ?? "cliente desconhecido" }}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
|
|
|
|
+ <span v-if="a.Setor">{{ a.Setor }} · </span>{{ formatData(a.Abertura) }}
|
|
|
|
|
+ <span v-if="a.Atendentes?.length"> · {{ a.Atendentes.join(", ") }}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <BaseBadge :variant="scoreVariant(a.ScoreAtendente)">
|
|
|
|
|
+ {{ a.ScoreAtendente !== null && a.ScoreAtendente !== undefined ? `Nota ${a.ScoreAtendente}` : "Sem nota" }}
|
|
|
|
|
+ </BaseBadge>
|
|
|
|
|
+ <BaseBadge :variant="resolvidoVariant[a.Resolvido] ?? 'default'">{{ resolvidoLabel[a.Resolvido] ?? a.Resolvido }}</BaseBadge>
|
|
|
|
|
+ <BaseBadge :variant="sentimentoVariant[a.Sentimento] ?? 'default'">{{ sentimentoLabel[a.Sentimento] ?? a.Sentimento }}</BaseBadge>
|
|
|
|
|
+ <BaseBadge v-if="a.VisitaAgendada" variant="violet">
|
|
|
|
|
+ Visita{{ a.HorarioVisitaInformado === "sim" ? " agendada" : " sem horário" }}
|
|
|
|
|
+ </BaseBadge>
|
|
|
|
|
+ </button>
|
|
|
|
|
+
|
|
|
|
|
+ <div v-if="expandido === a.AtendimentoId" class="space-y-3 px-4 pb-4 text-sm">
|
|
|
|
|
+ <div v-if="a.Resumo">
|
|
|
|
|
+ <div class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Resumo</div>
|
|
|
|
|
+ <p class="mt-1 text-gray-700 dark:text-gray-300">{{ a.Resumo }}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div v-if="a.JustificativaScore">
|
|
|
|
|
+ <div class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Justificativa da nota</div>
|
|
|
|
|
+ <p class="mt-1 text-gray-700 dark:text-gray-300">{{ a.JustificativaScore }}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div v-if="a.Sugestoes?.length">
|
|
|
|
|
+ <div class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Itens a melhorar</div>
|
|
|
|
|
+ <ul class="mt-1 list-disc space-y-0.5 pl-5 text-gray-700 dark:text-gray-300">
|
|
|
|
|
+ <li v-for="(s, i) in a.Sugestoes" :key="i">{{ s }}</li>
|
|
|
|
|
+ </ul>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div v-if="a.VisitaAgendada" class="text-gray-700 dark:text-gray-300">
|
|
|
|
|
+ <span class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Visita técnica: </span>
|
|
|
|
|
+ <template v-if="a.HorarioVisitaInformado === 'sim'">horário informado — {{ a.HorarioVisita }}</template>
|
|
|
|
|
+ <template v-else>agendada, mas o atendente não informou o horário</template>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="text-xs text-gray-400 dark:text-gray-500">
|
|
|
|
|
+ Avaliado por {{ a.Modelo }} em {{ formatData(a.UpdatedAt) }} · {{ a.TotalMensagens }} mensagens
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </li>
|
|
|
|
|
+ </ul>
|
|
|
|
|
+
|
|
|
|
|
+ <div
|
|
|
|
|
+ v-if="totalPaginas > 1"
|
|
|
|
|
+ class="flex items-center justify-between border-t border-gray-200 px-4 py-3 text-sm dark:border-gray-700"
|
|
|
|
|
+ >
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ class="rounded-lg border border-gray-300 px-3 py-1.5 text-gray-700 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:text-gray-200"
|
|
|
|
|
+ :disabled="page <= 1 || carregando"
|
|
|
|
|
+ @click="irParaPagina(page - 1)"
|
|
|
|
|
+ >
|
|
|
|
|
+ Anterior
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <span class="text-gray-500 dark:text-gray-400">Página {{ page }} de {{ totalPaginas }}</span>
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ class="rounded-lg border border-gray-300 px-3 py-1.5 text-gray-700 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:text-gray-200"
|
|
|
|
|
+ :disabled="page >= totalPaginas || carregando"
|
|
|
|
|
+ @click="irParaPagina(page + 1)"
|
|
|
|
|
+ >
|
|
|
|
|
+ Próxima
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </section>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </LayoutSistema>
|
|
|
|
|
+</template>
|