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,497 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/layout/configuracoes/ConfiguracoesSmsPage.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 { useRouter } from 'vue-router';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const router = useRouter();
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
// ── Contexto ──────────────────────────────────────────────────
|
||||
const userId = ref(null);
|
||||
const tenantId = ref(null);
|
||||
const loading = ref(true);
|
||||
|
||||
// ── Saldo de créditos ─────────────────────────────────────────
|
||||
const credits = ref(null);
|
||||
const hasCredits = computed(() => credits.value !== null);
|
||||
const balance = computed(() => credits.value?.balance ?? 0);
|
||||
const totalPurchased = computed(() => credits.value?.total_purchased ?? 0);
|
||||
const totalConsumed = computed(() => credits.value?.total_consumed ?? 0);
|
||||
|
||||
const balanceSeverity = computed(() => {
|
||||
if (!hasCredits.value || balance.value <= 0) return 'danger';
|
||||
if (balance.value <= (credits.value?.low_balance_threshold || 10)) return 'warn';
|
||||
return 'success';
|
||||
});
|
||||
|
||||
const balanceIcon = computed(() => {
|
||||
if (balance.value <= 0) return 'pi pi-times-circle';
|
||||
if (balance.value <= (credits.value?.low_balance_threshold || 10)) return 'pi pi-exclamation-triangle';
|
||||
return 'pi pi-check-circle';
|
||||
});
|
||||
|
||||
// ── Transações recentes ───────────────────────────────────────
|
||||
const transactions = ref([]);
|
||||
const txLoading = ref(false);
|
||||
|
||||
// ── Logs de envio ─────────────────────────────────────────────
|
||||
const recentLogs = ref([]);
|
||||
const logsLoading = ref(false);
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Templates SMS
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
const templates = ref([]);
|
||||
const templatesLoading = ref(false);
|
||||
const templateSaving = ref({});
|
||||
|
||||
// Labels para exibição
|
||||
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',
|
||||
intake_rejeitado: 'Triagem'
|
||||
};
|
||||
const EVENT_SEVERITY = {
|
||||
lembrete_sessao: 'info',
|
||||
confirmacao_sessao: 'success',
|
||||
cancelamento_sessao: 'danger',
|
||||
reagendamento: 'warn',
|
||||
cobranca_pendente: 'warn',
|
||||
boas_vindas_paciente: 'success',
|
||||
intake_recebido: 'info',
|
||||
intake_aprovado: 'success',
|
||||
intake_rejeitado: 'danger'
|
||||
};
|
||||
|
||||
// Referências dos textareas por template key
|
||||
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', 'sms')
|
||||
.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', 'sms').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: EVENT_TYPE_LABELS[g.event_type] || g.event_type,
|
||||
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,
|
||||
// Se tenant customizou, usa o texto dele; senão usa o global
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function insertVariable(templateKey, variable) {
|
||||
const snippet = `{{${variable}}}`;
|
||||
const tpl = templates.value.find((t) => t.key === templateKey);
|
||||
if (!tpl) return;
|
||||
|
||||
const taWrapper = textareaRefs.value[templateKey];
|
||||
const ta = taWrapper?.$el?.querySelector('textarea') ?? taWrapper;
|
||||
if (ta?.setSelectionRange) {
|
||||
const start = ta.selectionStart ?? ta.value.length;
|
||||
const end = ta.selectionEnd ?? start;
|
||||
tpl.body_text = (tpl.body_text || '').slice(0, start) + snippet + (tpl.body_text || '').slice(end);
|
||||
nextTick(() => {
|
||||
const pos = start + snippet.length;
|
||||
ta.focus();
|
||||
ta.setSelectionRange(pos, pos);
|
||||
});
|
||||
} else {
|
||||
tpl.body_text = (tpl.body_text || '') + snippet;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTemplate(tpl) {
|
||||
if (!tenantId.value || templateSaving.value[tpl.key]) return;
|
||||
templateSaving.value[tpl.key] = true;
|
||||
try {
|
||||
if (tpl.id) {
|
||||
const { error } = await supabase.from('notification_templates').update({ body_text: tpl.body_text }).eq('id', tpl.id);
|
||||
if (error) throw error;
|
||||
} else {
|
||||
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) {
|
||||
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 {
|
||||
const { data, error } = await supabase
|
||||
.from('notification_templates')
|
||||
.insert({
|
||||
owner_id: userId.value,
|
||||
tenant_id: tenantId.value,
|
||||
channel: 'sms',
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function isTemplateModified(tpl) {
|
||||
return tpl.default_body ? tpl.body_text !== tpl.default_body : false;
|
||||
}
|
||||
|
||||
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: () => {
|
||||
tpl.body_text = tpl.default_body;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Load ──────────────────────────────────────────────────────
|
||||
async function loadUser() {
|
||||
const {
|
||||
data: { user }
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) return;
|
||||
userId.value = user.id;
|
||||
tenantId.value = tenantStore.activeTenantId || user.id;
|
||||
}
|
||||
|
||||
async function loadCredits() {
|
||||
if (!tenantId.value) return;
|
||||
|
||||
const { data, error } = await supabase.from('addon_credits').select('*').eq('tenant_id', tenantId.value).eq('addon_type', 'sms').eq('is_active', true).maybeSingle();
|
||||
|
||||
if (!error && data) credits.value = data;
|
||||
}
|
||||
|
||||
async function loadTransactions() {
|
||||
if (!tenantId.value) return;
|
||||
txLoading.value = true;
|
||||
|
||||
const { data } = await supabase
|
||||
.from('addon_transactions')
|
||||
.select('id, type, amount, balance_after, description, payment_method, created_at')
|
||||
.eq('tenant_id', tenantId.value)
|
||||
.eq('addon_type', 'sms')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(15);
|
||||
|
||||
txLoading.value = false;
|
||||
if (data) transactions.value = data;
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
if (!tenantId.value) return;
|
||||
logsLoading.value = true;
|
||||
|
||||
const { data } = await supabase
|
||||
.from('notification_logs')
|
||||
.select('id, template_key, recipient_address, status, failure_reason, sent_at, failed_at, created_at')
|
||||
.eq('tenant_id', tenantId.value)
|
||||
.eq('channel', 'sms')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10);
|
||||
|
||||
logsLoading.value = false;
|
||||
if (data) recentLogs.value = data;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────
|
||||
function txTypeLabel(type) {
|
||||
const map = { purchase: 'Compra', consume: 'Consumo', adjustment: 'Ajuste', refund: 'Reembolso', expiration: 'Expiração' };
|
||||
return map[type] || type;
|
||||
}
|
||||
|
||||
function txTypeSeverity(type) {
|
||||
const map = { purchase: 'success', consume: 'secondary', adjustment: 'info', refund: 'warn', expiration: 'danger' };
|
||||
return map[type] || 'secondary';
|
||||
}
|
||||
|
||||
function logStatusSeverity(status) {
|
||||
if (status === 'sent') return 'success';
|
||||
if (status === 'failed') return 'danger';
|
||||
return 'secondary';
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' });
|
||||
}
|
||||
|
||||
function goToRecursosExtras() {
|
||||
router.push('/configuracoes/recursos-extras');
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────
|
||||
onMounted(async () => {
|
||||
await loadUser();
|
||||
await Promise.all([loadCredits(), loadTransactions(), loadLogs(), loadTemplates()]);
|
||||
loading.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-5">
|
||||
<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>
|
||||
|
||||
<!-- Saldo Card -->
|
||||
<Card>
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-comment text-xl" />
|
||||
Créditos SMS
|
||||
</div>
|
||||
</template>
|
||||
<template #subtitle>Seus créditos para envio de SMS aos pacientes.</template>
|
||||
<template #content>
|
||||
<div v-if="loading" class="flex items-center gap-2 text-surface-500"><i class="pi pi-spin pi-spinner" /> Carregando...</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<!-- Saldo principal -->
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<i :class="balanceIcon" :style="{ color: balanceSeverity === 'success' ? 'var(--p-green-500)' : balanceSeverity === 'warn' ? 'var(--p-yellow-500)' : 'var(--p-red-500)' }" class="text-2xl" />
|
||||
<span class="text-4xl font-bold">{{ balance }}</span>
|
||||
<span class="text-surface-500 text-sm">créditos disponíveis</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Estatísticas -->
|
||||
<div v-if="hasCredits" class="flex gap-6 text-sm text-surface-500">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-semibold text-surface-700">{{ totalPurchased }}</span>
|
||||
<span>Total comprado</span>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="font-semibold text-surface-700">{{ totalConsumed }}</span>
|
||||
<span>Total consumido</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alerta saldo baixo -->
|
||||
<Message v-if="hasCredits && balance <= 0" severity="error" :closable="false"> Sem créditos SMS. Os lembretes por SMS estão pausados. Adquira mais créditos para reativar. </Message>
|
||||
<Message v-else-if="hasCredits && balance <= (credits?.low_balance_threshold || 10)" severity="warn" :closable="false"> Saldo baixo! Restam apenas {{ balance }} créditos SMS. </Message>
|
||||
|
||||
<!-- Sem créditos ainda -->
|
||||
<Message v-if="!hasCredits" severity="info" :closable="false"> Você ainda não possui créditos SMS. Adquira um pacote em Recursos Extras. </Message>
|
||||
|
||||
<Button label="Adquirir créditos SMS" icon="pi pi-shopping-cart" @click="goToRecursosExtras" class="w-fit" />
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════ -->
|
||||
<!-- Templates de mensagem SMS -->
|
||||
<!-- ══════════════════════════════════════════════════════════ -->
|
||||
<Card>
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-file-edit text-xl" />
|
||||
Mensagens SMS
|
||||
</div>
|
||||
</template>
|
||||
<template #subtitle>Personalize as mensagens enviadas por SMS aos seus pacientes. Os textos padrão já funcionam — edite apenas se quiser personalizar.</template>
|
||||
<template #content>
|
||||
<!-- Skeleton loading -->
|
||||
<template v-if="templatesLoading">
|
||||
<div v-for="n in 4" :key="n" class="border border-surface rounded-xl p-4 mb-3">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<Skeleton width="5rem" height="1.4rem" borderRadius="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" borderRadius="999px" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Cards de templates -->
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<div v-for="tpl in templates" :key="tpl.key" class="border border-surface rounded-xl 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>
|
||||
|
||||
<!-- Editor Jodit -->
|
||||
<Textarea :ref="(el) => setTextareaRef(tpl.key, el)" v-model="tpl.body_text" rows="4" auto-resize class="w-full text-sm" />
|
||||
|
||||
<!-- Variáveis clicáveis -->
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="text-xs text-surface-500">Inserir variável:</span>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<Button v-for="v in tpl.variables" :key="v" size="small" severity="secondary" outlined class="font-mono !text-[0.68rem] !py-1 !px-2" @click="insertVariable(tpl.key, v)">
|
||||
<span v-text="'{{' + v + '}}'"></span>
|
||||
</Button>
|
||||
</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>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Histórico de transações -->
|
||||
<Card>
|
||||
<template #title>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Histórico de créditos</span>
|
||||
<Button icon="pi pi-refresh" text rounded size="small" @click="loadTransactions" :loading="txLoading" />
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<DataTable :value="transactions" :loading="txLoading" size="small" stripedRows emptyMessage="Nenhuma transação encontrada.">
|
||||
<Column field="created_at" header="Data" style="width: 140px">
|
||||
<template #body="{ data }">{{ formatDate(data.created_at) }}</template>
|
||||
</Column>
|
||||
<Column field="type" header="Tipo" style="width: 110px">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="txTypeLabel(data.type)" :severity="txTypeSeverity(data.type)" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="amount" header="Qtd" style="width: 80px">
|
||||
<template #body="{ data }">
|
||||
<span :class="data.amount > 0 ? 'text-green-500 font-semibold' : 'text-red-500'"> {{ data.amount > 0 ? '+' : '' }}{{ data.amount }} </span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="balance_after" header="Saldo" style="width: 80px" />
|
||||
<Column field="description" header="Descrição" />
|
||||
</DataTable>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Últimos envios -->
|
||||
<Card>
|
||||
<template #title>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Últimos envios SMS</span>
|
||||
<Button icon="pi pi-refresh" text rounded size="small" @click="loadLogs" :loading="logsLoading" />
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<DataTable :value="recentLogs" :loading="logsLoading" size="small" stripedRows emptyMessage="Nenhum registro de SMS encontrado.">
|
||||
<Column field="created_at" header="Data" style="width: 140px">
|
||||
<template #body="{ data }">{{ formatDate(data.sent_at || data.failed_at || data.created_at) }}</template>
|
||||
</Column>
|
||||
<Column field="template_key" header="Template" />
|
||||
<Column field="recipient_address" header="Destinatário" />
|
||||
<Column field="status" header="Status" style="width: 100px">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.status" :severity="logStatusSeverity(data.status)" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="failure_reason" header="Erro" style="max-width: 200px">
|
||||
<template #body="{ data }">
|
||||
<span class="text-sm text-red-500 truncate block" :title="data.failure_reason">{{ data.failure_reason || '—' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user