leonardo 1 mese fa
parent
commit
6c349faa77
42 ha cambiato i file con 1047 aggiunte e 798 eliminazioni
  1. 176 0
      docs/components.md
  2. 29 31
      docs/style.md
  3. 3 1
      package.json
  4. 4 1
      src/api/atendimentos.js
  5. 2 2
      src/components/ChatModeToggle.vue
  6. 4 4
      src/components/ChatWindow.vue
  7. 10 0
      src/components/DecorativeGradient.vue
  8. 7 7
      src/components/DocumentList.vue
  9. 5 5
      src/components/DocumentUpload.vue
  10. 6 35
      src/components/ModalEsqueciSenha.vue
  11. 10 29
      src/components/ModalRedefinirSenha.vue
  12. 13 32
      src/components/ModalUsuario.vue
  13. 6 6
      src/components/SearchBox.vue
  14. 2 2
      src/components/base/BaseDropdown.vue
  15. 10 0
      src/components/base/BaseIcon.vue
  16. 102 0
      src/components/base/BaseModal.vue
  17. 16 0
      src/components/base/ChatBubble.vue
  18. 26 0
      src/components/base/EmptyState.vue
  19. 34 0
      src/components/base/ListItemCard.vue
  20. 32 0
      src/components/base/PageHeaderCard.vue
  21. 38 0
      src/components/base/Spinner.vue
  22. 3 3
      src/components/base/StatCard.vue
  23. 9 45
      src/layout/HeaderSistema.vue
  24. 4 10
      src/layout/LayoutSistema.vue
  25. 5 34
      src/layout/ListaConversas.vue
  26. 1 1
      src/layout/ModalDocumentos.vue
  27. 28 133
      src/layout/SidebarSistema.vue
  28. 7 0
      src/main.js
  29. 52 27
      src/styles/tailwind.css
  30. 73 60
      src/views/atendimentos/AtendimentoView.vue
  31. 84 75
      src/views/atendimentos/AvaliacoesView.vue
  32. 43 24
      src/views/atendimentos/GoldenSetView.vue
  33. 80 71
      src/views/atendimentos/NaoResolvidosView.vue
  34. 7 13
      src/views/auth/LoginView.vue
  35. 15 15
      src/views/configuracoes/ConfiguracoesView.vue
  36. 7 7
      src/views/conversas/ConversasHistoricoView.vue
  37. 9 9
      src/views/conversas/ConversasPesquisarView.vue
  38. 37 72
      src/views/conversas/ConversasWhatsappView.vue
  39. 7 6
      src/views/documentos/CarregarDocumentoView.vue
  40. 22 19
      src/views/documentos/DocumentosView.vue
  41. 2 2
      src/views/pagina-inicial/PaginaInicialView.vue
  42. 17 17
      src/views/usuarios/UsuariosView.vue

+ 176 - 0
docs/components.md

@@ -118,6 +118,182 @@ Chip/etiqueta para exibir status, categorias ou contadores.
 
 ---
 
+## BaseDropdown
+
+**Arquivo:** `src/components/base/BaseDropdown.vue`
+
+Select customizado (não nativo), acessível (`role="listbox"`/`"option"`, `aria-expanded`/`aria-haspopup`), fecha ao clicar fora ou pressionar Esc. Compatível com `v-model`.
+
+### Props
+
+| Prop | Tipo | Padrão | Descrição |
+|---|---|---|---|
+| `modelValue` | `String` | `""` | Valor selecionado (use com `v-model`) |
+| `rotulo` | `String` | — (obrigatório) | Texto exibido antes do valor selecionado |
+| `rotuloPadrao` | `String` | — (obrigatório) | Texto quando nenhuma opção está selecionada (ex: `"todos"`) |
+| `opcoes` | `Array` | `[]` | Lista de `{ value, label }` |
+
+Emite `change` além de `update:modelValue`, útil para disparar buscas ao selecionar.
+
+### Exemplo
+
+```vue
+<BaseDropdown
+  v-model="filtroSetor"
+  rotulo="Setor"
+  rotulo-padrao="todos"
+  :opcoes="setores.map((s) => ({ value: s.sigla, label: s.nome }))"
+  @change="aplicarFiltros"
+/>
+```
+
+---
+
+## StatCard
+
+**Arquivo:** `src/components/base/StatCard.vue`
+
+Cartão de métrica (KPI) com ícone, valor em destaque e texto complementar opcional.
+
+### Props
+
+| Prop | Tipo | Padrão | Valores aceitos |
+|---|---|---|---|
+| `label` | `String` | — (obrigatório) | Rótulo da métrica |
+| `value` | `String \| Number` | `null` | Valor em destaque |
+| `suffix` | `String` | `""` | Sufixo do valor (ex: `" / 10"`) |
+| `variant` | `String` | `"default"` | `"default"` · `"success"` · `"danger"` · `"info"` · `"warning"` · `"violet"` |
+
+O slot `#icon` recebe o ícone (ideal: `<BaseIcon>`); o slot padrão (`default`) recebe um texto complementar abaixo do valor.
+
+### Exemplo
+
+```vue
+<StatCard label="Score médio" :value="stats.scoreMedio" suffix=" / 10" variant="info">
+  <template #icon><BaseIcon :icon="Star" class="h-4 w-4" /></template>
+  {{ stats.pendentes }} pendentes
+</StatCard>
+```
+
+---
+
+## BaseIcon
+
+**Arquivo:** `src/components/base/BaseIcon.vue`
+
+Wrapper fino sobre ícones do [Lucide](https://lucide.dev/icons/) (`lucide-vue-next`). Sempre prefira importar o ícone diretamente da lib (mantém tree-shaking) e passá-lo via prop `icon`, em vez de SVG inline copiado manualmente.
+
+### Props
+
+| Prop | Tipo | Padrão | Descrição |
+|---|---|---|---|
+| `icon` | `Object \| Function` | — (obrigatório) | Componente do ícone importado de `lucide-vue-next` |
+| `strokeWidth` | `String \| Number` | `1.8` | Espessura do traço |
+
+Tamanho e cor são controlados via `class` (ex: `class="h-4 w-4 text-primary"`), igual a um SVG comum.
+
+### Exemplo
+
+```vue
+<script setup>
+import { Search } from "lucide-vue-next";
+import BaseIcon from "@/components/base/BaseIcon.vue";
+</script>
+
+<template>
+  <BaseIcon :icon="Search" class="h-4 w-4 text-muted-foreground" />
+</template>
+```
+
+---
+
+## BaseModal
+
+**Arquivo:** `src/components/base/BaseModal.vue`
+
+Casca comum para modais: `Teleport` para `body`, backdrop, fecha com Esc ou clique fora, foco movido para dentro do modal ao abrir e preso nele (focus trap) enquanto aberto.
+
+### Props
+
+| Prop | Tipo | Padrão | Descrição |
+|---|---|---|---|
+| `title` | `String` | `""` | Título exibido no cabeçalho padrão |
+| `maxWidth` | `String` | `"max-w-md"` | Classe Tailwind de largura máxima do painel |
+| `closeOnBackdrop` | `Boolean` | `true` | Fecha ao clicar fora do painel |
+| `closeDisabled` | `Boolean` | `false` | Bloqueia fechamento (ex: enquanto envia um formulário) |
+| `showClose` | `Boolean` | `true` | Exibe o botão "×" no cabeçalho |
+
+### Slots
+
+| Slot | Descrição |
+|---|---|
+| `header` | Substitui o cabeçalho padrão (título + botão fechar) |
+| default | Conteúdo do corpo do modal |
+| `footer` | Rodapé opcional, com borda separadora |
+
+### Exemplo
+
+```vue
+<BaseModal title="Editar usuário" :close-disabled="salvando" @close="fechar">
+  <form class="grid gap-4 p-5" @submit.prevent="salvar">
+    ...
+  </form>
+</BaseModal>
+```
+
+---
+
+## EmptyState
+
+**Arquivo:** `src/components/base/EmptyState.vue`
+
+Estado vazio padronizado (ícone tracejado + título + descrição), usado no lugar de cada view escrever seu próprio bloco "Nenhum item encontrado".
+
+### Props
+
+| Prop | Tipo | Padrão | Descrição |
+|---|---|---|---|
+| `icon` | `Object \| Function` | `null` | Ícone do Lucide exibido no topo |
+| `titulo` | `String` | — (obrigatório) | Texto principal |
+| `descricao` | `String` | `""` | Texto secundário opcional |
+| `compacto` | `Boolean` | `false` | Reduz o padding vertical (para uso dentro de listas menores) |
+
+O slot padrão aceita uma ação (ex: um `BaseButton`) abaixo do texto.
+
+### Exemplo
+
+```vue
+<EmptyState
+  :icon="History"
+  titulo="Nenhuma conversa ainda"
+  descricao="Suas conversas aparecerão aqui após a primeira mensagem."
+/>
+```
+
+---
+
+## Spinner
+
+**Arquivo:** `src/components/base/Spinner.vue`
+
+Indicador de carregamento padronizado, para substituir o texto solto `"Carregando..."` repetido em várias views.
+
+### Props
+
+| Prop | Tipo | Padrão | Valores aceitos |
+|---|---|---|---|
+| `size` | `String` | `"md"` | `"sm"` · `"md"` · `"lg"` |
+| `label` | `String` | `""` | Texto exibido ao lado do spinner |
+| `center` | `Boolean` | `false` | Centraliza horizontalmente com padding vertical (para loading de seção inteira) |
+
+### Exemplo
+
+```vue
+<Spinner v-if="carregando" center label="Carregando avaliações..." />
+```
+
+---
+
 ## Skeletons
 
 **Pasta:** `src/components/skeletons/`

+ 29 - 31
docs/style.md

@@ -2,22 +2,22 @@
 
 ## Sistema de cor
 
-- **Background/foreground**: usar as variáveis CSS `bg-background` e `text-foreground` — tratam light/dark automaticamente pelo ThemeLoader.
-- **Demais cores**: Tailwind com `dark:` prefix manual (modo escuro é via classe `.dark` no elemento `<html>`).
+- **Sempre prefira os tokens semânticos** definidos em `src/styles/tailwind.css` (`@theme`) — eles já resolvem light/dark sozinhos, sem precisar de `dark:` manual: `bg-background`, `text-foreground`, `text-muted-foreground`, `bg-card`, `bg-popover`, `border-border`, `bg-primary`/`text-primary-foreground`, `bg-secondary`, `bg-destructive`, `border-input`, `ring-ring`.
+- **`gray-*` com `dark:` manual só quando não existir token equivalente** (ex.: variações pontuais de opacidade, estados de hover muito específicos). Nesse caso, modo escuro é via classe `.dark` no elemento `<html>`.
+- A paleta `abyss-950`/`abyss-900`/`abyss-800`/`abyss-700` (também em `@theme`) é reservada para painéis elevados no dark mode (sidebar, login) — não usar hex solto para esse tom de azul-marinho quase-preto.
+- Breakpoints nomeados no `@theme`: `--breakpoint-compact` (520px), `--breakpoint-whatsapp` (760px), `--breakpoint-layout` (980px) — geram variantes `compact:`/`max-compact:`, `whatsapp:`/`max-whatsapp:`, `layout:`/`max-layout:`. Não usar `max-[980px]` etc. soltos no meio do código.
 
 ## Mapeamento de cores
 
-| Elemento | Light | Dark |
+| Elemento | Classe recomendada | Equivalente antigo (evitar) |
 |---|---|---|
-| Texto primário | `text-gray-900` | `dark:text-gray-100` |
-| Texto secundário | `text-gray-500` | `dark:text-gray-400` |
-| Texto terciário/muted | `text-gray-400` | `dark:text-gray-500` |
-| Separadores finos | `text-gray-300` | `dark:text-gray-600` |
-| Bordas padrão | `border-gray-200` | `dark:border-gray-700` |
-| Bordas de badge/chip | `border-gray-300` | `dark:border-gray-600` |
-| Hover de link | `hover:text-gray-900` | `dark:hover:text-white` |
-| Hover de badge | `hover:border-gray-500` | `dark:hover:border-gray-400` |
-| Hover de texto muted | `hover:text-gray-600` | `dark:hover:text-gray-300` |
+| Texto primário | `text-foreground` | `text-gray-900 dark:text-gray-100` |
+| Texto secundário/muted | `text-muted-foreground` | `text-gray-500 dark:text-gray-400` |
+| Fundo de card/painel | `bg-card` ou `bg-background/70` | — |
+| Bordas padrão | `border-border` (já é o `border` default de qualquer elemento) | `border-gray-200 dark:border-gray-700` |
+| Hover de link | `hover:text-foreground` | `hover:text-gray-900 dark:hover:text-white` |
+
+Casos que ainda não têm token semântico direto (ex.: bordas tracejadas de empty state, chips de tipo de arquivo) continuam usando `gray-*` com par `dark:` manual — não force um token que não existe só por consistência.
 
 ## Backgrounds
 
@@ -25,44 +25,42 @@
 |---|---|---|
 | Fundo da página | `bg-background` (auto) | (auto) |
 | Barra do topo | `bg-primary` | `dark:bg-black` |
-| Footer | `bg-gray-100/50` | `dark:bg-secondary/50` |
-| Texto no footer | `text-black/50` | `dark:text-white/50` |
-
-## Botão primário (submit)
-
-Usar o componente `BaseButton` (variante `primary`) do design system — ele já trata light/dark via variáveis de cor do projeto, sem necessidade de overrides. Ver [components.md](components.md).
+| Cabeçalho de card | `bg-gray-100/50` | `dark:bg-secondary/50` |
+| Painel elevado (sidebar/login) | `bg-white/70` | `dark:bg-abyss-900` |
 
 ## Padrão de uso
 
-Todo `gray-*` que for texto, borda ou hover deve ganhar a contraparte `dark:` correspondente segundo a tabela acima. Exemplo prático:
-
 ```html
 <!-- Títulos -->
-<h2 class="text-gray-900 dark:text-gray-100">Título</h2>
+<h2 class="text-foreground">Título</h2>
 
 <!-- Texto descritivo -->
-<p class="text-gray-500 dark:text-gray-400">Descrição</p>
+<p class="text-muted-foreground">Descrição</p>
 
 <!-- Links -->
-<a class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:underline">Link</a>
+<a class="text-muted-foreground hover:text-foreground hover:underline">Link</a>
 
-<!-- Bordas/separadores -->
-<section class="border-t border-gray-200 dark:border-gray-700">
+<!-- Bordas/separadores (border-border já é o padrão implícito de qualquer `border`) -->
+<section class="border-t">
 ```
 
 ## Erros
 
-- `text-gray-900`/`dark:text-gray-100` — texto principal
-- `text-gray-500`/`dark:text-gray-400` — texto secundário
-- `text-gray-400`/`dark:text-gray-500` — texto terciário
+- `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. **Não há PrimeVue no projeto.**
+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`.
-
-Detalhes de props e exemplos em [components.md](components.md). Para cores que não vêm dos componentes base, aplicar Tailwind com o prefixo `dark:` segundo as tabelas acima.
+- `BaseDropdown`: select customizado acessível.
+- `StatCard`: cartão de métrica/KPI.
+- `BaseIcon`: wrapper sobre ícones do Lucide (`lucide-vue-next`) — preferir a SVG inline copiado.
+- `BaseModal`: casca de modal com focus trap e fechamento por Esc/backdrop.
+- `EmptyState`: bloco padronizado de "nada encontrado".
+- `Spinner`: indicador de carregamento padronizado.
+
+Detalhes de props e exemplos em [components.md](components.md). Para cores que não vêm dos componentes base, preferir os tokens semânticos da tabela acima; só cair para `gray-*` + `dark:` quando não houver token equivalente.

+ 3 - 1
package.json

@@ -9,8 +9,11 @@
     "lint": "node -c vite.config.js"
   },
   "dependencies": {
+    "@fontsource/inter": "^5.3.0",
+    "@fontsource/jetbrains-mono": "^5.3.0",
     "dompurify": "^3.4.11",
     "highlight.js": "^11.11.1",
+    "lucide-vue-next": "^1.0.0",
     "marked": "^18.0.5",
     "vue": "^3.4.27",
     "vue-router": "^4.4.5",
@@ -23,7 +26,6 @@
     "autoprefixer": "^10.5.0",
     "postcss": "^8.5.15",
     "tailwindcss": "^4.3.0",
-    "tailwindcss-primeui": "^0.6.1",
     "vite": "^6.0.0",
     "vite-plugin-vue-devtools": "^8.1.1"
   }

+ 4 - 1
src/api/atendimentos.js

@@ -74,7 +74,10 @@ export function salvarGoldenLabel(id, dados) {
   return apiFetch(`/api/atendimentos/${id}/golden-label`, { method: "PATCH", body: dados });
 }
 
-// não usa apiFetch: a resposta é um PDF (blob), não JSON/texto
+export function aceitarGoldenLabelComoIa(id) {
+  return apiFetch(`/api/atendimentos/${id}/golden-label/aceitar`, { method: "POST" });
+}
+
 export async function baixarRelatorioNaoResolvidos({ setor, sentimento, busca, ordenacao, dataInicio, dataFim } = {}) {
   const query = new URLSearchParams();
   if (setor) query.set("setor", setor);

+ 2 - 2
src/components/ChatModeToggle.vue

@@ -3,7 +3,7 @@
     <button
       type="button"
       class="rounded-md px-2.5 py-1 font-medium transition-colors"
-      :class="mode === 'documentos' ? 'bg-white text-foreground shadow-sm dark:bg-gray-700' : 'text-gray-500 dark:text-gray-400'"
+      :class="mode === 'documentos' ? 'bg-white text-foreground shadow-sm dark:bg-gray-700' : 'text-muted-foreground'"
       @click="$emit('update:mode', 'documentos')"
     >
       Documentos
@@ -11,7 +11,7 @@
     <button
       type="button"
       class="rounded-md px-2.5 py-1 font-medium transition-colors"
-      :class="mode === 'atendimentos' ? 'bg-white text-foreground shadow-sm dark:bg-gray-700' : 'text-gray-500 dark:text-gray-400'"
+      :class="mode === 'atendimentos' ? 'bg-white text-foreground shadow-sm dark:bg-gray-700' : 'text-muted-foreground'"
       @click="$emit('update:mode', 'atendimentos')"
     >
       Atendimentos

+ 4 - 4
src/components/ChatWindow.vue

@@ -7,7 +7,7 @@
       <div v-if="!messages.length" class="grid h-full place-items-center">
         <div class="text-center">
           <div class="text-xl font-semibold tracking-tight">Nova conversa</div>
-          <div class="mt-2 text-sm text-gray-500 dark:text-gray-400">Digite sua pergunta abaixo para começar.</div>
+          <div class="mt-2 text-sm text-muted-foreground">Digite sua pergunta abaixo para começar.</div>
         </div>
       </div>
 
@@ -20,7 +20,7 @@
             >
               {{ m.role === 'user' ? 'V' : 'O' }}
             </div>
-            <span class="text-xs font-medium text-gray-400 dark:text-gray-500">
+            <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">
@@ -88,14 +88,14 @@
         <div v-if="loading && !isStreaming" class="flex flex-col items-start gap-0.5">
           <div class="mb-0.5 flex items-center gap-1.5">
             <div class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-gray-400 text-[9px] font-bold text-white dark:bg-gray-600">O</div>
-            <span class="text-xs font-medium text-gray-400 dark:text-gray-500">Oráculo</span>
+            <span class="text-xs font-medium text-muted-foreground">Oráculo</span>
           </div>
           <div class="rounded-2xl border border-gray-200 bg-white/50 px-4 py-3 dark:border-gray-700 dark:bg-gray-800/60">
             <span class="inline-flex items-center gap-1.5">
               <span class="h-1.5 w-1.5 animate-bounce rounded-full bg-gray-400 dark:bg-gray-500" style="animation-delay: 0ms" />
               <span class="h-1.5 w-1.5 animate-bounce rounded-full bg-gray-400 dark:bg-gray-500" style="animation-delay: 150ms" />
               <span class="h-1.5 w-1.5 animate-bounce rounded-full bg-gray-400 dark:bg-gray-500" style="animation-delay: 300ms" />
-              <span class="ml-1 text-xs text-gray-400 dark:text-gray-500">{{ streamingStatusMsg }}</span>
+              <span class="ml-1 text-xs text-muted-foreground">{{ streamingStatusMsg }}</span>
             </span>
           </div>
         </div>

+ 10 - 0
src/components/DecorativeGradient.vue

@@ -0,0 +1,10 @@
+<template>
+  <div
+    aria-hidden="true"
+    class="pointer-events-none absolute inset-0 z-0 bg-[radial-gradient(900px_520px_at_20%_10%,rgba(37,99,235,0.12),transparent_60%),radial-gradient(700px_520px_at_85%_35%,rgba(162,102,255,0.10),transparent_60%)] dark:hidden"
+  />
+  <div
+    aria-hidden="true"
+    class="pointer-events-none absolute inset-0 z-0 hidden bg-[radial-gradient(900px_520px_at_20%_10%,rgba(91,140,255,0.18),transparent_60%),radial-gradient(700px_520px_at_85%_35%,rgba(162,102,255,0.14),transparent_60%)] dark:block"
+  />
+</template>

+ 7 - 7
src/components/DocumentList.vue

@@ -111,7 +111,7 @@ function fileType(source) {
 <template>
   <div class="grid gap-4">
     <div class="flex items-center justify-between gap-3">
-      <div class="text-xs text-gray-500 dark:text-gray-400">
+      <div class="text-xs text-muted-foreground">
         <span v-if="loading">Carregando...</span>
         <span v-else>
           {{ totalChunks }} chunk{{ totalChunks !== 1 ? 's' : '' }} em {{ totalDocs }} documento{{ totalDocs !== 1 ? 's' : '' }}
@@ -155,7 +155,7 @@ function fileType(source) {
       </div>
       <div>
         <div class="text-sm font-medium text-gray-700 dark:text-gray-200">Nenhum documento na base</div>
-        <div class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">Carregue um arquivo ou texto pela aba de upload.</div>
+        <div class="mt-0.5 text-xs text-muted-foreground">Carregue um arquivo ou texto pela aba de upload.</div>
       </div>
     </div>
 
@@ -191,18 +191,18 @@ function fileType(source) {
           </svg>
         </div>
 
-        <span class="line-clamp-2 w-full break-words text-xs font-medium text-gray-900 dark:text-gray-100">
+        <span class="line-clamp-2 w-full break-words text-xs font-medium text-foreground">
           {{ source || "sem fonte" }}
         </span>
-        <span class="text-[10px] text-gray-400 dark:text-gray-500">
+        <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>
         </span>
 
         <div
           v-if="confirmingSource === source"
-          class="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-background p-3 dark:bg-[#101827]"
+          class="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-background p-3 dark:bg-abyss-900"
         >
-          <span class="text-xs text-gray-500 dark:text-gray-400">Excluir tudo?</span>
+          <span class="text-xs text-muted-foreground">Excluir tudo?</span>
           <div class="flex items-center gap-1.5">
             <BaseButton
               variant="danger"
@@ -222,7 +222,7 @@ function fileType(source) {
 
       <div
         v-if="filterQuery && Object.keys(filteredGrouped).length === 0"
-        class="col-span-full py-6 text-center text-sm text-gray-400 dark:text-gray-500"
+        class="col-span-full py-6 text-center text-sm text-muted-foreground"
       >
         Nenhum documento corresponde ao filtro.
       </div>

+ 5 - 5
src/components/DocumentUpload.vue

@@ -126,8 +126,8 @@ const canSubmit = computed(() => {
           </svg>
         </div>
         <div class="min-w-0 flex-1">
-          <div class="truncate text-sm font-medium text-gray-900 dark:text-gray-100">{{ file.name }}</div>
-          <div class="text-xs text-gray-500 dark:text-gray-400">{{ formatSize(file.size) }}</div>
+          <div class="truncate text-sm font-medium text-foreground">{{ file.name }}</div>
+          <div class="text-xs text-muted-foreground">{{ formatSize(file.size) }}</div>
         </div>
         <button
           type="button"
@@ -160,7 +160,7 @@ const canSubmit = computed(() => {
           <div class="text-sm font-medium text-gray-700 dark:text-gray-200">
             Arraste um arquivo ou <span class="text-primary underline underline-offset-2">clique para selecionar</span>
           </div>
-          <div class="mt-1 text-xs text-gray-400 dark:text-gray-500">PDF, Word, TXT, PNG, JPG, WEBP — até 25 MB</div>
+          <div class="mt-1 text-xs text-muted-foreground">PDF, Word, TXT, PNG, JPG, WEBP — até 25 MB</div>
         </div>
         <input
           ref="fileInputRef"
@@ -189,7 +189,7 @@ const canSubmit = computed(() => {
           placeholder="https://exemplo.com/pagina"
           class="w-full rounded-xl border border-gray-200 bg-background px-3 py-2.5 text-sm text-gray-700 placeholder-gray-400 outline-none focus:border-primary dark:border-gray-700 dark:bg-white/5 dark:text-gray-200 dark:placeholder-gray-500"
         />
-        <p class="text-xs text-gray-400 dark:text-gray-500">
+        <p class="text-xs text-muted-foreground">
           Apenas URLs <code class="font-mono">https://</code> públicas. O conteúdo da página será extraído e indexado.
         </p>
       </div>
@@ -208,7 +208,7 @@ const canSubmit = computed(() => {
     </template>
 
     <div class="flex items-center justify-between gap-3">
-      <div class="text-xs text-gray-400 dark:text-gray-500">
+      <div class="text-xs text-muted-foreground">
         <span v-if="tab === 'arquivo'">O arquivo será processado e indexado na base de conhecimento.</span>
         <span v-else-if="tab === 'url'">A página será baixada, o texto extraído e indexado.</span>
         <span v-else>O texto será fragmentado e indexado na base.</span>

+ 6 - 35
src/components/ModalEsqueciSenha.vue

@@ -1,27 +1,5 @@
 <template>
-  <Teleport to="body">
-    <div class="fixed inset-0 z-[9999] flex items-center justify-center p-4" @click="!enviando && $emit('close')">
-      <div class="absolute inset-0 bg-black/40 dark:bg-black/60" />
-      <div
-        class="relative w-full max-w-md overflow-hidden rounded-2xl border border-gray-200 bg-background text-foreground shadow-xl dark:border-gray-700"
-        @click.stop
-      >
-        <div class="flex items-center justify-between gap-3 border-b border-gray-200 px-5 py-4 dark:border-gray-700">
-          <h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">
-            Esqueci minha senha
-          </h3>
-          <button
-            type="button"
-            :disabled="enviando"
-            class="rounded-lg border border-transparent p-1.5 text-gray-500 transition-colors hover:border-gray-200 hover:bg-gray-100 disabled:opacity-50 dark:text-gray-400 dark:hover:border-gray-700 dark:hover:bg-white/5"
-            @click="$emit('close')"
-          >
-            <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
-              <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
-            </svg>
-          </button>
-        </div>
-
+  <BaseModal title="Esqueci minha senha" :close-disabled="enviando" @close="$emit('close')">
         <div v-if="enviado" class="grid gap-4 p-5">
           <div class="rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-3 text-sm text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-300">
             Se o usuário existir, a solicitação foi registrada. Um administrador entrará em contato pelo canal interno.
@@ -34,15 +12,14 @@
         </div>
 
         <form v-else class="grid gap-4 p-5" @submit.prevent="onSubmit">
-          <p class="text-sm text-gray-500 dark:text-gray-400">
+          <p class="text-sm text-muted-foreground">
             Informe seu usuário ou e-mail. Um administrador será notificado para redefinir sua senha.
           </p>
 
           <div class="grid gap-1.5">
-            <label for="mes-login" class="text-sm font-medium text-gray-700 dark:text-gray-300">Usuário ou e-mail</label>
+            <label for="mes-login" class="text-sm font-medium text-foreground">Usuário ou e-mail</label>
             <input
               id="mes-login"
-              ref="loginInputRef"
               v-model="login"
               placeholder="Digite seu login"
               required
@@ -64,15 +41,14 @@
             </BaseButton>
           </div>
         </form>
-      </div>
-    </div>
-  </Teleport>
+  </BaseModal>
 </template>
 
 <script setup>
-import { onMounted, ref } from "vue";
+import { ref } from "vue";
 import { solicitarResetSenha } from "../api/auth.js";
 import BaseButton from "./base/BaseButton.vue";
+import BaseModal from "./base/BaseModal.vue";
 
 defineEmits(["close"]);
 
@@ -80,11 +56,6 @@ const login = ref("");
 const enviando = ref(false);
 const enviado = ref(false);
 const erro = ref("");
-const loginInputRef = ref(null);
-
-onMounted(() => {
-  loginInputRef.value?.focus();
-});
 
 async function onSubmit() {
   if (enviando.value) return;

+ 10 - 29
src/components/ModalRedefinirSenha.vue

@@ -1,30 +1,12 @@
 <template>
-  <Teleport to="body">
-    <div class="fixed inset-0 z-[9999] flex items-center justify-center p-4" @click="!salvando && $emit('close')">
-      <div class="absolute inset-0 bg-black/40 dark:bg-black/60" />
-      <div
-        class="relative w-full max-w-md overflow-hidden rounded-2xl border border-gray-200 bg-background text-foreground shadow-xl dark:border-gray-700"
-        @click.stop
-      >
-        <div class="flex items-center justify-between gap-3 border-b border-gray-200 px-5 py-4 dark:border-gray-700">
-          <h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">
-            Redefinir senha de {{ usuario.Nome || usuario.Login }}
-          </h3>
-          <button
-            type="button"
-            :disabled="salvando"
-            class="rounded-lg border border-transparent p-1.5 text-gray-500 transition-colors hover:border-gray-200 hover:bg-gray-100 disabled:opacity-50 dark:text-gray-400 dark:hover:border-gray-700 dark:hover:bg-white/5"
-            @click="$emit('close')"
-          >
-            <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
-              <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
-            </svg>
-          </button>
-        </div>
-
+  <BaseModal
+    :title="`Redefinir senha de ${usuario.Nome || usuario.Login}`"
+    :close-disabled="salvando"
+    @close="$emit('close')"
+  >
         <form class="grid gap-4 p-5" @submit.prevent="onSubmit">
           <div class="grid gap-1.5">
-            <label for="mrs-senha" class="text-sm font-medium text-gray-700 dark:text-gray-300">Nova senha</label>
+            <label for="mrs-senha" class="text-sm font-medium text-foreground">Nova senha</label>
             <input
               id="mrs-senha"
               v-model="senhaNova"
@@ -38,7 +20,7 @@
           </div>
 
           <div class="grid gap-1.5">
-            <label for="mrs-confirmacao" class="text-sm font-medium text-gray-700 dark:text-gray-300">Confirmar nova senha</label>
+            <label for="mrs-confirmacao" class="text-sm font-medium text-foreground">Confirmar nova senha</label>
             <input
               id="mrs-confirmacao"
               v-model="confirmacao"
@@ -54,7 +36,7 @@
             {{ erroForm }}
           </div>
 
-          <p class="text-xs text-gray-500 dark:text-gray-400">
+          <p class="text-xs text-muted-foreground">
             Comunique a nova senha ao usuário por canal interno.
           </p>
 
@@ -67,15 +49,14 @@
             </BaseButton>
           </div>
         </form>
-      </div>
-    </div>
-  </Teleport>
+  </BaseModal>
 </template>
 
 <script setup>
 import { ref } from "vue";
 import { alterarSenhaUsuario } from "../api/users.js";
 import BaseButton from "./base/BaseButton.vue";
+import BaseModal from "./base/BaseModal.vue";
 
 const props = defineProps({
   usuario: { type: Object, required: true }

+ 13 - 32
src/components/ModalUsuario.vue

@@ -1,51 +1,33 @@
 <template>
-  <Teleport to="body">
-    <div class="fixed inset-0 z-[9999] flex items-center justify-center p-4" @click="!salvando && $emit('close')">
-      <div class="absolute inset-0 bg-black/40 dark:bg-black/60" />
-      <div
-        class="relative w-full max-w-md overflow-hidden rounded-2xl border border-gray-200 bg-background text-foreground shadow-xl dark:border-gray-700"
-        @click.stop
-      >
-        <div class="flex items-center justify-between gap-3 border-b border-gray-200 px-5 py-4 dark:border-gray-700">
-          <h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">
-            {{ usuario ? "Editar usuário" : "Novo usuário" }}
-          </h3>
-          <button
-            type="button"
-            :disabled="salvando"
-            class="rounded-lg border border-transparent p-1.5 text-gray-500 transition-colors hover:border-gray-200 hover:bg-gray-100 disabled:opacity-50 dark:text-gray-400 dark:hover:border-gray-700 dark:hover:bg-white/5"
-            @click="$emit('close')"
-          >
-            <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
-              <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
-            </svg>
-          </button>
-        </div>
-
+  <BaseModal
+    :title="usuario ? 'Editar usuário' : 'Novo usuário'"
+    :close-disabled="salvando"
+    @close="$emit('close')"
+  >
         <form class="p-5 grid gap-4" @submit.prevent="onSubmit">
           <div class="grid gap-1.5">
-            <label for="mu-nome" class="text-sm font-medium text-gray-700 dark:text-gray-300">Nome</label>
+            <label for="mu-nome" class="text-sm font-medium text-foreground">Nome</label>
             <input id="mu-nome" v-model="form.Nome" placeholder="Nome completo" required :disabled="salvando" />
           </div>
 
           <div v-if="!usuario" class="grid gap-1.5">
-            <label for="mu-login" class="text-sm font-medium text-gray-700 dark:text-gray-300">Login</label>
+            <label for="mu-login" class="text-sm font-medium text-foreground">Login</label>
             <input id="mu-login" v-model="form.Login" placeholder="Nome de usuário" required :disabled="salvando" autocomplete="off" />
           </div>
 
           <div class="grid gap-1.5">
-            <label for="mu-email" class="text-sm font-medium text-gray-700 dark:text-gray-300">E-mail</label>
+            <label for="mu-email" class="text-sm font-medium text-foreground">E-mail</label>
             <input id="mu-email" v-model="form.Email" type="email" placeholder="email@empresa.com" required :disabled="salvando" />
           </div>
 
           <div v-if="!usuario" class="grid gap-1.5">
-            <label for="mu-senha" class="text-sm font-medium text-gray-700 dark:text-gray-300">Senha</label>
+            <label for="mu-senha" class="text-sm font-medium text-foreground">Senha</label>
             <input id="mu-senha" v-model="form.Senha" type="password" placeholder="Mínimo 6 caracteres" required minlength="6" :disabled="salvando" autocomplete="new-password" />
           </div>
 
           <div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
             <div class="grid gap-1.5">
-              <label for="mu-nivel" class="text-sm font-medium text-gray-700 dark:text-gray-300">Nível</label>
+              <label for="mu-nivel" class="text-sm font-medium text-foreground">Nível</label>
               <select id="mu-nivel" v-model="form.Nivel" :disabled="salvando">
                 <option value="1">Usuário</option>
                 <option value="2">Gestor</option>
@@ -53,7 +35,7 @@
               </select>
             </div>
             <div class="grid gap-1.5">
-              <label for="mu-setor" class="text-sm font-medium text-gray-700 dark:text-gray-300">Setor</label>
+              <label for="mu-setor" class="text-sm font-medium text-foreground">Setor</label>
               <input id="mu-setor" v-model="form.Setor" placeholder="Ex: TI, RH, Financeiro" required :disabled="salvando" />
             </div>
           </div>
@@ -71,15 +53,14 @@
             </BaseButton>
           </div>
         </form>
-      </div>
-    </div>
-  </Teleport>
+  </BaseModal>
 </template>
 
 <script setup>
 import { ref, watch } from "vue";
 import { useUsers } from "../composables/useUsers.js";
 import BaseButton from "./base/BaseButton.vue";
+import BaseModal from "./base/BaseModal.vue";
 
 const props = defineProps({
   usuario: { type: Object, default: null }

+ 6 - 6
src/components/SearchBox.vue

@@ -1,13 +1,13 @@
 <template>
   <section class="rounded-2xl border border-gray-200 bg-gray-100/50 p-4 shadow-sm backdrop-blur-md dark:border-gray-700 dark:bg-secondary/50">
-    <h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Busca</h2>
-    <div class="mt-3 grid grid-cols-1 gap-2 min-[520px]:grid-cols-[1fr_120px]">
+    <h2 class="text-lg font-semibold text-foreground">Busca</h2>
+    <div class="mt-3 grid grid-cols-1 gap-2 compact:grid-cols-[1fr_120px]">
       <input v-model="query" placeholder="Buscar na base..." @keydown.enter="onSearch" />
       <BaseButton :disabled="loading || !query.trim()" @click="onSearch">
         Buscar
       </BaseButton>
     </div>
-    <div v-if="error" class="mt-2.5 text-sm text-gray-500 dark:text-gray-400">Erro: {{ error }}</div>
+    <div v-if="error" class="mt-2.5 text-sm text-muted-foreground">Erro: {{ error }}</div>
 
     <div v-if="results.length" class="mt-3 grid gap-2">
       <div
@@ -16,10 +16,10 @@
         class="rounded-xl border border-gray-200 bg-background/60 p-3 dark:border-gray-700 dark:bg-background/10"
       >
         <div class="flex items-center justify-between gap-3">
-          <div class="text-xs text-gray-500 dark:text-gray-400">score: {{ r.score?.toFixed?.(3) ?? r.score }}</div>
-          <div class="truncate text-xs text-gray-500 dark:text-gray-400">{{ r.source || "sem fonte" }}</div>
+          <div class="text-xs text-muted-foreground">score: {{ r.score?.toFixed?.(3) ?? r.score }}</div>
+          <div class="truncate text-xs text-muted-foreground">{{ r.source || "sem fonte" }}</div>
         </div>
-        <div class="mt-2 whitespace-pre-wrap text-sm leading-snug text-gray-900 dark:text-gray-100">{{ r.text }}</div>
+        <div class="mt-2 whitespace-pre-wrap text-sm leading-snug text-foreground">{{ r.text }}</div>
       </div>
     </div>
   </section>

+ 2 - 2
src/components/base/BaseDropdown.vue

@@ -7,8 +7,8 @@
       aria-haspopup="listbox"
       @click="alternar"
     >
-      <span class="text-gray-500 dark:text-gray-400">{{ rotulo }}</span>
-      <span class="max-w-[110px] truncate font-medium text-gray-900 dark:text-gray-100">{{ valorAtual }}</span>
+      <span class="text-muted-foreground">{{ rotulo }}</span>
+      <span class="max-w-[110px] truncate font-medium text-foreground">{{ valorAtual }}</span>
       <svg
         class="h-3.5 w-3.5 shrink-0 text-gray-400 transition-transform dark:text-gray-500"
         :class="aberto ? 'rotate-180' : ''"

+ 10 - 0
src/components/base/BaseIcon.vue

@@ -0,0 +1,10 @@
+<script setup>
+defineProps({
+  icon: { type: [Object, Function], required: true },
+  strokeWidth: { type: [String, Number], default: 1.8 },
+});
+</script>
+
+<template>
+  <component :is="icon" :stroke-width="strokeWidth" v-bind="$attrs" />
+</template>

+ 102 - 0
src/components/base/BaseModal.vue

@@ -0,0 +1,102 @@
+<script setup>
+import { onBeforeUnmount, onMounted, ref } from "vue";
+import { X } from "lucide-vue-next";
+import BaseIcon from "./BaseIcon.vue";
+
+const props = defineProps({
+  title: { type: String, default: "" },
+  maxWidth: { type: String, default: "max-w-md" },
+  closeOnBackdrop: { type: Boolean, default: true },
+  closeDisabled: { type: Boolean, default: false },
+  showClose: { type: Boolean, default: true },
+});
+
+const emit = defineEmits(["close"]);
+
+const panelRef = ref(null);
+
+function fechar() {
+  if (props.closeDisabled) return;
+  emit("close");
+}
+
+function onBackdropClick() {
+  if (props.closeOnBackdrop) fechar();
+}
+
+function onKeydown(e) {
+  if (e.key === "Escape") {
+    fechar();
+    return;
+  }
+  if (e.key !== "Tab" || !panelRef.value) return;
+
+  const focaveis = panelRef.value.querySelectorAll(
+    'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
+  );
+  if (!focaveis.length) return;
+
+  const primeiro = focaveis[0];
+  const ultimo = focaveis[focaveis.length - 1];
+
+  if (e.shiftKey && document.activeElement === primeiro) {
+    e.preventDefault();
+    ultimo.focus();
+  } else if (!e.shiftKey && document.activeElement === ultimo) {
+    e.preventDefault();
+    primeiro.focus();
+  }
+}
+
+onMounted(() => {
+  document.addEventListener("keydown", onKeydown);
+  const alvo = panelRef.value?.querySelector(
+    'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
+  );
+  (alvo ?? panelRef.value)?.focus();
+});
+
+onBeforeUnmount(() => {
+  document.removeEventListener("keydown", onKeydown);
+});
+</script>
+
+<template>
+  <Teleport to="body">
+    <div class="fixed inset-0 z-[9999] flex items-center justify-center p-4" @click="onBackdropClick">
+      <div class="absolute inset-0 bg-black/40 dark:bg-black/60" />
+      <div
+        ref="panelRef"
+        role="dialog"
+        aria-modal="true"
+        :aria-label="title || undefined"
+        tabindex="-1"
+        class="relative w-full overflow-hidden rounded-2xl border border-gray-200 bg-background text-foreground shadow-xl outline-none dark:border-gray-700"
+        :class="maxWidth"
+        @click.stop
+      >
+        <div v-if="title || showClose || $slots.header" class="flex items-center justify-between gap-3 border-b border-gray-200 px-5 py-4 dark:border-gray-700">
+          <slot name="header">
+            <h3 class="text-base font-semibold text-foreground">{{ title }}</h3>
+          </slot>
+          <button
+            v-if="showClose"
+            type="button"
+            :disabled="closeDisabled"
+            class="rounded-lg border border-transparent p-1.5 text-gray-500 transition-colors hover:border-gray-200 hover:bg-gray-100 disabled:opacity-50 dark:text-gray-400 dark:hover:border-gray-700 dark:hover:bg-white/5"
+            aria-label="Fechar"
+            @click="fechar"
+          >
+            <BaseIcon :icon="X" class="h-4 w-4" stroke-width="2" />
+          </button>
+        </div>
+
+        <slot />
+
+        <div v-if="$slots.footer" class="border-t border-gray-200 px-5 py-4 dark:border-gray-700">
+          <slot name="footer" />
+        </div>
+      </div>
+    </div>
+  </Teleport>
+</template>

+ 16 - 0
src/components/base/ChatBubble.vue

@@ -0,0 +1,16 @@
+<script setup>
+defineProps({
+  own: { type: Boolean, default: false },
+});
+</script>
+
+<template>
+  <div class="flex" :class="own ? 'justify-end' : 'justify-start'">
+    <div
+      class="max-w-[75%] rounded-2xl px-3.5 py-2 shadow-sm"
+      :class="own ? 'rounded-br-sm bg-primary/10 dark:bg-primary/20' : 'rounded-bl-sm bg-gray-100 dark:bg-gray-800'"
+    >
+      <slot />
+    </div>
+  </div>
+</template>

+ 26 - 0
src/components/base/EmptyState.vue

@@ -0,0 +1,26 @@
+<script setup>
+import BaseIcon from "./BaseIcon.vue";
+
+defineProps({
+  icon: { type: [Object, Function], default: null },
+  titulo: { type: String, required: true },
+  descricao: { type: String, default: "" },
+  compacto: { type: Boolean, default: false },
+});
+</script>
+
+<template>
+  <div class="flex flex-col items-center gap-3 text-center" :class="compacto ? 'py-4' : 'py-8'">
+    <div
+      v-if="icon"
+      class="flex h-14 w-14 items-center justify-center rounded-2xl border border-dashed border-gray-300 dark:border-gray-600"
+    >
+      <BaseIcon :icon="icon" class="h-6 w-6 text-muted-foreground" />
+    </div>
+    <div class="text-sm font-medium text-muted-foreground">{{ titulo }}</div>
+    <div v-if="descricao" class="max-w-sm text-xs text-muted-foreground">{{ descricao }}</div>
+    <div v-if="$slots.default" class="mt-1">
+      <slot />
+    </div>
+  </div>
+</template>

+ 34 - 0
src/components/base/ListItemCard.vue

@@ -0,0 +1,34 @@
+<script setup>
+defineProps({
+  clickable: { type: Boolean, default: true },
+  expanded: { type: Boolean, default: false },
+});
+defineEmits(["toggle"]);
+</script>
+
+<template>
+  <div
+    class="overflow-hidden rounded-2xl border bg-background/70 shadow-sm transition-all duration-150 dark:bg-background/20"
+    :class="
+      expanded
+        ? 'border-primary/40 shadow-md'
+        : 'border-gray-200 hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-md dark:border-gray-700'
+    "
+  >
+    <button
+      v-if="clickable"
+      type="button"
+      class="flex w-full flex-wrap items-center gap-2 rounded-2xl bg-transparent px-4 py-3.5 text-left"
+      @click="$emit('toggle')"
+    >
+      <slot name="header" />
+    </button>
+    <div v-else class="flex w-full flex-wrap items-center gap-2 px-4 py-3.5">
+      <slot name="header" />
+    </div>
+
+    <div v-if="expanded && $slots.default" class="space-y-3 border-t border-gray-100 px-4 pb-4 pt-3 text-sm dark:border-gray-800">
+      <slot />
+    </div>
+  </div>
+</template>

+ 32 - 0
src/components/base/PageHeaderCard.vue

@@ -0,0 +1,32 @@
+<script setup>
+import BaseIcon from "./BaseIcon.vue";
+
+defineProps({
+  icon: { type: [Object, Function], default: null },
+  title: { type: String, required: true },
+  description: { type: String, default: "" },
+  iconClass: { type: String, default: "bg-primary/10 text-primary dark:bg-primary/20" },
+});
+</script>
+
+<template>
+  <section class="rounded-2xl border border-gray-200 bg-gray-100/50 p-6 shadow-sm backdrop-blur-md dark:border-gray-700 dark:bg-secondary/50">
+    <div class="flex flex-wrap items-center justify-between gap-4">
+      <div class="flex items-center gap-4">
+        <div
+          v-if="icon"
+          class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl"
+          :class="iconClass"
+        >
+          <BaseIcon :icon="icon" class="h-5 w-5" />
+        </div>
+        <div class="min-w-0">
+          <h2 class="text-base font-semibold text-foreground">{{ title }}</h2>
+          <p v-if="description" class="mt-0.5 text-sm text-muted-foreground">{{ description }}</p>
+        </div>
+      </div>
+      <slot name="actions" />
+    </div>
+    <slot />
+  </section>
+</template>

+ 38 - 0
src/components/base/Spinner.vue

@@ -0,0 +1,38 @@
+<script setup>
+import { computed } from "vue";
+
+const props = defineProps({
+  size: {
+    type: String,
+    default: "md",
+    validator: (v) => ["sm", "md", "lg"].includes(v),
+  },
+  label: { type: String, default: "" },
+  center: { type: Boolean, default: false },
+});
+
+const sizeClass = computed(() => ({
+  sm: "h-3.5 w-3.5",
+  md: "h-4 w-4",
+  lg: "h-5 w-5",
+}[props.size]));
+</script>
+
+<template>
+  <div
+    role="status"
+    aria-live="polite"
+    class="inline-flex items-center gap-2 text-sm text-muted-foreground"
+    :class="center ? 'w-full justify-center py-6' : ''"
+  >
+    <svg class="animate-spin" :class="sizeClass" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+      <path
+        stroke-linecap="round"
+        stroke-linejoin="round"
+        d="M12 3v3m0 12v3m9-9h-3M6 12H3m15.364-6.364-2.121 2.121M8.757 15.243l-2.121 2.121M18.364 18.364l-2.121-2.121M8.757 8.757 6.636 6.636"
+      />
+    </svg>
+    <span v-if="label">{{ label }}</span>
+    <slot />
+  </div>
+</template>

+ 3 - 3
src/components/base/StatCard.vue

@@ -6,12 +6,12 @@
       <div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg" :class="iconWrapClass">
         <slot name="icon" />
       </div>
-      <div class="text-xs text-gray-500 dark:text-gray-400">{{ label }}</div>
+      <div class="text-xs text-muted-foreground">{{ label }}</div>
     </div>
-    <div v-if="value !== null" class="mt-2 text-2xl font-semibold text-gray-900 dark:text-gray-100">
+    <div v-if="value !== null" class="mt-2 text-2xl font-semibold text-foreground">
       {{ value }}<span v-if="suffix" class="text-sm font-normal text-gray-400">{{ suffix }}</span>
     </div>
-    <div v-if="$slots.default" class="mt-1 text-xs text-gray-500 dark:text-gray-400" :class="{ 'mt-2': value === null }">
+    <div v-if="$slots.default" class="mt-1 text-xs text-muted-foreground" :class="{ 'mt-2': value === null }">
       <slot />
     </div>
   </div>

+ 9 - 45
src/layout/HeaderSistema.vue

@@ -1,7 +1,9 @@
 <script setup>
 import { computed, ref } from "vue";
 import { useRoute, useRouter } from "vue-router";
+import { ChevronDown, Settings, LogOut } from "lucide-vue-next";
 import BotaoAlterarTema from "../components/BotaoAlterarTema.vue";
+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";
@@ -55,7 +57,7 @@ async function encerrarSessao() {
 
 <template>
   <header
-    class="col-span-full row-start-1 relative z-30 grid grid-cols-[var(--layout-sidebar-width)_1fr] items-center gap-[18px] border-b border-gray-200 bg-primary px-[18px] py-[14px] text-primary-foreground dark:border-gray-700 dark:bg-primary max-[980px]:flex"
+    class="col-span-full row-start-1 relative z-30 grid grid-cols-[var(--layout-sidebar-width)_1fr] items-center gap-[18px] border-b border-gray-200 bg-primary px-[18px] py-[14px] text-primary-foreground dark:border-gray-700 dark:bg-primary max-layout:flex"
   >
     <div class="min-w-0 overflow-hidden">
       <div
@@ -72,7 +74,7 @@ async function encerrarSessao() {
     </div>
 
     <div
-      class="min-w-0 flex items-center justify-between gap-4 max-[980px]:flex-1"
+      class="min-w-0 flex items-center justify-between gap-4 max-layout:flex-1"
     >
       <div class="min-w-0">
         <div class="truncate text-base font-medium">{{ tituloPagina }}</div>
@@ -103,20 +105,11 @@ async function encerrarSessao() {
                 {{ usuarioSetor }}
               </div>
             </div>
-            <svg
+            <BaseIcon
+              :icon="ChevronDown"
               class="hidden h-4 w-4 shrink-0 text-primary-foreground/70 transition-transform md:block"
               :class="menuUsuarioAberto ? 'rotate-180' : ''"
-              viewBox="0 0 24 24"
-              fill="none"
-              stroke="currentColor"
-              stroke-width="1.8"
-            >
-              <path
-                stroke-linecap="round"
-                stroke-linejoin="round"
-                d="m6 9 6 6 6-6"
-              />
-            </svg>
+            />
           </button>
 
           <transition
@@ -157,24 +150,7 @@ async function encerrarSessao() {
                   class="flex w-full items-center gap-2.5 rounded-lg !border-0 !bg-none !bg-transparent px-2.5 py-2 text-left text-sm font-medium !text-foreground !transform-none motion-safe:transition-colors hover:!bg-black/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:hover:!bg-white/10"
                   @click="fecharMenuUsuario(); irPara('configuracoes')"
                 >
-                  <svg
-                    class="h-5 w-5 shrink-0 text-foreground/60"
-                    viewBox="0 0 24 24"
-                    fill="none"
-                    stroke="currentColor"
-                    stroke-width="1.8"
-                  >
-                    <path
-                      stroke-linecap="round"
-                      stroke-linejoin="round"
-                      d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 0 1 1.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.894.149c-.424.07-.764.383-.929.78-.165.398-.143.854.107 1.204l.527.738c.32.447.27 1.06-.12 1.45l-.774.773a1.125 1.125 0 0 1-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.398.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.27-1.45-.12l-.773-.774a1.125 1.125 0 0 1-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.108-1.204l-.526-.738a1.125 1.125 0 0 1 .12-1.45l.773-.773a1.125 1.125 0 0 1 1.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894Z"
-                    />
-                    <path
-                      stroke-linecap="round"
-                      stroke-linejoin="round"
-                      d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
-                    />
-                  </svg>
+                  <BaseIcon :icon="Settings" class="h-5 w-5 shrink-0 text-foreground/60" />
                   <span>Configurações</span>
                 </button>
 
@@ -185,19 +161,7 @@ async function encerrarSessao() {
                   :disabled="logoutLoading"
                   @click="encerrarSessao"
                 >
-                  <svg
-                    class="h-5 w-5 shrink-0"
-                    viewBox="0 0 24 24"
-                    fill="none"
-                    stroke="currentColor"
-                    stroke-width="1.8"
-                  >
-                    <path
-                      stroke-linecap="round"
-                      stroke-linejoin="round"
-                      d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15m3 0 3-3m0 0-3-3m3 3H9"
-                    />
-                  </svg>
+                  <BaseIcon :icon="LogOut" class="h-5 w-5 shrink-0" />
                   <span>{{ logoutLoading ? "Saindo..." : "Sair" }}</span>
                 </button>
               </div>

+ 4 - 10
src/layout/LayoutSistema.vue

@@ -1,6 +1,7 @@
 <script setup>
 import HeaderSistema from "./HeaderSistema.vue";
 import SidebarSistema from "./SidebarSistema.vue";
+import DecorativeGradient from "../components/DecorativeGradient.vue";
 import { useSidebar } from "../composables/useSidebar.js";
 
 const { layoutStyle } = useSidebar();
@@ -9,24 +10,17 @@ const { layoutStyle } = useSidebar();
 <template>
   <div class="h-screen overflow-hidden bg-background text-foreground">
     <div
-      class="[--layout-header-height:5rem] relative grid h-full min-h-0 grid-cols-[var(--layout-sidebar-width)_1fr] grid-rows-[var(--layout-header-height)_1fr] overflow-hidden transition-[grid-template-columns] duration-200 ease-in-out max-[980px]:grid-cols-1 max-[980px]:grid-rows-[var(--layout-header-height)_1fr_auto]"
+      class="[--layout-header-height:5rem] relative grid h-full min-h-0 grid-cols-[var(--layout-sidebar-width)_1fr] grid-rows-[var(--layout-header-height)_1fr] overflow-hidden transition-[grid-template-columns] duration-200 ease-in-out max-layout:grid-cols-1 max-layout:grid-rows-[var(--layout-header-height)_1fr_auto]"
       :style="layoutStyle"
     >
-      <div
-        aria-hidden="true"
-        class="pointer-events-none absolute inset-0 z-0 bg-[radial-gradient(900px_520px_at_20%_10%,rgba(37,99,235,0.12),transparent_60%),radial-gradient(700px_520px_at_85%_35%,rgba(162,102,255,0.10),transparent_60%)] dark:hidden"
-      />
-      <div
-        aria-hidden="true"
-        class="pointer-events-none absolute inset-0 z-0 hidden bg-[radial-gradient(900px_520px_at_20%_10%,rgba(91,140,255,0.18),transparent_60%),radial-gradient(700px_520px_at_85%_35%,rgba(162,102,255,0.14),transparent_60%)] dark:block"
-      />
+      <DecorativeGradient />
 
       <HeaderSistema />
 
       <SidebarSistema />
 
       <main
-        class="col-start-2 row-start-2 relative z-10 flex min-h-0 min-w-0 flex-col overflow-hidden p-0 max-[980px]:col-start-1 max-[980px]:row-start-2"
+        class="col-start-2 row-start-2 relative z-10 flex min-h-0 min-w-0 flex-col overflow-hidden p-0 max-layout:col-start-1 max-layout:row-start-2"
       >
         <div class="flex-1 min-h-0 overflow-auto p-4 md:p-6">
           <slot />

+ 5 - 34
src/layout/ListaConversas.vue

@@ -54,7 +54,7 @@ async function abrirConversa(id) {
 </script>
 
 <template>
-  <div class="conv-scroll mt-0.5 -mx-0.5 px-0.5 flex-1 min-h-0 overflow-y-auto">
+  <div class="scroll-thin mt-0.5 -mx-0.5 px-0.5 flex-1 min-h-0 overflow-y-auto">
     <div v-if="conversationsLoading" class="py-1">
       <SkeletonConversation :count="5" />
     </div>
@@ -115,10 +115,10 @@ async function abrirConversa(id) {
         <template v-else>
           <span class="flex-1 min-w-0 truncate leading-snug" :title="c.title">{{ c.title }}</span>
           <div
-            class="absolute right-0 top-0 h-full flex items-center gap-0.5 pr-2 pl-16 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 max-[760px]:opacity-100"
+            class="absolute right-0 top-0 h-full flex items-center gap-0.5 pr-2 pl-16 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 max-whatsapp:opacity-100"
             :class="String(activeConversationId) === c.id
-              ? 'bg-gradient-to-l from-gray-200 from-60% dark:from-[#131f30] to-transparent'
-              : 'bg-gradient-to-l from-gray-100 from-60% dark:from-[#101827] to-transparent'"
+              ? 'bg-gradient-to-l from-gray-200 from-60% dark:from-abyss-800 to-transparent'
+              : 'bg-gradient-to-l from-gray-100 from-60% dark:from-abyss-900 to-transparent'"
             @click.stop
             @keydown.stop
           >
@@ -148,7 +148,7 @@ async function abrirConversa(id) {
 
           <div
             v-if="confirmandoExclusaoId === c.id"
-            class="absolute inset-0 flex items-center justify-end gap-1.5 rounded-xl bg-gray-200/95 px-3 dark:bg-[#182335]"
+            class="absolute inset-0 flex items-center justify-end gap-1.5 rounded-xl bg-gray-200/95 px-3 dark:bg-abyss-700"
             @click.stop
             @keydown.stop
           >
@@ -190,32 +190,3 @@ async function abrirConversa(id) {
     </button>
   </div>
 </template>
-
-<style scoped>
-.conv-scroll {
-  scrollbar-width: thin;
-  scrollbar-color: rgba(0, 0, 0, 0.15) transparent;
-}
-.conv-scroll::-webkit-scrollbar {
-  width: 3px;
-}
-.conv-scroll::-webkit-scrollbar-track {
-  background: transparent;
-}
-.conv-scroll::-webkit-scrollbar-thumb {
-  background: rgba(0, 0, 0, 0.15);
-  border-radius: 999px;
-}
-.conv-scroll::-webkit-scrollbar-thumb:hover {
-  background: rgba(0, 0, 0, 0.25);
-}
-:is(.dark) .conv-scroll {
-  scrollbar-color: rgba(255, 255, 255, 0.12) transparent;
-}
-:is(.dark) .conv-scroll::-webkit-scrollbar-thumb {
-  background: rgba(255, 255, 255, 0.12);
-}
-:is(.dark) .conv-scroll::-webkit-scrollbar-thumb:hover {
-  background: rgba(255, 255, 255, 0.22);
-}
-</style>

+ 1 - 1
src/layout/ModalDocumentos.vue

@@ -35,7 +35,7 @@ const { docs, loadDocs, onIngested } = useDocumentos();
               class="flex items-center justify-between gap-3 border-b border-gray-200 px-5 py-4 dark:border-gray-700"
             >
               <div
-                class="text-base font-semibold text-gray-900 dark:text-gray-100"
+                class="text-base font-semibold text-foreground"
               >
                 {{ tituloModal }}
               </div>

+ 28 - 133
src/layout/SidebarSistema.vue

@@ -1,7 +1,22 @@
 <script setup>
 import { computed } from "vue";
 import { useRoute, useRouter } from "vue-router";
+import {
+  ChevronLeft,
+  ChevronRight,
+  Plus,
+  History,
+  Search,
+  MessageCircle,
+  Upload,
+  FileText,
+  Star,
+  TriangleAlert,
+  CircleCheck,
+  SlidersHorizontal,
+} from "lucide-vue-next";
 import ListaConversas from "./ListaConversas.vue";
+import BaseIcon from "../components/base/BaseIcon.vue";
 import { useAuth } from "../composables/useAuth.js";
 import { useChat } from "../composables/useChat.js";
 import { useSidebar } from "../composables/useSidebar.js";
@@ -29,10 +44,10 @@ async function novaConversa() {
 
 <template>
   <aside
-    class="col-start-1 row-start-2 relative z-10 flex min-h-0 min-w-0 flex-col gap-3 overflow-hidden border-r border-gray-200 bg-transparent p-2 dark:border-gray-700 max-[980px]:row-start-3 max-[980px]:border-r-0 max-[980px]:border-t"
+    class="col-start-1 row-start-2 relative z-10 flex min-h-0 min-w-0 flex-col gap-3 overflow-hidden border-r border-gray-200 bg-transparent p-2 dark:border-gray-700 max-layout:row-start-3 max-layout:border-r-0 max-layout:border-t"
   >
     <div
-      class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-[28px] border border-gray-200 bg-white/70 p-3 shadow-sm backdrop-blur-md dark:border-white/10 dark:bg-[#101827] dark:shadow-[0_18px_50px_rgba(0,0,0,0.35)]"
+      class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-[28px] border border-gray-200 bg-white/70 p-3 shadow-sm backdrop-blur-md dark:border-white/10 dark:bg-abyss-900 dark:shadow-[0_18px_50px_rgba(0,0,0,0.35)]"
     >
       <div
         :class="
@@ -50,34 +65,7 @@ async function novaConversa() {
           :title="sidebarRecolhida ? 'Expandir sidebar' : 'Recolher sidebar'"
           @click="sidebarRecolhida = !sidebarRecolhida"
         >
-          <svg
-            v-if="sidebarRecolhida"
-            class="h-5 w-5"
-            viewBox="0 0 24 24"
-            fill="none"
-            stroke="currentColor"
-            stroke-width="1.8"
-          >
-            <path
-              stroke-linecap="round"
-              stroke-linejoin="round"
-              d="m9 6 6 6-6 6"
-            />
-          </svg>
-          <svg
-            v-else
-            class="h-5 w-5"
-            viewBox="0 0 24 24"
-            fill="none"
-            stroke="currentColor"
-            stroke-width="1.8"
-          >
-            <path
-              stroke-linecap="round"
-              stroke-linejoin="round"
-              d="m15 6-6 6 6 6"
-            />
-          </svg>
+          <BaseIcon :icon="sidebarRecolhida ? ChevronRight : ChevronLeft" class="h-5 w-5" />
         </button>
       </div>
 
@@ -94,15 +82,7 @@ async function novaConversa() {
           :title="sidebarRecolhida ? 'Nova conversa' : ''"
           @click="novaConversa"
         >
-          <svg
-            :class="classeIcon(rotaAtiva === 'home' && !activeConversationId)"
-            viewBox="0 0 24 24"
-            fill="none"
-            stroke="currentColor"
-            stroke-width="1.8"
-          >
-            <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
-          </svg>
+          <BaseIcon :icon="Plus" :class="classeIcon(rotaAtiva === 'home' && !activeConversationId)" />
           <span :class="classeRotulo">Nova conversa</span>
         </button>
 
@@ -112,16 +92,7 @@ async function novaConversa() {
           :title="sidebarRecolhida ? 'Histórico' : ''"
           @click="irPara('conversas-historico')"
         >
-          <svg
-            :class="classeIcon(rotaAtiva === 'conversas-historico')"
-            viewBox="0 0 24 24"
-            fill="none"
-            stroke="currentColor"
-            stroke-width="1.8"
-          >
-            <path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6l4 2" />
-            <path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 1 1-3.2-6.9" />
-          </svg>
+          <BaseIcon :icon="History" :class="classeIcon(rotaAtiva === 'conversas-historico')" />
           <span :class="classeRotulo">Histórico</span>
         </button>
 
@@ -131,15 +102,7 @@ async function novaConversa() {
           :title="sidebarRecolhida ? 'Pesquisar' : ''"
           @click="irPara('conversas-pesquisar')"
         >
-          <svg
-            :class="classeIcon(rotaAtiva === 'conversas-pesquisar')"
-            viewBox="0 0 24 24"
-            fill="none"
-            stroke="currentColor"
-            stroke-width="1.8"
-          >
-            <path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
-          </svg>
+          <BaseIcon :icon="Search" :class="classeIcon(rotaAtiva === 'conversas-pesquisar')" />
           <span :class="classeRotulo">Pesquisar</span>
         </button>
 
@@ -149,15 +112,7 @@ async function novaConversa() {
           :title="sidebarRecolhida ? 'Atendimento WhatsApp' : ''"
           @click="irPara('conversas-whatsapp')"
         >
-          <svg
-            :class="classeIcon(rotaAtiva === 'conversas-whatsapp')"
-            viewBox="0 0 24 24"
-            fill="none"
-            stroke="currentColor"
-            stroke-width="1.8"
-          >
-            <path stroke-linecap="round" stroke-linejoin="round" d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
-          </svg>
+          <BaseIcon :icon="MessageCircle" :class="classeIcon(rotaAtiva === 'conversas-whatsapp')" />
           <span :class="classeRotulo">Atendimento WhatsApp</span>
         </button>
       </nav>
@@ -182,17 +137,7 @@ async function novaConversa() {
             :title="sidebarRecolhida ? 'Carregar documento' : ''"
             @click="irPara('documentos-carregar')"
           >
-            <svg
-              :class="classeIcon(rotaAtiva === 'documentos-carregar')"
-              viewBox="0 0 24 24"
-              fill="none"
-              stroke="currentColor"
-              stroke-width="1.8"
-            >
-              <path stroke-linecap="round" stroke-linejoin="round" d="M12 3v12" />
-              <path stroke-linecap="round" stroke-linejoin="round" d="M7.5 7.5 12 3l4.5 4.5" />
-              <path stroke-linecap="round" stroke-linejoin="round" d="M4 21h16" />
-            </svg>
+            <BaseIcon :icon="Upload" :class="classeIcon(rotaAtiva === 'documentos-carregar')" />
             <span :class="classeRotulo">Carregar documento</span>
           </button>
 
@@ -202,17 +147,7 @@ async function novaConversa() {
             :title="sidebarRecolhida ? 'Documentos' : ''"
             @click="irPara('documentos')"
           >
-            <svg
-              :class="classeIcon(rotaAtiva === 'documentos')"
-              viewBox="0 0 24 24"
-              fill="none"
-              stroke="currentColor"
-              stroke-width="1.8"
-            >
-              <path stroke-linecap="round" stroke-linejoin="round" d="M7 3h7l3 3v15a.75.75 0 0 1-.75.75H7.75A.75.75 0 0 1 7 21V3Z" />
-              <path stroke-linecap="round" stroke-linejoin="round" d="M14 3v4h4" />
-              <path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6M9 15.5h6" />
-            </svg>
+            <BaseIcon :icon="FileText" :class="classeIcon(rotaAtiva === 'documentos')" />
             <span :class="classeRotulo">Documentos</span>
           </button>
 
@@ -222,15 +157,7 @@ async function novaConversa() {
             :title="sidebarRecolhida ? 'Avaliações' : ''"
             @click="irPara('avaliacoes')"
           >
-            <svg
-              :class="classeIcon(rotaAtiva === 'avaliacoes')"
-              viewBox="0 0 24 24"
-              fill="none"
-              stroke="currentColor"
-              stroke-width="1.8"
-            >
-              <path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.5c.16-.4.88-.4 1.04 0l1.98 4.88 5.25.4c.43.03.6.57.28.85l-4 3.46 1.22 5.13c.1.42-.36.75-.73.52L12 15.98l-4.52 2.76c-.37.23-.83-.1-.73-.52l1.22-5.13-4-3.46c-.33-.28-.15-.82.28-.85l5.25-.4 1.98-4.88Z" />
-            </svg>
+            <BaseIcon :icon="Star" :class="classeIcon(rotaAtiva === 'avaliacoes')" />
             <span :class="classeRotulo">Avaliações</span>
           </button>
 
@@ -240,15 +167,7 @@ async function novaConversa() {
             :title="sidebarRecolhida ? 'Não resolvidos' : ''"
             @click="irPara('atendimentos-nao-resolvidos')"
           >
-            <svg
-              :class="classeIcon(rotaAtiva === 'atendimentos-nao-resolvidos')"
-              viewBox="0 0 24 24"
-              fill="none"
-              stroke="currentColor"
-              stroke-width="1.8"
-            >
-              <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v4m0 4h.01M10.29 3.86 1.82 18a1.5 1.5 0 0 0 1.3 2.25h17.76a1.5 1.5 0 0 0 1.3-2.25L13.71 3.86a1.5 1.5 0 0 0-2.42 0Z" />
-            </svg>
+            <BaseIcon :icon="TriangleAlert" :class="classeIcon(rotaAtiva === 'atendimentos-nao-resolvidos')" />
             <span :class="classeRotulo">Não resolvidos</span>
           </button>
 
@@ -258,15 +177,7 @@ async function novaConversa() {
             :title="sidebarRecolhida ? 'Golden set' : ''"
             @click="irPara('golden-set')"
           >
-            <svg
-              :class="classeIcon(rotaAtiva === 'golden-set')"
-              viewBox="0 0 24 24"
-              fill="none"
-              stroke="currentColor"
-              stroke-width="1.8"
-            >
-              <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
-            </svg>
+            <BaseIcon :icon="CircleCheck" :class="classeIcon(rotaAtiva === 'golden-set')" />
             <span :class="classeRotulo">Golden set</span>
           </button>
 
@@ -285,23 +196,7 @@ async function novaConversa() {
           :title="sidebarRecolhida ? 'Configurações' : ''"
           @click="irPara('configuracoes')"
         >
-          <svg
-            :class="classeIcon(rotaAtiva === 'configuracoes')"
-            viewBox="0 0 24 24"
-            fill="none"
-            stroke="currentColor"
-            stroke-width="1.8"
-          >
-            <path stroke-linecap="round" stroke-linejoin="round" d="M4 21v-7" />
-            <path stroke-linecap="round" stroke-linejoin="round" d="M4 10V3" />
-            <path stroke-linecap="round" stroke-linejoin="round" d="M12 21v-9" />
-            <path stroke-linecap="round" stroke-linejoin="round" d="M12 8V3" />
-            <path stroke-linecap="round" stroke-linejoin="round" d="M20 21v-5" />
-            <path stroke-linecap="round" stroke-linejoin="round" d="M20 12V3" />
-            <path stroke-linecap="round" stroke-linejoin="round" d="M1 14h6" />
-            <path stroke-linecap="round" stroke-linejoin="round" d="M9 8h6" />
-            <path stroke-linecap="round" stroke-linejoin="round" d="M17 16h6" />
-          </svg>
+          <BaseIcon :icon="SlidersHorizontal" :class="classeIcon(rotaAtiva === 'configuracoes')" />
           <span :class="classeRotulo">Configurações</span>
         </button>
       </div>

+ 7 - 0
src/main.js

@@ -1,5 +1,12 @@
 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";
 import "@/styles/tailwind.css";
 import "@/styles/highlight-theme.css";
 import "@/styles/toast-theme.css";

+ 52 - 27
src/styles/tailwind.css

@@ -1,5 +1,4 @@
 @import "tailwindcss";
-@import "tailwindcss-primeui";
 
 @layer theme, base, components, utilities, overrides;
 
@@ -40,7 +39,7 @@
   --secondary: oklch(0.3807 0.1708 261.58);
   --secondary-foreground: oklch(0.95 0 0);
   --muted: oklch(0.9461 0 0);
-  --muted-foreground: oklch(0.2435 0 0);
+  --muted-foreground: oklch(0.551 0.014 264.4);
   --accent: oklch(0.34 0.12 262);
   --accent-foreground: oklch(0.98 0 0);
   --destructive: oklch(0.5523 0.1927 32.7272);
@@ -148,31 +147,6 @@ body {
   --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);
-
-  --p-primary-50: var(--primary-50);
-  --p-primary-100: var(--primary-100);
-  --p-primary-200: var(--primary-200);
-  --p-primary-300: var(--primary-300);
-  --p-primary-400: var(--primary-400);
-  --p-primary-500: var(--primary-500);
-  --p-primary-600: var(--primary-600);
-  --p-primary-700: var(--primary-700);
-  --p-primary-800: var(--primary-800);
-  --p-primary-900: var(--primary-900);
-  --p-primary-950: var(--primary-950);
-  --p-primary-color: var(--primary-500);
-  --p-primary-contrast-color: var(--primary-foreground);
-  --p-primary-hover-color: var(--primary-600);
-  --p-primary-active-color: var(--primary-700);
-  --p-button-primary-background: var(--p-primary-color);
-  --p-button-primary-hover-background: var(--p-primary-hover-color);
-  --p-button-primary-active-background: var(--p-primary-active-color);
-  --p-button-primary-border-color: var(--p-primary-color);
-  --p-button-primary-hover-border-color: var(--p-primary-hover-color);
-  --p-button-primary-active-border-color: var(--p-primary-active-color);
-  --p-button-primary-color: var(--p-primary-contrast-color);
-  --p-button-primary-hover-color: var(--p-primary-contrast-color);
-  --p-button-primary-active-color: var(--p-primary-contrast-color);
 }
 
 @theme inline {
@@ -233,6 +207,16 @@ body {
   --tracking-wide: calc(var(--tracking-normal) + 0.025em);
   --tracking-wider: calc(var(--tracking-normal) + 0.05em);
   --tracking-widest: calc(var(--tracking-normal) + 0.1em);
+
+
+  --color-abyss-950: #070b16;
+  --color-abyss-900: #101827;
+  --color-abyss-800: #131f30;
+  --color-abyss-700: #182335;
+
+  --breakpoint-whatsapp: 760px;
+  --breakpoint-layout: 980px;
+  --breakpoint-compact: 520px;
 }
 
 @layer base {
@@ -288,6 +272,17 @@ body {
     border-color: var(--ring);
     box-shadow: 0 0 0 3px color-mix(in oklab, var(--ring) 18%, transparent);
   }
+
+  /* fallback global de foco por teclado — cobre qualquer elemento focável que não
+     defina seu próprio focus-visible:ring explícito (ex.: itens de lista com role="button") */
+  a:focus-visible,
+  button:focus-visible,
+  summary:focus-visible,
+  [tabindex]:not([tabindex="-1"]):focus-visible {
+    outline: 2px solid var(--ring);
+    outline-offset: 2px;
+    border-radius: var(--radius-sm);
+  }
 }
 
 @layer components {
@@ -438,3 +433,33 @@ body {
     border-radius: 0;
   }
 }
+
+@layer utilities {
+  /* scrollbar fina usada em listas/painéis com overflow (sidebar, whatsapp, chat) */
+  .scroll-thin {
+    scrollbar-width: thin;
+    scrollbar-color: rgba(0, 0, 0, 0.15) transparent;
+  }
+  .scroll-thin::-webkit-scrollbar {
+    width: 3px;
+  }
+  .scroll-thin::-webkit-scrollbar-track {
+    background: transparent;
+  }
+  .scroll-thin::-webkit-scrollbar-thumb {
+    background: rgba(0, 0, 0, 0.15);
+    border-radius: 999px;
+  }
+  .scroll-thin::-webkit-scrollbar-thumb:hover {
+    background: rgba(0, 0, 0, 0.25);
+  }
+  .dark .scroll-thin {
+    scrollbar-color: rgba(255, 255, 255, 0.12) transparent;
+  }
+  .dark .scroll-thin::-webkit-scrollbar-thumb {
+    background: rgba(255, 255, 255, 0.12);
+  }
+  .dark .scroll-thin::-webkit-scrollbar-thumb:hover {
+    background: rgba(255, 255, 255, 0.22);
+  }
+}

+ 73 - 60
src/views/atendimentos/AtendimentoView.vue

@@ -4,8 +4,9 @@ import { useRoute, useRouter } from "vue-router";
 import LayoutSistema from "../../layout/LayoutSistema.vue";
 import BaseBadge from "../../components/base/BaseBadge.vue";
 import BaseButton from "../../components/base/BaseButton.vue";
+import ChatBubble from "../../components/base/ChatBubble.vue";
 import ModalMidia from "../../components/ModalMidia.vue";
-import { obterAtendimento, salvarGoldenLabel, sincronizarAtendimento, avaliarAtendimento } from "../../api/atendimentos.js";
+import { obterAtendimento, salvarGoldenLabel, aceitarGoldenLabelComoIa, sincronizarAtendimento, avaliarAtendimento } from "../../api/atendimentos.js";
 import { useAuth } from "../../composables/useAuth.js";
 import {
   cancelamentoLabel,
@@ -97,9 +98,25 @@ async function salvarRotulo() {
   }
 }
 
-// volta para a lista de origem (Avaliações ou Não resolvidos) preservando página, filtros
-// e item aberto (guardados na query string da rota anterior); se o gestor chegou por link
-// direto, não há histórico para voltar — vai para a lista de avaliações limpa
+const aceitando = ref(false);
+
+
+async function aceitarComoIa() {
+  if (aceitando.value || salvando.value || !atendimento.value) return;
+  aceitando.value = true;
+  try {
+    const atualizado = await aceitarGoldenLabelComoIa(atendimento.value.Id);
+    atendimento.value = { ...atendimento.value, goldenLabel: atualizado };
+    form.value = criarFormulario(atualizado);
+    toast.sucesso("Avaliação da IA aceita como rótulo.");
+  } catch (err) {
+    toast.erro(err?.message ?? "Falha ao aceitar a avaliação da IA.");
+  } finally {
+    aceitando.value = false;
+  }
+}
+
+
 const ROTAS_ORIGEM = ["/atendimentos/avaliacoes", "/atendimentos/nao-resolvidos", "/atendimentos/golden-set"];
 
 function voltar() {
@@ -240,13 +257,13 @@ watch(() => route.params.id, carregar, { immediate: true });
                 </svg>
               </button>
               <div class="min-w-0">
-                <h2 class="text-base font-semibold text-gray-900 dark:text-gray-100">
+                <h2 class="text-base font-semibold text-foreground">
                   {{ atendimento?.Codigo ?? `Atendimento #${route.params.id}` }}
-                  <span v-if="atendimento?.cliente?.Nome" class="font-normal text-gray-500 dark:text-gray-400">
+                  <span v-if="atendimento?.cliente?.Nome" class="font-normal text-muted-foreground">
                     — {{ atendimento.cliente.Nome }}
                   </span>
                 </h2>
-                <p v-if="atendimento" class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">
+                <p v-if="atendimento" class="mt-0.5 text-sm text-muted-foreground">
                   <span v-if="atendimento.Setor">{{ atendimento.Setor }} · </span>{{ formatData(atendimento.Abertura) }}
                   · {{ mensagens.length }} mensagens
                 </p>
@@ -291,12 +308,12 @@ watch(() => route.params.id, carregar, { immediate: true });
             </div>
           </div>
 
-          <p v-if="avaliacao?.Resumo" class="mt-4 text-sm text-gray-600 dark:text-gray-300">
-            <span class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Resumo da IA: </span>
+          <p v-if="avaliacao?.Resumo" class="mt-4 text-sm text-muted-foreground">
+            <span class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Resumo da IA: </span>
             {{ avaliacao.Resumo }}
           </p>
-          <p v-if="avaliacao?.MotivoCancelamento" class="mt-1 text-sm text-gray-600 dark:text-gray-300">
-            <span class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Motivo do cancelamento: </span>
+          <p v-if="avaliacao?.MotivoCancelamento" class="mt-1 text-sm text-muted-foreground">
+            <span class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Motivo do cancelamento: </span>
             {{ avaliacao.MotivoCancelamento }}
           </p>
         </section>
@@ -306,11 +323,11 @@ watch(() => route.params.id, carregar, { immediate: true });
             Conversa completa
           </div>
 
-          <div v-if="carregando" class="p-6 text-sm text-gray-500 dark:text-gray-400">Carregando conversa...</div>
+          <div v-if="carregando" class="p-6 text-sm text-muted-foreground">Carregando conversa...</div>
           <div v-else-if="erro" class="m-4 rounded-lg bg-red-50 px-4 py-3 text-xs text-red-800 dark:bg-red-900/30 dark:text-red-300">
             {{ erro }}
           </div>
-          <div v-else-if="mensagens.length === 0" class="p-6 text-sm text-gray-500 dark:text-gray-400">
+          <div v-else-if="mensagens.length === 0" class="p-6 text-sm text-muted-foreground">
             Este atendimento não tem mensagens com conteúdo para exibir.
           </div>
 
@@ -323,37 +340,29 @@ watch(() => route.params.id, carregar, { immediate: true });
               </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-gray-400 dark:text-gray-500">
+                <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>
                 </div>
               </div>
 
-              <div v-else class="flex" :class="m.Papel === 'atendente' ? 'justify-end' : 'justify-start'">
-                <div
-                  class="max-w-[75%] rounded-2xl px-3.5 py-2 shadow-sm"
-                  :class="
-                    m.Papel === 'atendente'
-                      ? 'rounded-br-sm bg-primary/10 dark:bg-primary/20'
-                      : 'rounded-bl-sm bg-gray-100 dark:bg-gray-800'
-                  "
-                >
+              <ChatBubble v-else :own="m.Papel === 'atendente'">
                   <div
                     class="text-xs font-semibold"
-                    :class="m.Papel === 'atendente' ? 'text-primary' : 'text-gray-500 dark:text-gray-400'"
+                    :class="m.Papel === 'atendente' ? 'text-primary' : 'text-muted-foreground'"
                   >
                     {{ autorLabel(m) }}
                   </div>
                   <template v-if="ehAudio(m)">
-                    <div class="mt-1 flex items-center gap-1.5 text-xs font-medium text-gray-500 dark:text-gray-400">
+                    <div class="mt-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
                       <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
                         <path stroke-linecap="round" stroke-linejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 1 1 6 0v8.25a3 3 0 0 1-3 3Z" />
                       </svg>
                       Mensagem de áudio
                     </div>
-                    <p v-if="m.Transcricao" class="mt-1 whitespace-pre-wrap break-words text-sm text-gray-800 dark:text-gray-200">
+                    <p v-if="m.Transcricao" class="mt-1 whitespace-pre-wrap break-words text-sm text-foreground">
                       {{ m.Transcricao }}
                     </p>
-                    <p v-else class="mt-1 text-sm italic text-gray-400 dark:text-gray-500">Sem transcrição disponível</p>
+                    <p v-else class="mt-1 text-sm italic text-muted-foreground">Sem transcrição disponível</p>
                     <audio v-if="m.MidiaUrl" controls preload="none" :src="m.MidiaUrl" class="mt-1.5 h-9 w-64 max-w-full"></audio>
                     <div
                       v-else-if="caminhoMidia(m)"
@@ -388,7 +397,7 @@ watch(() => route.params.id, carregar, { immediate: true });
                       </div>
                     </div>
                   </template>
-                  <p v-else class="mt-0.5 whitespace-pre-wrap break-words text-sm text-gray-800 dark:text-gray-200">{{ m.Texto }}</p>
+                  <p v-else class="mt-0.5 whitespace-pre-wrap break-words text-sm text-foreground">{{ m.Texto }}</p>
                   <a
                     v-if="midiaLink(m)"
                     :href="midiaLink(m)"
@@ -398,9 +407,8 @@ watch(() => route.params.id, carregar, { immediate: true });
                   >
                     Abrir mídia
                   </a>
-                  <div class="mt-0.5 text-right text-[10px] text-gray-400 dark:text-gray-500">{{ formatHora(m.Timestamp) }}</div>
-                </div>
-              </div>
+                  <div class="mt-0.5 text-right text-[10px] text-muted-foreground">{{ formatHora(m.Timestamp) }}</div>
+              </ChatBubble>
             </template>
           </div>
         </section>
@@ -410,15 +418,17 @@ watch(() => route.params.id, carregar, { immediate: true });
           class="rounded-2xl border border-gray-200 bg-background/70 shadow-sm dark:border-gray-700 dark:bg-background/20"
         >
           <div class="flex items-center justify-between gap-3 border-b border-gray-200 px-4 py-3 dark:border-gray-700">
-            <div class="text-sm font-semibold text-gray-900 dark:text-gray-100">Rotulagem manual (golden set)</div>
-            <BaseBadge :variant="goldenLabel.Status === 'rotulado' ? 'success' : 'warning'">
-              {{ goldenLabel.Status === "rotulado" ? "Rotulado" : "Pendente" }}
+            <div class="text-sm font-semibold text-foreground">Rotulagem manual (golden set)</div>
+            <BaseBadge
+              :variant="goldenLabel.Status === 'rotulado' ? 'success' : goldenLabel.Status === 'aceito' ? 'info' : 'warning'"
+            >
+              {{ goldenLabel.Status === "rotulado" ? "Rotulado" : goldenLabel.Status === "aceito" ? "Aceito (IA)" : "Pendente" }}
             </BaseBadge>
           </div>
 
           <div class="grid grid-cols-1 gap-5 p-4 sm:grid-cols-2">
             <div>
-              <label class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
+              <label class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
                 Nota do atendente (1-10)
               </label>
               <div class="mt-1 flex items-center gap-2">
@@ -430,18 +440,18 @@ watch(() => route.params.id, carregar, { immediate: true });
                   :disabled="form.SemAtendente"
                   class="w-24 rounded-lg border border-gray-200 bg-background px-3 py-1.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:opacity-50 dark:border-gray-700 dark:text-gray-200"
                 />
-                <label class="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
+                <label class="flex items-center gap-1.5 text-xs text-muted-foreground">
                   <input v-model="form.SemAtendente" type="checkbox" class="rounded" />
                   Sem atendente humano
                 </label>
               </div>
-              <span class="mt-1 block text-xs text-gray-400 dark:text-gray-500">
+              <span class="mt-1 block text-xs text-muted-foreground">
                 IA: {{ avaliacao?.ScoreAtendente ?? "sem nota" }}
               </span>
             </div>
 
             <div>
-              <label class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Resolvido</label>
+              <label class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Resolvido</label>
               <select
                 v-model="form.Resolvido"
                 class="mt-1 w-full rounded-lg border border-gray-200 bg-background px-3 py-1.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-primary/30 dark:border-gray-700 dark:text-gray-200"
@@ -449,13 +459,13 @@ watch(() => route.params.id, carregar, { immediate: true });
                 <option value="">-- não rotulado --</option>
                 <option v-for="(label, value) in resolvidoLabel" :key="value" :value="value">{{ label }}</option>
               </select>
-              <span class="mt-1 block text-xs text-gray-400 dark:text-gray-500">
+              <span class="mt-1 block text-xs text-muted-foreground">
                 IA: {{ resolvidoLabel[avaliacao?.Resolvido] ?? "—" }}
               </span>
             </div>
 
             <div>
-              <label class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Sentimento do cliente</label>
+              <label class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Sentimento do cliente</label>
               <select
                 v-model="form.Sentimento"
                 class="mt-1 w-full rounded-lg border border-gray-200 bg-background px-3 py-1.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-primary/30 dark:border-gray-700 dark:text-gray-200"
@@ -463,13 +473,13 @@ watch(() => route.params.id, carregar, { immediate: true });
                 <option value="">-- não rotulado --</option>
                 <option v-for="(label, value) in sentimentoLabel" :key="value" :value="value">{{ label }}</option>
               </select>
-              <span class="mt-1 block text-xs text-gray-400 dark:text-gray-500">
+              <span class="mt-1 block text-xs text-muted-foreground">
                 IA: {{ sentimentoLabel[avaliacao?.Sentimento] ?? "—" }}
               </span>
             </div>
 
             <div>
-              <label class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Cancelamento</label>
+              <label class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Cancelamento</label>
               <select
                 v-model="form.StatusCancelamento"
                 class="mt-1 w-full rounded-lg border border-gray-200 bg-background px-3 py-1.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-primary/30 dark:border-gray-700 dark:text-gray-200"
@@ -477,13 +487,13 @@ watch(() => route.params.id, carregar, { immediate: true });
                 <option value="">-- não rotulado --</option>
                 <option v-for="(label, value) in statusCancelamentoLabel" :key="value" :value="value">{{ label }}</option>
               </select>
-              <span class="mt-1 block text-xs text-gray-400 dark:text-gray-500">
+              <span class="mt-1 block text-xs text-muted-foreground">
                 IA: {{ cancelamentoLabel[avaliacao?.StatusCancelamento] ?? "Não" }}
               </span>
             </div>
 
             <div>
-              <label class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Visita agendada</label>
+              <label class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Visita agendada</label>
               <select
                 v-model="form.VisitaAgendada"
                 class="mt-1 w-full rounded-lg border border-gray-200 bg-background px-3 py-1.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-primary/30 dark:border-gray-700 dark:text-gray-200"
@@ -492,13 +502,13 @@ watch(() => route.params.id, carregar, { immediate: true });
                 <option value="sim">Sim</option>
                 <option value="nao">Não</option>
               </select>
-              <span class="mt-1 block text-xs text-gray-400 dark:text-gray-500">
+              <span class="mt-1 block text-xs text-muted-foreground">
                 IA: {{ avaliacao?.VisitaAgendada === true ? "Sim" : avaliacao?.VisitaAgendada === false ? "Não" : "—" }}
               </span>
             </div>
 
             <div>
-              <label class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Horário de visita informado</label>
+              <label class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Horário de visita informado</label>
               <select
                 v-model="form.HorarioVisitaInformado"
                 class="mt-1 w-full rounded-lg border border-gray-200 bg-background px-3 py-1.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-primary/30 dark:border-gray-700 dark:text-gray-200"
@@ -506,24 +516,24 @@ watch(() => route.params.id, carregar, { immediate: true });
                 <option value="">-- não rotulado --</option>
                 <option v-for="(label, value) in HORARIO_VISITA_INFORMADO_LABEL" :key="value" :value="value">{{ label }}</option>
               </select>
-              <span class="mt-1 block text-xs text-gray-400 dark:text-gray-500">
+              <span class="mt-1 block text-xs text-muted-foreground">
                 IA: {{ HORARIO_VISITA_INFORMADO_LABEL[avaliacao?.HorarioVisitaInformado] ?? "—" }}
               </span>
             </div>
 
             <div>
-              <label class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Horário da visita</label>
+              <label class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Horário da visita</label>
               <input
                 v-model="form.HorarioVisita"
                 type="text"
                 :disabled="form.HorarioVisitaInformado !== 'sim'"
                 class="mt-1 w-full rounded-lg border border-gray-200 bg-background px-3 py-1.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:opacity-50 dark:border-gray-700 dark:text-gray-200"
               />
-              <span class="mt-1 block text-xs text-gray-400 dark:text-gray-500">IA: {{ avaliacao?.HorarioVisita || "—" }}</span>
+              <span class="mt-1 block text-xs text-muted-foreground">IA: {{ avaliacao?.HorarioVisita || "—" }}</span>
             </div>
 
             <div>
-              <label class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
+              <label class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
                 Atendentes (separados por vírgula)
               </label>
               <input
@@ -531,35 +541,35 @@ watch(() => route.params.id, carregar, { immediate: true });
                 type="text"
                 class="mt-1 w-full rounded-lg border border-gray-200 bg-background px-3 py-1.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-primary/30 dark:border-gray-700 dark:text-gray-200"
               />
-              <span class="mt-1 block text-xs text-gray-400 dark:text-gray-500">
+              <span class="mt-1 block text-xs text-muted-foreground">
                 IA: {{ avaliacao?.Atendentes?.join(", ") || "—" }}
               </span>
             </div>
 
             <div class="sm:col-span-2">
-              <label class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Justificativa da nota</label>
+              <label class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Justificativa da nota</label>
               <textarea
                 v-model="form.JustificativaScore"
                 rows="2"
                 class="mt-1 w-full rounded-lg border border-gray-200 bg-background px-3 py-1.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-primary/30 dark:border-gray-700 dark:text-gray-200"
               ></textarea>
-              <p v-if="avaliacao?.JustificativaScore" class="mt-1 text-xs text-gray-400 dark:text-gray-500">
+              <p v-if="avaliacao?.JustificativaScore" class="mt-1 text-xs text-muted-foreground">
                 IA: {{ avaliacao.JustificativaScore }}
               </p>
             </div>
 
             <div class="sm:col-span-2">
-              <label class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Resumo</label>
+              <label class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Resumo</label>
               <textarea
                 v-model="form.Resumo"
                 rows="3"
                 class="mt-1 w-full rounded-lg border border-gray-200 bg-background px-3 py-1.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-primary/30 dark:border-gray-700 dark:text-gray-200"
               ></textarea>
-              <p v-if="avaliacao?.Resumo" class="mt-1 text-xs text-gray-400 dark:text-gray-500">IA: {{ avaliacao.Resumo }}</p>
+              <p v-if="avaliacao?.Resumo" class="mt-1 text-xs text-muted-foreground">IA: {{ avaliacao.Resumo }}</p>
             </div>
 
             <div class="sm:col-span-2">
-              <label class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
+              <label class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
                 Sugestões de melhoria (uma por linha)
               </label>
               <textarea
@@ -567,26 +577,29 @@ watch(() => route.params.id, carregar, { immediate: true });
                 rows="3"
                 class="mt-1 w-full rounded-lg border border-gray-200 bg-background px-3 py-1.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-primary/30 dark:border-gray-700 dark:text-gray-200"
               ></textarea>
-              <p v-if="avaliacao?.Sugestoes?.length" class="mt-1 text-xs text-gray-400 dark:text-gray-500">
+              <p v-if="avaliacao?.Sugestoes?.length" class="mt-1 text-xs text-muted-foreground">
                 IA: {{ avaliacao.Sugestoes.join(" · ") }}
               </p>
             </div>
 
             <div class="sm:col-span-2">
-              <label class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Motivo do cancelamento</label>
+              <label class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Motivo do cancelamento</label>
               <textarea
                 v-model="form.MotivoCancelamento"
                 rows="2"
                 class="mt-1 w-full rounded-lg border border-gray-200 bg-background px-3 py-1.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-primary/30 dark:border-gray-700 dark:text-gray-200"
               ></textarea>
-              <p v-if="avaliacao?.MotivoCancelamento" class="mt-1 text-xs text-gray-400 dark:text-gray-500">
+              <p v-if="avaliacao?.MotivoCancelamento" class="mt-1 text-xs text-muted-foreground">
                 IA: {{ avaliacao.MotivoCancelamento }}
               </p>
             </div>
           </div>
 
           <div class="flex items-center justify-end gap-3 border-t border-gray-200 px-4 py-3 dark:border-gray-700">
-            <BaseButton :loading="salvando" @click="salvarRotulo">
+            <BaseButton variant="secondary" :loading="aceitando" :disabled="salvando" @click="aceitarComoIa">
+              {{ aceitando ? "Aceitando..." : "Concordo com a IA, deixar assim" }}
+            </BaseButton>
+            <BaseButton :loading="salvando" :disabled="aceitando" @click="salvarRotulo">
               {{ salvando ? "Salvando..." : "Salvar rótulo" }}
             </BaseButton>
           </div>

+ 84 - 75
src/views/atendimentos/AvaliacoesView.vue

@@ -6,6 +6,10 @@ import BaseBadge from "../../components/base/BaseBadge.vue";
 import BaseButton from "../../components/base/BaseButton.vue";
 import BaseDropdown from "../../components/base/BaseDropdown.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 } from "lucide-vue-next";
 import {
   avaliarAtendimentosPendentes,
   estatisticasAvaliacoes,
@@ -249,10 +253,10 @@ onBeforeUnmount(() => {
                 </svg>
               </div>
               <div class="min-w-0">
-                <h2 class="text-base font-semibold text-gray-900 dark:text-gray-100">
+                <h2 class="text-base font-semibold text-foreground">
                   Avaliações de Atendimento (IA)
                 </h2>
-                <p class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">
+                <p class="mt-0.5 text-sm text-muted-foreground">
                   Score do atendente, resolução, sentimento do cliente e pontos de melhoria gerados automaticamente.
                 </p>
               </div>
@@ -263,12 +267,12 @@ onBeforeUnmount(() => {
           </div>
 
           <div v-if="progresso" class="mt-5">
-            <div class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
+            <div class="flex items-center justify-between text-xs text-muted-foreground">
               <span>
                 Progresso das avaliações
-                <span v-if="stats.pendentes > 0" class="text-gray-400 dark:text-gray-500"> — atualiza sozinho a cada 30s</span>
+                <span v-if="stats.pendentes > 0" class="text-muted-foreground"> — atualiza sozinho a cada 30s</span>
               </span>
-              <span class="font-medium text-gray-700 dark:text-gray-300">
+              <span class="font-medium text-foreground">
                 {{ progresso.feitos }} de {{ progresso.total }} ({{ progresso.pct }}%)
               </span>
             </div>
@@ -341,7 +345,7 @@ onBeforeUnmount(() => {
           <div class="flex flex-wrap items-center gap-3 border-b border-gray-200 p-4 dark:border-gray-700">
             <div class="relative min-w-[240px] flex-1 sm:max-w-[340px]">
               <svg
-                class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400 dark:text-gray-500"
+                class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
                 viewBox="0 0 24 24"
                 fill="none"
                 stroke="currentColor"
@@ -360,7 +364,7 @@ onBeforeUnmount(() => {
               <button
                 v-if="filtroBusca"
                 type="button"
-                class="absolute right-2 top-1/2 -translate-y-1/2 rounded bg-transparent p-0.5 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
+                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();"
               >
@@ -404,87 +408,92 @@ onBeforeUnmount(() => {
                 <path v-else stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
               </svg>
             </BaseButton>
-            <div class="ml-auto text-xs text-gray-500 dark:text-gray-400">
+            <div class="ml-auto text-xs text-muted-foreground">
               {{ total }} avaliações
             </div>
           </div>
 
-          <div v-if="carregando" class="p-6 text-sm text-gray-500 dark:text-gray-400">Carregando...</div>
-          <div v-else-if="avaliacoes.length === 0" class="p-6 text-sm text-gray-500 dark:text-gray-400">
-            Nenhuma avaliação encontrada. Use "Avaliar pendentes" ou aguarde o job automático.
-          </div>
-
-          <ul v-else class="divide-y divide-gray-200 dark:divide-gray-700">
-            <li v-for="a in avaliacoes" :id="`avaliacao-${a.AtendimentoId}`" :key="a.AtendimentoId">
-              <button
-                type="button"
-                class="flex w-full flex-wrap items-center gap-2 bg-transparent px-4 py-3 text-left hover:bg-black/[0.03] dark:hover:bg-white/[0.04]"
-                @click="alternarExpandido(a.AtendimentoId)"
-              >
+          <Spinner v-if="carregando" center label="Carregando avaliações..." />
+          <EmptyState
+            v-else-if="avaliacoes.length === 0"
+            :icon="ClipboardList"
+            titulo="Nenhuma avaliação encontrada"
+            descricao="Use &quot;Avaliar pendentes&quot; ou aguarde o job automático."
+          />
+
+          <div v-else class="space-y-3 p-4">
+            <ListItemCard
+              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-gray-900 dark:text-gray-100">
+                  <div class="truncate text-sm font-semibold text-foreground">
                     {{ a.Codigo ?? `#${a.AtendimentoId}` }}
-                    <span class="font-normal text-gray-500 dark:text-gray-400"> — {{ a.ClienteNome ?? "cliente desconhecido" }}</span>
+                    <span class="font-normal text-muted-foreground"> — {{ a.ClienteNome ?? "cliente desconhecido" }}</span>
                   </div>
-                  <div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
+                  <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>
-                <BaseBadge :variant="scoreVariant(a.ScoreAtendente)">
-                  {{ a.ScoreAtendente !== null && a.ScoreAtendente !== undefined ? `Nota ${a.ScoreAtendente}` : "Sem nota" }}
-                </BaseBadge>
-                <BaseBadge :variant="resolvidoVariant[a.Resolvido] ?? 'default'">{{ resolvidoLabel[a.Resolvido] ?? a.Resolvido }}</BaseBadge>
-                <BaseBadge :variant="sentimentoVariant[a.Sentimento] ?? 'default'">{{ sentimentoLabel[a.Sentimento] ?? a.Sentimento }}</BaseBadge>
-                <BaseBadge v-if="a.VisitaAgendada" variant="violet">
-                  Visita{{ a.HorarioVisitaInformado === "sim" ? " agendada" : " sem horário" }}
-                </BaseBadge>
-                <BaseBadge v-if="cancelamentoLabel[a.StatusCancelamento]" :variant="cancelamentoVariant[a.StatusCancelamento]">
-                  {{ cancelamentoLabel[a.StatusCancelamento] }}
-                </BaseBadge>
-                <BaseBadge v-if="contextoIxcFalhou(a.ContextoIxcStatus)" variant="warning" :title="CONTEXTO_IXC_ERRO_LABEL">
-                  Contexto ERP indisponível
-                </BaseBadge>
-              </button>
-
-              <div v-if="expandido === a.AtendimentoId" class="space-y-3 px-4 pb-4 text-sm">
-                <div v-if="a.Resumo">
-                  <div class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Resumo</div>
-                  <p class="mt-1 text-gray-700 dark:text-gray-300">{{ a.Resumo }}</p>
-                </div>
-                <div v-if="a.JustificativaScore">
-                  <div class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Justificativa da nota</div>
-                  <p class="mt-1 text-gray-700 dark:text-gray-300">{{ a.JustificativaScore }}</p>
-                </div>
-                <div v-if="a.MotivoCancelamento">
-                  <div class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Motivo do cancelamento</div>
-                  <p class="mt-1 text-gray-700 dark:text-gray-300">{{ a.MotivoCancelamento }}</p>
+                <div 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>
-                <div v-if="a.Sugestoes?.length">
-                  <div class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Itens a melhorar</div>
-                  <ul class="mt-1 list-disc space-y-0.5 pl-5 text-gray-700 dark:text-gray-300">
-                    <li v-for="(s, i) in a.Sugestoes" :key="i">{{ s }}</li>
-                  </ul>
-                </div>
-                <div v-if="a.VisitaAgendada" class="text-gray-700 dark:text-gray-300">
-                  <span class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Visita técnica: </span>
-                  <template v-if="a.HorarioVisitaInformado === 'sim'">horário informado — {{ a.HorarioVisita }}</template>
-                  <template v-else>agendada, mas o atendente não informou o horário</template>
-                </div>
-                <div class="flex flex-wrap items-center justify-between gap-3">
-                  <BaseButton variant="secondary" size="sm" @click="verConversa(a.AtendimentoId)">
-                    Ver conversa completa
-                    <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
-                      <path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
-                    </svg>
-                  </BaseButton>
-                  <div class="text-xs text-gray-400 dark:text-gray-500">
-                    Avaliado por {{ a.Modelo }} em {{ formatData(a.UpdatedAt) }} · {{ a.TotalMensagens }} mensagens
-                  </div>
+              </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
+                  <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                    <path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
+                  </svg>
+                </BaseButton>
+                <div class="text-xs text-muted-foreground">
+                  Avaliado por {{ a.Modelo }} em {{ formatData(a.UpdatedAt) }} · {{ a.TotalMensagens }} mensagens
                 </div>
               </div>
-            </li>
-          </ul>
+            </ListItemCard>
+          </div>
 
           <div
             v-if="totalPaginas > 1"
@@ -498,7 +507,7 @@ onBeforeUnmount(() => {
             >
               Anterior
             </BaseButton>
-            <span class="text-gray-500 dark:text-gray-400">Página {{ page }} de {{ totalPaginas }}</span>
+            <span class="text-muted-foreground">Página {{ page }} de {{ totalPaginas }}</span>
             <BaseButton
               variant="secondary"
               size="sm"

+ 43 - 24
src/views/atendimentos/GoldenSetView.vue

@@ -5,6 +5,10 @@ import LayoutSistema from "../../layout/LayoutSistema.vue";
 import BaseBadge from "../../components/base/BaseBadge.vue";
 import BaseDropdown from "../../components/base/BaseDropdown.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 { FolderSearch } from "lucide-vue-next";
 import { listarGoldenSet } from "../../api/atendimentos.js";
 
 const router = useRouter();
@@ -16,7 +20,8 @@ const filtroStatus = ref("");
 
 const opcoesStatus = [
   { value: "pendente", label: "Pendente" },
-  { value: "rotulado", label: "Rotulado" }
+  { value: "rotulado", label: "Rotulado" },
+  { value: "aceito", label: "Aceito (IA)" }
 ];
 
 const itensFiltrados = computed(() =>
@@ -25,6 +30,7 @@ const itensFiltrados = computed(() =>
 
 const totalPendentes = computed(() => itens.value.filter((i) => i.Status === "pendente").length);
 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 "";
@@ -69,8 +75,8 @@ onMounted(carregar);
               </svg>
             </div>
             <div class="min-w-0">
-              <h2 class="text-base font-semibold text-gray-900 dark:text-gray-100">Golden Set</h2>
-              <p class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">
+              <h2 class="text-base font-semibold text-foreground">Golden Set</h2>
+              <p class="mt-0.5 text-sm text-muted-foreground">
                 Amostra de atendimentos rotulados à mão, usada para medir a acurácia do avaliador por IA.
               </p>
             </div>
@@ -81,7 +87,7 @@ onMounted(carregar);
           </div>
         </section>
 
-        <section class="grid grid-cols-3 gap-3">
+        <section class="grid grid-cols-2 gap-3 sm:grid-cols-4">
           <StatCard label="Total na amostra" :value="itens.length" variant="default">
             <template #icon>
               <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
@@ -103,41 +109,54 @@ onMounted(carregar);
               </svg>
             </template>
           </StatCard>
+          <StatCard label="Aceitos (IA)" :value="totalAceitos" variant="info">
+            <template #icon>
+              <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
+                <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
+              </svg>
+            </template>
+          </StatCard>
         </section>
 
         <section class="rounded-2xl border border-gray-200 bg-background/70 shadow-sm dark:border-gray-700 dark:bg-background/20">
           <div class="flex flex-wrap items-center gap-3 border-b border-gray-200 p-4 dark:border-gray-700">
             <BaseDropdown v-model="filtroStatus" rotulo="Status" rotulo-padrao="todos" :opcoes="opcoesStatus" />
-            <div class="ml-auto text-xs text-gray-500 dark:text-gray-400">{{ itensFiltrados.length }} atendimentos</div>
+            <div class="ml-auto text-xs text-muted-foreground">{{ itensFiltrados.length }} atendimentos</div>
           </div>
 
-          <div v-if="carregando" class="p-6 text-sm text-gray-500 dark:text-gray-400">Carregando...</div>
-          <div v-else-if="itensFiltrados.length === 0" class="p-6 text-sm text-gray-500 dark:text-gray-400">
-            Nenhum atendimento encontrado. Rode o script <code>gerarAmostraGoldenSet.js --apply</code> para gerar a amostra.
-          </div>
+          <Spinner v-if="carregando" center label="Carregando golden set..." />
+          <EmptyState
+            v-else-if="itensFiltrados.length === 0"
+            :icon="FolderSearch"
+            titulo="Nenhum atendimento encontrado"
+          >
+            <p class="text-xs text-muted-foreground">
+              Rode o script <code>gerarAmostraGoldenSet.js --apply</code> para gerar a amostra.
+            </p>
+          </EmptyState>
 
-          <ul v-else class="divide-y divide-gray-200 dark:divide-gray-700">
-            <li v-for="item in itensFiltrados" :key="item.AtendimentoId">
-              <button
-                type="button"
-                class="flex w-full flex-wrap items-center gap-2 bg-transparent px-4 py-3 text-left hover:bg-black/[0.03] dark:hover:bg-white/[0.04]"
-                @click="abrir(item.AtendimentoId)"
-              >
+          <div v-else class="space-y-3 p-4">
+            <ListItemCard
+              v-for="item in itensFiltrados"
+              :key="item.AtendimentoId"
+              @toggle="abrir(item.AtendimentoId)"
+            >
+              <template #header>
                 <div class="min-w-0 flex-1">
-                  <div class="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">
+                  <div class="truncate text-sm font-semibold text-foreground">
                     {{ item.Codigo ?? `#${item.AtendimentoId}` }}
-                    <span class="font-normal text-gray-500 dark:text-gray-400"> — {{ item.ClienteNome ?? "cliente desconhecido" }}</span>
+                    <span class="font-normal text-muted-foreground"> — {{ item.ClienteNome ?? "cliente desconhecido" }}</span>
                   </div>
-                  <div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
+                  <div class="mt-0.5 text-xs text-muted-foreground">
                     <span v-if="item.Setor">{{ item.Setor }} · </span>{{ formatData(item.Abertura) }}
                   </div>
                 </div>
-                <BaseBadge :variant="item.Status === 'rotulado' ? 'success' : 'warning'">
-                  {{ item.Status === "rotulado" ? "Rotulado" : "Pendente" }}
+                <BaseBadge :variant="item.Status === 'rotulado' ? 'success' : item.Status === 'aceito' ? 'info' : 'warning'">
+                  {{ item.Status === "rotulado" ? "Rotulado" : item.Status === "aceito" ? "Aceito (IA)" : "Pendente" }}
                 </BaseBadge>
-              </button>
-            </li>
-          </ul>
+              </template>
+            </ListItemCard>
+          </div>
         </section>
       </div>
     </div>

+ 80 - 71
src/views/atendimentos/NaoResolvidosView.vue

@@ -6,6 +6,10 @@ import BaseBadge from "../../components/base/BaseBadge.vue";
 import BaseButton from "../../components/base/BaseButton.vue";
 import BaseDropdown from "../../components/base/BaseDropdown.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 } from "lucide-vue-next";
 import { baixarRelatorioNaoResolvidos, estatisticasAvaliacoes, listarAvaliacoes, listarSetoresAvaliacoes } from "../../api/atendimentos.js";
 import { useToast } from "../../composables/useToast.js";
 import {
@@ -251,10 +255,10 @@ onBeforeUnmount(() => {
                 </svg>
               </div>
               <div class="min-w-0">
-                <h2 class="text-base font-semibold text-gray-900 dark:text-gray-100">
+                <h2 class="text-base font-semibold text-foreground">
                   Atendimentos Não Resolvidos (IA)
                 </h2>
-                <p class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">
+                <p class="mt-0.5 text-sm text-muted-foreground">
                   Atendimentos marcados como não resolvidos pelo avaliador automático, por período e setor.
                 </p>
               </div>
@@ -266,7 +270,7 @@ onBeforeUnmount(() => {
                 class="rounded-lg border border-gray-200 bg-background px-3 py-1.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-primary/30 dark:border-gray-700 dark:text-gray-200"
                 @change="aplicarFiltros"
               />
-              <span class="text-sm text-gray-400 dark:text-gray-500">até</span>
+              <span class="text-sm text-muted-foreground">até</span>
               <input
                 v-model="filtroDataFim"
                 type="date"
@@ -304,10 +308,10 @@ onBeforeUnmount(() => {
               </svg>
             </template>
             <div class="space-y-1">
-              <div v-if="setoresOrdenados.length === 0" class="text-sm text-gray-400 dark:text-gray-500">—</div>
+              <div v-if="setoresOrdenados.length === 0" class="text-sm text-muted-foreground">—</div>
               <div v-for="[sigla, qtd] in setoresOrdenados" :key="sigla" class="flex items-center justify-between text-xs">
-                <span class="text-gray-600 dark:text-gray-300">{{ sigla }}</span>
-                <span class="font-medium text-gray-900 dark:text-gray-100">{{ qtd }}</span>
+                <span class="text-muted-foreground">{{ sigla }}</span>
+                <span class="font-medium text-foreground">{{ qtd }}</span>
               </div>
             </div>
           </StatCard>
@@ -317,7 +321,7 @@ onBeforeUnmount(() => {
           <div class="flex flex-wrap items-center gap-3 border-b border-gray-200 p-4 dark:border-gray-700">
             <div class="relative min-w-[240px] flex-1 sm:max-w-[340px]">
               <svg
-                class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400 dark:text-gray-500"
+                class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
                 viewBox="0 0 24 24"
                 fill="none"
                 stroke="currentColor"
@@ -336,7 +340,7 @@ onBeforeUnmount(() => {
               <button
                 v-if="filtroBusca"
                 type="button"
-                class="absolute right-2 top-1/2 -translate-y-1/2 rounded bg-transparent p-0.5 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
+                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();"
               >
@@ -386,82 +390,87 @@ onBeforeUnmount(() => {
                 <path stroke-linecap="round" stroke-linejoin="round" d="M12 3v12m0 0-4-4m4 4 4-4M4 20h16" />
               </svg>
             </BaseButton>
-            <div class="ml-auto text-xs text-gray-500 dark:text-gray-400">
+            <div class="ml-auto text-xs text-muted-foreground">
               {{ total }} não resolvidos
             </div>
           </div>
 
-          <div v-if="carregando" class="p-6 text-sm text-gray-500 dark:text-gray-400">Carregando...</div>
-          <div v-else-if="avaliacoes.length === 0" class="p-6 text-sm text-gray-500 dark:text-gray-400">
-            Nenhum atendimento não resolvido encontrado para os filtros selecionados.
-          </div>
-
-          <ul v-else class="divide-y divide-gray-200 dark:divide-gray-700">
-            <li v-for="a in avaliacoes" :id="`nao-resolvido-${a.AtendimentoId}`" :key="a.AtendimentoId">
-              <button
-                type="button"
-                class="flex w-full flex-wrap items-center gap-2 bg-transparent px-4 py-3 text-left hover:bg-black/[0.03] dark:hover:bg-white/[0.04]"
-                @click="alternarExpandido(a.AtendimentoId)"
-              >
+          <Spinner v-if="carregando" center label="Carregando atendimentos..." />
+          <EmptyState
+            v-else-if="avaliacoes.length === 0"
+            :icon="CircleCheck"
+            titulo="Nenhum atendimento não resolvido encontrado"
+            descricao="Ajuste os filtros ou o período selecionado."
+          />
+
+          <div v-else class="space-y-3 p-4">
+            <ListItemCard
+              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-gray-900 dark:text-gray-100">
+                  <div class="truncate text-sm font-semibold text-foreground">
                     {{ a.Codigo ?? `#${a.AtendimentoId}` }}
-                    <span class="font-normal text-gray-500 dark:text-gray-400"> — {{ a.ClienteNome ?? "cliente desconhecido" }}</span>
+                    <span class="font-normal text-muted-foreground"> — {{ a.ClienteNome ?? "cliente desconhecido" }}</span>
                   </div>
-                  <div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
+                  <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>
-                <BaseBadge :variant="scoreVariant(a.ScoreAtendente)">
-                  {{ a.ScoreAtendente !== null && a.ScoreAtendente !== undefined ? `Nota ${a.ScoreAtendente}` : "Sem nota" }}
-                </BaseBadge>
-                <BaseBadge :variant="resolvidoVariant[a.Resolvido] ?? 'default'">{{ resolvidoLabel[a.Resolvido] ?? a.Resolvido }}</BaseBadge>
-                <BaseBadge :variant="sentimentoVariant[a.Sentimento] ?? 'default'">{{ sentimentoLabel[a.Sentimento] ?? a.Sentimento }}</BaseBadge>
-                <BaseBadge v-if="a.VisitaAgendada" variant="violet">
-                  Visita{{ a.HorarioVisitaInformado === "sim" ? " agendada" : " sem horário" }}
-                </BaseBadge>
-                <BaseBadge v-if="cancelamentoLabel[a.StatusCancelamento]" :variant="cancelamentoVariant[a.StatusCancelamento]">
-                  {{ cancelamentoLabel[a.StatusCancelamento] }}
-                </BaseBadge>
-                <BaseBadge v-if="contextoIxcFalhou(a.ContextoIxcStatus)" variant="warning" :title="CONTEXTO_IXC_ERRO_LABEL">
-                  Contexto ERP indisponível
-                </BaseBadge>
-              </button>
-
-              <div v-if="expandido === a.AtendimentoId" class="space-y-3 px-4 pb-4 text-sm">
-                <div v-if="a.Resumo">
-                  <div class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Resumo</div>
-                  <p class="mt-1 text-gray-700 dark:text-gray-300">{{ a.Resumo }}</p>
-                </div>
-                <div v-if="a.JustificativaScore">
-                  <div class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Justificativa da nota</div>
-                  <p class="mt-1 text-gray-700 dark:text-gray-300">{{ a.JustificativaScore }}</p>
+                <div 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>
-                <div v-if="a.MotivoCancelamento">
-                  <div class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Motivo do cancelamento</div>
-                  <p class="mt-1 text-gray-700 dark:text-gray-300">{{ a.MotivoCancelamento }}</p>
-                </div>
-                <div v-if="a.Sugestoes?.length">
-                  <div class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Itens a melhorar</div>
-                  <ul class="mt-1 list-disc space-y-0.5 pl-5 text-gray-700 dark:text-gray-300">
-                    <li v-for="(s, i) in a.Sugestoes" :key="i">{{ s }}</li>
-                  </ul>
-                </div>
-                <div class="flex flex-wrap items-center justify-between gap-3">
-                  <BaseButton variant="secondary" size="sm" @click="verConversa(a.AtendimentoId)">
-                    Ver conversa completa
-                    <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
-                      <path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
-                    </svg>
-                  </BaseButton>
-                  <div class="text-xs text-gray-400 dark:text-gray-500">
-                    Avaliado por {{ a.Modelo }} em {{ formatData(a.UpdatedAt) }} · {{ a.TotalMensagens }} mensagens
-                  </div>
+              </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
+                  <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                    <path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
+                  </svg>
+                </BaseButton>
+                <div class="text-xs text-muted-foreground">
+                  Avaliado por {{ a.Modelo }} em {{ formatData(a.UpdatedAt) }} · {{ a.TotalMensagens }} mensagens
                 </div>
               </div>
-            </li>
-          </ul>
+            </ListItemCard>
+          </div>
 
           <div
             v-if="totalPaginas > 1"
@@ -475,7 +484,7 @@ onBeforeUnmount(() => {
             >
               Anterior
             </BaseButton>
-            <span class="text-gray-500 dark:text-gray-400">Página {{ page }} de {{ totalPaginas }}</span>
+            <span class="text-muted-foreground">Página {{ page }} de {{ totalPaginas }}</span>
             <BaseButton
               variant="secondary"
               size="sm"

+ 7 - 13
src/views/auth/LoginView.vue

@@ -2,6 +2,7 @@
 import { computed, onMounted, onUnmounted, ref, watch } from "vue";
 import { useRoute, useRouter } from "vue-router";
 import BotaoAlterarTema from "../../components/BotaoAlterarTema.vue";
+import DecorativeGradient from "../../components/DecorativeGradient.vue";
 import ModalEsqueciSenha from "../../components/ModalEsqueciSenha.vue";
 import BaseButton from "../../components/base/BaseButton.vue";
 import { useAuth } from "../../composables/useAuth.js";
@@ -108,19 +109,12 @@ async function onSubmit() {
 
 <template>
   <div class="relative min-h-screen overflow-hidden bg-background text-foreground">
-    <div
-      aria-hidden="true"
-      class="pointer-events-none absolute inset-0 z-0 bg-[radial-gradient(900px_520px_at_20%_10%,rgba(37,99,235,0.12),transparent_60%),radial-gradient(700px_520px_at_85%_35%,rgba(162,102,255,0.10),transparent_60%)] dark:hidden"
-    />
-    <div
-      aria-hidden="true"
-      class="pointer-events-none absolute inset-0 z-0 hidden bg-[radial-gradient(900px_520px_at_20%_10%,rgba(91,140,255,0.18),transparent_60%),radial-gradient(700px_520px_at_85%_35%,rgba(162,102,255,0.14),transparent_60%)] dark:block"
-    />
+    <DecorativeGradient />
 
     <header class="relative z-10 flex items-center justify-between px-6 py-5">
       <div>
         <div class="text-[16px] font-extrabold tracking-[0.14em]">ORÁCULO</div>
-        <div class="mt-1 text-sm text-gray-500 dark:text-gray-400">
+        <div class="mt-1 text-sm text-muted-foreground">
           Acesso ao assistente interno
         </div>
       </div>
@@ -130,7 +124,7 @@ async function onSubmit() {
 
     <main class="relative z-10 flex min-h-[calc(100vh-88px)] items-center justify-center px-4 pb-8">
       <div
-        class="w-full max-w-md rounded-[28px] border border-gray-300 bg-white/80 p-6 shadow-sm backdrop-blur-md dark:border-white/[0.18] dark:bg-gradient-to-b dark:from-[#101827] dark:to-[#070b16] dark:shadow-[0_18px_50px_rgba(0,0,0,0.35)]"
+        class="w-full max-w-md rounded-[28px] border border-gray-300 bg-white/80 p-6 shadow-sm backdrop-blur-md dark:border-white/[0.18] dark:bg-gradient-to-b dark:from-abyss-900 dark:to-abyss-950 dark:shadow-[0_18px_50px_rgba(0,0,0,0.35)]"
       >
         <div class="flex items-center gap-3">
           <div
@@ -145,7 +139,7 @@ async function onSubmit() {
           </div>
           <div>
             <div class="text-2xl font-extrabold tracking-tight">Entrar</div>
-            <div class="text-sm text-gray-500 dark:text-gray-400">
+            <div class="text-sm text-muted-foreground">
               Informe seu usuario ou email e sua senha para acessar o sistema.
             </div>
           </div>
@@ -188,7 +182,7 @@ async function onSubmit() {
               />
               <button
                 type="button"
-                class="absolute inset-y-0 right-0 flex items-center border-0 bg-transparent px-3 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
+                class="absolute inset-y-0 right-0 flex items-center border-0 bg-transparent px-3 text-gray-400 hover:text-muted-foreground dark:hover:text-gray-300"
                 :aria-label="senhaVisivel ? 'Ocultar senha' : 'Mostrar senha'"
                 @click="senhaVisivel = !senhaVisivel"
               >
@@ -211,7 +205,7 @@ async function onSubmit() {
           </label>
 
           <div class="flex items-center justify-between gap-2">
-            <label class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
+            <label class="flex items-center gap-2 text-sm text-muted-foreground">
               <input
                 v-model="form.rememberMe"
                 type="checkbox"

+ 15 - 15
src/views/configuracoes/ConfiguracoesView.vue

@@ -96,10 +96,10 @@ async function importarTudo() {
               </svg>
             </div>
             <div class="min-w-0">
-              <h2 class="text-base font-semibold text-gray-900 dark:text-gray-100">
+              <h2 class="text-base font-semibold text-foreground">
                 Configurações do Sistema
               </h2>
-              <p class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">
+              <p class="mt-0.5 text-sm text-muted-foreground">
                 Gerencie usuários e preferências da plataforma.
               </p>
             </div>
@@ -107,7 +107,7 @@ async function importarTudo() {
         </section>
 
         <section v-if="isAdmin">
-          <div class="mb-3 text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
+          <div class="mb-3 text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
             Usuários & Acesso
           </div>
 
@@ -128,11 +128,11 @@ async function importarTudo() {
                   </svg>
                 </div>
                 <div>
-                  <div class="text-sm font-semibold text-gray-900 dark:text-gray-100">Gerenciar Usuários</div>
-                  <div class="text-xs text-gray-500 dark:text-gray-400">Cadastre e gerencie usuários, perfis e níveis de acesso.</div>
+                  <div class="text-sm font-semibold text-foreground">Gerenciar Usuários</div>
+                  <div class="text-xs text-muted-foreground">Cadastre e gerencie usuários, perfis e níveis de acesso.</div>
                 </div>
               </div>
-              <span class="inline-flex items-center justify-center rounded-md p-2 text-gray-400 dark:text-gray-500">
+              <span class="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground">
                 <svg class="h-5 w-5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
                   <path stroke-linecap="round" stroke-linejoin="round" d="m9 6 6 6-6 6" />
                 </svg>
@@ -142,7 +142,7 @@ async function importarTudo() {
         </section>
 
         <section v-if="isAdmin">
-          <div class="mb-3 text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
+          <div class="mb-3 text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
             Integrações
           </div>
 
@@ -155,8 +155,8 @@ async function importarTudo() {
                   </svg>
                 </div>
                 <div>
-                  <div class="text-sm font-semibold text-gray-900 dark:text-gray-100">Atendimentos do Chatbot</div>
-                  <div class="text-xs text-gray-500 dark:text-gray-400">
+                  <div class="text-sm font-semibold text-foreground">Atendimentos do Chatbot</div>
+                  <div class="text-xs text-muted-foreground">
                     Importa os protocolos abertos do ifbot com as mensagens de cada atendimento.
                   </div>
                 </div>
@@ -187,7 +187,7 @@ async function importarTudo() {
         </section>
 
         <section v-if="isAdmin">
-          <div class="mb-3 text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
+          <div class="mb-3 text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
             WhatsApp
           </div>
 
@@ -200,8 +200,8 @@ async function importarTudo() {
                   </svg>
                 </div>
                 <div>
-                  <div class="text-sm font-semibold text-gray-900 dark:text-gray-100">Número (WhatsApp Cloud API)</div>
-                  <div class="text-xs text-gray-500 dark:text-gray-400">Status do número oficial usado no Atendimento.</div>
+                  <div class="text-sm font-semibold text-foreground">Número (WhatsApp Cloud API)</div>
+                  <div class="text-xs text-muted-foreground">Status do número oficial usado no Atendimento.</div>
                 </div>
               </div>
               <div class="flex shrink-0 items-center gap-3">
@@ -223,7 +223,7 @@ async function importarTudo() {
         </section>
 
         <section>
-          <div class="mb-3 text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
+          <div class="mb-3 text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
             Aparência
           </div>
 
@@ -236,8 +236,8 @@ async function importarTudo() {
                   </svg>
                 </div>
                 <div>
-                  <div class="text-sm font-semibold text-gray-900 dark:text-gray-100">Tema</div>
-                  <div class="text-xs text-gray-500 dark:text-gray-400">Alternar entre tema claro e escuro</div>
+                  <div class="text-sm font-semibold text-foreground">Tema</div>
+                  <div class="text-xs text-muted-foreground">Alternar entre tema claro e escuro</div>
                 </div>
               </div>
               <BotaoAlterarTema />

+ 7 - 7
src/views/conversas/ConversasHistoricoView.vue

@@ -48,20 +48,20 @@ function confirmarExclusao(id) {
     <section class="rounded-2xl border border-gray-200 bg-gray-100/50 p-6 shadow-sm backdrop-blur-md dark:border-gray-700 dark:bg-secondary/50">
       <div class="flex items-start justify-between gap-4">
         <div>
-          <h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Histórico</h2>
-          <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ items.length }} conversa{{ items.length === 1 ? '' : 's' }}</p>
+          <h2 class="text-lg font-semibold text-foreground">Histórico</h2>
+          <p class="mt-1 text-sm text-muted-foreground">{{ items.length }} conversa{{ items.length === 1 ? '' : 's' }}</p>
         </div>
       </div>
 
       <div v-if="!items.length" class="mt-8 flex flex-col items-center gap-3 py-8 text-center">
         <div class="flex h-14 w-14 items-center justify-center rounded-2xl border border-dashed border-gray-300 dark:border-gray-600">
-          <svg class="h-6 w-6 text-gray-400 dark:text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
+          <svg class="h-6 w-6 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
             <path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6l4 2" />
             <path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 1 1-3.2-6.9" />
           </svg>
         </div>
-        <div class="text-sm font-medium text-gray-500 dark:text-gray-400">Nenhuma conversa ainda</div>
-        <div class="text-xs text-gray-400 dark:text-gray-500">Suas conversas aparecerão aqui após a primeira mensagem.</div>
+        <div class="text-sm font-medium text-muted-foreground">Nenhuma conversa ainda</div>
+        <div class="text-xs text-muted-foreground">Suas conversas aparecerão aqui após a primeira mensagem.</div>
       </div>
 
       <div v-else class="mt-4 grid gap-2">
@@ -81,9 +81,9 @@ function confirmarExclusao(id) {
                 v-if="c.id === activeConversationId"
                 class="h-1.5 w-1.5 shrink-0 rounded-full bg-primary"
               />
-              <div class="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">{{ c.title || 'Nova conversa' }}</div>
+              <div class="truncate text-sm font-semibold text-foreground">{{ c.title || 'Nova conversa' }}</div>
             </div>
-            <div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
+            <div class="mt-1 text-xs text-muted-foreground">
               {{ formatarData(c.updatedAt) }} · {{ c.messageCount ?? 0 }} msgs
             </div>
           </button>

+ 9 - 9
src/views/conversas/ConversasPesquisarView.vue

@@ -31,12 +31,12 @@ function abrirConversa(id) {
 <template>
   <LayoutSistema>
     <section class="rounded-2xl border border-gray-200 bg-gray-100/50 p-4 shadow-sm backdrop-blur-md dark:border-gray-700 dark:bg-secondary/50">
-      <h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Pesquisar conversas</h2>
+      <h2 class="text-lg font-semibold text-foreground">Pesquisar conversas</h2>
 
-      <div class="mt-3 grid grid-cols-1 gap-2 min-[520px]:grid-cols-[1fr_120px]">
+      <div class="mt-3 grid grid-cols-1 gap-2 compact:grid-cols-[1fr_120px]">
         <div class="relative">
           <svg
-            class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400 dark:text-gray-500"
+            class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
             viewBox="0 0 24 24"
             fill="none"
             stroke="currentColor"
@@ -55,7 +55,7 @@ function abrirConversa(id) {
       </div>
 
       <div class="mt-2.5 flex items-center justify-between gap-3">
-        <span class="text-sm text-gray-500 dark:text-gray-400">
+        <span class="text-sm text-muted-foreground">
           {{ query.trim() ? `${results.length} resultados` : "Digite para filtrar o histórico." }}
         </span>
         <button
@@ -77,8 +77,8 @@ function abrirConversa(id) {
           class="rounded-xl border border-gray-200 bg-background/60 p-3 text-left transition-colors hover:border-gray-500 dark:border-gray-700 dark:bg-background/10 dark:hover:border-gray-400"
           @click="abrirConversa(c.id)"
         >
-          <div class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ c.title || "Nova conversa" }}</div>
-          <div class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ c.messageCount ?? 0 }} msgs</div>
+          <div class="text-sm font-semibold text-foreground">{{ c.title || "Nova conversa" }}</div>
+          <div class="mt-1 text-xs text-muted-foreground">{{ c.messageCount ?? 0 }} msgs</div>
         </button>
 
         <div
@@ -86,12 +86,12 @@ function abrirConversa(id) {
           class="flex flex-col items-center gap-3 py-8 text-center"
         >
           <div class="flex h-14 w-14 items-center justify-center rounded-2xl border border-dashed border-gray-300 dark:border-gray-600">
-            <svg class="h-6 w-6 text-gray-400 dark:text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
+            <svg class="h-6 w-6 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
               <path stroke-linecap="round" stroke-linejoin="round" d="m21 21-4.35-4.35M17 10.5a6.5 6.5 0 1 1-13 0 6.5 6.5 0 0 1 13 0Z" />
             </svg>
           </div>
-          <div class="text-sm font-medium text-gray-500 dark:text-gray-400">Nenhuma conversa encontrada</div>
-          <div class="text-xs text-gray-400 dark:text-gray-500">Tente buscar por outro título.</div>
+          <div class="text-sm font-medium text-muted-foreground">Nenhuma conversa encontrada</div>
+          <div class="text-xs text-muted-foreground">Tente buscar por outro título.</div>
         </div>
       </div>
     </section>

+ 37 - 72
src/views/conversas/ConversasWhatsappView.vue

@@ -3,6 +3,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
 import LayoutSistema from "../../layout/LayoutSistema.vue";
 import ModalMidia from "../../components/ModalMidia.vue";
 import BaseBadge from "../../components/base/BaseBadge.vue";
+import ChatBubble from "../../components/base/ChatBubble.vue";
 import { useConversas } from "../../composables/useConversas.js";
 import { useAuth } from "../../composables/useAuth.js";
 import { useToast } from "../../composables/useToast.js";
@@ -231,19 +232,19 @@ onUnmounted(() => {
 
 <template>
   <LayoutSistema>
-    <div class="grid h-[calc(100vh-var(--layout-header-height)-4rem)] grid-cols-[300px_1fr] gap-4 max-[760px]:grid-cols-1 max-[760px]:gap-0">
+    <div class="grid h-[calc(100vh-var(--layout-header-height)-4rem)] grid-cols-[300px_1fr] gap-4 max-whatsapp:grid-cols-1 max-whatsapp:gap-0">
       <section
         class="flex min-h-0 flex-col rounded-2xl border border-gray-200 bg-gray-100/50 shadow-sm backdrop-blur-md dark:border-gray-700 dark:bg-secondary/50"
-        :class="conversaAtivaId ? 'max-[760px]:hidden' : ''"
+        :class="conversaAtivaId ? 'max-whatsapp:hidden' : ''"
       >
         <div class="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-gray-700">
-          <span class="text-sm font-semibold text-gray-900 dark:text-gray-100">Conversas</span>
+          <span class="text-sm font-semibold text-foreground">Conversas</span>
           <span v-if="conversas.length" class="rounded-full bg-black/[0.04] px-2 py-0.5 text-[11px] font-medium text-gray-500 dark:bg-white/[0.06] dark:text-gray-400">
             {{ conversas.length }}
           </span>
         </div>
 
-        <div v-if="conversasLoading && !conversas.length" class="flex flex-1 items-center justify-center p-4 text-sm text-gray-500 dark:text-gray-400">
+        <div v-if="conversasLoading && !conversas.length" class="flex flex-1 items-center justify-center p-4 text-sm text-muted-foreground">
           Carregando...
         </div>
         <div v-else-if="conversasError" class="m-4 rounded-lg bg-red-50 px-4 py-3 text-xs text-red-800 dark:bg-red-900/30 dark:text-red-300">
@@ -253,13 +254,13 @@ onUnmounted(() => {
           <svg class="h-8 w-8 text-gray-300 dark:text-gray-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
             <path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.86 9.86 0 0 1-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8Z" />
           </svg>
-          <p class="text-sm text-gray-500 dark:text-gray-400">Nenhuma conversa ainda.</p>
-          <p class="text-xs text-gray-400 dark:text-gray-500">Mande uma mensagem para o número de teste no WhatsApp.</p>
+          <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>
 
-        <div v-else class="conv-scroll min-h-0 flex-1 overflow-y-auto p-2">
+        <div v-else class="scroll-thin min-h-0 flex-1 overflow-y-auto p-2">
           <template v-for="grupo in conversasAgrupadas" :key="grupo.label">
-            <div class="px-2 pt-2 pb-1 text-[10px] font-medium uppercase tracking-wide text-gray-400 dark:text-gray-500">
+            <div class="px-2 pt-2 pb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
               {{ grupo.label }}
             </div>
             <div class="space-y-1">
@@ -282,13 +283,13 @@ onUnmounted(() => {
                 </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-gray-900 dark:text-gray-100">{{ formatPrevia(c) }}</span>
-                    <span v-if="formatUltimaMensagem(c.UltimaMensagemEm ?? c.CreatedAt)" class="shrink-0 text-[11px] text-gray-400 dark:text-gray-500">
+                    <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>
                   </div>
                   <div class="mt-0.5 flex items-center justify-between gap-2">
-                    <span class="truncate text-xs text-gray-500 dark:text-gray-400">{{ c.Telefone }}</span>
+                    <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) }}
                     </BaseBadge>
@@ -299,7 +300,7 @@ onUnmounted(() => {
               <button
                 v-if="isAdmin"
                 type="button"
-                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-[760px]:opacity-100 dark:hover:border-red-900/50 dark:hover:bg-red-950/30 dark:hover:text-red-400"
+                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"
@@ -311,9 +312,9 @@ onUnmounted(() => {
 
               <div
                 v-if="confirmandoExclusao === c.Id"
-                class="absolute inset-0 flex flex-col items-center justify-center gap-2 rounded-xl bg-background p-3 dark:bg-[#101827]"
+                class="absolute inset-0 flex flex-col items-center justify-center gap-2 rounded-xl bg-background p-3 dark:bg-abyss-900"
               >
-                <span class="text-xs text-gray-500 dark:text-gray-400">Excluir esta conversa?</span>
+                <span class="text-xs text-muted-foreground">Excluir esta conversa?</span>
                 <div class="flex items-center gap-1.5">
                   <button
                     type="button"
@@ -340,10 +341,10 @@ onUnmounted(() => {
 
       <section
         class="flex min-h-0 flex-col rounded-2xl border border-gray-200 bg-background/70 shadow-sm dark:border-gray-700 dark:bg-background/20"
-        :class="!conversaAtivaId ? 'max-[760px]:hidden' : ''"
+        :class="!conversaAtivaId ? 'max-whatsapp:hidden' : ''"
       >
         <template v-if="!conversaAtivaId">
-          <div class="flex flex-1 items-center justify-center text-sm text-gray-500 dark:text-gray-400">
+          <div class="flex flex-1 items-center justify-center text-sm text-muted-foreground">
             Selecione uma conversa para começar a responder.
           </div>
         </template>
@@ -352,7 +353,7 @@ onUnmounted(() => {
           <div class="flex items-center gap-3 border-b border-gray-200 px-4 py-3 dark:border-gray-700">
             <button
               type="button"
-              class="hidden h-8 w-8 shrink-0 items-center justify-center rounded-lg text-gray-500 hover:bg-black/[0.03] max-[760px]:inline-flex dark:text-gray-400 dark:hover:bg-white/[0.04]"
+              class="hidden h-8 w-8 shrink-0 items-center justify-center rounded-lg text-gray-500 hover:bg-black/[0.03] max-whatsapp:inline-flex dark:text-gray-400 dark:hover:bg-white/[0.04]"
               title="Voltar para a lista"
               @click="fecharConversa"
             >
@@ -368,22 +369,22 @@ onUnmounted(() => {
               {{ avatarIniciais(conversaAtiva.NomeContato || conversaAtiva.Telefone) }}
             </div>
             <div class="min-w-0 flex-1">
-              <div class="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">
+              <div class="truncate text-sm font-semibold text-foreground">
                 {{ conversaAtiva ? formatPrevia(conversaAtiva) : "" }}
               </div>
-              <div v-if="conversaAtiva" class="truncate text-xs text-gray-500 dark:text-gray-400">{{ conversaAtiva.Telefone }}</div>
+              <div v-if="conversaAtiva" class="truncate text-xs text-muted-foreground">{{ conversaAtiva.Telefone }}</div>
             </div>
             <BaseBadge v-if="conversaAtiva" :variant="statusBadgeVariant(conversaAtiva.Status)" class="shrink-0">
               {{ statusBadgeTexto(conversaAtiva.Status) }}
             </BaseBadge>
           </div>
 
-          <div ref="mensagensEl" class="conv-scroll min-h-0 flex-1 overflow-y-auto p-4">
-            <div v-if="mensagensLoading" class="text-sm text-gray-500 dark:text-gray-400">Carregando conversa...</div>
+          <div ref="mensagensEl" class="scroll-thin min-h-0 flex-1 overflow-y-auto p-4">
+            <div v-if="mensagensLoading" class="text-sm text-muted-foreground">Carregando conversa...</div>
             <div v-else-if="mensagensError" class="rounded-lg bg-red-50 px-4 py-3 text-xs text-red-800 dark:bg-red-900/30 dark:text-red-300">
               {{ mensagensError }}
             </div>
-            <div v-else-if="!mensagens.length" class="text-sm text-gray-500 dark:text-gray-400">
+            <div v-else-if="!mensagens.length" class="text-sm text-muted-foreground">
               Nenhuma mensagem nessa conversa ainda.
             </div>
 
@@ -394,17 +395,9 @@ onUnmounted(() => {
                     {{ formatData(m.Timestamp) }}
                   </span>
                 </div>
-              <div class="flex" :class="m.Direcao === 'saida' ? 'justify-end' : 'justify-start'">
-                <div
-                  class="max-w-[75%] rounded-2xl px-3.5 py-2 shadow-sm"
-                  :class="
-                    m.Direcao === 'saida'
-                      ? 'rounded-br-sm bg-primary/10 dark:bg-primary/20'
-                      : 'rounded-bl-sm bg-gray-100 dark:bg-gray-800'
-                  "
-                >
+              <ChatBubble :own="m.Direcao === 'saida'">
                   <template v-if="ehAudio(midiaInfo(m))">
-                    <div class="flex items-center gap-1.5 text-xs font-medium text-gray-500 dark:text-gray-400">
+                    <div class="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
                       <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
                         <path stroke-linecap="round" stroke-linejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 1 1 6 0v8.25a3 3 0 0 1-3 3Z" />
                       </svg>
@@ -419,8 +412,8 @@ onUnmounted(() => {
                     >
                       Falha ao carregar áudio — tentar novamente
                     </button>
-                    <p v-else class="mt-1 text-xs italic text-gray-400 dark:text-gray-500">Carregando áudio...</p>
-                    <p v-if="m.Corpo" class="mt-1 whitespace-pre-wrap break-words text-sm text-gray-800 dark:text-gray-200">{{ m.Corpo }}</p>
+                    <p v-else class="mt-1 text-xs italic text-muted-foreground">Carregando áudio...</p>
+                    <p v-if="m.Corpo" class="mt-1 whitespace-pre-wrap break-words text-sm text-foreground">{{ m.Corpo }}</p>
                   </template>
                   <template v-else-if="ehImagem(midiaInfo(m)) && m.MidiaPath">
                     <img
@@ -440,8 +433,8 @@ onUnmounted(() => {
                     >
                       Falha ao carregar imagem — tentar novamente
                     </button>
-                    <p v-else class="text-xs italic text-gray-400 dark:text-gray-500">Carregando imagem...</p>
-                    <p v-if="m.Corpo" class="mt-1 whitespace-pre-wrap break-words text-sm text-gray-800 dark:text-gray-200">{{ m.Corpo }}</p>
+                    <p v-else class="text-xs italic text-muted-foreground">Carregando imagem...</p>
+                    <p v-if="m.Corpo" class="mt-1 whitespace-pre-wrap break-words text-sm text-foreground">{{ m.Corpo }}</p>
                   </template>
                   <template v-else-if="ehVideo(midiaInfo(m)) && m.MidiaPath">
                     <div
@@ -467,10 +460,10 @@ onUnmounted(() => {
                     >
                       Falha ao carregar vídeo — tentar novamente
                     </button>
-                    <p v-else class="text-xs italic text-gray-400 dark:text-gray-500">Carregando vídeo...</p>
-                    <p v-if="m.Corpo" class="mt-1 whitespace-pre-wrap break-words text-sm text-gray-800 dark:text-gray-200">{{ m.Corpo }}</p>
+                    <p v-else class="text-xs italic text-muted-foreground">Carregando vídeo...</p>
+                    <p v-if="m.Corpo" class="mt-1 whitespace-pre-wrap break-words text-sm text-foreground">{{ m.Corpo }}</p>
                   </template>
-                  <p v-else-if="m.Corpo" class="whitespace-pre-wrap break-words text-sm text-gray-800 dark:text-gray-200">{{ m.Corpo }}</p>
+                  <p v-else-if="m.Corpo" class="whitespace-pre-wrap break-words text-sm text-foreground">{{ m.Corpo }}</p>
                   <a
                     v-if="m.MidiaPath && !ehAudio(midiaInfo(m)) && !ehImagem(midiaInfo(m)) && !ehVideo(midiaInfo(m)) && midiaUrls[m.Id]"
                     :href="midiaUrls[m.Id]"
@@ -480,9 +473,8 @@ onUnmounted(() => {
                   >
                     Abrir arquivo{{ m.MidiaNomeArquivo ? `: ${m.MidiaNomeArquivo}` : "" }}
                   </a>
-                  <div class="mt-0.5 text-right text-[10px] text-gray-400 dark:text-gray-500">{{ formatHora(m.Timestamp) }}</div>
-                </div>
-              </div>
+                  <div class="mt-0.5 text-right text-[10px] text-muted-foreground">{{ formatHora(m.Timestamp) }}</div>
+              </ChatBubble>
               </template>
             </div>
           </div>
@@ -513,7 +505,7 @@ onUnmounted(() => {
                 </button>
                 <button
                   type="button"
-                  class="rounded-lg p-1 text-gray-400 transition-colors hover:bg-black/[0.04] hover:text-gray-600 dark:text-gray-500 dark:hover:bg-white/[0.06] dark:hover:text-gray-300"
+                  class="rounded-lg p-1 text-gray-400 transition-colors hover:bg-black/[0.04] hover:text-muted-foreground dark:hover:bg-white/[0.06] dark:hover:text-gray-300"
                   title="Descartar sugestão"
                   aria-label="Descartar sugestão"
                   @click="descartarSugestao"
@@ -550,7 +542,7 @@ onUnmounted(() => {
               <span class="h-1.5 w-1.5 animate-bounce rounded-full bg-primary" style="animation-delay: 150ms" />
               <span class="h-1.5 w-1.5 animate-bounce rounded-full bg-primary" style="animation-delay: 300ms" />
             </span>
-            <span class="text-xs text-gray-500 dark:text-gray-400">Gerando sugestão...</span>
+            <span class="text-xs text-muted-foreground">Gerando sugestão...</span>
           </div>
           <div v-else-if="sugestaoError" class="mx-3 mb-2 flex items-start gap-2 rounded-xl border border-red-200 bg-red-50 p-3 dark:border-red-900/40 dark:bg-red-900/20">
             <svg class="mt-0.5 h-4 w-4 shrink-0 text-red-500 dark:text-red-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -575,7 +567,7 @@ onUnmounted(() => {
               v-model="texto"
               rows="1"
               placeholder="Digite uma resposta... (Enter envia, Shift+Enter quebra linha)"
-              class="conv-scroll min-w-0 flex-1 resize-none rounded-xl border border-gray-300 bg-background px-3 py-2 text-sm outline-none focus:border-primary dark:border-gray-600"
+              class="scroll-thin min-w-0 flex-1 resize-none rounded-xl border border-gray-300 bg-background px-3 py-2 text-sm outline-none focus:border-primary dark:border-gray-600"
               style="max-height: 120px"
               :disabled="enviando"
               @keydown.enter.exact.prevent="enviar"
@@ -612,31 +604,4 @@ onUnmounted(() => {
   opacity: 0;
   transform: translateY(4px);
 }
-
-.conv-scroll {
-  scrollbar-width: thin;
-  scrollbar-color: rgba(0, 0, 0, 0.15) transparent;
-}
-.conv-scroll::-webkit-scrollbar {
-  width: 3px;
-}
-.conv-scroll::-webkit-scrollbar-track {
-  background: transparent;
-}
-.conv-scroll::-webkit-scrollbar-thumb {
-  background: rgba(0, 0, 0, 0.15);
-  border-radius: 999px;
-}
-.conv-scroll::-webkit-scrollbar-thumb:hover {
-  background: rgba(0, 0, 0, 0.25);
-}
-:is(.dark) .conv-scroll {
-  scrollbar-color: rgba(255, 255, 255, 0.12) transparent;
-}
-:is(.dark) .conv-scroll::-webkit-scrollbar-thumb {
-  background: rgba(255, 255, 255, 0.12);
-}
-:is(.dark) .conv-scroll::-webkit-scrollbar-thumb:hover {
-  background: rgba(255, 255, 255, 0.22);
-}
 </style>

+ 7 - 6
src/views/documentos/CarregarDocumentoView.vue

@@ -2,6 +2,8 @@
 import { useRouter } from "vue-router";
 import LayoutSistema from "../../layout/LayoutSistema.vue";
 import DocumentUpload from "../../components/DocumentUpload.vue";
+import PageHeaderCard from "../../components/base/PageHeaderCard.vue";
+import { Upload } from "lucide-vue-next";
 
 const router = useRouter();
 
@@ -13,12 +15,11 @@ function aoIngerir() {
 <template>
   <LayoutSistema>
     <div class="mx-auto w-full max-w-[960px] space-y-6">
-      <div>
-        <h1 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Carregar documento</h1>
-        <p class="text-sm text-gray-500 dark:text-gray-400">
-          Adicione conhecimento à base via arquivo, URL ou texto colado.
-        </p>
-      </div>
+      <PageHeaderCard
+        :icon="Upload"
+        title="Carregar documento"
+        description="Adicione conhecimento à base via arquivo, URL ou texto colado."
+      />
 
       <DocumentUpload @ingested="aoIngerir" />
     </div>

+ 22 - 19
src/views/documentos/DocumentosView.vue

@@ -2,6 +2,8 @@
 import LayoutSistema from "../../layout/LayoutSistema.vue";
 import SearchBox from "../../components/SearchBox.vue";
 import DocumentList from "../../components/DocumentList.vue";
+import PageHeaderCard from "../../components/base/PageHeaderCard.vue";
+import { FileText } from "lucide-vue-next";
 import { useSearch } from "../../composables/useSearch.js";
 import { useDocumentos } from "../../composables/useDocumentos.js";
 
@@ -17,27 +19,28 @@ const { docs, loadDocs } = useDocumentos();
 
 <template>
   <LayoutSistema>
-    <div class="w-full space-y-6">
-      <div>
-        <h1 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Documentos</h1>
-        <p class="text-sm text-gray-500 dark:text-gray-400">
-          Pesquise, revise e remova documentos ingeridos na base de conhecimento.
-        </p>
-      </div>
+    <div class="min-h-[calc(100vh-var(--layout-header-height))] px-4 pt-8 pb-12">
+      <div class="mx-auto w-full max-w-[960px] space-y-6">
+        <PageHeaderCard
+          :icon="FileText"
+          title="Documentos"
+          description="Pesquise, revise e remova documentos ingeridos na base de conhecimento."
+        />
 
-      <SearchBox
-        :results="searchResults"
-        :loading="searchLoading"
-        :error="searchError"
-        @search="runSearch"
-      />
+        <SearchBox
+          :results="searchResults"
+          :loading="searchLoading"
+          :error="searchError"
+          @search="runSearch"
+        />
 
-      <DocumentList
-        :items="docs.items"
-        :loading="docs.loading"
-        :error="docs.error"
-        @refresh="loadDocs"
-      />
+        <DocumentList
+          :items="docs.items"
+          :loading="docs.loading"
+          :error="docs.error"
+          @refresh="loadDocs"
+        />
+      </div>
     </div>
   </LayoutSistema>
 </template>

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

@@ -122,7 +122,7 @@ onMounted(() => {
     <div v-if="isLanding" class="min-h-[calc(100vh-var(--layout-header-height))] flex items-center justify-center pb-12 md:pb-16">
       <div class="w-full max-w-[720px]">
         <div class="text-3xl font-extrabold tracking-tight">Como posso ajudar?</div>
-        <div class="mt-2 text-sm text-gray-500 dark:text-gray-400">
+        <div class="mt-2 text-sm text-muted-foreground">
           Digite sua pergunta abaixo. Você também pode alimentar a base pela barra lateral.
         </div>
 
@@ -137,7 +137,7 @@ onMounted(() => {
             @keydown.enter.exact.prevent="onLandingSend"
           />
           <div class="mt-3 flex items-center justify-between gap-3 ">
-            <span class="text-xs text-gray-400 dark:text-gray-500">Enter para enviar · Shift+Enter para nova linha</span>
+            <span class="text-xs text-muted-foreground">Enter para enviar · Shift+Enter para nova linha</span>
             <BaseButton :disabled="chatLoading || !landingDraft.trim()" :loading="chatLoading" @click="onLandingSend">
               <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                 <path stroke-linecap="round" stroke-linejoin="round" d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5" />

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

@@ -156,8 +156,8 @@ onMounted(async () => {
       
       <div class="flex items-start justify-between gap-4">
         <div>
-          <h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Usuários</h2>
-          <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
+          <h2 class="text-lg font-semibold text-foreground">Usuários</h2>
+          <p class="mt-1 text-sm text-muted-foreground">
             Lista dos usuários cadastrados no sistema.
           </p>
         </div>
@@ -209,7 +209,7 @@ onMounted(async () => {
       <div class="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
         <div class="relative flex-1">
           <svg
-            class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400 dark:text-gray-500"
+            class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
             viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
           >
             <path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
@@ -239,7 +239,7 @@ onMounted(async () => {
       </div>
 
      
-      <div v-if="loading" class="mt-6 flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
+      <div v-if="loading" class="mt-6 flex items-center gap-2 text-sm text-muted-foreground">
         <svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
           <path stroke-linecap="round" stroke-linejoin="round" d="M12 3v3m0 12v3m9-9h-3M6 12H3m15.364-6.364-2.121 2.121M8.757 15.243l-2.121 2.121M18.364 18.364l-2.121-2.121M8.757 8.757 6.636 6.636" />
         </svg>
@@ -259,12 +259,12 @@ onMounted(async () => {
         class="mt-6 flex flex-col items-center gap-3 py-8 text-center"
       >
         <div class="flex h-14 w-14 items-center justify-center rounded-2xl border border-dashed border-gray-300 dark:border-gray-600">
-          <svg class="h-6 w-6 text-gray-400 dark:text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
+          <svg class="h-6 w-6 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
             <path stroke-linecap="round" stroke-linejoin="round" d="M16 19a4 4 0 0 0-8 0" />
             <path stroke-linecap="round" stroke-linejoin="round" d="M12 12a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" />
           </svg>
         </div>
-        <div class="text-sm font-medium text-gray-500 dark:text-gray-400">Nenhum usuário encontrado.</div>
+        <div class="text-sm font-medium text-muted-foreground">Nenhum usuário encontrado.</div>
       </div>
 
       
@@ -272,13 +272,13 @@ onMounted(async () => {
         <table class="w-full text-sm">
           <thead class="bg-gray-50 dark:bg-white/5">
             <tr>
-              <th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Usuário</th>
-              <th class="hidden px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400 sm:table-cell">Login</th>
-              <th class="hidden px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400 md:table-cell">E-mail</th>
-              <th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Status</th>
-              <th class="hidden px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400 lg:table-cell">Nível</th>
-              <th class="hidden px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400 lg:table-cell">Setor</th>
-              <th class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Ações</th>
+              <th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Usuário</th>
+              <th class="hidden px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground sm:table-cell">Login</th>
+              <th class="hidden px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground md:table-cell">E-mail</th>
+              <th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Status</th>
+              <th class="hidden px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground lg:table-cell">Nível</th>
+              <th class="hidden px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground lg:table-cell">Setor</th>
+              <th class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wide text-muted-foreground">Ações</th>
             </tr>
           </thead>
           <tbody class="divide-y divide-gray-100 dark:divide-gray-700/60">
@@ -296,7 +296,7 @@ onMounted(async () => {
                   >
                     {{ avatarIniciais(usuario.Nome || usuario.Login) }}
                   </div>
-                  <span class="font-medium text-gray-900 dark:text-gray-100">
+                  <span class="font-medium text-foreground">
                     {{ usuario.Nome || usuario.Login || "Usuário sem nome" }}
                   </span>
                   <BaseBadge v-if="solicitacaoPorUsuario.has(usuario.Id)" variant="warning">
@@ -306,12 +306,12 @@ onMounted(async () => {
               </td>
 
               
-              <td class="hidden px-4 py-3 text-gray-600 dark:text-gray-400 sm:table-cell">
+              <td class="hidden px-4 py-3 text-muted-foreground sm:table-cell">
                 {{ usuario.Login || "-" }}
               </td>
 
               
-              <td class="hidden px-4 py-3 text-gray-600 dark:text-gray-400 md:table-cell">
+              <td class="hidden px-4 py-3 text-muted-foreground md:table-cell">
                 {{ usuario.Email || "-" }}
               </td>
 
@@ -380,7 +380,7 @@ onMounted(async () => {
         
         <div
           v-if="!usuariosFiltrados.length"
-          class="py-10 text-center text-sm text-gray-500 dark:text-gray-400"
+          class="py-10 text-center text-sm text-muted-foreground"
         >
           Nenhum resultado para
           <span class="font-medium">{{ busca || (filtroStatus !== "todos" ? filtroStatus : "") }}</span>.