9966b5f175
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>
1247 lines
64 KiB
Vue
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, vá 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>
|