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:
191
app/dashboard/reports/page.tsx
Normal file
191
app/dashboard/reports/page.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
'use client'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Download, Search, BarChart3 } from 'lucide-react'
|
||||
|
||||
export default function ReportsPage() {
|
||||
const [clients, setClients] = useState<any[]>([])
|
||||
const [logs, setLogs] = useState<any[]>([])
|
||||
const [stats, setStats] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [filters, setFilters] = useState({ clientId: '', from: '', to: '' })
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/clients').then(r => r.json()).then(setClients)
|
||||
loadReports()
|
||||
}, [])
|
||||
|
||||
async function loadReports() {
|
||||
setLoading(true)
|
||||
const params = new URLSearchParams()
|
||||
if (filters.clientId) params.append('clientId', filters.clientId)
|
||||
if (filters.from) params.append('from', filters.from)
|
||||
if (filters.to) params.append('to', filters.to)
|
||||
const res = await fetch(`/api/reports?${params}`)
|
||||
const data = await res.json()
|
||||
setStats(data)
|
||||
setLogs(data.logs || [])
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
async function handleExportCsv() {
|
||||
const params = new URLSearchParams({ format: 'csv' })
|
||||
if (filters.clientId) params.append('clientId', filters.clientId)
|
||||
if (filters.from) params.append('from', filters.from)
|
||||
if (filters.to) params.append('to', filters.to)
|
||||
const res = await fetch(`/api/reports?${params}`)
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `sms-report-${Date.now()}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Reports</h1>
|
||||
<p className="text-gray-500 mt-1">SMS delivery statistics and logs</p>
|
||||
</div>
|
||||
<Button onClick={handleExportCsv} variant="outline">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Filters</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label>Client</Label>
|
||||
<select
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={filters.clientId}
|
||||
onChange={e => setFilters(f => ({ ...f, clientId: e.target.value }))}
|
||||
>
|
||||
<option value="">All Clients</option>
|
||||
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>From Date</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.from}
|
||||
onChange={e => setFilters(f => ({ ...f, from: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>To Date</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.to}
|
||||
onChange={e => setFilters(f => ({ ...f, to: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button onClick={loadReports} disabled={loading} className="w-full">
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
{loading ? 'Loading...' : 'Search'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-3xl font-bold">{stats.total}</div>
|
||||
<p className="text-sm text-gray-500">Total SMS</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-3xl font-bold text-green-600">{stats.success}</div>
|
||||
<p className="text-sm text-gray-500">Delivered</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-3xl font-bold text-red-600">{stats.failed}</div>
|
||||
<p className="text-sm text-gray-500">Failed</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logs table */}
|
||||
<Card>
|
||||
<CardHeader><CardTitle>SMS Logs</CardTitle></CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<p className="text-center text-gray-500 py-8">Loading...</p>
|
||||
) : logs.length === 0 ? (
|
||||
<p className="text-center text-gray-500 py-8">No records found</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">Date</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">Student</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">Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{logs.map((log: any) => (
|
||||
<tr key={log.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-xs text-gray-500">
|
||||
{new Date(log.sentAt).toLocaleString('en-PH', { timeZone: 'Asia/Manila' })}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600">{log.client.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium">{log.queue.studentName}</div>
|
||||
<div className="text-xs text-gray-500">{log.queue.studentId}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600">{log.phone}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
log.queue.event === 'time_in'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-orange-100 text-orange-700'
|
||||
}`}>
|
||||
{log.queue.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 ${
|
||||
log.status === 'SUCCESS'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{log.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-red-500">{log.errorReason || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user