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,183 @@
import { useState, useEffect, useCallback } from 'react'
import { Plus, Trash2, Pencil, FolderTree } from 'lucide-react'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
import { Modal } from '@/components/common/Modal'
import api from '@/api/client'
interface TeamCategory {
id: string
name: string
slug: string
description: string | null
tree_count: number
}
export function TeamCategoriesPage() {
const [categories, setCategories] = useState<TeamCategory[]>([])
const [loading, setLoading] = useState(true)
const [createOpen, setCreateOpen] = useState(false)
const [editCategory, setEditCategory] = useState<TeamCategory | null>(null)
const [form, setForm] = useState({ name: '', slug: '', description: '' })
const fetchData = useCallback(async () => {
setLoading(true)
try {
const res = await api.get('/api/v1/categories')
setCategories(res.data)
} catch {
toast.error('Failed to load categories')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchData() }, [fetchData])
const generateSlug = (name: string) =>
name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
const handleCreate = async () => {
try {
await api.post('/api/v1/categories', form)
toast.success('Category created')
setCreateOpen(false)
setForm({ name: '', slug: '', description: '' })
fetchData()
} catch {
toast.error('Failed to create category')
}
}
const handleUpdate = async () => {
if (!editCategory) return
try {
await api.put(`/api/v1/categories/${editCategory.id}`, form)
toast.success('Category updated')
setEditCategory(null)
setForm({ name: '', slug: '', description: '' })
fetchData()
} catch {
toast.error('Failed to update category')
}
}
const handleDelete = async (id: string) => {
try {
await api.delete(`/api/v1/categories/${id}`)
toast.success('Category deleted')
fetchData()
} catch {
toast.error('Failed to delete category')
}
}
const openEdit = (cat: TeamCategory) => {
setEditCategory(cat)
setForm({ name: cat.name, slug: cat.slug, description: cat.description || '' })
}
const inputCn = 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')
return (
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="font-heading text-2xl font-bold text-foreground">Team Categories</h1>
<p className="mt-1 text-sm text-muted-foreground">Manage tree categories for your team</p>
</div>
<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 Category
</button>
</div>
{loading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-16 animate-pulse rounded-lg bg-muted" />
))}
</div>
) : categories.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-border bg-card py-16">
<FolderTree className="h-12 w-12 text-muted-foreground/50" />
<h3 className="mt-4 font-medium text-foreground">No team categories</h3>
<p className="mt-1 text-sm text-muted-foreground">Create categories to organize your team's trees.</p>
</div>
) : (
<div className="space-y-2">
{categories.map((cat) => (
<div key={cat.id} className="flex items-center justify-between rounded-lg border border-border bg-card px-4 py-3">
<div>
<span className="font-medium text-foreground">{cat.name}</span>
<span className="ml-3 text-sm text-muted-foreground">{cat.slug}</span>
{cat.description && <span className="ml-3 text-sm text-muted-foreground">- {cat.description}</span>}
<span className="ml-3 text-xs text-muted-foreground">{cat.tree_count} trees</span>
</div>
<div className="flex items-center gap-1">
<button onClick={() => openEdit(cat)} className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground">
<Pencil className="h-4 w-4" />
</button>
<button onClick={() => handleDelete(cat.id)} className="rounded-md p-1.5 text-muted-foreground hover:bg-destructive/10 hover:text-destructive">
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
)}
{/* Create Modal */}
<Modal isOpen={createOpen} onClose={() => setCreateOpen(false)} title="Create Category" 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} disabled={!form.name || !form.slug} className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50">Create</button>
</div>
}
>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Name</label>
<input type="text" value={form.name} onChange={(e) => { const name = e.target.value; setForm(f => ({ ...f, name, slug: generateSlug(name) })) }} placeholder="e.g. Networking" className={inputCn} />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Slug</label>
<input type="text" value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} className={inputCn} />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Description</label>
<input type="text" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Optional" className={inputCn} />
</div>
</div>
</Modal>
{/* Edit Modal */}
<Modal isOpen={!!editCategory} onClose={() => setEditCategory(null)} title="Edit Category" size="sm"
footer={
<div className="flex justify-end gap-3">
<button onClick={() => setEditCategory(null)} className="rounded-md border border-border px-4 py-2 text-sm font-medium text-card-foreground hover:bg-accent">Cancel</button>
<button onClick={handleUpdate} disabled={!form.name || !form.slug} className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50">Save</button>
</div>
}
>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Name</label>
<input type="text" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className={inputCn} />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Slug</label>
<input type="text" value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} className={inputCn} />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Description</label>
<input type="text" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Optional" className={inputCn} />
</div>
</div>
</Modal>
</div>
)
}
export default TeamCategoriesPage