Admin SaaS: ajuste manual de créditos WhatsApp (+/-) com proteção de compras
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>
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
-- ==========================================================================
|
||||
-- Agencia PSI — Migracao: Ajuste manual SaaS admin (WhatsApp credits)
|
||||
-- ==========================================================================
|
||||
-- Criado por: Leonardo Nohama
|
||||
-- Data: 2026-04-23 · Sao Carlos/SP — Brasil
|
||||
--
|
||||
-- Contexto:
|
||||
-- - add_whatsapp_credits (ja existia) so aceita amount > 0, kind fixo.
|
||||
-- - deduct_whatsapp_credits (ja existia) e exclusiva de uso (kind='usage').
|
||||
-- - Faltava fluxo de ajuste manual negativo pelo saas admin com safeguards.
|
||||
--
|
||||
-- Regras de negocio:
|
||||
-- 1. Admin pode ajustar +/- ate |amount| = 1000 por operacao (anti dedo-gordo).
|
||||
-- Pra valores maiores, admin repete a operacao (cada uma fica auditada).
|
||||
-- 2. Ao REMOVER (amount < 0), so pode mexer no "pool cortesia":
|
||||
-- saldo derivado de topup_manual / adjustment / refund.
|
||||
-- Creditos originados de 'purchase' (compras Asaas/PIX) sao intocaveis —
|
||||
-- estorno de compra real tem que ir pelo fluxo financeiro do Asaas.
|
||||
-- 3. Regra de consumo (FIFO cortesia primeiro): usage sempre subtrai do
|
||||
-- pool cortesia antes do pool compra. Formula:
|
||||
-- removable = max(0, sum_topup_like - usage_total)
|
||||
-- protected = balance - removable
|
||||
-- Exemplo: topup 100 + compra 200, usado 50 -> removable=50, protegido=200.
|
||||
-- 4. Exige is_saas_admin() — protege contra chamada de authenticated comum.
|
||||
-- ==========================================================================
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Helper: breakdown pra UI mostrar removivel vs protegido
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.get_whatsapp_removable_balance(p_tenant_id UUID)
|
||||
RETURNS TABLE (
|
||||
balance INT,
|
||||
removable INT,
|
||||
protected_amount INT,
|
||||
topup_net INT,
|
||||
usage_total INT
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_balance INT := 0;
|
||||
v_topup_net INT := 0;
|
||||
v_usage_total INT := 0;
|
||||
v_removable INT := 0;
|
||||
BEGIN
|
||||
IF NOT public.is_saas_admin() THEN
|
||||
RAISE EXCEPTION 'permission_denied';
|
||||
END IF;
|
||||
|
||||
SELECT COALESCE(b.balance, 0) INTO v_balance
|
||||
FROM public.whatsapp_credits_balance b
|
||||
WHERE b.tenant_id = p_tenant_id;
|
||||
|
||||
v_balance := COALESCE(v_balance, 0);
|
||||
|
||||
SELECT COALESCE(SUM(amount), 0) INTO v_topup_net
|
||||
FROM public.whatsapp_credits_transactions
|
||||
WHERE tenant_id = p_tenant_id
|
||||
AND kind IN ('topup_manual', 'adjustment', 'refund');
|
||||
|
||||
SELECT COALESCE(ABS(SUM(amount)), 0) INTO v_usage_total
|
||||
FROM public.whatsapp_credits_transactions
|
||||
WHERE tenant_id = p_tenant_id
|
||||
AND kind = 'usage';
|
||||
|
||||
v_removable := GREATEST(0, v_topup_net - v_usage_total);
|
||||
v_removable := LEAST(v_removable, v_balance);
|
||||
|
||||
RETURN QUERY SELECT
|
||||
v_balance,
|
||||
v_removable,
|
||||
GREATEST(0, v_balance - v_removable),
|
||||
v_topup_net,
|
||||
v_usage_total;
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.get_whatsapp_removable_balance(UUID) FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.get_whatsapp_removable_balance(UUID) TO authenticated, service_role;
|
||||
|
||||
COMMENT ON FUNCTION public.get_whatsapp_removable_balance(UUID) IS
|
||||
'Breakdown do saldo WhatsApp: removivel (pool cortesia restante) vs protegido (compras). Apenas saas_admin.';
|
||||
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- admin_adjust_whatsapp_credits: ajuste manual SaaS admin (+/-)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.admin_adjust_whatsapp_credits(
|
||||
p_tenant_id UUID,
|
||||
p_amount INT, -- com sinal: positivo=adicionar, negativo=remover
|
||||
p_admin_id UUID,
|
||||
p_note TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS INT
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_new_balance INT;
|
||||
v_current_balance INT;
|
||||
v_topup_net INT;
|
||||
v_usage_total INT;
|
||||
v_removable INT;
|
||||
v_clean_note TEXT;
|
||||
BEGIN
|
||||
IF NOT public.is_saas_admin() THEN
|
||||
RAISE EXCEPTION 'permission_denied';
|
||||
END IF;
|
||||
|
||||
IF p_tenant_id IS NULL THEN
|
||||
RAISE EXCEPTION 'tenant_required';
|
||||
END IF;
|
||||
|
||||
IF p_amount IS NULL OR p_amount = 0 THEN
|
||||
RAISE EXCEPTION 'amount_required';
|
||||
END IF;
|
||||
|
||||
IF ABS(p_amount) > 1000 THEN
|
||||
RAISE EXCEPTION 'amount_exceeds_limit_1000';
|
||||
END IF;
|
||||
|
||||
IF p_admin_id IS NULL THEN
|
||||
RAISE EXCEPTION 'admin_id_required';
|
||||
END IF;
|
||||
|
||||
v_clean_note := NULLIF(TRIM(COALESCE(p_note, '')), '');
|
||||
IF v_clean_note IS NOT NULL THEN
|
||||
v_clean_note := LEFT(v_clean_note, 500);
|
||||
END IF;
|
||||
|
||||
IF p_amount > 0 THEN
|
||||
-- ADICIONAR
|
||||
INSERT INTO public.whatsapp_credits_balance (tenant_id, balance)
|
||||
VALUES (p_tenant_id, p_amount)
|
||||
ON CONFLICT (tenant_id) DO UPDATE SET
|
||||
balance = whatsapp_credits_balance.balance + EXCLUDED.balance,
|
||||
low_balance_alerted_at = NULL
|
||||
RETURNING balance INTO v_new_balance;
|
||||
|
||||
ELSE
|
||||
-- REMOVER (amount < 0)
|
||||
SELECT balance INTO v_current_balance
|
||||
FROM public.whatsapp_credits_balance
|
||||
WHERE tenant_id = p_tenant_id
|
||||
FOR UPDATE;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'tenant_has_no_balance';
|
||||
END IF;
|
||||
|
||||
SELECT COALESCE(SUM(amount), 0) INTO v_topup_net
|
||||
FROM public.whatsapp_credits_transactions
|
||||
WHERE tenant_id = p_tenant_id
|
||||
AND kind IN ('topup_manual', 'adjustment', 'refund');
|
||||
|
||||
SELECT COALESCE(ABS(SUM(amount)), 0) INTO v_usage_total
|
||||
FROM public.whatsapp_credits_transactions
|
||||
WHERE tenant_id = p_tenant_id
|
||||
AND kind = 'usage';
|
||||
|
||||
v_removable := GREATEST(0, v_topup_net - v_usage_total);
|
||||
v_removable := LEAST(v_removable, v_current_balance);
|
||||
|
||||
IF ABS(p_amount) > v_removable THEN
|
||||
RAISE EXCEPTION 'cannot_remove_beyond_removable: max=%', v_removable;
|
||||
END IF;
|
||||
|
||||
UPDATE public.whatsapp_credits_balance
|
||||
SET balance = balance + p_amount -- p_amount ja e negativo
|
||||
WHERE tenant_id = p_tenant_id
|
||||
RETURNING balance INTO v_new_balance;
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.whatsapp_credits_transactions
|
||||
(tenant_id, kind, amount, balance_after, admin_id, note)
|
||||
VALUES
|
||||
(p_tenant_id, 'adjustment', p_amount, v_new_balance, p_admin_id, v_clean_note);
|
||||
|
||||
RETURN v_new_balance;
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.admin_adjust_whatsapp_credits(UUID, INT, UUID, TEXT) FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.admin_adjust_whatsapp_credits(UUID, INT, UUID, TEXT) TO authenticated, service_role;
|
||||
|
||||
COMMENT ON FUNCTION public.admin_adjust_whatsapp_credits(UUID, INT, UUID, TEXT) IS
|
||||
'Ajuste manual SaaS admin (+/-). |amount|<=1000 por operacao. Remocao so afeta pool cortesia — purchases sao protegidas. Requer is_saas_admin().';
|
||||
@@ -386,48 +386,130 @@ function deleteWaPkg(row) {
|
||||
});
|
||||
}
|
||||
|
||||
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: !row.is_active }).eq('id', row.id);
|
||||
const { error } = await supabase.from('whatsapp_credit_packages').update({ is_active: true }).eq('id', row.id);
|
||||
if (error) throw error;
|
||||
row.is_active = !row.is_active;
|
||||
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: Topup manual (add_whatsapp_credits RPC)
|
||||
// ABA 5 — WhatsApp: Ajuste manual SaaS admin (admin_adjust_whatsapp_credits RPC)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
const waTopup = ref({
|
||||
tenantId: null,
|
||||
amount: 100,
|
||||
kind: 'topup_manual',
|
||||
mode: 'add', // 'add' ou 'remove'
|
||||
note: ''
|
||||
});
|
||||
|
||||
const waTopupKinds = [
|
||||
{ label: 'Topup manual (cortesia)', value: 'topup_manual' },
|
||||
{ label: 'Ajuste', value: 'adjustment' },
|
||||
{ label: 'Estorno / Refund', value: 'refund' }
|
||||
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; waRecentTopups.value = []; return; }
|
||||
const [{ data: bal }, { data: txs }] = await Promise.all([
|
||||
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')
|
||||
@@ -437,6 +519,7 @@ async function loadWaBalance(tenantId) {
|
||||
.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 || [];
|
||||
}
|
||||
|
||||
@@ -444,37 +527,89 @@ async function onWaTenantChange() {
|
||||
await loadWaBalance(waTopup.value.tenantId);
|
||||
}
|
||||
|
||||
async function submitWaTopup() {
|
||||
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;
|
||||
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: 'Créditos deve ser >= 1', life: 2500 }); return; }
|
||||
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;
|
||||
const { error } = await supabase.rpc('add_whatsapp_credits', {
|
||||
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: amt,
|
||||
p_kind: t.kind,
|
||||
p_purchase_id: null,
|
||||
p_amount: signed,
|
||||
p_admin_id: adminId,
|
||||
p_note: note
|
||||
});
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: `+${amt} créditos`, detail: tenantName(t.tenantId), life: 3000 });
|
||||
|
||||
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) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
|
||||
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' },
|
||||
@@ -517,7 +652,7 @@ onMounted(() => {
|
||||
<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>
|
||||
<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" />
|
||||
@@ -830,7 +965,10 @@ onMounted(() => {
|
||||
</Column>
|
||||
<Column header="Ativo" style="width: 80px">
|
||||
<template #body="{ data }">
|
||||
<ToggleSwitch :modelValue="data.is_active" @update:modelValue="() => toggleWaPkgActive(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">
|
||||
@@ -850,12 +988,13 @@ onMounted(() => {
|
||||
<!-- 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-plus-circle text-sky-500" />
|
||||
<h3 class="text-sm font-bold uppercase tracking-wide m-0">Adicionar créditos WhatsApp a um tenant</h3>
|
||||
<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">
|
||||
Ações: cortesia onboarding, reembolso fora do Asaas, correção de falha técnica. Fica no extrato do tenant
|
||||
com <code>admin_id = você</code> pra auditoria.
|
||||
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">
|
||||
@@ -872,29 +1011,60 @@ onMounted(() => {
|
||||
<div><span class="text-[var(--text-color-secondary)]">Usados:</span> <strong>{{ waTenantBalance.lifetime_used }}</strong></div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex flex-col gap-1 flex-1">
|
||||
<label class="text-xs font-semibold uppercase tracking-wide text-[var(--text-color-secondary)]">Quantidade</label>
|
||||
<InputNumber v-model="waTopup.amount" :min="1" :max="100000" class="w-full" fluid />
|
||||
<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>
|
||||
<div class="flex flex-col gap-1 flex-1">
|
||||
<label class="text-xs font-semibold uppercase tracking-wide text-[var(--text-color-secondary)]">Tipo</label>
|
||||
<Select v-model="waTopup.kind" :options="waTopupKinds" optionLabel="label" optionValue="value" class="w-full" />
|
||||
|
||||
<!-- 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…"
|
||||
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="Adicionar créditos" icon="pi pi-check"
|
||||
<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"
|
||||
:disabled="!waTopup.tenantId || !waTopup.amount || (waTopup.mode === 'remove' && (!waRemovable || waRemovable.removable === 0))"
|
||||
@click="submitWaTopup" />
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user