Files
agenciapsilmno/src/layout/configuracoes/ConfiguracoesSmsPage.vue
T
Leonardo 9966b5f175 Melissa: paginas nativas cfg-* + temas + textos com fundo + drawer WA
CHROME COMPARTILHADO + 18 PAGINAS NATIVAS
- MelissaConfigPage: chrome unico (header, drawer mobile, sidebar com Configuracoes
  + FAQ slot, main com Suspense). Replica fake-dialog right rule e fica flush
  com o config-aside global.
- 18 wrappers finos (~25 linhas cada): cfg-precificacao, cfg-descontos,
  cfg-excecoes, cfg-convenios, cfg-wa, cfg-wa-pessoal, cfg-wa-oficial,
  cfg-wa-templates, cfg-conversas-tags/autoreply/optouts/sla/bots,
  cfg-lembretes, cfg-creditos-wa, cfg-sms, cfg-email-templates,
  cfg-recursos-extras, cfg-recursos-extras-extrato, cfg-auditoria.
- Cada wrapper usa defineAsyncComponent + Suspense pra evitar race com
  tenantStore no boot (loading travado em alguns chooser-style pages).
- MelissaLayout: imports + SECOES + MELISSA_NON_CONFIG_SLUGS + render
  conditions atualizados pra cobrir os 18 slugs.

PAGINAS LEGADAS DETECTAM CONTEXTO MELISSA
- ConfiguracoesWhatsappChooserPage, WhatsappPage, TwilioWhatsappPage,
  SmsPage, RecursosExtrasPage, EmailTemplatesPage, AddonsExtratoPage,
  AgendadorPage, ConversasAutoreplyPage: route.startsWith('/melissa')
  decide se router.push vai pro slug Melissa ou /configuracoes legado.
- Anchors <a href="/configuracoes/..."> (que recarregavam pagina e
  vazavam o usuario do Melissa) trocados por RouterLink context-aware.
- MelissaAgenda.goSettings agora vai pra /melissa/agenda-config.

PERSONALIZAR > TEMAS
- melissaThemes.js: catalogo Freud/Klein/Jung (wallpaper + cor primaria
  + preset Lara/Nora + surface).
- Toggle de tema aplica tudo de uma vez; persistido em melissa_prefs.themeName.
- Boot resolve themeName -> imagem via fetch + data URL (sem guardar
  data URL gigante no DB).
- onCustomFileChange/onClearBg invalidam themeName quando user mexe no bg.

PERSONALIZAR > FUNDO NOS TEXTOS
- Pref textBgEnabled em melissa_prefs.
- MelissaHeroClock: prop textBg envolve relogio/data/saudacao/resumo
  em <span class="hero-text"> que ganha bg branco/preto 60% + borda
  + padding + radius quando o toggle esta on.
- Vars --m-hero-text-bg / --m-hero-text-border flipam com light/dark.

TOP + DOCK COM GRADIENT HORIZONTAL
- Var --m-band: preto 80% (dark) / branco 80% (light).
- .melissa-topbar-band: gradiente cor->transparente (right->left) atras
  dos botoes do topo.
- .melissa-dock: gradiente cor->transparente (left->right) atras dos pins.

MELISSANEGOCIO ABSORVE MINHA EMPRESA
- Adiciona logo upload + preview "cartao de visita" (computeds
  enderecoLinhas/redesValidas/temDados/logoDisplay + redeIcon helper).
- Normaliza dados legados do cfg-empresa: redes_sociais.{rede} virou {name}.
- Preview teleporta entre 3 destinos baseado no viewport:
  mobile -> drawer; mid-desktop -> sidebar; wide-desktop (>=1340px) ->
  painel flutuante FORA do fake dialog (ancora no right edge + 14px gap,
  altura segue conteudo, header alinhado com header do dialog).
- Remove cfg-empresa de melissaConfigGrupos.js + COMPONENT_MAP do
  MelissaConfiguracoes; grupo "Empresa & Plataforma" -> "Plataforma".

CRONOMETRO -> SESSAO AGENDADA
- MelissaCronometro emite session-end ao parar com paciente selecionado
  (threshold 5s pra ignorar start/stop acidental).
- MelissaLayout.onCronometroSessionEnd busca agenda_eventos do paciente
  no dia (tipo='sessao'), pega o mais recente e grava em
  extra_fields.cronometro_duracao_seg + cronometro_parado_em.
- Toast: sucesso ("X min salvos") ou warn ("sessao nao encontrada").

CONVERSATIONDRAWER WHATSAPP-LIKE
- Nova imagem whatsapp-bg.jpg (renomeada de hash random) usada como
  tile (380px) no .cd-msgs.
- Light: bege #efeae2 + multiply blend.
- Dark: #0b141a + camada 78% sobre o doodle.
- Bubbles WA-style (verde out / branco-dark in com tails) ja existiam.

EXTRATO RECURSOS EXTRAS
- Filtros 2-por-linha em Melissa (vs 1/4 no /configuracoes).
- Cards de Resumo teleportam pro #cfg-page-side em Melissa
  (1-col empilhado no drawer; 4-col inline no /configuracoes).
- Botoes de exportar com flex-1 distribuidos em uma unica linha em
  desktop, wrap no mobile.
- DataTable scrollable em ambos os layouts.

OUTROS AJUSTES MENORES
- Cfg-conversas-autoreply: dias semana 4-cols em Melissa (vs 7-cols
  no /configuracoes).
- Cfg-creditos-wa: 1/2 por linha (vs 1/2/4) em Melissa.
- Cfg-recursos-extras: pacotes 1/2 (vs 1/2/4); "Em breve" 1-col.
- WhatsAppPage aba Templates: guia de formatacao teleporta pro side
  drawer em Melissa, deixando textareas full-width.
- ConfigPage chrome agora tem #cfg-page-actions target pros Teleport
  de acoes (refresh button etc).
- Imagens renomeadas em src/assets/themes/ (freudwebp/melainewebp/
  jungwebp.webp) e src/assets/whatsapp-bg.jpg.
- JoditTextEditor.vue novo (wrapper Jodit generico, sem features de email).
- MelissaConfigList.vue novo (lista compartilhada de configs pro drawer).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 23:48:18 -03:00

502 lines
23 KiB
Vue

<!--
|--------------------------------------------------------------------------
| 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, useRoute } from 'vue-router';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
const toast = useToast();
const confirm = useConfirm();
const router = useRouter();
const route = useRoute();
const tenantStore = useTenantStore();
// Preserva o layout em que a pagina esta aberta (Melissa vs /configuracoes).
const inMelissa = computed(() => String(route.path || '').startsWith('/melissa'));
// ── 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(inMelissa.value ? '/melissa/cfg-recursos-extras' : '/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 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>