9966b5f175
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>
502 lines
23 KiB
Vue
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 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>
|