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:
@@ -0,0 +1,443 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| 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 ────────────────────────────────────────────────
|
||||
async function toggleActive(t) {
|
||||
try {
|
||||
const { error } = await supabase.from('notification_templates').update({ is_active: !t.is_active }).eq('id', t.id);
|
||||
if (error) throw error;
|
||||
t.is_active = !t.is_active;
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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)]">{{ 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="p-4 md:p-6 max-w-[1200px] mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6 gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold m-0">Templates de Notificação</h1>
|
||||
<p class="text-sm text-[var(--text-color-secondary)] mt-1 mb-0">Templates globais de WhatsApp e SMS. Tenants herdam automaticamente.</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button label="Novo template" icon="pi pi-plus" size="small" @click="openNew" />
|
||||
<Button icon="pi pi-refresh" severity="secondary" text :loading="loading" @click="load" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs canal -->
|
||||
<div class="flex gap-2 mb-5">
|
||||
<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 -->
|
||||
<DataTable v-else :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" @update:modelValue="() => toggleActive(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>
|
||||
|
||||
<!-- ── 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)] 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"
|
||||
>
|
||||
<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>
|
||||
Reference in New Issue
Block a user