f76a2e3033
Fecha polimento do Marco B (créditos/Asaas) entregue em 21/04.
Nova RPC admin_adjust_whatsapp_credits(tenant, amount_signed, admin_id, note):
- |amount| <= 1000 por operação (anti dedo-gordo). Valores maiores → repetir.
- Em remoção (amount < 0), aplica regra FIFO cortesia primeiro:
removable = max(0, sum(topup_manual+adjustment+refund) - usage_total).
Créditos de 'purchase' (Asaas/PIX) são intocáveis — estorno real vai pelo
fluxo financeiro do Asaas.
- Protegida por is_saas_admin() — authenticated comum não consegue chamar.
- Registra como kind='adjustment' com amount signed (+ ou -).
Helper get_whatsapp_removable_balance(tenant) retorna {balance, removable,
protected_amount, topup_net, usage_total} pra UI mostrar breakdown.
Aba 4 (Pacotes WhatsApp):
- Desativação dispara ConfirmDialog com histórico (N compras, M tenants
distintos) + aviso forte se é o único pacote ativo + nota que créditos já
adquiridos continuam válidos.
- Fix visual: :key no ToggleSwitch força re-mount durante confirm pra não
desligar visualmente antes do accept.
Aba 5 (Topup → Ajuste):
- Substituído Select de kind por SelectButton Adicionar/Remover.
- InputNumber max 1000 · label e botão dinâmicos.
- Modo Remover: card laranja com breakdown removível/protegido, botão
vermelho, confirm obrigatório com saldo resultante.
- Error mapping friendly pt-BR pros códigos da RPC.
ConfirmDialog com v-html habilitado pra suportar <br><br> entre frases
e <strong>/cores. Inputs livres (row.name, tenantName) passam por
escapeHtml() antes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1327 lines
69 KiB
Vue
1327 lines
69 KiB
Vue
<!--
|
|
|--------------------------------------------------------------------------
|
|
| Agência PSI
|
|
|--------------------------------------------------------------------------
|
|
| Criado e desenvolvido por Leonardo Nohama
|
|
|
|
|
| Tecnologia aplicada à escuta.
|
|
| Estrutura para o cuidado.
|
|
|
|
|
| Arquivo: src/views/pages/saas/SaasAddonsPage.vue
|
|
| Data: 2026
|
|
| Local: São Carlos/SP — Brasil
|
|
|--------------------------------------------------------------------------
|
|
| © 2026 — Todos os direitos reservados
|
|
|--------------------------------------------------------------------------
|
|
-->
|
|
<script setup>
|
|
import { ref, computed, onMounted } from 'vue';
|
|
import { useToast } from 'primevue/usetoast';
|
|
import { useConfirm } from 'primevue/useconfirm';
|
|
import { supabase } from '@/lib/supabase/client';
|
|
|
|
const toast = useToast();
|
|
const confirm = useConfirm();
|
|
|
|
const activeTab = ref(0);
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// Tenants (para selecionar ao adicionar créditos)
|
|
// ══════════════════════════════════════════════════════════════
|
|
const tenants = ref([]);
|
|
const tenantMap = ref({});
|
|
const loadingTenants = ref(false);
|
|
|
|
async function loadTenants() {
|
|
loadingTenants.value = true;
|
|
try {
|
|
const { data, error } = await supabase.from('tenants').select('id, name, kind').order('name', { ascending: true });
|
|
if (error) throw error;
|
|
|
|
const list = data || [];
|
|
tenantMap.value = Object.fromEntries(list.map((t) => [t.id, t.name || t.id]));
|
|
tenants.value = list.map((t) => ({
|
|
value: t.id,
|
|
label: `${t.name} (${t.kind ?? 'tenant'})`
|
|
}));
|
|
} catch (e) {
|
|
toast.add({ severity: 'error', summary: 'Erro ao carregar tenants', detail: e?.message, life: 4000 });
|
|
} finally {
|
|
loadingTenants.value = false;
|
|
}
|
|
}
|
|
|
|
function tenantName(id) {
|
|
return tenantMap.value[id] || id?.slice(0, 8) + '…';
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// ABA 1 — Produtos (CRUD addon_products)
|
|
// ══════════════════════════════════════════════════════════════
|
|
const products = ref([]);
|
|
const productsLoading = ref(false);
|
|
const productDialog = ref(false);
|
|
const editingProduct = ref(null);
|
|
|
|
const emptyProduct = () => ({
|
|
slug: '',
|
|
name: '',
|
|
description: '',
|
|
addon_type: 'sms',
|
|
icon: 'pi pi-comment',
|
|
credits_amount: 0,
|
|
price_reais: 0,
|
|
is_active: true,
|
|
is_visible: true,
|
|
sort_order: 0
|
|
});
|
|
|
|
const productForm = ref(emptyProduct());
|
|
|
|
const addonTypes = [
|
|
{ label: 'SMS', value: 'sms' },
|
|
{ label: 'E-mail', value: 'email' },
|
|
{ label: 'Servidor', value: 'server' },
|
|
{ label: 'Domínio', value: 'domain' }
|
|
];
|
|
|
|
async function loadProducts() {
|
|
productsLoading.value = true;
|
|
const { data } = await supabase.from('addon_products').select('*').is('deleted_at', null).order('addon_type').order('sort_order');
|
|
productsLoading.value = false;
|
|
if (data) products.value = data;
|
|
}
|
|
|
|
function openNewProduct() {
|
|
editingProduct.value = null;
|
|
productForm.value = emptyProduct();
|
|
productDialog.value = true;
|
|
}
|
|
|
|
function openEditProduct(p) {
|
|
editingProduct.value = p;
|
|
productForm.value = { ...p, price_reais: (p.price_cents || 0) / 100 };
|
|
productDialog.value = true;
|
|
}
|
|
|
|
async function saveProduct() {
|
|
const f = productForm.value;
|
|
if (!f.slug || !f.name || !f.addon_type) {
|
|
toast.add({ severity: 'warn', summary: 'Preencha slug, nome e tipo', life: 3000 });
|
|
return;
|
|
}
|
|
|
|
const payload = {
|
|
slug: f.slug,
|
|
name: f.name,
|
|
description: f.description,
|
|
addon_type: f.addon_type,
|
|
icon: f.icon,
|
|
credits_amount: f.credits_amount || 0,
|
|
price_cents: Math.round((f.price_reais || 0) * 100),
|
|
is_active: f.is_active,
|
|
is_visible: f.is_visible,
|
|
sort_order: f.sort_order || 0,
|
|
updated_at: new Date().toISOString()
|
|
};
|
|
|
|
let error;
|
|
if (editingProduct.value) {
|
|
({ error } = await supabase.from('addon_products').update(payload).eq('id', editingProduct.value.id));
|
|
} else {
|
|
({ error } = await supabase.from('addon_products').insert(payload));
|
|
}
|
|
|
|
if (error) {
|
|
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: error.message, life: 4000 });
|
|
return;
|
|
}
|
|
|
|
toast.add({ severity: 'success', summary: 'Produto salvo', life: 3000 });
|
|
productDialog.value = false;
|
|
await loadProducts();
|
|
}
|
|
|
|
function deleteProduct(p) {
|
|
confirm.require({
|
|
group: 'headless',
|
|
header: 'Remover produto',
|
|
message: `Deseja remover "${p.name}"?`,
|
|
icon: 'pi-trash',
|
|
color: '#ef4444',
|
|
accept: async () => {
|
|
await supabase.from('addon_products').update({ deleted_at: new Date().toISOString() }).eq('id', p.id);
|
|
toast.add({ severity: 'success', summary: 'Produto removido', life: 3000 });
|
|
await loadProducts();
|
|
}
|
|
});
|
|
}
|
|
|
|
function formatPrice(cents) {
|
|
if (!cents) return '—';
|
|
return (cents / 100).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// ABA 2 — Créditos por Tenant
|
|
// ══════════════════════════════════════════════════════════════
|
|
const credits = ref([]);
|
|
const creditsLoading = ref(false);
|
|
|
|
// Dialog para adicionar créditos
|
|
const creditDialog = ref(false);
|
|
const creditForm = ref({ tenant_id: null, addon_type: 'sms', amount: 0, product_id: null, description: '', price_reais: 0 });
|
|
|
|
async function loadCredits() {
|
|
creditsLoading.value = true;
|
|
const { data } = await supabase
|
|
.from('addon_credits')
|
|
.select(
|
|
`
|
|
id, tenant_id, addon_type, balance, total_purchased, total_consumed,
|
|
low_balance_threshold, daily_limit, hourly_limit,
|
|
from_number_override, expires_at, is_active, created_at
|
|
`
|
|
)
|
|
.order('addon_type')
|
|
.order('balance', { ascending: true });
|
|
creditsLoading.value = false;
|
|
if (data) credits.value = data;
|
|
}
|
|
|
|
const productOptions = computed(() => products.value.filter((p) => p.is_active && p.addon_type === creditForm.value.addon_type).map((p) => ({ value: p.id, label: `${p.name} (${p.credits_amount} créd · ${formatPrice(p.price_cents)})`, product: p })));
|
|
|
|
function onProductSelect(evt) {
|
|
const opt = productOptions.value.find((o) => o.value === creditForm.value.product_id);
|
|
if (opt?.product) {
|
|
creditForm.value.amount = opt.product.credits_amount;
|
|
creditForm.value.price_reais = opt.product.price_cents / 100;
|
|
creditForm.value.description = opt.product.name;
|
|
}
|
|
}
|
|
|
|
function openAddCredit() {
|
|
creditForm.value = { tenant_id: null, addon_type: 'sms', amount: 100, product_id: null, description: 'Crédito manual', price_reais: 0 };
|
|
creditDialog.value = true;
|
|
}
|
|
|
|
function openAddCreditFor(tenantId) {
|
|
creditForm.value = { tenant_id: tenantId, addon_type: 'sms', amount: 100, product_id: null, description: 'Crédito manual', price_reais: 0 };
|
|
creditDialog.value = true;
|
|
}
|
|
|
|
// Agrupa créditos por tenant e vincula as transações de cada um
|
|
const tenantGroups = computed(() => {
|
|
const groups = {};
|
|
for (const c of credits.value) {
|
|
if (!groups[c.tenant_id]) {
|
|
groups[c.tenant_id] = { tenant_id: c.tenant_id, credits: [], transactions: [] };
|
|
}
|
|
groups[c.tenant_id].credits.push(c);
|
|
}
|
|
for (const tx of transactions.value) {
|
|
if (groups[tx.tenant_id]) {
|
|
groups[tx.tenant_id].transactions.push(tx);
|
|
}
|
|
}
|
|
// Ordena por nome do tenant
|
|
return Object.values(groups).sort((a, b) =>
|
|
tenantName(a.tenant_id).localeCompare(tenantName(b.tenant_id))
|
|
);
|
|
});
|
|
|
|
async function addCredit() {
|
|
const f = creditForm.value;
|
|
if (!f.tenant_id || !f.amount || f.amount <= 0) {
|
|
toast.add({ severity: 'warn', summary: 'Selecione um tenant e informe a quantidade', life: 3000 });
|
|
return;
|
|
}
|
|
|
|
const { data, error } = await supabase.rpc('admin_credit_addon', {
|
|
p_tenant_id: f.tenant_id,
|
|
p_addon_type: f.addon_type,
|
|
p_amount: f.amount,
|
|
p_product_id: f.product_id || null,
|
|
p_description: f.description || 'Crédito manual',
|
|
p_payment_method: 'manual',
|
|
p_price_cents: Math.round((f.price_reais || 0) * 100)
|
|
});
|
|
|
|
if (error) {
|
|
toast.add({ severity: 'error', summary: 'Erro ao creditar', detail: error.message, life: 5000 });
|
|
return;
|
|
}
|
|
|
|
toast.add({
|
|
severity: 'success',
|
|
summary: 'Créditos adicionados!',
|
|
detail: `Saldo: ${data?.balance_before} → ${data?.balance_after}`,
|
|
life: 5000
|
|
});
|
|
creditDialog.value = false;
|
|
await Promise.all([loadCredits(), loadTransactions()]);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// ABA 3 — Transações recentes
|
|
// ══════════════════════════════════════════════════════════════
|
|
const transactions = ref([]);
|
|
const txLoading = ref(false);
|
|
|
|
async function loadTransactions() {
|
|
txLoading.value = true;
|
|
const { data } = await supabase.from('addon_transactions').select('*').order('created_at', { ascending: false }).limit(500);
|
|
txLoading.value = false;
|
|
if (data) transactions.value = data;
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// ABA 4 — WhatsApp: Pacotes (CRUD whatsapp_credit_packages)
|
|
// ══════════════════════════════════════════════════════════════
|
|
const waPackages = ref([]);
|
|
const waPackagesLoading = ref(false);
|
|
const waPkgDialog = ref(false);
|
|
const waEditingPkgId = ref(null);
|
|
|
|
const emptyWaPkg = () => ({
|
|
name: '',
|
|
description: '',
|
|
credits: 100,
|
|
price_brl: 0,
|
|
is_active: true,
|
|
is_featured: false,
|
|
position: 100
|
|
});
|
|
|
|
const waPkgForm = ref(emptyWaPkg());
|
|
|
|
async function loadWaPackages() {
|
|
waPackagesLoading.value = true;
|
|
try {
|
|
const { data, error } = await supabase
|
|
.from('whatsapp_credit_packages')
|
|
.select('*')
|
|
.order('position', { ascending: true })
|
|
.order('price_brl', { ascending: true });
|
|
if (error) throw error;
|
|
waPackages.value = data || [];
|
|
} catch (e) {
|
|
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
|
|
} finally {
|
|
waPackagesLoading.value = false;
|
|
}
|
|
}
|
|
|
|
function openNewWaPkg() {
|
|
waEditingPkgId.value = null;
|
|
waPkgForm.value = emptyWaPkg();
|
|
waPkgDialog.value = true;
|
|
}
|
|
|
|
function openEditWaPkg(row) {
|
|
waEditingPkgId.value = row.id;
|
|
waPkgForm.value = {
|
|
name: row.name || '',
|
|
description: row.description || '',
|
|
credits: row.credits,
|
|
price_brl: Number(row.price_brl) || 0,
|
|
is_active: row.is_active,
|
|
is_featured: row.is_featured,
|
|
position: row.position ?? 100
|
|
};
|
|
waPkgDialog.value = true;
|
|
}
|
|
|
|
function sanitizeWaPkg(f) {
|
|
return {
|
|
name: String(f.name || '').trim().slice(0, 100),
|
|
description: f.description ? String(f.description).trim().slice(0, 500) : null,
|
|
credits: Math.max(1, Math.round(Number(f.credits) || 0)),
|
|
price_brl: Math.max(0.01, Number(f.price_brl) || 0),
|
|
is_active: !!f.is_active,
|
|
is_featured: !!f.is_featured,
|
|
position: Math.max(0, Math.round(Number(f.position) || 100))
|
|
};
|
|
}
|
|
|
|
async function saveWaPkg() {
|
|
const clean = sanitizeWaPkg(waPkgForm.value);
|
|
if (!clean.name) { toast.add({ severity: 'warn', summary: 'Nome é obrigatório', life: 2500 }); return; }
|
|
if (clean.credits < 1) { toast.add({ severity: 'warn', summary: 'Créditos deve ser > 0', life: 2500 }); return; }
|
|
if (clean.price_brl <= 0) { toast.add({ severity: 'warn', summary: 'Preço deve ser > 0', life: 2500 }); return; }
|
|
try {
|
|
if (waEditingPkgId.value) {
|
|
const { error } = await supabase.from('whatsapp_credit_packages').update(clean).eq('id', waEditingPkgId.value);
|
|
if (error) throw error;
|
|
toast.add({ severity: 'success', summary: 'Pacote atualizado', life: 2000 });
|
|
} else {
|
|
const { error } = await supabase.from('whatsapp_credit_packages').insert(clean);
|
|
if (error) throw error;
|
|
toast.add({ severity: 'success', summary: 'Pacote criado', life: 2000 });
|
|
}
|
|
waPkgDialog.value = false;
|
|
await loadWaPackages();
|
|
} catch (e) {
|
|
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
|
|
}
|
|
}
|
|
|
|
function deleteWaPkg(row) {
|
|
confirm.require({
|
|
group: 'headless',
|
|
header: 'Remover pacote',
|
|
message: `Remover "${row.name}"? Compras existentes continuam válidas (FK SET NULL).`,
|
|
icon: 'pi-trash',
|
|
color: '#ef4444',
|
|
accept: async () => {
|
|
try {
|
|
const { error } = await supabase.from('whatsapp_credit_packages').delete().eq('id', row.id);
|
|
if (error) throw error;
|
|
toast.add({ severity: 'success', summary: 'Pacote removido', life: 2000 });
|
|
await loadWaPackages();
|
|
} catch (e) {
|
|
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
const waPkgToggleBump = ref(0);
|
|
|
|
function escapeHtml(s) {
|
|
return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
}
|
|
|
|
async function toggleWaPkgActive(row) {
|
|
// Ativar: direto, sem confirm
|
|
if (!row.is_active) {
|
|
try {
|
|
const { error } = await supabase.from('whatsapp_credit_packages').update({ is_active: true }).eq('id', row.id);
|
|
if (error) throw error;
|
|
row.is_active = true;
|
|
toast.add({ severity: 'success', summary: 'Pacote ativado', life: 2000 });
|
|
} catch (e) {
|
|
waPkgToggleBump.value++;
|
|
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Desativar: força revert visual até o usuário decidir
|
|
waPkgToggleBump.value++;
|
|
|
|
// Computa impacto e pede confirm
|
|
let purchasesCount = 0;
|
|
let tenantsCount = 0;
|
|
let otherActiveCount = 0;
|
|
try {
|
|
const [{ count: pc }, { data: purchasesTenants }, { count: oa }] = await Promise.all([
|
|
supabase
|
|
.from('whatsapp_credit_purchases')
|
|
.select('id', { count: 'exact', head: true })
|
|
.eq('package_id', row.id),
|
|
supabase
|
|
.from('whatsapp_credit_purchases')
|
|
.select('tenant_id')
|
|
.eq('package_id', row.id),
|
|
supabase
|
|
.from('whatsapp_credit_packages')
|
|
.select('id', { count: 'exact', head: true })
|
|
.eq('is_active', true)
|
|
.neq('id', row.id)
|
|
]);
|
|
purchasesCount = pc || 0;
|
|
tenantsCount = new Set((purchasesTenants || []).map((r) => r.tenant_id)).size;
|
|
otherActiveCount = oa || 0;
|
|
} catch (e) {
|
|
toast.add({ severity: 'warn', summary: 'Impacto indisponível', detail: 'Não foi possível calcular histórico — confirme com atenção.', life: 3500 });
|
|
}
|
|
|
|
const impactLine =
|
|
purchasesCount > 0
|
|
? `Este pacote foi comprado <strong>${purchasesCount}</strong> ${purchasesCount === 1 ? 'vez' : 'vezes'} por <strong>${tenantsCount}</strong> ${tenantsCount === 1 ? 'tenant' : 'tenants distintos'}.`
|
|
: 'Este pacote <strong>nunca foi comprado</strong> até agora.';
|
|
|
|
const items = [
|
|
`Desativar <strong>"${escapeHtml(row.name)}"</strong> vai removê-lo da loja pros tenants.`,
|
|
impactLine,
|
|
'Créditos já adquiridos continuam válidos — apenas o pacote some da vitrine.'
|
|
];
|
|
if (otherActiveCount === 0) {
|
|
items.push('<span style="color:#dc2626;font-weight:600">⚠️ ATENÇÃO:</span> este é o <strong>único pacote ativo</strong>. A loja de créditos ficará vazia até você ativar outro.');
|
|
}
|
|
|
|
confirm.require({
|
|
group: 'headless',
|
|
header: `Desativar "${row.name}"?`,
|
|
message: items.join('<br><br>'),
|
|
icon: otherActiveCount === 0 ? 'pi-exclamation-triangle' : 'pi-eye-slash',
|
|
color: otherActiveCount === 0 ? '#f59e0b' : '#ef4444',
|
|
accept: async () => {
|
|
try {
|
|
const { error } = await supabase.from('whatsapp_credit_packages').update({ is_active: false }).eq('id', row.id);
|
|
if (error) throw error;
|
|
row.is_active = false;
|
|
toast.add({ severity: 'success', summary: 'Pacote desativado', life: 2000 });
|
|
} catch (e) {
|
|
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function formatBrl(v) {
|
|
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(Number(v) || 0);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// ABA 5 — WhatsApp: Ajuste manual SaaS admin (admin_adjust_whatsapp_credits RPC)
|
|
// ══════════════════════════════════════════════════════════════
|
|
const waTopup = ref({
|
|
tenantId: null,
|
|
amount: 100,
|
|
mode: 'add', // 'add' ou 'remove'
|
|
note: ''
|
|
});
|
|
|
|
const waTopupModes = [
|
|
{ label: 'Adicionar', value: 'add', icon: 'pi pi-plus' },
|
|
{ label: 'Remover', value: 'remove', icon: 'pi pi-minus' }
|
|
];
|
|
|
|
const WA_ADJUST_MAX = 1000;
|
|
|
|
const waTenantBalance = ref(null);
|
|
const waRemovable = ref(null); // { balance, removable, protected_amount, topup_net, usage_total }
|
|
const waRecentTopups = ref([]);
|
|
const waTopupSaving = ref(false);
|
|
|
|
async function loadWaBalance(tenantId) {
|
|
if (!tenantId) {
|
|
waTenantBalance.value = null;
|
|
waRemovable.value = null;
|
|
waRecentTopups.value = [];
|
|
return;
|
|
}
|
|
const [{ data: bal }, { data: rem, error: remErr }, { data: txs }] = await Promise.all([
|
|
supabase
|
|
.from('whatsapp_credits_balance')
|
|
.select('balance, lifetime_purchased, lifetime_used, low_balance_threshold')
|
|
.eq('tenant_id', tenantId)
|
|
.maybeSingle(),
|
|
supabase.rpc('get_whatsapp_removable_balance', { p_tenant_id: tenantId }),
|
|
supabase
|
|
.from('whatsapp_credits_transactions')
|
|
.select('id, kind, amount, balance_after, note, created_at, admin_id')
|
|
.eq('tenant_id', tenantId)
|
|
.in('kind', ['topup_manual', 'adjustment', 'refund'])
|
|
.order('created_at', { ascending: false })
|
|
.limit(20)
|
|
]);
|
|
waTenantBalance.value = bal || { balance: 0, lifetime_purchased: 0, lifetime_used: 0, low_balance_threshold: 20 };
|
|
waRemovable.value = Array.isArray(rem) && rem.length ? rem[0] : (remErr ? null : { balance: bal?.balance || 0, removable: 0, protected_amount: bal?.balance || 0, topup_net: 0, usage_total: 0 });
|
|
waRecentTopups.value = txs || [];
|
|
}
|
|
|
|
async function onWaTenantChange() {
|
|
await loadWaBalance(waTopup.value.tenantId);
|
|
}
|
|
|
|
function waMaxAmountForMode() {
|
|
if (waTopup.value.mode === 'remove') {
|
|
return Math.min(WA_ADJUST_MAX, Math.max(0, waRemovable.value?.removable || 0));
|
|
}
|
|
return WA_ADJUST_MAX;
|
|
}
|
|
|
|
async function doWaAdjust() {
|
|
const t = waTopup.value;
|
|
const amtAbs = Math.round(Number(t.amount) || 0);
|
|
const note = String(t.note || '').trim().slice(0, 500) || null;
|
|
const signed = t.mode === 'remove' ? -amtAbs : amtAbs;
|
|
|
|
waTopupSaving.value = true;
|
|
try {
|
|
const { data: authData } = await supabase.auth.getUser();
|
|
const adminId = authData?.user?.id || null;
|
|
if (!adminId) throw new Error('Sessão inválida — refaça login');
|
|
|
|
const { error } = await supabase.rpc('admin_adjust_whatsapp_credits', {
|
|
p_tenant_id: t.tenantId,
|
|
p_amount: signed,
|
|
p_admin_id: adminId,
|
|
p_note: note
|
|
});
|
|
if (error) throw error;
|
|
|
|
const sign = signed > 0 ? '+' : '';
|
|
toast.add({
|
|
severity: 'success',
|
|
summary: `${sign}${signed} créditos`,
|
|
detail: tenantName(t.tenantId),
|
|
life: 3000
|
|
});
|
|
waTopup.value.note = '';
|
|
waTopup.value.amount = 100;
|
|
await loadWaBalance(t.tenantId);
|
|
} catch (e) {
|
|
const raw = e?.message || '';
|
|
let friendly = raw;
|
|
if (raw.includes('amount_exceeds_limit')) friendly = 'Máximo 1000 por operação.';
|
|
else if (raw.includes('cannot_remove_beyond_removable')) {
|
|
const match = raw.match(/max=(\d+)/);
|
|
friendly = match ? `Só é possível remover até ${match[1]} créditos (o restante é compra protegida).` : 'Saldo removível insuficiente.';
|
|
} else if (raw.includes('tenant_has_no_balance')) friendly = 'Tenant ainda não tem saldo.';
|
|
else if (raw.includes('permission_denied')) friendly = 'Permissão negada — apenas saas_admin.';
|
|
toast.add({ severity: 'error', summary: 'Erro', detail: friendly, life: 5000 });
|
|
} finally {
|
|
waTopupSaving.value = false;
|
|
}
|
|
}
|
|
|
|
function submitWaTopup() {
|
|
const t = waTopup.value;
|
|
if (!t.tenantId) { toast.add({ severity: 'warn', summary: 'Selecione o tenant', life: 2500 }); return; }
|
|
const amt = Math.round(Number(t.amount) || 0);
|
|
if (amt < 1) { toast.add({ severity: 'warn', summary: 'Valor deve ser >= 1', life: 2500 }); return; }
|
|
if (amt > WA_ADJUST_MAX) { toast.add({ severity: 'warn', summary: `Máximo ${WA_ADJUST_MAX} por operação`, life: 3000 }); return; }
|
|
|
|
if (t.mode === 'remove') {
|
|
const max = waMaxAmountForMode();
|
|
if (max <= 0) { toast.add({ severity: 'warn', summary: 'Nada removível', detail: 'Este tenant não tem créditos de cortesia disponíveis pra remoção.', life: 4000 }); return; }
|
|
if (amt > max) { toast.add({ severity: 'warn', summary: `Máximo removível: ${max}`, life: 3000 }); return; }
|
|
|
|
const msgItems = [
|
|
`Vai subtrair <strong>${amt} créditos</strong> de <strong>${escapeHtml(tenantName(t.tenantId))}</strong> (pool cortesia).`,
|
|
`Saldo resultante: <strong>${(waTenantBalance.value?.balance || 0) - amt}</strong>.`,
|
|
'Créditos comprados continuam protegidos.',
|
|
'Esta operação fica registrada no extrato com seu <code>user_id</code>.'
|
|
];
|
|
confirm.require({
|
|
group: 'headless',
|
|
header: `Remover ${amt} créditos?`,
|
|
message: msgItems.join('<br><br>'),
|
|
icon: 'pi-minus-circle',
|
|
color: '#ef4444',
|
|
accept: () => doWaAdjust()
|
|
});
|
|
} else {
|
|
doWaAdjust();
|
|
}
|
|
}
|
|
|
|
const waKindBadge = {
|
|
topup_manual: { label: 'Topup', cls: 'bg-sky-500/10 text-sky-600' },
|
|
adjustment: { label: 'Ajuste', cls: 'bg-slate-500/10 text-slate-600' },
|
|
refund: { label: 'Refund', cls: 'bg-orange-500/10 text-orange-600' }
|
|
};
|
|
|
|
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 formatDate(iso) {
|
|
if (!iso) return '—';
|
|
return new Date(iso).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' });
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// Init
|
|
// ══════════════════════════════════════════════════════════════
|
|
onMounted(() => {
|
|
loadTenants();
|
|
loadProducts();
|
|
loadCredits();
|
|
loadTransactions();
|
|
loadWaPackages();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="flex flex-col gap-4 p-4">
|
|
<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)] leading-relaxed" 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>
|
|
|
|
<!-- Header -->
|
|
<div class="cfg-subheader">
|
|
<div class="cfg-subheader__icon"><i class="pi pi-box" /></div>
|
|
<div class="min-w-0 flex-1">
|
|
<div class="cfg-subheader__title">Recursos Extras (Add-ons)</div>
|
|
<div class="cfg-subheader__sub">Produtos, créditos WhatsApp/SMS e transações consumidas por tenants.</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Próximos passos -->
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
<div class="rounded-[6px] border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 border-l-4" style="border-left-color: var(--p-yellow-500)">
|
|
<div class="flex items-start gap-3">
|
|
<div class="w-10 h-10 rounded-[6px] flex items-center justify-center shrink-0 bg-yellow-100 text-yellow-700">
|
|
<i class="pi pi-bell text-lg" />
|
|
</div>
|
|
<div class="flex-1 min-w-0">
|
|
<div class="font-semibold text-[var(--text-color)] mb-1">Alerta de saldo baixo</div>
|
|
<p class="text-sm text-[var(--text-color-secondary)] m-0">
|
|
Próximo passo: notificar tenants automaticamente quando o saldo de créditos estiver abaixo do limite configurado. O campo <code class="font-mono text-xs">low_balance_threshold</code> já existe no banco — falta a Edge Function de verificação periódica.
|
|
</p>
|
|
<Tag value="Planejado" severity="warn" class="mt-2 text-[0.65rem]" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="rounded-[6px] border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 border-l-4" style="border-left-color: var(--p-blue-500)">
|
|
<div class="flex items-start gap-3">
|
|
<div class="w-10 h-10 rounded-[6px] flex items-center justify-center shrink-0 bg-blue-100 text-blue-700">
|
|
<i class="pi pi-credit-card text-lg" />
|
|
</div>
|
|
<div class="flex-1 min-w-0">
|
|
<div class="font-semibold text-[var(--text-color)] mb-1">Compra online (Gateway)</div>
|
|
<p class="text-sm text-[var(--text-color-secondary)] m-0">
|
|
Próximo passo: integrar gateway de pagamento (PIX, cartão) para que tenants comprem créditos diretamente pela plataforma. Os campos <code class="font-mono text-xs">payment_method</code> e <code class="font-mono text-xs">payment_reference</code> já estão prontos no banco.
|
|
</p>
|
|
<Tag value="Planejado" severity="info" class="mt-2 text-[0.65rem]" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Tabs v-model:value="activeTab">
|
|
<TabList>
|
|
<Tab :value="0">Produtos</Tab>
|
|
<Tab :value="1">Recursos Extras por Tenant</Tab>
|
|
<Tab :value="2">Transações</Tab>
|
|
<Tab :value="3"><i class="pi pi-whatsapp mr-1 text-emerald-500" />Pacotes WhatsApp</Tab>
|
|
<Tab :value="4"><i class="pi pi-whatsapp mr-1 text-emerald-500" />Topup WhatsApp</Tab>
|
|
</TabList>
|
|
|
|
<TabPanels>
|
|
<!-- ── ABA 1: Produtos ──────────────────────────────────── -->
|
|
<TabPanel :value="0">
|
|
<div class="flex justify-end mb-3">
|
|
<Button label="Novo produto" icon="pi pi-plus" size="small" @click="openNewProduct" />
|
|
</div>
|
|
|
|
<DataTable :value="products" :loading="productsLoading" size="small" stripedRows emptyMessage="Nenhum produto cadastrado.">
|
|
<Column field="slug" header="Slug" style="width: 130px" />
|
|
<Column field="name" header="Nome" />
|
|
<Column field="addon_type" header="Tipo" style="width: 90px">
|
|
<template #body="{ data }">
|
|
<Tag :value="data.addon_type.toUpperCase()" />
|
|
</template>
|
|
</Column>
|
|
<Column field="credits_amount" header="Créditos" style="width: 90px" />
|
|
<Column field="price_cents" header="Preço" style="width: 110px">
|
|
<template #body="{ data }">{{ formatPrice(data.price_cents) }}</template>
|
|
</Column>
|
|
<Column field="is_active" header="Ativo" style="width: 70px">
|
|
<template #body="{ data }">
|
|
<i :class="data.is_active ? 'pi pi-check text-green-500' : 'pi pi-times text-red-500'" />
|
|
</template>
|
|
</Column>
|
|
<Column field="is_visible" header="Visível" style="width: 70px">
|
|
<template #body="{ data }">
|
|
<i :class="data.is_visible ? 'pi pi-eye text-green-500' : 'pi pi-eye-slash text-surface-400'" />
|
|
</template>
|
|
</Column>
|
|
<Column header="Ações" style="width: 100px">
|
|
<template #body="{ data }">
|
|
<div class="flex gap-1">
|
|
<Button icon="pi pi-pencil" text rounded size="small" @click="openEditProduct(data)" />
|
|
<Button icon="pi pi-trash" text rounded size="small" severity="danger" @click="deleteProduct(data)" />
|
|
</div>
|
|
</template>
|
|
</Column>
|
|
</DataTable>
|
|
</TabPanel>
|
|
|
|
<!-- ── ABA 2: Créditos por Tenant ───────────────────────── -->
|
|
<TabPanel :value="1">
|
|
<div class="flex justify-between items-center mb-3">
|
|
<span class="text-sm text-surface-500">
|
|
{{ tenantGroups.length }} tenant(s) com recursos extras
|
|
</span>
|
|
<div class="flex gap-2">
|
|
<Button icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="creditsLoading" @click="() => { loadCredits(); loadTransactions(); }" />
|
|
<Button label="Adicionar créditos" icon="pi pi-plus" size="small" @click="openAddCredit" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Loading -->
|
|
<div v-if="creditsLoading" class="flex justify-center py-10">
|
|
<ProgressSpinner style="width:36px;height:36px" />
|
|
</div>
|
|
|
|
<!-- Vazio -->
|
|
<div v-else-if="!tenantGroups.length" class="text-center py-10 text-surface-400 text-sm">
|
|
<i class="pi pi-inbox block text-3xl opacity-30 mb-2" />
|
|
Nenhum tenant com recursos extras ainda.
|
|
</div>
|
|
|
|
<!-- Accordion por tenant -->
|
|
<Accordion v-else>
|
|
<AccordionPanel
|
|
v-for="group in tenantGroups"
|
|
:key="group.tenant_id"
|
|
:value="group.tenant_id"
|
|
>
|
|
<AccordionHeader>
|
|
<div class="flex items-center gap-3 w-full min-w-0 pr-3">
|
|
<!-- Nome do tenant -->
|
|
<span class="font-semibold text-sm truncate flex-1">
|
|
{{ tenantName(group.tenant_id) }}
|
|
</span>
|
|
|
|
<!-- Chips de saldo por addon_type -->
|
|
<div class="flex gap-1.5 flex-wrap shrink-0">
|
|
<Tag
|
|
v-for="c in group.credits"
|
|
:key="c.id"
|
|
:value="`${c.addon_type.toUpperCase()} · ${c.balance}`"
|
|
:severity="c.balance <= 0 ? 'danger' : c.balance <= (c.low_balance_threshold || 10) ? 'warn' : 'success'"
|
|
class="text-[0.68rem]"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Botão + créditos inline -->
|
|
<Button
|
|
icon="pi pi-plus"
|
|
label="Creditar"
|
|
size="small"
|
|
severity="secondary"
|
|
outlined
|
|
class="shrink-0 text-xs !py-1"
|
|
@click.stop="openAddCreditFor(group.tenant_id)"
|
|
/>
|
|
</div>
|
|
</AccordionHeader>
|
|
|
|
<AccordionContent>
|
|
<div class="pt-1 pb-2">
|
|
<!-- Resumo do saldo por addon_type -->
|
|
<div class="flex flex-wrap gap-3 mb-4">
|
|
<div
|
|
v-for="c in group.credits"
|
|
:key="c.id"
|
|
class="flex flex-col gap-0.5 border border-[var(--surface-border)] rounded-lg px-3 py-2 min-w-[120px]"
|
|
>
|
|
<span class="text-[0.65rem] font-bold uppercase text-[var(--text-color-secondary)]">{{ c.addon_type }}</span>
|
|
<span
|
|
class="text-2xl font-bold leading-none"
|
|
:class="c.balance <= 0 ? 'text-red-500' : c.balance <= (c.low_balance_threshold || 10) ? 'text-yellow-500' : 'text-green-500'"
|
|
>{{ c.balance }}</span>
|
|
<span class="text-[0.68rem] text-[var(--text-color-secondary)]">
|
|
comprado {{ c.total_purchased }} · consumido {{ c.total_consumed }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Histórico de adições -->
|
|
<div class="text-xs font-semibold text-[var(--text-color-secondary)] uppercase tracking-wide mb-2">
|
|
Histórico de movimentações
|
|
</div>
|
|
|
|
<DataTable
|
|
:value="group.transactions"
|
|
size="small"
|
|
striped-rows
|
|
empty-message="Nenhuma movimentação registrada."
|
|
class="text-sm"
|
|
>
|
|
<Column header="Data" style="width: 135px">
|
|
<template #body="{ data }">
|
|
<span class="text-xs">{{ formatDate(data.created_at) }}</span>
|
|
</template>
|
|
</Column>
|
|
<Column field="addon_type" header="Tipo" style="width: 65px">
|
|
<template #body="{ data }">
|
|
<span class="text-[0.65rem] font-semibold uppercase">{{ data.addon_type }}</span>
|
|
</template>
|
|
</Column>
|
|
<Column header="Operação" style="width: 100px">
|
|
<template #body="{ data }">
|
|
<Tag
|
|
:value="txTypeLabel(data.type)"
|
|
:severity="txTypeSeverity(data.type)"
|
|
class="text-[0.65rem]"
|
|
/>
|
|
</template>
|
|
</Column>
|
|
<Column header="Qtd" style="width: 70px">
|
|
<template #body="{ data }">
|
|
<span
|
|
class="font-semibold text-sm"
|
|
:class="data.amount > 0 ? 'text-green-500' : 'text-red-400'"
|
|
>
|
|
{{ data.amount > 0 ? '+' : '' }}{{ data.amount }}
|
|
</span>
|
|
</template>
|
|
</Column>
|
|
<Column header="Saldo após" style="width: 85px">
|
|
<template #body="{ data }">
|
|
<span class="text-sm font-semibold">{{ data.balance_after }}</span>
|
|
</template>
|
|
</Column>
|
|
<Column field="description" header="Descrição" />
|
|
<Column header="Valor pago" style="width: 100px">
|
|
<template #body="{ data }">
|
|
{{ data.price_cents ? formatPrice(data.price_cents) : '—' }}
|
|
</template>
|
|
</Column>
|
|
</DataTable>
|
|
</div>
|
|
</AccordionContent>
|
|
</AccordionPanel>
|
|
</Accordion>
|
|
</TabPanel>
|
|
|
|
<!-- ── ABA 3: Transações ─────────────────────────────── -->
|
|
<TabPanel :value="2">
|
|
<div class="flex justify-end mb-3">
|
|
<Button icon="pi pi-refresh" text rounded size="small" @click="loadTransactions" :loading="txLoading" />
|
|
</div>
|
|
|
|
<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="tenant_id" header="Tenant" style="max-width: 160px">
|
|
<template #body="{ data }">
|
|
<span class="text-sm" :title="data.tenant_id">{{ tenantName(data.tenant_id) }}</span>
|
|
</template>
|
|
</Column>
|
|
<Column field="addon_type" header="Tipo" style="width: 70px">
|
|
<template #body="{ data }"><Tag :value="data.addon_type" /></template>
|
|
</Column>
|
|
<Column field="type" header="Operação" style="width: 100px">
|
|
<template #body="{ data }">
|
|
<Tag :value="txTypeLabel(data.type)" :severity="txTypeSeverity(data.type)" />
|
|
</template>
|
|
</Column>
|
|
<Column field="amount" header="Qtd" style="width: 70px">
|
|
<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: 70px" />
|
|
<Column field="description" header="Descrição" />
|
|
<Column field="payment_method" header="Pgto" style="width: 80px">
|
|
<template #body="{ data }">{{ data.payment_method || '—' }}</template>
|
|
</Column>
|
|
</DataTable>
|
|
</TabPanel>
|
|
|
|
<!-- ── ABA 4: Pacotes WhatsApp (loja Twilio/Asaas) ────── -->
|
|
<TabPanel :value="3">
|
|
<div class="flex items-start justify-between gap-3 mb-3 flex-wrap">
|
|
<div class="text-xs text-[var(--text-color-secondary)] max-w-[660px]">
|
|
Pacotes que os tenants veem em <code>/configuracoes/creditos-whatsapp</code>. Consumidos só no canal
|
|
<strong>AgenciaPSI Oficial (Twilio)</strong> — WhatsApp Pessoal (Evolution) é gratuito.
|
|
<strong>Destaque</strong> aparece com estrela; <strong>posição</strong> ordena (menor primeiro).
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<Button icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="waPackagesLoading" @click="loadWaPackages" />
|
|
<Button label="Novo pacote" icon="pi pi-plus" size="small" @click="openNewWaPkg" />
|
|
</div>
|
|
</div>
|
|
|
|
<DataTable :value="waPackages" :loading="waPackagesLoading" size="small" stripedRows
|
|
emptyMessage="Nenhum pacote cadastrado.">
|
|
<Column field="position" header="#" style="width: 60px" />
|
|
<Column header="Nome">
|
|
<template #body="{ data }">
|
|
<div class="flex items-center gap-1.5">
|
|
<span class="font-semibold">{{ data.name }}</span>
|
|
<i v-if="data.is_featured" class="pi pi-star-fill text-amber-500 text-xs" v-tooltip.top="'Destaque'" />
|
|
</div>
|
|
<div v-if="data.description" class="text-xs text-[var(--text-color-secondary)] truncate max-w-[360px]">
|
|
{{ data.description }}
|
|
</div>
|
|
</template>
|
|
</Column>
|
|
<Column field="credits" header="Créditos" style="width: 100px" />
|
|
<Column header="Preço" style="width: 130px">
|
|
<template #body="{ data }">
|
|
<span class="font-mono">{{ formatBrl(data.price_brl) }}</span>
|
|
<div class="text-[0.68rem] text-[var(--text-color-secondary)]">{{ formatBrl(data.price_brl / data.credits) }} / msg</div>
|
|
</template>
|
|
</Column>
|
|
<Column header="Ativo" style="width: 80px">
|
|
<template #body="{ data }">
|
|
<ToggleSwitch
|
|
:key="`pkg-tog-${data.id}-${waPkgToggleBump}`"
|
|
:modelValue="data.is_active"
|
|
@update:modelValue="() => toggleWaPkgActive(data)" />
|
|
</template>
|
|
</Column>
|
|
<Column header="Ações" style="width: 100px">
|
|
<template #body="{ data }">
|
|
<div class="flex gap-1">
|
|
<Button icon="pi pi-pencil" text rounded size="small" @click="openEditWaPkg(data)" />
|
|
<Button icon="pi pi-trash" text rounded size="small" severity="danger" @click="deleteWaPkg(data)" />
|
|
</div>
|
|
</template>
|
|
</Column>
|
|
</DataTable>
|
|
</TabPanel>
|
|
|
|
<!-- ── ABA 5: Topup manual WhatsApp ───────────────────── -->
|
|
<TabPanel :value="4">
|
|
<div class="grid grid-cols-1 md:grid-cols-[1fr_1fr] gap-4">
|
|
<!-- Form -->
|
|
<div class="flex flex-col gap-3 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
|
|
<div class="flex items-center gap-2">
|
|
<i class="pi pi-wrench text-sky-500" />
|
|
<h3 class="text-sm font-bold uppercase tracking-wide m-0">Ajustar créditos WhatsApp de um tenant</h3>
|
|
</div>
|
|
<p class="text-xs text-[var(--text-color-secondary)] m-0">
|
|
Adicionar (cortesia onboarding, ressarcimento, correção de falha) ou remover (corrigir topup errado).
|
|
Limite de <strong>1000 por operação</strong> · remoção só afeta créditos de cortesia, nunca compras.
|
|
Fica no extrato com <code>admin_id = você</code>.
|
|
</p>
|
|
|
|
<div class="flex flex-col gap-1">
|
|
<label class="text-xs font-semibold uppercase tracking-wide text-[var(--text-color-secondary)]">Tenant</label>
|
|
<Select v-model="waTopup.tenantId" :options="tenants" optionLabel="label" optionValue="value"
|
|
filter placeholder="Selecionar tenant" class="w-full"
|
|
:loading="loadingTenants"
|
|
@update:modelValue="onWaTenantChange" />
|
|
</div>
|
|
|
|
<div v-if="waTenantBalance" class="grid grid-cols-3 gap-2 rounded-md bg-[var(--surface-ground)] p-2 text-xs">
|
|
<div><span class="text-[var(--text-color-secondary)]">Saldo:</span> <strong>{{ waTenantBalance.balance }}</strong></div>
|
|
<div><span class="text-[var(--text-color-secondary)]">Comprados:</span> <strong>{{ waTenantBalance.lifetime_purchased }}</strong></div>
|
|
<div><span class="text-[var(--text-color-secondary)]">Usados:</span> <strong>{{ waTenantBalance.lifetime_used }}</strong></div>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-1">
|
|
<label class="text-xs font-semibold uppercase tracking-wide text-[var(--text-color-secondary)]">Operação</label>
|
|
<SelectButton v-model="waTopup.mode" :options="waTopupModes" optionLabel="label" optionValue="value" :allowEmpty="false">
|
|
<template #option="slot">
|
|
<i :class="slot.option.icon + ' mr-1'" />{{ slot.option.label }}
|
|
</template>
|
|
</SelectButton>
|
|
</div>
|
|
|
|
<!-- Card de breakdown (modo remover) -->
|
|
<div v-if="waTopup.mode === 'remove' && waRemovable"
|
|
class="rounded-md border border-orange-200 bg-orange-50 dark:bg-orange-950/20 dark:border-orange-900/40 p-2.5 text-xs flex flex-col gap-1.5">
|
|
<div class="flex items-center gap-1.5 font-semibold text-orange-700 dark:text-orange-400">
|
|
<i class="pi pi-shield" />Breakdown do saldo
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-x-3 gap-y-0.5">
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-[var(--text-color-secondary)]">Removível (cortesia)</span>
|
|
<strong class="font-mono text-orange-700 dark:text-orange-400">{{ waRemovable.removable }}</strong>
|
|
</div>
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-[var(--text-color-secondary)]">Protegido (compras)</span>
|
|
<strong class="font-mono text-emerald-700 dark:text-emerald-400">{{ waRemovable.protected_amount }}</strong>
|
|
</div>
|
|
</div>
|
|
<div v-if="waRemovable.removable === 0" class="text-orange-700 dark:text-orange-400 italic">
|
|
Sem créditos de cortesia disponíveis pra remoção.
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-1">
|
|
<label class="text-xs font-semibold uppercase tracking-wide text-[var(--text-color-secondary)]">
|
|
{{ waTopup.mode === 'remove' ? 'Créditos a remover' : 'Créditos a adicionar' }}
|
|
</label>
|
|
<InputNumber v-model="waTopup.amount" :min="1" :max="WA_ADJUST_MAX" class="w-full" fluid />
|
|
<small class="text-[var(--text-color-secondary)]">
|
|
Máx. {{ WA_ADJUST_MAX }} por operação<span v-if="waTopup.mode === 'remove' && waRemovable"> · removível agora: {{ Math.min(WA_ADJUST_MAX, waRemovable.removable) }}</span>.
|
|
</small>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-1">
|
|
<label class="text-xs font-semibold uppercase tracking-wide text-[var(--text-color-secondary)]">Nota / motivo</label>
|
|
<Textarea v-model="waTopup.note" rows="2" autoResize
|
|
placeholder="Ex: Cortesia onboarding, ressarcimento #T123, topup errado corrigido…"
|
|
maxlength="500" />
|
|
<small class="text-[var(--text-color-secondary)]">Visível pro tenant no extrato. Max 500 chars.</small>
|
|
</div>
|
|
|
|
<Button :label="waTopup.mode === 'remove' ? 'Remover créditos' : 'Adicionar créditos'"
|
|
:icon="waTopup.mode === 'remove' ? 'pi pi-minus' : 'pi pi-plus'"
|
|
class="rounded-full self-start"
|
|
:severity="waTopup.mode === 'remove' ? 'danger' : undefined"
|
|
:loading="waTopupSaving"
|
|
:disabled="!waTopup.tenantId || !waTopup.amount || (waTopup.mode === 'remove' && (!waRemovable || waRemovable.removable === 0))"
|
|
@click="submitWaTopup" />
|
|
</div>
|
|
|
|
<!-- Histórico -->
|
|
<div class="flex flex-col gap-2 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
|
|
<div class="flex items-center gap-2">
|
|
<i class="pi pi-history text-[var(--primary-color)]" />
|
|
<h3 class="text-sm font-bold uppercase tracking-wide m-0">Topups / ajustes recentes</h3>
|
|
</div>
|
|
<div v-if="!waTopup.tenantId" class="text-xs text-[var(--text-color-secondary)] italic py-4 text-center">
|
|
Selecione um tenant pra ver o histórico.
|
|
</div>
|
|
<div v-else-if="!waRecentTopups.length" class="text-xs text-[var(--text-color-secondary)] italic py-4 text-center">
|
|
Nenhum topup/ajuste ainda pra esse tenant.
|
|
</div>
|
|
<div v-else class="flex flex-col gap-1 max-h-[340px] overflow-y-auto text-xs">
|
|
<div v-for="tx in waRecentTopups" :key="tx.id"
|
|
class="grid grid-cols-[auto_1fr_auto_auto] items-center gap-2 p-2 rounded hover:bg-[var(--surface-hover)]">
|
|
<span class="inline-flex items-center px-1.5 py-px rounded text-[0.62rem] font-bold uppercase"
|
|
:class="waKindBadge[tx.kind]?.cls">{{ waKindBadge[tx.kind]?.label || tx.kind }}</span>
|
|
<span class="truncate">{{ tx.note || '—' }}</span>
|
|
<span class="font-bold font-mono" :class="tx.amount > 0 ? 'text-green-600' : 'text-orange-600'">
|
|
{{ tx.amount > 0 ? '+' : '' }}{{ tx.amount }}
|
|
</span>
|
|
<span class="text-[var(--text-color-secondary)]">{{ formatDate(tx.created_at) }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</TabPanel>
|
|
</TabPanels>
|
|
</Tabs>
|
|
|
|
<!-- Dialog: Novo/Editar Produto -->
|
|
<Dialog
|
|
v-model:visible="productDialog"
|
|
modal
|
|
:draggable="false"
|
|
:closable="true"
|
|
:dismissableMask="true"
|
|
maximizable
|
|
class="dc-dialog w-[36rem]"
|
|
: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">{{ editingProduct ? 'Editar Produto' : 'Novo Produto' }}</div>
|
|
<div class="text-xs opacity-50">Configurar produto de recurso extra</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<div class="flex flex-col gap-3">
|
|
<div class="flex flex-col gap-1">
|
|
<label class="font-medium text-sm">Slug</label>
|
|
<InputText v-model="productForm.slug" placeholder="sms_100" :disabled="!!editingProduct" class="w-full" />
|
|
</div>
|
|
<div class="flex flex-col gap-1">
|
|
<label class="font-medium text-sm">Nome</label>
|
|
<InputText v-model="productForm.name" placeholder="SMS 100 créditos" class="w-full" />
|
|
</div>
|
|
<div class="flex flex-col gap-1">
|
|
<label class="font-medium text-sm">Descrição</label>
|
|
<Textarea v-model="productForm.description" rows="2" class="w-full" />
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<div class="flex flex-col gap-1">
|
|
<label class="font-medium text-sm">Tipo</label>
|
|
<Select v-model="productForm.addon_type" :options="addonTypes" optionLabel="label" optionValue="value" class="w-full" />
|
|
</div>
|
|
<div class="flex flex-col gap-1">
|
|
<label class="font-medium text-sm">Ícone</label>
|
|
<InputText v-model="productForm.icon" placeholder="pi pi-comment" class="w-full" />
|
|
</div>
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<div class="flex flex-col gap-1">
|
|
<label class="font-medium text-sm">Créditos</label>
|
|
<InputNumber v-model="productForm.credits_amount" :min="0" fluid />
|
|
</div>
|
|
<div class="flex flex-col gap-1">
|
|
<label class="font-medium text-sm">Preço (R$)</label>
|
|
<InputNumber
|
|
v-model="productForm.price_reais"
|
|
:min="0"
|
|
:min-fraction-digits="2"
|
|
:max-fraction-digits="2"
|
|
mode="currency"
|
|
currency="BRL"
|
|
locale="pt-BR"
|
|
fluid
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div class="flex flex-col gap-1 w-32">
|
|
<label class="font-medium text-sm">Ordem de exibição</label>
|
|
<InputNumber v-model="productForm.sort_order" :min="0" fluid />
|
|
</div>
|
|
<div class="flex gap-4">
|
|
<div class="flex items-center gap-2">
|
|
<ToggleSwitch v-model="productForm.is_active" />
|
|
<label class="text-sm">Ativo</label>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<ToggleSwitch v-model="productForm.is_visible" />
|
|
<label class="text-sm">Visível na vitrine</label>
|
|
</div>
|
|
</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" @click="productDialog = false" />
|
|
<Button label="Salvar" icon="pi pi-save" class="rounded-full" @click="saveProduct" />
|
|
</div>
|
|
</template>
|
|
</Dialog>
|
|
|
|
<!-- Dialog: Adicionar Créditos -->
|
|
<Dialog
|
|
v-model:visible="creditDialog"
|
|
modal
|
|
:draggable="false"
|
|
:closable="true"
|
|
:dismissableMask="true"
|
|
maximizable
|
|
class="dc-dialog w-[36rem]"
|
|
: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">Adicionar Créditos</div>
|
|
<div class="text-xs opacity-50">Creditar recursos extras para um tenant</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<div class="flex flex-col gap-3">
|
|
<div class="flex flex-col gap-1">
|
|
<label class="font-medium text-sm">Selecionar terapeuta / clínica</label>
|
|
<Select v-model="creditForm.tenant_id" :options="tenants" optionLabel="label" optionValue="value" placeholder="Escolha um tenant..." filter :loading="loadingTenants" class="w-full" />
|
|
</div>
|
|
<div class="flex flex-col gap-1">
|
|
<label class="font-medium text-sm">Tipo de recurso</label>
|
|
<Select v-model="creditForm.addon_type" :options="addonTypes" optionLabel="label" optionValue="value" class="w-full" />
|
|
</div>
|
|
<div class="flex flex-col gap-1">
|
|
<label class="font-medium text-sm">Pacote (opcional — preenche automaticamente)</label>
|
|
<Select v-model="creditForm.product_id" :options="productOptions" optionLabel="label" optionValue="value" placeholder="Selecione um pacote ou preencha manualmente" showClear class="w-full" @change="onProductSelect" />
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<div class="flex flex-col gap-1">
|
|
<label class="font-medium text-sm">Quantidade de créditos</label>
|
|
<InputNumber v-model="creditForm.amount" :min="1" class="w-full" />
|
|
</div>
|
|
<div class="flex flex-col gap-1">
|
|
<label class="font-medium text-sm">Valor pago (R$)</label>
|
|
<InputNumber v-model="creditForm.price_reais" :min="0" :minFractionDigits="2" :maxFractionDigits="2" mode="currency" currency="BRL" locale="pt-BR" class="w-full" />
|
|
</div>
|
|
</div>
|
|
<div class="flex flex-col gap-1">
|
|
<label class="font-medium text-sm">Descrição</label>
|
|
<InputText v-model="creditForm.description" placeholder="Crédito manual" class="w-full" />
|
|
</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" @click="creditDialog = false" />
|
|
<Button label="Creditar" icon="pi pi-plus" class="rounded-full" @click="addCredit" />
|
|
</div>
|
|
</template>
|
|
</Dialog>
|
|
|
|
<!-- Dialog: Novo/Editar pacote WhatsApp -->
|
|
<Dialog
|
|
v-model:visible="waPkgDialog"
|
|
modal
|
|
:draggable="false"
|
|
:closable="true"
|
|
:dismissableMask="true"
|
|
class="dc-dialog w-[32rem]"
|
|
: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' }
|
|
}"
|
|
pt:mask:class="backdrop-blur-xs"
|
|
>
|
|
<template #header>
|
|
<div class="flex w-full items-center gap-3 px-1">
|
|
<i class="pi pi-whatsapp text-emerald-500 text-xl" />
|
|
<div class="min-w-0">
|
|
<div class="text-base font-semibold truncate">{{ waEditingPkgId ? 'Editar pacote' : 'Novo pacote WhatsApp' }}</div>
|
|
<div class="text-xs opacity-50">Créditos consumidos no canal AgenciaPSI Oficial (Twilio)</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<div class="flex flex-col gap-3">
|
|
<div class="flex flex-col gap-1">
|
|
<label class="font-medium text-sm">Nome *</label>
|
|
<InputText v-model="waPkgForm.name" maxlength="100" placeholder="Ex: Pacote Mensal 500" class="w-full" />
|
|
</div>
|
|
<div class="flex flex-col gap-1">
|
|
<label class="font-medium text-sm">Descrição</label>
|
|
<InputText v-model="waPkgForm.description" maxlength="500" placeholder="Ex: Ideal pra clínicas pequenas" class="w-full" />
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<div class="flex flex-col gap-1">
|
|
<label class="font-medium text-sm">Créditos *</label>
|
|
<InputNumber v-model="waPkgForm.credits" :min="1" :max="100000" fluid />
|
|
</div>
|
|
<div class="flex flex-col gap-1">
|
|
<label class="font-medium text-sm">Preço *</label>
|
|
<InputNumber v-model="waPkgForm.price_brl" mode="currency" currency="BRL" locale="pt-BR" :min="0.01" :maxFractionDigits="2" fluid />
|
|
</div>
|
|
</div>
|
|
<div class="grid grid-cols-3 gap-3 items-end">
|
|
<div class="flex items-center gap-2">
|
|
<ToggleSwitch v-model="waPkgForm.is_active" />
|
|
<label class="text-sm">Ativo</label>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<ToggleSwitch v-model="waPkgForm.is_featured" />
|
|
<label class="text-sm">Destaque</label>
|
|
</div>
|
|
<div class="flex flex-col gap-1">
|
|
<label class="text-xs font-medium">Posição</label>
|
|
<InputNumber v-model="waPkgForm.position" :min="0" :max="9999" fluid />
|
|
</div>
|
|
</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" @click="waPkgDialog = false" />
|
|
<Button :label="waEditingPkgId ? 'Salvar' : 'Criar'" icon="pi pi-check" class="rounded-full" @click="saveWaPkg" />
|
|
</div>
|
|
</template>
|
|
</Dialog>
|
|
</div>
|
|
</template> |