Forráskód Böngészése

ajustes ajuste header e algumas mudanças frontend

leonardo 3 hónapja
szülő
commit
67fc26b944

+ 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-Cg266NFj.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DiByyltO.css">
+    <script type="module" crossorigin src="/assets/index-Dw5We78M.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-BVEeG9M8.css">
   </head>
   <body class="bg-background">
     <noscript>

+ 22 - 3
src/api/auth.js

@@ -26,7 +26,7 @@ export async function loginRequest({ login, senha, rememberMe = false }) {
     body: JSON.stringify({
       login,
       senha,
-      RemenberMe: rememberMe
+      rememberMe
     })
   });
 
@@ -39,12 +39,31 @@ export async function loginRequest({ login, senha, rememberMe = false }) {
   return payload;
 }
 
-export async function logoutRequest() {
+export async function refreshTokenRequest({ refreshToken }) {
+  const res = await fetch(`${apiBaseUrl}/api/auth/refresh`, {
+    method: "POST",
+    headers: {
+      "content-type": "application/json"
+    },
+    body: JSON.stringify({ refreshToken })
+  });
+
+  const payload = await parseResponse(res);
+
+  if (!res.ok || payload?.status === false) {
+    throw new Error(buildErrorMessage(payload, res.status));
+  }
+
+  return payload;
+}
+
+export async function logoutRequest({ refreshToken } = {}) {
   const res = await fetch(`${apiBaseUrl}/api/auth/logout`, {
     method: "POST",
     headers: {
       "content-type": "application/json"
-    }
+    },
+    body: JSON.stringify({ refreshToken })
   });
 
   const payload = await parseResponse(res);

+ 67 - 3
src/api/chat.js

@@ -1,12 +1,65 @@
-import { apiBaseUrl, apiFetch } from "./client.js";
+import { apiBaseUrl, apiFetch, getAccessToken } from "./client.js";
 
-export async function sendChat(message) {
+export async function sendChat(message, { conversationId } = {}) {
   return apiFetch("/api/chat", {
     method: "POST",
-    body: { message }
+    body: { message, ...(conversationId ? { conversationId } : {}) }
   });
 }
 
+export async function sendChatStream(message, { conversationId, onChunk, onSources, onDone, onError } = {}) {
+  const token = getAccessToken();
+  const headers = { "content-type": "application/json" };
+  if (token) headers["Authorization"] = `Bearer ${token}`;
+
+  let res;
+  try {
+    res = await fetch(`${apiBaseUrl}/api/chat/stream`, {
+      method: "POST",
+      headers,
+      body: JSON.stringify({ message, ...(conversationId ? { conversationId } : {}) })
+    });
+  } catch (e) {
+    onError?.(e);
+    return;
+  }
+
+  if (!res.ok) {
+    const text = await res.text().catch(() => "");
+    onError?.(new Error(text || `http_error:${res.status}`));
+    return;
+  }
+
+  const reader = res.body.getReader();
+  const decoder = new TextDecoder();
+  let buffer = "";
+
+  try {
+    while (true) {
+      const { done, value } = await reader.read();
+      if (done) break;
+      buffer += decoder.decode(value, { stream: true });
+      const lines = buffer.split("\n");
+      buffer = lines.pop() ?? "";
+      for (const line of lines) {
+        if (!line.startsWith("data: ")) continue;
+        const payload = line.slice(6);
+        if (payload === "[DONE]") { onDone?.(); return; }
+        try {
+          const parsed = JSON.parse(payload);
+          if (parsed.type === "delta") onChunk?.(parsed.delta);
+          else if (parsed.type === "sources") onSources?.(parsed.sources);
+          else if (parsed.type === "error") onError?.(new Error(parsed.error));
+        } catch {}
+      }
+    }
+  } catch (e) {
+    onError?.(e);
+  }
+
+  onDone?.();
+}
+
 export async function ingestDocuments(documents) {
   return apiFetch("/api/ingest", {
     method: "POST",
@@ -19,8 +72,13 @@ export async function ingestFile(file, { source } = {}) {
   form.append("file", file);
   if (source) form.append("source", source);
 
+  const headers = {};
+  const token = getAccessToken();
+  if (token) headers["Authorization"] = `Bearer ${token}`;
+
   const res = await fetch(`${apiBaseUrl}/api/ingest/file`, {
     method: "POST",
+    headers,
     body: form
   });
 
@@ -45,3 +103,9 @@ export async function listDocuments({ limit = 50, offset = 0 } = {}) {
   const params = new URLSearchParams({ limit: String(limit), offset: String(offset) }).toString();
   return apiFetch(`/api/documents?${params}`);
 }
+
+export async function deleteDocumentsBySource(source) {
+  return apiFetch(`/api/documents/source/${encodeURIComponent(source)}`, {
+    method: "DELETE"
+  });
+}

+ 54 - 5
src/api/client.js

@@ -1,17 +1,66 @@
 export const apiBaseUrl = import.meta.env.VITE_API_URL ?? "http://localhost:3001";
 
+let _accessToken = null;
+let _refreshFn = null;
+let _authErrorFn = null;
+
+export function setAccessToken(token) {
+  _accessToken = token ?? null;
+}
+
+export function setRefreshCallback(fn) {
+  _refreshFn = fn ?? null;
+}
+
+export function setAuthErrorCallback(fn) {
+  _authErrorFn = fn ?? null;
+}
+
+export function getAccessToken() {
+  return _accessToken;
+}
+
 export async function apiFetch(path, { method = "GET", body } = {}) {
-  const res = await fetch(`${apiBaseUrl}${path}`, {
+  const headers = { "content-type": "application/json" };
+  if (_accessToken) headers["Authorization"] = `Bearer ${_accessToken}`;
+
+  let res = await fetch(`${apiBaseUrl}${path}`, {
     method,
-    headers: {
-      "content-type": "application/json"
-    },
+    headers,
     body: body ? JSON.stringify(body) : undefined
   });
 
+  if (res.status === 401 && _refreshFn) {
+    try {
+      const newToken = await _refreshFn();
+      if (newToken) {
+        headers["Authorization"] = `Bearer ${newToken}`;
+        res = await fetch(`${apiBaseUrl}${path}`, {
+          method,
+          headers,
+          body: body ? JSON.stringify(body) : undefined
+        });
+      }
+    } catch {
+      _authErrorFn?.();
+      throw new Error("session_expired");
+    }
+  }
+
+  if (res.status === 401) {
+    _authErrorFn?.();
+    throw new Error("session_expired");
+  }
+
   if (!res.ok) {
     const text = await res.text().catch(() => "");
-    throw new Error(text || `http_error:${res.status}`);
+    let message = text;
+    try {
+      const parsed = JSON.parse(text);
+      if (parsed?.msg) message = parsed.msg;
+      else if (parsed?.error) message = parsed.error;
+    } catch {}
+    throw new Error(message || `http_error:${res.status}`);
   }
 
   const contentType = res.headers.get("content-type") ?? "";

+ 29 - 0
src/api/conversations.js

@@ -0,0 +1,29 @@
+import { apiFetch } from "./client.js";
+
+export async function listConversations() {
+  return apiFetch("/api/conversations");
+}
+
+export async function createConversation(title = "Nova conversa") {
+  return apiFetch("/api/conversations", {
+    method: "POST",
+    body: { title }
+  });
+}
+
+export async function getConversationMessages(id) {
+  return apiFetch(`/api/conversations/${id}/messages`);
+}
+
+export async function updateConversationTitle(id, title) {
+  return apiFetch(`/api/conversations/${id}`, {
+    method: "PATCH",
+    body: { title }
+  });
+}
+
+export async function deleteConversation(id) {
+  return apiFetch(`/api/conversations/${id}`, {
+    method: "DELETE"
+  });
+}

+ 12 - 0
src/api/users.js

@@ -4,3 +4,15 @@ export async function listUsers() {
   const payload = await apiFetch("/api/users");
   return Array.isArray(payload?.items) ? payload.items : [];
 }
+
+export async function createUser(data) {
+  return apiFetch("/api/users", { method: "POST", body: data });
+}
+
+export async function updateUser(id, data) {
+  return apiFetch(`/api/users/${id}`, { method: "PUT", body: data });
+}
+
+export async function toggleUserStatus(id) {
+  return apiFetch(`/api/users/${id}/status`, { method: "PATCH" });
+}

+ 2 - 4
src/components/BotaoAlterarTema.vue

@@ -46,17 +46,15 @@ function obterPreferenciaInicial() {
 }
 
 onMounted(() => {
-  const classeAtual = document.documentElement.classList.contains("dark");
   const preferencia = obterPreferenciaInicial();
-  const escuro = classeAtual || preferencia;
-  aplicarTema(escuro);
+  aplicarTema(preferencia);
 });
 </script>
 
 <template>
   <button
     type="button"
-    class="inline-flex items-center justify-center rounded-md !p-2 !bg-none !bg-transparent !border-0 !text-primary-foreground hover:!bg-white/10 motion-safe:transition-colors"
+    class="inline-flex items-center justify-center rounded-md !p-2 !bg-none !bg-transparent !border-0 !text-current hover:!bg-black/10 dark:hover:!bg-white/10 motion-safe:transition-colors"
     aria-label="Alternar tema"
     @click="darkMode = !darkMode"
   >

+ 56 - 8
src/components/ChatWindow.vue

@@ -11,23 +11,51 @@
         </div>
       </div>
 
-      <div v-else class="grid gap-3">
-        <div v-for="(m, idx) in messages" :key="idx" class="flex" :class="m.role === 'user' ? 'justify-end' : 'justify-start'">
+      <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 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"
+              :class="m.role === 'user' ? 'bg-primary' : 'bg-gray-400 dark:bg-gray-600'"
+            >
+              {{ m.role === 'user' ? 'V' : 'O' }}
+            </div>
+            <span class="text-xs font-medium text-gray-400 dark:text-gray-500">
+              {{ m.role === 'user' ? 'Você' : 'Oráculo' }}
+            </span>
+            <span v-if="m.sentAt" class="text-[10px] text-gray-300 dark:text-gray-600">
+              {{ formatarHora(m.sentAt) }}
+            </span>
+          </div>
           <div
-            class="max-w-[78%] whitespace-pre-wrap rounded-2xl border px-3 py-2 leading-snug"
+            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/30 text-foreground dark:border-gray-700 dark:bg-white/10'
+                : 'border-gray-200 bg-white/50 text-foreground dark:border-gray-700 dark:bg-white/[0.07]'
             "
           >
             {{ m.content }}
           </div>
         </div>
+
+        <div v-if="loading" class="flex flex-col items-start gap-0.5">
+          <div class="mb-0.5 flex items-center gap-1.5">
+            <div class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-gray-400 text-[9px] font-bold text-white dark:bg-gray-600">O</div>
+            <span class="text-xs font-medium text-gray-400 dark:text-gray-500">Oráculo</span>
+          </div>
+          <div class="rounded-2xl border border-gray-200 bg-white/50 px-4 py-3 dark:border-gray-700 dark:bg-white/[0.07]">
+            <span class="inline-flex items-center gap-1">
+              <span class="h-1.5 w-1.5 animate-bounce rounded-full bg-gray-400 dark:bg-gray-500" style="animation-delay: 0ms" />
+              <span class="h-1.5 w-1.5 animate-bounce rounded-full bg-gray-400 dark:bg-gray-500" style="animation-delay: 150ms" />
+              <span class="h-1.5 w-1.5 animate-bounce rounded-full bg-gray-400 dark:bg-gray-500" style="animation-delay: 300ms" />
+            </span>
+          </div>
+        </div>
       </div>
     </div>
 
-    <div class="grid gap-3 md:grid-cols-[1fr_140px] md:items-start">
+    <div class="grid gap-3 md:grid-cols-[1fr_auto] md:items-end">
       <textarea
         v-model="draft"
         rows="2"
@@ -36,10 +64,21 @@
         style="resize: none"
         @keydown.enter.exact.prevent="onSend"
       />
-      <button :disabled="loading || !draft.trim()" @click="onSend">Enviar</button>
+      <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"
+        :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>
+        Enviar
+      </button>
     </div>
 
-    <div v-if="error" class="text-sm text-gray-500 dark:text-gray-400">Erro: {{ error }}</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">
+      Erro: {{ error }}
+    </div>
   </div>
 </template>
 
@@ -57,8 +96,17 @@ const emit = defineEmits(["send"]);
 const draft = ref("");
 const chatEl = ref(null);
 
+function formatarHora(ts) {
+  if (!ts) return "";
+  try {
+    return new Date(ts).toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" });
+  } catch {
+    return "";
+  }
+}
+
 watch(
-  () => props.messages.length,
+  () => [props.messages.length, props.loading],
   async () => {
     await nextTick();
     const el = chatEl.value;

+ 38 - 3
src/components/DocumentList.vue

@@ -21,18 +21,53 @@
         :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="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 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 { deleteDocumentsBySource } from "../api/chat.js";
+
 defineProps({
   items: { type: Array, required: true },
   loading: { type: Boolean, required: true },
   error: { type: String, required: true }
 });
-defineEmits(["refresh"]);
+
+const emit = defineEmits(["refresh"]);
+
+const deleting = ref(null);
+
+async function onDeleteSource(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);
+    emit("refresh");
+  } catch (e) {
+    window.alert(`Erro ao excluir: ${e?.message || "erro desconhecido"}`);
+  } finally {
+    deleting.value = null;
+  }
+}
 </script>

+ 162 - 0
src/components/ModalUsuario.vue

@@ -0,0 +1,162 @@
+<template>
+  <Teleport to="body">
+    <div class="fixed inset-0 z-[9999] flex items-center justify-center p-4">
+      <div class="absolute inset-0 bg-black/40 dark:bg-black/60" @click="!salvando && $emit('close')" />
+      <div
+        class="relative w-full max-w-md overflow-hidden rounded-2xl border border-gray-200 bg-background text-foreground shadow-xl dark:border-gray-700"
+        @click.stop
+      >
+        <div class="flex items-center justify-between gap-3 border-b border-gray-200 px-5 py-4 dark:border-gray-700">
+          <h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">
+            {{ usuario ? "Editar usuário" : "Novo usuário" }}
+          </h3>
+          <button
+            type="button"
+            :disabled="salvando"
+            class="rounded-lg border border-transparent p-1.5 text-gray-500 transition-colors hover:border-gray-200 hover:bg-gray-100 disabled:opacity-50 dark:text-gray-400 dark:hover:border-gray-700 dark:hover:bg-white/5"
+            @click="$emit('close')"
+          >
+            <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
+            </svg>
+          </button>
+        </div>
+
+        <form class="p-5 grid gap-4" @submit.prevent="onSubmit">
+          <div class="grid gap-1.5">
+            <label class="text-sm font-medium text-gray-700 dark:text-gray-300">Nome</label>
+            <input v-model="form.Nome" placeholder="Nome completo" required :disabled="salvando" />
+          </div>
+
+          <div v-if="!usuario" class="grid gap-1.5">
+            <label class="text-sm font-medium text-gray-700 dark:text-gray-300">Login</label>
+            <input v-model="form.Login" placeholder="Nome de usuário" required :disabled="salvando" autocomplete="off" />
+          </div>
+
+          <div class="grid gap-1.5">
+            <label class="text-sm font-medium text-gray-700 dark:text-gray-300">E-mail</label>
+            <input v-model="form.Email" type="email" placeholder="email@empresa.com" required :disabled="salvando" />
+          </div>
+
+          <div v-if="!usuario" class="grid gap-1.5">
+            <label class="text-sm font-medium text-gray-700 dark:text-gray-300">Senha</label>
+            <input v-model="form.Senha" type="password" placeholder="Mínimo 6 caracteres" required :disabled="salvando" autocomplete="new-password" />
+          </div>
+
+          <div class="grid grid-cols-2 gap-3">
+            <div class="grid gap-1.5">
+              <label class="text-sm font-medium text-gray-700 dark:text-gray-300">Nível</label>
+              <select v-model="form.Nivel" :disabled="salvando">
+                <option value="1">1 — Básico</option>
+                <option value="2">2 — Intermediário</option>
+                <option value="3">3 — Administrador</option>
+              </select>
+            </div>
+            <div class="grid gap-1.5">
+              <label class="text-sm font-medium text-gray-700 dark:text-gray-300">Setor</label>
+              <input v-model="form.Setor" placeholder="Ex: TI, RH, Financeiro" required :disabled="salvando" />
+            </div>
+          </div>
+
+          <div v-if="erroForm" 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">
+            {{ erroForm }}
+          </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')"
+            >
+              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>
+          </div>
+        </form>
+      </div>
+    </div>
+  </Teleport>
+</template>
+
+<script setup>
+import { ref, watch } from "vue";
+import { useUsers } from "../composables/useUsers.js";
+
+const props = defineProps({
+  usuario: { type: Object, default: null }
+});
+
+const emit = defineEmits(["close", "saved"]);
+
+const { createUser, updateUser } = useUsers();
+
+const salvando = ref(false);
+const erroForm = ref("");
+
+const form = ref({
+  Nome: "",
+  Login: "",
+  Email: "",
+  Senha: "",
+  Nivel: "1",
+  Setor: ""
+});
+
+watch(
+  () => props.usuario,
+  (u) => {
+    if (u) {
+      form.value = {
+        Nome: u.Nome ?? "",
+        Login: u.Login ?? "",
+        Email: u.Email ?? "",
+        Senha: "",
+        Nivel: String(u.Nivel ?? "1"),
+        Setor: u.Setor ?? ""
+      };
+    } else {
+      form.value = { Nome: "", Login: "", Email: "", Senha: "", Nivel: "1", Setor: "" };
+    }
+    erroForm.value = "";
+  },
+  { immediate: true }
+);
+
+async function onSubmit() {
+  erroForm.value = "";
+  salvando.value = true;
+  try {
+    if (props.usuario) {
+      const result = await updateUser(props.usuario.Id, {
+        Nome: form.value.Nome,
+        Email: form.value.Email,
+        Nivel: form.value.Nivel,
+        Setor: form.value.Setor
+      });
+      emit("saved", result?.usuario);
+    } else {
+      const result = await createUser({
+        Nome: form.value.Nome,
+        Login: form.value.Login,
+        Email: form.value.Email,
+        Senha: form.value.Senha,
+        Nivel: form.value.Nivel,
+        Setor: form.value.Setor
+      });
+      emit("saved", result?.usuario);
+    }
+    emit("close");
+  } catch (e) {
+    erroForm.value = e?.message || "Erro ao salvar usuário.";
+  } finally {
+    salvando.value = false;
+  }
+}
+</script>

+ 78 - 7
src/composables/useAuth.js

@@ -1,5 +1,6 @@
 import { computed, readonly, ref } from "vue";
-import { loginRequest, logoutRequest } from "../api/auth.js";
+import { loginRequest, logoutRequest, refreshTokenRequest } from "../api/auth.js";
+import { setAccessToken, setRefreshCallback, setAuthErrorCallback } from "../api/client.js";
 
 const LOCAL_STORAGE_KEY = "auth.session";
 const SESSION_STORAGE_KEY = "auth.session.temp";
@@ -23,18 +24,18 @@ function loadStoredSession() {
   return null;
 }
 
-function persistSession(session) {
+function persistSession(sess) {
   try {
     getStorage("local")?.removeItem(LOCAL_STORAGE_KEY);
     getStorage("session")?.removeItem(SESSION_STORAGE_KEY);
 
-    if (session?.rememberMe) {
-      getStorage("local")?.setItem(LOCAL_STORAGE_KEY, JSON.stringify(session));
+    if (sess?.rememberMe) {
+      getStorage("local")?.setItem(LOCAL_STORAGE_KEY, JSON.stringify(sess));
       return;
     }
 
-    if (session) {
-      getStorage("session")?.setItem(SESSION_STORAGE_KEY, JSON.stringify(session));
+    if (sess) {
+      getStorage("session")?.setItem(SESSION_STORAGE_KEY, JSON.stringify(sess));
     }
   } catch {}
 }
@@ -43,29 +44,99 @@ const session = ref(loadStoredSession());
 const user = computed(() => session.value?.usuario ?? null);
 const isAuthenticated = computed(() => Boolean(user.value?.Id));
 
+let _proactiveRefreshTimer = null;
+
+function scheduleProactiveRefresh(token) {
+  clearTimeout(_proactiveRefreshTimer);
+  if (!token) return;
+  try {
+    const payload = JSON.parse(atob(token.split(".")[1]));
+    const delay = payload.exp * 1000 - Date.now() - 60_000;
+    if (delay <= 0) return;
+    _proactiveRefreshTimer = setTimeout(async () => {
+      const rt = session.value?.refreshToken;
+      if (!rt) return;
+      try {
+        const result = await refreshTokenRequest({ refreshToken: rt });
+        session.value = { ...session.value, accessToken: result.accessToken };
+        persistSession(session.value);
+        setAccessToken(result.accessToken);
+        scheduleProactiveRefresh(result.accessToken);
+      } catch {
+        // 401 interceptor handles expired token on next request
+      }
+    }, delay);
+  } catch {
+    // token não é JWT válido, ignora
+  }
+}
+
+function setupAuth() {
+  const token = session.value?.accessToken ?? null;
+  setAccessToken(token);
+
+  if (!token) return;
+
+  scheduleProactiveRefresh(token);
+
+  setRefreshCallback(async () => {
+    const rt = session.value?.refreshToken;
+    if (!rt) throw new Error("no_refresh_token");
+    const result = await refreshTokenRequest({ refreshToken: rt });
+    session.value = { ...session.value, accessToken: result.accessToken };
+    persistSession(session.value);
+    setAccessToken(result.accessToken);
+    scheduleProactiveRefresh(result.accessToken);
+    return result.accessToken;
+  });
+
+  setAuthErrorCallback(() => {
+    clearTimeout(_proactiveRefreshTimer);
+    session.value = null;
+    persistSession(null);
+    setAccessToken(null);
+    setRefreshCallback(null);
+    setAuthErrorCallback(null);
+    window.location.href = "/login";
+  });
+}
+
+setupAuth();
+
 async function login({ login, senha, rememberMe = false }) {
   const payload = await loginRequest({ login, senha, rememberMe });
   session.value = {
     usuario: payload.usuario,
+    accessToken: payload.accessToken ?? null,
+    refreshToken: payload.refreshToken ?? null,
     rememberMe,
     savedAt: new Date().toISOString()
   };
   persistSession(session.value);
+  setupAuth();
   return payload;
 }
 
 async function logout() {
+  clearTimeout(_proactiveRefreshTimer);
+  const rt = session.value?.refreshToken;
   try {
-    await logoutRequest();
+    await logoutRequest({ refreshToken: rt });
   } finally {
     session.value = null;
     persistSession(null);
+    setAccessToken(null);
+    setRefreshCallback(null);
+    setAuthErrorCallback(null);
   }
 }
 
 function clearSession() {
   session.value = null;
   persistSession(null);
+  setAccessToken(null);
+  setRefreshCallback(null);
+  setAuthErrorCallback(null);
 }
 
 export function useAuth() {

+ 131 - 226
src/composables/useChat.js

@@ -1,282 +1,187 @@
-import { ref, watch } from "vue";
-import { sendChat } from "../api/chat.js";
+import { ref } from "vue";
+import { sendChatStream } from "../api/chat.js";
+import {
+  listConversations,
+  createConversation,
+  getConversationMessages,
+  deleteConversation as deleteConversationAPI
+} from "../api/conversations.js";
 
 let singleton;
 
 export function useChat() {
+  if (singleton) return singleton;
+
   const defaultMessages = [
     {
       role: "assistant",
-      content: "Posso responder usando a base de conhecimento da empresa. Envie uma pergunta ou faça upload de documentos."
+      content: "Posso responder usando a base de conhecimento da empresa. Envie uma pergunta ou faça upload de documentos.",
+      sentAt: 0
     }
   ];
 
-  if (singleton) return singleton;
-
-  const STORAGE_CONVERSATIONS = "oraculo_conversations_v1";
-  const STORAGE_ACTIVE_ID = "oraculo_active_conversation_id_v1";
-  const STORAGE_MESSAGES_PREFIX = "oraculo_conversation_messages_v1:";
-
-  function genId() {
-    const a = Date.now().toString(36);
-    const b = Math.random().toString(36).slice(2, 10);
-    return `${a}_${b}`;
-  }
-
-  function normalizeMessages(next) {
-    return (next ?? [])
-      .filter((m) => m && typeof m === "object")
-      .slice(-200)
-      .map((m) => ({
-        role: m?.role === "user" ? "user" : "assistant",
-        content: String(m?.content ?? ""),
-        sources: Array.isArray(m?.sources) ? m.sources : undefined
-      }));
-  }
-
-  function messagesKey(id) {
-    return `${STORAGE_MESSAGES_PREFIX}${String(id ?? "")}`;
-  }
-
-  function loadConversations() {
-    if (typeof window === "undefined") return [];
-    try {
-      const raw = window.localStorage.getItem(STORAGE_CONVERSATIONS);
-      if (!raw) return [];
-      const parsed = JSON.parse(raw);
-      if (!Array.isArray(parsed)) return [];
-      return parsed
-        .filter((c) => c && typeof c === "object")
-        .map((c) => ({
-          id: String(c.id ?? ""),
-          title: String(c.title ?? "Nova conversa"),
-          createdAt: Number(c.createdAt ?? Date.now()),
-          updatedAt: Number(c.updatedAt ?? Date.now()),
-          messageCount: Number(c.messageCount ?? 0)
-        }))
-        .filter((c) => c.id);
-    } catch {
-      return [];
-    }
-  }
-
-  function saveConversations(items) {
-    if (typeof window === "undefined") return;
-    try {
-      window.localStorage.setItem(STORAGE_CONVERSATIONS, JSON.stringify(items));
-    } catch {}
-  }
-
-  function loadActiveConversationId() {
-    if (typeof window === "undefined") return "";
+  const conversations = ref([]);
+  const activeConversationId = ref(null);
+  const messages = ref([...defaultMessages]);
+  const loading = ref(false);
+  const error = ref("");
+  const conversationsLoading = ref(false);
+
+  function normalizeApiMessages(items) {
+    return (items ?? []).map((m) => ({
+      role: m.Role === "user" ? "user" : "assistant",
+      content: String(m.Content ?? ""),
+      sources: m.Sources
+        ? typeof m.Sources === "string"
+          ? JSON.parse(m.Sources)
+          : m.Sources
+        : undefined,
+      sentAt: m.SentAt ? new Date(m.SentAt).getTime() : 0
+    }));
+  }
+
+  async function loadConversationsList() {
+    conversationsLoading.value = true;
     try {
-      return String(window.localStorage.getItem(STORAGE_ACTIVE_ID) ?? "");
+      const data = await listConversations();
+      conversations.value = (data.items ?? []).map((c) => ({
+        id: String(c.Id),
+        title: c.Title || "Nova conversa",
+        createdAt: new Date(c.CreatedAt).getTime(),
+        updatedAt: new Date(c.UpdatedAt).getTime(),
+        messageCount: 0
+      }));
     } catch {
-      return "";
+      conversations.value = [];
+    } finally {
+      conversationsLoading.value = false;
     }
   }
 
-  function saveActiveConversationId(id) {
-    if (typeof window === "undefined") return;
-    try {
-      window.localStorage.setItem(STORAGE_ACTIVE_ID, String(id ?? ""));
-    } catch {}
-  }
-
-  function clearActiveConversationId() {
-    if (typeof window === "undefined") return;
-    try {
-      window.localStorage.removeItem(STORAGE_ACTIVE_ID);
-    } catch {}
-  }
-
-  function loadMessagesForConversation(id) {
-    if (typeof window === "undefined") return defaultMessages;
-    const key = messagesKey(id);
+  async function setActiveConversation(id) {
+    const strId = String(id ?? "").trim();
+    if (!strId) return;
+    activeConversationId.value = strId;
+    messages.value = [...defaultMessages];
     try {
-      const raw = window.localStorage.getItem(key);
-      if (!raw) return defaultMessages;
-      const parsed = JSON.parse(raw);
-      if (!Array.isArray(parsed) || parsed.length === 0) return defaultMessages;
-      return normalizeMessages(parsed);
+      const numId = Number(strId);
+      if (!numId) return;
+      const data = await getConversationMessages(numId);
+      const normalized = normalizeApiMessages(data?.items);
+      messages.value = normalized.length ? normalized : [...defaultMessages];
     } catch {
-      return defaultMessages;
+      messages.value = [...defaultMessages];
     }
   }
 
-  function saveMessagesForConversation(id, next) {
-    if (typeof window === "undefined") return;
-    const key = messagesKey(id);
-    try {
-      window.localStorage.setItem(key, JSON.stringify(normalizeMessages(next)));
-    } catch {}
-  }
-
-  function removeMessagesForConversation(id) {
-    if (typeof window === "undefined") return;
-    try {
-      window.localStorage.removeItem(messagesKey(id));
-    } catch {}
-  }
-
-  const conversations = ref(loadConversations());
-  const activeConversationId = ref(loadActiveConversationId());
-  const messages = ref([]);
-  const loading = ref(false);
-  const error = ref("");
-
-  function ensureConversationExists(id) {
-    const exists = conversations.value.some((c) => c.id === id);
-    if (exists) return;
-    const now = Date.now();
-    conversations.value = [{ id, title: "Nova conversa", createdAt: now, updatedAt: now, messageCount: 0 }, ...conversations.value];
-    saveConversations(conversations.value);
-  }
-
-  function upsertConversationMetaFromMessages(id, nextMessages) {
-    const normalized = normalizeMessages(nextMessages);
-    const firstUser = normalized.find((m) => m.role === "user" && String(m.content ?? "").trim());
-    const title = firstUser ? String(firstUser.content).trim().slice(0, 60) : "Nova conversa";
-    const now = Date.now();
-
-    const existing = conversations.value.find((c) => c.id === id);
-    if (!existing) {
-      conversations.value = [
-        { id, title, createdAt: now, updatedAt: now, messageCount: normalized.length },
-        ...conversations.value
-      ];
-    } else {
-      const next = conversations.value.map((c) =>
-        c.id === id
-          ? { ...c, title: c.title && c.title !== "Nova conversa" ? c.title : title, updatedAt: now, messageCount: normalized.length }
-          : c
-      );
-      next.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
-      conversations.value = next;
-    }
-    saveConversations(conversations.value);
-  }
-
-  function setActiveConversation(id) {
-    const nextId = String(id ?? "").trim();
-    if (!nextId) return;
-    ensureConversationExists(nextId);
-    activeConversationId.value = nextId;
-    saveActiveConversationId(nextId);
-    messages.value = loadMessagesForConversation(nextId);
-  }
-
   function newConversation() {
-    activeConversationId.value = "";
-    clearActiveConversationId();
+    activeConversationId.value = null;
     messages.value = [...defaultMessages];
-    return "";
+    return null;
   }
 
-  function deleteConversation(id) {
-    const targetId = String(id ?? "").trim();
-    if (!targetId) return;
-    conversations.value = conversations.value.filter((c) => c.id !== targetId);
-    saveConversations(conversations.value);
-    removeMessagesForConversation(targetId);
-
-    if (activeConversationId.value === targetId) {
-      const next = conversations.value[0]?.id;
-      if (next) setActiveConversation(next);
+  async function deleteConversation(id) {
+    const strId = String(id ?? "").trim();
+    const numId = Number(strId);
+    if (!numId) return;
+    try {
+      await deleteConversationAPI(numId);
+    } catch {}
+    conversations.value = conversations.value.filter((c) => c.id !== strId);
+    if (String(activeConversationId.value) === strId) {
+      const next = conversations.value[0];
+      if (next) await setActiveConversation(next.id);
       else newConversation();
     }
   }
 
   function searchConversations(query) {
     const q = String(query ?? "").trim().toLowerCase();
-    if (!q) return conversations.value.slice().sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
-
-    const items = conversations.value.slice().sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
-    return items.filter((c) => {
-      if (String(c.title ?? "").toLowerCase().includes(q)) return true;
-      const ms = loadMessagesForConversation(c.id);
-      return ms.some((m) => String(m.content ?? "").toLowerCase().includes(q));
-    });
+    const sorted = conversations.value
+      .slice()
+      .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
+    if (!q) return sorted;
+    return sorted.filter((c) => String(c.title ?? "").toLowerCase().includes(q));
   }
 
-  function ensureInitialState() {
-    const existingActive = String(activeConversationId.value ?? "").trim();
-    const hasActive = existingActive && conversations.value.some((c) => c.id === existingActive);
-    if (hasActive) {
-      messages.value = loadMessagesForConversation(existingActive);
-      return;
-    }
-
-    const first = conversations.value[0]?.id;
-    if (first) {
-      setActiveConversation(first);
-      return;
-    }
-
-    activeConversationId.value = "";
-    clearActiveConversationId();
-    messages.value = [...defaultMessages];
-  }
-
-  ensureInitialState();
-
-  watch(
-    messages,
-    (next) => {
-      const id = String(activeConversationId.value ?? "").trim();
-      if (!id) return;
-      saveMessagesForConversation(id, next);
-      upsertConversationMetaFromMessages(id, next);
-    },
-    { deep: true }
-  );
-
   async function send(content) {
     error.value = "";
     const trimmed = String(content ?? "").trim();
     if (!trimmed) return;
 
-    if (!String(activeConversationId.value ?? "").trim()) {
-      activeConversationId.value = genId();
-      ensureConversationExists(activeConversationId.value);
+    let convId = activeConversationId.value ? Number(activeConversationId.value) : null;
+
+    if (!convId) {
+      try {
+        const title = trimmed.slice(0, 60);
+        const conv = await createConversation(title);
+        convId = conv.id;
+        activeConversationId.value = String(convId);
+        const now = Date.now();
+        conversations.value = [
+          { id: String(convId), title, createdAt: now, updatedAt: now, messageCount: 0 },
+          ...conversations.value
+        ];
+      } catch {
+        // Continue without persistence if creation fails
+      }
     }
 
-    messages.value.push({ role: "user", content: trimmed });
-    saveActiveConversationId(activeConversationId.value);
-    saveMessagesForConversation(activeConversationId.value, messages.value);
-    upsertConversationMetaFromMessages(activeConversationId.value, messages.value);
+    messages.value.push({ role: "user", content: trimmed, sentAt: Date.now() });
+
+    const assistantMsg = { role: "assistant", content: "", sources: [], sentAt: Date.now(), streaming: true };
+    messages.value.push(assistantMsg);
+    const msgIndex = messages.value.length - 1;
+
     loading.value = true;
 
-    try {
-      const data = await sendChat(trimmed);
-      messages.value.push({
-        role: "assistant",
-        content: data.answer || "(sem resposta)",
-        sources: data.sources ?? []
-      });
-      saveMessagesForConversation(activeConversationId.value, messages.value);
-      upsertConversationMetaFromMessages(activeConversationId.value, messages.value);
-    } catch (e) {
-      error.value = e?.message || "erro";
-      messages.value.push({ role: "assistant", content: "Não consegui responder no momento." });
-      saveMessagesForConversation(activeConversationId.value, messages.value);
-      upsertConversationMetaFromMessages(activeConversationId.value, messages.value);
-    } finally {
-      loading.value = false;
-    }
+    await sendChatStream(trimmed, {
+      conversationId: convId,
+      onChunk: (delta) => {
+        messages.value[msgIndex].content += delta;
+      },
+      onSources: (sources) => {
+        messages.value[msgIndex].sources = sources ?? [];
+      },
+      onDone: () => {
+        messages.value[msgIndex].streaming = false;
+        loading.value = false;
+        if (convId) {
+          conversations.value = conversations.value
+            .map((c) =>
+              c.id === String(convId)
+                ? { ...c, updatedAt: Date.now(), messageCount: (c.messageCount ?? 0) + 2 }
+                : c
+            )
+            .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
+        }
+      },
+      onError: (e) => {
+        error.value = e?.message || "erro";
+        if (!messages.value[msgIndex].content) {
+          messages.value[msgIndex].content = "Não consegui responder no momento.";
+        }
+        messages.value[msgIndex].streaming = false;
+        loading.value = false;
+      }
+    });
   }
 
+  loadConversationsList();
+
   singleton = {
     conversations,
     activeConversationId,
     messages,
     loading,
     error,
+    conversationsLoading,
     send,
     newConversation,
     setActiveConversation,
     deleteConversation,
-    searchConversations
+    searchConversations,
+    loadConversationsList
   };
 
   return singleton;

+ 31 - 3
src/composables/useUsers.js

@@ -1,5 +1,5 @@
 import { ref } from "vue";
-import { listUsers } from "../api/users.js";
+import { listUsers, createUser as createUserAPI, updateUser as updateUserAPI, toggleUserStatus as toggleUserStatusAPI } from "../api/users.js";
 
 const users = ref([]);
 const loading = ref(false);
@@ -9,7 +9,6 @@ export function useUsers() {
   async function loadUsers() {
     loading.value = true;
     error.value = "";
-
     try {
       users.value = await listUsers();
     } catch (err) {
@@ -20,10 +19,39 @@ export function useUsers() {
     }
   }
 
+  async function createUser(data) {
+    const result = await createUserAPI(data);
+    if (result?.usuario) {
+      users.value = [result.usuario, ...users.value];
+    }
+    return result;
+  }
+
+  async function updateUser(id, data) {
+    const result = await updateUserAPI(id, data);
+    if (result?.usuario) {
+      users.value = users.value.map((u) => (u.Id === id ? result.usuario : u));
+    }
+    return result;
+  }
+
+  async function toggleStatus(id) {
+    const result = await toggleUserStatusAPI(id);
+    if (result?.novoStatus !== undefined) {
+      users.value = users.value.map((u) =>
+        u.Id === id ? { ...u, Status: result.novoStatus } : u
+      );
+    }
+    return result;
+  }
+
   return {
     users,
     loading,
     error,
-    loadUsers
+    loadUsers,
+    createUser,
+    updateUser,
+    toggleStatus
   };
 }

+ 169 - 22
src/layout/LayoutSistema.vue

@@ -38,6 +38,8 @@ const logoutLoading = ref(false);
 
 const sidebarRecolhida = ref(false);
 const painelAberto = ref("");
+const menuUsuarioAberto = ref(false);
+const menuUsuarioRef = ref(null);
 const rotaAtiva = computed(() => String(route.name ?? ""));
 const usuarioNome = computed(() => user.value?.Nome || user.value?.Login || "Usuario");
 const usuarioSetor = computed(() => user.value?.Setor || user.value?.Nivel || "");
@@ -69,6 +71,24 @@ 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 tituloPagina = computed(() => {
   const nome = rotaAtiva.value;
   if (nome === "home") return "Nova conversa";
@@ -80,7 +100,7 @@ const tituloPagina = computed(() => {
 });
 
 const tituloModal = computed(() => {
-  if (painelAberto.value === "update") return "Update";
+  if (painelAberto.value === "update") return "Carregar documento";
   if (painelAberto.value === "documentos") return "Documentos";
   return "";
 });
@@ -98,6 +118,14 @@ function fecharModal() {
   painelAberto.value = "";
 }
 
+function alternarMenuUsuario() {
+  menuUsuarioAberto.value = !menuUsuarioAberto.value;
+}
+
+function fecharMenuUsuario() {
+  menuUsuarioAberto.value = false;
+}
+
 async function novaConversa() {
   newConversation();
   await router.push({ name: "home" });
@@ -160,16 +188,24 @@ watch(
 );
 
 function onKeyDown(e) {
-  if (!painelAberto.value) return;
-  if (e?.key === "Escape") fecharModal();
+  if (e?.key !== "Escape") return;
+  if (menuUsuarioAberto.value) fecharMenuUsuario();
+  else if (painelAberto.value) fecharModal();
+}
+
+function onDocumentClick(e) {
+  if (!menuUsuarioAberto.value) return;
+  if (!menuUsuarioRef.value?.contains(e.target)) fecharMenuUsuario();
 }
 
 onMounted(() => {
   window.addEventListener("keydown", onKeyDown);
+  document.addEventListener("click", onDocumentClick);
 });
 
 onBeforeUnmount(() => {
   window.removeEventListener("keydown", onKeyDown);
+  document.removeEventListener("click", onDocumentClick);
 });
 
 let overflowAntes = "";
@@ -203,7 +239,7 @@ watch(
       />
 
       <header
-        class="col-span-full row-start-1 relative z-10 grid grid-cols-[var(--layout-sidebar-width)_1fr] items-center gap-[18px] border-b border-gray-200 bg-primary px-[18px] py-[14px] text-primary-foreground dark:border-gray-700 dark:bg-primary max-[980px]:flex"
+        class="col-span-full row-start-1 relative z-30 grid grid-cols-[var(--layout-sidebar-width)_1fr] items-center gap-[18px] border-b border-gray-200 bg-primary px-[18px] py-[14px] text-primary-foreground dark:border-gray-700 dark:bg-primary max-[980px]:flex"
       >
         <div class="min-w-0 overflow-hidden">
           <div
@@ -225,28 +261,139 @@ watch(
           <div class="min-w-0">
             <div class="truncate text-base font-medium">{{ tituloPagina }}</div>
           </div>
-          <div class="flex items-center gap-3">
-            <div class="hidden text-right md:block">
-              <div class="text-sm font-semibold leading-tight">
-                {{ usuarioNome }}
-              </div>
-             
-            </div>
+          <div class="flex items-center gap-2">
             <BotaoAlterarTema />
-            <button
-              type="button"
-              class="rounded-lg !border-primary !bg-none !bg-primary px-3 py-2 text-sm font-medium !text-primary-foreground !transform-none transition-colors hover:!transform-none hover:!border-[var(--primary-600)] hover:!bg-[var(--primary-600)]"
-              :disabled="logoutLoading"
-              @click="encerrarSessao"
-            >
-              {{ logoutLoading ? "Saindo..." : "Sair" }}
-            </button>
+            <div ref="menuUsuarioRef" class="relative">
+              <button
+                type="button"
+                class="flex items-center gap-2.5 rounded-xl px-1.5 py-1.5 !text-primary-foreground !border-0 !bg-none !bg-transparent !transform-none motion-safe:transition-colors hover:!bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background md:pr-2.5"
+                :aria-expanded="menuUsuarioAberto"
+                aria-haspopup="menu"
+                aria-label="Menu do usuário"
+                @click="alternarMenuUsuario"
+              >
+                <div
+                  class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white ring-1 ring-white/20"
+                  :class="avatarCor"
+                >
+                  {{ avatarIniciais }}
+                </div>
+                <div class="hidden min-w-0 text-left md:block">
+                  <div class="truncate text-sm font-semibold leading-tight">{{ usuarioNome }}</div>
+                  <div
+                    v-if="usuarioSetor"
+                    class="truncate text-xs text-primary-foreground/70 leading-tight"
+                  >
+                    {{ usuarioSetor }}
+                  </div>
+                </div>
+                <svg
+                  class="hidden h-4 w-4 shrink-0 text-primary-foreground/70 transition-transform md:block"
+                  :class="menuUsuarioAberto ? 'rotate-180' : ''"
+                  viewBox="0 0 24 24"
+                  fill="none"
+                  stroke="currentColor"
+                  stroke-width="1.8"
+                >
+                  <path
+                    stroke-linecap="round"
+                    stroke-linejoin="round"
+                    d="m6 9 6 6 6-6"
+                  />
+                </svg>
+              </button>
+
+              <transition
+                enter-active-class="transition duration-150 ease-out"
+                enter-from-class="opacity-0 -translate-y-1 scale-95"
+                enter-to-class="opacity-100 translate-y-0 scale-100"
+                leave-active-class="transition duration-100 ease-in"
+                leave-from-class="opacity-100 translate-y-0 scale-100"
+                leave-to-class="opacity-0 -translate-y-1 scale-95"
+              >
+                <div
+                  v-if="menuUsuarioAberto"
+                  role="menu"
+                  class="absolute right-0 top-full z-20 mt-2 w-60 origin-top-right overflow-hidden rounded-xl border border-border bg-background text-foreground shadow-lg"
+                >
+                  <div class="flex items-center gap-3 border-b border-border px-3 py-3">
+                    <div
+                      class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white"
+                      :class="avatarCor"
+                    >
+                      {{ avatarIniciais }}
+                    </div>
+                    <div class="min-w-0">
+                      <div class="truncate text-sm font-semibold leading-tight">{{ usuarioNome }}</div>
+                      <div
+                        v-if="usuarioSetor"
+                        class="truncate text-xs text-foreground/60 leading-tight"
+                      >
+                        {{ usuarioSetor }}
+                      </div>
+                    </div>
+                  </div>
+
+                  <div class="p-1.5">
+                    <button
+                      type="button"
+                      role="menuitem"
+                      class="flex w-full items-center gap-2.5 rounded-lg !border-0 !bg-none !bg-transparent px-2.5 py-2 text-left text-sm font-medium !text-foreground !transform-none motion-safe:transition-colors hover:!bg-black/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:hover:!bg-white/10"
+                      @click="fecharMenuUsuario(); irPara('configuracoes')"
+                    >
+                      <svg
+                        class="h-5 w-5 shrink-0 text-foreground/60"
+                        viewBox="0 0 24 24"
+                        fill="none"
+                        stroke="currentColor"
+                        stroke-width="1.8"
+                      >
+                        <path
+                          stroke-linecap="round"
+                          stroke-linejoin="round"
+                          d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 0 1 1.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.894.149c-.424.07-.764.383-.929.78-.165.398-.143.854.107 1.204l.527.738c.32.447.27 1.06-.12 1.45l-.774.773a1.125 1.125 0 0 1-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.398.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.27-1.45-.12l-.773-.774a1.125 1.125 0 0 1-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.108-1.204l-.526-.738a1.125 1.125 0 0 1 .12-1.45l.773-.773a1.125 1.125 0 0 1 1.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894Z"
+                        />
+                        <path
+                          stroke-linecap="round"
+                          stroke-linejoin="round"
+                          d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
+                        />
+                      </svg>
+                      <span>Configurações</span>
+                    </button>
+
+                    <button
+                      type="button"
+                      role="menuitem"
+                      class="flex w-full items-center gap-2.5 rounded-lg !border-0 !bg-none !bg-transparent px-2.5 py-2 text-left text-sm font-medium !text-red-600 !transform-none motion-safe:transition-colors hover:!bg-red-500/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 dark:!text-red-400"
+                      :disabled="logoutLoading"
+                      @click="encerrarSessao"
+                    >
+                      <svg
+                        class="h-5 w-5 shrink-0"
+                        viewBox="0 0 24 24"
+                        fill="none"
+                        stroke="currentColor"
+                        stroke-width="1.8"
+                      >
+                        <path
+                          stroke-linecap="round"
+                          stroke-linejoin="round"
+                          d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15m3 0 3-3m0 0-3-3m3 3H9"
+                        />
+                      </svg>
+                      <span>{{ logoutLoading ? "Saindo..." : "Sair" }}</span>
+                    </button>
+                  </div>
+                </div>
+              </transition>
+            </div>
           </div>
         </div>
       </header>
 
       <aside
-        class="col-start-1 row-start-2 relative z-10 flex min-h-0 min-w-0 flex-col gap-3 overflow-hidden border-r border-gray-200 bg-transparent p-0 dark:border-gray-700 max-[980px]:row-start-3 max-[980px]:border-r-0 max-[980px]:border-t"
+        class="col-start-1 row-start-2 relative z-10 flex min-h-0 min-w-0 flex-col gap-3 overflow-hidden border-r border-gray-200 bg-transparent p-2 dark:border-gray-700 max-[980px]:row-start-3 max-[980px]:border-r-0 max-[980px]:border-t"
       >
         <div
           class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-[28px] border border-gray-200 bg-white/70 p-3 shadow-sm backdrop-blur-md dark:border-white/10 dark:bg-gradient-to-b dark:from-[#101827] dark:to-[#070b16] dark:shadow-[0_18px_50px_rgba(0,0,0,0.35)]"
@@ -412,7 +559,7 @@ watch(
                   d="M4 21h16"
                 />
               </svg>
-              <span :class="classeRotulo">Update</span>
+              <span :class="classeRotulo">Carregar</span>
             </button>
 
             <button
@@ -571,7 +718,7 @@ watch(
       <main
         class="col-start-2 row-start-2 relative z-10 flex min-h-0 min-w-0 flex-col overflow-hidden p-0 max-[980px]:col-start-1 max-[980px]:row-start-2"
       >
-        <div class="flex-1 min-h-0 overflow-auto bg-black/10 p-4 md:p-6">
+        <div class="flex-1 min-h-0 overflow-auto p-4 md:p-6">
           <slot />
         </div>
       </main>

+ 18 - 16
src/styles/style.css

@@ -77,24 +77,26 @@ body {
   font-size: 12px;
 }
 
-button {
-  border: 1px solid var(--btn-border);
-  background: linear-gradient(180deg, var(--btn-bg-1), var(--btn-bg-2));
-  color: var(--text);
-  padding: 10px 12px;
-  border-radius: 10px;
-  cursor: pointer;
-  transition: transform 120ms ease, border-color 120ms ease;
-}
+@layer base {
+  button {
+    border: 1px solid var(--btn-border);
+    background: linear-gradient(180deg, var(--btn-bg-1), var(--btn-bg-2));
+    color: var(--text);
+    padding: 10px 12px;
+    border-radius: 10px;
+    cursor: pointer;
+    transition: transform 120ms ease, border-color 120ms ease;
+  }
 
-button:hover:not(:disabled) {
-  transform: translateY(-1px);
-  border-color: var(--ring);
-}
+  button:hover:not(:disabled) {
+    transform: translateY(-1px);
+    border-color: var(--ring);
+  }
 
-button:disabled {
-  opacity: 0.5;
-  cursor: not-allowed;
+  button:disabled {
+    opacity: 0.5;
+    cursor: not-allowed;
+  }
 }
 
 input,

+ 14 - 3
src/views/auth/LoginView.vue

@@ -1,5 +1,5 @@
 <script setup>
-import { computed, ref } from "vue";
+import { computed, ref, watch } from "vue";
 import { useRoute, useRouter } from "vue-router";
 import BotaoAlterarTema from "../../components/BotaoAlterarTema.vue";
 import { useAuth } from "../../composables/useAuth.js";
@@ -20,6 +20,10 @@ const canSubmit = computed(() => {
   return Boolean(form.value.login.trim() && form.value.senha);
 });
 
+watch(() => [form.value.login, form.value.senha], () => {
+  if (error.value) error.value = "";
+});
+
 async function onSubmit() {
   if (!canSubmit.value || loading.value) return;
 
@@ -28,7 +32,8 @@ async function onSubmit() {
 
   try {
     await login(form.value);
-    const redirect = typeof route.query.redirect === "string" ? route.query.redirect : "/";
+    const raw = typeof route.query.redirect === "string" ? route.query.redirect : "";
+    const redirect = raw.startsWith("/") && !raw.startsWith("//") ? raw : "/";
     await router.replace(redirect);
   } catch (err) {
     error.value = err?.message || "Falha ao realizar login.";
@@ -75,8 +80,11 @@ async function onSubmit() {
             <input
               v-model="form.login"
               type="text"
+              name="username"
               autocomplete="username"
               placeholder="Digite seu login"
+              required
+              aria-required="true"
             />
           </label>
 
@@ -85,8 +93,11 @@ async function onSubmit() {
             <input
               v-model="form.senha"
               type="password"
+              name="password"
               autocomplete="current-password"
               placeholder="Digite sua senha"
+              required
+              aria-required="true"
             />
           </label>
 
@@ -108,7 +119,7 @@ async function onSubmit() {
 
           <button
             type="submit"
-            class="mt-2 inline-flex items-center justify-center 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)]"
+            class="mt-2 inline-flex items-center justify-center 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"
             :disabled="!canSubmit || loading"
           >
             {{ loading ? "Entrando..." : "Entrar" }}

+ 63 - 126
src/views/configuracoes/ConfiguracoesView.vue

@@ -1,6 +1,7 @@
 <script setup>
 import { useRouter } from "vue-router";
 import LayoutSistema from "../../layout/LayoutSistema.vue";
+import BotaoAlterarTema from "../../components/BotaoAlterarTema.vue";
 
 const router = useRouter();
 
@@ -12,160 +13,96 @@ function irParaUsuarios() {
 <template>
   <LayoutSistema>
     <div
-      class="min-h-[calc(100vh-var(--layout-header-height))] flex items-start justify-center pt-8"
+      class="min-h-[calc(100vh-var(--layout-header-height))] flex items-start justify-center px-4 pt-8 pb-12"
     >
-      <div class="w-full max-w-[980px] space-y-6">
+      <div class="w-full max-w-[640px] space-y-6">
         <section
           class="rounded-2xl border border-gray-200 bg-gray-100/50 p-6 shadow-sm backdrop-blur-md dark:border-gray-700 dark:bg-secondary/50"
         >
-          <div class="flex items-start gap-4">
+          <div class="flex items-center gap-4">
             <div
-              class="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary dark:bg-primary/20"
+              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-6 w-6"
-                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 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>
             </div>
             <div class="min-w-0">
-              <h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
+              <h2 class="text-base font-semibold text-gray-900 dark:text-gray-100">
                 Configurações do Sistema
               </h2>
-              <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
-                Gerencie usuários e algumas preferências da plataforma.
+              <p class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">
+                Gerencie usuários e preferências da plataforma.
               </p>
             </div>
           </div>
         </section>
 
         <section>
-          <div
-            class="mb-3 text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400"
-          >
+          <div class="mb-3 text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
             Usuários & Acesso
           </div>
 
           <button
             type="button"
-            class="grid w-full max-w-[620px] grid-cols-[auto_1fr_auto] items-center gap-4 rounded-2xl border border-gray-200 bg-background/70 p-5 text-left shadow-sm transition-colors hover:border-primary/40 dark:border-gray-700 dark:bg-background/20 dark:hover:border-primary/40"
+            class="w-full rounded-2xl border border-gray-200 !bg-none bg-background/70 p-5 text-left shadow-sm dark:border-gray-700 dark:bg-background/20"
             @click="irParaUsuarios"
           >
-            <div
-              class="flex h-14 w-14 items-center justify-center rounded-2xl bg-[linear-gradient(135deg,var(--primary-500),#4f46e5)] text-white shadow-[0_10px_24px_rgba(37,99,235,0.28)]"
-            >
-              <svg
-                class="h-7 w-7"
-                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>
+            <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>
+                </div>
+                <div>
+                  <div class="text-sm font-semibold text-gray-900 dark:text-gray-100">Gerenciar Usuários</div>
+                  <div class="text-xs text-gray-500 dark:text-gray-400">Cadastre e gerencie usuários, perfis e níveis de acesso.</div>
+                </div>
+              </div>
+              <span class="inline-flex items-center justify-center rounded-md !p-2 !bg-none !bg-transparent !border-0 !text-current hover:!bg-black/10 dark:hover:!bg-white/10 motion-safe:transition-colors">
+                <svg class="h-5 w-5 shrink-0 text-gray-400 dark:text-gray-500" 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>
+              </span>
             </div>
+          </button>
+        </section>
 
-            <div class="min-w-0">
-              <div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
-                Usuários
-              </div>
-              <div class="mt-1 text-sm text-gray-500 dark:text-gray-400">
-                Cadastre e gerencie usuários, perfis e níveis de acesso ao
-                sistema.
+        <section>
+          <div class="mb-3 text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
+            Aparência
+          </div>
+
+          <div class="w-full rounded-2xl border border-gray-200 bg-background/70 p-5 shadow-sm dark:border-gray-700 dark:bg-background/20">
+            <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>
+                </div>
+                <div>
+                  <div class="text-sm font-semibold text-gray-900 dark:text-gray-100">Tema</div>
+                  <div class="text-xs text-gray-500 dark:text-gray-400">Alternar entre tema claro e escuro</div>
+                </div>
               </div>
+              <BotaoAlterarTema />
             </div>
-
-            <svg
-              class="h-5 w-5 text-gray-400 dark:text-gray-500"
-              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>
-          </button>
+          </div>
         </section>
       </div>
     </div>

+ 40 - 11
src/views/conversas/ConversasHistoricoView.vue

@@ -13,7 +13,10 @@ const items = computed(() =>
 
 function formatarData(ts) {
   try {
-    return new Date(Number(ts ?? 0)).toLocaleString();
+    return new Date(Number(ts ?? 0)).toLocaleString("pt-BR", {
+      day: "2-digit", month: "2-digit", year: "numeric",
+      hour: "2-digit", minute: "2-digit"
+    });
   } catch {
     return "";
   }
@@ -31,23 +34,44 @@ function excluirConversa(id) {
 
 <template>
   <LayoutSistema>
-    <section class="rounded-2xl border border-gray-200 bg-gray-100/50 p-4 shadow-sm backdrop-blur-md dark:border-gray-700 dark:bg-secondary/50">
-      <h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Histórico</h2>
-      <div class="mt-2 text-sm text-gray-500 dark:text-gray-400">{{ items.length }} conversas</div>
+    <section class="rounded-2xl border border-gray-200 bg-gray-100/50 p-6 shadow-sm backdrop-blur-md dark:border-gray-700 dark:bg-secondary/50">
+      <div class="flex items-start justify-between gap-4">
+        <div>
+          <h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Histórico</h2>
+          <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ items.length }} conversa{{ items.length === 1 ? '' : 's' }}</p>
+        </div>
+      </div>
+
+      <div v-if="!items.length" class="mt-8 flex flex-col items-center gap-3 py-8 text-center">
+        <div class="flex h-14 w-14 items-center justify-center rounded-2xl border border-dashed border-gray-300 dark:border-gray-600">
+          <svg class="h-6 w-6 text-gray-400 dark:text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
+            <path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6l4 2" />
+            <path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 1 1-3.2-6.9" />
+          </svg>
+        </div>
+        <div class="text-sm font-medium text-gray-500 dark:text-gray-400">Nenhuma conversa ainda</div>
+        <div class="text-xs text-gray-400 dark:text-gray-500">Suas conversas aparecerão aqui após a primeira mensagem.</div>
+      </div>
 
-      <div class="mt-3 grid gap-2">
+      <div v-else class="mt-4 grid gap-2">
         <div v-for="c in items" :key="c.id" class="grid grid-cols-[1fr_auto] items-stretch gap-2">
           <button
             type="button"
-            class="rounded-xl border bg-background/60 p-3 text-left transition-colors dark:bg-background/10"
+            class="rounded-xl border p-3 text-left transition-colors"
             :class="
               c.id === activeConversationId
-                ? 'border-gray-500 dark:border-gray-400'
-                : 'border-gray-200 hover:border-gray-500 dark:border-gray-700 dark:hover:border-gray-400'
+                ? 'border-primary/40 bg-primary/5 dark:border-primary/30 dark:bg-primary/10'
+                : 'border-gray-200 bg-background/60 hover:border-gray-400 dark:border-gray-700 dark:bg-background/10 dark:hover:border-gray-500'
             "
             @click="abrirConversa(c.id)"
           >
-            <div class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ c.title || 'Nova conversa' }}</div>
+            <div class="flex items-center gap-2">
+              <div
+                v-if="c.id === activeConversationId"
+                class="h-1.5 w-1.5 shrink-0 rounded-full bg-primary"
+              />
+              <div class="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">{{ c.title || 'Nova conversa' }}</div>
+            </div>
             <div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
               {{ formatarData(c.updatedAt) }} · {{ c.messageCount ?? 0 }} msgs
             </div>
@@ -55,10 +79,15 @@ function excluirConversa(id) {
 
           <button
             type="button"
-            class="rounded-xl border border-gray-200 bg-background/60 px-3 py-2 text-sm text-gray-500 transition-colors hover:border-gray-500 dark:border-gray-700 dark:bg-background/10 dark:text-gray-400 dark:hover:border-gray-400"
+            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"
             @click="excluirConversa(c.id)"
           >
-            Excluir
+            <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>
           </button>
         </div>
       </div>

+ 22 - 7
src/views/pagina-inicial/PaginaInicialView.vue

@@ -24,25 +24,40 @@ async function onLandingSend() {
 
 <template>
   <LayoutSistema>
-    <div v-if="isLanding" class="min-h-[calc(100vh-var(--layout-header-height))] flex items-end justify-center pb-12 md:pb-16">
-      <div class="w-full max-w-[760px]">
+    <div v-if="isLanding" class="min-h-[calc(100vh-var(--layout-header-height))] flex items-center justify-center pb-12 md:pb-16">
+      <div class="w-full max-w-[720px]">
         <div class="text-3xl font-extrabold tracking-tight">Como posso ajudar?</div>
         <div class="mt-2 text-sm text-gray-500 dark:text-gray-400">
-          Digite sua pergunta no campo abaixo. Você também pode alimentar a base pela barra lateral.
+          Digite sua pergunta abaixo. Você também pode alimentar a base pela barra lateral.
         </div>
 
-        <div class="mt-5 p-4">
+        <div class="mt-6 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="landingDraft"
-            rows="2"
+            rows="3"
             placeholder="Digite sua pergunta..."
-            class="min-h-24 resize-none"
+            class="min-h-20 resize-none border-0 bg-transparent shadow-none focus:ring-0 !outline-none"
             style="resize: none"
             @keydown.enter.exact.prevent="onLandingSend"
           />
+          <div class="mt-3 flex items-center justify-between gap-3">
+            <span class="text-xs text-gray-400 dark:text-gray-500">Enter para enviar · Shift+Enter para nova linha</span>
+            <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"
+            >
+              <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>
+          </div>
         </div>
 
-        <div v-if="chatError" class="mt-2.5 text-sm text-gray-500 dark:text-gray-400">Erro: {{ chatError }}</div>
+        <div v-if="chatError" class="mt-3 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">
+          Erro: {{ chatError }}
+        </div>
       </div>
     </div>
 

+ 141 - 26
src/views/usuarios/UsuariosView.vue

@@ -1,11 +1,33 @@
 <script setup>
-import { computed, onMounted } from "vue";
+import { computed, onMounted, ref } from "vue";
 import LayoutSistema from "../../layout/LayoutSistema.vue";
+import ModalUsuario from "../../components/ModalUsuario.vue";
 import { useUsers } from "../../composables/useUsers.js";
 
-const { users, loading, error, loadUsers } = useUsers();
+const { users, loading, error, loadUsers, toggleStatus } = useUsers();
 
 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";
@@ -17,6 +39,32 @@ function classeStatus(status) {
     : "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;
+}
+
+function fecharModal() {
+  modalAberto.value = false;
+  usuarioEmEdicao.value = null;
+}
+
+async function alternarStatus(usuario) {
+  erroAcao.value = "";
+  try {
+    await toggleStatus(usuario.Id);
+  } catch (e) {
+    erroAcao.value = e?.message || "Erro ao alterar status.";
+  }
+}
+
 onMounted(async () => {
   await loadUsers();
 });
@@ -29,21 +77,38 @@ onMounted(async () => {
     >
       <div class="flex items-start justify-between gap-4">
         <div>
-          <h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
-            Usuários
-          </h2>
+          <h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Usuários</h2>
           <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
             Lista dos usuários cadastrados no sistema.
           </p>
         </div>
-        <div
-          class="rounded-xl border border-primary/15 bg-primary/10 px-3 py-2 text-sm font-medium text-primary dark:border-primary/20 dark:bg-primary/15"
-        >
-          {{ totalUsuarios }} usuario{{ totalUsuarios === 1 ? "" : "s" }}
+        <div class="flex items-center gap-3">
+          <div
+            class="rounded-xl border border-primary/15 bg-primary/10 px-3 py-2 text-sm font-medium text-primary dark:border-primary/20 dark:bg-primary/15"
+          >
+            {{ 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"
+          >
+            <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>
         </div>
       </div>
 
-      <div v-if="loading" class="mt-6 text-sm text-gray-500 dark:text-gray-400">
+      <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" />
+        </svg>
         Carregando usuários...
       </div>
 
@@ -56,9 +121,15 @@ onMounted(async () => {
 
       <div
         v-else-if="!users.length"
-        class="mt-6 rounded-xl border border-dashed border-gray-300 px-4 py-6 text-sm text-gray-500 dark:border-gray-600 dark:text-gray-400"
+        class="mt-6 flex flex-col items-center gap-3 py-8 text-center"
       >
-        Nenhum usuário encontrado.
+        <div class="flex h-14 w-14 items-center justify-center rounded-2xl border border-dashed border-gray-300 dark:border-gray-600">
+          <svg class="h-6 w-6 text-gray-400 dark:text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
+            <path stroke-linecap="round" stroke-linejoin="round" d="M16 19a4 4 0 0 0-8 0" />
+            <path stroke-linecap="round" stroke-linejoin="round" d="M12 12a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" />
+          </svg>
+        </div>
+        <div class="text-sm font-medium text-gray-500 dark:text-gray-400">Nenhum usuário encontrado.</div>
       </div>
 
       <div v-else class="mt-6 grid gap-3">
@@ -67,22 +138,35 @@ onMounted(async () => {
           :key="usuario.Id"
           class="rounded-2xl border border-gray-200 bg-background/70 p-4 shadow-sm dark:border-gray-700 dark:bg-background/20"
         >
-          <div
-            class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between"
-          >
-            <div class="min-w-0">
+          <div class="flex flex-col gap-3 md:flex-row md:items-start md:gap-4">
+            <div
+              class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white"
+              :class="avatarCor(usuario.Nome || usuario.Login)"
+            >
+              {{ avatarIniciais(usuario.Nome || usuario.Login) }}
+            </div>
+
+            <div class="min-w-0 flex-1">
               <div class="text-base font-semibold text-gray-900 dark:text-gray-100">
                 {{ usuario.Nome || usuario.Login || "Usuário sem nome" }}
               </div>
-              <div class="mt-1 text-sm text-gray-500 dark:text-gray-400">
-                Login: {{ usuario.Login || "-" }}
-              </div>
-              <div class="mt-1 text-sm text-gray-500 dark:text-gray-400">
-                E-mail: {{ usuario.Email || "-" }}
+              <div class="mt-1.5 flex flex-wrap gap-x-4 gap-y-1">
+                <div class="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400">
+                  <svg class="h-3.5 w-3.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
+                    <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
+                  </svg>
+                  <span>{{ usuario.Login || "-" }}</span>
+                </div>
+                <div class="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400">
+                  <svg class="h-3.5 w-3.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
+                    <path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" />
+                  </svg>
+                  <span>{{ usuario.Email || "-" }}</span>
+                </div>
               </div>
             </div>
 
-            <div class="flex flex-wrap gap-2">
+            <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)"
@@ -90,19 +174,50 @@ onMounted(async () => {
                 {{ formatarStatus(usuario.Status) }}
               </span>
               <span
-                class="inline-flex items-center rounded-full border border-gray-200 bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-600 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300"
+                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"
               >
-                Nivel: {{ usuario.Nivel ?? "-" }}
+                Nível {{ usuario.Nivel ?? "-" }}
               </span>
               <span
-                class="inline-flex items-center rounded-full border border-gray-200 bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-600 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300"
+                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"
               >
-                Setor: {{ usuario.Setor ?? "-" }}
+                {{ 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"
+                title="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'
+                "
+                :title="String(usuario.Status) === '1' ? 'Desativar usuário' : 'Ativar usuário'"
+                @click="alternarStatus(usuario)"
+              >
+                {{ String(usuario.Status) === "1" ? "Desativar" : "Ativar" }}
+              </button>
             </div>
           </div>
         </article>
       </div>
     </section>
+
+    <ModalUsuario
+      v-if="modalAberto"
+      :usuario="usuarioEmEdicao"
+      @close="fecharModal"
+    />
   </LayoutSistema>
 </template>