From bdb0ffde956e38a2996e84719e54acf0b35f19ec Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Thu, 26 Oct 2023 22:41:24 +0100 Subject: [PATCH] - Added more panels and lists; - Added the ability to view more information on the user; - Added the ability to update the user's expiry date --- src/components/High/Layout.tsx | 2 +- src/components/Low/CountrySelect.tsx | 9 +- src/components/Modal.tsx | 50 +++++ src/components/UserCard.tsx | 230 ++++++++++++++++++++++ src/constants/userPermissions.ts | 7 + src/dashboards/Owner.tsx | 277 +++++++++++++++++++++------ src/pages/(admin)/Lists/UserList.tsx | 59 +++++- 7 files changed, 563 insertions(+), 71 deletions(-) create mode 100644 src/components/Modal.tsx create mode 100644 src/components/UserCard.tsx diff --git a/src/components/High/Layout.tsx b/src/components/High/Layout.tsx index 81a6040e..4383e7a8 100644 --- a/src/components/High/Layout.tsx +++ b/src/components/High/Layout.tsx @@ -37,7 +37,7 @@ export default function Layout({user, children, className, navDisabled = false, />
{children} diff --git a/src/components/Low/CountrySelect.tsx b/src/components/Low/CountrySelect.tsx index 01453d0b..838395da 100644 --- a/src/components/Low/CountrySelect.tsx +++ b/src/components/Low/CountrySelect.tsx @@ -6,7 +6,8 @@ import countryCodes from "country-codes-list"; interface Props { value?: string; - onChange: (value: string) => void; + onChange?: (value: string) => void; + disabled?: boolean; } const mapCountries = (codes: string[]) => { @@ -18,7 +19,7 @@ const mapCountries = (codes: string[]) => { })); }; -export default function CountrySelect({value, onChange}: Props) { +export default function CountrySelect({value, disabled = false, onChange}: Props) { const [query, setQuery] = useState(""); const filteredCountries = @@ -32,11 +33,11 @@ export default function CountrySelect({value, onChange}: Props) { return ( <> - +
setQuery(e.target.value)} displayValue={(code: string) => { const country = countries[code as unknown as keyof TCountries]; diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx new file mode 100644 index 00000000..aab17052 --- /dev/null +++ b/src/components/Modal.tsx @@ -0,0 +1,50 @@ +import {Dialog, Transition} from "@headlessui/react"; +import {Fragment, ReactElement} from "react"; + +interface Props { + isOpen: boolean; + onClose: () => void; + title?: string; + children?: ReactElement; +} + +export default function Modal({isOpen, title, onClose, children}: Props) { + return ( + + + +
+ + +
+
+ + + {title && ( + + {title} + + )} + {children} + + +
+
+
+
+ ); +} diff --git a/src/components/UserCard.tsx b/src/components/UserCard.tsx new file mode 100644 index 00000000..f6643834 --- /dev/null +++ b/src/components/UserCard.tsx @@ -0,0 +1,230 @@ +import useStats from "@/hooks/useStats"; +import {EMPLOYMENT_STATUS, User} from "@/interfaces/user"; +import {groupBySession, averageScore} from "@/utils/stats"; +import {RadioGroup} from "@headlessui/react"; +import axios from "axios"; +import clsx from "clsx"; +import moment from "moment"; +import {useState} from "react"; +import ReactDatePicker from "react-datepicker"; +import {BsFileEarmarkText, BsPencil, BsStar} from "react-icons/bs"; +import {toast} from "react-toastify"; +import Button from "./Low/Button"; +import Checkbox from "./Low/Checkbox"; +import CountrySelect from "./Low/CountrySelect"; +import Input from "./Low/Input"; +import ProfileSummary from "./ProfileSummary"; + +const expirationDateColor = (date: Date) => { + const momentDate = moment(date); + const today = moment(new Date()); + + if (today.add(1, "days").isAfter(momentDate)) return "!bg-mti-red-ultralight border-mti-red-light"; + if (today.add(3, "days").isAfter(momentDate)) return "!bg-mti-rose-ultralight border-mti-rose-light"; + if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light"; +}; + +const UserCard = ({onClose, ...user}: User & {onClose: (reload?: boolean) => void}) => { + const [expiryDate, setExpiryDate] = useState(user.subscriptionExpirationDate); + const {stats} = useStats(user.id); + + const updateUser = () => { + if (!confirm(`Are you sure you want to update ${user.name}'s account?`)) return; + + axios + .post<{user?: User; ok?: boolean}>(`/api/users/update?id=${user.id}`, {...user, subscriptionExpirationDate: expiryDate}) + .then(() => { + toast.success("User updated successfully!"); + onClose(true); + }) + .catch(() => { + toast.error("Something went wrong!", {toastId: "update-error"}); + }); + }; + + return ( + <> + , + value: Object.keys(groupBySession(stats)).length, + label: "Exams", + }, + { + icon: , + value: stats.length, + label: "Exercises", + }, + { + icon: , + value: `${stats.length > 0 ? averageScore(stats) : 0}%`, + label: "Average Score", + }, + ]} + /> + +
+
+ null} + placeholder="Enter your name" + defaultValue={user.name} + disabled + /> + null} + placeholder="Enter email address" + defaultValue={user.email} + disabled + /> +
+ +
+
+ + +
+ null} + placeholder="Enter phone number" + defaultValue={user.demographicInformation?.phone} + disabled + /> +
+ +
+
+ + + {EMPLOYMENT_STATUS.map(({status, label}) => ( + + {({checked}) => ( + + {label} + + )} + + ))} + +
+
+
+ + + + {({checked}) => ( + + Male + + )} + + + {({checked}) => ( + + Female + + )} + + + {({checked}) => ( + + Other + + )} + + +
+
+
+ + setExpiryDate(checked ? user.subscriptionExpirationDate || new Date() : undefined)}> + Enabled + +
+ {!expiryDate && ( +
+ {!expiryDate && "Unlimited"} + {expiryDate && moment(expiryDate).format("DD/MM/YYYY")} +
+ )} + {expiryDate && ( + moment(date).isAfter(new Date())} + dateFormat="dd/MM/yyyy" + selected={moment(expiryDate).toDate()} + onChange={(date) => setExpiryDate(date)} + /> + )} +
+
+
+
+ +
+ + +
+ + ); +}; + +export default UserCard; diff --git a/src/constants/userPermissions.ts b/src/constants/userPermissions.ts index ac3ae152..46d548ef 100644 --- a/src/constants/userPermissions.ts +++ b/src/constants/userPermissions.ts @@ -22,6 +22,13 @@ export const PERMISSIONS = { owner: ["developer", "owner"], developer: ["developer"], }, + updateExpiryDate: { + student: ["developer", "owner"], + teacher: ["developer", "owner"], + admin: ["owner", "developer"], + owner: ["developer", "owner"], + developer: ["developer"], + }, examManagement: { delete: ["developer", "owner"], }, diff --git a/src/dashboards/Owner.tsx b/src/dashboards/Owner.tsx index bd9053e6..75949942 100644 --- a/src/dashboards/Owner.tsx +++ b/src/dashboards/Owner.tsx @@ -1,64 +1,149 @@ /* eslint-disable @next/next/no-img-element */ -import ProgressBar from "@/components/Low/ProgressBar"; -import ProfileSummary from "@/components/ProfileSummary"; +import Modal from "@/components/Modal"; import useStats from "@/hooks/useStats"; import useUsers from "@/hooks/useUsers"; import {User} from "@/interfaces/user"; +import UserList from "@/pages/(admin)/Lists/UserList"; import {dateSorter} from "@/utils"; -import {MODULE_ARRAY} from "@/utils/moduleUtils"; -import {averageScore, groupBySession} from "@/utils/stats"; -import {capitalize} from "lodash"; import moment from "moment"; -import { - BsBook, - BsFileEarmarkText, - BsGlobeCentralSouthAsia, - BsHeadphones, - BsMegaphone, - BsPen, - BsPencil, - BsPeopleFill, - BsPerson, - BsPersonFill, - BsPersonFillGear, - BsPersonGear, - BsPersonLinesFill, - BsStar, -} from "react-icons/bs"; +import {useState} from "react"; +import {BsArrowLeft, BsGlobeCentralSouthAsia, BsPerson, BsPersonFill, BsPersonFillGear, BsPersonGear, BsPersonLinesFill} from "react-icons/bs"; +import UserCard from "@/components/UserCard"; interface Props { user: User; } export default function OwnerDashboard({user}: Props) { - const {stats} = useStats(user.id); - const {users} = useUsers(); + const [page, setPage] = useState(""); + const [selectedUser, setSelectedUser] = useState(); - return ( + const {stats} = useStats(user.id); + const {users, reload} = useUsers(); + + const UserDisplay = (displayUser: User) => ( +
setSelectedUser(displayUser)} + className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300"> + {displayUser.name} +
+ {displayUser.name} + {displayUser.email} +
+
+ ); + + const StudentsList = () => ( <> -
-
+
+
setPage("")} + className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> + + Back +
+

Students

+
+ + x.type === "student"} /> + + ); + + const TeachersList = () => ( + <> +
+
setPage("")} + className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> + + Back +
+

Teachers

+
+ + x.type === "teacher"} /> + + ); + + const CorporateList = () => ( + <> +
+
setPage("")} + className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> + + Back +
+

Corporate

+
+ + x.type === "admin"} /> + + ); + + const InactiveStudentsList = () => ( + <> +
+
setPage("")} + className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> + + Back +
+

Inactive Students

+
+ + x.type === "student" && (x.isDisabled || moment().isAfter(x.subscriptionExpirationDate))} /> + + ); + + const InactiveCorporateList = () => ( + <> +
+
setPage("")} + className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> + + Back +
+

Inactive Corporate

+
+ + x.type === "admin" && (x.isDisabled || moment().isAfter(x.subscriptionExpirationDate))} /> + + ); + + const DefaultDashboard = () => ( + <> +
+
setPage("students")} + className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-52 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300"> Students {users.filter((x) => x.type === "student").length}
-
+
setPage("teachers")} + className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-52 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300"> Teachers {users.filter((x) => x.type === "teacher").length}
-
+
setPage("corporate")} + className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-52 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300"> Corporate {users.filter((x) => x.type === "admin").length}
-
+
Countries @@ -67,63 +152,135 @@ export default function OwnerDashboard({user}: Props) {
-
- +
setPage("inactiveStudents")} + className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-52 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300"> + Inactive Students - + {users.filter((x) => x.type === "student" && (x.isDisabled || moment().isAfter(x.subscriptionExpirationDate))).length}
-
- +
setPage("inactiveCorporate")} + className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-52 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300"> + Inactive Corporate - + {users.filter((x) => x.type === "admin" && (x.isDisabled || moment().isAfter(x.subscriptionExpirationDate))).length}
-
-
-
- Latest students -
+
+ +
+
+ Latest students +
{users .filter((x) => x.type === "student") .sort((a, b) => dateSorter(a, b, "asc", "registrationDate")) - .slice(0, (users.filter((x) => x.type === "student").length - 1) / 2) .map((x) => ( -
- {x.name} -
- {x.name} - {x.email} -
-
+ ))}
-
- Latest corporate -
+
+ Latest corporate +
{users .filter((x) => x.type === "admin") .sort((a, b) => dateSorter(a, b, "asc", "registrationDate")) - .slice(0, (users.filter((x) => x.type === "admin").length - 1) / 2) .map((x) => ( -
- {x.name} -
- {x.name} - {x.email} -
-
+ ))}
-
+
+ Disabled Corporate +
+ {users + .filter((x) => x.type === "admin" && x.isDisabled) + .map((x) => ( + + ))} +
+
+
+ Students expiring in 1 month +
+ {users + .filter( + (x) => + x.type === "student" && + x.subscriptionExpirationDate && + moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")), + ) + .map((x) => ( + + ))} +
+
+
+ Teachers expiring in 1 month +
+ {users + .filter( + (x) => + x.type === "teacher" && + x.subscriptionExpirationDate && + moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")), + ) + .map((x) => ( + + ))} +
+
+
+ Corporate expiring in 1 month +
+ {users + .filter( + (x) => + x.type === "admin" && + x.subscriptionExpirationDate && + moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")), + ) + .map((x) => ( + + ))} +
+
+
+ + ); + + return ( + <> + setSelectedUser(undefined)}> + <> + {selectedUser && ( +
+ { + setSelectedUser(undefined); + if (shouldReload) reload(); + }} + {...selectedUser} + /> +
+ )} + +
+ {page === "students" && } + {page === "teachers" && } + {page === "corporate" && } + {page === "inactiveStudents" && } + {page === "inactiveCorporate" && } + {page === "" && } ); } diff --git a/src/pages/(admin)/Lists/UserList.tsx b/src/pages/(admin)/Lists/UserList.tsx index 0e9f118e..584d3b90 100644 --- a/src/pages/(admin)/Lists/UserList.tsx +++ b/src/pages/(admin)/Lists/UserList.tsx @@ -10,17 +10,20 @@ import clsx from "clsx"; import {capitalize, reverse} from "lodash"; import moment from "moment"; import {Fragment, useEffect, useState} from "react"; -import {BsArrowDown, BsArrowDownUp, BsArrowUp, BsCheck, BsCheckCircle, BsFillExclamationOctagonFill, BsPerson, BsTrash} from "react-icons/bs"; +import {BsArrowDown, BsArrowDownUp, BsArrowUp, BsCheck, BsCheckCircle, BsEye, BsFillExclamationOctagonFill, BsPerson, BsTrash} from "react-icons/bs"; import {toast} from "react-toastify"; import {countries, TCountries} from "countries-list"; import countryCodes from "country-codes-list"; +import Modal from "@/components/Modal"; +import UserCard from "@/components/UserCard"; const columnHelper = createColumnHelper(); -export default function UserList({user}: {user: User}) { +export default function UserList({user, filter}: {user: User; filter?: (user: User) => boolean}) { const [showDemographicInformation, setShowDemographicInformation] = useState(false); const [sorter, setSorter] = useState(); const [displayUsers, setDisplayUsers] = useState([]); + const [selectedUser, setSelectedUser] = useState(); const {users, reload} = useUsers(); const {groups} = useGroups(user ? user.id : undefined); @@ -30,7 +33,9 @@ export default function UserList({user}: {user: User}) { const filterUsers = user.type === "admin" || user.type === "student" ? users.filter((u) => groups.flatMap((g) => g.participants).includes(u.id)) : users; - setDisplayUsers([...filterUsers.sort(sortFunction)]); + const filteredUsers = filter ? filterUsers.filter(filter) : filterUsers; + + setDisplayUsers([...filteredUsers.sort(sortFunction)]); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [user, users, sorter, groups]); @@ -186,7 +191,16 @@ export default function UserList({user}: {user: User}) { ) as any, - cell: (info) => info.getValue(), + cell: ({row, getValue}) => ( +
(PERMISSIONS.updateExpiryDate[row.original.type].includes(user.type) ? setSelectedUser(row.original) : null)}> + {getValue()} +
+ ), }), columnHelper.accessor("demographicInformation.country", { header: ( @@ -251,7 +265,16 @@ export default function UserList({user}: {user: User}) { ) as any, - cell: (info) => info.getValue(), + cell: ({row, getValue}) => ( +
(PERMISSIONS.updateExpiryDate[row.original.type].includes(user.type) ? setSelectedUser(row.original) : null)}> + {getValue()} +
+ ), }), columnHelper.accessor("email", { header: ( @@ -260,7 +283,16 @@ export default function UserList({user}: {user: User}) { ) as any, - cell: (info) => info.getValue(), + cell: ({row, getValue}) => ( +
(PERMISSIONS.updateExpiryDate[row.original.type].includes(user.type) ? setSelectedUser(row.original) : null)}> + {getValue()} +
+ ), }), columnHelper.accessor("type", { header: ( @@ -397,6 +429,21 @@ export default function UserList({user}: {user: User}) { return (
+ setSelectedUser(undefined)}> + <> + {selectedUser && ( +
+ { + setSelectedUser(undefined); + if (shouldReload) reload(); + }} + {...selectedUser} + /> +
+ )} + +
{table.getHeaderGroups().map((headerGroup) => (