diff --git a/admin/src/app/api/dashboard/route.ts b/admin/src/app/api/dashboard/route.ts new file mode 100644 index 0000000..8bdddfd --- /dev/null +++ b/admin/src/app/api/dashboard/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from 'next/server'; +import { query } from '@/lib/db'; + +export async function GET() { + try { + // Basic KPIs + const kpiRes = await query(` + SELECT + COUNT(*) FILTER (WHERE subscription_status = 'active') as active_tenants, + COUNT(*) FILTER (WHERE subscription_status = 'trial') as trial_tenants, + COUNT(*) FILTER (WHERE subscription_status = 'past_due') as past_due_tenants + FROM tenants + `); + const { active_tenants, trial_tenants, past_due_tenants } = kpiRes.rows[0]; + + // Total MRR + const mrrRes = await query(` + SELECT COALESCE(SUM(p.price), 0) as mrr + FROM tenants t + JOIN plans p ON t.active_plan_id = p.id + WHERE t.subscription_status = 'active' + `); + const mrr = mrrRes.rows[0].mrr; + + // Plans distribution + const plansRes = await query(` + SELECT p.name, COUNT(t.id) as count + FROM plans p + LEFT JOIN tenants t ON t.active_plan_id = p.id AND t.subscription_status = 'active' + GROUP BY p.name + ORDER BY count DESC + `); + + // Recent tenants + const recentRes = await query(` + SELECT t.id, t.name, COALESCE(p.name, 'N/A') as plan, t.subscription_status as status, + COALESCE(p.price, 0) as mrr, TO_CHAR(t.created_at, 'YYYY-MM-DD') as date + FROM tenants t + LEFT JOIN plans p ON t.active_plan_id = p.id + ORDER BY t.created_at DESC + LIMIT 5 + `); + + return NextResponse.json({ + mrr: Number(mrr), + activeTenants: Number(active_tenants), + trialTenants: Number(trial_tenants), + pastDueTenants: Number(past_due_tenants), + plansDistribution: plansRes.rows.map(r => ({ name: r.name, count: Number(r.count) })), + recentTenants: recentRes.rows.map(r => ({ ...r, mrr: Number(r.mrr) })) + }); + } catch (error: any) { + console.error('GET Dashboard error:', error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/admin/src/app/api/payments/route.ts b/admin/src/app/api/payments/route.ts new file mode 100644 index 0000000..e623159 --- /dev/null +++ b/admin/src/app/api/payments/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from 'next/server'; +import { query } from '@/lib/db'; + +export async function GET() { + try { + const res = await query(` + SELECT sp.id, + t.name as tenant, + COALESCE(pl.name, 'N/A') as plan, + sp.amount, + sp.status, + COALESCE(s.payment_method, 'PIX') as method, + TO_CHAR(COALESCE(sp.paid_at, sp.created_at), 'YYYY-MM-DD') as date, + COALESCE(sp.gateway_payment_id, 'MANUAL-' || SUBSTRING(sp.id::text, 1, 8)) as "invoiceId" + FROM subscription_payments sp + JOIN tenants t ON sp.tenant_id = t.id + LEFT JOIN subscriptions s ON sp.subscription_id = s.id + LEFT JOIN plans pl ON s.plan_id = pl.id + ORDER BY sp.created_at DESC + `); + + const payments = res.rows.map(row => ({ + ...row, + amount: Number(row.amount) + })); + + return NextResponse.json(payments); + } catch (error: any) { + console.error('GET Payments error:', error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/admin/src/app/api/plans/route.ts b/admin/src/app/api/plans/route.ts new file mode 100644 index 0000000..2b76741 --- /dev/null +++ b/admin/src/app/api/plans/route.ts @@ -0,0 +1,86 @@ +import { NextRequest, NextResponse } from 'next/server'; +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", + 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 { + if (typeof row.features === 'string') features = JSON.parse(row.features); + else if (Array.isArray(row.features)) features = row.features; + } catch (e) {} + + 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)), + features: features.length > 0 ? features : ['Agendamento online'] + }; + }); + + return NextResponse.json(plans); + } catch (error: any) { + console.error('GET Plans error:', error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} + +export async function POST(req: NextRequest) { + try { + const { name, priceMonthly, features } = await req.json(); + if (!name) return NextResponse.json({ error: 'Missing name' }, { status: 400 }); + + const res = await query( + `INSERT INTO plans (name, price, features) + VALUES ($1, $2, $3) RETURNING *`, + [name, priceMonthly || 0, JSON.stringify(features || [])] + ); + + return NextResponse.json(res.rows[0]); + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} + +export async function PUT(req: NextRequest) { + try { + const { id, name, priceMonthly, features } = await req.json(); + if (!id || !name) return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + + 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] + ); + + return NextResponse.json(res.rows[0]); + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} + +export async function DELETE(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const id = searchParams.get('id'); + if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 }); + + await query('DELETE FROM plans WHERE id = $1', [id]); + return NextResponse.json({ success: true }); + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/admin/src/app/api/tenants/route.ts b/admin/src/app/api/tenants/route.ts new file mode 100644 index 0000000..2712391 --- /dev/null +++ b/admin/src/app/api/tenants/route.ts @@ -0,0 +1,83 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { query } from '@/lib/db'; + +export async function GET(req: NextRequest) { + try { + const res = await query(` + SELECT t.id, t.name, t.slug, 'Barbearia' as type, + COALESCE(p.name, 'Starter') as plan, + t.subscription_status as status, + t.email, t.phone, + COALESCE(p.price, 0) as mrr, + TO_CHAR(t.created_at, 'YYYY-MM-DD') as "createdAt", + TO_CHAR(t.subscription_ends_at, 'YYYY-MM-DD') as "trialEnds" + FROM tenants t + LEFT JOIN plans p ON t.active_plan_id = p.id + ORDER BY t.created_at DESC + `); + + const tenants = res.rows.map(row => ({ + ...row, + mrr: Number(row.mrr) + })); + + return NextResponse.json(tenants); + } catch (error: any) { + console.error('GET Tenants error:', error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} + +export async function POST(req: NextRequest) { + try { + const { name, slug, email, phone, plan, status } = await req.json(); + if (!name || !slug) return NextResponse.json({ error: 'Missing fields' }, { 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( + `INSERT INTO tenants (name, slug, email, phone, active_plan_id, subscription_status, subscription_ends_at) + VALUES ($1, $2, $3, $4, $5, $6, NOW() + INTERVAL '30 days') RETURNING *`, + [name, slug, email, phone, planId, status || 'trial'] + ); + + return NextResponse.json(res.rows[0]); + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} + +export async function PUT(req: NextRequest) { + try { + const { id, name, slug, email, phone, plan, status } = 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, active_plan_id = $5, subscription_status = $6, updated_at = NOW() + WHERE id = $7 RETURNING *`, + [name, slug, email, phone, planId, status, id] + ); + + return NextResponse.json(res.rows[0]); + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} + +export async function DELETE(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const id = searchParams.get('id'); + if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 }); + + await query('DELETE FROM tenants WHERE id = $1', [id]); + return NextResponse.json({ success: true }); + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/admin/src/components/AdminDashboard.tsx b/admin/src/components/AdminDashboard.tsx index ef24e37..3aaf247 100644 --- a/admin/src/components/AdminDashboard.tsx +++ b/admin/src/components/AdminDashboard.tsx @@ -3,14 +3,6 @@ import { DollarSign, Users, TrendingUp, AlertTriangle, ArrowUpRight, CreditCard, interface Props { onNavigate: (page: string) => void; } -const recentTenants = [ - { id: '1', name: 'Barbearia Premium', plan: 'Professional', status: 'active', mrr: 99.90, date: '2026-05-20' }, - { id: '2', name: 'Studio Bella Nails', plan: 'Starter', status: 'active', mrr: 49.90, date: '2026-05-22' }, - { id: '3', name: 'Spa Relaxe', plan: 'Business', status: 'active', mrr: 199.90, date: '2026-05-18' }, - { id: '4', name: 'Barber Shop Elite', plan: 'Professional', status: 'trial', mrr: 0, date: '2026-05-28' }, - { id: '5', name: 'Salão Glamour', plan: 'Starter', status: 'past_due', mrr: 49.90, date: '2026-05-10' }, -]; - const revenueData = [65, 72, 58, 80, 95, 88, 102, 91, 110, 98, 115, 125]; const months = ['Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez', 'Jan', 'Fev', 'Mar', 'Abr', 'Mai']; @@ -22,74 +14,62 @@ const statusMap: Record = { suspended: { label: 'Suspenso', cls: 'badge-danger' }, }; +const planColors: Record = { + Starter: '#3b82f6', + Professional: '#6C63FF', + Business: '#a855f7', + Enterprise: '#f59e0b', +}; + export default function AdminDashboard({ onNavigate }: Props) { - const [tenants, setTenants] = useState(recentTenants); - const [mrrValue, setMrrValue] = useState(12480); - const maxRevenue = Math.max(...revenueData); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); useEffect(() => { - const getCookie = (name: string): string => { - if (typeof document === 'undefined') return ''; - const value = `; ${document.cookie}`; - const parts = value.split(`; ${name}=`); - if (parts.length === 2) return parts.pop()!.split(';').shift() || ''; - return ''; - }; - - const savedPlan = getCookie('agendapro_plan'); - if (savedPlan) { - setTenants(prev => prev.map(t => { - if (t.id === '1') { - let mrr = 99.90; - if (savedPlan === 'Starter') mrr = 49.90; - else if (savedPlan === 'Business') mrr = 199.90; - else if (savedPlan === 'Enterprise') mrr = 399.90; - return { ...t, plan: savedPlan, mrr }; - } - return t; - })); - - let diff = 0; - if (savedPlan === 'Starter') diff = 49.90 - 99.90; - else if (savedPlan === 'Business') diff = 199.90 - 99.90; - else if (savedPlan === 'Enterprise') diff = 399.90 - 99.90; - setMrrValue(12480 + diff); - } + fetch('/api/dashboard') + .then(res => res.json()) + .then(d => setData(d)) + .catch(err => console.error(err)) + .finally(() => setLoading(false)); }, []); + const maxRevenue = Math.max(...revenueData); + + if (loading) return
Carregando dashboard...
; + if (!data) return
Erro ao carregar dados.
; + + const totalTenants = data.activeTenants + data.trialTenants + data.pastDueTenants; + return (
-
R$ {mrrValue.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+
R$ {data.mrr.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
MRR (Receita Mensal)
-
↑ 18% vs mês anterior
+
Tempo Real
-
87
+
{data.activeTenants}
Assinantes Ativos
-
↑ 12 novos este mês
-
94.3%
+
{totalTenants > 0 ? ((data.activeTenants / totalTenants) * 100).toFixed(1) : 0}%
Taxa de Retenção
-
↑ 2.1% vs mês anterior
-
5
+
{data.pastDueTenants}
Inadimplentes
-
↑ 2 vs mês anterior
@@ -114,33 +94,29 @@ export default function AdminDashboard({ onNavigate }: Props) {

Distribuição de Planos

- {[ - { name: 'Starter', count: 35, color: '#3b82f6', pct: 40 }, - { name: 'Professional', count: 32, color: '#6C63FF', pct: 37 }, - { name: 'Business', count: 15, color: '#a855f7', pct: 17 }, - { name: 'Enterprise', count: 5, color: '#f59e0b', pct: 6 }, - ].map(p => ( + {data.plansDistribution.map((p: any) => { + const pct = totalTenants > 0 ? ((p.count / totalTenants) * 100).toFixed(1) : 0; + const color = planColors[p.name] || '#94a3b8'; + return (
{p.name} - {p.count} ({p.pct}%) + {p.count} ({pct}%)
-
+
- ))} + )})}

Métricas Rápidas

{[ - { label: 'Ticket Médio', value: 'R$ 143,45' }, - { label: 'Churn Rate', value: '5.7%' }, - { label: 'LTV Médio', value: 'R$ 1.723' }, - { label: 'Em Trial', value: '14' }, + { label: 'Ticket Médio', value: data.activeTenants > 0 ? `R$ ${(data.mrr / data.activeTenants).toFixed(2)}` : 'R$ 0,00' }, + { label: 'Em Trial', value: String(data.trialTenants) }, ].map((m, i) => ( -
+
{m.label} {m.value}
@@ -162,15 +138,18 @@ export default function AdminDashboard({ onNavigate }: Props) { - {tenants.map(t => ( + {data.recentTenants.map((t: any) => { + const st = statusMap[t.status] || statusMap.trial; + return ( - + - ))} + )})} + {data.recentTenants.length === 0 && }
EstabelecimentoPlanoStatusMRRDesde
{t.name[0]}
{t.name}
{t.plan}{statusMap[t.status].label}{st.label} {t.mrr > 0 ? `R$ ${t.mrr.toFixed(2)}` : '-'} {new Date(t.date + 'T12:00:00').toLocaleDateString('pt-BR')}
Nenhum assinante encontrado.
diff --git a/admin/src/components/Payments.tsx b/admin/src/components/Payments.tsx index dcbca32..4eb574e 100644 --- a/admin/src/components/Payments.tsx +++ b/admin/src/components/Payments.tsx @@ -1,5 +1,5 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Search, Download, Eye, CheckCircle, AlertCircle, XCircle, DollarSign, Filter } from 'lucide-react'; interface Payment { @@ -13,25 +13,24 @@ const statusMap: Record([]); const [search, setSearch] = useState(''); const [filterStatus, setFilterStatus] = useState('all'); const [filterMonth, setFilterMonth] = useState('all'); + const [loading, setLoading] = useState(true); - const filtered = initialPayments.filter(p => { + useEffect(() => { + fetch('/api/payments') + .then(res => res.json()) + .then(data => { + setPayments(Array.isArray(data) ? data : []); + }) + .catch(err => console.error(err)) + .finally(() => setLoading(false)); + }, []); + + const filtered = payments.filter(p => { const matchSearch = p.tenant.toLowerCase().includes(search.toLowerCase()) || p.invoiceId.toLowerCase().includes(search.toLowerCase()); const matchStatus = filterStatus === 'all' || p.status === filterStatus; const matchMonth = filterMonth === 'all' || p.date.startsWith(filterMonth); @@ -42,6 +41,11 @@ export default function Payments() { const totalPending = filtered.filter(p => p.status === 'pending').reduce((s, p) => s + p.amount, 0); const totalFailed = filtered.filter(p => p.status === 'failed').reduce((s, p) => s + p.amount, 0); + // Helper to get unique months for the filter + const months = Array.from(new Set(payments.map(p => p.date.substring(0, 7)))).sort().reverse(); + + if (loading) return
Carregando pagamentos...
; + return (
@@ -60,7 +64,10 @@ export default function Payments() {
@@ -72,7 +79,7 @@ export default function Payments() { FaturaEstabelecimentoPlanoValorStatusMétodoData {filtered.map(p => { - const st = statusMap[p.status]; + const st = statusMap[p.status] || statusMap.pending; return ( {p.invoiceId} diff --git a/admin/src/components/Plans.tsx b/admin/src/components/Plans.tsx index 512ef60..095bf93 100644 --- a/admin/src/components/Plans.tsx +++ b/admin/src/components/Plans.tsx @@ -1,40 +1,76 @@ 'use client'; -import { useState } from 'react'; -import { Plus, X, Edit, Trash2, Check, Users, DollarSign, AlertTriangle } from 'lucide-react'; +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; } -const initialPlans: Plan[] = [ - { id: '1', name: 'Starter', slug: 'starter', priceMonthly: 49.90, priceYearly: 479, maxProfessionals: 1, maxServices: 10, features: ['Agendamento online', 'Página de reservas', 'Lembretes por e-mail', 'Relatórios básicos'], active: true, subscribers: 35 }, - { id: '2', name: 'Professional', slug: 'professional', priceMonthly: 99.90, priceYearly: 959, maxProfessionals: 5, maxServices: 30, features: ['Tudo do Starter', 'Múltiplos profissionais', 'Comissionamento', 'Relatórios avançados', 'Personalização da marca'], active: true, subscribers: 32 }, - { id: '3', name: 'Business', slug: 'business', priceMonthly: 199.90, priceYearly: 1919, maxProfessionals: 15, maxServices: 100, features: ['Tudo do Professional', 'API de integração', 'Suporte prioritário', 'Multi-unidades', 'Automações avançadas'], active: true, subscribers: 15 }, - { id: '4', name: 'Enterprise', slug: 'enterprise', priceMonthly: 399.90, priceYearly: 3839, maxProfessionals: 999, maxServices: 999, features: ['Tudo do Business', 'Gerente dedicado', 'SLA garantido', 'Integrações customizadas', 'White-label'], active: true, subscribers: 5 }, -]; - const emptyPlan: Partial = { name: '', slug: '', priceMonthly: 0, priceYearly: 0, maxProfessionals: 1, maxServices: 10, features: [], active: true }; export default function Plans() { - const [plans, setPlans] = useState(initialPlans); + const [plans, setPlans] = useState([]); const [showModal, setShowModal] = useState(false); const [editing, setEditing] = useState(null); const [form, setForm] = useState>(emptyPlan); const [featureInput, setFeatureInput] = useState(''); const [deleteConfirm, setDeleteConfirm] = useState(null); + const [loading, setLoading] = useState(true); - const openAdd = () => { setEditing(null); setForm({ ...emptyPlan, id: Date.now().toString(), features: [] }); setShowModal(true); }; - const openEdit = (p: Plan) => { setEditing(p); setForm({ ...p }); setShowModal(true); }; - const handleSave = () => { - if (!form.name?.trim()) return; - if (editing) { setPlans(prev => prev.map(p => p.id === editing.id ? { ...p, ...form } as Plan : p)); } - else { setPlans(prev => [...prev, { ...form, subscribers: 0 } as Plan]); } - setShowModal(false); + const fetchPlans = async () => { + try { + const res = await fetch('/api/plans'); + if (res.ok) { + const data = await res.json(); + setPlans(data); + } + } catch (e) { + console.error(e); + } finally { + setLoading(false); + } }; - const handleDelete = (id: string) => { setPlans(prev => prev.filter(p => p.id !== id)); setDeleteConfirm(null); }; + + useEffect(() => { + fetchPlans(); + }, []); + + const openAdd = () => { setEditing(null); setForm({ ...emptyPlan, features: [] }); setShowModal(true); }; + const openEdit = (p: Plan) => { setEditing(p); setForm({ ...p }); setShowModal(true); }; + + const handleSave = async () => { + if (!form.name?.trim()) return; + const method = editing ? 'PUT' : 'POST'; + try { + const res = await fetch('/api/plans', { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(form) + }); + if (res.ok) { + fetchPlans(); + setShowModal(false); + } + } catch (e) { + console.error(e); + } + }; + + const handleDelete = async (id: string) => { + try { + await fetch(`/api/plans?id=${id}`, { method: 'DELETE' }); + setPlans(prev => prev.filter(p => p.id !== id)); + setDeleteConfirm(null); + } catch (e) { + console.error(e); + } + }; + 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']; + if (loading) return
Carregando planos...
; + return (
@@ -85,6 +121,12 @@ export default function Plans() {
))}
+ + {plans.length === 0 && ( +
+ Nenhum plano cadastrado. +
+ )} {showModal && (
setShowModal(false)}> @@ -96,15 +138,15 @@ export default function Plans() {
setForm(p => ({ ...p, name: e.target.value }))} />
-
setForm(p => ({ ...p, slug: e.target.value }))} />
+
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) }))} />
+
setForm(p => ({ ...p, priceYearly: Number(e.target.value) }))} disabled />
-
setForm(p => ({ ...p, maxProfessionals: Number(e.target.value) }))} />
-
setForm(p => ({ ...p, maxServices: Number(e.target.value) }))} />
+
setForm(p => ({ ...p, maxProfessionals: Number(e.target.value) }))} disabled />
+
setForm(p => ({ ...p, maxServices: Number(e.target.value) }))} disabled />
diff --git a/admin/src/components/Tenants.tsx b/admin/src/components/Tenants.tsx index 56b2e89..e3886d1 100644 --- a/admin/src/components/Tenants.tsx +++ b/admin/src/components/Tenants.tsx @@ -1,6 +1,6 @@ 'use client'; import { useState, useEffect } from 'react'; -import { Search, Plus, X, Edit, Trash2, Eye, AlertTriangle, Ban, CheckCircle, Building2 } from 'lucide-react'; +import { Search, Plus, X, Edit, Trash2, AlertTriangle, Ban, CheckCircle } from 'lucide-react'; interface Tenant { id: string; name: string; slug: string; type: string; plan: string; status: string; email: string; phone: string; mrr: number; createdAt: string; trialEnds: string; @@ -12,112 +12,88 @@ const statusMap: Record = { suspended: { label: 'Suspenso', cls: 'badge-danger' }, }; -const initialTenants: Tenant[] = [ - { id: '1', name: 'Barbearia Premium', slug: 'barbearia-premium', type: 'Barbearia', plan: 'Professional', status: 'active', email: 'contato@barbeariapremium.com.br', phone: '(11) 99999-1234', mrr: 99.90, createdAt: '2026-01-15', trialEnds: '' }, - { id: '2', name: 'Studio Bella Nails', slug: 'bella-nails', type: 'Manicure', plan: 'Starter', status: 'active', email: 'bella@nails.com', phone: '(21) 98888-5555', mrr: 49.90, createdAt: '2026-02-20', trialEnds: '' }, - { id: '3', name: 'Spa Relaxe', slug: 'spa-relaxe', type: 'Massagem', plan: 'Business', status: 'active', email: 'contato@sparelaxe.com', phone: '(11) 97777-3333', mrr: 199.90, createdAt: '2026-01-05', trialEnds: '' }, - { id: '4', name: 'Barber Shop Elite', slug: 'barber-elite', type: 'Barbearia', plan: 'Professional', status: 'trial', email: 'elite@barber.com', phone: '(31) 96666-4444', mrr: 0, createdAt: '2026-05-28', trialEnds: '2026-06-27' }, - { id: '5', name: 'Salão Glamour', slug: 'salao-glamour', type: 'Salão', plan: 'Starter', status: 'past_due', email: 'glamour@salao.com', phone: '(41) 95555-7777', mrr: 49.90, createdAt: '2026-03-10', trialEnds: '' }, - { id: '6', name: 'Estética Renova', slug: 'estetica-renova', type: 'Estética', plan: 'Business', status: 'active', email: 'renova@estetica.com', phone: '(51) 94444-8888', mrr: 199.90, createdAt: '2026-04-01', trialEnds: '' }, - { id: '7', name: 'Barbearia do Zé', slug: 'barbearia-ze', type: 'Barbearia', plan: 'Starter', status: 'cancelled', email: 'ze@barbearia.com', phone: '(61) 93333-9999', mrr: 0, createdAt: '2026-02-15', trialEnds: '' }, -]; - export default function Tenants() { - const [tenants, setTenants] = useState(initialTenants); + const [tenants, setTenants] = useState([]); const [search, setSearch] = useState(''); const [filterStatus, setFilterStatus] = useState('all'); const [showModal, setShowModal] = useState(false); const [editing, setEditing] = useState(null); const [deleteConfirm, setDeleteConfirm] = useState(null); const [form, setForm] = useState>({}); + const [loading, setLoading] = useState(true); + + const fetchTenants = async () => { + try { + const res = await fetch('/api/tenants'); + if (res.ok) { + const data = await res.json(); + setTenants(data); + } + } catch (e) { + console.error(e); + } finally { + setLoading(false); + } + }; useEffect(() => { - // Busca dados reais do banco do Barbearia Premium para mostrar com transparência - fetch('/api/tenant/subscription?slug=barbearia-premium') - .then(res => res.json()) - .then(data => { - if (data && data.status) { - setTenants(prev => prev.map(t => { - if (t.id === '1') { - let mrr = 99.90; - if (data.subscription?.amount) { - mrr = parseFloat(data.subscription.amount); - } - return { - ...t, - plan: data.subscription?.plan_name || t.plan, - status: data.status, - mrr - }; - } - return t; - })); - } - }) - .catch(err => { - console.log('Não foi possível carregar dados em tempo real da API principal. Usando cookie fallback.'); - const getCookie = (name: string): string => { - if (typeof document === 'undefined') return ''; - const value = `; ${document.cookie}`; - const parts = value.split(`; ${name}=`); - if (parts.length === 2) return parts.pop()!.split(';').shift() || ''; - return ''; - }; - - const savedPlan = getCookie('agendapro_plan'); - if (savedPlan) { - setTenants(prev => prev.map(t => { - if (t.id === '1') { - let mrr = 99.90; - if (savedPlan === 'Starter') mrr = 49.90; - else if (savedPlan === 'Business') mrr = 199.90; - else if (savedPlan === 'Enterprise') mrr = 399.90; - return { ...t, plan: savedPlan, mrr }; - } - return t; - })); - } - }); + fetchTenants(); }, []); const filtered = tenants.filter(t => { - const matchSearch = t.name.toLowerCase().includes(search.toLowerCase()) || t.email.toLowerCase().includes(search.toLowerCase()); + const matchSearch = t.name.toLowerCase().includes(search.toLowerCase()) || (t.email && t.email.toLowerCase().includes(search.toLowerCase())); const matchStatus = filterStatus === 'all' || t.status === filterStatus; return matchSearch && matchStatus; }); const openEdit = (t: Tenant) => { setEditing(t); setForm({ ...t }); setShowModal(true); }; - const openAdd = () => { setEditing(null); setForm({ id: Date.now().toString(), status: 'trial', plan: 'Starter', mrr: 0, createdAt: new Date().toISOString().split('T')[0] }); setShowModal(true); }; - const handleSave = () => { - if (!form.name?.trim()) return; - if (editing) { setTenants(prev => prev.map(t => t.id === editing.id ? { ...t, ...form } as Tenant : t)); } - else { setTenants(prev => [...prev, form as Tenant]); } - setShowModal(false); - }; - const handleDelete = (id: string) => { setTenants(prev => prev.filter(t => t.id !== id)); setDeleteConfirm(null); }; + const openAdd = () => { setEditing(null); setForm({ status: 'trial', plan: 'Starter', type: 'Barbearia' }); setShowModal(true); }; - const toggleStatus = async (id: string, newStatus: string) => { - if (id === '1') { - try { - const res = await fetch('/api/tenant/subscription/simulate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - slug: 'barbearia-premium', - status: newStatus === 'active' ? 'active' : 'suspended' - }) - }); - if (!res.ok) { - alert('Erro ao sincronizar status no banco de dados do AgendaPRO.'); - return; - } - } catch (err) { - console.error('Falha ao conectar na API do AgendaPRO:', err); + const handleSave = async () => { + if (!form.name?.trim() || !form.slug?.trim()) return; + const method = editing ? 'PUT' : 'POST'; + try { + const res = await fetch('/api/tenants', { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(form) + }); + if (res.ok) { + fetchTenants(); + setShowModal(false); } + } catch (e) { + console.error(e); } - setTenants(prev => prev.map(t => t.id === id ? { ...t, status: newStatus } : t)); }; + const handleDelete = async (id: string) => { + try { + await fetch(`/api/tenants?id=${id}`, { method: 'DELETE' }); + setTenants(prev => prev.filter(t => t.id !== id)); + setDeleteConfirm(null); + } catch (e) { + console.error(e); + } + }; + + const toggleStatus = async (t: Tenant, newStatus: string) => { + try { + const res = await fetch('/api/tenants', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...t, status: newStatus }) + }); + if (res.ok) { + fetchTenants(); + } + } catch (err) { + console.error(err); + } + }; + + if (loading) return
Carregando estabelecimentos...
; + return (
@@ -145,12 +121,12 @@ export default function Tenants() { {filtered.map(t => ( -
{t.name[0]}
{t.name}
{t.email}
+
{t.name[0]}
{t.name}
{t.email || t.slug}
{t.type} {t.plan} - {statusMap[t.status]?.label} + {statusMap[t.status]?.label || t.status} {t.mrr > 0 ? `R$ ${t.mrr.toFixed(2)}` : '-'} - {new Date(t.createdAt + 'T12:00:00').toLocaleDateString('pt-BR')} + {t.createdAt ? new Date(t.createdAt + 'T12:00:00').toLocaleDateString('pt-BR') : '-'} {deleteConfirm === t.id ? (
@@ -160,14 +136,15 @@ export default function Tenants() { ) : (
- {t.status === 'active' && } - {t.status === 'suspended' && } + {t.status === 'active' && } + {t.status === 'suspended' && }
)} ))} + {filtered.length === 0 && Nenhum assinante encontrado.}
@@ -183,7 +160,7 @@ export default function Tenants() {
setForm(p => ({ ...p, name: e.target.value }))} />
-
setForm(p => ({ ...p, slug: e.target.value }))} />
+
setForm(p => ({ ...p, slug: e.target.value }))} disabled={!!editing} />