Correcao Sidebar Classico e Rail, Correcao Layout, Ajuste de Breakpoint para Tailwind, Ajuste AppTopbar, Ajuste Menu PopOver, Recriado Paleta de Cores, Inserido algumas animações leves, Reajuste Cor items NOVOS da tabela, Drawer Ajuda Corrigido no Logout, Whatsapp, sms, email, recursos extras
This commit is contained in:
@@ -0,0 +1,745 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| 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;
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-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)]">{{ 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>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-2xl font-bold m-0">Recursos Extras (Add-ons)</h2>
|
||||
</div>
|
||||
|
||||
<!-- Próximos passos -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Card class="border-l-4" style="border-left-color: var(--p-yellow-500)">
|
||||
<template #content>
|
||||
<div class="flex items-start gap-3">
|
||||
<i class="pi pi-bell text-2xl" style="color: var(--p-yellow-500)" />
|
||||
<div>
|
||||
<h4 class="font-semibold m-0 mb-1">Alerta de saldo baixo</h4>
|
||||
<p class="text-sm text-surface-500 m-0">
|
||||
Próximo passo: Notificar tenants automaticamente quando o saldo de créditos estiver abaixo do limite configurado. O campo <code>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" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Card class="border-l-4" style="border-left-color: var(--p-blue-500)">
|
||||
<template #content>
|
||||
<div class="flex items-start gap-3">
|
||||
<i class="pi pi-credit-card text-2xl" style="color: var(--p-blue-500)" />
|
||||
<div>
|
||||
<h4 class="font-semibold m-0 mb-1">Compra online (Gateway)</h4>
|
||||
<p class="text-sm text-surface-500 m-0">
|
||||
Próximo passo: Integrar gateway de pagamento (PIX, cartão) para que tenants comprem créditos diretamente pela plataforma. Os campos <code>payment_method</code> e <code>payment_reference</code> já estão prontos no
|
||||
banco.
|
||||
</p>
|
||||
<Tag value="Planejado" severity="info" class="mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user