Pārlūkot izejas kodu

ajuste em carregar e documentos

leonardo 3 mēneši atpakaļ
vecāks
revīzija
a71a489a75
3 mainītis faili ar 367 papildinājumiem un 122 dzēšanām
  1. 128 49
      src/components/DocumentList.vue
  2. 175 33
      src/components/DocumentUpload.vue
  3. 64 40
      src/layout/LayoutSistema.vue

+ 128 - 49
src/components/DocumentList.vue

@@ -1,53 +1,8 @@
-<template>
-  <section class="rounded-2xl border border-gray-200 bg-gray-100/50 p-4 shadow-sm backdrop-blur-md dark:border-gray-700 dark:bg-secondary/50">
-    <h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Documentos</h2>
-
-    <div class="mt-3 flex items-center justify-between gap-3">
-      <button
-        class="rounded-lg 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)] disabled:cursor-not-allowed disabled:opacity-50"
-        :disabled="loading"
-        @click="$emit('refresh')"
-      >
-        Atualizar
-      </button>
-      <div class="text-xs text-gray-500 dark:text-gray-400">{{ loading ? "Carregando..." : `${items.length} itens` }}</div>
-    </div>
-
-    <div v-if="error" class="mt-2.5 text-sm text-gray-500 dark:text-gray-400">Erro: {{ error }}</div>
-
-    <div class="mt-3 grid max-h-[520px] gap-2 overflow-auto">
-      <div
-        v-for="it in items"
-        :key="String(it.id)"
-        class="rounded-xl border border-gray-200 bg-background/60 p-3 dark:border-gray-700 dark:bg-background/10"
-      >
-        <div class="flex items-start justify-between gap-2">
-          <div class="min-w-0">
-            <div class="text-xs text-gray-500 dark:text-gray-400">{{ it.source || "sem fonte" }} · chunk {{ it.chunkIndex ?? "-" }}</div>
-            <div class="mt-1 whitespace-pre-wrap text-sm leading-snug text-gray-900 dark:text-gray-100">{{ it.text }}</div>
-          </div>
-          <button
-            v-if="it.source"
-            class="mt-0.5 shrink-0 rounded-lg border border-red-200 bg-red-50 p-1.5 text-red-600 transition-colors hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-400 dark:hover:bg-red-900/40"
-            :disabled="deleting === it.source"
-            :title="`Excluir todos os chunks de '${it.source}'`"
-            @click="onDeleteSource(it.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>
-          </button>
-        </div>
-      </div>
-    </div>
-  </section>
-</template>
-
 <script setup>
-import { ref } from "vue";
+import { ref, computed } from "vue";
 import { deleteDocumentsBySource } from "../api/chat.js";
 
-defineProps({
+const props = defineProps({
   items: { type: Array, required: true },
   loading: { type: Boolean, required: true },
   error: { type: String, required: true }
@@ -56,13 +11,24 @@ defineProps({
 const emit = defineEmits(["refresh"]);
 
 const deleting = ref(null);
+const confirmingSource = ref(null);
 
-async function onDeleteSource(source) {
+const grouped = computed(() => {
+  const result = {};
+  for (const item of props.items) {
+    const key = item.source ?? "";
+    if (!result[key]) result[key] = [];
+    result[key].push(item);
+  }
+  return result;
+});
+
+async function onConfirmDelete(source) {
   if (!source) return;
-  if (!window.confirm(`Excluir todos os chunks do documento "${source}"?\nEsta ação não pode ser desfeita.`)) return;
   deleting.value = source;
   try {
     await deleteDocumentsBySource(source);
+    confirmingSource.value = null;
     emit("refresh");
   } catch (e) {
     window.alert(`Erro ao excluir: ${e?.message || "erro desconhecido"}`);
@@ -71,3 +37,116 @@ async function onDeleteSource(source) {
   }
 }
 </script>
+
+<template>
+  <div class="grid gap-4">
+    <!-- Barra de ações -->
+    <div class="flex items-center justify-between gap-3">
+      <div class="text-xs text-gray-500 dark:text-gray-400">
+        <span v-if="loading">Carregando...</span>
+        <span v-else>
+          {{ Object.keys(grouped).length }} documento{{ Object.keys(grouped).length !== 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')"
+      >
+        <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>
+    </div>
+
+    <!-- Alerta de erro -->
+    <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>
+
+    <!-- Skeleton de carregamento -->
+    <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>
+
+    <!-- Estado vazio -->
+    <div
+      v-else-if="items.length === 0 && !error"
+      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>
+      </div>
+      <div>
+        <div class="text-sm font-medium text-gray-700 dark:text-gray-200">Nenhum documento na base</div>
+        <div class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">Carregue um arquivo ou texto pela aba de upload.</div>
+      </div>
+    </div>
+
+    <!-- Lista agrupada por documento -->
+    <div v-else class="grid max-h-[520px] gap-2 overflow-auto">
+      <div
+        v-for="(chunks, source) in grouped"
+        :key="source"
+        class="overflow-hidden rounded-xl border border-gray-200 bg-background/60 dark:border-gray-700 dark:bg-background/10"
+      >
+        <!-- Cabeçalho do grupo -->
+        <div class="flex items-center justify-between gap-2 px-3 py-2.5">
+          <div class="flex min-w-0 items-center gap-2">
+            <svg class="h-4 w-4 shrink-0 text-gray-400" 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>
+            <span class="truncate text-sm font-medium text-gray-900 dark:text-gray-100">{{ source || "sem fonte" }}</span>
+            <span class="shrink-0 rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-500 dark:bg-white/10 dark:text-gray-400">
+              {{ chunks.length }} chunk{{ chunks.length !== 1 ? 's' : '' }}
+            </span>
+          </div>
+
+          <!-- Confirmação inline de exclusão -->
+          <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"
+              :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"
+            >
+              Cancelar
+            </button>
+          </div>
+          <button
+            v-else-if="source"
+            class="shrink-0 rounded-lg border border-transparent p-1.5 text-gray-400 transition-colors hover:border-red-200 hover:bg-red-50 hover:text-red-600 dark:hover:border-red-900/50 dark:hover:bg-red-950/30 dark:hover:text-red-400"
+            :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>
+          </button>
+        </div>
+
+        <!-- Preview do primeiro chunk -->
+        <div class="border-t border-gray-100 px-3 py-2.5 dark:border-gray-700/50">
+          <div class="line-clamp-3 whitespace-pre-wrap text-xs leading-relaxed text-gray-500 dark:text-gray-400">
+            {{ chunks[0]?.text }}
+          </div>
+          <div v-if="chunks.length > 1" class="mt-1 text-xs text-gray-400 dark:text-gray-500">
+            + {{ chunks.length - 1 }} chunk{{ chunks.length - 1 !== 1 ? 's' : '' }} adicionais
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>

+ 175 - 33
src/components/DocumentUpload.vue

@@ -1,19 +1,54 @@
 <script setup>
-import { ref } from "vue";
+import { ref, computed } from "vue";
 
 const emit = defineEmits(["ingested"]);
 
+const tab = ref("arquivo");
 const manualText = ref("");
 const file = ref(null);
 const loading = ref(false);
 const status = ref("");
 const error = ref("");
+const dragOver = ref(false);
+const fileInputRef = ref(null);
 
-async function onFile(e) {
+function formatSize(bytes) {
+  if (bytes < 1024) return `${bytes} B`;
+  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+}
+
+function getFileIcon(f) {
+  const ext = f?.name?.split(".").pop()?.toLowerCase() ?? "";
+  if (ext === "pdf") return "pdf";
+  if (["png", "jpg", "jpeg", "webp"].includes(ext)) return "image";
+  return "file";
+}
+
+function onFileInputChange(e) {
   const f = e.target.files?.[0];
   if (!f) return;
   file.value = f;
-  status.value = `Arquivo carregado: ${f.name}`;
+  error.value = "";
+  status.value = "";
+}
+
+function onDrop(e) {
+  dragOver.value = false;
+  const f = e.dataTransfer?.files?.[0];
+  if (!f) return;
+  file.value = f;
+  error.value = "";
+  status.value = "";
+}
+
+function removeFile() {
+  file.value = null;
+  if (fileInputRef.value) fileInputRef.value.value = "";
+}
+
+function triggerFileInput() {
+  fileInputRef.value?.click();
 }
 
 async function onIngest() {
@@ -21,50 +56,157 @@ async function onIngest() {
   status.value = "";
   loading.value = true;
   try {
-    const hasManual = Boolean(manualText.value.trim());
-    if (hasManual) {
+    if (tab.value === "texto" && manualText.value.trim()) {
       emit("ingested", { text: manualText.value.trim(), source: "manual" });
     } else if (file.value) {
       emit("ingested", { file: file.value, source: "upload" });
     }
     manualText.value = "";
     file.value = null;
-    status.value = "Enviado para ingestão.";
+    if (fileInputRef.value) fileInputRef.value.value = "";
+    status.value = "Documento enviado para ingestão com sucesso.";
   } catch (e) {
-    error.value = e?.message || "erro";
+    error.value = e?.message || "Erro desconhecido.";
   } finally {
     loading.value = false;
   }
 }
-</script>
 
+const canSubmit = computed(() => {
+  if (tab.value === "arquivo") return !!file.value;
+  return !!manualText.value.trim();
+});
+</script>
 
 <template>
-  <section class="rounded-2xl border border-gray-300 bg-gray-100 p-4 shadow-sm backdrop-blur-md dark:border-gray-700 dark:bg-secondary/50">
-    <h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Update</h2>
-    <div class="mt-2 text-sm text-gray-500 dark:text-gray-400">
-      Envie .txt, .pdf, .docx ou imagens (png/jpg) com manuais/prints para alimentar a base.
+  <div class="grid gap-4">
+    <!-- Tabs -->
+    <div class="flex gap-1 rounded-xl bg-gray-100 p-1 dark:bg-white/5">
+      <button
+        v-for="t in [{ key: 'arquivo', label: 'Arquivo' }, { key: 'texto', label: 'Texto' }]"
+        :key="t.key"
+        type="button"
+        class="flex-1 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors"
+        :class="tab === t.key
+          ? 'bg-background text-gray-900 shadow-sm dark:text-gray-100'
+          : 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
+        @click="tab = t.key"
+      >
+        {{ t.label }}
+      </button>
     </div>
-    <input
-      class="mt-3"
-      type="file"
-      accept=".txt,.pdf,.docx,.png,.jpg,.jpeg,.webp,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,image/*,text/plain"
-      @change="onFile"
-    />
-    <textarea
-      v-model="manualText"
-      class="mt-3"
-      rows="6"
-      placeholder="Ou cole um texto aqui para ingestão..."
-    />
-    <button
-      class="mt-3 rounded-lg border border-primary bg-primary px-4 py-2.5 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"
-      :disabled="loading || (!manualText.trim() && !file)"
-      @click="onIngest"
+
+    <!-- Aba: Arquivo -->
+    <template v-if="tab === 'arquivo'">
+      <!-- Preview do arquivo selecionado -->
+      <div
+        v-if="file"
+        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>
+        </div>
+        <div class="min-w-0 flex-1">
+          <div class="truncate text-sm font-medium text-gray-900 dark:text-gray-100">{{ file.name }}</div>
+          <div class="text-xs text-gray-500 dark:text-gray-400">{{ formatSize(file.size) }}</div>
+        </div>
+        <button
+          type="button"
+          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>
+        </button>
+      </div>
+
+      <!-- Zona de drag-and-drop -->
+      <div
+        v-else
+        class="relative flex cursor-pointer flex-col items-center gap-3 rounded-xl border-2 border-dashed px-6 py-10 transition-colors"
+        :class="dragOver
+          ? 'border-primary bg-primary/5'
+          : 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600'"
+        @click="triggerFileInput"
+        @dragover.prevent="dragOver = true"
+        @dragleave.prevent="dragOver = false"
+        @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>
+        </div>
+        <div class="text-center">
+          <div class="text-sm font-medium text-gray-700 dark:text-gray-200">
+            Arraste um arquivo ou <span class="text-primary underline underline-offset-2">clique para selecionar</span>
+          </div>
+          <div class="mt-1 text-xs text-gray-400 dark:text-gray-500">PDF, Word, TXT, PNG, JPG, WEBP</div>
+        </div>
+        <input
+          ref="fileInputRef"
+          type="file"
+          accept=".txt,.pdf,.docx,.png,.jpg,.jpeg,.webp,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,image/*,text/plain"
+          class="sr-only"
+          @change="onFileInputChange"
+        />
+      </div>
+    </template>
+
+    <!-- Aba: Texto -->
+    <template v-else>
+      <div class="rounded-2xl border border-gray-200 bg-white/70 p-4 shadow-sm backdrop-blur-md dark:border-white/10 dark:bg-white/[0.04]">
+        <textarea
+          v-model="manualText"
+          rows="8"
+          placeholder="Cole o conteúdo aqui para ingestão..."
+          class="min-h-32 w-full resize-none border-0 bg-transparent text-sm shadow-none !outline-none focus:ring-0"
+        />
+      </div>
+    </template>
+
+    <!-- Linha de ação -->
+    <div class="flex items-center justify-between gap-3">
+      <div class="text-xs text-gray-400 dark:text-gray-500">
+        {{ tab === 'arquivo'
+          ? 'O arquivo será processado e indexado na base de conhecimento.'
+          : 'O texto será fragmentado e indexado na base.' }}
+      </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"
+        @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>
+        {{ loading ? "Enviando..." : "Ingerir" }}
+      </button>
+    </div>
+
+    <!-- Alertas de feedback -->
+    <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"
     >
-      Ingerir
-    </button>
-    <div v-if="status" class="mt-2.5 text-sm text-gray-500 dark:text-gray-400">{{ status }}</div>
-    <div v-if="error" class="mt-2.5 text-sm text-gray-500 dark:text-gray-400">Erro: {{ error }}</div>
-  </section>
+      {{ 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>

+ 64 - 40
src/layout/LayoutSistema.vue

@@ -662,57 +662,61 @@ watch(
       </aside>
 
       <Teleport to="body">
-        <div v-if="painelAberto" class="fixed inset-0 z-[9999]">
-          <div
-            class="absolute inset-0 bg-black/40 dark:bg-black/60"
-            @click="fecharModal"
-          />
-          <div class="absolute inset-0 flex items-center justify-center p-4">
+        <Transition name="modal">
+          <div v-if="painelAberto" class="fixed inset-0 z-[9999]">
             <div
-              class="w-full max-w-[980px] overflow-hidden rounded-2xl border border-gray-200 bg-background text-foreground shadow-lg dark:border-gray-700"
-              @click.stop
-            >
+              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">
               <div
-                class="flex items-center justify-between gap-3 border-b border-gray-300 px-4 py-3 dark:border-gray-700"
+                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="text-sm font-semibold text-gray-900 dark:text-gray-100"
+                  class="flex items-center justify-between gap-3 border-b border-gray-200 px-5 py-4 dark:border-gray-700"
                 >
-                  {{ tituloModal }}
+                  <div
+                    class="text-base font-semibold text-gray-900 dark:text-gray-100"
+                  >
+                    {{ 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>
-                <button
-                  type="button"
-                  class="rounded-lg border border-primary bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:border-[var(--primary-600)] hover:bg-[var(--primary-600)]"
-                  @click="fecharModal"
-                >
-                  Fechar
-                </button>
-              </div>
 
-              <div class="max-h-[calc(100vh-8rem)] overflow-auto p-4">
-                <DocumentUpload
-                  v-if="painelAberto === 'update'"
-                  @ingested="onIngested"
-                />
-                <template v-else-if="painelAberto === 'documentos'">
-                  <SearchBox
-                    :results="searchResults"
-                    :loading="searchLoading"
-                    :error="searchError"
-                    @search="runSearch"
+                <div class="max-h-[calc(100vh-8rem)] overflow-auto p-5">
+                  <DocumentUpload
+                    v-if="painelAberto === 'update'"
+                    @ingested="onIngested"
                   />
-                  <div class="h-3" />
-                  <DocumentList
-                    :items="docs.items"
-                    :loading="docs.loading"
-                    :error="docs.error"
-                    @refresh="loadDocs"
-                  />
-                </template>
+                  <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>
-        </div>
+        </Transition>
       </Teleport>
 
       <main
@@ -725,3 +729,23 @@ watch(
     </div>
   </div>
 </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>