- Multi-tenant client management with unique API keys - Semaphore SMS integration (per-client key + sender name + credit balance) - DB-based SMS queue with 3-attempt retry and exponential backoff - Failed SMS dashboard with manual retry button - Reports page with date/client filter and CSV export - Admin auth via NextAuth (email/password) - Docker Compose setup (app + PostgreSQL 16) - Prisma 5 schema with SmsQueue, SmsLog, Client, User models Tech: Next.js 14 App Router + TypeScript + Tailwind CSS + Prisma + PostgreSQL
93 lines
3.9 KiB
TypeScript
93 lines
3.9 KiB
TypeScript
import { prisma } from '@/lib/prisma'
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Building2, MessageSquare, CheckCircle, XCircle, Clock } from 'lucide-react'
|
|
|
|
export default async function DashboardPage() {
|
|
const [totalClients, activeClients, pendingCount, sentCount, failedCount] = await Promise.all([
|
|
prisma.client.count(),
|
|
prisma.client.count({ where: { isActive: true } }),
|
|
prisma.smsQueue.count({ where: { status: { in: ['PENDING', 'RETRYING'] } } }),
|
|
prisma.smsQueue.count({ where: { status: 'SENT' } }),
|
|
prisma.smsQueue.count({ where: { status: 'FAILED' } }),
|
|
])
|
|
|
|
const recentLogs = await prisma.smsLog.findMany({
|
|
take: 10,
|
|
orderBy: { sentAt: 'desc' },
|
|
include: { client: { select: { name: true } }, queue: true },
|
|
})
|
|
|
|
const stats = [
|
|
{ title: 'Total Clients', value: totalClients, sub: `${activeClients} active`, icon: Building2, color: 'text-blue-600' },
|
|
{ title: 'Pending SMS', value: pendingCount, sub: 'In queue', icon: Clock, color: 'text-yellow-600' },
|
|
{ title: 'SMS Sent', value: sentCount, sub: 'All time', icon: CheckCircle, color: 'text-green-600' },
|
|
{ title: 'Failed SMS', value: failedCount, sub: 'Needs attention', icon: XCircle, color: 'text-red-600' },
|
|
]
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
|
|
<p className="text-gray-500 mt-1">Overview of your NFC Attendance Hub</p>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
{stats.map(stat => {
|
|
const Icon = stat.icon
|
|
return (
|
|
<Card key={stat.title}>
|
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
<CardTitle className="text-sm font-medium text-gray-600">{stat.title}</CardTitle>
|
|
<Icon className={`h-5 w-5 ${stat.color}`} />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-3xl font-bold">{stat.value}</div>
|
|
<p className="text-xs text-gray-500 mt-1">{stat.sub}</p>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Recent SMS Activity</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{recentLogs.length === 0 ? (
|
|
<p className="text-gray-500 text-sm text-center py-8">No SMS activity yet</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{recentLogs.map(log => (
|
|
<div key={log.id} className="flex items-center justify-between py-2 border-b last:border-0">
|
|
<div className="flex-1">
|
|
<span className="font-medium text-sm">{log.queue.studentName}</span>
|
|
<span className="text-gray-500 text-xs ml-2">({log.queue.studentId})</span>
|
|
<span className="text-xs text-gray-400 ml-2">via {log.client.name}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className={`text-xs px-2 py-1 rounded-full font-medium ${
|
|
log.queue.event === 'time_in'
|
|
? 'bg-green-100 text-green-700'
|
|
: 'bg-orange-100 text-orange-700'
|
|
}`}>
|
|
{log.queue.event === 'time_in' ? '✅ Time In' : '🔴 Time Out'}
|
|
</span>
|
|
<span className={`text-xs px-2 py-1 rounded-full ${
|
|
log.status === 'SUCCESS'
|
|
? 'bg-green-100 text-green-700'
|
|
: 'bg-red-100 text-red-700'
|
|
}`}>
|
|
{log.status}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|