feat: conectar Admin App ao banco de dados PostgreSQL
Build and Deploy (Gitea Actions) / build-and-deploy (push) Failing after 1m56s Details

This commit is contained in:
Sidney 2026-06-07 19:42:48 -03:00
parent 867275388a
commit d8ae863327
9 changed files with 484 additions and 193 deletions

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -3,14 +3,6 @@ import { DollarSign, Users, TrendingUp, AlertTriangle, ArrowUpRight, CreditCard,
interface Props { onNavigate: (page: string) => void; } 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 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']; const months = ['Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez', 'Jan', 'Fev', 'Mar', 'Abr', 'Mai'];
@ -22,74 +14,62 @@ const statusMap: Record<string, { label: string; cls: string }> = {
suspended: { label: 'Suspenso', cls: 'badge-danger' }, suspended: { label: 'Suspenso', cls: 'badge-danger' },
}; };
export default function AdminDashboard({ onNavigate }: Props) { const planColors: Record<string, string> = {
const [tenants, setTenants] = useState(recentTenants); Starter: '#3b82f6',
const [mrrValue, setMrrValue] = useState(12480); Professional: '#6C63FF',
const maxRevenue = Math.max(...revenueData); Business: '#a855f7',
Enterprise: '#f59e0b',
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'); export default function AdminDashboard({ onNavigate }: Props) {
if (savedPlan) { const [data, setData] = useState<any>(null);
setTenants(prev => prev.map(t => { const [loading, setLoading] = useState(true);
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; useEffect(() => {
if (savedPlan === 'Starter') diff = 49.90 - 99.90; fetch('/api/dashboard')
else if (savedPlan === 'Business') diff = 199.90 - 99.90; .then(res => res.json())
else if (savedPlan === 'Enterprise') diff = 399.90 - 99.90; .then(d => setData(d))
setMrrValue(12480 + diff); .catch(err => console.error(err))
} .finally(() => setLoading(false));
}, []); }, []);
const maxRevenue = Math.max(...revenueData);
if (loading) return <div className="p-8 text-center" style={{color: 'var(--text-muted)'}}>Carregando dashboard...</div>;
if (!data) return <div className="p-8 text-center" style={{color: 'var(--text-muted)'}}>Erro ao carregar dados.</div>;
const totalTenants = data.activeTenants + data.trialTenants + data.pastDueTenants;
return ( return (
<div className="slide-up"> <div className="slide-up">
<div className="stats-grid"> <div className="stats-grid">
<div className="stat-card"> <div className="stat-card">
<div className="stat-icon amber"><DollarSign size={24} /></div> <div className="stat-icon amber"><DollarSign size={24} /></div>
<div> <div>
<div className="stat-value">R$ {mrrValue.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</div> <div className="stat-value">R$ {data.mrr.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</div>
<div className="stat-label">MRR (Receita Mensal)</div> <div className="stat-label">MRR (Receita Mensal)</div>
<div style={{ fontSize: '12px', fontWeight: 600, color: 'var(--success)', marginTop: 4 }}> 18% vs mês anterior</div> <div style={{ fontSize: '12px', fontWeight: 600, color: 'var(--success)', marginTop: 4 }}>Tempo Real</div>
</div> </div>
</div> </div>
<div className="stat-card"> <div className="stat-card">
<div className="stat-icon green"><Users size={24} /></div> <div className="stat-icon green"><Users size={24} /></div>
<div> <div>
<div className="stat-value">87</div> <div className="stat-value">{data.activeTenants}</div>
<div className="stat-label">Assinantes Ativos</div> <div className="stat-label">Assinantes Ativos</div>
<div style={{ fontSize: '12px', fontWeight: 600, color: 'var(--success)', marginTop: 4 }}> 12 novos este mês</div>
</div> </div>
</div> </div>
<div className="stat-card"> <div className="stat-card">
<div className="stat-icon purple"><TrendingUp size={24} /></div> <div className="stat-icon purple"><TrendingUp size={24} /></div>
<div> <div>
<div className="stat-value">94.3%</div> <div className="stat-value">{totalTenants > 0 ? ((data.activeTenants / totalTenants) * 100).toFixed(1) : 0}%</div>
<div className="stat-label">Taxa de Retenção</div> <div className="stat-label">Taxa de Retenção</div>
<div style={{ fontSize: '12px', fontWeight: 600, color: 'var(--success)', marginTop: 4 }}> 2.1% vs mês anterior</div>
</div> </div>
</div> </div>
<div className="stat-card"> <div className="stat-card">
<div className="stat-icon red"><AlertTriangle size={24} /></div> <div className="stat-icon red"><AlertTriangle size={24} /></div>
<div> <div>
<div className="stat-value">5</div> <div className="stat-value">{data.pastDueTenants}</div>
<div className="stat-label">Inadimplentes</div> <div className="stat-label">Inadimplentes</div>
<div style={{ fontSize: '12px', fontWeight: 600, color: 'var(--danger)', marginTop: 4 }}> 2 vs mês anterior</div>
</div> </div>
</div> </div>
</div> </div>
@ -114,33 +94,29 @@ export default function AdminDashboard({ onNavigate }: Props) {
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div className="card"> <div className="card">
<h3 className="card-title mb-4">Distribuição de Planos</h3> <h3 className="card-title mb-4">Distribuição de Planos</h3>
{[ {data.plansDistribution.map((p: any) => {
{ name: 'Starter', count: 35, color: '#3b82f6', pct: 40 }, const pct = totalTenants > 0 ? ((p.count / totalTenants) * 100).toFixed(1) : 0;
{ name: 'Professional', count: 32, color: '#6C63FF', pct: 37 }, const color = planColors[p.name] || '#94a3b8';
{ name: 'Business', count: 15, color: '#a855f7', pct: 17 }, return (
{ name: 'Enterprise', count: 5, color: '#f59e0b', pct: 6 },
].map(p => (
<div key={p.name} style={{ marginBottom: '12px' }}> <div key={p.name} style={{ marginBottom: '12px' }}>
<div className="flex-between" style={{ marginBottom: '4px' }}> <div className="flex-between" style={{ marginBottom: '4px' }}>
<span style={{ fontSize: '13px', fontWeight: 600 }}>{p.name}</span> <span style={{ fontSize: '13px', fontWeight: 600 }}>{p.name}</span>
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>{p.count} ({p.pct}%)</span> <span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>{p.count} ({pct}%)</span>
</div> </div>
<div style={{ height: '6px', background: 'var(--bg-elevated)', borderRadius: '3px', overflow: 'hidden' }}> <div style={{ height: '6px', background: 'var(--bg-elevated)', borderRadius: '3px', overflow: 'hidden' }}>
<div style={{ width: `${p.pct}%`, height: '100%', background: p.color, borderRadius: '3px' }} /> <div style={{ width: `${pct}%`, height: '100%', background: color, borderRadius: '3px' }} />
</div> </div>
</div> </div>
))} )})}
</div> </div>
<div className="card"> <div className="card">
<h3 className="card-title mb-4">Métricas Rápidas</h3> <h3 className="card-title mb-4">Métricas Rápidas</h3>
{[ {[
{ label: 'Ticket Médio', value: 'R$ 143,45' }, { label: 'Ticket Médio', value: data.activeTenants > 0 ? `R$ ${(data.mrr / data.activeTenants).toFixed(2)}` : 'R$ 0,00' },
{ label: 'Churn Rate', value: '5.7%' }, { label: 'Em Trial', value: String(data.trialTenants) },
{ label: 'LTV Médio', value: 'R$ 1.723' },
{ label: 'Em Trial', value: '14' },
].map((m, i) => ( ].map((m, i) => (
<div key={i} className="flex-between" style={{ padding: '8px 0', borderBottom: i < 3 ? '1px solid var(--border-color)' : 'none' }}> <div key={i} className="flex-between" style={{ padding: '8px 0', borderBottom: i < 1 ? '1px solid var(--border-color)' : 'none' }}>
<span style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>{m.label}</span> <span style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>{m.label}</span>
<span style={{ fontSize: '14px', fontWeight: 700 }}>{m.value}</span> <span style={{ fontSize: '14px', fontWeight: 700 }}>{m.value}</span>
</div> </div>
@ -162,15 +138,18 @@ export default function AdminDashboard({ onNavigate }: Props) {
<table> <table>
<thead><tr><th>Estabelecimento</th><th>Plano</th><th>Status</th><th>MRR</th><th>Desde</th></tr></thead> <thead><tr><th>Estabelecimento</th><th>Plano</th><th>Status</th><th>MRR</th><th>Desde</th></tr></thead>
<tbody> <tbody>
{tenants.map(t => ( {data.recentTenants.map((t: any) => {
const st = statusMap[t.status] || statusMap.trial;
return (
<tr key={t.id}> <tr key={t.id}>
<td style={{ fontWeight: 600 }}><div className="flex-gap"><div className="avatar" style={{ width: 30, height: 30, fontSize: 11 }}>{t.name[0]}</div>{t.name}</div></td> <td style={{ fontWeight: 600 }}><div className="flex-gap"><div className="avatar" style={{ width: 30, height: 30, fontSize: 11 }}>{t.name[0]}</div>{t.name}</div></td>
<td><span className="badge badge-amber">{t.plan}</span></td> <td><span className="badge badge-amber">{t.plan}</span></td>
<td><span className={`badge ${statusMap[t.status].cls}`}>{statusMap[t.status].label}</span></td> <td><span className={`badge ${st.cls}`}>{st.label}</span></td>
<td style={{ fontWeight: 600 }}>{t.mrr > 0 ? `R$ ${t.mrr.toFixed(2)}` : '-'}</td> <td style={{ fontWeight: 600 }}>{t.mrr > 0 ? `R$ ${t.mrr.toFixed(2)}` : '-'}</td>
<td style={{ color: 'var(--text-secondary)', fontSize: '13px' }}>{new Date(t.date + 'T12:00:00').toLocaleDateString('pt-BR')}</td> <td style={{ color: 'var(--text-secondary)', fontSize: '13px' }}>{new Date(t.date + 'T12:00:00').toLocaleDateString('pt-BR')}</td>
</tr> </tr>
))} )})}
{data.recentTenants.length === 0 && <tr><td colSpan={5} style={{textAlign: 'center', padding: '40px'}}>Nenhum assinante encontrado.</td></tr>}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@ -1,5 +1,5 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { Search, Download, Eye, CheckCircle, AlertCircle, XCircle, DollarSign, Filter } from 'lucide-react'; import { Search, Download, Eye, CheckCircle, AlertCircle, XCircle, DollarSign, Filter } from 'lucide-react';
interface Payment { interface Payment {
@ -13,25 +13,24 @@ const statusMap: Record<string, { label: string; cls: string; icon: typeof Check
refunded: { label: 'Estornado', cls: 'badge-info', icon: DollarSign }, refunded: { label: 'Estornado', cls: 'badge-info', icon: DollarSign },
}; };
const initialPayments: Payment[] = [
{ id: '1', tenant: 'Barbearia Premium', plan: 'Professional', amount: 99.90, status: 'paid', method: 'Cartão de Crédito', date: '2026-05-31', invoiceId: 'INV-2026-001' },
{ id: '2', tenant: 'Studio Bella Nails', plan: 'Starter', amount: 49.90, status: 'paid', method: 'PIX', date: '2026-05-30', invoiceId: 'INV-2026-002' },
{ id: '3', tenant: 'Spa Relaxe', plan: 'Business', amount: 199.90, status: 'paid', method: 'Boleto', date: '2026-05-29', invoiceId: 'INV-2026-003' },
{ id: '4', tenant: 'Salão Glamour', plan: 'Starter', amount: 49.90, status: 'failed', method: 'Cartão de Crédito', date: '2026-05-28', invoiceId: 'INV-2026-004' },
{ id: '5', tenant: 'Estética Renova', plan: 'Business', amount: 199.90, status: 'paid', method: 'PIX', date: '2026-05-28', invoiceId: 'INV-2026-005' },
{ id: '6', tenant: 'Barbearia Premium', plan: 'Professional', amount: 99.90, status: 'paid', method: 'Cartão de Crédito', date: '2026-04-30', invoiceId: 'INV-2026-006' },
{ id: '7', tenant: 'Studio Bella Nails', plan: 'Starter', amount: 49.90, status: 'paid', method: 'PIX', date: '2026-04-30', invoiceId: 'INV-2026-007' },
{ id: '8', tenant: 'Spa Relaxe', plan: 'Business', amount: 199.90, status: 'paid', method: 'Boleto', date: '2026-04-29', invoiceId: 'INV-2026-008' },
{ id: '9', tenant: 'Salão Glamour', plan: 'Starter', amount: 49.90, status: 'pending', method: 'Boleto', date: '2026-05-15', invoiceId: 'INV-2026-009' },
{ id: '10', tenant: 'Barbearia do Zé', plan: 'Starter', amount: 49.90, status: 'refunded', method: 'PIX', date: '2026-04-20', invoiceId: 'INV-2026-010' },
];
export default function Payments() { export default function Payments() {
const [payments, setPayments] = useState<Payment[]>([]);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [filterStatus, setFilterStatus] = useState('all'); const [filterStatus, setFilterStatus] = useState('all');
const [filterMonth, setFilterMonth] = 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 matchSearch = p.tenant.toLowerCase().includes(search.toLowerCase()) || p.invoiceId.toLowerCase().includes(search.toLowerCase());
const matchStatus = filterStatus === 'all' || p.status === filterStatus; const matchStatus = filterStatus === 'all' || p.status === filterStatus;
const matchMonth = filterMonth === 'all' || p.date.startsWith(filterMonth); 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 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); 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 <div className="p-8 text-center" style={{color: 'var(--text-muted)'}}>Carregando pagamentos...</div>;
return ( return (
<div className="slide-up"> <div className="slide-up">
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(3, 1fr)' }}> <div className="stats-grid" style={{ gridTemplateColumns: 'repeat(3, 1fr)' }}>
@ -60,7 +64,10 @@ export default function Payments() {
<option value="all">Todos</option><option value="paid">Pagos</option><option value="pending">Pendentes</option><option value="failed">Falhas</option><option value="refunded">Estornados</option> <option value="all">Todos</option><option value="paid">Pagos</option><option value="pending">Pendentes</option><option value="failed">Falhas</option><option value="refunded">Estornados</option>
</select> </select>
<select className="form-select" style={{ width: 140 }} value={filterMonth} onChange={e => setFilterMonth(e.target.value)}> <select className="form-select" style={{ width: 140 }} value={filterMonth} onChange={e => setFilterMonth(e.target.value)}>
<option value="all">Todo período</option><option value="2026-05">Maio 2026</option><option value="2026-04">Abril 2026</option> <option value="all">Todo período</option>
{months.map(m => (
<option key={m} value={m}>{m}</option>
))}
</select> </select>
</div> </div>
<button className="btn btn-secondary"><Download size={16} /> Exportar CSV</button> <button className="btn btn-secondary"><Download size={16} /> Exportar CSV</button>
@ -72,7 +79,7 @@ export default function Payments() {
<thead><tr><th>Fatura</th><th>Estabelecimento</th><th>Plano</th><th>Valor</th><th>Status</th><th>Método</th><th>Data</th></tr></thead> <thead><tr><th>Fatura</th><th>Estabelecimento</th><th>Plano</th><th>Valor</th><th>Status</th><th>Método</th><th>Data</th></tr></thead>
<tbody> <tbody>
{filtered.map(p => { {filtered.map(p => {
const st = statusMap[p.status]; const st = statusMap[p.status] || statusMap.pending;
return ( return (
<tr key={p.id}> <tr key={p.id}>
<td><span style={{ fontFamily: 'monospace', fontSize: 13, color: 'var(--text-muted)' }}>{p.invoiceId}</span></td> <td><span style={{ fontFamily: 'monospace', fontSize: 13, color: 'var(--text-muted)' }}>{p.invoiceId}</span></td>

View File

@ -1,40 +1,76 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { Plus, X, Edit, Trash2, Check, Users, DollarSign, 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 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<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[]>(initialPlans); const [plans, setPlans] = useState<Plan[]>([]);
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [editing, setEditing] = useState<Plan | null>(null); const [editing, setEditing] = useState<Plan | null>(null);
const [form, setForm] = useState<Partial<Plan>>(emptyPlan); const [form, setForm] = useState<Partial<Plan>>(emptyPlan);
const [featureInput, setFeatureInput] = useState(''); const [featureInput, setFeatureInput] = useState('');
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null); const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const openAdd = () => { setEditing(null); setForm({ ...emptyPlan, id: Date.now().toString(), features: [] }); setShowModal(true); }; const fetchPlans = async () => {
const openEdit = (p: Plan) => { setEditing(p); setForm({ ...p }); setShowModal(true); }; try {
const handleSave = () => { const res = await fetch('/api/plans');
if (!form.name?.trim()) return; if (res.ok) {
if (editing) { setPlans(prev => prev.map(p => p.id === editing.id ? { ...p, ...form } as Plan : p)); } const data = await res.json();
else { setPlans(prev => [...prev, { ...form, subscribers: 0 } as Plan]); } setPlans(data);
setShowModal(false); }
} 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 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 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'];
if (loading) return <div className="p-8 text-center" style={{color: 'var(--text-muted)'}}>Carregando planos...</div>;
return ( return (
<div className="slide-up"> <div className="slide-up">
<div className="flex-between mb-6"> <div className="flex-between mb-6">
@ -86,6 +122,12 @@ export default function Plans() {
))} ))}
</div> </div>
{plans.length === 0 && (
<div className="p-8 text-center" style={{color: 'var(--text-muted)', border: '1px dashed var(--border-color)', borderRadius: 'var(--radius-lg)'}}>
Nenhum plano cadastrado.
</div>
)}
{showModal && ( {showModal && (
<div className="modal-overlay" onClick={() => setShowModal(false)}> <div className="modal-overlay" onClick={() => setShowModal(false)}>
<div className="modal" onClick={e => e.stopPropagation()}> <div className="modal" onClick={e => e.stopPropagation()}>
@ -96,15 +138,15 @@ export default function Plans() {
<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"><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"><label className="form-label">Slug</label><input className="form-input" value={form.slug || ''} onChange={e => setForm(p => ({ ...p, slug: e.target.value }))} /></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"><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"><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 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>
</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) }))} /></div> <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"><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 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>
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="form-label">Recursos</label> <label className="form-label">Recursos</label>

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; 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 { interface Tenant {
id: string; name: string; slug: string; type: string; plan: string; status: string; email: string; phone: string; mrr: number; createdAt: string; trialEnds: string; 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<string, { label: string; cls: string }> = {
suspended: { label: 'Suspenso', cls: 'badge-danger' }, 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() { export default function Tenants() {
const [tenants, setTenants] = useState<Tenant[]>(initialTenants); const [tenants, setTenants] = useState<Tenant[]>([]);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [filterStatus, setFilterStatus] = useState('all'); const [filterStatus, setFilterStatus] = useState('all');
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [editing, setEditing] = useState<Tenant | null>(null); const [editing, setEditing] = useState<Tenant | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null); const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [form, setForm] = useState<Partial<Tenant>>({}); const [form, setForm] = useState<Partial<Tenant>>({});
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(() => { useEffect(() => {
// Busca dados reais do banco do Barbearia Premium para mostrar com transparência fetchTenants();
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;
}));
}
});
}, []); }, []);
const filtered = tenants.filter(t => { 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; const matchStatus = filterStatus === 'all' || t.status === filterStatus;
return matchSearch && matchStatus; return matchSearch && matchStatus;
}); });
const openEdit = (t: Tenant) => { setEditing(t); setForm({ ...t }); setShowModal(true); }; 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 openAdd = () => { setEditing(null); setForm({ status: 'trial', plan: 'Starter', type: 'Barbearia' }); 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 toggleStatus = async (id: string, newStatus: string) => { const handleSave = async () => {
if (id === '1') { if (!form.name?.trim() || !form.slug?.trim()) return;
const method = editing ? 'PUT' : 'POST';
try { try {
const res = await fetch('/api/tenant/subscription/simulate', { const res = await fetch('/api/tenants', {
method: 'POST', method,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify(form)
slug: 'barbearia-premium',
status: newStatus === 'active' ? 'active' : 'suspended'
})
}); });
if (!res.ok) { if (res.ok) {
alert('Erro ao sincronizar status no banco de dados do AgendaPRO.'); fetchTenants();
return; setShowModal(false);
}
} catch (e) {
console.error(e);
}
};
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) { } catch (err) {
console.error('Falha ao conectar na API do AgendaPRO:', err); console.error(err);
} }
}
setTenants(prev => prev.map(t => t.id === id ? { ...t, status: newStatus } : t));
}; };
if (loading) return <div className="p-8 text-center" style={{color: 'var(--text-muted)'}}>Carregando estabelecimentos...</div>;
return ( return (
<div className="slide-up"> <div className="slide-up">
<div className="flex-between mb-6"> <div className="flex-between mb-6">
@ -145,12 +121,12 @@ export default function Tenants() {
<tbody> <tbody>
{filtered.map(t => ( {filtered.map(t => (
<tr key={t.id}> <tr key={t.id}>
<td><div className="flex-gap"><div className="avatar" style={{ width: 32, height: 32, fontSize: 12 }}>{t.name[0]}</div><div><div style={{ fontWeight: 600 }}>{t.name}</div><div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{t.email}</div></div></div></td> <td><div className="flex-gap"><div className="avatar" style={{ width: 32, height: 32, fontSize: 12 }}>{t.name[0]}</div><div><div style={{ fontWeight: 600 }}>{t.name}</div><div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{t.email || t.slug}</div></div></div></td>
<td style={{ fontSize: 13 }}>{t.type}</td> <td style={{ fontSize: 13 }}>{t.type}</td>
<td><span className="badge badge-amber">{t.plan}</span></td> <td><span className="badge badge-amber">{t.plan}</span></td>
<td><span className={`badge ${statusMap[t.status]?.cls}`}>{statusMap[t.status]?.label}</span></td> <td><span className={`badge ${statusMap[t.status]?.cls || 'badge-secondary'}`}>{statusMap[t.status]?.label || t.status}</span></td>
<td style={{ fontWeight: 600 }}>{t.mrr > 0 ? `R$ ${t.mrr.toFixed(2)}` : '-'}</td> <td style={{ fontWeight: 600 }}>{t.mrr > 0 ? `R$ ${t.mrr.toFixed(2)}` : '-'}</td>
<td style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{new Date(t.createdAt + 'T12:00:00').toLocaleDateString('pt-BR')}</td> <td style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t.createdAt ? new Date(t.createdAt + 'T12:00:00').toLocaleDateString('pt-BR') : '-'}</td>
<td> <td>
{deleteConfirm === t.id ? ( {deleteConfirm === t.id ? (
<div style={{ display: 'flex', gap: 4 }}> <div style={{ display: 'flex', gap: 4 }}>
@ -160,14 +136,15 @@ export default function Tenants() {
) : ( ) : (
<div style={{ display: 'flex', gap: 4 }}> <div style={{ display: 'flex', gap: 4 }}>
<button className="btn-ghost btn-icon" title="Editar" onClick={() => openEdit(t)}><Edit size={14} /></button> <button className="btn-ghost btn-icon" title="Editar" onClick={() => openEdit(t)}><Edit size={14} /></button>
{t.status === 'active' && <button className="btn-ghost btn-icon" title="Suspender" style={{ color: 'var(--warning)' }} onClick={() => toggleStatus(t.id, 'suspended')}><Ban size={14} /></button>} {t.status === 'active' && <button className="btn-ghost btn-icon" title="Suspender" style={{ color: 'var(--warning)' }} onClick={() => toggleStatus(t, 'suspended')}><Ban size={14} /></button>}
{t.status === 'suspended' && <button className="btn-ghost btn-icon" title="Reativar" style={{ color: 'var(--success)' }} onClick={() => toggleStatus(t.id, 'active')}><CheckCircle size={14} /></button>} {t.status === 'suspended' && <button className="btn-ghost btn-icon" title="Reativar" style={{ color: 'var(--success)' }} onClick={() => toggleStatus(t, 'active')}><CheckCircle size={14} /></button>}
<button className="btn-ghost btn-icon" title="Excluir" style={{ color: 'var(--danger)' }} onClick={() => setDeleteConfirm(t.id)}><Trash2 size={14} /></button> <button className="btn-ghost btn-icon" title="Excluir" style={{ color: 'var(--danger)' }} onClick={() => setDeleteConfirm(t.id)}><Trash2 size={14} /></button>
</div> </div>
)} )}
</td> </td>
</tr> </tr>
))} ))}
{filtered.length === 0 && <tr><td colSpan={7} style={{textAlign: 'center', padding: '40px'}}>Nenhum assinante encontrado.</td></tr>}
</tbody> </tbody>
</table> </table>
</div> </div>
@ -183,7 +160,7 @@ export default function Tenants() {
<div className="modal-body"> <div className="modal-body">
<div className="form-group"><label className="form-label">Nome do Estabelecimento *</label><input className="form-input" value={form.name || ''} onChange={e => setForm(p => ({ ...p, name: e.target.value }))} /></div> <div className="form-group"><label className="form-label">Nome do Estabelecimento *</label><input className="form-input" value={form.name || ''} onChange={e => setForm(p => ({ ...p, name: e.target.value }))} /></div>
<div className="grid-2"> <div className="grid-2">
<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 }))} /></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 className="form-group"><label className="form-label">Tipo</label> <div className="form-group"><label className="form-label">Tipo</label>
<select className="form-select" value={form.type || ''} onChange={e => setForm(p => ({ ...p, type: e.target.value }))}> <select className="form-select" value={form.type || ''} onChange={e => setForm(p => ({ ...p, type: e.target.value }))}>
<option value="Barbearia">Barbearia</option><option value="Manicure">Manicure</option><option value="Massagem">Massagem</option><option value="Estética">Estética</option><option value="Salão">Salão</option> <option value="Barbearia">Barbearia</option><option value="Manicure">Manicure</option><option value="Massagem">Massagem</option><option value="Estética">Estética</option><option value="Salão">Salão</option>

29
admin/src/lib/db.ts Normal file
View File

@ -0,0 +1,29 @@
import { Pool } from 'pg';
const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgresql://agendapro:agendapro123@localhost:5432/agendapro',
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
pool.on('error', (err) => {
console.error('Unexpected error on idle client', err);
});
export async function query(text: string, params?: unknown[]) {
const start = Date.now();
const res = await pool.query(text, params);
const duration = Date.now() - start;
if (process.env.NODE_ENV === 'development') {
console.log('Executed query', { text: text.substring(0, 80), duration, rows: res.rowCount });
}
return res;
}
export async function getClient() {
const client = await pool.connect();
return client;
}
export default pool;