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

192 lines
7.7 KiB
TypeScript

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