diff --git a/frontend/app/admin/members/page.tsx b/frontend/app/admin/members/page.tsx new file mode 100644 index 00000000..0d300b44 --- /dev/null +++ b/frontend/app/admin/members/page.tsx @@ -0,0 +1,53 @@ +"use client"; +import { useState } from "react"; +import DashboardLayout from "@/components/dashboard/DashboardLayout"; +import { MemberStats } from "@/components/dashboard/admin/MemberStats"; +import { MembersTable } from "@/components/dashboard/admin/MembersTable"; +import { Search } from "lucide-react"; + +export default function AdminMembersPage() { + const [search, setSearch] = useState(""); + const [filter, setFilter] = useState("ALL"); + const [page, setPage] = useState(1); + + return ( + +
+

Members

+

Manage users and roles.

+
+ +
+
+
+ + { + setSearch(e.target.value); + setPage(1); + }} + className="w-full pl-10 pr-4 py-2 text-sm border rounded-lg outline-none focus:ring-1 focus:ring-gray-200" + placeholder="Search..." + /> +
+ +
+ +
+
+ ); +} \ No newline at end of file diff --git a/frontend/components/dashboard/admin/ActionConfirmationModal.tsx b/frontend/components/dashboard/admin/ActionConfirmationModal.tsx new file mode 100644 index 00000000..67910a1d --- /dev/null +++ b/frontend/components/dashboard/admin/ActionConfirmationModal.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Member } from "@/lib/types/member"; +import { + MemberAction, + useMemberMutations, +} from "@/lib/react-query/hooks/useMemberMutations"; + +interface Props { + isOpen: boolean; + onClose: () => void; + member: Member; + action: MemberAction; +} + +export function ActionConfirmationModal({ isOpen, onClose, member, action }: Props) { + const { mutate, isPending } = useMemberMutations(); + const isMutating = isPending; + const [errorMessage, setErrorMessage] = useState(null); + const modalRef = useRef(null); + const confirmButtonRef = useRef(null); + + useEffect(() => { + if (!isOpen) return; + + const getFocusableElements = () => { + if (!modalRef.current) return [] as HTMLElement[]; + const selectors = + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + return Array.from(modalRef.current.querySelectorAll(selectors)); + }; + + const focusableElements = getFocusableElements(); + (focusableElements[0] ?? confirmButtonRef.current)?.focus(); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + return; + } + + if (event.key !== "Tab") return; + + const elements = getFocusableElements(); + if (elements.length === 0) return; + + const firstElement = elements[0]; + const lastElement = elements[elements.length - 1]; + const activeElement = document.activeElement as HTMLElement | null; + + if (event.shiftKey) { + if (activeElement === firstElement || !activeElement) { + event.preventDefault(); + lastElement.focus(); + } + return; + } + + if (activeElement === lastElement) { + event.preventDefault(); + firstElement.focus(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [isOpen, onClose]); + + if (!isOpen) return null; + + const handleConfirm = () => { + setErrorMessage(null); + mutate( + { memberId: member.id, action }, + { + onSuccess: () => onClose(), + onError: (error) => { + const message = + error instanceof Error + ? error.message + : "Failed to update member. Please try again."; + setErrorMessage(message); + }, + } + ); + }; + + return ( +
+
+

+ Confirm {action} +

+

+ Are you sure you want to {action.toLowerCase()} {member.firstName}? + This will take effect immediately. +

+ {errorMessage && ( +

+ {errorMessage} +

+ )} +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/components/dashboard/admin/MemberActionButtons.tsx b/frontend/components/dashboard/admin/MemberActionButtons.tsx new file mode 100644 index 00000000..aaeb39a8 --- /dev/null +++ b/frontend/components/dashboard/admin/MemberActionButtons.tsx @@ -0,0 +1,56 @@ +"use client"; +import { useState } from "react"; +import { Member } from "@/lib/types/member"; +import { ActionConfirmationModal } from "./ActionConfirmationModal"; +import { MemberAction } from "@/lib/react-query/hooks/useMemberMutations"; + +export function MemberActionButtons({ member }: { member: Member }) { + const [modal, setModal] = useState<{ open: boolean; action: MemberAction | null }>({ + open: false, + action: null, + }); + if (["ADMIN", "SUPER_ADMIN"].includes(member.role)) return Read-only; + + const open = (action: MemberAction) => setModal({ open: true, action }); + + return ( + <> +
+ {member.status === "ACTIVE" ? ( + <> + + + + ) : ( + + )} +
+ {modal.open && modal.action && ( + setModal({ ...modal, open: false })} + member={member} + action={modal.action} + /> + )} + + ); +} \ No newline at end of file diff --git a/frontend/components/dashboard/admin/MemberStats.tsx b/frontend/components/dashboard/admin/MemberStats.tsx new file mode 100644 index 00000000..2ed14ecc --- /dev/null +++ b/frontend/components/dashboard/admin/MemberStats.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useGetMemberStats } from "@/lib/react-query/hooks/members"; // Ensure hook path + +export function MemberStats() { + const { + data: stats, + isLoading, + isError, + error, + refetch, + } = useGetMemberStats(); + + if (isLoading) { + return ( +
+ {[...Array(4)].map((_, i) => ( +
+

Loading

+
+
+ ))} +
+ ); + } + + if (isError) { + const message = + error instanceof Error + ? error.message + : "Could not load member stats right now."; + + return ( +
+

Unable to load member stats

+

{message}

+ +
+ ); + } + + if (!stats) { + return null; + } + + const cards = [ + { label: "Total Members", value: stats.total }, + { label: "Active", value: stats.active }, + { label: "Suspended", value: stats.suspended }, + { label: "New This Month", value: stats.newThisMonth }, + ]; + + return ( +
+ {cards.map((card, i) => ( +
+

+ {card.label} +

+

{card.value}

+
+ ))} +
+ ); +} \ No newline at end of file diff --git a/frontend/components/dashboard/admin/MembersTable.tsx b/frontend/components/dashboard/admin/MembersTable.tsx new file mode 100644 index 00000000..d9556058 --- /dev/null +++ b/frontend/components/dashboard/admin/MembersTable.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useEffect } from "react"; +import { useGetAllMembers } from "@/lib/react-query/hooks/members"; +import { MemberActionButtons } from "./MemberActionButtons"; +import { TablePagination } from "./TablePagination"; // Ensure this component is created +import type { Member } from "@/lib/types/member"; + +interface Props { + search: string; + filter: string; + page: number; + setPage: (p: number) => void; +} + +export function MembersTable({ search, filter, page, setPage }: Props) { + const { data, isLoading, isError, error, refetch } = useGetAllMembers({ + search, + status: filter, + page, + }); + + useEffect(() => { + if (isError && error) { + console.error("Failed to fetch members", error); + } + }, [isError, error]); + + // 1. Loading Skeleton State + if (isLoading) { + return ( +
+ {[...Array(5)].map((_, i) => ( +
+
+
+ ))} +
+ ); + } + + if (isError || error) { + const message = + error instanceof Error + ? error.message + : "We could not load members right now. Please try again."; + + return ( +
+

Unable to load members.

+

{message}

+ +
+ ); + } + + // 2. Empty State + if (!data?.members?.length) { + return ( +
+

No members found matching your criteria.

+

Try adjusting your search or filters.

+
+ ); + } + + return ( +
+ + + + + + + + + + + + {data.members.map((member: Member) => ( + + + + + + + + ))} + +
MemberStatusRoleJoinedActions
+
+ {member.firstName} {member.lastName} +
+
{member.email}
+
+ + {member.status} + + + + {member.role} + + + {new Date(member.createdAt).toLocaleDateString()} + + +
+ + {/* 3. Pagination Controls (Uses setPage, fixing the ESLint warning) */} + setPage(newPage)} + /> +
+ ); +} \ No newline at end of file diff --git a/frontend/components/dashboard/admin/TablePagination.tsx b/frontend/components/dashboard/admin/TablePagination.tsx new file mode 100644 index 00000000..ac24f06f --- /dev/null +++ b/frontend/components/dashboard/admin/TablePagination.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { ChevronLeft, ChevronRight } from "lucide-react"; + +interface Props { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; +} + +export function TablePagination({ currentPage, totalPages, onPageChange }: Props) { + // Don't render if there's only one page + if (totalPages <= 1) return null; + + return ( +
+
+ + +
+
+
+

+ Showing page {currentPage} of{" "} + {totalPages} +

+
+
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/lib/react-query/hooks/members.ts b/frontend/lib/react-query/hooks/members.ts new file mode 100644 index 00000000..28b51bec --- /dev/null +++ b/frontend/lib/react-query/hooks/members.ts @@ -0,0 +1,53 @@ +import { useQuery } from "@tanstack/react-query"; +import { apiClient } from "@/lib/apiClient"; +import { memberKeys } from "../keys/memberKeys"; +import { Member, MemberStats, MembersResponse } from "@/lib/types/member"; + +interface BackendMember extends Omit { + firstname: string; + lastname: string; +} + +interface BackendMembersResponse extends Omit { + members: BackendMember[]; +} + +function mapBackendMember(member: BackendMember): Member { + const { firstname, lastname, ...rest } = member; + return { + ...rest, + firstName: firstname, + lastName: lastname, + }; +} + +export function useGetMemberStats() { + return useQuery({ + queryKey: memberKeys.stats(), + queryFn: async () => { + const data = await apiClient.get("/dashboard/member-stats"); + return data; + }, + }); +} + +export function useGetAllMembers({ search, status, page }: { search: string, status: string, page: number }) { + return useQuery({ + queryKey: memberKeys.list({ search, status, page }), + queryFn: async () => { + const params = new URLSearchParams({ + page: page.toString(), + limit: "10", + ...(search && { search }), + ...(status !== "ALL" && { status }), + }); + const data = await apiClient.get(`/users?${params.toString()}`); + + return { + ...data, + members: data.members.map(mapBackendMember), + }; + }, + placeholderData: (prev) => prev, + }); +} \ No newline at end of file diff --git a/frontend/lib/react-query/hooks/useMemberMutations.ts b/frontend/lib/react-query/hooks/useMemberMutations.ts new file mode 100644 index 00000000..8e3ef5d9 --- /dev/null +++ b/frontend/lib/react-query/hooks/useMemberMutations.ts @@ -0,0 +1,30 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { apiClient } from "@/lib/apiClient"; +import { memberKeys } from "../keys/memberKeys"; + +export type MemberAction = "SUSPEND" | "ACTIVATE" | "PROMOTE" | "DEMOTE"; + +export function useMemberMutations() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ memberId, action }: { memberId: string; action: MemberAction }) => { + const endpoints: Record = { + SUSPEND: `/users/${memberId}/suspend`, + ACTIVATE: `/users/${memberId}/activate`, + PROMOTE: `/users/${memberId}/promote`, + DEMOTE: `/users/${memberId}/demote`, + }; + const endpoint = endpoints[action]; + if (!endpoint) { + throw new Error(`Invalid member action: ${action}`); + } + + const data = await apiClient.patch(endpoint); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: memberKeys.all }); + }, + }); +} \ No newline at end of file diff --git a/frontend/lib/react-query/keys/memberKeys.ts b/frontend/lib/react-query/keys/memberKeys.ts new file mode 100644 index 00000000..00fb434a --- /dev/null +++ b/frontend/lib/react-query/keys/memberKeys.ts @@ -0,0 +1,6 @@ +export const memberKeys = { + all: ['members'] as const, + lists: () => [...memberKeys.all, 'list'] as const, + list: (filters: Record) => [...memberKeys.lists(), { ...filters }] as const, + stats: () => [...memberKeys.all, 'stats'] as const, +}; \ No newline at end of file diff --git a/frontend/lib/types/member.ts b/frontend/lib/types/member.ts new file mode 100644 index 00000000..86f5b318 --- /dev/null +++ b/frontend/lib/types/member.ts @@ -0,0 +1,25 @@ +export type UserRole = "USER" | "STAFF" | "ADMIN" | "SUPER_ADMIN"; +export type UserStatus = "ACTIVE" | "SUSPENDED"; + +export interface Member { + id: string; + firstName: string; + lastName: string; + email: string; + role: UserRole; + status: UserStatus; + createdAt: string; +} + +export interface MembersResponse { + members: Member[]; + totalPages: number; + currentPage: number; +} + +export interface MemberStats { + total: number; + active: number; + suspended: number; + newThisMonth: number; +} \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1214fc77..a33203cc 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1488,7 +1488,6 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", "license": "MIT", - "peer": true, "dependencies": { "@tanstack/query-core": "5.90.20" }, @@ -1635,7 +1634,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1646,7 +1644,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -1702,7 +1699,6 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -2215,7 +2211,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2697,8 +2692,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/d3-array": { "version": "3.2.4", @@ -3214,7 +3208,6 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5606,7 +5599,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5616,7 +5608,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -5629,7 +5620,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz", "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -5670,7 +5660,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -5723,8 +5712,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -6431,7 +6419,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6591,7 +6578,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver"