leonardo 1 개월 전
부모
커밋
c4a1ded1fb
33개의 변경된 파일279개의 추가작업 그리고 563개의 파일을 삭제
  1. 1 8
      src/api/atendimentos.js
  2. 0 7
      src/api/chat.js
  3. 0 4
      src/api/client.js
  4. 4 14
      src/components/BotaoAlterarTema.vue
  5. 18 18
      src/components/ChatWindow.vue
  6. 17 38
      src/components/DocumentList.vue
  7. 7 15
      src/components/DocumentUpload.vue
  8. 5 9
      src/components/ModalMidia.vue
  9. 1 0
      src/components/ModalRedefinirSenha.vue
  10. 1 0
      src/components/ModalUsuario.vue
  11. 30 0
      src/components/base/ConfirmDialog.vue
  12. 1 1
      src/components/base/ListItemCard.vue
  13. 36 0
      src/composables/useAsyncState.js
  14. 1 2
      src/composables/useAuth.js
  15. 0 1
      src/composables/useChat.js
  16. 7 6
      src/composables/useDocumentos.js
  17. 4 16
      src/composables/useSearch.js
  18. 4 11
      src/composables/useUsers.js
  19. 7 9
      src/composables/useWhatsappConexao.js
  20. 15 35
      src/layout/ListaConversas.vue
  21. 0 99
      src/layout/ModalDocumentos.vue
  22. 1 1
      src/utils/avatar.js
  23. 5 9
      src/views/atendimentos/AtendimentoView.vue
  24. 19 38
      src/views/atendimentos/AvaliacoesView.vue
  25. 7 16
      src/views/atendimentos/GoldenSetView.vue
  26. 16 35
      src/views/atendimentos/NaoResolvidosView.vue
  27. 5 12
      src/views/auth/LoginView.vue
  28. 8 31
      src/views/configuracoes/ConfiguracoesView.vue
  29. 15 31
      src/views/conversas/ConversasHistoricoView.vue
  30. 6 11
      src/views/conversas/ConversasPesquisarView.vue
  31. 22 53
      src/views/conversas/ConversasWhatsappView.vue
  32. 5 9
      src/views/pagina-inicial/PaginaInicialView.vue
  33. 11 24
      src/views/usuarios/UsuariosView.vue

+ 1 - 8
src/api/atendimentos.js

@@ -8,13 +8,6 @@ export function sincronizarTodosAtendimentos(params = {}) {
   return apiFetch("/api/atendimentos/sync-all", { method: "POST", body: params });
 }
 
-export function listarAtendimentos({ page = 1, pageSize = 20, setor, status } = {}) {
-  const query = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
-  if (setor) query.set("setor", setor);
-  if (status !== undefined) query.set("status", String(status));
-  return apiFetch(`/api/atendimentos?${query.toString()}`);
-}
-
 export function obterAtendimento(id) {
   return apiFetch(`/api/atendimentos/${id}`);
 }
@@ -81,7 +74,7 @@ export function aceitarGoldenLabelComoIa(id) {
 export async function baixarRelatorioNaoResolvidos({ setor, sentimento, busca, ordenacao, dataInicio, dataFim } = {}) {
   const query = new URLSearchParams();
   if (setor) query.set("setor", setor);
-  if (sentimento) query.set("sentimento", sentimento);
+if (sentimento) query.set("sentimento", sentimento);
   if (busca) query.set("busca", busca);
   if (ordenacao === "asc") query.set("ordenacao", ordenacao);
   if (dataInicio) query.set("dataInicio", dataInicio);

+ 0 - 7
src/api/chat.js

@@ -1,12 +1,5 @@
 import { apiBaseUrl, apiFetch, buildAuthHeaders, refreshAccessToken, notifyAuthError, extractErrorMessage } from "./client.js";
 
-export async function sendChat(message, { conversationId, mode } = {}) {
-  return apiFetch("/api/chat", {
-    method: "POST",
-    body: { message, ...(conversationId ? { conversationId } : {}), ...(mode ? { mode } : {}) }
-  });
-}
-
 export async function sendChatStream(message, { conversationId, mode, onChunk, onSources, onStatus, onDone, onError, signal } = {}) {
   const body = JSON.stringify({ message, ...(conversationId ? { conversationId } : {}), ...(mode ? { mode } : {}) });
 

+ 0 - 4
src/api/client.js

@@ -39,10 +39,6 @@ export function setAuthErrorCallback(fn) {
   _authErrorFn = fn ?? null;
 }
 
-export function getAccessToken() {
-  return _accessToken;
-}
-
 export function buildAuthHeaders(extra = {}) {
   const headers = { "content-type": "application/json", ...extra };
   if (_accessToken) headers["Authorization"] = `Bearer ${_accessToken}`;

+ 4 - 14
src/components/BotaoAlterarTema.vue

@@ -1,5 +1,7 @@
 <script setup>
 import { useTheme } from "../composables/useTheme.js";
+import BaseIcon from "./base/BaseIcon.vue";
+import { Sun, Moon } from "lucide-vue-next";
 
 const { darkMode } = useTheme();
 </script>
@@ -12,20 +14,8 @@ const { darkMode } = useTheme();
     @click="darkMode = !darkMode"
   >
     <Transition name="tema-icon" mode="out-in">
-      <svg v-if="darkMode" key="sun" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
-        <path
-          stroke-linecap="round"
-          stroke-linejoin="round"
-          d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z"
-        />
-      </svg>
-      <svg v-else key="moon" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
-        <path
-          stroke-linecap="round"
-          stroke-linejoin="round"
-          d="M21.752 15.002A9.718 9.718 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z"
-        />
-      </svg>
+      <BaseIcon v-if="darkMode" :icon="Sun" key="sun" class="w-5 h-5" strokeWidth="1.5" />
+      <BaseIcon v-else :icon="Moon" key="moon" class="w-5 h-5" strokeWidth="1.5" />
     </Transition>
   </button>
 </template>

+ 18 - 18
src/components/ChatWindow.vue

@@ -32,13 +32,8 @@
               :aria-label="`Copiar mensagem ${m.role === 'user' ? 'sua' : 'do Oráculo'}`"
               @click="copiarMensagem(m.content, idx)"
             >
-              <svg v-if="copiado !== idx" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
-                <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
-                <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
-              </svg>
-              <svg v-else class="h-3 w-3 text-green-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
-                <polyline points="20 6 9 17 4 12" />
-              </svg>
+              <BaseIcon v-if="copiado !== idx" :icon="Copy" class="h-3 w-3" />
+              <BaseIcon v-else :icon="Check" class="h-3 w-3 text-green-500" strokeWidth="2.5" />
               {{ copiado === idx ? 'Copiado!' : 'Copiar' }}
             </button>
           </div>
@@ -76,9 +71,7 @@
                 :title="src.count > 1 ? `${src.count} trechos deste documento` : undefined"
                 class="inline-flex items-center gap-1 rounded-md border border-gray-200 bg-gray-50 px-2 py-0.5 text-[10px] text-gray-500 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-400"
               >
-                <svg class="h-2.5 w-2.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
-                  <path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5.586a1 1 0 0 1 .707.293l5.414 5.414a1 1 0 0 1 .293.707V19a2 2 0 0 1-2 2z" />
-                </svg>
+                <BaseIcon :icon="FileText" class="h-2.5 w-2.5 shrink-0" />
                 {{ src.source }}
               </span>
             </div>
@@ -118,9 +111,7 @@
         :disabled="loading || !draft.trim()"
         @click="onSend"
       >
-        <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" />
-        </svg>
+        <BaseIcon :icon="Send" class="h-4 w-4" />
         Enviar
       </BaseButton>
     </div>
@@ -133,9 +124,7 @@
         aria-label="Fechar erro"
         @click="$emit('clearError')"
       >
-        <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
-          <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
-        </svg>
+        <BaseIcon :icon="X" class="h-3.5 w-3.5" strokeWidth="2.5" />
       </button>
     </div>
   </div>
@@ -147,6 +136,8 @@ import { marked, Renderer } from "marked";
 import DOMPurify from "dompurify";
 import hljs from "highlight.js";
 import BaseButton from "./base/BaseButton.vue";
+import BaseIcon from "./base/BaseIcon.vue";
+import { Copy, Check, FileText, Send, X } from "lucide-vue-next";
 import ChatModeToggle from "./ChatModeToggle.vue";
 
 const props = defineProps({
@@ -234,6 +225,8 @@ function renderMarkdown(content) {
   });
 }
 
+const codeCopyTimeouts = new Set();
+
 function handleCodeCopy(e) {
   const btn = e.target.closest(".code-copy-btn");
   if (!btn) return;
@@ -243,15 +236,22 @@ function handleCodeCopy(e) {
     btn.querySelector("svg").style.display = "none";
     const original = btn.innerHTML;
     btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg> Copiado!`;
-    setTimeout(() => {
+    const timer = setTimeout(() => {
       btn.innerHTML = original;
       btn.classList.remove("copied");
+      codeCopyTimeouts.delete(timer);
     }, 2000);
+    codeCopyTimeouts.add(timer);
   });
 }
 
 onMounted(() => chatEl.value?.addEventListener("click", handleCodeCopy));
-onUnmounted(() => chatEl.value?.removeEventListener("click", handleCodeCopy));
+onUnmounted(() => {
+  chatEl.value?.removeEventListener("click", handleCodeCopy);
+  clearTimeout(copiadoTimeout);
+  for (const timer of codeCopyTimeouts) clearTimeout(timer);
+  codeCopyTimeouts.clear();
+});
 
 function copiarMensagem(content, idx) {
   navigator.clipboard.writeText(content).then(() => {

+ 17 - 38
src/components/DocumentList.vue

@@ -4,6 +4,9 @@ import { deleteDocumentsBySource } from "../api/chat.js";
 import { useToast } from "../composables/useToast.js";
 import SkeletonDocumentCard from "./skeletons/SkeletonDocumentCard.vue";
 import BaseButton from "./base/BaseButton.vue";
+import BaseIcon from "./base/BaseIcon.vue";
+import ConfirmDialog from "./base/ConfirmDialog.vue";
+import { RefreshCw, FileText, Image, Trash2 } from "lucide-vue-next";
 
 const props = defineProps({
   items: { type: Array, required: true },
@@ -118,9 +121,7 @@ function fileType(source) {
         </span>
       </div>
       <BaseButton variant="secondary" size="sm" :disabled="loading" @click="$emit('refresh')">
-        <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="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
-        </svg>
+        <BaseIcon :icon="RefreshCw" class="h-3.5 w-3.5" />
         Atualizar
       </BaseButton>
     </div>
@@ -149,9 +150,7 @@ function fileType(source) {
       class="flex flex-col items-center gap-3 rounded-xl border border-dashed border-gray-200 py-12 text-center dark:border-gray-700"
     >
       <div class="flex h-12 w-12 items-center justify-center rounded-xl border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-white/5">
-        <svg class="h-6 w-6 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
-          <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
-        </svg>
+        <BaseIcon :icon="FileText" class="h-6 w-6 text-gray-400" />
       </div>
       <div>
         <div class="text-sm font-medium text-gray-700 dark:text-gray-200">Nenhum documento na base</div>
@@ -172,23 +171,15 @@ function fileType(source) {
           :title="`Excluir '${source}'`"
           @click="confirmingSource = source"
         >
-          <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="m19 7-.867 12.142A2 2 0 0 1 16.138 21H7.862a2 2 0 0 1-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v3M4 7h16" />
-          </svg>
+          <BaseIcon :icon="Trash2" class="h-4 w-4" />
         </button>
 
         <div
           class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
           :class="fileType(source).chipClass"
         >
-          <svg v-if="fileType(source).icon === 'image'" class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
-            <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6.75A2.25 2.25 0 0 1 4.5 4.5h15a2.25 2.25 0 0 1 2.25 2.25v10.5A2.25 2.25 0 0 1 19.5 19.5h-15a2.25 2.25 0 0 1-2.25-2.25V6.75Z" />
-            <path stroke-linecap="round" stroke-linejoin="round" d="m3 16.5 5.159-5.159a2.25 2.25 0 0 1 3.182 0L16.5 16.5m-3-3 1.409-1.409a2.25 2.25 0 0 1 3.182 0L21 15" />
-            <circle cx="8.25" cy="8.625" r="1.125" />
-          </svg>
-          <svg v-else class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
-            <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
-          </svg>
+          <BaseIcon v-if="fileType(source).icon === 'image'" :icon="Image" class="h-5 w-5" />
+          <BaseIcon v-else :icon="FileText" class="h-5 w-5" />
         </div>
 
         <span class="line-clamp-2 w-full break-words text-xs font-medium text-foreground">
@@ -197,27 +188,6 @@ function fileType(source) {
         <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-abyss-900"
-        >
-          <span class="text-xs text-muted-foreground">Excluir tudo?</span>
-          <div class="flex items-center gap-1.5">
-            <BaseButton
-              variant="danger"
-              size="sm"
-              :loading="deleting.has(source)"
-              :disabled="deleting.has(source)"
-              @click="onConfirmDelete(source)"
-            >
-              Confirmar
-            </BaseButton>
-            <BaseButton variant="secondary" size="sm" @click="confirmingSource = null">
-              Cancelar
-            </BaseButton>
-          </div>
-        </div>
       </div>
 
       <div
@@ -227,5 +197,14 @@ function fileType(source) {
         Nenhum documento corresponde ao filtro.
       </div>
     </div>
+
+    <ConfirmDialog
+      :open="!!confirmingSource"
+      title="Excluir documento"
+      :message="`Excluir todos os chunks de ${confirmingSource}? Esta ação não pode ser desfeita.`"
+      :loading="deleting.has(confirmingSource)"
+      @confirm="onConfirmDelete(confirmingSource)"
+      @cancel="confirmingSource = null"
+    />
   </div>
 </template>

+ 7 - 15
src/components/DocumentUpload.vue

@@ -3,6 +3,8 @@ import { ref, computed } from "vue";
 import { ingestFile, ingestDocuments, ingestUrl } from "../api/chat.js";
 import { useToast } from "../composables/useToast.js";
 import BaseButton from "./base/BaseButton.vue";
+import BaseIcon from "./base/BaseIcon.vue";
+import { FileText, Image, X, Upload } from "lucide-vue-next";
 
 const emit = defineEmits(["ingested"]);
 const toast = useToast();
@@ -115,15 +117,9 @@ const canSubmit = computed(() => {
         class="flex items-center gap-3 rounded-xl border border-gray-200 bg-background/60 p-3 dark:border-gray-700 dark:bg-background/10"
       >
         <div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-white/5">
-          <svg v-if="getFileIcon(file) === 'pdf'" class="h-5 w-5 text-red-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
-            <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
-          </svg>
-          <svg v-else-if="getFileIcon(file) === 'image'" class="h-5 w-5 text-blue-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
-            <path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
-          </svg>
-          <svg v-else class="h-5 w-5 text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
-            <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
-          </svg>
+          <BaseIcon v-if="getFileIcon(file) === 'pdf'" :icon="FileText" class="h-5 w-5 text-red-500" />
+          <BaseIcon v-else-if="getFileIcon(file) === 'image'" :icon="Image" class="h-5 w-5 text-blue-500" />
+          <BaseIcon v-else :icon="FileText" class="h-5 w-5 text-gray-500" />
         </div>
         <div class="min-w-0 flex-1">
           <div class="truncate text-sm font-medium text-foreground">{{ file.name }}</div>
@@ -134,9 +130,7 @@ const canSubmit = computed(() => {
           class="shrink-0 rounded-lg border border-transparent p-1.5 text-gray-400 transition-colors hover:border-gray-200 hover:bg-gray-100 dark:hover:border-gray-700 dark:hover:bg-white/5"
           @click="removeFile"
         >
-          <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>
+          <BaseIcon :icon="X" class="h-4 w-4" />
         </button>
       </div>
 
@@ -152,9 +146,7 @@ const canSubmit = computed(() => {
         @drop.prevent="onDrop"
       >
         <div class="flex h-12 w-12 items-center justify-center rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-white/5">
-          <svg class="h-6 w-6 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
-            <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" />
-          </svg>
+          <BaseIcon :icon="Upload" class="h-6 w-6 text-gray-400" />
         </div>
         <div class="text-center">
           <div class="text-sm font-medium text-gray-700 dark:text-gray-200">

+ 5 - 9
src/components/ModalMidia.vue

@@ -1,5 +1,7 @@
 <script setup>
 import { onMounted, onUnmounted, ref, watch } from "vue";
+import BaseIcon from "./base/BaseIcon.vue";
+import { X, Minus, Plus } from "lucide-vue-next";
 
 const props = defineProps({
   url: { type: String, required: true },
@@ -75,9 +77,7 @@ onUnmounted(() => window.removeEventListener("keydown", aoTeclado));
           title="Fechar"
           @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>
+          <BaseIcon :icon="X" class="h-4 w-4" />
         </button>
 
         <div class="relative flex h-full w-full items-center justify-center p-6" @click.self="emit('close')">
@@ -117,9 +117,7 @@ onUnmounted(() => window.removeEventListener("keydown", aoTeclado));
             :disabled="zoom <= 1"
             @click="aplicarZoom(-0.25)"
           >
-            <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="M5 12h14" />
-            </svg>
+            <BaseIcon :icon="Minus" class="h-4 w-4" />
           </button>
           <span class="w-10 text-center text-xs font-medium text-white">{{ Math.round(zoom * 100) }}%</span>
           <button
@@ -129,9 +127,7 @@ onUnmounted(() => window.removeEventListener("keydown", aoTeclado));
             :disabled="zoom >= 4"
             @click="aplicarZoom(0.25)"
           >
-            <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="M12 5v14M5 12h14" />
-            </svg>
+            <BaseIcon :icon="Plus" class="h-4 w-4" />
           </button>
           <button
             v-if="zoom > 1"

+ 1 - 0
src/components/ModalRedefinirSenha.vue

@@ -70,6 +70,7 @@ const salvando = ref(false);
 const erroForm = ref("");
 
 async function onSubmit() {
+  if (salvando.value) return;
   erroForm.value = "";
 
   if (senhaNova.value.length < 6) {

+ 1 - 0
src/components/ModalUsuario.vue

@@ -103,6 +103,7 @@ watch(
 );
 
 async function onSubmit() {
+  if (salvando.value) return;
   erroForm.value = "";
 
   if (!props.usuario && form.value.Senha.length < 6) {

+ 30 - 0
src/components/base/ConfirmDialog.vue

@@ -0,0 +1,30 @@
+<script setup>
+import BaseModal from "./BaseModal.vue";
+import BaseButton from "./BaseButton.vue";
+
+defineProps({
+  open: { type: Boolean, required: true },
+  title: { type: String, default: "Confirmar ação" },
+  message: { type: String, default: "" },
+  confirmLabel: { type: String, default: "Confirmar" },
+  cancelLabel: { type: String, default: "Cancelar" },
+  variant: { type: String, default: "danger" },
+  loading: { type: Boolean, default: false },
+});
+
+const emit = defineEmits(["confirm", "cancel"]);
+</script>
+
+<template>
+  <BaseModal v-if="open" :title="title" max-width="max-w-sm" :close-disabled="loading" @close="emit('cancel')">
+    <div class="p-5 text-sm text-muted-foreground">
+      <slot>{{ message }}</slot>
+    </div>
+    <template #footer>
+      <div class="flex justify-end gap-2">
+        <BaseButton variant="secondary" :disabled="loading" @click="emit('cancel')">{{ cancelLabel }}</BaseButton>
+        <BaseButton :variant="variant" :loading="loading" :disabled="loading" @click="emit('confirm')">{{ confirmLabel }}</BaseButton>
+      </div>
+    </template>
+  </BaseModal>
+</template>

+ 1 - 1
src/components/base/ListItemCard.vue

@@ -12,7 +12,7 @@ defineEmits(["toggle"]);
     :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'
+        : 'border-gray-200 hover:-translate-y-0.5 hover:border-primary/60 hover:bg-black/[0.03] hover:shadow-md dark:border-gray-700 dark:hover:border-primary/30 dark:hover:bg-white/[0.04]'
     "
   >
     <button

+ 36 - 0
src/composables/useAsyncState.js

@@ -0,0 +1,36 @@
+import { ref } from "vue";
+
+export function useAsyncState(defaultValue = null) {
+  const data = ref(defaultValue);
+  const loading = ref(false);
+  const error = ref("");
+  let requestId = 0;
+
+  function nextId() {
+    return ++requestId;
+  }
+
+  function isStale(id) {
+    return id !== requestId;
+  }
+
+  async function run(fn, { onErrorMessage } = {}) {
+    const id = nextId();
+    loading.value = true;
+    error.value = "";
+    try {
+      const result = await fn();
+      if (isStale(id)) return;
+      data.value = result;
+      return result;
+    } catch (e) {
+      if (isStale(id)) return;
+      error.value = e?.message || onErrorMessage || "Erro inesperado.";
+      throw e;
+    } finally {
+      if (!isStale(id)) loading.value = false;
+    }
+  }
+
+  return { data, loading, error, run, isStale, nextId };
+}

+ 1 - 2
src/composables/useAuth.js

@@ -1,4 +1,4 @@
-import { computed, readonly, ref } from "vue";
+import { computed, ref } from "vue";
 import { loginRequest, logoutRequest, refreshTokenRequest } from "../api/auth.js";
 import { setAccessToken, setRefreshCallback, setAuthErrorCallback } from "../api/client.js";
 import { resetChatSingleton } from "./useChat.js";
@@ -154,7 +154,6 @@ async function logout() {
 
 export function useAuth() {
   return {
-    session: readonly(session),
     user,
     isAuthenticated,
     login,

+ 0 - 1
src/composables/useChat.js

@@ -352,7 +352,6 @@ export function useChat() {
     renameConversation,
     deleteConversation,
     searchConversations,
-    loadConversationsList,
     loadMoreConversations,
     exportConversation
   };

+ 7 - 6
src/composables/useDocumentos.js

@@ -1,5 +1,6 @@
 import { onMounted, reactive } from "vue";
 import { listDocuments } from "../api/chat.js";
+import { useAsyncState } from "./useAsyncState.js";
 
 export function useDocumentos() {
   const docs = reactive({
@@ -10,10 +11,10 @@ export function useDocumentos() {
 
   const PAGE_SIZE = 200;
   const MAX_PAGES = 25;
-  let docsEpoch = 0;
+  const raceGuard = useAsyncState();
 
   async function loadDocs() {
-    const epoch = ++docsEpoch;
+    const epoch = raceGuard.nextId();
     docs.loading = true;
     docs.error = "";
     try {
@@ -22,19 +23,19 @@ export function useDocumentos() {
       let offset = 0;
       for (let page = 0; page < MAX_PAGES; page += 1) {
         const data = await listDocuments({ limit: PAGE_SIZE, offset });
-        if (epoch !== docsEpoch) return;
+        if (raceGuard.isStale(epoch)) return;
         all.push(...(data.items ?? []));
         if (data.nextOffset == null) break;
         offset = data.nextOffset;
       }
-      if (epoch !== docsEpoch) return;
+      if (raceGuard.isStale(epoch)) return;
       docs.items = all;
     } catch (e) {
-      if (epoch !== docsEpoch) return;
+      if (raceGuard.isStale(epoch)) return;
       docs.error = e?.message || "erro";
       docs.items = [];
     } finally {
-      if (epoch === docsEpoch) docs.loading = false;
+      if (!raceGuard.isStale(epoch)) docs.loading = false;
     }
   }
 

+ 4 - 16
src/composables/useSearch.js

@@ -1,28 +1,16 @@
-import { ref } from "vue";
+import { useAsyncState } from "./useAsyncState.js";
 import { search } from "../api/chat.js";
 
 export function useSearch() {
-  const results = ref([]);
-  const loading = ref(false);
-  const error = ref("");
-  let requestId = 0;
+  const { data: results, loading, error, run: exec } = useAsyncState([]);
 
   async function run(query) {
     const q = String(query ?? "").trim();
     if (!q) return;
-    const currentRequest = ++requestId;
-    loading.value = true;
-    error.value = "";
     try {
-      const data = await search(q);
-      if (currentRequest !== requestId) return;
-      results.value = data.results ?? [];
-    } catch (e) {
-      if (currentRequest !== requestId) return;
-      error.value = e?.message || "erro";
+      await exec(() => search(q).then((data) => data.results ?? []), { onErrorMessage: "erro" });
+    } catch {
       results.value = [];
-    } finally {
-      if (currentRequest === requestId) loading.value = false;
     }
   }
 

+ 4 - 11
src/composables/useUsers.js

@@ -1,9 +1,7 @@
-import { ref } from "vue";
 import { listUsers, createUser as createUserAPI, updateUser as updateUserAPI, toggleUserStatus as toggleUserStatusAPI } from "../api/users.js";
+import { useAsyncState } from "./useAsyncState.js";
 
-const users = ref([]);
-const loading = ref(false);
-const error = ref("");
+const { data: users, loading, error, run: exec } = useAsyncState([]);
 
 export function resetUsersSingleton() {
   users.value = [];
@@ -13,15 +11,10 @@ export function resetUsersSingleton() {
 
 export function useUsers() {
   async function loadUsers() {
-    loading.value = true;
-    error.value = "";
     try {
-      users.value = await listUsers();
-    } catch (err) {
+      await exec(() => listUsers(), { onErrorMessage: "Erro ao carregar usuarios." });
+    } catch {
       users.value = [];
-      error.value = err?.message || "Erro ao carregar usuarios.";
-    } finally {
-      loading.value = false;
     }
   }
 

+ 7 - 9
src/composables/useWhatsappConexao.js

@@ -1,27 +1,25 @@
 import { ref } from "vue";
 import { statusWhatsapp } from "../api/whatsappConexao.js";
+import { useAsyncState } from "./useAsyncState.js";
 
 export function useWhatsappConexao() {
   const status = ref("nao_configurado");
   const telefone = ref(null);
   const verifiedName = ref(null);
   const ultimoErro = ref(null);
-  const carregando = ref(false);
-  const erro = ref("");
+  const { loading: carregando, error: erro, run: exec } = useAsyncState();
 
   async function atualizarStatus() {
-    carregando.value = true;
-    erro.value = "";
     try {
-      const estado = await statusWhatsapp();
+      const estado = await exec(() => statusWhatsapp(), {
+        onErrorMessage: "Falha ao consultar status do WhatsApp"
+      });
       status.value = estado.status;
       telefone.value = estado.telefone;
       verifiedName.value = estado.verifiedName;
       ultimoErro.value = estado.ultimoErro;
-    } catch (err) {
-      erro.value = err?.message ?? "Falha ao consultar status do WhatsApp";
-    } finally {
-      carregando.value = false;
+    } catch {
+      // erro já registrado em `erro` pelo useAsyncState
     }
   }
 

+ 15 - 35
src/layout/ListaConversas.vue

@@ -2,6 +2,9 @@
 import { computed, ref } from "vue";
 import { useRouter } from "vue-router";
 import SkeletonConversation from "../components/skeletons/SkeletonConversation.vue";
+import BaseIcon from "../components/base/BaseIcon.vue";
+import ConfirmDialog from "../components/base/ConfirmDialog.vue";
+import { Check, X, Pencil, Trash2 } from "lucide-vue-next";
 import { useChat } from "../composables/useChat.js";
 import { useConversaEdicao } from "../composables/useConversaEdicao.js";
 
@@ -96,9 +99,7 @@ async function abrirConversa(id) {
               title="Confirmar"
               @click.stop="confirmarEdicao(c.id)"
             >
-              <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
-                <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
-              </svg>
+              <BaseIcon :icon="Check" class="h-3.5 w-3.5" strokeWidth="2.5" />
             </button>
             <button
               type="button"
@@ -106,9 +107,7 @@ async function abrirConversa(id) {
               title="Cancelar"
               @click.stop="cancelarEdicao"
             >
-              <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
-                <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
-              </svg>
+              <BaseIcon :icon="X" class="h-3.5 w-3.5" strokeWidth="2.5" />
             </button>
           </div>
         </template>
@@ -129,9 +128,7 @@ async function abrirConversa(id) {
               aria-label="Renomear conversa"
               @click.stop="iniciarEdicao(c)"
             >
-              <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="M16.862 4.487l1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125" />
-              </svg>
+              <BaseIcon :icon="Pencil" class="h-3.5 w-3.5" />
             </button>
             <button
               type="button"
@@ -140,32 +137,7 @@ async function abrirConversa(id) {
               aria-label="Excluir conversa"
               @click.stop="confirmandoExclusaoId = c.id"
             >
-              <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="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
-              </svg>
-            </button>
-          </div>
-
-          <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-abyss-700"
-            @click.stop
-            @keydown.stop
-          >
-            <span class="min-w-0 flex-1 truncate text-xs text-gray-600 dark:text-white/60">Excluir esta conversa?</span>
-            <button
-              type="button"
-              class="shrink-0 rounded-lg bg-red-600 px-2.5 py-1 text-xs font-medium text-white"
-              @click.stop="onExcluir(c.id)"
-            >
-              Confirmar
-            </button>
-            <button
-              type="button"
-              class="shrink-0 rounded-lg border border-gray-300 px-2.5 py-1 text-xs font-medium text-gray-700 dark:border-white/20 dark:text-white/70"
-              @click.stop="confirmandoExclusaoId = null"
-            >
-              Cancelar
+              <BaseIcon :icon="Trash2" class="h-3.5 w-3.5" />
             </button>
           </div>
         </template>
@@ -188,5 +160,13 @@ async function abrirConversa(id) {
     >
       {{ conversationsLoading ? "Carregando..." : "Carregar mais" }}
     </button>
+
+    <ConfirmDialog
+      :open="!!confirmandoExclusaoId"
+      title="Excluir conversa"
+      message="Excluir esta conversa? Esta ação não pode ser desfeita."
+      @confirm="onExcluir(confirmandoExclusaoId)"
+      @cancel="confirmandoExclusaoId = null"
+    />
   </div>
 </template>

+ 0 - 99
src/layout/ModalDocumentos.vue

@@ -1,99 +0,0 @@
-<script setup>
-import DocumentList from "../components/DocumentList.vue";
-import DocumentUpload from "../components/DocumentUpload.vue";
-import SearchBox from "../components/SearchBox.vue";
-import { useSearch } from "../composables/useSearch.js";
-import { usePainelDocumentos } from "../composables/usePainelDocumentos.js";
-import { useDocumentos } from "../composables/useDocumentos.js";
-
-const { painelAberto, tituloModal, fecharModal } = usePainelDocumentos();
-
-const {
-  results: searchResults,
-  loading: searchLoading,
-  error: searchError,
-  run: runSearch,
-} = useSearch();
-
-const { docs, loadDocs, onIngested } = useDocumentos();
-</script>
-
-<template>
-  <Teleport to="body">
-    <Transition name="modal">
-      <div v-if="painelAberto" class="fixed inset-0 z-[9999]">
-        <div
-          class="modal-backdrop absolute inset-0 bg-black/40 dark:bg-black/60"
-          @click="fecharModal"
-        />
-        <div class="absolute inset-0 flex items-center justify-center p-4" @click="fecharModal">
-          <div
-            class="modal-panel w-full max-w-[980px] 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"
-            >
-              <div
-                class="text-base font-semibold text-foreground"
-              >
-                {{ tituloModal }}
-              </div>
-              <button
-                type="button"
-                class="rounded-lg border border-transparent p-1.5 text-gray-500 transition-colors hover:border-gray-200 hover:bg-gray-100 dark:text-gray-400 dark:hover:border-gray-700 dark:hover:bg-white/5"
-                @click="fecharModal"
-              >
-                <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
-                  <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
-                </svg>
-              </button>
-            </div>
-
-            <div class="max-h-[calc(100vh-8rem)] overflow-auto p-5">
-              <DocumentUpload
-                v-if="painelAberto === 'update'"
-                @ingested="onIngested"
-              />
-              <template v-else-if="painelAberto === 'documentos'">
-                <SearchBox
-                  :results="searchResults"
-                  :loading="searchLoading"
-                  :error="searchError"
-                  @search="runSearch"
-                />
-                <div class="h-4" />
-                <DocumentList
-                  :items="docs.items"
-                  :loading="docs.loading"
-                  :error="docs.error"
-                  @refresh="loadDocs"
-                />
-              </template>
-            </div>
-          </div>
-        </div>
-      </div>
-    </Transition>
-  </Teleport>
-</template>
-
-<style scoped>
-.modal-enter-active,
-.modal-leave-active {
-  transition: opacity 0.2s ease;
-}
-.modal-enter-from,
-.modal-leave-to {
-  opacity: 0;
-}
-.modal-enter-active .modal-panel,
-.modal-leave-active .modal-panel {
-  transition: transform 0.2s ease, opacity 0.2s ease;
-}
-.modal-enter-from .modal-panel,
-.modal-leave-to .modal-panel {
-  transform: scale(0.96) translateY(8px);
-  opacity: 0;
-}
-</style>

+ 1 - 1
src/utils/avatar.js

@@ -1,4 +1,4 @@
-export const AVATAR_CORES = [
+const AVATAR_CORES = [
   "bg-blue-500", "bg-violet-500", "bg-emerald-500",
   "bg-amber-500", "bg-rose-500", "bg-cyan-500", "bg-indigo-500",
 ];

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

@@ -6,6 +6,8 @@ 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 BaseIcon from "../../components/base/BaseIcon.vue";
+import { ArrowLeft, Mic, Play } from "lucide-vue-next";
 import { obterAtendimento, salvarGoldenLabel, aceitarGoldenLabelComoIa, sincronizarAtendimento, avaliarAtendimento } from "../../api/atendimentos.js";
 import { useAuth } from "../../composables/useAuth.js";
 import {
@@ -258,9 +260,7 @@ watch(() => route.params.id, carregar, { immediate: true });
                 title="Voltar"
                 @click="voltar"
               >
-                <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="M15.75 19.5 8.25 12l7.5-7.5" />
-                </svg>
+                <BaseIcon :icon="ArrowLeft" class="h-4 w-4" />
               </button>
               <div class="min-w-0">
                 <h2 class="text-base font-semibold text-foreground">
@@ -360,9 +360,7 @@ watch(() => route.params.id, carregar, { immediate: true });
                   </div>
                   <template v-if="ehAudio(m)">
                     <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>
+                      <BaseIcon :icon="Mic" class="h-3.5 w-3.5" />
                       Mensagem de áudio
                     </div>
                     <p v-if="m.Transcricao" class="mt-1 whitespace-pre-wrap break-words text-sm text-foreground">
@@ -396,9 +394,7 @@ watch(() => route.params.id, carregar, { immediate: true });
                       <video :src="m.MidiaUrl" preload="metadata" muted class="max-h-80 max-w-full rounded-lg object-contain"></video>
                       <div class="absolute inset-0 flex items-center justify-center bg-black/20">
                         <div class="flex h-11 w-11 items-center justify-center rounded-full bg-white/85 text-gray-900">
-                          <svg class="h-5 w-5 translate-x-0.5" viewBox="0 0 24 24" fill="currentColor">
-                            <path d="M8 5v14l11-7z" />
-                          </svg>
+                          <BaseIcon :icon="Play" class="h-5 w-5 translate-x-0.5" fill="currentColor" stroke="none" />
                         </div>
                       </div>
                     </div>

+ 19 - 38
src/views/atendimentos/AvaliacoesView.vue

@@ -9,13 +9,15 @@ 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 { ClipboardList, Star, CircleCheck, Check, Smile, TriangleAlert, Search, X, ChevronUp, ChevronDown, ChevronRight } from "lucide-vue-next";
+import BaseIcon from "../../components/base/BaseIcon.vue";
 import {
   avaliarAtendimentosPendentes,
   estatisticasAvaliacoes,
   listarAvaliacoes,
   listarSetoresAvaliacoes
 } from "../../api/atendimentos.js";
+import { useToast } from "../../composables/useToast.js";
 import {
   cancelamentoLabel,
   cancelamentoVariant,
@@ -36,6 +38,7 @@ const opcoesSentimento = Object.entries(sentimentoLabel).map(([value, label]) =>
 
 const route = useRoute();
 const router = useRouter();
+const toast = useToast();
 
 const carregando = ref(false);
 const avaliandoLote = ref(false);
@@ -121,8 +124,9 @@ async function carregarSetores() {
   try {
     const r = await listarSetoresAvaliacoes();
     setores.value = r.results ?? [];
-  } catch {
+  } catch (e) {
     setores.value = [];
+    toast.erro(e?.message ?? "Falha ao carregar setores");
   }
 }
 
@@ -248,9 +252,7 @@ onBeforeUnmount(() => {
               <div
                 class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary dark:bg-primary/20"
               >
-                <svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
-                  <path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.5c.16-.4.88-.4 1.04 0l1.98 4.88 5.25.4c.43.03.6.57.28.85l-4 3.46 1.22 5.13c.1.42-.36.75-.73.52L12 15.98l-4.52 2.76c-.37.23-.83-.1-.73-.52l1.22-5.13-4-3.46c-.33-.28-.15-.82.28-.85l5.25-.4 1.98-4.88Z" />
-                </svg>
+                <BaseIcon :icon="Star" class="h-5 w-5" />
               </div>
               <div class="min-w-0">
                 <h2 class="text-base font-semibold text-foreground">
@@ -302,40 +304,30 @@ onBeforeUnmount(() => {
         <section v-if="stats" class="grid grid-cols-2 gap-3 md:grid-cols-5">
           <StatCard label="Score médio" :value="stats.scoreMedio ?? '—'" suffix=" / 10" 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="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="h-4 w-4" />
             </template>
           </StatCard>
           <StatCard label="Avaliados" :value="stats.avaliados" variant="default">
             <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>
+              <BaseIcon :icon="CircleCheck" class="h-4 w-4" />
             </template>
             {{ stats.pendentes }} pendentes
           </StatCard>
           <StatCard label="Problemas resolvidos" :value="pct(stats.resolvido?.sim ?? 0, stats.avaliados)" variant="success">
             <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="m4.5 12.75 6 6 9-13.5" />
-              </svg>
+              <BaseIcon :icon="Check" class="h-4 w-4" />
             </template>
             {{ stats.resolvido?.sim ?? 0 }} sim · {{ stats.resolvido?.parcial ?? 0 }} parcial · {{ stats.resolvido?.nao ?? 0 }} não
           </StatCard>
           <StatCard label="Clientes satisfeitos" :value="pct(stats.sentimento?.positivo ?? 0, stats.avaliados)" variant="violet">
             <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 15s1.125 1.5 3 1.5S15 15 15 15M9.75 9.75h.008v.008H9.75V9.75Zm4.5 0h.008v.008h-.008V9.75ZM21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
-              </svg>
+              <BaseIcon :icon="Smile" class="h-4 w-4" />
             </template>
             {{ stats.sentimento?.negativo ?? 0 }} saíram insatisfeitos
           </StatCard>
           <StatCard label="Cancelamentos" :value="pct(stats.cancelamento?.cancelou ?? 0, stats.avaliados)" variant="danger">
             <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="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="h-4 w-4" />
             </template>
             {{ stats.cancelamento?.cancelou ?? 0 }} cancelaram · {{ stats.cancelamento?.ameacou ?? 0 }} ameaçaram cancelar
           </StatCard>
@@ -344,15 +336,10 @@ onBeforeUnmount(() => {
         <section class="rounded-2xl border border-gray-200 bg-background/70 shadow-sm dark:border-gray-700 dark:bg-background/20">
           <div class="flex flex-wrap items-center gap-3 border-b border-gray-200 p-4 dark:border-gray-700">
             <div class="relative min-w-[240px] flex-1 sm:max-w-[340px]">
-              <svg
+              <BaseIcon
+                :icon="Search"
                 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"
-                stroke-width="2"
-              >
-                <path stroke-linecap="round" stroke-linejoin="round" d="m21 21-4.35-4.35M17 10.5a6.5 6.5 0 1 1-13 0 6.5 6.5 0 0 1 13 0Z" />
-              </svg>
+              />
               <input
                 v-model="filtroBusca"
                 type="search"
@@ -368,9 +355,7 @@ onBeforeUnmount(() => {
                 title="Limpar busca"
                 @click="filtroBusca = ''; onBuscaEnter();"
               >
-                <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
-                  <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
-                </svg>
+                <BaseIcon :icon="X" class="h-4 w-4" />
               </button>
             </div>
             <div class="flex shrink-0 items-center gap-2">
@@ -403,10 +388,8 @@ onBeforeUnmount(() => {
               @click="alternarOrdenacao"
             >
               Data
-              <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
-                <path v-if="filtroOrdenacao === 'asc'" stroke-linecap="round" stroke-linejoin="round" d="m4.5 15.75 7.5-7.5 7.5 7.5" />
-                <path v-else stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
-              </svg>
+              <BaseIcon v-if="filtroOrdenacao === 'asc'" :icon="ChevronUp" class="h-3.5 w-3.5" />
+              <BaseIcon v-else :icon="ChevronDown" class="h-3.5 w-3.5" />
             </BaseButton>
             <div class="ml-auto text-xs text-muted-foreground">
               {{ total }} avaliações
@@ -484,9 +467,7 @@ onBeforeUnmount(() => {
               <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>
+                  <BaseIcon :icon="ChevronRight" class="h-3.5 w-3.5" />
                 </BaseButton>
                 <div class="text-xs text-muted-foreground">
                   Avaliado por {{ a.Modelo }} em {{ formatData(a.UpdatedAt) }} · {{ a.TotalMensagens }} mensagens

+ 7 - 16
src/views/atendimentos/GoldenSetView.vue

@@ -8,7 +8,8 @@ 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 { FolderSearch, CircleCheck, CircleAlert, Check } from "lucide-vue-next";
+import BaseIcon from "../../components/base/BaseIcon.vue";
 import { listarGoldenSet } from "../../api/atendimentos.js";
 
 const router = useRouter();
@@ -70,9 +71,7 @@ onMounted(carregar);
             <div
               class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary dark:bg-primary/20"
             >
-              <svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
-                <path stroke-linecap="round" stroke-linejoin="round" d="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="h-5 w-5" />
             </div>
             <div class="min-w-0">
               <h2 class="text-base font-semibold text-foreground">Golden Set</h2>
@@ -90,30 +89,22 @@ onMounted(carregar);
         <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">
-                <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="h-4 w-4" />
             </template>
           </StatCard>
           <StatCard label="Pendentes" :value="totalPendentes" variant="warning">
             <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="M12 9v4m0 4h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
-              </svg>
+              <BaseIcon :icon="CircleAlert" class="h-4 w-4" />
             </template>
           </StatCard>
           <StatCard label="Rotulados" :value="totalRotulados" variant="success">
             <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="m4.5 12.75 6 6 9-13.5" />
-              </svg>
+              <BaseIcon :icon="Check" class="h-4 w-4" />
             </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>
+              <BaseIcon :icon="CircleCheck" class="h-4 w-4" />
             </template>
           </StatCard>
         </section>

+ 16 - 35
src/views/atendimentos/NaoResolvidosView.vue

@@ -9,7 +9,8 @@ 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 { CircleCheck, TriangleAlert, Star, BarChart3, Search, X, ChevronUp, ChevronDown, Download, ChevronRight } from "lucide-vue-next";
+import BaseIcon from "../../components/base/BaseIcon.vue";
 import { baixarRelatorioNaoResolvidos, estatisticasAvaliacoes, listarAvaliacoes, listarSetoresAvaliacoes } from "../../api/atendimentos.js";
 import { useToast } from "../../composables/useToast.js";
 import {
@@ -127,8 +128,9 @@ async function carregarSetores() {
   try {
     const r = await listarSetoresAvaliacoes();
     setores.value = r.results ?? [];
-  } catch {
+  } catch (e) {
     setores.value = [];
+    toast.erro(e?.message ?? "Falha ao carregar setores");
   }
 }
 
@@ -250,9 +252,7 @@ onBeforeUnmount(() => {
               <div
                 class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-red-500/10 text-red-600 dark:bg-red-500/20 dark:text-red-400"
               >
-                <svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
-                  <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v4m0 4h.01M10.29 3.86 1.82 18a1.5 1.5 0 0 0 1.3 2.25h17.76a1.5 1.5 0 0 0 1.3-2.25L13.71 3.86a1.5 1.5 0 0 0-2.42 0Z" />
-                </svg>
+                <BaseIcon :icon="TriangleAlert" class="h-5 w-5" />
               </div>
               <div class="min-w-0">
                 <h2 class="text-base font-semibold text-foreground">
@@ -288,24 +288,18 @@ onBeforeUnmount(() => {
         <section v-if="stats" class="grid grid-cols-2 gap-3 md:grid-cols-3">
           <StatCard label="Não resolvidos no período" :value="stats.avaliados" variant="danger">
             <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="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="h-4 w-4" />
             </template>
             {{ pct(stats.avaliados, statsGeral?.avaliados) }} dos {{ statsGeral?.avaliados ?? 0 }} avaliados
           </StatCard>
           <StatCard label="Score médio dos não resolvidos" :value="stats.scoreMedio ?? '—'" suffix=" / 10" 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="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="h-4 w-4" />
             </template>
           </StatCard>
           <StatCard label="Por setor" variant="default">
             <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="M3 13.5h4.5V21H3v-7.5Zm6.75-6H14.25V21H9.75V7.5Zm6.75 3H21V21h-4.5v-10.5Z" />
-              </svg>
+              <BaseIcon :icon="BarChart3" class="h-4 w-4" />
             </template>
             <div class="space-y-1">
               <div v-if="setoresOrdenados.length === 0" class="text-sm text-muted-foreground">—</div>
@@ -320,15 +314,10 @@ onBeforeUnmount(() => {
         <section class="rounded-2xl border border-gray-200 bg-background/70 shadow-sm dark:border-gray-700 dark:bg-background/20">
           <div class="flex flex-wrap items-center gap-3 border-b border-gray-200 p-4 dark:border-gray-700">
             <div class="relative min-w-[240px] flex-1 sm:max-w-[340px]">
-              <svg
+              <BaseIcon
+                :icon="Search"
                 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"
-                stroke-width="2"
-              >
-                <path stroke-linecap="round" stroke-linejoin="round" d="m21 21-4.35-4.35M17 10.5a6.5 6.5 0 1 1-13 0 6.5 6.5 0 0 1 13 0Z" />
-              </svg>
+              />
               <input
                 v-model="filtroBusca"
                 type="search"
@@ -344,9 +333,7 @@ onBeforeUnmount(() => {
                 title="Limpar busca"
                 @click="filtroBusca = ''; onBuscaEnter();"
               >
-                <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
-                  <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
-                </svg>
+                <BaseIcon :icon="X" class="h-4 w-4" />
               </button>
             </div>
             <div class="flex shrink-0 items-center gap-2">
@@ -372,10 +359,8 @@ onBeforeUnmount(() => {
               @click="alternarOrdenacao"
             >
               Data
-              <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
-                <path v-if="filtroOrdenacao === 'asc'" stroke-linecap="round" stroke-linejoin="round" d="m4.5 15.75 7.5-7.5 7.5 7.5" />
-                <path v-else stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
-              </svg>
+              <BaseIcon v-if="filtroOrdenacao === 'asc'" :icon="ChevronUp" class="h-3.5 w-3.5" />
+              <BaseIcon v-else :icon="ChevronDown" class="h-3.5 w-3.5" />
             </BaseButton>
             <BaseButton
               variant="secondary"
@@ -386,9 +371,7 @@ onBeforeUnmount(() => {
               @click="baixarRelatorio"
             >
               Baixar relatório
-              <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="M12 3v12m0 0-4-4m4 4 4-4M4 20h16" />
-              </svg>
+              <BaseIcon :icon="Download" class="h-3.5 w-3.5" />
             </BaseButton>
             <div class="ml-auto text-xs text-muted-foreground">
               {{ total }} não resolvidos
@@ -461,9 +444,7 @@ onBeforeUnmount(() => {
               <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>
+                  <BaseIcon :icon="ChevronRight" class="h-3.5 w-3.5" />
                 </BaseButton>
                 <div class="text-xs text-muted-foreground">
                   Avaliado por {{ a.Modelo }} em {{ formatData(a.UpdatedAt) }} · {{ a.TotalMensagens }} mensagens

+ 5 - 12
src/views/auth/LoginView.vue

@@ -5,6 +5,8 @@ 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 BaseIcon from "../../components/base/BaseIcon.vue";
+import { LocateFixed, Eye, EyeOff } from "lucide-vue-next";
 import { useAuth } from "../../composables/useAuth.js";
 
 const router = useRouter();
@@ -131,11 +133,7 @@ async function onSubmit() {
             aria-hidden="true"
             class="flex h-11 w-11 items-center justify-center rounded-2xl bg-primary/10 text-primary dark:bg-primary/20"
           >
-            <svg class="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
-              <circle cx="12" cy="12" r="8.25" />
-              <circle cx="12" cy="12" r="3" fill="currentColor" stroke="none" />
-              <path stroke-linecap="round" d="M12 1.75v2.5M12 19.75v2.5M1.75 12h2.5M19.75 12h2.5" />
-            </svg>
+            <BaseIcon :icon="LocateFixed" class="h-6 w-6" />
           </div>
           <div>
             <div class="text-2xl font-extrabold tracking-tight">Entrar</div>
@@ -186,13 +184,8 @@ async function onSubmit() {
                 :aria-label="senhaVisivel ? 'Ocultar senha' : 'Mostrar senha'"
                 @click="senhaVisivel = !senhaVisivel"
               >
-                <svg v-if="!senhaVisivel" 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="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
-                  <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
-                </svg>
-                <svg v-else 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="M3.98 8.223A10.477 10.477 0 0 0 1.934 12c1.292 4.338 5.31 7.5 10.066 7.5.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" />
-                </svg>
+                <BaseIcon v-if="!senhaVisivel" :icon="Eye" class="h-4 w-4" />
+                <BaseIcon v-else :icon="EyeOff" class="h-4 w-4" />
               </button>
             </div>
             <p

+ 8 - 31
src/views/configuracoes/ConfiguracoesView.vue

@@ -4,6 +4,8 @@ import LayoutSistema from "../../layout/LayoutSistema.vue";
 import BotaoAlterarTema from "../../components/BotaoAlterarTema.vue";
 import BaseButton from "../../components/base/BaseButton.vue";
 import BaseBadge from "../../components/base/BaseBadge.vue";
+import BaseIcon from "../../components/base/BaseIcon.vue";
+import { SlidersVertical, Users, ChevronRight, RefreshCw, MessageCircle, Sun } from "lucide-vue-next";
 import { useAuth } from "../../composables/useAuth.js";
 import { useWhatsappConexao } from "../../composables/useWhatsappConexao.js";
 import { sincronizarAtendimentos, sincronizarTodosAtendimentos } from "../../api/atendimentos.js";
@@ -83,17 +85,7 @@ async function importarTudo() {
             <div
               class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary dark:bg-primary/20"
             >
-              <svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
-                <path stroke-linecap="round" stroke-linejoin="round" d="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="SlidersVertical" class="h-5 w-5" />
             </div>
             <div class="min-w-0">
               <h2 class="text-base font-semibold text-foreground">
@@ -118,14 +110,7 @@ async function importarTudo() {
             <div class="flex items-center justify-between gap-4">
               <div class="flex items-center gap-3">
                 <div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary dark:bg-primary/20">
-                  <svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
-                    <path stroke-linecap="round" stroke-linejoin="round" d="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" />
-                    <path stroke-linecap="round" stroke-linejoin="round" d="M7.5 18.5A3.5 3.5 0 0 0 4 15" />
-                    <path stroke-linecap="round" stroke-linejoin="round" d="M16.5 18.5A3.5 3.5 0 0 1 20 15" />
-                    <path stroke-linecap="round" stroke-linejoin="round" d="M5 12a2.25 2.25 0 1 0 0-4.5A2.25 2.25 0 0 0 5 12Z" />
-                    <path stroke-linecap="round" stroke-linejoin="round" d="M19 12a2.25 2.25 0 1 0 0-4.5A2.25 2.25 0 0 0 19 12Z" />
-                  </svg>
+                  <BaseIcon :icon="Users" class="h-5 w-5" />
                 </div>
                 <div>
                   <div class="text-sm font-semibold text-foreground">Gerenciar Usuários</div>
@@ -133,9 +118,7 @@ async function importarTudo() {
                 </div>
               </div>
               <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>
+                <BaseIcon :icon="ChevronRight" class="h-5 w-5 shrink-0" />
               </span>
             </div>
           </RouterLink>
@@ -150,9 +133,7 @@ async function importarTudo() {
             <div class="flex items-center justify-between gap-4">
               <div class="flex items-center gap-3">
                 <div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary dark:bg-primary/20">
-                  <svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
-                    <path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
-                  </svg>
+                  <BaseIcon :icon="RefreshCw" class="h-5 w-5" />
                 </div>
                 <div>
                   <div class="text-sm font-semibold text-foreground">Atendimentos do Chatbot</div>
@@ -195,9 +176,7 @@ async function importarTudo() {
             <div class="flex items-center justify-between gap-4">
               <div class="flex items-center gap-3">
                 <div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary dark:bg-primary/20">
-                  <svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
-                    <path stroke-linecap="round" stroke-linejoin="round" d="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>
+                  <BaseIcon :icon="MessageCircle" class="h-5 w-5" />
                 </div>
                 <div>
                   <div class="text-sm font-semibold text-foreground">Número (WhatsApp Cloud API)</div>
@@ -231,9 +210,7 @@ async function importarTudo() {
             <div class="flex items-center justify-between gap-4">
               <div class="flex items-center gap-3">
                 <div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary dark:bg-primary/20">
-                  <svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
-                    <path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" />
-                  </svg>
+                  <BaseIcon :icon="Sun" class="h-5 w-5" />
                 </div>
                 <div>
                   <div class="text-sm font-semibold text-foreground">Tema</div>

+ 15 - 31
src/views/conversas/ConversasHistoricoView.vue

@@ -2,6 +2,9 @@
 import { computed, ref } from "vue";
 import { useRouter } from "vue-router";
 import LayoutSistema from "../../layout/LayoutSistema.vue";
+import BaseIcon from "../../components/base/BaseIcon.vue";
+import ConfirmDialog from "../../components/base/ConfirmDialog.vue";
+import { History, Trash2 } from "lucide-vue-next";
 import { useChat } from "../../composables/useChat.js";
 
 const router = useRouter();
@@ -19,7 +22,8 @@ function formatarData(ts) {
       day: "2-digit", month: "2-digit", year: "numeric",
       hour: "2-digit", minute: "2-digit"
     });
-  } catch {
+  } catch (e) {
+    console.warn("[ConversasHistoricoView] formatarData falhou:", e);
     return "";
   }
 }
@@ -55,10 +59,7 @@ function confirmarExclusao(id) {
 
       <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-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>
+          <BaseIcon :icon="History" class="h-6 w-6 text-muted-foreground" />
         </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>
@@ -89,41 +90,24 @@ function confirmarExclusao(id) {
           </button>
 
           <button
-            v-if="confirmandoExclusaoId !== c.id"
             type="button"
             class="rounded-xl border border-gray-200 bg-background/60 px-3 py-2 text-gray-400 transition-colors hover:border-red-300 hover:bg-red-50 hover:text-red-600 dark:border-gray-700 dark:bg-background/10 dark:text-gray-500 dark:hover:border-red-500/40 dark:hover:bg-red-500/10 dark:hover:text-red-400"
             title="Excluir conversa"
             aria-label="Excluir conversa"
             @click="pedirExclusao(c.id)"
           >
-            <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="M3 6h18" />
-              <path stroke-linecap="round" stroke-linejoin="round" d="M19 6l-1 14H6L5 6" />
-              <path stroke-linecap="round" stroke-linejoin="round" d="M9 6V4h6v2" />
-            </svg>
+            <BaseIcon :icon="Trash2" class="h-4 w-4" />
           </button>
-          <div
-            v-else
-            class="flex items-center gap-1.5 rounded-xl border border-red-300 bg-red-50 px-2 dark:border-red-500/40 dark:bg-red-500/10"
-          >
-            <span class="text-xs text-red-600 dark:text-red-400">Excluir?</span>
-            <button
-              type="button"
-              class="rounded-lg bg-red-600 px-2 py-1 text-xs font-medium text-white"
-              @click="confirmarExclusao(c.id)"
-            >
-              Sim
-            </button>
-            <button
-              type="button"
-              class="rounded-lg border border-gray-300 px-2 py-1 text-xs font-medium text-gray-700 dark:border-gray-600 dark:text-gray-300"
-              @click="cancelarExclusao"
-            >
-              Não
-            </button>
-          </div>
         </div>
       </div>
     </section>
+
+    <ConfirmDialog
+      :open="!!confirmandoExclusaoId"
+      title="Excluir conversa"
+      message="Excluir esta conversa? Esta ação não pode ser desfeita."
+      @confirm="confirmarExclusao(confirmandoExclusaoId)"
+      @cancel="cancelarExclusao"
+    />
   </LayoutSistema>
 </template>

+ 6 - 11
src/views/conversas/ConversasPesquisarView.vue

@@ -3,6 +3,8 @@ import { computed, ref } from "vue";
 import { useRouter } from "vue-router";
 import LayoutSistema from "../../layout/LayoutSistema.vue";
 import BaseButton from "../../components/base/BaseButton.vue";
+import BaseIcon from "../../components/base/BaseIcon.vue";
+import { Search } from "lucide-vue-next";
 import { useChat } from "../../composables/useChat.js";
 
 const router = useRouter();
@@ -35,15 +37,10 @@ function abrirConversa(id) {
 
       <div class="mt-3 grid grid-cols-1 gap-2 compact:grid-cols-[1fr_120px]">
         <div class="relative">
-          <svg
+          <BaseIcon
+            :icon="Search"
             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"
-            stroke-width="2"
-          >
-            <path stroke-linecap="round" stroke-linejoin="round" d="m21 21-4.35-4.35M17 10.5a6.5 6.5 0 1 1-13 0 6.5 6.5 0 0 1 13 0Z" />
-          </svg>
+          />
           <input
             v-model="draftQuery"
             placeholder="Buscar por título..."
@@ -86,9 +83,7 @@ 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-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>
+            <BaseIcon :icon="Search" class="h-6 w-6 text-muted-foreground" />
           </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>

+ 22 - 53
src/views/conversas/ConversasWhatsappView.vue

@@ -4,6 +4,9 @@ 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 BaseIcon from "../../components/base/BaseIcon.vue";
+import ConfirmDialog from "../../components/base/ConfirmDialog.vue";
+import { MessageCircle, Trash2, ArrowLeft, Mic, Play, Sparkles, RefreshCw, X, TriangleAlert } from "lucide-vue-next";
 import { useConversas } from "../../composables/useConversas.js";
 import { useAuth } from "../../composables/useAuth.js";
 import { useToast } from "../../composables/useToast.js";
@@ -251,9 +254,7 @@ onUnmounted(() => {
           {{ conversasError }}
         </div>
         <div v-else-if="!conversas.length" class="flex flex-1 flex-col items-center justify-center gap-2 p-4 text-center">
-          <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>
+          <BaseIcon :icon="MessageCircle" class="h-8 w-8 text-gray-300 dark:text-gray-600" strokeWidth="1.5" />
           <p class="text-sm text-muted-foreground">Nenhuma conversa ainda.</p>
           <p class="text-xs text-muted-foreground">Mande uma mensagem para o número de teste no WhatsApp.</p>
         </div>
@@ -305,34 +306,9 @@ onUnmounted(() => {
                 aria-label="Excluir conversa"
                 @click="confirmandoExclusao = c.Id"
               >
-                <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="m19 7-.867 12.142A2 2 0 0 1 16.138 21H7.862a2 2 0 0 1-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v3M4 7h16" />
-                </svg>
+                <BaseIcon :icon="Trash2" class="h-4 w-4" />
               </button>
 
-              <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-abyss-900"
-              >
-                <span class="text-xs text-muted-foreground">Excluir esta conversa?</span>
-                <div class="flex items-center gap-1.5">
-                  <button
-                    type="button"
-                    class="rounded-lg bg-red-600 px-3 py-1 text-xs font-medium text-white disabled:opacity-50"
-                    :disabled="excluindo === c.Id"
-                    @click="onExcluir(c.Id)"
-                  >
-                    Confirmar
-                </button>
-                <button
-                  type="button"
-                  class="rounded-lg border border-gray-300 px-3 py-1 text-xs font-medium text-gray-700 dark:border-gray-600 dark:text-gray-300"
-                  @click="confirmandoExclusao = null"
-                >
-                  Cancelar
-                </button>
-              </div>
-            </div>
             </div>
           </div>
         </template>
@@ -357,9 +333,7 @@ onUnmounted(() => {
               title="Voltar para a lista"
               @click="fecharConversa"
             >
-              <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="M15 19l-7-7 7-7" />
-              </svg>
+              <BaseIcon :icon="ArrowLeft" class="h-4 w-4" />
             </button>
             <div
               v-if="conversaAtiva"
@@ -398,9 +372,7 @@ onUnmounted(() => {
               <ChatBubble :own="m.Direcao === 'saida'">
                   <template v-if="ehAudio(midiaInfo(m))">
                     <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>
+                      <BaseIcon :icon="Mic" class="h-3.5 w-3.5" />
                       Mensagem de áudio
                     </div>
                     <audio v-if="midiaUrls[m.Id]" controls preload="none" :src="midiaUrls[m.Id]" class="mt-1.5 h-9 w-64 max-w-full"></audio>
@@ -446,9 +418,7 @@ onUnmounted(() => {
                       <video :src="midiaUrls[m.Id]" preload="metadata" muted class="max-h-80 max-w-full rounded-lg object-contain"></video>
                       <div class="absolute inset-0 flex items-center justify-center bg-black/20">
                         <div class="flex h-11 w-11 items-center justify-center rounded-full bg-white/85 text-gray-900">
-                          <svg class="h-5 w-5 translate-x-0.5" viewBox="0 0 24 24" fill="currentColor">
-                            <path d="M8 5v14l11-7z" />
-                          </svg>
+                          <BaseIcon :icon="Play" class="h-5 w-5 translate-x-0.5" fill="currentColor" stroke="none" />
                         </div>
                       </div>
                     </div>
@@ -484,9 +454,7 @@ onUnmounted(() => {
             <div class="mb-1.5 flex items-center justify-between gap-2">
               <div class="flex min-w-0 items-center gap-2">
                 <span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/15 text-primary dark:bg-primary/25">
-                  <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
-                    <path d="M12 3v4M12 17v4M3 12h4M17 12h4M6.34 6.34l1.42 1.42M16.24 16.24l1.42 1.42M6.34 17.66l1.42-1.42M16.24 7.76l1.42-1.42" />
-                  </svg>
+                  <BaseIcon :icon="Sparkles" class="h-3.5 w-3.5" />
                 </span>
                 <span class="truncate text-[11px] font-semibold uppercase tracking-wide text-primary">Sugestão do star-atendente</span>
               </div>
@@ -499,9 +467,7 @@ onUnmounted(() => {
                   :disabled="sugestaoLoading"
                   @click="buscarSugestao"
                 >
-                  <svg class="h-3.5 w-3.5" :class="{ 'animate-spin': sugestaoLoading }" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
-                    <path d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
-                  </svg>
+                  <BaseIcon :icon="RefreshCw" class="h-3.5 w-3.5" :class="{ 'animate-spin': sugestaoLoading }" />
                 </button>
                 <button
                   type="button"
@@ -510,9 +476,7 @@ onUnmounted(() => {
                   aria-label="Descartar sugestão"
                   @click="descartarSugestao"
                 >
-                  <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
-                    <path d="M18 6L6 18M6 6l12 12" />
-                  </svg>
+                  <BaseIcon :icon="X" class="h-3.5 w-3.5" />
                 </button>
               </div>
             </div>
@@ -533,9 +497,7 @@ onUnmounted(() => {
           </div>
           <div v-else-if="sugestaoLoading" class="mx-3 mb-2 flex items-center gap-2 rounded-xl border border-primary/30 bg-primary/5 p-3 dark:border-primary/40 dark:bg-primary/10">
             <span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/15 text-primary dark:bg-primary/25">
-              <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
-                <path d="M12 3v4M12 17v4M3 12h4M17 12h4M6.34 6.34l1.42 1.42M16.24 16.24l1.42 1.42M6.34 17.66l1.42-1.42M16.24 7.76l1.42-1.42" />
-              </svg>
+              <BaseIcon :icon="Sparkles" class="h-3.5 w-3.5" />
             </span>
             <span class="flex items-center gap-1">
               <span class="h-1.5 w-1.5 animate-bounce rounded-full bg-primary" style="animation-delay: 0ms" />
@@ -545,9 +507,7 @@ onUnmounted(() => {
             <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">
-              <path d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
-            </svg>
+            <BaseIcon :icon="TriangleAlert" class="mt-0.5 h-4 w-4 shrink-0 text-red-500 dark:text-red-400" />
             <div class="min-w-0 flex-1">
               <p class="text-xs text-red-800 dark:text-red-300">Não foi possível gerar a sugestão: {{ sugestaoError }}</p>
               <button
@@ -591,6 +551,15 @@ onUnmounted(() => {
       :tipo="mediaViewer.tipo"
       @close="mediaViewer = null"
     />
+
+    <ConfirmDialog
+      :open="!!confirmandoExclusao"
+      title="Excluir conversa"
+      message="Excluir esta conversa? Esta ação não pode ser desfeita."
+      :loading="excluindo === confirmandoExclusao"
+      @confirm="onExcluir(confirmandoExclusao)"
+      @cancel="confirmandoExclusao = null"
+    />
   </LayoutSistema>
 </template>
 

+ 5 - 9
src/views/pagina-inicial/PaginaInicialView.vue

@@ -5,6 +5,8 @@ import ChatModeToggle from "../../components/ChatModeToggle.vue";
 import LayoutSistema from "../../layout/LayoutSistema.vue";
 import { useChat } from "../../composables/useChat.js";
 import BaseButton from "../../components/base/BaseButton.vue";
+import BaseIcon from "../../components/base/BaseIcon.vue";
+import { Send, X, Download } from "lucide-vue-next";
 
 const {
   messages: chatMessages,
@@ -139,9 +141,7 @@ onMounted(() => {
           <div class="mt-3 flex items-center justify-between gap-3 ">
             <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" />
-              </svg>
+              <BaseIcon :icon="Send" class="h-4 w-4" />
               Enviar
             </BaseButton>
           </div>
@@ -152,9 +152,7 @@ onMounted(() => {
         <div v-if="chatError" class="mt-3 flex items-center justify-between gap-2 rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-300">
           <span>Erro: {{ chatError }}</span>
           <button type="button" class="shrink-0 rounded p-0.5 opacity-60 hover:opacity-100 transition-opacity" aria-label="Fechar erro" @click="clearError">
-            <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
-              <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
-            </svg>
+            <BaseIcon :icon="X" class="h-3.5 w-3.5" strokeWidth="2.5" />
           </button>
         </div>
       </div>
@@ -170,9 +168,7 @@ onMounted(() => {
             :disabled="exportando || chatLoading"
             @click="onExport"
           >
-            <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="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
-            </svg>
+            <BaseIcon :icon="Download" class="h-3.5 w-3.5" />
             {{ exportando ? "Exportando..." : "Exportar .md" }}
           </button>
         </div>

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

@@ -8,6 +8,9 @@ import { useToast } from "../../composables/useToast.js";
 import { listarSolicitacoesReset, dispensarSolicitacaoReset } from "../../api/users.js";
 import BaseButton from "../../components/base/BaseButton.vue";
 import BaseBadge from "../../components/base/BaseBadge.vue";
+import Spinner from "../../components/base/Spinner.vue";
+import BaseIcon from "../../components/base/BaseIcon.vue";
+import { Plus, Search, User, Pencil, KeyRound } from "lucide-vue-next";
 import { avatarIniciais, avatarCor } from "../../utils/avatar.js";
 
 const { users, loading, error, loadUsers, toggleStatus } = useUsers();
@@ -168,9 +171,7 @@ onMounted(async () => {
             {{ totalUsuarios }} usuario{{ totalUsuarios === 1 ? "" : "s" }}
           </div>
           <BaseButton @click="abrirNovoUsuario">
-            <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="M12 4.5v15m7.5-7.5h-15" />
-            </svg>
+            <BaseIcon :icon="Plus" class="h-4 w-4" />
             Novo usuário
           </BaseButton>
         </div>
@@ -208,12 +209,10 @@ 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
+          <BaseIcon
+            :icon="Search"
             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" />
-          </svg>
+          />
           <input
             v-model="busca"
             placeholder="Buscar por nome, login ou e-mail..."
@@ -239,12 +238,7 @@ onMounted(async () => {
       </div>
 
      
-      <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>
-        Carregando usuários...
-      </div>
+      <Spinner v-if="loading" class="mt-6" label="Carregando usuários..." />
 
       <div
         v-else-if="error"
@@ -259,10 +253,7 @@ 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-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>
+          <BaseIcon :icon="User" class="h-6 w-6 text-muted-foreground" />
         </div>
         <div class="text-sm font-medium text-muted-foreground">Nenhum usuário encontrado.</div>
       </div>
@@ -345,9 +336,7 @@ onMounted(async () => {
                     aria-label="Editar usuário"
                     @click="abrirEdicao(usuario)"
                   >
-                    <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="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" />
-                    </svg>
+                    <BaseIcon :icon="Pencil" class="h-4 w-4" />
                   </BaseButton>
                   <BaseButton
                     variant="ghost"
@@ -357,9 +346,7 @@ onMounted(async () => {
                     aria-label="Redefinir senha"
                     @click="abrirRedefinicaoSenha(usuario)"
                   >
-                    <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="M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1 1 21.75 8.25Z" />
-                    </svg>
+                    <BaseIcon :icon="KeyRound" class="h-4 w-4" />
                   </BaseButton>
                   <BaseButton
                     :variant="String(usuario.Status) === '1' ? 'danger' : 'secondary'"