ConversasWhatsappView.vue 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561
  1. <script setup>
  2. import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
  3. import LayoutSistema from "../../layout/LayoutSistema.vue";
  4. import ModalMidia from "../../components/ModalMidia.vue";
  5. import SkeletonConversation from "../../components/skeletons/SkeletonConversation.vue";
  6. import BaseBadge from "../../components/base/BaseBadge.vue";
  7. import ChatBubble from "../../components/base/ChatBubble.vue";
  8. import BaseIcon from "../../components/base/BaseIcon.vue";
  9. import ConfirmDialog from "../../components/base/ConfirmDialog.vue";
  10. import { MessageCircle, Trash2, ArrowLeft, Mic, Play, Sparkles, RefreshCw, X, TriangleAlert } from "lucide-vue-next";
  11. import { useConversas } from "../../composables/useConversas.js";
  12. import { useAuth } from "../../composables/useAuth.js";
  13. import { useToast } from "../../composables/useToast.js";
  14. import { ehAudio, ehImagem, ehVideo } from "../../utils/midia.js";
  15. import { agruparPorRecencia, ehNovoDia, formatarData, formatarHora, formatarRecencia } from "../../utils/data.js";
  16. import { avatarIniciais, avatarCor } from "../../utils/avatar.js";
  17. const toast = useToast();
  18. const {
  19. conversas,
  20. conversasLoading,
  21. conversasError,
  22. conversaAtivaId,
  23. mensagens,
  24. mensagensLoading,
  25. mensagensError,
  26. enviando,
  27. midiaUrls,
  28. midiaErros,
  29. sugestao,
  30. sugestaoLoading,
  31. sugestaoError,
  32. resolverMidia,
  33. carregarConversas,
  34. abrirConversa,
  35. fecharConversa,
  36. enviarResposta,
  37. excluirConversa,
  38. buscarSugestao,
  39. descartarSugestao
  40. } = useConversas();
  41. const mediaViewer = ref(null);
  42. const mensagensEl = ref(null);
  43. const NEAR_BOTTOM_THRESHOLD_PX = 80;
  44. function isNearBottom() {
  45. const el = mensagensEl.value;
  46. if (!el) return true;
  47. return el.scrollHeight - el.scrollTop - el.clientHeight <= NEAR_BOTTOM_THRESHOLD_PX;
  48. }
  49. watch(
  50. () => [mensagens.value.length, mensagensLoading.value],
  51. async () => {
  52. const el = mensagensEl.value;
  53. const shouldStick = !el || isNearBottom();
  54. await nextTick();
  55. if (!el || !shouldStick) return;
  56. el.scrollTop = el.scrollHeight;
  57. }
  58. );
  59. watch(conversaAtivaId, async () => {
  60. await nextTick();
  61. const el = mensagensEl.value;
  62. if (el) el.scrollTop = el.scrollHeight;
  63. });
  64. function midiaInfo(m) {
  65. return { tipo: m.TipoMidia, transcricao: null, url: midiaUrls.value[m.Id] };
  66. }
  67. function abrirMidia(m) {
  68. const url = midiaUrls.value[m.Id];
  69. if (!url) return;
  70. mediaViewer.value = { url, tipo: ehImagem(midiaInfo(m)) ? "imagem" : "video" };
  71. }
  72. // Durante o polling a lista só cresce por append, então varrer tudo a cada ciclo é
  73. // trabalho jogado fora; ao trocar de conversa ela é reiniciada e o índice volta a zero.
  74. let midiaResolvidaAte = 0;
  75. watch(
  76. mensagens,
  77. (lista) => {
  78. if (lista.length < midiaResolvidaAte) midiaResolvidaAte = 0;
  79. for (let i = midiaResolvidaAte; i < lista.length; i += 1) {
  80. if (lista[i].MidiaPath) resolverMidia(lista[i]);
  81. }
  82. midiaResolvidaAte = lista.length;
  83. },
  84. { immediate: true }
  85. );
  86. const confirmandoExclusao = ref(null);
  87. const excluindo = ref(null);
  88. async function onExcluir(id) {
  89. excluindo.value = id;
  90. try {
  91. await excluirConversa(id);
  92. confirmandoExclusao.value = null;
  93. toast.sucesso("Conversa excluída com sucesso.");
  94. } catch (err) {
  95. toast.erro(`Erro ao excluir: ${err?.message ?? "erro desconhecido"}`);
  96. } finally {
  97. excluindo.value = null;
  98. }
  99. }
  100. const { isAdmin } = useAuth();
  101. const texto = ref("");
  102. const inputResposta = ref(null);
  103. const RESPOSTA_ALTURA_MAX_PX = 120;
  104. function ajustarAlturaResposta() {
  105. const el = inputResposta.value;
  106. if (!el) return;
  107. el.style.height = "auto";
  108. el.style.height = `${Math.min(el.scrollHeight, RESPOSTA_ALTURA_MAX_PX)}px`;
  109. }
  110. function usarSugestao() {
  111. if (!sugestao.value) return;
  112. texto.value = sugestao.value;
  113. nextTick(() => {
  114. inputResposta.value?.focus();
  115. ajustarAlturaResposta();
  116. });
  117. }
  118. const conversaAtiva = computed(() => conversas.value.find((c) => c.Id === conversaAtivaId.value) ?? null);
  119. function statusBadgeTexto(status) {
  120. if (status === "aberta") return "Aberta";
  121. if (status === "fechada") return "Fechada";
  122. return status || "—";
  123. }
  124. function montarConversaView(c) {
  125. const nome = c.NomeContato || c.Telefone;
  126. return {
  127. id: c.Id,
  128. titulo: nome,
  129. telefone: c.Telefone,
  130. avatarCor: avatarCor(nome),
  131. avatarIniciais: avatarIniciais(nome),
  132. recencia: formatarRecencia(c.UltimaMensagemEm ?? c.CreatedAt),
  133. statusVariant: c.Status === "aberta" ? "success" : "default",
  134. statusTexto: statusBadgeTexto(c.Status)
  135. };
  136. }
  137. // A lista repinta a cada ciclo de polling. Tudo o que dependia só da conversa é calculado
  138. // aqui, numa passada, em vez de virar chamada de função repetida no template.
  139. const conversasAgrupadas = computed(() =>
  140. agruparPorRecencia(conversas.value, (c) => c.UltimaMensagemEm ?? c.CreatedAt).map((grupo) => ({
  141. label: grupo.label,
  142. items: grupo.items.map(montarConversaView)
  143. }))
  144. );
  145. const conversaAtivaView = computed(() =>
  146. conversaAtiva.value ? montarConversaView(conversaAtiva.value) : null
  147. );
  148. function novoDia(idx) {
  149. return ehNovoDia(mensagens.value[idx]?.Timestamp, idx === 0 ? null : mensagens.value[idx - 1]?.Timestamp);
  150. }
  151. function mensagemErroEnvio(err) {
  152. if (err?.message === "whatsapp_nao_conectado") return "WhatsApp desconectado. Peça a um admin para reconectar.";
  153. return `Erro ao enviar: ${err?.message ?? "erro desconhecido"}`;
  154. }
  155. async function enviar() {
  156. if (!texto.value.trim() || enviando.value) return;
  157. const valor = texto.value;
  158. texto.value = "";
  159. await nextTick();
  160. ajustarAlturaResposta();
  161. try {
  162. await enviarResposta(valor);
  163. } catch (err) {
  164. texto.value = valor;
  165. toast.erro(mensagemErroEnvio(err));
  166. await nextTick();
  167. ajustarAlturaResposta();
  168. }
  169. }
  170. let refreshTimer = null;
  171. function pollConversas() {
  172. if (document.visibilityState !== "visible") return;
  173. carregarConversas();
  174. }
  175. function handleVisibilityChange() {
  176. if (document.visibilityState === "visible") carregarConversas();
  177. }
  178. onMounted(() => {
  179. carregarConversas();
  180. refreshTimer = setInterval(pollConversas, 5000);
  181. document.addEventListener("visibilitychange", handleVisibilityChange);
  182. });
  183. onUnmounted(() => {
  184. if (refreshTimer) clearInterval(refreshTimer);
  185. document.removeEventListener("visibilitychange", handleVisibilityChange);
  186. fecharConversa();
  187. });
  188. </script>
  189. <template>
  190. <LayoutSistema>
  191. <div class="grid h-[calc(100vh-var(--layout-header-height)-4rem)] grid-cols-[300px_1fr] gap-4 max-whatsapp:grid-cols-1 max-whatsapp:gap-0">
  192. <section
  193. 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"
  194. :class="conversaAtivaId ? 'max-whatsapp:hidden' : ''"
  195. >
  196. <div class="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-gray-700">
  197. <span class="text-sm font-semibold text-foreground">Conversas</span>
  198. <span v-if="conversas.length" class="rounded-full bg-black/[0.04] px-2 py-0.5 text-[11px] font-medium text-gray-500 dark:bg-white/[0.06] dark:text-gray-400">
  199. {{ conversas.length }}
  200. </span>
  201. </div>
  202. <div v-if="conversasLoading && !conversas.length" class="flex-1 overflow-y-auto p-2">
  203. <SkeletonConversation :count="5" />
  204. </div>
  205. <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">
  206. {{ conversasError }}
  207. </div>
  208. <div v-else-if="!conversas.length" class="flex flex-1 flex-col items-center justify-center gap-2 p-4 text-center">
  209. <BaseIcon :icon="MessageCircle" class="h-8 w-8 text-gray-300 dark:text-gray-600" stroke-width="1.5" />
  210. <p class="text-sm text-muted-foreground">Nenhuma conversa ainda.</p>
  211. <p class="text-xs text-muted-foreground">Mande uma mensagem para o número de teste no WhatsApp.</p>
  212. </div>
  213. <div v-else class="scroll-thin min-h-0 flex-1 overflow-y-auto p-2">
  214. <template v-for="grupo in conversasAgrupadas" :key="grupo.label">
  215. <div class="px-2 pt-2 pb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
  216. {{ grupo.label }}
  217. </div>
  218. <div class="space-y-1">
  219. <div
  220. v-for="c in grupo.items"
  221. :key="c.id"
  222. class="group relative rounded-xl transition-colors"
  223. :class="
  224. c.id === conversaAtivaId
  225. ? 'bg-primary/10 dark:bg-primary/20'
  226. : 'hover:bg-black/[0.03] dark:hover:bg-white/[0.04]'
  227. "
  228. >
  229. <button type="button" class="flex w-full items-center gap-3 p-2.5 pr-9 text-left" @click="abrirConversa(c.id)">
  230. <div
  231. class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white ring-1 ring-white/20"
  232. :class="c.avatarCor"
  233. >
  234. {{ c.avatarIniciais }}
  235. </div>
  236. <div class="min-w-0 flex-1">
  237. <div class="flex items-center justify-between gap-2">
  238. <span class="truncate text-sm font-semibold text-foreground">{{ c.titulo }}</span>
  239. <span v-if="c.recencia" class="shrink-0 text-[11px] text-muted-foreground">
  240. {{ c.recencia }}
  241. </span>
  242. </div>
  243. <div class="mt-0.5 flex items-center justify-between gap-2">
  244. <span class="truncate text-xs text-muted-foreground">{{ c.telefone }}</span>
  245. <BaseBadge :variant="c.statusVariant" class="shrink-0 !px-2 !py-0.5 text-[10px]">
  246. {{ c.statusTexto }}
  247. </BaseBadge>
  248. </div>
  249. </div>
  250. </button>
  251. <button
  252. v-if="isAdmin"
  253. type="button"
  254. 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 focus:opacity-100 focus-visible:opacity-100 group-hover:opacity-100 max-whatsapp:opacity-100 dark:hover:border-red-900/50 dark:hover:bg-red-950/30 dark:hover:text-red-400"
  255. title="Excluir conversa"
  256. aria-label="Excluir conversa"
  257. @click="confirmandoExclusao = c.id"
  258. >
  259. <BaseIcon :icon="Trash2" class="h-4 w-4" />
  260. </button>
  261. </div>
  262. </div>
  263. </template>
  264. </div>
  265. </section>
  266. <section
  267. 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"
  268. :class="!conversaAtivaId ? 'max-whatsapp:hidden' : ''"
  269. >
  270. <template v-if="!conversaAtivaId">
  271. <div class="flex flex-1 items-center justify-center text-sm text-muted-foreground">
  272. Selecione uma conversa para começar a responder.
  273. </div>
  274. </template>
  275. <template v-else>
  276. <div class="flex items-center gap-3 border-b border-gray-200 px-4 py-3 dark:border-gray-700">
  277. <button
  278. type="button"
  279. class="hidden h-8 w-8 shrink-0 items-center justify-center rounded-lg text-gray-500 hover:bg-black/[0.03] max-whatsapp:inline-flex dark:text-gray-400 dark:hover:bg-white/[0.04]"
  280. title="Voltar para a lista"
  281. @click="fecharConversa"
  282. >
  283. <BaseIcon :icon="ArrowLeft" class="h-4 w-4" />
  284. </button>
  285. <div
  286. v-if="conversaAtivaView"
  287. class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white ring-1 ring-white/20"
  288. :class="conversaAtivaView.avatarCor"
  289. >
  290. {{ conversaAtivaView.avatarIniciais }}
  291. </div>
  292. <div class="min-w-0 flex-1">
  293. <div class="truncate text-sm font-semibold text-foreground">
  294. {{ conversaAtivaView?.titulo ?? "" }}
  295. </div>
  296. <div v-if="conversaAtivaView" class="truncate text-xs text-muted-foreground">{{ conversaAtivaView.telefone }}</div>
  297. </div>
  298. <BaseBadge v-if="conversaAtivaView" :variant="conversaAtivaView.statusVariant" class="shrink-0">
  299. {{ conversaAtivaView.statusTexto }}
  300. </BaseBadge>
  301. </div>
  302. <div ref="mensagensEl" class="scroll-thin min-h-0 flex-1 overflow-y-auto p-4">
  303. <div v-if="mensagensLoading" class="text-sm text-muted-foreground">Carregando conversa...</div>
  304. <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">
  305. {{ mensagensError }}
  306. </div>
  307. <div v-else-if="!mensagens.length" class="text-sm text-muted-foreground">
  308. Nenhuma mensagem nessa conversa ainda.
  309. </div>
  310. <div v-else class="space-y-2">
  311. <template v-for="(m, i) in mensagens" :key="m.Id">
  312. <div v-if="novoDia(i)" class="flex justify-center py-2 first:pt-0">
  313. <span class="rounded-full bg-black/[0.05] px-3 py-0.5 text-[11px] font-medium text-gray-500 dark:bg-white/[0.06] dark:text-gray-400">
  314. {{ formatarData(m.Timestamp) }}
  315. </span>
  316. </div>
  317. <ChatBubble :own="m.Direcao === 'saida'">
  318. <template v-if="ehAudio(midiaInfo(m))">
  319. <div class="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
  320. <BaseIcon :icon="Mic" class="h-3.5 w-3.5" />
  321. Mensagem de áudio
  322. </div>
  323. <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>
  324. <button
  325. v-else-if="midiaErros[m.Id]"
  326. type="button"
  327. class="mt-1 text-xs font-medium text-red-500 underline"
  328. @click="resolverMidia(m)"
  329. >
  330. Falha ao carregar áudio — tentar novamente
  331. </button>
  332. <p v-else class="mt-1 text-xs italic text-muted-foreground">Carregando áudio...</p>
  333. <p v-if="m.Corpo" class="mt-1 whitespace-pre-wrap break-words text-sm text-foreground">{{ m.Corpo }}</p>
  334. </template>
  335. <template v-else-if="ehImagem(midiaInfo(m)) && m.MidiaPath">
  336. <img
  337. v-if="midiaUrls[m.Id]"
  338. :src="midiaUrls[m.Id]"
  339. alt="Imagem enviada na conversa"
  340. loading="lazy"
  341. title="Ver imagem"
  342. class="max-h-80 max-w-full cursor-pointer rounded-lg object-contain"
  343. @click="abrirMidia(m)"
  344. />
  345. <button
  346. v-else-if="midiaErros[m.Id]"
  347. type="button"
  348. class="text-xs font-medium text-red-500 underline"
  349. @click="resolverMidia(m)"
  350. >
  351. Falha ao carregar imagem — tentar novamente
  352. </button>
  353. <p v-else class="text-xs italic text-muted-foreground">Carregando imagem...</p>
  354. <p v-if="m.Corpo" class="mt-1 whitespace-pre-wrap break-words text-sm text-foreground">{{ m.Corpo }}</p>
  355. </template>
  356. <template v-else-if="ehVideo(midiaInfo(m)) && m.MidiaPath">
  357. <div
  358. v-if="midiaUrls[m.Id]"
  359. class="relative inline-block cursor-pointer overflow-hidden rounded-lg"
  360. title="Ver vídeo"
  361. @click="abrirMidia(m)"
  362. >
  363. <video :src="midiaUrls[m.Id]" preload="metadata" muted class="max-h-80 max-w-full rounded-lg object-contain"></video>
  364. <div class="absolute inset-0 flex items-center justify-center bg-black/20">
  365. <div class="flex h-11 w-11 items-center justify-center rounded-full bg-white/85 text-gray-900">
  366. <BaseIcon :icon="Play" class="h-5 w-5 translate-x-0.5" fill="currentColor" stroke="none" />
  367. </div>
  368. </div>
  369. </div>
  370. <button
  371. v-else-if="midiaErros[m.Id]"
  372. type="button"
  373. class="text-xs font-medium text-red-500 underline"
  374. @click="resolverMidia(m)"
  375. >
  376. Falha ao carregar vídeo — tentar novamente
  377. </button>
  378. <p v-else class="text-xs italic text-muted-foreground">Carregando vídeo...</p>
  379. <p v-if="m.Corpo" class="mt-1 whitespace-pre-wrap break-words text-sm text-foreground">{{ m.Corpo }}</p>
  380. </template>
  381. <p v-else-if="m.Corpo" class="whitespace-pre-wrap break-words text-sm text-foreground">{{ m.Corpo }}</p>
  382. <a
  383. v-if="m.MidiaPath && !ehAudio(midiaInfo(m)) && !ehImagem(midiaInfo(m)) && !ehVideo(midiaInfo(m)) && midiaUrls[m.Id]"
  384. :href="midiaUrls[m.Id]"
  385. target="_blank"
  386. rel="noopener noreferrer"
  387. class="mt-1 inline-block text-xs font-medium text-primary underline"
  388. >
  389. Abrir arquivo{{ m.MidiaNomeArquivo ? `: ${m.MidiaNomeArquivo}` : "" }}
  390. </a>
  391. <div class="mt-0.5 text-right text-[10px] text-muted-foreground">{{ formatarHora(m.Timestamp) }}</div>
  392. </ChatBubble>
  393. </template>
  394. </div>
  395. </div>
  396. <Transition name="sugestao-fade">
  397. <div v-if="sugestao" class="mx-3 mb-2 rounded-xl border border-primary/30 bg-primary/5 p-3 dark:border-primary/40 dark:bg-primary/10">
  398. <div class="mb-1.5 flex items-center justify-between gap-2">
  399. <div class="flex min-w-0 items-center gap-2">
  400. <span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/15 text-primary dark:bg-primary/25">
  401. <BaseIcon :icon="Sparkles" class="h-3.5 w-3.5" />
  402. </span>
  403. <span class="truncate text-[11px] font-semibold uppercase tracking-wide text-primary">Sugestão do star-atendente</span>
  404. </div>
  405. <div class="flex shrink-0 items-center gap-0.5">
  406. <button
  407. type="button"
  408. class="rounded-lg p-1 text-gray-400 transition-colors hover:bg-black/[0.04] hover:text-gray-600 disabled:opacity-40 dark:text-gray-500 dark:hover:bg-white/[0.06] dark:hover:text-gray-300"
  409. title="Gerar outra sugestão"
  410. aria-label="Gerar outra sugestão"
  411. :disabled="sugestaoLoading"
  412. @click="buscarSugestao"
  413. >
  414. <BaseIcon :icon="RefreshCw" class="h-3.5 w-3.5" :class="{ 'animate-spin': sugestaoLoading }" />
  415. </button>
  416. <button
  417. type="button"
  418. class="rounded-lg p-1 text-gray-400 transition-colors hover:bg-black/[0.04] hover:text-muted-foreground dark:hover:bg-white/[0.06] dark:hover:text-gray-300"
  419. title="Descartar sugestão"
  420. aria-label="Descartar sugestão"
  421. @click="descartarSugestao"
  422. >
  423. <BaseIcon :icon="X" class="h-3.5 w-3.5" />
  424. </button>
  425. </div>
  426. </div>
  427. <p
  428. class="whitespace-pre-wrap break-words text-sm leading-relaxed text-gray-800 transition-opacity dark:text-gray-200"
  429. :class="{ 'opacity-50': sugestaoLoading }"
  430. >
  431. {{ sugestao }}
  432. </p>
  433. <button
  434. type="button"
  435. class="mt-2 rounded-lg bg-primary px-3 py-1 text-xs font-medium text-white transition-colors hover:bg-primary/90 disabled:opacity-50"
  436. :disabled="sugestaoLoading"
  437. @click="usarSugestao"
  438. >
  439. Usar esta resposta
  440. </button>
  441. </div>
  442. <div v-else-if="sugestaoLoading" class="mx-3 mb-2 flex items-center gap-2 rounded-xl border border-primary/30 bg-primary/5 p-3 dark:border-primary/40 dark:bg-primary/10">
  443. <span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/15 text-primary dark:bg-primary/25">
  444. <BaseIcon :icon="Sparkles" class="h-3.5 w-3.5" />
  445. </span>
  446. <span class="flex items-center gap-1">
  447. <span class="h-1.5 w-1.5 animate-bounce rounded-full bg-primary" style="animation-delay: 0ms" />
  448. <span class="h-1.5 w-1.5 animate-bounce rounded-full bg-primary" style="animation-delay: 150ms" />
  449. <span class="h-1.5 w-1.5 animate-bounce rounded-full bg-primary" style="animation-delay: 300ms" />
  450. </span>
  451. <span class="text-xs text-muted-foreground">Gerando sugestão...</span>
  452. </div>
  453. <div v-else-if="sugestaoError" class="mx-3 mb-2 flex items-start gap-2 rounded-xl border border-red-200 bg-red-50 p-3 dark:border-red-900/40 dark:bg-red-900/20">
  454. <BaseIcon :icon="TriangleAlert" class="mt-0.5 h-4 w-4 shrink-0 text-red-500 dark:text-red-400" />
  455. <div class="min-w-0 flex-1">
  456. <p class="text-xs text-red-800 dark:text-red-300">Não foi possível gerar a sugestão: {{ sugestaoError }}</p>
  457. <button
  458. type="button"
  459. class="mt-1.5 text-xs font-medium text-red-700 underline hover:text-red-900 dark:text-red-300 dark:hover:text-red-100"
  460. @click="buscarSugestao"
  461. >
  462. Tentar novamente
  463. </button>
  464. </div>
  465. </div>
  466. </Transition>
  467. <form class="flex items-end gap-2 border-t border-gray-200 p-3 dark:border-gray-700" @submit.prevent="enviar">
  468. <textarea
  469. ref="inputResposta"
  470. v-model="texto"
  471. rows="1"
  472. placeholder="Digite uma resposta... (Enter envia, Shift+Enter quebra linha)"
  473. class="scroll-thin min-w-0 flex-1 resize-none rounded-xl border border-gray-300 bg-background px-3 py-2 text-sm outline-none focus:border-primary dark:border-gray-600"
  474. style="max-height: 120px"
  475. :disabled="enviando"
  476. @keydown.enter.exact.prevent="enviar"
  477. @input="ajustarAlturaResposta"
  478. />
  479. <button
  480. type="submit"
  481. class="shrink-0 rounded-xl bg-primary px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
  482. :disabled="enviando || !texto.trim()"
  483. >
  484. Enviar
  485. </button>
  486. </form>
  487. </template>
  488. </section>
  489. </div>
  490. <ModalMidia
  491. v-if="mediaViewer"
  492. :url="mediaViewer.url"
  493. :tipo="mediaViewer.tipo"
  494. @close="mediaViewer = null"
  495. />
  496. <ConfirmDialog
  497. :open="!!confirmandoExclusao"
  498. title="Excluir conversa"
  499. message="Excluir esta conversa? Esta ação não pode ser desfeita."
  500. :loading="excluindo === confirmandoExclusao"
  501. @confirm="onExcluir(confirmandoExclusao)"
  502. @cancel="confirmandoExclusao = null"
  503. />
  504. </LayoutSistema>
  505. </template>
  506. <style scoped>
  507. .sugestao-fade-enter-active,
  508. .sugestao-fade-leave-active {
  509. transition: opacity 0.15s ease, transform 0.15s ease;
  510. }
  511. .sugestao-fade-enter-from,
  512. .sugestao-fade-leave-to {
  513. opacity: 0;
  514. transform: translateY(4px);
  515. }
  516. </style>