| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399 |
- <script setup>
- import { computed, onMounted, onUnmounted, ref, watch } from "vue";
- import LayoutSistema from "../../layout/LayoutSistema.vue";
- import ModalWhatsappConexao from "../../components/ModalWhatsappConexao.vue";
- import ModalMidia from "../../components/ModalMidia.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";
- import { ehAudio, ehImagem, ehVideo } from "../../utils/midia.js";
- const toast = useToast();
- const {
- conversas,
- conversasLoading,
- conversasError,
- conversaAtivaId,
- mensagens,
- mensagensLoading,
- mensagensError,
- enviando,
- midiaUrls,
- midiaErros,
- resolverMidia,
- carregarConversas,
- abrirConversa,
- fecharConversa,
- enviarResposta,
- excluirConversa
- } = useConversas();
- const mediaViewer = ref(null);
- function midiaInfo(m) {
- return { tipo: m.TipoMidia, transcricao: null, url: midiaUrls.value[m.Id] };
- }
- function abrirMidia(m) {
- const url = midiaUrls.value[m.Id];
- if (!url) return;
- mediaViewer.value = { url, tipo: ehImagem(midiaInfo(m)) ? "imagem" : "video" };
- }
- watch(
- mensagens,
- (lista) => {
- for (const m of lista) {
- if (m.MidiaPath) resolverMidia(m);
- }
- },
- { immediate: true }
- );
- 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 space-y-1">
- <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'
- "
- >
- <template v-if="ehAudio(midiaInfo(m))">
- <div class="flex items-center gap-1.5 text-xs font-medium text-gray-500 dark:text-gray-400">
- <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
- <path stroke-linecap="round" stroke-linejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 1 1 6 0v8.25a3 3 0 0 1-3 3Z" />
- </svg>
- Mensagem de áudio
- </div>
- <audio v-if="midiaUrls[m.Id]" controls preload="none" :src="midiaUrls[m.Id]" class="mt-1.5 h-9 w-64 max-w-full"></audio>
- <button
- v-else-if="midiaErros[m.Id]"
- type="button"
- class="mt-1 text-xs font-medium text-red-500 underline"
- @click="resolverMidia(m)"
- >
- Falha ao carregar áudio — tentar novamente
- </button>
- <p v-else class="mt-1 text-xs italic text-gray-400 dark:text-gray-500">Carregando áudio...</p>
- <p v-if="m.Corpo" class="mt-1 whitespace-pre-wrap break-words text-sm text-gray-800 dark:text-gray-200">{{ m.Corpo }}</p>
- </template>
- <template v-else-if="ehImagem(midiaInfo(m)) && m.MidiaPath">
- <img
- v-if="midiaUrls[m.Id]"
- :src="midiaUrls[m.Id]"
- alt="Imagem enviada na conversa"
- loading="lazy"
- title="Ver imagem"
- class="max-h-80 max-w-full cursor-pointer rounded-lg object-contain"
- @click="abrirMidia(m)"
- />
- <button
- v-else-if="midiaErros[m.Id]"
- type="button"
- class="text-xs font-medium text-red-500 underline"
- @click="resolverMidia(m)"
- >
- Falha ao carregar imagem — tentar novamente
- </button>
- <p v-else class="text-xs italic text-gray-400 dark:text-gray-500">Carregando imagem...</p>
- <p v-if="m.Corpo" class="mt-1 whitespace-pre-wrap break-words text-sm text-gray-800 dark:text-gray-200">{{ m.Corpo }}</p>
- </template>
- <template v-else-if="ehVideo(midiaInfo(m)) && m.MidiaPath">
- <div
- v-if="midiaUrls[m.Id]"
- class="relative inline-block cursor-pointer overflow-hidden rounded-lg"
- title="Ver vídeo"
- @click="abrirMidia(m)"
- >
- <video :src="midiaUrls[m.Id]" preload="metadata" muted class="max-h-80 max-w-full rounded-lg object-contain"></video>
- <div class="absolute inset-0 flex items-center justify-center bg-black/20">
- <div class="flex h-11 w-11 items-center justify-center rounded-full bg-white/85 text-gray-900">
- <svg class="h-5 w-5 translate-x-0.5" viewBox="0 0 24 24" fill="currentColor">
- <path d="M8 5v14l11-7z" />
- </svg>
- </div>
- </div>
- </div>
- <button
- v-else-if="midiaErros[m.Id]"
- type="button"
- class="text-xs font-medium text-red-500 underline"
- @click="resolverMidia(m)"
- >
- Falha ao carregar vídeo — tentar novamente
- </button>
- <p v-else class="text-xs italic text-gray-400 dark:text-gray-500">Carregando vídeo...</p>
- <p v-if="m.Corpo" class="mt-1 whitespace-pre-wrap break-words text-sm text-gray-800 dark:text-gray-200">{{ m.Corpo }}</p>
- </template>
- <p v-else-if="m.Corpo" class="whitespace-pre-wrap break-words text-sm text-gray-800 dark:text-gray-200">{{ m.Corpo }}</p>
- <a
- v-if="m.MidiaPath && !ehAudio(midiaInfo(m)) && !ehImagem(midiaInfo(m)) && !ehVideo(midiaInfo(m)) && midiaUrls[m.Id]"
- :href="midiaUrls[m.Id]"
- target="_blank"
- rel="noopener noreferrer"
- class="mt-1 inline-block text-xs font-medium text-primary underline"
- >
- Abrir arquivo{{ m.MidiaNomeArquivo ? `: ${m.MidiaNomeArquivo}` : "" }}
- </a>
- <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>
- <ModalMidia
- v-if="mediaViewer"
- :url="mediaViewer.url"
- :tipo="mediaViewer.tipo"
- @close="mediaViewer = null"
- />
- </LayoutSistema>
- </template>
|