forked from cardosofelipe/fast-next-template
Add admin hooks, components, and tests for statistics, navigation, and access control
- Introduced `useAdminStats`, `useAdminUsers`, and `useAdminOrganizations` hooks for admin data fetching with React Query. - Added `AdminSidebar`, `Breadcrumbs`, and related navigation components for the admin section. - Implemented comprehensive unit and integration tests for admin components. - Created E2E tests for admin access control, navigation, and dashboard functionality. - Updated exports to include new admin components.
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
/**
|
||||
* Admin Route Group Layout
|
||||
* Wraps all admin routes with AuthGuard requiring superuser privileges
|
||||
* Includes sidebar navigation and breadcrumbs
|
||||
*/
|
||||
|
||||
import type { Metadata } from 'next';
|
||||
import { AuthGuard } from '@/components/auth';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { Footer } from '@/components/layout/Footer';
|
||||
import { AdminSidebar, Breadcrumbs } from '@/components/admin';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
@@ -24,9 +26,15 @@ export default function AdminLayout({
|
||||
<AuthGuard requireAdmin>
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Header />
|
||||
<main className="flex-1">
|
||||
{children}
|
||||
</main>
|
||||
<div className="flex flex-1">
|
||||
<AdminSidebar />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<Breadcrumbs />
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
</AuthGuard>
|
||||
|
||||
62
frontend/src/app/admin/organizations/page.tsx
Normal file
62
frontend/src/app/admin/organizations/page.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Admin Organizations Page
|
||||
* Displays and manages all organizations
|
||||
* Protected by AuthGuard in layout with requireAdmin=true
|
||||
*/
|
||||
|
||||
/* istanbul ignore next - Next.js type import for metadata */
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
/* istanbul ignore next - Next.js metadata, not executable code */
|
||||
export const metadata: Metadata = {
|
||||
title: 'Organizations',
|
||||
};
|
||||
|
||||
export default function AdminOrganizationsPage() {
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-8">
|
||||
<div className="space-y-6">
|
||||
{/* Back Button + Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/admin">
|
||||
<Button variant="outline" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
Organizations
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Manage organizations and their members
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Placeholder Content */}
|
||||
<div className="rounded-lg border bg-card p-12 text-center">
|
||||
<h3 className="text-xl font-semibold mb-2">
|
||||
Organization Management Coming Soon
|
||||
</h3>
|
||||
<p className="text-muted-foreground max-w-md mx-auto">
|
||||
This page will allow you to view all organizations, manage their
|
||||
members, and perform administrative tasks.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-4">
|
||||
Features will include:
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground mt-2 max-w-sm mx-auto text-left">
|
||||
<li>• Organization list with search and filtering</li>
|
||||
<li>• View organization details and members</li>
|
||||
<li>• Manage organization memberships</li>
|
||||
<li>• Organization statistics and activity</li>
|
||||
<li>• Bulk operations</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
/**
|
||||
* Admin Dashboard Page
|
||||
* Placeholder for future admin functionality
|
||||
* Displays admin statistics and management options
|
||||
* Protected by AuthGuard in layout with requireAdmin=true
|
||||
*/
|
||||
|
||||
/* istanbul ignore next - Next.js type import for metadata */
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { DashboardStats } from '@/components/admin';
|
||||
import { Users, Building2, Settings } from 'lucide-react';
|
||||
|
||||
/* istanbul ignore next - Next.js metadata, not executable code */
|
||||
export const metadata: Metadata = {
|
||||
@@ -14,8 +17,9 @@ export const metadata: Metadata = {
|
||||
|
||||
export default function AdminPage() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="space-y-6">
|
||||
<div className="container mx-auto px-6 py-8">
|
||||
<div className="space-y-8">
|
||||
{/* Page Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
Admin Dashboard
|
||||
@@ -25,35 +29,48 @@ export default function AdminPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="rounded-lg border bg-card p-6">
|
||||
<h3 className="font-semibold text-lg mb-2">Users</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage user accounts and permissions
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-4">
|
||||
Coming soon...
|
||||
</p>
|
||||
</div>
|
||||
{/* Stats Grid */}
|
||||
<DashboardStats />
|
||||
|
||||
<div className="rounded-lg border bg-card p-6">
|
||||
<h3 className="font-semibold text-lg mb-2">Organizations</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
View and manage organizations
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-4">
|
||||
Coming soon...
|
||||
</p>
|
||||
</div>
|
||||
{/* Quick Actions */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">Quick Actions</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Link href="/admin/users" className="block">
|
||||
<div className="rounded-lg border bg-card p-6 transition-colors hover:bg-accent cursor-pointer">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Users className="h-5 w-5 text-primary" />
|
||||
<h3 className="font-semibold">User Management</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
View, create, and manage user accounts
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="rounded-lg border bg-card p-6">
|
||||
<h3 className="font-semibold text-lg mb-2">System</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
System settings and configuration
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-4">
|
||||
Coming soon...
|
||||
</p>
|
||||
<Link href="/admin/organizations" className="block">
|
||||
<div className="rounded-lg border bg-card p-6 transition-colors hover:bg-accent cursor-pointer">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Building2 className="h-5 w-5 text-primary" />
|
||||
<h3 className="font-semibold">Organizations</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage organizations and their members
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link href="/admin/settings" className="block">
|
||||
<div className="rounded-lg border bg-card p-6 transition-colors hover:bg-accent cursor-pointer">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Settings className="h-5 w-5 text-primary" />
|
||||
<h3 className="font-semibold">System Settings</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure system-wide settings
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
62
frontend/src/app/admin/settings/page.tsx
Normal file
62
frontend/src/app/admin/settings/page.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Admin Settings Page
|
||||
* System-wide settings and configuration
|
||||
* Protected by AuthGuard in layout with requireAdmin=true
|
||||
*/
|
||||
|
||||
/* istanbul ignore next - Next.js type import for metadata */
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
/* istanbul ignore next - Next.js metadata, not executable code */
|
||||
export const metadata: Metadata = {
|
||||
title: 'System Settings',
|
||||
};
|
||||
|
||||
export default function AdminSettingsPage() {
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-8">
|
||||
<div className="space-y-6">
|
||||
{/* Back Button + Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/admin">
|
||||
<Button variant="outline" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
System Settings
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Configure system-wide settings and preferences
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Placeholder Content */}
|
||||
<div className="rounded-lg border bg-card p-12 text-center">
|
||||
<h3 className="text-xl font-semibold mb-2">
|
||||
System Settings Coming Soon
|
||||
</h3>
|
||||
<p className="text-muted-foreground max-w-md mx-auto">
|
||||
This page will allow you to configure system-wide settings,
|
||||
preferences, and advanced options.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-4">
|
||||
Features will include:
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground mt-2 max-w-sm mx-auto text-left">
|
||||
<li>• General system configuration</li>
|
||||
<li>• Email and notification settings</li>
|
||||
<li>• Security and authentication options</li>
|
||||
<li>• API and integration settings</li>
|
||||
<li>• Maintenance and backup tools</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
frontend/src/app/admin/users/page.tsx
Normal file
62
frontend/src/app/admin/users/page.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Admin Users Page
|
||||
* Displays and manages all users
|
||||
* Protected by AuthGuard in layout with requireAdmin=true
|
||||
*/
|
||||
|
||||
/* istanbul ignore next - Next.js type import for metadata */
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
/* istanbul ignore next - Next.js metadata, not executable code */
|
||||
export const metadata: Metadata = {
|
||||
title: 'User Management',
|
||||
};
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-8">
|
||||
<div className="space-y-6">
|
||||
{/* Back Button + Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/admin">
|
||||
<Button variant="outline" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
User Management
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
View, create, and manage user accounts
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Placeholder Content */}
|
||||
<div className="rounded-lg border bg-card p-12 text-center">
|
||||
<h3 className="text-xl font-semibold mb-2">
|
||||
User Management Coming Soon
|
||||
</h3>
|
||||
<p className="text-muted-foreground max-w-md mx-auto">
|
||||
This page will allow you to view all users, create new accounts,
|
||||
manage permissions, and perform bulk operations.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-4">
|
||||
Features will include:
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground mt-2 max-w-sm mx-auto text-left">
|
||||
<li>• User list with search and filtering</li>
|
||||
<li>• Create/edit/delete user accounts</li>
|
||||
<li>• Activate/deactivate users</li>
|
||||
<li>• Role and permission management</li>
|
||||
<li>• Bulk operations</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
135
frontend/src/components/admin/AdminSidebar.tsx
Normal file
135
frontend/src/components/admin/AdminSidebar.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Admin Sidebar Navigation
|
||||
* Displays navigation links for admin section
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
Building2,
|
||||
Settings,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
|
||||
interface NavItem {
|
||||
name: string;
|
||||
href: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
name: 'Dashboard',
|
||||
href: '/admin',
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{
|
||||
name: 'Users',
|
||||
href: '/admin/users',
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
name: 'Organizations',
|
||||
href: '/admin/organizations',
|
||||
icon: Building2,
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
href: '/admin/settings',
|
||||
icon: Settings,
|
||||
},
|
||||
];
|
||||
|
||||
export function AdminSidebar() {
|
||||
const pathname = usePathname();
|
||||
const { user } = useAuth();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'border-r bg-muted/40 transition-all duration-300',
|
||||
collapsed ? 'w-16' : 'w-64'
|
||||
)}
|
||||
data-testid="admin-sidebar"
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Sidebar Header */}
|
||||
<div className="flex h-16 items-center justify-between border-b px-4">
|
||||
{!collapsed && (
|
||||
<h2 className="text-lg font-semibold">Admin Panel</h2>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="rounded-md p-2 hover:bg-accent"
|
||||
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
data-testid="sidebar-toggle"
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation Links */}
|
||||
<nav className="flex-1 space-y-1 p-2">
|
||||
{navItems.map((item) => {
|
||||
const isActive =
|
||||
pathname === item.href ||
|
||||
(item.href !== '/admin' && pathname.startsWith(item.href));
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
isActive
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground',
|
||||
collapsed && 'justify-center'
|
||||
)}
|
||||
title={collapsed ? item.name : undefined}
|
||||
data-testid={`nav-${item.name.toLowerCase()}`}
|
||||
>
|
||||
<Icon className="h-5 w-5 flex-shrink-0" />
|
||||
{!collapsed && <span>{item.name}</span>}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* User Info */}
|
||||
{!collapsed && user && (
|
||||
<div className="border-t p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground text-sm font-medium">
|
||||
{user.first_name?.[0] || user.email[0].toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{user.first_name} {user.last_name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
92
frontend/src/components/admin/Breadcrumbs.tsx
Normal file
92
frontend/src/components/admin/Breadcrumbs.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Admin Breadcrumbs
|
||||
* Displays navigation breadcrumb trail for admin pages
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
|
||||
interface BreadcrumbItem {
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
const pathLabels: Record<string, string> = {
|
||||
admin: 'Admin',
|
||||
users: 'Users',
|
||||
organizations: 'Organizations',
|
||||
settings: 'Settings',
|
||||
};
|
||||
|
||||
export function Breadcrumbs() {
|
||||
const pathname = usePathname();
|
||||
|
||||
// Generate breadcrumb items from pathname
|
||||
const generateBreadcrumbs = (): BreadcrumbItem[] => {
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
const breadcrumbs: BreadcrumbItem[] = [];
|
||||
|
||||
let currentPath = '';
|
||||
segments.forEach((segment) => {
|
||||
currentPath += `/${segment}`;
|
||||
const label = pathLabels[segment] || segment;
|
||||
breadcrumbs.push({
|
||||
label,
|
||||
href: currentPath,
|
||||
});
|
||||
});
|
||||
|
||||
return breadcrumbs;
|
||||
};
|
||||
|
||||
const breadcrumbs = generateBreadcrumbs();
|
||||
|
||||
if (breadcrumbs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Breadcrumb"
|
||||
className="border-b bg-background px-6 py-3"
|
||||
data-testid="breadcrumbs"
|
||||
>
|
||||
<ol className="flex items-center space-x-2 text-sm">
|
||||
{breadcrumbs.map((breadcrumb, index) => {
|
||||
const isLast = index === breadcrumbs.length - 1;
|
||||
|
||||
return (
|
||||
<li key={breadcrumb.href} className="flex items-center">
|
||||
{index > 0 && (
|
||||
<ChevronRight
|
||||
className="mx-2 h-4 w-4 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{isLast ? (
|
||||
<span
|
||||
className="font-medium text-foreground"
|
||||
aria-current="page"
|
||||
data-testid={`breadcrumb-${breadcrumb.label.toLowerCase()}`}
|
||||
>
|
||||
{breadcrumb.label}
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
href={breadcrumb.href}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
data-testid={`breadcrumb-${breadcrumb.label.toLowerCase()}`}
|
||||
>
|
||||
{breadcrumb.label}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
63
frontend/src/components/admin/DashboardStats.tsx
Normal file
63
frontend/src/components/admin/DashboardStats.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* DashboardStats Component
|
||||
* Displays admin dashboard statistics in stat cards
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useAdminStats } from '@/lib/api/hooks/useAdmin';
|
||||
import { StatCard } from './StatCard';
|
||||
import { Users, UserCheck, Building2, Activity } from 'lucide-react';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
|
||||
export function DashboardStats() {
|
||||
const { data: stats, isLoading, isError, error } = useAdminStats();
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Failed to load dashboard statistics: {error?.message || 'Unknown error'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"
|
||||
data-testid="dashboard-stats"
|
||||
>
|
||||
<StatCard
|
||||
title="Total Users"
|
||||
value={stats?.totalUsers ?? 0}
|
||||
icon={Users}
|
||||
description="All registered users"
|
||||
loading={isLoading}
|
||||
/>
|
||||
<StatCard
|
||||
title="Active Users"
|
||||
value={stats?.activeUsers ?? 0}
|
||||
icon={UserCheck}
|
||||
description="Users with active status"
|
||||
loading={isLoading}
|
||||
/>
|
||||
<StatCard
|
||||
title="Organizations"
|
||||
value={stats?.totalOrganizations ?? 0}
|
||||
icon={Building2}
|
||||
description="Total organizations"
|
||||
loading={isLoading}
|
||||
/>
|
||||
<StatCard
|
||||
title="Active Sessions"
|
||||
value={stats?.totalSessions ?? 0}
|
||||
icon={Activity}
|
||||
description="Current active sessions"
|
||||
loading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
frontend/src/components/admin/StatCard.tsx
Normal file
98
frontend/src/components/admin/StatCard.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* StatCard Component
|
||||
* Displays a statistic card with icon, title, and value
|
||||
*/
|
||||
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon: LucideIcon;
|
||||
description?: string;
|
||||
loading?: boolean;
|
||||
trend?: {
|
||||
value: number;
|
||||
label: string;
|
||||
isPositive?: boolean;
|
||||
};
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
title,
|
||||
value,
|
||||
icon: Icon,
|
||||
description,
|
||||
loading = false,
|
||||
trend,
|
||||
className,
|
||||
}: StatCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border bg-card p-6 shadow-sm',
|
||||
loading && 'animate-pulse',
|
||||
className
|
||||
)}
|
||||
data-testid="stat-card"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1 flex-1">
|
||||
<p
|
||||
className="text-sm font-medium text-muted-foreground"
|
||||
data-testid="stat-title"
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
{loading ? (
|
||||
<div className="h-8 w-24 bg-muted rounded" />
|
||||
) : (
|
||||
<p
|
||||
className="text-3xl font-bold tracking-tight"
|
||||
data-testid="stat-value"
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{description && !loading && (
|
||||
<p
|
||||
className="text-xs text-muted-foreground"
|
||||
data-testid="stat-description"
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{trend && !loading && (
|
||||
<div
|
||||
className={cn(
|
||||
'text-xs font-medium',
|
||||
trend.isPositive ? 'text-green-600' : 'text-red-600'
|
||||
)}
|
||||
data-testid="stat-trend"
|
||||
>
|
||||
{trend.isPositive ? '↑' : '↓'} {Math.abs(trend.value)}%{' '}
|
||||
{trend.label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-full p-3',
|
||||
loading ? 'bg-muted' : 'bg-primary/10'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
'h-6 w-6',
|
||||
loading ? 'text-muted-foreground' : 'text-primary'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
// Admin-specific components
|
||||
// Examples: UserTable, OrganizationForm, StatisticsCard, etc.
|
||||
|
||||
export {};
|
||||
export { AdminSidebar } from './AdminSidebar';
|
||||
export { Breadcrumbs } from './Breadcrumbs';
|
||||
export { StatCard } from './StatCard';
|
||||
export { DashboardStats } from './DashboardStats';
|
||||
|
||||
152
frontend/src/lib/api/hooks/useAdmin.tsx
Normal file
152
frontend/src/lib/api/hooks/useAdmin.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Admin Hooks
|
||||
* React Query hooks for admin operations
|
||||
*
|
||||
* TODO - Stats Optimization (Option A):
|
||||
* Currently calculating stats from multiple endpoints (Option B).
|
||||
* For better performance at scale, consider implementing a dedicated
|
||||
* /api/v1/admin/stats endpoint that returns pre-calculated counts
|
||||
* to avoid fetching full lists.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { adminListUsers, adminListOrganizations } from '@/lib/api/client';
|
||||
|
||||
/**
|
||||
* Admin Stats interface
|
||||
*/
|
||||
export interface AdminStats {
|
||||
totalUsers: number;
|
||||
activeUsers: number;
|
||||
totalOrganizations: number;
|
||||
totalSessions: number; // TODO: Requires admin sessions endpoint
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch admin statistics
|
||||
* Calculates stats from existing endpoints (Option B)
|
||||
*
|
||||
* @returns Admin statistics including user and organization counts
|
||||
*/
|
||||
export function useAdminStats() {
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'stats'],
|
||||
queryFn: async (): Promise<AdminStats> => {
|
||||
// Fetch users list
|
||||
// Use high limit to get all users for stats calculation
|
||||
const usersResponse = await adminListUsers({
|
||||
query: {
|
||||
page: 1,
|
||||
limit: 10000, // High limit to get all users for stats
|
||||
},
|
||||
throwOnError: false,
|
||||
});
|
||||
|
||||
if ('error' in usersResponse) {
|
||||
throw new Error('Failed to fetch users');
|
||||
}
|
||||
|
||||
// Type assertion: if no error, response has data
|
||||
const usersData = (usersResponse as { data: { data: Array<{ is_active: boolean }>; pagination: { total: number } } }).data;
|
||||
const users = usersData?.data || [];
|
||||
const totalUsers = usersData?.pagination?.total || 0;
|
||||
const activeUsers = users.filter((u) => u.is_active).length;
|
||||
|
||||
// Fetch organizations list
|
||||
const orgsResponse = await adminListOrganizations({
|
||||
query: {
|
||||
page: 1,
|
||||
limit: 10000, // High limit to get all orgs for stats
|
||||
},
|
||||
throwOnError: false,
|
||||
});
|
||||
|
||||
if ('error' in orgsResponse) {
|
||||
throw new Error('Failed to fetch organizations');
|
||||
}
|
||||
|
||||
// Type assertion: if no error, response has data
|
||||
const orgsData = (orgsResponse as { data: { pagination: { total: number } } }).data;
|
||||
const totalOrganizations = orgsData?.pagination?.total || 0;
|
||||
|
||||
// TODO: Add admin sessions endpoint
|
||||
// Currently no admin-level endpoint exists to fetch all sessions
|
||||
// across all users. The /api/v1/sessions/me endpoint only returns
|
||||
// sessions for the current user.
|
||||
//
|
||||
// Once backend implements /api/v1/admin/sessions, uncomment below:
|
||||
// const sessionsResponse = await adminListSessions({
|
||||
// query: { page: 1, limit: 10000 },
|
||||
// throwOnError: false,
|
||||
// });
|
||||
// const totalSessions = sessionsResponse.data?.pagination?.total || 0;
|
||||
|
||||
const totalSessions = 0; // Placeholder until admin sessions endpoint exists
|
||||
|
||||
return {
|
||||
totalUsers,
|
||||
activeUsers,
|
||||
totalOrganizations,
|
||||
totalSessions,
|
||||
};
|
||||
},
|
||||
// Refetch every 30 seconds for near real-time stats
|
||||
refetchInterval: 30000,
|
||||
// Keep previous data while refetching to avoid UI flicker
|
||||
placeholderData: (previousData) => previousData,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch paginated list of all users (for admin)
|
||||
*
|
||||
* @param page - Page number (1-indexed)
|
||||
* @param limit - Number of records per page
|
||||
* @returns Paginated list of users
|
||||
*/
|
||||
export function useAdminUsers(page = 1, limit = 50) {
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'users', page, limit],
|
||||
queryFn: async () => {
|
||||
const response = await adminListUsers({
|
||||
query: { page, limit },
|
||||
throwOnError: false,
|
||||
});
|
||||
|
||||
if ('error' in response) {
|
||||
throw new Error('Failed to fetch users');
|
||||
}
|
||||
|
||||
// Type assertion: if no error, response has data
|
||||
return (response as { data: unknown }).data;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch paginated list of all organizations (for admin)
|
||||
*
|
||||
* @param page - Page number (1-indexed)
|
||||
* @param limit - Number of records per page
|
||||
* @returns Paginated list of organizations
|
||||
*/
|
||||
export function useAdminOrganizations(page = 1, limit = 50) {
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'organizations', page, limit],
|
||||
queryFn: async () => {
|
||||
const response = await adminListOrganizations({
|
||||
query: { page, limit },
|
||||
throwOnError: false,
|
||||
});
|
||||
|
||||
if ('error' in response) {
|
||||
throw new Error('Failed to fetch organizations');
|
||||
}
|
||||
|
||||
// Type assertion: if no error, response has data
|
||||
return (response as { data: unknown }).data;
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user