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