Files
agenciapsilmno/src/views/pages/saas/SaasNotificationTemplatesPage.vue
T
Leonardo 86311ef305 Melissa: hub Configuracoes + Embed + 9 Pages novas + dialog blueprint dark
Sprints 04-29 + 04-30 acumuladas.

- MelissaConfiguracoes: hub 2-col com 6 grupos (Layout/Conta/Agenda/
  Financeiro/WhatsApp/Sistema), tudo embedado via MelissaEmbed.
- MelissaEmbed: wrapper generico que injeta layout-variant=melissa
  e remove cromos pra reaproveitar Pages tradicionais.
- 9 Melissa Pages novas: CadastrosRecebidos, Compromissos, Configuracoes,
  Conversas, Embed, Grupos, Medicos, Recorrencias, Tags.
- Dialog blueprint atualizado: bg-gray-100 (hardcoded light) ->
  bg-[var(--surface-ground)] (tema-aware). 22 dialogs migrados em
  9 arquivos. Anti-pattern documentado.
- PatientsCadastroPage: bug fix dropdown Grupo (optionLabel nome->name),
  toggle vertical/abas com persist localStorage, sticky margin-top.
- Surface picker no popover do MelissaLayout (8 swatches).
- useTopbarPlanMenu, useMelissaWhatsapp, useMelissaPacientesAside novos.
- Migration: status agenda remarcado/confirmado.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 11:41:19 -03:00

511 lines
23 KiB
Vue

<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/views/pages/saas/SaasNotificationTemplatesPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted, nextTick } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { supabase } from '@/lib/supabase/client';
const toast = useToast();
const confirm = useConfirm();
// ── Constantes ──────────────────────────────────────────────────
const CHANNELS = [
{ label: 'WhatsApp', value: 'whatsapp', icon: 'pi pi-whatsapp' },
{ label: 'SMS', value: 'sms', icon: 'pi pi-mobile' }
];
const DOMAIN_OPTIONS = [
{ label: 'Sessão', value: 'session' },
{ label: 'Triagem', value: 'intake' },
{ label: 'Financeiro', value: 'billing' },
{ label: 'Sistema', value: 'system' }
];
const EVENT_TYPE_OPTIONS = [
{ label: 'Lembrete de sessão', value: 'lembrete_sessao' },
{ label: 'Confirmação de sessão', value: 'confirmacao_sessao' },
{ label: 'Cancelamento de sessão', value: 'cancelamento_sessao' },
{ label: 'Reagendamento', value: 'reagendamento' },
{ label: 'Cobrança pendente', value: 'cobranca_pendente' },
{ label: 'Boas-vindas paciente', value: 'boas_vindas_paciente' },
{ label: 'Intake recebido', value: 'intake_recebido' },
{ label: 'Intake aprovado', value: 'intake_aprovado' },
{ label: 'Intake rejeitado', value: 'intake_rejeitado' }
];
const EVENT_TYPE_LABELS = Object.fromEntries(EVENT_TYPE_OPTIONS.map((e) => [e.value, e.label]));
const DOMAIN_LABELS = { session: 'Sessão', intake: 'Triagem', billing: 'Financeiro', system: 'Sistema' };
const DOMAIN_SEVERITY = { session: 'info', intake: 'warning', billing: 'success', system: 'secondary' };
const VARS_BY_EVENT = {
lembrete_sessao: ['patient_name', 'session_date', 'session_time', 'therapist_name', 'session_modality', 'session_link'],
confirmacao_sessao: ['patient_name', 'session_date', 'session_time', 'therapist_name', 'session_modality'],
cancelamento_sessao: ['patient_name', 'session_date', 'session_time', 'therapist_name', 'cancellation_reason'],
reagendamento: ['patient_name', 'session_date', 'session_time', 'therapist_name', 'session_modality'],
cobranca_pendente: ['patient_name', 'therapist_name', 'valor', 'vencimento'],
boas_vindas_paciente: ['patient_name', 'clinic_name', 'therapist_name', 'portal_link'],
intake_recebido: ['patient_name', 'clinic_name', 'therapist_name'],
intake_aprovado: ['patient_name', 'therapist_name', 'session_date', 'session_time'],
intake_rejeitado: ['patient_name', 'therapist_name', 'rejection_reason']
};
// ── Estado ──────────────────────────────────────────────────────
const activeChannel = ref('whatsapp');
const templates = ref([]);
const loading = ref(false);
// ── Load ────────────────────────────────────────────────────────
async function load() {
loading.value = true;
try {
const { data, error } = await supabase.from('notification_templates').select('*').is('tenant_id', null).eq('is_default', true).is('deleted_at', null).order('domain').order('event_type');
if (error) throw error;
templates.value = data || [];
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao carregar', detail: e.message, life: 4000 });
} finally {
loading.value = false;
}
}
const filtered = computed(() => templates.value.filter((t) => t.channel === activeChannel.value));
// ── Dialog ──────────────────────────────────────────────────────
const dlg = ref({ open: false, saving: false, id: null, isNew: false });
const form = ref({});
const bodyTextareaRef = ref(null);
function _emptyForm() {
return {
key: '',
domain: 'session',
channel: activeChannel.value,
event_type: 'lembrete_sessao',
body_text: '',
is_active: true
};
}
function openNew() {
form.value = _emptyForm();
dlg.value = { open: true, saving: false, id: null, isNew: true };
}
function openEdit(t) {
form.value = {
key: t.key,
domain: t.domain,
channel: t.channel,
event_type: t.event_type,
body_text: t.body_text,
is_active: t.is_active
};
dlg.value = { open: true, saving: false, id: t.id, isNew: false };
}
function closeDlg() {
dlg.value.open = false;
}
// Variáveis disponíveis para o event_type selecionado
const availableVars = computed(() => VARS_BY_EVENT[form.value.event_type] || []);
// Variáveis detectadas no body_text
const detectedVars = computed(() => {
const matches = (form.value.body_text || '').matchAll(/\{\{(\w+)\}\}/g);
return [...new Set([...matches].map((m) => m[1]))];
});
// Insere variável no cursor do textarea
function insertVar(varName) {
const snippet = `{{${varName}}}`;
const ta = bodyTextareaRef.value?.$el?.querySelector('textarea');
if (!ta) {
form.value.body_text = (form.value.body_text || '') + snippet;
return;
}
const start = ta.selectionStart ?? ta.value.length;
const end = ta.selectionEnd ?? start;
const val = form.value.body_text || '';
form.value.body_text = val.slice(0, start) + snippet + val.slice(end);
nextTick(() => {
const pos = start + snippet.length;
ta.focus();
ta.setSelectionRange(pos, pos);
});
}
// ── Save ────────────────────────────────────────────────────────
async function save() {
if (!form.value.body_text?.trim()) {
toast.add({ severity: 'warn', summary: 'Mensagem é obrigatória', life: 3000 });
return;
}
if (dlg.value.isNew && !form.value.key?.trim()) {
toast.add({ severity: 'warn', summary: 'Key é obrigatória', life: 3000 });
return;
}
dlg.value.saving = true;
try {
if (dlg.value.isNew) {
// Detecta variáveis usadas
const vars = [...(form.value.body_text || '').matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]);
const { error } = await supabase.from('notification_templates').insert({
tenant_id: null,
owner_id: null,
key: form.value.key,
domain: form.value.domain,
channel: form.value.channel,
event_type: form.value.event_type,
body_text: form.value.body_text,
variables: vars,
is_default: true,
is_active: form.value.is_active
});
if (error) throw error;
toast.add({ severity: 'success', summary: 'Template criado', life: 3000 });
} else {
const vars = [...(form.value.body_text || '').matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]);
const currentVersion = templates.value.find((t) => t.id === dlg.value.id)?.version || 0;
const { error } = await supabase
.from('notification_templates')
.update({
body_text: form.value.body_text,
domain: form.value.domain,
event_type: form.value.event_type,
variables: vars,
is_active: form.value.is_active,
version: currentVersion + 1
})
.eq('id', dlg.value.id);
if (error) throw error;
toast.add({ severity: 'success', summary: 'Template atualizado', life: 3000 });
}
closeDlg();
await load();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 5000 });
} finally {
dlg.value.saving = false;
}
}
// ── Toggle ativo ────────────────────────────────────────────────
const togglingId = ref(null);
async function countAffectedTenants(t) {
const [{ data: schedules }, { data: overrides }] = await Promise.all([
supabase.from('notification_schedules').select('tenant_id').eq('event_type', t.event_type).eq('channel', t.channel).eq('is_active', true).is('deleted_at', null),
supabase.from('notification_templates').select('tenant_id').eq('key', t.key).eq('is_active', true).is('deleted_at', null).not('tenant_id', 'is', null)
]);
const overrideIds = new Set((overrides || []).map((o) => o.tenant_id).filter(Boolean));
const affected = new Set((schedules || []).map((s) => s.tenant_id).filter((id) => id && !overrideIds.has(id)));
return affected.size;
}
async function doToggleActive(t) {
togglingId.value = t.id;
try {
const next = !t.is_active;
const { error } = await supabase.from('notification_templates').update({ is_active: next }).eq('id', t.id);
if (error) throw error;
t.is_active = next;
toast.add({
severity: next ? 'success' : 'warn',
summary: next ? 'Template reativado' : 'Template desativado',
detail: next ? 'Tenants voltam a usar este template padrão.' : 'Tenants sem personalização ficarão sem este template.',
life: 3500
});
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
} finally {
togglingId.value = null;
}
}
async function requestToggleActive(t) {
if (togglingId.value) return;
if (!t.is_active) {
await doToggleActive(t);
return;
}
togglingId.value = t.id;
let affectedCount = 0;
try {
affectedCount = await countAffectedTenants(t);
} catch {
// silenciosamente ignora — mostramos aviso genérico
} finally {
togglingId.value = null;
}
const channelLabel = t.channel === 'whatsapp' ? 'WhatsApp' : 'SMS';
const eventLabel = EVENT_TYPE_LABELS[t.event_type] || t.event_type;
const impactLine =
affectedCount > 0
? `<strong>${affectedCount} ${affectedCount === 1 ? 'tenant está' : 'tenants estão'}</strong> agendando este evento sem template personalizado — ${affectedCount === 1 ? 'ficará' : 'ficarão'} sem mensagem.`
: 'Atualmente nenhum tenant depende deste template, mas futuros eventos não terão mensagem padrão.';
const msg = [
`Você vai desativar o template padrão de <strong>${channelLabel}</strong> para o evento "<strong>${eventLabel}</strong>".`,
impactLine,
'Tenants com template personalizado <strong>NÃO</strong> são afetados.'
].join('<br><br>');
confirm.require({
group: 'headless',
header: `Desativar "${eventLabel}"?`,
message: msg,
icon: 'pi-exclamation-triangle',
color: '#f59e0b',
accept: () => doToggleActive(t)
});
}
// ── Soft delete ─────────────────────────────────────────────────
function deleteTemplate(t) {
confirm.require({
group: 'headless',
message: `Excluir o template "${t.key}"?`,
header: 'Confirmar exclusão',
icon: 'pi-trash',
color: '#ef4444',
accept: async () => {
try {
const { error } = await supabase.from('notification_templates').update({ deleted_at: new Date().toISOString() }).eq('id', t.id);
if (error) throw error;
toast.add({ severity: 'success', summary: 'Template excluído', life: 3000 });
await load();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
}
}
});
}
// ── Truncate ────────────────────────────────────────────────────
function truncate(str, len = 80) {
if (!str) return '';
return str.length > len ? str.slice(0, len) + '…' : str;
}
onMounted(load);
</script>
<template>
<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)]" v-html="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 class="flex flex-col gap-4 p-4">
<!-- Header -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-comment" /></div>
<div class="min-w-0 flex-1">
<div class="cfg-subheader__title">Templates de Notificação</div>
<div class="cfg-subheader__sub">Templates globais de WhatsApp e SMS. Tenants herdam automaticamente.</div>
</div>
<div class="flex gap-2 shrink-0">
<Button label="Novo template" icon="pi pi-plus" size="small" @click="openNew" />
<Button icon="pi pi-refresh" severity="secondary" text :loading="loading" v-tooltip.bottom="'Recarregar'" @click="load" />
</div>
</div>
<!-- Tabs canal -->
<div class="flex gap-2 flex-wrap">
<Button v-for="ch in CHANNELS" :key="ch.value" :label="ch.label" :icon="ch.icon" size="small" :severity="activeChannel === ch.value ? 'primary' : 'secondary'" :outlined="activeChannel !== ch.value" @click="activeChannel = ch.value" />
</div>
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-12">
<ProgressSpinner />
</div>
<!-- DataTable encapsulada em card -->
<div v-else class="rounded-[6px] border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<DataTable :value="filtered" stripedRows size="small" class="text-sm" :rowClass="(data) => (!data.is_active ? 'opacity-50' : '')">
<Column field="key" header="Key" sortable style="min-width: 200px">
<template #body="{ data }">
<code class="font-mono text-xs">{{ data.key }}</code>
</template>
</Column>
<Column field="domain" header="Domínio" sortable style="width: 110px">
<template #body="{ data }">
<Tag :value="DOMAIN_LABELS[data.domain] ?? data.domain" :severity="DOMAIN_SEVERITY[data.domain] ?? 'secondary'" class="text-[0.65rem]" />
</template>
</Column>
<Column field="event_type" header="Evento" sortable style="min-width: 160px">
<template #body="{ data }">
<span class="text-xs">{{ EVENT_TYPE_LABELS[data.event_type] ?? data.event_type }}</span>
</template>
</Column>
<Column field="body_text" header="Mensagem" style="min-width: 200px">
<template #body="{ data }">
<span class="text-xs text-[var(--text-color-secondary)]">{{ truncate(data.body_text) }}</span>
</template>
</Column>
<Column field="version" header="v" sortable style="width: 50px" class="text-center">
<template #body="{ data }">
<span class="text-xs text-[var(--text-color-secondary)]">{{ data.version }}</span>
</template>
</Column>
<Column header="Ativo" style="width: 70px" class="text-center">
<template #body="{ data }">
<ToggleSwitch :modelValue="data.is_active" :disabled="togglingId === data.id" @update:modelValue="() => requestToggleActive(data)" />
</template>
</Column>
<Column header="" style="width: 90px">
<template #body="{ data }">
<div class="flex gap-1">
<Button icon="pi pi-pencil" text rounded size="small" @click="openEdit(data)" />
<Button icon="pi pi-trash" text rounded size="small" severity="danger" @click="deleteTemplate(data)" />
</div>
</template>
</Column>
<template #empty>
<div class="text-center py-8 text-[var(--text-color-secondary)]">
<i class="pi pi-comment text-3xl opacity-30 block mb-2" />
Nenhum template {{ activeChannel === 'sms' ? 'SMS' : 'WhatsApp' }} cadastrado.
</div>
</template>
</DataTable>
</div>
<!-- Dialog Cadastro / Edição -->
<Dialog
v-model:visible="dlg.open"
modal
:draggable="false"
:closable="!dlg.saving"
:dismissableMask="!dlg.saving"
maximizable
class="dc-dialog w-[50rem]"
: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"
>
<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">{{ dlg.isNew ? 'Novo Template' : 'Editar Template' }}</div>
<div class="text-xs opacity-50">{{ dlg.isNew ? 'Cadastrar novo template de notificação' : form.key }}</div>
</div>
</div>
</div>
</template>
<div class="flex flex-col gap-4 py-2">
<!-- Key + Channel (somente no cadastro) -->
<div v-if="dlg.isNew" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Key *</label>
<InputText v-model="form.key" class="w-full font-mono text-sm" placeholder="ex: session.reminder.sms" />
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Canal</label>
<Select v-model="form.channel" :options="CHANNELS" option-label="label" option-value="value" class="w-full" />
</div>
</div>
<div v-else class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Key</label>
<InputText :model-value="form.key" class="w-full font-mono text-sm" disabled />
</div>
<!-- Domain + Event Type -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Domínio</label>
<Select v-model="form.domain" :options="DOMAIN_OPTIONS" option-label="label" option-value="value" class="w-full" />
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-semibold">Tipo de evento</label>
<Select v-model="form.event_type" :options="EVENT_TYPE_OPTIONS" option-label="label" option-value="value" class="w-full" />
</div>
</div>
<!-- Body text -->
<div class="flex flex-col gap-2">
<label class="text-xs font-semibold">Mensagem *</label>
<Textarea ref="bodyTextareaRef" v-model="form.body_text" rows="6" auto-resize class="w-full text-sm" />
<!-- Chips de variáveis -->
<div v-if="availableVars.length" 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 availableVars" :key="v" :label="`{{${v}}}`" size="small" severity="secondary" outlined class="font-mono !text-[0.68rem] !py-1 !px-2" @click="insertVar(v)" />
</div>
</div>
<!-- Variáveis detectadas -->
<div v-if="detectedVars.length" class="flex items-center gap-1.5 flex-wrap">
<span class="text-xs text-[var(--text-color-secondary)]">Variáveis usadas:</span>
<Tag v-for="v in detectedVars" :key="v" :value="v" severity="info" class="!text-[0.6rem] font-mono" />
</div>
</div>
<!-- Ativo -->
<div class="flex items-center gap-2 pt-1">
<ToggleSwitch v-model="form.is_active" inputId="sw-active-tpl" />
<label for="sw-active-tpl" class="text-sm cursor-pointer select-none">Ativo</label>
</div>
</div>
<template #footer>
<div class="flex items-center justify-end gap-2 px-3 py-3">
<Button label="Cancelar" severity="secondary" text class="rounded-full hover:!text-red-500" :disabled="dlg.saving" @click="closeDlg" />
<Button :label="dlg.isNew ? 'Criar template' : 'Salvar'" icon="pi pi-check" class="rounded-full" :loading="dlg.saving" @click="save" />
</div>
</template>
</Dialog>
</div>
</template>