feat: adicionar notificações reais no painel admin, limpar no banco e habilitar exportar CSV nos pagamentos
Build and Deploy (Gitea Actions) / build-and-deploy (push) Successful in 2m38s Details

This commit is contained in:
Sidney 2026-06-07 22:13:47 -03:00
parent 39c06c03d7
commit 4bb1e4b690
5 changed files with 160 additions and 11 deletions

View File

@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
export async function GET(req: NextRequest) {
try {
// Fetch admin notifications (where tenant_id is null)
const notifsRes = await query(`
SELECT
id,
type,
title,
message,
is_read as read,
created_at
FROM notifications
WHERE tenant_id IS NULL
ORDER BY created_at DESC
LIMIT 20
`);
// Format relative time helper
const formatted = notifsRes.rows.map(n => {
const diffMs = new Date().getTime() - new Date(n.created_at).getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
let timeStr = 'Agora mesmo';
if (diffDays > 0) {
timeStr = `${diffDays} dia${diffDays > 1 ? 's' : ''} atrás`;
} else if (diffHours > 0) {
timeStr = `${diffHours} hora${diffHours > 1 ? 's' : ''} atrás`;
} else if (diffMins > 0) {
timeStr = `${diffMins} min${diffMins > 1 ? 's' : ''} atrás`;
}
return {
id: n.id,
type: n.type,
title: n.title,
message: n.message,
read: n.read,
time: timeStr
};
});
return NextResponse.json(formatted);
} catch (error: any) {
console.error('GET Admin Notifications error:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function PUT(req: NextRequest) {
try {
// Mark all admin notifications as read
await query('UPDATE notifications SET is_read = true WHERE tenant_id IS NULL');
return NextResponse.json({ success: true });
} catch (error: any) {
console.error('PUT Admin Notifications error:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function DELETE(req: NextRequest) {
try {
// Delete all admin notifications
await query('DELETE FROM notifications WHERE tenant_id IS NULL');
return NextResponse.json({ success: true });
} catch (error: any) {
console.error('DELETE Admin Notifications error:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@ -27,12 +27,6 @@ const pages: Record<string, { title: string; subtitle: string }> = {
settings: { title: 'Configurações', subtitle: 'Configurações do sistema' }, settings: { title: 'Configurações', subtitle: 'Configurações do sistema' },
}; };
const mockNotifications = [
{ id: 1, title: 'Nova Assinatura', message: 'Barbearia do Zé contratou o plano Starter', type: 'success', time: '5 min atrás', read: false },
{ id: 2, title: 'Falha no Pagamento', message: 'Salão Glamour não conseguiu renovar a assinatura', type: 'warning', time: '1 hora atrás', read: true },
{ id: 3, title: 'Ticket de Suporte', message: 'Novo chamado aberto por Studio Bella Nails', type: 'info', time: '2 horas atrás', read: true },
];
export default function AdminPage() { export default function AdminPage() {
const router = useRouter(); const router = useRouter();
const [currentPage, setCurrentPage] = useState('dashboard'); const [currentPage, setCurrentPage] = useState('dashboard');
@ -40,7 +34,7 @@ export default function AdminPage() {
const [showNotifications, setShowNotifications] = useState(false); const [showNotifications, setShowNotifications] = useState(false);
const [showProfile, setShowProfile] = useState(false); const [showProfile, setShowProfile] = useState(false);
const [notifications, setNotifications] = useState(mockNotifications); const [notifications, setNotifications] = useState<any[]>([]);
const [adminName, setAdminName] = useState('Super Admin'); const [adminName, setAdminName] = useState('Super Admin');
const [adminEmail, setAdminEmail] = useState('admin@agendapro.com'); const [adminEmail, setAdminEmail] = useState('admin@agendapro.com');
@ -48,6 +42,18 @@ export default function AdminPage() {
const notifRef = useRef<HTMLDivElement>(null); const notifRef = useRef<HTMLDivElement>(null);
const profileRef = useRef<HTMLDivElement>(null); const profileRef = useRef<HTMLDivElement>(null);
const fetchNotifications = async () => {
try {
const res = await fetch('/api/notifications');
if (res.ok) {
const data = await res.json();
setNotifications(data);
}
} catch (err) {
console.error('Error fetching admin notifications:', err);
}
};
useEffect(() => { useEffect(() => {
// Auth Check // Auth Check
const token = localStorage.getItem('agendapro_admin_token'); const token = localStorage.getItem('agendapro_admin_token');
@ -59,12 +65,19 @@ export default function AdminPage() {
setIsLoading(false); setIsLoading(false);
} }
fetchNotifications();
const interval = setInterval(fetchNotifications, 15000); // Auto-refresh every 15s
const handleClickOutside = (e: MouseEvent) => { const handleClickOutside = (e: MouseEvent) => {
if (notifRef.current && !notifRef.current.contains(e.target as Node)) setShowNotifications(false); if (notifRef.current && !notifRef.current.contains(e.target as Node)) setShowNotifications(false);
if (profileRef.current && !profileRef.current.contains(e.target as Node)) setShowProfile(false); if (profileRef.current && !profileRef.current.contains(e.target as Node)) setShowProfile(false);
}; };
document.addEventListener('mousedown', handleClickOutside); document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside); return () => {
document.removeEventListener('mousedown', handleClickOutside);
clearInterval(interval);
};
}, [router]); }, [router]);
const playNotificationSound = () => { const playNotificationSound = () => {
@ -106,8 +119,22 @@ export default function AdminPage() {
setShowNotifications(false); setShowNotifications(false);
}; };
const markAllAsRead = () => { const markAllAsRead = async () => {
setNotifications(notifications.map(n => ({ ...n, read: true }))); setNotifications(notifications.map(n => ({ ...n, read: true })));
try {
await fetch('/api/notifications', { method: 'PUT' });
} catch (err) {
console.error(err);
}
};
const clearAllNotifications = async () => {
setNotifications([]);
try {
await fetch('/api/notifications', { method: 'DELETE' });
} catch (err) {
console.error(err);
}
}; };
const logout = () => { const logout = () => {
@ -203,7 +230,7 @@ export default function AdminPage() {
</button> </button>
)} )}
{notifications.length > 0 && ( {notifications.length > 0 && (
<button className="btn-ghost" style={{ fontSize: '12px', padding: '4px 8px', color: 'var(--danger)' }} onClick={() => setNotifications([])}> <button className="btn-ghost" style={{ fontSize: '12px', padding: '4px 8px', color: 'var(--danger)' }} onClick={clearAllNotifications}>
Limpar Limpar
</button> </button>
)} )}

View File

@ -44,6 +44,29 @@ export default function Payments() {
// Helper to get unique months for the filter // Helper to get unique months for the filter
const months = Array.from(new Set(payments.map(p => p.date.substring(0, 7)))).sort().reverse(); const months = Array.from(new Set(payments.map(p => p.date.substring(0, 7)))).sort().reverse();
const handleExportCSV = () => {
if (filtered.length === 0) return;
const headers = ['Fatura', 'Estabelecimento', 'Plano', 'Valor', 'Status', 'Metodo', 'Data'];
const rows = filtered.map(p => [
p.invoiceId,
p.tenant,
p.plan,
p.amount.toFixed(2),
statusMap[p.status]?.label || p.status,
p.method,
new Date(p.date + 'T12:00:00').toLocaleDateString('pt-BR')
]);
const csvContent = [headers.join(','), ...rows.map(e => e.map(val => `"${val}"`).join(','))].join('\n');
const blob = new Blob([new Uint8Array([0xEF, 0xBB, 0xBF]), csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('href', url);
link.setAttribute('download', `pagamentos_${new Date().toISOString().slice(0,10)}.csv`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
if (loading) return <div className="p-8 text-center" style={{color: 'var(--text-muted)'}}>Carregando pagamentos...</div>; if (loading) return <div className="p-8 text-center" style={{color: 'var(--text-muted)'}}>Carregando pagamentos...</div>;
return ( return (
@ -70,7 +93,7 @@ export default function Payments() {
))} ))}
</select> </select>
</div> </div>
<button className="btn btn-secondary"><Download size={16} /> Exportar CSV</button> <button className="btn btn-secondary" onClick={handleExportCSV}><Download size={16} /> Exportar CSV</button>
</div> </div>
<div className="card" style={{ padding: 0 }}> <div className="card" style={{ padding: 0 }}>

View File

@ -44,6 +44,13 @@ export async function POST(req: Request) {
const newUser = userRes.rows[0]; const newUser = userRes.rows[0];
// 3.5 Criar notificação para o Admin
await query(
`INSERT INTO notifications (tenant_id, type, title, message)
VALUES (NULL, 'success', 'Novo Assinante', $1)`,
[`O estabelecimento ${name} (${slug}) se cadastrou no plano Starter.`]
);
// 4. Gerar Token JWT // 4. Gerar Token JWT
const secret = process.env.JWT_SECRET || 'fallback_secret_for_development'; const secret = process.env.JWT_SECRET || 'fallback_secret_for_development';
const token = jwt.sign( const token = jwt.sign(

View File

@ -193,6 +193,15 @@ export async function POST(req: NextRequest) {
WHERE id = $2`, WHERE id = $2`,
[periodInterval, subscription.tenant_id] [periodInterval, subscription.tenant_id]
); );
// Retrieve tenant name for notification
const tenantNameRes = await client.query('SELECT name FROM tenants WHERE id = $1', [subscription.tenant_id]);
const tenantName = tenantNameRes.rows[0]?.name || 'Estabelecimento';
await client.query(
`INSERT INTO notifications (tenant_id, type, title, message)
VALUES (NULL, 'success', 'Pagamento Recebido', $1)`,
[`A assinatura de ${tenantName} foi paga no valor de R$ ${value}.`]
);
} }
} }
} }
@ -219,6 +228,15 @@ export async function POST(req: NextRequest) {
`UPDATE tenants SET subscription_status = 'cancelled', updated_at = NOW() WHERE id = $1`, `UPDATE tenants SET subscription_status = 'cancelled', updated_at = NOW() WHERE id = $1`,
[tenantId] [tenantId]
); );
// Retrieve tenant name for notification
const tenantNameRes = await client.query('SELECT name FROM tenants WHERE id = $1', [tenantId]);
const tenantName = tenantNameRes.rows[0]?.name || 'Estabelecimento';
await client.query(
`INSERT INTO notifications (tenant_id, type, title, message)
VALUES (NULL, 'warning', 'Assinatura Cancelada', $1)`,
[`A assinatura de ${tenantName} foi cancelada.`]
);
} }
} }
} }