feat: implement full admin panel with dashboard, user management, and platform settings

Adds complete super_admin panel with 9 pages and account owner categories page.
Backend includes 5 new DB tables, ~25 API endpoints, settings manager with
in-memory cache, and 29 integration tests. Frontend includes reusable admin
components (DataTable, Pagination, ActionMenu, etc.) with code-split lazy loading.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-02-08 06:05:59 -05:00
parent 4f57c84d43
commit b570f8415f
50 changed files with 4589 additions and 5 deletions

View File

@@ -0,0 +1,203 @@
import { useState, useEffect, useCallback } from 'react'
import { Plus, Copy, Trash2, Ticket } from 'lucide-react'
import { DataTable, PageHeader, StatusBadge, ActionMenu, EmptyState } from '@/components/admin'
import type { Column } from '@/components/admin'
import { Modal } from '@/components/common/Modal'
import { adminApi } from '@/api/admin'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
interface InviteCode {
id: string
code: string
created_by_id: string
used_by_id: string | null
is_active: boolean
expires_at: string | null
created_at: string
}
export function InviteCodesPage() {
const [codes, setCodes] = useState<InviteCode[]>([])
const [loading, setLoading] = useState(true)
const [createOpen, setCreateOpen] = useState(false)
const [expiresInDays, setExpiresInDays] = useState('')
const fetchCodes = useCallback(async () => {
setLoading(true)
try {
const data = await adminApi.listInviteCodes()
setCodes(Array.isArray(data) ? data : data.items || [])
} catch {
toast.error('Failed to load invite codes')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchCodes() }, [fetchCodes])
const handleCreate = async () => {
try {
const expiresAt = expiresInDays
? new Date(Date.now() + parseInt(expiresInDays) * 86400000).toISOString()
: undefined
await adminApi.createInviteCode(expiresAt ? { expires_at: expiresAt } : undefined)
toast.success('Invite code created')
setCreateOpen(false)
setExpiresInDays('')
fetchCodes()
} catch {
toast.error('Failed to create invite code')
}
}
const handleCopy = (code: string) => {
navigator.clipboard.writeText(code)
toast.success('Code copied to clipboard')
}
const handleDelete = async (id: string) => {
try {
await adminApi.deleteInviteCode(id)
toast.success('Invite code deleted')
fetchCodes()
} catch {
toast.error('Failed to delete invite code')
}
}
const columns: Column<InviteCode>[] = [
{
key: 'code',
header: 'Code',
render: (c) => (
<code className="rounded bg-muted px-2 py-1 text-sm font-mono">{c.code}</code>
),
},
{
key: 'status',
header: 'Status',
render: (c) => {
if (c.used_by_id) return <StatusBadge variant="default">Used</StatusBadge>
if (!c.is_active) return <StatusBadge variant="destructive">Inactive</StatusBadge>
if (c.expires_at && new Date(c.expires_at) < new Date()) return <StatusBadge variant="warning">Expired</StatusBadge>
return <StatusBadge variant="success">Active</StatusBadge>
},
},
{
key: 'expires_at',
header: 'Expires',
render: (c) => (
<span className="text-sm text-muted-foreground">
{c.expires_at ? new Date(c.expires_at).toLocaleDateString() : 'Never'}
</span>
),
},
{
key: 'created_at',
header: 'Created',
render: (c) => (
<span className="text-sm text-muted-foreground">
{new Date(c.created_at).toLocaleDateString()}
</span>
),
},
{
key: 'actions',
header: '',
className: 'w-12',
render: (c) => (
<ActionMenu items={[
{
label: 'Copy Code',
icon: <Copy className="h-4 w-4" />,
onClick: () => handleCopy(c.code),
},
{
label: 'Delete',
icon: <Trash2 className="h-4 w-4" />,
onClick: () => handleDelete(c.id),
destructive: true,
},
]} />
),
},
]
return (
<div className="space-y-6">
<PageHeader
title="Invite Codes"
description="Manage registration invite codes"
action={
<button
onClick={() => setCreateOpen(true)}
className={cn(
'flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium',
'bg-primary text-primary-foreground hover:bg-primary/90'
)}
>
<Plus className="h-4 w-4" />
Create Code
</button>
}
/>
<DataTable
columns={columns}
data={codes}
keyExtractor={(c) => c.id}
isLoading={loading}
emptyState={
<EmptyState
icon={<Ticket className="h-12 w-12" />}
title="No invite codes"
description="Create an invite code to allow new user registrations."
/>
}
/>
<Modal
isOpen={createOpen}
onClose={() => setCreateOpen(false)}
title="Create Invite Code"
size="sm"
footer={
<div className="flex justify-end gap-3">
<button
onClick={() => setCreateOpen(false)}
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-card-foreground hover:bg-accent"
>
Cancel
</button>
<button
onClick={handleCreate}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Create
</button>
</div>
}
>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Expires in (days)</label>
<input
type="number"
value={expiresInDays}
onChange={(e) => setExpiresInDays(e.target.value)}
placeholder="Leave empty for no expiry"
className={cn(
'w-full rounded-md border border-border bg-background px-3 py-2 text-sm',
'placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring'
)}
/>
</div>
</div>
</Modal>
</div>
)
}
export default InviteCodesPage