first commit
This commit is contained in:
@@ -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>
|
||||
@@ -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, é só 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user