Correcao Sidebar Classico e Rail, Correcao Layout, Ajuste de Breakpoint para Tailwind, Ajuste AppTopbar, Ajuste Menu PopOver, Recriado Paleta de Cores, Inserido algumas animações leves, Reajuste Cor items NOVOS da tabela, Drawer Ajuda Corrigido no Logout, Whatsapp, sms, email, recursos extras

This commit is contained in:
Leonardo
2026-03-24 21:26:58 -03:00
parent a89d1f5560
commit 53a4980396
453 changed files with 121427 additions and 174407 deletions
@@ -0,0 +1,787 @@
<!--
|--------------------------------------------------------------------------
| 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 { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
const toast = useToast();
const confirm = useConfirm();
const tenantStore = useTenantStore();
// ── 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 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' };
}
});
// 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;
// Tentar por tenant_id primeiro (como o SaaS salva)
let { data, error } = await supabase.from('notification_channels').select('*').eq('tenant_id', tenantId.value).eq('channel', 'whatsapp').is('deleted_at', null).maybeSingle();
// Fallback: 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').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;
}
}
// 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();
const inst = Array.isArray(instances) ? instances.find((i) => i.instance?.instanceName === credentials.value.instance_name) : null;
connectionStatus.value = inst?.instance?.status || 'close';
} 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;
}
}
// 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();
const base64 = data?.base64;
if (!base64) {
// Instância pode já estar conectada
if (data?.instance?.status === 'open') {
connectionStatus.value = 'open';
toast.add({ severity: 'success', summary: 'WhatsApp já está conectado!', life: 3000 });
qrDialog.value = false;
return;
}
throw new Error('QR Code não retornado pela API.');
}
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();
}
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 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();
});
onBeforeUnmount(() => {
isMounted = false;
clearQrTimer();
});
</script>
<template>
<div class="flex flex-col gap-4">
<!-- Subheader -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-comments" /></div>
<div class="min-w-0">
<div class="cfg-subheader__title">WhatsApp</div>
<div class="cfg-subheader__sub">Configure a integração e os templates de mensagem do WhatsApp</div>
</div>
<div class="cfg-subheader__actions">
<Tag :value="connectionTag.label" :severity="connectionTag.severity" class="text-xs" />
</div>
</div>
<!-- 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">
<!-- Sem credenciais WhatsApp não configurado pelo admin -->
<div v-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>
</template>
</div>
</TabPanel>
<!-- ABA 2 Templates -->
<TabPanel :value="1">
<div class="flex flex-col gap-3 pt-3">
<!-- 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>
</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)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
content: { class: '!p-3' },
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
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>
.cfg-subheader {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.875rem 1rem;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--primary-color, #6366f1) 30%, transparent);
background: linear-gradient(135deg, color-mix(in srgb, var(--primary-color, #6366f1) 12%, var(--surface-card)) 0%, color-mix(in srgb, var(--primary-color, #6366f1) 4%, var(--surface-card)) 60%, var(--surface-card) 100%);
position: relative;
overflow: hidden;
}
.cfg-subheader::before {
content: '';
position: absolute;
top: -20px;
right: -20px;
width: 80px;
height: 80px;
border-radius: 50%;
background: color-mix(in srgb, var(--primary-color, #6366f1) 15%, transparent);
filter: blur(20px);
pointer-events: none;
}
.cfg-subheader__icon {
display: grid;
place-items: center;
width: 2rem;
height: 2rem;
border-radius: 6px;
flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color, #6366f1) 20%, transparent);
color: var(--primary-color, #6366f1);
font-size: 0.85rem;
}
.cfg-subheader__title {
font-size: 0.95rem;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--primary-color, #6366f1);
}
.cfg-subheader__sub {
font-size: 0.75rem;
color: var(--text-color-secondary);
opacity: 0.85;
}
.cfg-subheader__actions {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
flex-shrink: 0;
position: relative;
z-index: 1;
}
</style>