diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx index 3b388e2..62c419f 100644 --- a/app/admin/users/page.tsx +++ b/app/admin/users/page.tsx @@ -25,6 +25,29 @@ export const metadata: Metadata = { title: 'Manage Users | Admin', }; +type SortBy = + | 'user' + | 'joinedDate' + | 'workspacesOwned' + | 'invitedMembers' + | 'projectsOwned' + | 'totalComments' + | 'bunnyUpload' + | 'mediaStorage'; + +type SortDirection = 'asc' | 'desc'; + +const SORTABLE_COLUMNS: SortBy[] = [ + 'user', + 'joinedDate', + 'workspacesOwned', + 'invitedMembers', + 'projectsOwned', + 'totalComments', + 'bunnyUpload', + 'mediaStorage', +]; + function formatBytes(bytes: number, decimals = 2) { if (bytes < 0) return 'Error Fetching'; if (!+bytes) return '0 Bytes'; @@ -35,31 +58,55 @@ function formatBytes(bytes: number, decimals = 2) { return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`; } +function isSortBy(value: string | undefined): value is SortBy { + return !!value && SORTABLE_COLUMNS.includes(value as SortBy); +} + +function getDefaultSortDirection(sortBy: SortBy): SortDirection { + return sortBy === 'user' ? 'asc' : 'desc'; +} + +function getSortIndicator(column: SortBy, activeSortBy: SortBy, activeSortDirection: SortDirection): string { + if (column !== activeSortBy) return '↕'; + return activeSortDirection === 'asc' ? '↑' : '↓'; +} + export default async function AdminUsersPage({ searchParams }: { - searchParams: { page?: string } + searchParams: Promise<{ page?: string; sortBy?: string; sortDirection?: string }> }) { const session = await auth(); if (!session?.user?.isAdmin) { redirect('/'); } - const page = Number(searchParams?.page) || 1; + const resolvedSearchParams = await searchParams; + const requestedPage = Number(resolvedSearchParams?.page) || 1; const pageSize = 20; - const skip = (page - 1) * pageSize; + const sortBy: SortBy = isSortBy(resolvedSearchParams?.sortBy) ? resolvedSearchParams.sortBy : 'joinedDate'; + const sortDirection: SortDirection = + resolvedSearchParams?.sortDirection === 'asc' || resolvedSearchParams?.sortDirection === 'desc' + ? resolvedSearchParams.sortDirection + : getDefaultSortDirection(sortBy); - // Fetch users with their counts and total count - const [users, totalUsers] = await Promise.all([ + const [users, userStorage, userBunnyStorage, bunnyStorageStats] = await Promise.all([ db.user.findMany({ - skip, - take: pageSize, orderBy: { createdAt: 'desc' }, select: { id: true, name: true, email: true, createdAt: true, + ownedWorkspaces: { + select: { + _count: { + select: { + members: true, + } + } + } + }, _count: { select: { ownedWorkspaces: true, @@ -69,18 +116,82 @@ export default async function AdminUsersPage({ } } }), - db.user.count() - ]); - - const totalPages = Math.ceil(totalUsers / pageSize); - - // Determine per-user storage usage (cached) - const [userStorage, userBunnyStorage, bunnyStorageStats] = await Promise.all([ getCachedUserMediaStorage(), getCachedUserBunnyStorage(), getCachedBunnyStorageStats(), ]); + const usersWithMetrics = users.map((user) => ({ + ...user, + invitedMembersCount: user.ownedWorkspaces.reduce( + (total, workspace) => total + workspace._count.members, + 0 + ), + bunnyUploadBytes: userBunnyStorage[user.id] || 0, + mediaStorageBytes: userStorage[user.id]?.total || 0, + })); + + const sortedUsers = [...usersWithMetrics].sort((a, b) => { + let comparison = 0; + + if (sortBy === 'user') { + const aLabel = (a.name || a.email || 'Anonymous').toLowerCase(); + const bLabel = (b.name || b.email || 'Anonymous').toLowerCase(); + comparison = aLabel.localeCompare(bLabel); + } else if (sortBy === 'joinedDate') { + comparison = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); + } else if (sortBy === 'workspacesOwned') { + comparison = a._count.ownedWorkspaces - b._count.ownedWorkspaces; + } else if (sortBy === 'invitedMembers') { + comparison = a.invitedMembersCount - b.invitedMembersCount; + } else if (sortBy === 'projectsOwned') { + comparison = a._count.projects - b._count.projects; + } else if (sortBy === 'totalComments') { + comparison = a._count.comments - b._count.comments; + } else if (sortBy === 'bunnyUpload') { + comparison = a.bunnyUploadBytes - b.bunnyUploadBytes; + } else if (sortBy === 'mediaStorage') { + comparison = a.mediaStorageBytes - b.mediaStorageBytes; + } + + if (comparison === 0) { + comparison = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + } + + return sortDirection === 'asc' ? comparison : -comparison; + }); + + const totalUsers = sortedUsers.length; + const totalPages = Math.max(1, Math.ceil(totalUsers / pageSize)); + const page = Math.min(Math.max(1, requestedPage), totalPages); + const skip = (page - 1) * pageSize; + const paginatedUsers = sortedUsers.slice(skip, skip + pageSize); + + const buildUsersPageHref = ( + targetPage: number, + targetSortBy: SortBy = sortBy, + targetSortDirection: SortDirection = sortDirection + ): string => { + const params = new URLSearchParams({ + page: String(targetPage), + sortBy: targetSortBy, + sortDirection: targetSortDirection, + }); + + return `/admin/users?${params.toString()}`; + }; + + const buildSortHref = (column: SortBy): string => { + const nextDirection: SortDirection = + column === sortBy + ? sortDirection === 'asc' + ? 'desc' + : 'asc' + : getDefaultSortDirection(column); + + return buildUsersPageHref(1, column, nextDirection); + }; + return (