leonardo 4 周之前
父節點
當前提交
6a219d21c2
共有 56 個文件被更改,包括 1264 次插入1136 次删除
  1. 1 1
      docs/architecture.md
  2. 83 36
      docs/components.md
  3. 59 2
      docs/composables.md
  4. 0 2
      docs/style.md
  5. 48 0
      eslint.config.js
  6. 1 1
      index.html
  7. 5 2
      package.json
  8. 5 36
      src/api/atendimentos.js
  9. 25 65
      src/api/auth.js
  10. 2 28
      src/api/chat.js
  11. 0 0
      src/api/chatConversas.js
  12. 43 0
      src/api/client.js
  13. 5 21
      src/api/conversas.js
  14. 2 2
      src/components/BotaoAlterarTema.vue
  15. 1 0
      src/components/ChatMessageContent.vue
  16. 55 18
      src/components/ChatWindow.vue
  17. 6 11
      src/components/DocumentList.vue
  18. 4 0
      src/components/ModalMidia.vue
  19. 1 1
      src/components/SearchBox.vue
  20. 109 0
      src/components/atendimentos/ItemAvaliacao.vue
  21. 42 0
      src/components/base/CampoBusca.vue
  22. 31 0
      src/components/base/PaginacaoLista.vue
  23. 3 1
      src/composables/useAsyncState.js
  24. 6 0
      src/composables/useAuth.js
  25. 52 15
      src/composables/useChat.js
  26. 1 2
      src/composables/useConversas.js
  27. 7 9
      src/composables/useDocumentos.js
  28. 172 0
      src/composables/useListaAvaliacoes.js
  29. 1 2
      src/composables/useMenuUsuario.js
  30. 0 45
      src/composables/usePainelDocumentos.js
  31. 1 1
      src/composables/useSearch.js
  32. 1 1
      src/composables/useTheme.js
  33. 0 1
      src/composables/useToast.js
  34. 1 1
      src/composables/useUsers.js
  35. 25 0
      src/composables/useValorDebounced.js
  36. 0 6
      src/composables/useWhatsappConexao.js
  37. 1 3
      src/layout/HeaderSistema.vue
  38. 6 21
      src/layout/ListaConversas.vue
  39. 3 4
      src/layout/SidebarSistema.vue
  40. 17 8
      src/main.js
  41. 31 23
      src/router/index.js
  42. 0 61
      src/styles/tailwind.css
  43. 7 3
      src/utils/avaliacoes.js
  44. 96 0
      src/utils/data.js
  45. 17 0
      src/utils/download.js
  46. 1 1
      src/utils/midia.js
  47. 7 26
      src/views/atendimentos/AtendimentoView.vue
  48. 95 279
      src/views/atendimentos/AvaliacoesView.vue
  49. 2 8
      src/views/atendimentos/GoldenSetView.vue
  50. 90 274
      src/views/atendimentos/NaoResolvidosView.vue
  51. 1 2
      src/views/configuracoes/ConfiguracoesView.vue
  52. 2 13
      src/views/conversas/ConversasHistoricoView.vue
  53. 60 87
      src/views/conversas/ConversasWhatsappView.vue
  54. 6 2
      src/views/pagina-inicial/PaginaInicialView.vue
  55. 11 11
      src/views/usuarios/UsuariosView.vue
  56. 13 0
      vite.config.js

+ 1 - 1
docs/architecture.md

@@ -6,7 +6,7 @@
 frontend/src/
 ├── api/              Funções de chamada HTTP (uma por domínio)
 ├── components/       Componentes Vue reutilizáveis
-│   ├── base/         Design system: BaseButton, BaseInput, BaseBadge
+│   ├── base/         Design system: BaseButton, BaseBadge, BaseModal
 │   └── skeletons/    Skeletons de carregamento
 ├── composables/      Estado e lógica reutilizável (useAuth, useChat, etc.)
 ├── docs/             Documentação do frontend

+ 83 - 36
docs/components.md

@@ -45,42 +45,6 @@ Botão com suporte a variantes, tamanhos e estado de carregamento.
 
 ---
 
-## BaseInput
-
-**Arquivo:** `src/components/base/BaseInput.vue`
-
-Campo de texto com suporte a label, hint e mensagem de erro. Compatível com `v-model`.
-
-### Props
-
-| Prop | Tipo | Padrão | Descrição |
-|---|---|---|---|
-| `modelValue` | `String` | `""` | Valor do campo (use com `v-model`) |
-| `label` | `String` | `""` | Rótulo exibido acima do input |
-| `hint` | `String` | `""` | Texto de ajuda abaixo do campo |
-| `error` | `String` | `""` | Mensagem de erro (substitui hint quando preenchida) |
-| `id` | `String` | `""` | ID do input; gerado automaticamente se omitido |
-
-Aceita todos os atributos HTML nativos de `<input>` via `v-bind="$attrs"` (ex: `placeholder`, `type`, `autocomplete`).
-
-### Exemplos
-
-```vue
-<BaseInput v-model="nome" label="Nome completo" placeholder="Ex: João Silva" />
-
-<BaseInput
-  v-model="email"
-  label="E-mail"
-  type="email"
-  hint="Usado para login no sistema"
-  :error="erros.email"
-/>
-
-<BaseInput v-model="senha" label="Senha" type="password" />
-```
-
----
-
 ## BaseBadge
 
 **Arquivo:** `src/components/base/BaseBadge.vue`
@@ -294,6 +258,89 @@ Indicador de carregamento padronizado, para substituir o texto solto `"Carregand
 
 ---
 
+## CampoBusca
+
+**Arquivo:** `src/components/base/CampoBusca.vue`
+
+Campo de busca das listagens: `input[type=search]` com ícone à esquerda e botão de limpar
+à direita quando há texto. Não confundir com `SearchBox.vue`, que é o painel de busca
+semântica da base de conhecimento.
+
+### Props
+
+| Prop | Tipo | Padrão | Descrição |
+|---|---|---|---|
+| `modelValue` | `String` | `""` | Texto da busca (`v-model`) |
+| `placeholder` | `String` | `"Buscar..."` | |
+
+### Eventos
+
+`update:modelValue` · `input` (a cada digitação, para debounce) · `enter` · `limpar`
+
+### Exemplo
+
+```vue
+<CampoBusca
+  v-model="filtroBusca"
+  placeholder="Protocolo, cliente, atendente ou data (dd/mm/aaaa)"
+  @input="onBuscaInput"
+  @enter="onBuscaEnter"
+  @limpar="onBuscaEnter"
+/>
+```
+
+---
+
+## PaginacaoLista
+
+**Arquivo:** `src/components/base/PaginacaoLista.vue`
+
+Rodapé de paginação "Anterior / Página X de Y / Próxima". Some sozinho quando
+`totalPaginas <= 1` — não precisa de `v-if` do lado de fora.
+
+### Props
+
+| Prop | Tipo | Padrão | Descrição |
+|---|---|---|---|
+| `page` | `Number` | — | Página atual (1-based) |
+| `totalPaginas` | `Number` | — | |
+| `desabilitado` | `Boolean` | `false` | Trava os botões enquanto carrega |
+
+### Eventos
+
+`ir` — emitido com a página destino.
+
+### Exemplo
+
+```vue
+<PaginacaoLista :page="page" :total-paginas="totalPaginas" :desabilitado="carregando" @ir="irParaPagina" />
+```
+
+---
+
+## ItemAvaliacao
+
+**Arquivo:** `src/components/atendimentos/ItemAvaliacao.vue`
+
+Card de uma avaliação de atendimento nas listagens (`AvaliacoesView` e
+`NaoResolvidosView`): cabeçalho com protocolo/cliente/setor/atendentes, a faixa de badges
+(nota, resolução, sentimento, visita, cancelamento, contexto ERP) e o corpo expandido com
+resumo, justificativa, motivo do cancelamento, itens a melhorar e visita técnica.
+
+### Props
+
+| Prop | Tipo | Padrão | Descrição |
+|---|---|---|---|
+| `avaliacao` | `Object` | — | Item retornado por `listarAvaliacoes()` |
+| `prefixoId` | `String` | — | Prefixo do `id` no DOM, para o scroll de retorno (`"avaliacao"`, `"nao-resolvido"`) |
+| `expandida` | `Boolean` | `false` | |
+
+### Eventos
+
+`toggle` e `verConversa`, ambos com o `AtendimentoId`.
+
+---
+
 ## Skeletons
 
 **Pasta:** `src/components/skeletons/`

+ 59 - 2
docs/composables.md

@@ -17,6 +17,8 @@ Gerencia a sessão do usuário: login, logout, persistência de token e refresh
 |---|---|---|
 | `session` | `Readonly<Ref>` | Sessão completa (usuario, accessToken, refreshToken, rememberMe) |
 | `user` | `ComputedRef` | Objeto do usuário logado (`null` se não autenticado) |
+| `nivel` | `ComputedRef<Number>` | Nível do usuário como número (`NaN` se ausente/inválido, o que nega acesso em qualquer comparação) |
+| `isAdmin` | `ComputedRef<Boolean>` | `true` se `nivel >= NIVEL_ADMIN` (3). Use este, não reimplemente a checagem na view |
 | `isAuthenticated` | `ComputedRef<Boolean>` | `true` se há usuário com `Id` na sessão |
 | `login(params)` | `Function` | Faz login; params: `{ login, senha, rememberMe? }` |
 | `logout()` | `Function` | Revoga o token e limpa a sessão |
@@ -33,18 +35,73 @@ Gerencia a sessão do usuário: login, logout, persistência de token e refresh
 ```js
 import { useAuth } from "@/composables/useAuth.js";
 
-const { user, isAuthenticated, login, logout } = useAuth();
+const { user, isAdmin, isAuthenticated, login, logout } = useAuth();
 
 // Login
 await login({ login: "usuario@empresa.com", senha: "123456", rememberMe: true });
 
 // Verificar nível
-if (String(user.value?.Nivel) === "3") { /* admin */ }
+if (isAdmin.value) { /* admin */ }
 
 // Logout
 await logout();
 ```
 
+O router usa o mesmo `nivel` para autorizar: uma tela restrita se declara com
+`meta: { nivelMinimo: NIVEL_ADMIN }` e o guard global cuida do resto — não há lista de
+rotas protegidas espalhada pelo `beforeEach`.
+
+---
+
+## useListaAvaliacoes
+
+**Arquivo:** `src/composables/useListaAvaliacoes.js`
+**Padrão:** Instância por componente
+
+Estado compartilhado pelas listas de avaliações de atendimento (`AvaliacoesView` e
+`NaoResolvidosView`): paginação, busca com debounce de 400 ms, ordenação, sincronização
+com a query string e o item expandido — incluindo a rolagem de volta até ele quando se
+volta da tela de conversa completa. Usa `useAsyncState` por baixo, então respostas
+atrasadas de filtros antigos são descartadas.
+
+O que difere entre as duas telas (filtros próprios, formato dos stats, ações em lote)
+fica na view; só entra aqui o que é idêntico nas duas.
+
+### Opções
+
+| Nome | Tipo | Descrição |
+|---|---|---|
+| `montarQueryExtra` | `Function` | Chaves de query específicas da view (só as preenchidas) |
+| `montarFiltrosApi` | `Function` | Filtros específicos repassados a `listarAvaliacoes()` |
+| `aoAplicarFiltros` | `Function?` | Chamado quando os filtros mudam — normalmente o `carregarStats` da view |
+| `prefixoElemento` | `String` | Prefixo do `id` no DOM, usado no scroll de retorno |
+| `mensagemErroLista` | `String` | Mensagem de fallback quando a listagem falha |
+| `pageSize` | `Number` | Padrão `20` |
+
+### O que expõe
+
+`busca`, `ordenacao`, `page`, `expandido`, `setores`, `avaliacoes`, `total`,
+`totalPaginas`, `carregando`, `erro`, `carregarLista`, `carregarSetores`,
+`aplicarFiltros`, `alternarOrdenacao`, `onBuscaInput`, `onBuscaEnter`, `irParaPagina`,
+`alternarExpandido`, `verConversa`, `sincronizarQuery`, `inicializar`.
+
+Exporta também `queryTexto(route, chave, padrao)`, para ler um filtro da query string
+ignorando arrays (`?x=1&x=2`).
+
+### Exemplo
+
+```js
+const { avaliacoes, carregando, aplicarFiltros, inicializar } = useListaAvaliacoes({
+  montarQueryExtra: () => (filtroSetor.value ? { setor: filtroSetor.value } : {}),
+  montarFiltrosApi: () => ({ setor: filtroSetor.value || undefined }),
+  aoAplicarFiltros: carregarStats,
+  prefixoElemento: "avaliacao",
+  mensagemErroLista: "Falha ao carregar avaliações"
+});
+
+onMounted(inicializar);
+```
+
 ---
 
 ## useChat

+ 0 - 2
docs/style.md

@@ -47,14 +47,12 @@ Casos que ainda não têm token semântico direto (ex.: bordas tracejadas de emp
 ## Erros
 
 - `text-red-600 dark:text-red-400` — texto de erro (não há token semântico `destructive-foreground` de texto solto no tema atual, usar a paleta `red-*` diretamente).
-- Use a prop `error` do `BaseInput` para exibir mensagens de erro nos formulários.
 
 ## Design system
 
 A UI é construída sobre o design system próprio (`src/components/base/`) + Tailwind CSS v4. **Não há PrimeVue no projeto** (a dependência `tailwindcss-primeui` foi removida — era resíduo não utilizado).
 
 - `BaseButton`: variantes `primary`/`secondary`/`ghost`/`danger`, já tratam light/dark.
-- `BaseInput`: campo com `label`, `hint` e `error` integrados.
 - `BaseBadge`: variantes `default`/`success`/`danger`/`info`/`warning`/`violet`.
 - `BaseDropdown`: select customizado acessível.
 - `StatCard`: cartão de métrica/KPI.

+ 48 - 0
eslint.config.js

@@ -0,0 +1,48 @@
+import js from "@eslint/js";
+import pluginVue from "eslint-plugin-vue";
+import globals from "globals";
+
+export default [
+  { ignores: ["dist/**", "node_modules/**"] },
+
+  js.configs.recommended,
+  ...pluginVue.configs["flat/recommended"],
+
+  {
+    files: ["**/*.{js,vue}"],
+    languageOptions: {
+      ecmaVersion: "latest",
+      sourceType: "module",
+      globals: { ...globals.browser }
+    },
+    rules: {
+      // o que esta config existe para pegar: o `npm run build` passa com identificador
+      // inexistente no template, e não havia nada apontando símbolo morto
+      "vue/no-undef-properties": "error",
+      "vue/no-unused-properties": ["error", { groups: ["props", "setup"] }],
+      "no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
+
+      // catch vazio é o idioma correto ao redor de localStorage/JSON.parse, que podem
+      // falhar em navegação privada e não têm tratamento útil
+      "no-empty": ["error", { allowEmptyCatch: true }],
+
+      // o design system tem componentes de uma palavra por convenção (Spinner)
+      "vue/multi-word-component-names": "off",
+
+      // formatação fica com o editor; aqui só o que indica defeito
+      "vue/max-attributes-per-line": "off",
+      "vue/singleline-html-element-content-newline": "off",
+      "vue/html-self-closing": "off",
+      "vue/html-indent": "off",
+      "vue/html-closing-bracket-newline": "off",
+      "vue/attributes-order": "off",
+      "vue/first-attribute-linebreak": "off",
+      "vue/multiline-html-element-content-newline": "off"
+    }
+  },
+
+  {
+    files: ["vite.config.js", "eslint.config.js", "scripts/**"],
+    languageOptions: { globals: { ...globals.node } }
+  }
+];

+ 1 - 1
index.html

@@ -14,7 +14,7 @@
         continue.</strong>
 
     </noscript>
-    <div id="app" class="g-sidenav-show">Carregando…</div>
+    <div id="app">Carregando…</div>
     <script type="module" src="/src/main.js"></script>
   </body>
 </html>

+ 5 - 2
package.json

@@ -6,7 +6,7 @@
     "dev": "vite",
     "build": "vite build",
     "preview": "vite preview",
-    "lint": "node -c vite.config.js"
+    "lint": "eslint ."
   },
   "dependencies": {
     "@fontsource/inter": "^5.3.0",
@@ -20,10 +20,13 @@
     "vue-toastification": "^2.0.0-rc.5"
   },
   "devDependencies": {
-    "@tailwindcss/cli": "^4.3.0",
+    "@eslint/js": "^10.0.1",
     "@tailwindcss/postcss": "^4.3.0",
     "@vitejs/plugin-vue": "^6.0.6",
     "autoprefixer": "^10.5.0",
+    "eslint": "^10.9.0",
+    "eslint-plugin-vue": "^10.10.0",
+    "globals": "^17.11.0",
     "postcss": "^8.5.15",
     "tailwindcss": "^4.3.0",
     "vite": "^6.0.0",

+ 5 - 36
src/api/atendimentos.js

@@ -1,4 +1,5 @@
-import { apiFetch, apiBaseUrl, buildAuthHeaders, refreshAccessToken, notifyAuthError } from "./client.js";
+import { apiFetch, apiBaseUrl, fetchAutenticado } from "./client.js";
+import { baixarBlob } from "../utils/download.js";
 
 export function sincronizarAtendimentos(params = {}) {
   return apiFetch("/api/atendimentos/sync", { method: "POST", body: params });
@@ -74,7 +75,7 @@ export function aceitarGoldenLabelComoIa(id) {
 export async function baixarRelatorioNaoResolvidos({ setor, sentimento, busca, ordenacao, dataInicio, dataFim } = {}) {
   const query = new URLSearchParams();
   if (setor) query.set("setor", setor);
-if (sentimento) query.set("sentimento", sentimento);
+  if (sentimento) query.set("sentimento", sentimento);
   if (busca) query.set("busca", busca);
   if (ordenacao === "asc") query.set("ordenacao", ordenacao);
   if (dataInicio) query.set("dataInicio", dataInicio);
@@ -82,31 +83,7 @@ if (sentimento) query.set("sentimento", sentimento);
 
   const reqUrl = `${apiBaseUrl}/api/atendimentos/avaliacoes/relatorio?${query.toString()}`;
 
-  let res;
-  try {
-    res = await fetch(reqUrl, { headers: buildAuthHeaders() });
-  } catch {
-    throw new Error("network_error");
-  }
-
-  if (res.status === 401) {
-    try {
-      await refreshAccessToken();
-    } catch {
-      notifyAuthError();
-      throw new Error("session_expired");
-    }
-    try {
-      res = await fetch(reqUrl, { headers: buildAuthHeaders() });
-    } catch {
-      throw new Error("network_error");
-    }
-  }
-
-  if (res.status === 401) {
-    notifyAuthError();
-    throw new Error("session_expired");
-  }
+  const res = await fetchAutenticado(reqUrl);
 
   if (!res.ok) {
     const text = await res.text().catch(() => "");
@@ -118,13 +95,5 @@ if (sentimento) query.set("sentimento", sentimento);
     throw new Error(payload?.error || text || `http_error:${res.status}`);
   }
 
-  const blob = await res.blob();
-  const url = URL.createObjectURL(blob);
-  const a = document.createElement("a");
-  a.href = url;
-  a.download = `atendimentos-nao-resolvidos-${new Date().toISOString().slice(0, 10)}.pdf`;
-  document.body.appendChild(a);
-  a.click();
-  a.remove();
-  URL.revokeObjectURL(url);
+  baixarBlob(await res.blob(), `atendimentos-nao-resolvidos-${new Date().toISOString().slice(0, 10)}.pdf`);
 }

+ 25 - 65
src/api/auth.js

@@ -10,19 +10,22 @@ async function parseResponse(res) {
   return text ? { message: text } : {};
 }
 
-export async function loginRequest({ login, senha, rememberMe = false }) {
+/**
+ * POST para uma rota de autenticação. Estas rotas não passam por `apiFetch` de propósito:
+ * é `apiFetch` quem chama o refresh em caso de 401, e usá-lo aqui faria a própria rota de
+ * refresh se chamar em laço.
+ *
+ * @param {string} caminho
+ * @param {object} corpo
+ * @param {boolean} [comApiError] usa ApiError (categoria + retryAfterSeconds) em vez de Error
+ */
+async function postAuth(caminho, corpo, comApiError = false) {
   let res;
   try {
-    res = await fetch(`${apiBaseUrl}/api/auth/login`, {
+    res = await fetch(`${apiBaseUrl}${caminho}`, {
       method: "POST",
-      headers: {
-        "content-type": "application/json"
-      },
-      body: JSON.stringify({
-        login,
-        senha,
-        rememberMe
-      })
+      headers: { "content-type": "application/json" },
+      body: JSON.stringify(corpo)
     });
   } catch {
     throw new ApiError("network", "network_error");
@@ -31,7 +34,9 @@ export async function loginRequest({ login, senha, rememberMe = false }) {
   const payload = await parseResponse(res);
 
   if (!res.ok || payload?.status === false) {
-    throw new ApiError(categorizeHttpStatus(res.status), extractErrorMessage(payload, res.status), {
+    const mensagem = extractErrorMessage(payload, res.status);
+    if (!comApiError) throw new Error(mensagem);
+    throw new ApiError(categorizeHttpStatus(res.status), mensagem, {
       retryAfterSeconds: Number(payload?.retryAfterSeconds) || 0
     });
   }
@@ -39,63 +44,18 @@ export async function loginRequest({ login, senha, rememberMe = false }) {
   return payload;
 }
 
-export async function solicitarResetSenha({ login }) {
-  let res;
-  try {
-    res = await fetch(`${apiBaseUrl}/api/auth/esqueci-senha`, {
-      method: "POST",
-      headers: {
-        "content-type": "application/json"
-      },
-      body: JSON.stringify({ login })
-    });
-  } catch {
-    throw new ApiError("network", "network_error");
-  }
-
-  const payload = await parseResponse(res);
-
-  if (!res.ok || payload?.status === false) {
-    throw new ApiError(categorizeHttpStatus(res.status), extractErrorMessage(payload, res.status), {
-      retryAfterSeconds: Number(payload?.retryAfterSeconds) || 0
-    });
-  }
-
-  return payload;
+export function loginRequest({ login, senha, rememberMe = false }) {
+  return postAuth("/api/auth/login", { login, senha, rememberMe }, true);
 }
 
-export async function refreshTokenRequest({ refreshToken }) {
-  const res = await fetch(`${apiBaseUrl}/api/auth/refresh`, {
-    method: "POST",
-    headers: {
-      "content-type": "application/json"
-    },
-    body: JSON.stringify({ refreshToken })
-  });
-
-  const payload = await parseResponse(res);
-
-  if (!res.ok || payload?.status === false) {
-    throw new Error(extractErrorMessage(payload, res.status));
-  }
-
-  return payload;
+export function solicitarResetSenha({ login }) {
+  return postAuth("/api/auth/esqueci-senha", { login }, true);
 }
 
-export async function logoutRequest({ refreshToken } = {}) {
-  const res = await fetch(`${apiBaseUrl}/api/auth/logout`, {
-    method: "POST",
-    headers: {
-      "content-type": "application/json"
-    },
-    body: JSON.stringify({ refreshToken })
-  });
-
-  const payload = await parseResponse(res);
-
-  if (!res.ok || payload?.status === false) {
-    throw new Error(extractErrorMessage(payload, res.status));
-  }
+export function refreshTokenRequest({ refreshToken }) {
+  return postAuth("/api/auth/refresh", { refreshToken });
+}
 
-  return payload;
+export function logoutRequest({ refreshToken } = {}) {
+  return postAuth("/api/auth/logout", { refreshToken });
 }

+ 2 - 28
src/api/chat.js

@@ -1,43 +1,17 @@
-import { apiBaseUrl, apiFetch, buildAuthHeaders, refreshAccessToken, notifyAuthError, extractErrorMessage } from "./client.js";
+import { apiBaseUrl, apiFetch, buildAuthHeaders, fetchAutenticado, refreshAccessToken, notifyAuthError, extractErrorMessage } from "./client.js";
 
 export async function sendChatStream(message, { conversationId, mode, onChunk, onSources, onStatus, onDone, onError, signal } = {}) {
   const body = JSON.stringify({ message, ...(conversationId ? { conversationId } : {}), ...(mode ? { mode } : {}) });
 
-  const doFetch = () =>
-    fetch(`${apiBaseUrl}/api/chat/stream`, {
-      method: "POST",
-      headers: buildAuthHeaders(),
-      body,
-      signal
-    });
-
   let res;
   try {
-    res = await doFetch();
-
-    if (res.status === 401) {
-      let newToken = null;
-      try {
-        newToken = await refreshAccessToken();
-      } catch {
-        notifyAuthError();
-        onError?.(new Error("session_expired"));
-        return;
-      }
-      if (newToken) res = await doFetch();
-    }
+    res = await fetchAutenticado(`${apiBaseUrl}/api/chat/stream`, { method: "POST", body, signal });
   } catch (e) {
     if (e?.name === "AbortError") return;
     onError?.(e);
     return;
   }
 
-  if (res.status === 401) {
-    notifyAuthError();
-    onError?.(new Error("session_expired"));
-    return;
-  }
-
   if (!res.ok) {
     const text = await res.text().catch(() => "");
     onError?.(new Error(text || `http_error:${res.status}`));

+ 0 - 0
src/api/conversations.js → src/api/chatConversas.js


+ 43 - 0
src/api/client.js

@@ -58,6 +58,49 @@ export function notifyAuthError() {
   _authErrorFn?.();
 }
 
+/**
+ * Requisição autenticada que renova o token e repete uma vez em caso de 401, devolvendo a
+ * Response crua — para quem precisa do corpo como stream ou blob e por isso não pode usar
+ * `apiFetch`. Lança ApiError("network") em falha de rede, Error("session_expired") quando o
+ * refresh não resolve, e propaga AbortError intacto.
+ */
+export async function fetchAutenticado(url, { headers, ...opcoes } = {}) {
+  const executar = () => fetch(url, { ...opcoes, headers: buildAuthHeaders(headers) });
+
+  let res;
+  try {
+    res = await executar();
+  } catch (e) {
+    if (e?.name === "AbortError") throw e;
+    throw new ApiError("network", "network_error");
+  }
+
+  if (res.status === 401) {
+    let novoToken;
+    try {
+      novoToken = await refreshAccessToken();
+    } catch {
+      notifyAuthError();
+      throw new Error("session_expired");
+    }
+    if (novoToken) {
+      try {
+        res = await executar();
+      } catch (e) {
+        if (e?.name === "AbortError") throw e;
+        throw new ApiError("network", "network_error");
+      }
+    }
+  }
+
+  if (res.status === 401) {
+    notifyAuthError();
+    throw new Error("session_expired");
+  }
+
+  return res;
+}
+
 export async function apiFetch(path, { method = "GET", body } = {}) {
   const headers = { "content-type": "application/json" };
   if (_accessToken) headers["Authorization"] = `Bearer ${_accessToken}`;

+ 5 - 21
src/api/conversas.js

@@ -1,4 +1,4 @@
-import { apiFetch, apiBaseUrl, buildAuthHeaders, refreshAccessToken, notifyAuthError } from "./client.js";
+import { apiFetch, apiBaseUrl, fetchAutenticado } from "./client.js";
 
 export function listarConversas() {
   return apiFetch("/api/conversas");
@@ -24,25 +24,9 @@ export function gerarSugestaoResposta(id) {
 // a rota de mídia exige Bearer token, então <img>/<audio>/<video> não podem apontar
 // direto pra ela — busca o arquivo autenticado e devolve um blob URL local
 export async function buscarMidiaBlob(conversaId, mensagemId) {
-  const url = `${apiBaseUrl}/api/conversas/${conversaId}/mensagens/${mensagemId}/midia`;
-  let res = await fetch(url, { headers: buildAuthHeaders() });
-
-  if (res.status === 401) {
-    try {
-      await refreshAccessToken();
-    } catch {
-      notifyAuthError();
-      throw new Error("session_expired");
-    }
-    res = await fetch(url, { headers: buildAuthHeaders() });
-  }
-
-  if (res.status === 401) {
-    notifyAuthError();
-    throw new Error("session_expired");
-  }
-
+  const res = await fetchAutenticado(
+    `${apiBaseUrl}/api/conversas/${conversaId}/mensagens/${mensagemId}/midia`
+  );
   if (!res.ok) throw new Error(`http_error:${res.status}`);
-  const blob = await res.blob();
-  return URL.createObjectURL(blob);
+  return URL.createObjectURL(await res.blob());
 }

+ 2 - 2
src/components/BotaoAlterarTema.vue

@@ -14,8 +14,8 @@ const { darkMode } = useTheme();
     @click="darkMode = !darkMode"
   >
     <Transition name="tema-icon" mode="out-in">
-      <BaseIcon v-if="darkMode" :icon="Sun" key="sun" class="w-5 h-5" strokeWidth="1.5" />
-      <BaseIcon v-else :icon="Moon" key="moon" class="w-5 h-5" strokeWidth="1.5" />
+      <BaseIcon v-if="darkMode" :icon="Sun" key="sun" class="w-5 h-5" stroke-width="1.5" />
+      <BaseIcon v-else :icon="Moon" key="moon" class="w-5 h-5" stroke-width="1.5" />
     </Transition>
   </button>
 </template>

+ 1 - 0
src/components/ChatMessageContent.vue

@@ -1,4 +1,5 @@
 <template>
+  <!-- eslint-disable-next-line vue/no-v-html -- `html` já vem sanitizado por DOMPurify em renderMarkdown() -->
   <div class="prose-content" v-html="html" />
 </template>
 

+ 55 - 18
src/components/ChatWindow.vue

@@ -12,7 +12,13 @@
       </div>
 
       <div v-else class="grid gap-4">
-        <div v-for="(m, idx) in messages" v-show="!m.streaming || m.content" :key="idx" class="group flex flex-col" :class="m.role === 'user' ? 'items-end' : 'items-start'">
+        <div
+          v-for="(m, idx) in mensagensView"
+          v-show="!m.streaming || m.content"
+          :key="m.id ?? idx"
+          class="group flex flex-col"
+          :class="m.role === 'user' ? 'items-end' : 'items-start'"
+        >
           <div class="mb-0.5 flex items-center gap-1.5" :class="m.role === 'user' ? 'flex-row-reverse' : ''">
             <div
               class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[9px] font-bold text-white"
@@ -23,8 +29,8 @@
             <span class="text-xs font-medium text-muted-foreground">
               {{ m.role === 'user' ? 'Você' : 'Oráculo' }}
             </span>
-            <span v-if="m.sentAt" class="text-[10px] text-gray-300 dark:text-gray-600">
-              {{ formatarHora(m.sentAt) }}
+            <span v-if="m.hora" class="text-[10px] text-gray-300 dark:text-gray-600">
+              {{ m.hora }}
             </span>
             <button
               type="button"
@@ -33,7 +39,7 @@
               @click="copiarMensagem(m.content, idx)"
             >
               <BaseIcon v-if="copiado !== idx" :icon="Copy" class="h-3 w-3" />
-              <BaseIcon v-else :icon="Check" class="h-3 w-3 text-green-500" strokeWidth="2.5" />
+              <BaseIcon v-else :icon="Check" class="h-3 w-3 text-green-500" stroke-width="2.5" />
               {{ copiado === idx ? 'Copiado!' : 'Copiar' }}
             </button>
           </div>
@@ -62,11 +68,11 @@
             </div>
 
             <div
-              v-if="m.role === 'assistant' && agruparFontes(m.sources).length"
+              v-if="m.fontes.length"
               class="mt-1.5 flex flex-wrap gap-1"
             >
               <span
-                v-for="(src, si) in agruparFontes(m.sources)"
+                v-for="(src, si) in m.fontes"
                 :key="si"
                 :title="src.count > 1 ? `${src.count} trechos deste documento` : undefined"
                 class="inline-flex items-center gap-1 rounded-md border border-gray-200 bg-gray-50 px-2 py-0.5 text-[10px] text-gray-500 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-400"
@@ -124,7 +130,7 @@
         aria-label="Fechar erro"
         @click="$emit('clearError')"
       >
-        <BaseIcon :icon="X" class="h-3.5 w-3.5" strokeWidth="2.5" />
+        <BaseIcon :icon="X" class="h-3.5 w-3.5" stroke-width="2.5" />
       </button>
     </div>
   </div>
@@ -133,6 +139,7 @@
 <script setup>
 import { ref, computed, nextTick, onMounted, onUnmounted, watch } from "vue";
 import { getStoredCode } from "../utils/markdownRenderer.js";
+import { formatarHora } from "../utils/data.js";
 import BaseButton from "./base/BaseButton.vue";
 import BaseIcon from "./base/BaseIcon.vue";
 import { Copy, Check, FileText, Send, X } from "lucide-vue-next";
@@ -160,10 +167,49 @@ watch(() => props.messages, () => {
 
 const isStreaming = computed(() => props.messages.some((m) => m.streaming && m.content));
 const streamingStatusMsg = computed(() => {
-  const m = [...props.messages].reverse().find((m) => m.streaming);
-  return m?.statusMsg || "Buscando documentos...";
+  // percorre de trás pra frente sem copiar o array: roda a cada flush do streaming
+  for (let i = props.messages.length - 1; i >= 0; i -= 1) {
+    if (props.messages[i].streaming) return props.messages[i].statusMsg || "Buscando documentos...";
+  }
+  return "Buscando documentos...";
 });
 
+// `agruparFontes` e `formatarHora` eram chamadas no template, então re-executavam para
+// todas as mensagens a cada render — inclusive a cada flush do streaming, que altera o
+// `content` da última mensagem e invalida o computed inteiro. Os dois caches abaixo fazem
+// o recálculo custar O(1) por mensagem já vista: `sources` e o timestamp não mudam depois
+// de definidos, então servem de chave estável.
+const SEM_FONTES = [];
+const cacheFontes = new WeakMap();
+const cacheHora = new Map();
+
+function fontesDaMensagem(m) {
+  if (m.role !== "assistant" || !m.sources?.length) return SEM_FONTES;
+  const emCache = cacheFontes.get(m.sources);
+  if (emCache) return emCache;
+  const fontes = agruparFontes(m.sources);
+  cacheFontes.set(m.sources, fontes);
+  return fontes;
+}
+
+function horaDaMensagem(sentAt) {
+  if (!sentAt) return "";
+  let hora = cacheHora.get(sentAt);
+  if (hora === undefined) {
+    hora = formatarHora(sentAt);
+    cacheHora.set(sentAt, hora);
+  }
+  return hora;
+}
+
+const mensagensView = computed(() =>
+  props.messages.map((m) => ({
+    ...m,
+    hora: horaDaMensagem(m.sentAt),
+    fontes: fontesDaMensagem(m)
+  }))
+);
+
 
 function agruparFontes(sources) {
   const porDocumento = new Map();
@@ -214,15 +260,6 @@ function copiarMensagem(content, idx) {
   });
 }
 
-function formatarHora(ts) {
-  if (!ts) return "";
-  try {
-    return new Date(ts).toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" });
-  } catch {
-    return "";
-  }
-}
-
 const NEAR_BOTTOM_THRESHOLD_PX = 80;
 
 function isNearBottom() {

+ 6 - 11
src/components/DocumentList.vue

@@ -2,11 +2,13 @@
 import { ref, computed } from "vue";
 import { deleteDocumentsBySource } from "../api/chat.js";
 import { useToast } from "../composables/useToast.js";
+import { useValorDebounced } from "../composables/useValorDebounced.js";
 import SkeletonDocumentCard from "./skeletons/SkeletonDocumentCard.vue";
 import BaseButton from "./base/BaseButton.vue";
 import BaseIcon from "./base/BaseIcon.vue";
 import ConfirmDialog from "./base/ConfirmDialog.vue";
 import { RefreshCw, FileText, Image, Trash2 } from "lucide-vue-next";
+import { formatarDataHoraAnoCurto } from "../utils/data.js";
 
 const props = defineProps({
   items: { type: Array, required: true },
@@ -31,8 +33,10 @@ const grouped = computed(() => {
   return result;
 });
 
+const filtroAtrasado = useValorDebounced(filterQuery);
+
 const filteredGrouped = computed(() => {
-  const q = filterQuery.value.trim().toLowerCase();
+  const q = filtroAtrasado.value.trim().toLowerCase();
   if (!q) return grouped.value;
   return Object.fromEntries(
     Object.entries(grouped.value).filter(([source]) => source.toLowerCase().includes(q))
@@ -57,15 +61,6 @@ async function onConfirmDelete(source) {
   }
 }
 
-function formatDate(iso) {
-  if (!iso) return null;
-  try {
-    return new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "2-digit", year: "2-digit", hour: "2-digit", minute: "2-digit" }).format(new Date(iso));
-  } catch {
-    return null;
-  }
-}
-
 const FILE_TYPES = {
   pdf: {
     chipClass: "bg-red-100 text-red-600 dark:bg-red-500/10 dark:text-red-400",
@@ -186,7 +181,7 @@ function fileType(source) {
           {{ source || "sem fonte" }}
         </span>
         <span class="text-[10px] text-muted-foreground">
-          {{ chunks.length }} chunk{{ chunks.length !== 1 ? 's' : '' }}<template v-if="chunks[0]?.ingestedAt"> · {{ formatDate(chunks[0].ingestedAt) }}</template>
+          {{ chunks.length }} chunk{{ chunks.length !== 1 ? 's' : '' }}<template v-if="chunks[0]?.ingestedAt"> · {{ formatarDataHoraAnoCurto(chunks[0].ingestedAt) }}</template>
         </span>
       </div>
 

+ 4 - 0
src/components/ModalMidia.vue

@@ -67,7 +67,11 @@ onUnmounted(() => window.removeEventListener("keydown", aoTeclado));
 
 <template>
   <Teleport to="body">
+    <!-- DEFEITO CONHECIDO: o v-if está no pai (ModalMidia v-if="mediaViewer"), então esta
+         Transition nunca anima. Corrigir exige trocar o v-if do pai por uma prop `aberto`
+         aqui dentro, o que muda o contrato do componente e o comportamento visível. -->
     <Transition name="modal-midia">
+      <!-- eslint-disable-next-line vue/require-toggle-inside-transition -->
       <div class="fixed inset-0 z-[9999]">
         <div class="absolute inset-0 bg-black/90" @click="emit('close')" />
 

+ 1 - 1
src/components/SearchBox.vue

@@ -29,7 +29,7 @@
 import { ref } from "vue";
 import BaseButton from "./base/BaseButton.vue";
 
-const props = defineProps({
+defineProps({
   results: { type: Array, required: true },
   loading: { type: Boolean, required: true },
   error: { type: String, required: true }

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

@@ -0,0 +1,109 @@
+<script setup>
+import { ChevronRight } from "lucide-vue-next";
+import BaseBadge from "../base/BaseBadge.vue";
+import BaseButton from "../base/BaseButton.vue";
+import BaseIcon from "../base/BaseIcon.vue";
+import ListItemCard from "../base/ListItemCard.vue";
+import { formatarData } from "../../utils/data.js";
+import {
+  cancelamentoLabel,
+  cancelamentoVariant,
+  CONTEXTO_IXC_ERRO_LABEL,
+  contextoIxcFalhou,
+  resolvidoLabel,
+  resolvidoVariant,
+  scoreVariant,
+  sentimentoLabel,
+  sentimentoVariant
+} from "../../utils/avaliacoes.js";
+
+defineProps({
+  avaliacao: { type: Object, required: true },
+  prefixoId: { type: String, required: true },
+  expandida: { type: Boolean, default: false }
+});
+
+defineEmits(["toggle", "verConversa"]);
+</script>
+
+<template>
+  <ListItemCard
+    :id="`${prefixoId}-${avaliacao.AtendimentoId}`"
+    :expanded="expandida"
+    @toggle="$emit('toggle', avaliacao.AtendimentoId)"
+  >
+    <template #header>
+      <div class="min-w-0 flex-1">
+        <div class="truncate text-sm font-semibold text-foreground">
+          {{ avaliacao.Codigo ?? `#${avaliacao.AtendimentoId}` }}
+          <span class="font-normal text-muted-foreground"> — {{ avaliacao.ClienteNome ?? "cliente desconhecido" }}</span>
+        </div>
+        <div class="mt-0.5 text-xs text-muted-foreground">
+          <span v-if="avaliacao.Setor">{{ avaliacao.Setor }} · </span>{{ formatarData(avaliacao.Abertura) }}
+          <span v-if="avaliacao.Atendentes?.length"> · {{ avaliacao.Atendentes.join(", ") }}</span>
+        </div>
+      </div>
+      <div class="flex flex-wrap items-center justify-end gap-1.5">
+        <BaseBadge :variant="scoreVariant(avaliacao.ScoreAtendente)">
+          {{
+            avaliacao.ScoreAtendente !== null && avaliacao.ScoreAtendente !== undefined
+              ? `Nota ${avaliacao.ScoreAtendente}`
+              : "Sem nota"
+          }}
+        </BaseBadge>
+        <BaseBadge :variant="resolvidoVariant[avaliacao.Resolvido] ?? 'default'">
+          {{ resolvidoLabel[avaliacao.Resolvido] ?? avaliacao.Resolvido }}
+        </BaseBadge>
+        <BaseBadge :variant="sentimentoVariant[avaliacao.Sentimento] ?? 'default'">
+          {{ sentimentoLabel[avaliacao.Sentimento] ?? avaliacao.Sentimento }}
+        </BaseBadge>
+        <BaseBadge v-if="avaliacao.VisitaAgendada" variant="violet">
+          Visita{{ avaliacao.HorarioVisitaInformado === "sim" ? " agendada" : " sem horário" }}
+        </BaseBadge>
+        <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>
+      </div>
+    </template>
+
+    <div v-if="avaliacao.Resumo">
+      <div class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Resumo</div>
+      <p class="mt-1 text-foreground">{{ avaliacao.Resumo }}</p>
+    </div>
+    <div v-if="avaliacao.JustificativaScore">
+      <div class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Justificativa da nota</div>
+      <p class="mt-1 text-foreground">{{ avaliacao.JustificativaScore }}</p>
+    </div>
+    <div v-if="avaliacao.MotivoCancelamento">
+      <div class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Motivo do cancelamento</div>
+      <p class="mt-1 text-foreground">{{ avaliacao.MotivoCancelamento }}</p>
+    </div>
+    <div v-if="avaliacao.Sugestoes?.length">
+      <div class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Itens a melhorar</div>
+      <ul class="mt-1 list-disc space-y-0.5 pl-5 text-foreground">
+        <li v-for="(s, i) in avaliacao.Sugestoes" :key="i">{{ s }}</li>
+      </ul>
+    </div>
+    <div v-if="avaliacao.VisitaAgendada" class="text-foreground">
+      <span class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Visita técnica: </span>
+      <template v-if="avaliacao.HorarioVisitaInformado === 'sim'">horário informado — {{ avaliacao.HorarioVisita }}</template>
+      <template v-else>agendada, mas o atendente não informou o horário</template>
+    </div>
+    <div class="flex flex-wrap items-center justify-between gap-3">
+      <BaseButton variant="secondary" size="sm" @click="$emit('verConversa', avaliacao.AtendimentoId)">
+        Ver conversa completa
+        <BaseIcon :icon="ChevronRight" class="h-3.5 w-3.5" />
+      </BaseButton>
+      <div class="text-xs text-muted-foreground">
+        Avaliado por {{ avaliacao.Modelo }} em {{ formatarData(avaliacao.UpdatedAt) }} ·
+        {{ avaliacao.TotalMensagens }} mensagens
+      </div>
+    </div>
+  </ListItemCard>
+</template>

+ 42 - 0
src/components/base/CampoBusca.vue

@@ -0,0 +1,42 @@
+<script setup>
+import { Search, X } from "lucide-vue-next";
+import BaseIcon from "./BaseIcon.vue";
+
+defineProps({
+  modelValue: { type: String, default: "" },
+  placeholder: { type: String, default: "Buscar..." }
+});
+
+const emit = defineEmits(["update:modelValue", "input", "enter", "limpar"]);
+
+function aoLimpar() {
+  emit("update:modelValue", "");
+  emit("limpar");
+}
+</script>
+
+<template>
+  <div class="relative min-w-[240px] flex-1 sm:max-w-[340px]">
+    <BaseIcon
+      :icon="Search"
+      class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
+    />
+    <input
+      :value="modelValue"
+      type="search"
+      :placeholder="placeholder"
+      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="emit('update:modelValue', $event.target.value); emit('input')"
+      @keyup.enter="emit('enter')"
+    />
+    <button
+      v-if="modelValue"
+      type="button"
+      class="absolute right-2 top-1/2 -translate-y-1/2 rounded bg-transparent p-0.5 text-gray-400 hover:text-muted-foreground dark:hover:text-gray-300"
+      title="Limpar busca"
+      @click="aoLimpar"
+    >
+      <BaseIcon :icon="X" class="h-4 w-4" />
+    </button>
+  </div>
+</template>

+ 31 - 0
src/components/base/PaginacaoLista.vue

@@ -0,0 +1,31 @@
+<script setup>
+import BaseButton from "./BaseButton.vue";
+
+defineProps({
+  page: { type: Number, required: true },
+  totalPaginas: { type: Number, required: true },
+  desabilitado: { type: Boolean, default: false }
+});
+
+defineEmits(["ir"]);
+</script>
+
+<template>
+  <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 || desabilitado" @click="$emit('ir', page - 1)">
+      Anterior
+    </BaseButton>
+    <span class="text-muted-foreground">Página {{ page }} de {{ totalPaginas }}</span>
+    <BaseButton
+      variant="secondary"
+      size="sm"
+      :disabled="page >= totalPaginas || desabilitado"
+      @click="$emit('ir', page + 1)"
+    >
+      Próxima
+    </BaseButton>
+  </div>
+</template>

+ 3 - 1
src/composables/useAsyncState.js

@@ -25,7 +25,9 @@ export function useAsyncState(defaultValue = null) {
       return result;
     } catch (e) {
       if (isStale(id)) return;
-      error.value = e?.message || onErrorMessage || "Erro inesperado.";
+      // a mensagem da chamada vem primeiro: o que sobe da API costuma ser técnico
+      // ("http_error:500", "network_error") e não diz nada para quem está na tela
+      error.value = onErrorMessage || e?.message || "Erro inesperado.";
       throw e;
     } finally {
       if (!isStale(id)) loading.value = false;

+ 6 - 0
src/composables/useAuth.js

@@ -43,9 +43,13 @@ function persistSession(sess) {
   } catch {}
 }
 
+export const NIVEL_ADMIN = 3;
+
 const session = ref(loadStoredSession());
 const user = computed(() => session.value?.usuario ?? null);
 const isAuthenticated = computed(() => Boolean(user.value?.Id));
+const nivel = computed(() => Number(user.value?.Nivel ?? 0));
+const isAdmin = computed(() => nivel.value >= NIVEL_ADMIN);
 
 let _proactiveRefreshTimer = null;
 let _refreshInFlight = null;
@@ -155,6 +159,8 @@ async function logout() {
 export function useAuth() {
   return {
     user,
+    nivel,
+    isAdmin,
     isAuthenticated,
     login,
     logout

+ 52 - 15
src/composables/useChat.js

@@ -7,17 +7,21 @@ import {
   updateConversationTitle as updateConversationTitleAPI,
   deleteConversation as deleteConversationAPI,
   exportConversation as exportConversationAPI
-} from "../api/conversations.js";
+} from "../api/chatConversas.js";
 import { useToast } from "./useToast.js";
+import { baixarBlob } from "../utils/download.js";
 
 let singleton;
 let _streamAbort = null;
+// flush pendente do stream em andamento (ver FLUSH_STREAM_MS em send())
+let _flushStream = null;
 
 export function resetChatSingleton() {
   if (_streamAbort) {
     _streamAbort.abort();
     _streamAbort = null;
   }
+  _flushStream = null;
   singleton = undefined;
 }
 
@@ -28,6 +32,7 @@ export function useChat() {
 
   const defaultMessages = [
     {
+      id: "boas-vindas",
       role: "assistant",
       content: "Posso responder usando a base de conhecimento da empresa. Envie uma pergunta ou faça upload de documentos.",
       sentAt: 0
@@ -35,6 +40,12 @@ export function useChat() {
   ];
 
   const CONV_PAGE_SIZE = 20;
+  const FLUSH_STREAM_MS = 80;
+
+  // id local e estável por mensagem: serve de :key na lista do chat. O backend não devolve
+  // um id utilizável para as mensagens em streaming, que só existem no cliente.
+  let ultimoIdMensagem = 0;
+  const proximoIdMensagem = () => `m${++ultimoIdMensagem}`;
 
   const conversations = ref([]);
   const activeConversationId = ref(null);
@@ -58,6 +69,7 @@ export function useChat() {
         }
       }
       return {
+        id: proximoIdMensagem(),
         role: m.Role === "user" ? "user" : "assistant",
         content: String(m.Content ?? ""),
         sources,
@@ -70,7 +82,6 @@ export function useChat() {
     return {
       id: String(c.Id),
       title: c.Title || "Nova conversa",
-      createdAt: new Date(c.CreatedAt).getTime(),
       updatedAt: new Date(c.UpdatedAt).getTime(),
       messageCount: Number(c.MessageCount ?? 0)
     };
@@ -184,13 +195,7 @@ export function useChat() {
       const data = await exportConversationAPI(numId);
       const conv = conversations.value.find((c) => c.id === String(id));
       const filename = `conversa-${conv?.title?.slice(0, 40).replace(/[^a-zA-Z0-9À-ÿ\s]/g, "").trim().replace(/\s+/g, "-") || numId}.md`;
-      const blob = new Blob([data.markdown ?? ""], { type: "text/markdown" });
-      const url = URL.createObjectURL(blob);
-      const a = document.createElement("a");
-      a.href = url;
-      a.download = filename;
-      a.click();
-      URL.revokeObjectURL(url);
+      baixarBlob(new Blob([data.markdown ?? ""], { type: "text/markdown" }), filename);
     } catch (err) {
       if (err?.message !== "session_expired") {
         toast.erro("Não foi possível exportar a conversa.");
@@ -217,6 +222,7 @@ export function useChat() {
   }
 
   function cancelCurrentStream() {
+    _flushStream?.();
     if (_streamAbort) {
       _streamAbort.abort();
       _streamAbort = null;
@@ -230,8 +236,9 @@ export function useChat() {
     if (!trimmed) return;
 
     if (_streamAbort) {
+      _flushStream?.();
       _streamAbort.abort();
-     
+
       messages.value = messages.value
         .filter((m) => !(m.role === "assistant" && m.streaming && !m.content))
         .map((m) => (m.role === "assistant" && m.streaming ? { ...m, streaming: false } : m));
@@ -255,7 +262,7 @@ export function useChat() {
         activeConversationId.value = String(convId);
         const now = Date.now();
         conversations.value = [
-          { id: String(convId), title, createdAt: now, updatedAt: now, messageCount: 0 },
+          { id: String(convId), title, updatedAt: now, messageCount: 0 },
           ...conversations.value
         ];
       } catch {
@@ -267,13 +274,36 @@ export function useChat() {
       return;
     }
 
-    messages.value.push({ role: "user", content: trimmed, sentAt: Date.now() });
+    messages.value.push({ id: proximoIdMensagem(), role: "user", content: trimmed, sentAt: Date.now() });
 
-    const assistantMsg = { role: "assistant", content: "", sources: [], sentAt: Date.now(), streaming: true, statusMsg: "" };
+    const assistantMsg = {
+      id: proximoIdMensagem(),
+      role: "assistant",
+      content: "",
+      sources: [],
+      sentAt: Date.now(),
+      streaming: true,
+      statusMsg: ""
+    };
     messages.value.push(assistantMsg);
 
     const liveMsg = messages.value[messages.value.length - 1];
 
+    // O markdown da mensagem inteira é reprocessado (marked + highlight.js + DOMPurify)
+    // a cada alteração de `content`. Acumular os deltas e escrever no máximo a cada
+    // FLUSH_STREAM_MS mantém a sensação de digitação contínua sem o custo quadrático.
+    let bufferStream = "";
+    let flushTimer = null;
+
+    function flushStream() {
+      clearTimeout(flushTimer);
+      flushTimer = null;
+      if (!bufferStream) return;
+      liveMsg.content += bufferStream;
+      bufferStream = "";
+    }
+    _flushStream = flushStream;
+
     try {
       await sendChatStream(trimmed, {
         conversationId: convId,
@@ -286,16 +316,19 @@ export function useChat() {
           else if (stage === "gerando") liveMsg.statusMsg = "Gerando resposta...";
         },
         onChunk: (delta) => {
-          liveMsg.statusMsg = "";
-          liveMsg.content += delta;
+          if (liveMsg.statusMsg) liveMsg.statusMsg = "";
+          bufferStream += delta;
+          if (!flushTimer) flushTimer = setTimeout(flushStream, FLUSH_STREAM_MS);
         },
         onSources: (sources) => {
           liveMsg.sources = sources ?? [];
         },
         onDone: () => {
+          flushStream();
           liveMsg.streaming = false;
           if (_streamAbort !== abortController) return;
           _streamAbort = null;
+          _flushStream = null;
           loading.value = false;
           if (convId) {
             conversations.value = conversations.value
@@ -308,23 +341,27 @@ export function useChat() {
           }
         },
         onError: (e) => {
+          flushStream();
           liveMsg.streaming = false;
           if (!liveMsg.content) {
             liveMsg.content = "Não consegui responder no momento.";
           }
           if (_streamAbort !== abortController) return;
           _streamAbort = null;
+          _flushStream = null;
           error.value = e?.message || "erro";
           loading.value = false;
         }
       });
     } catch (e) {
+      flushStream();
       liveMsg.streaming = false;
       if (!liveMsg.content) {
         liveMsg.content = "Não consegui responder no momento.";
       }
       if (_streamAbort === abortController) {
         _streamAbort = null;
+        _flushStream = null;
         error.value = e?.message || "erro";
         loading.value = false;
       }

+ 1 - 2
src/composables/useConversas.js

@@ -123,9 +123,8 @@ export function useConversas() {
         const novas = results.filter((m) => !idsExistentes.has(m.Id));
         mensagens.value = [...mensagens.value, ...novas];
         ultimoTimestamp = results[results.length - 1].Timestamp;
-        if ( novas.some((m) => m.Direcao === "entrada")) buscarSugestao();
+        if (novas.some((m) => m.Direcao === "entrada")) buscarSugestao();
       }
-      await carregarConversas();
     } catch {
       // falha pontual de polling não deve interromper o ciclo seguinte
     } finally {

+ 7 - 9
src/composables/useDocumentos.js

@@ -1,4 +1,4 @@
-import { onMounted, reactive } from "vue";
+import { markRaw, onMounted, reactive } from "vue";
 import { listDocuments } from "../api/chat.js";
 import { useAsyncState } from "./useAsyncState.js";
 
@@ -18,18 +18,21 @@ export function useDocumentos() {
     docs.loading = true;
     docs.error = "";
     try {
-
+      // O `nextOffset` é um cursor opaco do Qdrant, então as páginas só podem ser buscadas
+      // em série. Para a lista não ficar vazia até a última delas chegar, cada página já
+      // vai para a tela assim que responde.
       const all = [];
       let offset = 0;
       for (let page = 0; page < MAX_PAGES; page += 1) {
         const data = await listDocuments({ limit: PAGE_SIZE, offset });
         if (raceGuard.isStale(epoch)) return;
         all.push(...(data.items ?? []));
+        // markRaw: são payloads imutáveis; sem isso o Vue cria um proxy por objeto (até 5000)
+        docs.items = markRaw(all.slice());
+        if (page === 0) docs.loading = false;
         if (data.nextOffset == null) break;
         offset = data.nextOffset;
       }
-      if (raceGuard.isStale(epoch)) return;
-      docs.items = all;
     } catch (e) {
       if (raceGuard.isStale(epoch)) return;
       docs.error = e?.message || "erro";
@@ -39,10 +42,6 @@ export function useDocumentos() {
     }
   }
 
-  async function onIngested() {
-    await loadDocs();
-  }
-
   onMounted(async () => {
     await loadDocs();
   });
@@ -50,6 +49,5 @@ export function useDocumentos() {
   return {
     docs,
     loadDocs,
-    onIngested,
   };
 }

+ 172 - 0
src/composables/useListaAvaliacoes.js

@@ -0,0 +1,172 @@
+import { computed, nextTick, onBeforeUnmount, ref } from "vue";
+import { useRoute, useRouter } from "vue-router";
+import { listarAvaliacoes, listarSetoresAvaliacoes } from "../api/atendimentos.js";
+import { useAsyncState } from "./useAsyncState.js";
+import { useToast } from "./useToast.js";
+
+const DEBOUNCE_BUSCA_MS = 400;
+
+// lê um parâmetro de query string como texto, ignorando arrays (?x=1&x=2)
+export function queryTexto(route, chave, padrao = "") {
+  return typeof route.query[chave] === "string" ? route.query[chave] : padrao;
+}
+
+/**
+ * Estado compartilhado pelas listas de avaliações de atendimento (AvaliacoesView e
+ * NaoResolvidosView): paginação, busca com debounce, ordenação, sincronização com a
+ * query string e o item expandido — incluindo a rolagem de volta até ele.
+ *
+ * O que difere entre as duas telas (filtros próprios, formato dos stats, ações em lote)
+ * fica na view; aqui só entra o que é idêntico.
+ *
+ * @param {object} opcoes
+ * @param {() => object} opcoes.montarQueryExtra  chaves de query específicas da view (só as preenchidas)
+ * @param {() => object} opcoes.montarFiltrosApi  filtros específicos passados a listarAvaliacoes()
+ * @param {() => void}   [opcoes.aoAplicarFiltros] chamado quando os filtros mudam (ex.: recarregar stats)
+ * @param {string}       opcoes.prefixoElemento   prefixo do id no DOM, para o scroll de retorno
+ * @param {string}       opcoes.mensagemErroLista
+ * @param {number}       [opcoes.pageSize]
+ */
+export function useListaAvaliacoes({
+  montarQueryExtra,
+  montarFiltrosApi,
+  aoAplicarFiltros,
+  prefixoElemento,
+  mensagemErroLista,
+  pageSize = 20
+}) {
+  const route = useRoute();
+  const router = useRouter();
+  const toast = useToast();
+
+  const busca = ref(queryTexto(route, "q"));
+  const ordenacao = ref(queryTexto(route, "ordenacao", "desc"));
+  const page = ref(Math.max(1, Number(route.query.page) || 1));
+  const expandido = ref(route.query.aberto ? Number(route.query.aberto) : null);
+
+  const setores = ref([]);
+
+  // useAsyncState já cuida do anti-race (respostas antigas são descartadas)
+  const { data: resposta, loading: carregando, error: erro, run } = useAsyncState(null);
+
+  const avaliacoes = computed(() => resposta.value?.results ?? []);
+  const total = computed(() => resposta.value?.total ?? 0);
+  const totalPaginas = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
+
+  function sincronizarQuery() {
+    const query = { ...montarQueryExtra() };
+    if (page.value > 1) query.page = String(page.value);
+    if (busca.value.trim()) query.q = busca.value.trim();
+    if (ordenacao.value === "asc") query.ordenacao = "asc";
+    if (expandido.value) query.aberto = String(expandido.value);
+    router.replace({ query });
+  }
+
+  async function carregarLista() {
+    try {
+      await run(
+        () =>
+          listarAvaliacoes({
+            page: page.value,
+            pageSize,
+            busca: busca.value.trim() || undefined,
+            ordenacao: ordenacao.value,
+            ...montarFiltrosApi()
+          }),
+        { onErrorMessage: mensagemErroLista }
+      );
+    } catch {
+      // erro já registrado em `erro` pelo useAsyncState
+    }
+  }
+
+  async function carregarSetores() {
+    try {
+      const r = await listarSetoresAvaliacoes();
+      setores.value = r.results ?? [];
+    } catch (e) {
+      setores.value = [];
+      toast.erro(e?.message ?? "Falha ao carregar setores");
+    }
+  }
+
+  function aplicarFiltros() {
+    page.value = 1;
+    expandido.value = null;
+    sincronizarQuery();
+    aoAplicarFiltros?.();
+    carregarLista();
+  }
+
+  function alternarOrdenacao() {
+    ordenacao.value = ordenacao.value === "asc" ? "desc" : "asc";
+    aplicarFiltros();
+  }
+
+  let buscaTimer = null;
+
+  function onBuscaInput() {
+    clearTimeout(buscaTimer);
+    buscaTimer = setTimeout(aplicarFiltros, DEBOUNCE_BUSCA_MS);
+  }
+
+  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 } });
+  }
+
+  // primeira carga: lista + setores e, voltando da conversa completa, reposiciona na
+  // avaliação que estava aberta
+  async function inicializar() {
+    carregarSetores();
+    await carregarLista();
+
+    if (expandido.value) {
+      await nextTick();
+      document.getElementById(`${prefixoElemento}-${expandido.value}`)?.scrollIntoView({ block: "center" });
+    }
+  }
+
+  onBeforeUnmount(() => {
+    clearTimeout(buscaTimer);
+  });
+
+  return {
+    busca,
+    ordenacao,
+    page,
+    expandido,
+    setores,
+    avaliacoes,
+    total,
+    totalPaginas,
+    carregando,
+    erro,
+    carregarLista,
+    aplicarFiltros,
+    alternarOrdenacao,
+    onBuscaInput,
+    onBuscaEnter,
+    irParaPagina,
+    alternarExpandido,
+    verConversa,
+    inicializar
+  };
+}

+ 1 - 2
src/composables/useMenuUsuario.js

@@ -1,6 +1,6 @@
 import { onBeforeUnmount, onMounted, ref } from "vue";
 
-export function useMenuUsuario({ onEscape } = {}) {
+export function useMenuUsuario() {
   const menuUsuarioAberto = ref(false);
   const menuUsuarioRef = ref(null);
 
@@ -15,7 +15,6 @@ export function useMenuUsuario({ onEscape } = {}) {
   function onKeyDown(e) {
     if (e?.key !== "Escape") return;
     if (menuUsuarioAberto.value) fecharMenuUsuario();
-    else onEscape?.();
   }
 
   function onDocumentClick(e) {

+ 0 - 45
src/composables/usePainelDocumentos.js

@@ -1,45 +0,0 @@
-import { computed, ref, watch } from "vue";
-
-let singleton;
-
-export function usePainelDocumentos() {
-  if (singleton) return singleton;
-
-  const painelAberto = ref("");
-  let overflowAntes = "";
-
-  const tituloModal = computed(() => {
-    if (painelAberto.value === "update") return "Carregar documento";
-    if (painelAberto.value === "documentos") return "Documentos";
-    return "";
-  });
-
-  function alternarPainel(nome) {
-    const n = String(nome ?? "");
-    painelAberto.value = painelAberto.value === n ? "" : n;
-  }
-
-  function fecharModal() {
-    painelAberto.value = "";
-  }
-
-  watch(
-    () => painelAberto.value,
-    (v) => {
-      if (v) {
-        overflowAntes = document.body.style.overflow || "";
-        document.body.style.overflow = "hidden";
-      } else {
-        document.body.style.overflow = overflowAntes;
-      }
-    },
-  );
-
-  singleton = {
-    painelAberto,
-    tituloModal,
-    alternarPainel,
-    fecharModal,
-  };
-  return singleton;
-}

+ 1 - 1
src/composables/useSearch.js

@@ -8,7 +8,7 @@ export function useSearch() {
     const q = String(query ?? "").trim();
     if (!q) return;
     try {
-      await exec(() => search(q).then((data) => data.results ?? []), { onErrorMessage: "erro" });
+      await exec(() => search(q).then((data) => data.results ?? []), { onErrorMessage: "Não foi possível buscar na base de conhecimento." });
     } catch {
       results.value = [];
     }

+ 1 - 1
src/composables/useTheme.js

@@ -9,7 +9,7 @@ function obterPreferenciaInicial() {
   try {
     salvo = localStorage.getItem(CHAVE_STORAGE);
   } catch {
-    salvo = null;
+    // navegação privada pode bloquear o storage; segue com a preferência do sistema
   }
   if (salvo === "dark") return true;
   if (salvo === "light") return false;

+ 0 - 1
src/composables/useToast.js

@@ -6,6 +6,5 @@ export function useToast() {
     sucesso: (msg) => toast.success(msg),
     erro: (msg) => toast.error(msg),
     info: (msg) => toast.info(msg),
-    aviso: (msg) => toast.warning(msg),
   };
 }

+ 1 - 1
src/composables/useUsers.js

@@ -12,7 +12,7 @@ export function resetUsersSingleton() {
 export function useUsers() {
   async function loadUsers() {
     try {
-      await exec(() => listUsers(), { onErrorMessage: "Erro ao carregar usuarios." });
+      await exec(() => listUsers(), { onErrorMessage: "Não foi possível carregar os usuários." });
     } catch {
       users.value = [];
     }

+ 25 - 0
src/composables/useValorDebounced.js

@@ -0,0 +1,25 @@
+import { onBeforeUnmount, ref, watch } from "vue";
+
+/**
+ * Espelha um ref com atraso, para filtros locais que percorrem listas grandes não
+ * recalcularem a cada tecla. Para busca que dispara requisição, o debounce fica na ação
+ * (ver useListaAvaliacoes), não no valor.
+ *
+ * @param {import("vue").Ref} origem
+ * @param {number} ms
+ */
+export function useValorDebounced(origem, ms = 250) {
+  const atrasado = ref(origem.value);
+  let timer = null;
+
+  watch(origem, (novo) => {
+    clearTimeout(timer);
+    timer = setTimeout(() => {
+      atrasado.value = novo;
+    }, ms);
+  });
+
+  onBeforeUnmount(() => clearTimeout(timer));
+
+  return atrasado;
+}

+ 0 - 6
src/composables/useWhatsappConexao.js

@@ -5,8 +5,6 @@ import { useAsyncState } from "./useAsyncState.js";
 export function useWhatsappConexao() {
   const status = ref("nao_configurado");
   const telefone = ref(null);
-  const verifiedName = ref(null);
-  const ultimoErro = ref(null);
   const { loading: carregando, error: erro, run: exec } = useAsyncState();
 
   async function atualizarStatus() {
@@ -16,8 +14,6 @@ export function useWhatsappConexao() {
       });
       status.value = estado.status;
       telefone.value = estado.telefone;
-      verifiedName.value = estado.verifiedName;
-      ultimoErro.value = estado.ultimoErro;
     } catch {
       // erro já registrado em `erro` pelo useAsyncState
     }
@@ -26,8 +22,6 @@ export function useWhatsappConexao() {
   return {
     status,
     telefone,
-    verifiedName,
-    ultimoErro,
     carregando,
     erro,
     atualizarStatus

+ 1 - 3
src/layout/HeaderSistema.vue

@@ -7,7 +7,6 @@ import BaseIcon from "../components/base/BaseIcon.vue";
 import { avatarIniciais as _avatarIniciais, avatarCor as _avatarCor } from "../utils/avatar.js";
 import { useAuth } from "../composables/useAuth.js";
 import { useSidebar } from "../composables/useSidebar.js";
-import { usePainelDocumentos } from "../composables/usePainelDocumentos.js";
 import { useMenuUsuario } from "../composables/useMenuUsuario.js";
 
 const router = useRouter();
@@ -15,8 +14,7 @@ const route = useRoute();
 
 const { user, logout } = useAuth();
 const { sidebarRecolhida } = useSidebar();
-const { fecharModal } = usePainelDocumentos();
-const { menuUsuarioAberto, menuUsuarioRef, alternarMenuUsuario, fecharMenuUsuario } = useMenuUsuario({ onEscape: fecharModal });
+const { menuUsuarioAberto, menuUsuarioRef, alternarMenuUsuario, fecharMenuUsuario } = useMenuUsuario();
 
 const logoutLoading = ref(false);
 const rotaAtiva = computed(() => String(route.name ?? ""));

+ 6 - 21
src/layout/ListaConversas.vue

@@ -7,6 +7,7 @@ import ConfirmDialog from "../components/base/ConfirmDialog.vue";
 import { Check, X, Pencil, Trash2 } from "lucide-vue-next";
 import { useChat } from "../composables/useChat.js";
 import { useConversaEdicao } from "../composables/useConversaEdicao.js";
+import { agruparPorRecencia } from "../utils/data.js";
 
 const router = useRouter();
 
@@ -30,25 +31,9 @@ function onExcluir(id) {
   confirmandoExclusaoId.value = null;
 }
 
-const conversacoesAgrupadas = computed(() => {
-  const hoje = new Date().setHours(0, 0, 0, 0);
-  const ontem = hoje - 86400000;
-  const semana = hoje - 6 * 86400000;
-  const grupos = [
-    { label: "Hoje",           items: [] },
-    { label: "Ontem",          items: [] },
-    { label: "Últimos 7 dias", items: [] },
-    { label: "Mais antigas",   items: [] },
-  ];
-  for (const c of conversations.value) {
-    const t = c.updatedAt ?? 0;
-    if      (t >= hoje)   grupos[0].items.push(c);
-    else if (t >= ontem)  grupos[1].items.push(c);
-    else if (t >= semana) grupos[2].items.push(c);
-    else                  grupos[3].items.push(c);
-  }
-  return grupos.filter((g) => g.items.length > 0);
-});
+const conversacoesAgrupadas = computed(() =>
+  agruparPorRecencia(conversations.value, (c) => c.updatedAt ?? 0)
+);
 
 async function abrirConversa(id) {
   await setActiveConversation(id);
@@ -99,7 +84,7 @@ async function abrirConversa(id) {
               title="Confirmar"
               @click.stop="confirmarEdicao(c.id)"
             >
-              <BaseIcon :icon="Check" class="h-3.5 w-3.5" strokeWidth="2.5" />
+              <BaseIcon :icon="Check" class="h-3.5 w-3.5" stroke-width="2.5" />
             </button>
             <button
               type="button"
@@ -107,7 +92,7 @@ async function abrirConversa(id) {
               title="Cancelar"
               @click.stop="cancelarEdicao"
             >
-              <BaseIcon :icon="X" class="h-3.5 w-3.5" strokeWidth="2.5" />
+              <BaseIcon :icon="X" class="h-3.5 w-3.5" stroke-width="2.5" />
             </button>
           </div>
         </template>

+ 3 - 4
src/layout/SidebarSistema.vue

@@ -24,13 +24,12 @@ import { useSidebar } from "../composables/useSidebar.js";
 const router = useRouter();
 const route = useRoute();
 
-const { user } = useAuth();
 const { activeConversationId, newConversation } = useChat();
-const { sidebarRecolhida, classeRotulo, classeItemSidebar, classeIcon } = useSidebar();
+const { sidebarRecolhida, classeItemSidebar, classeIcon, classeRotulo } = useSidebar();
 
-const rotaAtiva = computed(() => String(route.name ?? ""));
+const { isAdmin } = useAuth();
 
-const isAdmin = computed(() => String(user.value?.Nivel) === "3");
+const rotaAtiva = computed(() => String(route.name ?? ""));
 
 function irPara(nome) {
   router.push({ name: nome });

+ 17 - 8
src/main.js

@@ -1,12 +1,21 @@
 import { createApp } from "vue";
 import App from "./App.vue";
-import "@fontsource/inter/400.css";
-import "@fontsource/inter/500.css";
-import "@fontsource/inter/600.css";
-import "@fontsource/inter/700.css";
-import "@fontsource/inter/800.css";
-import "@fontsource/jetbrains-mono/400.css";
-import "@fontsource/jetbrains-mono/500.css";
+// Só os subsets latin/latin-ext: os demais (cirílico, grego, vietnamita) nunca são baixados
+// por causa do unicode-range, mas suas regras @font-face pesavam ~24KB no CSS render-blocking.
+import "@fontsource/inter/latin-400.css";
+import "@fontsource/inter/latin-500.css";
+import "@fontsource/inter/latin-600.css";
+import "@fontsource/inter/latin-700.css";
+import "@fontsource/inter/latin-800.css";
+import "@fontsource/inter/latin-ext-400.css";
+import "@fontsource/inter/latin-ext-500.css";
+import "@fontsource/inter/latin-ext-600.css";
+import "@fontsource/inter/latin-ext-700.css";
+import "@fontsource/inter/latin-ext-800.css";
+import "@fontsource/jetbrains-mono/latin-400.css";
+import "@fontsource/jetbrains-mono/latin-500.css";
+import "@fontsource/jetbrains-mono/latin-ext-400.css";
+import "@fontsource/jetbrains-mono/latin-ext-500.css";
 import "@/styles/tailwind.css";
 import "@/styles/highlight-theme.css";
 import "@/styles/toast-theme.css";
@@ -31,7 +40,7 @@ function aplicarTemaInicial() {
   try {
     salvo = localStorage.getItem("theme");
   } catch {
-    salvo = null;
+    // navegação privada pode bloquear o storage; segue com a preferência do sistema
   }
   const tema =
     salvo === "dark" || salvo === "light"

+ 31 - 23
src/router/index.js

@@ -1,5 +1,5 @@
 import { createRouter, createWebHistory } from "vue-router";
-import { useAuth } from "../composables/useAuth.js";
+import { NIVEL_ADMIN, useAuth } from "../composables/useAuth.js";
 
 const PaginaInicialView = () => import("../views/pagina-inicial/PaginaInicialView.vue");
 const ConversasHistoricoView = () => import("../views/conversas/ConversasHistoricoView.vue");
@@ -23,25 +23,45 @@ export const router = createRouter({
     { path: "/conversas/historico", name: "conversas-historico", component: ConversasHistoricoView, meta: { requiresAuth: true } },
     { 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: "/usuarios", name: "usuarios", component: UsuariosView, meta: { requiresAuth: true, nivelMinimo: NIVEL_ADMIN } },
+    {
+      path: "/atendimentos/avaliacoes",
+      name: "avaliacoes",
+      component: AvaliacoesView,
+      meta: { requiresAuth: true, nivelMinimo: NIVEL_ADMIN }
+    },
     {
       path: "/atendimentos/nao-resolvidos",
       name: "atendimentos-nao-resolvidos",
       component: NaoResolvidosView,
-      meta: { requiresAuth: true }
+      meta: { requiresAuth: true, nivelMinimo: NIVEL_ADMIN }
+    },
+    {
+      path: "/atendimentos/:id(\\d+)",
+      name: "atendimento",
+      component: AtendimentoView,
+      meta: { requiresAuth: true, nivelMinimo: NIVEL_ADMIN }
+    },
+    {
+      path: "/atendimentos/golden-set",
+      name: "golden-set",
+      component: GoldenSetView,
+      meta: { requiresAuth: true, nivelMinimo: NIVEL_ADMIN }
     },
-    { path: "/atendimentos/:id(\\d+)", name: "atendimento", component: AtendimentoView, meta: { requiresAuth: true } },
-    { path: "/atendimentos/golden-set", name: "golden-set", component: GoldenSetView, 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/carregar", name: "documentos-carregar", component: CarregarDocumentoView, meta: { requiresAuth: true } },
+    { path: "/documentos", name: "documentos", component: DocumentosView, meta: { requiresAuth: true, nivelMinimo: NIVEL_ADMIN } },
+    {
+      path: "/documentos/carregar",
+      name: "documentos-carregar",
+      component: CarregarDocumentoView,
+      meta: { requiresAuth: true, nivelMinimo: NIVEL_ADMIN }
+    },
     { path: "/:pathMatch(.*)*", redirect: "/" }
   ]
 });
 
 router.beforeEach((to) => {
-  const { isAuthenticated } = useAuth();
+  const { isAuthenticated, nivel } = useAuth();
 
   if (to.meta?.guestOnly && isAuthenticated.value) {
     return { name: "home" };
@@ -54,20 +74,8 @@ router.beforeEach((to) => {
     };
   }
 
-  if (
-    to.name === "usuarios" ||
-    to.name === "avaliacoes" ||
-    to.name === "atendimentos-nao-resolvidos" ||
-    to.name === "atendimento" ||
-    to.name === "golden-set" ||
-    to.name === "documentos" ||
-    to.name === "documentos-carregar"
-  ) {
-    const { user } = useAuth();
-
-    if (String(user.value?.Nivel) !== "3") {
-      return { name: "configuracoes" };
-    }
+  if (to.meta?.nivelMinimo && !(nivel.value >= to.meta.nivelMinimo)) {
+    return { name: "configuracoes" };
   }
 
   return true;

+ 0 - 61
src/styles/tailwind.css

@@ -4,29 +4,6 @@
 
 @custom-variant dark (&:is(.dark *));
 
-@utility container {
-  max-width: 100%;
-  margin-left: auto;
-  margin-right: auto;
-  padding-left: 1rem;
-  padding-right: 1rem;
-
-  @media (min-width: 640px) {
-    max-width: 640px;
-    padding-left: 1.5rem;
-    padding-right: 1.5rem;
-  }
-  @media (min-width: 768px) {
-    max-width: 768px;
-  }
-  @media (min-width: 1024px) {
-    max-width: 1024px;
-  }
-  @media (min-width: 1440px) {
-    max-width: 1440px;
-  }
-}
-
 :root {
   --background: oklch(0.9911 0 0);
   --foreground: oklch(0.2046 0 0);
@@ -51,15 +28,6 @@
   --chart-2: oklch(0.6231 0.188 259.8145);
   --chart-3: oklch(0.6056 0.2189 292.7172);
   --chart-4: oklch(0.7686 0.1647 70.0804);
-  --chart-5: oklch(0.6959 0.1491 162.4796);
-  --sidebar: oklch(0.9911 0 0);
-  --sidebar-foreground: oklch(0.5452 0 0);
-  --sidebar-primary: oklch(0.8348 0.1302 160.908);
-  --sidebar-primary-foreground: oklch(0.2626 0.0147 166.4589);
-  --sidebar-accent: oklch(0.9461 0 0);
-  --sidebar-accent-foreground: oklch(0.2435 0 0);
-  --sidebar-border: oklch(0.9037 0 0);
-  --sidebar-ring: oklch(0.8348 0.1302 160.908);
   --font-sans: Inter, ui-sans-serif, sans-serif, system-ui;
   --font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
   --font-mono: JetBrains Mono, ui-monospace, monospace;
@@ -106,15 +74,6 @@
   --chart-2: oklch(0.7137 0.1434 254.624);
   --chart-3: oklch(0.709 0.1592 293.5412);
   --chart-4: oklch(0.8369 0.1644 84.4286);
-  --chart-5: oklch(0.7845 0.1325 181.912);
-  --sidebar: oklch(0.1822 0 0);
-  --sidebar-foreground: oklch(0.6301 0 0);
-  --sidebar-primary: oklch(0.4365 0.1044 156.7556);
-  --sidebar-primary-foreground: oklch(0.9213 0.0135 167.1556);
-  --sidebar-accent: oklch(0.3132 0 0);
-  --sidebar-accent-foreground: oklch(0.9851 0 0);
-  --sidebar-border: oklch(0.2809 0 0);
-  --sidebar-ring: oklch(0.8003 0.1821 151.711);
   --font-sans: Inter, ui-sans-serif, sans-serif, system-ui;
   --font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
   --font-mono: JetBrains Mono, ui-monospace, monospace;
@@ -136,17 +95,7 @@
 }
 
 body {
-  --primary-50: color-mix(in oklab, var(--primary) 12%, white);
-  --primary-100: color-mix(in oklab, var(--primary) 20%, white);
-  --primary-200: color-mix(in oklab, var(--primary) 32%, white);
-  --primary-300: color-mix(in oklab, var(--primary) 44%, white);
-  --primary-400: color-mix(in oklab, var(--primary) 60%, white);
-  --primary-500: var(--primary);
   --primary-600: color-mix(in oklab, var(--primary) 84%, black);
-  --primary-700: color-mix(in oklab, var(--primary) 70%, black);
-  --primary-800: color-mix(in oklab, var(--primary) 56%, black);
-  --primary-900: color-mix(in oklab, var(--primary) 44%, black);
-  --primary-950: color-mix(in oklab, var(--primary) 30%, black);
 }
 
 @theme inline {
@@ -173,15 +122,6 @@ body {
   --color-chart-2: var(--chart-2);
   --color-chart-3: var(--chart-3);
   --color-chart-4: var(--chart-4);
-  --color-chart-5: var(--chart-5);
-  --color-sidebar: var(--sidebar);
-  --color-sidebar-foreground: var(--sidebar-foreground);
-  --color-sidebar-primary: var(--sidebar-primary);
-  --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
-  --color-sidebar-accent: var(--sidebar-accent);
-  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
-  --color-sidebar-border: var(--sidebar-border);
-  --color-sidebar-ring: var(--sidebar-ring);
 
   --font-sans: var(--font-sans);
   --font-mono: var(--font-mono);
@@ -212,7 +152,6 @@ body {
   --color-abyss-950: #070b16;
   --color-abyss-900: #101827;
   --color-abyss-800: #131f30;
-  --color-abyss-700: #182335;
 
   --breakpoint-whatsapp: 760px;
   --breakpoint-layout: 980px;

+ 7 - 3
src/utils/avaliacoes.js

@@ -14,11 +14,15 @@ export const sentimentoLabel = { positivo: "Positivo", neutro: "Neutro", negativ
 export const cancelamentoVariant = { cancelou: "danger", ameacou: "warning" };
 export const cancelamentoLabel = { cancelou: "Cancelou", ameacou: "Quer cancelar" };
 
-// enum completo de StatusCancelamento (cancelamentoLabel só cobre os 2 valores "notáveis"
-// pra badge — nao/indefinido ficam de fora de propósito pra não gerar badge de ruído nas
-// listas). Usado só onde o formulário precisa das 4 opções, ex. rotulagem do golden set.
+
 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";

+ 96 - 0
src/utils/data.js

@@ -0,0 +1,96 @@
+// Formatadores criados uma única vez: instanciar um Intl.DateTimeFormat custa ~10-40µs e
+// estas funções são chamadas dentro de v-for que repinta a cada poll.
+const FMT_DATA = new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric" });
+const FMT_HORA = new Intl.DateTimeFormat("pt-BR", { hour: "2-digit", minute: "2-digit" });
+const FMT_DIA_MES = new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "2-digit" });
+const FMT_DATA_HORA = new Intl.DateTimeFormat("pt-BR", {
+  day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit"
+});
+// ano com 2 dígitos: usado na lista de documentos, onde o espaço é apertado
+const FMT_DATA_HORA_ANO_CURTO = new Intl.DateTimeFormat("pt-BR", {
+  day: "2-digit", month: "2-digit", year: "2-digit", hour: "2-digit", minute: "2-digit"
+});
+// sem ano: usado nas solicitações de reset de senha, todas recentes
+const FMT_DATA_HORA_SEM_ANO = new Intl.DateTimeFormat("pt-BR", {
+  day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit"
+});
+
+const UM_DIA_MS = 86400000;
+
+function paraData(valor) {
+  if (!valor) return null;
+  const d = new Date(valor);
+  return Number.isNaN(d.getTime()) ? null : d;
+}
+
+function formatarCom(fmt, valor) {
+  const d = paraData(valor);
+  return d ? fmt.format(d) : "";
+}
+
+export function formatarData(valor) {
+  return formatarCom(FMT_DATA, valor);
+}
+
+export function formatarHora(valor) {
+  return formatarCom(FMT_HORA, valor);
+}
+
+export function formatarDataHora(valor) {
+  return formatarCom(FMT_DATA_HORA, valor);
+}
+
+export function formatarDataHoraAnoCurto(valor) {
+  return formatarCom(FMT_DATA_HORA_ANO_CURTO, valor);
+}
+
+export function formatarDataHoraSemAno(valor) {
+  return formatarCom(FMT_DATA_HORA_SEM_ANO, valor);
+}
+
+export function ehNovoDia(atual, anterior) {
+  if (!atual) return false;
+  if (!anterior) return true;
+  return new Date(atual).toDateString() !== new Date(anterior).toDateString();
+}
+
+// rótulo curto de recência para listas de conversa: hora se for hoje, "Ontem", senão dd/mm
+export function formatarRecencia(valor) {
+  const d = paraData(valor);
+  if (!d) return "";
+  const hoje = new Date().setHours(0, 0, 0, 0);
+  const dia = new Date(d).setHours(0, 0, 0, 0);
+  if (dia === hoje) return FMT_HORA.format(d);
+  if (dia === hoje - UM_DIA_MS) return "Ontem";
+  return FMT_DIA_MES.format(d);
+}
+
+/**
+ * Agrupa uma lista em faixas de recência ("Hoje", "Ontem", "Últimos 7 dias", "Mais antigas"),
+ * descartando as faixas vazias. Usado pela lista de conversas do chat e pela do WhatsApp.
+ *
+ * @param {Array} lista
+ * @param {(item: any) => number|string|Date|null} obterTimestamp
+ */
+export function agruparPorRecencia(lista, obterTimestamp) {
+  const hoje = new Date().setHours(0, 0, 0, 0);
+  const ontem = hoje - UM_DIA_MS;
+  const semana = hoje - 6 * UM_DIA_MS;
+  const grupos = [
+    { label: "Hoje", items: [] },
+    { label: "Ontem", items: [] },
+    { label: "Últimos 7 dias", items: [] },
+    { label: "Mais antigas", items: [] }
+  ];
+
+  for (const item of lista ?? []) {
+    const bruto = obterTimestamp(item);
+    const t = bruto ? new Date(bruto).getTime() : 0;
+    if (t >= hoje) grupos[0].items.push(item);
+    else if (t >= ontem) grupos[1].items.push(item);
+    else if (t >= semana) grupos[2].items.push(item);
+    else grupos[3].items.push(item);
+  }
+
+  return grupos.filter((g) => g.items.length > 0);
+}

+ 17 - 0
src/utils/download.js

@@ -0,0 +1,17 @@
+/**
+ * Entrega um Blob ao navegador como download. O <a> precisa estar no documento para o
+ * clique valer no Firefox, e o object URL só pode ser revogado depois do clique.
+ *
+ * @param {Blob} blob
+ * @param {string} nomeArquivo
+ */
+export function baixarBlob(blob, nomeArquivo) {
+  const url = URL.createObjectURL(blob);
+  const a = document.createElement("a");
+  a.href = url;
+  a.download = nomeArquivo;
+  document.body.appendChild(a);
+  a.click();
+  a.remove();
+  URL.revokeObjectURL(url);
+}

+ 1 - 1
src/utils/midia.js

@@ -2,7 +2,7 @@ const TIPOS_AUDIO = new Set(["mpga", "oga", "mp3", "audio", "ptt"]);
 const TIPOS_IMAGEM = new Set(["jpg", "jpeg", "png", "webp", "gif", "image", "sticker", "imagem"]);
 const TIPOS_VIDEO = new Set(["mp4", "webm", "mov", "3gp", "video", "m4v"]);
 
-export function ehAudio({ tipo, transcricao, url } = {}) {
+export function ehAudio({ tipo, transcricao } = {}) {
   return Boolean(transcricao) || TIPOS_AUDIO.has(tipo);
 }
 

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

@@ -23,12 +23,12 @@ import {
   statusCancelamentoLabel
 } from "../../utils/avaliacoes.js";
 import { ehAudio as ehAudioMidia, ehImagem as ehImagemMidia, ehVideo as ehVideoMidia } from "../../utils/midia.js";
+import { ehNovoDia, formatarData, formatarHora } from "../../utils/data.js";
 import { useToast } from "../../composables/useToast.js";
 
 const HORARIO_VISITA_INFORMADO_LABEL = { sim: "Sim", nao: "Não", nao_se_aplica: "Não se aplica" };
 const toast = useToast();
-const { user } = useAuth();
-const isAdmin = computed(() => String(user.value?.Nivel) === "3");
+const { isAdmin } = useAuth();
 
 const route = useRoute();
 const router = useRouter();
@@ -167,27 +167,8 @@ function caminhoMidia(m) {
   return null;
 }
 
-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 formatHora(value) {
-  if (!value) return "";
-  const d = new Date(value);
-  if (Number.isNaN(d.getTime())) return "";
-  return d.toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" });
-}
-
 function novoDia(idx) {
-  const atual = mensagens.value[idx]?.Timestamp;
-  if (!atual) return false;
-  if (idx === 0) return true;
-  const anterior = mensagens.value[idx - 1]?.Timestamp;
-  if (!anterior) return true;
-  return new Date(atual).toDateString() !== new Date(anterior).toDateString();
+  return ehNovoDia(mensagens.value[idx]?.Timestamp, idx === 0 ? null : mensagens.value[idx - 1]?.Timestamp);
 }
 
 async function carregar(id) {
@@ -270,7 +251,7 @@ watch(() => route.params.id, carregar, { immediate: true });
                   </span>
                 </h2>
                 <p v-if="atendimento" class="mt-0.5 text-sm text-muted-foreground">
-                  <span v-if="atendimento.Setor">{{ atendimento.Setor }} · </span>{{ formatData(atendimento.Abertura) }}
+                  <span v-if="atendimento.Setor">{{ atendimento.Setor }} · </span>{{ formatarData(atendimento.Abertura) }}
                   · {{ mensagens.length }} mensagens
                 </p>
               </div>
@@ -341,13 +322,13 @@ watch(() => route.params.id, carregar, { immediate: true });
             <template v-for="(m, i) in mensagens" :key="m.Id">
               <div v-if="novoDia(i)" class="flex justify-center pt-2 first:pt-0">
                 <span class="rounded-full bg-gray-200/70 px-3 py-0.5 text-[11px] font-medium text-gray-500 dark:bg-gray-700/70 dark:text-gray-400">
-                  {{ formatData(m.Timestamp) }}
+                  {{ formatarData(m.Timestamp) }}
                 </span>
               </div>
 
               <div v-if="m.Papel === 'sistema'" class="flex justify-center">
                 <div class="max-w-[85%] px-3 py-1 text-center text-xs italic text-muted-foreground">
-                  {{ m.Texto }}<span v-if="formatHora(m.Timestamp)"> · {{ formatHora(m.Timestamp) }}</span>
+                  {{ m.Texto }}<span v-if="formatarHora(m.Timestamp)"> · {{ formatarHora(m.Timestamp) }}</span>
                 </div>
               </div>
 
@@ -409,7 +390,7 @@ watch(() => route.params.id, carregar, { immediate: true });
                   >
                     Abrir mídia
                   </a>
-                  <div class="mt-0.5 text-right text-[10px] text-muted-foreground">{{ formatHora(m.Timestamp) }}</div>
+                  <div class="mt-0.5 text-right text-[10px] text-muted-foreground">{{ formatarHora(m.Timestamp) }}</div>
               </ChatBubble>
             </template>
           </div>

+ 95 - 279
src/views/atendimentos/AvaliacoesView.vue

@@ -1,34 +1,22 @@
 <script setup>
-import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
-import { useRoute, useRouter } from "vue-router";
+import { computed, onBeforeUnmount, onMounted, ref } from "vue";
+import { useRoute } 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 CampoBusca from "../../components/base/CampoBusca.vue";
+import PaginacaoLista from "../../components/base/PaginacaoLista.vue";
 import StatCard from "../../components/base/StatCard.vue";
-import ListItemCard from "../../components/base/ListItemCard.vue";
 import EmptyState from "../../components/base/EmptyState.vue";
 import Spinner from "../../components/base/Spinner.vue";
-import { ClipboardList, Star, CircleCheck, Check, Smile, TriangleAlert, Search, X, ChevronUp, ChevronDown, ChevronRight } from "lucide-vue-next";
+import ItemAvaliacao from "../../components/atendimentos/ItemAvaliacao.vue";
+import { ClipboardList, Star, CircleCheck, Check, Smile, TriangleAlert, ChevronUp, ChevronDown } from "lucide-vue-next";
 import BaseIcon from "../../components/base/BaseIcon.vue";
-import {
-  avaliarAtendimentosPendentes,
-  estatisticasAvaliacoes,
-  listarAvaliacoes,
-  listarSetoresAvaliacoes
-} from "../../api/atendimentos.js";
+import { avaliarAtendimentosPendentes, estatisticasAvaliacoes } from "../../api/atendimentos.js";
+import { queryTexto, useListaAvaliacoes } from "../../composables/useListaAvaliacoes.js";
+import { useAsyncState } from "../../composables/useAsyncState.js";
 import { useToast } from "../../composables/useToast.js";
-import {
-  cancelamentoLabel,
-  cancelamentoVariant,
-  CONTEXTO_IXC_ERRO_LABEL,
-  contextoIxcFalhou,
-  resolvidoLabel,
-  resolvidoVariant,
-  scoreVariant,
-  sentimentoLabel,
-  sentimentoVariant
-} from "../../utils/avaliacoes.js";
+import { cancelamentoLabel, pct, resolvidoLabel, sentimentoLabel } from "../../utils/avaliacoes.js";
 
 const opcoesResolucao = Object.entries({ ...resolvidoLabel, ...cancelamentoLabel }).map(([value, label]) => ({
   value,
@@ -37,50 +25,18 @@ const opcoesResolucao = Object.entries({ ...resolvidoLabel, ...cancelamentoLabel
 const opcoesSentimento = Object.entries(sentimentoLabel).map(([value, label]) => ({ value, label }));
 
 const route = useRoute();
-const router = useRouter();
 const toast = useToast();
 
-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 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 filtroResolvido = ref(typeof route.query.resolvido === "string" ? route.query.resolvido : "");
-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");
-
-function sincronizarQuery() {
-  const query = {};
-  if (page.value > 1) query.page = String(page.value);
-  if (filtroSetor.value) query.setor = filtroSetor.value;
-  if (filtroResolvido.value) query.resolvido = filtroResolvido.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 (expandido.value) query.aberto = String(expandido.value);
-  router.replace({ query });
-}
-
-const totalPaginas = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
-
-const progresso = computed(() => {
-  const totalAt = stats.value?.totalAtendimentos ?? 0;
-  if (!totalAt) return null;
-  const feitos = Math.max(0, totalAt - (stats.value?.pendentes ?? 0));
-  return { total: totalAt, feitos, pct: Math.round((feitos / totalAt) * 100) };
-});
 
+const filtroSetor = ref(queryTexto(route, "setor"));
+const filtroResolvido = ref(queryTexto(route, "resolvido"));
+const filtroSentimento = ref(queryTexto(route, "sentimento"));
 
+// o dropdown de "Resolução" mistura dois campos do backend: os status de resolução vão em
+// `resolvido`, e "cancelou"/"ameacou" são na verdade StatusCancelamento
 const resolvidoParaApi = computed(() =>
   ["sim", "parcial", "nao", "indefinido"].includes(filtroResolvido.value) ? filtroResolvido.value : undefined
 );
@@ -88,109 +44,77 @@ const statusCancelamentoParaApi = computed(() =>
   filtroResolvido.value === "cancelou" || filtroResolvido.value === "ameacou" ? filtroResolvido.value : undefined
 );
 
-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 filtrosProprios() {
+  return {
+    setor: filtroSetor.value || undefined,
+    resolvido: resolvidoParaApi.value,
+    sentimento: filtroSentimento.value || undefined,
+    statusCancelamento: statusCancelamentoParaApi.value
+  };
 }
 
-function pct(parte, todo) {
-  if (!todo) return "0%";
-  return `${Math.round((parte / todo) * 100)}%`;
-}
+const {
+  busca: filtroBusca,
+  ordenacao: filtroOrdenacao,
+  page,
+  expandido,
+  setores,
+  avaliacoes,
+  total,
+  totalPaginas,
+  carregando,
+  erro,
+  carregarLista,
+  aplicarFiltros,
+  alternarOrdenacao,
+  onBuscaInput,
+  onBuscaEnter,
+  irParaPagina,
+  alternarExpandido,
+  verConversa,
+  inicializar
+} = useListaAvaliacoes({
+  montarQueryExtra: () => {
+    const query = {};
+    if (filtroSetor.value) query.setor = filtroSetor.value;
+    if (filtroResolvido.value) query.resolvido = filtroResolvido.value;
+    if (filtroSentimento.value) query.sentimento = filtroSentimento.value;
+    return query;
+  },
+  montarFiltrosApi: filtrosProprios,
+  aoAplicarFiltros: carregarStats,
+  prefixoElemento: "avaliacao",
+  mensagemErroLista: "Falha ao carregar avaliações"
+});
 
-let statsRequestId = 0;
+const progresso = computed(() => {
+  const totalAt = stats.value?.totalAtendimentos ?? 0;
+  if (!totalAt) return null;
+  const feitos = Math.max(0, totalAt - (stats.value?.pendentes ?? 0));
+  return { total: totalAt, feitos, pct: Math.round((feitos / totalAt) * 100) };
+});
+
+// useAsyncState já descarta respostas antigas; aqui ele só serve de guarda anti-race
+const guardaStats = useAsyncState();
 
 async function carregarStats() {
-  const requestId = ++statsRequestId;
+  const epoca = guardaStats.nextId();
   try {
     const resultado = await estatisticasAvaliacoes({
-      setor: filtroSetor.value || undefined,
-      resolvido: resolvidoParaApi.value,
-      sentimento: filtroSentimento.value || undefined,
-      statusCancelamento: statusCancelamentoParaApi.value,
+      ...filtrosProprios(),
       busca: filtroBusca.value.trim() || undefined
     });
-    if (requestId !== statsRequestId) return;
+    if (guardaStats.isStale(epoca)) return;
     stats.value = resultado;
-  } catch {
-    if (requestId !== statsRequestId) return;
-    stats.value = null;
-  }
-}
-
-async function carregarSetores() {
-  try {
-    const r = await listarSetoresAvaliacoes();
-    setores.value = r.results ?? [];
-  } catch (e) {
-    setores.value = [];
-    toast.erro(e?.message ?? "Falha ao carregar setores");
-  }
-}
-
-let listaRequestId = 0;
-
-async function carregarLista() {
-  const requestId = ++listaRequestId;
-  carregando.value = true;
-  erro.value = "";
-  try {
-    const r = await listarAvaliacoes({
-      page: page.value,
-      pageSize,
-      setor: filtroSetor.value || undefined,
-      resolvido: resolvidoParaApi.value,
-      sentimento: filtroSentimento.value || undefined,
-      statusCancelamento: statusCancelamentoParaApi.value,
-      busca: filtroBusca.value.trim() || undefined,
-      ordenacao: filtroOrdenacao.value
-    });
-    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 avaliações";
-  } finally {
-    if (requestId === listaRequestId) carregando.value = false;
+    if (guardaStats.isStale(epoca)) return;
+    stats.value = null;
+    // sem isto os cartões somem da tela sem nenhuma explicação
+    toast.erro("Não foi possível carregar as estatísticas das avaliações.");
+    console.warn("[AvaliacoesView] carregarStats:", err);
   }
 }
 
-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();
-}
-
 async function avaliarPendentes() {
   if (avaliandoLote.value) return;
   avaliandoLote.value = true;
@@ -206,37 +130,21 @@ async function avaliarPendentes() {
   }
 }
 
-function alternarExpandido(id) {
-  expandido.value = expandido.value === id ? null : id;
-  sincronizarQuery();
-}
-
-function verConversa(id) {
-  router.push({ name: "atendimento", params: { id } });
-}
-
 // enquanto houver pendentes, atualiza as estatísticas sozinho para a barra andar
 let statsTimer = null;
 
-onMounted(async () => {
+onMounted(() => {
   carregarStats();
-  carregarSetores();
-  await carregarLista();
-
-  // voltando da conversa completa, reposiciona na avaliação que estava aberta
-  if (expandido.value) {
-    await nextTick();
-    document.getElementById(`avaliacao-${expandido.value}`)?.scrollIntoView({ block: "center" });
-  }
+  inicializar();
 
   statsTimer = setInterval(() => {
+    if (document.visibilityState !== "visible") return;
     if (stats.value?.pendentes > 0 && !avaliandoLote.value) carregarStats();
   }, 30_000);
 });
 
 onBeforeUnmount(() => {
   if (statsTimer) clearInterval(statsTimer);
-  clearTimeout(buscaTimer);
 });
 </script>
 
@@ -335,29 +243,13 @@ onBeforeUnmount(() => {
 
         <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]">
-              <BaseIcon
-                :icon="Search"
-                class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
-              />
-              <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-muted-foreground dark:hover:text-gray-300"
-                title="Limpar busca"
-                @click="filtroBusca = ''; onBuscaEnter();"
-              >
-                <BaseIcon :icon="X" class="h-4 w-4" />
-              </button>
-            </div>
+            <CampoBusca
+              v-model="filtroBusca"
+              placeholder="Protocolo, cliente, atendente ou data (dd/mm/aaaa)"
+              @input="onBuscaInput"
+              @enter="onBuscaEnter"
+              @limpar="onBuscaEnter"
+            />
             <div class="flex shrink-0 items-center gap-2">
               <BaseDropdown
                 v-model="filtroSetor"
@@ -405,99 +297,23 @@ onBeforeUnmount(() => {
           />
 
           <div v-else class="space-y-3 p-4">
-            <ListItemCard
+            <ItemAvaliacao
               v-for="a in avaliacoes"
-              :id="`avaliacao-${a.AtendimentoId}`"
               :key="a.AtendimentoId"
-              :expanded="expandido === a.AtendimentoId"
-              @toggle="alternarExpandido(a.AtendimentoId)"
-            >
-              <template #header>
-                <div class="min-w-0 flex-1">
-                  <div class="truncate text-sm font-semibold text-foreground">
-                    {{ a.Codigo ?? `#${a.AtendimentoId}` }}
-                    <span class="font-normal text-muted-foreground"> — {{ a.ClienteNome ?? "cliente desconhecido" }}</span>
-                  </div>
-                  <div class="mt-0.5 text-xs text-muted-foreground">
-                    <span v-if="a.Setor">{{ a.Setor }} · </span>{{ formatData(a.Abertura) }}
-                    <span v-if="a.Atendentes?.length"> · {{ a.Atendentes.join(", ") }}</span>
-                  </div>
-                </div>
-                <div class="flex flex-wrap items-center justify-end gap-1.5">
-                  <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>
-                </div>
-              </template>
-
-              <div v-if="a.Resumo">
-                <div class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Resumo</div>
-                <p class="mt-1 text-foreground">{{ a.Resumo }}</p>
-              </div>
-              <div v-if="a.JustificativaScore">
-                <div class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Justificativa da nota</div>
-                <p class="mt-1 text-foreground">{{ a.JustificativaScore }}</p>
-              </div>
-              <div v-if="a.MotivoCancelamento">
-                <div class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Motivo do cancelamento</div>
-                <p class="mt-1 text-foreground">{{ a.MotivoCancelamento }}</p>
-              </div>
-              <div v-if="a.Sugestoes?.length">
-                <div class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Itens a melhorar</div>
-                <ul class="mt-1 list-disc space-y-0.5 pl-5 text-foreground">
-                  <li v-for="(s, i) in a.Sugestoes" :key="i">{{ s }}</li>
-                </ul>
-              </div>
-              <div v-if="a.VisitaAgendada" class="text-foreground">
-                <span class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">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="flex flex-wrap items-center justify-between gap-3">
-                <BaseButton variant="secondary" size="sm" @click="verConversa(a.AtendimentoId)">
-                  Ver conversa completa
-                  <BaseIcon :icon="ChevronRight" class="h-3.5 w-3.5" />
-                </BaseButton>
-                <div class="text-xs text-muted-foreground">
-                  Avaliado por {{ a.Modelo }} em {{ formatData(a.UpdatedAt) }} · {{ a.TotalMensagens }} mensagens
-                </div>
-              </div>
-            </ListItemCard>
+              :avaliacao="a"
+              prefixo-id="avaliacao"
+              :expandida="expandido === a.AtendimentoId"
+              @toggle="alternarExpandido"
+              @ver-conversa="verConversa"
+            />
           </div>
 
-          <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-muted-foreground">Página {{ page }} de {{ totalPaginas }}</span>
-            <BaseButton
-              variant="secondary"
-              size="sm"
-              :disabled="page >= totalPaginas || carregando"
-              @click="irParaPagina(page + 1)"
-            >
-              Próxima
-            </BaseButton>
-          </div>
+          <PaginacaoLista
+            :page="page"
+            :total-paginas="totalPaginas"
+            :desabilitado="carregando"
+            @ir="irParaPagina"
+          />
         </section>
       </div>
     </div>

+ 2 - 8
src/views/atendimentos/GoldenSetView.vue

@@ -11,6 +11,7 @@ import Spinner from "../../components/base/Spinner.vue";
 import { FolderSearch, CircleCheck, CircleAlert, Check } from "lucide-vue-next";
 import BaseIcon from "../../components/base/BaseIcon.vue";
 import { listarGoldenSet } from "../../api/atendimentos.js";
+import { formatarData } from "../../utils/data.js";
 
 const router = useRouter();
 
@@ -33,13 +34,6 @@ const totalPendentes = computed(() => itens.value.filter((i) => i.Status === "pe
 const totalRotulados = computed(() => itens.value.filter((i) => i.Status === "rotulado").length);
 const totalAceitos = computed(() => itens.value.filter((i) => i.Status === "aceito").length);
 
-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" });
-}
-
 async function carregar() {
   carregando.value = true;
   erro.value = "";
@@ -139,7 +133,7 @@ onMounted(carregar);
                     <span class="font-normal text-muted-foreground"> — {{ item.ClienteNome ?? "cliente desconhecido" }}</span>
                   </div>
                   <div class="mt-0.5 text-xs text-muted-foreground">
-                    <span v-if="item.Setor">{{ item.Setor }} · </span>{{ formatData(item.Abertura) }}
+                    <span v-if="item.Setor">{{ item.Setor }} · </span>{{ formatarData(item.Abertura) }}
                   </div>
                 </div>
                 <BaseBadge :variant="item.Status === 'rotulado' ? 'success' : item.Status === 'aceito' ? 'info' : 'warning'">

+ 90 - 274
src/views/atendimentos/NaoResolvidosView.vue

@@ -1,34 +1,26 @@
 <script setup>
-import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
-import { useRoute, useRouter } from "vue-router";
+import { computed, onMounted, ref } from "vue";
+import { useRoute } 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 CampoBusca from "../../components/base/CampoBusca.vue";
+import PaginacaoLista from "../../components/base/PaginacaoLista.vue";
 import StatCard from "../../components/base/StatCard.vue";
-import ListItemCard from "../../components/base/ListItemCard.vue";
 import EmptyState from "../../components/base/EmptyState.vue";
 import Spinner from "../../components/base/Spinner.vue";
-import { CircleCheck, TriangleAlert, Star, BarChart3, Search, X, ChevronUp, ChevronDown, Download, ChevronRight } from "lucide-vue-next";
+import ItemAvaliacao from "../../components/atendimentos/ItemAvaliacao.vue";
+import { CircleCheck, TriangleAlert, Star, BarChart3, ChevronUp, ChevronDown, Download } from "lucide-vue-next";
 import BaseIcon from "../../components/base/BaseIcon.vue";
-import { baixarRelatorioNaoResolvidos, estatisticasAvaliacoes, listarAvaliacoes, listarSetoresAvaliacoes } from "../../api/atendimentos.js";
+import { baixarRelatorioNaoResolvidos, estatisticasAvaliacoes } from "../../api/atendimentos.js";
 import { useToast } from "../../composables/useToast.js";
-import {
-  cancelamentoLabel,
-  cancelamentoVariant,
-  CONTEXTO_IXC_ERRO_LABEL,
-  contextoIxcFalhou,
-  resolvidoLabel,
-  resolvidoVariant,
-  scoreVariant,
-  sentimentoLabel,
-  sentimentoVariant
-} from "../../utils/avaliacoes.js";
+import { queryTexto, useListaAvaliacoes } from "../../composables/useListaAvaliacoes.js";
+import { useAsyncState } from "../../composables/useAsyncState.js";
+import { pct, sentimentoLabel } from "../../utils/avaliacoes.js";
 
 const opcoesSentimento = Object.entries(sentimentoLabel).map(([value, label]) => ({ value, label }));
 
 const route = useRoute();
-const router = useRouter();
 const toast = useToast();
 
 function formatISO(date) {
@@ -41,52 +33,59 @@ function padraoDataInicio() {
   return formatISO(d);
 }
 
-const carregando = ref(false);
-const erro = ref("");
 const gerandoRelatorio = ref(false);
 
 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 filtroSetor = ref(queryTexto(route, "setor"));
+const filtroSentimento = ref(queryTexto(route, "sentimento"));
+const filtroDataInicio = ref(queryTexto(route, "dataInicio", padraoDataInicio()));
+const filtroDataFim = ref(queryTexto(route, "dataFim", formatISO(new Date())));
+
+// filtros próprios desta tela, comuns à listagem, aos stats e ao relatório
+function filtrosProprios() {
+  return {
+    setor: filtroSetor.value || undefined,
+    sentimento: filtroSentimento.value || undefined,
+    dataInicio: filtroDataInicio.value || undefined,
+    dataFim: filtroDataFim.value || undefined
+  };
 }
 
-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 {
+  busca: filtroBusca,
+  ordenacao: filtroOrdenacao,
+  page,
+  expandido,
+  setores,
+  avaliacoes,
+  total,
+  totalPaginas,
+  carregando,
+  erro,
+  aplicarFiltros,
+  alternarOrdenacao,
+  onBuscaInput,
+  onBuscaEnter,
+  irParaPagina,
+  alternarExpandido,
+  verConversa,
+  inicializar
+} = useListaAvaliacoes({
+  montarQueryExtra: () => {
+    const query = {};
+    if (filtroSetor.value) query.setor = filtroSetor.value;
+    if (filtroSentimento.value) query.sentimento = filtroSentimento.value;
+    if (filtroDataInicio.value) query.dataInicio = filtroDataInicio.value;
+    if (filtroDataFim.value) query.dataFim = filtroDataFim.value;
+    return query;
+  },
+  montarFiltrosApi: () => ({ resolvido: "nao", ...filtrosProprios() }),
+  aoAplicarFiltros: carregarStats,
+  prefixoElemento: "nao-resolvido",
+  mensagemErroLista: "Falha ao carregar atendimentos não resolvidos"
+});
 
 const setoresOrdenados = computed(() => {
   const mapa = stats.value?.porSetor ?? {};
@@ -95,18 +94,13 @@ const setoresOrdenados = computed(() => {
     .slice(0, 6);
 });
 
-let statsRequestId = 0;
+// useAsyncState já descarta respostas antigas; aqui ele só serve de guarda anti-race
+const guardaStats = useAsyncState();
 
 async function carregarStats() {
-  const requestId = ++statsRequestId;
+  const epoca = guardaStats.nextId();
   try {
-    const filtrosComuns = {
-      setor: filtroSetor.value || undefined,
-      sentimento: filtroSentimento.value || undefined,
-      busca: filtroBusca.value.trim() || undefined,
-      dataInicio: filtroDataInicio.value || undefined,
-      dataFim: filtroDataFim.value || undefined
-    };
+    const filtrosComuns = { ...filtrosProprios(), busca: filtroBusca.value.trim() || 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)
@@ -114,108 +108,27 @@ async function carregarStats() {
       estatisticasAvaliacoes(filtrosComuns),
       estatisticasAvaliacoes({ ...filtrosComuns, resolvido: "nao" })
     ]);
-    if (requestId !== statsRequestId) return;
+    if (guardaStats.isStale(epoca)) return;
     statsGeral.value = geral;
     stats.value = naoResolvidos;
-  } catch {
-    if (requestId !== statsRequestId) return;
+  } catch (err) {
+    if (guardaStats.isStale(epoca)) return;
     statsGeral.value = null;
     stats.value = null;
+    // sem isto os cartões somem da tela sem nenhuma explicação
+    toast.erro("Não foi possível carregar as estatísticas dos não resolvidos.");
+    console.warn("[NaoResolvidosView] carregarStats:", err);
   }
 }
 
-async function carregarSetores() {
-  try {
-    const r = await listarSetoresAvaliacoes();
-    setores.value = r.results ?? [];
-  } catch (e) {
-    setores.value = [];
-    toast.erro(e?.message ?? "Falha ao carregar setores");
-  }
-}
-
-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 } });
-}
-
 async function baixarRelatorio() {
   if (gerandoRelatorio.value) return;
   gerandoRelatorio.value = true;
   try {
     await baixarRelatorioNaoResolvidos({
-      setor: filtroSetor.value || undefined,
-      sentimento: filtroSentimento.value || undefined,
+      ...filtrosProprios(),
       busca: filtroBusca.value.trim() || undefined,
-      ordenacao: filtroOrdenacao.value,
-      dataInicio: filtroDataInicio.value || undefined,
-      dataFim: filtroDataFim.value || undefined
+      ordenacao: filtroOrdenacao.value
     });
   } catch (err) {
     toast.erro(err?.message ?? "Falha ao gerar o relatório");
@@ -224,19 +137,9 @@ async function baixarRelatorio() {
   }
 }
 
-onMounted(async () => {
+onMounted(() => {
   carregarStats();
-  carregarSetores();
-  await carregarLista();
-
-  if (expandido.value) {
-    await nextTick();
-    document.getElementById(`nao-resolvido-${expandido.value}`)?.scrollIntoView({ block: "center" });
-  }
-});
-
-onBeforeUnmount(() => {
-  clearTimeout(buscaTimer);
+  inicializar();
 });
 </script>
 
@@ -313,29 +216,13 @@ onBeforeUnmount(() => {
 
         <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]">
-              <BaseIcon
-                :icon="Search"
-                class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
-              />
-              <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-muted-foreground dark:hover:text-gray-300"
-                title="Limpar busca"
-                @click="filtroBusca = ''; onBuscaEnter();"
-              >
-                <BaseIcon :icon="X" class="h-4 w-4" />
-              </button>
-            </div>
+            <CampoBusca
+              v-model="filtroBusca"
+              placeholder="Protocolo, cliente, atendente ou data (dd/mm/aaaa)"
+              @input="onBuscaInput"
+              @enter="onBuscaEnter"
+              @limpar="onBuscaEnter"
+            />
             <div class="flex shrink-0 items-center gap-2">
               <BaseDropdown
                 v-model="filtroSetor"
@@ -387,94 +274,23 @@ onBeforeUnmount(() => {
           />
 
           <div v-else class="space-y-3 p-4">
-            <ListItemCard
+            <ItemAvaliacao
               v-for="a in avaliacoes"
-              :id="`nao-resolvido-${a.AtendimentoId}`"
               :key="a.AtendimentoId"
-              :expanded="expandido === a.AtendimentoId"
-              @toggle="alternarExpandido(a.AtendimentoId)"
-            >
-              <template #header>
-                <div class="min-w-0 flex-1">
-                  <div class="truncate text-sm font-semibold text-foreground">
-                    {{ a.Codigo ?? `#${a.AtendimentoId}` }}
-                    <span class="font-normal text-muted-foreground"> — {{ a.ClienteNome ?? "cliente desconhecido" }}</span>
-                  </div>
-                  <div class="mt-0.5 text-xs text-muted-foreground">
-                    <span v-if="a.Setor">{{ a.Setor }} · </span>{{ formatData(a.Abertura) }}
-                    <span v-if="a.Atendentes?.length"> · {{ a.Atendentes.join(", ") }}</span>
-                  </div>
-                </div>
-                <div class="flex flex-wrap items-center justify-end gap-1.5">
-                  <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>
-                </div>
-              </template>
-
-              <div v-if="a.Resumo">
-                <div class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Resumo</div>
-                <p class="mt-1 text-foreground">{{ a.Resumo }}</p>
-              </div>
-              <div v-if="a.JustificativaScore">
-                <div class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Justificativa da nota</div>
-                <p class="mt-1 text-foreground">{{ a.JustificativaScore }}</p>
-              </div>
-              <div v-if="a.MotivoCancelamento">
-                <div class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Motivo do cancelamento</div>
-                <p class="mt-1 text-foreground">{{ a.MotivoCancelamento }}</p>
-              </div>
-              <div v-if="a.Sugestoes?.length">
-                <div class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Itens a melhorar</div>
-                <ul class="mt-1 list-disc space-y-0.5 pl-5 text-foreground">
-                  <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
-                  <BaseIcon :icon="ChevronRight" class="h-3.5 w-3.5" />
-                </BaseButton>
-                <div class="text-xs text-muted-foreground">
-                  Avaliado por {{ a.Modelo }} em {{ formatData(a.UpdatedAt) }} · {{ a.TotalMensagens }} mensagens
-                </div>
-              </div>
-            </ListItemCard>
+              :avaliacao="a"
+              prefixo-id="nao-resolvido"
+              :expandida="expandido === a.AtendimentoId"
+              @toggle="alternarExpandido"
+              @ver-conversa="verConversa"
+            />
           </div>
 
-          <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-muted-foreground">Página {{ page }} de {{ totalPaginas }}</span>
-            <BaseButton
-              variant="secondary"
-              size="sm"
-              :disabled="page >= totalPaginas || carregando"
-              @click="irParaPagina(page + 1)"
-            >
-              Próxima
-            </BaseButton>
-          </div>
+          <PaginacaoLista
+            :page="page"
+            :total-paginas="totalPaginas"
+            :desabilitado="carregando"
+            @ir="irParaPagina"
+          />
         </section>
       </div>
     </div>

+ 1 - 2
src/views/configuracoes/ConfiguracoesView.vue

@@ -10,8 +10,7 @@ import { useAuth } from "../../composables/useAuth.js";
 import { useWhatsappConexao } from "../../composables/useWhatsappConexao.js";
 import { sincronizarAtendimentos, sincronizarTodosAtendimentos } from "../../api/atendimentos.js";
 
-const { user } = useAuth();
-const isAdmin = computed(() => String(user.value?.Nivel) === "3");
+const { isAdmin } = useAuth();
 
 const {
   status: whatsappStatus,

+ 2 - 13
src/views/conversas/ConversasHistoricoView.vue

@@ -6,6 +6,7 @@ import BaseIcon from "../../components/base/BaseIcon.vue";
 import ConfirmDialog from "../../components/base/ConfirmDialog.vue";
 import { History, Trash2 } from "lucide-vue-next";
 import { useChat } from "../../composables/useChat.js";
+import { formatarDataHora } from "../../utils/data.js";
 
 const router = useRouter();
 const { conversations, activeConversationId, setActiveConversation, deleteConversation } = useChat();
@@ -16,18 +17,6 @@ const items = computed(() =>
 
 const confirmandoExclusaoId = ref(null);
 
-function formatarData(ts) {
-  try {
-    return new Date(Number(ts ?? 0)).toLocaleString("pt-BR", {
-      day: "2-digit", month: "2-digit", year: "numeric",
-      hour: "2-digit", minute: "2-digit"
-    });
-  } catch (e) {
-    console.warn("[ConversasHistoricoView] formatarData falhou:", e);
-    return "";
-  }
-}
-
 function abrirConversa(id) {
   setActiveConversation(id);
   router.push({ name: "home" });
@@ -85,7 +74,7 @@ function confirmarExclusao(id) {
               <div class="truncate text-sm font-semibold text-foreground">{{ c.title || 'Nova conversa' }}</div>
             </div>
             <div class="mt-1 text-xs text-muted-foreground">
-              {{ formatarData(c.updatedAt) }} · {{ c.messageCount ?? 0 }} msgs
+              {{ formatarDataHora(c.updatedAt) }} · {{ c.messageCount ?? 0 }} msgs
             </div>
           </button>
 

+ 60 - 87
src/views/conversas/ConversasWhatsappView.vue

@@ -11,6 +11,7 @@ import { useConversas } from "../../composables/useConversas.js";
 import { useAuth } from "../../composables/useAuth.js";
 import { useToast } from "../../composables/useToast.js";
 import { ehAudio, ehImagem, ehVideo } from "../../utils/midia.js";
+import { agruparPorRecencia, ehNovoDia, formatarData, formatarHora, formatarRecencia } from "../../utils/data.js";
 import { avatarIniciais, avatarCor } from "../../utils/avatar.js";
 
 const toast = useToast();
@@ -77,12 +78,18 @@ function abrirMidia(m) {
   mediaViewer.value = { url, tipo: ehImagem(midiaInfo(m)) ? "imagem" : "video" };
 }
 
+// Durante o polling a lista só cresce por append, então varrer tudo a cada ciclo é
+// trabalho jogado fora; ao trocar de conversa ela é reiniciada e o índice volta a zero.
+let midiaResolvidaAte = 0;
+
 watch(
   mensagens,
   (lista) => {
-    for (const m of lista) {
-      if (m.MidiaPath) resolverMidia(m);
+    if (lista.length < midiaResolvidaAte) midiaResolvidaAte = 0;
+    for (let i = midiaResolvidaAte; i < lista.length; i += 1) {
+      if (lista[i].MidiaPath) resolverMidia(lista[i]);
     }
+    midiaResolvidaAte = lista.length;
   },
   { immediate: true }
 );
@@ -103,8 +110,7 @@ async function onExcluir(id) {
   }
 }
 
-const { user } = useAuth();
-const isAdmin = computed(() => String(user.value?.Nivel) === "3");
+const { isAdmin } = useAuth();
 
 const texto = ref("");
 const inputResposta = ref(null);
@@ -129,74 +135,41 @@ function usarSugestao() {
 
 const conversaAtiva = computed(() => conversas.value.find((c) => c.Id === conversaAtivaId.value) ?? null);
 
-function formatHora(value) {
-  if (!value) return "";
-  const d = new Date(value);
-  if (Number.isNaN(d.getTime())) return "";
-  return d.toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" });
-}
-
-function formatPrevia(conversa) {
-  return conversa.NomeContato || conversa.Telefone;
-}
-
-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 formatUltimaMensagem(value) {
-  if (!value) return "";
-  const d = new Date(value);
-  if (Number.isNaN(d.getTime())) return "";
-  const hoje = new Date().setHours(0, 0, 0, 0);
-  const ontem = hoje - 86400000;
-  const dia = new Date(value).setHours(0, 0, 0, 0);
-  if (dia === hoje) return formatHora(value);
-  if (dia === ontem) return "Ontem";
-  return d.toLocaleDateString("pt-BR", { day: "2-digit", month: "2-digit" });
-}
-
-const conversasAgrupadas = computed(() => {
-  const hoje = new Date().setHours(0, 0, 0, 0);
-  const ontem = hoje - 86400000;
-  const semana = hoje - 6 * 86400000;
-  const grupos = [
-    { label: "Hoje", items: [] },
-    { label: "Ontem", items: [] },
-    { label: "Últimos 7 dias", items: [] },
-    { label: "Mais antigas", items: [] }
-  ];
-  for (const c of conversas.value) {
-    const raw = c.UltimaMensagemEm ?? c.CreatedAt;
-    const t = raw ? new Date(raw).getTime() : 0;
-    if (t >= hoje) grupos[0].items.push(c);
-    else if (t >= ontem) grupos[1].items.push(c);
-    else if (t >= semana) grupos[2].items.push(c);
-    else grupos[3].items.push(c);
-  }
-  return grupos.filter((g) => g.items.length > 0);
-});
-
-function statusBadgeVariant(status) {
-  return status === "aberta" ? "success" : "default";
-}
-
 function statusBadgeTexto(status) {
   if (status === "aberta") return "Aberta";
   if (status === "fechada") return "Fechada";
   return status || "—";
 }
 
+function montarConversaView(c) {
+  const nome = c.NomeContato || c.Telefone;
+  return {
+    id: c.Id,
+    titulo: nome,
+    telefone: c.Telefone,
+    avatarCor: avatarCor(nome),
+    avatarIniciais: avatarIniciais(nome),
+    recencia: formatarRecencia(c.UltimaMensagemEm ?? c.CreatedAt),
+    statusVariant: c.Status === "aberta" ? "success" : "default",
+    statusTexto: statusBadgeTexto(c.Status)
+  };
+}
+
+// A lista repinta a cada ciclo de polling. Tudo o que dependia só da conversa é calculado
+// aqui, numa passada, em vez de virar chamada de função repetida no template.
+const conversasAgrupadas = computed(() =>
+  agruparPorRecencia(conversas.value, (c) => c.UltimaMensagemEm ?? c.CreatedAt).map((grupo) => ({
+    label: grupo.label,
+    items: grupo.items.map(montarConversaView)
+  }))
+);
+
+const conversaAtivaView = computed(() =>
+  conversaAtiva.value ? montarConversaView(conversaAtiva.value) : null
+);
+
 function novoDia(idx) {
-  const atual = mensagens.value[idx]?.Timestamp;
-  if (!atual) return false;
-  if (idx === 0) return true;
-  const anterior = mensagens.value[idx - 1]?.Timestamp;
-  if (!anterior) return true;
-  return new Date(atual).toDateString() !== new Date(anterior).toDateString();
+  return ehNovoDia(mensagens.value[idx]?.Timestamp, idx === 0 ? null : mensagens.value[idx - 1]?.Timestamp);
 }
 
 function mensagemErroEnvio(err) {
@@ -265,7 +238,7 @@ onUnmounted(() => {
           {{ conversasError }}
         </div>
         <div v-else-if="!conversas.length" class="flex flex-1 flex-col items-center justify-center gap-2 p-4 text-center">
-          <BaseIcon :icon="MessageCircle" class="h-8 w-8 text-gray-300 dark:text-gray-600" strokeWidth="1.5" />
+          <BaseIcon :icon="MessageCircle" class="h-8 w-8 text-gray-300 dark:text-gray-600" stroke-width="1.5" />
           <p class="text-sm text-muted-foreground">Nenhuma conversa ainda.</p>
           <p class="text-xs text-muted-foreground">Mande uma mensagem para o número de teste no WhatsApp.</p>
         </div>
@@ -278,32 +251,32 @@ onUnmounted(() => {
             <div class="space-y-1">
             <div
               v-for="c in grupo.items"
-              :key="c.Id"
+              :key="c.id"
               class="group relative rounded-xl transition-colors"
               :class="
-                c.Id === conversaAtivaId
+                c.id === conversaAtivaId
                   ? 'bg-primary/10 dark:bg-primary/20'
                   : 'hover:bg-black/[0.03] dark:hover:bg-white/[0.04]'
               "
             >
-              <button type="button" class="flex w-full items-center gap-3 p-2.5 pr-9 text-left" @click="abrirConversa(c.Id)">
+              <button type="button" class="flex w-full items-center gap-3 p-2.5 pr-9 text-left" @click="abrirConversa(c.id)">
                 <div
                   class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white ring-1 ring-white/20"
-                  :class="avatarCor(c.NomeContato || c.Telefone)"
+                  :class="c.avatarCor"
                 >
-                  {{ avatarIniciais(c.NomeContato || c.Telefone) }}
+                  {{ c.avatarIniciais }}
                 </div>
                 <div class="min-w-0 flex-1">
                   <div class="flex items-center justify-between gap-2">
-                    <span class="truncate text-sm font-semibold text-foreground">{{ formatPrevia(c) }}</span>
-                    <span v-if="formatUltimaMensagem(c.UltimaMensagemEm ?? c.CreatedAt)" class="shrink-0 text-[11px] text-muted-foreground">
-                      {{ formatUltimaMensagem(c.UltimaMensagemEm ?? c.CreatedAt) }}
+                    <span class="truncate text-sm font-semibold text-foreground">{{ c.titulo }}</span>
+                    <span v-if="c.recencia" class="shrink-0 text-[11px] text-muted-foreground">
+                      {{ c.recencia }}
                     </span>
                   </div>
                   <div class="mt-0.5 flex items-center justify-between gap-2">
-                    <span class="truncate text-xs text-muted-foreground">{{ c.Telefone }}</span>
-                    <BaseBadge :variant="statusBadgeVariant(c.Status)" class="shrink-0 !px-2 !py-0.5 text-[10px]">
-                      {{ statusBadgeTexto(c.Status) }}
+                    <span class="truncate text-xs text-muted-foreground">{{ c.telefone }}</span>
+                    <BaseBadge :variant="c.statusVariant" class="shrink-0 !px-2 !py-0.5 text-[10px]">
+                      {{ c.statusTexto }}
                     </BaseBadge>
                   </div>
                 </div>
@@ -315,7 +288,7 @@ onUnmounted(() => {
                 class="absolute right-1 top-1 rounded-lg border border-transparent p-1.5 text-gray-400 opacity-0 transition-opacity hover:border-red-200 hover:bg-red-50 hover:text-red-600 focus:opacity-100 focus-visible:opacity-100 group-hover:opacity-100 max-whatsapp:opacity-100 dark:hover:border-red-900/50 dark:hover:bg-red-950/30 dark:hover:text-red-400"
                 title="Excluir conversa"
                 aria-label="Excluir conversa"
-                @click="confirmandoExclusao = c.Id"
+                @click="confirmandoExclusao = c.id"
               >
                 <BaseIcon :icon="Trash2" class="h-4 w-4" />
               </button>
@@ -347,20 +320,20 @@ onUnmounted(() => {
               <BaseIcon :icon="ArrowLeft" class="h-4 w-4" />
             </button>
             <div
-              v-if="conversaAtiva"
+              v-if="conversaAtivaView"
               class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white ring-1 ring-white/20"
-              :class="avatarCor(conversaAtiva.NomeContato || conversaAtiva.Telefone)"
+              :class="conversaAtivaView.avatarCor"
             >
-              {{ avatarIniciais(conversaAtiva.NomeContato || conversaAtiva.Telefone) }}
+              {{ conversaAtivaView.avatarIniciais }}
             </div>
             <div class="min-w-0 flex-1">
               <div class="truncate text-sm font-semibold text-foreground">
-                {{ conversaAtiva ? formatPrevia(conversaAtiva) : "" }}
+                {{ conversaAtivaView?.titulo ?? "" }}
               </div>
-              <div v-if="conversaAtiva" class="truncate text-xs text-muted-foreground">{{ conversaAtiva.Telefone }}</div>
+              <div v-if="conversaAtivaView" class="truncate text-xs text-muted-foreground">{{ conversaAtivaView.telefone }}</div>
             </div>
-            <BaseBadge v-if="conversaAtiva" :variant="statusBadgeVariant(conversaAtiva.Status)" class="shrink-0">
-              {{ statusBadgeTexto(conversaAtiva.Status) }}
+            <BaseBadge v-if="conversaAtivaView" :variant="conversaAtivaView.statusVariant" class="shrink-0">
+              {{ conversaAtivaView.statusTexto }}
             </BaseBadge>
           </div>
 
@@ -377,7 +350,7 @@ onUnmounted(() => {
               <template v-for="(m, i) in mensagens" :key="m.Id">
                 <div v-if="novoDia(i)" class="flex justify-center py-2 first:pt-0">
                   <span class="rounded-full bg-black/[0.05] px-3 py-0.5 text-[11px] font-medium text-gray-500 dark:bg-white/[0.06] dark:text-gray-400">
-                    {{ formatData(m.Timestamp) }}
+                    {{ formatarData(m.Timestamp) }}
                   </span>
                 </div>
               <ChatBubble :own="m.Direcao === 'saida'">
@@ -454,7 +427,7 @@ onUnmounted(() => {
                   >
                     Abrir arquivo{{ m.MidiaNomeArquivo ? `: ${m.MidiaNomeArquivo}` : "" }}
                   </a>
-                  <div class="mt-0.5 text-right text-[10px] text-muted-foreground">{{ formatHora(m.Timestamp) }}</div>
+                  <div class="mt-0.5 text-right text-[10px] text-muted-foreground">{{ formatarHora(m.Timestamp) }}</div>
               </ChatBubble>
               </template>
             </div>

+ 6 - 2
src/views/pagina-inicial/PaginaInicialView.vue

@@ -28,7 +28,11 @@ async function onExport() {
   if (!activeConversationId.value || exportando.value) return;
   exportando.value = true;
   try {
+    // exportConversation já avisa o usuário; o catch aqui é só para o botão não
+    // ficar travado em "exportando" se algo escapar
     await exportConversation(activeConversationId.value);
+  } catch (err) {
+    console.warn("[PaginaInicialView] onExport:", err);
   } finally {
     exportando.value = false;
   }
@@ -152,7 +156,7 @@ onMounted(() => {
         <div v-if="chatError" class="mt-3 flex items-center justify-between gap-2 rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-300">
           <span>Erro: {{ chatError }}</span>
           <button type="button" class="shrink-0 rounded p-0.5 opacity-60 hover:opacity-100 transition-opacity" aria-label="Fechar erro" @click="clearError">
-            <BaseIcon :icon="X" class="h-3.5 w-3.5" strokeWidth="2.5" />
+            <BaseIcon :icon="X" class="h-3.5 w-3.5" stroke-width="2.5" />
           </button>
         </div>
       </div>
@@ -172,7 +176,7 @@ onMounted(() => {
             {{ exportando ? "Exportando..." : "Exportar .md" }}
           </button>
         </div>
-        <ChatWindow class="flex-1 min-h-1" :messages="chatMessages" :loading="chatLoading" :error="chatError" :mode="chatMode" @send="sendMessage" @clearError="clearError" @update:mode="setMode" />
+        <ChatWindow class="flex-1 min-h-1" :messages="chatMessages" :loading="chatLoading" :error="chatError" :mode="chatMode" @send="sendMessage" @clear-error="clearError" @update:mode="setMode" />
       </div>
     </div>
   </LayoutSistema>

+ 11 - 11
src/views/usuarios/UsuariosView.vue

@@ -5,6 +5,7 @@ import ModalUsuario from "../../components/ModalUsuario.vue";
 import ModalRedefinirSenha from "../../components/ModalRedefinirSenha.vue";
 import { useUsers } from "../../composables/useUsers.js";
 import { useToast } from "../../composables/useToast.js";
+import { useValorDebounced } from "../../composables/useValorDebounced.js";
 import { listarSolicitacoesReset, dispensarSolicitacaoReset } from "../../api/users.js";
 import BaseButton from "../../components/base/BaseButton.vue";
 import BaseBadge from "../../components/base/BaseBadge.vue";
@@ -12,6 +13,7 @@ import Spinner from "../../components/base/Spinner.vue";
 import BaseIcon from "../../components/base/BaseIcon.vue";
 import { Plus, Search, User, Pencil, KeyRound } from "lucide-vue-next";
 import { avatarIniciais, avatarCor } from "../../utils/avatar.js";
+import { formatarDataHoraSemAno } from "../../utils/data.js";
 
 const { users, loading, error, loadUsers, toggleStatus } = useUsers();
 const toast = useToast();
@@ -39,12 +41,14 @@ const filtros = [
   { v: "inativo", l: "Inativos" },
 ];
 
+const buscaAtrasada = useValorDebounced(busca);
+
 const usuariosFiltrados = computed(() => {
   let lista = users.value ?? [];
   if (filtroStatus.value === "ativo")   lista = lista.filter((u) => String(u.Status) === "1");
   if (filtroStatus.value === "inativo") lista = lista.filter((u) => String(u.Status) === "0");
-  if (busca.value.trim()) {
-    const q = busca.value.toLowerCase();
+  if (buscaAtrasada.value.trim()) {
+    const q = buscaAtrasada.value.toLowerCase();
     lista = lista.filter(
       (u) =>
         (u.Nome ?? "").toLowerCase().includes(q) ||
@@ -100,8 +104,11 @@ function aoSalvarUsuario() {
 async function carregarSolicitacoes() {
   try {
     solicitacoes.value = await listarSolicitacoesReset();
-  } catch {
+  } catch (err) {
     solicitacoes.value = [];
+    // falhar em silêncio esconderia do admin que há pedidos de reset esperando
+    toast.erro("Não foi possível carregar as solicitações de redefinição de senha.");
+    console.warn("[UsuariosView] carregarSolicitacoes:", err);
   }
 }
 
@@ -139,13 +146,6 @@ function usuarioDaSolicitacao(solicitacao) {
   );
 }
 
-function formatarDataSolicitacao(valor) {
-  const data = new Date(valor);
-  return Number.isNaN(data.getTime())
-    ? ""
-    : data.toLocaleString("pt-BR", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
-}
-
 onMounted(async () => {
   await Promise.all([loadUsers(), carregarSolicitacoes()]);
 });
@@ -193,7 +193,7 @@ onMounted(async () => {
             <div class="text-sm text-amber-700 dark:text-amber-200">
               <span class="font-medium">{{ solicitacao.usuario?.Nome || solicitacao.usuario?.Login || `Usuário #${solicitacao.UsuarioId}` }}</span>
               <span v-if="solicitacao.usuario?.Login" class="text-amber-600/80 dark:text-amber-200/70"> ({{ solicitacao.usuario.Login }})</span>
-              <span class="text-amber-600/80 dark:text-amber-200/70"> — {{ formatarDataSolicitacao(solicitacao.CreatedAt) }}</span>
+              <span class="text-amber-600/80 dark:text-amber-200/70"> — {{ formatarDataHoraSemAno(solicitacao.CreatedAt) }}</span>
             </div>
             <div class="flex shrink-0 items-center gap-1.5">
               <BaseButton size="sm" @click="abrirRedefinicaoSenha(usuarioDaSolicitacao(solicitacao))">

+ 13 - 0
vite.config.js

@@ -22,6 +22,19 @@ export default defineConfig(({ command }) => ({
       plugins: [tailwindcss(), autoprefixer()]
     }
   },
+  build: {
+    rollupOptions: {
+      output: {
+        // separa as libs do código da aplicação para que um deploy não invalide o cache delas
+        manualChunks: {
+          vue: ["vue", "vue-router"],
+          markdown: ["marked", "dompurify"],
+          highlight: ["highlight.js"],
+          toast: ["vue-toastification"]
+        }
+      }
+    }
+  },
   server: {
     port: 5173
   }