first commit

This commit is contained in:
Leonardo
2026-02-18 22:36:45 -03:00
parent ec6b6ef53a
commit 676042268b
122 changed files with 26354 additions and 1615 deletions
+288
View File
@@ -0,0 +1,288 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import Card from 'primevue/card'
import Button from 'primevue/button'
import Tag from 'primevue/tag'
import Toast from 'primevue/toast'
import Chart from 'primevue/chart'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
const router = useRouter()
const toast = useToast()
const loading = ref(false)
const totalActive = ref(0)
const totalCanceled = ref(0)
const totalMismatches = ref(0)
const plans = ref([])
const subs = ref([])
function moneyBRLFromCents (cents) {
const v = Number(cents || 0) / 100
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
}
/**
* MRR = soma de price_cents do plano para subscriptions ativas (intervalo month).
* Se no futuro você tiver anual, dá pra normalizar (annual/12).
*/
const mrrCents = computed(() => {
const planById = new Map(plans.value.map(p => [p.id, p]))
let sum = 0
for (const s of subs.value) {
if (s.status !== 'active') continue
const p = planById.get(s.plan_id)
if (!p) continue
if ((p.billing_interval || 'month') !== 'month') continue
sum += Number(p.price_cents || 0)
}
return sum
})
const arpaCents = computed(() => {
const act = subs.value.filter(s => s.status === 'active').length
return act ? Math.round(mrrCents.value / act) : 0
})
const breakdown = computed(() => {
const planById = new Map(plans.value.map(p => [p.id, p]))
const agg = new Map()
for (const s of subs.value) {
const p = planById.get(s.plan_id)
const key = p?.key || '(sem plano)'
if (!agg.has(key)) {
agg.set(key, {
plan_key: key,
active_count: 0,
canceled_count: 0,
price_cents: Number(p?.price_cents || 0),
mrr_cents: 0
})
}
const row = agg.get(key)
if (s.status === 'active') {
row.active_count += 1
// só soma MRR se for mensal
if ((p?.billing_interval || 'month') === 'month') row.mrr_cents += Number(p?.price_cents || 0)
} else if (s.status === 'canceled') {
row.canceled_count += 1
}
}
return Array.from(agg.values()).sort((a, b) => (b.mrr_cents - a.mrr_cents))
})
const chartData = computed(() => {
const labels = breakdown.value.map(r => r.plan_key)
const data = breakdown.value.map(r => Math.round((r.mrr_cents || 0) / 100))
return {
labels,
datasets: [{ label: 'MRR por plano (R$)', data }]
}
})
const chartOptions = computed(() => ({
maintainAspectRatio: false,
plugins: {
legend: { display: true }
},
scales: {
y: { beginAtZero: true }
}
}))
async function loadStats () {
loading.value = true
try {
const [{ data: p, error: ep }, { data: s, error: es }] = await Promise.all([
supabase.from('plans').select('id,key,price_cents,currency,billing_interval').order('key', { ascending: true }),
supabase.from('subscriptions').select('id,tenant_id,plan_id,status,updated_at').order('updated_at', { ascending: false })
])
if (ep) throw ep
if (es) throw es
plans.value = p || []
subs.value = s || []
totalActive.value = subs.value.filter(x => x.status === 'active').length
totalCanceled.value = subs.value.filter(x => x.status === 'canceled').length
const { data: mismatches, error: em } = await supabase
.from('v_subscription_feature_mismatch')
.select('*')
if (em) throw em
totalMismatches.value = (mismatches || []).length
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
} finally {
loading.value = false
}
}
async function fixAll () {
loading.value = true
try {
const { error } = await supabase.rpc('fix_all_subscription_mismatches')
if (error) throw error
toast.add({
severity: 'success',
summary: 'Sistema corrigido',
detail: 'Entitlements reconstruídos com sucesso.',
life: 3000
})
await loadStats()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
} finally {
loading.value = false
}
}
onMounted(loadStats)
</script>
<template>
<Toast />
<div class="p-4">
<div class="flex items-center justify-content-between mb-4">
<div>
<div class="text-2xl font-semibold">SaaS Control Center</div>
<small class="text-color-secondary">Visão estratégica + saúde de consistência.</small>
</div>
<div class="flex gap-2">
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined :loading="loading" @click="loadStats" />
<Button label="Subscriptions" icon="pi pi-credit-card" severity="secondary" outlined @click="router.push('/saas/subscriptions')" />
<Button label="Histórico" icon="pi pi-history" severity="secondary" outlined @click="router.push('/saas/subscription-events')" />
</div>
</div>
<!-- KPIs -->
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 md:col-span-3">
<Card>
<template #title>Ativas</template>
<template #content>
<div class="text-4xl font-bold text-green-500">{{ totalActive }}</div>
</template>
</Card>
</div>
<div class="col-span-12 md:col-span-3">
<Card>
<template #title>Canceladas</template>
<template #content>
<div class="text-4xl font-bold text-red-400">{{ totalCanceled }}</div>
</template>
</Card>
</div>
<div class="col-span-12 md:col-span-3">
<Card>
<template #title>MRR</template>
<template #content>
<div class="text-3xl font-bold">{{ moneyBRLFromCents(mrrCents) }}</div>
<small class="text-color-secondary">somente planos mensais</small>
</template>
</Card>
</div>
<div class="col-span-12 md:col-span-3">
<Card>
<template #title>ARPA</template>
<template #content>
<div class="text-3xl font-bold">{{ moneyBRLFromCents(arpaCents) }}</div>
<small class="text-color-secondary">receita média por ativa</small>
</template>
</Card>
</div>
</div>
<!-- Health + Actions -->
<div class="grid grid-cols-12 gap-4 mt-4">
<div class="col-span-12 md:col-span-4">
<Card>
<template #title>System Health</template>
<template #content>
<div class="flex items-center justify-content-between">
<div class="text-4xl font-bold" :class="totalMismatches > 0 ? 'text-red-500' : 'text-green-500'">
{{ totalMismatches }}
</div>
<Tag
:severity="totalMismatches > 0 ? 'danger' : 'success'"
:value="totalMismatches > 0 ? 'Inconsistências' : 'Saudável'"
/>
</div>
<div class="mt-3 flex gap-2 flex-wrap">
<Button
v-if="totalMismatches > 0"
label="Fix All"
icon="pi pi-refresh"
severity="danger"
:loading="loading"
@click="fixAll"
/>
<Button
label="Ver divergências"
icon="pi pi-search"
severity="secondary"
outlined
@click="router.push('/saas/subscription-health')"
/>
</div>
</template>
</Card>
</div>
<div class="col-span-12 md:col-span-8">
<Card>
<template #title>MRR por plano</template>
<template #content>
<div style="height: 260px;">
<Chart type="bar" :data="chartData" :options="chartOptions" />
</div>
</template>
</Card>
</div>
</div>
<!-- Breakdown table -->
<div class="mt-4">
<Card>
<template #title>Distribuição por plano</template>
<template #content>
<DataTable :value="breakdown" stripedRows responsiveLayout="scroll">
<Column field="plan_key" header="Plano" style="min-width: 12rem" />
<Column header="Ativas" style="width: 8rem">
<template #body="{ data }">{{ data.active_count }}</template>
</Column>
<Column header="Canceladas" style="width: 10rem">
<template #body="{ data }">{{ data.canceled_count }}</template>
</Column>
<Column header="Preço" style="min-width: 12rem">
<template #body="{ data }">{{ moneyBRLFromCents(data.price_cents) }}</template>
</Column>
<Column header="MRR" style="min-width: 12rem">
<template #body="{ data }">{{ moneyBRLFromCents(data.mrr_cents) }}</template>
</Column>
</DataTable>
</template>
</Card>
</div>
</div>
</template>
+271
View File
@@ -0,0 +1,271 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { supabase } from '@/lib/supabase/client'
import Toolbar from 'primevue/toolbar'
import Button from 'primevue/button'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Textarea from 'primevue/textarea'
import Toast from 'primevue/toast'
import ConfirmDialog from 'primevue/confirmdialog'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
const toast = useToast()
const confirm = useConfirm()
const loading = ref(false)
const rows = ref([])
const showDlg = ref(false)
const saving = ref(false)
const isEdit = ref(false)
const form = ref({
id: null,
key: '',
descricao: ''
})
const hasCreatedAt = computed(() => rows.value?.length && 'created_at' in rows.value[0])
async function fetchAll () {
loading.value = true
const { data, error } = await supabase
.from('features')
.select('*')
.order('key', { ascending: true })
loading.value = false
if (error) {
toast.add({ severity: 'error', summary: 'Erro', detail: error.message, life: 4500 })
return
}
rows.value = data || []
}
function openCreate () {
isEdit.value = false
form.value = { id: null, key: '', descricao: '' }
showDlg.value = true
}
function openEdit (row) {
isEdit.value = true
form.value = {
id: row.id,
key: row.key ?? '',
descricao: row.descricao ?? ''
}
showDlg.value = true
}
function validate () {
const k = String(form.value.key || '').trim()
if (!k) {
toast.add({
severity: 'warn',
summary: 'Atenção',
detail: 'Informe a key da feature.',
life: 3000
})
return false
}
const d = String(form.value.descricao || '').trim()
// 🔒 evita duplicidade no frontend (exceto no próprio registro em edição)
const exists = rows.value.some(r =>
String(r.key).trim() === k && r.id !== form.value.id
)
if (exists) {
toast.add({
severity: 'warn',
summary: 'Key já existente',
detail: 'Já existe uma feature com essa key. Use outra.',
life: 3000
})
return false
}
form.value.key = k
form.value.descricao = d
return true
}
function isUniqueViolation (err) {
if (!err) return false
if (err.code === '23505') return true
const msg = String(err.message || '')
return msg.includes('duplicate key value') || msg.includes('unique constraint')
}
async function save () {
if (!validate()) return
saving.value = true
try {
if (isEdit.value) {
const { error } = await supabase
.from('features')
.update({
key: form.value.key,
descricao: form.value.descricao
})
.eq('id', form.value.id)
if (error) throw error
toast.add({ severity: 'success', summary: 'Ok', detail: 'Feature atualizada.', life: 2500 })
} else {
const { error } = await supabase
.from('features')
.insert({
key: form.value.key,
descricao: form.value.descricao
})
if (error) throw error
toast.add({ severity: 'success', summary: 'Ok', detail: 'Feature criada.', life: 2500 })
}
showDlg.value = false
await fetchAll()
} catch (e) {
if (isUniqueViolation(e)) {
toast.add({
severity: 'warn',
summary: 'Key já existente',
detail: 'Já existe uma feature com essa key. Use outra.',
life: 3500
})
} else {
toast.add({
severity: 'error',
summary: 'Erro',
detail: e.message || String(e),
life: 4500
})
}
} finally {
saving.value = false
}
}
function askDelete (row) {
confirm.require({
message: `Excluir a feature "${row.key}"?`,
header: 'Confirmar exclusão',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
accept: () => doDelete(row)
})
}
async function doDelete (row) {
const { error } = await supabase
.from('features')
.delete()
.eq('id', row.id)
if (error) {
toast.add({ severity: 'error', summary: 'Erro', detail: error.message, life: 4500 })
return
}
toast.add({ severity: 'success', summary: 'Ok', detail: 'Feature excluída.', life: 2500 })
await fetchAll()
}
onMounted(fetchAll)
</script>
<template>
<Toast />
<ConfirmDialog />
<div class="p-4">
<Toolbar class="mb-4">
<template #start>
<div class="flex flex-col">
<div class="text-xl font-semibold leading-none">Features</div>
<small class="text-color-secondary mt-1">
Funcionalidades que podem ser ativadas por plano.
</small>
</div>
</template>
<template #end>
<div class="flex items-center gap-2">
<Button label="Atualizar" icon="pi pi-refresh" severity="secondary" outlined :loading="loading" @click="fetchAll" />
<Button label="Adicionar" icon="pi pi-plus" @click="openCreate" />
</div>
</template>
</Toolbar>
<DataTable :value="rows" dataKey="id" :loading="loading" stripedRows responsiveLayout="scroll">
<Column field="key" header="Key" sortable />
<Column field="descricao" header="Descrição" sortable style="min-width: 22rem">
<template #body="{ data }">
<!-- Tooltip opcional: se v-tooltip estiver habilitado no app, vai ficar ótimo.
Se não estiver, é remover o v-tooltip que segue normal. -->
<div
class="max-w-[520px] whitespace-nowrap overflow-hidden text-ellipsis text-color-secondary"
v-tooltip.top="data.descricao || ''"
>
{{ data.descricao || '-' }}
</div>
</template>
</Column>
<Column v-if="hasCreatedAt" field="created_at" header="Criado em" sortable />
<Column header="Ações" style="width: 12rem">
<template #body="{ data }">
<div class="flex gap-2">
<Button icon="pi pi-pencil" severity="secondary" outlined @click="openEdit(data)" />
<Button icon="pi pi-trash" severity="danger" outlined @click="askDelete(data)" />
</div>
</template>
</Column>
</DataTable>
<Dialog v-model:visible="showDlg" modal :header="isEdit ? 'Editar feature' : 'Nova feature'" :style="{ width: '620px' }">
<div class="flex flex-col gap-4">
<div>
<label class="block mb-2">Key</label>
<InputText v-model="form.key" class="w-full" placeholder="ex: online_scheduling.manage" />
</div>
<div>
<label class="block mb-2">Descrição</label>
<Textarea
v-model="form.descricao"
class="w-full"
:autoResize="true"
rows="3"
placeholder="Explique em linguagem humana o que essa feature habilita..."
/>
<small class="text-color-secondary block mt-2">
Dica: isso aparece no admin e ajuda a documentar o que cada feature faz.
</small>
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined @click="showDlg = false" />
<Button :label="isEdit ? 'Salvar' : 'Criar'" icon="pi pi-check" :loading="saving" @click="save" />
</template>
</Dialog>
</div>
</template>
+8
View File
@@ -0,0 +1,8 @@
<template>
<div class="p-4">
<div class="text-xl font-semibold">Em construção</div>
<div class="text-color-secondary mt-2">
Esta área do Admin SaaS ainda será implementada.
</div>
</div>
</template>
@@ -0,0 +1,178 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { supabase } from '@/lib/supabase/client'
import Toolbar from 'primevue/toolbar'
import Button from 'primevue/button'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Checkbox from 'primevue/checkbox'
import Toast from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import InputText from 'primevue/inputtext'
const toast = useToast()
const loading = ref(false)
const plans = ref([])
const features = ref([])
const links = ref([]) // plan_features rows
const q = ref('')
const planById = computed(() => {
const m = new Map()
for (const p of plans.value) m.set(p.id, p)
return m
})
const enabledSet = computed(() => {
// Set de "planId::featureId" para lookup rápido
const s = new Set()
for (const r of links.value) s.add(`${r.plan_id}::${r.feature_id}`)
return s
})
const filteredFeatures = computed(() => {
const term = String(q.value || '').trim().toLowerCase()
if (!term) return features.value
return features.value.filter(f => String(f.key || '').toLowerCase().includes(term))
})
async function fetchAll () {
loading.value = true
try {
const [{ data: p, error: ep }, { data: f, error: ef }, { data: pf, error: epf }] = await Promise.all([
supabase.from('plans').select('*').order('key', { ascending: true }),
supabase.from('features').select('*').order('key', { ascending: true }),
supabase.from('plan_features').select('plan_id, feature_id')
])
if (ep) throw ep
if (ef) throw ef
if (epf) throw epf
plans.value = p || []
features.value = f || []
links.value = pf || []
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
} finally {
loading.value = false
}
}
function isEnabled (planId, featureId) {
return enabledSet.value.has(`${planId}::${featureId}`)
}
async function toggle (planId, featureId, nextValue) {
// otimista (UI responde rápido) — mas com rollback se falhar
const key = `${planId}::${featureId}`
const prev = links.value.slice()
try {
if (nextValue) {
// adiciona
links.value = [...links.value, { plan_id: planId, feature_id: featureId }]
const { error } = await supabase.from('plan_features').insert({ plan_id: planId, feature_id: featureId })
if (error) throw error
} else {
// remove
links.value = links.value.filter(x => !(x.plan_id === planId && x.feature_id === featureId))
const { error } = await supabase
.from('plan_features')
.delete()
.eq('plan_id', planId)
.eq('feature_id', featureId)
if (error) throw error
}
} catch (e) {
links.value = prev
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
return
}
}
onMounted(fetchAll)
</script>
<template>
<Toast />
<div class="p-4">
<Toolbar class="mb-4">
<template #start>
<div class="flex flex-col">
<div class="text-xl font-semibold leading-none">Plan Features</div>
<small class="text-color-secondary mt-1">
Marque quais features pertencem a cada plano. Isso define FREE/PRO sem mexer no código.
</small>
</div>
</template>
<template #end>
<div class="flex items-center gap-2">
<span class="p-input-icon-left">
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText
v-model="q"
id="features_search"
class="w-full pr-10"
variant="filled"
/>
</IconField>
<label for="features_search">Filtrar features</label>
</FloatLabel>
</span>
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined :loading="loading" @click="fetchAll" />
</div>
</template>
</Toolbar>
<DataTable
:value="filteredFeatures"
dataKey="id"
:loading="loading"
stripedRows
responsiveLayout="scroll"
:scrollable="true"
scrollHeight="70vh"
>
<Column header="Feature" frozen style="min-width: 22rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">
{{ data.key }}
</span>
<small class="text-color-secondary leading-snug mt-1">
{{ data.descricao || '—' }}
</small>
</div>
</template>
</Column>
<Column
v-for="p in plans"
:key="p.id"
:header="p.key"
:style="{ minWidth: '10rem' }"
>
<template #body="{ data }">
<div class="flex justify-center">
<Checkbox
:binary="true"
:modelValue="isEnabled(p.id, data.id)"
@update:modelValue="(val) => toggle(p.id, data.id, val)"
/>
</div>
</template>
</Column>
</DataTable>
</div>
</template>
+412
View File
@@ -0,0 +1,412 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { supabase } from '@/lib/supabase/client'
import Toolbar from 'primevue/toolbar'
import Button from 'primevue/button'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import InputNumber from 'primevue/inputnumber'
import Toast from 'primevue/toast'
import ConfirmDialog from 'primevue/confirmdialog'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
const toast = useToast()
const confirm = useConfirm()
const loading = ref(false)
const rows = ref([])
const showDlg = ref(false)
const saving = ref(false)
const isEdit = ref(false)
const form = ref({
id: null,
key: '',
name: '',
price_monthly: null, // em R$ (UI)
price_yearly: null // em R$ (UI)
})
const hasCreatedAt = computed(() => rows.value?.length && 'created_at' in rows.value[0])
const isSystemKeyLocked = computed(() => {
const k = String(form.value.key || '').trim().toLowerCase()
return isEdit.value && (k === 'free' || k === 'pro')
})
function isUniqueViolation (err) {
if (!err) return false
if (err.code === '23505') return true
const msg = String(err.message || '')
return msg.includes('duplicate key value') || msg.includes('unique constraint')
}
// slug técnico para key (sem acento, sem espaço, lowercase, só [a-z0-9_])
function slugifyKey (s) {
return String(s || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim()
.replace(/\s+/g, '_')
.replace(/[^a-z0-9_]/g, '')
}
function formatBRLFromCents (cents) {
if (cents == null) return '—'
const v = Number(cents) / 100
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
}
function toCents (valueReais) {
if (valueReais == null || valueReais === '') return null
const n = Number(valueReais)
if (Number.isNaN(n)) return null
return Math.round(n * 100)
}
function fromCentsToReais (cents) {
if (cents == null) return null
return Number(cents) / 100
}
async function fetchAll () {
loading.value = true
try {
// 1) planos
const { data: p, error: ep } = await supabase.from('plans').select('*').order('key', { ascending: true })
if (ep) throw ep
// 2) preços ativos (view)
const { data: ap, error: eap } = await supabase.from('v_plan_active_prices').select('*')
if (eap) throw eap
const priceMap = new Map()
for (const r of (ap || [])) priceMap.set(r.plan_id, r)
rows.value = (p || []).map(plan => {
const pr = priceMap.get(plan.id) || {}
return {
...plan,
monthly_cents: pr.monthly_cents ?? null,
yearly_cents: pr.yearly_cents ?? null
}
})
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 4500 })
} finally {
loading.value = false
}
}
function openEdit (row) {
isEdit.value = true
form.value = {
id: row.id,
key: row.key ?? '',
name: row.name ?? '',
price_monthly: fromCentsToReais(row.monthly_cents),
price_yearly: fromCentsToReais(row.yearly_cents)
}
showDlg.value = true
}
function openCreate () {
isEdit.value = false
form.value = {
id: null,
key: '',
name: '',
price_monthly: null,
price_yearly: null
}
showDlg.value = true
}
function validate () {
const k = slugifyKey(form.value.key)
if (!k) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Informe a key do plano.', life: 3000 })
return false
}
const n = String(form.value.name || '').trim()
if (!n) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Informe o nome do plano.', life: 3000 })
return false
}
// preços opcionais — se vierem preenchidos, não podem ser negativos.
const m = form.value.price_monthly
const y = form.value.price_yearly
if (m != null && Number(m) < 0) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Preço mensal não pode ser negativo.', life: 3000 })
return false
}
if (y != null && Number(y) < 0) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Preço anual não pode ser negativo.', life: 3000 })
return false
}
// 🔒 evita key duplicada no frontend (case-insensitive)
const exists = rows.value.some(r => String(r.key || '').trim().toLowerCase() === k && r.id !== form.value.id)
if (exists) {
toast.add({
severity: 'warn',
summary: 'Key já existente',
detail: 'Já existe um plano com essa key. Use outra.',
life: 3000
})
return false
}
form.value.key = k
form.value.name = n
return true
}
async function upsertPlanPrice ({ planId, interval, nextCents, prevCents }) {
const same = (prevCents == null && nextCents == null) || (Number(prevCents) === Number(nextCents))
if (same) return
const nowIso = new Date().toISOString()
// se admin apagou o preço: desativa o ativo
if (nextCents == null) {
const { error } = await supabase
.from('plan_prices')
.update({ is_active: false, active_to: nowIso })
.eq('plan_id', planId)
.eq('interval', interval)
.eq('is_active', true)
if (error) throw error
return
}
// fecha ativo atual (se existir)
if (prevCents != null) {
const { error: eClose } = await supabase
.from('plan_prices')
.update({ is_active: false, active_to: nowIso })
.eq('plan_id', planId)
.eq('interval', interval)
.eq('is_active', true)
if (eClose) throw eClose
}
// cria novo preço ativo
const { error: eIns } = await supabase.from('plan_prices').insert({
plan_id: planId,
interval,
amount_cents: nextCents,
currency: 'BRL',
is_active: true,
active_from: nowIso,
source: 'manual'
})
if (eIns) throw eIns
}
async function save () {
if (!validate()) return
saving.value = true
try {
let planId = form.value.id
if (isEdit.value) {
const { error } = await supabase.from('plans').update({ key: form.value.key, name: form.value.name }).eq('id', form.value.id)
if (error) throw error
} else {
const { data, error } = await supabase.from('plans').insert({ key: form.value.key, name: form.value.name }).select('id').single()
if (error) throw error
planId = data.id
}
// preços atuais (da listagem)
const currentRow = rows.value.find(r => r.id === planId)
const prevMonthly = currentRow?.monthly_cents ?? null
const prevYearly = currentRow?.yearly_cents ?? null
const nextMonthly = toCents(form.value.price_monthly)
const nextYearly = toCents(form.value.price_yearly)
await upsertPlanPrice({ planId, interval: 'month', nextCents: nextMonthly, prevCents: prevMonthly })
await upsertPlanPrice({ planId, interval: 'year', nextCents: nextYearly, prevCents: prevYearly })
toast.add({
severity: 'success',
summary: 'Ok',
detail: isEdit.value ? 'Plano atualizado.' : 'Plano criado.',
life: 2500
})
showDlg.value = false
await fetchAll()
} catch (e) {
if (isUniqueViolation(e)) {
toast.add({
severity: 'warn',
summary: 'Key já existente',
detail: 'Já existe um plano com essa key. Use outra.',
life: 3500
})
} else {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 4500 })
}
} finally {
saving.value = false
}
}
function askDelete (row) {
confirm.require({
message: `Excluir o plano "${row.key}"?`,
header: 'Confirmar exclusão',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
accept: () => doDelete(row)
})
}
async function doDelete (row) {
const { error } = await supabase.from('plans').delete().eq('id', row.id)
if (error) {
toast.add({ severity: 'error', summary: 'Erro', detail: error.message, life: 4500 })
return
}
toast.add({ severity: 'success', summary: 'Ok', detail: 'Plano excluído.', life: 2500 })
await fetchAll()
}
onMounted(fetchAll)
</script>
<template>
<Toast />
<ConfirmDialog />
<div class="p-4">
<Toolbar class="mb-4">
<template #start>
<div class="flex flex-col">
<div class="text-xl font-semibold leading-none">Plans</div>
<small class="text-color-secondary mt-1">
Catálogo de planos do SaaS. A <b>key</b> é a referência estável usada no sistema.
</small>
</div>
</template>
<template #end>
<div class="flex items-center gap-2">
<Button label="Atualizar" icon="pi pi-refresh" severity="secondary" outlined :loading="loading" @click="fetchAll" />
<Button label="Adicionar" icon="pi pi-plus" @click="openCreate" />
</div>
</template>
</Toolbar>
<DataTable :value="rows" dataKey="id" :loading="loading" stripedRows responsiveLayout="scroll">
<Column field="name" header="Nome" sortable style="min-width: 14rem" />
<Column field="key" header="Key" sortable />
<Column header="Mensal" sortable style="width: 12rem">
<template #body="{ data }">
<span class="font-medium">
{{ formatBRLFromCents(data.monthly_cents) }}
</span>
</template>
</Column>
<Column header="Anual" sortable style="width: 12rem">
<template #body="{ data }">
<span class="font-medium">
{{ formatBRLFromCents(data.yearly_cents) }}
</span>
</template>
</Column>
<Column v-if="hasCreatedAt" field="created_at" header="Criado em" sortable />
<Column header="Ações" style="width: 12rem">
<template #body="{ data }">
<div class="flex gap-2">
<Button icon="pi pi-pencil" severity="secondary" outlined @click="openEdit(data)" />
<Button icon="pi pi-trash" severity="danger" outlined @click="askDelete(data)" />
</div>
</template>
</Column>
</DataTable>
<Dialog v-model:visible="showDlg" modal :header="isEdit ? 'Editar plano' : 'Novo plano'" :style="{ width: '620px' }">
<div class="flex flex-col gap-4">
<div>
<label class="block mb-2">Key</label>
<InputText
v-model="form.key"
class="w-full"
placeholder="ex.: free, pro, clinic_pro"
:disabled="isSystemKeyLocked"
/>
<small class="text-color-secondary">
A key é técnica e estável (slug). Ex.: "Clínica Pro" vira "clinica_pro".
{{ isSystemKeyLocked ? ' Este plano é do sistema (key travada).' : '' }}
</small>
</div>
<div>
<label class="block mb-2">Nome do plano</label>
<InputText v-model="form.name" class="w-full" placeholder="ex.: Free, Pro, Clínica Pro" />
<small class="text-color-secondary">Nome interno para o admin. (O nome público vem depois.)</small>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block mb-2">Preço mensal (R$)</label>
<InputNumber
v-model="form.price_monthly"
class="w-full"
inputClass="w-full"
mode="decimal"
:minFractionDigits="2"
:maxFractionDigits="2"
placeholder="ex.: 39,90"
/>
<small class="text-color-secondary">Deixe vazio para sem preço definido.</small>
</div>
<div>
<label class="block mb-2">Preço anual (R$)</label>
<InputNumber
v-model="form.price_yearly"
class="w-full"
inputClass="w-full"
mode="decimal"
:minFractionDigits="2"
:maxFractionDigits="2"
placeholder="ex.: 399,90"
/>
<small class="text-color-secondary">Deixe vazio para sem preço definido.</small>
</div>
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined @click="showDlg = false" />
<Button :label="isEdit ? 'Salvar' : 'Criar'" icon="pi pi-check" :loading="saving" @click="save" />
</template>
</Dialog>
</div>
</template>
@@ -0,0 +1,637 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { supabase } from '@/lib/supabase/client'
import Toolbar from 'primevue/toolbar'
import Button from 'primevue/button'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Textarea from 'primevue/textarea'
import InputNumber from 'primevue/inputnumber'
import Checkbox from 'primevue/checkbox'
import Toast from 'primevue/toast'
import ConfirmDialog from 'primevue/confirmdialog'
import Popover from 'primevue/popover'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import Avatar from 'primevue/avatar'
import AvatarGroup from 'primevue/avatargroup'
import Divider from 'primevue/divider'
import Badge from 'primevue/badge'
const billingInterval = ref('month') // 'month' | 'year'
function priceCentsFor (p, interval) {
return interval === 'year' ? p.yearly_cents : p.monthly_cents
}
// busca com FloatLabel (padrão)
import FloatLabel from 'primevue/floatlabel'
import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'
const toast = useToast()
const confirm = useConfirm()
const loading = ref(false)
const rows = ref([])
const q = ref('')
const showDlg = ref(false)
const saving = ref(false)
const showBulletDlg = ref(false)
const bulletSaving = ref(false)
const bulletIsEdit = ref(false)
const selected = ref(null)
const form = ref({
plan_id: null,
public_name: '',
public_description: '',
badge: '',
is_featured: false,
is_visible: true,
sort_order: 0
})
const bullets = ref([])
const bulletForm = ref({
id: null,
text: '',
sort_order: 0,
highlight: false
})
/* popover bullets (na tabela) */
const bulletsPop = ref(null)
const popPlanTitle = ref('')
const popBullets = ref([])
function openBulletsPopover (event, row) {
popPlanTitle.value = row.public_name || row.plan_name || row.plan_key || 'Benefícios'
popBullets.value = Array.isArray(row.bullets) ? row.bullets : []
bulletsPop.value?.toggle(event)
}
function formatBRLFromCents (cents) {
if (cents == null) return '—'
const v = Number(cents) / 100
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
}
const filteredRows = computed(() => {
const term = String(q.value || '').trim().toLowerCase()
if (!term) return rows.value
return rows.value.filter(r => {
const a = String(r.public_name || '').toLowerCase()
const b = String(r.plan_key || '').toLowerCase()
const c = String(r.plan_name || '').toLowerCase()
return a.includes(term) || b.includes(term) || c.includes(term)
})
})
const previewPlans = computed(() => {
// preview: só visíveis, ordenados. Featured primeiro dentro da mesma ordem.
const list = (rows.value || [])
.filter(r => r.is_visible !== false)
.slice()
.sort((a, b) => {
const ao = Number(a.sort_order ?? 0)
const bo = Number(b.sort_order ?? 0)
if (ao !== bo) return ao - bo
const af = a.is_featured ? 0 : 1
const bf = b.is_featured ? 0 : 1
if (af !== bf) return af - bf
return String(a.plan_key || '').localeCompare(String(b.plan_key || ''))
})
return list
})
async function fetchAll () {
loading.value = true
try {
const { data, error } = await supabase
.from('v_public_pricing')
.select('*')
if (error) throw error
rows.value = data || []
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 4500 })
} finally {
loading.value = false
}
}
async function openEdit (row) {
selected.value = row
form.value = {
plan_id: row.plan_id,
public_name: row.public_name || row.plan_name || row.plan_key || '',
public_description: row.public_description || '',
badge: row.badge || '',
is_featured: !!row.is_featured,
is_visible: row.is_visible !== false,
sort_order: Number(row.sort_order ?? 0)
}
await fetchBullets(row.plan_id)
showDlg.value = true
}
async function fetchBullets (planId) {
const { data, error } = await supabase
.from('plan_public_bullets')
.select('*')
.eq('plan_id', planId)
.order('sort_order', { ascending: true })
.order('created_at', { ascending: true })
if (error) {
toast.add({ severity: 'error', summary: 'Erro', detail: error.message, life: 4500 })
bullets.value = []
return
}
bullets.value = data || []
}
function validate () {
const name = String(form.value.public_name || '').trim()
if (!name) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Informe o nome público do plano.', life: 3000 })
return false
}
form.value.public_name = name
form.value.public_description = String(form.value.public_description || '').trim()
form.value.badge = String(form.value.badge || '').trim() || null
form.value.sort_order = Number(form.value.sort_order ?? 0)
return true
}
async function save () {
if (!validate()) return
saving.value = true
try {
const payload = { ...form.value }
const { error } = await supabase
.from('plan_public')
.upsert(payload, { onConflict: 'plan_id' })
if (error) throw error
toast.add({ severity: 'success', summary: 'Ok', detail: 'Vitrine atualizada.', life: 2500 })
showDlg.value = false
await fetchAll()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 4500 })
} finally {
saving.value = false
}
}
/* ---------- bullets (CRUD no dialog) ---------- */
function openBulletCreate () {
bulletIsEdit.value = false
bulletForm.value = { id: null, text: '', sort_order: (bullets.value?.length || 0) + 1, highlight: false }
showBulletDlg.value = true
}
function openBulletEdit (row) {
bulletIsEdit.value = true
bulletForm.value = {
id: row.id,
text: row.text ?? '',
sort_order: Number(row.sort_order ?? 0),
highlight: !!row.highlight
}
showBulletDlg.value = true
}
function validateBullet () {
const t = String(bulletForm.value.text || '').trim()
if (!t) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Informe o texto do benefício.', life: 3000 })
return false
}
bulletForm.value.text = t
bulletForm.value.sort_order = Number(bulletForm.value.sort_order ?? 0)
return true
}
async function saveBullet () {
if (!selected.value?.plan_id) return
if (!validateBullet()) return
bulletSaving.value = true
try {
const payload = {
plan_id: selected.value.plan_id,
text: bulletForm.value.text,
sort_order: bulletForm.value.sort_order,
highlight: !!bulletForm.value.highlight
}
if (bulletIsEdit.value) {
const { error } = await supabase
.from('plan_public_bullets')
.update(payload)
.eq('id', bulletForm.value.id)
if (error) throw error
toast.add({ severity: 'success', summary: 'Ok', detail: 'Benefício atualizado.', life: 2200 })
} else {
const { error } = await supabase
.from('plan_public_bullets')
.insert(payload)
if (error) throw error
toast.add({ severity: 'success', summary: 'Ok', detail: 'Benefício adicionado.', life: 2200 })
}
showBulletDlg.value = false
await fetchBullets(selected.value.plan_id)
await fetchAll()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 4500 })
} finally {
bulletSaving.value = false
}
}
function askDeleteBullet (row) {
confirm.require({
message: 'Excluir este benefício?',
header: 'Confirmar exclusão',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
accept: () => doDeleteBullet(row)
})
}
async function doDeleteBullet (row) {
const { error } = await supabase
.from('plan_public_bullets')
.delete()
.eq('id', row.id)
if (error) {
toast.add({ severity: 'error', summary: 'Erro', detail: error.message, life: 4500 })
return
}
toast.add({ severity: 'success', summary: 'Ok', detail: 'Benefício removido.', life: 2200 })
await fetchBullets(selected.value.plan_id)
await fetchAll()
}
onMounted(fetchAll)
</script>
<template>
<Toast />
<ConfirmDialog />
<div class="p-4">
<Toolbar class="mb-4">
<template #start>
<div class="flex flex-col">
<div class="text-xl font-semibold leading-none">Vitrine de Planos</div>
<small class="text-color-secondary mt-1">
Configure como os planos aparecem na página pública (nome, descrição, badge, ordem e benefícios).
</small>
</div>
</template>
<template #end>
<div class="flex items-center gap-2 w-full md:w-auto">
<div class="w-full md:w-80">
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText v-model="q" id="plans_public_search" class="w-full pr-10" variant="filled" />
</IconField>
<label for="plans_public_search">Buscar plano</label>
</FloatLabel>
</div>
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined :loading="loading" @click="fetchAll" />
</div>
</template>
</Toolbar>
<!-- Popover global (reutilizado) -->
<Popover ref="bulletsPop">
<div class="w-[340px] max-w-[80vw]">
<div class="text-sm font-semibold mb-2">{{ popPlanTitle }}</div>
<div v-if="!popBullets?.length" class="text-sm text-color-secondary">
Nenhum benefício configurado.
</div>
<ul v-else class="m-0 pl-4 space-y-2">
<li v-for="b in popBullets" :key="b.id" class="text-sm leading-snug">
<span :class="b.highlight ? 'font-semibold' : ''">
{{ b.text }}
</span>
<small v-if="b.highlight" class="ml-2 text-color-secondary">(destaque)</small>
</li>
</ul>
</div>
</Popover>
<DataTable :value="filteredRows" dataKey="plan_id" :loading="loading" stripedRows responsiveLayout="scroll">
<Column header="Plano" style="min-width: 18rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-semibold">{{ data.public_name || data.plan_name || data.plan_key }}</span>
<small class="text-color-secondary">
{{ data.plan_key }} {{ data.plan_name || '—' }}
</small>
</div>
</template>
</Column>
<Column header="Mensal" style="width: 12rem">
<template #body="{ data }">
<span class="font-medium">{{ formatBRLFromCents(data.monthly_cents) }}</span>
</template>
</Column>
<Column header="Anual" style="width: 12rem">
<template #body="{ data }">
<span class="font-medium">{{ formatBRLFromCents(data.yearly_cents) }}</span>
</template>
</Column>
<Column field="badge" header="Badge" style="min-width: 12rem" />
<Column header="Visível" style="width: 8rem">
<template #body="{ data }">
<span>{{ data.is_visible ? 'Sim' : 'Não' }}</span>
</template>
</Column>
<Column header="Destaque" style="width: 9rem">
<template #body="{ data }">
<span>{{ data.is_featured ? 'Sim' : 'Não' }}</span>
</template>
</Column>
<Column field="sort_order" header="Ordem" style="width: 8rem" />
<Column header="Ações" style="width: 14rem">
<template #body="{ data }">
<div class="flex gap-2 justify-end">
<!-- Popover bullets: ícone + quantidade -->
<Button
severity="secondary"
outlined
size="small"
@click="(e) => openBulletsPopover(e, data)"
>
<i class="pi pi-list mr-2" />
<span class="font-medium">{{ data.bullets?.length || 0 }}</span>
</Button>
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" @click="openEdit(data)" />
</div>
</template>
</Column>
</DataTable>
<!-- PREVIEW PÚBLICO (estilo PrimeBlocks) -->
<div class="mt-10">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-6 md:p-10 overflow-hidden">
<!-- topo "happy customers" + título -->
<div class="flex flex-col items-center text-center">
<div class="flex items-center gap-3 mb-4">
<AvatarGroup>
<Avatar image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-m-1.png" shape="circle" />
<Avatar image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-f-21.png" shape="circle" />
<Avatar image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-f-1.png" shape="circle" />
<Avatar image="https://fqjltiegiezfetthbags.supabase.co/storage/v1/render/image/public/block.images/blocks/avatars/circle/avatar-m-3.png" shape="circle" />
</AvatarGroup>
<Divider layout="vertical" />
<span class="text-sm text-color-secondary font-medium">Happy Customers</span>
</div>
<h2 class="text-3xl md:text-5xl font-semibold leading-tight">
Get a plan and<br />
increase your efficiency
</h2>
<p class="text-color-secondary mt-3 max-w-2xl">
Optimize your workflow and boost productivity by choosing the right plan tailored to your business needs.
</p>
<!-- Toggle Monthly / Yearly -->
<div class="mt-6 inline-flex items-center rounded-xl border border-[var(--surface-border)] bg-[var(--surface-50)] p-1">
<Button
label="Mensal"
size="small"
:severity="billingInterval === 'month' ? 'success' : 'secondary'"
:outlined="billingInterval !== 'month'"
@click="billingInterval = 'month'"
/>
<Button
label="Anual"
size="small"
:severity="billingInterval === 'year' ? 'success' : 'secondary'"
:outlined="billingInterval !== 'year'"
class="ml-1"
@click="billingInterval = 'year'"
/>
</div>
</div>
<!-- cards -->
<div class="mt-10 grid grid-cols-1 md:grid-cols-3 gap-6">
<div
v-for="p in previewPlans"
:key="p.plan_id"
:class="[
'relative rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden',
'shadow-sm transition-transform',
p.is_featured ? 'md:-translate-y-2 md:scale-[1.02] ring-1 ring-emerald-500/25' : ''
]"
>
<!-- franja/topo decorativo (bem leve) -->
<div class="h-3 w-full opacity-40 bg-[var(--surface-100)]" />
<div class="p-6">
<!-- badge do plano -->
<div class="flex items-center justify-between gap-3">
<Badge
:value="p.badge || (p.is_featured ? 'Popular' : '')"
:severity="p.is_featured ? 'success' : 'secondary'"
v-if="p.badge || p.is_featured"
/>
<span class="text-xs text-color-secondary">{{ p.plan_key }}</span>
</div>
<!-- preço -->
<div class="mt-4 text-4xl font-semibold leading-none">
{{ formatBRLFromCents(priceCentsFor(p, billingInterval)) }}
</div>
<p class="text-color-secondary mt-3 min-h-[44px]">
{{ p.public_description || '—' }}
</p>
<Button
class="mt-5 w-full"
:label="p.is_featured ? 'Começar agora!' : 'Selecionar este'"
:severity="p.is_featured ? 'success' : 'secondary'"
:outlined="!p.is_featured"
/>
<!-- divisória pontilhada conceitual -->
<div class="mt-6">
<Divider type="dashed" />
</div>
<!-- bullets -->
<ul v-if="p.bullets?.length" class="mt-4 space-y-2">
<li v-for="b in p.bullets" :key="b.id" class="flex items-start gap-2">
<i class="pi pi-check mt-1 text-sm"></i>
<span :class="['text-sm leading-snug', b.highlight ? 'font-semibold' : '']">
{{ b.text }}
</span>
</li>
</ul>
<div v-else class="mt-4 text-sm text-color-secondary">
Nenhum benefício configurado.
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Dialog principal -->
<Dialog v-model:visible="showDlg" modal header="Editar vitrine" :style="{ width: '820px' }">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="flex flex-col gap-4">
<div>
<label class="block mb-2">Nome público</label>
<InputText v-model="form.public_name" class="w-full" placeholder="ex.: Profissional" />
</div>
<div>
<label class="block mb-2">Descrição pública</label>
<Textarea
v-model="form.public_description"
class="w-full"
:autoResize="true"
rows="3"
placeholder="Uma frase curta e clara..."
/>
</div>
<div>
<label class="block mb-2">Badge</label>
<InputText v-model="form.badge" class="w-full" placeholder="ex.: Mais popular (opcional)" />
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block mb-2">Ordem</label>
<InputNumber v-model="form.sort_order" class="w-full" inputClass="w-full" />
</div>
<div class="flex flex-col gap-3 pt-2">
<div class="flex items-center gap-2">
<Checkbox v-model="form.is_visible" :binary="true" />
<label>Visível no público</label>
</div>
<div class="flex items-center gap-2">
<Checkbox v-model="form.is_featured" :binary="true" />
<label>Destaque</label>
</div>
</div>
</div>
</div>
<!-- bullets -->
<div>
<div class="flex items-center justify-between mb-3">
<div class="font-semibold">Benefícios (bullets)</div>
<Button label="Adicionar" icon="pi pi-plus" size="small" @click="openBulletCreate" />
</div>
<DataTable :value="bullets" dataKey="id" stripedRows responsiveLayout="scroll">
<Column field="text" header="Texto" />
<Column field="sort_order" header="Ordem" style="width: 7rem" />
<Column header="Destaque" style="width: 8rem">
<template #body="{ data }">
<span>{{ data.highlight ? 'Sim' : 'Não' }}</span>
</template>
</Column>
<Column header="Ações" style="width: 9rem">
<template #body="{ data }">
<div class="flex gap-2">
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" @click="openBulletEdit(data)" />
<Button icon="pi pi-trash" severity="danger" outlined size="small" @click="askDeleteBullet(data)" />
</div>
</template>
</Column>
</DataTable>
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined @click="showDlg = false" />
<Button label="Salvar" icon="pi pi-check" :loading="saving" @click="save" />
</template>
</Dialog>
<!-- Dialog bullet -->
<Dialog v-model:visible="showBulletDlg" modal :header="bulletIsEdit ? 'Editar benefício' : 'Novo benefício'" :style="{ width: '560px' }">
<div class="flex flex-col gap-4">
<div>
<label class="block mb-2">Texto</label>
<Textarea
v-model="bulletForm.text"
class="w-full"
:autoResize="true"
rows="3"
placeholder="ex.: Agendamento online com página pública"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block mb-2">Ordem</label>
<InputNumber v-model="bulletForm.sort_order" class="w-full" inputClass="w-full" />
</div>
<div class="flex items-center gap-2 pt-7">
<Checkbox v-model="bulletForm.highlight" :binary="true" />
<label>Destaque</label>
</div>
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined @click="showBulletDlg = false" />
<Button :label="bulletIsEdit ? 'Salvar' : 'Criar'" icon="pi pi-check" :loading="bulletSaving" @click="saveBullet" />
</template>
</Dialog>
</div>
</template>
@@ -0,0 +1,217 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { supabase } from '@/lib/supabase/client'
import Toolbar from 'primevue/toolbar'
import Button from 'primevue/button'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Toast from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import InputText from 'primevue/inputtext'
import Tag from 'primevue/tag'
const route = useRoute()
const toast = useToast()
const loading = ref(false)
const events = ref([])
const plans = ref([])
const q = ref('')
const planKeyById = computed(() => {
const m = new Map()
for (const p of plans.value) m.set(p.id, p.key)
return m
})
function planKey (planId) {
if (!planId) return '—'
return planKeyById.value.get(planId) || planId // fallback pro uuid se não achou
}
function typeLabel (t) {
if (t === 'plan_changed') return 'Plano alterado'
return t || '—'
}
function typeSeverity (t) {
if (t === 'plan_changed') return 'info'
return 'secondary'
}
function formatWhen (iso) {
if (!iso) return '—'
try {
return new Date(iso).toLocaleString('pt-BR')
} catch {
return iso
}
}
const profiles = ref([])
const profileById = computed(() => {
const m = new Map()
for (const p of profiles.value) m.set(p.id, p)
return m
})
function displayUser (userId) {
if (!userId) return '—'
const p = profileById.value.get(userId)
if (!p) return userId
// tenta achar campos comuns sem assumir
const name =
p.nome || p.name || p.full_name || p.display_name || p.username || null
const email =
p.email || p.email_principal || p.user_email || null
if (name && email) return `${name} <${email}>`
if (name) return name
if (email) return email
return userId
}
async function fetchAll () {
loading.value = true
try {
const [{ data: p, error: ep }, { data: e, error: ee }] = await Promise.all([
supabase.from('plans').select('id,key'),
supabase
.from('subscription_events')
.select('*')
.order('created_at', { ascending: false })
.limit(500)
])
if (ep) throw ep
if (ee) throw ee
plans.value = p || []
events.value = e || []
// pega ids únicos para buscar profiles
const ids = new Set()
for (const ev of events.value) {
if (ev.owner_id) ids.add(ev.owner_id)
if (ev.created_by) ids.add(ev.created_by)
}
if (ids.size) {
const { data: pr, error: epr } = await supabase
.from('profiles')
.select('*')
.in('id', Array.from(ids))
// se profiles tiver RLS restrito, pode falhar; aí só cai no fallback UUID
if (!epr) profiles.value = pr || []
} else {
profiles.value = []
}
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: err.message || String(err), life: 5000 })
} finally {
loading.value = false
}
}
const filtered = computed(() => {
const term = String(q.value || '').trim().toLowerCase()
if (!term) return events.value
return events.value.filter(ev => {
const oldKey = planKey(ev.old_plan_id)
const newKey = planKey(ev.new_plan_id)
return (
String(ev.owner_id || '').toLowerCase().includes(term) ||
String(ev.subscription_id || '').toLowerCase().includes(term) ||
String(ev.event_type || '').toLowerCase().includes(term) ||
String(oldKey || '').toLowerCase().includes(term) ||
String(newKey || '').toLowerCase().includes(term)
)
})
})
onMounted(async () => {
const initialQ = route.query?.q
if (typeof initialQ === 'string' && initialQ.trim()) q.value = initialQ.trim()
await fetchAll()
})
</script>
<template>
<Toast />
<div class="p-4">
<Toolbar class="mb-4">
<template #start>
<div class="flex flex-col">
<div class="text-xl font-semibold leading-none">Histórico de Planos</div>
<small class="text-color-secondary mt-1">
Auditoria das mudanças de plano (eventos). Read-only.
</small>
</div>
</template>
<template #end>
<div class="flex items-center gap-2">
<span class="p-input-icon-left">
<i class="pi pi-search" />
<InputText v-model="q" placeholder="Buscar owner, subscription, plano, tipo..." />
</span>
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined :loading="loading" @click="fetchAll" />
</div>
</template>
</Toolbar>
<DataTable :value="filtered" dataKey="id" :loading="loading" stripedRows responsiveLayout="scroll">
<Column header="Quando" style="min-width: 14rem">
<template #body="{ data }">
{{ formatWhen(data.created_at) }}
</template>
</Column>
<Column header="Owner" style="min-width: 22rem">
<template #body="{ data }">
{{ displayUser(data.owner_id) }}
</template>
</Column>
<Column header="Evento" style="min-width: 12rem">
<template #body="{ data }">
<Tag :value="typeLabel(data.event_type)" :severity="typeSeverity(data.event_type)" />
</template>
</Column>
<Column header="De → Para" style="min-width: 18rem">
<template #body="{ data }">
<div class="flex items-center gap-2">
<Tag :value="planKey(data.old_plan_id)" severity="secondary" />
<i class="pi pi-arrow-right text-color-secondary" />
<Tag :value="planKey(data.new_plan_id)" severity="success" />
</div>
</template>
</Column>
<Column field="subscription_id" header="Subscription" style="min-width: 22rem" />
<Column header="Alterado por" style="min-width: 22rem">
<template #body="{ data }">
{{ displayUser(data.created_by) }}
</template>
</Column>
</DataTable>
<div class="text-color-secondary mt-3 text-sm">
Mostrando até 500 eventos mais recentes.
</div>
</div>
</template>
@@ -0,0 +1,231 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { supabase } from '@/lib/supabase/client'
import { useToast } from 'primevue/usetoast'
import Toolbar from 'primevue/toolbar'
import Button from 'primevue/button'
import Toast from 'primevue/toast'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Tag from 'primevue/tag'
import InputText from 'primevue/inputtext'
import Divider from 'primevue/divider'
const router = useRouter()
const toast = useToast()
const loading = ref(false)
const fixing = ref(false)
const fixingOwner = ref(null)
const rows = ref([])
const q = ref('')
const total = computed(() => rows.value.length)
const totalMissing = computed(() => rows.value.filter(r => r.mismatch_type === 'missing_entitlement').length)
const totalUnexpected = computed(() => rows.value.filter(r => r.mismatch_type === 'unexpected_entitlement').length)
function severityForMismatch (t) {
if (t === 'missing_entitlement') return 'danger'
if (t === 'unexpected_entitlement') return 'warning'
return 'secondary'
}
function labelForMismatch (t) {
if (t === 'missing_entitlement') return 'Missing'
if (t === 'unexpected_entitlement') return 'Unexpected'
return t || '-'
}
async function fetchAll () {
loading.value = true
try {
const { data, error } = await supabase
.from('v_subscription_feature_mismatch')
.select('*')
if (error) throw error
rows.value = data || []
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
} finally {
loading.value = false
}
}
function filteredRows () {
const term = String(q.value || '').trim().toLowerCase()
if (!term) return rows.value
return rows.value.filter(r =>
String(r.owner_id || '').toLowerCase().includes(term) ||
String(r.feature_key || '').toLowerCase().includes(term) ||
String(r.mismatch_type || '').toLowerCase().includes(term)
)
}
function openOwnerSubscriptions (ownerId) {
if (!ownerId) return
router.push({ path: '/saas/subscriptions', query: { q: ownerId } })
}
async function fixOwner (ownerId) {
if (!ownerId) return
fixing.value = true
fixingOwner.value = ownerId
try {
const { error } = await supabase.rpc('rebuild_owner_entitlements', {
p_owner_id: ownerId
})
if (error) throw error
toast.add({
severity: 'success',
summary: 'Corrigido',
detail: 'Entitlements reconstruídos para este owner.',
life: 2500
})
await fetchAll()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
} finally {
fixing.value = false
fixingOwner.value = null
}
}
async function fixAll () {
fixing.value = true
try {
const { error } = await supabase.rpc('fix_all_subscription_mismatches')
if (error) throw error
toast.add({
severity: 'success',
summary: 'Sistema corrigido',
detail: 'Entitlements reconstruídos para todos os owners com divergência.',
life: 3000
})
await fetchAll()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
} finally {
fixing.value = false
}
}
onMounted(fetchAll)
</script>
<template>
<Toast />
<div class="p-4">
<Toolbar class="mb-4">
<template #start>
<div class="flex flex-col">
<div class="text-xl font-semibold leading-none">Subscription Health</div>
<small class="text-color-secondary mt-1">
Divergências entre Plano (esperado) e Entitlements (atual). Use Fix para reconstruir.
</small>
</div>
</template>
<template #end>
<div class="flex items-center gap-2 flex-wrap justify-end">
<span class="p-input-icon-left">
<i class="pi pi-search" />
<InputText v-model="q" placeholder="Buscar owner_id, feature_key..." />
</span>
<Button
label="Recarregar"
icon="pi pi-refresh"
severity="secondary"
outlined
:loading="loading"
@click="fetchAll"
/>
<Button
v-if="total > 0"
label="Fix All"
icon="pi pi-refresh"
severity="danger"
:loading="fixing"
@click="fixAll"
/>
</div>
</template>
</Toolbar>
<!-- resumo conceitual -->
<div class="surface-100 border-round p-3 mb-4">
<div class="flex flex-wrap gap-2 items-center justify-content-between">
<div class="flex gap-2 items-center flex-wrap">
<Tag :value="`Total: ${total}`" severity="secondary" />
<Tag :value="`Missing: ${totalMissing}`" severity="danger" />
<Tag :value="`Unexpected: ${totalUnexpected}`" severity="warning" />
</div>
<div class="text-color-secondary text-sm">
Missing = plano exige, mas não está ativo · Unexpected = ativo sem constar no plano
</div>
</div>
</div>
<DataTable
:value="filteredRows()"
dataKey="owner_id"
:loading="loading"
stripedRows
responsiveLayout="scroll"
sortField="owner_id"
:sortOrder="1"
>
<Column field="owner_id" header="Owner" style="min-width: 22rem" />
<Column field="feature_key" header="Feature" style="min-width: 18rem" />
<Column header="Tipo" style="width: 12rem">
<template #body="{ data }">
<Tag :severity="severityForMismatch(data.mismatch_type)" :value="labelForMismatch(data.mismatch_type)" />
</template>
</Column>
<Column header="Ações" style="min-width: 18rem">
<template #body="{ data }">
<div class="flex gap-2 flex-wrap">
<Button
icon="pi pi-external-link"
severity="secondary"
outlined
v-tooltip.top="'Abrir subscriptions deste owner (filtro)'"
@click="openOwnerSubscriptions(data.owner_id)"
/>
<Button
label="Fix owner"
icon="pi pi-wrench"
severity="danger"
outlined
:loading="fixing && fixingOwner === data.owner_id"
@click="fixOwner(data.owner_id)"
/>
</div>
</template>
</Column>
</DataTable>
<Divider class="my-5" />
<div class="text-color-secondary text-sm">
Dica: se você trocar plano e o cliente não refletir de imediato, essa página te mostra exatamente o que ficou divergente.
</div>
</div>
</template>
@@ -0,0 +1,271 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { supabase } from '@/lib/supabase/client'
import Toolbar from 'primevue/toolbar'
import Button from 'primevue/button'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Select from 'primevue/select'
import Toast from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import InputText from 'primevue/inputtext'
import Tag from 'primevue/tag'
const toast = useToast()
const router = useRouter()
const route = useRoute()
const loading = ref(false)
const savingId = ref(null)
const plans = ref([])
const subs = ref([])
const q = ref('')
const isFocused = computed(() => {
return typeof route.query?.q === 'string' && route.query.q.trim().length > 0
})
async function fetchAll () {
loading.value = true
try {
const [{ data: p, error: ep }, { data: s, error: es }] = await Promise.all([
supabase.from('plans').select('*').order('key', { ascending: true }),
supabase.from('subscriptions').select('*').order('updated_at', { ascending: false })
])
if (ep) throw ep
if (es) throw es
plans.value = p || []
subs.value = s || []
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
} finally {
loading.value = false
}
}
function planKey (planId) {
const p = plans.value.find(x => x.id === planId)
return p?.key || '(sem plano)'
}
function severityForPlan (key) {
if (!key) return 'secondary'
if (key === 'free') return 'secondary'
if (key === 'pro') return 'success'
return 'info'
}
async function updatePlan (subRow, nextPlanId) {
const prev = subRow.plan_id
subRow.plan_id = nextPlanId
savingId.value = subRow.id
try {
const { data, error } = await supabase.rpc('change_subscription_plan', {
p_subscription_id: subRow.id,
p_new_plan_id: nextPlanId
})
if (error) throw error
if (data?.plan_id) subRow.plan_id = data.plan_id
toast.add({
severity: 'success',
summary: 'Ok',
detail: 'Plano atualizado (transação + histórico).',
life: 2500
})
} catch (e) {
subRow.plan_id = prev
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
} finally {
savingId.value = null
}
}
async function cancelSubscription(row) {
savingId.value = row.id
try {
const { error } = await supabase.rpc('cancel_subscription', {
p_subscription_id: row.id
})
if (error) throw error
toast.add({ severity: 'success', summary: 'Cancelada', life: 2500 })
await fetchAll()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message })
} finally {
savingId.value = null
}
}
async function reactivateSubscription(row) {
savingId.value = row.id
try {
const { error } = await supabase.rpc('reactivate_subscription', {
p_subscription_id: row.id
})
if (error) throw error
toast.add({ severity: 'success', summary: 'Reativada', life: 2500 })
await fetchAll()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message })
} finally {
savingId.value = null
}
}
function goToSubscriptionsForOwner (ownerId) {
if (!ownerId) return
router.push({ path: '/saas/subscriptions', query: { q: ownerId } })
}
function filteredSubs () {
const term = String(q.value || '').trim().toLowerCase()
if (!term) return subs.value
return subs.value.filter(s =>
String(s.owner_id || '').toLowerCase().includes(term) ||
String(s.status || '').toLowerCase().includes(term) ||
String(planKey(s.plan_id)).toLowerCase().includes(term)
)
}
onMounted(async () => {
const initialQ = route.query?.q
if (typeof initialQ === 'string' && initialQ.trim()) {
q.value = initialQ.trim()
}
await fetchAll()
})
</script>
<template>
<Toast />
<div class="p-4">
<!-- Header foco -->
<div v-if="isFocused" class="mb-3 p-3 surface-100 border-round">
<div class="flex align-items-center justify-content-between">
<div>
<div class="text-lg font-semibold">
Subscription em foco
</div>
<small class="text-color-secondary">
Owner: {{ route.query.q }}
</small>
</div>
<Button
label="Limpar filtro"
icon="pi pi-times"
severity="secondary"
outlined
@click="$router.push('/saas/subscriptions')"
/>
</div>
</div>
<Toolbar class="mb-4">
<template #start>
<div class="flex flex-col">
<div class="text-xl font-semibold leading-none">Subscriptions</div>
<small class="text-color-secondary mt-1">
Painel operacional SaaS.
</small>
</div>
</template>
<template #end>
<div class="flex items-center gap-2">
<span class="p-input-icon-left">
<i class="pi pi-search" />
<InputText v-model="q" placeholder="Buscar por owner_id, status, plano..." />
</span>
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined :loading="loading" @click="fetchAll" />
</div>
</template>
</Toolbar>
<DataTable :value="filteredSubs()" dataKey="id" :loading="loading" stripedRows responsiveLayout="scroll">
<Column field="owner_id" header="Owner" style="min-width: 22rem" />
<Column header="Plano" style="min-width: 14rem">
<template #body="{ data }">
<div class="flex items-center gap-2">
<Tag :value="planKey(data.plan_id)" :severity="severityForPlan(planKey(data.plan_id))" />
<Select
:modelValue="data.plan_id"
:options="plans"
optionLabel="key"
optionValue="id"
placeholder="Selecione..."
class="w-14rem"
:disabled="savingId === data.id"
@update:modelValue="(val) => updatePlan(data, val)"
/>
</div>
</template>
</Column>
<Column header="Período" style="min-width: 16rem">
<template #body="{ data }">
<div>
<div>{{ data.current_period_start || '-' }}</div>
<small class="text-color-secondary">
até {{ data.current_period_end || '-' }}
</small>
</div>
</template>
</Column>
<Column field="status" header="Status" style="width: 10rem" />
<Column field="updated_at" header="Atualizado" style="min-width: 14rem" />
<Column header="Ações" style="min-width: 14rem">
<template #body="{ data }">
<div class="flex gap-2 flex-wrap">
<Button
icon="pi pi-history"
severity="secondary"
outlined
@click="$router.push('/saas/subscription-events?q=' + data.owner_id)"
/>
<Button
v-if="data.status === 'active'"
icon="pi pi-ban"
severity="danger"
outlined
:loading="savingId === data.id"
@click="cancelSubscription(data)"
/>
<Button
v-if="data.status !== 'active'"
icon="pi pi-refresh"
severity="success"
outlined
:loading="savingId === data.id"
@click="reactivateSubscription(data)"
/>
</div>
</template>
</Column>
</DataTable>
</div>
</template>
@@ -0,0 +1,290 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useToast } from 'primevue/usetoast'
import Card from 'primevue/card'
import Button from 'primevue/button'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Tag from 'primevue/tag'
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Dropdown from 'primevue/dropdown'
import FloatLabel from 'primevue/floatlabel'
import Textarea from 'primevue/textarea'
import Divider from 'primevue/divider'
import {
listSubscriptionIntents,
markIntentPaid,
cancelIntent
} from '@/services/subscriptionIntents'
const toast = useToast()
const loading = ref(false)
const rows = ref([])
// filtros (FloatLabel)
const q = ref('')
const status = ref(null)
const planKey = ref(null)
const interval = ref(null)
const statusOptions = [
{ label: 'Novo', value: 'new' },
{ label: 'Aguardando pagamento', value: 'waiting_payment' },
{ label: 'Pago', value: 'paid' },
{ label: 'Cancelado', value: 'canceled' }
]
const intervalOptions = [
{ label: 'Mensal', value: 'month' },
{ label: 'Anual', value: 'year' }
]
const planOptions = computed(() => {
const keys = Array.from(new Set((rows.value || []).map(r => r.plan_key).filter(Boolean)))
return keys.map(k => ({ label: k, value: k }))
})
function moneyBRL(cents) {
if (cents == null) return '—'
return (Number(cents) / 100).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
}
function fmtDate(iso) {
if (!iso) return '—'
const d = new Date(iso)
return d.toLocaleString('pt-BR')
}
function statusSeverity(s) {
if (s === 'paid') return 'success'
if (s === 'new') return 'info'
if (s === 'waiting_payment') return 'warning'
if (s === 'canceled') return 'danger'
return 'secondary'
}
async function refresh() {
loading.value = true
try {
rows.value = await listSubscriptionIntents({
q: q.value,
status: status.value,
planKey: planKey.value,
interval: interval.value
})
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || 'Falha ao carregar.', life: 4000 })
} finally {
loading.value = false
}
}
onMounted(refresh)
// dialog detalhes/ação
const showDialog = ref(false)
const selected = ref(null)
const actionMode = ref(null) // 'paid' | 'canceled'
const notes = ref('')
function openAction(row, mode) {
selected.value = row
actionMode.value = mode
notes.value = ''
showDialog.value = true
}
async function confirmAction() {
if (!selected.value) return
try {
if (actionMode.value === 'paid') {
const res = await markIntentPaid(selected.value.id, notes.value)
const plan = res?.subscription?.plan_key || selected.value.plan_key || '—'
const intervalLabel = (res?.subscription?.interval || selected.value.interval) === 'year'
? 'Anual'
: (res?.subscription?.interval || selected.value.interval) === 'month'
? 'Mensal'
: (res?.subscription?.interval || selected.value.interval)
toast.add({
severity: 'success',
summary: 'OK',
detail: `Pago confirmado. Assinatura ativada: ${plan} (${intervalLabel}).`,
life: 3000
})
} else if (actionMode.value === 'canceled') {
await cancelIntent(selected.value.id, notes.value)
toast.add({
severity: 'warn',
summary: 'OK',
detail: 'Cancelado.',
life: 2500
})
}
showDialog.value = false
await refresh()
} catch (e) {
console.error(e)
toast.add({
severity: 'error',
summary: 'Erro',
detail: e?.message || 'Falha ao atualizar.',
life: 4000
})
}
}
</script>
<template>
<div class="p-4 md:p-6 lg:p-8">
<Card class="overflow-hidden">
<template #content>
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-2xl font-semibold">Intenções de assinatura</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1">
Caixa de entrada do pagamento manual (PIX/boleto). Aqui você marca como pago e avança o fluxo.
</div>
</div>
<div class="flex gap-2">
<Button icon="pi pi-refresh" severity="secondary" outlined label="Atualizar" @click="refresh" :loading="loading" />
</div>
</div>
<Divider class="my-4" />
<!-- filtros (FloatLabel) -->
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 md:col-span-5">
<FloatLabel>
<InputText v-model="q" class="w-full" @keyup.enter="refresh" />
<label>Buscar por email</label>
</FloatLabel>
</div>
<div class="col-span-12 md:col-span-3">
<FloatLabel>
<Dropdown v-model="status" :options="statusOptions" optionLabel="label" optionValue="value" class="w-full" showClear />
<label>Status</label>
</FloatLabel>
</div>
<div class="col-span-12 md:col-span-2">
<FloatLabel>
<Dropdown v-model="interval" :options="intervalOptions" optionLabel="label" optionValue="value" class="w-full" showClear />
<label>Intervalo</label>
</FloatLabel>
</div>
<div class="col-span-12 md:col-span-2">
<FloatLabel>
<Dropdown v-model="planKey" :options="planOptions" optionLabel="label" optionValue="value" class="w-full" showClear />
<label>Plano</label>
</FloatLabel>
</div>
<div class="col-span-12">
<Button label="Aplicar filtros" icon="pi pi-filter" class="w-full md:w-auto" @click="refresh" />
</div>
</div>
<Divider class="my-4" />
<DataTable :value="rows" :loading="loading" paginator :rows="20" class="text-sm">
<Column field="created_at" header="Criado em" :sortable="true">
<template #body="{ data }">{{ fmtDate(data.created_at) }}</template>
</Column>
<Column field="email" header="Email" :sortable="true" />
<Column field="plan_key" header="Plano" :sortable="true" />
<Column field="interval" header="Intervalo" :sortable="true">
<template #body="{ data }">
<span>{{ data.interval === 'year' ? 'Anual' : data.interval === 'month' ? 'Mensal' : data.interval }}</span>
</template>
</Column>
<Column field="amount_cents" header="Valor" :sortable="true" style="width: 160px">
<template #body="{ data }">{{ moneyBRL(data.amount_cents) }}</template>
</Column>
<Column field="status" header="Status" :sortable="true" style="width: 140px">
<template #body="{ data }">
<Tag :severity="statusSeverity(data.status)" :value="data.status" />
</template>
</Column>
<Column header="Ações" style="width: 220px">
<template #body="{ data }">
<div class="flex gap-2">
<Button
label="Pago"
icon="pi pi-check"
size="small"
severity="success"
:disabled="data.status === 'paid'"
@click="openAction(data, 'paid')"
/>
<Button
label="Cancelar"
icon="pi pi-times"
size="small"
severity="danger"
outlined
:disabled="data.status === 'canceled'"
@click="openAction(data, 'canceled')"
/>
</div>
</template>
</Column>
</DataTable>
</template>
</Card>
<Dialog v-model:visible="showDialog" modal header="Confirmar ação" :style="{ width: '520px' }">
<div v-if="selected" class="text-sm">
<div class="mb-3">
<div class="font-semibold">{{ selected.email }}</div>
<div class="text-[var(--text-color-secondary)]">
Plano: {{ selected.plan_key }} Intervalo: {{ selected.interval }} Valor: {{ moneyBRL(selected.amount_cents) }}
</div>
</div>
<FloatLabel class="w-full">
<Textarea v-model="notes" rows="3" class="w-full" autoResize />
<label>Notas (opcional)</label>
</FloatLabel>
<div class="flex justify-end gap-2 mt-4">
<Button label="Voltar" severity="secondary" outlined @click="showDialog = false" />
<Button
v-if="actionMode === 'paid'"
label="Marcar como pago"
icon="pi pi-check"
severity="success"
@click="confirmAction"
/>
<Button
v-else
label="Cancelar"
icon="pi pi-times"
severity="danger"
@click="confirmAction"
/>
</div>
</div>
</Dialog>
</div>
</template>