diff --git a/app/admin/const.ts b/app/admin/const.ts new file mode 100644 index 0000000..9450fcd --- /dev/null +++ b/app/admin/const.ts @@ -0,0 +1,123 @@ +import { ApplicationType } from '@/types/adminDashboard'; + +export const ADMIN_DASHBOARD_CONST = { + PAGE_TITLE: 'Admin Dashboard', + TABS: [ + { + label: 'All', + value: 'all', + }, + { + label: 'To Review', + value: 'toReview', + }, + { + label: 'Payment Pending', + value: 'paymentPending', + }, + { + label: 'Active', + value: 'active', + }, + { + label: 'Expired', + value: 'expired', + }, + { + label: 'Rejected', + value: 'rejected', + }, + ], + TABLE_COLUMNS: [ + { label: 'Applicant Name', value: 'applicantName' }, + { label: 'Application Type', value: 'type' }, + { label: 'Date Received', value: 'dateReceived' }, + { label: 'Status', value: 'status' }, + { label: 'Reviewer 1', value: 'reviewer1' }, + { label: 'Reviewer 2', value: 'reviewer2' }, + ], +}; + +export const ADMIN_DASHBOARD_MOCK = { + ADMIN_NAME: 'John Doe', + PROFILE_PICTURE_URL: undefined, + STAT_CARDS: [ + { + label: 'Independent Members', + value: 30, + }, + { + label: 'Organization Members', + value: 20, + }, + { + label: 'New Applications', + value: 6, + }, + ], + APPLICATIONS_MOCK: [ + { + id: 'APP01', + applicantName: 'Leighton Kramer', + type: 'Individual', + dateReceived: '2026-01-06', + status: 'toReview', + reviewer1: '', + reviewer2: '', + }, + { + id: 'APP02', + applicantName: 'Marceline Avila', + type: 'Organization', + dateReceived: '2025-12-18', + status: 'toReview', + reviewer1: 'Jane Doe', + reviewer2: '', + }, + { + id: 'APP03', + applicantName: 'Jensen Vang', + type: 'Organization', + dateReceived: '2025-11-11', + status: 'rejected', + reviewer1: 'John Doe', + reviewer2: 'Jane Doe', + }, + { + id: 'APP04', + applicantName: 'Linda Wu', + type: 'Individual', + dateReceived: '2025-11-11', + status: 'paymentPending', + reviewer1: 'John Doe', + reviewer2: 'Jane Doe', + }, + { + id: 'APP05', + applicantName: 'Carter Higgins', + type: 'Organization', + dateReceived: '2025-11-05', + status: 'active', + reviewer1: 'John Doe', + reviewer2: 'Jane Doe', + }, + { + id: 'APP06', + applicantName: 'Sarah Johnson', + type: 'Individual', + dateReceived: '2025-11-10', + status: 'active', + reviewer1: 'Jane Doe', + reviewer2: 'John Doe', + }, + { + id: 'APP07', + applicantName: 'Michael Chen', + type: 'Organization', + dateReceived: '2025-11-09', + status: 'expired', + reviewer1: 'John Doe', + reviewer2: '', + }, + ] as ApplicationType[], +}; diff --git a/app/admin/page.tsx b/app/admin/page.tsx index e69de29..e44f275 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { inter } from '@/app/fonts'; +import AdminNavbar from '@/components/AdminNavbar'; +import StatCard from '@/components/StatCard'; +import Tabs from '@/components/Tabs'; +import AdminSearchBar from '@/components/AdminSearchBar'; +import { ADMIN_DASHBOARD_MOCK, ADMIN_DASHBOARD_CONST } from './const'; +import AdminDashboardTable from '@/components/AdminDashboardTable'; +import AdminDashboardMobileTable from '@/components/AdminDashboardMobileTable'; + +export default function AdminDashboard() { + const [currentTab, setCurrentTab] = useState(ADMIN_DASHBOARD_CONST.TABS[0]); + const [searchQuery, setSearchQuery] = useState(''); + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(''); + + // Debounce the search query + // TODO: Use the debouncedSearchQuery to filter the applications in the backend + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearchQuery(searchQuery); + }, 300); // 300ms delay + + return () => clearTimeout(timer); + }, [searchQuery]); + + return ( + <> + +
+ +
+
+ {ADMIN_DASHBOARD_MOCK.STAT_CARDS.map((card) => ( + + ))} +
+
+ +
+ +
+
+ +
+
+ + ); +} diff --git a/app/fonts.ts b/app/fonts.ts new file mode 100644 index 0000000..9e9f732 --- /dev/null +++ b/app/fonts.ts @@ -0,0 +1,11 @@ +import { Roboto_Condensed, Inter } from 'next/font/google'; + +export const robotoCondensed = Roboto_Condensed({ + variable: '--font-roboto-condensed', + subsets: ['latin'], +}); + +export const inter = Inter({ + variable: '--font-inter', + subsets: ['latin'], +}); diff --git a/components/AdminDashboardMobileTable.tsx b/components/AdminDashboardMobileTable.tsx new file mode 100644 index 0000000..5821ff2 --- /dev/null +++ b/components/AdminDashboardMobileTable.tsx @@ -0,0 +1,89 @@ +import Table from './Table'; +import { formatDateWithOrdinal } from '@/lib/utils'; +import StatusChip from './StatusChip'; +import { AdminDashboardMobileTablePropTypes } from '@/types/adminDashboard'; +import { useState, useMemo } from 'react'; +import { Pagination } from './Pagination'; + +export default function AdminDashboardMobileTable({ + applications, + currentTab, + pagination, +}: AdminDashboardMobileTablePropTypes) { + const [currentPage, setCurrentPage] = useState(1); + + const handlePageChange = (page: number) => { + setCurrentPage(page); + }; + + // TODO: This should be handled in the backend + const filteredApplications = useMemo(() => { + return applications.filter((app) => + currentTab.value !== 'all' ? app.status === currentTab.value : true, + ); + }, [applications, currentTab]); + + // TODO: This should be handled in the backend + const paginatedApplications = useMemo(() => { + if (!pagination) { + return filteredApplications; + } + const startIndex = (currentPage - 1) * pagination.numberPerPage; + const endIndex = startIndex + pagination.numberPerPage; + return filteredApplications.slice(startIndex, endIndex); + }, [filteredApplications, currentPage, pagination]); + + return ( + <> +
+ {paginatedApplications.map((app) => ( +
+
+
{app.applicantName}
+
+ +
+
+
+
+

Type

+

{app.type}

+
+
+

Date Received

+

+ {formatDateWithOrdinal(app.dateReceived)} +

+
+
+
+
+

Reviewer 1

+

+ {app.reviewer1 === '' ? '-' : app.reviewer1} +

+
+
+

Reviewer 2

+

+ {app.reviewer2 === '' ? '-' : app.reviewer2} +

+
+
+
+ ))} +
+ {pagination && ( + + )} + + ); +} diff --git a/components/AdminDashboardTable.tsx b/components/AdminDashboardTable.tsx new file mode 100644 index 0000000..3f923c4 --- /dev/null +++ b/components/AdminDashboardTable.tsx @@ -0,0 +1,78 @@ +import Table from './Table'; +import { formatDateWithOrdinal } from '@/lib/utils'; +import StatusChip from './StatusChip'; +import { AdminDashboardTablePropTypes } from '@/types/adminDashboard'; +import { useState, useMemo } from 'react'; + +export default function AdminDashboardTable({ + columns, + applications, + currentTab, + pagination, +}: AdminDashboardTablePropTypes) { + const [currentPage, setCurrentPage] = useState(1); + + // TODO: This should be handled in the backend + const filteredApplications = useMemo(() => { + return applications.filter((app) => + currentTab.value !== 'all' ? app.status === currentTab.value : true, + ); + }, [applications, currentTab]); + + // TODO: This should be handled in the backend + const paginatedApplications = useMemo(() => { + if (!pagination) { + return filteredApplications; + } + const startIndex = (currentPage - 1) * pagination.numberPerPage; + const endIndex = startIndex + pagination.numberPerPage; + return filteredApplications.slice(startIndex, endIndex); + }, [filteredApplications, currentPage, pagination]); + + return ( + pagination.numberPerPage, + } + : undefined + } + onPageChange={setCurrentPage} + > + {paginatedApplications.map((app) => ( + + + + + + + + + + + + + + ))} +
{app.applicantName}{app.type} + {formatDateWithOrdinal(app.dateReceived)} + + + + {app.reviewer1 === '' ? '-' : app.reviewer1} + + {app.reviewer2 === '' ? '-' : app.reviewer2} +
+ ); +} diff --git a/components/AdminNavbar.tsx b/components/AdminNavbar.tsx new file mode 100644 index 0000000..b41f2c9 --- /dev/null +++ b/components/AdminNavbar.tsx @@ -0,0 +1,22 @@ +import { robotoCondensed } from '@/app/fonts'; + +export default function AdminNavbar() { + return ( +
+

+ RPRC Membership Management +

+
+ ); +} diff --git a/components/AdminSearchBar.tsx b/components/AdminSearchBar.tsx new file mode 100644 index 0000000..8223bbf --- /dev/null +++ b/components/AdminSearchBar.tsx @@ -0,0 +1,34 @@ +import { inter } from '@/app/fonts'; +import { Search } from 'lucide-react'; + +type AdminSearchBarProps = { + value: string; + onChange: (value: string) => void; + placeholder?: string; +}; + +export default function AdminSearchBar({ + value, + onChange, + placeholder = 'Search by name...', +}: AdminSearchBarProps) { + return ( +
+ + onChange(e.target.value)} + className={` + ${inter.className} + w-full placeholder:text-[#A9A295] placeholder:text-[16px] focus:outline-none + `} + /> +
+ ); +} diff --git a/components/Button.tsx b/components/Button.tsx new file mode 100644 index 0000000..ab1287f --- /dev/null +++ b/components/Button.tsx @@ -0,0 +1,35 @@ +type IconButtonAdditionalClassesPropTypes = { + button: string[]; +}; + +type ButtonComponentPropTypes = { + children: React.ReactNode; + name?: string; + value?: string; + handleClick: React.MouseEventHandler; + additionalClasses?: IconButtonAdditionalClassesPropTypes; + isActive?: boolean; + disabled?: boolean; +}; + +export const Button = ({ + children, + name, + value, + handleClick, + disabled = false, + additionalClasses, + isActive = false, +}: Readonly) => { + return ( + + ); +}; diff --git a/components/Pagination.tsx b/components/Pagination.tsx new file mode 100644 index 0000000..46134e9 --- /dev/null +++ b/components/Pagination.tsx @@ -0,0 +1,111 @@ +import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'; +import { Button } from './Button'; +import { useState, useEffect } from 'react'; + +type PaginationPropTypes = { + activePage: number; + itemCount: number; + numberItemsPerPage?: number; + onPageChange: (page: number) => void; +}; + +type usePaginationPropTypes = { + numberItemsPerPage?: number; + activePage: number; + itemCount: number; +}; + +const usePagination = ({ + numberItemsPerPage, + itemCount, + activePage, +}: usePaginationPropTypes) => { + const [pageCount, setPageCount] = useState(1); + const [numberPerPage, setNumberPerPage] = useState(10); + + const windowSize = 5; + const maxPage = pageCount; + const shouldShift = activePage > windowSize; + + const windowEnd = shouldShift ? Math.min(activePage, maxPage) : windowSize; + const windowStart = Math.max(windowEnd - (windowSize - 1), 1); + + const setupPagination = (itemCount: number) => { + const numberOfPages = Math.ceil(itemCount / numberPerPage); + setPageCount(numberOfPages); + }; + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + if (numberItemsPerPage) setNumberPerPage(numberItemsPerPage); + }, [numberItemsPerPage]); + + useEffect(() => { + if (!itemCount) return; + // eslint-disable-next-line react-hooks/set-state-in-effect + setupPagination(itemCount); + }, [itemCount, numberPerPage]); + + return { + pageCount, + windowStart, + }; +}; + +export const Pagination = ({ + activePage, + itemCount, + numberItemsPerPage, + onPageChange, +}: PaginationPropTypes) => { + const hook = usePagination({ numberItemsPerPage, itemCount, activePage }); + + if (hook.pageCount <= 1) return null; + + return ( +
+
+ + +
+ {Array.from({ length: Math.min(5, hook.pageCount) }).map( + (_, index) => { + const pageNumber = hook.windowStart + index; + const isActive = activePage === pageNumber; + + return ( + + ); + }, + )} +
+ + +
+
+ ); +}; diff --git a/components/StatCard.tsx b/components/StatCard.tsx new file mode 100644 index 0000000..99c0e74 --- /dev/null +++ b/components/StatCard.tsx @@ -0,0 +1,24 @@ +import { inter, robotoCondensed } from '@/app/fonts'; + +type StatCardProps = { + label: string; + value: number; +}; + +export default function StatCard({ label, value }: StatCardProps) { + return ( +
+

+ + {value} + + +

+

+ {label} +

+
+ ); +} diff --git a/components/StatusChip.tsx b/components/StatusChip.tsx new file mode 100644 index 0000000..c0face2 --- /dev/null +++ b/components/StatusChip.tsx @@ -0,0 +1,36 @@ +import { inter } from '@/app/fonts'; + +type StatusChipType = { + color: string; + label: string; + theme: string; +}; + +const STATUS_CHIPS: StatusChipType[] = [ + { color: '#00519F', label: 'To Review', theme: 'toReview' }, + { color: '#393533', label: 'Rejected', theme: 'rejected' }, + { color: '#C67D38', label: 'Payment Pending', theme: 'paymentPending' }, + { color: '#5EB42D', label: 'Active', theme: 'active' }, + { color: '#BE282B', label: 'Expired', theme: 'expired' }, +]; + +type StatusChipProps = { + theme: 'toReview' | 'rejected' | 'paymentPending' | 'active' | 'expired'; +}; + +export default function StatusChip({ theme }: StatusChipProps) { + const status = STATUS_CHIPS.find((s) => s.theme === theme); + + if (!status) return null; + + return ( +
+
+ +

{status.label}

+
+ ); +} diff --git a/components/Table.tsx b/components/Table.tsx new file mode 100644 index 0000000..bc1786f --- /dev/null +++ b/components/Table.tsx @@ -0,0 +1,77 @@ +import { inter } from '@/app/fonts'; +import { ReactNode, useState } from 'react'; +import { ColumnType } from '@/types/adminDashboard'; +import { Pagination } from './Pagination'; + +type TableProps = { + columnNames: ColumnType[]; + sort?: { + sortedBy?: string; + sortingOrder: 'asc' | 'desc'; + }; + pagination?: { + itemCount: number; + numberPerPage: number; + usePagination?: boolean; + }; + children: ReactNode; + additionalClasses?: { + wrapper?: string; + table?: string; + }; + onPageChange?: (page: number) => void; +}; + +export default function Table({ + columnNames, + children, + additionalClasses, + pagination, + onPageChange, +}: TableProps) { + const [activePage, setActivePage] = useState(1); + + const handlePageChange = (page: number) => { + setActivePage(page); + onPageChange?.(page); + }; + + return ( +
+
+ + + + {columnNames.map((column, idx) => ( + + ))} + + + + {children} +
+ {column.label} +
+
+ + {pagination?.usePagination && ( + + )} +
+ ); +} diff --git a/components/Tabs.tsx b/components/Tabs.tsx new file mode 100644 index 0000000..19450cd --- /dev/null +++ b/components/Tabs.tsx @@ -0,0 +1,47 @@ +import { inter } from '@/app/fonts'; + +type Tab = { + label: string; + value: string; +}; + +type TabsProps = { + tabs: Tab[]; + currentTab: Tab; + setCurrentTab: (tab: Tab) => void; + additionalClasses?: { + wrapper: string; + }; +}; + +export default function Tabs({ + tabs, + currentTab, + setCurrentTab, + additionalClasses, +}: TabsProps) { + return ( +
+ {tabs.map((tab) => { + const isActive = tab.value === currentTab.value; + + return ( + + ); + })} +
+ ); +} diff --git a/lib/utils.ts b/lib/utils.ts index bd0c391..c7ca753 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,6 +1,30 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) + return twMerge(clsx(inputs)); +} + +function getOrdinal(day: number) { + if (day > 3 && day < 21) return 'th'; + switch (day % 10) { + case 1: + return 'st'; + case 2: + return 'nd'; + case 3: + return 'rd'; + default: + return 'th'; + } +} + +export function formatDateWithOrdinal(date: string | Date) { + const d = new Date(date); + + const day = d.getDate(); + const month = d.toLocaleString('en-US', { month: 'short' }); + const year = d.getFullYear(); + + return `${day}${getOrdinal(day)} ${month} ${year}`; } diff --git a/types/adminDashboard.ts b/types/adminDashboard.ts new file mode 100644 index 0000000..824fac7 --- /dev/null +++ b/types/adminDashboard.ts @@ -0,0 +1,40 @@ +export type ColumnType = { + label: string; + value: string; + width?: string; +}; + +export type ApplicationType = { + id: string; + applicantName: string; + type: 'Individual' | 'Organization'; + dateReceived: string | Date; + status: 'toReview' | 'rejected' | 'paymentPending' | 'active' | 'expired'; + reviewer1: string; + reviewer2: string; +}; + +export type AdminDashboardTablePropTypes = { + columns: ColumnType[]; + applications: ApplicationType[]; + currentTab: { + label: string; + value: string; + }; + pagination?: { + itemCount: number; + numberPerPage: number; + }; +}; + +export type AdminDashboardMobileTablePropTypes = { + applications: ApplicationType[]; + currentTab: { + label: string; + value: string; + }; + pagination?: { + itemCount: number; + numberPerPage: number; + }; +};