|
@@ -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>
|