Spaces:
Running
Running
| import { useCallback, useEffect, useMemo, useState } from 'react' | |
| import useSWR from 'swr' | |
| import toast from 'react-hot-toast' | |
| import { adminAPI, fetcher } from '../api/api' | |
| export default function useAdminUsers() { | |
| const [pageIndex, setPageIndex] = useState(0) | |
| const [pageCursors, setPageCursors] = useState([null]) | |
| const [searchInput, setSearchInput] = useState('') | |
| const [searchQuery, setSearchQuery] = useState('') | |
| const [updating, setUpdating] = useState({}) | |
| const [resetLink, setResetLink] = useState(null) | |
| const limit = 50 | |
| useEffect(() => { | |
| const timer = setTimeout(() => { | |
| setSearchQuery(searchInput.trim()) | |
| setPageIndex(0) | |
| setPageCursors([null]) | |
| }, 300) | |
| return () => clearTimeout(timer) | |
| }, [searchInput]) | |
| const currentCursor = pageCursors[pageIndex] | |
| const params = new URLSearchParams({ limit: String(limit) }) | |
| if (currentCursor) params.set('cursor', currentCursor) | |
| if (searchQuery) params.set('q', searchQuery) | |
| const { data: usersData, isLoading: loading, mutate } = useSWR(`/admin/users?${params.toString()}`, fetcher) | |
| const safeUsersData = usersData || {} | |
| const users = usersData?.items || [] | |
| const usersById = useMemo(() => new Map(users.map(user => [user.id, user])), [users]) | |
| const totalUsers = usersData?.total || 0 | |
| const pendingUsers = usersData?.pending_count || 0 | |
| const aspirantsCount = usersData?.aspirants_count || 0 | |
| const hasPagination = totalUsers > limit | |
| const hasMore = usersData?.has_more ?? false | |
| const showingFrom = totalUsers === 0 ? 0 : pageIndex * limit + 1 | |
| const showingTo = Math.min(pageIndex * limit + users.length, totalUsers) | |
| const reload = useCallback(() => mutate(), [mutate]) | |
| const changeRole = useCallback(async (userId, role) => { | |
| const oldRole = usersById.get(userId)?.role | |
| setUpdating(u => ({ ...u, [userId]: true })) | |
| try { | |
| await adminAPI.updateRole(userId, role) | |
| let newPending = pendingUsers | |
| let newAspirants = aspirantsCount | |
| if (oldRole === 'user' && role === 'aspirant') { | |
| newPending-- | |
| newAspirants++ | |
| } | |
| if (oldRole === 'aspirant' && role === 'user') { | |
| newPending++ | |
| newAspirants-- | |
| } | |
| mutate({ | |
| ...safeUsersData, | |
| items: users.map(u => (u.id === userId ? { ...u, role } : u)), | |
| pending_count: newPending, | |
| aspirants_count: newAspirants, | |
| }, false) | |
| toast.success(`Role updated to ${role}`) | |
| } catch (err) { | |
| toast.error(err.response?.data?.detail || 'Failed') | |
| } finally { | |
| setUpdating(u => ({ ...u, [userId]: false })) | |
| } | |
| }, [usersById, pendingUsers, aspirantsCount, usersData, users, mutate]) | |
| const toggleStatus = useCallback(async (userId) => { | |
| setUpdating(u => ({ ...u, [userId]: true })) | |
| try { | |
| await adminAPI.toggleStatus(userId) | |
| mutate({ ...safeUsersData, items: users.map(u => (u.id === userId ? { ...u, is_active: !u.is_active } : u)) }, false) | |
| toast.success('Status updated') | |
| } catch { | |
| toast.error('Failed') | |
| } finally { | |
| setUpdating(u => ({ ...u, [userId]: false })) | |
| } | |
| }, [usersData, users, mutate]) | |
| const createResetLink = useCallback(async (userId) => { | |
| const key = `reset-${userId}` | |
| setUpdating(u => ({ ...u, [key]: true })) | |
| try { | |
| const res = await adminAPI.createPasswordReset(userId) | |
| setResetLink(res.data) | |
| try { | |
| await navigator.clipboard.writeText(res.data.reset_url) | |
| toast.success('Reset link created & copied to clipboard') | |
| } catch { | |
| toast.success('Reset link created — copy it from the dialog') | |
| } | |
| } catch (err) { | |
| toast.error(err.response?.data?.detail || 'Failed to create reset link') | |
| } finally { | |
| setUpdating(u => ({ ...u, [key]: false })) | |
| } | |
| }, []) | |
| const copyResetLink = useCallback(async () => { | |
| if (!resetLink?.reset_url) return | |
| try { | |
| await navigator.clipboard.writeText(resetLink.reset_url) | |
| toast.success('Reset link copied') | |
| } catch { | |
| toast.error('Could not copy link') | |
| } | |
| }, [resetLink]) | |
| const goToPreviousPage = useCallback(() => setPageIndex(i => Math.max(0, i - 1)), []) | |
| const goToNextPage = useCallback(() => { | |
| if (!usersData?.next_cursor) return | |
| setPageCursors(cursors => [ | |
| ...cursors.slice(0, pageIndex + 1), | |
| usersData.next_cursor, | |
| ]) | |
| setPageIndex(i => i + 1) | |
| }, [usersData?.next_cursor, pageIndex]) | |
| return { | |
| pageIndex, | |
| users, | |
| loading, | |
| totalUsers, | |
| pendingUsers, | |
| searchInput, | |
| setSearchInput, | |
| updating, | |
| resetLink, | |
| setResetLink, | |
| reload, | |
| changeRole, | |
| toggleStatus, | |
| createResetLink, | |
| copyResetLink, | |
| goToPreviousPage, | |
| goToNextPage, | |
| showingFrom, | |
| showingTo, | |
| hasPagination, | |
| hasMore, | |
| } | |
| } | |