leonardo 2 tháng trước cách đây
mục cha
commit
555d3edcbe

+ 2 - 2
dist/index.html

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

+ 107 - 14
src/components/ChatWindow.vue

@@ -12,7 +12,7 @@
       </div>
 
       <div v-else class="grid gap-4">
-        <div v-for="(m, idx) in messages" :key="idx" class="flex flex-col" :class="m.role === 'user' ? 'items-end' : 'items-start'">
+        <div v-for="(m, idx) in messages" :key="idx" class="group flex flex-col" :class="m.role === 'user' ? 'items-end' : 'items-start'">
           <div class="mb-0.5 flex items-center gap-1.5" :class="m.role === 'user' ? 'flex-row-reverse' : ''">
             <div
               class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[9px] font-bold text-white"
@@ -27,15 +27,40 @@
               {{ formatarHora(m.sentAt) }}
             </span>
           </div>
-          <div
-            class="max-w-[78%] whitespace-pre-wrap rounded-2xl border px-3 py-2 leading-snug text-sm"
-            :class="
-              m.role === 'user'
-                ? 'border-primary/25 bg-primary/10 text-foreground'
-                : 'border-gray-200 bg-white/50 text-foreground dark:border-gray-700 dark:bg-gray-800/60'
-            "
-          >
-            {{ m.content }}
+
+          <div class="relative max-w-[78%]">
+            <!-- Botão copiar mensagem -->
+            <button
+              type="button"
+              class="copy-btn absolute -top-1 z-10 flex items-center gap-1 rounded-md border border-gray-200 bg-white px-1.5 py-0.5 text-[10px] font-medium text-gray-500 opacity-0 shadow-sm transition-opacity group-hover:opacity-100 hover:text-gray-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
+              :class="m.role === 'user' ? 'right-0' : 'left-0'"
+              :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>
+              {{ copiado === idx ? 'Copiado!' : 'Copiar' }}
+            </button>
+
+            <div
+              class="rounded-2xl border px-3 py-2 text-sm leading-relaxed"
+              :class="
+                m.role === 'user'
+                  ? 'border-primary/25 bg-primary/10 text-foreground'
+                  : 'prose-chat border-gray-200 bg-white/50 text-foreground dark:border-gray-700 dark:bg-gray-800/60'
+              "
+            >
+              <!-- Usuário: texto puro -->
+              <span v-if="m.role === 'user'" class="whitespace-pre-wrap">{{ m.content }}</span>
+              <!-- Assistente: Markdown renderizado -->
+              <!-- eslint-disable-next-line vue/no-v-html -->
+              <div v-else class="prose-content" v-html="renderMarkdown(m.content)" />
+            </div>
           </div>
         </div>
 
@@ -64,8 +89,8 @@
         style="resize: none"
         @keydown.enter.exact.prevent="onSend"
       />
-      <button
-        class="inline-flex items-center justify-center gap-2 rounded-xl border border-primary bg-primary px-4 py-2.5 text-sm font-semibold text-primary-foreground transition-colors hover:border-[var(--primary-600)] hover:bg-[var(--primary-600)] disabled:opacity-50 disabled:cursor-not-allowed"
+      <BaseButton
+        class="py-2.5"
         :disabled="loading || !draft.trim()"
         @click="onSend"
       >
@@ -73,7 +98,7 @@
           <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>
         Enviar
-      </button>
+      </BaseButton>
     </div>
 
     <div v-if="error" class="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">
@@ -93,8 +118,12 @@
 </template>
 
 <script setup>
-import { ref, nextTick, onUnmounted, watch } from "vue";
+import { ref, nextTick, onMounted, onUnmounted, watch } from "vue";
+import { marked, Renderer } from "marked";
+import DOMPurify from "dompurify";
+import hljs from "highlight.js";
 import { useChat } from "../composables/useChat.js";
+import BaseButton from "./base/BaseButton.vue";
 
 const props = defineProps({
   messages: { type: Array, required: true },
@@ -109,6 +138,70 @@ onUnmounted(() => cancelCurrentStream());
 
 const draft = ref("");
 const chatEl = ref(null);
+const copiado = ref(null);
+let copiadoTimeout = null;
+
+// Configurar marked com highlight.js
+const renderer = new Renderer();
+
+renderer.code = ({ text, lang }) => {
+  const language = lang && hljs.getLanguage(lang) ? lang : "plaintext";
+  const highlighted = hljs.highlight(text, { language }).value;
+  const escapedCode = text.replace(/`/g, "&#96;");
+  return `<div class="code-block-wrapper">
+    <div class="code-block-header">
+      <span class="code-lang">${language}</span>
+      <button class="code-copy-btn" data-code="${escapedCode}" aria-label="Copiar código">
+        <svg 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>
+        Copiar
+      </button>
+    </div>
+    <pre><code class="hljs language-${language}">${highlighted}</code></pre>
+  </div>`;
+};
+
+marked.use({
+  renderer,
+  breaks: true,
+  gfm: true,
+});
+
+function renderMarkdown(content) {
+  if (!content) return "";
+  const html = marked.parse(content);
+  return DOMPurify.sanitize(html, {
+    ADD_ATTR: ["data-code"],
+    ADD_TAGS: ["button"],
+  });
+}
+
+// Delegação de eventos para botões de copiar dentro do HTML renderizado
+function handleCodeCopy(e) {
+  const btn = e.target.closest(".code-copy-btn");
+  if (!btn) return;
+  const code = btn.dataset.code?.replace(/&#96;/g, "`") ?? "";
+  navigator.clipboard.writeText(code).then(() => {
+    btn.classList.add("copied");
+    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(() => {
+      btn.innerHTML = original;
+      btn.classList.remove("copied");
+    }, 2000);
+  });
+}
+
+onMounted(() => chatEl.value?.addEventListener("click", handleCodeCopy));
+onUnmounted(() => chatEl.value?.removeEventListener("click", handleCodeCopy));
+
+function copiarMensagem(content, idx) {
+  navigator.clipboard.writeText(content).then(() => {
+    clearTimeout(copiadoTimeout);
+    copiado.value = idx;
+    copiadoTimeout = setTimeout(() => { copiado.value = null; }, 2000);
+  });
+}
 
 function formatarHora(ts) {
   if (!ts) return "";

+ 17 - 28
src/components/DocumentList.vue

@@ -1,6 +1,9 @@
 <script setup>
 import { ref, computed } from "vue";
 import { deleteDocumentsBySource } from "../api/chat.js";
+import { useToast } from "../composables/useToast.js";
+import SkeletonDocumentCard from "./skeletons/SkeletonDocumentCard.vue";
+import BaseButton from "./base/BaseButton.vue";
 
 const props = defineProps({
   items: { type: Array, required: true },
@@ -9,10 +12,10 @@ const props = defineProps({
 });
 
 const emit = defineEmits(["refresh"]);
+const toast = useToast();
 
 const deleting = ref(null);
 const confirmingSource = ref(null);
-const deleteError = ref("");
 const filterQuery = ref("");
 
 const grouped = computed(() => {
@@ -38,14 +41,14 @@ const totalDocs = computed(() => Object.keys(grouped.value).length);
 
 async function onConfirmDelete(source) {
   if (!source) return;
-  deleteError.value = "";
   deleting.value = source;
   try {
     await deleteDocumentsBySource(source);
     confirmingSource.value = null;
+    toast.sucesso(`Documento "${source}" excluído com sucesso.`);
     emit("refresh");
   } catch (e) {
-    deleteError.value = `Erro ao excluir: ${e?.message || "erro desconhecido"}`;
+    toast.erro(`Erro ao excluir: ${e?.message || "erro desconhecido"}`);
   } finally {
     deleting.value = null;
   }
@@ -70,16 +73,12 @@ function formatDate(iso) {
           {{ totalChunks }} chunk{{ totalChunks !== 1 ? 's' : '' }} em {{ totalDocs }} documento{{ totalDocs !== 1 ? 's' : '' }}
         </span>
       </div>
-      <button
-        class="inline-flex items-center gap-1.5 rounded-xl border border-gray-200 bg-background px-3 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-white/5"
-        :disabled="loading"
-        @click="$emit('refresh')"
-      >
+      <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>
         Atualizar
-      </button>
+      </BaseButton>
     </div>
 
     <!-- Filtro -->
@@ -99,16 +98,7 @@ function formatDate(iso) {
       {{ error }}
     </div>
 
-    <div
-      v-if="deleteError"
-      class="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"
-    >
-      {{ deleteError }}
-    </div>
-
-    <div v-if="loading" class="grid gap-2">
-      <div v-for="i in 3" :key="i" class="h-16 animate-pulse rounded-xl bg-gray-100 dark:bg-white/5" />
-    </div>
+    <SkeletonDocumentCard v-if="loading" :count="3" />
 
     <div
       v-else-if="items.length === 0 && !error"
@@ -149,19 +139,18 @@ function formatDate(iso) {
 
           <div v-if="confirmingSource === source" class="flex shrink-0 items-center gap-1.5">
             <span class="text-xs text-gray-500 dark:text-gray-400">Excluir tudo?</span>
-            <button
-              class="rounded-lg border border-red-200 bg-red-50 px-2 py-1 text-xs font-medium text-red-600 transition-colors hover:bg-red-100 disabled:opacity-50 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-400"
+            <BaseButton
+              variant="danger"
+              size="sm"
+              :loading="deleting === source"
               :disabled="deleting === source"
               @click="onConfirmDelete(source)"
             >
-              {{ deleting === source ? '...' : 'Confirmar' }}
-            </button>
-            <button
-              class="rounded-lg border border-gray-200 bg-background px-2 py-1 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-white/5"
-              @click="confirmingSource = null"
-            >
+              Confirmar
+            </BaseButton>
+            <BaseButton variant="secondary" size="sm" @click="confirmingSource = null">
               Cancelar
-            </button>
+            </BaseButton>
           </div>
           <button
             v-else-if="source"

+ 10 - 31
src/components/DocumentUpload.vue

@@ -1,8 +1,11 @@
 <script setup>
 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";
 
 const emit = defineEmits(["ingested"]);
+const toast = useToast();
 
 const tab = ref("arquivo");
 const manualText = ref("");
@@ -10,8 +13,6 @@ const file = ref(null);
 const urlInput = ref("");
 const loading = ref(false);
 const uploadProgress = ref(0);
-const status = ref("");
-const error = ref("");
 const dragOver = ref(false);
 const fileInputRef = ref(null);
 
@@ -32,8 +33,6 @@ function onFileInputChange(e) {
   const f = e.target.files?.[0];
   if (!f) return;
   file.value = f;
-  error.value = "";
-  status.value = "";
 }
 
 function onDrop(e) {
@@ -41,8 +40,6 @@ function onDrop(e) {
   const f = e.dataTransfer?.files?.[0];
   if (!f) return;
   file.value = f;
-  error.value = "";
-  status.value = "";
 }
 
 function removeFile() {
@@ -55,8 +52,6 @@ function triggerFileInput() {
 }
 
 async function onIngest() {
-  error.value = "";
-  status.value = "";
   loading.value = true;
   uploadProgress.value = 0;
 
@@ -79,11 +74,11 @@ async function onIngest() {
     }
 
     uploadProgress.value = 0;
-    status.value = `Ingerido com sucesso${result?.upserted != null ? ` (${result.upserted} chunks)` : ""}.`;
+    toast.sucesso(`Ingerido com sucesso${result?.upserted != null ? ` (${result.upserted} chunks)` : ""}.`);
     emit("ingested", result);
   } catch (e) {
     uploadProgress.value = 0;
-    error.value = e?.message || "Erro desconhecido.";
+    toast.erro(e?.message || "Erro ao ingerir documento.");
   } finally {
     loading.value = false;
   }
@@ -218,32 +213,16 @@ const canSubmit = computed(() => {
         <span v-else-if="tab === 'url'">A página será baixada, o texto extraído e indexado.</span>
         <span v-else>O texto será fragmentado e indexado na base.</span>
       </div>
-      <button
-        type="button"
-        class="inline-flex shrink-0 items-center gap-2 rounded-xl border border-primary bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground transition-colors hover:border-[var(--primary-600)] hover:bg-[var(--primary-600)] disabled:cursor-not-allowed disabled:opacity-50"
-        :disabled="loading || !canSubmit"
+      <BaseButton
+        class="shrink-0"
+        :disabled="!canSubmit"
+        :loading="loading"
         @click="onIngest"
       >
-        <svg v-if="loading" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
-          <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
-          <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
-        </svg>
         <span v-if="loading && tab === 'arquivo' && uploadProgress > 0">{{ uploadProgress }}%</span>
         <span v-else>{{ loading ? "Processando..." : "Ingerir" }}</span>
-      </button>
+      </BaseButton>
     </div>
 
-    <div
-      v-if="status"
-      class="rounded-xl border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700 dark:border-green-500/30 dark:bg-green-500/10 dark:text-green-300"
-    >
-      {{ status }}
-    </div>
-    <div
-      v-if="error"
-      class="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"
-    >
-      {{ error }}
-    </div>
   </div>
 </template>

+ 6 - 15
src/components/ModalUsuario.vue

@@ -63,21 +63,12 @@
           </div>
 
           <div class="flex justify-end gap-2 pt-1">
-            <button
-              type="button"
-              :disabled="salvando"
-              class="rounded-xl border border-gray-200 bg-background px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100 disabled:opacity-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-white/5"
-              @click="$emit('close')"
-            >
+            <BaseButton variant="secondary" :disabled="salvando" @click="$emit('close')">
               Cancelar
-            </button>
-            <button
-              type="submit"
-              :disabled="salvando"
-              class="rounded-xl border border-primary bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:border-[var(--primary-600)] hover:bg-[var(--primary-600)] disabled:cursor-not-allowed disabled:opacity-50"
-            >
-              {{ salvando ? "Salvando..." : (usuario ? "Salvar" : "Criar") }}
-            </button>
+            </BaseButton>
+            <BaseButton type="submit" :disabled="salvando" :loading="salvando">
+              {{ usuario ? "Salvar" : "Criar" }}
+            </BaseButton>
           </div>
         </form>
       </div>
@@ -88,6 +79,7 @@
 <script setup>
 import { ref, watch } from "vue";
 import { useUsers } from "../composables/useUsers.js";
+import BaseButton from "./base/BaseButton.vue";
 
 const props = defineProps({
   usuario: { type: Object, default: null }
@@ -152,7 +144,6 @@ async function onSubmit() {
       });
       emit("saved", result?.usuario);
     }
-    emit("close");
   } catch (e) {
     erroForm.value = e?.message || "Erro ao salvar usuário.";
   } finally {

+ 35 - 0
src/components/base/BaseBadge.vue

@@ -0,0 +1,35 @@
+<template>
+  <span
+    class="inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-medium"
+    :class="variantClass"
+  >
+    <slot />
+  </span>
+</template>
+
+<script setup>
+import { computed } from "vue";
+
+const props = defineProps({
+  variant: {
+    type: String,
+    default: "default",
+    validator: (v) => ["default", "success", "danger", "info", "warning", "violet"].includes(v),
+  },
+});
+
+const variantClass = computed(() => ({
+  default:
+    "border-gray-200 bg-gray-100 text-gray-600 dark:border-gray-700 dark:bg-white/10 dark:text-gray-300",
+  success:
+    "border-emerald-200 bg-emerald-100 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300",
+  danger:
+    "border-red-200 bg-red-100 text-red-700 dark:border-red-500/20 dark:bg-red-500/10 dark:text-red-300",
+  info:
+    "border-blue-200 bg-blue-100 text-blue-700 dark:border-blue-500/20 dark:bg-blue-500/10 dark:text-blue-300",
+  warning:
+    "border-amber-200 bg-amber-100 text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-300",
+  violet:
+    "border-violet-200 bg-violet-100 text-violet-700 dark:border-violet-500/20 dark:bg-violet-500/10 dark:text-violet-300",
+}[props.variant]));
+</script>

+ 58 - 0
src/components/base/BaseButton.vue

@@ -0,0 +1,58 @@
+<template>
+  <button
+    :type="type"
+    :disabled="disabled || loading"
+    class="inline-flex items-center justify-center gap-2 rounded-xl font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50"
+    :class="[sizeClass, variantClass]"
+    v-bind="$attrs"
+  >
+    <svg v-if="loading" class="animate-spin" :class="iconSize" viewBox="0 0 24 24" fill="none">
+      <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
+      <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
+    </svg>
+    <slot />
+  </button>
+</template>
+
+<script setup>
+import { computed } from "vue";
+
+const props = defineProps({
+  variant: {
+    type: String,
+    default: "primary",
+    validator: (v) => ["primary", "secondary", "ghost", "danger"].includes(v),
+  },
+  size: {
+    type: String,
+    default: "md",
+    validator: (v) => ["sm", "md", "lg"].includes(v),
+  },
+  type: { type: String, default: "button" },
+  disabled: { type: Boolean, default: false },
+  loading: { type: Boolean, default: false },
+});
+
+const sizeClass = computed(() => ({
+  sm: "px-3 py-1.5 text-xs",
+  md: "px-4 py-2 text-sm",
+  lg: "px-5 py-2.5 text-base",
+}[props.size]));
+
+const iconSize = computed(() => ({
+  sm: "h-3 w-3",
+  md: "h-4 w-4",
+  lg: "h-5 w-5",
+}[props.size]));
+
+const variantClass = computed(() => ({
+  primary:
+    "border border-primary bg-primary text-primary-foreground hover:border-[var(--primary-600)] hover:bg-[var(--primary-600)]",
+  secondary:
+    "border border-gray-200 bg-background text-gray-700 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-white/5",
+  ghost:
+    "border border-transparent bg-transparent text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-white !transform-none",
+  danger:
+    "border border-red-200 bg-red-50 text-red-600 hover:bg-red-100 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-400 dark:hover:bg-red-900/40",
+}[props.variant]));
+</script>

+ 50 - 0
src/components/base/BaseInput.vue

@@ -0,0 +1,50 @@
+<template>
+  <div class="grid gap-1.5">
+    <label v-if="label" :for="inputId" class="text-sm font-medium text-gray-700 dark:text-gray-300">
+      {{ label }}
+      <span v-if="required" class="ml-0.5 text-red-500" aria-hidden="true">*</span>
+    </label>
+    <component
+      :is="tag"
+      :id="inputId"
+      v-bind="$attrs"
+      :value="modelValue"
+      :disabled="disabled"
+      :required="required"
+      :placeholder="placeholder"
+      class="w-full rounded-xl border bg-background px-3 text-sm text-foreground placeholder-gray-400 outline-none transition-colors focus:border-primary dark:placeholder-gray-500"
+      :class="[
+        tag === 'textarea' ? 'py-2.5 resize-none' : 'py-2',
+        error
+          ? 'border-red-300 dark:border-red-700'
+          : 'border-gray-200 dark:border-gray-700',
+        disabled ? 'opacity-60 cursor-not-allowed' : '',
+      ]"
+      @input="$emit('update:modelValue', $event.target.value)"
+    />
+    <p v-if="error" class="text-xs text-red-600 dark:text-red-400">{{ error }}</p>
+    <p v-else-if="hint" class="text-xs text-gray-400 dark:text-gray-500">{{ hint }}</p>
+  </div>
+</template>
+
+<script setup>
+import { computed } from "vue";
+
+defineOptions({ inheritAttrs: false });
+
+const props = defineProps({
+  modelValue: { type: String, default: "" },
+  label: { type: String, default: "" },
+  placeholder: { type: String, default: "" },
+  error: { type: String, default: "" },
+  hint: { type: String, default: "" },
+  tag: { type: String, default: "input" },
+  disabled: { type: Boolean, default: false },
+  required: { type: Boolean, default: false },
+  id: { type: String, default: "" },
+});
+
+defineEmits(["update:modelValue"]);
+
+const inputId = computed(() => props.id || `base-input-${Math.random().toString(36).slice(2, 7)}`);
+</script>

+ 33 - 0
src/components/skeletons/SkeletonConversation.vue

@@ -0,0 +1,33 @@
+<template>
+  <div class="flex flex-col gap-1.5 px-1">
+    <div v-for="i in count" :key="i" class="flex items-center gap-2 rounded-xl px-2 py-2">
+      <div class="h-3.5 rounded skeleton" :style="{ width: widths[i % widths.length] }" />
+    </div>
+  </div>
+</template>
+
+<script setup>
+defineProps({
+  count: { type: Number, default: 5 }
+});
+
+const widths = ["65%", "80%", "55%", "72%", "88%"];
+</script>
+
+<style scoped>
+.skeleton {
+  background: linear-gradient(
+    90deg,
+    rgba(156, 163, 175, 0.18) 25%,
+    rgba(156, 163, 175, 0.32) 50%,
+    rgba(156, 163, 175, 0.18) 75%
+  );
+  background-size: 200% 100%;
+  animation: shimmer 1.5s infinite;
+}
+
+@keyframes shimmer {
+  0% { background-position: 200% 0; }
+  100% { background-position: -200% 0; }
+}
+</style>

+ 35 - 0
src/components/skeletons/SkeletonDocumentCard.vue

@@ -0,0 +1,35 @@
+<template>
+  <div class="flex flex-col gap-2">
+    <div v-for="i in count" :key="i" class="rounded-xl border border-gray-200 dark:border-gray-700 p-3 flex items-center gap-3">
+      <div class="h-8 w-8 rounded-lg skeleton shrink-0" />
+      <div class="flex-1 flex flex-col gap-1.5">
+        <div class="h-3.5 rounded skeleton w-[60%]" />
+        <div class="h-3 rounded skeleton w-[40%]" />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+defineProps({
+  count: { type: Number, default: 3 }
+});
+</script>
+
+<style scoped>
+.skeleton {
+  background: linear-gradient(
+    90deg,
+    rgba(156, 163, 175, 0.18) 25%,
+    rgba(156, 163, 175, 0.32) 50%,
+    rgba(156, 163, 175, 0.18) 75%
+  );
+  background-size: 200% 100%;
+  animation: shimmer 1.5s infinite;
+}
+
+@keyframes shimmer {
+  0% { background-position: 200% 0; }
+  100% { background-position: -200% 0; }
+}
+</style>

+ 43 - 0
src/components/skeletons/SkeletonMessage.vue

@@ -0,0 +1,43 @@
+<template>
+  <div class="flex flex-col gap-3">
+    <!-- Mensagem do usuário (direita) -->
+    <div class="flex flex-col items-end">
+      <div class="mb-0.5 flex items-center gap-1.5 flex-row-reverse">
+        <div class="h-5 w-5 rounded-full skeleton" />
+        <div class="h-3 w-10 rounded skeleton" />
+      </div>
+      <div class="h-10 w-52 rounded-2xl skeleton" />
+    </div>
+
+    <!-- Mensagem do assistente (esquerda) -->
+    <div class="flex flex-col items-start">
+      <div class="mb-0.5 flex items-center gap-1.5">
+        <div class="h-5 w-5 rounded-full skeleton" />
+        <div class="h-3 w-12 rounded skeleton" />
+      </div>
+      <div class="flex flex-col gap-1.5 w-[72%]">
+        <div class="h-4 rounded skeleton w-full" />
+        <div class="h-4 rounded skeleton w-[90%]" />
+        <div class="h-4 rounded skeleton w-[75%]" />
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.skeleton {
+  background: linear-gradient(
+    90deg,
+    rgba(156, 163, 175, 0.18) 25%,
+    rgba(156, 163, 175, 0.32) 50%,
+    rgba(156, 163, 175, 0.18) 75%
+  );
+  background-size: 200% 100%;
+  animation: shimmer 1.5s infinite;
+}
+
+@keyframes shimmer {
+  0% { background-position: 200% 0; }
+  100% { background-position: -200% 0; }
+}
+</style>

+ 4 - 4
src/composables/useChat.js

@@ -99,8 +99,8 @@ export function useChat() {
     if (!numId || !title?.trim()) return;
     try {
       await updateConversationTitleAPI(numId, title.trim());
-    } catch (err) {
-      console.error("[useChat] falha ao renomear conversa:", err);
+    } catch {
+      // falha ao renomear é silenciosa — o estado local já foi atualizado
     }
     conversations.value = conversations.value.map((c) =>
       c.id === strId ? { ...c, title: title.trim() } : c
@@ -113,8 +113,8 @@ export function useChat() {
     if (!numId) return;
     try {
       await deleteConversationAPI(numId);
-    } catch (err) {
-      console.error("[useChat] falha ao excluir conversa:", err);
+    } catch {
+      // falha ao excluir é silenciosa — o estado local já foi atualizado
     }
     conversations.value = conversations.value.filter((c) => c.id !== strId);
     if (String(activeConversationId.value) === strId) {

+ 11 - 0
src/composables/useToast.js

@@ -0,0 +1,11 @@
+import { useToast as _useToast } from "vue-toastification";
+
+export function useToast() {
+  const toast = _useToast();
+  return {
+    sucesso: (msg) => toast.success(msg),
+    erro: (msg) => toast.error(msg),
+    info: (msg) => toast.info(msg),
+    aviso: (msg) => toast.warning(msg),
+  };
+}

+ 6 - 21
src/layout/LayoutSistema.vue

@@ -12,6 +12,8 @@ import BotaoAlterarTema from "../components/BotaoAlterarTema.vue";
 import DocumentList from "../components/DocumentList.vue";
 import DocumentUpload from "../components/DocumentUpload.vue";
 import SearchBox from "../components/SearchBox.vue";
+import SkeletonConversation from "../components/skeletons/SkeletonConversation.vue";
+import { avatarIniciais as _avatarIniciais, avatarCor as _avatarCor } from "../utils/avatar.js";
 import { listDocuments } from "../api/chat.js";
 import { useChat } from "../composables/useChat.js";
 import { useSearch } from "../composables/useSearch.js";
@@ -84,23 +86,8 @@ function classeItemSidebar(ativo) {
 function classeIcon(ativo) {
   return ["h-5 w-5 shrink-0", ativo ? classeIconAtivo : classeIconInativo];
 }
-const AVATAR_CORES = [
-  "bg-blue-500", "bg-violet-500", "bg-emerald-500",
-  "bg-amber-500", "bg-rose-500", "bg-cyan-500", "bg-indigo-500"
-];
-
-const avatarIniciais = computed(() => {
-  const nome = usuarioNome.value.trim();
-  const partes = nome.split(/\s+/);
-  if (partes.length >= 2) return (partes[0][0] + partes[1][0]).toUpperCase();
-  return nome.slice(0, 2).toUpperCase();
-});
-
-const avatarCor = computed(() => {
-  let hash = 0;
-  for (const c of usuarioNome.value) hash = (hash * 31 + c.charCodeAt(0)) & 0xffffffff;
-  return AVATAR_CORES[Math.abs(hash) % AVATAR_CORES.length];
-});
+const avatarIniciais = computed(() => _avatarIniciais(usuarioNome.value));
+const avatarCor = computed(() => _avatarCor(usuarioNome.value));
 
 const tituloPagina = computed(() => {
   const nome = rotaAtiva.value;
@@ -521,10 +508,8 @@ watch(
             v-if="!sidebarRecolhida"
             class="conv-scroll mt-0.5 -mx-0.5 px-0.5 flex-1 min-h-0 overflow-y-auto"
           >
-            <div v-if="conversationsLoading" class="grid gap-1 px-1 py-1">
-              <div v-for="i in 4" :key="i" class="flex items-center gap-2 rounded-xl px-3 py-2.5">
-                <div class="h-3 animate-pulse rounded-full bg-gray-200 dark:bg-white/10" :style="`width: ${55 + (i * 11) % 35}%`" />
-              </div>
+            <div v-if="conversationsLoading" class="py-1">
+              <SkeletonConversation :count="5" />
             </div>
             <div v-else-if="conversationsError" class="px-3 py-2 text-xs text-red-400 dark:text-red-400/70">
               {{ conversationsError }}

+ 0 - 0
src/layout/configuracao-sidebar.js


+ 17 - 0
src/main.js

@@ -2,6 +2,8 @@ import { createApp } from "vue";
 import App from "./App.vue";
 import "@/styles/tailwind.css";
 import "@/styles/style.css";
+import Toast from "vue-toastification";
+import "vue-toastification/dist/index.css";
 import { router } from "./router/index.js";
 
 const appEl = document.getElementById("app");
@@ -35,7 +37,22 @@ function aplicarTemaInicial() {
 
 aplicarTemaInicial();
 
+const toastOptions = {
+  position: "top-right",
+  timeout: 4000,
+  closeOnClick: true,
+  pauseOnFocusLoss: true,
+  pauseOnHover: true,
+  draggable: true,
+  showCloseButtonOnHover: false,
+  hideProgressBar: false,
+  closeButton: "button",
+  icon: true,
+  rtl: false,
+};
+
 const app = createApp(App);
 app.use(router);
+app.use(Toast, toastOptions);
 app.mount("#app");
 if (appEl) appEl.dataset.mounted = "1";

+ 260 - 0
src/styles/style.css

@@ -245,3 +245,263 @@ select:focus {
     grid-template-columns: 1fr;
   }
 }
+
+/* ─── Prose: estilos para Markdown renderizado no chat ─── */
+.prose-content {
+  font-size: 0.875rem;
+  line-height: 1.65;
+  color: inherit;
+}
+
+.prose-content p {
+  margin: 0 0 0.6em;
+}
+.prose-content p:last-child {
+  margin-bottom: 0;
+}
+
+.prose-content ul,
+.prose-content ol {
+  margin: 0.4em 0 0.6em;
+  padding-left: 1.4em;
+}
+.prose-content li {
+  margin: 0.2em 0;
+}
+
+.prose-content h1, .prose-content h2, .prose-content h3,
+.prose-content h4, .prose-content h5, .prose-content h6 {
+  font-weight: 650;
+  line-height: 1.3;
+  margin: 0.8em 0 0.3em;
+}
+.prose-content h1 { font-size: 1.15em; }
+.prose-content h2 { font-size: 1.05em; }
+.prose-content h3 { font-size: 0.95em; }
+.prose-content h4, .prose-content h5, .prose-content h6 { font-size: 0.875em; }
+
+.prose-content strong { font-weight: 650; }
+.prose-content em { font-style: italic; }
+
+.prose-content a {
+  color: var(--ring);
+  text-decoration: underline;
+  text-underline-offset: 2px;
+}
+
+.prose-content blockquote {
+  margin: 0.5em 0;
+  padding-left: 0.75em;
+  border-left: 3px solid var(--border);
+  color: var(--muted);
+  font-style: italic;
+}
+
+.prose-content hr {
+  border: none;
+  border-top: 1px solid var(--border);
+  margin: 0.8em 0;
+}
+
+/* Código inline */
+.prose-content code:not(pre code) {
+  background: rgba(91, 140, 255, 0.1);
+  border: 1px solid rgba(91, 140, 255, 0.2);
+  border-radius: 4px;
+  padding: 0.1em 0.35em;
+  font-family: "JetBrains Mono", "Fira Mono", monospace;
+  font-size: 0.82em;
+}
+html.dark .prose-content code:not(pre code) {
+  background: rgba(91, 140, 255, 0.15);
+  border-color: rgba(91, 140, 255, 0.25);
+}
+
+/* Blocos de código */
+.code-block-wrapper {
+  margin: 0.5em 0;
+  border-radius: 10px;
+  overflow: hidden;
+  border: 1px solid var(--border);
+  font-size: 0.8em;
+}
+
+.code-block-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 4px 10px;
+  background: rgba(15, 23, 42, 0.06);
+  border-bottom: 1px solid var(--border);
+}
+html.dark .code-block-header {
+  background: rgba(255, 255, 255, 0.05);
+}
+
+.code-lang {
+  font-family: "JetBrains Mono", monospace;
+  font-size: 0.78em;
+  color: var(--muted);
+  text-transform: lowercase;
+}
+
+.code-copy-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  border: 1px solid var(--border);
+  background: transparent;
+  color: var(--muted);
+  padding: 2px 8px;
+  border-radius: 5px;
+  cursor: pointer;
+  font-size: 0.75em;
+  font-family: ui-sans-serif, system-ui, sans-serif;
+  transition: color 120ms, border-color 120ms;
+  transform: none;
+}
+.code-copy-btn:hover {
+  color: var(--text);
+  border-color: var(--ring);
+  transform: none;
+}
+.code-copy-btn.copied {
+  color: #22c55e;
+  border-color: #22c55e44;
+}
+.code-copy-btn svg {
+  width: 11px;
+  height: 11px;
+}
+
+.code-block-wrapper pre {
+  margin: 0;
+  padding: 12px 14px;
+  overflow-x: auto;
+  background: rgba(15, 23, 42, 0.04);
+}
+html.dark .code-block-wrapper pre {
+  background: rgba(0, 0, 0, 0.3);
+}
+.code-block-wrapper pre code {
+  font-family: "JetBrains Mono", "Fira Mono", monospace;
+  font-size: 1em;
+  line-height: 1.6;
+  border: none;
+  background: none;
+  padding: 0;
+  border-radius: 0;
+}
+
+/* ─── Highlight.js tema (light/dark) ─── */
+.hljs { color: #24292e; }
+.hljs-comment, .hljs-punctuation { color: #6a737d; }
+.hljs-keyword, .hljs-selector-tag, .hljs-built_in { color: #d73a49; font-weight: 600; }
+.hljs-string, .hljs-attr { color: #032f62; }
+.hljs-number, .hljs-literal { color: #005cc5; }
+.hljs-title, .hljs-function { color: #6f42c1; font-weight: 600; }
+.hljs-variable, .hljs-template-variable { color: #e36209; }
+.hljs-type, .hljs-class { color: #005cc5; font-weight: 600; }
+.hljs-meta { color: #6a737d; }
+.hljs-tag { color: #22863a; }
+.hljs-name { color: #22863a; font-weight: 600; }
+.hljs-attribute { color: #6f42c1; }
+.hljs-symbol, .hljs-bullet, .hljs-link { color: #005cc5; }
+.hljs-deletion { color: #b31d28; background-color: #ffeef0; }
+.hljs-addition { color: #22863a; background-color: #f0fff4; }
+
+html.dark .hljs { color: #cdd9e5; }
+html.dark .hljs-comment, html.dark .hljs-punctuation { color: #768390; }
+html.dark .hljs-keyword, html.dark .hljs-selector-tag, html.dark .hljs-built_in { color: #f47067; font-weight: 600; }
+html.dark .hljs-string, html.dark .hljs-attr { color: #96d0ff; }
+html.dark .hljs-number, html.dark .hljs-literal { color: #6cb6ff; }
+html.dark .hljs-title, html.dark .hljs-function { color: #dcbdfb; font-weight: 600; }
+html.dark .hljs-variable, html.dark .hljs-template-variable { color: #f69d50; }
+html.dark .hljs-type, html.dark .hljs-class { color: #6cb6ff; font-weight: 600; }
+html.dark .hljs-meta { color: #768390; }
+html.dark .hljs-tag { color: #8ddb8c; }
+html.dark .hljs-name { color: #8ddb8c; font-weight: 600; }
+html.dark .hljs-attribute { color: #dcbdfb; }
+html.dark .hljs-symbol, html.dark .hljs-bullet, html.dark .hljs-link { color: #6cb6ff; }
+html.dark .hljs-deletion { color: #ff938a; background-color: rgba(255, 80, 60, 0.12); }
+html.dark .hljs-addition { color: #8ddb8c; background-color: rgba(70, 200, 100, 0.1); }
+
+/* ─── Vue-Toastification overrides ─── */
+:root {
+  --toastify-toast-min-height: 48px;
+  --toastify-toast-max-height: 200px;
+  --toastify-font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
+  --toastify-z-index: 99999;
+}
+
+.Vue-Toastification__toast {
+  border-radius: 12px !important;
+  font-size: 0.875rem !important;
+  font-weight: 500 !important;
+  box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12) !important;
+  padding: 12px 16px !important;
+  min-height: 48px !important;
+}
+
+.Vue-Toastification__toast--success {
+  background-color: #f0fdf4 !important;
+  color: #15803d !important;
+  border: 1px solid #bbf7d0 !important;
+}
+
+.Vue-Toastification__toast--error {
+  background-color: #fef2f2 !important;
+  color: #b91c1c !important;
+  border: 1px solid #fecaca !important;
+}
+
+.Vue-Toastification__toast--info {
+  background-color: #eff6ff !important;
+  color: #1d4ed8 !important;
+  border: 1px solid #bfdbfe !important;
+}
+
+.Vue-Toastification__toast--warning {
+  background-color: #fffbeb !important;
+  color: #b45309 !important;
+  border: 1px solid #fde68a !important;
+}
+
+html.dark .Vue-Toastification__toast--success {
+  background-color: rgba(20, 83, 45, 0.9) !important;
+  color: #86efac !important;
+  border-color: rgba(74, 222, 128, 0.25) !important;
+  backdrop-filter: blur(10px);
+}
+
+html.dark .Vue-Toastification__toast--error {
+  background-color: rgba(127, 29, 29, 0.9) !important;
+  color: #fca5a5 !important;
+  border-color: rgba(252, 165, 165, 0.25) !important;
+  backdrop-filter: blur(10px);
+}
+
+html.dark .Vue-Toastification__toast--info {
+  background-color: rgba(30, 58, 138, 0.9) !important;
+  color: #93c5fd !important;
+  border-color: rgba(147, 197, 253, 0.25) !important;
+  backdrop-filter: blur(10px);
+}
+
+html.dark .Vue-Toastification__toast--warning {
+  background-color: rgba(120, 53, 15, 0.9) !important;
+  color: #fcd34d !important;
+  border-color: rgba(252, 211, 77, 0.25) !important;
+  backdrop-filter: blur(10px);
+}
+
+.Vue-Toastification__progress-bar {
+  opacity: 0.35 !important;
+}
+
+.Vue-Toastification__close-button {
+  opacity: 0.5 !important;
+}
+.Vue-Toastification__close-button:hover {
+  opacity: 1 !important;
+}

+ 17 - 0
src/utils/avatar.js

@@ -0,0 +1,17 @@
+export const AVATAR_CORES = [
+  "bg-blue-500", "bg-violet-500", "bg-emerald-500",
+  "bg-amber-500", "bg-rose-500", "bg-cyan-500", "bg-indigo-500",
+];
+
+export function avatarIniciais(nome) {
+  const n = String(nome || "?").trim();
+  const partes = n.split(/\s+/);
+  if (partes.length >= 2) return (partes[0][0] + partes[1][0]).toUpperCase();
+  return n.slice(0, 2).toUpperCase();
+}
+
+export function avatarCor(nome) {
+  let hash = 0;
+  for (const c of String(nome || "")) hash = (hash * 31 + c.charCodeAt(0)) & 0xffffffff;
+  return AVATAR_CORES[Math.abs(hash) % AVATAR_CORES.length];
+}

+ 1 - 0
src/views/conversas/ConversasHistoricoView.vue

@@ -81,6 +81,7 @@ function excluirConversa(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="excluirConversa(c.id)"
           >
             <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">

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

@@ -3,6 +3,7 @@ import { computed, onUnmounted, ref } from "vue";
 import ChatWindow from "../../components/ChatWindow.vue";
 import LayoutSistema from "../../layout/LayoutSistema.vue";
 import { useChat } from "../../composables/useChat.js";
+import BaseButton from "../../components/base/BaseButton.vue";
 
 const { messages: chatMessages, loading: chatLoading, error: chatError, send: sendMessage, clearError, cancelCurrentStream } = useChat();
 onUnmounted(() => cancelCurrentStream());
@@ -43,16 +44,12 @@ async function onLandingSend() {
           />
           <div class="mt-3 flex items-center justify-between gap-3">
             <span class="text-xs text-gray-400 dark:text-gray-500">Enter para enviar · Shift+Enter para nova linha</span>
-            <button
-              class="inline-flex items-center gap-2 rounded-xl border border-primary bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground transition-colors hover:border-[var(--primary-600)] hover:bg-[var(--primary-600)] disabled:opacity-50 disabled:cursor-not-allowed"
-              :disabled="chatLoading || !landingDraft.trim()"
-              @click="onLandingSend"
-            >
+            <BaseButton :disabled="chatLoading || !landingDraft.trim()" @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>
               Enviar
-            </button>
+            </BaseButton>
           </div>
         </div>
 

+ 35 - 69
src/views/usuarios/UsuariosView.vue

@@ -3,51 +3,29 @@ import { computed, onMounted, ref } from "vue";
 import LayoutSistema from "../../layout/LayoutSistema.vue";
 import ModalUsuario from "../../components/ModalUsuario.vue";
 import { useUsers } from "../../composables/useUsers.js";
+import { useToast } from "../../composables/useToast.js";
+import BaseButton from "../../components/base/BaseButton.vue";
+import BaseBadge from "../../components/base/BaseBadge.vue";
+import { avatarIniciais, avatarCor } from "../../utils/avatar.js";
 
 const { users, loading, error, loadUsers, toggleStatus } = useUsers();
+const toast = useToast();
 
 const totalUsuarios = computed(() => users.value?.length ?? 0);
 const modalAberto = ref(false);
 const usuarioEmEdicao = ref(null);
-const erroAcao = ref("");
-
-const AVATAR_CORES = [
-  "bg-blue-500", "bg-violet-500", "bg-emerald-500",
-  "bg-amber-500", "bg-rose-500", "bg-cyan-500", "bg-indigo-500"
-];
-
-function avatarIniciais(nome) {
-  const n = String(nome || "?").trim();
-  const partes = n.split(/\s+/);
-  if (partes.length >= 2) return (partes[0][0] + partes[1][0]).toUpperCase();
-  return n.slice(0, 2).toUpperCase();
-}
-
-function avatarCor(nome) {
-  let hash = 0;
-  for (const c of String(nome || "")) hash = (hash * 31 + c.charCodeAt(0)) & 0xffffffff;
-  return AVATAR_CORES[Math.abs(hash) % AVATAR_CORES.length];
-}
 
 function formatarStatus(status) {
   return String(status) === "0" ? "Inativo" : "Ativo";
 }
 
-function classeStatus(status) {
-  return String(status) === "0"
-    ? "border-red-200 bg-red-100 text-red-700 dark:border-red-500/20 dark:bg-red-500/10 dark:text-red-300"
-    : "border-emerald-200 bg-emerald-100 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300";
-}
-
 function abrirNovoUsuario() {
   usuarioEmEdicao.value = null;
-  erroAcao.value = "";
   modalAberto.value = true;
 }
 
 function abrirEdicao(usuario) {
   usuarioEmEdicao.value = usuario;
-  erroAcao.value = "";
   modalAberto.value = true;
 }
 
@@ -57,14 +35,22 @@ function fecharModal() {
 }
 
 async function alternarStatus(usuario) {
-  erroAcao.value = "";
   try {
     await toggleStatus(usuario.Id);
+    const nome = usuario.Nome || usuario.Login || "Usuário";
+    const novoStatus = String(usuario.Status) === "1" ? "desativado" : "ativado";
+    toast.sucesso(`${nome} ${novoStatus} com sucesso.`);
   } catch (e) {
-    erroAcao.value = e?.message || "Erro ao alterar status.";
+    toast.erro(e?.message || "Erro ao alterar status do usuário.");
   }
 }
 
+function aoSalvarUsuario() {
+  const acao = usuarioEmEdicao.value ? "atualizado" : "criado";
+  toast.sucesso(`Usuário ${acao} com sucesso.`);
+  fecharModal();
+}
+
 onMounted(async () => {
   await loadUsers();
 });
@@ -88,23 +74,15 @@ onMounted(async () => {
           >
             {{ totalUsuarios }} usuario{{ totalUsuarios === 1 ? "" : "s" }}
           </div>
-          <button
-            type="button"
-            class="inline-flex items-center gap-2 rounded-xl border border-primary bg-primary px-3 py-2 text-sm font-medium text-primary-foreground transition-colors hover:border-[var(--primary-600)] hover:bg-[var(--primary-600)]"
-            @click="abrirNovoUsuario"
-          >
+          <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>
             Novo usuário
-          </button>
+          </BaseButton>
         </div>
       </div>
 
-      <div v-if="erroAcao" class="mt-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-500/20 dark:bg-red-500/10 dark:text-red-300">
-        {{ erroAcao }}
-      </div>
-
       <div v-if="loading" class="mt-6 flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
         <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" />
@@ -167,47 +145,34 @@ onMounted(async () => {
             </div>
 
             <div class="flex flex-wrap items-center gap-2">
-              <span
-                class="inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-medium"
-                :class="classeStatus(usuario.Status)"
-              >
+              <BaseBadge :variant="String(usuario.Status) === '0' ? 'danger' : 'success'">
                 {{ formatarStatus(usuario.Status) }}
-              </span>
-              <span
-                class="inline-flex items-center rounded-full border border-blue-200 bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-700 dark:border-blue-500/20 dark:bg-blue-500/10 dark:text-blue-300"
-              >
-                Nível {{ usuario.Nivel ?? "-" }}
-              </span>
-              <span
-                class="inline-flex items-center rounded-full border border-violet-200 bg-violet-100 px-2.5 py-1 text-xs font-medium text-violet-700 dark:border-violet-500/20 dark:bg-violet-500/10 dark:text-violet-300"
-              >
-                {{ usuario.Setor ?? "-" }}
-              </span>
-
-              <button
-                type="button"
-                class="ml-auto rounded-lg border border-gray-200 bg-background/60 p-1.5 text-gray-500 transition-colors hover:border-gray-400 hover:text-gray-900 dark:border-gray-700 dark:bg-background/10 dark:text-gray-400 dark:hover:border-gray-500 dark:hover:text-gray-100"
+              </BaseBadge>
+              <BaseBadge variant="info">Nível {{ usuario.Nivel ?? "-" }}</BaseBadge>
+              <BaseBadge variant="violet">{{ usuario.Setor ?? "-" }}</BaseBadge>
+
+              <BaseButton
+                variant="ghost"
+                size="sm"
+                class="ml-auto !px-2"
                 title="Editar usuário"
+                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>
-              </button>
-
-              <button
-                type="button"
-                class="rounded-lg border p-1.5 text-xs font-medium transition-colors"
-                :class="
-                  String(usuario.Status) === '1'
-                    ? 'border-red-200 bg-red-50 text-red-600 hover:bg-red-100 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-400 dark:hover:bg-red-900/40'
-                    : 'border-emerald-200 bg-emerald-50 text-emerald-600 hover:bg-emerald-100 dark:border-emerald-900/50 dark:bg-emerald-950/30 dark:text-emerald-400 dark:hover:bg-emerald-900/40'
-                "
+              </BaseButton>
+
+              <BaseButton
+                :variant="String(usuario.Status) === '1' ? 'danger' : 'secondary'"
+                size="sm"
                 :title="String(usuario.Status) === '1' ? 'Desativar usuário' : 'Ativar usuário'"
+                :aria-label="String(usuario.Status) === '1' ? 'Desativar usuário' : 'Ativar usuário'"
                 @click="alternarStatus(usuario)"
               >
                 {{ String(usuario.Status) === "1" ? "Desativar" : "Ativar" }}
-              </button>
+              </BaseButton>
             </div>
           </div>
         </article>
@@ -218,6 +183,7 @@ onMounted(async () => {
       v-if="modalAberto"
       :usuario="usuarioEmEdicao"
       @close="fecharModal"
+      @saved="aoSalvarUsuario"
     />
   </LayoutSistema>
 </template>