From c4cb13a9128d646e2ba7c0e162115a2068a6b6b7 Mon Sep 17 00:00:00 2001 From: Sidney Date: Sun, 19 Jul 2026 19:57:31 -0300 Subject: [PATCH] fix(admin): habilitar edicao de limites dos planos, auto-seed dos planos padrao e corrigir botao de adicionar recurso --- admin/src/app/api/plans/route.ts | 76 +++++++++++++++----- admin/src/components/Plans.tsx | 117 +++++++++++++++++++++++++------ 2 files changed, 155 insertions(+), 38 deletions(-) diff --git a/admin/src/app/api/plans/route.ts b/admin/src/app/api/plans/route.ts index 2b76741..3717e02 100644 --- a/admin/src/app/api/plans/route.ts +++ b/admin/src/app/api/plans/route.ts @@ -3,15 +3,48 @@ import { query } from '@/lib/db'; export async function GET() { try { - const res = await query(` - SELECT p.id, p.name, LOWER(p.name) as slug, p.price as "priceMonthly", - (p.price * 10) as "priceYearly", + let res = await query(` + SELECT p.id, p.name, COALESCE(p.slug, LOWER(p.name)) as slug, + COALESCE(p.price, p.price_monthly, 0) as "priceMonthly", + COALESCE(p.price_yearly, p.price * 10, 0) as "priceYearly", + COALESCE(p.max_professionals, 1) as "maxProfessionals", + COALESCE(p.max_services, 10) as "maxServices", p.features, p.is_active as active, (SELECT COUNT(*) FROM tenants WHERE active_plan_id = p.id) as subscribers FROM plans p ORDER BY p.price ASC `); + // Auto-seed plans if empty + if (res.rows.length === 0) { + const defaultPlans = [ + { name: 'Starter', price: 49.90, priceYearly: 499.00, slug: 'starter', maxProf: 1, maxSvc: 10, features: ['1 profissional', '10 serviços', '200 agendamentos/mês', 'Agendamento online', 'Página de reservas', 'Lembretes por e-mail', 'Relatórios básicos'] }, + { name: 'Professional', price: 99.90, priceYearly: 999.00, slug: 'professional', maxProf: 5, maxSvc: 30, features: ['5 profissionais', '30 serviços', 'Agendamentos ilimitados', 'Tudo do Starter', 'Múltiplos profissionais', 'Comissionamento', 'Relatórios avançados', 'Personalização da marca'] }, + { name: 'Business', price: 199.90, priceYearly: 1990.00, slug: 'business', maxProf: 15, maxSvc: 100, features: ['15 profissionais', '100 serviços', 'Agendamentos ilimitados', 'Tudo do Professional', 'API de integração', 'Suporte prioritário', 'Multi-unidades', 'Automações avançadas'] }, + { name: 'Enterprise', price: 399.90, priceYearly: 3990.00, slug: 'enterprise', maxProf: 999, maxSvc: 999, features: ['Profissionais ilimitados', 'Serviços ilimitados', 'Agendamentos ilimitados', 'Tudo do Business', 'Gerente dedicado', 'SLA garantido', 'Integrações customizadas', 'White-label'] } + ]; + + for (const p of defaultPlans) { + await query( + `INSERT INTO plans (name, price, price_monthly, price_yearly, slug, max_professionals, max_services, features) + VALUES ($1, $2, $2, $3, $4, $5, $6, $7)`, + [p.name, p.price, p.priceYearly, p.slug, p.maxProf, p.maxSvc, JSON.stringify(p.features)] + ); + } + + res = await query(` + SELECT p.id, p.name, COALESCE(p.slug, LOWER(p.name)) as slug, + COALESCE(p.price, p.price_monthly, 0) as "priceMonthly", + COALESCE(p.price_yearly, p.price * 10, 0) as "priceYearly", + COALESCE(p.max_professionals, 1) as "maxProfessionals", + COALESCE(p.max_services, 10) as "maxServices", + p.features, p.is_active as active, + (SELECT COUNT(*) FROM tenants WHERE active_plan_id = p.id) as subscribers + FROM plans p + ORDER BY p.price ASC + `); + } + const plans = res.rows.map(row => { let features = []; try { @@ -21,11 +54,11 @@ export async function GET() { return { ...row, - priceMonthly: Number(row.priceMonthly), - priceYearly: Number(row.priceYearly), - subscribers: Number(row.subscribers), - maxProfessionals: row.name === 'Enterprise' ? 999 : (row.name === 'Business' ? 15 : (row.name === 'Professional' ? 5 : 1)), - maxServices: row.name === 'Enterprise' ? 999 : (row.name === 'Business' ? 100 : (row.name === 'Professional' ? 30 : 10)), + priceMonthly: Number(row.priceMonthly || 0), + priceYearly: Number(row.priceYearly || 0), + maxProfessionals: Number(row.maxProfessionals || 1), + maxServices: Number(row.maxServices || 10), + subscribers: Number(row.subscribers || 0), features: features.length > 0 ? features : ['Agendamento online'] }; }); @@ -39,13 +72,19 @@ export async function GET() { export async function POST(req: NextRequest) { try { - const { name, priceMonthly, features } = await req.json(); + const { name, slug, priceMonthly, priceYearly, maxProfessionals, maxServices, features } = await req.json(); if (!name) return NextResponse.json({ error: 'Missing name' }, { status: 400 }); + const safeSlug = slug || name.toLowerCase().replace(/[^a-z0-9]/g, '-'); + const pMonthly = priceMonthly || 0; + const pYearly = priceYearly || pMonthly * 10; + const maxProf = maxProfessionals || 1; + const maxSvc = maxServices || 10; + const res = await query( - `INSERT INTO plans (name, price, features) - VALUES ($1, $2, $3) RETURNING *`, - [name, priceMonthly || 0, JSON.stringify(features || [])] + `INSERT INTO plans (name, slug, price, price_monthly, price_yearly, max_professionals, max_services, features) + VALUES ($1, $2, $3, $3, $4, $5, $6, $7) RETURNING *`, + [name, safeSlug, pMonthly, pYearly, maxProf, maxSvc, JSON.stringify(features || [])] ); return NextResponse.json(res.rows[0]); @@ -56,14 +95,19 @@ export async function POST(req: NextRequest) { export async function PUT(req: NextRequest) { try { - const { id, name, priceMonthly, features } = await req.json(); + const { id, name, slug, priceMonthly, priceYearly, maxProfessionals, maxServices, features } = await req.json(); if (!id || !name) return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + const pMonthly = priceMonthly || 0; + const pYearly = priceYearly || pMonthly * 10; + const maxProf = maxProfessionals || 1; + const maxSvc = maxServices || 10; + const res = await query( `UPDATE plans - SET name = $1, price = $2, features = $3, updated_at = NOW() - WHERE id = $4 RETURNING *`, - [name, priceMonthly || 0, JSON.stringify(features || []), id] + SET name = $1, price = $2, price_monthly = $2, price_yearly = $3, max_professionals = $4, max_services = $5, features = $6, updated_at = NOW() + WHERE id = $7 RETURNING *`, + [name, pMonthly, pYearly, maxProf, maxSvc, JSON.stringify(features || []), id] ); return NextResponse.json(res.rows[0]); diff --git a/admin/src/components/Plans.tsx b/admin/src/components/Plans.tsx index 095bf93..b37a4fb 100644 --- a/admin/src/components/Plans.tsx +++ b/admin/src/components/Plans.tsx @@ -2,9 +2,29 @@ import { useState, useEffect } from 'react'; import { Plus, X, Edit, Trash2, Check, Users, AlertTriangle } from 'lucide-react'; -interface Plan { id: string; name: string; slug: string; priceMonthly: number; priceYearly: number; maxProfessionals: number; maxServices: number; features: string[]; active: boolean; subscribers: number; } +interface Plan { + id: string; + name: string; + slug: string; + priceMonthly: number; + priceYearly: number; + maxProfessionals: number; + maxServices: number; + features: string[]; + active: boolean; + subscribers: number; +} -const emptyPlan: Partial = { name: '', slug: '', priceMonthly: 0, priceYearly: 0, maxProfessionals: 1, maxServices: 10, features: [], active: true }; +const emptyPlan: Partial = { + name: '', + slug: '', + priceMonthly: 0, + priceYearly: 0, + maxProfessionals: 1, + maxServices: 10, + features: [], + active: true +}; export default function Plans() { const [plans, setPlans] = useState([]); @@ -33,8 +53,17 @@ export default function Plans() { fetchPlans(); }, []); - const openAdd = () => { setEditing(null); setForm({ ...emptyPlan, features: [] }); setShowModal(true); }; - const openEdit = (p: Plan) => { setEditing(p); setForm({ ...p }); setShowModal(true); }; + const openAdd = () => { + setEditing(null); + setForm({ ...emptyPlan, features: [] }); + setShowModal(true); + }; + + const openEdit = (p: Plan) => { + setEditing(p); + setForm({ ...p, features: p.features || [] }); + setShowModal(true); + }; const handleSave = async () => { if (!form.name?.trim()) return; @@ -64,8 +93,16 @@ export default function Plans() { } }; - const addFeature = () => { if (featureInput.trim()) { setForm(p => ({ ...p, features: [...(p.features || []), featureInput.trim()] })); setFeatureInput(''); } }; - const removeFeature = (idx: number) => { setForm(p => ({ ...p, features: (p.features || []).filter((_, i) => i !== idx) })); }; + const addFeature = () => { + if (featureInput.trim()) { + setForm(p => ({ ...p, features: [...(p.features || []), featureInput.trim()] })); + setFeatureInput(''); + } + }; + + const removeFeature = (idx: number) => { + setForm(p => ({ ...p, features: (p.features || []).filter((_, i) => i !== idx) })); + }; const colors = ['#3b82f6', '#6C63FF', '#a855f7', '#f59e0b']; @@ -86,7 +123,10 @@ export default function Plans() {

Excluir plano {p.name}?

{p.subscribers} assinantes serão afetados

-
+
+ + +
)}
@@ -97,22 +137,22 @@ export default function Plans() {
- R$ {p.priceMonthly.toFixed(2).replace('.', ',')} + R$ {Number(p.priceMonthly || 0).toFixed(2).replace('.', ',')} /mês -
ou R$ {p.priceYearly.toFixed(2).replace('.', ',')} /ano
+
ou R$ {Number(p.priceYearly || 0).toFixed(2).replace('.', ',')} /ano
-
{p.maxProfessionals === 999 ? '∞' : p.maxProfessionals}
+
{p.maxProfessionals >= 999 ? '∞' : p.maxProfessionals}
Profissionais
-
{p.subscribers}
-
Assinantes
+
{p.maxServices >= 999 ? '∞' : p.maxServices}
+
Serviços
    - {p.features.map((f, i) => ( + {(p.features || []).map((f, i) => (
  • {f}
  • @@ -137,28 +177,61 @@ export default function Plans() {
    -
    setForm(p => ({ ...p, name: e.target.value }))} />
    -
    setForm(p => ({ ...p, slug: e.target.value }))} disabled={!!editing} />
    +
    + + { + const val = e.target.value; + setForm(p => ({ + ...p, + name: val, + slug: editing ? p.slug : val.toLowerCase().replace(/[^a-z0-9]/g, '-') + })); + }} /> +
    +
    + + setForm(p => ({ ...p, slug: e.target.value }))} disabled={!!editing} /> +
    -
    setForm(p => ({ ...p, priceMonthly: Number(e.target.value) }))} />
    -
    setForm(p => ({ ...p, priceYearly: Number(e.target.value) }))} disabled />
    +
    + + { + const val = Number(e.target.value); + setForm(p => ({ ...p, priceMonthly: val, priceYearly: p.priceYearly || val * 10 })); + }} /> +
    +
    + + setForm(p => ({ ...p, priceYearly: Number(e.target.value) }))} /> +
    -
    setForm(p => ({ ...p, maxProfessionals: Number(e.target.value) }))} disabled />
    -
    setForm(p => ({ ...p, maxServices: Number(e.target.value) }))} disabled />
    +
    + + setForm(p => ({ ...p, maxProfessionals: Number(e.target.value) }))} /> +
    +
    + + setForm(p => ({ ...p, maxServices: Number(e.target.value) }))} /> +
    - setFeatureInput(e.target.value)} onKeyDown={e => e.key === 'Enter' && addFeature()} /> - + setFeatureInput(e.target.value)} onKeyDown={e => { + if (e.key === 'Enter') { + e.preventDefault(); + addFeature(); + } + }} /> +
    {(form.features || []).map((f, i) => (
    {f} - +
    ))}