first commit

This commit is contained in:
Leonardo
2026-02-18 22:36:45 -03:00
parent ec6b6ef53a
commit 676042268b
122 changed files with 26354 additions and 1615 deletions
+412
View File
@@ -0,0 +1,412 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { supabase } from '@/lib/supabase/client'
import Toolbar from 'primevue/toolbar'
import Button from 'primevue/button'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import InputNumber from 'primevue/inputnumber'
import Toast from 'primevue/toast'
import ConfirmDialog from 'primevue/confirmdialog'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
const toast = useToast()
const confirm = useConfirm()
const loading = ref(false)
const rows = ref([])
const showDlg = ref(false)
const saving = ref(false)
const isEdit = ref(false)
const form = ref({
id: null,
key: '',
name: '',
price_monthly: null, // em R$ (UI)
price_yearly: null // em R$ (UI)
})
const hasCreatedAt = computed(() => rows.value?.length && 'created_at' in rows.value[0])
const isSystemKeyLocked = computed(() => {
const k = String(form.value.key || '').trim().toLowerCase()
return isEdit.value && (k === 'free' || k === 'pro')
})
function isUniqueViolation (err) {
if (!err) return false
if (err.code === '23505') return true
const msg = String(err.message || '')
return msg.includes('duplicate key value') || msg.includes('unique constraint')
}
// slug técnico para key (sem acento, sem espaço, lowercase, só [a-z0-9_])
function slugifyKey (s) {
return String(s || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim()
.replace(/\s+/g, '_')
.replace(/[^a-z0-9_]/g, '')
}
function formatBRLFromCents (cents) {
if (cents == null) return '—'
const v = Number(cents) / 100
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
}
function toCents (valueReais) {
if (valueReais == null || valueReais === '') return null
const n = Number(valueReais)
if (Number.isNaN(n)) return null
return Math.round(n * 100)
}
function fromCentsToReais (cents) {
if (cents == null) return null
return Number(cents) / 100
}
async function fetchAll () {
loading.value = true
try {
// 1) planos
const { data: p, error: ep } = await supabase.from('plans').select('*').order('key', { ascending: true })
if (ep) throw ep
// 2) preços ativos (view)
const { data: ap, error: eap } = await supabase.from('v_plan_active_prices').select('*')
if (eap) throw eap
const priceMap = new Map()
for (const r of (ap || [])) priceMap.set(r.plan_id, r)
rows.value = (p || []).map(plan => {
const pr = priceMap.get(plan.id) || {}
return {
...plan,
monthly_cents: pr.monthly_cents ?? null,
yearly_cents: pr.yearly_cents ?? null
}
})
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 4500 })
} finally {
loading.value = false
}
}
function openEdit (row) {
isEdit.value = true
form.value = {
id: row.id,
key: row.key ?? '',
name: row.name ?? '',
price_monthly: fromCentsToReais(row.monthly_cents),
price_yearly: fromCentsToReais(row.yearly_cents)
}
showDlg.value = true
}
function openCreate () {
isEdit.value = false
form.value = {
id: null,
key: '',
name: '',
price_monthly: null,
price_yearly: null
}
showDlg.value = true
}
function validate () {
const k = slugifyKey(form.value.key)
if (!k) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Informe a key do plano.', life: 3000 })
return false
}
const n = String(form.value.name || '').trim()
if (!n) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Informe o nome do plano.', life: 3000 })
return false
}
// preços opcionais — se vierem preenchidos, não podem ser negativos.
const m = form.value.price_monthly
const y = form.value.price_yearly
if (m != null && Number(m) < 0) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Preço mensal não pode ser negativo.', life: 3000 })
return false
}
if (y != null && Number(y) < 0) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Preço anual não pode ser negativo.', life: 3000 })
return false
}
// 🔒 evita key duplicada no frontend (case-insensitive)
const exists = rows.value.some(r => String(r.key || '').trim().toLowerCase() === k && r.id !== form.value.id)
if (exists) {
toast.add({
severity: 'warn',
summary: 'Key já existente',
detail: 'Já existe um plano com essa key. Use outra.',
life: 3000
})
return false
}
form.value.key = k
form.value.name = n
return true
}
async function upsertPlanPrice ({ planId, interval, nextCents, prevCents }) {
const same = (prevCents == null && nextCents == null) || (Number(prevCents) === Number(nextCents))
if (same) return
const nowIso = new Date().toISOString()
// se admin apagou o preço: desativa o ativo
if (nextCents == null) {
const { error } = await supabase
.from('plan_prices')
.update({ is_active: false, active_to: nowIso })
.eq('plan_id', planId)
.eq('interval', interval)
.eq('is_active', true)
if (error) throw error
return
}
// fecha ativo atual (se existir)
if (prevCents != null) {
const { error: eClose } = await supabase
.from('plan_prices')
.update({ is_active: false, active_to: nowIso })
.eq('plan_id', planId)
.eq('interval', interval)
.eq('is_active', true)
if (eClose) throw eClose
}
// cria novo preço ativo
const { error: eIns } = await supabase.from('plan_prices').insert({
plan_id: planId,
interval,
amount_cents: nextCents,
currency: 'BRL',
is_active: true,
active_from: nowIso,
source: 'manual'
})
if (eIns) throw eIns
}
async function save () {
if (!validate()) return
saving.value = true
try {
let planId = form.value.id
if (isEdit.value) {
const { error } = await supabase.from('plans').update({ key: form.value.key, name: form.value.name }).eq('id', form.value.id)
if (error) throw error
} else {
const { data, error } = await supabase.from('plans').insert({ key: form.value.key, name: form.value.name }).select('id').single()
if (error) throw error
planId = data.id
}
// preços atuais (da listagem)
const currentRow = rows.value.find(r => r.id === planId)
const prevMonthly = currentRow?.monthly_cents ?? null
const prevYearly = currentRow?.yearly_cents ?? null
const nextMonthly = toCents(form.value.price_monthly)
const nextYearly = toCents(form.value.price_yearly)
await upsertPlanPrice({ planId, interval: 'month', nextCents: nextMonthly, prevCents: prevMonthly })
await upsertPlanPrice({ planId, interval: 'year', nextCents: nextYearly, prevCents: prevYearly })
toast.add({
severity: 'success',
summary: 'Ok',
detail: isEdit.value ? 'Plano atualizado.' : 'Plano criado.',
life: 2500
})
showDlg.value = false
await fetchAll()
} catch (e) {
if (isUniqueViolation(e)) {
toast.add({
severity: 'warn',
summary: 'Key já existente',
detail: 'Já existe um plano com essa key. Use outra.',
life: 3500
})
} else {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 4500 })
}
} finally {
saving.value = false
}
}
function askDelete (row) {
confirm.require({
message: `Excluir o plano "${row.key}"?`,
header: 'Confirmar exclusão',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
accept: () => doDelete(row)
})
}
async function doDelete (row) {
const { error } = await supabase.from('plans').delete().eq('id', row.id)
if (error) {
toast.add({ severity: 'error', summary: 'Erro', detail: error.message, life: 4500 })
return
}
toast.add({ severity: 'success', summary: 'Ok', detail: 'Plano excluído.', life: 2500 })
await fetchAll()
}
onMounted(fetchAll)
</script>
<template>
<Toast />
<ConfirmDialog />
<div class="p-4">
<Toolbar class="mb-4">
<template #start>
<div class="flex flex-col">
<div class="text-xl font-semibold leading-none">Plans</div>
<small class="text-color-secondary mt-1">
Catálogo de planos do SaaS. A <b>key</b> é a referência estável usada no sistema.
</small>
</div>
</template>
<template #end>
<div class="flex items-center gap-2">
<Button label="Atualizar" icon="pi pi-refresh" severity="secondary" outlined :loading="loading" @click="fetchAll" />
<Button label="Adicionar" icon="pi pi-plus" @click="openCreate" />
</div>
</template>
</Toolbar>
<DataTable :value="rows" dataKey="id" :loading="loading" stripedRows responsiveLayout="scroll">
<Column field="name" header="Nome" sortable style="min-width: 14rem" />
<Column field="key" header="Key" sortable />
<Column header="Mensal" sortable style="width: 12rem">
<template #body="{ data }">
<span class="font-medium">
{{ formatBRLFromCents(data.monthly_cents) }}
</span>
</template>
</Column>
<Column header="Anual" sortable style="width: 12rem">
<template #body="{ data }">
<span class="font-medium">
{{ formatBRLFromCents(data.yearly_cents) }}
</span>
</template>
</Column>
<Column v-if="hasCreatedAt" field="created_at" header="Criado em" sortable />
<Column header="Ações" style="width: 12rem">
<template #body="{ data }">
<div class="flex gap-2">
<Button icon="pi pi-pencil" severity="secondary" outlined @click="openEdit(data)" />
<Button icon="pi pi-trash" severity="danger" outlined @click="askDelete(data)" />
</div>
</template>
</Column>
</DataTable>
<Dialog v-model:visible="showDlg" modal :header="isEdit ? 'Editar plano' : 'Novo plano'" :style="{ width: '620px' }">
<div class="flex flex-col gap-4">
<div>
<label class="block mb-2">Key</label>
<InputText
v-model="form.key"
class="w-full"
placeholder="ex.: free, pro, clinic_pro"
:disabled="isSystemKeyLocked"
/>
<small class="text-color-secondary">
A key é técnica e estável (slug). Ex.: "Clínica Pro" vira "clinica_pro".
{{ isSystemKeyLocked ? ' Este plano é do sistema (key travada).' : '' }}
</small>
</div>
<div>
<label class="block mb-2">Nome do plano</label>
<InputText v-model="form.name" class="w-full" placeholder="ex.: Free, Pro, Clínica Pro" />
<small class="text-color-secondary">Nome interno para o admin. (O nome público vem depois.)</small>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block mb-2">Preço mensal (R$)</label>
<InputNumber
v-model="form.price_monthly"
class="w-full"
inputClass="w-full"
mode="decimal"
:minFractionDigits="2"
:maxFractionDigits="2"
placeholder="ex.: 39,90"
/>
<small class="text-color-secondary">Deixe vazio para sem preço definido.</small>
</div>
<div>
<label class="block mb-2">Preço anual (R$)</label>
<InputNumber
v-model="form.price_yearly"
class="w-full"
inputClass="w-full"
mode="decimal"
:minFractionDigits="2"
:maxFractionDigits="2"
placeholder="ex.: 399,90"
/>
<small class="text-color-secondary">Deixe vazio para sem preço definido.</small>
</div>
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined @click="showDlg = false" />
<Button :label="isEdit ? 'Salvar' : 'Criar'" icon="pi pi-check" :loading="saving" @click="save" />
</template>
</Dialog>
</div>
</template>