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
This commit is contained in:
92
app/dashboard/page.tsx
Normal file
92
app/dashboard/page.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user