Quran_Tech_Server / F_Pro /src /components /admin /UserManagement.tsx
aboalaa147's picture
Initial deployment
eb6a2f9
Raw
History Blame Contribute Delete
30.4 kB
// ─────────────────────────────────────────────────────────────────────────────
// src/components/admin/UserManagement.tsx
// Fixed: Added null checks for all optional fields
// ─────────────────────────────────────────────────────────────────────────────
import { useState, useEffect, useCallback } from 'react';
import {
Card, CardContent, CardDescription,
CardHeader, CardTitle,
} from '../ui/card';
import { Input } from '../ui/input';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
import {
Search, Users, GraduationCap, Shield,
MoreVertical, Eye, Ban, Trash2,
Mail, Calendar, TrendingUp, Loader2, RefreshCw,
} from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import { useNavigate } from 'react-router-dom';
import { useLanguage } from '../../lib/languageContext';
import { translations } from '../../lib/translations';
// Import from the User Management API
import adminUserManagementApi, {
type StudentDtoAdmin,
type SheikhDtoAdmin,
type UserManagementStatsDto,
type UserManagementApiError,
} from '../../lib/api/adminUserManagementApi';
import { toast } from 'sonner';
// ─── Types ───────────────────────────────────────────────────────────────────
// Combined user type for display
interface DisplayUser {
id: number;
name: string;
email: string;
registrationDate: string;
type: 'student' | 'sheikh' | 'admin';
status: string;
// Student fields
streak?: number | null;
totalSessions?: number | null;
// Sheikh fields
numberOfSessions?: number | null;
totalRevenue?: number | null;
averageRating?: number | null;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function formatDate(dateString: string, locale: string): string {
const d = new Date(dateString);
return d.toLocaleDateString(locale === 'ar' ? 'ar-EG' : 'en-US', {
year: 'numeric', month: 'short', day: 'numeric',
});
}
function formatNumber(value: number | null | undefined, locale: string): string {
if (value === null || value === undefined) return '—';
return value.toLocaleString(locale === 'ar' ? 'ar-EG' : 'en-US');
}
function formatCurrency(value: number | null | undefined, locale: string): string {
if (value === null || value === undefined) return '—';
return `${value.toLocaleString(locale === 'ar' ? 'ar-EG' : 'en-US')} ${locale === 'ar' ? 'ج.م' : 'EGP'}`;
}
function formatRating(value: number | null | undefined): string {
if (value === null || value === undefined) return '—';
return value.toFixed(1);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function UserManagement() {
const { language } = useLanguage();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const t = (translations[language] as any).userManagement ?? {};
const isAr = language === 'ar';
const navigate = useNavigate();
// ── State ─────────────────────────────────────────────────────────────────
const [students, setStudents] = useState<StudentDtoAdmin[]>([]);
const [sheikhs, setSheikhs] = useState<SheikhDtoAdmin[]>([]);
const [stats, setStats] = useState<UserManagementStatsDto | null>(null);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [activeTab, setActiveTab] = useState('all');
// ── Fetch all data ────────────────────────────────────────────────────────
const loadData = useCallback(async () => {
setLoading(true);
setError(null);
try {
// Fetch students, sheikhs, and stats in parallel
const [studentsData, sheikhsData, statsData] = await Promise.all([
adminUserManagementApi.fetchAllStudents(),
adminUserManagementApi.fetchAllSheikhs(),
adminUserManagementApi.fetchUserManagementStats(),
]);
setStudents(studentsData);
setSheikhs(sheikhsData);
setStats(statsData);
} catch (err) {
console.error('Failed to load data:', err);
// Handle different error types
if (err instanceof UserManagementApiError) {
if (err.status === 403) {
setError(isAr ? 'صلاحية المسؤول مطلوبة' : 'Admin access required');
} else if (err.status === 401) {
setError(isAr ? 'انتهت الجلسة، يرجى تسجيل الدخول مرة أخرى' : 'Session expired');
} else {
setError(isAr ? 'فشل تحميل البيانات' : 'Failed to load data');
}
} else {
setError(isAr ? 'خطأ في الشبكة' : 'Network error');
}
setStudents([]);
setSheikhs([]);
setStats(null);
} finally {
setLoading(false);
}
}, [isAr]);
useEffect(() => { loadData(); }, [loadData]);
// ── Combine users for display ─────────────────────────────────────────────
const getAllUsers = useCallback((): DisplayUser[] => {
const allUsers: DisplayUser[] = [
...students.map(s => ({
id: s.id,
name: s.name,
email: s.email,
registrationDate: s.registrationDate,
type: 'student' as const,
status: s.status,
streak: s.streak,
totalSessions: s.totalSessions,
})),
...sheikhs.map(sh => ({
id: sh.id,
name: sh.name,
email: sh.email,
registrationDate: sh.registrationDate,
type: 'sheikh' as const,
status: sh.status,
numberOfSessions: sh.numberOfSessions,
totalRevenue: sh.totalRevenue,
averageRating: sh.averageRating,
})),
];
// Add mock admins (since admin list not available from API)
allUsers.push({
id: 999,
name: isAr ? 'مدير النظام' : 'System Admin',
email: 'admin@example.com',
registrationDate: new Date().toISOString(),
type: 'admin',
status: 'ACTIVE',
});
return allUsers;
}, [students, sheikhs, isAr]);
// ── Filter ────────────────────────────────────────────────────────────────
const filterUsers = (type?: 'student' | 'sheikh' | 'admin') => {
let list = getAllUsers();
if (type) {
list = list.filter(u => u.type === type);
}
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
list = list.filter(
u =>
(u.name ?? '').toLowerCase().includes(q) ||
(u.email ?? '').toLowerCase().includes(q),
);
}
return list;
};
const allUsers = filterUsers();
const studentList = filterUsers('student');
const sheikhList = filterUsers('sheikh');
const adminList = filterUsers('admin');
// ── Actions ───────────────────────────────────────────────────────────────
const handleBlockUser = async (user: DisplayUser) => {
setActionLoading(user.id);
try {
await adminUserManagementApi.blockUser(user.id);
// Refresh data after blocking
await loadData();
} catch (err) {
console.error('Block failed:', err);
toast.error(isAr ? 'فشلت العملية، حاول مرة أخرى' : 'Operation failed, please try again');
} finally {
setActionLoading(null);
}
};
const handleUnblockUser = async (user: DisplayUser) => {
setActionLoading(user.id);
try {
await adminUserManagementApi.unblockUser(user.id);
// Refresh data after unblocking
await loadData();
} catch (err) {
console.error('Unblock failed:', err);
toast.error(isAr ? 'فشلت العملية، حاول مرة أخرى' : 'Operation failed, please try again');
} finally {
setActionLoading(null);
}
};
// const handleDeleteUser = async (user: DisplayUser) => {
// const msg = isAr
// ? 'هل أنت متأكد من حذف هذا المستخدم؟ لا يمكن التراجع عن هذا الإجراء.'
// : 'Are you sure you want to delete this user? This action cannot be undone.';
// if (!confirm(msg)) return;
// setActionLoading(user.id);
// try {
// // Note: Delete user API not in docs
// alert(isAr ? 'حذف المستخدمين غير متاح حالياً' : 'Delete user not available yet');
// } catch (err) {
// console.error('Delete failed:', err);
// alert(isAr ? 'فشل الحذف، حاول مرة أخرى' : 'Delete failed, please try again');
// } finally {
// setActionLoading(null);
// }
// };
const handleViewProfile = (user: DisplayUser) => {
if (user.type === 'student') {
navigate(`/admin/student/${user.id}`);
} else if (user.type === 'sheikh') {
navigate(`/admin/sheikh/${user.id}`);
} else {
navigate(`/admin/user/${user.id}`);
}
};
// ── Role helpers ──────────────────────────────────────────────────────────
const getRoleIcon = (type: string) => {
if (type === 'student') return <Users className="h-4 w-4" />;
if (type === 'sheikh') return <GraduationCap className="h-4 w-4" />;
return <Shield className="h-4 w-4" />;
};
const getRoleBadgeVariant = (type: string) => {
if (type === 'student') return 'default' as const;
if (type === 'sheikh') return 'secondary' as const;
return 'destructive' as const;
};
const getRoleLabel = (type: string) => {
if (!isAr) {
if (type === 'student') return 'Student';
if (type === 'sheikh') return 'Sheikh';
return 'Admin';
}
if (type === 'student') return 'طالب';
if (type === 'sheikh') return 'شيخ';
return 'مسؤول';
};
const getStatusBadge = (status: string) => {
if (status === 'BLOCKED') {
return (
<Badge variant="destructive">
<Ban className="h-3 w-3 mr-1" />
{isAr ? 'محظور' : 'Blocked'}
</Badge>
);
}
if (status === 'PENDING' || status === 'INACTIVE') {
return (
<Badge variant="outline" className="text-yellow-600 border-yellow-400">
{isAr ? 'قيد المراجعة' : 'Pending'}
</Badge>
);
}
return null;
};
// ── Stats ─────────────────────────────────────────────────────────────────
const totalUsers = stats?.totalUsers ?? (students.length + sheikhs.length + 1); // +1 for mock admin
const totalStudents = stats?.totalStudents ?? students.length;
const totalSheikhs = stats?.totalSheikhs ?? sheikhs.length;
// const totalBlocked = stats?.blockedUsers ?? users.filter(u => u.status === 'BLOCKED').length;
const totalBlocked =
stats?.blockedUsers ??
[...students, ...sheikhs].filter(u => u.status === 'BLOCKED').length;
// ── User Table ────────────────────────────────────────────────────────────
const UserTable = ({ list }: { list: DisplayUser[] }) => {
if (loading) {
return (
<div className="flex justify-center items-center py-16 gap-2 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
{isAr ? 'جاري التحميل…' : 'Loading…'}
</div>
);
}
if (list.length === 0) {
return (
<div className="text-center py-12 text-muted-foreground">
<Users className="h-12 w-12 mx-auto mb-4 opacity-20" />
<p>{t.noUsersFound ?? (isAr ? 'لا يوجد مستخدمون' : 'No users found')}</p>
</div>
);
}
return (
<div className="space-y-2">
{list.map((user) => {
const isBlocked = user.status === 'BLOCKED';
const isActioning = actionLoading === user.id;
return (
<div
key={`${user.type}-${user.id}`}
className="flex items-center justify-between p-4 border rounded-lg hover:bg-accent transition-colors"
>
<div className="flex items-center gap-3 sm:gap-4 flex-1 min-w-0 w-full sm:w-auto">
<div className="w-10 h-10 sm:w-12 sm:h-12 rounded-full bg-gradient-to-br from-emerald-400 to-emerald-600 flex items-center justify-center text-white font-semibold shrink-0 text-sm sm:text-base">
{(user.name ?? '?').charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 sm:gap-2 flex-wrap">
<span className="font-medium text-sm sm:text-base truncate max-w-[120px] sm:max-w-[200px] md:max-w-none">
{user.name ?? '—'}
</span>
<Badge
variant={getRoleBadgeVariant(user.type)}
className="text-[10px] text-xs shrink-0"
>
{getRoleIcon(user.type)}
<span className="ml-1 ">{getRoleLabel(user.type)}</span>
</Badge>
<span>{getStatusBadge(user.status)}</span>
</div>
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2 text-xs sm:text-sm text-muted-foreground mt-1">
<span className="flex items-center gap-0.5 sm:gap-1 truncate max-w-[100px] sm:max-w-[150px] md:max-w-none">
<Mail className="h-3 w-3 mr-1" />
{user.email}
</span>
<span className="flex items-center gap-0.5 sm:gap-1 shrink-0 my-1">
<Calendar className="h-3 w-3" />
{t.joined ?? (isAr ? 'انضم' : 'Joined')}{' '}
{formatDate(user.registrationDate, language)}
</span>
</div>
{/* Mobile stats row */}
<div className="flex items-center gap-3 mt-1.5 sm:hidden text-xs">
{user.type === 'student' && (
<>
{user.totalSessions !== null && user.totalSessions !== undefined && (
<span className="text-muted-foreground">
<span className="font-semibold">{formatNumber(user.totalSessions, language)}</span> {t.sessions ?? (isAr ? 'ج' : 'Sess')}
</span>
)}
{user.streak !== null && user.streak !== undefined && (
<span className="text-muted-foreground">
<span className="font-semibold">{formatNumber(user.streak, language)}</span> 🔥
</span>
)}
</>
)}
{user.type === 'sheikh' && (
<>
{user.numberOfSessions !== null && user.numberOfSessions !== undefined && (
<span className="text-muted-foreground">
<span className="font-semibold">{formatNumber(user.numberOfSessions, language)}</span> {t.sessions ?? (isAr ? 'ج' : 'Sess')}
</span>
)}
{user.averageRating !== null && user.averageRating !== undefined && (
<span className="text-muted-foreground">
<span className="font-semibold">{formatRating(user.averageRating)}</span>
</span>
)}
</>
)}
{user.status === 'BLOCKED' && (
<Badge variant="destructive" className="text-[10px]">
<Ban className="h-2 w-2 mr-1" />
{isAr ? 'محظور' : 'Blocked'}
</Badge>
)}
</div>
</div>
</div>
{/* ── Left: avatar + info ── */}
{/* ── Right: stats + actions ── */}
<div className="flex items-center gap-4 shrink-0">
<div className="flex gap-4 text-sm">
{user.type === 'student' && (
<>
{user.totalSessions !== null && user.totalSessions !== undefined && (
<div className="text-center hidden sm:block">
<div className="font-semibold">{formatNumber(user.totalSessions, language)}</div>
<div className="text-muted-foreground text-xs">
{t.sessions ?? (isAr ? 'جلسة' : 'Sessions')}
</div>
</div>
)}
{user.streak !== null && user.streak !== undefined && (
<div className="text-center hidden sm:block">
<div className="font-semibold">{formatNumber(user.streak, language)} 🔥</div>
<div className="text-muted-foreground text-xs">
{t.streak ?? (isAr ? 'سلسلة' : 'Streak')}
</div>
</div>
)}
</>
)}
{user.type === 'sheikh' && (
<>
{user.numberOfSessions !== null && user.numberOfSessions !== undefined && (
<div className="text-center hidden sm:block">
<div className="font-semibold">{formatNumber(user.numberOfSessions, language)}</div>
<div className="text-muted-foreground text-xs">
{t.sessions ?? (isAr ? 'جلسة' : 'Sessions')}
</div>
</div>
)}
{user.averageRating !== null && user.averageRating !== undefined && (
<div className="text-center hidden sm:block">
<div className="font-semibold">{formatRating(user.averageRating)} ⭐</div>
<div className="text-muted-foreground text-xs">
{t.rating ?? (isAr ? 'تقييم' : 'Rating')}
</div>
</div>
)}
{user.totalRevenue !== null && user.totalRevenue !== undefined && (
<div className="text-center hidden sm:block">
<div className="font-semibold">{formatCurrency(user.totalRevenue, language)}</div>
<div className="text-muted-foreground text-xs">
{t.earnings ?? (isAr ? 'أرباح' : 'Earnings')}
</div>
</div>
)}
</>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" disabled={isActioning}>
{isActioning
? <Loader2 className="h-4 w-4 animate-spin" />
: <MoreVertical className="h-4 w-4" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleViewProfile(user)}>
<Eye className="h-4 w-4 mr-2" />
{t.viewProfile ?? (isAr ? 'عرض الملف' : 'View Profile')}
</DropdownMenuItem>
<DropdownMenuSeparator />
{isBlocked ? (
<DropdownMenuItem onClick={() => handleUnblockUser(user)}>
<Ban className="h-4 w-4 mr-2" />
{t.unblockUser ?? (isAr ? 'إلغاء الحظر' : 'Unblock User')}
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => handleBlockUser(user)}>
<Ban className="h-4 w-4 mr-2" />
{t.blockUser ?? (isAr ? 'حظر المستخدم' : 'Block User')}
</DropdownMenuItem>
)}
{/* <DropdownMenuItem
onClick={() => handleDeleteUser(user)}
className="text-red-600"
>
<Trash2 className="h-4 w-4 mr-2" />
{t.deleteUser ?? (isAr ? 'حذف المستخدم' : 'Delete User')}
</DropdownMenuItem> */}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
})}
</div>
);
};
// ─────────────────────────────────────────────────────────────────────────
return (
<div className="container mx-auto p-6 space-y-6">
{/* ── Page Title ── */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">
{t.title ?? (isAr ? 'إدارة المستخدمين' : 'User Management')}
</h1>
<p className="text-muted-foreground">
{t.description ?? (isAr ? 'بحث وإدارة جميع مستخدمي المنصة' : 'Search and manage all platform users')}
</p>
</div>
{/* <Button variant="outline" onClick={loadData} disabled={loading}>
<RefreshCw className={`h-4 w-4 me-2 ${loading ? 'animate-spin' : ''}`} />
{isAr ? 'تحديث' : 'Refresh'}
</Button> */}
</div>
{/* ── Error Banner ── */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded-lg flex items-center justify-between">
<span>{error}</span>
<Button variant="outline" size="sm" onClick={loadData}>
{isAr ? 'إعادة المحاولة' : 'Retry'}
</Button>
</div>
)}
{/* ── Statistics Cards ── */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t.totalUsers ?? (isAr ? 'إجمالي المستخدمين' : 'Total Users')}
</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{loading ? <Loader2 className="h-5 w-5 animate-spin" /> : totalUsers}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t.students ?? (isAr ? 'الطلاب' : 'Students')}
</CardTitle>
<Users className="h-4 w-4 text-blue-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{loading ? <Loader2 className="h-5 w-5 animate-spin" /> : totalStudents}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t.sheikhs ?? (isAr ? 'الشيوخ' : 'Sheikhs')}
</CardTitle>
<GraduationCap className="h-4 w-4 text-purple-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{loading ? <Loader2 className="h-5 w-5 animate-spin" /> : totalSheikhs}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t.blockedUsers ?? (isAr ? 'المحظورون' : 'Blocked Users')}
</CardTitle>
<Ban className="h-4 w-4 text-red-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{loading ? <Loader2 className="h-5 w-5 animate-spin" /> : totalBlocked}
</div>
</CardContent>
</Card>
</div>
{/* ── Search ── */}
<Card>
<CardHeader>
<CardTitle>
{t.searchUsers ?? (isAr ? 'بحث عن مستخدم' : 'Search Users')}
</CardTitle>
<CardDescription>
{t.searchDescription ?? (isAr ? 'ابحث بالاسم أو البريد الإلكتروني' : 'Find users by name or email address')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t.searchPlaceholder ?? (isAr ? 'ابحث بالاسم أو البريد…' : 'Search by name or email...')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
</CardContent>
</Card>
{/* ── Users Table with Tabs ── */}
{/* ── Users Table with Tabs ── */}
<Card>
<CardContent className="pt-4 sm:pt-6 px-2 sm:px-4">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-4 h-auto p-0.5 sm:p-1">
<TabsTrigger
value="all"
className="text-[10px] sm:text-xs md:text-sm py-1.5 sm:py-2 px-1 sm:px-2 data-[state=active]:bg-background"
>
<span className="hidden xs:inline">{t.allUsers ?? (isAr ? 'الكل' : 'All')}</span>
<span className="xs:hidden">{isAr ? 'الكل' : 'All'}</span>
<span className="ml-0.5 sm:ml-1 text-muted-foreground">({allUsers.length})</span>
</TabsTrigger>
<TabsTrigger
value="students"
className="text-[10px] sm:text-xs md:text-sm py-1.5 sm:py-2 px-1 sm:px-2 data-[state=active]:bg-background"
>
<span className="hidden xs:inline">{t.students ?? (isAr ? 'طلاب' : 'Students')}</span>
<span className="xs:hidden">{isAr ? 'طلاب' : 'Stu'}</span>
<span className="ml-0.5 sm:ml-1 text-muted-foreground">({studentList.length})</span>
</TabsTrigger>
<TabsTrigger
value="sheikhs"
className="text-[10px] sm:text-xs md:text-sm py-1.5 sm:py-2 px-1 sm:px-2 data-[state=active]:bg-background"
>
<span className="hidden xs:inline">{t.sheikhs ?? (isAr ? 'شيوخ' : 'Sheikhs')}</span>
<span className="xs:hidden">{isAr ? 'شيوخ' : 'Sh'}</span>
<span className="ml-0.5 sm:ml-1 text-muted-foreground">({sheikhList.length})</span>
</TabsTrigger>
<TabsTrigger
value="admins"
className="text-[10px] sm:text-xs md:text-sm py-1.5 sm:py-2 px-1 sm:px-2 data-[state=active]:bg-background"
>
<span className="hidden xs:inline">{t.admins ?? (isAr ? 'مسؤولون' : 'Admins')}</span>
<span className="xs:hidden">{isAr ? 'مسؤول' : 'Adm'}</span>
<span className="ml-0.5 sm:ml-1 text-muted-foreground">({adminList.length})</span>
</TabsTrigger>
</TabsList>
<TabsContent value="all" className="mt-4 sm:mt-6"><UserTable list={allUsers} /></TabsContent>
<TabsContent value="students" className="mt-4 sm:mt-6"><UserTable list={studentList} /></TabsContent>
<TabsContent value="sheikhs" className="mt-4 sm:mt-6"><UserTable list={sheikhList} /></TabsContent>
<TabsContent value="admins" className="mt-4 sm:mt-6"><UserTable list={adminList} /></TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
);
}