feat: conectar Admin App ao banco de dados PostgreSQL
Build and Deploy (Gitea Actions) / build-and-deploy (push) Failing after 1m56s
Details
Build and Deploy (Gitea Actions) / build-and-deploy (push) Failing after 1m56s
Details
This commit is contained in:
parent
867275388a
commit
d8ae863327
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, { label: string; cls: string }> = {
|
|||
suspended: { label: 'Suspenso', cls: 'badge-danger' },
|
||||
};
|
||||
|
||||
export default function AdminDashboard({ onNavigate }: Props) {
|
||||
const [tenants, setTenants] = useState(recentTenants);
|
||||
const [mrrValue, setMrrValue] = useState(12480);
|
||||
const maxRevenue = Math.max(...revenueData);
|
||||
|
||||
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 planColors: Record<string, string> = {
|
||||
Starter: '#3b82f6',
|
||||
Professional: '#6C63FF',
|
||||
Business: '#a855f7',
|
||||
Enterprise: '#f59e0b',
|
||||
};
|
||||
|
||||
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;
|
||||
}));
|
||||
export default function AdminDashboard({ onNavigate }: Props) {
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
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);
|
||||
}
|
||||
useEffect(() => {
|
||||
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 <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 (
|
||||
<div className="slide-up">
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon amber"><DollarSign size={24} /></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 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 className="stat-card">
|
||||
<div className="stat-icon green"><Users size={24} /></div>
|
||||
<div>
|
||||
<div className="stat-value">87</div>
|
||||
<div className="stat-value">{data.activeTenants}</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 className="stat-card">
|
||||
<div className="stat-icon purple"><TrendingUp size={24} /></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 style={{ fontSize: '12px', fontWeight: 600, color: 'var(--success)', marginTop: 4 }}>↑ 2.1% vs mês anterior</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon red"><AlertTriangle size={24} /></div>
|
||||
<div>
|
||||
<div className="stat-value">5</div>
|
||||
<div className="stat-value">{data.pastDueTenants}</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>
|
||||
|
|
@ -114,33 +94,29 @@ export default function AdminDashboard({ onNavigate }: Props) {
|
|||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<div className="card">
|
||||
<h3 className="card-title mb-4">Distribuição de Planos</h3>
|
||||
{[
|
||||
{ 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 (
|
||||
<div key={p.name} style={{ marginBottom: '12px' }}>
|
||||
<div className="flex-between" style={{ marginBottom: '4px' }}>
|
||||
<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 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 className="card">
|
||||
<h3 className="card-title mb-4">Métricas Rápidas</h3>
|
||||
{[
|
||||
{ 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) => (
|
||||
<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: '14px', fontWeight: 700 }}>{m.value}</span>
|
||||
</div>
|
||||
|
|
@ -162,15 +138,18 @@ export default function AdminDashboard({ onNavigate }: Props) {
|
|||
<table>
|
||||
<thead><tr><th>Estabelecimento</th><th>Plano</th><th>Status</th><th>MRR</th><th>Desde</th></tr></thead>
|
||||
<tbody>
|
||||
{tenants.map(t => (
|
||||
{data.recentTenants.map((t: any) => {
|
||||
const st = statusMap[t.status] || statusMap.trial;
|
||||
return (
|
||||
<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><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={{ color: 'var(--text-secondary)', fontSize: '13px' }}>{new Date(t.date + 'T12:00:00').toLocaleDateString('pt-BR')}</td>
|
||||
</tr>
|
||||
))}
|
||||
)})}
|
||||
{data.recentTenants.length === 0 && <tr><td colSpan={5} style={{textAlign: 'center', padding: '40px'}}>Nenhum assinante encontrado.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<string, { label: string; cls: string; icon: typeof Check
|
|||
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() {
|
||||
const [payments, setPayments] = useState<Payment[]>([]);
|
||||
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 <div className="p-8 text-center" style={{color: 'var(--text-muted)'}}>Carregando pagamentos...</div>;
|
||||
|
||||
return (
|
||||
<div className="slide-up">
|
||||
<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>
|
||||
</select>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
<tbody>
|
||||
{filtered.map(p => {
|
||||
const st = statusMap[p.status];
|
||||
const st = statusMap[p.status] || statusMap.pending;
|
||||
return (
|
||||
<tr key={p.id}>
|
||||
<td><span style={{ fontFamily: 'monospace', fontSize: 13, color: 'var(--text-muted)' }}>{p.invoiceId}</span></td>
|
||||
|
|
|
|||
|
|
@ -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<Plan> = { name: '', slug: '', priceMonthly: 0, priceYearly: 0, maxProfessionals: 1, maxServices: 10, features: [], active: true };
|
||||
|
||||
export default function Plans() {
|
||||
const [plans, setPlans] = useState<Plan[]>(initialPlans);
|
||||
const [plans, setPlans] = useState<Plan[]>([]);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editing, setEditing] = useState<Plan | null>(null);
|
||||
const [form, setForm] = useState<Partial<Plan>>(emptyPlan);
|
||||
const [featureInput, setFeatureInput] = useState('');
|
||||
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 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 <div className="p-8 text-center" style={{color: 'var(--text-muted)'}}>Carregando planos...</div>;
|
||||
|
||||
return (
|
||||
<div className="slide-up">
|
||||
<div className="flex-between mb-6">
|
||||
|
|
@ -86,6 +122,12 @@ export default function Plans() {
|
|||
))}
|
||||
</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 && (
|
||||
<div className="modal-overlay" onClick={() => setShowModal(false)}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||
|
|
@ -96,15 +138,15 @@ export default function Plans() {
|
|||
<div className="modal-body">
|
||||
<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">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 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 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 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 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 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) }))} disabled /></div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Recursos</label>
|
||||
|
|
|
|||
|
|
@ -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<string, { label: string; cls: string }> = {
|
|||
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<Tenant[]>(initialTenants);
|
||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState('all');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editing, setEditing] = useState<Tenant | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
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(() => {
|
||||
// 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') {
|
||||
const handleSave = async () => {
|
||||
if (!form.name?.trim() || !form.slug?.trim()) return;
|
||||
const method = editing ? 'PUT' : 'POST';
|
||||
try {
|
||||
const res = await fetch('/api/tenant/subscription/simulate', {
|
||||
method: 'POST',
|
||||
const res = await fetch('/api/tenants', {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
slug: 'barbearia-premium',
|
||||
status: newStatus === 'active' ? 'active' : 'suspended'
|
||||
})
|
||||
body: JSON.stringify(form)
|
||||
});
|
||||
if (!res.ok) {
|
||||
alert('Erro ao sincronizar status no banco de dados do AgendaPRO.');
|
||||
return;
|
||||
if (res.ok) {
|
||||
fetchTenants();
|
||||
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) {
|
||||
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 (
|
||||
<div className="slide-up">
|
||||
<div className="flex-between mb-6">
|
||||
|
|
@ -145,12 +121,12 @@ export default function Tenants() {
|
|||
<tbody>
|
||||
{filtered.map(t => (
|
||||
<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><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={{ 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>
|
||||
{deleteConfirm === t.id ? (
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
|
|
@ -160,14 +136,15 @@ export default function Tenants() {
|
|||
) : (
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<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 === 'suspended' && <button className="btn-ghost btn-icon" title="Reativar" style={{ color: 'var(--success)' }} onClick={() => toggleStatus(t.id, 'active')}><CheckCircle 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, '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>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{filtered.length === 0 && <tr><td colSpan={7} style={{textAlign: 'center', padding: '40px'}}>Nenhum assinante encontrado.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
@ -183,7 +160,7 @@ export default function Tenants() {
|
|||
<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="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>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
Loading…
Reference in New Issue