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:
11
frontend/src/components/account/AccountLayout.tsx
Normal file
11
frontend/src/components/account/AccountLayout.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
|
||||
export function AccountLayout() {
|
||||
return (
|
||||
<div className="container mx-auto max-w-screen-lg px-4 py-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccountLayout
|
||||
71
frontend/src/components/admin/ActionMenu.tsx
Normal file
71
frontend/src/components/admin/ActionMenu.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useState, useRef, useEffect, type ReactNode } from 'react'
|
||||
import { MoreHorizontal } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface ActionMenuItem {
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
onClick: () => void
|
||||
destructive?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface ActionMenuProps {
|
||||
items: ActionMenuItem[]
|
||||
}
|
||||
|
||||
export function ActionMenu({ items }: ActionMenuProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className={cn(
|
||||
'rounded-md p-1.5 text-muted-foreground transition-colors',
|
||||
'hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
{open && (
|
||||
<div className={cn(
|
||||
'absolute right-0 top-full z-50 mt-1 min-w-[160px] rounded-md border border-border',
|
||||
'bg-card py-1 shadow-lg animate-scale-in'
|
||||
)}>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
onClick={() => { item.onClick(); setOpen(false) }}
|
||||
disabled={item.disabled}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors',
|
||||
'disabled:opacity-50 disabled:pointer-events-none',
|
||||
item.destructive
|
||||
? 'text-destructive hover:bg-destructive/10'
|
||||
: 'text-foreground hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ActionMenu
|
||||
77
frontend/src/components/admin/AdminLayout.tsx
Normal file
77
frontend/src/components/admin/AdminLayout.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Outlet, useLocation } from 'react-router-dom'
|
||||
import { Menu, X } from 'lucide-react'
|
||||
import { AdminSidebar } from './AdminSidebar'
|
||||
|
||||
export function AdminLayout() {
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const location = useLocation()
|
||||
|
||||
// Close on route change
|
||||
useEffect(() => {
|
||||
setMobileOpen(false)
|
||||
}, [location.pathname])
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setMobileOpen(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (mobileOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [mobileOpen, handleKeyDown])
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)]">
|
||||
{/* Desktop sidebar */}
|
||||
<div className="hidden w-60 flex-shrink-0 border-r border-border bg-card md:block">
|
||||
<AdminSidebar />
|
||||
</div>
|
||||
|
||||
{/* Mobile sidebar overlay */}
|
||||
{mobileOpen && (
|
||||
<div className="fixed inset-0 z-40 md:hidden">
|
||||
<div
|
||||
className="absolute inset-0 bg-background/80 backdrop-blur-sm"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
/>
|
||||
<div className="absolute inset-y-0 left-0 w-60 border-r border-border bg-card shadow-xl">
|
||||
<div className="flex h-12 items-center justify-end px-3">
|
||||
<button
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<AdminSidebar onNavigate={() => setMobileOpen(false)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto max-w-screen-2xl p-6">
|
||||
{/* Mobile menu button */}
|
||||
<button
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="mb-4 rounded-md p-2 text-muted-foreground hover:bg-accent md:hidden"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AdminLayout
|
||||
79
frontend/src/components/admin/AdminSidebar.tsx
Normal file
79
frontend/src/components/admin/AdminSidebar.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
Ticket,
|
||||
FileText,
|
||||
Gauge,
|
||||
ToggleLeft,
|
||||
Settings,
|
||||
FolderTree,
|
||||
ArrowLeft,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const navItems = [
|
||||
{ path: '/admin', label: 'Dashboard', icon: LayoutDashboard, end: true },
|
||||
{ path: '/admin/users', label: 'Users', icon: Users },
|
||||
{ path: '/admin/invite-codes', label: 'Invite Codes', icon: Ticket },
|
||||
{ path: '/admin/audit-logs', label: 'Audit Logs', icon: FileText },
|
||||
{ path: '/admin/plan-limits', label: 'Plan Limits', icon: Gauge },
|
||||
{ path: '/admin/feature-flags', label: 'Feature Flags', icon: ToggleLeft },
|
||||
{ path: '/admin/settings', label: 'Settings', icon: Settings },
|
||||
{ path: '/admin/categories', label: 'Categories', icon: FolderTree },
|
||||
]
|
||||
|
||||
interface AdminSidebarProps {
|
||||
className?: string
|
||||
onNavigate?: () => void
|
||||
}
|
||||
|
||||
export function AdminSidebar({ className, onNavigate }: AdminSidebarProps) {
|
||||
const location = useLocation()
|
||||
|
||||
const isActive = (path: string, end?: boolean) => {
|
||||
if (end) return location.pathname === path
|
||||
return location.pathname.startsWith(path)
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className={cn('flex h-full flex-col', className)}>
|
||||
<div className="p-4">
|
||||
<h2 className="font-heading text-lg font-bold text-foreground">Admin Panel</h2>
|
||||
</div>
|
||||
<nav className="flex-1 space-y-1 px-3">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={onNavigate}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive(item.path, item.end)
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<div className="border-t border-border p-3">
|
||||
<Link
|
||||
to="/trees"
|
||||
onClick={onNavigate}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium',
|
||||
'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to App
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
export default AdminSidebar
|
||||
126
frontend/src/components/admin/DataTable.tsx
Normal file
126
frontend/src/components/admin/DataTable.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface Column<T> {
|
||||
key: string
|
||||
header: string
|
||||
render: (item: T) => ReactNode
|
||||
sortable?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
columns: Column<T>[]
|
||||
data: T[]
|
||||
keyExtractor: (item: T) => string
|
||||
isLoading?: boolean
|
||||
skeletonRows?: number
|
||||
onSort?: (key: string, direction: 'asc' | 'desc') => void
|
||||
sortKey?: string
|
||||
sortDirection?: 'asc' | 'desc'
|
||||
emptyState?: ReactNode
|
||||
}
|
||||
|
||||
export function DataTable<T>({
|
||||
columns,
|
||||
data,
|
||||
keyExtractor,
|
||||
isLoading = false,
|
||||
skeletonRows = 5,
|
||||
onSort,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
emptyState,
|
||||
}: DataTableProps<T>) {
|
||||
const [localSortKey, setLocalSortKey] = useState<string | null>(null)
|
||||
const [localSortDir, setLocalSortDir] = useState<'asc' | 'desc'>('asc')
|
||||
|
||||
const activeSortKey = sortKey ?? localSortKey
|
||||
const activeSortDir = sortDirection ?? localSortDir
|
||||
|
||||
const handleSort = (key: string) => {
|
||||
const newDir = activeSortKey === key && activeSortDir === 'asc' ? 'desc' : 'asc'
|
||||
if (onSort) {
|
||||
onSort(key, newDir)
|
||||
} else {
|
||||
setLocalSortKey(key)
|
||||
setLocalSortDir(newDir)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={cn(
|
||||
'px-4 py-3 text-left font-medium text-muted-foreground',
|
||||
col.sortable && 'cursor-pointer select-none hover:text-foreground',
|
||||
col.className
|
||||
)}
|
||||
onClick={col.sortable ? () => handleSort(col.key) : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{col.header}
|
||||
{col.sortable && (
|
||||
<span className="inline-flex">
|
||||
{activeSortKey === col.key ? (
|
||||
activeSortDir === 'asc' ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)
|
||||
) : (
|
||||
<ChevronsUpDown className="h-3.5 w-3.5 opacity-40" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading ? (
|
||||
Array.from({ length: skeletonRows }).map((_, i) => (
|
||||
<tr key={i} className="border-b border-border last:border-0">
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className="px-4 py-3">
|
||||
<div className="h-4 w-3/4 animate-pulse rounded bg-muted" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-4 py-12 text-center">
|
||||
{emptyState || (
|
||||
<span className="text-muted-foreground">No data found</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((item) => (
|
||||
<tr
|
||||
key={keyExtractor(item)}
|
||||
className="border-b border-border last:border-0 hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className={cn('px-4 py-3', col.className)}>
|
||||
{col.render(item)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DataTable
|
||||
25
frontend/src/components/admin/EmptyState.tsx
Normal file
25
frontend/src/components/admin/EmptyState.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: ReactNode
|
||||
title: string
|
||||
description?: string
|
||||
action?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) {
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center justify-center py-12 text-center', className)}>
|
||||
{icon && <div className="mb-4 text-muted-foreground">{icon}</div>}
|
||||
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
|
||||
{description && (
|
||||
<p className="mt-1 max-w-sm text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmptyState
|
||||
25
frontend/src/components/admin/PageHeader.tsx
Normal file
25
frontend/src/components/admin/PageHeader.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string
|
||||
description?: string
|
||||
action?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function PageHeader({ title, description, action, className }: PageHeaderProps) {
|
||||
return (
|
||||
<div className={cn('flex items-start justify-between gap-4', className)}>
|
||||
<div>
|
||||
<h1 className="font-heading text-2xl font-bold text-foreground">{title}</h1>
|
||||
{description && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{action && <div className="flex-shrink-0">{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PageHeader
|
||||
81
frontend/src/components/admin/Pagination.tsx
Normal file
81
frontend/src/components/admin/Pagination.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface PaginationProps {
|
||||
page: number
|
||||
totalPages: number
|
||||
total: number
|
||||
pageSize: number
|
||||
onPageChange: (page: number) => void
|
||||
}
|
||||
|
||||
export function Pagination({ page, totalPages, total, pageSize, onPageChange }: PaginationProps) {
|
||||
const start = (page - 1) * pageSize + 1
|
||||
const end = Math.min(page * pageSize, total)
|
||||
|
||||
const getPageNumbers = (): (number | 'ellipsis')[] => {
|
||||
if (totalPages <= 7) {
|
||||
return Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||
}
|
||||
const pages: (number | 'ellipsis')[] = [1]
|
||||
if (page > 3) pages.push('ellipsis')
|
||||
for (let i = Math.max(2, page - 1); i <= Math.min(totalPages - 1, page + 1); i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
if (page < totalPages - 2) pages.push('ellipsis')
|
||||
pages.push(totalPages)
|
||||
return pages
|
||||
}
|
||||
|
||||
if (totalPages <= 1) return null
|
||||
|
||||
const btnBase = cn(
|
||||
'inline-flex h-8 min-w-8 items-center justify-center rounded-md text-sm font-medium',
|
||||
'transition-colors disabled:opacity-50 disabled:pointer-events-none'
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 pt-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Showing {start}-{end} of {total}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page <= 1}
|
||||
className={cn(btnBase, 'px-2 hover:bg-accent')}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
{getPageNumbers().map((p, i) =>
|
||||
p === 'ellipsis' ? (
|
||||
<span key={`e${i}`} className="px-1 text-muted-foreground">...</span>
|
||||
) : (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => onPageChange(p)}
|
||||
className={cn(
|
||||
btnBase,
|
||||
'px-2',
|
||||
p === page
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'hover:bg-accent text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<button
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
className={cn(btnBase, 'px-2 hover:bg-accent')}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Pagination
|
||||
66
frontend/src/components/admin/SearchInput.tsx
Normal file
66
frontend/src/components/admin/SearchInput.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { Search, X } from 'lucide-react'
|
||||
import { debounce } from 'lodash'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SearchInputProps {
|
||||
value?: string
|
||||
onSearch: (value: string) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SearchInput({ value = '', onSearch, placeholder = 'Search...', className }: SearchInputProps) {
|
||||
const [localValue, setLocalValue] = useState(value)
|
||||
const debouncedRef = useRef<ReturnType<typeof debounce> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValue(value)
|
||||
}, [value])
|
||||
|
||||
useEffect(() => {
|
||||
debouncedRef.current = debounce((v: string) => {
|
||||
onSearch(v)
|
||||
}, 300)
|
||||
return () => {
|
||||
debouncedRef.current?.cancel()
|
||||
}
|
||||
}, [onSearch])
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const v = e.target.value
|
||||
setLocalValue(v)
|
||||
debouncedRef.current?.(v)
|
||||
}, [])
|
||||
|
||||
const handleClear = () => {
|
||||
setLocalValue('')
|
||||
onSearch('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={localValue}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
'h-9 w-full rounded-md border border-border bg-background pl-9 pr-8 text-sm',
|
||||
'placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring'
|
||||
)}
|
||||
/>
|
||||
{localValue && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SearchInput
|
||||
30
frontend/src/components/admin/StatusBadge.tsx
Normal file
30
frontend/src/components/admin/StatusBadge.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type BadgeVariant = 'success' | 'destructive' | 'warning' | 'default'
|
||||
|
||||
interface StatusBadgeProps {
|
||||
variant?: BadgeVariant
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
const variantClasses: Record<BadgeVariant, string> = {
|
||||
success: 'bg-green-500/10 text-green-600 dark:text-green-400',
|
||||
destructive: 'bg-red-500/10 text-red-600 dark:text-red-400',
|
||||
warning: 'bg-yellow-500/10 text-yellow-600 dark:text-yellow-400',
|
||||
default: 'bg-muted text-muted-foreground',
|
||||
}
|
||||
|
||||
export function StatusBadge({ variant = 'default', children, className }: StatusBadgeProps) {
|
||||
return (
|
||||
<span className={cn(
|
||||
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
|
||||
variantClasses[variant],
|
||||
className
|
||||
)}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default StatusBadge
|
||||
9
frontend/src/components/admin/index.ts
Normal file
9
frontend/src/components/admin/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { DataTable, type Column } from './DataTable'
|
||||
export { Pagination } from './Pagination'
|
||||
export { ActionMenu, type ActionMenuItem } from './ActionMenu'
|
||||
export { StatusBadge } from './StatusBadge'
|
||||
export { EmptyState } from './EmptyState'
|
||||
export { SearchInput } from './SearchInput'
|
||||
export { PageHeader } from './PageHeader'
|
||||
export { AdminLayout } from './AdminLayout'
|
||||
export { AdminSidebar } from './AdminSidebar'
|
||||
@@ -52,7 +52,7 @@ export function AppLayout() {
|
||||
{ path: '/sessions', label: 'Sessions' },
|
||||
{ path: '/account', label: 'Account' },
|
||||
{ path: '/settings', label: 'Settings' },
|
||||
...(isSuperAdmin ? [{ path: '/admin/categories', label: 'Admin: Categories' }] : []),
|
||||
...(isSuperAdmin ? [{ path: '/admin', label: 'Admin Panel' }] : []),
|
||||
]
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user