| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561 |
- <script setup>
- import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
- import LayoutSistema from "../../layout/LayoutSistema.vue";
- import ModalMidia from "../../components/ModalMidia.vue";
- import SkeletonConversation from "../../components/skeletons/SkeletonConversation.vue";
- import BaseBadge from "../../components/base/BaseBadge.vue";
- import ChatBubble from "../../components/base/ChatBubble.vue";
- import BaseIcon from "../../components/base/BaseIcon.vue";
- import ConfirmDialog from "../../components/base/ConfirmDialog.vue";
- import { MessageCircle, Trash2, ArrowLeft, Mic, Play, Sparkles, RefreshCw, X, TriangleAlert } from "lucide-vue-next";
- import { useConversas } from "../../composables/useConversas.js";
- import { useAuth } from "../../composables/useAuth.js";
- import { useToast } from "../../composables/useToast.js";
- import { ehAudio, ehImagem, ehVideo } from "../../utils/midia.js";
- import { agruparPorRecencia, ehNovoDia, formatarData, formatarHora, formatarRecencia } from "../../utils/data.js";
- import { avatarIniciais, avatarCor } from "../../utils/avatar.js";
- const toast = useToast();
- const {
- conversas,
- conversasLoading,
- conversasError,
- conversaAtivaId,
- mensagens,
- mensagensLoading,
- mensagensError,
- enviando,
- midiaUrls,
- midiaErros,
- sugestao,
- sugestaoLoading,
- sugestaoError,
- resolverMidia,
- carregarConversas,
- abrirConversa,
- fecharConversa,
- enviarResposta,
- excluirConversa,
- buscarSugestao,
- descartarSugestao
- } = useConversas();
- const mediaViewer = ref(null);
- const mensagensEl = ref(null);
- const NEAR_BOTTOM_THRESHOLD_PX = 80;
- function isNearBottom() {
- const el = mensagensEl.value;
- if (!el) return true;
- return el.scrollHeight - el.scrollTop - el.clientHeight <= NEAR_BOTTOM_THRESHOLD_PX;
- }
- watch(
- () => [mensagens.value.length, mensagensLoading.value],
- async () => {
- const el = mensagensEl.value;
- const shouldStick = !el || isNearBottom();
- await nextTick();
- if (!el || !shouldStick) return;
- el.scrollTop = el.scrollHeight;
- }
- );
- watch(conversaAtivaId, async () => {
- await nextTick();
- const el = mensagensEl.value;
- if (el) el.scrollTop = el.scrollHeight;
- });
- 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" };
- }
- // Durante o polling a lista só cresce por append, então varrer tudo a cada ciclo é
- // trabalho jogado fora; ao trocar de conversa ela é reiniciada e o índice volta a zero.
- let midiaResolvidaAte = 0;
- watch(
- mensagens,
- (lista) => {
- if (lista.length < midiaResolvidaAte) midiaResolvidaAte = 0;
- for (let i = midiaResolvidaAte; i < lista.length; i += 1) {
- if (lista[i].MidiaPath) resolverMidia(lista[i]);
- }
- midiaResolvidaAte = lista.length;
- },
- { 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 { isAdmin } = useAuth();
- const texto = ref("");
- const inputResposta = ref(null);
- const RESPOSTA_ALTURA_MAX_PX = 120;
- function ajustarAlturaResposta() {
- const el = inputResposta.value;
- if (!el) return;
- el.style.height = "auto";
- el.style.height = `${Math.min(el.scrollHeight, RESPOSTA_ALTURA_MAX_PX)}px`;
- }
- function usarSugestao() {
- if (!sugestao.value) return;
- texto.value = sugestao.value;
- nextTick(() => {
- inputResposta.value?.focus();
- ajustarAlturaResposta();
- });
- }
- const conversaAtiva = computed(() => conversas.value.find((c) => c.Id === conversaAtivaId.value) ?? null);
- function statusBadgeTexto(status) {
- if (status === "aberta") return "Aberta";
- if (status === "fechada") return "Fechada";
- return status || "—";
- }
- function montarConversaView(c) {
- const nome = c.NomeContato || c.Telefone;
- return {
- id: c.Id,
- titulo: nome,
- telefone: c.Telefone,
- avatarCor: avatarCor(nome),
- avatarIniciais: avatarIniciais(nome),
- recencia: formatarRecencia(c.UltimaMensagemEm ?? c.CreatedAt),
- statusVariant: c.Status === "aberta" ? "success" : "default",
- statusTexto: statusBadgeTexto(c.Status)
- };
- }
- // A lista repinta a cada ciclo de polling. Tudo o que dependia só da conversa é calculado
- // aqui, numa passada, em vez de virar chamada de função repetida no template.
- const conversasAgrupadas = computed(() =>
- agruparPorRecencia(conversas.value, (c) => c.UltimaMensagemEm ?? c.CreatedAt).map((grupo) => ({
- label: grupo.label,
- items: grupo.items.map(montarConversaView)
- }))
- );
- const conversaAtivaView = computed(() =>
- conversaAtiva.value ? montarConversaView(conversaAtiva.value) : null
- );
- function novoDia(idx) {
- return ehNovoDia(mensagens.value[idx]?.Timestamp, idx === 0 ? null : mensagens.value[idx - 1]?.Timestamp);
- }
- function mensagemErroEnvio(err) {
- if (err?.message === "whatsapp_nao_conectado") return "WhatsApp desconectado. Peça a um admin para reconectar.";
- return `Erro ao enviar: ${err?.message ?? "erro desconhecido"}`;
- }
- async function enviar() {
- if (!texto.value.trim() || enviando.value) return;
- const valor = texto.value;
- texto.value = "";
- await nextTick();
- ajustarAlturaResposta();
- try {
- await enviarResposta(valor);
- } catch (err) {
- texto.value = valor;
- toast.erro(mensagemErroEnvio(err));
- await nextTick();
- ajustarAlturaResposta();
- }
- }
- let refreshTimer = null;
- function pollConversas() {
- if (document.visibilityState !== "visible") return;
- carregarConversas();
- }
- function handleVisibilityChange() {
- if (document.visibilityState === "visible") carregarConversas();
- }
- onMounted(() => {
- carregarConversas();
- refreshTimer = setInterval(pollConversas, 5000);
- document.addEventListener("visibilitychange", handleVisibilityChange);
- });
- onUnmounted(() => {
- if (refreshTimer) clearInterval(refreshTimer);
- document.removeEventListener("visibilitychange", handleVisibilityChange);
- fecharConversa();
- });
- </script>
- <template>
- <LayoutSistema>
- <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">
- <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"
- :class="conversaAtivaId ? 'max-whatsapp:hidden' : ''"
- >
- <div class="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-gray-700">
- <span class="text-sm font-semibold text-foreground">Conversas</span>
- <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">
- {{ conversas.length }}
- </span>
- </div>
- <div v-if="conversasLoading && !conversas.length" class="flex-1 overflow-y-auto p-2">
- <SkeletonConversation :count="5" />
- </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="flex flex-1 flex-col items-center justify-center gap-2 p-4 text-center">
- <BaseIcon :icon="MessageCircle" class="h-8 w-8 text-gray-300 dark:text-gray-600" stroke-width="1.5" />
- <p class="text-sm text-muted-foreground">Nenhuma conversa ainda.</p>
- <p class="text-xs text-muted-foreground">Mande uma mensagem para o número de teste no WhatsApp.</p>
- </div>
- <div v-else class="scroll-thin min-h-0 flex-1 overflow-y-auto p-2">
- <template v-for="grupo in conversasAgrupadas" :key="grupo.label">
- <div class="px-2 pt-2 pb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
- {{ grupo.label }}
- </div>
- <div class="space-y-1">
- <div
- v-for="c in grupo.items"
- :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="flex w-full items-center gap-3 p-2.5 pr-9 text-left" @click="abrirConversa(c.id)">
- <div
- 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"
- :class="c.avatarCor"
- >
- {{ c.avatarIniciais }}
- </div>
- <div class="min-w-0 flex-1">
- <div class="flex items-center justify-between gap-2">
- <span class="truncate text-sm font-semibold text-foreground">{{ c.titulo }}</span>
- <span v-if="c.recencia" class="shrink-0 text-[11px] text-muted-foreground">
- {{ c.recencia }}
- </span>
- </div>
- <div class="mt-0.5 flex items-center justify-between gap-2">
- <span class="truncate text-xs text-muted-foreground">{{ c.telefone }}</span>
- <BaseBadge :variant="c.statusVariant" class="shrink-0 !px-2 !py-0.5 text-[10px]">
- {{ c.statusTexto }}
- </BaseBadge>
- </div>
- </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 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"
- title="Excluir conversa"
- aria-label="Excluir conversa"
- @click="confirmandoExclusao = c.id"
- >
- <BaseIcon :icon="Trash2" class="h-4 w-4" />
- </button>
- </div>
- </div>
- </template>
- </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"
- :class="!conversaAtivaId ? 'max-whatsapp:hidden' : ''"
- >
- <template v-if="!conversaAtivaId">
- <div class="flex flex-1 items-center justify-center text-sm text-muted-foreground">
- Selecione uma conversa para começar a responder.
- </div>
- </template>
- <template v-else>
- <div class="flex items-center gap-3 border-b border-gray-200 px-4 py-3 dark:border-gray-700">
- <button
- type="button"
- 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]"
- title="Voltar para a lista"
- @click="fecharConversa"
- >
- <BaseIcon :icon="ArrowLeft" class="h-4 w-4" />
- </button>
- <div
- v-if="conversaAtivaView"
- 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"
- :class="conversaAtivaView.avatarCor"
- >
- {{ conversaAtivaView.avatarIniciais }}
- </div>
- <div class="min-w-0 flex-1">
- <div class="truncate text-sm font-semibold text-foreground">
- {{ conversaAtivaView?.titulo ?? "" }}
- </div>
- <div v-if="conversaAtivaView" class="truncate text-xs text-muted-foreground">{{ conversaAtivaView.telefone }}</div>
- </div>
- <BaseBadge v-if="conversaAtivaView" :variant="conversaAtivaView.statusVariant" class="shrink-0">
- {{ conversaAtivaView.statusTexto }}
- </BaseBadge>
- </div>
- <div ref="mensagensEl" class="scroll-thin min-h-0 flex-1 overflow-y-auto p-4">
- <div v-if="mensagensLoading" class="text-sm text-muted-foreground">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-muted-foreground">
- Nenhuma mensagem nessa conversa ainda.
- </div>
- <div v-else class="space-y-2">
- <template v-for="(m, i) in mensagens" :key="m.Id">
- <div v-if="novoDia(i)" class="flex justify-center py-2 first:pt-0">
- <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">
- {{ formatarData(m.Timestamp) }}
- </span>
- </div>
- <ChatBubble :own="m.Direcao === 'saida'">
- <template v-if="ehAudio(midiaInfo(m))">
- <div class="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
- <BaseIcon :icon="Mic" class="h-3.5 w-3.5" />
- 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-muted-foreground">Carregando áudio...</p>
- <p v-if="m.Corpo" class="mt-1 whitespace-pre-wrap break-words text-sm text-foreground">{{ 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-muted-foreground">Carregando imagem...</p>
- <p v-if="m.Corpo" class="mt-1 whitespace-pre-wrap break-words text-sm text-foreground">{{ 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">
- <BaseIcon :icon="Play" class="h-5 w-5 translate-x-0.5" fill="currentColor" stroke="none" />
- </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-muted-foreground">Carregando vídeo...</p>
- <p v-if="m.Corpo" class="mt-1 whitespace-pre-wrap break-words text-sm text-foreground">{{ m.Corpo }}</p>
- </template>
- <p v-else-if="m.Corpo" class="whitespace-pre-wrap break-words text-sm text-foreground">{{ 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-muted-foreground">{{ formatarHora(m.Timestamp) }}</div>
- </ChatBubble>
- </template>
- </div>
- </div>
- <Transition name="sugestao-fade">
- <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">
- <div class="mb-1.5 flex items-center justify-between gap-2">
- <div class="flex min-w-0 items-center gap-2">
- <span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/15 text-primary dark:bg-primary/25">
- <BaseIcon :icon="Sparkles" class="h-3.5 w-3.5" />
- </span>
- <span class="truncate text-[11px] font-semibold uppercase tracking-wide text-primary">Sugestão do star-atendente</span>
- </div>
- <div class="flex shrink-0 items-center gap-0.5">
- <button
- type="button"
- 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"
- title="Gerar outra sugestão"
- aria-label="Gerar outra sugestão"
- :disabled="sugestaoLoading"
- @click="buscarSugestao"
- >
- <BaseIcon :icon="RefreshCw" class="h-3.5 w-3.5" :class="{ 'animate-spin': sugestaoLoading }" />
- </button>
- <button
- type="button"
- 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"
- title="Descartar sugestão"
- aria-label="Descartar sugestão"
- @click="descartarSugestao"
- >
- <BaseIcon :icon="X" class="h-3.5 w-3.5" />
- </button>
- </div>
- </div>
- <p
- class="whitespace-pre-wrap break-words text-sm leading-relaxed text-gray-800 transition-opacity dark:text-gray-200"
- :class="{ 'opacity-50': sugestaoLoading }"
- >
- {{ sugestao }}
- </p>
- <button
- type="button"
- 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"
- :disabled="sugestaoLoading"
- @click="usarSugestao"
- >
- Usar esta resposta
- </button>
- </div>
- <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">
- <span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/15 text-primary dark:bg-primary/25">
- <BaseIcon :icon="Sparkles" class="h-3.5 w-3.5" />
- </span>
- <span class="flex items-center gap-1">
- <span class="h-1.5 w-1.5 animate-bounce rounded-full bg-primary" style="animation-delay: 0ms" />
- <span class="h-1.5 w-1.5 animate-bounce rounded-full bg-primary" style="animation-delay: 150ms" />
- <span class="h-1.5 w-1.5 animate-bounce rounded-full bg-primary" style="animation-delay: 300ms" />
- </span>
- <span class="text-xs text-muted-foreground">Gerando sugestão...</span>
- </div>
- <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">
- <BaseIcon :icon="TriangleAlert" class="mt-0.5 h-4 w-4 shrink-0 text-red-500 dark:text-red-400" />
- <div class="min-w-0 flex-1">
- <p class="text-xs text-red-800 dark:text-red-300">Não foi possível gerar a sugestão: {{ sugestaoError }}</p>
- <button
- type="button"
- 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"
- @click="buscarSugestao"
- >
- Tentar novamente
- </button>
- </div>
- </div>
- </Transition>
- <form class="flex items-end gap-2 border-t border-gray-200 p-3 dark:border-gray-700" @submit.prevent="enviar">
- <textarea
- ref="inputResposta"
- v-model="texto"
- rows="1"
- placeholder="Digite uma resposta... (Enter envia, Shift+Enter quebra linha)"
- 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"
- style="max-height: 120px"
- :disabled="enviando"
- @keydown.enter.exact.prevent="enviar"
- @input="ajustarAlturaResposta"
- />
- <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"
- />
- <ConfirmDialog
- :open="!!confirmandoExclusao"
- title="Excluir conversa"
- message="Excluir esta conversa? Esta ação não pode ser desfeita."
- :loading="excluindo === confirmandoExclusao"
- @confirm="onExcluir(confirmandoExclusao)"
- @cancel="confirmandoExclusao = null"
- />
- </LayoutSistema>
- </template>
- <style scoped>
- .sugestao-fade-enter-active,
- .sugestao-fade-leave-active {
- transition: opacity 0.15s ease, transform 0.15s ease;
- }
- .sugestao-fade-enter-from,
- .sugestao-fade-leave-to {
- opacity: 0;
- transform: translateY(4px);
- }
- </style>
|