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
Build and Deploy (Gitea Actions) / build-and-deploy (push) Successful in 2m38s
Details
This commit is contained in:
parent
39c06c03d7
commit
4bb1e4b690
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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 }}>
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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.`]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue