diff --git a/admin/src/app/api/tenants/route.ts b/admin/src/app/api/tenants/route.ts index 4b5099c..897cf96 100644 --- a/admin/src/app/api/tenants/route.ts +++ b/admin/src/app/api/tenants/route.ts @@ -63,18 +63,20 @@ export async function POST(req: NextRequest) { export async function PUT(req: NextRequest) { try { - const { id, name, slug, email, phone, plan, status, password } = await req.json(); + const { id, name, slug, email, phone, plan, status, password, grantAccessDays } = await req.json(); if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 }); const planRes = await query('SELECT id FROM plans WHERE name = $1 LIMIT 1', [plan || 'Starter']); const planId = planRes.rows.length > 0 ? planRes.rows[0].id : null; - const res = await query( - `UPDATE tenants - SET name = $1, slug = $2, email = $3, phone = $4, plan_id = $5, subscription_status = $6, updated_at = NOW() - WHERE id = $7 RETURNING *`, - [name, slug, email, phone, planId, status, id] - ); + let updateQuery = `UPDATE tenants SET name = $1, slug = $2, email = $3, phone = $4, plan_id = $5, subscription_status = $6, updated_at = NOW() WHERE id = $7 RETURNING *`; + const params: any[] = [name, slug, email, phone, planId, status, id]; + + if (grantAccessDays) { + updateQuery = `UPDATE tenants SET name = $1, slug = $2, email = $3, phone = $4, plan_id = $5, subscription_status = 'active', updated_at = NOW(), subscription_ends_at = NOW() + INTERVAL '${Number(grantAccessDays)} days' WHERE id = $7 RETURNING *`; + } + + const res = await query(updateQuery, params); if (password) { const salt = await bcrypt.genSalt(10); diff --git a/admin/src/app/globals.css b/admin/src/app/globals.css index 5d497d4..76addc3 100644 --- a/admin/src/app/globals.css +++ b/admin/src/app/globals.css @@ -104,18 +104,30 @@ body { .stat-value { font-size: 28px; font-weight: 800; letter-spacing: -1px; line-height: 1.2; } .stat-label { font-size: 13px; color: var(--text-secondary); margin-top: 2px; } -.btn { - display: inline-flex; align-items: center; gap: 8px; padding: 10px 20px; border-radius: var(--radius-sm); - font-size: 14px; font-weight: 600; cursor: pointer; border: none; transition: all 0.2s; font-family: inherit; +.btn, button:not(.tab):not(.toggle) { + display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 8px 16px; border-radius: 6px; + font-size: 13.5px; font-weight: 500; cursor: pointer; border: 1px solid transparent; background: transparent; + box-shadow: none !important; outline: none; transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.08s ease; font-family: inherit; user-select: none; } -.btn-primary { background: var(--gradient-primary); color: white; box-shadow: var(--shadow-glow); } -.btn-primary:hover { transform: translateY(-1px); } -.btn-secondary { background: var(--bg-elevated); color: var(--text-primary); border: 1px solid var(--border-color); } -.btn-danger { background: var(--danger-bg); color: var(--danger); border: 1px solid rgba(239,68,68,0.2); } -.btn-ghost { background: transparent; color: var(--text-secondary); padding: 8px 12px; border: none; cursor: pointer; font-family: inherit; } -.btn-ghost:hover { color: var(--text-primary); background: var(--bg-card); } -.btn-sm { padding: 6px 14px; font-size: 13px; } -.btn-icon { padding: 8px; border-radius: var(--radius-sm); } +.btn:active, button:not(.tab):not(.toggle):active { + transform: scale(0.95) !important; +} +.btn-primary { background: var(--accent); color: white; border: 1px solid transparent; } +.btn-primary:hover { background: var(--accent-hover); color: white; filter: brightness(1.15); } +.btn-secondary { background: rgba(255, 255, 255, 0.04); color: var(--text-primary); border: 1px solid var(--border-color); } +.btn-secondary:hover { background: rgba(255, 255, 255, 0.1); border-color: rgba(255, 255, 255, 0.2); color: white; } +.btn-danger { background: rgba(239, 68, 68, 0.1); color: #ef4444; border: 1px solid rgba(239, 68, 68, 0.25); } +.btn-danger:hover { background: rgba(239, 68, 68, 0.25); border-color: rgba(239, 68, 68, 0.5); color: #f87171; } +.btn-ghost { background: rgba(255, 255, 255, 0.03); color: var(--text-secondary); border: 1px solid rgba(255, 255, 255, 0.08); padding: 6px 10px; } +.btn-ghost:hover { color: var(--text-primary); background: rgba(255, 255, 255, 0.12); border-color: rgba(255, 255, 255, 0.25); } +.btn-ghost[style*="danger"], .btn-ghost[style*="239, 68, 68"], .btn-ghost.danger { + background: rgba(239, 68, 68, 0.1) !important; border: 1px solid rgba(239, 68, 68, 0.2) !important; color: #ef4444 !important; +} +.btn-ghost[style*="danger"]:hover, .btn-ghost[style*="239, 68, 68"]:hover, .btn-ghost.danger:hover { + background: rgba(239, 68, 68, 0.25) !important; border-color: rgba(239, 68, 68, 0.5) !important; color: #f87171 !important; +} +.btn-sm { padding: 5px 12px; font-size: 12.5px; } +.btn-icon { padding: 6px; border-radius: 6px; display: inline-flex; align-items: center; justify-content: center; } .form-group { margin-bottom: 20px; } .form-label { display: block; font-size: 13px; font-weight: 600; color: var(--text-secondary); margin-bottom: 6px; } diff --git a/admin/src/components/Plans.tsx b/admin/src/components/Plans.tsx index 169b92c..2b80d2d 100644 --- a/admin/src/components/Plans.tsx +++ b/admin/src/components/Plans.tsx @@ -198,24 +198,24 @@ export default function Plans() {
- { - const val = Number(e.target.value); - setForm(p => ({ ...p, priceMonthly: val, priceYearly: p.priceYearly || val * 10 })); + { + const val = e.target.value === '' ? '' : Number(e.target.value); + setForm(p => ({ ...p, priceMonthly: val as any, priceYearly: p.priceYearly || (val as number) * 10 })); }} />
- setForm(p => ({ ...p, priceYearly: Number(e.target.value) }))} /> + setForm(p => ({ ...p, priceYearly: e.target.value === '' ? '' as any : Number(e.target.value) }))} />
- setForm(p => ({ ...p, maxProfessionals: Number(e.target.value) }))} /> + setForm(p => ({ ...p, maxProfessionals: e.target.value === '' ? '' as any : Number(e.target.value) }))} />
- setForm(p => ({ ...p, maxServices: Number(e.target.value) }))} /> + setForm(p => ({ ...p, maxServices: e.target.value === '' ? '' as any : Number(e.target.value) }))} />
diff --git a/admin/src/components/Tenants.tsx b/admin/src/components/Tenants.tsx index fbc4282..bfdf87e 100644 --- a/admin/src/components/Tenants.tsx +++ b/admin/src/components/Tenants.tsx @@ -186,6 +186,21 @@ export default function Tenants() {
+ +
+

Liberação de Acesso Gratuito (Sem Cobrança)

+

Define a validade da assinatura sem exigir pagamento do cliente. O status mudará automaticamente para Ativo.

+
+
+ +
+ setForm(p => ({ ...p, grantAccessDays: e.target.value === '' ? '' : Number(e.target.value) }))} /> + + +
+
+
+
diff --git a/src/app/globals.css b/src/app/globals.css index e9dc163..496873d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -299,60 +299,91 @@ body { .stat-change.up { color: var(--success); } .stat-change.down { color: var(--danger); } -.btn { +.btn, button:not(.tab):not(.toggle) { display: inline-flex; align-items: center; + justify-content: center; gap: 8px; padding: 8px 16px; border-radius: 6px; font-size: 13.5px; font-weight: 500; cursor: pointer; - border: none; - transition: all 0.15s ease; + border: 1px solid transparent; + background: transparent; + box-shadow: none !important; + outline: none; + transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.08s ease; text-decoration: none; font-family: inherit; + user-select: none; +} + +/* Efeito básico de apertar quando clicar */ +.btn:active, button:not(.tab):not(.toggle):active { + transform: scale(0.95) !important; } .btn-primary { background: var(--accent); color: white; + border: 1px solid transparent; } .btn-primary:hover { background: var(--accent-hover); + color: white; + filter: brightness(1.15); } .btn-secondary { - background: transparent; + background: rgba(255, 255, 255, 0.04); color: var(--text-primary); border: 1px solid var(--border-color); } .btn-secondary:hover { - background: var(--bg-elevated); - border-color: var(--text-secondary); + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.2); + color: white; } .btn-danger { - background: transparent; - color: var(--danger); - border: 1px solid rgba(239,68,68,0.3); + background: rgba(239, 68, 68, 0.1); + color: #ef4444; + border: 1px solid rgba(239, 68, 68, 0.25); } .btn-danger:hover { - background: var(--danger-bg); + background: rgba(239, 68, 68, 0.25); + border-color: rgba(239, 68, 68, 0.5); + color: #f87171; } .btn-ghost { - background: transparent; + background: rgba(255, 255, 255, 0.03); color: var(--text-secondary); + border: 1px solid rgba(255, 255, 255, 0.08); padding: 6px 10px; } .btn-ghost:hover { color: var(--text-primary); - background: var(--bg-elevated); + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.25); +} + +/* Ícone de excluir no modo ghost (vermelho plano que acende no hover) */ +.btn-ghost[style*="danger"], .btn-ghost[style*="239, 68, 68"], .btn-ghost.danger { + background: rgba(239, 68, 68, 0.1) !important; + border: 1px solid rgba(239, 68, 68, 0.2) !important; + color: #ef4444 !important; +} + +.btn-ghost[style*="danger"]:hover, .btn-ghost[style*="239, 68, 68"]:hover, .btn-ghost.danger:hover { + background: rgba(239, 68, 68, 0.25) !important; + border-color: rgba(239, 68, 68, 0.5) !important; + color: #f87171 !important; } .btn-sm { @@ -363,6 +394,9 @@ body { .btn-icon { padding: 6px; border-radius: 6px; + display: inline-flex; + align-items: center; + justify-content: center; } /* === FORMS === */ diff --git a/src/components/Subscription.tsx b/src/components/Subscription.tsx index 155a16c..342797f 100644 --- a/src/components/Subscription.tsx +++ b/src/components/Subscription.tsx @@ -106,6 +106,25 @@ export default function Subscription({ currentPlan, onPlanChange, isLocked = fal else if (Array.isArray(p.features)) featuresList = p.features; } catch (e) {} + const maxProf = Number(p.max_professionals ?? (p.name === 'Starter' ? 1 : p.name === 'Professional' ? 5 : p.name === 'Business' ? 15 : 999)); + const maxSvc = Number(p.max_services ?? (p.name === 'Starter' ? 10 : p.name === 'Professional' ? 30 : p.name === 'Business' ? 100 : 999)); + const maxAppt = p.max_appointments_month ? Number(p.max_appointments_month) : (p.name === 'Starter' ? 200 : 999); + + const profText = maxProf >= 999 ? 'Profissionais ilimitados' : maxProf === 1 ? '1 profissional' : `${maxProf} profissionais`; + const svcText = maxSvc >= 999 ? 'Serviços ilimitados' : maxSvc === 1 ? '1 serviço' : `${maxSvc} serviços`; + const apptText = maxAppt >= 999 ? 'Agendamentos ilimitados' : `${maxAppt} agendamentos/mês`; + + const isLimitLine = (f: string) => { + const lower = f.toLowerCase(); + if (/(\d+|ilimitados)\s*profissional/i.test(lower)) return true; + if (/(\d+|ilimitados)\s*serviço/i.test(lower)) return true; + if (lower.includes('agendamentos/mês') || lower.includes('agendamentos ilimitados')) return true; + return false; + }; + + const customFeatures = featuresList.filter(f => !isLimitLine(f)); + const dynamicFeatures = [profText, svcText, apptText, ...customFeatures]; + return { id: p.id, name: p.name, @@ -113,7 +132,7 @@ export default function Subscription({ currentPlan, onPlanChange, isLocked = fal icon: iconMap[p.name] || Crown, color: colorMap[p.name] || '#6C63FF', featured: p.name === 'Professional', - features: featuresList.length > 0 ? featuresList : ['Agendamento online'] + features: dynamicFeatures }; }) : staticPlans;