Explorar o código

adição de chat interno para testar ia

leonardo hai 1 mes
pai
achega
ac04492011

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

+ 18 - 0
src/api/conversas.js

@@ -0,0 +1,18 @@
+import { apiFetch } from "./client.js";
+
+export function listarConversas() {
+  return apiFetch("/api/conversas");
+}
+
+export function listarMensagensConversa(id, after) {
+  const query = after ? `?after=${encodeURIComponent(after)}` : "";
+  return apiFetch(`/api/conversas/${id}/mensagens${query}`);
+}
+
+export function responderConversa(id, texto) {
+  return apiFetch(`/api/conversas/${id}/mensagens`, { method: "POST", body: { texto } });
+}
+
+export function excluirConversa(id) {
+  return apiFetch(`/api/conversas/${id}`, { method: "DELETE" });
+}

+ 13 - 0
src/api/whatsappConexao.js

@@ -0,0 +1,13 @@
+import { apiFetch } from "./client.js";
+
+export function statusWhatsapp() {
+  return apiFetch("/api/whatsapp/status");
+}
+
+export function conectarWhatsapp() {
+  return apiFetch("/api/whatsapp/conectar", { method: "POST" });
+}
+
+export function desconectarWhatsapp() {
+  return apiFetch("/api/whatsapp/desconectar", { method: "POST" });
+}

+ 61 - 0
src/components/ModalWhatsappConexao.vue

@@ -0,0 +1,61 @@
+<template>
+  <Teleport to="body">
+    <div class="fixed inset-0 z-[9999] flex items-center justify-center p-4" @click="$emit('close')">
+      <div class="absolute inset-0 bg-black/40 dark:bg-black/60" />
+      <div
+        class="relative w-full max-w-sm 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">Conectar WhatsApp</h3>
+          <button
+            type="button"
+            class="rounded-lg border border-transparent p-1.5 text-gray-500 transition-colors hover:border-gray-200 hover:bg-gray-100 dark:text-gray-400 dark:hover:border-gray-700 dark:hover:bg-white/5"
+            @click="$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>
+
+        <div class="flex flex-col items-center gap-4 p-6">
+          <template v-if="status === 'qr_pendente' && qrDataUrl">
+            <img :src="qrDataUrl" alt="QR code de conexão do WhatsApp" class="h-56 w-56 rounded-lg border border-gray-200 dark:border-gray-700" />
+            <p class="text-center text-sm text-gray-600 dark:text-gray-300">
+              Abra o WhatsApp no celular que vai atender → Configurações → Aparelhos conectados → Conectar aparelho e escaneie o código acima.
+            </p>
+          </template>
+
+          <template v-else-if="status === 'conectando'">
+            <p class="text-sm text-gray-500 dark:text-gray-400">Gerando código de conexão...</p>
+          </template>
+
+          <template v-else-if="status === 'conectado'">
+            <p class="text-sm font-medium text-emerald-600 dark:text-emerald-400">Conectado com sucesso!</p>
+            <p class="text-sm text-gray-500 dark:text-gray-400">Número: {{ telefone }}</p>
+          </template>
+
+          <template v-else>
+            <p class="text-sm text-gray-500 dark:text-gray-400">Sem conexão ativa.</p>
+          </template>
+
+          <p v-if="erro" class="w-full rounded-lg bg-red-50 px-4 py-3 text-xs text-red-800 dark:bg-red-900/30 dark:text-red-300">
+            {{ erro }}
+          </p>
+        </div>
+      </div>
+    </div>
+  </Teleport>
+</template>
+
+<script setup>
+defineProps({
+  status: { type: String, default: "desconectado" },
+  qrDataUrl: { type: String, default: null },
+  telefone: { type: String, default: null },
+  erro: { type: String, default: "" }
+});
+
+defineEmits(["close"]);
+</script>

+ 58 - 39
src/composables/useChat.js

@@ -223,7 +223,8 @@ export function useChat() {
     if (_streamAbort) {
       _streamAbort.abort();
     }
-    _streamAbort = new AbortController();
+    const abortController = new AbortController();
+    _streamAbort = abortController;
 
     let convId = activeConversationId.value ? Number(activeConversationId.value) : null;
 
@@ -239,60 +240,78 @@ export function useChat() {
           ...conversations.value
         ];
       } catch {
-       
+
       }
     }
 
+    if (_streamAbort !== abortController) {
+      return;
+    }
+
     messages.value.push({ role: "user", content: trimmed, sentAt: Date.now() });
 
     const assistantMsg = { role: "assistant", content: "", sources: [], sentAt: Date.now(), streaming: true, statusMsg: "" };
     messages.value.push(assistantMsg);
-    
+
     const liveMsg = messages.value[messages.value.length - 1];
 
     loading.value = true;
 
-    await sendChatStream(trimmed, {
-      conversationId: convId,
-      mode: chatMode.value,
-      signal: _streamAbort.signal,
-      onStatus: ({ stage, count }) => {
-        if (stage === "buscando") liveMsg.statusMsg = "Buscando documentos...";
-        else if (stage === "encontrou") liveMsg.statusMsg = `Encontrei ${count} fonte${count !== 1 ? "s" : ""}...`;
-        else if (stage === "reordenando") liveMsg.statusMsg = "Selecionando as fontes mais relevantes...";
-        else if (stage === "gerando") liveMsg.statusMsg = "Gerando resposta...";
-      },
-      onChunk: (delta) => {
-        liveMsg.statusMsg = "";
-        liveMsg.content += delta;
-      },
-      onSources: (sources) => {
-        liveMsg.sources = sources ?? [];
-      },
-      onDone: () => {
-        _streamAbort = null;
-        liveMsg.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));
+    try {
+      await sendChatStream(trimmed, {
+        conversationId: convId,
+        mode: chatMode.value,
+        signal: abortController.signal,
+        onStatus: ({ stage, count }) => {
+          if (stage === "buscando") liveMsg.statusMsg = "Buscando documentos...";
+          else if (stage === "encontrou") liveMsg.statusMsg = `Encontrei ${count} fonte${count !== 1 ? "s" : ""}...`;
+          else if (stage === "reordenando") liveMsg.statusMsg = "Selecionando as fontes mais relevantes...";
+          else if (stage === "gerando") liveMsg.statusMsg = "Gerando resposta...";
+        },
+        onChunk: (delta) => {
+          liveMsg.statusMsg = "";
+          liveMsg.content += delta;
+        },
+        onSources: (sources) => {
+          liveMsg.sources = sources ?? [];
+        },
+        onDone: () => {
+          liveMsg.streaming = false;
+          if (_streamAbort !== abortController) return;
+          _streamAbort = null;
+          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) => {
+          liveMsg.streaming = false;
+          if (!liveMsg.content) {
+            liveMsg.content = "Não consegui responder no momento.";
+          }
+          if (_streamAbort !== abortController) return;
+          _streamAbort = null;
+          error.value = e?.message || "erro";
+          loading.value = false;
         }
-      },
-      onError: (e) => {
+      });
+    } catch (e) {
+      liveMsg.streaming = false;
+      if (!liveMsg.content) {
+        liveMsg.content = "Não consegui responder no momento.";
+      }
+      if (_streamAbort === abortController) {
         _streamAbort = null;
         error.value = e?.message || "erro";
-        if (!liveMsg.content) {
-          liveMsg.content = "Não consegui responder no momento.";
-        }
-        liveMsg.streaming = false;
         loading.value = false;
       }
-    });
+    }
   }
 
   loadConversationsList();

+ 128 - 0
src/composables/useConversas.js

@@ -0,0 +1,128 @@
+import { ref } from "vue";
+import { listarConversas, listarMensagensConversa, responderConversa, excluirConversa as excluirConversaApi } from "../api/conversas.js";
+
+let singleton;
+
+const POLL_INTERVAL_MS = 3000;
+
+export function useConversas() {
+  if (singleton) return singleton;
+
+  const conversas = ref([]);
+  const conversasLoading = ref(false);
+  const conversasError = ref("");
+
+  const conversaAtivaId = ref(null);
+  const mensagens = ref([]);
+  const mensagensLoading = ref(false);
+  const mensagensError = ref("");
+  const enviando = ref(false);
+
+  let pollTimer = null;
+  let ultimoTimestamp = null;
+  let buscandoNovasMensagens = false;
+
+  async function carregarConversas() {
+    conversasLoading.value = true;
+    conversasError.value = "";
+    try {
+      const { results } = await listarConversas();
+      conversas.value = results;
+    } catch (err) {
+      conversasError.value = err?.message ?? "Falha ao carregar conversas";
+    } finally {
+      conversasLoading.value = false;
+    }
+  }
+
+  function pararPolling() {
+    if (pollTimer) {
+      clearInterval(pollTimer);
+      pollTimer = null;
+    }
+  }
+
+  async function buscarNovasMensagens() {
+    if (!conversaAtivaId.value || buscandoNovasMensagens) return;
+    buscandoNovasMensagens = true;
+    try {
+      const { results } = await listarMensagensConversa(conversaAtivaId.value, ultimoTimestamp);
+      if (results.length > 0) {
+        const idsExistentes = new Set(mensagens.value.map((m) => m.Id));
+        const novas = results.filter((m) => !idsExistentes.has(m.Id));
+        mensagens.value.push(...novas);
+        ultimoTimestamp = results[results.length - 1].Timestamp;
+      }
+      await carregarConversas();
+    } catch {
+      // falha pontual de polling não deve interromper o ciclo seguinte
+    } finally {
+      buscandoNovasMensagens = false;
+    }
+  }
+
+  async function abrirConversa(id) {
+    pararPolling();
+    conversaAtivaId.value = id;
+    mensagens.value = [];
+    ultimoTimestamp = null;
+    mensagensLoading.value = true;
+    mensagensError.value = "";
+
+    try {
+      const { results } = await listarMensagensConversa(id);
+      mensagens.value = results;
+      ultimoTimestamp = results.length > 0 ? results[results.length - 1].Timestamp : null;
+    } catch (err) {
+      mensagensError.value = err?.message ?? "Falha ao carregar mensagens";
+    } finally {
+      mensagensLoading.value = false;
+    }
+
+    pollTimer = setInterval(buscarNovasMensagens, POLL_INTERVAL_MS);
+  }
+
+  function fecharConversa() {
+    pararPolling();
+    conversaAtivaId.value = null;
+    mensagens.value = [];
+    ultimoTimestamp = null;
+  }
+
+  async function enviarResposta(texto) {
+    if (!conversaAtivaId.value || !texto.trim()) return;
+    enviando.value = true;
+    try {
+      const mensagem = await responderConversa(conversaAtivaId.value, texto.trim());
+      mensagens.value.push(mensagem);
+      ultimoTimestamp = mensagem.Timestamp;
+      await carregarConversas();
+    } finally {
+      enviando.value = false;
+    }
+  }
+
+  async function excluirConversa(id) {
+    await excluirConversaApi(id);
+    conversas.value = conversas.value.filter((c) => c.Id !== id);
+    if (conversaAtivaId.value === id) fecharConversa();
+  }
+
+  singleton = {
+    conversas,
+    conversasLoading,
+    conversasError,
+    conversaAtivaId,
+    mensagens,
+    mensagensLoading,
+    mensagensError,
+    enviando,
+    carregarConversas,
+    abrirConversa,
+    fecharConversa,
+    enviarResposta,
+    excluirConversa
+  };
+
+  return singleton;
+}

+ 84 - 0
src/composables/useWhatsappConexao.js

@@ -0,0 +1,84 @@
+import { ref } from "vue";
+import { statusWhatsapp, conectarWhatsapp, desconectarWhatsapp } from "../api/whatsappConexao.js";
+
+const POLL_INTERVAL_MS = 2500;
+
+export function useWhatsappConexao() {
+  const status = ref("desconectado");
+  const qrDataUrl = ref(null);
+  const telefone = ref(null);
+  const ultimoErro = ref(null);
+  const carregando = ref(false);
+  const erro = ref("");
+
+  let pollTimer = null;
+
+  function aplicarEstado(estado) {
+    status.value = estado.status;
+    qrDataUrl.value = estado.qrDataUrl;
+    telefone.value = estado.telefone;
+    ultimoErro.value = estado.ultimoErro;
+  }
+
+  function pararPolling() {
+    if (pollTimer) {
+      clearInterval(pollTimer);
+      pollTimer = null;
+    }
+  }
+
+  async function atualizarStatus() {
+    try {
+      aplicarEstado(await statusWhatsapp());
+    } catch (err) {
+      erro.value = err?.message ?? "Falha ao consultar status do WhatsApp";
+    }
+  }
+
+  function iniciarPolling() {
+    pararPolling();
+    pollTimer = setInterval(async () => {
+      await atualizarStatus();
+      if (status.value === "conectado" || status.value === "desconectado") pararPolling();
+    }, POLL_INTERVAL_MS);
+  }
+
+  async function conectar() {
+    carregando.value = true;
+    erro.value = "";
+    try {
+      aplicarEstado(await conectarWhatsapp());
+      iniciarPolling();
+    } catch (err) {
+      erro.value = err?.message ?? "Falha ao iniciar conexão do WhatsApp";
+    } finally {
+      carregando.value = false;
+    }
+  }
+
+  async function desconectar() {
+    carregando.value = true;
+    erro.value = "";
+    try {
+      pararPolling();
+      aplicarEstado(await desconectarWhatsapp());
+    } catch (err) {
+      erro.value = err?.message ?? "Falha ao desconectar o WhatsApp";
+    } finally {
+      carregando.value = false;
+    }
+  }
+
+  return {
+    status,
+    qrDataUrl,
+    telefone,
+    ultimoErro,
+    carregando,
+    erro,
+    atualizarStatus,
+    conectar,
+    desconectar,
+    pararPolling
+  };
+}

+ 18 - 0
src/layout/SidebarSistema.vue

@@ -142,6 +142,24 @@ async function novaConversa() {
           </svg>
           <span :class="classeRotulo">Pesquisar</span>
         </button>
+
+        <button
+          type="button"
+          :class="classeItemSidebar(rotaAtiva === 'conversas-whatsapp')"
+          :title="sidebarRecolhida ? 'Atendimento WhatsApp' : ''"
+          @click="irPara('conversas-whatsapp')"
+        >
+          <svg
+            :class="classeIcon(rotaAtiva === 'conversas-whatsapp')"
+            viewBox="0 0 24 24"
+            fill="none"
+            stroke="currentColor"
+            stroke-width="1.8"
+          >
+            <path stroke-linecap="round" stroke-linejoin="round" d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
+          </svg>
+          <span :class="classeRotulo">Atendimento WhatsApp</span>
+        </button>
       </nav>
 
       

+ 2 - 0
src/router/index.js

@@ -6,6 +6,7 @@ import ConfiguracoesView from "../views/configuracoes/ConfiguracoesView.vue";
 import UsuariosView from "../views/usuarios/UsuariosView.vue";
 import AvaliacoesView from "../views/atendimentos/AvaliacoesView.vue";
 import AtendimentoView from "../views/atendimentos/AtendimentoView.vue";
+import ConversasWhatsappView from "../views/conversas/ConversasWhatsappView.vue";
 import DocumentosView from "../views/documentos/DocumentosView.vue";
 import CarregarDocumentoView from "../views/documentos/CarregarDocumentoView.vue";
 import LoginView from "../views/auth/LoginView.vue";
@@ -22,6 +23,7 @@ export const router = createRouter({
     { path: "/usuarios", name: "usuarios", component: UsuariosView, meta: { requiresAuth: true } },
     { path: "/atendimentos/avaliacoes", name: "avaliacoes", component: AvaliacoesView, meta: { requiresAuth: true } },
     { path: "/atendimentos/:id(\\d+)", name: "atendimento", component: AtendimentoView, meta: { requiresAuth: true } },
+    { path: "/conversas-whatsapp", name: "conversas-whatsapp", component: ConversasWhatsappView, meta: { requiresAuth: true } },
     { path: "/documentos", name: "documentos", component: DocumentosView, meta: { requiresAuth: true } },
     { path: "/documentos/carregar", name: "documentos-carregar", component: CarregarDocumentoView, meta: { requiresAuth: true } },
     { path: "/:pathMatch(.*)*", redirect: "/" }

+ 289 - 0
src/views/conversas/ConversasWhatsappView.vue

@@ -0,0 +1,289 @@
+<script setup>
+import { computed, onMounted, onUnmounted, ref } from "vue";
+import LayoutSistema from "../../layout/LayoutSistema.vue";
+import ModalWhatsappConexao from "../../components/ModalWhatsappConexao.vue";
+import BaseBadge from "../../components/base/BaseBadge.vue";
+import { useConversas } from "../../composables/useConversas.js";
+import { useWhatsappConexao } from "../../composables/useWhatsappConexao.js";
+import { useAuth } from "../../composables/useAuth.js";
+import { useToast } from "../../composables/useToast.js";
+
+const toast = useToast();
+
+const {
+  conversas,
+  conversasLoading,
+  conversasError,
+  conversaAtivaId,
+  mensagens,
+  mensagensLoading,
+  mensagensError,
+  enviando,
+  carregarConversas,
+  abrirConversa,
+  fecharConversa,
+  enviarResposta,
+  excluirConversa
+} = useConversas();
+
+const confirmandoExclusao = ref(null);
+const excluindo = ref(null);
+
+async function onExcluir(id) {
+  excluindo.value = id;
+  try {
+    await excluirConversa(id);
+    confirmandoExclusao.value = null;
+    toast.sucesso("Conversa excluída com sucesso.");
+  } catch (err) {
+    toast.erro(`Erro ao excluir: ${err?.message ?? "erro desconhecido"}`);
+  } finally {
+    excluindo.value = null;
+  }
+}
+
+const { user } = useAuth();
+const isAdmin = computed(() => String(user.value?.Nivel) === "3");
+
+const {
+  status: whatsappStatus,
+  qrDataUrl,
+  telefone: whatsappTelefone,
+  carregando: conectandoWhatsapp,
+  erro: erroWhatsapp,
+  atualizarStatus: atualizarStatusWhatsapp,
+  conectar: conectarWhatsapp,
+  desconectar: desconectarWhatsapp,
+  pararPolling: pararPollingWhatsapp
+} = useWhatsappConexao();
+
+const mostrarModalConexao = ref(false);
+
+const badgeConexaoVariant = computed(() => {
+  if (whatsappStatus.value === "conectado") return "success";
+  if (whatsappStatus.value === "qr_pendente" || whatsappStatus.value === "conectando") return "warning";
+  return "danger";
+});
+
+const badgeConexaoTexto = computed(() => {
+  if (whatsappStatus.value === "conectado") return `Conectado — ${whatsappTelefone.value}`;
+  if (whatsappStatus.value === "qr_pendente") return "Aguardando leitura do QR";
+  if (whatsappStatus.value === "conectando") return "Conectando...";
+  return "Desconectado";
+});
+
+async function abrirConexao() {
+  mostrarModalConexao.value = true;
+  await conectarWhatsapp();
+}
+
+function fecharConexao() {
+  mostrarModalConexao.value = false;
+  pararPollingWhatsapp();
+}
+
+async function desconectar() {
+  await desconectarWhatsapp();
+}
+
+const texto = ref("");
+
+const conversaAtiva = computed(() => conversas.value.find((c) => c.Id === conversaAtivaId.value) ?? null);
+
+function formatHora(value) {
+  if (!value) return "";
+  const d = new Date(value);
+  if (Number.isNaN(d.getTime())) return "";
+  return d.toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" });
+}
+
+function formatPrevia(conversa) {
+  return conversa.NomeContato || conversa.Telefone;
+}
+
+async function enviar() {
+  if (!texto.value.trim() || enviando.value) return;
+  const valor = texto.value;
+  texto.value = "";
+  await enviarResposta(valor);
+}
+
+let refreshTimer = null;
+
+onMounted(() => {
+  carregarConversas();
+  refreshTimer = setInterval(carregarConversas, 5000);
+  if (isAdmin.value) atualizarStatusWhatsapp();
+});
+
+onUnmounted(() => {
+  if (refreshTimer) clearInterval(refreshTimer);
+  fecharConversa();
+  pararPollingWhatsapp();
+});
+</script>
+
+<template>
+  <LayoutSistema>
+    <div v-if="isAdmin" class="mb-4 flex items-center justify-between gap-3 rounded-2xl border border-gray-200 bg-gray-100/50 px-4 py-3 shadow-sm dark:border-gray-700 dark:bg-secondary/50">
+      <div class="flex items-center gap-2">
+        <span class="text-sm font-medium text-gray-700 dark:text-gray-300">Número conectado:</span>
+        <BaseBadge :variant="badgeConexaoVariant">{{ badgeConexaoTexto }}</BaseBadge>
+      </div>
+      <button
+        v-if="whatsappStatus === 'conectado'"
+        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 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-white/5"
+        :disabled="conectandoWhatsapp"
+        @click="desconectar"
+      >
+        Desconectar
+      </button>
+      <button
+        v-else
+        type="button"
+        class="rounded-xl bg-primary px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50"
+        :disabled="conectandoWhatsapp"
+        @click="abrirConexao"
+      >
+        Conectar número
+      </button>
+    </div>
+
+    <ModalWhatsappConexao
+      v-if="mostrarModalConexao"
+      :status="whatsappStatus"
+      :qr-data-url="qrDataUrl"
+      :telefone="whatsappTelefone"
+      :erro="erroWhatsapp"
+      @close="fecharConexao"
+    />
+
+    <div class="grid h-[calc(100vh-var(--layout-header-height)-4rem)] grid-cols-[280px_1fr] gap-4">
+      <section class="flex min-h-0 flex-col rounded-2xl border border-gray-200 bg-gray-100/50 shadow-sm backdrop-blur-md dark:border-gray-700 dark:bg-secondary/50">
+        <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">
+          Conversas
+        </div>
+
+        <div v-if="conversasLoading && !conversas.length" class="p-4 text-sm text-gray-500 dark:text-gray-400">Carregando...</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 }}
+        </div>
+        <div v-else-if="!conversas.length" class="p-4 text-sm text-gray-500 dark:text-gray-400">
+          Nenhuma conversa ainda. Mande uma mensagem para o número de teste no WhatsApp.
+        </div>
+
+        <div v-else class="min-h-0 flex-1 overflow-y-auto p-2">
+          <div
+            v-for="c in conversas"
+            :key="c.Id"
+            class="group relative rounded-xl transition-colors"
+            :class="
+              c.Id === conversaAtivaId
+                ? 'bg-primary/10 dark:bg-primary/20'
+                : 'hover:bg-black/[0.03] dark:hover:bg-white/[0.04]'
+            "
+          >
+            <button type="button" class="block w-full p-3 pr-9 text-left" @click="abrirConversa(c.Id)">
+              <div class="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">{{ formatPrevia(c) }}</div>
+              <div class="mt-0.5 truncate text-xs text-gray-500 dark:text-gray-400">{{ c.Telefone }}</div>
+            </button>
+
+            <button
+              v-if="isAdmin"
+              type="button"
+              class="absolute right-1 top-1 rounded-lg border border-transparent p-1.5 text-gray-400 opacity-0 transition-opacity hover:border-red-200 hover:bg-red-50 hover:text-red-600 group-hover:opacity-100 dark:hover:border-red-900/50 dark:hover:bg-red-950/30 dark:hover:text-red-400"
+              title="Excluir conversa"
+              @click="confirmandoExclusao = c.Id"
+            >
+              <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
+              v-if="confirmandoExclusao === c.Id"
+              class="absolute inset-0 flex flex-col items-center justify-center gap-2 rounded-xl bg-background p-3 dark:bg-[#101827]"
+            >
+              <span class="text-xs text-gray-500 dark:text-gray-400">Excluir esta conversa?</span>
+              <div class="flex items-center gap-1.5">
+                <button
+                  type="button"
+                  class="rounded-lg bg-red-600 px-3 py-1 text-xs font-medium text-white disabled:opacity-50"
+                  :disabled="excluindo === c.Id"
+                  @click="onExcluir(c.Id)"
+                >
+                  Confirmar
+                </button>
+                <button
+                  type="button"
+                  class="rounded-lg border border-gray-300 px-3 py-1 text-xs font-medium text-gray-700 dark:border-gray-600 dark:text-gray-300"
+                  @click="confirmandoExclusao = null"
+                >
+                  Cancelar
+                </button>
+              </div>
+            </div>
+          </div>
+        </div>
+      </section>
+
+      <section class="flex min-h-0 flex-col rounded-2xl border border-gray-200 bg-background/70 shadow-sm dark:border-gray-700 dark:bg-background/20">
+        <template v-if="!conversaAtivaId">
+          <div class="flex flex-1 items-center justify-center text-sm text-gray-500 dark:text-gray-400">
+            Selecione uma conversa para começar a responder.
+          </div>
+        </template>
+
+        <template v-else>
+          <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">
+            {{ conversaAtiva ? formatPrevia(conversaAtiva) : "" }}
+          </div>
+
+          <div class="min-h-0 flex-1 overflow-y-auto p-4">
+            <div v-if="mensagensLoading" class="text-sm text-gray-500 dark:text-gray-400">Carregando conversa...</div>
+            <div v-else-if="mensagensError" class="rounded-lg bg-red-50 px-4 py-3 text-xs text-red-800 dark:bg-red-900/30 dark:text-red-300">
+              {{ mensagensError }}
+            </div>
+            <div v-else-if="!mensagens.length" class="text-sm text-gray-500 dark:text-gray-400">
+              Nenhuma mensagem nessa conversa ainda.
+            </div>
+
+            <div v-else class="space-y-2">
+              <div v-for="m in mensagens" :key="m.Id" class="flex" :class="m.Direcao === 'saida' ? 'justify-end' : 'justify-start'">
+                <div
+                  class="max-w-[75%] rounded-2xl px-3.5 py-2 shadow-sm"
+                  :class="
+                    m.Direcao === 'saida'
+                      ? 'rounded-br-sm bg-primary/10 dark:bg-primary/20'
+                      : 'rounded-bl-sm bg-gray-100 dark:bg-gray-800'
+                  "
+                >
+                  <p class="whitespace-pre-wrap break-words text-sm text-gray-800 dark:text-gray-200">{{ m.Corpo }}</p>
+                  <div class="mt-0.5 text-right text-[10px] text-gray-400 dark:text-gray-500">{{ formatHora(m.Timestamp) }}</div>
+                </div>
+              </div>
+            </div>
+          </div>
+
+          <form class="flex items-center gap-2 border-t border-gray-200 p-3 dark:border-gray-700" @submit.prevent="enviar">
+            <input
+              v-model="texto"
+              type="text"
+              placeholder="Digite uma resposta..."
+              class="min-w-0 flex-1 rounded-xl border border-gray-300 bg-background px-3 py-2 text-sm outline-none focus:border-primary dark:border-gray-600"
+              :disabled="enviando"
+            />
+            <button
+              type="submit"
+              class="shrink-0 rounded-xl bg-primary px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
+              :disabled="enviando || !texto.trim()"
+            >
+              Enviar
+            </button>
+          </form>
+        </template>
+      </section>
+    </div>
+  </LayoutSistema>
+</template>