CognxSafeTrack commited on
Commit ·
6c2d132
1
Parent(s): 6faac59
feat: implement dynamic admin dashboard with API data
Browse files- apps/admin/src/App.tsx +105 -11
- apps/admin/src/vite-env.d.ts +1 -0
- apps/api/src/index.ts +3 -0
- apps/api/src/routes/admin.ts +43 -0
apps/admin/src/App.tsx
CHANGED
|
@@ -1,21 +1,115 @@
|
|
| 1 |
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
function Dashboard() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
return (
|
| 5 |
<div className="p-8">
|
| 6 |
-
<h1 className="text-3xl font-bold mb-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
</div>
|
| 12 |
-
<div className="bg-white p-6 rounded-
|
| 13 |
-
<h2 className="text-
|
| 14 |
-
<p className="text-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
</div>
|
| 16 |
-
<div className="
|
| 17 |
-
<
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
</div>
|
| 20 |
</div>
|
| 21 |
</div>
|
|
|
|
| 1 |
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
|
| 2 |
|
| 3 |
+
import { useEffect, useState } from 'react';
|
| 4 |
+
|
| 5 |
+
interface DashboardData {
|
| 6 |
+
stats: {
|
| 7 |
+
totalUsers: number;
|
| 8 |
+
activeEnrollments: number;
|
| 9 |
+
completedEnrollments: number;
|
| 10 |
+
totalTracks: number;
|
| 11 |
+
} | null;
|
| 12 |
+
enrollments: any[];
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
function Dashboard() {
|
| 16 |
+
const [data, setData] = useState<DashboardData>({ stats: null, enrollments: [] });
|
| 17 |
+
const [loading, setLoading] = useState(true);
|
| 18 |
+
|
| 19 |
+
useEffect(() => {
|
| 20 |
+
const fetchData = async () => {
|
| 21 |
+
try {
|
| 22 |
+
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
| 23 |
+
|
| 24 |
+
const [statsRes, enrollmentsRes] = await Promise.all([
|
| 25 |
+
fetch(`${API_URL}/v1/admin/stats`),
|
| 26 |
+
fetch(`${API_URL}/v1/admin/enrollments`)
|
| 27 |
+
]);
|
| 28 |
+
|
| 29 |
+
const stats = await statsRes.json();
|
| 30 |
+
const enrollments = await enrollmentsRes.json();
|
| 31 |
+
|
| 32 |
+
setData({ stats, enrollments });
|
| 33 |
+
} catch (error) {
|
| 34 |
+
console.error("Error fetching dashboard data", error);
|
| 35 |
+
} finally {
|
| 36 |
+
setLoading(false);
|
| 37 |
+
}
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
fetchData();
|
| 41 |
+
}, []);
|
| 42 |
+
|
| 43 |
+
if (loading) return <div className="p-8">Loading dashboard...</div>;
|
| 44 |
+
|
| 45 |
return (
|
| 46 |
<div className="p-8">
|
| 47 |
+
<h1 className="text-3xl font-bold mb-8 text-slate-800">Admin Dashboard</h1>
|
| 48 |
+
|
| 49 |
+
{/* Stats Overview */}
|
| 50 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
| 51 |
+
<div className="bg-white p-6 rounded-xl shadow-sm border border-slate-100 flex flex-col justify-center items-center">
|
| 52 |
+
<h2 className="text-sm font-semibold text-slate-500 uppercase tracking-wider mb-1">Total Users</h2>
|
| 53 |
+
<p className="text-4xl font-bold text-slate-900">{data.stats?.totalUsers || 0}</p>
|
| 54 |
+
</div>
|
| 55 |
+
<div className="bg-white p-6 rounded-xl shadow-sm border border-slate-100 flex flex-col justify-center items-center">
|
| 56 |
+
<h2 className="text-sm font-semibold text-slate-500 uppercase tracking-wider mb-1">Active Enrollments</h2>
|
| 57 |
+
<p className="text-4xl font-bold text-blue-600">{data.stats?.activeEnrollments || 0}</p>
|
| 58 |
</div>
|
| 59 |
+
<div className="bg-white p-6 rounded-xl shadow-sm border border-slate-100 flex flex-col justify-center items-center">
|
| 60 |
+
<h2 className="text-sm font-semibold text-slate-500 uppercase tracking-wider mb-1">Completed</h2>
|
| 61 |
+
<p className="text-4xl font-bold text-green-600">{data.stats?.completedEnrollments || 0}</p>
|
| 62 |
+
</div>
|
| 63 |
+
<div className="bg-white p-6 rounded-xl shadow-sm border border-slate-100 flex flex-col justify-center items-center">
|
| 64 |
+
<h2 className="text-sm font-semibold text-slate-500 uppercase tracking-wider mb-1">Tracks</h2>
|
| 65 |
+
<p className="text-4xl font-bold text-purple-600">{data.stats?.totalTracks || 0}</p>
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
|
| 69 |
+
{/* Recent Enrollments Table */}
|
| 70 |
+
<div className="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden">
|
| 71 |
+
<div className="px-6 py-4 border-b border-slate-100">
|
| 72 |
+
<h2 className="text-lg font-semibold text-slate-800">Recent Enrollments</h2>
|
| 73 |
</div>
|
| 74 |
+
<div className="overflow-x-auto">
|
| 75 |
+
<table className="w-full text-sm text-left">
|
| 76 |
+
<thead className="text-xs text-slate-500 bg-slate-50 uppercase">
|
| 77 |
+
<tr>
|
| 78 |
+
<th className="px-6 py-3">User (WhatsApp)</th>
|
| 79 |
+
<th className="px-6 py-3">Track</th>
|
| 80 |
+
<th className="px-6 py-3">Status</th>
|
| 81 |
+
<th className="px-6 py-3">Progress</th>
|
| 82 |
+
<th className="px-6 py-3">Date</th>
|
| 83 |
+
</tr>
|
| 84 |
+
</thead>
|
| 85 |
+
<tbody>
|
| 86 |
+
{data.enrollments.map((env: any) => (
|
| 87 |
+
<tr key={env.id} className="border-b border-slate-50 hover:bg-slate-50/50">
|
| 88 |
+
<td className="px-6 py-4 font-medium text-slate-900">{env.user?.whatsappId || 'Unknown'}</td>
|
| 89 |
+
<td className="px-6 py-4">{env.track?.title || 'Unknown Track'}</td>
|
| 90 |
+
<td className="px-6 py-4">
|
| 91 |
+
<span className={`px-2.5 py-1 py-0.5 rounded-full text-xs font-medium
|
| 92 |
+
${env.status === 'ACTIVE' ? 'bg-blue-100 text-blue-800' :
|
| 93 |
+
env.status === 'COMPLETED' ? 'bg-green-100 text-green-800' :
|
| 94 |
+
'bg-slate-100 text-slate-800'}`}>
|
| 95 |
+
{env.status}
|
| 96 |
+
</span>
|
| 97 |
+
</td>
|
| 98 |
+
<td className="px-6 py-4">Day {env.currentDay}</td>
|
| 99 |
+
<td className="px-6 py-4 text-slate-500">
|
| 100 |
+
{new Date(env.startedAt).toLocaleDateString()}
|
| 101 |
+
</td>
|
| 102 |
+
</tr>
|
| 103 |
+
))}
|
| 104 |
+
{data.enrollments.length === 0 && (
|
| 105 |
+
<tr>
|
| 106 |
+
<td colSpan={5} className="px-6 py-8 text-center text-slate-500">
|
| 107 |
+
No enrollments found.
|
| 108 |
+
</td>
|
| 109 |
+
</tr>
|
| 110 |
+
)}
|
| 111 |
+
</tbody>
|
| 112 |
+
</table>
|
| 113 |
</div>
|
| 114 |
</div>
|
| 115 |
</div>
|
apps/admin/src/vite-env.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
/// <reference types="vite/client" />
|
apps/api/src/index.ts
CHANGED
|
@@ -11,6 +11,9 @@ server.register(cors);
|
|
| 11 |
import { whatsappRoutes } from './routes/whatsapp';
|
| 12 |
server.register(whatsappRoutes, { prefix: '/v1/whatsapp' });
|
| 13 |
|
|
|
|
|
|
|
|
|
|
| 14 |
server.get('/health', async () => {
|
| 15 |
return { status: 'ok', timestamp: new Date().toISOString() };
|
| 16 |
});
|
|
|
|
| 11 |
import { whatsappRoutes } from './routes/whatsapp';
|
| 12 |
server.register(whatsappRoutes, { prefix: '/v1/whatsapp' });
|
| 13 |
|
| 14 |
+
import { adminRoutes } from './routes/admin';
|
| 15 |
+
server.register(adminRoutes, { prefix: '/v1/admin' });
|
| 16 |
+
|
| 17 |
server.get('/health', async () => {
|
| 18 |
return { status: 'ok', timestamp: new Date().toISOString() };
|
| 19 |
});
|
apps/api/src/routes/admin.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { FastifyInstance } from 'fastify';
|
| 2 |
+
import { PrismaClient } from '@prisma/client';
|
| 3 |
+
|
| 4 |
+
const prisma = new PrismaClient();
|
| 5 |
+
|
| 6 |
+
export async function adminRoutes(fastify: FastifyInstance) {
|
| 7 |
+
// 1. Get Dashboard Stats
|
| 8 |
+
fastify.get('/stats', async () => {
|
| 9 |
+
const totalUsers = await prisma.user.count();
|
| 10 |
+
const activeEnrollments = await prisma.enrollment.count({ where: { status: 'ACTIVE' } });
|
| 11 |
+
const completedEnrollments = await prisma.enrollment.count({ where: { status: 'COMPLETED' } });
|
| 12 |
+
const totalTracks = await prisma.track.count();
|
| 13 |
+
|
| 14 |
+
return {
|
| 15 |
+
totalUsers,
|
| 16 |
+
activeEnrollments,
|
| 17 |
+
completedEnrollments,
|
| 18 |
+
totalTracks
|
| 19 |
+
};
|
| 20 |
+
});
|
| 21 |
+
|
| 22 |
+
// 2. Get Users List
|
| 23 |
+
fastify.get('/users', async () => {
|
| 24 |
+
const users = await prisma.user.findMany({
|
| 25 |
+
orderBy: { createdAt: 'desc' },
|
| 26 |
+
take: 50,
|
| 27 |
+
});
|
| 28 |
+
return users;
|
| 29 |
+
});
|
| 30 |
+
|
| 31 |
+
// 3. Get Recent Enrollments
|
| 32 |
+
fastify.get('/enrollments', async () => {
|
| 33 |
+
const enrollments = await prisma.enrollment.findMany({
|
| 34 |
+
include: {
|
| 35 |
+
user: true,
|
| 36 |
+
track: true,
|
| 37 |
+
},
|
| 38 |
+
orderBy: { startedAt: 'desc' },
|
| 39 |
+
take: 50,
|
| 40 |
+
});
|
| 41 |
+
return enrollments;
|
| 42 |
+
});
|
| 43 |
+
}
|