86311ef305
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>
511 lines
23 KiB
Vue
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> |