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:
125
app/dashboard/sms/failed/page.tsx
Normal file
125
app/dashboard/sms/failed/page.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
'use client'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { RefreshCw, AlertTriangle } from 'lucide-react'
|
||||
|
||||
export default function FailedSmsPage() {
|
||||
const [failed, setFailed] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [retrying, setRetrying] = useState<string | null>(null)
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
async function loadFailed() {
|
||||
setLoading(true)
|
||||
const res = await fetch('/api/sms/failed')
|
||||
const data = await res.json()
|
||||
setFailed(data)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => { loadFailed() }, [])
|
||||
|
||||
async function handleRetry(id: string) {
|
||||
setRetrying(id)
|
||||
setMessage('')
|
||||
const res = await fetch(`/api/sms/${id}/retry`, { method: 'POST' })
|
||||
const data = await res.json()
|
||||
setMessage(data.message || (res.ok ? 'Queued for retry' : 'Retry failed'))
|
||||
setRetrying(null)
|
||||
if (res.ok) {
|
||||
setTimeout(() => loadFailed(), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Failed SMS</h1>
|
||||
<p className="text-gray-500 mt-1">SMS messages that failed after 3 attempts</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={loadFailed} disabled={loading}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className="bg-blue-50 border border-blue-200 text-blue-700 px-4 py-3 rounded-md text-sm">{message}</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-red-500" />
|
||||
Failed Messages ({failed.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<p className="text-center text-gray-500 py-8">Loading...</p>
|
||||
) : failed.length === 0 ? (
|
||||
<p className="text-center text-green-600 py-8">🎉 No failed messages!</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">Error</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Failed At</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{failed.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="text-red-600 text-xs bg-red-50 px-2 py-1 rounded max-w-xs block truncate" title={item.lastError}>
|
||||
{item.lastError || 'Unknown error'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500 text-xs">
|
||||
{new Date(item.updatedAt).toLocaleString('en-PH', { timeZone: 'Asia/Manila' })}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleRetry(item.id)}
|
||||
disabled={retrying === item.id}
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 mr-1 ${retrying === item.id ? 'animate-spin' : ''}`} />
|
||||
Retry
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
101
app/dashboard/sms/page.tsx
Normal file
101
app/dashboard/sms/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user