소스 검색

feat: avaliaçoes não resolvidas

leonardo 1 개월 전
부모
커밋
76ac80be9e
5개의 변경된 파일491개의 추가작업 그리고 7개의 파일을 삭제
  1. 6 2
      src/api/atendimentos.js
  2. 18 0
      src/layout/SidebarSistema.vue
  3. 8 0
      src/router/index.js
  4. 7 5
      src/views/atendimentos/AtendimentoView.vue
  5. 452 0
      src/views/atendimentos/NaoResolvidosView.vue

+ 6 - 2
src/api/atendimentos.js

@@ -27,7 +27,7 @@ export function avaliarAtendimento(id) {
   return apiFetch(`/api/atendimentos/${id}/avaliar`, { method: "POST", body: {} });
   return apiFetch(`/api/atendimentos/${id}/avaliar`, { method: "POST", body: {} });
 }
 }
 
 
-export function listarAvaliacoes({ page = 1, pageSize = 20, setor, resolvido, sentimento, statusCancelamento, scoreMax, busca, ordenacao } = {}) {
+export function listarAvaliacoes({ page = 1, pageSize = 20, setor, resolvido, sentimento, statusCancelamento, scoreMax, busca, ordenacao, dataInicio, dataFim } = {}) {
   const query = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
   const query = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
   if (setor) query.set("setor", setor);
   if (setor) query.set("setor", setor);
   if (resolvido) query.set("resolvido", resolvido);
   if (resolvido) query.set("resolvido", resolvido);
@@ -36,16 +36,20 @@ export function listarAvaliacoes({ page = 1, pageSize = 20, setor, resolvido, se
   if (scoreMax !== undefined) query.set("scoreMax", String(scoreMax));
   if (scoreMax !== undefined) query.set("scoreMax", String(scoreMax));
   if (busca) query.set("busca", busca);
   if (busca) query.set("busca", busca);
   if (ordenacao === "asc") query.set("ordenacao", ordenacao);
   if (ordenacao === "asc") query.set("ordenacao", ordenacao);
+  if (dataInicio) query.set("dataInicio", dataInicio);
+  if (dataFim) query.set("dataFim", dataFim);
   return apiFetch(`/api/atendimentos/avaliacoes?${query.toString()}`);
   return apiFetch(`/api/atendimentos/avaliacoes?${query.toString()}`);
 }
 }
 
 
-export function estatisticasAvaliacoes({ setor, resolvido, sentimento, statusCancelamento, busca } = {}) {
+export function estatisticasAvaliacoes({ setor, resolvido, sentimento, statusCancelamento, busca, dataInicio, dataFim } = {}) {
   const query = new URLSearchParams();
   const query = new URLSearchParams();
   if (setor) query.set("setor", setor);
   if (setor) query.set("setor", setor);
   if (resolvido) query.set("resolvido", resolvido);
   if (resolvido) query.set("resolvido", resolvido);
   if (sentimento) query.set("sentimento", sentimento);
   if (sentimento) query.set("sentimento", sentimento);
   if (statusCancelamento) query.set("statusCancelamento", statusCancelamento);
   if (statusCancelamento) query.set("statusCancelamento", statusCancelamento);
   if (busca) query.set("busca", busca);
   if (busca) query.set("busca", busca);
+  if (dataInicio) query.set("dataInicio", dataInicio);
+  if (dataFim) query.set("dataFim", dataFim);
   const qs = query.toString();
   const qs = query.toString();
   return apiFetch(`/api/atendimentos/avaliacoes/estatisticas${qs ? `?${qs}` : ""}`);
   return apiFetch(`/api/atendimentos/avaliacoes/estatisticas${qs ? `?${qs}` : ""}`);
 }
 }

+ 18 - 0
src/layout/SidebarSistema.vue

@@ -234,6 +234,24 @@ async function novaConversa() {
             <span :class="classeRotulo">Avaliações</span>
             <span :class="classeRotulo">Avaliações</span>
           </button>
           </button>
 
 
+          <button
+            type="button"
+            :class="classeItemSidebar(rotaAtiva === 'atendimentos-nao-resolvidos')"
+            :title="sidebarRecolhida ? 'Não resolvidos' : ''"
+            @click="irPara('atendimentos-nao-resolvidos')"
+          >
+            <svg
+              :class="classeIcon(rotaAtiva === 'atendimentos-nao-resolvidos')"
+              viewBox="0 0 24 24"
+              fill="none"
+              stroke="currentColor"
+              stroke-width="1.8"
+            >
+              <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v4m0 4h.01M10.29 3.86 1.82 18a1.5 1.5 0 0 0 1.3 2.25h17.76a1.5 1.5 0 0 0 1.3-2.25L13.71 3.86a1.5 1.5 0 0 0-2.42 0Z" />
+            </svg>
+            <span :class="classeRotulo">Não resolvidos</span>
+          </button>
+
           <div class="my-1 h-px bg-black/10 dark:bg-white/10" />
           <div class="my-1 h-px bg-black/10 dark:bg-white/10" />
         </template>
         </template>
         <div
         <div

+ 8 - 0
src/router/index.js

@@ -5,6 +5,7 @@ import ConversasPesquisarView from "../views/conversas/ConversasPesquisarView.vu
 import ConfiguracoesView from "../views/configuracoes/ConfiguracoesView.vue";
 import ConfiguracoesView from "../views/configuracoes/ConfiguracoesView.vue";
 import UsuariosView from "../views/usuarios/UsuariosView.vue";
 import UsuariosView from "../views/usuarios/UsuariosView.vue";
 import AvaliacoesView from "../views/atendimentos/AvaliacoesView.vue";
 import AvaliacoesView from "../views/atendimentos/AvaliacoesView.vue";
+import NaoResolvidosView from "../views/atendimentos/NaoResolvidosView.vue";
 import AtendimentoView from "../views/atendimentos/AtendimentoView.vue";
 import AtendimentoView from "../views/atendimentos/AtendimentoView.vue";
 import ConversasWhatsappView from "../views/conversas/ConversasWhatsappView.vue";
 import ConversasWhatsappView from "../views/conversas/ConversasWhatsappView.vue";
 import DocumentosView from "../views/documentos/DocumentosView.vue";
 import DocumentosView from "../views/documentos/DocumentosView.vue";
@@ -22,6 +23,12 @@ export const router = createRouter({
     { path: "/configuracoes", name: "configuracoes", component: ConfiguracoesView, meta: { requiresAuth: true } },
     { path: "/configuracoes", name: "configuracoes", component: ConfiguracoesView, meta: { requiresAuth: true } },
     { path: "/usuarios", name: "usuarios", component: UsuariosView, meta: { requiresAuth: true } },
     { path: "/usuarios", name: "usuarios", component: UsuariosView, meta: { requiresAuth: true } },
     { path: "/atendimentos/avaliacoes", name: "avaliacoes", component: AvaliacoesView, meta: { requiresAuth: true } },
     { path: "/atendimentos/avaliacoes", name: "avaliacoes", component: AvaliacoesView, meta: { requiresAuth: true } },
+    {
+      path: "/atendimentos/nao-resolvidos",
+      name: "atendimentos-nao-resolvidos",
+      component: NaoResolvidosView,
+      meta: { requiresAuth: true }
+    },
     { path: "/atendimentos/:id(\\d+)", name: "atendimento", component: AtendimentoView, meta: { requiresAuth: true } },
     { path: "/atendimentos/:id(\\d+)", name: "atendimento", component: AtendimentoView, meta: { requiresAuth: true } },
     { path: "/conversas-whatsapp", name: "conversas-whatsapp", component: ConversasWhatsappView, meta: { requiresAuth: true } },
     { path: "/conversas-whatsapp", name: "conversas-whatsapp", component: ConversasWhatsappView, meta: { requiresAuth: true } },
     { path: "/documentos", name: "documentos", component: DocumentosView, meta: { requiresAuth: true } },
     { path: "/documentos", name: "documentos", component: DocumentosView, meta: { requiresAuth: true } },
@@ -47,6 +54,7 @@ router.beforeEach((to) => {
   if (
   if (
     to.name === "usuarios" ||
     to.name === "usuarios" ||
     to.name === "avaliacoes" ||
     to.name === "avaliacoes" ||
+    to.name === "atendimentos-nao-resolvidos" ||
     to.name === "atendimento" ||
     to.name === "atendimento" ||
     to.name === "documentos" ||
     to.name === "documentos" ||
     to.name === "documentos-carregar"
     to.name === "documentos-carregar"

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

@@ -29,12 +29,14 @@ const mediaViewer = ref(null);
 const avaliacao = computed(() => atendimento.value?.avaliacao ?? null);
 const avaliacao = computed(() => atendimento.value?.avaliacao ?? null);
 const mensagens = computed(() => atendimento.value?.mensagens ?? []);
 const mensagens = computed(() => atendimento.value?.mensagens ?? []);
 
 
-// volta para a lista de avaliações preservando página, filtros e avaliação aberta
-// (guardados na query string da rota anterior); se o gestor chegou por link direto,
-// não há histórico para voltar — vai para a lista limpa
+// volta para a lista de origem (Avaliações ou Não resolvidos) preservando página, filtros
+// e item aberto (guardados na query string da rota anterior); se o gestor chegou por link
+// direto, não há histórico para voltar — vai para a lista de avaliações limpa
+const ROTAS_ORIGEM = ["/atendimentos/avaliacoes", "/atendimentos/nao-resolvidos"];
+
 function voltar() {
 function voltar() {
   const anterior = window.history.state?.back;
   const anterior = window.history.state?.back;
-  if (typeof anterior === "string" && anterior.startsWith("/atendimentos/avaliacoes")) {
+  if (typeof anterior === "string" && ROTAS_ORIGEM.some((rota) => anterior.startsWith(rota))) {
     router.back();
     router.back();
   } else {
   } else {
     router.push({ name: "avaliacoes" });
     router.push({ name: "avaliacoes" });
@@ -128,7 +130,7 @@ watch(() => route.params.id, carregar, { immediate: true });
               <button
               <button
                 type="button"
                 type="button"
                 class="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-gray-300 text-gray-600 hover:bg-black/[0.03] dark:border-gray-600 dark:text-gray-300 dark:hover:bg-white/[0.04]"
                 class="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-gray-300 text-gray-600 hover:bg-black/[0.03] dark:border-gray-600 dark:text-gray-300 dark:hover:bg-white/[0.04]"
-                title="Voltar para as avaliações"
+                title="Voltar"
                 @click="voltar"
                 @click="voltar"
               >
               >
                 <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                 <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">

+ 452 - 0
src/views/atendimentos/NaoResolvidosView.vue

@@ -0,0 +1,452 @@
+<script setup>
+import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
+import { useRoute, useRouter } from "vue-router";
+import LayoutSistema from "../../layout/LayoutSistema.vue";
+import BaseBadge from "../../components/base/BaseBadge.vue";
+import BaseButton from "../../components/base/BaseButton.vue";
+import BaseDropdown from "../../components/base/BaseDropdown.vue";
+import { estatisticasAvaliacoes, listarAvaliacoes, listarSetoresAvaliacoes } from "../../api/atendimentos.js";
+import {
+  cancelamentoLabel,
+  cancelamentoVariant,
+  CONTEXTO_IXC_ERRO_LABEL,
+  contextoIxcFalhou,
+  resolvidoLabel,
+  resolvidoVariant,
+  scoreVariant,
+  sentimentoLabel,
+  sentimentoVariant
+} from "../../utils/avaliacoes.js";
+
+const opcoesSentimento = Object.entries(sentimentoLabel).map(([value, label]) => ({ value, label }));
+
+const route = useRoute();
+const router = useRouter();
+
+function formatISO(date) {
+  return date.toISOString().slice(0, 10);
+}
+
+function padraoDataInicio() {
+  const d = new Date();
+  d.setDate(d.getDate() - 30);
+  return formatISO(d);
+}
+
+const carregando = ref(false);
+const erro = ref("");
+
+const stats = ref(null);
+const statsGeral = ref(null);
+const avaliacoes = ref([]);
+const total = ref(0);
+const setores = ref([]);
+const pageSize = 20;
+
+const page = ref(Math.max(1, Number(route.query.page) || 1));
+const expandido = ref(route.query.aberto ? Number(route.query.aberto) : null);
+const filtroSetor = ref(typeof route.query.setor === "string" ? route.query.setor : "");
+const filtroSentimento = ref(typeof route.query.sentimento === "string" ? route.query.sentimento : "");
+const filtroBusca = ref(typeof route.query.q === "string" ? route.query.q : "");
+const filtroOrdenacao = ref(typeof route.query.ordenacao === "string" ? route.query.ordenacao : "desc");
+const filtroDataInicio = ref(typeof route.query.dataInicio === "string" ? route.query.dataInicio : padraoDataInicio());
+const filtroDataFim = ref(typeof route.query.dataFim === "string" ? route.query.dataFim : formatISO(new Date()));
+
+function sincronizarQuery() {
+  const query = {};
+  if (page.value > 1) query.page = String(page.value);
+  if (filtroSetor.value) query.setor = filtroSetor.value;
+  if (filtroSentimento.value) query.sentimento = filtroSentimento.value;
+  if (filtroBusca.value.trim()) query.q = filtroBusca.value.trim();
+  if (filtroOrdenacao.value === "asc") query.ordenacao = "asc";
+  if (filtroDataInicio.value) query.dataInicio = filtroDataInicio.value;
+  if (filtroDataFim.value) query.dataFim = filtroDataFim.value;
+  if (expandido.value) query.aberto = String(expandido.value);
+  router.replace({ query });
+}
+
+const totalPaginas = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
+
+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)}%`;
+}
+
+const setoresOrdenados = computed(() => {
+  const mapa = stats.value?.porSetor ?? {};
+  return Object.entries(mapa)
+    .sort((a, b) => b[1] - a[1])
+    .slice(0, 6);
+});
+
+let statsRequestId = 0;
+
+async function carregarStats() {
+  const requestId = ++statsRequestId;
+  try {
+    const filtrosComuns = {
+      setor: filtroSetor.value || undefined,
+      sentimento: filtroSentimento.value || undefined,
+      busca: filtroBusca.value.trim() || undefined,
+      dataInicio: filtroDataInicio.value || undefined,
+      dataFim: filtroDataFim.value || undefined
+    };
+    // duas chamadas: "geral" (sem resolvido) só pra saber o total avaliado no período, usado
+    // no % de não resolvidos; "nao" filtrada pra score médio e distribuição por setor
+    // específicos dos não resolvidos (senão o porSetor mistura todos os status)
+    const [geral, naoResolvidos] = await Promise.all([
+      estatisticasAvaliacoes(filtrosComuns),
+      estatisticasAvaliacoes({ ...filtrosComuns, resolvido: "nao" })
+    ]);
+    if (requestId !== statsRequestId) return;
+    statsGeral.value = geral;
+    stats.value = naoResolvidos;
+  } catch {
+    if (requestId !== statsRequestId) return;
+    statsGeral.value = null;
+    stats.value = null;
+  }
+}
+
+async function carregarSetores() {
+  try {
+    const r = await listarSetoresAvaliacoes();
+    setores.value = r.results ?? [];
+  } catch {
+    setores.value = [];
+  }
+}
+
+let listaRequestId = 0;
+
+async function carregarLista() {
+  const requestId = ++listaRequestId;
+  carregando.value = true;
+  erro.value = "";
+  try {
+    const r = await listarAvaliacoes({
+      page: page.value,
+      pageSize,
+      resolvido: "nao",
+      setor: filtroSetor.value || undefined,
+      sentimento: filtroSentimento.value || undefined,
+      busca: filtroBusca.value.trim() || undefined,
+      ordenacao: filtroOrdenacao.value,
+      dataInicio: filtroDataInicio.value || undefined,
+      dataFim: filtroDataFim.value || undefined
+    });
+    if (requestId !== listaRequestId) return;
+    avaliacoes.value = r.results ?? [];
+    total.value = r.total ?? 0;
+  } catch (err) {
+    if (requestId !== listaRequestId) return;
+    erro.value = err?.message ?? "Falha ao carregar atendimentos não resolvidos";
+  } finally {
+    if (requestId === listaRequestId) carregando.value = false;
+  }
+}
+
+function aplicarFiltros() {
+  page.value = 1;
+  expandido.value = null;
+  sincronizarQuery();
+  carregarStats();
+  carregarLista();
+}
+
+function alternarOrdenacao() {
+  filtroOrdenacao.value = filtroOrdenacao.value === "asc" ? "desc" : "asc";
+  aplicarFiltros();
+}
+
+let buscaTimer = null;
+
+function onBuscaInput() {
+  clearTimeout(buscaTimer);
+  buscaTimer = setTimeout(aplicarFiltros, 400);
+}
+
+function onBuscaEnter() {
+  clearTimeout(buscaTimer);
+  aplicarFiltros();
+}
+
+function irParaPagina(nova) {
+  if (nova < 1 || nova > totalPaginas.value || nova === page.value) return;
+  page.value = nova;
+  expandido.value = null;
+  sincronizarQuery();
+  carregarLista();
+}
+
+function alternarExpandido(id) {
+  expandido.value = expandido.value === id ? null : id;
+  sincronizarQuery();
+}
+
+function verConversa(id) {
+  router.push({ name: "atendimento", params: { id } });
+}
+
+onMounted(async () => {
+  carregarStats();
+  carregarSetores();
+  await carregarLista();
+
+  if (expandido.value) {
+    await nextTick();
+    document.getElementById(`nao-resolvido-${expandido.value}`)?.scrollIntoView({ block: "center" });
+  }
+});
+
+onBeforeUnmount(() => {
+  clearTimeout(buscaTimer);
+});
+</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-red-500/10 text-red-600 dark:bg-red-500/20 dark:text-red-400"
+              >
+                <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="M12 9v4m0 4h.01M10.29 3.86 1.82 18a1.5 1.5 0 0 0 1.3 2.25h17.76a1.5 1.5 0 0 0 1.3-2.25L13.71 3.86a1.5 1.5 0 0 0-2.42 0Z" />
+                </svg>
+              </div>
+              <div class="min-w-0">
+                <h2 class="text-base font-semibold text-gray-900 dark:text-gray-100">
+                  Atendimentos Não Resolvidos (IA)
+                </h2>
+                <p class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">
+                  Atendimentos marcados como não resolvidos pelo avaliador automático, por período e setor.
+                </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="aplicarFiltros"
+              />
+              <span class="text-sm text-gray-400 dark:text-gray-500">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="aplicarFiltros"
+              />
+            </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>
+        </section>
+
+        <section v-if="stats" class="grid grid-cols-2 gap-3 md:grid-cols-3">
+          <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">Não resolvidos no período</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">
+              {{ pct(stats.avaliados, statsGeral?.avaliados) }} dos {{ statsGeral?.avaliados ?? 0 }} avaliados
+            </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">Score médio dos não resolvidos</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">Por setor</div>
+            <div class="mt-1.5 space-y-1">
+              <div v-if="setoresOrdenados.length === 0" class="text-sm text-gray-400 dark:text-gray-500">—</div>
+              <div v-for="[sigla, qtd] in setoresOrdenados" :key="sigla" class="flex items-center justify-between text-xs">
+                <span class="text-gray-600 dark:text-gray-300">{{ sigla }}</span>
+                <span class="font-medium text-gray-900 dark:text-gray-100">{{ qtd }}</span>
+              </div>
+            </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">
+            <div class="relative min-w-[240px] flex-1 sm:max-w-[340px]">
+              <svg
+                class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400 dark:text-gray-500"
+                viewBox="0 0 24 24"
+                fill="none"
+                stroke="currentColor"
+                stroke-width="2"
+              >
+                <path stroke-linecap="round" stroke-linejoin="round" d="m21 21-4.35-4.35M17 10.5a6.5 6.5 0 1 1-13 0 6.5 6.5 0 0 1 13 0Z" />
+              </svg>
+              <input
+                v-model="filtroBusca"
+                type="search"
+                placeholder="Protocolo, cliente, atendente ou data (dd/mm/aaaa)"
+                class="w-full rounded-lg border border-gray-200 bg-background py-1.5 pl-9 pr-8 text-sm text-gray-700 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-primary/30 dark:border-gray-700 dark:text-gray-200 dark:placeholder:text-gray-500"
+                @input="onBuscaInput"
+                @keyup.enter="onBuscaEnter"
+              />
+              <button
+                v-if="filtroBusca"
+                type="button"
+                class="absolute right-2 top-1/2 -translate-y-1/2 rounded bg-transparent p-0.5 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
+                title="Limpar busca"
+                @click="filtroBusca = ''; onBuscaEnter();"
+              >
+                <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                  <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
+                </svg>
+              </button>
+            </div>
+            <div class="flex shrink-0 items-center gap-2">
+              <BaseDropdown
+                v-model="filtroSetor"
+                rotulo="Setor"
+                rotulo-padrao="todos"
+                :opcoes="setores.map((s) => ({ value: s.sigla, label: `${s.nome} (${s.total})` }))"
+                @change="aplicarFiltros"
+              />
+              <BaseDropdown
+                v-model="filtroSentimento"
+                rotulo="Sentimento"
+                rotulo-padrao="todos"
+                :opcoes="opcoesSentimento"
+                @change="aplicarFiltros"
+              />
+            </div>
+            <BaseButton
+              variant="secondary"
+              size="sm"
+              title="Alternar ordem por data da avaliação"
+              @click="alternarOrdenacao"
+            >
+              Data
+              <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                <path v-if="filtroOrdenacao === 'asc'" stroke-linecap="round" stroke-linejoin="round" d="m4.5 15.75 7.5-7.5 7.5 7.5" />
+                <path v-else stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
+              </svg>
+            </BaseButton>
+            <div class="ml-auto text-xs text-gray-500 dark:text-gray-400">
+              {{ total }} não resolvidos
+            </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">
+            Nenhum atendimento não resolvido encontrado para os filtros selecionados.
+          </div>
+
+          <ul v-else class="divide-y divide-gray-200 dark:divide-gray-700">
+            <li v-for="a in avaliacoes" :id="`nao-resolvido-${a.AtendimentoId}`" :key="a.AtendimentoId">
+              <button
+                type="button"
+                class="flex w-full flex-wrap items-center gap-2 bg-transparent 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>
+                <BaseBadge v-if="cancelamentoLabel[a.StatusCancelamento]" :variant="cancelamentoVariant[a.StatusCancelamento]">
+                  {{ cancelamentoLabel[a.StatusCancelamento] }}
+                </BaseBadge>
+                <BaseBadge v-if="contextoIxcFalhou(a.ContextoIxcStatus)" variant="warning" :title="CONTEXTO_IXC_ERRO_LABEL">
+                  Contexto ERP indisponível
+                </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.MotivoCancelamento">
+                  <div class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Motivo do cancelamento</div>
+                  <p class="mt-1 text-gray-700 dark:text-gray-300">{{ a.MotivoCancelamento }}</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 class="flex flex-wrap items-center justify-between gap-3">
+                  <BaseButton variant="secondary" size="sm" @click="verConversa(a.AtendimentoId)">
+                    Ver conversa completa
+                    <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                      <path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
+                    </svg>
+                  </BaseButton>
+                  <div class="text-xs text-gray-400 dark:text-gray-500">
+                    Avaliado por {{ a.Modelo }} em {{ formatData(a.UpdatedAt) }} · {{ a.TotalMensagens }} mensagens
+                  </div>
+                </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"
+          >
+            <BaseButton
+              variant="secondary"
+              size="sm"
+              :disabled="page <= 1 || carregando"
+              @click="irParaPagina(page - 1)"
+            >
+              Anterior
+            </BaseButton>
+            <span class="text-gray-500 dark:text-gray-400">Página {{ page }} de {{ totalPaginas }}</span>
+            <BaseButton
+              variant="secondary"
+              size="sm"
+              :disabled="page >= totalPaginas || carregando"
+              @click="irParaPagina(page + 1)"
+            >
+              Próxima
+            </BaseButton>
+          </div>
+        </section>
+      </div>
+    </div>
+  </LayoutSistema>
+</template>