ソースを参照

implementando treino agente

GabrielRamison 2 ヶ月 前
コミット
45220a5b51

+ 2 - 2
dist/index.html

@@ -6,8 +6,8 @@
     <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
     <link rel="icon" type="image/x-icon" href="/favicon.ico?v=2" />
     <title>ORÁCULO</title>
-    <script type="module" crossorigin src="/assets/index--kZF7n2V.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-BjdCURZL.css">
+    <script type="module" crossorigin src="/assets/index-B5Tyh7gQ.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-BovA5VUQ.css">
   </head>
   <body class="bg-background">
     <noscript>

+ 24 - 0
src/api/atendimentos.js

@@ -18,3 +18,27 @@ export function listarAtendimentos({ page = 1, pageSize = 20, setor, status } =
 export function obterAtendimento(id) {
   return apiFetch(`/api/atendimentos/${id}`);
 }
+
+export function avaliarAtendimentosPendentes(params = {}) {
+  return apiFetch("/api/atendimentos/avaliar", { method: "POST", body: params });
+}
+
+export function avaliarAtendimento(id) {
+  return apiFetch(`/api/atendimentos/${id}/avaliar`, { method: "POST", body: {} });
+}
+
+export function listarAvaliacoes({ page = 1, pageSize = 20, setor, resolvido, sentimento, scoreMax } = {}) {
+  const query = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
+  if (setor) query.set("setor", setor);
+  if (resolvido) query.set("resolvido", resolvido);
+  if (sentimento) query.set("sentimento", sentimento);
+  if (scoreMax !== undefined) query.set("scoreMax", String(scoreMax));
+  return apiFetch(`/api/atendimentos/avaliacoes?${query.toString()}`);
+}
+
+export function estatisticasAvaliacoes({ setor } = {}) {
+  const query = new URLSearchParams();
+  if (setor) query.set("setor", setor);
+  const qs = query.toString();
+  return apiFetch(`/api/atendimentos/avaliacoes/estatisticas${qs ? `?${qs}` : ""}`);
+}

+ 1 - 0
src/layout/HeaderSistema.vue

@@ -30,6 +30,7 @@ const tituloPagina = computed(() => {
   if (nome === "conversas-historico") return "Histórico";
   if (nome === "configuracoes") return "Configurações";
   if (nome === "usuarios") return "Usuários";
+  if (nome === "avaliacoes") return "Avaliações de Atendimento";
   return "Oráculo";
 });
 

+ 18 - 0
src/layout/SidebarSistema.vue

@@ -200,6 +200,24 @@ async function novaConversa() {
             <span :class="classeRotulo">Documentos</span>
           </button>
 
+          <button
+            type="button"
+            :class="classeItemSidebar(rotaAtiva === 'avaliacoes')"
+            :title="sidebarRecolhida ? 'Avaliações' : ''"
+            @click="irPara('avaliacoes')"
+          >
+            <svg
+              :class="classeIcon(rotaAtiva === 'avaliacoes')"
+              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>
+            <span :class="classeRotulo">Avaliações</span>
+          </button>
+
           <div class="my-1 h-px bg-black/10 dark:bg-white/10" />
         </template>
         <div

+ 4 - 2
src/router/index.js

@@ -4,6 +4,7 @@ import ConversasHistoricoView from "../views/conversas/ConversasHistoricoView.vu
 import ConversasPesquisarView from "../views/conversas/ConversasPesquisarView.vue";
 import ConfiguracoesView from "../views/configuracoes/ConfiguracoesView.vue";
 import UsuariosView from "../views/usuarios/UsuariosView.vue";
+import AvaliacoesView from "../views/atendimentos/AvaliacoesView.vue";
 import LoginView from "../views/auth/LoginView.vue";
 import { useAuth } from "../composables/useAuth.js";
 
@@ -16,6 +17,7 @@ export const router = createRouter({
     { path: "/conversas/pesquisar", name: "conversas-pesquisar", component: ConversasPesquisarView, meta: { requiresAuth: true } },
     { path: "/configuracoes", name: "configuracoes", component: ConfiguracoesView, meta: { requiresAuth: true } },
     { path: "/usuarios", name: "usuarios", component: UsuariosView, meta: { requiresAuth: true } },
+    { path: "/atendimentos/avaliacoes", name: "avaliacoes", component: AvaliacoesView, meta: { requiresAuth: true } },
     { path: "/:pathMatch(.*)*", redirect: "/" }
   ]
 });
@@ -34,9 +36,9 @@ router.beforeEach((to) => {
     };
   }
 
-  if (to.name === "usuarios") {
+  if (to.name === "usuarios" || to.name === "avaliacoes") {
     const { user } = useAuth();
-    
+
     if (String(user.value?.Nivel) !== "3") {
       return { name: "configuracoes" };
     }

+ 313 - 0
src/views/atendimentos/AvaliacoesView.vue

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