import { ChevronDown, ChevronLeft, ChevronRight, Search, TrendingUp, UserCheck, Users, X } from "lucide-react" import { useEffect, useState } from "react" import { useNavigate } from "react-router-dom" import * as DropdownMenu from "@radix-ui/react-dropdown-menu" import { Input } from "../../components/ui/input" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table" import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar" import { Button } from "../../components/ui/button" import { Card, CardContent } from "../../components/ui/card" import { SpinnerIcon } from "../../components/ui/spinner-icon" import { cn } from "../../lib/utils" import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination" import { getDashboard } from "../../api/analytics.api" import { getUsers, updateUserStatus, type UserStatus } from "../../api/users.api" import type { DashboardUsers } from "../../types/analytics.types" import { mapUserApiToUser } from "../../types/user.types" import { useUsersStore } from "../../zustand/userStore" import { toast } from "sonner" import axios from "axios" import { USER_FILTER_COUNTRIES, USER_FILTER_ETHIOPIA_REGIONS } from "../../data/userFilterLocations" function formatJoinedAt(iso: string): string { if (!iso?.trim()) return "—" const d = new Date(iso) if (Number.isNaN(d.getTime())) return "—" return d.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) } /** Convert `` value to RFC3339 for GET /users. */ function toRfc3339FromDatetimeLocal(value: string): string | undefined { const t = value?.trim() if (!t) return undefined const d = new Date(t) if (Number.isNaN(d.getTime())) return undefined return d.toISOString() } /** Portaled menu — native `` lists break inside `overflow-y-auto` shells (e.g. app main). */ function UserListFilterDropdown({ id, label, value, allLabel, options, onSelect, }: { id: string label: string value: string allLabel: string options: readonly string[] onSelect: (next: string) => void }) { return ( {label} {value || allLabel} e.preventDefault()} > onSelect("")} > {allLabel} {options.map((opt) => ( onSelect(opt)} > {opt} ))} ) } export function UsersListPage() { const navigate = useNavigate() const { users, total, page, pageSize, search, setUsers, setTotal, setPage, setPageSize, setSearch, } = useUsersStore() const [selectedIds, setSelectedIds] = useState>(new Set()) const [toggledStatuses, setToggledStatuses] = useState>({}) const [updatingStatusIds, setUpdatingStatusIds] = useState>(new Set()) const [confirmDialog, setConfirmDialog] = useState<{ id: number name: string nextStatus: UserStatus } | null>(null) const [roleFilter, setRoleFilter] = useState("") const [statusFilter, setStatusFilter] = useState("") const [createdAfterLocal, setCreatedAfterLocal] = useState("") const [createdBeforeLocal, setCreatedBeforeLocal] = useState("") const [countryFilter, setCountryFilter] = useState("") const [regionFilter, setRegionFilter] = useState("") const [subscriptionStatusFilter, setSubscriptionStatusFilter] = useState("") const [loading, setLoading] = useState(false) const [userSummary, setUserSummary] = useState(null) const [userSummaryLoading, setUserSummaryLoading] = useState(true) useEffect(() => { const fetchSummary = async () => { try { const res = await getDashboard() setUserSummary(res.data.users ?? null) } catch { setUserSummary(null) } finally { setUserSummaryLoading(false) } } fetchSummary() }, []) useEffect(() => { const fetchUsers = async () => { setLoading(true) try { const res = await getUsers({ page, page_size: pageSize, role: roleFilter || undefined, status: statusFilter || undefined, query: search || undefined, created_after: toRfc3339FromDatetimeLocal(createdAfterLocal), created_before: toRfc3339FromDatetimeLocal(createdBeforeLocal), country: countryFilter.trim() || undefined, region: regionFilter.trim() || undefined, subscription_status: subscriptionStatusFilter || undefined, }) const apiUsers = res.data.data.users const mapped = apiUsers.map(mapUserApiToUser) setUsers(mapped) setTotal(res.data.data.total) const initialStatuses: Record = {} mapped.forEach((u) => { initialStatuses[u.id] = u.status === "ACTIVE" }) setToggledStatuses((prev) => ({ ...prev, ...initialStatuses })) } catch (error) { console.error("Failed to fetch users:", error) setUsers([]) setTotal(0) const msg = axios.isAxiosError(error) ? (error.response?.data as { message?: string } | undefined)?.message : undefined toast.error(typeof msg === "string" && msg.trim() ? msg : "Failed to fetch users") } finally { setLoading(false) } } fetchUsers() }, [ page, pageSize, roleFilter, statusFilter, search, createdAfterLocal, createdBeforeLocal, countryFilter, regionFilter, subscriptionStatusFilter, setUsers, setTotal, ]) const pageCount = Math.max(1, Math.ceil(total / pageSize)) const safePage = Math.min(page, pageCount) const handlePrev = () => safePage > 1 && setPage(safePage - 1) const handleNext = () => safePage < pageCount && setPage(safePage + 1) const handleSelectAll = (checked: boolean) => { if (checked) { setSelectedIds(new Set(users.map((u) => u.id))) } else { setSelectedIds(new Set()) } } const handleSelectOne = (id: number, checked: boolean) => { const newSet = new Set(selectedIds) if (checked) { newSet.add(id) } else { newSet.delete(id) } setSelectedIds(newSet) } const allSelected = users.length > 0 && selectedIds.size === users.length const startEntry = total === 0 ? 0 : (safePage - 1) * pageSize + 1 const endEntry = Math.min(safePage * pageSize, total) const formatSummaryNum = (n: number) => n.toLocaleString() const activeUsersTotal = userSummary?.by_status?.find((item) => item.label?.toUpperCase() === "ACTIVE")?.count ?? null const getPageNumbers = () => { const pages: (number | string)[] = [] if (pageCount <= 7) { for (let i = 1; i <= pageCount; i++) pages.push(i) } else { pages.push(1, 2, 3, 4) if (safePage > 5) { pages.push("...") } if (safePage > 4 && safePage < pageCount - 3) { pages.push(safePage) } if (safePage < pageCount - 4) { pages.push("...") } pages.push(pageCount) } return pages } const handleToggle = (id: number) => { if (updatingStatusIds.has(id)) return const user = users.find((u) => u.id === id) if (!user) return const isCurrentlyActive = toggledStatuses[id] ?? false const nextStatus: UserStatus = isCurrentlyActive ? "DEACTIVATED" : "ACTIVE" setConfirmDialog({ id, name: `${user.firstName} ${user.lastName}`.trim(), nextStatus, }) } const handleConfirmStatusUpdate = async () => { if (!confirmDialog) return const { id, nextStatus } = confirmDialog const nextActive = nextStatus === "ACTIVE" const previousActive = toggledStatuses[id] ?? false setToggledStatuses((prev) => ({ ...prev, [id]: nextActive })) setUpdatingStatusIds((prev) => new Set(prev).add(id)) try { await updateUserStatus({ user_id: id, status: nextStatus }) setUsers( users.map((user) => (user.id === id ? { ...user, status: nextStatus } : user)), ) toast.success(`User ${nextActive ? "activated" : "deactivated"} successfully`) } catch (err: any) { setToggledStatuses((prev) => ({ ...prev, [id]: previousActive })) toast.error("Failed to update user status", { description: err?.response?.data?.message || "Please try again.", }) } finally { setUpdatingStatusIds((prev) => { const next = new Set(prev) next.delete(id) return next }) setConfirmDialog(null) } } const handleRowClick = (userId: number) => { navigate(`/users/${userId}`) } const clearExtraFilters = () => { setCreatedAfterLocal("") setCreatedBeforeLocal("") setCountryFilter("") setRegionFilter("") setSubscriptionStatusFilter("") setPage(1) } const renderContactDetails = (phone: string | undefined, email: string | undefined) => { const hasPhone = Boolean(phone?.trim()) const hasEmail = Boolean(email?.trim()) if (!hasPhone && !hasEmail) { return — } return ( {hasPhone ? {phone!.trim()} : null} {hasEmail ? {email!.trim()} : null} ) } return ( {/* Header */} Users List View and manage all registered users. {/* Platform-wide user summary (same metrics as former User Management dashboard) */} Total Users {userSummaryLoading ? ( ) : userSummary ? ( formatSummaryNum(userSummary.total_users) ) : ( "—" )} Active Users {userSummaryLoading ? ( ) : activeUsersTotal !== null ? ( formatSummaryNum(activeUsersTotal) ) : ( "—" )} New This Month {userSummaryLoading ? ( ) : userSummary ? ( formatSummaryNum(userSummary.new_month) ) : ( "—" )} {/* Search & Filters */} setSearch(e.target.value)} /> { setRoleFilter(e.target.value) setPage(1) }} className="h-9 w-full sm:w-auto appearance-none rounded-md border bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-1 focus:ring-brand-500" > All roles Student Teacher Admin { setStatusFilter(e.target.value) setPage(1) }} className="h-9 w-full sm:w-auto appearance-none rounded-md border bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-1 focus:ring-brand-500" > All statuses Active Deactivated Suspended Pending Created on or after { setCreatedAfterLocal(e.target.value) setPage(1) }} className="h-9 w-full rounded-md border border-grayScale-200 bg-white px-2 text-sm text-grayScale-700 focus:outline-none focus:ring-1 focus:ring-brand-500" /> Created on or before { setCreatedBeforeLocal(e.target.value) setPage(1) }} className="h-9 w-full rounded-md border border-grayScale-200 bg-white px-2 text-sm text-grayScale-700 focus:outline-none focus:ring-1 focus:ring-brand-500" /> { setCountryFilter(next) setPage(1) }} /> { setRegionFilter(next) setPage(1) }} /> Subscription status { setSubscriptionStatusFilter(e.target.value) setPage(1) }} className="h-9 w-full appearance-none rounded-md border border-grayScale-200 bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-1 focus:ring-brand-500" > All Active Pending Unsubscribed Dates are sent as RFC3339 (UTC). Country and region filters use the lists above; the API matches case-insensitively. Clear date, location & subscription filters {/* Table */} handleSelectAll(e.target.checked)} className="h-4 w-4 rounded border-grayScale-300 text-brand-600 focus:ring-brand-500" /> USER Contact details Country Region Joined at Subscription status Status {loading ? ( Loading users... ) : users.length === 0 ? ( No users found Try adjusting your search or filters. ) : ( users.map((u) => { const isActive = toggledStatuses[u.id] ?? false const isUpdatingStatus = updatingStatusIds.has(u.id) return ( handleRowClick(u.id)} > e.stopPropagation()}> handleSelectOne(u.id, e.target.checked)} className="h-4 w-4 rounded border-grayScale-300 text-brand-600 focus:ring-brand-500" /> {`${u.firstName?.[0] ?? ""}${u.lastName?.[0] ?? ""}`.toUpperCase()} {u.firstName} {u.lastName} {renderContactDetails(u.phoneNumber, u.email)} {u.country || "-"} {u.region || "-"} {formatJoinedAt(u.createdAt)} {u.subscriptionStatus} e.stopPropagation()}> handleToggle(u.id)} disabled={isUpdatingStatus} className={cn( "relative inline-flex h-7 w-12 shrink-0 cursor-pointer items-center rounded-full border p-0.5 transition-all duration-200", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-300 focus-visible:ring-offset-1", isActive ? "border-brand-500 bg-brand-500 shadow-[0_6px_16px_rgba(168,85,247,0.35)]" : "border-grayScale-300 bg-grayScale-200 hover:bg-grayScale-300/80", isUpdatingStatus && "cursor-not-allowed opacity-60", )} > ) }) )} {/* Pagination */} Showing {startEntry}-{endEntry} of {total} entries Rows per page { setPageSize(Number(e.target.value)) setPage(1) }} className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none" > {TABLE_PAGE_SIZE_OPTIONS.map((size) => ( {size} ))} {getPageNumbers().map((n, idx) => typeof n === "string" ? ( ... ) : ( setPage(n)} className={cn( "h-8 w-8 rounded-md border text-sm font-medium", n === safePage ? "border-brand-500 bg-brand-500 text-white" : "bg-white text-grayScale-600 hover:bg-grayScale-50" )} > {n} ) )} {confirmDialog && ( Confirm Status Change setConfirmDialog(null)} className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600" > Are you sure you want to change the status of{" "} {confirmDialog.name || "this user"} to{" "} {confirmDialog.nextStatus.toLowerCase()}? setConfirmDialog(null)}> Cancel {updatingStatusIds.has(confirmDialog.id) ? "Updating..." : "Confirm"} )} ) }
View and manage all registered users.
Total Users
{userSummaryLoading ? ( ) : userSummary ? ( formatSummaryNum(userSummary.total_users) ) : ( "—" )}
Active Users
{userSummaryLoading ? ( ) : activeUsersTotal !== null ? ( formatSummaryNum(activeUsersTotal) ) : ( "—" )}
New This Month
{userSummaryLoading ? ( ) : userSummary ? ( formatSummaryNum(userSummary.new_month) ) : ( "—" )}
Dates are sent as RFC3339 (UTC). Country and region filters use the lists above; the API matches case-insensitively.
Loading users...
No users found
Try adjusting your search or filters.
Are you sure you want to change the status of{" "} {confirmDialog.name || "this user"} to{" "} {confirmDialog.nextStatus.toLowerCase()}?