leonardo 16 timmar sedan
förälder
incheckning
9b9357da6b

+ 4 - 0
index.html

@@ -4,7 +4,11 @@
     <meta charset="UTF-8" />
     <meta http-equiv="X-UA-Compatible" content="IE=edge" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
+    <meta name="description" content="Assistente interno com base de conhecimento — busca em documentos e atendimentos." />
+    <meta name="theme-color" content="#1e2d4b" />
+    <meta property="og:title" content="ORÁCULO" />
     <link rel="icon" type="image/x-icon" href="/favicon.ico?v=2" />
+    <link rel="apple-touch-icon" href="/favicon.ico?v=2" />
     <title>ORÁCULO</title>
   </head>
   <body class="bg-background">

+ 3 - 0
src/components/base/Logo.vue

@@ -0,0 +1,3 @@
+<template>
+  <div class="text-[16px] font-extrabold tracking-[0.14em]">ORÁCULO</div>
+</template>

+ 9 - 3
src/components/base/PageHeaderCard.vue

@@ -6,13 +6,17 @@ defineProps({
   title: { type: String, required: true },
   description: { type: String, default: "" },
   iconClass: { type: String, default: "bg-primary/10 text-primary dark:bg-primary/20" },
+  // headers com conteúdo extra (ex.: botão "voltar", título multilinha) alinham ao topo
+  // em vez de centralizar verticalmente contra o ícone
+  alignStart: { type: Boolean, default: false },
 });
 </script>
 
 <template>
   <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 flex-wrap items-center justify-between gap-4">
-      <div class="flex items-center gap-4">
+    <div class="flex flex-wrap justify-between gap-4" :class="alignStart ? 'items-start' : 'items-center'">
+      <div class="flex min-w-0 gap-4" :class="alignStart ? 'items-start' : 'items-center'">
+        <slot name="leading" />
         <div
           v-if="icon"
           class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl"
@@ -21,7 +25,9 @@ defineProps({
           <BaseIcon :icon="icon" class="h-5 w-5" />
         </div>
         <div class="min-w-0">
-          <h2 class="text-base font-semibold text-foreground">{{ title }}</h2>
+          <slot name="title">
+            <h2 class="text-base font-semibold text-foreground">{{ title }}</h2>
+          </slot>
           <p v-if="description" class="mt-0.5 text-sm text-muted-foreground">{{ description }}</p>
         </div>
       </div>

+ 2 - 3
src/composables/useAsyncState.js

@@ -1,4 +1,5 @@
 import { ref } from "vue";
+import { friendlyErrorMessage } from "../api/client.js";
 
 export function useAsyncState(defaultValue = null) {
   const data = ref(defaultValue);
@@ -25,9 +26,7 @@ export function useAsyncState(defaultValue = null) {
       return result;
     } catch (e) {
       if (isStale(id)) return;
-      // a mensagem da chamada vem primeiro: o que sobe da API costuma ser técnico
-      // ("http_error:500", "network_error") e não diz nada para quem está na tela
-      error.value = onErrorMessage || e?.message || "Erro inesperado.";
+      error.value = friendlyErrorMessage(e, onErrorMessage || "Erro inesperado.");
       throw e;
     } finally {
       if (!isStale(id)) loading.value = false;

+ 4 - 3
src/composables/useChat.js

@@ -10,6 +10,7 @@ import {
 } from "../api/chatConversas.js";
 import { useToast } from "./useToast.js";
 import { baixarBlob } from "../utils/download.js";
+import { friendlyErrorMessage } from "../api/client.js";
 
 let singleton;
 let _streamAbort = null;
@@ -100,7 +101,7 @@ export function useChat() {
       conversations.value = [];
       conversationsHasMore.value = false;
       if (err?.message !== "session_expired") {
-        conversationsError.value = err?.message || "Erro ao carregar conversas.";
+        conversationsError.value = friendlyErrorMessage(err, "Erro ao carregar conversas.");
       }
     } finally {
       conversationsLoading.value = false;
@@ -343,7 +344,7 @@ export function useChat() {
           if (_streamAbort !== abortController) return;
           _streamAbort = null;
           _flushStream = null;
-          error.value = e?.message || "erro";
+          error.value = friendlyErrorMessage(e, "Não consegui responder no momento.");
           loading.value = false;
         }
       });
@@ -356,7 +357,7 @@ export function useChat() {
       if (_streamAbort === abortController) {
         _streamAbort = null;
         _flushStream = null;
-        error.value = e?.message || "erro";
+        error.value = friendlyErrorMessage(e, "Não consegui responder no momento.");
         loading.value = false;
       }
     }

+ 4 - 3
src/composables/useConversas.js

@@ -1,5 +1,6 @@
 import { ref } from "vue";
 import { listarConversas, listarMensagensConversa, responderConversa, excluirConversa as excluirConversaApi, buscarMidiaBlob, gerarSugestaoResposta } from "../api/conversas.js";
+import { friendlyErrorMessage } from "../api/client.js";
 
 let singleton;
 
@@ -70,7 +71,7 @@ export function useConversas() {
       conversas.value = results;
     } catch (err) {
       if (epoch !== conversasEpoch) return;
-      conversasError.value = err?.message ?? "Falha ao carregar conversas";
+      conversasError.value = friendlyErrorMessage(err, "Falha ao carregar conversas");
     } finally {
       if (epoch === conversasEpoch) conversasLoading.value = false;
     }
@@ -100,7 +101,7 @@ export function useConversas() {
     } catch (err){
       if ( conversaAtivaId.value !== idAlvo) return;
       sugestao.value = null;
-      sugestaoError.value = err?.message ?? "Falha ao gerar sugestão";
+      sugestaoError.value = friendlyErrorMessage(err, "Falha ao gerar sugestão");
     } finally {
       sugestaoLoading.value = false;
     }
@@ -151,7 +152,7 @@ export function useConversas() {
       if (results.length && results[results.length - 1].Direcao === "entrada") buscarSugestao();
     } catch (err) {
       if (conversaAtivaId.value !== id) return;
-      mensagensError.value = err?.message ?? "Falha ao carregar mensagens";
+      mensagensError.value = friendlyErrorMessage(err, "Falha ao carregar mensagens");
     } finally {
       if (conversaAtivaId.value === id) mensagensLoading.value = false;
     }

+ 2 - 1
src/composables/useDocumentos.js

@@ -1,6 +1,7 @@
 import { markRaw, onMounted, reactive } from "vue";
 import { listDocuments } from "../api/chat.js";
 import { useAsyncState } from "./useAsyncState.js";
+import { friendlyErrorMessage } from "../api/client.js";
 
 export function useDocumentos() {
   const docs = reactive({
@@ -35,7 +36,7 @@ export function useDocumentos() {
       }
     } catch (e) {
       if (raceGuard.isStale(epoch)) return;
-      docs.error = e?.message || "erro";
+      docs.error = friendlyErrorMessage(e, "Falha ao carregar os documentos.");
       docs.items = [];
     } finally {
       if (!raceGuard.isStale(epoch)) docs.loading = false;

+ 2 - 1
src/composables/useListaAvaliacoes.js

@@ -3,6 +3,7 @@ import { useRoute, useRouter } from "vue-router";
 import { listarAvaliacoes, listarSetoresAvaliacoes } from "../api/atendimentos.js";
 import { useAsyncState } from "./useAsyncState.js";
 import { useToast } from "./useToast.js";
+import { friendlyErrorMessage } from "../api/client.js";
 
 const DEBOUNCE_BUSCA_MS = 400;
 
@@ -86,7 +87,7 @@ export function useListaAvaliacoes({
       setores.value = r.results ?? [];
     } catch (e) {
       setores.value = [];
-      toast.erro(e?.message ?? "Falha ao carregar setores");
+      toast.erro(friendlyErrorMessage(e, "Falha ao carregar setores"));
     }
   }
 

+ 2 - 3
src/layout/HeaderSistema.vue

@@ -4,6 +4,7 @@ import { useRoute, useRouter } from "vue-router";
 import { ChevronDown, Settings, LogOut } from "lucide-vue-next";
 import BotaoAlterarTema from "../components/BotaoAlterarTema.vue";
 import BaseIcon from "../components/base/BaseIcon.vue";
+import Logo from "../components/base/Logo.vue";
 import { avatarIniciais as _avatarIniciais, avatarCor as _avatarCor } from "../utils/avatar.js";
 import { useAuth } from "../composables/useAuth.js";
 import { useMenuUsuario } from "../composables/useMenuUsuario.js";
@@ -56,9 +57,7 @@ async function encerrarSessao() {
     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 tracking-[0.14em]">
-        ORÁCULO
-      </div>
+      <Logo class="truncate whitespace-nowrap" />
       <div class="mt-1.5 truncate whitespace-nowrap text-xs text-primary-foreground/80">
         Assistente interno com base de conhecimento
       </div>

+ 0 - 2
src/layout/SidebarSistema.vue

@@ -11,7 +11,6 @@ import {
   FileText,
   Star,
   TriangleAlert,
-  CircleCheck,
   SlidersHorizontal,
   Trophy,
 } from "lucide-vue-next";
@@ -47,7 +46,6 @@ const itensBiblioteca = [
   { 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) {

+ 37 - 34
src/views/atendimentos/AtendimentoView.vue

@@ -23,6 +23,9 @@ import {
 import { ehAudio as ehAudioMidia, ehImagem as ehImagemMidia, ehVideo as ehVideoMidia } from "../../utils/midia.js";
 import { ehNovoDia, formatarData, formatarHora } from "../../utils/data.js";
 import { useToast } from "../../composables/useToast.js";
+import { friendlyErrorMessage } from "../../api/client.js";
+import PageHeaderCard from "../../components/base/PageHeaderCard.vue";
+import Spinner from "../../components/base/Spinner.vue";
 
 const HORARIO_VISITA_INFORMADO_LABEL = { sim: "Sim", nao: "Não", nao_se_aplica: "Não se aplica" };
 const toast = useToast();
@@ -98,7 +101,7 @@ async function salvarRotulo() {
     atendimento.value = { ...atendimento.value, goldenLabel: atualizado };
     toast.sucesso("Rótulo salvo.");
   } catch (err) {
-    toast.erro(err?.message ?? "Falha ao salvar o rótulo.");
+    toast.erro(friendlyErrorMessage(err, "Falha ao salvar o rótulo."));
   } finally {
     salvando.value = false;
   }
@@ -116,7 +119,7 @@ async function aceitarComoIa() {
     form.value = criarFormulario(atualizado);
     toast.sucesso("Avaliação da IA aceita como rótulo.");
   } catch (err) {
-    toast.erro(err?.message ?? "Falha ao aceitar a avaliação da IA.");
+    toast.erro(friendlyErrorMessage(err, "Falha ao aceitar a avaliação da IA."));
   } finally {
     aceitando.value = false;
   }
@@ -180,7 +183,7 @@ async function carregar(id) {
     form.value = criarFormulario(resultado.goldenLabel ?? null);
   } catch (err) {
     if (String(route.params.id) !== String(id)) return;
-    erro.value = err?.message ?? "Falha ao carregar o atendimento";
+    erro.value = friendlyErrorMessage(err, "Falha ao carregar o atendimento");
   } finally {
     if (String(route.params.id) === String(id)) carregando.value = false;
   }
@@ -197,7 +200,7 @@ async function atualizarDoIfbot() {
     await carregar(atendimento.value.Id);
     toast.sucesso("Atendimento atualizado com os dados mais recentes do ifbot.");
   } catch (err) {
-    toast.erro(err?.message ?? "Falha ao atualizar o atendimento.");
+    toast.erro(friendlyErrorMessage(err, "Falha ao atualizar o atendimento."));
   } finally {
     sincronizandoIfbot.value = false;
   }
@@ -213,7 +216,7 @@ async function reavaliar() {
     await carregar(atendimento.value.Id);
     toast.sucesso("Atendimento reavaliado.");
   } catch (err) {
-    toast.erro(err?.message ?? "Falha ao reavaliar o atendimento.");
+    toast.erro(friendlyErrorMessage(err, "Falha ao reavaliar o atendimento."));
   } finally {
     reavaliando.value = false;
   }
@@ -228,32 +231,32 @@ watch(() => route.params.id, carregar, { immediate: true });
   <LayoutSistema>
     <div class="min-h-[calc(100vh-var(--layout-header-height))] px-4 pt-8 pb-12">
       <div class="mx-auto w-full max-w-[960px] 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 flex-wrap items-start justify-between gap-4">
-            <div class="flex min-w-0 items-start gap-4">
-              <button
-                type="button"
-                class="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-gray-300 text-gray-600 hover:bg-black/[0.03] dark:border-gray-600 dark:text-gray-300 dark:hover:bg-white/[0.04]"
-                title="Voltar"
-                @click="voltar"
-              >
-                <BaseIcon :icon="ArrowLeft" class="h-4 w-4" />
-              </button>
-              <div class="min-w-0">
-                <h2 class="text-base font-semibold text-foreground">
-                  {{ atendimento?.Codigo ?? `Atendimento #${route.params.id}` }}
-                  <span v-if="atendimento?.cliente?.Nome" class="font-normal text-muted-foreground">
-                    — {{ atendimento.cliente.Nome }}
-                  </span>
-                </h2>
-                <p v-if="atendimento" class="mt-0.5 text-sm text-muted-foreground">
-                  <span v-if="atendimento.Setor">{{ atendimento.Setor }} · </span>{{ formatarData(atendimento.Abertura) }}
-                  · {{ mensagens.length }} mensagens
-                </p>
-              </div>
-            </div>
+        <PageHeaderCard align-start title="">
+          <template #leading>
+            <button
+              type="button"
+              class="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-gray-300 text-gray-600 hover:bg-black/[0.03] dark:border-gray-600 dark:text-gray-300 dark:hover:bg-white/[0.04]"
+              title="Voltar"
+              @click="voltar"
+            >
+              <BaseIcon :icon="ArrowLeft" class="h-4 w-4" />
+            </button>
+          </template>
+
+          <template #title>
+            <h2 class="text-base font-semibold text-foreground">
+              {{ atendimento?.Codigo ?? `Atendimento #${route.params.id}` }}
+              <span v-if="atendimento?.cliente?.Nome" class="font-normal text-muted-foreground">
+                — {{ atendimento.cliente.Nome }}
+              </span>
+            </h2>
+            <p v-if="atendimento" class="mt-0.5 text-sm text-muted-foreground">
+              <span v-if="atendimento.Setor">{{ atendimento.Setor }} · </span>{{ formatarData(atendimento.Abertura) }}
+              · {{ mensagens.length }} mensagens
+            </p>
+          </template>
+
+          <template #actions>
             <div class="flex flex-wrap items-center gap-2">
               <BaseButton
                 v-if="isAdmin && atendimento"
@@ -288,7 +291,7 @@ watch(() => route.params.id, carregar, { immediate: true });
                 </BaseBadge>
               </template>
             </div>
-          </div>
+          </template>
 
           <p v-if="avaliacao?.Resumo" class="mt-4 text-sm text-muted-foreground">
             <span class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Resumo da IA: </span>
@@ -298,14 +301,14 @@ watch(() => route.params.id, carregar, { immediate: true });
             <span class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Motivo do cancelamento: </span>
             {{ avaliacao.MotivoCancelamento }}
           </p>
-        </section>
+        </PageHeaderCard>
 
         <section class="rounded-2xl border border-gray-200 bg-background/70 shadow-sm dark:border-gray-700 dark:bg-background/20">
           <div class="border-b border-gray-200 px-4 py-3 text-sm font-semibold text-gray-900 dark:border-gray-700 dark:text-gray-100">
             Conversa completa
           </div>
 
-          <div v-if="carregando" class="p-6 text-sm text-muted-foreground">Carregando conversa...</div>
+          <Spinner v-if="carregando" class="p-6" label="Carregando conversa..." />
           <div v-else-if="erro" 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">
             {{ erro }}
           </div>

+ 10 - 21
src/views/atendimentos/AvaliacoesView.vue

@@ -12,11 +12,13 @@ import Spinner from "../../components/base/Spinner.vue";
 import ItemAvaliacao from "../../components/atendimentos/ItemAvaliacao.vue";
 import { ClipboardList, Star, CircleCheck, Check, Smile, TriangleAlert, ChevronUp, ChevronDown } from "lucide-vue-next";
 import BaseIcon from "../../components/base/BaseIcon.vue";
+import PageHeaderCard from "../../components/base/PageHeaderCard.vue";
 import { avaliarAtendimentosPendentes, estatisticasAvaliacoes } from "../../api/atendimentos.js";
 import { queryTexto, useListaAvaliacoes } from "../../composables/useListaAvaliacoes.js";
 import { useAsyncState } from "../../composables/useAsyncState.js";
 import { useToast } from "../../composables/useToast.js";
 import { cancelamentoLabel, pct, resolvidoLabel, sentimentoLabel } from "../../utils/avaliacoes.js";
+import { friendlyErrorMessage } from "../../api/client.js";
 
 const opcoesResolucao = Object.entries({ ...resolvidoLabel, ...cancelamentoLabel }).map(([value, label]) => ({
   value,
@@ -124,7 +126,7 @@ async function avaliarPendentes() {
     loteResultado.value = await avaliarAtendimentosPendentes({ limite: 10 });
     await Promise.all([carregarStats(), carregarLista()]);
   } catch (err) {
-    erro.value = err?.message ?? "Falha ao avaliar pendentes";
+    erro.value = friendlyErrorMessage(err, "Falha ao avaliar pendentes");
   } finally {
     avaliandoLote.value = false;
   }
@@ -152,29 +154,16 @@ onBeforeUnmount(() => {
   <LayoutSistema>
     <div class="min-h-[calc(100vh-var(--layout-header-height))] px-4 pt-8 pb-12">
       <div class="mx-auto w-full max-w-[960px] 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"
+        <PageHeaderCard
+          :icon="Star"
+          title="Avaliações de Atendimento (IA)"
+          description="Score do atendente, resolução, sentimento do cliente e pontos de melhoria gerados automaticamente."
         >
-          <div class="flex flex-wrap items-center justify-between gap-4">
-            <div class="flex items-center gap-4">
-              <div
-                class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary dark:bg-primary/20"
-              >
-                <BaseIcon :icon="Star" class="h-5 w-5" />
-              </div>
-              <div class="min-w-0">
-                <h2 class="text-base font-semibold text-foreground">
-                  Avaliações de Atendimento (IA)
-                </h2>
-                <p class="mt-0.5 text-sm text-muted-foreground">
-                  Score do atendente, resolução, sentimento do cliente e pontos de melhoria gerados automaticamente.
-                </p>
-              </div>
-            </div>
+          <template #actions>
             <BaseButton :loading="avaliandoLote" @click="avaliarPendentes">
               {{ avaliandoLote ? "Avaliando..." : `Avaliar pendentes${stats?.pendentes ? ` (${stats.pendentes})` : ""}` }}
             </BaseButton>
-          </div>
+          </template>
 
           <div v-if="progresso" class="mt-5">
             <div class="flex items-center justify-between text-xs text-muted-foreground">
@@ -207,7 +196,7 @@ onBeforeUnmount(() => {
           <div v-if="erro" class="mt-4 rounded-lg bg-red-50 px-4 py-3 text-xs text-red-800 dark:bg-red-900/30 dark:text-red-300">
             {{ erro }}
           </div>
-        </section>
+        </PageHeaderCard>
 
         <section v-if="stats" class="grid grid-cols-2 gap-3 md:grid-cols-5">
           <StatCard label="Score médio" :value="stats.scoreMedio ?? '—'" suffix=" / 10" variant="info">

+ 8 - 18
src/views/atendimentos/GoldenSetView.vue

@@ -8,10 +8,12 @@ import StatCard from "../../components/base/StatCard.vue";
 import ListItemCard from "../../components/base/ListItemCard.vue";
 import EmptyState from "../../components/base/EmptyState.vue";
 import Spinner from "../../components/base/Spinner.vue";
+import PageHeaderCard from "../../components/base/PageHeaderCard.vue";
 import { FolderSearch, CircleCheck, CircleAlert, Check } from "lucide-vue-next";
 import BaseIcon from "../../components/base/BaseIcon.vue";
 import { listarGoldenSet } from "../../api/atendimentos.js";
 import { formatarData } from "../../utils/data.js";
+import { friendlyErrorMessage } from "../../api/client.js";
 
 const router = useRouter();
 
@@ -41,7 +43,7 @@ async function carregar() {
     const r = await listarGoldenSet();
     itens.value = r.results ?? [];
   } catch (err) {
-    erro.value = err?.message ?? "Falha ao carregar o golden set";
+    erro.value = friendlyErrorMessage(err, "Falha ao carregar o golden set");
   } finally {
     carregando.value = false;
   }
@@ -58,27 +60,15 @@ onMounted(carregar);
   <LayoutSistema>
     <div class="min-h-[calc(100vh-var(--layout-header-height))] px-4 pt-8 pb-12">
       <div class="mx-auto w-full max-w-[960px] 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"
+        <PageHeaderCard
+          :icon="CircleCheck"
+          title="Golden Set"
+          description="Amostra de atendimentos rotulados à mão, usada para medir a acurácia do avaliador por IA."
         >
-          <div class="flex items-center gap-4">
-            <div
-              class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary dark:bg-primary/20"
-            >
-              <BaseIcon :icon="CircleCheck" class="h-5 w-5" />
-            </div>
-            <div class="min-w-0">
-              <h2 class="text-base font-semibold text-foreground">Golden Set</h2>
-              <p class="mt-0.5 text-sm text-muted-foreground">
-                Amostra de atendimentos rotulados à mão, usada para medir a acurácia do avaliador por IA.
-              </p>
-            </div>
-          </div>
-
           <div v-if="erro" class="mt-4 rounded-lg bg-red-50 px-4 py-3 text-xs text-red-800 dark:bg-red-900/30 dark:text-red-300">
             {{ erro }}
           </div>
-        </section>
+        </PageHeaderCard>
 
         <section class="grid grid-cols-2 gap-3 sm:grid-cols-4">
           <StatCard label="Total na amostra" :value="itens.length" variant="default">

+ 11 - 21
src/views/atendimentos/NaoResolvidosView.vue

@@ -12,11 +12,13 @@ import Spinner from "../../components/base/Spinner.vue";
 import ItemAvaliacao from "../../components/atendimentos/ItemAvaliacao.vue";
 import { CircleCheck, TriangleAlert, Star, BarChart3, ChevronUp, ChevronDown, Download } from "lucide-vue-next";
 import BaseIcon from "../../components/base/BaseIcon.vue";
+import PageHeaderCard from "../../components/base/PageHeaderCard.vue";
 import { baixarRelatorioNaoResolvidos, estatisticasAvaliacoes } from "../../api/atendimentos.js";
 import { useToast } from "../../composables/useToast.js";
 import { queryTexto, useListaAvaliacoes } from "../../composables/useListaAvaliacoes.js";
 import { useAsyncState } from "../../composables/useAsyncState.js";
 import { pct, sentimentoLabel } from "../../utils/avaliacoes.js";
+import { friendlyErrorMessage } from "../../api/client.js";
 
 const opcoesSentimento = Object.entries(sentimentoLabel).map(([value, label]) => ({ value, label }));
 
@@ -131,7 +133,7 @@ async function baixarRelatorio() {
       ordenacao: filtroOrdenacao.value
     });
   } catch (err) {
-    toast.erro(err?.message ?? "Falha ao gerar o relatório");
+    toast.erro(friendlyErrorMessage(err, "Falha ao gerar o relatório"));
   } finally {
     gerandoRelatorio.value = false;
   }
@@ -147,25 +149,13 @@ onMounted(() => {
   <LayoutSistema>
     <div class="min-h-[calc(100vh-var(--layout-header-height))] px-4 pt-8 pb-12">
       <div class="mx-auto w-full max-w-[960px] 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"
+        <PageHeaderCard
+          :icon="TriangleAlert"
+          icon-class="bg-red-500/10 text-red-600 dark:bg-red-500/20 dark:text-red-400"
+          title="Atendimentos Não Resolvidos (IA)"
+          description="Atendimentos marcados como não resolvidos pelo avaliador automático, por período e setor."
         >
-          <div class="flex flex-wrap items-center justify-between gap-4">
-            <div class="flex items-center gap-4">
-              <div
-                class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-red-500/10 text-red-600 dark:bg-red-500/20 dark:text-red-400"
-              >
-                <BaseIcon :icon="TriangleAlert" class="h-5 w-5" />
-              </div>
-              <div class="min-w-0">
-                <h2 class="text-base font-semibold text-foreground">
-                  Atendimentos Não Resolvidos (IA)
-                </h2>
-                <p class="mt-0.5 text-sm text-muted-foreground">
-                  Atendimentos marcados como não resolvidos pelo avaliador automático, por período e setor.
-                </p>
-              </div>
-            </div>
+          <template #actions>
             <div class="flex items-center gap-2">
               <input
                 v-model="filtroDataInicio"
@@ -181,12 +171,12 @@ onMounted(() => {
                 @change="aplicarFiltros"
               />
             </div>
-          </div>
+          </template>
 
           <div v-if="erro" class="mt-4 rounded-lg bg-red-50 px-4 py-3 text-xs text-red-800 dark:bg-red-900/30 dark:text-red-300">
             {{ erro }}
           </div>
-        </section>
+        </PageHeaderCard>
 
         <section v-if="stats" class="grid grid-cols-2 gap-3 md:grid-cols-3">
           <StatCard label="Não resolvidos no período" :value="stats.avaliados" variant="danger">

+ 11 - 19
src/views/atendimentos/RankingAtendentesView.vue

@@ -8,12 +8,14 @@ import BaseBadge from "../../components/base/BaseBadge.vue";
 import BaseButton from "../../components/base/BaseButton.vue";
 import EmptyState from "../../components/base/EmptyState.vue";
 import Spinner from "../../components/base/Spinner.vue";
+import PageHeaderCard from "../../components/base/PageHeaderCard.vue";
 import { Trophy, Star, Users, CircleCheck, Info, ChevronUp, ChevronDown, Download } from "lucide-vue-next";
 import { rankingAtendentesAvaliacoes, baixarRelatorioRankingAtendentes } from "../../api/atendimentos.js";
 import { useAsyncState } from "../../composables/useAsyncState.js";
 import { useToast } from "../../composables/useToast.js";
 import { queryTexto } from "../../composables/useListaAvaliacoes.js";
 import { scoreVariant } from "../../utils/avaliacoes.js";
+import { friendlyErrorMessage } from "../../api/client.js";
 
 
 const COR_BARRA = {
@@ -157,7 +159,7 @@ async function baixarRelatorio() {
       dataFim: filtroDataFim.value || undefined
     });
   } catch (err) {
-    toast.erro(err?.message ?? "Falha ao gerar o relatório.");
+    toast.erro(friendlyErrorMessage(err, "Falha ao gerar o relatório."));
   } finally {
     gerandoRelatorio.value = false;
   }
@@ -172,23 +174,13 @@ onMounted(() => {
   <LayoutSistema>
     <div class="min-h-[calc(100vh-var(--layout-header-height))] px-4 pt-8 pb-12">
       <div class="mx-auto w-full max-w-[960px] 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"
+        <PageHeaderCard
+          :icon="Trophy"
+          icon-class="bg-amber-500/10 text-amber-600 dark:bg-amber-500/20 dark:text-amber-400"
+          title="Ranking de Atendentes — Suporte Interno"
+          description="Técnicos do Suporte Interno ordenados pela nota média das avaliações automáticas, por período."
         >
-          <div class="flex flex-wrap items-center justify-between gap-4">
-            <div class="flex items-center gap-4">
-              <div
-                class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-amber-500/10 text-amber-600 dark:bg-amber-500/20 dark:text-amber-400"
-              >
-                <BaseIcon :icon="Trophy" class="h-5 w-5" />
-              </div>
-              <div class="min-w-0">
-                <h2 class="text-base font-semibold text-foreground">Ranking de Atendentes — Suporte Interno</h2>
-                <p class="mt-0.5 text-sm text-muted-foreground">
-                  Técnicos do Suporte Interno ordenados pela nota média das avaliações automáticas, por período.
-                </p>
-              </div>
-            </div>
+          <template #actions>
             <div class="flex items-center gap-2">
               <input
                 v-model="filtroDataInicio"
@@ -204,7 +196,7 @@ onMounted(() => {
                 @change="carregar"
               />
             </div>
-          </div>
+          </template>
 
           <div v-if="erro" class="mt-4 rounded-lg bg-red-50 px-4 py-3 text-xs text-red-800 dark:bg-red-900/30 dark:text-red-300">
             {{ erro }}
@@ -215,7 +207,7 @@ onMounted(() => {
             {{ metadados.descartadasSemAtendente + metadados.descartadasNomeInvalido + metadados.descartadasForaDaEquipe }}
             sem atendente identificado ou fora da equipe
           </p>
-        </section>
+        </PageHeaderCard>
 
         <section class="grid grid-cols-2 gap-3 md:grid-cols-3">
           <StatCard label="Atendentes no ranking" :value="resumo.totalAtendentes" variant="default">

+ 2 - 1
src/views/auth/LoginView.vue

@@ -6,6 +6,7 @@ import DecorativeGradient from "../../components/DecorativeGradient.vue";
 import ModalEsqueciSenha from "../../components/ModalEsqueciSenha.vue";
 import BaseButton from "../../components/base/BaseButton.vue";
 import BaseIcon from "../../components/base/BaseIcon.vue";
+import Logo from "../../components/base/Logo.vue";
 import { LocateFixed, Eye, EyeOff } from "lucide-vue-next";
 import { useAuth } from "../../composables/useAuth.js";
 
@@ -115,7 +116,7 @@ async function onSubmit() {
 
     <header class="relative z-10 flex items-center justify-between px-6 py-5">
       <div>
-        <div class="text-[16px] font-extrabold tracking-[0.14em]">ORÁCULO</div>
+        <Logo />
         <div class="mt-1 text-sm text-muted-foreground">
           Acesso ao assistente interno
         </div>

+ 14 - 26
src/views/configuracoes/ConfiguracoesView.vue

@@ -5,10 +5,12 @@ import BotaoAlterarTema from "../../components/BotaoAlterarTema.vue";
 import BaseButton from "../../components/base/BaseButton.vue";
 import BaseBadge from "../../components/base/BaseBadge.vue";
 import BaseIcon from "../../components/base/BaseIcon.vue";
+import PageHeaderCard from "../../components/base/PageHeaderCard.vue";
 import { SlidersVertical, Users, ChevronRight, RefreshCw, MessageCircle, Sun } from "lucide-vue-next";
 import { useAuth } from "../../composables/useAuth.js";
 import { useWhatsappConexao } from "../../composables/useWhatsappConexao.js";
 import { sincronizarAtendimentos, sincronizarTodosAtendimentos } from "../../api/atendimentos.js";
+import { friendlyErrorMessage } from "../../api/client.js";
 
 const { isAdmin } = useAuth();
 
@@ -50,7 +52,7 @@ async function sincronizarAgora() {
   try {
     syncResultado.value = await sincronizarAtendimentos({ limite: 50 });
   } catch (err) {
-    syncErro.value = err?.message ?? "Falha ao sincronizar";
+    syncErro.value = friendlyErrorMessage(err, "Falha ao sincronizar");
   } finally {
     sincronizando.value = false;
   }
@@ -64,7 +66,7 @@ async function importarTudo() {
   try {
     syncResultado.value = await sincronizarTodosAtendimentos();
   } catch (err) {
-    syncErro.value = err?.message ?? "Falha ao importar";
+    syncErro.value = friendlyErrorMessage(err, "Falha ao importar");
   } finally {
     importandoTudo.value = false;
   }
@@ -77,25 +79,11 @@ async function importarTudo() {
       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-[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-center gap-4">
-            <div
-              class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary dark:bg-primary/20"
-            >
-              <BaseIcon :icon="SlidersVertical" class="h-5 w-5" />
-            </div>
-            <div class="min-w-0">
-              <h2 class="text-base font-semibold text-foreground">
-                Configurações do Sistema
-              </h2>
-              <p class="mt-0.5 text-sm text-muted-foreground">
-                Gerencie usuários e preferências da plataforma.
-              </p>
-            </div>
-          </div>
-        </section>
+        <PageHeaderCard
+          :icon="SlidersVertical"
+          title="Configurações do Sistema"
+          description="Gerencie usuários e preferências da plataforma."
+        />
 
         <section v-if="isAdmin">
           <div class="mb-3 text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
@@ -184,14 +172,14 @@ async function importarTudo() {
               </div>
               <div class="flex shrink-0 items-center gap-3">
                 <BaseBadge :variant="badgeConexaoVariant">{{ badgeConexaoTexto }}</BaseBadge>
-                <button
-                  type="button"
-                  class="rounded-xl border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100 disabled:opacity-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-white/5"
-                  :disabled="verificandoWhatsapp"
+                <BaseButton
+                  variant="secondary"
+                  size="sm"
+                  :loading="verificandoWhatsapp"
                   @click="atualizarStatusWhatsapp"
                 >
                   Verificar status
-                </button>
+                </BaseButton>
               </div>
             </div>
             <div v-if="erroWhatsapp" class="mt-4 rounded-lg bg-red-50 px-4 py-3 text-xs text-red-800 dark:bg-red-900/30 dark:text-red-300">

+ 14 - 16
src/views/conversas/ConversasHistoricoView.vue

@@ -4,6 +4,8 @@ import { useRouter } from "vue-router";
 import LayoutSistema from "../../layout/LayoutSistema.vue";
 import BaseIcon from "../../components/base/BaseIcon.vue";
 import ConfirmDialog from "../../components/base/ConfirmDialog.vue";
+import PageHeaderCard from "../../components/base/PageHeaderCard.vue";
+import EmptyState from "../../components/base/EmptyState.vue";
 import { History, Trash2 } from "lucide-vue-next";
 import { useChat } from "../../composables/useChat.js";
 import { formatarDataHora } from "../../utils/data.js";
@@ -38,21 +40,17 @@ function confirmarExclusao(id) {
 
 <template>
   <LayoutSistema>
-    <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-foreground">Histórico</h2>
-          <p class="mt-1 text-sm text-muted-foreground">{{ 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">
-          <BaseIcon :icon="History" class="h-6 w-6 text-muted-foreground" />
-        </div>
-        <div class="text-sm font-medium text-muted-foreground">Nenhuma conversa ainda</div>
-        <div class="text-xs text-muted-foreground">Suas conversas aparecerão aqui após a primeira mensagem.</div>
-      </div>
+    <PageHeaderCard
+      title="Histórico"
+      :description="`${items.length} conversa${items.length === 1 ? '' : 's'}`"
+    >
+      <EmptyState
+        v-if="!items.length"
+        class="mt-8"
+        :icon="History"
+        titulo="Nenhuma conversa ainda"
+        descricao="Suas conversas aparecerão aqui após a primeira mensagem."
+      />
 
       <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">
@@ -89,7 +87,7 @@ function confirmarExclusao(id) {
           </button>
         </div>
       </div>
-    </section>
+    </PageHeaderCard>
 
     <ConfirmDialog
       :open="!!confirmandoExclusaoId"

+ 9 - 13
src/views/conversas/ConversasPesquisarView.vue

@@ -4,6 +4,8 @@ import { useRouter } from "vue-router";
 import LayoutSistema from "../../layout/LayoutSistema.vue";
 import BaseButton from "../../components/base/BaseButton.vue";
 import BaseIcon from "../../components/base/BaseIcon.vue";
+import PageHeaderCard from "../../components/base/PageHeaderCard.vue";
+import EmptyState from "../../components/base/EmptyState.vue";
 import { Search } from "lucide-vue-next";
 import { useChat } from "../../composables/useChat.js";
 
@@ -32,9 +34,7 @@ function abrirConversa(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-foreground">Pesquisar conversas</h2>
-
+    <PageHeaderCard title="Pesquisar conversas">
       <div class="mt-3 grid grid-cols-1 gap-2 compact:grid-cols-[1fr_120px]">
         <div class="relative">
           <BaseIcon
@@ -78,17 +78,13 @@ function abrirConversa(id) {
           <div class="mt-1 text-xs text-muted-foreground">{{ c.messageCount ?? 0 }} msgs</div>
         </button>
 
-        <div
+        <EmptyState
           v-if="query.trim() && !results.length"
-          class="flex flex-col items-center gap-3 py-8 text-center"
-        >
-          <div class="flex h-14 w-14 items-center justify-center rounded-2xl border border-dashed border-gray-300 dark:border-gray-600">
-            <BaseIcon :icon="Search" class="h-6 w-6 text-muted-foreground" />
-          </div>
-          <div class="text-sm font-medium text-muted-foreground">Nenhuma conversa encontrada</div>
-          <div class="text-xs text-muted-foreground">Tente buscar por outro título.</div>
-        </div>
+          :icon="Search"
+          titulo="Nenhuma conversa encontrada"
+          descricao="Tente buscar por outro título."
+        />
       </div>
-    </section>
+    </PageHeaderCard>
   </LayoutSistema>
 </template>

+ 12 - 7
src/views/conversas/ConversasWhatsappView.vue

@@ -7,6 +7,7 @@ import BaseBadge from "../../components/base/BaseBadge.vue";
 import ChatBubble from "../../components/base/ChatBubble.vue";
 import BaseIcon from "../../components/base/BaseIcon.vue";
 import ConfirmDialog from "../../components/base/ConfirmDialog.vue";
+import EmptyState from "../../components/base/EmptyState.vue";
 import { MessageCircle, Trash2, ArrowLeft, Mic, Play, Sparkles, RefreshCw, X, TriangleAlert } from "lucide-vue-next";
 import { useConversas } from "../../composables/useConversas.js";
 import { useAuth } from "../../composables/useAuth.js";
@@ -14,6 +15,7 @@ import { useToast } from "../../composables/useToast.js";
 import { ehAudio, ehImagem, ehVideo } from "../../utils/midia.js";
 import { agruparPorRecencia, ehNovoDia, formatarData, formatarHora, formatarRecencia } from "../../utils/data.js";
 import { avatarIniciais, avatarCor } from "../../utils/avatar.js";
+import { friendlyErrorMessage } from "../../api/client.js";
 
 const toast = useToast();
 
@@ -105,7 +107,7 @@ async function onExcluir(id) {
     confirmandoExclusao.value = null;
     toast.sucesso("Conversa excluída com sucesso.");
   } catch (err) {
-    toast.erro(`Erro ao excluir: ${err?.message ?? "erro desconhecido"}`);
+    toast.erro(friendlyErrorMessage(err, "Falha ao excluir a conversa."));
   } finally {
     excluindo.value = null;
   }
@@ -175,7 +177,7 @@ function novoDia(idx) {
 
 function mensagemErroEnvio(err) {
   if (err?.message === "whatsapp_nao_conectado") return "WhatsApp desconectado. Peça a um admin para reconectar.";
-  return `Erro ao enviar: ${err?.message ?? "erro desconhecido"}`;
+  return friendlyErrorMessage(err, "Falha ao enviar a mensagem.");
 }
 
 async function enviar() {
@@ -238,11 +240,14 @@ onUnmounted(() => {
         <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 }}
         </div>
-        <div v-else-if="!conversas.length" class="flex flex-1 flex-col items-center justify-center gap-2 p-4 text-center">
-          <BaseIcon :icon="MessageCircle" class="h-8 w-8 text-gray-300 dark:text-gray-600" stroke-width="1.5" />
-          <p class="text-sm text-muted-foreground">Nenhuma conversa ainda.</p>
-          <p class="text-xs text-muted-foreground">Mande uma mensagem para o número de teste no WhatsApp.</p>
-        </div>
+        <EmptyState
+          v-else-if="!conversas.length"
+          class="flex-1 justify-center"
+          compacto
+          :icon="MessageCircle"
+          titulo="Nenhuma conversa ainda."
+          descricao="Mande uma mensagem para o número de teste no WhatsApp."
+        />
 
         <div v-else class="scroll-thin min-h-0 flex-1 overflow-y-auto p-2">
           <template v-for="grupo in conversasAgrupadas" :key="grupo.label">

+ 1 - 1
src/views/pagina-inicial/PaginaInicialView.vue

@@ -53,7 +53,7 @@ async function onLandingSend() {
 
 const FRASES_EXEMPLO = [
   "Como posso ajudar você hoje?",
-  "Qual a sua duvida?",
+  "Qual a sua dúvida?",
   "Qual a configuração que você deseja saber?"
 ];
 const PLACEHOLDER_PADRAO = "Digite sua pergunta...";

+ 14 - 23
src/views/usuarios/UsuariosView.vue

@@ -11,9 +11,12 @@ import BaseButton from "../../components/base/BaseButton.vue";
 import BaseBadge from "../../components/base/BaseBadge.vue";
 import Spinner from "../../components/base/Spinner.vue";
 import BaseIcon from "../../components/base/BaseIcon.vue";
+import PageHeaderCard from "../../components/base/PageHeaderCard.vue";
+import EmptyState from "../../components/base/EmptyState.vue";
 import { Plus, Search, User, Pencil, KeyRound } from "lucide-vue-next";
 import { avatarIniciais, avatarCor } from "../../utils/avatar.js";
 import { formatarDataHoraSemAno } from "../../utils/data.js";
+import { friendlyErrorMessage } from "../../api/client.js";
 
 const { users, loading, error, loadUsers, toggleStatus } = useUsers();
 const toast = useToast();
@@ -89,7 +92,7 @@ async function alternarStatus(usuario) {
     const novoStatus = String(usuario.Status) === "1" ? "desativado" : "ativado";
     toast.sucesso(`${nome} ${novoStatus} com sucesso.`);
   } catch (e) {
-    toast.erro(e?.message || "Erro ao alterar status do usuário.");
+    toast.erro(friendlyErrorMessage(e, "Erro ao alterar status do usuário."));
   } finally {
     statusEmProcessamento.value.delete(usuario.Id);
   }
@@ -134,7 +137,7 @@ async function dispensarSolicitacao(solicitacao) {
     toast.sucesso("Solicitação dispensada.");
     await carregarSolicitacoes();
   } catch (e) {
-    toast.erro(e?.message || "Erro ao dispensar solicitação.");
+    toast.erro(friendlyErrorMessage(e, "Erro ao dispensar solicitação."));
   }
 }
 
@@ -153,17 +156,8 @@ onMounted(async () => {
 
 <template>
   <LayoutSistema>
-    <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-foreground">Usuários</h2>
-          <p class="mt-1 text-sm text-muted-foreground">
-            Lista dos usuários cadastrados no sistema.
-          </p>
-        </div>
+    <PageHeaderCard title="Usuários" description="Lista dos usuários cadastrados no sistema.">
+      <template #actions>
         <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"
@@ -175,7 +169,7 @@ onMounted(async () => {
             Novo usuário
           </BaseButton>
         </div>
-      </div>
+      </template>
 
       <div
         v-if="solicitacoes.length"
@@ -248,15 +242,12 @@ onMounted(async () => {
       </div>
 
   
-      <div
+      <EmptyState
         v-else-if="!users.length"
-        class="mt-6 flex flex-col items-center gap-3 py-8 text-center"
-      >
-        <div class="flex h-14 w-14 items-center justify-center rounded-2xl border border-dashed border-gray-300 dark:border-gray-600">
-          <BaseIcon :icon="User" class="h-6 w-6 text-muted-foreground" />
-        </div>
-        <div class="text-sm font-medium text-muted-foreground">Nenhum usuário encontrado.</div>
-      </div>
+        class="mt-6"
+        :icon="User"
+        titulo="Nenhum usuário encontrado."
+      />
 
       
       <div v-else class="mt-4 overflow-hidden rounded-xl border border-gray-200 dark:border-gray-700">
@@ -373,7 +364,7 @@ onMounted(async () => {
           <span class="font-medium">{{ busca || (filtroStatus !== "todos" ? filtroStatus : "") }}</span>.
         </div>
       </div>
-    </section>
+    </PageHeaderCard>
 
     <ModalUsuario
       v-if="modalAberto"