Files
Nemo b273f1a573 feat: initial NFC Attendance Hub implementation
- 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
2026-03-12 08:49:13 +00:00

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>
)
}