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:
Nemo
2026-03-12 08:49:13 +00:00
parent bb3f8d0ef2
commit b273f1a573
53 changed files with 6000 additions and 2 deletions

101
app/dashboard/sms/page.tsx Normal file
View File

@@ -0,0 +1,101 @@
import { prisma } from '@/lib/prisma'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
const statusColors: Record<string, string> = {
PENDING: 'bg-yellow-100 text-yellow-700',
PROCESSING: 'bg-blue-100 text-blue-700',
SENT: 'bg-green-100 text-green-700',
FAILED: 'bg-red-100 text-red-700',
RETRYING: 'bg-orange-100 text-orange-700',
}
export default async function SmsQueuePage() {
const queue = await prisma.smsQueue.findMany({
orderBy: { createdAt: 'desc' },
take: 100,
include: { client: { select: { name: true } } },
})
const counts = {
PENDING: queue.filter(q => q.status === 'PENDING').length,
PROCESSING: queue.filter(q => q.status === 'PROCESSING').length,
SENT: queue.filter(q => q.status === 'SENT').length,
FAILED: queue.filter(q => q.status === 'FAILED').length,
RETRYING: queue.filter(q => q.status === 'RETRYING').length,
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-gray-900">SMS Queue</h1>
<p className="text-gray-500 mt-1">Monitor SMS delivery status</p>
</div>
<div className="flex gap-3 flex-wrap">
{Object.entries(counts).map(([status, count]) => (
<div key={status} className={`px-4 py-2 rounded-full text-sm font-medium ${statusColors[status]}`}>
{status}: {count}
</div>
))}
</div>
<Card>
<CardHeader>
<CardTitle>Recent Queue (last 100)</CardTitle>
</CardHeader>
<CardContent className="p-0">
{queue.length === 0 ? (
<p className="text-center text-gray-500 py-8">No SMS in queue</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b">
<tr>
<th className="text-left px-4 py-3 font-medium text-gray-600">Student</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Client</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Phone</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Event</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Status</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Attempts</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Created</th>
</tr>
</thead>
<tbody className="divide-y">
{queue.map(item => (
<tr key={item.id} className="hover:bg-gray-50">
<td className="px-4 py-3">
<div className="font-medium">{item.studentName}</div>
<div className="text-xs text-gray-500">{item.studentId}</div>
</td>
<td className="px-4 py-3 text-gray-600">{item.client.name}</td>
<td className="px-4 py-3 text-gray-600">{item.parentPhone}</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
item.event === 'time_in'
? 'bg-green-100 text-green-700'
: 'bg-orange-100 text-orange-700'
}`}>
{item.event === 'time_in' ? '✅ In' : '🔴 Out'}
</span>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${statusColors[item.status]}`}>
{item.status}
</span>
</td>
<td className="px-4 py-3 text-center">{item.attempts}/3</td>
<td className="px-4 py-3 text-gray-500 text-xs">
{new Date(item.createdAt).toLocaleString('en-PH', { timeZone: 'Asia/Manila' })}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
)
}