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

69
components/sidebar.tsx Normal file
View File

@@ -0,0 +1,69 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { signOut } from 'next-auth/react'
import { cn } from '@/lib/utils'
import {
LayoutDashboard,
Building2,
MessageSquare,
AlertTriangle,
BarChart3,
LogOut,
} from 'lucide-react'
const navItems = [
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ href: '/dashboard/clients', label: 'Clients', icon: Building2 },
{ href: '/dashboard/sms', label: 'SMS Queue', icon: MessageSquare },
{ href: '/dashboard/sms/failed', label: 'Failed SMS', icon: AlertTriangle },
{ href: '/dashboard/reports', label: 'Reports', icon: BarChart3 },
]
export function Sidebar() {
const pathname = usePathname()
return (
<aside className="w-64 bg-gray-900 text-white flex flex-col min-h-screen">
<div className="p-6 border-b border-gray-700">
<div className="flex items-center gap-3">
<span className="text-2xl">📡</span>
<div>
<h1 className="font-bold text-sm">NFC Attendance Hub</h1>
<p className="text-xs text-gray-400">Admin Portal</p>
</div>
</div>
</div>
<nav className="flex-1 p-4 space-y-1">
{navItems.map(item => {
const Icon = item.icon
const active = pathname === item.href || (item.href !== '/dashboard' && pathname.startsWith(item.href))
return (
<Link
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors',
active
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-800 hover:text-white'
)}
>
<Icon className="h-4 w-4" />
{item.label}
</Link>
)
})}
</nav>
<div className="p-4 border-t border-gray-700">
<button
onClick={() => signOut({ callbackUrl: '/login' })}
className="flex items-center gap-3 px-3 py-2 rounded-md text-sm text-gray-300 hover:bg-gray-800 hover:text-white w-full transition-colors"
>
<LogOut className="h-4 w-4" />
Sign Out
</button>
</div>
</aside>
)
}