leonardo há 1 semana atrás
pai
commit
246b65e76a

+ 4 - 0
src/App.vue

@@ -1,7 +1,11 @@
 <script setup>
 import { RouterView } from "vue-router";
+import RouteProgressBar from "./components/RouteProgressBar.vue";
+import ChunkErrorBanner from "./components/ChunkErrorBanner.vue";
 </script>
 
 <template>
+  <RouteProgressBar />
+  <ChunkErrorBanner />
   <RouterView />
 </template>

+ 1 - 2
src/api/atendimentos.js

@@ -25,13 +25,12 @@ export function avaliarAtendimento(id) {
   return apiFetch(`/api/atendimentos/${id}/avaliar`, { method: "POST", body: {} });
 }
 
-export function listarAvaliacoes({ page = 1, pageSize = 20, setor, resolvido, sentimento, statusCancelamento, scoreMax, busca, ordenacao, dataInicio, dataFim } = {}) {
+export function listarAvaliacoes({ page = 1, pageSize = 20, setor, resolvido, sentimento, statusCancelamento, busca, ordenacao, dataInicio, dataFim } = {}) {
   const query = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
   if (setor) query.set("setor", setor);
   if (resolvido) query.set("resolvido", resolvido);
   if (sentimento) query.set("sentimento", sentimento);
   if (statusCancelamento) query.set("statusCancelamento", statusCancelamento);
-  if (scoreMax !== undefined) query.set("scoreMax", String(scoreMax));
   if (busca) query.set("busca", busca);
   if (ordenacao === "asc") query.set("ordenacao", ordenacao);
   if (dataInicio) query.set("dataInicio", dataInicio);

+ 17 - 0
src/api/client.js

@@ -22,6 +22,23 @@ export function categorizeHttpStatus(status) {
   return "server";
 }
 
+const FRIENDLY_ERROR_MESSAGES = {
+  network: "Não foi possível conectar ao servidor. Verifique sua conexão.",
+  session_expired: "Sua sessão expirou. Faça login novamente.",
+};
+
+/**
+ * Traduz erros conhecidos (ApiError de rede, erro de rede do upload via XHR sem
+ * .category) para uma mensagem amigável. Cai no fallback/e.message original nos
+ * demais casos, já que mensagens vindas do backend via extractErrorMessage já
+ * são amigáveis.
+ */
+export function friendlyErrorMessage(e, fallback = "Ocorreu um erro. Tente novamente.") {
+  if (e?.category === "network" || e?.message === "network_error") return FRIENDLY_ERROR_MESSAGES.network;
+  if (e?.message === "session_expired") return FRIENDLY_ERROR_MESSAGES.session_expired;
+  return e?.message || fallback;
+}
+
 let _accessToken = null;
 let _refreshFn = null;
 let _authErrorFn = null;

+ 16 - 0
src/components/ChunkErrorBanner.vue

@@ -0,0 +1,16 @@
+<script setup>
+import { chunkLoadError } from "../composables/useRouteLoading.js";
+</script>
+
+<template>
+  <div
+    v-if="chunkLoadError"
+    class="fixed inset-x-0 top-0 z-[9999] flex items-center justify-center gap-3 bg-amber-500 px-4 py-2 text-sm font-medium text-white"
+    role="alert"
+  >
+    <span>Uma nova versão do sistema está disponível.</span>
+    <button type="button" class="underline underline-offset-2" @click="() => window.location.reload()">
+      Recarregar agora
+    </button>
+  </div>
+</template>

+ 2 - 1
src/components/DocumentList.vue

@@ -1,6 +1,7 @@
 <script setup>
 import { ref, computed } from "vue";
 import { deleteDocumentsBySource } from "../api/chat.js";
+import { friendlyErrorMessage } from "../api/client.js";
 import { useToast } from "../composables/useToast.js";
 import { useValorDebounced } from "../composables/useValorDebounced.js";
 import SkeletonDocumentCard from "./skeletons/SkeletonDocumentCard.vue";
@@ -55,7 +56,7 @@ async function onConfirmDelete(source) {
     toast.sucesso(`Documento "${source}" excluído com sucesso.`);
     emit("refresh");
   } catch (e) {
-    toast.erro(`Erro ao excluir: ${e?.message || "erro desconhecido"}`);
+    toast.erro(`Erro ao excluir: ${friendlyErrorMessage(e, "erro desconhecido")}`);
   } finally {
     deleting.value.delete(source);
   }

+ 18 - 1
src/components/DocumentUpload.vue

@@ -1,6 +1,7 @@
 <script setup>
 import { ref, computed } from "vue";
 import { ingestFile, ingestDocuments, ingestUrl } from "../api/chat.js";
+import { friendlyErrorMessage } from "../api/client.js";
 import { useToast } from "../composables/useToast.js";
 import BaseButton from "./base/BaseButton.vue";
 import BaseIcon from "./base/BaseIcon.vue";
@@ -24,6 +25,17 @@ function formatSize(bytes) {
   return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
 }
 
+// Mesmo limite do multer em backend/src/routes/Ingest.Rotas.js
+const MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024;
+
+function validarTamanho(f) {
+  if (f.size > MAX_FILE_SIZE_BYTES) {
+    toast.erro(`Arquivo muito grande (${formatSize(f.size)}). O limite é ${formatSize(MAX_FILE_SIZE_BYTES)}.`);
+    return false;
+  }
+  return true;
+}
+
 function getFileIcon(f) {
   const ext = f?.name?.split(".").pop()?.toLowerCase() ?? "";
   if (ext === "pdf") return "pdf";
@@ -34,6 +46,10 @@ function getFileIcon(f) {
 function onFileInputChange(e) {
   const f = e.target.files?.[0];
   if (!f) return;
+  if (!validarTamanho(f)) {
+    e.target.value = "";
+    return;
+  }
   file.value = f;
 }
 
@@ -41,6 +57,7 @@ function onDrop(e) {
   dragOver.value = false;
   const f = e.dataTransfer?.files?.[0];
   if (!f) return;
+  if (!validarTamanho(f)) return;
   file.value = f;
 }
 
@@ -80,7 +97,7 @@ async function onIngest() {
     emit("ingested", result);
   } catch (e) {
     uploadProgress.value = 0;
-    toast.erro(e?.message || "Erro ao ingerir documento.");
+    toast.erro(friendlyErrorMessage(e, "Erro ao ingerir documento."));
   } finally {
     loading.value = false;
   }

+ 2 - 1
src/components/ModalRedefinirSenha.vue

@@ -55,6 +55,7 @@
 <script setup>
 import { ref } from "vue";
 import { alterarSenhaUsuario } from "../api/users.js";
+import { friendlyErrorMessage } from "../api/client.js";
 import BaseButton from "./base/BaseButton.vue";
 import BaseModal from "./base/BaseModal.vue";
 
@@ -87,7 +88,7 @@ async function onSubmit() {
     await alterarSenhaUsuario(props.usuario.Id, { senhaNova: senhaNova.value });
     emit("saved");
   } catch (e) {
-    erroForm.value = e?.message || "Erro ao redefinir a senha.";
+    erroForm.value = friendlyErrorMessage(e, "Erro ao redefinir a senha.");
   } finally {
     salvando.value = false;
   }

+ 2 - 1
src/components/ModalUsuario.vue

@@ -59,6 +59,7 @@
 <script setup>
 import { ref, watch } from "vue";
 import { useUsers } from "../composables/useUsers.js";
+import { friendlyErrorMessage } from "../api/client.js";
 import BaseButton from "./base/BaseButton.vue";
 import BaseModal from "./base/BaseModal.vue";
 
@@ -133,7 +134,7 @@ async function onSubmit() {
       emit("saved", result?.usuario);
     }
   } catch (e) {
-    erroForm.value = e?.message || "Erro ao salvar usuário.";
+    erroForm.value = friendlyErrorMessage(e, "Erro ao salvar usuário.");
   } finally {
     salvando.value = false;
   }

+ 28 - 0
src/components/RouteProgressBar.vue

@@ -0,0 +1,28 @@
+<script setup>
+import { isNavigating } from "../composables/useRouteLoading.js";
+</script>
+
+<template>
+  <div v-if="isNavigating" class="route-progress-bar" role="status" aria-label="Carregando página" />
+</template>
+
+<style scoped>
+.route-progress-bar {
+  position: fixed;
+  inset: 0 0 auto 0;
+  height: 3px;
+  z-index: 9999;
+  background: linear-gradient(90deg, transparent, var(--color-primary), transparent);
+  background-size: 200% 100%;
+  animation: route-progress-slide 1.1s ease-in-out infinite;
+}
+
+@keyframes route-progress-slide {
+  0% {
+    background-position: 200% 0;
+  }
+  100% {
+    background-position: -200% 0;
+  }
+}
+</style>

+ 2 - 0
src/composables/useConversas.js

@@ -156,6 +156,8 @@ export function useConversas() {
       if (conversaAtivaId.value === id) mensagensLoading.value = false;
     }
 
+    if (conversaAtivaId.value !== id) return;
+
     pollTimer = setInterval(() => {
       if (document.visibilityState === "visible") buscarNovasMensagens();
     }, POLL_INTERVAL_MS);

+ 20 - 0
src/composables/useRouteLoading.js

@@ -0,0 +1,20 @@
+import { ref } from "vue";
+
+export const isNavigating = ref(false);
+export const chunkLoadError = ref(false);
+
+const SHOW_DELAY_MS = 150; // evita flash em navegações instantâneas (chunk já em cache)
+
+let showTimer = null;
+
+export function startNavigationLoading() {
+  clearTimeout(showTimer);
+  showTimer = setTimeout(() => {
+    isNavigating.value = true;
+  }, SHOW_DELAY_MS);
+}
+
+export function stopNavigationLoading() {
+  clearTimeout(showTimer);
+  isNavigating.value = false;
+}

+ 45 - 11
src/composables/useSidebar.js

@@ -1,24 +1,29 @@
-import { computed, ref, watch } from "vue";
+import { computed, readonly, ref, watch } from "vue";
 
 let singleton;
 
+const CHAVE_STORAGE = "layout.sidebarRecolhida";
+const LARGURA_SIDEBAR_RECOLHIDA = "84px";
+const LARGURA_SIDEBAR_EXPANDIDA = "280px";
+
 export function useSidebar() {
   if (singleton) return singleton;
 
   const sidebarRecolhida = ref(false);
 
   const layoutStyle = computed(() => ({
-    "--layout-sidebar-width": sidebarRecolhida.value ? "84px" : "280px",
+    "--layout-sidebar-width": sidebarRecolhida.value
+     ? LARGURA_SIDEBAR_RECOLHIDA
+     : LARGURA_SIDEBAR_EXPANDIDA,
   }));
 
   const classeItemSidebarBase = computed(() =>
     sidebarRecolhida.value
-      ? "w-full flex items-center justify-center gap-0 rounded-xl p-2.5 text-sm font-medium transition-colors !bg-none !bg-transparent !border-0 !transform-none hover:!transform-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
-      : "w-full flex items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm font-medium transition-colors !bg-none !bg-transparent !border-0 !transform-none hover:!transform-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
+      ? "w-full flex items-center justify-center gap-0 rounded-xl p-2.5 text-sm font-medium transition-all duration-200 ease-in-out !bg-none !bg-transparent !border-0 !transform-none hover:!transform-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
+      : "w-full flex items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm font-medium transition-all duration-200 ease-in-out !bg-none !bg-transparent !border-0 !transform-none hover:!transform-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
   );
 
-  const classeItemSidebarAtivo =
-    "text-gray-900 !bg-gray-200/70 hover:!bg-gray-300/70 dark:text-white dark:!bg-white/10 dark:hover:!bg-white/10";
+  const classeItemSidebarAtivo = "text-gray-900 dark:text-white";
   const classeItemSidebarInativo =
     "text-gray-600 hover:text-gray-900 hover:!bg-gray-300/70 dark:text-white/70 dark:hover:text-white dark:hover:!bg-white/10";
   const classeIconAtivo = "text-gray-900 dark:text-white";
@@ -36,20 +41,49 @@ export function useSidebar() {
     return ["h-5 w-5 shrink-0", ativo ? classeIconAtivo : classeIconInativo];
   }
 
-  const armazenado = window.localStorage.getItem("layout.sidebarRecolhida");
-  if (armazenado != null) sidebarRecolhida.value = armazenado === "1" || armazenado === "true";
+  function alterarSidebar(){
+    sidebarRecolhida.value = !sidebarRecolhida.value;
+  }
 
-  watch(
+  function definirSidebar(valor){
+    sidebarRecolhida.value = Boolean(valor); 
+  }
+
+  let armazenado = null;
+  try {
+    armazenado = window.localStorage.getItem(CHAVE_STORAGE);
+  } catch (erro) {
+    console.warn("Não foi possivel ler o estado da sidebar do localStorage", erro);
+  }
+  if( armazenado != null) {
+    sidebarRecolhida.value = armazenado === "1" || armazenado === "true";
+  }
+
+  watch (
     () => sidebarRecolhida.value,
-    (v) => { window.localStorage.setItem("layout.sidebarRecolhida", v ? "1" : "0"); },
+    (v) => {
+      try {
+        window.localStorage.setItem(CHAVE_STORAGE, v ? "1" : "0");
+      } catch (erro) {
+        console.warn("Não foi possivel salvar o estado da sidebar no localStorage:", erro);
+      }
+    },
   );
 
   singleton = {
-    sidebarRecolhida,
+    sidebarRecolhida : readonly(sidebarRecolhida),
     layoutStyle,
     classeRotulo,
     classeItemSidebar,
     classeIcon,
+    alterarSidebar,
+    definirSidebar,
   };
   return singleton;
 }
+
+if (import.meta.hot) {
+  import.meta.hot.dispose(() => {
+    singleton = undefined;
+  });
+}

+ 3 - 11
src/layout/HeaderSistema.vue

@@ -6,14 +6,12 @@ import BotaoAlterarTema from "../components/BotaoAlterarTema.vue";
 import BaseIcon from "../components/base/BaseIcon.vue";
 import { avatarIniciais as _avatarIniciais, avatarCor as _avatarCor } from "../utils/avatar.js";
 import { useAuth } from "../composables/useAuth.js";
-import { useSidebar } from "../composables/useSidebar.js";
 import { useMenuUsuario } from "../composables/useMenuUsuario.js";
 
 const router = useRouter();
 const route = useRoute();
 
 const { user, logout } = useAuth();
-const { sidebarRecolhida } = useSidebar();
 const { menuUsuarioAberto, menuUsuarioRef, alternarMenuUsuario, fecharMenuUsuario } = useMenuUsuario();
 
 const logoutLoading = ref(false);
@@ -55,19 +53,13 @@ async function encerrarSessao() {
 
 <template>
   <header
-    class="col-span-full row-start-1 relative z-30 grid grid-cols-[var(--layout-sidebar-width)_1fr] items-center gap-4 border-b border-border bg-primary px-5 py-3.5 text-primary-foreground dark:bg-primary max-layout:flex transition-grid-cols"
+    class="col-span-full row-start-1 relative z-30 grid grid-cols-[280px_1fr] items-center gap-4 border-b border-border bg-primary px-5 py-3.5 text-primary-foreground dark:bg-primary max-layout:flex"
   >
     <div class="min-w-0 overflow-hidden">
-      <div
-        class="truncate whitespace-nowrap text-[16px] font-extrabold"
-        :class="sidebarRecolhida ? 'tracking-wide' : 'tracking-[0.14em]'"
-      >
+      <div class="truncate whitespace-nowrap text-[16px] font-extrabold tracking-[0.14em]">
         ORÁCULO
       </div>
-      <div
-        class="mt-1.5 truncate whitespace-nowrap text-xs text-primary-foreground/80"
-        :class="sidebarRecolhida ? 'invisible' : ''"
-      >
+      <div class="mt-1.5 truncate whitespace-nowrap text-xs text-primary-foreground/80">
         Assistente interno com base de conhecimento
       </div>
     </div>

+ 4 - 5
src/layout/ListaConversas.vue

@@ -61,7 +61,7 @@ async function abrirConversa(id) {
         tabindex="0"
         class="group relative w-full flex items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm transition-colors overflow-hidden cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
         :class="String(activeConversationId) === c.id
-          ? 'text-gray-900 bg-gray-200/70 dark:text-white dark:bg-white/10'
+          ? 'text-gray-900 hover:bg-gray-100/80 dark:text-white dark:hover:bg-white/10'
           : 'text-gray-600 hover:text-gray-900 hover:bg-gray-100/80 dark:text-white/70 dark:hover:text-white dark:hover:bg-white/10'"
         @click="editingConversaId !== c.id && abrirConversa(c.id)"
         @keydown.enter.prevent="editingConversaId !== c.id && abrirConversa(c.id)"
@@ -82,6 +82,7 @@ async function abrirConversa(id) {
               type="button"
               class="rounded p-0.5 text-gray-500 hover:text-gray-900 dark:text-white/50 dark:hover:text-white transition-colors"
               title="Confirmar"
+              aria-label="Confirmar"
               @click.stop="confirmarEdicao(c.id)"
             >
               <BaseIcon :icon="Check" class="h-3.5 w-3.5" stroke-width="2.5" />
@@ -90,6 +91,7 @@ async function abrirConversa(id) {
               type="button"
               class="rounded p-0.5 text-gray-500 hover:text-gray-900 dark:text-white/50 dark:hover:text-white transition-colors"
               title="Cancelar"
+              aria-label="Cancelar"
               @click.stop="cancelarEdicao"
             >
               <BaseIcon :icon="X" class="h-3.5 w-3.5" stroke-width="2.5" />
@@ -99,10 +101,7 @@ async function abrirConversa(id) {
         <template v-else>
           <span class="flex-1 min-w-0 truncate leading-snug" :title="c.title">{{ c.title }}</span>
           <div
-            class="absolute right-0 top-0 h-full flex items-center gap-0.5 pr-2 pl-16 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 max-whatsapp:opacity-100"
-            :class="String(activeConversationId) === c.id
-              ? 'bg-gradient-to-l from-gray-200 from-60% dark:from-abyss-800 to-transparent'
-              : 'bg-gradient-to-l from-gray-100 from-60% dark:from-abyss-900 to-transparent'"
+            class="absolute right-0 top-0 h-full flex items-center gap-0.5 pr-2 pl-16 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 max-whatsapp:opacity-100 bg-gradient-to-l from-gray-100 from-60% dark:from-abyss-900 to-transparent"
             @click.stop
             @keydown.stop
           >

+ 54 - 92
src/layout/SidebarSistema.vue

@@ -3,7 +3,6 @@ import { computed } from "vue";
 import { useRoute, useRouter } from "vue-router";
 import {
   ChevronLeft,
-  ChevronRight,
   Plus,
   History,
   Search,
@@ -26,12 +25,31 @@ const router = useRouter();
 const route = useRoute();
 
 const { activeConversationId, newConversation } = useChat();
-const { sidebarRecolhida, classeItemSidebar, classeIcon, classeRotulo } = useSidebar();
+const { sidebarRecolhida, classeItemSidebar, classeIcon, classeRotulo, alterarSidebar } = useSidebar();
 
 const { isAdmin } = useAuth();
 
 const rotaAtiva = computed(() => String(route.name ?? ""));
 
+function ariaAtual(ativo) {
+  return ativo ? "page" : undefined;
+}
+
+const itensChat = [
+  { rota: "conversas-historico", icon: History, label: "Histórico" },
+  { rota: "conversas-pesquisar", icon: Search, label: "Pesquisar" },
+  { rota: "conversas-whatsapp", icon: MessageCircle, label: "Atendimento WhatsApp" },
+];
+
+const itensBiblioteca = [
+  { rota: "documentos-carregar", icon: Upload, label: "Carregar documento" },
+  { rota: "documentos", icon: FileText, label: "Documentos" },
+  { rota: "avaliacoes", icon: Star, label: "Avaliações" },
+  { rota: "ranking-atendentes", icon: Trophy, label: "Ranking de atendentes" },
+  { rota: "atendimentos-nao-resolvidos", icon: TriangleAlert, label: "Não resolvidos" },
+  { rota: "golden-set", icon: CircleCheck, label: "Golden set" },
+];
+
 function irPara(nome) {
   router.push({ name: nome });
 }
@@ -44,7 +62,7 @@ async function novaConversa() {
 
 <template>
   <aside
-    class="col-start-1 row-start-2 relative z-10 flex min-h-0 min-w-0 flex-col overflow-hidden rounded-[28px] border border-gray-200 bg-white/70 p-3 shadow-sm backdrop-blur-md dark:border-white/10 dark:bg-abyss-900 dark:shadow-[0_18px_50px_rgba(0,0,0,0.35)] max-layout:row-start-3 max-layout:max-h-[45vh] max-layout:overflow-y-auto"
+    class="col-start-1 row-start-2 relative z-10 flex min-h-0 min-w-0 flex-col overflow-hidden rounded-[28px] border border-gray-200 bg-white/70 p-3 shadow-sm backdrop-blur-md dark:border-white/10 dark:bg-abyss-900 dark:shadow-[0_18px_50px_rgba(0,0,0,0.35)] max-layout:row-start-3 max-layout:max-h-[45vh] max-layout:overflow-y-auto max-layout:[mask-image:linear-gradient(to_bottom,black_calc(100%-24px),transparent_100%)]"
   >
     <div
       :class="
@@ -55,14 +73,18 @@ async function novaConversa() {
     >
       <button
         type="button"
-        class="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-gray-200 bg-white/60 text-gray-600 transition-colors hover:bg-white/80 hover:text-gray-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background dark:border-white/10 dark:bg-white/5 dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
+        class="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-gray-200 bg-white/60 text-gray-600 transition-all duration-200 ease-in-out hover:bg-white/80 hover:text-gray-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background active:scale-90 dark:border-white/10 dark:bg-white/5 dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
         :aria-label="
           sidebarRecolhida ? 'Expandir sidebar' : 'Recolher sidebar'
         "
         :title="sidebarRecolhida ? 'Expandir sidebar' : 'Recolher sidebar'"
-        @click="sidebarRecolhida = !sidebarRecolhida"
+        @click="alterarSidebar"
       >
-        <BaseIcon :icon="sidebarRecolhida ? ChevronRight : ChevronLeft" class="h-5 w-5" />
+        <BaseIcon
+          :icon="ChevronLeft"
+          class="h-5 w-5 transition-transform duration-200 ease-in-out"
+          :class="{ 'rotate-180': sidebarRecolhida }"
+        />
       </button>
     </div>
 
@@ -72,11 +94,12 @@ async function novaConversa() {
     >
       Chat
     </div>
-    <nav class="grid gap-1">
+    <nav class="grid gap-1" aria-label="Chat">
       <button
         type="button"
         :class="classeItemSidebar(rotaAtiva === 'home' && !activeConversationId)"
         :title="sidebarRecolhida ? 'Nova conversa' : ''"
+        :aria-current="ariaAtual(rotaAtiva === 'home' && !activeConversationId)"
         @click="novaConversa"
       >
         <BaseIcon :icon="Plus" :class="classeIcon(rotaAtiva === 'home' && !activeConversationId)" />
@@ -84,42 +107,27 @@ async function novaConversa() {
       </button>
 
       <button
+        v-for="item in itensChat"
+        :key="item.rota"
         type="button"
-        :class="classeItemSidebar(rotaAtiva === 'conversas-historico')"
-        :title="sidebarRecolhida ? 'Histórico' : ''"
-        @click="irPara('conversas-historico')"
-      >
-        <BaseIcon :icon="History" :class="classeIcon(rotaAtiva === 'conversas-historico')" />
-        <span :class="classeRotulo">Histórico</span>
-      </button>
-
-      <button
-        type="button"
-        :class="classeItemSidebar(rotaAtiva === 'conversas-pesquisar')"
-        :title="sidebarRecolhida ? 'Pesquisar' : ''"
-        @click="irPara('conversas-pesquisar')"
-      >
-        <BaseIcon :icon="Search" :class="classeIcon(rotaAtiva === 'conversas-pesquisar')" />
-        <span :class="classeRotulo">Pesquisar</span>
-      </button>
-
-      <button
-        type="button"
-        :class="classeItemSidebar(rotaAtiva === 'conversas-whatsapp')"
-        :title="sidebarRecolhida ? 'Atendimento WhatsApp' : ''"
-        @click="irPara('conversas-whatsapp')"
+        :class="classeItemSidebar(rotaAtiva === item.rota)"
+        :title="sidebarRecolhida ? item.label : ''"
+        :aria-current="ariaAtual(rotaAtiva === item.rota)"
+        @click="irPara(item.rota)"
       >
-        <BaseIcon :icon="MessageCircle" :class="classeIcon(rotaAtiva === 'conversas-whatsapp')" />
-        <span :class="classeRotulo">Atendimento WhatsApp</span>
+        <BaseIcon :icon="item.icon" :class="classeIcon(rotaAtiva === item.rota)" />
+        <span :class="classeRotulo">{{ item.label }}</span>
       </button>
     </nav>
 
-    
     <ListaConversas v-if="!sidebarRecolhida" />
 
-    
-    <div class="mt-auto flex-shrink-0 grid gap-1 pt-2">
-      <div class="my-1 h-px bg-black/10 dark:bg-white/10" />
+    <div
+      class="mt-auto flex-shrink-0 grid gap-1 pt-2"
+      role="navigation"
+      aria-label="Biblioteca e sistema"
+    >
+      <div class="my-1 h-px bg-black/10 dark:bg-white/10" role="separator" />
       <template v-if="isAdmin">
         <div
           v-if="!sidebarRecolhida"
@@ -129,66 +137,19 @@ async function novaConversa() {
         </div>
 
         <button
+          v-for="item in itensBiblioteca"
+          :key="item.rota"
           type="button"
-          :class="classeItemSidebar(rotaAtiva === 'documentos-carregar')"
-          :title="sidebarRecolhida ? 'Carregar documento' : ''"
-          @click="irPara('documentos-carregar')"
-        >
-          <BaseIcon :icon="Upload" :class="classeIcon(rotaAtiva === 'documentos-carregar')" />
-          <span :class="classeRotulo">Carregar documento</span>
-        </button>
-
-        <button
-          type="button"
-          :class="classeItemSidebar(rotaAtiva === 'documentos')"
-          :title="sidebarRecolhida ? 'Documentos' : ''"
-          @click="irPara('documentos')"
-        >
-          <BaseIcon :icon="FileText" :class="classeIcon(rotaAtiva === 'documentos')" />
-          <span :class="classeRotulo">Documentos</span>
-        </button>
-
-        <button
-          type="button"
-          :class="classeItemSidebar(rotaAtiva === 'avaliacoes')"
-          :title="sidebarRecolhida ? 'Avaliações' : ''"
-          @click="irPara('avaliacoes')"
-        >
-          <BaseIcon :icon="Star" :class="classeIcon(rotaAtiva === 'avaliacoes')" />
-          <span :class="classeRotulo">Avaliações</span>
-        </button>
-
-        <button
-          type="button"
-          :class="classeItemSidebar(rotaAtiva === 'ranking-atendentes')"
-          :title="sidebarRecolhida ? 'Ranking de atendentes' : ''"
-          @click="irPara('ranking-atendentes')"
-        >
-          <BaseIcon :icon="Trophy" :class="classeIcon(rotaAtiva === 'ranking-atendentes')" />
-          <span :class="classeRotulo">Ranking de atendentes</span>
-        </button>
-
-        <button
-          type="button"
-          :class="classeItemSidebar(rotaAtiva === 'atendimentos-nao-resolvidos')"
-          :title="sidebarRecolhida ? 'Não resolvidos' : ''"
-          @click="irPara('atendimentos-nao-resolvidos')"
-        >
-          <BaseIcon :icon="TriangleAlert" :class="classeIcon(rotaAtiva === 'atendimentos-nao-resolvidos')" />
-          <span :class="classeRotulo">Não resolvidos</span>
-        </button>
-
-        <button
-          type="button"
-          :class="classeItemSidebar(rotaAtiva === 'golden-set')"
-          :title="sidebarRecolhida ? 'Golden set' : ''"
-          @click="irPara('golden-set')"
+          :class="classeItemSidebar(rotaAtiva === item.rota)"
+          :title="sidebarRecolhida ? item.label : ''"
+          :aria-current="ariaAtual(rotaAtiva === item.rota)"
+          @click="irPara(item.rota)"
         >
-          <BaseIcon :icon="CircleCheck" :class="classeIcon(rotaAtiva === 'golden-set')" />
-          <span :class="classeRotulo">Golden set</span>
+          <BaseIcon :icon="item.icon" :class="classeIcon(rotaAtiva === item.rota)" />
+          <span :class="classeRotulo">{{ item.label }}</span>
         </button>
 
-        <div class="my-1 h-px bg-black/10 dark:bg-white/10" />
+        <div class="my-1 h-px bg-black/10 dark:bg-white/10" role="separator" />
       </template>
       <div
         v-if="!sidebarRecolhida"
@@ -201,6 +162,7 @@ async function novaConversa() {
         type="button"
         :class="classeItemSidebar(rotaAtiva === 'configuracoes')"
         :title="sidebarRecolhida ? 'Configurações' : ''"
+        :aria-current="ariaAtual(rotaAtiva === 'configuracoes')"
         @click="irPara('configuracoes')"
       >
         <BaseIcon :icon="SlidersHorizontal" :class="classeIcon(rotaAtiva === 'configuracoes')" />

+ 41 - 0
src/router/index.js

@@ -1,5 +1,6 @@
 import { createRouter, createWebHistory } from "vue-router";
 import { NIVEL_ADMIN, useAuth } from "../composables/useAuth.js";
+import { startNavigationLoading, stopNavigationLoading, chunkLoadError } from "../composables/useRouteLoading.js";
 
 const PaginaInicialView = () => import("../views/pagina-inicial/PaginaInicialView.vue");
 const ConversasHistoricoView = () => import("../views/conversas/ConversasHistoricoView.vue");
@@ -68,6 +69,8 @@ export const router = createRouter({
 });
 
 router.beforeEach((to) => {
+  startNavigationLoading();
+
   const { isAuthenticated, nivel } = useAuth();
 
   if (to.meta?.guestOnly && isAuthenticated.value) {
@@ -87,3 +90,41 @@ router.beforeEach((to) => {
 
   return true;
 });
+
+const CHUNK_RELOAD_KEY = "oraculo:chunk-reload-attempted";
+const CHUNK_ERROR_RE = /failed to fetch dynamically imported module|error loading dynamically imported module|importing a module script failed/i;
+
+router.afterEach(() => {
+  stopNavigationLoading();
+  chunkLoadError.value = false;
+  try {
+    sessionStorage.removeItem(CHUNK_RELOAD_KEY);
+  } catch {
+    // navegação privada pode bloquear o storage; sem efeito prático aqui
+  }
+});
+
+router.onError((error, to) => {
+  stopNavigationLoading();
+  const msg = String(error?.message || error || "");
+  if (!CHUNK_ERROR_RE.test(msg)) return;
+
+  let jaTentouReload = false;
+  try {
+    jaTentouReload = sessionStorage.getItem(CHUNK_RELOAD_KEY) === "1";
+  } catch {
+    // navegação privada pode bloquear o storage; segue sem retry automático
+  }
+
+  if (!jaTentouReload && to?.fullPath) {
+    try {
+      sessionStorage.setItem(CHUNK_RELOAD_KEY, "1");
+    } catch {
+      // sem storage disponível: cai direto no banner manual abaixo
+    }
+    window.location.href = to.fullPath;
+    return;
+  }
+
+  chunkLoadError.value = true;
+});

+ 1 - 3
src/styles/tailwind.css

@@ -373,9 +373,7 @@ body {
 }
 
 @layer utilities {
-  /* transição de grid-template-columns ao recolher/expandir a sidebar;
-     compartilhada entre o grid raiz (LayoutSistema) e o grid interno do
-     header (HeaderSistema), que replica --layout-sidebar-width */
+ 
   .transition-grid-cols {
     transition: grid-template-columns 200ms ease-in-out;
   }

+ 23 - 0
src/utils/markdownRenderer.js

@@ -15,6 +15,15 @@ import java from "highlight.js/lib/languages/java";
 import csharp from "highlight.js/lib/languages/csharp";
 import php from "highlight.js/lib/languages/php";
 import plaintext from "highlight.js/lib/languages/plaintext";
+import c from "highlight.js/lib/languages/c";
+import cpp from "highlight.js/lib/languages/cpp";
+import go from "highlight.js/lib/languages/go";
+import rust from "highlight.js/lib/languages/rust";
+import ruby from "highlight.js/lib/languages/ruby";
+import diff from "highlight.js/lib/languages/diff";
+import dockerfile from "highlight.js/lib/languages/dockerfile";
+import http from "highlight.js/lib/languages/http";
+import ini from "highlight.js/lib/languages/ini";
 
 hljs.registerLanguage("javascript", javascript);
 hljs.registerLanguage("typescript", typescript);
@@ -30,6 +39,20 @@ hljs.registerLanguage("java", java);
 hljs.registerLanguage("csharp", csharp);
 hljs.registerLanguage("php", php);
 hljs.registerLanguage("plaintext", plaintext);
+hljs.registerLanguage("c", c);
+hljs.registerLanguage("cpp", cpp);
+hljs.registerLanguage("go", go);
+hljs.registerLanguage("rust", rust);
+hljs.registerLanguage("ruby", ruby);
+hljs.registerLanguage("diff", diff);
+hljs.registerLanguage("dockerfile", dockerfile);
+hljs.registerLanguage("http", http);
+hljs.registerLanguage("ini", ini);
+
+// "shell" não é alias nativo de bash no core do highlight.js — o módulo
+// languages/shell.js é a gramática "Shell Session" (transcrições com prompt
+// $/#), não serve para blocos de código shell genéricos. Mapeamos manualmente.
+hljs.registerAliases(["shell"], { languageName: "bash" });
 
 const renderer = new Renderer();
 

+ 3 - 2
src/views/conversas/ConversasWhatsappView.vue

@@ -2,6 +2,7 @@
 import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
 import LayoutSistema from "../../layout/LayoutSistema.vue";
 import ModalMidia from "../../components/ModalMidia.vue";
+import SkeletonConversation from "../../components/skeletons/SkeletonConversation.vue";
 import BaseBadge from "../../components/base/BaseBadge.vue";
 import ChatBubble from "../../components/base/ChatBubble.vue";
 import BaseIcon from "../../components/base/BaseIcon.vue";
@@ -231,8 +232,8 @@ onUnmounted(() => {
           </span>
         </div>
 
-        <div v-if="conversasLoading && !conversas.length" class="flex flex-1 items-center justify-center p-4 text-sm text-muted-foreground">
-          Carregando...
+        <div v-if="conversasLoading && !conversas.length" class="flex-1 overflow-y-auto p-2">
+          <SkeletonConversation :count="5" />
         </div>
         <div v-else-if="conversasError" class="m-4 rounded-lg bg-red-50 px-4 py-3 text-xs text-red-800 dark:bg-red-900/30 dark:text-red-300">
           {{ conversasError }}