- 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
192 lines
7.7 KiB
TypeScript
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>
|
|
)
|
|
}
|