Files
agenciapsilmno/src/layout/configuracoes/ConfiguracoesWhatsappPage.vue
T
Leonardo 9966b5f175 Melissa: paginas nativas cfg-* + temas + textos com fundo + drawer WA
CHROME COMPARTILHADO + 18 PAGINAS NATIVAS
- MelissaConfigPage: chrome unico (header, drawer mobile, sidebar com Configuracoes
  + FAQ slot, main com Suspense). Replica fake-dialog right rule e fica flush
  com o config-aside global.
- 18 wrappers finos (~25 linhas cada): cfg-precificacao, cfg-descontos,
  cfg-excecoes, cfg-convenios, cfg-wa, cfg-wa-pessoal, cfg-wa-oficial,
  cfg-wa-templates, cfg-conversas-tags/autoreply/optouts/sla/bots,
  cfg-lembretes, cfg-creditos-wa, cfg-sms, cfg-email-templates,
  cfg-recursos-extras, cfg-recursos-extras-extrato, cfg-auditoria.
- Cada wrapper usa defineAsyncComponent + Suspense pra evitar race com
  tenantStore no boot (loading travado em alguns chooser-style pages).
- MelissaLayout: imports + SECOES + MELISSA_NON_CONFIG_SLUGS + render
  conditions atualizados pra cobrir os 18 slugs.

PAGINAS LEGADAS DETECTAM CONTEXTO MELISSA
- ConfiguracoesWhatsappChooserPage, WhatsappPage, TwilioWhatsappPage,
  SmsPage, RecursosExtrasPage, EmailTemplatesPage, AddonsExtratoPage,
  AgendadorPage, ConversasAutoreplyPage: route.startsWith('/melissa')
  decide se router.push vai pro slug Melissa ou /configuracoes legado.
- Anchors <a href="/configuracoes/..."> (que recarregavam pagina e
  vazavam o usuario do Melissa) trocados por RouterLink context-aware.
- MelissaAgenda.goSettings agora vai pra /melissa/agenda-config.

PERSONALIZAR > TEMAS
- melissaThemes.js: catalogo Freud/Klein/Jung (wallpaper + cor primaria
  + preset Lara/Nora + surface).
- Toggle de tema aplica tudo de uma vez; persistido em melissa_prefs.themeName.
- Boot resolve themeName -> imagem via fetch + data URL (sem guardar
  data URL gigante no DB).
- onCustomFileChange/onClearBg invalidam themeName quando user mexe no bg.

PERSONALIZAR > FUNDO NOS TEXTOS
- Pref textBgEnabled em melissa_prefs.
- MelissaHeroClock: prop textBg envolve relogio/data/saudacao/resumo
  em <span class="hero-text"> que ganha bg branco/preto 60% + borda
  + padding + radius quando o toggle esta on.
- Vars --m-hero-text-bg / --m-hero-text-border flipam com light/dark.

TOP + DOCK COM GRADIENT HORIZONTAL
- Var --m-band: preto 80% (dark) / branco 80% (light).
- .melissa-topbar-band: gradiente cor->transparente (right->left) atras
  dos botoes do topo.
- .melissa-dock: gradiente cor->transparente (left->right) atras dos pins.

MELISSANEGOCIO ABSORVE MINHA EMPRESA
- Adiciona logo upload + preview "cartao de visita" (computeds
  enderecoLinhas/redesValidas/temDados/logoDisplay + redeIcon helper).
- Normaliza dados legados do cfg-empresa: redes_sociais.{rede} virou {name}.
- Preview teleporta entre 3 destinos baseado no viewport:
  mobile -> drawer; mid-desktop -> sidebar; wide-desktop (>=1340px) ->
  painel flutuante FORA do fake dialog (ancora no right edge + 14px gap,
  altura segue conteudo, header alinhado com header do dialog).
- Remove cfg-empresa de melissaConfigGrupos.js + COMPONENT_MAP do
  MelissaConfiguracoes; grupo "Empresa & Plataforma" -> "Plataforma".

CRONOMETRO -> SESSAO AGENDADA
- MelissaCronometro emite session-end ao parar com paciente selecionado
  (threshold 5s pra ignorar start/stop acidental).
- MelissaLayout.onCronometroSessionEnd busca agenda_eventos do paciente
  no dia (tipo='sessao'), pega o mais recente e grava em
  extra_fields.cronometro_duracao_seg + cronometro_parado_em.
- Toast: sucesso ("X min salvos") ou warn ("sessao nao encontrada").

CONVERSATIONDRAWER WHATSAPP-LIKE
- Nova imagem whatsapp-bg.jpg (renomeada de hash random) usada como
  tile (380px) no .cd-msgs.
- Light: bege #efeae2 + multiply blend.
- Dark: #0b141a + camada 78% sobre o doodle.
- Bubbles WA-style (verde out / branco-dark in com tails) ja existiam.

EXTRATO RECURSOS EXTRAS
- Filtros 2-por-linha em Melissa (vs 1/4 no /configuracoes).
- Cards de Resumo teleportam pro #cfg-page-side em Melissa
  (1-col empilhado no drawer; 4-col inline no /configuracoes).
- Botoes de exportar com flex-1 distribuidos em uma unica linha em
  desktop, wrap no mobile.
- DataTable scrollable em ambos os layouts.

OUTROS AJUSTES MENORES
- Cfg-conversas-autoreply: dias semana 4-cols em Melissa (vs 7-cols
  no /configuracoes).
- Cfg-creditos-wa: 1/2 por linha (vs 1/2/4) em Melissa.
- Cfg-recursos-extras: pacotes 1/2 (vs 1/2/4); "Em breve" 1-col.
- WhatsAppPage aba Templates: guia de formatacao teleporta pro side
  drawer em Melissa, deixando textareas full-width.
- ConfigPage chrome agora tem #cfg-page-actions target pros Teleport
  de acoes (refresh button etc).
- Imagens renomeadas em src/assets/themes/ (freudwebp/melainewebp/
  jungwebp.webp) e src/assets/whatsapp-bg.jpg.
- JoditTextEditor.vue novo (wrapper Jodit generico, sem features de email).
- MelissaConfigList.vue novo (lista compartilhada de configs pro drawer).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 23:48:18 -03:00

1247 lines
64 KiB
Vue

<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/layout/configuracoes/ConfiguracoesWhatsappPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
const router = useRouter();
const route = useRoute();
const toast = useToast();
const confirm = useConfirm();
const tenantStore = useTenantStore();
// "Trocar canal" volta pro chooser. Mantem o usuario no layout em que esta:
// /melissa/cfg-wa se aberto via Melissa, /configuracoes/whatsapp caso contrario.
const inMelissa = computed(() => String(route.path || '').startsWith('/melissa'));
const chooserPath = computed(() => (inMelissa.value ? '/melissa/cfg-wa' : '/configuracoes/whatsapp'));
// Detecta area (admin clinica vs therapist) pra direcionar corretamente
function goConversas() {
const role = tenantStore.activeRole || tenantStore.role || '';
const isTherapistArea = role === 'therapist';
router.push(isTherapistArea ? '/therapist/conversas' : '/admin/conversas');
}
// ── Contexto ──────────────────────────────────────────────────
const userId = ref(null);
const tenantId = ref(null); // tenant_id real (da tabela tenants)
const activeTab = ref(0);
async function loadUser() {
const {
data: { user }
} = await supabase.auth.getUser();
if (!user) return;
userId.value = user.id;
// Usar o tenantId do store (tabela tenants), fallback para user.id
tenantId.value = tenantStore.activeTenantId || user.id;
}
// ══════════════════════════════════════════════════════════════
// ABA 1 — Conexão WhatsApp
// ══════════════════════════════════════════════════════════════
const credentials = ref({ api_url: '', api_key: '', instance_name: '' });
const hasCredentials = ref(false);
const channelRecord = ref(null); // row de notification_channels pra sincronizar connection_status
const connectionStatus = ref(null); // 'open' | 'close' | 'connecting' | null
const connectionLoading = ref(false);
// QR Code
const qrDialog = ref(false);
const qrCodeBase64 = ref(null);
const qrLoading = ref(false);
const qrCountdown = ref(0);
let qrTimer = null;
let isMounted = true;
const connectionTag = computed(() => {
if (connectionLoading.value) return { label: 'Verificando...', severity: 'secondary' };
if (!hasCredentials.value) return { label: 'Não configurado', severity: 'secondary' };
switch (connectionStatus.value) {
case 'open':
return { label: 'Conectado', severity: 'success' };
case 'connecting':
return { label: 'Conectando...', severity: 'warn' };
default:
return { label: 'Desconectado', severity: 'danger' };
}
});
const softDeletedRecord = ref(null);
const reactivating = ref(false);
// Carregar credenciais do banco — busca por tenant_id (consistente com SaaS)
// com fallback para owner_id (caso tenantId == userId)
async function loadCredentials() {
if (!tenantId.value) return;
softDeletedRecord.value = null;
// 1) Tentar canal ativo por tenant_id (evolution_api)
let { data, error } = await supabase
.from('notification_channels')
.select('*')
.eq('tenant_id', tenantId.value)
.eq('channel', 'whatsapp')
.eq('provider', 'evolution_api')
.is('deleted_at', null)
.maybeSingle();
// Fallback 1: buscar por owner_id (cenário legado ou tenant solo)
if (!data && userId.value && userId.value !== tenantId.value) {
const fallback = await supabase
.from('notification_channels')
.select('*')
.eq('owner_id', userId.value)
.eq('channel', 'whatsapp')
.eq('provider', 'evolution_api')
.is('deleted_at', null)
.maybeSingle();
data = fallback.data;
error = fallback.error;
}
if (error) {
toast.add({ severity: 'error', summary: 'Erro ao carregar credenciais', detail: error.message, life: 4000 });
return;
}
if (data?.credentials) {
credentials.value = {
api_url: data.credentials.api_url || '',
api_key: data.credentials.api_key || '',
instance_name: data.credentials.instance_name || ''
};
hasCredentials.value = true;
channelRecord.value = data;
return;
}
// 2) Não tem ativo — verifica soft-deleted pra oferecer reativar
const { data: deleted } = await supabase
.from('notification_channels')
.select('*')
.eq('tenant_id', tenantId.value)
.eq('channel', 'whatsapp')
.eq('provider', 'evolution_api')
.not('deleted_at', 'is', null)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (deleted) softDeletedRecord.value = deleted;
}
async function reactivateChannel() {
if (!softDeletedRecord.value?.id) return;
reactivating.value = true;
try {
const { data, error } = await supabase.functions.invoke('reactivate-notification-channel', {
body: { channel_id: softDeletedRecord.value.id }
});
if (error || !data?.ok) throw new Error(error?.message || data?.error || 'reactivation_failed');
toast.add({
severity: 'success',
summary: 'Canal reativado',
detail: data.deactivated_others > 0
? `Conexão WhatsApp restaurada. ${data.deactivated_others} canal(is) alternativo(s) foi/foram desativado(s).`
: 'Conexão WhatsApp restaurada. Agora escaneie o QR Code pra conectar o celular.',
life: 4500
});
await loadCredentials();
await loadHeartbeatConfig();
await loadIncidents();
if (hasCredentials.value) await checkConnectionStatus();
} catch (e) {
toast.add({ severity: 'error', summary: 'Não foi possível reativar', detail: e.message || 'Tente novamente ou contate o suporte.', life: 5000 });
} finally {
reactivating.value = false;
}
}
// Verificar status da conexão via Evolution API
async function checkConnectionStatus() {
if (!hasCredentials.value) return;
connectionLoading.value = true;
try {
const res = await fetch(`${credentials.value.api_url}/instance/fetchInstances`, {
headers: { apikey: credentials.value.api_key }
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const instances = await res.json();
// Evolution v1 retorna [{ instance: { instanceName, status } }], v2 retorna [{ instanceName, connectionStatus/state }]
const arr = Array.isArray(instances) ? instances : [];
const inst = arr.find((i) => {
const name = i?.instance?.instanceName ?? i?.instanceName ?? i?.name;
return name === credentials.value.instance_name;
});
const rawState = inst?.instance?.status ?? inst?.instance?.state ?? inst?.connectionStatus ?? inst?.state ?? inst?.status;
connectionStatus.value = rawState || 'close';
// Persiste no DB pra SaaS admin ver status atualizado na listagem
if (channelRecord.value?.id) {
const dbStatus = rawState === 'open' ? 'connected' : rawState === 'connecting' ? 'connecting' : 'disconnected';
if (channelRecord.value.connection_status !== dbStatus) {
await supabase
.from('notification_channels')
.update({ connection_status: dbStatus, last_health_check: new Date().toISOString() })
.eq('id', channelRecord.value.id);
channelRecord.value.connection_status = dbStatus;
}
}
} catch (e) {
connectionStatus.value = 'close';
toast.add({
severity: 'warn',
summary: 'Não foi possível conectar à Evolution API',
detail: 'Verifique a URL e a chave de API.',
life: 5000
});
} finally {
connectionLoading.value = false;
}
}
// ──────────────────────────────────────────────────────────────
// Monitoramento de conexão (Heartbeat — Grupo 6.1)
// ──────────────────────────────────────────────────────────────
const heartbeatConfig = ref({
threshold_minutes: 5,
alerts_enabled: true,
reconnect_enabled: true
});
const heartbeatConfigSaving = ref(false);
const incidents = ref([]);
const incidentsLoading = ref(false);
async function loadHeartbeatConfig() {
if (!channelRecord.value) return;
const meta = channelRecord.value.metadata || {};
heartbeatConfig.value = {
threshold_minutes: Number(meta.heartbeat_threshold_minutes) || 5,
alerts_enabled: meta.heartbeat_alerts_enabled !== false,
reconnect_enabled: meta.heartbeat_reconnect_enabled !== false
};
}
async function saveHeartbeatConfig() {
if (!channelRecord.value?.id) return;
heartbeatConfigSaving.value = true;
try {
const threshold = Math.max(1, Math.min(60, Math.round(Number(heartbeatConfig.value.threshold_minutes) || 5)));
const newMeta = {
...(channelRecord.value.metadata || {}),
heartbeat_threshold_minutes: threshold,
heartbeat_alerts_enabled: !!heartbeatConfig.value.alerts_enabled,
heartbeat_reconnect_enabled: !!heartbeatConfig.value.reconnect_enabled
};
const { error } = await supabase
.from('notification_channels')
.update({ metadata: newMeta })
.eq('id', channelRecord.value.id);
if (error) throw error;
channelRecord.value.metadata = newMeta;
heartbeatConfig.value.threshold_minutes = threshold;
toast.add({ severity: 'success', summary: 'Configuração salva', life: 2000 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
} finally {
heartbeatConfigSaving.value = false;
}
}
async function loadIncidents() {
if (!tenantId.value || !channelRecord.value?.id) return;
incidentsLoading.value = true;
try {
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 3600 * 1000).toISOString();
const { data, error } = await supabase
.from('whatsapp_connection_incidents')
.select('id, kind, last_state, started_at, resolved_at, duration_seconds, notified_at')
.eq('channel_id', channelRecord.value.id)
.gte('started_at', sevenDaysAgo)
.order('started_at', { ascending: false })
.limit(30);
if (error) throw error;
incidents.value = data || [];
} catch (e) {
toast.add({ severity: 'warn', summary: 'Histórico indisponível', detail: e.message, life: 3000 });
} finally {
incidentsLoading.value = false;
}
}
function formatDuration(seconds) {
if (!seconds && seconds !== 0) return '—';
const s = Number(seconds);
if (s < 60) return `${s}s`;
if (s < 3600) return `${Math.floor(s / 60)}min ${s % 60}s`;
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
return `${h}h ${m}min`;
}
function incidentKindLabel(kind) {
return {
disconnected: 'Desconectado',
error: 'Erro',
qr_pending: 'Aguardando QR',
connecting: 'Conectando',
unknown: 'Desconhecido'
}[kind] || kind;
}
function incidentKindSeverity(kind) {
return {
disconnected: 'danger',
error: 'danger',
qr_pending: 'warn',
connecting: 'info',
unknown: 'secondary'
}[kind] || 'secondary';
}
// Buscar QR Code para conectar
async function fetchQrCode() {
if (!isMounted) return;
qrLoading.value = true;
qrCodeBase64.value = null;
clearQrTimer();
try {
const res = await fetch(`${credentials.value.api_url}/instance/connect/${credentials.value.instance_name}`, { headers: { apikey: credentials.value.api_key } });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
// Evolution v1: data.qrcode.base64 · v2: data.base64 · variantes: data.code/qr/qrCode
const base64 =
data?.base64 ||
data?.qrcode?.base64 ||
data?.qrCode?.base64 ||
data?.qr?.base64 ||
data?.qr ||
data?.code ||
null;
if (!base64) {
const openState =
data?.instance?.status === 'open' ||
data?.instance?.state === 'open' ||
data?.state === 'open' ||
data?.status === 'open';
if (openState) {
connectionStatus.value = 'open';
toast.add({ severity: 'success', summary: 'WhatsApp já está conectado!', life: 3000 });
qrDialog.value = false;
return;
}
console.error('[QR] Resposta inesperada da Evolution:', data);
throw new Error('QR Code não retornado pela API. Veja o console (F12) pra debug.');
}
qrCodeBase64.value = base64.startsWith('data:') ? base64 : `data:image/png;base64,${base64}`;
startQrCountdown();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao gerar QR Code', detail: e.message, life: 5000 });
} finally {
qrLoading.value = false;
}
}
function startQrCountdown() {
qrCountdown.value = 30;
qrTimer = setInterval(() => {
qrCountdown.value--;
if (qrCountdown.value <= 0) {
clearQrTimer();
fetchQrCode();
}
}, 1000);
}
function clearQrTimer() {
if (qrTimer) {
clearInterval(qrTimer);
qrTimer = null;
}
qrCountdown.value = 0;
}
function openQrDialog() {
qrDialog.value = true;
fetchQrCode();
}
// ══════════════════════════════════════════════════════════════
// Inbox (Fase 5a) — configurar webhook MESSAGES_UPSERT na Evolution
// ══════════════════════════════════════════════════════════════
const inboxProvisioning = ref(false);
const inboxWebhookUrl = ref('');
// Resolve URL pública do Supabase pra Evolution conseguir alcançar a edge function.
// Em dev local, localhost de dentro do container Evolution não funciona — precisa host.docker.internal.
function resolveSupabasePublicUrl() {
const envUrl = import.meta.env.VITE_SUPABASE_URL || '';
try {
const u = new URL(envUrl);
if (u.hostname === 'localhost' || u.hostname === '127.0.0.1') {
u.hostname = 'host.docker.internal';
return u.toString().replace(/\/+$/, '');
}
} catch {}
return envUrl.replace(/\/+$/, '');
}
async function provisionInbox() {
if (!tenantId.value) {
toast.add({ severity: 'warn', summary: 'Tenant inválido', life: 3000 });
return;
}
if (!credentials.value?.api_url || !credentials.value?.api_key || !credentials.value?.instance_name) {
toast.add({ severity: 'warn', summary: 'Salve as credenciais antes', detail: 'URL, API key e nome da instância são obrigatórios.', life: 4000 });
return;
}
inboxProvisioning.value = true;
try {
const publicUrl = resolveSupabasePublicUrl();
const { data, error } = await supabase.functions.invoke('evolution-webhook-provision', {
body: {
tenant_id: tenantId.value,
api_url: credentials.value.api_url,
api_key: credentials.value.api_key,
instance_name: credentials.value.instance_name,
public_url: publicUrl
}
});
if (error) throw error;
if (!data?.ok) throw new Error(data?.error || 'Falha no provisionamento');
inboxWebhookUrl.value = data.webhook_url;
toast.add({ severity: 'success', summary: 'Inbox conectada!', detail: 'Mensagens recebidas agora vão aparecer em /admin/conversas.', life: 5000 });
} catch (err) {
toast.add({ severity: 'error', summary: 'Falha ao configurar inbox', detail: err?.message || String(err), life: 6000 });
} finally {
inboxProvisioning.value = false;
}
}
function closeQrDialog() {
qrDialog.value = false;
clearQrTimer();
qrCodeBase64.value = null;
// Verificar se conectou depois de fechar o dialog
checkConnectionStatus();
}
// ══════════════════════════════════════════════════════════════
// ABA 2 — Templates de mensagem
// ══════════════════════════════════════════════════════════════
const templates = ref([]);
const templatesLoading = ref(false);
const templateSaving = ref({});
// Labels para exibição por event_type
const EVENT_TYPE_LABELS = {
lembrete_sessao: 'Lembrete',
confirmacao_sessao: 'Confirmação',
cancelamento_sessao: 'Cancelamento',
reagendamento: 'Reagendamento',
cobranca_pendente: 'Financeiro',
boas_vindas_paciente: 'Boas-vindas',
intake_recebido: 'Triagem',
intake_aprovado: 'Triagem'
};
const EVENT_SEVERITY = {
lembrete_sessao: 'info',
confirmacao_sessao: 'success',
cancelamento_sessao: 'danger',
reagendamento: 'warn',
cobranca_pendente: 'warning',
boas_vindas_paciente: 'success',
intake_recebido: 'info',
intake_aprovado: 'success'
};
// Label amigável a partir da key (ex: 'session.lembrete.whatsapp' → 'Lembrete de sessão')
function keyToLabel(key) {
const parts = key.replace('.whatsapp', '').split('.');
const map = {
'session.lembrete': 'Lembrete de sessão (24h antes)',
'session.lembrete_2h': 'Lembrete de sessão (2h antes)',
'session.confirmacao': 'Confirmação de agendamento',
'session.cancelamento': 'Sessão cancelada',
'session.reagendamento': 'Sessão reagendada',
'cobranca.pendente': 'Cobrança pendente',
'sistema.boas_vindas': 'Boas-vindas ao paciente'
};
return map[parts.slice(0, 2).join('.')] || key;
}
// Referências dos textareas para inserção no cursor
const textareaRefs = ref({});
function setTextareaRef(key, el) {
if (el) textareaRefs.value[key] = el;
}
async function loadTemplates() {
if (!tenantId.value) return;
templatesLoading.value = true;
try {
// 1. Busca templates globais do SaaS (tenant_id IS NULL)
const { data: globals, error: gErr } = await supabase
.from('notification_templates')
.select('*')
.is('tenant_id', null)
.eq('channel', 'whatsapp')
.eq('is_default', true)
.eq('is_active', true)
.is('deleted_at', null)
.order('domain')
.order('event_type');
if (gErr) throw gErr;
// 2. Busca customizações do tenant
const { data: customs, error: cErr } = await supabase.from('notification_templates').select('*').eq('tenant_id', tenantId.value).eq('channel', 'whatsapp').is('deleted_at', null);
if (cErr) throw cErr;
const customMap = {};
for (const c of customs || []) customMap[c.key] = c;
// 3. Mescla: para cada global, verifica se o tenant tem customização
templates.value = (globals || []).map((g) => {
const custom = customMap[g.key];
return {
key: g.key,
domain: g.domain,
event_type: g.event_type,
label: keyToLabel(g.key),
type: EVENT_TYPE_LABELS[g.event_type] || g.event_type,
type_severity: EVENT_SEVERITY[g.event_type] || 'secondary',
variables: g.variables || [],
default_body: g.body_text,
id: custom?.id || null,
body_text: custom?.body_text || g.body_text,
is_custom: !!custom
};
});
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao carregar templates', detail: e.message, life: 4000 });
} finally {
templatesLoading.value = false;
}
}
// Inserir variável no textarea na posição do cursor
function insertVariable(templateKey, variable) {
const snippet = `{{${variable}}}`;
const tpl = templates.value.find((t) => t.key === templateKey);
if (!tpl) return;
const textarea = textareaRefs.value[templateKey]?.$el?.querySelector('textarea');
if (textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = tpl.body_text;
tpl.body_text = text.substring(0, start) + snippet + text.substring(end);
nextTick(() => {
textarea.focus();
textarea.setSelectionRange(start + snippet.length, start + snippet.length);
});
} else {
// Fallback: adicionar ao final
tpl.body_text = (tpl.body_text || '') + snippet;
}
}
// Salvar template individual
async function saveTemplate(tpl) {
if (!tenantId.value || templateSaving.value[tpl.key]) return;
templateSaving.value[tpl.key] = true;
try {
if (tpl.id) {
// Atualizar existente
const { error } = await supabase.from('notification_templates').update({ body_text: tpl.body_text }).eq('id', tpl.id);
if (error) throw error;
} else {
// Verificar se já existe um registro ativo para esta key
const { data: existing } = await supabase.from('notification_templates').select('id').eq('tenant_id', tenantId.value).eq('key', tpl.key).is('deleted_at', null).maybeSingle();
if (existing?.id) {
// Já existe (criado por outra sessão) — atualizar
const { error } = await supabase.from('notification_templates').update({ body_text: tpl.body_text, is_active: true }).eq('id', existing.id);
if (error) throw error;
tpl.id = existing.id;
} else {
// Inserir novo
const { data, error } = await supabase
.from('notification_templates')
.insert({
owner_id: userId.value,
tenant_id: tenantId.value,
channel: 'whatsapp',
key: tpl.key,
domain: tpl.domain,
event_type: tpl.event_type,
body_text: tpl.body_text,
variables: tpl.variables,
is_active: true,
is_default: false
})
.select('id')
.single();
if (error) throw error;
tpl.id = data.id;
}
tpl.is_custom = true;
}
toast.add({ severity: 'success', summary: 'Template salvo', life: 3000 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao salvar template', detail: e.message, life: 5000 });
} finally {
templateSaving.value[tpl.key] = false;
}
}
// Verificar se template difere do padrão global
function isTemplateModified(tpl) {
return tpl.default_body ? tpl.body_text !== tpl.default_body : false;
}
// Restaurar template para o padrão global
function confirmRestoreTemplate(tpl) {
if (!tpl.default_body) return;
confirm.require({
group: 'headless',
message: `Restaurar "${tpl.label}" para o texto original definido pelo administrador?`,
header: 'Restaurar template',
icon: 'pi-undo',
accept: async () => {
tpl.body_text = tpl.default_body;
if (tpl.id) {
await saveTemplate(tpl);
}
}
});
}
// ══════════════════════════════════════════════════════════════
// ABA 2 — Emojis rápidos para o guia de formatação
// ══════════════════════════════════════════════════════════════
const QUICK_EMOJIS = [
{ char: '📅', label: 'Calendário' },
{ char: '⏰', label: 'Relógio / Lembrete' },
{ char: '✅', label: 'Confirmado' },
{ char: '❌', label: 'Cancelado' },
{ char: '🔔', label: 'Notificação' },
{ char: '💬', label: 'Mensagem' },
{ char: '💙', label: 'Cuidado / Saúde' },
{ char: '🌿', label: 'Bem-estar' },
{ char: '🤝', label: 'Parceria / Encontro' },
{ char: '📋', label: 'Formulário / Triagem' },
{ char: '💰', label: 'Financeiro' },
{ char: '🔗', label: 'Link' },
{ char: '📍', label: 'Local' },
{ char: '📞', label: 'Telefone' },
{ char: '🏥', label: 'Clínica' },
{ char: '🧠', label: 'Psicologia' },
{ char: '👤', label: 'Paciente' },
{ char: '🌟', label: 'Destaque' },
{ char: '⚠️', label: 'Atenção' },
{ char: '➡️', label: 'Seta / Próximo passo' },
{ char: '🗓️', label: 'Agenda' },
{ char: '🕐', label: 'Hora' },
{ char: '📩', label: 'Recebido' },
{ char: '🔄', label: 'Reagendamento' }
];
function copyEmoji(char) {
navigator.clipboard?.writeText(char).catch(() => {});
toast.add({ severity: 'info', summary: `${char} copiado!`, life: 1500 });
}
// ══════════════════════════════════════════════════════════════
// ABA 3 — Logs de envio
// ══════════════════════════════════════════════════════════════
const logs = ref([]);
const logsLoading = ref(false);
const logsFilter = ref('todos');
const logsPage = ref(1);
const logsPerPage = 20;
const logsTotal = ref(0);
const FILTER_OPTIONS = [
{ label: 'Todos', value: 'todos' },
{ label: 'Enviado', value: 'sent' },
{ label: 'Falhou', value: 'failed' }
];
// Mapear keys para nomes amigáveis (dinâmico a partir dos templates carregados)
function friendlyTemplateKey(key) {
const tpl = templates.value.find((t) => t.key === key || t.event_type === key);
return tpl?.label || key || '—';
}
function statusTag(status) {
switch (status) {
case 'sent':
return { label: 'Enviado', severity: 'success' };
case 'failed':
return { label: 'Falhou', severity: 'danger' };
case 'pending':
return { label: 'Pendente', severity: 'warn' };
default:
return { label: status || '—', severity: 'secondary' };
}
}
function formatDate(dt) {
if (!dt) return '—';
const d = new Date(dt);
return d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric' }) + ' ' + d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
}
async function loadLogs() {
if (!tenantId.value) return;
logsLoading.value = true;
try {
let query = supabase.from('notification_logs').select('*', { count: 'exact' }).eq('tenant_id', tenantId.value).eq('channel', 'whatsapp').order('created_at', { ascending: false });
if (logsFilter.value !== 'todos') {
query = query.eq('status', logsFilter.value);
}
const from = (logsPage.value - 1) * logsPerPage;
const to = from + logsPerPage - 1;
query = query.range(from, to);
const { data, error, count } = await query;
if (error) throw error;
logs.value = data || [];
logsTotal.value = count || 0;
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao carregar logs', detail: e.message, life: 4000 });
} finally {
logsLoading.value = false;
}
}
function onFilterChange(val) {
logsFilter.value = val;
logsPage.value = 1;
loadLogs();
}
function onPageChange(event) {
logsPage.value = event.page + 1;
loadLogs();
}
// ══════════════════════════════════════════════════════════════
// Inicialização
// ══════════════════════════════════════════════════════════════
onMounted(async () => {
await loadUser();
await Promise.all([loadCredentials(), loadTemplates(), loadLogs()]);
if (hasCredentials.value) {
await checkConnectionStatus();
await loadHeartbeatConfig();
await loadIncidents();
}
});
onBeforeUnmount(() => {
isMounted = false;
clearQrTimer();
});
</script>
<template>
<Teleport to="#cfg-page-actions" defer>
<router-link :to="chooserPath">
<Button label="Trocar canal" icon="pi pi-arrow-left" severity="secondary" outlined size="small" class="rounded-full" />
</router-link>
</Teleport>
<div class="flex flex-col gap-4">
<!-- Abas -->
<Tabs :value="activeTab" @update:value="activeTab = $event">
<TabList>
<Tab :value="0"><i class="pi pi-link mr-2" />Conexão</Tab>
<Tab :value="1"><i class="pi pi-file-edit mr-2" />Templates</Tab>
<Tab :value="2"><i class="pi pi-list mr-2" />Logs de envio</Tab>
</TabList>
<TabPanels>
<!-- ABA 1 Conexão -->
<TabPanel :value="0">
<div class="flex flex-col gap-4 pt-3">
<!-- Canal soft-deleted oferecer reativação -->
<div v-if="!hasCredentials && softDeletedRecord" class="border border-amber-500/40 bg-amber-500/5 rounded-lg p-6 flex flex-col items-center text-center gap-3">
<div class="grid place-items-center w-14 h-14 rounded-full bg-amber-100 text-amber-600">
<i class="pi pi-history text-2xl" />
</div>
<div class="font-semibold text-sm text-amber-800 dark:text-amber-300">WhatsApp Pessoal foi usado anteriormente</div>
<p class="text-sm text-[var(--text-color-secondary)] m-0 max-w-md">
As credenciais continuam salvas basta reativar e escanear o QR Code novamente. Se você tem outro canal WhatsApp ativo (ex: WhatsApp Oficial), ele será desativado.
</p>
<Button label="Reativar WhatsApp Pessoal" icon="pi pi-refresh" severity="warn" :loading="reactivating" @click="reactivateChannel" />
</div>
<!-- Sem credenciais nem histórico WhatsApp nunca configurado -->
<div v-else-if="!hasCredentials" class="border border-[var(--surface-border)] rounded-lg p-6 bg-[var(--surface-card)] text-center">
<div class="grid place-items-center w-14 h-14 rounded-full bg-gray-100 text-gray-400 mx-auto mb-3">
<i class="pi pi-comments text-2xl" />
</div>
<div class="font-semibold text-sm mb-1">WhatsApp ainda não configurado</div>
<p class="text-sm text-[var(--text-color-secondary)] m-0 max-w-md mx-auto">
A integração com o WhatsApp precisa ser ativada pela equipe de suporte. Entre em contato para que possamos configurar o envio automático de mensagens para você.
</p>
</div>
<!-- Com credenciais: status + QR Code -->
<template v-else>
<!-- Status da conexão -->
<div class="border border-[var(--surface-border)] rounded-lg p-4 bg-[var(--surface-card)]">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-3">
<div class="grid place-items-center w-10 h-10 rounded-full" :class="connectionStatus === 'open' ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-500'">
<i class="pi pi-comments text-lg" />
</div>
<div>
<div class="font-semibold text-sm">Status da conexão</div>
<Tag :value="connectionTag.label" :severity="connectionTag.severity" class="text-xs mt-1" />
</div>
</div>
<div class="flex gap-2">
<Button :label="connectionStatus === 'open' ? 'Reconectar' : 'Conectar WhatsApp'" icon="pi pi-qrcode" size="small" @click="openQrDialog" />
<Button icon="pi pi-refresh" size="small" severity="secondary" outlined :loading="connectionLoading" v-tooltip.bottom="'Verificar status'" @click="checkConnectionStatus" />
</div>
</div>
</div>
<!-- Instruções simples para o terapeuta -->
<div v-if="connectionStatus !== 'open'" class="flex items-start gap-3 px-4 py-3 rounded-lg border border-[var(--surface-border)] bg-[var(--surface-ground)]">
<i class="pi pi-info-circle text-[var(--primary-color)] mt-0.5" />
<div class="text-sm text-[var(--text-color-secondary)]">
<strong class="text-[var(--text-color)]">Como conectar:</strong>
clique em <strong>"Conectar WhatsApp"</strong>, abra o WhatsApp no seu celular, em <strong>Configurações > Aparelhos conectados > Conectar aparelho</strong>
e escaneie o QR Code que aparecerá na tela.
</div>
</div>
<!-- Monitoramento de conexão (heartbeat) -->
<div class="border border-[var(--surface-border)] rounded-lg p-4 bg-[var(--surface-card)] flex flex-col gap-3">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-3">
<div class="grid place-items-center w-10 h-10 rounded-full bg-amber-100 text-amber-600">
<i class="pi pi-heart-fill text-lg" />
</div>
<div>
<div class="font-semibold text-sm">Monitoramento de conexão</div>
<div class="text-xs text-[var(--text-color-secondary)]">
Recebe alerta quando o celular fica offline por mais tempo que o configurado.
</div>
</div>
</div>
<Button icon="pi pi-refresh" size="small" severity="secondary" outlined :loading="incidentsLoading" v-tooltip.bottom="'Recarregar histórico'" @click="loadIncidents" />
</div>
<div class="grid grid-cols-1 md:grid-cols-[auto_auto_1fr] gap-3 items-end">
<div class="flex items-center gap-2">
<ToggleSwitch v-model="heartbeatConfig.alerts_enabled" inputId="hb-alerts" />
<label for="hb-alerts" class="text-sm cursor-pointer select-none">Alertas ativos</label>
</div>
<div class="flex items-center gap-2">
<label class="text-sm whitespace-nowrap">Alertar após</label>
<InputNumber v-model="heartbeatConfig.threshold_minutes" :min="1" :max="60" showButtons buttonLayout="horizontal" :inputStyle="{ width: '3.5rem', textAlign: 'center' }" incrementButtonIcon="pi pi-plus" decrementButtonIcon="pi pi-minus" />
<span class="text-sm text-[var(--text-color-secondary)]">min sem conexão</span>
</div>
<div class="flex justify-end">
<Button label="Salvar" icon="pi pi-check" size="small" :loading="heartbeatConfigSaving" @click="saveHeartbeatConfig" />
</div>
</div>
<!-- Reconnect automático -->
<div class="flex items-start gap-2 pt-2 border-t border-[var(--surface-border)]">
<ToggleSwitch v-model="heartbeatConfig.reconnect_enabled" inputId="hb-reconnect" />
<label for="hb-reconnect" class="text-sm cursor-pointer select-none flex-1">
<span class="font-semibold">Tentar reconectar automaticamente</span>
<span class="block text-xs text-[var(--text-color-secondary)]">Antes de abrir um alerta, o sistema pede à Evolution pra restaurar a sessão. Se funcionar, ninguém é incomodado. Máximo 1 tentativa a cada 10 minutos.</span>
</label>
</div>
<!-- Histórico de incidents -->
<div class="border-t border-[var(--surface-border)] pt-3">
<div class="text-xs font-semibold uppercase tracking-wide text-[var(--text-color-secondary)] mb-2">
Últimos 7 dias ({{ incidents.length }} {{ incidents.length === 1 ? 'evento' : 'eventos' }})
</div>
<div v-if="incidentsLoading" class="text-xs text-[var(--text-color-secondary)] italic py-3 text-center">
Carregando
</div>
<div v-else-if="!incidents.length" class="text-xs text-[var(--text-color-secondary)] italic py-3 text-center">
Nenhum evento registrado nos últimos 7 dias conexão estável.
</div>
<div v-else class="flex flex-col gap-1 max-h-[240px] overflow-y-auto text-xs">
<div v-for="inc in incidents" :key="inc.id"
class="grid grid-cols-[auto_auto_1fr_auto] items-center gap-3 px-2 py-1.5 rounded hover:bg-[var(--surface-hover)]">
<Tag :value="incidentKindLabel(inc.kind)" :severity="incidentKindSeverity(inc.kind)" class="text-[0.62rem]" />
<span :class="inc.resolved_at ? 'text-green-600' : 'text-orange-600'" class="text-[0.62rem] font-bold uppercase">
{{ inc.resolved_at ? 'Resolvido' : 'Aberto' }}
</span>
<span class="text-[var(--text-color-secondary)]">
<span class="font-mono">{{ new Date(inc.started_at).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' }) }}</span>
<span v-if="inc.resolved_at"> {{ new Date(inc.resolved_at).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) }}</span>
</span>
<span class="text-[var(--text-color-secondary)] font-mono">{{ formatDuration(inc.duration_seconds) }}</span>
</div>
</div>
</div>
</div>
<!-- Inbox / CRM de conversas (Fase 5a) -->
<div v-if="connectionStatus === 'open'" class="flex flex-col gap-3 px-4 py-3 rounded-lg border border-[var(--surface-border)] bg-[var(--surface-ground)]">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-3">
<div class="grid place-items-center w-10 h-10 rounded-full bg-blue-100 text-blue-600">
<i class="pi pi-inbox text-lg" />
</div>
<div>
<div class="font-semibold text-sm">Inbox de conversas</div>
<div class="text-xs text-[var(--text-color-secondary)]">Receba respostas dos pacientes direto no sistema.</div>
</div>
</div>
<div class="flex gap-2">
<Button label="Conectar inbox" icon="pi pi-link" size="small" :loading="inboxProvisioning" @click="provisionInbox" />
<Button icon="pi pi-external-link" severity="secondary" outlined size="small" v-tooltip.bottom="'Abrir Conversas'" @click="goConversas" />
</div>
</div>
<div v-if="inboxWebhookUrl" class="text-[0.7rem] text-[var(--text-color-secondary)] bg-[var(--surface-card)] border border-[var(--surface-border)] rounded px-2 py-1.5 font-mono break-all">
Webhook configurado: {{ inboxWebhookUrl }}
</div>
</div>
</template>
</div>
</TabPanel>
<!-- ABA 2 Templates -->
<TabPanel :value="1">
<div class="flex gap-4 pt-3 items-start">
<!-- Coluna esquerda: cards de templates (65% no /configuracoes; 100% no /melissa pq a guia vai pro drawer) -->
<div class="flex flex-col gap-3 min-w-0" :style="inMelissa ? 'flex: 1;' : 'flex: 0 0 65%;'">
<!-- Skeleton loading -->
<template v-if="templatesLoading">
<div v-for="n in 4" :key="n" class="border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] p-4">
<div class="flex items-center gap-2 mb-3">
<Skeleton width="5rem" height="1.4rem" border-radius="999px" />
<Skeleton width="10rem" height="1rem" />
</div>
<Skeleton width="100%" height="5rem" class="mb-2" />
<div class="flex gap-1">
<Skeleton v-for="i in 3" :key="i" width="6rem" height="1.6rem" border-radius="999px" />
</div>
</div>
</template>
<!-- Cards de templates -->
<div v-else v-for="tpl in templates" :key="tpl.key" class="border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] p-4 flex flex-col gap-3">
<!-- Header do card -->
<div class="flex items-center gap-2 flex-wrap">
<span class="font-semibold text-sm">{{ tpl.label }}</span>
<Tag :value="tpl.type" :severity="tpl.type_severity" class="text-[0.65rem]" />
<Tag v-if="tpl.is_custom" value="Personalizado" severity="success" class="text-[0.65rem]" />
</div>
<!-- Textarea editável -->
<Textarea :ref="(el) => setTextareaRef(tpl.key, el)" v-model="tpl.body_text" rows="5" auto-resize class="w-full text-sm font-mono" />
<!-- Variáveis clicáveis -->
<div class="flex flex-col gap-1.5">
<span class="text-xs text-[var(--text-color-secondary)]">Inserir variável no cursor:</span>
<div class="flex flex-wrap gap-1.5">
<Button v-for="v in tpl.variables" :key="v" :label="`{{${v}}}`" size="small" severity="secondary" outlined class="font-mono !text-[0.68rem] !py-1 !px-2" @click="insertVariable(tpl.key, v)" />
</div>
</div>
<!-- Ações -->
<div class="flex items-center gap-2 justify-end">
<Button v-if="isTemplateModified(tpl)" label="Restaurar original" icon="pi pi-undo" size="small" severity="secondary" outlined @click="confirmRestoreTemplate(tpl)" />
<Button label="Salvar" icon="pi pi-check" size="small" :loading="templateSaving[tpl.key]" :disabled="templateSaving[tpl.key]" @click="saveTemplate(tpl)" />
</div>
</div>
</div>
<!-- Coluna direita: guia de formatacao.
Em /configuracoes: fica inline (35%).
Em /melissa: teleporta pro drawer (#cfg-page-side). -->
<Teleport to="#cfg-page-side" :disabled="!inMelissa" defer>
<div
v-show="!inMelissa || activeTab === 1"
class="flex flex-col gap-3"
:class="inMelissa ? '' : 'sticky top-4'"
:style="inMelissa ? '' : 'flex: 0 0 35%;'"
>
<div class="border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] p-4 flex flex-col gap-4">
<div class="flex items-center gap-2">
<i class="pi pi-book text-[var(--primary-color)]" />
<span class="font-semibold text-sm">Guia de formatação</span>
</div>
<!-- Formatação oficial -->
<div class="flex flex-col gap-2">
<div class="flex items-center gap-1.5 mb-1">
<span class="text-[0.7rem] font-semibold uppercase tracking-wider text-[var(--text-color-secondary)]">Formatação oficial</span>
<div class="flex-1 h-px bg-[var(--surface-border)]" />
</div>
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-between gap-2">
<span class="font-mono text-xs bg-[var(--surface-ground)] px-2 py-0.5 rounded text-[var(--text-color-secondary)]">*texto*</span>
<span class="text-xs font-bold">Negrito</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="font-mono text-xs bg-[var(--surface-ground)] px-2 py-0.5 rounded text-[var(--text-color-secondary)]">_texto_</span>
<span class="text-xs italic">Itálico</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="font-mono text-xs bg-[var(--surface-ground)] px-2 py-0.5 rounded text-[var(--text-color-secondary)]">~texto~</span>
<span class="text-xs line-through">Tachado</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="font-mono text-xs bg-[var(--surface-ground)] px-2 py-0.5 rounded text-[var(--text-color-secondary)]">`texto`</span>
<span class="text-xs font-mono bg-[var(--surface-ground)] px-1 rounded">Monoespaçado</span>
</div>
</div>
</div>
<!-- Efeitos extras Unicode -->
<div class="flex flex-col gap-2">
<div class="flex items-center gap-1.5 mb-1">
<span class="text-[0.7rem] font-semibold uppercase tracking-wider text-[var(--text-color-secondary)]">Efeitos extras (Unicode)</span>
<div class="flex-1 h-px bg-[var(--surface-border)]" />
</div>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-0.5">
<div class="flex items-center justify-between gap-2">
<span class="text-xs text-[var(--text-color-secondary)]">Negrito Unicode</span>
<span class="text-xs">𝙝𝙤𝙡𝙖</span>
</div>
<span class="text-[0.65rem] text-[var(--text-color-secondary)] opacity-70">Copie de sites de "font generator"</span>
</div>
<div class="flex flex-col gap-0.5">
<div class="flex items-center justify-between gap-2">
<span class="text-xs text-[var(--text-color-secondary)]">Cursiva Unicode</span>
<span class="text-xs">𝓽𝓮𝔁𝓽𝓸</span>
</div>
<span class="text-[0.65rem] text-[var(--text-color-secondary)] opacity-70">Cada letra é um caractere diferente</span>
</div>
<div class="flex flex-col gap-0.5">
<div class="flex items-center justify-between gap-2">
<span class="text-xs text-[var(--text-color-secondary)]">Small Caps</span>
<span class="text-xs">ᴛᴇxᴛᴏ</span>
</div>
<span class="text-[0.65rem] text-[var(--text-color-secondary)] opacity-70">Bom para títulos curtos</span>
</div>
<div class="flex flex-col gap-0.5">
<div class="flex items-center justify-between gap-2">
<span class="text-xs text-[var(--text-color-secondary)]">Sublinhado</span>
<span class="text-xs">t̲e̲x̲t̲o̲</span>
</div>
<span class="text-[0.65rem] text-[var(--text-color-secondary)] opacity-70">U+0332 após cada letra</span>
</div>
</div>
</div>
<!-- Emojis mais usados -->
<div class="flex flex-col gap-2">
<div class="flex items-center gap-1.5 mb-1">
<span class="text-[0.7rem] font-semibold uppercase tracking-wider text-[var(--text-color-secondary)]">Emojis mais usados</span>
<div class="flex-1 h-px bg-[var(--surface-border)]" />
</div>
<div class="flex flex-wrap gap-1">
<button
v-for="emoji in QUICK_EMOJIS"
:key="emoji.char"
v-tooltip.top="emoji.label"
class="text-base leading-none p-1 rounded hover:bg-[var(--surface-hover)] transition-colors cursor-pointer border-0 bg-transparent"
@click="copyEmoji(emoji.char)"
>{{ emoji.char }}</button>
</div>
<span class="text-[0.65rem] text-[var(--text-color-secondary)] opacity-70">Clique para copiar</span>
</div>
<!-- Dica -->
<div class="flex items-start gap-2 px-3 py-2.5 rounded-lg bg-[var(--surface-ground)] border border-[var(--surface-border)]">
<i class="pi pi-lightbulb text-amber-500 text-xs mt-0.5 shrink-0" />
<p class="text-[0.68rem] text-[var(--text-color-secondary)] m-0 leading-relaxed">
Use <strong>*negrito*</strong> para destacar horários e datas. Evite excesso de formatação mensagens simples têm maior taxa de leitura.
</p>
</div>
</div>
</div>
</Teleport>
</div>
</TabPanel>
<!-- ABA 3 Logs -->
<TabPanel :value="2">
<div class="flex flex-col gap-3 pt-3">
<!-- Filtros -->
<div class="flex items-center gap-2 flex-wrap">
<Button
v-for="opt in FILTER_OPTIONS"
:key="opt.value"
:label="opt.label"
size="small"
:severity="logsFilter === opt.value ? 'primary' : 'secondary'"
:outlined="logsFilter !== opt.value"
@click="onFilterChange(opt.value)"
/>
<Button icon="pi pi-refresh" size="small" severity="secondary" outlined :loading="logsLoading" v-tooltip.bottom="'Atualizar'" class="ml-auto" @click="loadLogs" />
</div>
<!-- Tabela -->
<DataTable :value="logs" :loading="logsLoading" responsive-layout="scroll" striped-rows class="text-sm">
<Column field="created_at" header="Data/hora" style="min-width: 140px">
<template #body="{ data }">
{{ formatDate(data.created_at) }}
</template>
</Column>
<Column field="recipient_address" header="Destinatário" style="min-width: 140px" />
<Column field="template_key" header="Template" style="min-width: 160px">
<template #body="{ data }">
{{ friendlyTemplateKey(data.template_key) }}
</template>
</Column>
<Column field="status" header="Status" style="min-width: 100px">
<template #body="{ data }">
<Tag :value="statusTag(data.status).label" :severity="statusTag(data.status).severity" class="text-[0.7rem]" />
</template>
</Column>
<Column field="failure_reason" header="Erro" style="min-width: 160px">
<template #body="{ data }">
<span v-if="data.failure_reason" v-tooltip.top="data.failure_reason" class="text-xs text-[var(--text-color-secondary)] truncate block max-w-[200px]">
{{ data.failure_reason }}
</span>
<span v-else class="text-xs text-[var(--text-color-secondary)] opacity-40"></span>
</template>
</Column>
<template #empty>
<div class="text-center py-6 text-sm text-[var(--text-color-secondary)]">Nenhum log de envio encontrado.</div>
</template>
</DataTable>
<!-- Paginação -->
<Paginator v-if="logsTotal > logsPerPage" :rows="logsPerPage" :totalRecords="logsTotal" :first="(logsPage - 1) * logsPerPage" @page="onPageChange" />
</div>
</TabPanel>
</TabPanels>
</Tabs>
<!-- Dialog QR Code -->
<Dialog
v-model:visible="qrDialog"
modal
:draggable="false"
:closable="!qrLoading"
:dismissableMask="!qrLoading"
maximizable
class="dc-dialog w-[36rem]"
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
:pt="{
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] bg-[var(--surface-ground)]' },
content: { class: '!p-3' },
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] bg-[var(--surface-ground)]' },
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } }
}"
pt:mask:class="backdrop-blur-xs"
@hide="closeQrDialog"
>
<template #header>
<div class="flex w-full items-center justify-between gap-3 px-1">
<div class="flex items-center gap-3 min-w-0">
<div class="min-w-0">
<div class="text-base font-semibold truncate">Conectar WhatsApp</div>
<div class="text-xs opacity-50">Escaneie o QR Code para conectar</div>
</div>
</div>
</div>
</template>
<div class="flex flex-col items-center gap-4 py-2">
<p class="text-sm text-[var(--text-color-secondary)] text-center m-0">Escaneie o QR Code abaixo com o WhatsApp do seu celular para conectar.</p>
<!-- Loading -->
<div v-if="qrLoading" class="flex flex-col items-center gap-3 py-6">
<ProgressSpinner style="width: 48px; height: 48px" />
<span class="text-xs text-[var(--text-color-secondary)]">Gerando QR Code...</span>
</div>
<!-- QR Code -->
<div v-else-if="qrCodeBase64" class="flex flex-col items-center gap-3">
<img :src="qrCodeBase64" alt="QR Code WhatsApp" class="w-64 h-64 rounded-lg border border-[var(--surface-border)]" />
<div class="flex items-center gap-2 text-xs text-[var(--text-color-secondary)]">
<i class="pi pi-clock" />
<span
>Atualiza automaticamente em <strong>{{ qrCountdown }}s</strong></span
>
</div>
</div>
<!-- Erro / sem QR -->
<div v-else class="text-center py-6">
<i class="pi pi-exclamation-circle text-3xl text-[var(--text-color-secondary)] opacity-40 mb-2" />
<p class="text-sm text-[var(--text-color-secondary)] m-0">Não foi possível gerar o QR Code.</p>
</div>
<Button label="Atualizar QR Code" icon="pi pi-refresh" size="small" severity="secondary" outlined :loading="qrLoading" @click="fetchQrCode" />
</div>
<template #footer>
<div class="flex items-center justify-end gap-2 px-3 py-3">
<Button label="Fechar" severity="secondary" text class="rounded-full" @click="closeQrDialog" />
</div>
</template>
</Dialog>
<ConfirmDialog group="headless">
<template #container="{ message, acceptCallback, rejectCallback }">
<div class="flex flex-col items-center p-8 bg-surface-0 dark:bg-surface-900 rounded-xl shadow-xl">
<div class="rounded-full inline-flex justify-center items-center h-24 w-24 -mt-20" :style="{ background: message.color || 'var(--p-primary-color)', color: '#fff' }">
<i :class="`pi ${message.icon || 'pi-question'} !text-4xl`"></i>
</div>
<span class="font-bold text-2xl block mb-2 mt-6">{{ message.header }}</span>
<p class="mb-0 text-center text-[var(--text-color-secondary)]">{{ message.message }}</p>
<div class="flex items-center gap-2 mt-6">
<Button label="Confirmar" class="rounded-full" :style="{ background: message.color || 'var(--p-primary-color)', borderColor: message.color || 'var(--p-primary-color)' }" @click="acceptCallback" />
<Button label="Cancelar" variant="outlined" class="rounded-full" @click="rejectCallback" />
</div>
</div>
</template>
</ConfirmDialog>
</div>
</template>
<style scoped>
</style>