200 lines
11 KiB
TypeScript
200 lines
11 KiB
TypeScript
'use client';
|
|
import { useState, useEffect } from '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; password?: string;
|
|
}
|
|
|
|
const statusMap: Record<string, { label: string; cls: string }> = {
|
|
active: { label: 'Ativo', cls: 'badge-success' }, trial: { label: 'Trial', cls: 'badge-info' },
|
|
past_due: { label: 'Inadimplente', cls: 'badge-danger' }, cancelled: { label: 'Cancelado', cls: 'badge-warning' },
|
|
suspended: { label: 'Suspenso', cls: 'badge-danger' },
|
|
};
|
|
|
|
export default function Tenants() {
|
|
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(() => {
|
|
fetchTenants();
|
|
}, []);
|
|
|
|
const filtered = tenants.filter(t => {
|
|
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({ status: 'trial', plan: 'Starter', type: 'Barbearia' }); setShowModal(true); };
|
|
|
|
const handleSave = async () => {
|
|
if (!form.name?.trim() || !form.slug?.trim()) return;
|
|
const method = editing ? 'PUT' : 'POST';
|
|
try {
|
|
const res = await fetch('/api/tenants', {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(form)
|
|
});
|
|
if (res.ok) {
|
|
fetchTenants();
|
|
setShowModal(false);
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (id: string) => {
|
|
try {
|
|
await fetch(`/api/tenants?id=${id}`, { method: 'DELETE' });
|
|
setTenants(prev => prev.filter(t => t.id !== id));
|
|
setDeleteConfirm(null);
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
};
|
|
|
|
const toggleStatus = async (t: Tenant, newStatus: string) => {
|
|
try {
|
|
const res = await fetch('/api/tenants', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ ...t, status: newStatus })
|
|
});
|
|
if (res.ok) {
|
|
fetchTenants();
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
if (loading) return <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">
|
|
<div className="flex-gap" style={{ gap: '12px' }}>
|
|
<div style={{ position: 'relative' }}>
|
|
<Search size={18} style={{ position: 'absolute', left: 14, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)' }} />
|
|
<input className="form-input" placeholder="Buscar assinante..." style={{ width: 300, paddingLeft: 42 }} value={search} onChange={e => setSearch(e.target.value)} />
|
|
</div>
|
|
<select className="form-select" style={{ width: 160 }} value={filterStatus} onChange={e => setFilterStatus(e.target.value)}>
|
|
<option value="all">Todos</option>
|
|
<option value="active">Ativos</option>
|
|
<option value="trial">Em Trial</option>
|
|
<option value="past_due">Inadimplentes</option>
|
|
<option value="cancelled">Cancelados</option>
|
|
<option value="suspended">Suspensos</option>
|
|
</select>
|
|
</div>
|
|
<button className="btn btn-primary" onClick={openAdd}><Plus size={16} /> Novo Assinante</button>
|
|
</div>
|
|
|
|
<div className="card" style={{ padding: 0 }}>
|
|
<div className="table-container">
|
|
<table>
|
|
<thead><tr><th>Estabelecimento</th><th>Tipo</th><th>Plano</th><th>Status</th><th>MRR</th><th>Desde</th><th style={{ width: 120 }}>Ações</th></tr></thead>
|
|
<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 || 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 || '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)' }}>{t.createdAt ? new Date(t.createdAt + 'T12:00:00').toLocaleDateString('pt-BR') : '-'}</td>
|
|
<td>
|
|
{deleteConfirm === t.id ? (
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
<button className="btn btn-sm btn-danger" onClick={() => handleDelete(t.id)}>Sim</button>
|
|
<button className="btn btn-sm btn-secondary" onClick={() => setDeleteConfirm(null)}>Não</button>
|
|
</div>
|
|
) : (
|
|
<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, '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>
|
|
</div>
|
|
|
|
{showModal && (
|
|
<div className="modal-overlay" onClick={() => setShowModal(false)}>
|
|
<div className="modal" onClick={e => e.stopPropagation()}>
|
|
<div className="modal-header">
|
|
<h2 className="modal-title">{editing ? 'Editar Assinante' : 'Novo Assinante'}</h2>
|
|
<button className="btn-ghost btn-icon" onClick={() => setShowModal(false)}><X size={20} /></button>
|
|
</div>
|
|
<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 }))} 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>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div className="grid-2">
|
|
<div className="form-group"><label className="form-label">E-mail (Login) *</label><input type="email" required className="form-input" value={form.email || ''} onChange={e => setForm(p => ({ ...p, email: e.target.value }))} /></div>
|
|
<div className="form-group"><label className="form-label">Telefone</label><input className="form-input" value={form.phone || ''} onChange={e => setForm(p => ({ ...p, phone: e.target.value }))} /></div>
|
|
</div>
|
|
<div className="grid-2">
|
|
<div className="form-group"><label className="form-label">Senha de Acesso {editing ? '(Deixe em branco para manter)' : '*'}</label><input type="text" className="form-input" placeholder={editing ? '••••••••' : 'Senha do Inquilino'} value={form.password || ''} onChange={e => setForm(p => ({ ...p, password: e.target.value }))} /></div>
|
|
</div>
|
|
<div className="grid-2">
|
|
<div className="form-group"><label className="form-label">Plano</label>
|
|
<select className="form-select" value={form.plan || ''} onChange={e => setForm(p => ({ ...p, plan: e.target.value }))}>
|
|
<option>Starter</option><option>Professional</option><option>Business</option><option>Enterprise</option>
|
|
</select>
|
|
</div>
|
|
<div className="form-group"><label className="form-label">Status</label>
|
|
<select className="form-select" value={form.status || ''} onChange={e => setForm(p => ({ ...p, status: e.target.value }))}>
|
|
<option value="active">Ativo</option><option value="trial">Trial</option><option value="past_due">Inadimplente</option><option value="suspended">Suspenso</option><option value="cancelled">Cancelado</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="modal-footer">
|
|
<button className="btn btn-secondary" onClick={() => setShowModal(false)}>Cancelar</button>
|
|
<button className="btn btn-primary" onClick={handleSave}>{editing ? 'Salvar' : 'Criar Assinante'}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|