ConversasWhatsappView.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  1. <script setup>
  2. import { computed, onMounted, onUnmounted, ref, watch } from "vue";
  3. import LayoutSistema from "../../layout/LayoutSistema.vue";
  4. import ModalWhatsappConexao from "../../components/ModalWhatsappConexao.vue";
  5. import ModalMidia from "../../components/ModalMidia.vue";
  6. import BaseBadge from "../../components/base/BaseBadge.vue";
  7. import { useConversas } from "../../composables/useConversas.js";
  8. import { useWhatsappConexao } from "../../composables/useWhatsappConexao.js";
  9. import { useAuth } from "../../composables/useAuth.js";
  10. import { useToast } from "../../composables/useToast.js";
  11. import { ehAudio, ehImagem, ehVideo } from "../../utils/midia.js";
  12. const toast = useToast();
  13. const {
  14. conversas,
  15. conversasLoading,
  16. conversasError,
  17. conversaAtivaId,
  18. mensagens,
  19. mensagensLoading,
  20. mensagensError,
  21. enviando,
  22. midiaUrls,
  23. midiaErros,
  24. resolverMidia,
  25. carregarConversas,
  26. abrirConversa,
  27. fecharConversa,
  28. enviarResposta,
  29. excluirConversa
  30. } = useConversas();
  31. const mediaViewer = ref(null);
  32. function midiaInfo(m) {
  33. return { tipo: m.TipoMidia, transcricao: null, url: midiaUrls.value[m.Id] };
  34. }
  35. function abrirMidia(m) {
  36. const url = midiaUrls.value[m.Id];
  37. if (!url) return;
  38. mediaViewer.value = { url, tipo: ehImagem(midiaInfo(m)) ? "imagem" : "video" };
  39. }
  40. watch(
  41. mensagens,
  42. (lista) => {
  43. for (const m of lista) {
  44. if (m.MidiaPath) resolverMidia(m);
  45. }
  46. },
  47. { immediate: true }
  48. );
  49. const confirmandoExclusao = ref(null);
  50. const excluindo = ref(null);
  51. async function onExcluir(id) {
  52. excluindo.value = id;
  53. try {
  54. await excluirConversa(id);
  55. confirmandoExclusao.value = null;
  56. toast.sucesso("Conversa excluída com sucesso.");
  57. } catch (err) {
  58. toast.erro(`Erro ao excluir: ${err?.message ?? "erro desconhecido"}`);
  59. } finally {
  60. excluindo.value = null;
  61. }
  62. }
  63. const { user } = useAuth();
  64. const isAdmin = computed(() => String(user.value?.Nivel) === "3");
  65. const {
  66. status: whatsappStatus,
  67. qrDataUrl,
  68. telefone: whatsappTelefone,
  69. carregando: conectandoWhatsapp,
  70. erro: erroWhatsapp,
  71. atualizarStatus: atualizarStatusWhatsapp,
  72. conectar: conectarWhatsapp,
  73. desconectar: desconectarWhatsapp,
  74. pararPolling: pararPollingWhatsapp
  75. } = useWhatsappConexao();
  76. const mostrarModalConexao = ref(false);
  77. const badgeConexaoVariant = computed(() => {
  78. if (whatsappStatus.value === "conectado") return "success";
  79. if (whatsappStatus.value === "qr_pendente" || whatsappStatus.value === "conectando") return "warning";
  80. return "danger";
  81. });
  82. const badgeConexaoTexto = computed(() => {
  83. if (whatsappStatus.value === "conectado") return `Conectado — ${whatsappTelefone.value}`;
  84. if (whatsappStatus.value === "qr_pendente") return "Aguardando leitura do QR";
  85. if (whatsappStatus.value === "conectando") return "Conectando...";
  86. return "Desconectado";
  87. });
  88. async function abrirConexao() {
  89. mostrarModalConexao.value = true;
  90. await conectarWhatsapp();
  91. }
  92. function fecharConexao() {
  93. mostrarModalConexao.value = false;
  94. pararPollingWhatsapp();
  95. }
  96. async function desconectar() {
  97. await desconectarWhatsapp();
  98. }
  99. const texto = ref("");
  100. const conversaAtiva = computed(() => conversas.value.find((c) => c.Id === conversaAtivaId.value) ?? null);
  101. function formatHora(value) {
  102. if (!value) return "";
  103. const d = new Date(value);
  104. if (Number.isNaN(d.getTime())) return "";
  105. return d.toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" });
  106. }
  107. function formatPrevia(conversa) {
  108. return conversa.NomeContato || conversa.Telefone;
  109. }
  110. async function enviar() {
  111. if (!texto.value.trim() || enviando.value) return;
  112. const valor = texto.value;
  113. texto.value = "";
  114. await enviarResposta(valor);
  115. }
  116. let refreshTimer = null;
  117. onMounted(() => {
  118. carregarConversas();
  119. refreshTimer = setInterval(carregarConversas, 5000);
  120. if (isAdmin.value) atualizarStatusWhatsapp();
  121. });
  122. onUnmounted(() => {
  123. if (refreshTimer) clearInterval(refreshTimer);
  124. fecharConversa();
  125. pararPollingWhatsapp();
  126. });
  127. </script>
  128. <template>
  129. <LayoutSistema>
  130. <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">
  131. <div class="flex items-center gap-2">
  132. <span class="text-sm font-medium text-gray-700 dark:text-gray-300">Número conectado:</span>
  133. <BaseBadge :variant="badgeConexaoVariant">{{ badgeConexaoTexto }}</BaseBadge>
  134. </div>
  135. <button
  136. v-if="whatsappStatus === 'conectado'"
  137. type="button"
  138. 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"
  139. :disabled="conectandoWhatsapp"
  140. @click="desconectar"
  141. >
  142. Desconectar
  143. </button>
  144. <button
  145. v-else
  146. type="button"
  147. class="rounded-xl bg-primary px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50"
  148. :disabled="conectandoWhatsapp"
  149. @click="abrirConexao"
  150. >
  151. Conectar número
  152. </button>
  153. </div>
  154. <ModalWhatsappConexao
  155. v-if="mostrarModalConexao"
  156. :status="whatsappStatus"
  157. :qr-data-url="qrDataUrl"
  158. :telefone="whatsappTelefone"
  159. :erro="erroWhatsapp"
  160. @close="fecharConexao"
  161. />
  162. <div class="grid h-[calc(100vh-var(--layout-header-height)-4rem)] grid-cols-[280px_1fr] gap-4">
  163. <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">
  164. <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">
  165. Conversas
  166. </div>
  167. <div v-if="conversasLoading && !conversas.length" class="p-4 text-sm text-gray-500 dark:text-gray-400">Carregando...</div>
  168. <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">
  169. {{ conversasError }}
  170. </div>
  171. <div v-else-if="!conversas.length" class="p-4 text-sm text-gray-500 dark:text-gray-400">
  172. Nenhuma conversa ainda. Mande uma mensagem para o número de teste no WhatsApp.
  173. </div>
  174. <div v-else class="min-h-0 flex-1 overflow-y-auto p-2 space-y-1">
  175. <div
  176. v-for="c in conversas"
  177. :key="c.Id"
  178. class="group relative rounded-xl transition-colors"
  179. :class="
  180. c.Id === conversaAtivaId
  181. ? 'bg-primary/10 dark:bg-primary/20'
  182. : 'hover:bg-black/[0.03] dark:hover:bg-white/[0.04]'
  183. "
  184. >
  185. <button type="button" class="block w-full p-3 pr-9 text-left" @click="abrirConversa(c.Id)">
  186. <div class="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">{{ formatPrevia(c) }}</div>
  187. <div class="mt-0.5 truncate text-xs text-gray-500 dark:text-gray-400">{{ c.Telefone }}</div>
  188. </button>
  189. <button
  190. v-if="isAdmin"
  191. type="button"
  192. 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"
  193. title="Excluir conversa"
  194. @click="confirmandoExclusao = c.Id"
  195. >
  196. <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
  197. <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" />
  198. </svg>
  199. </button>
  200. <div
  201. v-if="confirmandoExclusao === c.Id"
  202. class="absolute inset-0 flex flex-col items-center justify-center gap-2 rounded-xl bg-background p-3 dark:bg-[#101827]"
  203. >
  204. <span class="text-xs text-gray-500 dark:text-gray-400">Excluir esta conversa?</span>
  205. <div class="flex items-center gap-1.5">
  206. <button
  207. type="button"
  208. class="rounded-lg bg-red-600 px-3 py-1 text-xs font-medium text-white disabled:opacity-50"
  209. :disabled="excluindo === c.Id"
  210. @click="onExcluir(c.Id)"
  211. >
  212. Confirmar
  213. </button>
  214. <button
  215. type="button"
  216. 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"
  217. @click="confirmandoExclusao = null"
  218. >
  219. Cancelar
  220. </button>
  221. </div>
  222. </div>
  223. </div>
  224. </div>
  225. </section>
  226. <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">
  227. <template v-if="!conversaAtivaId">
  228. <div class="flex flex-1 items-center justify-center text-sm text-gray-500 dark:text-gray-400">
  229. Selecione uma conversa para começar a responder.
  230. </div>
  231. </template>
  232. <template v-else>
  233. <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">
  234. {{ conversaAtiva ? formatPrevia(conversaAtiva) : "" }}
  235. </div>
  236. <div class="min-h-0 flex-1 overflow-y-auto p-4">
  237. <div v-if="mensagensLoading" class="text-sm text-gray-500 dark:text-gray-400">Carregando conversa...</div>
  238. <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">
  239. {{ mensagensError }}
  240. </div>
  241. <div v-else-if="!mensagens.length" class="text-sm text-gray-500 dark:text-gray-400">
  242. Nenhuma mensagem nessa conversa ainda.
  243. </div>
  244. <div v-else class="space-y-2">
  245. <div v-for="m in mensagens" :key="m.Id" class="flex" :class="m.Direcao === 'saida' ? 'justify-end' : 'justify-start'">
  246. <div
  247. class="max-w-[75%] rounded-2xl px-3.5 py-2 shadow-sm"
  248. :class="
  249. m.Direcao === 'saida'
  250. ? 'rounded-br-sm bg-primary/10 dark:bg-primary/20'
  251. : 'rounded-bl-sm bg-gray-100 dark:bg-gray-800'
  252. "
  253. >
  254. <template v-if="ehAudio(midiaInfo(m))">
  255. <div class="flex items-center gap-1.5 text-xs font-medium text-gray-500 dark:text-gray-400">
  256. <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
  257. <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" />
  258. </svg>
  259. Mensagem de áudio
  260. </div>
  261. <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>
  262. <button
  263. v-else-if="midiaErros[m.Id]"
  264. type="button"
  265. class="mt-1 text-xs font-medium text-red-500 underline"
  266. @click="resolverMidia(m)"
  267. >
  268. Falha ao carregar áudio — tentar novamente
  269. </button>
  270. <p v-else class="mt-1 text-xs italic text-gray-400 dark:text-gray-500">Carregando áudio...</p>
  271. <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>
  272. </template>
  273. <template v-else-if="ehImagem(midiaInfo(m)) && m.MidiaPath">
  274. <img
  275. v-if="midiaUrls[m.Id]"
  276. :src="midiaUrls[m.Id]"
  277. alt="Imagem enviada na conversa"
  278. loading="lazy"
  279. title="Ver imagem"
  280. class="max-h-80 max-w-full cursor-pointer rounded-lg object-contain"
  281. @click="abrirMidia(m)"
  282. />
  283. <button
  284. v-else-if="midiaErros[m.Id]"
  285. type="button"
  286. class="text-xs font-medium text-red-500 underline"
  287. @click="resolverMidia(m)"
  288. >
  289. Falha ao carregar imagem — tentar novamente
  290. </button>
  291. <p v-else class="text-xs italic text-gray-400 dark:text-gray-500">Carregando imagem...</p>
  292. <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>
  293. </template>
  294. <template v-else-if="ehVideo(midiaInfo(m)) && m.MidiaPath">
  295. <div
  296. v-if="midiaUrls[m.Id]"
  297. class="relative inline-block cursor-pointer overflow-hidden rounded-lg"
  298. title="Ver vídeo"
  299. @click="abrirMidia(m)"
  300. >
  301. <video :src="midiaUrls[m.Id]" preload="metadata" muted class="max-h-80 max-w-full rounded-lg object-contain"></video>
  302. <div class="absolute inset-0 flex items-center justify-center bg-black/20">
  303. <div class="flex h-11 w-11 items-center justify-center rounded-full bg-white/85 text-gray-900">
  304. <svg class="h-5 w-5 translate-x-0.5" viewBox="0 0 24 24" fill="currentColor">
  305. <path d="M8 5v14l11-7z" />
  306. </svg>
  307. </div>
  308. </div>
  309. </div>
  310. <button
  311. v-else-if="midiaErros[m.Id]"
  312. type="button"
  313. class="text-xs font-medium text-red-500 underline"
  314. @click="resolverMidia(m)"
  315. >
  316. Falha ao carregar vídeo — tentar novamente
  317. </button>
  318. <p v-else class="text-xs italic text-gray-400 dark:text-gray-500">Carregando vídeo...</p>
  319. <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>
  320. </template>
  321. <p v-else-if="m.Corpo" class="whitespace-pre-wrap break-words text-sm text-gray-800 dark:text-gray-200">{{ m.Corpo }}</p>
  322. <a
  323. v-if="m.MidiaPath && !ehAudio(midiaInfo(m)) && !ehImagem(midiaInfo(m)) && !ehVideo(midiaInfo(m)) && midiaUrls[m.Id]"
  324. :href="midiaUrls[m.Id]"
  325. target="_blank"
  326. rel="noopener noreferrer"
  327. class="mt-1 inline-block text-xs font-medium text-primary underline"
  328. >
  329. Abrir arquivo{{ m.MidiaNomeArquivo ? `: ${m.MidiaNomeArquivo}` : "" }}
  330. </a>
  331. <div class="mt-0.5 text-right text-[10px] text-gray-400 dark:text-gray-500">{{ formatHora(m.Timestamp) }}</div>
  332. </div>
  333. </div>
  334. </div>
  335. </div>
  336. <form class="flex items-center gap-2 border-t border-gray-200 p-3 dark:border-gray-700" @submit.prevent="enviar">
  337. <input
  338. v-model="texto"
  339. type="text"
  340. placeholder="Digite uma resposta..."
  341. 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"
  342. :disabled="enviando"
  343. />
  344. <button
  345. type="submit"
  346. class="shrink-0 rounded-xl bg-primary px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
  347. :disabled="enviando || !texto.trim()"
  348. >
  349. Enviar
  350. </button>
  351. </form>
  352. </template>
  353. </section>
  354. </div>
  355. <ModalMidia
  356. v-if="mediaViewer"
  357. :url="mediaViewer.url"
  358. :tipo="mediaViewer.tipo"
  359. @close="mediaViewer = null"
  360. />
  361. </LayoutSistema>
  362. </template>