|
@@ -1,14 +1,13 @@
|
|
|
<script setup>
|
|
<script setup>
|
|
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
|
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
|
|
|
import LayoutSistema from "../../layout/LayoutSistema.vue";
|
|
import LayoutSistema from "../../layout/LayoutSistema.vue";
|
|
|
-import ModalWhatsappConexao from "../../components/ModalWhatsappConexao.vue";
|
|
|
|
|
import ModalMidia from "../../components/ModalMidia.vue";
|
|
import ModalMidia from "../../components/ModalMidia.vue";
|
|
|
import BaseBadge from "../../components/base/BaseBadge.vue";
|
|
import BaseBadge from "../../components/base/BaseBadge.vue";
|
|
|
import { useConversas } from "../../composables/useConversas.js";
|
|
import { useConversas } from "../../composables/useConversas.js";
|
|
|
-import { useWhatsappConexao } from "../../composables/useWhatsappConexao.js";
|
|
|
|
|
import { useAuth } from "../../composables/useAuth.js";
|
|
import { useAuth } from "../../composables/useAuth.js";
|
|
|
import { useToast } from "../../composables/useToast.js";
|
|
import { useToast } from "../../composables/useToast.js";
|
|
|
import { ehAudio, ehImagem, ehVideo } from "../../utils/midia.js";
|
|
import { ehAudio, ehImagem, ehVideo } from "../../utils/midia.js";
|
|
|
|
|
+import { avatarIniciais, avatarCor } from "../../utils/avatar.js";
|
|
|
|
|
|
|
|
const toast = useToast();
|
|
const toast = useToast();
|
|
|
|
|
|
|
@@ -32,6 +31,7 @@ const {
|
|
|
fecharConversa,
|
|
fecharConversa,
|
|
|
enviarResposta,
|
|
enviarResposta,
|
|
|
excluirConversa,
|
|
excluirConversa,
|
|
|
|
|
+ buscarSugestao,
|
|
|
descartarSugestao
|
|
descartarSugestao
|
|
|
} = useConversas();
|
|
} = useConversas();
|
|
|
|
|
|
|
@@ -76,47 +76,6 @@ async function onExcluir(id) {
|
|
|
const { user } = useAuth();
|
|
const { user } = useAuth();
|
|
|
const isAdmin = computed(() => String(user.value?.Nivel) === "3");
|
|
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 texto = ref("");
|
|
|
const inputResposta = ref(null);
|
|
const inputResposta = ref(null);
|
|
|
|
|
|
|
@@ -139,6 +98,65 @@ function formatPrevia(conversa) {
|
|
|
return conversa.NomeContato || conversa.Telefone;
|
|
return conversa.NomeContato || conversa.Telefone;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function formatData(value) {
|
|
|
|
|
+ if (!value) return "";
|
|
|
|
|
+ const d = new Date(value);
|
|
|
|
|
+ if (Number.isNaN(d.getTime())) return "";
|
|
|
|
|
+ return d.toLocaleDateString("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric" });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function formatUltimaMensagem(value) {
|
|
|
|
|
+ if (!value) return "";
|
|
|
|
|
+ const d = new Date(value);
|
|
|
|
|
+ if (Number.isNaN(d.getTime())) return "";
|
|
|
|
|
+ const hoje = new Date().setHours(0, 0, 0, 0);
|
|
|
|
|
+ const ontem = hoje - 86400000;
|
|
|
|
|
+ const dia = new Date(value).setHours(0, 0, 0, 0);
|
|
|
|
|
+ if (dia === hoje) return formatHora(value);
|
|
|
|
|
+ if (dia === ontem) return "Ontem";
|
|
|
|
|
+ return d.toLocaleDateString("pt-BR", { day: "2-digit", month: "2-digit" });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const conversasAgrupadas = computed(() => {
|
|
|
|
|
+ const hoje = new Date().setHours(0, 0, 0, 0);
|
|
|
|
|
+ const ontem = hoje - 86400000;
|
|
|
|
|
+ const semana = hoje - 6 * 86400000;
|
|
|
|
|
+ const grupos = [
|
|
|
|
|
+ { label: "Hoje", items: [] },
|
|
|
|
|
+ { label: "Ontem", items: [] },
|
|
|
|
|
+ { label: "Últimos 7 dias", items: [] },
|
|
|
|
|
+ { label: "Mais antigas", items: [] }
|
|
|
|
|
+ ];
|
|
|
|
|
+ for (const c of conversas.value) {
|
|
|
|
|
+ const raw = c.UltimaMensagemEm ?? c.CreatedAt;
|
|
|
|
|
+ const t = raw ? new Date(raw).getTime() : 0;
|
|
|
|
|
+ if (t >= hoje) grupos[0].items.push(c);
|
|
|
|
|
+ else if (t >= ontem) grupos[1].items.push(c);
|
|
|
|
|
+ else if (t >= semana) grupos[2].items.push(c);
|
|
|
|
|
+ else grupos[3].items.push(c);
|
|
|
|
|
+ }
|
|
|
|
|
+ return grupos.filter((g) => g.items.length > 0);
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+function statusBadgeVariant(status) {
|
|
|
|
|
+ return status === "aberta" ? "success" : "default";
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function statusBadgeTexto(status) {
|
|
|
|
|
+ if (status === "aberta") return "Aberta";
|
|
|
|
|
+ if (status === "fechada") return "Fechada";
|
|
|
|
|
+ return status || "—";
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function novoDia(idx) {
|
|
|
|
|
+ const atual = mensagens.value[idx]?.Timestamp;
|
|
|
|
|
+ if (!atual) return false;
|
|
|
|
|
+ if (idx === 0) return true;
|
|
|
|
|
+ const anterior = mensagens.value[idx - 1]?.Timestamp;
|
|
|
|
|
+ if (!anterior) return true;
|
|
|
|
|
+ return new Date(atual).toDateString() !== new Date(anterior).toDateString();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function mensagemErroEnvio(err) {
|
|
function mensagemErroEnvio(err) {
|
|
|
if (err?.message === "whatsapp_nao_conectado") return "WhatsApp desconectado. Peça a um admin para reconectar.";
|
|
if (err?.message === "whatsapp_nao_conectado") return "WhatsApp desconectado. Peça a um admin para reconectar.";
|
|
|
return `Erro ao enviar: ${err?.message ?? "erro desconhecido"}`;
|
|
return `Erro ao enviar: ${err?.message ?? "erro desconhecido"}`;
|
|
@@ -161,107 +179,106 @@ let refreshTimer = null;
|
|
|
onMounted(() => {
|
|
onMounted(() => {
|
|
|
carregarConversas();
|
|
carregarConversas();
|
|
|
refreshTimer = setInterval(carregarConversas, 5000);
|
|
refreshTimer = setInterval(carregarConversas, 5000);
|
|
|
- if (isAdmin.value) atualizarStatusWhatsapp();
|
|
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
|
onUnmounted(() => {
|
|
|
if (refreshTimer) clearInterval(refreshTimer);
|
|
if (refreshTimer) clearInterval(refreshTimer);
|
|
|
fecharConversa();
|
|
fecharConversa();
|
|
|
- pararPollingWhatsapp();
|
|
|
|
|
});
|
|
});
|
|
|
</script>
|
|
</script>
|
|
|
|
|
|
|
|
<template>
|
|
<template>
|
|
|
<LayoutSistema>
|
|
<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"
|
|
|
|
|
|
|
+ <div class="grid h-[calc(100vh-var(--layout-header-height)-4rem)] grid-cols-[300px_1fr] gap-4 max-[760px]:grid-cols-1 max-[760px]: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-[760px]:hidden' : ''"
|
|
|
>
|
|
>
|
|
|
- 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 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-gray-900 dark:text-gray-100">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>
|
|
|
|
|
|
|
|
- <div v-if="conversasLoading && !conversas.length" class="p-4 text-sm text-gray-500 dark:text-gray-400">Carregando...</div>
|
|
|
|
|
|
|
+ <div v-if="conversasLoading && !conversas.length" class="flex flex-1 items-center justify-center 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">
|
|
<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 }}
|
|
{{ conversasError }}
|
|
|
</div>
|
|
</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 v-else-if="!conversas.length" class="flex flex-1 flex-col items-center justify-center gap-2 p-4 text-center">
|
|
|
|
|
+ <svg class="h-8 w-8 text-gray-300 dark:text-gray-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
|
|
|
+ <path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.86 9.86 0 0 1-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8Z" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ <p class="text-sm text-gray-500 dark:text-gray-400">Nenhuma conversa ainda.</p>
|
|
|
|
|
+ <p class="text-xs text-gray-400 dark:text-gray-500">Mande uma mensagem para o número de teste no WhatsApp.</p>
|
|
|
</div>
|
|
</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-else class="conv-scroll 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-gray-400 dark:text-gray-500">
|
|
|
|
|
+ {{ grupo.label }}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="space-y-1">
|
|
|
<div
|
|
<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]"
|
|
|
|
|
|
|
+ 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]'
|
|
|
|
|
+ "
|
|
|
>
|
|
>
|
|
|
- <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)"
|
|
|
|
|
|
|
+ <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="avatarCor(c.NomeContato || c.Telefone)"
|
|
|
>
|
|
>
|
|
|
- Confirmar
|
|
|
|
|
|
|
+ {{ avatarIniciais(c.NomeContato || c.Telefone) }}
|
|
|
|
|
+ </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-gray-900 dark:text-gray-100">{{ formatPrevia(c) }}</span>
|
|
|
|
|
+ <span v-if="formatUltimaMensagem(c.UltimaMensagemEm ?? c.CreatedAt)" class="shrink-0 text-[11px] text-gray-400 dark:text-gray-500">
|
|
|
|
|
+ {{ formatUltimaMensagem(c.UltimaMensagemEm ?? c.CreatedAt) }}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="mt-0.5 flex items-center justify-between gap-2">
|
|
|
|
|
+ <span class="truncate text-xs text-gray-500 dark:text-gray-400">{{ c.Telefone }}</span>
|
|
|
|
|
+ <BaseBadge :variant="statusBadgeVariant(c.Status)" class="shrink-0 !px-2 !py-0.5 text-[10px]">
|
|
|
|
|
+ {{ statusBadgeTexto(c.Status) }}
|
|
|
|
|
+ </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 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>
|
|
|
<button
|
|
<button
|
|
|
type="button"
|
|
type="button"
|
|
@@ -272,11 +289,16 @@ onUnmounted(() => {
|
|
|
</button>
|
|
</button>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+ </template>
|
|
|
</div>
|
|
</div>
|
|
|
</section>
|
|
</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">
|
|
|
|
|
|
|
+ <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-[760px]:hidden' : ''"
|
|
|
|
|
+ >
|
|
|
<template v-if="!conversaAtivaId">
|
|
<template v-if="!conversaAtivaId">
|
|
|
<div class="flex flex-1 items-center justify-center text-sm text-gray-500 dark:text-gray-400">
|
|
<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.
|
|
Selecione uma conversa para começar a responder.
|
|
@@ -284,11 +306,36 @@ onUnmounted(() => {
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
|
<template v-else>
|
|
<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 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-[760px]:inline-flex dark:text-gray-400 dark:hover:bg-white/[0.04]"
|
|
|
|
|
+ title="Voltar para a lista"
|
|
|
|
|
+ @click="fecharConversa"
|
|
|
|
|
+ >
|
|
|
|
|
+ <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
|
|
|
+ <path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <div
|
|
|
|
|
+ v-if="conversaAtiva"
|
|
|
|
|
+ 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="avatarCor(conversaAtiva.NomeContato || conversaAtiva.Telefone)"
|
|
|
|
|
+ >
|
|
|
|
|
+ {{ avatarIniciais(conversaAtiva.NomeContato || conversaAtiva.Telefone) }}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="min-w-0 flex-1">
|
|
|
|
|
+ <div class="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">
|
|
|
|
|
+ {{ conversaAtiva ? formatPrevia(conversaAtiva) : "" }}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div v-if="conversaAtiva" class="truncate text-xs text-gray-500 dark:text-gray-400">{{ conversaAtiva.Telefone }}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <BaseBadge v-if="conversaAtiva" :variant="statusBadgeVariant(conversaAtiva.Status)" class="shrink-0">
|
|
|
|
|
+ {{ statusBadgeTexto(conversaAtiva.Status) }}
|
|
|
|
|
+ </BaseBadge>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- <div class="min-h-0 flex-1 overflow-y-auto p-4">
|
|
|
|
|
|
|
+ <div class="conv-scroll 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-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">
|
|
<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 }}
|
|
{{ mensagensError }}
|
|
@@ -298,7 +345,13 @@ onUnmounted(() => {
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<div v-else class="space-y-2">
|
|
<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'">
|
|
|
|
|
|
|
+ <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">
|
|
|
|
|
+ {{ formatData(m.Timestamp) }}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="flex" :class="m.Direcao === 'saida' ? 'justify-end' : 'justify-start'">
|
|
|
<div
|
|
<div
|
|
|
class="max-w-[75%] rounded-2xl px-3.5 py-2 shadow-sm"
|
|
class="max-w-[75%] rounded-2xl px-3.5 py-2 shadow-sm"
|
|
|
:class="
|
|
:class="
|
|
@@ -387,46 +440,91 @@ onUnmounted(() => {
|
|
|
<div class="mt-0.5 text-right text-[10px] text-gray-400 dark:text-gray-500">{{ formatHora(m.Timestamp) }}</div>
|
|
<div class="mt-0.5 text-right text-[10px] text-gray-400 dark:text-gray-500">{{ formatHora(m.Timestamp) }}</div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+ </template>
|
|
|
</div>
|
|
</div>
|
|
|
</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 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 flex items-center justify-between">
|
|
|
|
|
- <div class="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-primary">
|
|
|
|
|
- <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
|
|
|
- <path d="M12 3v4M12 17v4M3 12h4M17 12h4M6.34 6.34l1.42 1.42M16.24 16.24l1.42 1.42M6.34 17.66l1.42-1.42M16.24 7.76l1.42-1.42" />
|
|
|
|
|
- </svg>
|
|
|
|
|
- Sugestão do star-atendente
|
|
|
|
|
|
|
+ <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">
|
|
|
|
|
+ <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
|
|
|
+ <path d="M12 3v4M12 17v4M3 12h4M17 12h4M6.34 6.34l1.42 1.42M16.24 16.24l1.42 1.42M6.34 17.66l1.42-1.42M16.24 7.76l1.42-1.42" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ </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"
|
|
|
|
|
+ >
|
|
|
|
|
+ <svg class="h-3.5 w-3.5" :class="{ 'animate-spin': sugestaoLoading }" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
|
|
|
+ <path d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ class="rounded-lg p-1 text-gray-400 transition-colors hover:bg-black/[0.04] hover:text-gray-600 dark:text-gray-500 dark:hover:bg-white/[0.06] dark:hover:text-gray-300"
|
|
|
|
|
+ title="Descartar sugestão"
|
|
|
|
|
+ aria-label="Descartar sugestão"
|
|
|
|
|
+ @click="descartarSugestao"
|
|
|
|
|
+ >
|
|
|
|
|
+ <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
|
|
|
+ <path d="M18 6L6 18M6 6l12 12" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ </button>
|
|
|
</div>
|
|
</div>
|
|
|
- <button
|
|
|
|
|
- type="button"
|
|
|
|
|
- class="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
|
|
|
|
|
- aria-label="Descartar sugestão"
|
|
|
|
|
- @click="descartarSugestao"
|
|
|
|
|
- >
|
|
|
|
|
- <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
|
|
|
- <path d="M18 6L6 18M6 6l12 12" />
|
|
|
|
|
- </svg>
|
|
|
|
|
- </button>
|
|
|
|
|
</div>
|
|
</div>
|
|
|
- <p class="whitespace-pre-wrap break-words text-sm text-gray-800 dark:text-gray-200">{{ sugestao }}</p>
|
|
|
|
|
|
|
+ <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
|
|
<button
|
|
|
type="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"
|
|
|
|
|
|
|
+ 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"
|
|
@click="usarSugestao"
|
|
|
>
|
|
>
|
|
|
Usar esta resposta
|
|
Usar esta resposta
|
|
|
</button>
|
|
</button>
|
|
|
</div>
|
|
</div>
|
|
|
- <div v-else-if="sugestaoLoading" class="mx-3 mb-2 flex items-center gap-1 rounded-xl border border-primary/30 bg-primary/5 px-3 py-2 dark:border-primary/40 dark:bg-primary/10">
|
|
|
|
|
- <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 class="ml-1 text-xs text-gray-400 dark:text-gray-500">Gerando sugestão...</span>
|
|
|
|
|
|
|
+ <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">
|
|
|
|
|
+ <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
|
|
|
+ <path d="M12 3v4M12 17v4M3 12h4M17 12h4M6.34 6.34l1.42 1.42M16.24 16.24l1.42 1.42M6.34 17.66l1.42-1.42M16.24 7.76l1.42-1.42" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ </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-gray-500 dark:text-gray-400">Gerando sugestão...</span>
|
|
|
</div>
|
|
</div>
|
|
|
- <div v-else-if="sugestaoError" class="mx-3 mb-2 rounded-lg bg-red-50 px-3 py-2 text-xs text-red-800 dark:bg-red-900/30 dark:text-red-300">
|
|
|
|
|
- Não foi possível gerar a sugestão: {{ sugestaoError }}
|
|
|
|
|
|
|
+ <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">
|
|
|
|
|
+ <svg class="mt-0.5 h-4 w-4 shrink-0 text-red-500 dark:text-red-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
|
|
|
+ <path d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ <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>
|
|
</div>
|
|
|
|
|
+ </Transition>
|
|
|
|
|
|
|
|
<form class="flex items-center gap-2 border-t border-gray-200 p-3 dark:border-gray-700" @submit.prevent="enviar">
|
|
<form class="flex items-center gap-2 border-t border-gray-200 p-3 dark:border-gray-700" @submit.prevent="enviar">
|
|
|
<input
|
|
<input
|
|
@@ -457,3 +555,42 @@ onUnmounted(() => {
|
|
|
/>
|
|
/>
|
|
|
</LayoutSistema>
|
|
</LayoutSistema>
|
|
|
</template>
|
|
</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);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.conv-scroll {
|
|
|
|
|
+ scrollbar-width: thin;
|
|
|
|
|
+ scrollbar-color: rgba(0, 0, 0, 0.15) transparent;
|
|
|
|
|
+}
|
|
|
|
|
+.conv-scroll::-webkit-scrollbar {
|
|
|
|
|
+ width: 3px;
|
|
|
|
|
+}
|
|
|
|
|
+.conv-scroll::-webkit-scrollbar-track {
|
|
|
|
|
+ background: transparent;
|
|
|
|
|
+}
|
|
|
|
|
+.conv-scroll::-webkit-scrollbar-thumb {
|
|
|
|
|
+ background: rgba(0, 0, 0, 0.15);
|
|
|
|
|
+ border-radius: 999px;
|
|
|
|
|
+}
|
|
|
|
|
+.conv-scroll::-webkit-scrollbar-thumb:hover {
|
|
|
|
|
+ background: rgba(0, 0, 0, 0.25);
|
|
|
|
|
+}
|
|
|
|
|
+:is(.dark) .conv-scroll {
|
|
|
|
|
+ scrollbar-color: rgba(255, 255, 255, 0.12) transparent;
|
|
|
|
|
+}
|
|
|
|
|
+:is(.dark) .conv-scroll::-webkit-scrollbar-thumb {
|
|
|
|
|
+ background: rgba(255, 255, 255, 0.12);
|
|
|
|
|
+}
|
|
|
|
|
+:is(.dark) .conv-scroll::-webkit-scrollbar-thumb:hover {
|
|
|
|
|
+ background: rgba(255, 255, 255, 0.22);
|
|
|
|
|
+}
|
|
|
|
|
+</style>
|