fix(admin): habilitar edicao de limites dos planos, auto-seed dos planos padrao e corrigir botao de adicionar recurso
Build and Deploy (Gitea Actions) / build-and-deploy (push) Successful in 2m42s Details

This commit is contained in:
Sidney 2026-07-19 19:57:31 -03:00
parent cf4a838d9c
commit c4cb13a912
2 changed files with 155 additions and 38 deletions

View File

@ -3,15 +3,48 @@ import { query } from '@/lib/db';
export async function GET() { export async function GET() {
try { try {
const res = await query(` let res = await query(`
SELECT p.id, p.name, LOWER(p.name) as slug, p.price as "priceMonthly", SELECT p.id, p.name, COALESCE(p.slug, LOWER(p.name)) as slug,
(p.price * 10) as "priceYearly", 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, p.features, p.is_active as active,
(SELECT COUNT(*) FROM tenants WHERE active_plan_id = p.id) as subscribers (SELECT COUNT(*) FROM tenants WHERE active_plan_id = p.id) as subscribers
FROM plans p FROM plans p
ORDER BY p.price ASC 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 => { const plans = res.rows.map(row => {
let features = []; let features = [];
try { try {
@ -21,11 +54,11 @@ export async function GET() {
return { return {
...row, ...row,
priceMonthly: Number(row.priceMonthly), priceMonthly: Number(row.priceMonthly || 0),
priceYearly: Number(row.priceYearly), priceYearly: Number(row.priceYearly || 0),
subscribers: Number(row.subscribers), maxProfessionals: Number(row.maxProfessionals || 1),
maxProfessionals: row.name === 'Enterprise' ? 999 : (row.name === 'Business' ? 15 : (row.name === 'Professional' ? 5 : 1)), maxServices: Number(row.maxServices || 10),
maxServices: row.name === 'Enterprise' ? 999 : (row.name === 'Business' ? 100 : (row.name === 'Professional' ? 30 : 10)), subscribers: Number(row.subscribers || 0),
features: features.length > 0 ? features : ['Agendamento online'] features: features.length > 0 ? features : ['Agendamento online']
}; };
}); });
@ -39,13 +72,19 @@ export async function GET() {
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { 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 }); 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( const res = await query(
`INSERT INTO plans (name, price, features) `INSERT INTO plans (name, slug, price, price_monthly, price_yearly, max_professionals, max_services, features)
VALUES ($1, $2, $3) RETURNING *`, VALUES ($1, $2, $3, $3, $4, $5, $6, $7) RETURNING *`,
[name, priceMonthly || 0, JSON.stringify(features || [])] [name, safeSlug, pMonthly, pYearly, maxProf, maxSvc, JSON.stringify(features || [])]
); );
return NextResponse.json(res.rows[0]); return NextResponse.json(res.rows[0]);
@ -56,14 +95,19 @@ export async function POST(req: NextRequest) {
export async function PUT(req: NextRequest) { export async function PUT(req: NextRequest) {
try { 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 }); 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( const res = await query(
`UPDATE plans `UPDATE plans
SET name = $1, price = $2, features = $3, updated_at = NOW() SET name = $1, price = $2, price_monthly = $2, price_yearly = $3, max_professionals = $4, max_services = $5, features = $6, updated_at = NOW()
WHERE id = $4 RETURNING *`, WHERE id = $7 RETURNING *`,
[name, priceMonthly || 0, JSON.stringify(features || []), id] [name, pMonthly, pYearly, maxProf, maxSvc, JSON.stringify(features || []), id]
); );
return NextResponse.json(res.rows[0]); return NextResponse.json(res.rows[0]);

View File

@ -2,9 +2,29 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Plus, X, Edit, Trash2, Check, Users, AlertTriangle } from 'lucide-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<Plan> = { name: '', slug: '', priceMonthly: 0, priceYearly: 0, maxProfessionals: 1, maxServices: 10, features: [], active: true }; const emptyPlan: Partial<Plan> = {
name: '',
slug: '',
priceMonthly: 0,
priceYearly: 0,
maxProfessionals: 1,
maxServices: 10,
features: [],
active: true
};
export default function Plans() { export default function Plans() {
const [plans, setPlans] = useState<Plan[]>([]); const [plans, setPlans] = useState<Plan[]>([]);
@ -33,8 +53,17 @@ export default function Plans() {
fetchPlans(); fetchPlans();
}, []); }, []);
const openAdd = () => { setEditing(null); setForm({ ...emptyPlan, features: [] }); setShowModal(true); }; const openAdd = () => {
const openEdit = (p: Plan) => { setEditing(p); setForm({ ...p }); setShowModal(true); }; setEditing(null);
setForm({ ...emptyPlan, features: [] });
setShowModal(true);
};
const openEdit = (p: Plan) => {
setEditing(p);
setForm({ ...p, features: p.features || [] });
setShowModal(true);
};
const handleSave = async () => { const handleSave = async () => {
if (!form.name?.trim()) return; 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 addFeature = () => {
const removeFeature = (idx: number) => { setForm(p => ({ ...p, features: (p.features || []).filter((_, i) => i !== idx) })); }; 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']; const colors = ['#3b82f6', '#6C63FF', '#a855f7', '#f59e0b'];
@ -86,7 +123,10 @@ export default function Plans() {
<AlertTriangle size={32} color="var(--warning)" /> <AlertTriangle size={32} color="var(--warning)" />
<p style={{ fontWeight: 600 }}>Excluir plano {p.name}?</p> <p style={{ fontWeight: 600 }}>Excluir plano {p.name}?</p>
<p style={{ fontSize: 12, color: 'var(--text-secondary)' }}>{p.subscribers} assinantes serão afetados</p> <p style={{ fontSize: 12, color: 'var(--text-secondary)' }}>{p.subscribers} assinantes serão afetados</p>
<div style={{ display: 'flex', gap: 8 }}><button className="btn btn-sm btn-danger" onClick={() => handleDelete(p.id)}>Excluir</button><button className="btn btn-sm btn-secondary" onClick={() => setDeleteConfirm(null)}>Cancelar</button></div> <div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-sm btn-danger" onClick={() => handleDelete(p.id)}>Excluir</button>
<button className="btn btn-sm btn-secondary" onClick={() => setDeleteConfirm(null)}>Cancelar</button>
</div>
</div> </div>
)} )}
<div className="flex-between mb-4"> <div className="flex-between mb-4">
@ -97,22 +137,22 @@ export default function Plans() {
</div> </div>
</div> </div>
<div style={{ marginBottom: '16px' }}> <div style={{ marginBottom: '16px' }}>
<span style={{ fontSize: '32px', fontWeight: 900 }}>R$ {p.priceMonthly.toFixed(2).replace('.', ',')}</span> <span style={{ fontSize: '32px', fontWeight: 900 }}>R$ {Number(p.priceMonthly || 0).toFixed(2).replace('.', ',')}</span>
<span style={{ fontSize: '14px', color: 'var(--text-muted)' }}>/mês</span> <span style={{ fontSize: '14px', color: 'var(--text-muted)' }}>/mês</span>
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: 2 }}>ou R$ {p.priceYearly.toFixed(2).replace('.', ',')} /ano</div> <div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: 2 }}>ou R$ {Number(p.priceYearly || 0).toFixed(2).replace('.', ',')} /ano</div>
</div> </div>
<div className="grid-2 mb-4"> <div className="grid-2 mb-4">
<div style={{ padding: '10px', background: 'var(--bg-primary)', borderRadius: 'var(--radius-sm)', textAlign: 'center' }}> <div style={{ padding: '10px', background: 'var(--bg-primary)', borderRadius: 'var(--radius-sm)', textAlign: 'center' }}>
<div style={{ fontSize: '16px', fontWeight: 800, color: colors[idx % colors.length] }}>{p.maxProfessionals === 999 ? '∞' : p.maxProfessionals}</div> <div style={{ fontSize: '16px', fontWeight: 800, color: colors[idx % colors.length] }}>{p.maxProfessionals >= 999 ? '∞' : p.maxProfessionals}</div>
<div style={{ fontSize: '10px', color: 'var(--text-muted)' }}>Profissionais</div> <div style={{ fontSize: '10px', color: 'var(--text-muted)' }}>Profissionais</div>
</div> </div>
<div style={{ padding: '10px', background: 'var(--bg-primary)', borderRadius: 'var(--radius-sm)', textAlign: 'center' }}> <div style={{ padding: '10px', background: 'var(--bg-primary)', borderRadius: 'var(--radius-sm)', textAlign: 'center' }}>
<div style={{ fontSize: '16px', fontWeight: 800, color: colors[idx % colors.length] }}><Users size={14} style={{ display: 'inline' }} /> {p.subscribers}</div> <div style={{ fontSize: '16px', fontWeight: 800, color: colors[idx % colors.length] }}>{p.maxServices >= 999 ? '∞' : p.maxServices}</div>
<div style={{ fontSize: '10px', color: 'var(--text-muted)' }}>Assinantes</div> <div style={{ fontSize: '10px', color: 'var(--text-muted)' }}>Serviços</div>
</div> </div>
</div> </div>
<ul style={{ listStyle: 'none', fontSize: '13px' }}> <ul style={{ listStyle: 'none', fontSize: '13px' }}>
{p.features.map((f, i) => ( {(p.features || []).map((f, i) => (
<li key={i} style={{ padding: '4px 0', color: 'var(--text-secondary)', display: 'flex', alignItems: 'center', gap: 6 }}> <li key={i} style={{ padding: '4px 0', color: 'var(--text-secondary)', display: 'flex', alignItems: 'center', gap: 6 }}>
<Check size={14} color="var(--success)" /> {f} <Check size={14} color="var(--success)" /> {f}
</li> </li>
@ -137,28 +177,61 @@ export default function Plans() {
</div> </div>
<div className="modal-body"> <div className="modal-body">
<div className="grid-2"> <div className="grid-2">
<div className="form-group"><label className="form-label">Nome *</label><input className="form-input" value={form.name || ''} onChange={e => setForm(p => ({ ...p, name: e.target.value }))} /></div> <div className="form-group">
<div className="form-group"><label className="form-label">Slug</label><input className="form-input" value={form.slug || ''} onChange={e => setForm(p => ({ ...p, slug: e.target.value }))} disabled={!!editing} /></div> <label className="form-label">Nome *</label>
<input className="form-input" value={form.name || ''} onChange={e => {
const val = e.target.value;
setForm(p => ({
...p,
name: val,
slug: editing ? p.slug : val.toLowerCase().replace(/[^a-z0-9]/g, '-')
}));
}} />
</div>
<div className="form-group">
<label className="form-label">Slug</label>
<input className="form-input" value={form.slug || ''} onChange={e => setForm(p => ({ ...p, slug: e.target.value }))} disabled={!!editing} />
</div>
</div> </div>
<div className="grid-2"> <div className="grid-2">
<div className="form-group"><label className="form-label">Preço Mensal (R$)</label><input className="form-input" type="number" step="0.01" value={form.priceMonthly || 0} onChange={e => setForm(p => ({ ...p, priceMonthly: Number(e.target.value) }))} /></div> <div className="form-group">
<div className="form-group"><label className="form-label">Preço Anual (R$)</label><input className="form-input" type="number" step="0.01" value={form.priceYearly || 0} onChange={e => setForm(p => ({ ...p, priceYearly: Number(e.target.value) }))} disabled /></div> <label className="form-label">Preço Mensal (R$)</label>
<input className="form-input" type="number" step="0.01" value={form.priceMonthly || 0} onChange={e => {
const val = Number(e.target.value);
setForm(p => ({ ...p, priceMonthly: val, priceYearly: p.priceYearly || val * 10 }));
}} />
</div>
<div className="form-group">
<label className="form-label">Preço Anual (R$)</label>
<input className="form-input" type="number" step="0.01" value={form.priceYearly || 0} onChange={e => setForm(p => ({ ...p, priceYearly: Number(e.target.value) }))} />
</div>
</div> </div>
<div className="grid-2"> <div className="grid-2">
<div className="form-group"><label className="form-label">Max Profissionais</label><input className="form-input" type="number" value={form.maxProfessionals || 1} onChange={e => setForm(p => ({ ...p, maxProfessionals: Number(e.target.value) }))} disabled /></div> <div className="form-group">
<div className="form-group"><label className="form-label">Max Serviços</label><input className="form-input" type="number" value={form.maxServices || 10} onChange={e => setForm(p => ({ ...p, maxServices: Number(e.target.value) }))} disabled /></div> <label className="form-label">Max Profissionais</label>
<input className="form-input" type="number" value={form.maxProfessionals || 1} onChange={e => setForm(p => ({ ...p, maxProfessionals: Number(e.target.value) }))} />
</div>
<div className="form-group">
<label className="form-label">Max Serviços</label>
<input className="form-input" type="number" value={form.maxServices || 10} onChange={e => setForm(p => ({ ...p, maxServices: Number(e.target.value) }))} />
</div>
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="form-label">Recursos</label> <label className="form-label">Recursos</label>
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}> <div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<input className="form-input" placeholder="Adicionar recurso..." value={featureInput} onChange={e => setFeatureInput(e.target.value)} onKeyDown={e => e.key === 'Enter' && addFeature()} /> <input className="form-input" placeholder="Adicionar recurso..." value={featureInput} onChange={e => setFeatureInput(e.target.value)} onKeyDown={e => {
<button className="btn btn-sm btn-secondary" onClick={addFeature}>+</button> if (e.key === 'Enter') {
e.preventDefault();
addFeature();
}
}} />
<button type="button" className="btn btn-sm btn-secondary" onClick={addFeature}>+</button>
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{(form.features || []).map((f, i) => ( {(form.features || []).map((f, i) => (
<div key={i} className="flex-between" style={{ padding: '6px 10px', background: 'var(--bg-primary)', borderRadius: 'var(--radius-sm)', fontSize: 13 }}> <div key={i} className="flex-between" style={{ padding: '6px 10px', background: 'var(--bg-primary)', borderRadius: 'var(--radius-sm)', fontSize: 13 }}>
<span>{f}</span> <span>{f}</span>
<button className="btn-ghost btn-icon" onClick={() => removeFeature(i)} style={{ color: 'var(--danger)' }}><X size={12} /></button> <button type="button" className="btn-ghost btn-icon" onClick={() => removeFeature(i)} style={{ color: 'var(--danger)' }}><X size={12} /></button>
</div> </div>
))} ))}
</div> </div>