ConversasWhatsappView.vue 24 KB

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