diff --git a/src/App.tsx b/src/App.tsx index 971c786..7ae2c03 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,6 +26,8 @@ import MemberProfilePictureTest from "@/routes/test/MemberProfilePictureTest"; // routes import AdminLogin from "./routes/admin/AdminLogin"; +import AdminHome from "./routes/admin/AdminHome"; + import CaregiverLogin from "./routes/caregiver/CaregiverLogin"; const router = createBrowserRouter([ @@ -125,11 +127,7 @@ const router = createBrowserRouter([ // Admin Routes { path: "/admin", - element: ( -
-

Admin Home Page

-
- ), + element: , }, { path: "/admin/login", diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 4d83cca..aa01acc 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -6,7 +6,7 @@ interface ButtonProps extends React.ButtonHTMLAttributes { text?: string; rounded?: boolean; fill?: boolean; - disabled: boolean; + disabled?: boolean; fontSize?: string; color?: string; } @@ -44,16 +44,21 @@ const Button: React.FC = ({ > {Icon && !text && (
- +
)} {Icon && text && ( - <> +
- +
- {text} - + + {text} + +
)} {!Icon && text && {text}} diff --git a/src/components/MemberTable.tsx b/src/components/MemberTable.tsx index 763f782..3498718 100644 --- a/src/components/MemberTable.tsx +++ b/src/components/MemberTable.tsx @@ -1,21 +1,30 @@ //#region imports +import { twMerge } from "tailwind-merge"; import { UserCircle, Folder, ArrowsClockwise } from "@phosphor-icons/react"; -import ProfilePictures from "./ProfilePictures"; import * as Icon from "@phosphor-icons/react"; + +import ProfilePictures from "./ProfilePictures"; //#endregion //#region interfaces interface MemberTableProps { - data: { - profilePicture: { - type: "img" | "icon" | string; - src: string; - backgroundColor?: string; - }; - name: string; - storageUsed: string; - lastUpdated: string; - }[]; + className?: string; + data: memberData[]; + + isLoading: boolean; + sortbyStorageUsed: () => void; + sortbyLastUpdated: () => void; +} + +export interface memberData { + profilePicture: { + type: "img" | "icon" | string; + src: string; + backgroundColor?: string; + }; + name: string; + storageUsed: string; + lastUpdated: string; } //#endregion @@ -48,39 +57,66 @@ function selectIcon(type: string) { * Represents a table component that displays member data. * @param data - An array of objects representing each member's data. */ -export function MemberTable({ data }: MemberTableProps) { - const headers = [ - { icon: , label: "Name" }, - { icon: , label: "Storage Used" }, - { icon: , label: "Last Updated" }, - ]; - +export function MemberTable({ + data, + className, + isLoading, + sortbyStorageUsed, + sortbyLastUpdated, +}: MemberTableProps) { function handleClick(row: any) { alert(`Clicked ${row.name}`); } return ( -
- +
+
{/* Headers */} - {headers.map((header, index) => ( - + - ))} + + + Storage Used + + + + @@ -119,7 +155,7 @@ export function MemberTable({ data }: MemberTableProps) {
+
+ + + Name + +
+
+ + +
- {row.storageUsed} + {row.storageUsed} MB {row.lastUpdated} diff --git a/src/components/NavigationBar.tsx b/src/components/NavigationBar.tsx index 96f0870..2238fa3 100644 --- a/src/components/NavigationBar.tsx +++ b/src/components/NavigationBar.tsx @@ -1,25 +1,29 @@ //#region Imports +import { useNavigate } from "react-router-dom"; +import { ToastContainer } from "react-toastify"; +import "react-toastify/dist/ReactToastify.css"; + import { twMerge } from "tailwind-merge"; + +import { initializeApp } from "firebase/app"; +import { getAuth } from "firebase/auth"; + import logoUrl from "@/assets/images/asc_logo.svg"; + +import { displayToast } from "@/utils"; //#endregion //#region Interface interface NavigationBarProps { - /** - * The type of user. - * - "user" for regular users. - * - "admin" for admin users. - */ - userType?: "user" | "admin"; - className?: string; + userType?: "user" | "admin"; + className?: string; } //#endregion -//#region Functions -function signOut() { - // TODO: Implement sign out logic - console.log("Signed out"); -} +//#region firebase +const firebaseConfig = JSON.parse(import.meta.env.VITE_FIREBASE_CONFIG); +const app = initializeApp(firebaseConfig); +const auth = getAuth(app); //#endregion /** @@ -30,41 +34,77 @@ function signOut() { * @returns {JSX.Element} The rendered navigation bar. */ export function NavigationBar({ - userType = "user", - className, + userType = "user", + className, }: NavigationBarProps): JSX.Element { - return ( - - ); + return ( +
+ + +
+ ); } diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index d317464..35ee735 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -1,34 +1,43 @@ -import {useState, useRef} from "react"; +//#region imports import { MagnifyingGlass } from "@phosphor-icons/react"; +import { capitalizeSearchTerm } from "@/utils"; +//#endregion -export interface SearchBarProps { - target: string; - } +//#region interfaces +interface SearchBarProps { + setSearch: React.Dispatch>; + handleClick: () => void; +} +//#endregion - - function SearchBar({ target }: SearchBarProps) { - // const input = useRef(null); - const [query,setQuery] = useState("") - +function SearchBar({ setSearch, handleClick }: SearchBarProps) { + const handleEnterKey = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleClick(); + } + }; - const handleClick = () => { - //handle query - //redirect to results - console.log(query) - } + return ( +
+ + setSearch(capitalizeSearchTerm(e.target.value)) + } + onKeyDown={(e) => handleEnterKey(e)} + /> + +
+ ); +} - return
- setQuery(e.target.value)} - /> - -
; - } - - export default SearchBar; - \ No newline at end of file +export default SearchBar; diff --git a/src/routes/admin/AdminHome.tsx b/src/routes/admin/AdminHome.tsx new file mode 100644 index 0000000..4247f01 --- /dev/null +++ b/src/routes/admin/AdminHome.tsx @@ -0,0 +1,292 @@ +//#region Imports +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { Plus, CaretDown } from "@phosphor-icons/react"; +import { ToastContainer } from "react-toastify"; +import "react-toastify/dist/ReactToastify.css"; + +import { initializeApp } from "firebase/app"; +import { + getFirestore, + collection, + getDocs, + query, + orderBy, + limit, + startAfter, + QueryDocumentSnapshot, + DocumentData, + startAt, + endAt, + OrderByDirection, +} from "firebase/firestore"; +import { getAuth, onAuthStateChanged } from "firebase/auth"; + +import { memberData } from "@/components/MemberTable"; +import Button from "@/components/Button"; +import { MemberTable } from "@/components/MemberTable"; +import { NavigationBar } from "@/components/NavigationBar"; +import SearchBar from "@/components/SearchBar"; + +import { displayToast, convertTimestamp } from "@/utils"; +//#endregion + +const MAX_MEMBER_PER_LOAD = 4; + +//#region firebase +const firebaseConfig = JSON.parse(import.meta.env.VITE_FIREBASE_CONFIG); +const app = initializeApp(firebaseConfig); +const database = getFirestore(app); +const auth = getAuth(app); +const usersRef = collection(database, "users"); +//#endregion + +/** + * Represents the admin home page. + * @returns {JSX.Element} The rendered admin home page. + */ +export default function AdminHome() { + const navigate = useNavigate(); + const [totalStorageUsed, setTotalStorageUsed] = useState(0); + const [members, setMembers] = useState([]); + const [allLoaded, setAllLoaded] = useState(false); + + const [lastDoc, setLastDoc] = useState< + QueryDocumentSnapshot | undefined + >(); + const [expanded, setExpanded] = useState(false); + + // Sorting + const [isSorting, setIsSorting] = useState(false); + const [sortBy, setSortBy] = useState<{ + [key: string]: OrderByDirection | undefined; + }>({ + fullName: "asc", + }); + + // Search + const [searchTerm, setSearchTerm] = useState(""); + + //#region functions + const fetchMembers = async () => { + const [category, order] = Object.entries(sortBy)[0]; + try { + let q; + if (lastDoc) { + console.log("Fetching more members"); + q = await getDocs( + query( + usersRef, + orderBy(category, order), + startAfter(lastDoc), + limit(MAX_MEMBER_PER_LOAD), + ), + ); + } else { + console.log("Fetching initial members"); + q = await getDocs( + query( + usersRef, + orderBy(category, order), + limit(MAX_MEMBER_PER_LOAD), + ), + ); + } + + // Check if there are no more documents to load + if (!q.empty) setLastDoc(q.docs[q.docs.length - 1]); + else setAllLoaded(true); + + // Load member data + const membersList: memberData[] = []; + let storageUsed: number = totalStorageUsed; + q.forEach((doc) => { + storageUsed += doc.data().storageUsed; + membersList.push({ + profilePicture: { + type: doc.data().profilePicture.type, + src: doc.data().profilePicture.src, + }, + name: doc.data().fullName, + storageUsed: String(doc.data().storageUsed), + lastUpdated: String( + convertTimestamp(doc.data().lastUpdated.toDate()), + ), + }); + }); + + if (isSorting) { + setMembers(membersList); + setIsSorting(false); + } else setMembers([...members, ...membersList]); + + setIsSorting(false); + setTotalStorageUsed(storageUsed); + console.log("Last doc", lastDoc?.data().fullName); + } catch (error) { + console.error("Error fetching members: ", error); + displayToast("Failed to load members", "error"); + } + }; + + const searchMember = async () => { + try { + // Check if search term is empty + if (searchTerm === "") { + displayToast("Please enter a search term", "info"); + return; + } + + // Search by last name + const q_lastName = await getDocs( + query( + usersRef, + orderBy("lastName"), + startAt(searchTerm), + endAt(searchTerm + "\uf8ff"), + ), + ); + + // Search by full name + const q_fullName = await getDocs( + query( + usersRef, + orderBy("fullName"), + startAt(searchTerm), + endAt(searchTerm + "\uf8ff"), + ), + ); + + if (q_lastName.empty && q_fullName.empty) { + displayToast("No member found", "info"); + return; + } else { + const membersList: memberData[] = []; + let storageUsed: number = 0; + const combined_q = q_lastName.docs.concat(q_fullName.docs); + combined_q.forEach((doc) => { + storageUsed += doc.data().storageUsed; + membersList.push({ + profilePicture: { + type: doc.data().profilePicture.type, + src: doc.data().profilePicture.src, + }, + name: doc.data().fullName, + storageUsed: String(doc.data().storageUsed), + lastUpdated: String( + convertTimestamp(doc.data().lastUpdated.toDate()), + ), + }); + }); + setMembers(membersList); + setTotalStorageUsed(storageUsed); + } + } catch (error) { + console.error("Error searching for member: ", error); + displayToast("Failed to searchTerm for this member", "error"); + } + }; + //#endregion + + //#region UseEffect + useEffect(() => { + // Check if user is signed in + onAuthStateChanged(auth, (user) => { + if (!user) { + console.log("User is not signed in"); + navigate("/admin/login"); + } else { + fetchMembers(); + } + }); + }, [sortBy, expanded]); + //#endregion + + return ( +
+ + + + +
+

+ All Members +

+ + {/* Search Section */} +
+

+ Total Storage Used: {totalStorageUsed} MB +

+
+ +
+
+ + {/* Table Section */} + { + sortBy["storageUsed"] === "asc" + ? setSortBy({ storageUsed: "desc" }) + : setSortBy({ storageUsed: "asc" }); + + setIsSorting(true); + setLastDoc(undefined); + setAllLoaded(false); + }} + sortbyLastUpdated={() => { + sortBy["lastUpdated"] === "asc" + ? setSortBy({ lastUpdated: "desc" }) + : setSortBy({ lastUpdated: "asc" }); + setIsSorting(true); + setLastDoc(undefined); + setAllLoaded(false); + }} + isLoading={isSorting} + /> + + {/* Load More Button */} +
+ +
+
+
+ ); +} diff --git a/src/utils.tsx b/src/utils.tsx new file mode 100644 index 0000000..c0569d0 --- /dev/null +++ b/src/utils.tsx @@ -0,0 +1,52 @@ +//#region Imports +import { toast } from "react-toastify"; +import Toast from "@/components/Toast"; +//#endregion + +/** + * Capitalizes the first letter of each word in a string. + */ +const capitalizeSearchTerm = (term: string) => { + const words = term.toLowerCase().split(" "); + const capitalizedWords = words.map( + (word) => word.charAt(0).toUpperCase() + word.slice(1), + ); + return capitalizedWords.join(" "); +}; + +/** + * Displays a toast message. + */ +const displayToast = ( + message: string, + severity: "error" | "success" | "warning" | "info" | "neutral", +) => { + if (!toast.isActive(severity)) + toast( + , + { + toastId: "error", + style: { background: "transparent", boxShadow: "none" }, + }, + ); +}; + +/** + * Converts a timestamp to a formatted date. + * @param {string} timestamp - The Firebase timestamp to convert. + * @returns The formatted date (MM/DD/YYYY). + */ +const convertTimestamp = (timestamp: string) => { + const date = new Date(timestamp); + + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + + const formattedDate = `${month}/${day}/${year}`; + return formattedDate; +}; +export { capitalizeSearchTerm, displayToast, convertTimestamp };