diff --git a/src/components/High/CardList.tsx b/src/components/High/CardList.tsx new file mode 100644 index 00000000..31075dcd --- /dev/null +++ b/src/components/High/CardList.tsx @@ -0,0 +1,34 @@ +import {useListSearch} from "@/hooks/useListSearch"; +import usePagination from "@/hooks/usePagination"; +import { clsx } from "clsx"; +import {ReactNode} from "react"; +import Checkbox from "../Low/Checkbox"; +import Separator from "../Low/Separator"; + +interface Props { + list: T[]; + searchFields: string[][]; + pageSize?: number; + firstCard?: () => ReactNode; + renderCard: (item: T) => ReactNode; + className?: string +} + +export default function CardList({list, searchFields, renderCard, firstCard, className, pageSize = 20}: Props) { + const {rows, renderSearch} = useListSearch(searchFields, list); + + const {items, page, render, renderMinimal} = usePagination(rows, pageSize); + + return ( +
+
+ {searchFields.length > 0 && renderSearch()} + {searchFields.length > 0 ? renderMinimal() : render()} +
+
+ {page === 0 && !!firstCard && firstCard()} + {items.map(renderCard)} +
+
+ ); +} diff --git a/src/components/High/Layout.tsx b/src/components/High/Layout.tsx index f148146f..eb72323f 100644 --- a/src/components/High/Layout.tsx +++ b/src/components/High/Layout.tsx @@ -1,7 +1,7 @@ import {User} from "@/interfaces/user"; import clsx from "clsx"; import {useRouter} from "next/router"; -import BottomBar from "../BottomBar"; +import { ToastContainer } from "react-toastify"; import Navbar from "../Navbar"; import Sidebar from "../Sidebar"; @@ -20,6 +20,7 @@ export default function Layout({user, children, className, bgColor="bg-white", n return (
+ { + data: T[] + columns: ColumnDef[] + searchFields: string[][] + size?: number + onDownload?: (rows: T[]) => void +} + +export default function Table({ data, columns, searchFields, size = 16, onDownload }: Props) { + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 16, + }) + + const { rows, renderSearch } = useListSearch(searchFields, data); + + const table = useReactTable({ + data: rows, + columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onPaginationChange: setPagination, + state: { + pagination + } + }); + + return ( +
+
+ {renderSearch()} + {onDownload && ( + + ) + } +
+ +
+
+ +
+
+ +
Page
+ + {table.getState().pagination.pageIndex + 1} of{' '} + {table.getPageCount().toLocaleString()} + +
| Total: {table.getRowCount().toLocaleString()}
+
+ +
+
+ + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} + {{ + asc: , + desc: , + }[header.column.getIsSorted() as string] ?? null} +
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ ) +} diff --git a/src/components/List.tsx b/src/components/List.tsx index f62e7172..37e6a099 100644 --- a/src/components/List.tsx +++ b/src/components/List.tsx @@ -1,13 +1,26 @@ +import {useListSearch} from "@/hooks/useListSearch"; +import usePagination from "@/hooks/usePagination"; import {Column, flexRender, getCoreRowModel, getSortedRowModel, useReactTable} from "@tanstack/react-table"; +import clsx from "clsx"; import {useMemo, useState} from "react"; import Button from "./Low/Button"; const SIZE = 25; -export default function List({data, columns}: {data: T[]; columns: any[]}) { - const [page, setPage] = useState(0); +export default function List({ + data, + columns, + searchFields = [], + pageSize = SIZE, +}: { + data: T[]; + columns: any[]; + searchFields?: string[][]; + pageSize?: number; +}) { + const {rows, renderSearch} = useListSearch(searchFields, data); - const items = useMemo(() => data.slice(page * SIZE, (page + 1) * SIZE > data.length ? data.length : (page + 1) * SIZE), [data, page]); + const {items, page, renderMinimal} = usePagination(rows, pageSize); const table = useReactTable({ data: items, @@ -17,19 +30,10 @@ export default function List({data, columns}: {data: T[]; columns: any[]}) { }); return ( -
-
- -
- - {page * SIZE + 1} - {(page + 1) * SIZE > data.length ? data.length : (page + 1) * SIZE} / {data.length} - - -
+
+
+ {searchFields.length > 0 && renderSearch()} + {renderMinimal()}
diff --git a/src/components/Low/Checkbox.tsx b/src/components/Low/Checkbox.tsx index fa90951f..d527bc91 100644 --- a/src/components/Low/Checkbox.tsx +++ b/src/components/Low/Checkbox.tsx @@ -5,7 +5,7 @@ import {BsCheck} from "react-icons/bs"; interface Props { isChecked: boolean; onChange: (isChecked: boolean) => void; - children: ReactNode; + children?: ReactNode; disabled?: boolean; } diff --git a/src/components/Low/Select.tsx b/src/components/Low/Select.tsx index 186f6a2c..2b3c16b0 100644 --- a/src/components/Low/Select.tsx +++ b/src/components/Low/Select.tsx @@ -18,9 +18,10 @@ interface Props { isClearable?: boolean; styles?: StylesConfig>; className?: string; + label?: string; } -export default function Select({value, defaultValue, options, placeholder, disabled, onChange, styles, isClearable, className}: Props) { +export default function Select({value, defaultValue, options, placeholder, disabled, onChange, styles, isClearable, label, className}: Props) { const [target, setTarget] = useState(); useEffect(() => { @@ -28,43 +29,46 @@ export default function Select({value, defaultValue, options, placeholder, disab }, []); return ( - ({...base, zIndex: 9999}), - control: (styles) => ({ - ...styles, - paddingLeft: "4px", - border: "none", - outline: "none", - ":focus": { - outline: "none", - }, - }), - option: (styles, state) => ({ - ...styles, - backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", - color: state.isFocused ? "black" : styles.color, - }), +
+ {label && } + + options={options} + value={value} + onChange={onChange as any} + placeholder={placeholder} + menuPortalTarget={target} + defaultValue={defaultValue} + styles={ + styles || { + menuPortal: (base) => ({...base, zIndex: 9999}), + control: (styles) => ({ + ...styles, + paddingLeft: "4px", + border: "none", + outline: "none", + ":focus": { + outline: "none", + }, + }), + option: (styles, state) => ({ + ...styles, + backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", + color: state.isFocused ? "black" : styles.color, + }), + } + } + isDisabled={disabled} + isClearable={isClearable} + /> +
); } diff --git a/src/components/Low/Separator.tsx b/src/components/Low/Separator.tsx new file mode 100644 index 00000000..5772b705 --- /dev/null +++ b/src/components/Low/Separator.tsx @@ -0,0 +1,3 @@ +const Separator = () =>
; + +export default Separator; diff --git a/src/components/Low/Tooltip.tsx b/src/components/Low/Tooltip.tsx new file mode 100644 index 00000000..bbe26a1f --- /dev/null +++ b/src/components/Low/Tooltip.tsx @@ -0,0 +1,17 @@ +import clsx from "clsx"; +import {ReactNode} from "react"; + +interface Props { + tooltip: string; + disabled?: boolean; + className?: string; + children: ReactNode; +} + +export default function Tooltip({tooltip, disabled = false, className, children}: Props) { + return ( +
+ {children} +
+ ); +} diff --git a/src/components/Medium/InviteWithUserCard.tsx b/src/components/Medium/InviteWithUserCard.tsx new file mode 100644 index 00000000..158723f7 --- /dev/null +++ b/src/components/Medium/InviteWithUserCard.tsx @@ -0,0 +1,67 @@ +import {Invite, InviteWithUsers} from "@/interfaces/invite"; +import {User} from "@/interfaces/user"; +import {getUserName} from "@/utils/users"; +import axios from "axios"; +import {useMemo, useState} from "react"; +import {BsArrowRepeat} from "react-icons/bs"; +import {toast} from "react-toastify"; + +interface Props { + invite: InviteWithUsers; + reload: () => void; +} + +export default function InviteWithUserCard({invite, reload}: Props) { + const [isLoading, setIsLoading] = useState(false); + + const name = useMemo(() => (!invite.from ? null : getUserName(invite.from)), [invite.from]); + + const decide = (decision: "accept" | "decline") => { + if (!confirm(`Are you sure you want to ${decision} this invite?`)) return; + + setIsLoading(true); + axios + .get(`/api/invites/${decision}/${invite.id}`) + .then(() => { + toast.success(`Successfully ${decision === "accept" ? "accepted" : "declined"} the invite!`, {toastId: "success"}); + reload(); + }) + .catch((e) => { + toast.success(`Something went wrong, please try again later!`, { + toastId: "error", + }); + reload(); + }) + .finally(() => setIsLoading(false)); + }; + + return ( +
+ Invited by {name} +
+ + +
+
+ ); +} diff --git a/src/components/Medium/RecordFilter.tsx b/src/components/Medium/RecordFilter.tsx index c70882c7..a8e5ce89 100644 --- a/src/components/Medium/RecordFilter.tsx +++ b/src/components/Medium/RecordFilter.tsx @@ -1,11 +1,13 @@ import { User } from "@/interfaces/user"; import { checkAccess } from "@/utils/permissions"; import Select from "../Low/Select"; -import { ReactNode, useEffect, useState } from "react"; +import { ReactNode, useEffect, useMemo, useState } from "react"; import clsx from "clsx"; import useUsers from "@/hooks/useUsers"; import useGroups from "@/hooks/useGroups"; import useRecordStore from "@/stores/recordStore"; +import { EntityWithRoles } from "@/interfaces/entity"; +import { mapBy } from "@/utils"; type TimeFilter = "months" | "weeks" | "days"; @@ -13,6 +15,8 @@ type Filter = TimeFilter | "assignments" | undefined; interface Props { user: User; + entities: EntityWithRoles[] + users: User[] filterState: { filter: Filter, setFilter: React.Dispatch> @@ -28,83 +32,41 @@ const defaultSelectableCorporate = { const RecordFilter: React.FC = ({ user, + entities, + users, filterState, assignments = true, children }) => { const { filter, setFilter } = filterState; - const [statsUserId, setStatsUserId] = useRecordStore((state) => [ + const [entity, setEntity] = useState() + + const [, setStatsUserId] = useRecordStore((state) => [ state.selectedUser, state.setSelectedUser ]); - const { users } = useUsers(); - const { groups: allGroups } = useGroups({}); - const { groups } = useGroups({ admin: user?.id, userType: user?.type }); + const entityUsers = useMemo(() => !entity ? users : users.filter(u => mapBy(u.entities, 'id').includes(entity)), [users, entity]) + + useEffect(() => setStatsUserId(user.id), [setStatsUserId, user.id]) const toggleFilter = (value: "months" | "weeks" | "days" | "assignments") => { setFilter((prev) => (prev === value ? undefined : value)); }; - const selectableCorporates = [ - defaultSelectableCorporate, - ...users - .filter((x) => groups.flatMap((g) => [g.admin, ...g.participants]).includes(x.id)) - .filter((x) => x.type === "corporate") - .map((x) => ({ - value: x.id, - label: `${x.name} - ${x.email}`, - })), - ]; - - const [selectedCorporate, setSelectedCorporate] = useState(defaultSelectableCorporate.value); - - const getUsersList = (): User[] => { - if (selectedCorporate) { - const selectedCorporateGroups = allGroups.filter((x) => x.admin === selectedCorporate); - const selectedCorporateGroupsParticipants = selectedCorporateGroups.flatMap((x) => x.participants); - - const userListWithUsers = selectedCorporateGroupsParticipants.map((x) => users.find((y) => y.id === x)) as User[]; - return userListWithUsers.filter((x) => x); - } - - return user.type !== "mastercorporate" ? users : users.filter((x) => groups.flatMap((g) => [g.admin, ...g.participants]).includes(x.id)); - }; - - const corporateFilteredUserList = getUsersList(); - - const getSelectedUser = () => { - if (selectedCorporate) { - const userInCorporate = corporateFilteredUserList.find((x) => x.id === statsUserId); - return userInCorporate || corporateFilteredUserList[0]; - } - - return users.find((x) => x.id === statsUserId) || user; - }; - - const selectedUser = getSelectedUser(); - const selectedUserSelectValue = selectedUser - ? { - value: selectedUser.id, - label: `${selectedUser.name} - ${selectedUser.email}`, - } - : { - value: "", - label: "", - }; - return (
-
+
{checkAccess(user, ["developer", "admin", "mastercorporate"]) && !children && ( <> - +
+ + }} /> +
+
groups.flatMap((y) => y.participants).includes(x.id)) .map((x) => ({ value: x.id, label: `${x.name} - ${x.email}`, }))} - value={selectedUserSelectValue} + defaultValue={{value: user.id, label: `${user.name} - ${user.email}`}} onChange={(value) => setStatsUserId(value?.value!)} styles={{ menuPortal: (base) => ({ ...base, zIndex: 9999 }), @@ -155,7 +119,7 @@ const RecordFilter: React.FC = ({ }), }} /> - +
)} {children}
@@ -203,4 +167,4 @@ const RecordFilter: React.FC = ({ ); } -export default RecordFilter; \ No newline at end of file +export default RecordFilter; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 19d5d40f..0fd83f58 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,242 +1,236 @@ import clsx from "clsx"; -import {IconType} from "react-icons"; -import {MdSpaceDashboard} from "react-icons/md"; +import { IconType } from "react-icons"; +import { MdSpaceDashboard } from "react-icons/md"; import { - BsFileEarmarkText, - BsClockHistory, - BsPencil, - BsGraphUp, - BsChevronBarRight, - BsChevronBarLeft, - BsShieldFill, - BsCloudFill, - BsCurrencyDollar, - BsClipboardData, - BsFileLock, - BsPeople, + BsFileEarmarkText, + BsClockHistory, + BsPencil, + BsGraphUp, + BsChevronBarRight, + BsChevronBarLeft, + BsShieldFill, + BsCloudFill, + BsCurrencyDollar, + BsClipboardData, + BsFileLock, + BsPeople, } from "react-icons/bs"; -import {CiDumbbell} from "react-icons/ci"; -import {RiLogoutBoxFill} from "react-icons/ri"; -import {SlPencil} from "react-icons/sl"; -import {FaAward} from "react-icons/fa"; +import { CiDumbbell } from "react-icons/ci"; +import { RiLogoutBoxFill } from "react-icons/ri"; +import { SlPencil } from "react-icons/sl"; +import { FaAward } from "react-icons/fa"; import Link from "next/link"; -import {useRouter} from "next/router"; +import { useRouter } from "next/router"; import axios from "axios"; import FocusLayer from "@/components/FocusLayer"; -import {preventNavigation} from "@/utils/navigation.disabled"; -import {useEffect, useState} from "react"; +import { preventNavigation } from "@/utils/navigation.disabled"; +import { useEffect, useState } from "react"; import usePreferencesStore from "@/stores/preferencesStore"; -import {User} from "@/interfaces/user"; +import { User } from "@/interfaces/user"; import useTicketsListener from "@/hooks/useTicketsListener"; -import {checkAccess, getTypesOfUser} from "@/utils/permissions"; +import { checkAccess, getTypesOfUser } from "@/utils/permissions"; import usePermissions from "@/hooks/usePermissions"; interface Props { - path: string; - navDisabled?: boolean; - focusMode?: boolean; - onFocusLayerMouseEnter?: () => void; - className?: string; - user: User; + path: string; + navDisabled?: boolean; + focusMode?: boolean; + onFocusLayerMouseEnter?: () => void; + className?: string; + user: User; } interface NavProps { - Icon: IconType; - label: string; - path: string; - keyPath: string; - disabled?: boolean; - isMinimized?: boolean; - badge?: number; + Icon: IconType; + label: string; + path: string; + keyPath: string; + disabled?: boolean; + isMinimized?: boolean; + badge?: number; } -const Nav = ({Icon, label, path, keyPath, disabled = false, isMinimized = false, badge}: NavProps) => { - return ( - - - {!isMinimized && {label}} - {!!badge && badge > 0 && ( -
- {badge} -
- )} - - ); +const Nav = ({ Icon, label, path, keyPath, disabled = false, isMinimized = false, badge }: NavProps) => { + return ( + + + {!isMinimized && {label}} + {!!badge && badge > 0 && ( +
+ {badge} +
+ )} + + ); }; -export default function Sidebar({path, navDisabled = false, focusMode = false, user, onFocusLayerMouseEnter, className}: Props) { - const router = useRouter(); +export default function Sidebar({ path, navDisabled = false, focusMode = false, user, onFocusLayerMouseEnter, className }: Props) { + const router = useRouter(); - const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]); + const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]); - const {totalAssignedTickets} = useTicketsListener(user.id); - const {permissions} = usePermissions(user.id); + const { totalAssignedTickets } = useTicketsListener(user.id); + const { permissions } = usePermissions(user.id); - const logout = async () => { - axios.post("/api/logout").finally(() => { - setTimeout(() => router.reload(), 500); - }); - }; + const logout = async () => { + axios.post("/api/logout").finally(() => { + setTimeout(() => router.reload(), 500); + }); + }; - const disableNavigation = preventNavigation(navDisabled, focusMode); + const disableNavigation = preventNavigation(navDisabled, focusMode); - return ( -
-
-
-
-
+ return ( +
+
+
+
+
-
-
- {isMinimized ? : } - {!isMinimized && Minimize} -
-
{} : logout} - className={clsx( - "hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out", - isMinimized ? "w-fit" : "w-full min-w-[250px] px-8", - )}> - - {!isMinimized && Log Out} -
-
- {focusMode && } -
- ); +
+
+ {isMinimized ? : } + {!isMinimized && Minimize} +
+
{ } : logout} + className={clsx( + "hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out", + isMinimized ? "w-fit" : "w-full min-w-[250px] px-8", + )}> + + {!isMinimized && Log Out} +
+
+ {focusMode && } +
+ ); } diff --git a/src/components/UserDisplayList.tsx b/src/components/UserDisplayList.tsx new file mode 100644 index 00000000..8f1e803f --- /dev/null +++ b/src/components/UserDisplayList.tsx @@ -0,0 +1,30 @@ +/** eslint-disable @next/next/no-img-element */ +import { User } from "@/interfaces/user" + +interface Props { + users: User[] + title: string; +} + +const UserDisplay = (displayUser: User) => ( +
+ {displayUser.name} +
+ {displayUser.name} + {displayUser.email} +
+
+); + +export default function UserDisplayList({ title, users }: Props) { + return (
+ {title} +
+ {users + .slice(0, 10) + .map((x) => ( + + ))} +
+
) +} diff --git a/src/dashboards/Admin.tsx b/src/dashboards/Admin.tsx index edb7d165..656a4377 100644 --- a/src/dashboards/Admin.tsx +++ b/src/dashboards/Admin.tsx @@ -581,7 +581,7 @@ export default function AdminDashboard({user}: Props) { .includes(x.id), }); - router.push("/list/users"); + router.push("/users"); } : undefined } @@ -601,7 +601,7 @@ export default function AdminDashboard({user}: Props) { .includes(x.id), }); - router.push("/list/users"); + router.push("/users"); } : undefined } @@ -621,7 +621,7 @@ export default function AdminDashboard({user}: Props) { .includes(x.id), }); - router.push("/list/users"); + router.push("/users"); } : undefined } diff --git a/src/dashboards/AssignmentCard.tsx b/src/dashboards/AssignmentCard.tsx index d3243656..3bd07d67 100644 --- a/src/dashboards/AssignmentCard.tsx +++ b/src/dashboards/AssignmentCard.tsx @@ -49,7 +49,6 @@ export default function AssignmentCard({ const renderUnarchiveIcon = useAssignmentUnarchive(id, reload); const renderReleaseIcon = useAssignmentRelease(id, reload); - const calculateAverageModuleScore = (module: Module) => { const resultModuleBandScores = results.map((r) => { const moduleStats = r.stats.filter((s) => s.module === module); @@ -65,26 +64,26 @@ export default function AssignmentCard({ const uniqModules = uniqBy(exams, (x) => x.module); const shouldRenderPDF = () => { - if(released && allowDownload) { + if (released && allowDownload) { // in order to be downloadable, the assignment has to be released // the component should have the allowDownload prop // and the assignment should not have the level module - return uniqModules.every(({ module }) => module !== 'level'); + return uniqModules.every(({module}) => module !== "level"); } return false; - } + }; const shouldRenderExcel = () => { - if(released && allowExcelDownload) { + if (released && allowExcelDownload) { // in order to be downloadable, the assignment has to be released // the component should have the allowExcelDownload prop // and the assignment should have the level module - return uniqModules.some(({ module }) => module === 'level'); + return uniqModules.some(({module}) => module === "level"); } return false; - } + }; return (
void; + isOpen: boolean; + users: User[]; + assignment?: Assignment; + onClose: () => void; } -export default function AssignmentView({ isOpen, assignment, onClose }: Props) { - const { users } = useUsers(); - const router = useRouter(); +export default function AssignmentView({isOpen, users, assignment, onClose}: Props) { + const router = useRouter(); - const setExams = useExamStore((state) => state.setExams); - const setShowSolutions = useExamStore((state) => state.setShowSolutions); - const setUserSolutions = useExamStore((state) => state.setUserSolutions); - const setSelectedModules = useExamStore((state) => state.setSelectedModules); + const setExams = useExamStore((state) => state.setExams); + const setShowSolutions = useExamStore((state) => state.setShowSolutions); + const setUserSolutions = useExamStore((state) => state.setUserSolutions); + const setSelectedModules = useExamStore((state) => state.setSelectedModules); - const deleteAssignment = async () => { - if (!confirm("Are you sure you want to delete this assignment?")) return; + const deleteAssignment = async () => { + if (!confirm("Are you sure you want to delete this assignment?")) return; - axios - .delete(`/api/assignments/${assignment?.id}`) - .then(() => - toast.success( - `Successfully deleted the assignment "${assignment?.name}".` - ) - ) - .catch(() => toast.error("Something went wrong, please try again later.")) - .finally(onClose); - }; + axios + .delete(`/api/assignments/${assignment?.id}`) + .then(() => toast.success(`Successfully deleted the assignment "${assignment?.name}".`)) + .catch(() => toast.error("Something went wrong, please try again later.")) + .finally(onClose); + }; - const startAssignment = () => { - if (assignment) { - axios - .post(`/api/assignments/${assignment.id}/start`) - .then(() => { - toast.success( - `The assignment "${assignment.name}" has been started successfully!` - ); - }) - .catch((e) => { - console.log(e); - toast.error("Something went wrong, please try again later!"); - }); - } - }; + const startAssignment = () => { + if (assignment) { + axios + .post(`/api/assignments/${assignment.id}/start`) + .then(() => { + toast.success(`The assignment "${assignment.name}" has been started successfully!`); + }) + .catch((e) => { + console.log(e); + toast.error("Something went wrong, please try again later!"); + }); + } + }; - const formatTimestamp = (timestamp: string) => { - const date = moment(parseInt(timestamp)); - const formatter = "YYYY/MM/DD - HH:mm"; + const formatTimestamp = (timestamp: string) => { + const date = moment(parseInt(timestamp)); + const formatter = "YYYY/MM/DD - HH:mm"; - return date.format(formatter); - }; + return date.format(formatter); + }; - const calculateAverageModuleScore = (module: Module) => { - if (!assignment) return -1; + const calculateAverageModuleScore = (module: Module) => { + if (!assignment) return -1; - const resultModuleBandScores = assignment.results.map((r) => { - const moduleStats = r.stats.filter((s) => s.module === module); + const resultModuleBandScores = assignment.results.map((r) => { + const moduleStats = r.stats.filter((s) => s.module === module); - const correct = moduleStats.reduce( - (acc, curr) => acc + curr.score.correct, - 0 - ); - const total = moduleStats.reduce( - (acc, curr) => acc + curr.score.total, - 0 - ); - return calculateBandScore(correct, total, module, r.type); - }); + const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0); + const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0); + return calculateBandScore(correct, total, module, r.type); + }); - return resultModuleBandScores.length === 0 - ? -1 - : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / - assignment.results.length; - }; + return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / assignment.results.length; + }; - const aggregateScoresByModule = ( - stats: Stat[] - ): { module: Module; total: number; missing: number; correct: number }[] => { - const scores: { - [key in Module]: { total: number; missing: number; correct: number }; - } = { - reading: { - total: 0, - correct: 0, - missing: 0, - }, - listening: { - total: 0, - correct: 0, - missing: 0, - }, - writing: { - total: 0, - correct: 0, - missing: 0, - }, - speaking: { - total: 0, - correct: 0, - missing: 0, - }, - level: { - total: 0, - correct: 0, - missing: 0, - }, - }; + const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => { + const scores: { + [key in Module]: {total: number; missing: number; correct: number}; + } = { + reading: { + total: 0, + correct: 0, + missing: 0, + }, + listening: { + total: 0, + correct: 0, + missing: 0, + }, + writing: { + total: 0, + correct: 0, + missing: 0, + }, + speaking: { + total: 0, + correct: 0, + missing: 0, + }, + level: { + total: 0, + correct: 0, + missing: 0, + }, + }; - stats.forEach((x) => { - scores[x.module!] = { - total: scores[x.module!].total + x.score.total, - correct: scores[x.module!].correct + x.score.correct, - missing: scores[x.module!].missing + x.score.missing, - }; - }); + stats.forEach((x) => { + scores[x.module!] = { + total: scores[x.module!].total + x.score.total, + correct: scores[x.module!].correct + x.score.correct, + missing: scores[x.module!].missing + x.score.missing, + }; + }); - return Object.keys(scores) - .filter((x) => scores[x as Module].total > 0) - .map((x) => ({ module: x as Module, ...scores[x as Module] })); - }; + return Object.keys(scores) + .filter((x) => scores[x as Module].total > 0) + .map((x) => ({module: x as Module, ...scores[x as Module]})); + }; - const customContent = ( - stats: Stat[], - user: string, - focus: "academic" | "general" - ) => { - const correct = stats.reduce( - (accumulator, current) => accumulator + current.score.correct, - 0 - ); - const total = stats.reduce( - (accumulator, current) => accumulator + current.score.total, - 0 - ); - const aggregatedScores = aggregateScoresByModule(stats).filter( - (x) => x.total > 0 - ); + const customContent = (stats: Stat[], user: string, focus: "academic" | "general") => { + const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0); + const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0); + const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0); - const aggregatedLevels = aggregatedScores.map((x) => ({ - module: x.module, - level: calculateBandScore(x.correct, x.total, x.module, focus), - })); + const aggregatedLevels = aggregatedScores.map((x) => ({ + module: x.module, + level: calculateBandScore(x.correct, x.total, x.module, focus), + })); - const timeSpent = stats[0].timeSpent; + const timeSpent = stats[0].timeSpent; - const selectExam = () => { - const examPromises = uniqBy(stats, "exam").map((stat) => - getExamById(stat.module, stat.exam) - ); + const selectExam = () => { + const examPromises = uniqBy(stats, "exam").map((stat) => getExamById(stat.module, stat.exam)); - Promise.all(examPromises).then((exams) => { - if (exams.every((x) => !!x)) { - setUserSolutions(convertToUserSolutions(stats)); - setShowSolutions(true); - setExams(exams.map((x) => x!).sort(sortByModule)); - setSelectedModules( - exams - .map((x) => x!) - .sort(sortByModule) - .map((x) => x!.module) - ); - router.push("/exercises"); - } - }); - }; + Promise.all(examPromises).then((exams) => { + if (exams.every((x) => !!x)) { + setUserSolutions(convertToUserSolutions(stats)); + setShowSolutions(true); + setExams(exams.map((x) => x!).sort(sortByModule)); + setSelectedModules( + exams + .map((x) => x!) + .sort(sortByModule) + .map((x) => x!.module), + ); + router.push("/exercises"); + } + }); + }; - const content = ( - <> -
-
- - {formatTimestamp(stats[0].date.toString())} - - {timeSpent && ( - <> - - - {Math.floor(timeSpent / 60)} minutes - - - )} -
- = 0.7 && "text-mti-purple", - correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red", - correct / total < 0.3 && "text-mti-rose" - )} - > - Level{" "} - {( - aggregatedLevels.reduce( - (accumulator, current) => accumulator + current.level, - 0 - ) / aggregatedLevels.length - ).toFixed(1)} - -
+ const content = ( + <> +
+
+ {formatTimestamp(stats[0].date.toString())} + {timeSpent && ( + <> + + {Math.floor(timeSpent / 60)} minutes + + )} +
+ = 0.7 && "text-mti-purple", + correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red", + correct / total < 0.3 && "text-mti-rose", + )}> + Level{" "} + {(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)} + +
-
-
- {aggregatedLevels.map(({ module, level }) => ( -
- {module === "reading" && } - {module === "listening" && } - {module === "writing" && } - {module === "speaking" && } - {module === "level" && } - {level.toFixed(1)} -
- ))} -
-
- - ); +
+
+ {aggregatedLevels.map(({module, level}) => ( +
+ {module === "reading" && } + {module === "listening" && } + {module === "writing" && } + {module === "speaking" && } + {module === "level" && } + {level.toFixed(1)} +
+ ))} +
+
+ + ); - return ( -
- - {(() => { - const student = users.find((u) => u.id === user); - return `${student?.name} (${student?.email})`; - })()} - -
= 0.7 && "hover:border-mti-purple", - correct / total >= 0.3 && - correct / total < 0.7 && - "hover:border-mti-red", - correct / total < 0.3 && "hover:border-mti-rose" - )} - onClick={selectExam} - role="button" - > - {content} -
-
= 0.7 && "hover:border-mti-purple", - correct / total >= 0.3 && - correct / total < 0.7 && - "hover:border-mti-red", - correct / total < 0.3 && "hover:border-mti-rose" - )} - data-tip="Your screen size is too small to view previous exams." - role="button" - > - {content} -
-
- ); - }; + return ( +
+ + {(() => { + const student = users.find((u) => u.id === user); + return `${student?.name} (${student?.email})`; + })()} + +
= 0.7 && "hover:border-mti-purple", + correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red", + correct / total < 0.3 && "hover:border-mti-rose", + )} + onClick={selectExam} + role="button"> + {content} +
+
= 0.7 && "hover:border-mti-purple", + correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red", + correct / total < 0.3 && "hover:border-mti-rose", + )} + data-tip="Your screen size is too small to view previous exams." + role="button"> + {content} +
+
+ ); + }; - const shouldRenderStart = () => { - if (assignment) { - if (futureAssignmentFilter(assignment)) { - return true; - } - } + const shouldRenderStart = () => { + if (assignment) { + if (futureAssignmentFilter(assignment)) { + return true; + } + } - return false; - }; + return false; + }; - return ( - -
- -
-
- - Start Date:{" "} - {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")} - - - End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")} - -
-
- - Assignees:{" "} - {users - .filter((u) => assignment?.assignees.includes(u.id)) - .map((u) => `${u.name} (${u.email})`) - .join(", ")} - - - Assigner:{" "} - {getUserName(users.find((x) => x.id === assignment?.assigner))} - -
-
-
- Average Scores -
- {assignment && - uniqBy(assignment.exams, (x) => x.module).map(({ module }) => ( -
- {module === "reading" && } - {module === "listening" && ( - - )} - {module === "writing" && } - {module === "speaking" && } - {module === "level" && } - {calculateAverageModuleScore(module) > -1 && ( - - {calculateAverageModuleScore(module).toFixed(1)} - - )} -
- ))} -
-
-
- - Results ({assignment?.results.length}/{assignment?.assignees.length} - ) - -
- {assignment && assignment?.results.length > 0 && ( -
- {assignment.results.map((r) => - customContent(r.stats, r.user, r.type) - )} -
- )} - {assignment && assignment?.results.length === 0 && ( - No results yet... - )} -
-
+ return ( + +
+ +
+
+ Start Date: {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")} + End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")} +
+
+ + Assignees:{" "} + {users + .filter((u) => assignment?.assignees.includes(u.id)) + .map((u) => `${u.name} (${u.email})`) + .join(", ")} + + Assigner: {getUserName(users.find((x) => x.id === assignment?.assigner))} +
+
+
+ Average Scores +
+ {assignment && + uniqBy(assignment.exams, (x) => x.module).map(({module}) => ( +
+ {module === "reading" && } + {module === "listening" && } + {module === "writing" && } + {module === "speaking" && } + {module === "level" && } + {calculateAverageModuleScore(module) > -1 && ( + {calculateAverageModuleScore(module).toFixed(1)} + )} +
+ ))} +
+
+
+ + Results ({assignment?.results.length}/{assignment?.assignees.length}) + +
+ {assignment && assignment?.results.length > 0 && ( +
+ {assignment.results.map((r) => customContent(r.stats, r.user, r.type))} +
+ )} + {assignment && assignment?.results.length === 0 && No results yet...} +
+
-
- {assignment && - (assignment.results.length === assignment.assignees.length || - moment().isAfter(moment(assignment.endDate))) && ( - - )} - {/** if the assignment is not deemed as active yet, display start */} - {shouldRenderStart() && ( - - )} - -
-
-
- ); +
+ {assignment && (assignment.results.length === assignment.assignees.length || moment().isAfter(moment(assignment.endDate))) && ( + + )} + {/** if the assignment is not deemed as active yet, display start */} + {shouldRenderStart() && ( + + )} + +
+
+
+ ); } diff --git a/src/dashboards/Corporate/index.tsx b/src/dashboards/Corporate/index.tsx index e16028b3..b21aaf28 100644 --- a/src/dashboards/Corporate/index.tsx +++ b/src/dashboards/Corporate/index.tsx @@ -1,61 +1,40 @@ /* eslint-disable @next/next/no-img-element */ import Modal from "@/components/Modal"; import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser"; -import useUsers, {userHashStudent, userHashTeacher, userHashCorporate} from "@/hooks/useUsers"; +import useUsers from "@/hooks/useUsers"; import {CorporateUser, Group, MasterCorporateUser, Stat, User} from "@/interfaces/user"; import UserList from "@/pages/(admin)/Lists/UserList"; -import {dateSorter} from "@/utils"; +import {dateSorter, mapBy} from "@/utils"; import moment from "moment"; import {useEffect, useMemo, useState} from "react"; import { BsArrowLeft, BsClipboard2Data, - BsClipboard2DataFill, BsClock, - BsGlobeCentralSouthAsia, BsPaperclip, - BsPerson, - BsPersonAdd, BsPersonFill, BsPersonFillGear, - BsPersonGear, BsPencilSquare, - BsPersonBadge, BsPersonCheck, BsPeople, - BsArrowRepeat, - BsPlus, BsEnvelopePaper, BsDatabase, } from "react-icons/bs"; import UserCard from "@/components/UserCard"; import useGroups from "@/hooks/useGroups"; -import {averageLevelCalculator, calculateAverageLevel, calculateBandScore} from "@/utils/score"; -import {MODULE_ARRAY} from "@/utils/moduleUtils"; +import {calculateAverageLevel, calculateBandScore} from "@/utils/score"; import {Module} from "@/interfaces"; import {groupByExam} from "@/utils/stats"; import IconCard from "../IconCard"; import GroupList from "@/pages/(admin)/Lists/GroupList"; import useFilterStore from "@/stores/listFilterStore"; import {useRouter} from "next/router"; -import useCodes from "@/hooks/useCodes"; -import {getUserCorporate} from "@/utils/groups"; import useAssignments from "@/hooks/useAssignments"; -import {Assignment} from "@/interfaces/results"; -import AssignmentView from "../AssignmentView"; -import AssignmentCreator from "../AssignmentCreator"; -import clsx from "clsx"; -import AssignmentCard from "../AssignmentCard"; -import {createColumnHelper} from "@tanstack/react-table"; -import Checkbox from "@/components/Low/Checkbox"; -import List from "@/components/List"; -import {getUserCompanyName} from "@/resources/user"; -import {futureAssignmentFilter, pastAssignmentFilter, archivedAssignmentFilter, activeAssignmentFilter} from "@/utils/assignments"; import useUserBalance from "@/hooks/useUserBalance"; import AssignmentsPage from "../views/AssignmentsPage"; import StudentPerformancePage from "./StudentPerformancePage"; -import MasterStatistical from "../MasterCorporate/MasterStatistical"; import MasterStatisticalPage from "./MasterStatisticalPage"; +import {getEntitiesUsers} from "@/utils/users.be"; interface Props { user: CorporateUser; @@ -91,19 +70,6 @@ export default function CorporateDashboard({user, linkedCorporate}: Props) { const assignmentsGroups = useMemo(() => groups.filter((x) => x.admin === user.id || x.participants.includes(user.id)), [groups, user.id]); - const assignmentsUsers = useMemo( - () => - [...teachers, ...students].filter((x) => - !!selectedUser - ? groups - .filter((g) => g.admin === selectedUser.id) - .flatMap((g) => g.participants) - .includes(x.id) || false - : groups.flatMap((g) => g.participants).includes(x.id), - ), - [groups, teachers, students, selectedUser], - ); - useEffect(() => { setShowModal(!!selectedUser && router.asPath === "/#"); }, [selectedUser, router.asPath]); @@ -251,7 +217,7 @@ export default function CorporateDashboard({user, linkedCorporate}: Props) { .includes(x.id), }); - router.push("/list/users"); + router.push("/users"); } : undefined } @@ -271,7 +237,7 @@ export default function CorporateDashboard({user, linkedCorporate}: Props) { .includes(x.id), }); - router.push("/list/users"); + router.push("/users"); } : undefined } diff --git a/src/dashboards/IconCard.tsx b/src/dashboards/IconCard.tsx index e21e1e05..dcea0834 100644 --- a/src/dashboards/IconCard.tsx +++ b/src/dashboards/IconCard.tsx @@ -22,10 +22,10 @@ export default function IconCard({Icon, label, value, color, tooltip, onClick, c }; return ( -
-
+ ); } diff --git a/src/dashboards/MasterCorporate/index.tsx b/src/dashboards/MasterCorporate/index.tsx index efa958e0..72d34c25 100644 --- a/src/dashboards/MasterCorporate/index.tsx +++ b/src/dashboards/MasterCorporate/index.tsx @@ -276,7 +276,7 @@ export default function MasterCorporateDashboard({user}: Props) { .includes(x.id), }); - router.push("/list/users"); + router.push("/users"); } : undefined } @@ -296,7 +296,7 @@ export default function MasterCorporateDashboard({user}: Props) { .includes(x.id), }); - router.push("/list/users"); + router.push("/users"); } : undefined } diff --git a/src/dashboards/Teacher.tsx b/src/dashboards/Teacher.tsx index 18927ee4..b10c379c 100644 --- a/src/dashboards/Teacher.tsx +++ b/src/dashboards/Teacher.tsx @@ -202,7 +202,7 @@ export default function TeacherDashboard({user, linkedCorporate}: Props) { .includes(x.id), }); - router.push("/list/users"); + router.push("/users"); } : undefined } @@ -222,7 +222,7 @@ export default function TeacherDashboard({user, linkedCorporate}: Props) { .includes(x.id), }); - router.push("/list/users"); + router.push("/users"); } : undefined } diff --git a/src/dashboards/views/AssignmentsPage.tsx b/src/dashboards/views/AssignmentsPage.tsx index 805f364d..a0fc2b3a 100644 --- a/src/dashboards/views/AssignmentsPage.tsx +++ b/src/dashboards/views/AssignmentsPage.tsx @@ -42,6 +42,7 @@ export default function AssignmentsPage({assignments, corporateAssignments, user {displayAssignmentView && ( { setSelectedAssignment(undefined); setIsCreatingAssignment(false); diff --git a/src/email/templates/resetPassword.handlebars b/src/email/templates/resetPassword.handlebars new file mode 100644 index 00000000..487c3a1a --- /dev/null +++ b/src/email/templates/resetPassword.handlebars @@ -0,0 +1,16 @@ + + + + + + + +

Hi {{name}},

+

You requested to reset your password.

+

Please, click the link below to reset your password

+ Reset Password + + + \ No newline at end of file diff --git a/src/hooks/useEntities.tsx b/src/hooks/useEntities.tsx new file mode 100644 index 00000000..434d5d1f --- /dev/null +++ b/src/hooks/useEntities.tsx @@ -0,0 +1,23 @@ +import { EntityWithRoles } from "@/interfaces/entity"; +import { Discount } from "@/interfaces/paypal"; +import { Code, Group, User } from "@/interfaces/user"; +import axios from "axios"; +import { useEffect, useState } from "react"; + +export default function useEntities() { + const [entities, setEntities] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isError, setIsError] = useState(false); + + const getData = () => { + setIsLoading(true); + axios + .get("/api/entities?showRoles=true") + .then((response) => setEntities(response.data)) + .finally(() => setIsLoading(false)); + }; + + useEffect(getData, []); + + return { entities, isLoading, isError, reload: getData }; +} diff --git a/src/hooks/useEntitiesGroups.tsx b/src/hooks/useEntitiesGroups.tsx new file mode 100644 index 00000000..f0bd565c --- /dev/null +++ b/src/hooks/useEntitiesGroups.tsx @@ -0,0 +1,23 @@ +import { EntityWithRoles, WithEntity, WithLabeledEntities } from "@/interfaces/entity"; +import { Discount } from "@/interfaces/paypal"; +import { Code, Group, Type, User } from "@/interfaces/user"; +import axios from "axios"; +import { useEffect, useState } from "react"; + +export default function useEntitiesGroups() { + const [groups, setGroups] = useState[]>([]); + const [isLoading, setIsLoading] = useState(false); + const [isError, setIsError] = useState(false); + + const getData = () => { + setIsLoading(true); + axios + .get[]>(`/api/entities/groups`) + .then((response) => setGroups(response.data)) + .finally(() => setIsLoading(false)); + }; + + useEffect(getData, []); + + return { groups, isLoading, isError, reload: getData }; +} diff --git a/src/hooks/useEntitiesUsers.tsx b/src/hooks/useEntitiesUsers.tsx new file mode 100644 index 00000000..f1c7be6a --- /dev/null +++ b/src/hooks/useEntitiesUsers.tsx @@ -0,0 +1,23 @@ +import { EntityWithRoles, WithLabeledEntities } from "@/interfaces/entity"; +import { Discount } from "@/interfaces/paypal"; +import { Code, Group, Type, User } from "@/interfaces/user"; +import axios from "axios"; +import { useEffect, useState } from "react"; + +export default function useEntitiesUsers(type?: Type) { + const [users, setUsers] = useState[]>([]); + const [isLoading, setIsLoading] = useState(false); + const [isError, setIsError] = useState(false); + + const getData = () => { + setIsLoading(true); + axios + .get[]>(`/api/entities/users${type ? "?type=" + type : ""}`) + .then((response) => setUsers(response.data)) + .finally(() => setIsLoading(false)); + }; + + useEffect(getData, [type]); + + return { users, isLoading, isError, reload: getData }; +} diff --git a/src/hooks/useEntityPermissions.tsx b/src/hooks/useEntityPermissions.tsx new file mode 100644 index 00000000..c065275f --- /dev/null +++ b/src/hooks/useEntityPermissions.tsx @@ -0,0 +1,16 @@ +import { EntityWithRoles } from "@/interfaces/entity"; +import { User } from "@/interfaces/user"; +import { RolePermission } from "@/resources/entityPermissions"; +import { mapBy } from "@/utils"; +import { doesEntityAllow, findAllowedEntities } from "@/utils/permissions"; +import { useMemo, useState } from "react"; + +export const useAllowedEntities = (user: User, entities: EntityWithRoles[], permission: RolePermission) => { + const allowedEntityIds = useMemo(() => findAllowedEntities(user, entities, permission), [user, entities, permission]) + return allowedEntityIds +} + +export const useEntityPermission = (user: User, entity: EntityWithRoles, permission: RolePermission) => { + const isAllowed = useMemo(() => doesEntityAllow(user, entity, permission), [user, entity, permission]) + return isAllowed +} diff --git a/src/hooks/useListSearch.tsx b/src/hooks/useListSearch.tsx index 512a644a..af4ed1e4 100644 --- a/src/hooks/useListSearch.tsx +++ b/src/hooks/useListSearch.tsx @@ -5,7 +5,7 @@ import {search} from "@/utils/search"; export function useListSearch(fields: string[][], rows: T[]) { const [text, setText] = useState(""); - const renderSearch = () => ; + const renderSearch = () => ; const updatedRows = useMemo(() => { if (text.length > 0) return search(text, fields, rows); diff --git a/src/hooks/usePagination.tsx b/src/hooks/usePagination.tsx index 490c6c9e..9075dbbe 100644 --- a/src/hooks/usePagination.tsx +++ b/src/hooks/usePagination.tsx @@ -1,5 +1,7 @@ import Button from "@/components/Low/Button"; import {useMemo, useState} from "react"; +import {BiChevronLeft} from "react-icons/bi"; +import {BsChevronDoubleLeft, BsChevronDoubleRight, BsChevronLeft, BsChevronRight} from "react-icons/bs"; export default function usePagination(list: T[], size = 25) { const [page, setPage] = useState(0); @@ -24,5 +26,35 @@ export default function usePagination(list: T[], size = 25) {
); - return {page, items, setPage, render}; + const renderMinimal = () => ( +
+
+ + +
+ + {page * size + 1} - {(page + 1) * size > list.length ? list.length : (page + 1) * size} / {list.length} + +
+ + +
+
+ ); + + return {page, items, setPage, render, renderMinimal}; } diff --git a/src/interfaces/entity.ts b/src/interfaces/entity.ts new file mode 100644 index 00000000..d7c7d7f3 --- /dev/null +++ b/src/interfaces/entity.ts @@ -0,0 +1,29 @@ +export interface Entity { + id: string; + label: string; +} + +export interface Role { + id: string; + entityID: string; + permissions: string[]; + label: string; + isDefault?: boolean +} + +export interface EntityWithRoles extends Entity { + roles: Role[]; +}; + +export type WithLabeledEntities = T extends { entities: { id: string; role: string }[] } + ? Omit & { entities: { id: string; label?: string; role: string, roleLabel?: string }[] } + : T; + + +export type WithEntity = T extends { entity?: string } + ? Omit & { entity: Entity } + : T; + +export type WithEntities = T extends { entities: { id: string; role: string }[] } + ? Omit & { entities: { entity?: Entity; role?: Role }[] } + : T; diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts index cd740bd4..5bbe259a 100644 --- a/src/interfaces/index.ts +++ b/src/interfaces/index.ts @@ -8,5 +8,6 @@ export interface Step { export interface Grading { user: string; + entity?: string; steps: Step[]; } diff --git a/src/interfaces/invite.ts b/src/interfaces/invite.ts index ce5e9f5e..4dda38b7 100644 --- a/src/interfaces/invite.ts +++ b/src/interfaces/invite.ts @@ -1,5 +1,11 @@ +import {User} from "./user"; + export interface Invite { - id: string; - from: string; - to: string; + id: string; + from: string; + to: string; +} + +export interface InviteWithUsers extends Omit { + from?: User; } diff --git a/src/interfaces/results.ts b/src/interfaces/results.ts index ebc26bd8..cd9ff373 100644 --- a/src/interfaces/results.ts +++ b/src/interfaces/results.ts @@ -34,6 +34,7 @@ export interface Assignment { start?: boolean; autoStartDate?: Date; autoStart?: boolean; + entity?: string; } export type AssignmentWithCorporateId = Assignment & {corporateId: string}; diff --git a/src/interfaces/user.ts b/src/interfaces/user.ts index 110650a5..54fba92a 100644 --- a/src/interfaces/user.ts +++ b/src/interfaces/user.ts @@ -22,6 +22,7 @@ export interface BasicUser { status: UserStatus; permissions: PermissionType[]; lastLogin?: Date; + entities: {id: string; role: string}[]; } export interface StudentUser extends BasicUser { @@ -149,6 +150,12 @@ export interface Group { participants: string[]; id: string; disableEditing?: boolean; + entity?: string; +} + +export interface GroupWithUsers extends Omit { + admin: User; + participants: User[]; } export interface Code { @@ -165,4 +172,6 @@ export interface Code { } export type Type = "student" | "teacher" | "corporate" | "admin" | "developer" | "agent" | "mastercorporate"; -export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent", "mastercorporate"]; \ No newline at end of file +export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent", "mastercorporate"]; + +export type WithUser = T extends {participants: string[]} ? Omit & {participants: User[]} : T; diff --git a/src/pages/(admin)/Lists/CodeList.tsx b/src/pages/(admin)/Lists/CodeList.tsx index f95a48a9..d3a5631e 100644 --- a/src/pages/(admin)/Lists/CodeList.tsx +++ b/src/pages/(admin)/Lists/CodeList.tsx @@ -29,7 +29,7 @@ const CreatorCell = ({id, users}: {id: string; users: User[]}) => { return ( <> {(creatorUser?.type === "corporate" ? creatorUser?.corporateInformation?.companyInformation?.name : creatorUser?.name || "N/A") || "N/A"}{" "} - {creatorUser && `(${USER_TYPE_LABELS[creatorUser.type]})`} + {creatorUser && `(${USER_TYPE_LABELS[creatorUser?.type]})`} ); }; @@ -216,10 +216,10 @@ export default function CodeList({user}: {user: User}) { filteredCorporate ? { label: `${ - filteredCorporate.type === "corporate" + filteredCorporate?.type === "corporate" ? filteredCorporate.corporateInformation?.companyInformation?.name || filteredCorporate.name : filteredCorporate.name - } (${USER_TYPE_LABELS[filteredCorporate.type]})`, + } (${USER_TYPE_LABELS[filteredCorporate?.type]})`, value: filteredCorporate.id, } : null diff --git a/src/pages/(admin)/Lists/GroupList.tsx b/src/pages/(admin)/Lists/GroupList.tsx index 993f56da..12e5a491 100644 --- a/src/pages/(admin)/Lists/GroupList.tsx +++ b/src/pages/(admin)/Lists/GroupList.tsx @@ -3,373 +3,300 @@ import Input from "@/components/Low/Input"; import Modal from "@/components/Modal"; import useGroups from "@/hooks/useGroups"; import useUsers from "@/hooks/useUsers"; -import {CorporateUser, Group, User} from "@/interfaces/user"; -import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table"; +import { CorporateUser, Group, User } from "@/interfaces/user"; +import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table"; import axios from "axios"; -import {capitalize, uniq} from "lodash"; -import {useEffect, useMemo, useState} from "react"; -import {BsPencil, BsQuestionCircleFill, BsTrash} from "react-icons/bs"; +import { capitalize, uniq } from "lodash"; +import { useEffect, useMemo, useState } from "react"; +import { BsPencil, BsQuestionCircleFill, BsTrash } from "react-icons/bs"; import Select from "react-select"; -import {toast} from "react-toastify"; +import { toast } from "react-toastify"; import readXlsxFile from "read-excel-file"; -import {useFilePicker} from "use-file-picker"; -import {getUserCorporate} from "@/utils/groups"; -import {isAgentUser, isCorporateUser, USER_TYPE_LABELS} from "@/resources/user"; -import {checkAccess} from "@/utils/permissions"; +import { useFilePicker } from "use-file-picker"; +import { getUserCorporate } from "@/utils/groups"; +import { isAgentUser, isCorporateUser, USER_TYPE_LABELS } from "@/resources/user"; +import { checkAccess } from "@/utils/permissions"; import usePermissions from "@/hooks/usePermissions"; -import {useListSearch} from "@/hooks/useListSearch"; +import { useListSearch } from "@/hooks/useListSearch"; +import Table from "@/components/High/Table"; +import useEntitiesGroups from "@/hooks/useEntitiesGroups"; +import useEntitiesUsers from "@/hooks/useEntitiesUsers"; +import { WithEntity } from "@/interfaces/entity"; const searchFields = [["name"]]; -const columnHelper = createColumnHelper(); +const columnHelper = createColumnHelper>(); const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/); -const LinkedCorporate = ({userId, users, groups}: {userId: string; users: User[]; groups: Group[]}) => { - const [companyName, setCompanyName] = useState(""); - const [isLoading, setIsLoading] = useState(false); - - useEffect(() => { - const user = users.find((u) => u.id === userId); - if (!user) return setCompanyName(""); - - if (isCorporateUser(user)) return setCompanyName(user.corporateInformation?.companyInformation?.name || user.name); - if (isAgentUser(user)) return setCompanyName(user.agentInformation?.companyName || user.name); - - const belongingGroups = groups.filter((x) => x.participants.includes(userId)); - const belongingGroupsAdmins = belongingGroups.map((x) => users.find((u) => u.id === x.admin)).filter((x) => !!x && isCorporateUser(x)); - - if (belongingGroupsAdmins.length === 0) return setCompanyName(""); - - const admin = belongingGroupsAdmins[0] as CorporateUser; - setCompanyName(admin.corporateInformation?.companyInformation.name || admin.name); - }, [userId, users, groups]); - - return isLoading ? Loading... : <>{companyName}; -}; - interface CreateDialogProps { - user: User; - users: User[]; - group?: Group; - onClose: () => void; + user: User; + users: User[]; + group?: Group; + onClose: () => void; } -const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => { - const [name, setName] = useState(group?.name || undefined); - const [admin, setAdmin] = useState(group?.admin || user.id); - const [participants, setParticipants] = useState(group?.participants || []); - const [isLoading, setIsLoading] = useState(false); +const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => { + const [name, setName] = useState(group?.name || undefined); + const [admin, setAdmin] = useState(group?.admin || user.id); + const [participants, setParticipants] = useState(group?.participants || []); + const [isLoading, setIsLoading] = useState(false); - const {openFilePicker, filesContent, clear} = useFilePicker({ - accept: ".xlsx", - multiple: false, - readAs: "ArrayBuffer", - }); + const { openFilePicker, filesContent, clear } = useFilePicker({ + accept: ".xlsx", + multiple: false, + readAs: "ArrayBuffer", + }); - const availableUsers = useMemo(() => { - if (user.type === "teacher") return users.filter((x) => ["student"].includes(x.type)); - if (user.type === "corporate") return users.filter((x) => ["teacher", "student"].includes(x.type)); - if (user.type === "mastercorporate") return users.filter((x) => ["corporate", "teacher", "student"].includes(x.type)); + const availableUsers = useMemo(() => { + if (user?.type === "teacher") return users.filter((x) => ["student"].includes(x.type)); + if (user?.type === "corporate") return users.filter((x) => ["teacher", "student"].includes(x.type)); + if (user?.type === "mastercorporate") return users.filter((x) => ["corporate", "teacher", "student"].includes(x.type)); - return users; - }, [user, users]); + return users; + }, [user, users]); - useEffect(() => { - if (filesContent.length > 0) { - setIsLoading(true); + useEffect(() => { + if (filesContent.length > 0) { + setIsLoading(true); - const file = filesContent[0]; - readXlsxFile(file.content).then((rows) => { - const emails = uniq( - rows - .map((row) => { - const [email] = row as string[]; - return EMAIL_REGEX.test(email) && !users.map((u) => u.email).includes(email) ? email.toString().trim() : undefined; - }) - .filter((x) => !!x), - ); + const file = filesContent[0]; + readXlsxFile(file.content).then((rows) => { + const emails = uniq( + rows + .map((row) => { + const [email] = row as string[]; + return EMAIL_REGEX.test(email) && !users.map((u) => u.email).includes(email) ? email.toString().trim() : undefined; + }) + .filter((x) => !!x), + ); - if (emails.length === 0) { - toast.error("Please upload an Excel file containing e-mails!"); - clear(); - setIsLoading(false); - return; - } + if (emails.length === 0) { + toast.error("Please upload an Excel file containing e-mails!"); + clear(); + setIsLoading(false); + return; + } - const emailUsers = [...new Set(emails)].map((x) => users.find((y) => y.email.toLowerCase() === x)).filter((x) => x !== undefined); - const filteredUsers = emailUsers.filter( - (x) => - ((user.type === "developer" || user.type === "admin" || user.type === "corporate" || user.type === "mastercorporate") && - (x?.type === "student" || x?.type === "teacher")) || - (user.type === "teacher" && x?.type === "student"), - ); + const emailUsers = [...new Set(emails)].map((x) => users.find((y) => y.email.toLowerCase() === x)).filter((x) => x !== undefined); + const filteredUsers = emailUsers.filter( + (x) => + ((user.type === "developer" || user.type === "admin" || user.type === "corporate" || user.type === "mastercorporate") && + (x?.type === "student" || x?.type === "teacher")) || + (user.type === "teacher" && x?.type === "student"), + ); - setParticipants(filteredUsers.filter((x) => !!x).map((x) => x!.id)); - toast.success( - user.type !== "teacher" - ? "Added all teachers and students found in the file you've provided!" - : "Added all students found in the file you've provided!", - {toastId: "upload-success"}, - ); - setIsLoading(false); - }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filesContent, user.type, users]); + setParticipants(filteredUsers.filter((x) => !!x).map((x) => x!.id)); + toast.success( + user.type !== "teacher" + ? "Added all teachers and students found in the file you've provided!" + : "Added all students found in the file you've provided!", + { toastId: "upload-success" }, + ); + setIsLoading(false); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filesContent, user.type, users]); - const submit = () => { - setIsLoading(true); + const submit = () => { + setIsLoading(true); - if (name !== group?.name && (name?.trim() === "Students" || name?.trim() === "Teachers" || name?.trim() === "Corporate")) { - toast.error("That group name is reserved and cannot be used, please enter another one."); - setIsLoading(false); - return; - } + if (name !== group?.name && (name?.trim() === "Students" || name?.trim() === "Teachers" || name?.trim() === "Corporate")) { + toast.error("That group name is reserved and cannot be used, please enter another one."); + setIsLoading(false); + return; + } - (group ? axios.patch : axios.post)(group ? `/api/groups/${group.id}` : "/api/groups", {name, admin, participants}) - .then(() => { - toast.success(`Group "${name}" ${group ? "edited" : "created"} successfully`); - return true; - }) - .catch(() => { - toast.error("Something went wrong, please try again later!"); - return false; - }) - .finally(() => { - setIsLoading(false); - onClose(); - }); - }; + (group ? axios.patch : axios.post)(group ? `/api/groups/${group.id}` : "/api/groups", { name, admin, participants }) + .then(() => { + toast.success(`Group "${name}" ${group ? "edited" : "created"} successfully`); + return true; + }) + .catch(() => { + toast.error("Something went wrong, please try again later!"); + return false; + }) + .finally(() => { + setIsLoading(false); + onClose(); + }); + }; - return ( -
-
- -
-
- -
- -
-
-
- +
+
+ +
+ +
+
+
+
- const defaultColumns = [ - columnHelper.accessor("id", { - header: "ID", - cell: (info) => info.getValue(), - }), - columnHelper.accessor("name", { - header: "Name", - cell: (info) => info.getValue(), - }), - columnHelper.accessor("admin", { - header: "Admin", - cell: (info) => ( -
x.id === info.getValue())?.type || "student"]}> - {users.find((x) => x.id === info.getValue())?.name} -
- ), - }), - columnHelper.accessor("admin", { - header: "Linked Corporate", - cell: (info) => , - }), - columnHelper.accessor("participants", { - header: "Participants", - cell: (info) => ( - - {info - .getValue() - .slice(0, viewingAllParticipants === info.row.original.id ? undefined : 5) - .map((x) => users.find((y) => y.id === x)?.name) - .join(", ")} - {info.getValue().length > 5 && viewingAllParticipants !== info.row.original.id && ( - - )} - {info.getValue().length > 5 && viewingAllParticipants === info.row.original.id && ( - - )} - - ), - }), - { - header: "", - id: "actions", - cell: ({row}: {row: {original: Group}}) => { - return ( - <> - {user && (checkAccess(user, ["developer", "admin"]) || user.id === row.original.admin) && ( -
- {(!row.original.disableEditing || checkAccess(user, ["developer", "admin"]), "editGroup") && ( -
setEditingGroup(row.original)}> - -
- )} - {(!row.original.disableEditing || checkAccess(user, ["developer", "admin"]), "deleteGroup") && ( -
deleteGroup(row.original)}> - -
- )} -
- )} - - ); - }, - }, - ]; - - const table = useReactTable({ - data: filteredRows, - columns: defaultColumns, - getCoreRowModel: getCoreRowModel(), - }); - - const closeModal = () => { - setIsCreating(false); - setEditingGroup(undefined); - reload(); - }; - - return ( -
- - - groups - .filter((g) => g.admin === user.id) - .flatMap((g) => g.participants) - .includes(u.id) || - (user?.type === "teacher" ? corporateGroups : groups).flatMap((g) => g.participants).includes(u.id), - ) - : users - } - /> - - {renderSearch()} -
- - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - ))} - - ))} - - - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - ))} - - ))} - -
- {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} -
- {flexRender(cell.column.columnDef.cell, cell.getContext())} -
- - {checkAccess(user, ["teacher", "corporate", "mastercorporate", "admin", "developer"], permissions, "createGroup") && ( - - )} -
- ); + {checkAccess(user, ["teacher", "corporate", "mastercorporate", "admin", "developer"], permissions, "createGroup") && ( + + )} +
+ ); } diff --git a/src/pages/(admin)/Lists/StudentPerformanceList.tsx b/src/pages/(admin)/Lists/StudentPerformanceList.tsx new file mode 100644 index 00000000..f202c088 --- /dev/null +++ b/src/pages/(admin)/Lists/StudentPerformanceList.tsx @@ -0,0 +1,111 @@ +/* eslint-disable @next/next/no-img-element */ +import {Stat, StudentUser, User} from "@/interfaces/user"; +import {useState} from "react"; +import {averageLevelCalculator} from "@/utils/score"; +import {groupByExam} from "@/utils/stats"; +import {createColumnHelper} from "@tanstack/react-table"; +import Checkbox from "@/components/Low/Checkbox"; +import List from "@/components/List"; +import Table from "@/components/High/Table"; + +type StudentPerformanceItem = StudentUser & {entitiesLabel: string; group: string}; + +const StudentPerformanceList = ({items = [], stats}: {items: StudentPerformanceItem[]; stats: Stat[]}) => { + const [isShowingAmount, setIsShowingAmount] = useState(false); + + const columnHelper = createColumnHelper(); + + const columns = [ + columnHelper.accessor("name", { + header: "Student Name", + cell: (info) => info.getValue(), + }), + columnHelper.accessor("email", { + header: "E-mail", + cell: (info) => info.getValue(), + }), + columnHelper.accessor("studentID", { + header: "ID", + cell: (info) => info.getValue() || "N/A", + }), + columnHelper.accessor("group", { + header: "Group", + cell: (info) => info.getValue(), + }), + columnHelper.accessor("entitiesLabel", { + header: "Entities", + cell: (info) => info.getValue() || "N/A", + }), + columnHelper.accessor("levels.reading", { + header: "Reading", + cell: (info) => + !isShowingAmount + ? info.getValue() || 0 + : `${Object.keys(groupByExam(stats.filter((x) => x.module === "reading" && x.user === info.row.original.id))).length} exams`, + }), + columnHelper.accessor("levels.listening", { + header: "Listening", + cell: (info) => + !isShowingAmount + ? info.getValue() || 0 + : `${Object.keys(groupByExam(stats.filter((x) => x.module === "listening" && x.user === info.row.original.id))).length} exams`, + }), + columnHelper.accessor("levels.writing", { + header: "Writing", + cell: (info) => + !isShowingAmount + ? info.getValue() || 0 + : `${Object.keys(groupByExam(stats.filter((x) => x.module === "writing" && x.user === info.row.original.id))).length} exams`, + }), + columnHelper.accessor("levels.speaking", { + header: "Speaking", + cell: (info) => + !isShowingAmount + ? info.getValue() || 0 + : `${Object.keys(groupByExam(stats.filter((x) => x.module === "speaking" && x.user === info.row.original.id))).length} exams`, + }), + columnHelper.accessor("levels.level", { + header: "Level", + cell: (info) => + !isShowingAmount + ? info.getValue() || 0 + : `${Object.keys(groupByExam(stats.filter((x) => x.module === "level" && x.user === info.row.original.id))).length} exams`, + }), + columnHelper.accessor("levels", { + id: "overall_level", + header: "Overall", + cell: (info) => + !isShowingAmount + ? averageLevelCalculator( + items, + stats.filter((x) => x.user === info.row.original.id), + ).toFixed(1) + : `${Object.keys(groupByExam(stats.filter((x) => x.user === info.row.original.id))).length} exams`, + }), + ]; + + return ( +
+ + Show Utilization + + + data={items.sort( + (a, b) => + averageLevelCalculator( + items, + stats.filter((x) => x.user === b.id), + ) - + averageLevelCalculator( + items, + stats.filter((x) => x.user === a.id), + ), + )} + columns={columns} + searchFields={[["name"], ["email"], ["studentID"], ["entitiesLabel"], ["group"]]} + /> +
+ ); +}; + +export default StudentPerformanceList; diff --git a/src/pages/(admin)/Lists/UserList.tsx b/src/pages/(admin)/Lists/UserList.tsx index 619630fa..91d6af3b 100644 --- a/src/pages/(admin)/Lists/UserList.tsx +++ b/src/pages/(admin)/Lists/UserList.tsx @@ -1,666 +1,426 @@ -import Button from "@/components/Low/Button"; -import {PERMISSIONS} from "@/constants/userPermissions"; -import useGroups from "@/hooks/useGroups"; -import useUsers from "@/hooks/useUsers"; -import {Type, User, userTypes, CorporateUser, Group} from "@/interfaces/user"; -import {Popover, Transition} from "@headlessui/react"; -import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table"; +import { PERMISSIONS } from "@/constants/userPermissions"; +import { Type, User } from "@/interfaces/user"; +import { createColumnHelper } from "@tanstack/react-table"; import axios from "axios"; import clsx from "clsx"; -import {capitalize, reverse} from "lodash"; +import { capitalize } from "lodash"; import moment from "moment"; -import {Fragment, useEffect, useState, useMemo} from "react"; -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 { useEffect, useMemo, useState } from "react"; +import { BsCheck, BsCheckCircle, BsFillExclamationOctagonFill, 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"; -import {getUserCompanyName, isAgentUser, USER_TYPE_LABELS} from "@/resources/user"; +import { USER_TYPE_LABELS } from "@/resources/user"; import useFilterStore from "@/stores/listFilterStore"; -import {useRouter} from "next/router"; -import {isCorporateUser} from "@/resources/user"; -import {useListSearch} from "@/hooks/useListSearch"; -import {getUserCorporate} from "@/utils/groups"; -import {asyncSorter} from "@/utils"; -import {exportListToExcel, UserListRow} from "@/utils/users"; -import {checkAccess} from "@/utils/permissions"; -import {PermissionType} from "@/interfaces/permissions"; +import { useRouter } from "next/router"; +import { mapBy } from "@/utils"; +import { exportListToExcel } from "@/utils/users"; import usePermissions from "@/hooks/usePermissions"; import useUserBalance from "@/hooks/useUserBalance"; -import usePagination from "@/hooks/usePagination"; -const columnHelper = createColumnHelper(); -const searchFields = [["name"], ["email"], ["corporateInformation", "companyInformation", "name"]]; +import useEntitiesUsers from "@/hooks/useEntitiesUsers"; +import { WithLabeledEntities } from "@/interfaces/entity"; +import Table from "@/components/High/Table"; +import useEntities from "@/hooks/useEntities"; +import { useAllowedEntities } from "@/hooks/useEntityPermissions"; -const corporatesHash = { - type: "corporate", -}; - -const CompanyNameCell = ({users, user, groups}: {user: User; users: User[]; groups: Group[]}) => { - const [companyName, setCompanyName] = useState(""); - const [isLoading, setIsLoading] = useState(false); - - useEffect(() => { - const name = getUserCompanyName(user, users, groups); - setCompanyName(name); - }, [user, users, groups]); - - return isLoading ? Loading... : <>{companyName}; -}; +const columnHelper = createColumnHelper>(); +const searchFields = [["name"], ["email"], ["entities", ""]]; export default function UserList({ - user, - filters = [], - type, - renderHeader, + user, + filters = [], + type, + renderHeader, }: { - user: User; - filters?: ((user: User) => boolean)[]; - type?: Type; - renderHeader?: (total: number) => JSX.Element; + user: User; + filters?: ((user: User) => boolean)[]; + type?: Type; + renderHeader?: (total: number) => JSX.Element; }) { - const [showDemographicInformation, setShowDemographicInformation] = useState(false); - const [sorter, setSorter] = useState(); - const [displayUsers, setDisplayUsers] = useState([]); - const [selectedUser, setSelectedUser] = useState(); + const [showDemographicInformation, setShowDemographicInformation] = useState(false); + const [selectedUser, setSelectedUser] = useState(); - const userHash = useMemo( - () => ({ - type, - }), - [type], - ); + const { users, reload } = useEntitiesUsers(type) + const { entities } = useEntities() - const {users, total, isLoading, reload} = useUsers(userHash); - const {users: corporates} = useUsers(corporatesHash); + const { balance } = useUserBalance(); - const totalUsers = useMemo(() => [...users, ...corporates], [users, corporates]); + const isAdmin = useMemo(() => ["admin", "developer"].includes(user?.type), [user?.type]) - const {permissions} = usePermissions(user?.id || ""); - const {balance} = useUserBalance(); - const {groups} = useGroups({ - admin: user && ["corporate", "teacher", "mastercorporate"].includes(user?.type) ? user.id : undefined, - userType: user?.type, - }); + const entitiesEditStudents = useAllowedEntities(user, entities, "edit_students") + const entitiesDeleteStudents = useAllowedEntities(user, entities, "delete_students") - const appendUserFilters = useFilterStore((state) => state.appendUserFilter); - const router = useRouter(); + const entitiesEditTeachers = useAllowedEntities(user, entities, "edit_teachers") + const entitiesDeleteTeachers = useAllowedEntities(user, entities, "delete_teachers") - const expirationDateColor = (date: Date) => { - const momentDate = moment(date); - const today = moment(new Date()); + const entitiesEditCorporates = useAllowedEntities(user, entities, "edit_corporates") + const entitiesDeleteCorporates = useAllowedEntities(user, entities, "delete_corporates") - if (today.isAfter(momentDate)) return "!text-mti-red-light font-bold line-through"; - if (today.add(1, "weeks").isAfter(momentDate)) return "!text-mti-red-light"; - if (today.add(2, "weeks").isAfter(momentDate)) return "!text-mti-rose-light"; - if (today.add(1, "months").isAfter(momentDate)) return "!text-mti-orange-light"; - }; + const entitiesEditMasterCorporates = useAllowedEntities(user, entities, "edit_mastercorporates") + const entitiesDeleteMasterCorporates = useAllowedEntities(user, entities, "delete_mastercorporates") - useEffect(() => { - (async () => { - if (users && users.length > 0) { - const filteredUsers = filters.reduce((d, f) => d.filter(f), users); - // const sortedUsers = await asyncSorter(filteredUsers, sortFunction); + const appendUserFilters = useFilterStore((state) => state.appendUserFilter); + const router = useRouter(); - setDisplayUsers([...filteredUsers]); - } - })(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [users, sorter]); + const expirationDateColor = (date: Date) => { + const momentDate = moment(date); + const today = moment(new Date()); - const deleteAccount = (user: User) => { - if (!confirm(`Are you sure you want to delete ${user.name}'s account?`)) return; + if (today.isAfter(momentDate)) return "!text-mti-red-light font-bold line-through"; + if (today.add(1, "weeks").isAfter(momentDate)) return "!text-mti-red-light"; + if (today.add(2, "weeks").isAfter(momentDate)) return "!text-mti-rose-light"; + if (today.add(1, "months").isAfter(momentDate)) return "!text-mti-orange-light"; + }; - axios - .delete<{ok: boolean}>(`/api/user?id=${user.id}`) - .then(() => { - toast.success("User deleted successfully!"); - reload(); - }) - .catch(() => { - toast.error("Something went wrong!", {toastId: "delete-error"}); - }) - .finally(reload); - }; + const displayUsers = useMemo(() => filters.length > 0 ? filters.reduce((d, f) => d.filter(f), users) : users, [filters, users]) - const verifyAccount = (user: User) => { - axios - .post<{user?: User; ok?: boolean}>(`/api/users/update?id=${user.id}`, { - ...user, - isVerified: true, - }) - .then(() => { - toast.success("User verified successfully!"); - reload(); - }) - .catch(() => { - toast.error("Something went wrong!", {toastId: "update-error"}); - }); - }; + const deleteAccount = (user: User) => { + if (!confirm(`Are you sure you want to delete ${user.name}'s account?`)) return; - const toggleDisableAccount = (user: User) => { - if ( - !confirm( - `Are you sure you want to ${user.status === "disabled" ? "enable" : "disable"} ${ - user.name - }'s account? This change is usually related to their payment state.`, - ) - ) - return; + axios + .delete<{ ok: boolean }>(`/api/user?id=${user.id}`) + .then(() => { + toast.success("User deleted successfully!"); + reload() + }) + .catch(() => { + toast.error("Something went wrong!", { toastId: "delete-error" }); + }) + .finally(reload); + }; - axios - .post<{user?: User; ok?: boolean}>(`/api/users/update?id=${user.id}`, { - ...user, - status: user.status === "disabled" ? "active" : "disabled", - }) - .then(() => { - toast.success(`User ${user.status === "disabled" ? "enabled" : "disabled"} successfully!`); - reload(); - }) - .catch(() => { - toast.error("Something went wrong!", {toastId: "update-error"}); - }); - }; + const verifyAccount = (user: User) => { + axios + .post<{ user?: User; ok?: boolean }>(`/api/users/update?id=${user.id}`, { + ...user, + isVerified: true, + }) + .then(() => { + toast.success("User verified successfully!"); + reload(); + }) + .catch(() => { + toast.error("Something went wrong!", { toastId: "update-error" }); + }); + }; - const SorterArrow = ({name}: {name: string}) => { - if (sorter === name) return ; - if (sorter === reverseString(name)) return ; + const toggleDisableAccount = (user: User) => { + if ( + !confirm( + `Are you sure you want to ${user.status === "disabled" ? "enable" : "disable"} ${user.name + }'s account? This change is usually related to their payment state.`, + ) + ) + return; - return ; - }; + axios + .post<{ user?: User; ok?: boolean }>(`/api/users/update?id=${user.id}`, { + ...user, + status: user.status === "disabled" ? "active" : "disabled", + }) + .then(() => { + toast.success(`User ${user.status === "disabled" ? "enabled" : "disabled"} successfully!`); + reload(); + }) + .catch(() => { + toast.error("Something went wrong!", { toastId: "update-error" }); + }); + }; - const actionColumn = ({row}: {row: {original: User}}) => { - const updateUserPermission = PERMISSIONS.updateUser[row.original.type] as { - list: Type[]; - perm: PermissionType; - }; - const deleteUserPermission = PERMISSIONS.deleteUser[row.original.type] as { - list: Type[]; - perm: PermissionType; - }; - return ( -
- {!row.original.isVerified && checkAccess(user, updateUserPermission.list, permissions, updateUserPermission.perm) && ( -
verifyAccount(row.original)}> - -
- )} - {checkAccess(user, updateUserPermission.list, permissions, updateUserPermission.perm) && ( -
toggleDisableAccount(row.original)}> - {row.original.status === "disabled" ? ( - - ) : ( - - )} -
- )} - {checkAccess(user, deleteUserPermission.list, permissions, deleteUserPermission.perm) && ( -
deleteAccount(row.original)}> - -
- )} -
- ); - }; + const getEditPermission = (type: Type) => { + if (type === "student") return entitiesEditStudents + if (type === "teacher") return entitiesEditTeachers + if (type === "corporate") return entitiesEditCorporates + if (type === "mastercorporate") return entitiesEditMasterCorporates - const demographicColumns = [ - columnHelper.accessor("name", { - header: ( - - ) as any, - cell: ({row, getValue}) => ( -
- checkAccess(user, ["admin", "corporate", "developer", "mastercorporate"]) ? setSelectedUser(row.original) : null - }> - {getValue()} -
- ), - }), - columnHelper.accessor("demographicInformation.country", { - header: ( - - ) as any, - cell: (info) => - info.getValue() - ? `${countryCodes.findOne("countryCode" as any, info.getValue())?.flag} ${ - countries[info.getValue() as unknown as keyof TCountries]?.name - } (+${countryCodes.findOne("countryCode" as any, info.getValue())?.countryCallingCode})` - : "N/A", - }), - columnHelper.accessor("demographicInformation.phone", { - header: ( - - ) as any, - cell: (info) => info.getValue() || "N/A", - enableSorting: true, - }), - columnHelper.accessor( - (x) => - x.type === "corporate" || x.type === "mastercorporate" ? x.demographicInformation?.position : x.demographicInformation?.employment, - { - id: "employment", - header: ( - - ) as any, - cell: (info) => (info.row.original.type === "corporate" ? info.getValue() : capitalize(info.getValue())) || "N/A", - enableSorting: true, - }, - ), - columnHelper.accessor("lastLogin", { - header: ( - - ) as any, - cell: (info) => (!!info.getValue() ? moment(info.getValue()).format("YYYY-MM-DD HH:mm") : "N/A"), - }), - columnHelper.accessor("demographicInformation.gender", { - header: ( - - ) as any, - cell: (info) => capitalize(info.getValue()) || "N/A", - enableSorting: true, - }), - { - header: ( - setShowDemographicInformation((prev) => !prev)}> - Switch - - ), - id: "actions", - cell: actionColumn, - }, - ]; + return [] + } - const defaultColumns = [ - columnHelper.accessor("name", { - header: ( - - ) as any, - cell: ({row, getValue}) => ( -
- checkAccess(user, ["admin", "corporate", "developer", "mastercorporate"]) ? setSelectedUser(row.original) : null - }> - {getValue()} -
- ), - }), - columnHelper.accessor("email", { - header: ( - - ) as any, - cell: ({row, getValue}) => ( -
(PERMISSIONS.updateExpiryDate[row.original.type]?.includes(user.type) ? setSelectedUser(row.original) : null)}> - {getValue()} -
- ), - }), - columnHelper.accessor("type", { - header: ( - - ) as any, - cell: (info) => USER_TYPE_LABELS[info.getValue()], - }), - columnHelper.accessor("studentID", { - header: ( - - ) as any, - cell: (info) => info.getValue() || "N/A", - }), - columnHelper.accessor("corporateInformation.companyInformation.name", { - header: ( - - ) as any, - cell: (info) => , - }), - columnHelper.accessor("subscriptionExpirationDate", { - header: ( - - ) as any, - cell: (info) => ( - - {!info.getValue() ? "No expiry date" : moment(info.getValue()).format("DD/MM/YYYY")} - - ), - }), - columnHelper.accessor("isVerified", { - header: ( - - ) as any, - cell: (info) => ( -
-
- -
-
- ), - }), - { - header: ( - setShowDemographicInformation((prev) => !prev)}> - Switch - - ), - id: "actions", - cell: actionColumn, - }, - ]; + const getDeletePermission = (type: Type) => { + if (type === "student") return entitiesDeleteStudents + if (type === "teacher") return entitiesDeleteTeachers + if (type === "corporate") return entitiesDeleteCorporates + if (type === "mastercorporate") return entitiesDeleteMasterCorporates - const reverseString = (str: string) => reverse(str.split("")).join(""); + return [] + } - const selectSorter = (previous: string | undefined, name: string) => { - if (!previous) return name; - if (previous === name) return reverseString(name); + const canEditUser = (u: User) => + isAdmin || u.entities.some(e => mapBy(getEditPermission(u.type), 'id').includes(e.id)) + const canDeleteUser = (u: User) => + isAdmin || u.entities.some(e => mapBy(getDeletePermission(u.type), 'id').includes(e.id)) - return undefined; - }; + const actionColumn = ({ row }: { row: { original: User } }) => { + const canEdit = canEditUser(row.original) + const canDelete = canDeleteUser(row.original) - const sortFunction = async (a: User, b: User) => { - if (sorter === "name" || sorter === reverseString("name")) - return sorter === "name" ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name); + return ( +
+ {!row.original.isVerified && canEdit && ( +
verifyAccount(row.original)}> + +
+ )} + {canEdit && ( +
toggleDisableAccount(row.original)}> + {row.original.status === "disabled" ? ( + + ) : ( + + )} +
+ )} + {canDelete && ( +
deleteAccount(row.original)}> + +
+ )} +
+ ); + }; - if (sorter === "email" || sorter === reverseString("email")) - return sorter === "email" ? a.email.localeCompare(b.email) : b.email.localeCompare(a.email); + const demographicColumns = [ + columnHelper.accessor("name", { + header: "Name", + cell: ({ row, getValue }) => ( +
+ canEditUser(row.original) ? setSelectedUser(row.original) : null + }> + {getValue()} +
+ ), + }), + columnHelper.accessor("demographicInformation.country", { + header: "Country", + cell: (info) => + info.getValue() + ? `${countryCodes.findOne("countryCode" as any, info.getValue())?.flag} ${countries[info.getValue() as unknown as keyof TCountries]?.name + } (+${countryCodes.findOne("countryCode" as any, info.getValue())?.countryCallingCode})` + : "N/A", + }), + columnHelper.accessor("demographicInformation.phone", { + header: "Phone", + cell: (info) => info.getValue() || "N/A", + enableSorting: true, + }), + columnHelper.accessor( + (x) => + x.type === "corporate" || x.type === "mastercorporate" ? x.demographicInformation?.position : x.demographicInformation?.employment, + { + id: "employment", + header: "Employment", + cell: (info) => (info.row.original.type === "corporate" ? info.getValue() : capitalize(info.getValue())) || "N/A", + enableSorting: true, + }, + ), + columnHelper.accessor("lastLogin", { + header: "Last Login", + cell: (info) => (!!info.getValue() ? moment(info.getValue()).format("YYYY-MM-DD HH:mm") : "N/A"), + }), + columnHelper.accessor("demographicInformation.gender", { + header: "Gender", + cell: (info) => capitalize(info.getValue()) || "N/A", + enableSorting: true, + }), + { + header: ( + setShowDemographicInformation((prev) => !prev)}> + Switch + + ), + id: "actions", + cell: actionColumn, + sortable: false + }, + ]; - if (sorter === "type" || sorter === reverseString("type")) - return sorter === "type" - ? userTypes.findIndex((t) => a.type === t) - userTypes.findIndex((t) => b.type === t) - : userTypes.findIndex((t) => b.type === t) - userTypes.findIndex((t) => a.type === t); + const defaultColumns = [ + columnHelper.accessor("name", { + header: "Name", + cell: ({ row, getValue }) => ( +
+ canEditUser(row.original) ? setSelectedUser(row.original) : null + }> + {getValue()} +
+ ), + }), + columnHelper.accessor("email", { + header: "E-mail", + cell: ({ row, getValue }) => ( +
(canEditUser(row.original) ? setSelectedUser(row.original) : null)}> + {getValue()} +
+ ), + }), + columnHelper.accessor("type", { + header: "Type", + cell: (info) => USER_TYPE_LABELS[info.getValue()], + }), + columnHelper.accessor("studentID", { + header: "Student ID", + cell: (info) => info.getValue() || "N/A", + }), + columnHelper.accessor("entities", { + header: "Entities", + cell: ({ getValue }) => mapBy(getValue(), 'label').join(', '), + }), + columnHelper.accessor("subscriptionExpirationDate", { + header: "Expiration", + cell: (info) => ( + + {!info.getValue() ? "No expiry date" : moment(info.getValue()).format("DD/MM/YYYY")} + + ), + }), + columnHelper.accessor("isVerified", { + header: "Verified", + cell: (info) => ( +
+
+ +
+
+ ), + }), + { + header: ( + setShowDemographicInformation((prev) => !prev)}> + Switch + + ), + id: "actions", + cell: actionColumn, + sortable: false + }, + ]; - if (sorter === "studentID" || sorter === reverseString("studentID")) - return sorter === "studentID" - ? (a.type === "student" ? a.studentID || "N/A" : "N/A").localeCompare(b.type === "student" ? b.studentID || "N/A" : "N/A") - : (b.type === "student" ? b.studentID || "N/A" : "N/A").localeCompare(a.type === "student" ? a.studentID || "N/A" : "N/A"); + const downloadExcel = (rows: WithLabeledEntities[]) => { + const csv = exportListToExcel(rows); - if (sorter === "verification" || sorter === reverseString("verification")) - return sorter === "verification" - ? a.isVerified.toString().localeCompare(b.isVerified.toString()) - : b.isVerified.toString().localeCompare(a.isVerified.toString()); + const element = document.createElement("a"); + const file = new Blob([csv], { type: "text/csv" }); + element.href = URL.createObjectURL(file); + element.download = "users.csv"; + document.body.appendChild(element); + element.click(); + document.body.removeChild(element); + }; - if (sorter === "expiryDate" || sorter === reverseString("expiryDate")) { - if (!a.subscriptionExpirationDate && b.subscriptionExpirationDate) return sorter === "expiryDate" ? -1 : 1; - if (a.subscriptionExpirationDate && !b.subscriptionExpirationDate) return sorter === "expiryDate" ? 1 : -1; - if (!a.subscriptionExpirationDate && !b.subscriptionExpirationDate) return 0; - if (moment(a.subscriptionExpirationDate).isAfter(b.subscriptionExpirationDate)) return sorter === "expiryDate" ? -1 : 1; - if (moment(b.subscriptionExpirationDate).isAfter(a.subscriptionExpirationDate)) return sorter === "expiryDate" ? 1 : -1; - return 0; - } + const viewStudentFilter = (x: User) => x.type === "student"; + const viewTeacherFilter = (x: User) => x.type === "teacher"; + const belongsToAdminFilter = (x: User) => x.entities.some(({ id }) => mapBy(selectedUser?.entities || [], 'id').includes(id)); - if (sorter === "lastLogin" || sorter === reverseString("lastLogin")) { - if (!a.lastLogin && b.lastLogin) return sorter === "lastLogin" ? -1 : 1; - if (a.lastLogin && !b.lastLogin) return sorter === "lastLogin" ? 1 : -1; - if (!a.lastLogin && !b.lastLogin) return 0; - if (moment(a.lastLogin).isAfter(b.lastLogin)) return sorter === "lastLogin" ? -1 : 1; - if (moment(b.lastLogin).isAfter(a.lastLogin)) return sorter === "lastLogin" ? 1 : -1; - return 0; - } + const viewStudentFilterBelongsToAdmin = (x: User) => viewStudentFilter(x) && belongsToAdminFilter(x); + const viewTeacherFilterBelongsToAdmin = (x: User) => viewTeacherFilter(x) && belongsToAdminFilter(x); - if (sorter === "country" || sorter === reverseString("country")) { - if (!a.demographicInformation?.country && b.demographicInformation?.country) return sorter === "country" ? -1 : 1; - if (a.demographicInformation?.country && !b.demographicInformation?.country) return sorter === "country" ? 1 : -1; - if (!a.demographicInformation?.country && !b.demographicInformation?.country) return 0; + const renderUserCard = (selectedUser: User) => { + const studentsFromAdmin = users.filter(viewStudentFilterBelongsToAdmin); + const teachersFromAdmin = users.filter(viewTeacherFilterBelongsToAdmin); + return ( +
+ 0 + ? () => { + appendUserFilters({ + id: "view-students", + filter: viewStudentFilter, + }); + appendUserFilters({ + id: "belongs-to-admin", + filter: belongsToAdminFilter, + }); - return sorter === "country" - ? a.demographicInformation!.country.localeCompare(b.demographicInformation!.country) - : b.demographicInformation!.country.localeCompare(a.demographicInformation!.country); - } + router.push("/users"); + } + : undefined + } + onViewTeachers={ + (selectedUser.type === "corporate" || selectedUser.type === "student") && teachersFromAdmin.length > 0 + ? () => { + appendUserFilters({ + id: "view-teachers", + filter: viewTeacherFilter, + }); + appendUserFilters({ + id: "belongs-to-admin", + filter: belongsToAdminFilter, + }); - if (sorter === "phone" || sorter === reverseString("phone")) { - if (!a.demographicInformation?.phone && b.demographicInformation?.phone) return sorter === "phone" ? -1 : 1; - if (a.demographicInformation?.phone && !b.demographicInformation?.phone) return sorter === "phone" ? 1 : -1; - if (!a.demographicInformation?.phone && !b.demographicInformation?.phone) return 0; + router.push("/users"); + } + : undefined + } + onViewCorporate={ + selectedUser.type === "teacher" || selectedUser.type === "student" + ? () => { + appendUserFilters({ + id: "view-corporate", + filter: (x: User) => x.type === "corporate", + }); + appendUserFilters({ + id: "belongs-to-admin", + filter: belongsToAdminFilter + }); - return sorter === "phone" - ? a.demographicInformation!.phone.localeCompare(b.demographicInformation!.phone) - : b.demographicInformation!.phone.localeCompare(a.demographicInformation!.phone); - } + router.push("/users"); + } + : undefined + } + onClose={(shouldReload) => { + setSelectedUser(undefined); + if (shouldReload) reload(); + }} + user={selectedUser} + /> +
+ ); + }; - if (sorter === "employment" || sorter === reverseString("employment")) { - const aSortingItem = - a.type === "corporate" || a.type === "mastercorporate" ? a.demographicInformation?.position : a.demographicInformation?.employment; - const bSortingItem = - b.type === "corporate" || b.type === "mastercorporate" ? b.demographicInformation?.position : b.demographicInformation?.employment; - - if (!aSortingItem && bSortingItem) return sorter === "employment" ? -1 : 1; - if (aSortingItem && !bSortingItem) return sorter === "employment" ? 1 : -1; - if (!aSortingItem && !bSortingItem) return 0; - - return sorter === "employment" ? aSortingItem!.localeCompare(bSortingItem!) : bSortingItem!.localeCompare(aSortingItem!); - } - - if (sorter === "gender" || sorter === reverseString("gender")) { - if (!a.demographicInformation?.gender && b.demographicInformation?.gender) return sorter === "employment" ? -1 : 1; - if (a.demographicInformation?.gender && !b.demographicInformation?.gender) return sorter === "employment" ? 1 : -1; - if (!a.demographicInformation?.gender && !b.demographicInformation?.gender) return 0; - - return sorter === "gender" - ? a.demographicInformation!.gender.localeCompare(b.demographicInformation!.gender) - : b.demographicInformation!.gender.localeCompare(a.demographicInformation!.gender); - } - - if (sorter === "companyName" || sorter === reverseString("companyName")) { - const aCorporateName = getUserCompanyName(a, users, groups); - const bCorporateName = getUserCompanyName(b, users, groups); - if (!aCorporateName && bCorporateName) return sorter === "companyName" ? -1 : 1; - if (aCorporateName && !bCorporateName) return sorter === "companyName" ? 1 : -1; - if (!aCorporateName && !bCorporateName) return 0; - - return sorter === "companyName" ? aCorporateName.localeCompare(bCorporateName) : bCorporateName.localeCompare(aCorporateName); - } - - return a.id.localeCompare(b.id); - }; - - const {rows: filteredRows, renderSearch, text: searchText} = useListSearch(searchFields, displayUsers); - const {items, setPage, render: renderPagination} = usePagination(filteredRows, 16); - - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(() => setPage(0), [searchText]); - - const table = useReactTable({ - data: items, - columns: (!showDemographicInformation ? defaultColumns : demographicColumns) as any, - getCoreRowModel: getCoreRowModel(), - }); - - const downloadExcel = () => { - const csv = exportListToExcel(filteredRows, users, groups); - - const element = document.createElement("a"); - const file = new Blob([csv], {type: "text/csv"}); - element.href = URL.createObjectURL(file); - element.download = "users.csv"; - document.body.appendChild(element); - element.click(); - document.body.removeChild(element); - }; - - const viewStudentFilter = (x: User) => x.type === "student"; - const viewTeacherFilter = (x: User) => x.type === "teacher"; - const belongsToAdminFilter = (x: User) => { - if (!selectedUser) return false; - return groups - .filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id)) - .flatMap((g) => g.participants) - .includes(x.id); - }; - - const viewStudentFilterBelongsToAdmin = (x: User) => x.type === "student" && belongsToAdminFilter(x); - const viewTeacherFilterBelongsToAdmin = (x: User) => x.type === "teacher" && belongsToAdminFilter(x); - - const renderUserCard = (selectedUser: User) => { - const studentsFromAdmin = users.filter(viewStudentFilterBelongsToAdmin); - const teachersFromAdmin = users.filter(viewTeacherFilterBelongsToAdmin); - return ( -
- 0 - ? () => { - appendUserFilters({ - id: "view-students", - filter: viewStudentFilter, - }); - appendUserFilters({ - id: "belongs-to-admin", - filter: belongsToAdminFilter, - }); - - router.push("/list/users"); - } - : undefined - } - onViewTeachers={ - (selectedUser.type === "corporate" || selectedUser.type === "student") && teachersFromAdmin.length > 0 - ? () => { - appendUserFilters({ - id: "view-teachers", - filter: viewTeacherFilter, - }); - appendUserFilters({ - id: "belongs-to-admin", - filter: belongsToAdminFilter, - }); - - router.push("/list/users"); - } - : undefined - } - onViewCorporate={ - selectedUser.type === "teacher" || selectedUser.type === "student" - ? () => { - appendUserFilters({ - id: "view-corporate", - filter: (x: User) => x.type === "corporate", - }); - appendUserFilters({ - id: "belongs-to-admin", - filter: (x: User) => - groups - .filter((g) => g.participants.includes(selectedUser.id)) - .flatMap((g) => [g.admin, ...g.participants]) - .includes(x.id), - }); - - router.push("/list/users"); - } - : undefined - } - onClose={(shouldReload) => { - setSelectedUser(undefined); - if (shouldReload) reload(); - }} - user={selectedUser} - /> -
- ); - }; - - return ( - <> - {renderHeader && renderHeader(total)} -
- setSelectedUser(undefined)}> - {selectedUser && renderUserCard(selectedUser)} - -
-
- {renderSearch()} - -
- {renderPagination()} - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - ))} - - ))} - - - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - ))} - - ))} - -
- {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} -
- {flexRender(cell.column.columnDef.cell, cell.getContext())} -
-
-
- - ); + return ( + <> + {renderHeader && renderHeader(displayUsers.length)} +
+ setSelectedUser(undefined)}> + {selectedUser && renderUserCard(selectedUser)} + + > + data={displayUsers} + columns={(!showDemographicInformation ? defaultColumns : demographicColumns) as any} + searchFields={searchFields} + onDownload={downloadExcel} + /> +
+ + ); } diff --git a/src/pages/(admin)/UserCreator.tsx b/src/pages/(admin)/UserCreator.tsx index 03fc35e6..d01667c2 100644 --- a/src/pages/(admin)/UserCreator.tsx +++ b/src/pages/(admin)/UserCreator.tsx @@ -1,288 +1,273 @@ import Button from "@/components/Low/Button"; import Checkbox from "@/components/Low/Checkbox"; -import {PERMISSIONS} from "@/constants/userPermissions"; -import {CorporateUser, TeacherUser, Type, User} from "@/interfaces/user"; -import {USER_TYPE_LABELS} from "@/resources/user"; +import { PERMISSIONS } from "@/constants/userPermissions"; +import { CorporateUser, TeacherUser, Type, User } from "@/interfaces/user"; +import { USER_TYPE_LABELS } from "@/resources/user"; import axios from "axios"; import clsx from "clsx"; -import {capitalize, uniqBy} from "lodash"; +import { capitalize, uniqBy } from "lodash"; import moment from "moment"; -import {useEffect, useState} from "react"; +import { useEffect, useState } from "react"; import ReactDatePicker from "react-datepicker"; -import {toast} from "react-toastify"; +import { toast } from "react-toastify"; import ShortUniqueId from "short-unique-id"; -import {checkAccess, getTypesOfUser} from "@/utils/permissions"; -import {PermissionType} from "@/interfaces/permissions"; +import { checkAccess, getTypesOfUser } from "@/utils/permissions"; +import { PermissionType } from "@/interfaces/permissions"; import usePermissions from "@/hooks/usePermissions"; import Input from "@/components/Low/Input"; import CountrySelect from "@/components/Low/CountrySelect"; import useGroups from "@/hooks/useGroups"; import useUsers from "@/hooks/useUsers"; -import {getUserName} from "@/utils/users"; +import { getUserName } from "@/utils/users"; import Select from "@/components/Low/Select"; +import { EntityWithRoles } from "@/interfaces/entity"; +import useEntitiesGroups from "@/hooks/useEntitiesGroups"; const USER_TYPE_PERMISSIONS: { - [key in Type]: {perm: PermissionType | undefined; list: Type[]}; + [key in Type]: { perm: PermissionType | undefined; list: Type[] }; } = { - student: { - perm: "createCodeStudent", - list: [], - }, - teacher: { - perm: "createCodeTeacher", - list: [], - }, - agent: { - perm: "createCodeCountryManager", - list: ["student", "teacher", "corporate", "mastercorporate"], - }, - corporate: { - perm: "createCodeCorporate", - list: ["student", "teacher"], - }, - mastercorporate: { - perm: undefined, - list: ["student", "teacher", "corporate"], - }, - admin: { - perm: "createCodeAdmin", - list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"], - }, - developer: { - perm: undefined, - list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"], - }, + student: { + perm: "createCodeStudent", + list: [], + }, + teacher: { + perm: "createCodeTeacher", + list: [], + }, + agent: { + perm: "createCodeCountryManager", + list: ["student", "teacher", "corporate", "mastercorporate"], + }, + corporate: { + perm: "createCodeCorporate", + list: ["student", "teacher"], + }, + mastercorporate: { + perm: undefined, + list: ["student", "teacher", "corporate"], + }, + admin: { + perm: "createCodeAdmin", + list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"], + }, + developer: { + perm: undefined, + list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"], + }, }; interface Props { - user: User; - users: User[]; - permissions: PermissionType[]; - onFinish: () => void; + user: User; + users: User[]; + entities: EntityWithRoles[] + permissions: PermissionType[]; + onFinish: () => void; } -export default function UserCreator({user, users, permissions, onFinish}: Props) { - const [name, setName] = useState(); - const [email, setEmail] = useState(); - const [phone, setPhone] = useState(); - const [passportID, setPassportID] = useState(); - const [studentID, setStudentID] = useState(); - const [country, setCountry] = useState(user?.demographicInformation?.country); - const [group, setGroup] = useState(); - const [availableCorporates, setAvailableCorporates] = useState([]); - const [selectedCorporate, setSelectedCorporate] = useState(); - const [password, setPassword] = useState(); - const [confirmPassword, setConfirmPassword] = useState(); - const [expiryDate, setExpiryDate] = useState( - user?.subscriptionExpirationDate ? moment(user?.subscriptionExpirationDate).toDate() : null, - ); - const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true); - const [isLoading, setIsLoading] = useState(false); - const [type, setType] = useState("student"); - const [position, setPosition] = useState(); +export default function UserCreator({ user, users, entities = [], permissions, onFinish }: Props) { + const [name, setName] = useState(); + const [email, setEmail] = useState(); + const [phone, setPhone] = useState(); + const [passportID, setPassportID] = useState(); + const [studentID, setStudentID] = useState(); + const [country, setCountry] = useState(user?.demographicInformation?.country); + const [group, setGroup] = useState(); + const [password, setPassword] = useState(); + const [confirmPassword, setConfirmPassword] = useState(); + const [expiryDate, setExpiryDate] = useState( + user?.subscriptionExpirationDate ? moment(user?.subscriptionExpirationDate).toDate() : null, + ); + const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true); + const [isLoading, setIsLoading] = useState(false); + const [type, setType] = useState("student"); + const [position, setPosition] = useState(); + const [entity, setEntity] = useState((entities || [])[0]?.id || undefined) - const {groups} = useGroups({admin: ["developer", "admin"].includes(user?.type) ? undefined : user?.id, userType: user?.type}); + const { groups } = useEntitiesGroups(); - useEffect(() => { - if (!isExpiryDateEnabled) setExpiryDate(null); - }, [isExpiryDateEnabled]); + useEffect(() => { + if (!isExpiryDateEnabled) setExpiryDate(null); + }, [isExpiryDateEnabled]); - useEffect(() => { - setAvailableCorporates( - uniqBy( - users.filter((u) => u.type === "corporate" && groups.flatMap((g) => g.participants).includes(u.id)), - "id", - ), - ); - }, [users, groups]); + const createUser = () => { + if (!name || name.trim().length === 0) return toast.error("Please enter a valid name!"); + if (!email || email.trim().length === 0) return toast.error("Please enter a valid e-mail address!"); + if (users.map((x) => x.email).includes(email.trim())) return toast.error("That e-mail is already in use!"); + if (!password || password.trim().length < 6) return toast.error("Please enter a valid password!"); + if (password !== confirmPassword) return toast.error("The passwords do not match!"); - const createUser = () => { - if (!name || name.trim().length === 0) return toast.error("Please enter a valid name!"); - if (!email || email.trim().length === 0) return toast.error("Please enter a valid e-mail address!"); - if (users.map((x) => x.email).includes(email.trim())) return toast.error("That e-mail is already in use!"); - if (!password || password.trim().length < 6) return toast.error("Please enter a valid password!"); - if (password !== confirmPassword) return toast.error("The passwords do not match!"); + setIsLoading(true); - setIsLoading(true); + const body = { + name, + email, + password, + groupID: group, + entity, + type, + studentID: type === "student" ? studentID : undefined, + expiryDate, + demographicInformation: { + passport_id: type === "student" ? passportID : undefined, + phone, + country, + position, + }, + }; - const body = { - name, - email, - password, - groupID: group, - corporate: selectedCorporate || user.id, - type, - studentID: type === "student" ? studentID : undefined, - expiryDate, - demographicInformation: { - passport_id: type === "student" ? passportID : undefined, - phone, - country, - position, - }, - }; + axios + .post("/api/make_user", body) + .then(() => { + toast.success("That user has been created!"); + onFinish(); - axios - .post("/api/make_user", body) - .then(() => { - toast.success("That user has been created!"); - onFinish(); + setName(""); + setEmail(""); + setPhone(""); + setPassportID(""); + setStudentID(""); + setCountry(user?.demographicInformation?.country); + setGroup(null); + setEntity((entities || [])[0]?.id || undefined) + setExpiryDate(user?.subscriptionExpirationDate ? moment(user?.subscriptionExpirationDate).toDate() : null); + setIsExpiryDateEnabled(true); + setType("student"); + setPosition(undefined); + }) + .catch((error) => { + const data = error?.response?.data; + if (!!data?.message) return toast.error(data.message); + toast.error("Something went wrong! Please try again later!"); + }) + .finally(() => setIsLoading(false)); + }; - setName(""); - setEmail(""); - setPhone(""); - setPassportID(""); - setStudentID(""); - setCountry(user?.demographicInformation?.country); - setGroup(null); - setSelectedCorporate(null); - setExpiryDate(user?.subscriptionExpirationDate ? moment(user?.subscriptionExpirationDate).toDate() : null); - setIsExpiryDateEnabled(true); - setType("student"); - setPosition(undefined); - }) - .catch((error) => { - const data = error?.response?.data; - if (!!data?.message) return toast.error(data.message); - toast.error("Something went wrong! Please try again later!"); - }) - .finally(() => setIsLoading(false)); - }; + return ( +
+
+ + - return ( -
-
- - + + - - +
+ + +
-
- - -
+ - + {type === "student" && ( + <> + + + + )} - {type === "student" && ( - <> - - - - )} +
+ + ({value: u.id, label: getUserName(u)}))} - isClearable - onChange={(e) => setSelectedCorporate(e?.value || undefined)} - /> -
- )} + {["corporate", "mastercorporate"].includes(type) && ( + + )} - {["corporate", "mastercorporate"].includes(type) && ( - - )} +
+ + (!selectedCorporate ? true : x.admin === selectedCorporate)) - .map((g) => ({value: g.id, label: g.name}))} - onChange={(e) => setGroup(e?.value || undefined)} - /> -
- )} +
+ + {user && ( + + )} +
-
- - {user && ( - - )} -
+
+ {user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && ( + <> +
+ + + Enabled + +
+ {isExpiryDateEnabled && ( + + moment(date).isAfter(new Date()) && + (user?.subscriptionExpirationDate ? moment(date).isBefore(user?.subscriptionExpirationDate) : true) + } + dateFormat="dd/MM/yyyy" + selected={expiryDate} + onChange={(date) => setExpiryDate(date)} + /> + )} + + )} +
+
-
- {user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && ( - <> -
- - - Enabled - -
- {isExpiryDateEnabled && ( - - moment(date).isAfter(new Date()) && - (user?.subscriptionExpirationDate ? moment(date).isBefore(user?.subscriptionExpirationDate) : true) - } - dateFormat="dd/MM/yyyy" - selected={expiryDate} - onChange={(date) => setExpiryDate(date)} - /> - )} - - )} -
-
- - -
- ); + +
+ ); } diff --git a/src/pages/api/assignments/[id]/index.ts b/src/pages/api/assignments/[id]/index.ts index 89b8ef9f..11af9c71 100644 --- a/src/pages/api/assignments/[id]/index.ts +++ b/src/pages/api/assignments/[id]/index.ts @@ -4,7 +4,6 @@ import client from "@/lib/mongodb"; import {withIronSessionApiRoute} from "iron-session/next"; import {sessionOptions} from "@/lib/session"; - const db = client.db(process.env.MONGODB_DB); export default withIronSessionApiRoute(handler, sessionOptions); @@ -25,7 +24,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { async function GET(req: NextApiRequest, res: NextApiResponse) { const {id} = req.query; - const snapshot = await db.collection("assignments").findOne({ id: id as string }); + const snapshot = await db.collection("assignments").findOne({id: id as string}); if (snapshot) { res.status(200).json({...snapshot, id: snapshot.id}); @@ -35,9 +34,7 @@ async function GET(req: NextApiRequest, res: NextApiResponse) { async function DELETE(req: NextApiRequest, res: NextApiResponse) { const {id} = req.query; - await db.collection("assignments").deleteOne( - { id: id as string } - ); + await db.collection("assignments").deleteOne({id}); res.status(200).json({ok: true}); } @@ -45,10 +42,7 @@ async function DELETE(req: NextApiRequest, res: NextApiResponse) { async function PATCH(req: NextApiRequest, res: NextApiResponse) { const {id} = req.query; - await db.collection("assignments").updateOne( - { id: id as string }, - { $set: {assigner: req.session.user?.id, ...req.body} } - ); + await db.collection("assignments").updateOne({id: id as string}, {$set: {assigner: req.session.user?.id, ...req.body}}); res.status(200).json({ok: true}); } diff --git a/src/pages/api/assignments/index.ts b/src/pages/api/assignments/index.ts index 35819b58..a799cfb3 100644 --- a/src/pages/api/assignments/index.ts +++ b/src/pages/api/assignments/index.ts @@ -128,8 +128,10 @@ async function POST(req: NextApiRequest, res: NextApiResponse) { return; } + const id = uuidv4(); + await db.collection("assignments").insertOne({ - id: uuidv4(), + id, assigner: req.session.user?.id, assignees, results: [], @@ -138,11 +140,10 @@ async function POST(req: NextApiRequest, res: NextApiResponse) { ...body, }); - res.status(200).json({ok: true}); + res.status(200).json({ok: true, id}); for (const assigneeID of assignees) { - - const assignee = await db.collection("users").findOne({ id: assigneeID }); + const assignee = await db.collection("users").findOne({id: assigneeID}); if (!assignee) continue; const name = body.name; diff --git a/src/pages/api/batch_users.ts b/src/pages/api/batch_users.ts index f54c0d49..fdd2ec7b 100644 --- a/src/pages/api/batch_users.ts +++ b/src/pages/api/batch_users.ts @@ -1,6 +1,6 @@ -import type {NextApiRequest, NextApiResponse} from "next"; -import {withIronSessionApiRoute} from "iron-session/next"; -import {sessionOptions} from "@/lib/session"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { withIronSessionApiRoute } from "iron-session/next"; +import { sessionOptions } from "@/lib/session"; import { FirebaseScrypt } from 'firebase-scrypt'; import { firebaseAuthScryptParams } from "@/firebase"; import crypto from 'crypto'; @@ -9,53 +9,58 @@ import axios from "axios"; export default withIronSessionApiRoute(handler, sessionOptions); async function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method === "POST") return post(req, res); + if (req.method === "POST") return post(req, res); - return res.status(404).json({ok: false}); + return res.status(404).json({ ok: false }); } async function post(req: NextApiRequest, res: NextApiResponse) { - const maker = req.session.user; - if (!maker) { - return res.status(401).json({ok: false, reason: "You must be logged in to make user!"}); - } + const maker = req.session.user; + if (!maker) { + return res.status(401).json({ ok: false, reason: "You must be logged in to make user!" }); + } - const scrypt = new FirebaseScrypt(firebaseAuthScryptParams) + const scrypt = new FirebaseScrypt(firebaseAuthScryptParams) - const users = req.body.users as { - email: string; - name: string; - type: string; - passport_id: string; - groupName?: string; - corporate?: string; - studentID?: string; - expiryDate?: string; - demographicInformation: { - country?: string; - passport_id?: string; - phone: string; - }; - passwordHash: string | undefined; - passwordSalt: string | undefined; - }[]; + const users = req.body.users as { + email: string; + name: string; + type: string; + passport_id: string; + groupName?: string; + corporate?: string; + studentID?: string; + expiryDate?: string; + demographicInformation: { + country?: string; + passport_id?: string; + phone: string; + }; + entity?: string + entities: { id: string, role: string }[] + passwordHash: string | undefined; + passwordSalt: string | undefined; + }[]; - const usersWithPasswordHashes = await Promise.all(users.map(async (user) => { - const currentUser = { ...user }; - const salt = crypto.randomBytes(16).toString('base64'); - const hash = await scrypt.hash(user.passport_id, salt); - - currentUser.email = currentUser.email.toLowerCase(); - currentUser.passwordHash = hash; - currentUser.passwordSalt = salt; - return currentUser; - })); - - const backendRequest = await axios.post(`${process.env.BACKEND_URL}/batch_users`, { makerID: maker.id, users: usersWithPasswordHashes }, { - headers: { - Authorization: `Bearer ${process.env.BACKEND_JWT}`, - }, - }); + const usersWithPasswordHashes = await Promise.all(users.map(async (user) => { + const currentUser = { ...user }; + const salt = crypto.randomBytes(16).toString('base64'); + const hash = await scrypt.hash(user.passport_id, salt); - return res.status(backendRequest.status).json(backendRequest.data) + currentUser.entities = [{ id: currentUser.entity!, role: "90ce8f08-08c8-41e4-9848-f1500ddc3930" }] + delete currentUser.entity + + currentUser.email = currentUser.email.toLowerCase(); + currentUser.passwordHash = hash; + currentUser.passwordSalt = salt; + return currentUser; + })); + + const backendRequest = await axios.post(`${process.env.BACKEND_URL}/batch_users`, { makerID: maker.id, users: usersWithPasswordHashes }, { + headers: { + Authorization: `Bearer ${process.env.BACKEND_JWT}`, + }, + }); + + return res.status(backendRequest.status).json(backendRequest.data) } diff --git a/src/pages/api/entities/[id]/index.ts b/src/pages/api/entities/[id]/index.ts new file mode 100644 index 00000000..b2e7179b --- /dev/null +++ b/src/pages/api/entities/[id]/index.ts @@ -0,0 +1,59 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import type {NextApiRequest, NextApiResponse} from "next"; +import {withIronSessionApiRoute} from "iron-session/next"; +import {sessionOptions} from "@/lib/session"; +import {deleteEntity, getEntity, getEntityWithRoles} from "@/utils/entities.be"; +import client from "@/lib/mongodb"; +import {Entity} from "@/interfaces/entity"; +import { doesEntityAllow } from "@/utils/permissions"; +import { getUser } from "@/utils/users.be"; +import { requestUser } from "@/utils/api"; + +const db = client.db(process.env.MONGODB_DB); + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "get") return await get(req, res); + if (req.method === "PATCH") return await patch(req, res); +} + +async function get(req: NextApiRequest, res: NextApiResponse) { + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); + + const {id, showRoles} = req.query as {id: string; showRoles: string}; + + const entity = await (!!showRoles ? getEntityWithRoles : getEntity)(id); + res.status(200).json(entity); +} + +async function del(req: NextApiRequest, res: NextApiResponse) { + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); + + const { id } = req.query as { id: string }; + + const entity = await getEntityWithRoles(id) + if (!entity) return res.status(404).json({ok: false}) + + if (!doesEntityAllow(user, entity, "delete_entity_role")) return res.status(403).json({ok: false}) + + await deleteEntity(entity) + return res.status(200).json({ok: true}); +} + +async function patch(req: NextApiRequest, res: NextApiResponse) { + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); + + const {id} = req.query as {id: string}; + + if (!user.entities.map((x) => x.id).includes(id)) { + return res.status(403).json({ok: false}); + } + + const entity = await db.collection("entities").updateOne({id}, {$set: {label: req.body.label}}); + + return res.status(200).json({ok: entity.acknowledged}); +} diff --git a/src/pages/api/entities/[id]/users.ts b/src/pages/api/entities/[id]/users.ts new file mode 100644 index 00000000..044f9c26 --- /dev/null +++ b/src/pages/api/entities/[id]/users.ts @@ -0,0 +1,65 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import type {NextApiRequest, NextApiResponse} from "next"; +import {withIronSessionApiRoute} from "iron-session/next"; +import {sessionOptions} from "@/lib/session"; +import {countEntityUsers, getEntityUsers} from "@/utils/users.be"; +import client from "@/lib/mongodb"; + +const db = client.db(process.env.MONGODB_DB); + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "get") return await get(req, res); + if (req.method === "PATCH") return await patch(req, res); +} + +async function get(req: NextApiRequest, res: NextApiResponse) { + if (!req.session.user) { + res.status(401).json({ok: false}); + return; + } + + const {id, onlyCount} = req.query as {id: string; onlyCount: string}; + + if (onlyCount) return res.status(200).json(await countEntityUsers(id)); + + const users = await getEntityUsers(id); + res.status(200).json(users); +} + +async function patch(req: NextApiRequest, res: NextApiResponse) { + if (!req.session.user) { + res.status(401).json({ok: false}); + return; + } + + const {id} = req.query as {id: string}; + const {add, members, role} = req.body as {add: boolean; members: string[]; role?: string}; + + if (add) { + await db.collection("users").updateMany( + {id: {$in: members}}, + { + // @ts-expect-error + $push: { + entities: {id, role}, + }, + }, + ); + + return res.status(204).end(); + } + + await db.collection("users").updateMany( + {id: {$in: members}}, + { + // @ts-expect-error + $pull: { + entities: {id}, + }, + }, + ); + + return res.status(204).end(); +} diff --git a/src/pages/api/entities/groups.ts b/src/pages/api/entities/groups.ts new file mode 100644 index 00000000..cf210d2f --- /dev/null +++ b/src/pages/api/entities/groups.ts @@ -0,0 +1,29 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import type { NextApiRequest, NextApiResponse } from "next"; +import { withIronSessionApiRoute } from "iron-session/next"; +import { sessionOptions } from "@/lib/session"; +import { getEntities, getEntitiesWithRoles } from "@/utils/entities.be"; +import { Entity, WithEntities, WithEntity, WithLabeledEntities } from "@/interfaces/entity"; +import { v4 } from "uuid"; +import { mapBy } from "@/utils"; +import { getEntitiesUsers, getUser, getUsers } from "@/utils/users.be"; +import { Group, User } from "@/interfaces/user"; +import { getGroups, getGroupsByEntities } from "@/utils/groups.be"; +import { requestUser } from "@/utils/api"; + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "GET") return await get(req, res); +} + +async function get(req: NextApiRequest, res: NextApiResponse) { + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); + + const groups: WithEntity[] = ["admin", "developer"].includes(user.type) + ? await getGroups() + : await getGroupsByEntities(mapBy(user.entities || [], 'id')) + + res.status(200).json(groups); +} diff --git a/src/pages/api/entities/index.ts b/src/pages/api/entities/index.ts new file mode 100644 index 00000000..11b5bd64 --- /dev/null +++ b/src/pages/api/entities/index.ts @@ -0,0 +1,44 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import type {NextApiRequest, NextApiResponse} from "next"; +import {withIronSessionApiRoute} from "iron-session/next"; +import {sessionOptions} from "@/lib/session"; +import {createEntity, getEntities, getEntitiesWithRoles} from "@/utils/entities.be"; +import {Entity} from "@/interfaces/entity"; +import {v4} from "uuid"; +import { requestUser } from "@/utils/api"; + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "GET") return await get(req, res); + if (req.method === "POST") return await post(req, res); +} + +async function get(req: NextApiRequest, res: NextApiResponse) { + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); + + const {showRoles} = req.query as {showRoles: string}; + + const getFn = showRoles ? getEntitiesWithRoles : getEntities; + + if (["admin", "developer"].includes(user.type)) return res.status(200).json(await getFn()); + res.status(200).json(await getFn(user.entities.map((x) => x.id))); +} + +async function post(req: NextApiRequest, res: NextApiResponse) { + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); + + if (!["admin", "developer"].includes(user.type)) { + return res.status(403).json({ok: false}); + } + + const entity: Entity = { + id: v4(), + label: req.body.label, + }; + + await createEntity(entity) + return res.status(200).json(entity); +} diff --git a/src/pages/api/entities/users.ts b/src/pages/api/entities/users.ts new file mode 100644 index 00000000..ed162db4 --- /dev/null +++ b/src/pages/api/entities/users.ts @@ -0,0 +1,63 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import type { NextApiRequest, NextApiResponse } from "next"; +import { withIronSessionApiRoute } from "iron-session/next"; +import { sessionOptions } from "@/lib/session"; +import { getEntities, getEntitiesWithRoles } from "@/utils/entities.be"; +import { Entity, EntityWithRoles, WithEntities, WithLabeledEntities } from "@/interfaces/entity"; +import { v4 } from "uuid"; +import { mapBy } from "@/utils"; +import { getEntitiesUsers, getUser, getUsers } from "@/utils/users.be"; +import { User } from "@/interfaces/user"; +import { findAllowedEntities } from "@/utils/permissions"; +import { RolePermission } from "@/resources/entityPermissions"; + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "GET") return await get(req, res); +} + +const labelUserEntity = (u: User, entities: EntityWithRoles[]) => ({ + ...u, entities: (u.entities || []).map((e) => { + const entity = entities.find((x) => x.id === e.id) + if (!entity) return e + + const role = entity.roles.find((x) => x.id === e.role) + return { id: e.id, label: entity.label, role: e.role, roleLabel: role?.label } + }) +}) + +async function get(req: NextApiRequest, res: NextApiResponse) { + if (!req.session.user) return res.status(401).json({ ok: false }); + + const user = await getUser(req.session.user.id) + if (!user) return res.status(401).json({ ok: false }); + + const { type } = req.query as { type: string } + + const entityIDs = mapBy(user.entities || [], 'id') + const entities = await getEntitiesWithRoles(entityIDs) + + const isAdmin = ["admin", "developer"].includes(user.type) + + const filter = !type ? undefined : { type } + const users = isAdmin + ? await getUsers(filter) + : await getEntitiesUsers(mapBy(entities, 'id') as string[], filter) + + const filteredUsers = users.map((u) => { + if (isAdmin) return labelUserEntity(u, entities) + if (!isAdmin && ["admin", "developer", "agent"].includes(user.type)) return undefined + + const userEntities = mapBy(u.entities || [], 'id') + const sameEntities = entities.filter(e => userEntities.includes(e.id)) + + const permission = `view_${u.type}s` as RolePermission + const allowedEntities = findAllowedEntities(user, sameEntities, permission) + + if (allowedEntities.length === 0) return undefined + return labelUserEntity(u, allowedEntities) + }).filter(x => !!x) as WithLabeledEntities[] + + res.status(200).json(filteredUsers); +} diff --git a/src/pages/api/groups/[id].ts b/src/pages/api/groups/[id].ts index 48df6782..cdc09c1d 100644 --- a/src/pages/api/groups/[id].ts +++ b/src/pages/api/groups/[id].ts @@ -5,6 +5,7 @@ import {withIronSessionApiRoute} from "iron-session/next"; import {sessionOptions} from "@/lib/session"; import {Group} from "@/interfaces/user"; import {updateExpiryDateOnGroup} from "@/utils/groups.be"; +import { requestUser } from "@/utils/api"; const db = client.db(process.env.MONGODB_DB); @@ -19,10 +20,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { } async function get(req: NextApiRequest, res: NextApiResponse) { - if (!req.session.user) { - res.status(401).json({ok: false}); - return; - } + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); const {id} = req.query as {id: string}; @@ -36,10 +35,8 @@ async function get(req: NextApiRequest, res: NextApiResponse) { } async function del(req: NextApiRequest, res: NextApiResponse) { - if (!req.session.user) { - res.status(401).json({ok: false}); - return; - } + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); const {id} = req.query as {id: string}; const group = await db.collection("groups").findOne({id: id}); @@ -49,7 +46,6 @@ async function del(req: NextApiRequest, res: NextApiResponse) { return; } - const user = req.session.user; if (user.type === "admin" || user.type === "developer" || user.id === group.admin) { await db.collection("groups").deleteOne({id: id}); @@ -61,10 +57,8 @@ async function del(req: NextApiRequest, res: NextApiResponse) { } async function patch(req: NextApiRequest, res: NextApiResponse) { - if (!req.session.user) { - res.status(401).json({ok: false}); - return; - } + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); const {id} = req.query as {id: string}; @@ -74,14 +68,20 @@ async function patch(req: NextApiRequest, res: NextApiResponse) { return; } - const user = req.session.user; - if (user.type === "admin" || user.type === "developer" || user.id === group.admin) { - if ("participants" in req.body) { + if ( + user.type === "admin" || + user.type === "developer" || + user.type === "mastercorporate" || + user.type === "corporate" || + user.id === group.admin + ) { + if ("participants" in req.body && req.body.participants.length > 0) { const newParticipants = (req.body.participants as string[]).filter((x) => !group.participants.includes(x)); await Promise.all(newParticipants.map(async (p) => await updateExpiryDateOnGroup(p, group.admin))); } - await db.collection("groups").updateOne({id: req.session.user.id}, {$set: {id, ...req.body}}, {upsert: true}); + console.log(req.body); + await db.collection("groups").updateOne({id}, {$set: {id, ...req.body}}, {upsert: true}); res.status(200).json({ok: true}); return; diff --git a/src/pages/api/groups/index.ts b/src/pages/api/groups/index.ts index 1165c522..86c0ae63 100644 --- a/src/pages/api/groups/index.ts +++ b/src/pages/api/groups/index.ts @@ -39,11 +39,13 @@ async function post(req: NextApiRequest, res: NextApiResponse) { await Promise.all(body.participants.map(async (p) => await updateExpiryDateOnGroup(p, body.admin))); - await db.collection("groups").insertOne({ - id: v4(), - name: body.name, - admin: body.admin, - participants: body.participants, - }) - res.status(200).json({ok: true}); + const id = v4(); + await db.collection("groups").insertOne({ + id, + name: body.name, + admin: body.admin, + participants: body.participants, + entity: body.entity, + }); + res.status(200).json({ok: true, id}); } diff --git a/src/pages/api/hello.ts b/src/pages/api/hello.ts index f8bcc7e5..d301b5bf 100644 --- a/src/pages/api/hello.ts +++ b/src/pages/api/hello.ts @@ -1,13 +1,15 @@ // Next.js API route support: https://nextjs.org/docs/api-routes/introduction -import type { NextApiRequest, NextApiResponse } from 'next' +import client from "@/lib/mongodb"; +import type {NextApiRequest, NextApiResponse} from "next"; + +const db = client.db(process.env.MONGODB_DB); type Data = { - name: string -} + name: string; +}; -export default function handler( - req: NextApiRequest, - res: NextApiResponse -) { - res.status(200).json({ name: 'John Doe' }) +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + await db.collection("users").updateMany({}, {$set: {entities: []}}); + + res.status(200).json({name: "John Doe"}); } diff --git a/src/pages/api/invites/[id].ts b/src/pages/api/invites/[id].ts index eb0ce24f..ac069e95 100644 --- a/src/pages/api/invites/[id].ts +++ b/src/pages/api/invites/[id].ts @@ -4,6 +4,7 @@ import client from "@/lib/mongodb"; import { withIronSessionApiRoute } from "iron-session/next"; import { sessionOptions } from "@/lib/session"; import { Invite } from "@/interfaces/invite"; +import { requestUser } from "@/utils/api"; const db = client.db(process.env.MONGODB_DB); @@ -18,10 +19,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { } async function get(req: NextApiRequest, res: NextApiResponse) { - if (!req.session.user) { - res.status(401).json({ ok: false }); - return; - } + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); const { id } = req.query as { id: string }; @@ -35,10 +34,8 @@ async function get(req: NextApiRequest, res: NextApiResponse) { } async function del(req: NextApiRequest, res: NextApiResponse) { - if (!req.session.user) { - res.status(401).json({ ok: false }); - return; - } + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); const { id } = req.query as { id: string }; @@ -48,7 +45,6 @@ async function del(req: NextApiRequest, res: NextApiResponse) { return; } - const user = req.session.user; if (user.type === "admin" || user.type === "developer") { await db.collection("invites").deleteOne({ id: id }); res.status(200).json({ ok: true }); @@ -59,13 +55,10 @@ async function del(req: NextApiRequest, res: NextApiResponse) { } async function patch(req: NextApiRequest, res: NextApiResponse) { - if (!req.session.user) { - res.status(401).json({ ok: false }); - return; - } + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); const { id } = req.query as { id: string }; - const user = req.session.user; if (user.type === "admin" || user.type === "developer") { await db.collection("invites").updateOne( diff --git a/src/pages/api/make_user.ts b/src/pages/api/make_user.ts index b8128015..dd148874 100644 --- a/src/pages/api/make_user.ts +++ b/src/pages/api/make_user.ts @@ -1,28 +1,28 @@ -import type {NextApiRequest, NextApiResponse} from "next"; -import {app} from "@/firebase"; -import {withIronSessionApiRoute} from "iron-session/next"; -import {sessionOptions} from "@/lib/session"; -import {v4} from "uuid"; -import {CorporateUser, Group, Type, User} from "@/interfaces/user"; -import {createUserWithEmailAndPassword, getAuth} from "firebase/auth"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { app } from "@/firebase"; +import { withIronSessionApiRoute } from "iron-session/next"; +import { sessionOptions } from "@/lib/session"; +import { v4 } from "uuid"; +import { CorporateUser, Group, Type, User } from "@/interfaces/user"; +import { createUserWithEmailAndPassword, getAuth } from "firebase/auth"; import ShortUniqueId from "short-unique-id"; -import {getGroup, getGroups, getUserCorporate, getUserGroups, getUserNamedGroup} from "@/utils/groups.be"; -import {uniq} from "lodash"; -import {getSpecificUsers, getUser} from "@/utils/users.be"; +import { getGroup, getGroups, getUserCorporate, getUserGroups, getUserNamedGroup } from "@/utils/groups.be"; +import { uniq } from "lodash"; +import { getSpecificUsers, getUser } from "@/utils/users.be"; import client from "@/lib/mongodb"; const DEFAULT_DESIRED_LEVELS = { - reading: 9, - listening: 9, - writing: 9, - speaking: 9, + reading: 9, + listening: 9, + writing: 9, + speaking: 9, }; const DEFAULT_LEVELS = { - reading: 0, - listening: 0, - writing: 0, - speaking: 0, + reading: 0, + listening: 0, + writing: 0, + speaking: 0, }; const auth = getAuth(app); @@ -30,198 +30,97 @@ const db = client.db(process.env.MONGODB_DB); export default withIronSessionApiRoute(handler, sessionOptions); -const getUsersOfType = async (admin: string, type: Type) => { - const groups = await getUserGroups(admin); - const participants = groups.flatMap((x) => x.participants); - const users = await getSpecificUsers(participants); - - return users.filter((x) => x?.type === type).map((x) => x?.id); -}; - async function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method === "POST") return post(req, res); + if (req.method === "POST") return post(req, res); - return res.status(404).json({ok: false}); + return res.status(404).json({ ok: false }); } async function post(req: NextApiRequest, res: NextApiResponse) { - const maker = req.session.user; - if (!maker) { - return res.status(401).json({ok: false, reason: "You must be logged in to make user!"}); - } + const maker = req.session.user; + if (!maker) { + return res.status(401).json({ ok: false, reason: "You must be logged in to make user!" }); + } - const corporateCorporate = await getUserCorporate(maker.id); + const { email, passport_id, password, type, groupID, entity, expiryDate, corporate } = req.body as { + email: string; + password?: string; + passport_id: string; + type: string; + entity: string; + groupID?: string; + corporate?: string; + expiryDate: null | Date; + }; - const {email, passport_id, password, type, groupID, expiryDate, corporate} = req.body as { - email: string; - password?: string; - passport_id: string; - type: string; - groupID?: string; - corporate?: string; - expiryDate: null | Date; - }; - // cleaning data - delete req.body.passport_id; - delete req.body.groupID; - delete req.body.expiryDate; - delete req.body.password; - delete req.body.corporate; + // cleaning data + delete req.body.passport_id; + delete req.body.groupID; + delete req.body.expiryDate; + delete req.body.password; + delete req.body.corporate; + delete req.body.entity - await createUserWithEmailAndPassword(auth, email.toLowerCase(), !!password ? password : passport_id) - .then(async (userCredentials) => { - const userId = userCredentials.user.uid; + await createUserWithEmailAndPassword(auth, email.toLowerCase(), !!password ? password : passport_id) + .then(async (userCredentials) => { + const userId = userCredentials.user.uid; - const profilePicture = !corporateCorporate ? "/defaultAvatar.png" : corporateCorporate.profilePicture; + const user = { + ...req.body, + bio: "", + id: userId, + type: type, + focus: "academic", + status: "active", + desiredLevels: DEFAULT_DESIRED_LEVELS, + profilePicture: "/defaultAvatar.png", + levels: DEFAULT_LEVELS, + isFirstLogin: false, + isVerified: true, + registrationDate: new Date(), + entities: [{ id: entity, role: "90ce8f08-08c8-41e4-9848-f1500ddc3930" }], + subscriptionExpirationDate: expiryDate || null, + ...((maker.type === "corporate" || maker.type === "mastercorporate") && type === "corporate" + ? { + corporateInformation: { + companyInformation: { + name: maker.corporateInformation?.companyInformation?.name || "N/A", + userAmount: 0, + }, + }, + } + : {}), + }; - const user = { - ...req.body, - bio: "", - id: userId, - type: type, - focus: "academic", - status: "active", - desiredLevels: DEFAULT_DESIRED_LEVELS, - profilePicture, - levels: DEFAULT_LEVELS, - isFirstLogin: false, - isVerified: true, - registrationDate: new Date(), - subscriptionExpirationDate: expiryDate || null, - ...((maker.type === "corporate" || maker.type === "mastercorporate") && type === "corporate" - ? { - corporateInformation: { - companyInformation: { - name: maker.corporateInformation?.companyInformation?.name || "N/A", - userAmount: 0, - }, - }, - } - : {}), - }; + const uid = new ShortUniqueId(); + const code = uid.randomUUID(6); - const uid = new ShortUniqueId(); - const code = uid.randomUUID(6); + await db.collection("users").insertOne(user); + await db.collection("codes").insertOne({ + code, + creator: maker.id, + expiryDate, + type, + creationDate: new Date(), + userId, + email: email.toLowerCase(), + name: req.body.name, + ...(!!passport_id ? { passport_id } : {}), + }); - await db.collection("users").insertOne(user); - await db.collection("codes").insertOne({ - code, - creator: maker.id, - expiryDate, - type, - creationDate: new Date(), - userId, - email: email.toLowerCase(), - name: req.body.name, - ...(!!passport_id ? {passport_id} : {}), - }); + if (!!groupID) { + const group = await getGroup(groupID); + if (!!group) await db.collection("groups").updateOne({ id: group.id }, { $set: { participants: [...group.participants, userId] } }); + } - if (type === "corporate") { - const students = maker.type === "corporate" ? await getUsersOfType(maker.id, "student") : []; - const teachers = maker.type === "corporate" ? await getUsersOfType(maker.id, "teacher") : []; + console.log(`Returning - ${email}`); + return res.status(200).json({ ok: true }); + }) + .catch((error) => { + if (error.code.includes("email-already-in-use")) return res.status(403).json({ error, message: "E-mail is already in the platform." }); - const defaultTeachersGroup: Group = { - admin: userId, - id: v4(), - name: "Teachers", - participants: teachers, - disableEditing: true, - }; - - const defaultStudentsGroup: Group = { - admin: userId, - id: v4(), - name: "Students", - participants: students, - disableEditing: true, - }; - - await db.collection("groups").insertMany([defaultStudentsGroup, defaultTeachersGroup]); - } - - if (!!corporate) { - const corporateUser = await db.collection("users").findOne({email: corporate.trim().toLowerCase()}); - - if (!!corporateUser) { - await db.collection("codes").updateOne({code}, {$set: {creator: corporateUser.id}}); - const typeGroup = await db - .collection("groups") - .findOne({creator: corporateUser.id, name: type === "student" ? "Students" : "Teachers"}); - - if (!!typeGroup) { - if (!typeGroup.participants.includes(userId)) { - await db.collection("groups").updateOne({id: typeGroup.id}, {$set: {participants: [...typeGroup.participants, userId]}}); - } - } else { - const defaultGroup: Group = { - admin: corporateUser.id, - id: v4(), - name: type === "student" ? "Students" : "Teachers", - participants: [userId], - disableEditing: true, - }; - - await db.collection("groups").insertOne(defaultGroup); - } - } - } - - if (maker.type === "corporate") { - await db.collection("codes").updateOne({code}, {$set: {creator: maker.id}}); - const typeGroup = await getUserNamedGroup(maker.id, type === "student" ? "Students" : "Teachers"); - - if (!!typeGroup) { - if (!typeGroup.participants.includes(userId)) { - await db.collection("groups").updateOne({id: typeGroup.id}, {$set: {participants: [...typeGroup.participants, userId]}}); - } - } else { - const defaultGroup: Group = { - admin: maker.id, - id: v4(), - name: type === "student" ? "Students" : "Teachers", - participants: [userId], - disableEditing: true, - }; - - await db.collection("groups").insertOne(defaultGroup); - } - } - - if (!!corporateCorporate && corporateCorporate.type === "mastercorporate" && type === "corporate") { - const corporateGroup = await getUserNamedGroup(corporateCorporate.id, "Corporate"); - - if (!!corporateGroup) { - if (!corporateGroup.participants.includes(userId)) { - await db - .collection("groups") - .updateOne({id: corporateGroup.id}, {$set: {participants: [...corporateGroup.participants, userId]}}); - } - } else { - const defaultGroup: Group = { - admin: corporateCorporate.id, - id: v4(), - name: "Corporate", - participants: [userId], - disableEditing: true, - }; - - await db.collection("groups").insertOne(defaultGroup); - } - } - - if (!!groupID) { - const group = await getGroup(groupID); - if (!!group) await db.collection("groups").updateOne({id: group.id}, {$set: {participants: [...group.participants, userId]}}); - } - - console.log(`Returning - ${email}`); - return res.status(200).json({ok: true}); - }) - .catch((error) => { - if (error.code.includes("email-already-in-use")) return res.status(403).json({error, message: "E-mail is already in the platform."}); - - console.log(`Failing - ${email}`); - console.log(error); - return res.status(401).json({error}); - }); + console.log(`Failing - ${email}`); + console.log(error); + return res.status(401).json({ error }); + }); } diff --git a/src/pages/api/payments/[id].ts b/src/pages/api/payments/[id].ts index 58167029..d69ba47d 100644 --- a/src/pages/api/payments/[id].ts +++ b/src/pages/api/payments/[id].ts @@ -7,6 +7,7 @@ import {Group} from "@/interfaces/user"; import {Payment} from "@/interfaces/paypal"; import {deleteObject, ref} from "firebase/storage"; import client from "@/lib/mongodb"; +import { requestUser } from "@/utils/api"; const db = client.db(process.env.MONGODB_DB); @@ -38,17 +39,14 @@ async function get(req: NextApiRequest, res: NextApiResponse) { } async function del(req: NextApiRequest, res: NextApiResponse) { - if (!req.session.user) { - res.status(401).json({ok: false}); - return; - } + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); const {id} = req.query as {id: string}; const payment = await db.collection("payments").findOne({id}); if (!payment) return res.status(404).json({ok: false}); - const user = req.session.user; if (user.type === "admin" || user.type === "developer") { if (payment.commissionTransfer) await deleteObject(ref(storage, payment.commissionTransfer)); if (payment.corporateTransfer) await deleteObject(ref(storage, payment.corporateTransfer)); @@ -62,17 +60,14 @@ async function del(req: NextApiRequest, res: NextApiResponse) { } async function patch(req: NextApiRequest, res: NextApiResponse) { - if (!req.session.user) { - res.status(401).json({ok: false}); - return; - } + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); const {id} = req.query as {id: string}; const payment = await db.collection("payments").findOne({id}); if (!payment) return res.status(404).json({ok: false}); - const user = req.session.user; if (user.type === "admin" || user.type === "developer") { await db.collection("payments").updateOne({id: payment.id}, {$set: req.body}); diff --git a/src/pages/api/paypal/approve.ts b/src/pages/api/paypal/approve.ts index f2603125..9908f69a 100644 --- a/src/pages/api/paypal/approve.ts +++ b/src/pages/api/paypal/approve.ts @@ -11,6 +11,7 @@ import { OrderResponseBody } from "@paypal/paypal-js"; import { getAccessToken } from "@/utils/paypal"; import moment from "moment"; import { Group } from "@/interfaces/user"; +import { requestUser } from "@/utils/api"; const db = client.db(process.env.MONGODB_DB); @@ -25,6 +26,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { if (!accessToken) return res.status(401).json({ ok: false, reason: "Authorization failed!" }); + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); + const { id, duration, duration_unit, trackingId } = req.body as { id: string; duration: number; diff --git a/src/pages/api/roles/[id]/index.ts b/src/pages/api/roles/[id]/index.ts new file mode 100644 index 00000000..9e9e4397 --- /dev/null +++ b/src/pages/api/roles/[id]/index.ts @@ -0,0 +1,79 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import type {NextApiRequest, NextApiResponse} from "next"; +import {withIronSessionApiRoute} from "iron-session/next"; +import {sessionOptions} from "@/lib/session"; +import {getEntityWithRoles} from "@/utils/entities.be"; +import client from "@/lib/mongodb"; +import {Entity} from "@/interfaces/entity"; +import { deleteRole, getRole, transferRole } from "@/utils/roles.be"; +import { doesEntityAllow } from "@/utils/permissions"; +import { findBy } from "@/utils"; +import { requestUser } from "@/utils/api"; + +const db = client.db(process.env.MONGODB_DB); + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "GET") return await get(req, res); + if (req.method === "PATCH") return await patch(req, res); + if (req.method === "DELETE") return await del(req, res); +} + +async function get(req: NextApiRequest, res: NextApiResponse) { + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); + + const {id} = req.query as {id: string}; + + const role = await getRole(id) + if (!role) return res.status(404).json({ok: false}) + + res.status(200).json(role); +} + +async function del(req: NextApiRequest, res: NextApiResponse) { + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); + + const { id } = req.query as { id: string }; + + const role = await getRole(id) + if (!role) return res.status(404).json({ok: false}) + + if (role.isDefault) return res.status(403).json({ok: false}) + + const entity = await getEntityWithRoles(role.entityID) + if (!entity) return res.status(404).json({ok: false}) + + if (!doesEntityAllow(user, entity, "delete_entity_role")) return res.status(403).json({ok: false}) + + const defaultRole = findBy(entity.roles, 'isDefault', true)! + + await transferRole(role.id, defaultRole.id) + await deleteRole(role.id) + + return res.status(200).json({ok: true}); +} + +async function patch(req: NextApiRequest, res: NextApiResponse) { + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); + + const { id } = req.query as { id: string }; + const {label, permissions} = req.body as {label?: string, permissions?: string} + + const role = await getRole(id) + if (!role) return res.status(404).json({ok: false}) + + const entity = await getEntityWithRoles(role.entityID) + if (!entity) return res.status(404).json({ok: false}) + + if (!doesEntityAllow(user, entity, "rename_entity_role") && !!label) return res.status(403).json({ok: false}) + if (!doesEntityAllow(user, entity, "edit_role_permissions") && !!permissions) return res.status(403).json({ok: false}) + + if (!!label) await db.collection("roles").updateOne({ id }, { $set: {label} }); + if (!!permissions) await db.collection("roles").updateOne({ id }, { $set: {permissions} }); + + return res.status(200).json({ok: true}); +} diff --git a/src/pages/api/roles/[id]/users.ts b/src/pages/api/roles/[id]/users.ts new file mode 100644 index 00000000..8a1b7efc --- /dev/null +++ b/src/pages/api/roles/[id]/users.ts @@ -0,0 +1,40 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import type {NextApiRequest, NextApiResponse} from "next"; +import {withIronSessionApiRoute} from "iron-session/next"; +import {sessionOptions} from "@/lib/session"; +import {getEntityWithRoles} from "@/utils/entities.be"; +import client from "@/lib/mongodb"; +import {Entity} from "@/interfaces/entity"; +import { assignRoleToUsers, deleteRole, getRole, transferRole } from "@/utils/roles.be"; +import { doesEntityAllow } from "@/utils/permissions"; +import { findBy } from "@/utils"; +import { getUser } from "@/utils/users.be"; + +const db = client.db(process.env.MONGODB_DB); + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "POST") return await post(req, res); +} + +async function post(req: NextApiRequest, res: NextApiResponse) { + if (!req.session.user) return res.status(401).json({ ok: false }) + + const user = await getUser(req.session.user.id); + if (!user) return res.status(401).json({ ok: false }) + + const { id } = req.query as { id: string }; + const {users} = req.body as {users: string[]} + + const role = await getRole(id) + if (!role) return res.status(404).json({ok: false}) + + const entity = await getEntityWithRoles(role.entityID) + if (!entity) return res.status(404).json({ok: false}) + + if (!doesEntityAllow(user, entity, "assign_to_role")) return res.status(403).json({ok: false}) + + const result = await assignRoleToUsers(users, entity.id, role.id) + return res.status(200).json({ok: result.acknowledged}); +} diff --git a/src/pages/api/roles/index.ts b/src/pages/api/roles/index.ts new file mode 100644 index 00000000..5ffe94d2 --- /dev/null +++ b/src/pages/api/roles/index.ts @@ -0,0 +1,50 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import type {NextApiRequest, NextApiResponse} from "next"; +import {withIronSessionApiRoute} from "iron-session/next"; +import {sessionOptions} from "@/lib/session"; +import {getEntities, getEntitiesWithRoles, getEntity, getEntityWithRoles} from "@/utils/entities.be"; +import {Entity} from "@/interfaces/entity"; +import {v4} from "uuid"; +import { createRole, getRoles, getRolesByEntity } from "@/utils/roles.be"; +import { mapBy } from "@/utils"; +import { RolePermission } from "@/resources/entityPermissions"; +import { doesEntityAllow } from "@/utils/permissions"; +import { User } from "@/interfaces/user"; +import { requestUser } from "@/utils/api"; + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "GET") return await get(req, res); + if (req.method === "POST") return await post(req, res); +} + +async function get(req: NextApiRequest, res: NextApiResponse) { + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); + + if (["admin", "developer"].includes(user.type)) return res.status(200).json(await getRoles()); + res.status(200).json(await getRoles(mapBy(user.entities, 'role'))); +} + +async function post(req: NextApiRequest, res: NextApiResponse) { + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); + + const {entityID, label, permissions} = req.body as {entityID: string, label: string, permissions: RolePermission[]} + + const entity = await getEntityWithRoles(entityID) + if (!entity) return res.status(404).json({ok: false}) + + if (!doesEntityAllow(user, entity, "create_entity_role")) return res.status(403).json({ok: false}) + + const role = { + id: v4(), + entityID, + label, + permissions + } + + await createRole(role) + return res.status(200).json(role); +} diff --git a/src/pages/api/stats/index.ts b/src/pages/api/stats/index.ts index 41cb6d77..4b150553 100644 --- a/src/pages/api/stats/index.ts +++ b/src/pages/api/stats/index.ts @@ -6,6 +6,7 @@ import { sessionOptions } from "@/lib/session"; import { Stat } from "@/interfaces/user"; import { Assignment } from "@/interfaces/results"; import { groupBy } from "lodash"; +import { requestUser } from "@/utils/api"; const db = client.db(process.env.MONGODB_DB); @@ -17,20 +18,17 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { } async function get(req: NextApiRequest, res: NextApiResponse) { - if (!req.session.user) { - res.status(401).json({ ok: false }); - return; - } + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); + const snapshot = await db.collection("stats").find({}).toArray(); res.status(200).json(snapshot); } async function post(req: NextApiRequest, res: NextApiResponse) { - if (!req.session.user) { - res.status(401).json({ ok: false }); - return; - } + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); const stats = req.body as Stat[]; stats.forEach(async (stat) => await db.collection("stats").updateOne( @@ -59,7 +57,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) { $set: { results: [ ...assignmentSnapshot ? assignmentSnapshot.results : [], - { user: req.session.user?.id, type: req.session.user?.focus, stats: assignmentStats }, + { user: user.id, type: user.focus, stats: assignmentStats }, ], } } diff --git a/src/pages/api/stats/update.ts b/src/pages/api/stats/update.ts index 11c76017..f3d27fbb 100644 --- a/src/pages/api/stats/update.ts +++ b/src/pages/api/stats/update.ts @@ -10,21 +10,23 @@ import client from "@/lib/mongodb"; import {withIronSessionApiRoute} from "iron-session/next"; import {groupBy} from "lodash"; import {NextApiRequest, NextApiResponse} from "next"; +import { requestUser } from "@/utils/api"; const db = client.db(process.env.MONGODB_DB); export default withIronSessionApiRoute(update, sessionOptions); async function update(req: NextApiRequest, res: NextApiResponse) { - if (req.session.user) { - const docUser = await db.collection("users").findOne({ id: req.session.user.id }); + const user = await requestUser(req, res) + if (user) { + const docUser = await db.collection("users").findOne({ id: user.id }); if (!docUser) { res.status(401).json(undefined); return; } - const stats = await db.collection("stats").find({ user: req.session.user.id }).toArray(); + const stats = await db.collection("stats").find({ user: user.id }).toArray(); const groupedStats = groupBySession(stats); const sessionLevels: {[key in Module]: {correct: number; total: number}}[] = Object.keys(groupedStats).map((key) => { @@ -91,18 +93,18 @@ async function update(req: NextApiRequest, res: NextApiResponse) { const levels = { - reading: calculateBandScore(readingLevel.correct, readingLevel.total, "reading", req.session.user.focus), - listening: calculateBandScore(listeningLevel.correct, listeningLevel.total, "listening", req.session.user.focus), - writing: calculateBandScore(writingLevel.correct, writingLevel.total, "writing", req.session.user.focus), - speaking: calculateBandScore(speakingLevel.correct, speakingLevel.total, "speaking", req.session.user.focus), - level: calculateBandScore(levelLevel.correct, levelLevel.total, "level", req.session.user.focus), + reading: calculateBandScore(readingLevel.correct, readingLevel.total, "reading", user.focus), + listening: calculateBandScore(listeningLevel.correct, listeningLevel.total, "listening", user.focus), + writing: calculateBandScore(writingLevel.correct, writingLevel.total, "writing", user.focus), + speaking: calculateBandScore(speakingLevel.correct, speakingLevel.total, "speaking", user.focus), + level: calculateBandScore(levelLevel.correct, levelLevel.total, "level", user.focus), }; await db.collection("users").updateOne( - { id: req.session.user.id}, + { id: user.id}, { $set: {levels} } ); - + res.status(200).json({ok: true}); } else { res.status(401).json(undefined); diff --git a/src/pages/api/user.ts b/src/pages/api/user.ts index c1e8ceac..7ee2cdca 100644 --- a/src/pages/api/user.ts +++ b/src/pages/api/user.ts @@ -7,7 +7,9 @@ import {withIronSessionApiRoute} from "iron-session/next"; import {NextApiRequest, NextApiResponse} from "next"; import {getPermissions, getPermissionDocs} from "@/utils/permissions.be"; import client from "@/lib/mongodb"; -import {getGroupsForUser, getParticipantGroups} from "@/utils/groups.be"; +import {getGroupsForUser, getParticipantGroups, removeParticipantFromGroup} from "@/utils/groups.be"; +import { mapBy } from "@/utils"; +import { getUser } from "@/utils/users.be"; const auth = getAuth(adminApp); const db = client.db(process.env.MONGODB_DB); @@ -41,20 +43,6 @@ async function del(req: NextApiRequest, res: NextApiResponse) { return; } - if (user.type === "corporate" && (targetUser.type === "student" || targetUser.type === "teacher")) { - const groups = await getGroupsForUser(user.id, targetUser.id); - await Promise.all([ - ...groups - .filter((x) => x.admin === user.id) - .map( - async (x) => - await db.collection("groups").updateOne({id: x.id}, {$set: {participants: x.participants.filter((y: string) => y !== id)}}), - ), - ]); - - return; - } - await auth.deleteUser(id); await db.collection("users").deleteOne({id: targetUser.id}); await db.collection("codes").deleteMany({userId: targetUser.id}); @@ -62,22 +50,18 @@ async function del(req: NextApiRequest, res: NextApiResponse) { await db.collection("stats").deleteMany({user: targetUser.id}); const groups = await getParticipantGroups(targetUser.id); - await Promise.all( - groups.map( - async (x) => await db.collection("groups").updateOne({id: x.id}, {$set: {participants: x.participants.filter((y: string) => y !== id)}}), - ), - ); + await Promise.all( + groups + .map(async (g) => await removeParticipantFromGroup(g.id, targetUser.id)), + ); res.json({ok: true}); } async function get(req: NextApiRequest, res: NextApiResponse) { if (req.session.user) { - const user = await db.collection("users").findOne({id: req.session.user.id}); - if (!user) { - res.status(401).json(undefined); - return; - } + const user = await getUser(req.session.user.id) + if (!user) return res.status(401).json(undefined); await db.collection("users").updateOne({id: user.id}, {$set: {lastLogin: new Date().toISOString()}}); diff --git a/src/pages/assignments/[id].tsx b/src/pages/assignments/[id].tsx new file mode 100644 index 00000000..9cafc5ba --- /dev/null +++ b/src/pages/assignments/[id].tsx @@ -0,0 +1,410 @@ +import Button from "@/components/Low/Button"; +import ProgressBar from "@/components/Low/ProgressBar"; +import Modal from "@/components/Modal"; +import useUsers from "@/hooks/useUsers"; +import {Module} from "@/interfaces"; +import {Assignment} from "@/interfaces/results"; +import {Group, Stat, User} from "@/interfaces/user"; +import useExamStore from "@/stores/examStore"; +import {getExamById} from "@/utils/exams"; +import {sortByModule} from "@/utils/moduleUtils"; +import {calculateBandScore} from "@/utils/score"; +import {convertToUserSolutions} from "@/utils/stats"; +import {getUserName} from "@/utils/users"; +import axios from "axios"; +import clsx from "clsx"; +import {capitalize, uniqBy} from "lodash"; +import moment from "moment"; +import {useRouter} from "next/router"; +import {BsBook, BsChevronLeft, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs"; +import {toast} from "react-toastify"; +import {futureAssignmentFilter} from "@/utils/assignments"; +import {withIronSessionSsr} from "iron-session/next"; +import {checkAccess, doesEntityAllow} from "@/utils/permissions"; +import {mapBy, redirect, serialize} from "@/utils"; +import {getAssignment} from "@/utils/assignments.be"; +import {getEntitiesUsers, getEntityUsers, getUsers} from "@/utils/users.be"; +import {getEntitiesWithRoles, getEntityWithRoles} from "@/utils/entities.be"; +import {getGroups, getGroupsByEntities, getGroupsByEntity} from "@/utils/groups.be"; +import {sessionOptions} from "@/lib/session"; +import {EntityWithRoles} from "@/interfaces/entity"; +import Head from "next/head"; +import Layout from "@/components/High/Layout"; +import Separator from "@/components/Low/Separator"; +import Link from "next/link"; +import { requestUser } from "@/utils/api"; +import { useEntityPermission } from "@/hooks/useEntityPermissions"; + +export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) => { + const user = await requestUser(req, res) + if (!user) return redirect("/login") + + if (!checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"])) + return redirect("/assignments") + + res.setHeader("Cache-Control", "public, s-maxage=10, stale-while-revalidate=59"); + + const {id} = params as {id: string}; + + const assignment = await getAssignment(id); + if (!assignment) return redirect("/assignments") + + const entity = await getEntityWithRoles(assignment.entity || "") + if (!entity) return redirect("/assignments") + + if (!doesEntityAllow(user, entity, 'view_assignments')) return redirect("/assignments") + + const users = await (checkAccess(user, ["developer", "admin"]) ? getUsers() : getEntityUsers(entity.id)); + + return {props: serialize({user, users, entity, assignment})}; +}, sessionOptions); + +interface Props { + user: User; + users: User[]; + assignment: Assignment; + entity: EntityWithRoles +} + +export default function AssignmentView({user, users, entity, assignment}: Props) { + const canDeleteAssignment = useEntityPermission(user, entity, 'delete_assignment') + const canStartAssignment = useEntityPermission(user, entity, 'start_assignment') + + const setExams = useExamStore((state) => state.setExams); + const setShowSolutions = useExamStore((state) => state.setShowSolutions); + const setUserSolutions = useExamStore((state) => state.setUserSolutions); + const setSelectedModules = useExamStore((state) => state.setSelectedModules); + + const router = useRouter(); + + const deleteAssignment = async () => { + if (!canDeleteAssignment) return + if (!confirm("Are you sure you want to delete this assignment?")) return; + + axios + .delete(`/api/assignments/${assignment?.id}`) + .then(() => toast.success(`Successfully deleted the assignment "${assignment?.name}".`)) + .catch(() => toast.error("Something went wrong, please try again later.")) + .finally(() => router.push("/assignments")); + }; + + const startAssignment = () => { + if (!canStartAssignment) return + if (!confirm("Are you sure you want to start this assignment?")) return; + + axios + .post(`/api/assignments/${assignment.id}/start`) + .then(() => { + toast.success(`The assignment "${assignment.name}" has been started successfully!`); + router.replace(router.asPath); + }) + .catch((e) => { + console.log(e); + toast.error("Something went wrong, please try again later!"); + }); + }; + + const formatTimestamp = (timestamp: string) => { + const date = moment(parseInt(timestamp)); + const formatter = "YYYY/MM/DD - HH:mm"; + + return date.format(formatter); + }; + + const calculateAverageModuleScore = (module: Module) => { + if (!assignment) return -1; + + const resultModuleBandScores = assignment.results.map((r) => { + const moduleStats = r.stats.filter((s) => s.module === module); + + const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0); + const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0); + return calculateBandScore(correct, total, module, r.type); + }); + + return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / assignment.results.length; + }; + + const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => { + const scores: { + [key in Module]: {total: number; missing: number; correct: number}; + } = { + reading: { + total: 0, + correct: 0, + missing: 0, + }, + listening: { + total: 0, + correct: 0, + missing: 0, + }, + writing: { + total: 0, + correct: 0, + missing: 0, + }, + speaking: { + total: 0, + correct: 0, + missing: 0, + }, + level: { + total: 0, + correct: 0, + missing: 0, + }, + }; + + stats.forEach((x) => { + scores[x.module!] = { + total: scores[x.module!].total + x.score.total, + correct: scores[x.module!].correct + x.score.correct, + missing: scores[x.module!].missing + x.score.missing, + }; + }); + + return Object.keys(scores) + .filter((x) => scores[x as Module].total > 0) + .map((x) => ({module: x as Module, ...scores[x as Module]})); + }; + + const customContent = (stats: Stat[], user: string, focus: "academic" | "general") => { + const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0); + const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0); + const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0); + + const aggregatedLevels = aggregatedScores.map((x) => ({ + module: x.module, + level: calculateBandScore(x.correct, x.total, x.module, focus), + })); + + const timeSpent = stats[0].timeSpent; + + const selectExam = () => { + const examPromises = uniqBy(stats, "exam").map((stat) => getExamById(stat.module, stat.exam)); + + Promise.all(examPromises).then((exams) => { + if (exams.every((x) => !!x)) { + setUserSolutions(convertToUserSolutions(stats)); + setShowSolutions(true); + setExams(exams.map((x) => x!).sort(sortByModule)); + setSelectedModules( + exams + .map((x) => x!) + .sort(sortByModule) + .map((x) => x!.module), + ); + router.push("/exercises"); + } + }); + }; + + const content = ( + <> +
+
+ {formatTimestamp(stats[0].date.toString())} + {timeSpent && ( + <> + + {Math.floor(timeSpent / 60)} minutes + + )} +
+ = 0.7 && "text-mti-purple", + correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red", + correct / total < 0.3 && "text-mti-rose", + )}> + Level{" "} + {(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)} + +
+ +
+
+ {aggregatedLevels.map(({module, level}) => ( +
+ {module === "reading" && } + {module === "listening" && } + {module === "writing" && } + {module === "speaking" && } + {module === "level" && } + {level.toFixed(1)} +
+ ))} +
+
+ + ); + + return ( +
+ + {(() => { + const student = users.find((u) => u.id === user); + return `${student?.name} (${student?.email})`; + })()} + +
= 0.7 && "hover:border-mti-purple", + correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red", + correct / total < 0.3 && "hover:border-mti-rose", + )} + onClick={selectExam} + role="button"> + {content} +
+
= 0.7 && "hover:border-mti-purple", + correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red", + correct / total < 0.3 && "hover:border-mti-rose", + )} + data-tip="Your screen size is too small to view previous exams." + role="button"> + {content} +
+
+ ); + }; + + const shouldRenderStart = () => { + if (assignment) { + if (futureAssignmentFilter(assignment)) { + return true; + } + } + + return false; + }; + + return ( + <> + + {assignment.name} | EnCoach + + + + + +
+
+ + + +

{assignment.name}

+
+ +
+
+ +
+
+ Start Date: {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")} + End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")} +
+
+ + Assignees:{" "} + {users + .filter((u) => assignment?.assignees.includes(u.id)) + .map((u) => `${u.name} (${u.email})`) + .join(", ")} + + Assigner: {getUserName(users.find((x) => x.id === assignment?.assigner))} +
+
+
+ Average Scores +
+ {assignment && + uniqBy(assignment.exams, (x) => x.module).map(({module}) => ( +
+ {module === "reading" && } + {module === "listening" && } + {module === "writing" && } + {module === "speaking" && } + {module === "level" && } + {calculateAverageModuleScore(module) > -1 && ( + {calculateAverageModuleScore(module).toFixed(1)} + )} +
+ ))} +
+
+
+ + Results ({assignment?.results.length}/{assignment?.assignees.length}) + +
+ {assignment && assignment?.results.length > 0 && ( +
+ {assignment.results.map((r) => customContent(r.stats, r.user, r.type))} +
+ )} + {assignment && assignment?.results.length === 0 && No results yet...} +
+
+ +
+ {assignment && + (assignment.results.length === assignment.assignees.length || moment().isAfter(moment(assignment.endDate))) && ( + + )} + {/** if the assignment is not deemed as active yet, display start */} + {shouldRenderStart() && ( + + )} + +
+
+
+ + ); +} diff --git a/src/pages/assignments/creator/[id].tsx b/src/pages/assignments/creator/[id].tsx new file mode 100644 index 00000000..2bf0b20d --- /dev/null +++ b/src/pages/assignments/creator/[id].tsx @@ -0,0 +1,606 @@ +import Layout from "@/components/High/Layout"; +import Button from "@/components/Low/Button"; +import Checkbox from "@/components/Low/Checkbox"; +import Input from "@/components/Low/Input"; +import ProgressBar from "@/components/Low/ProgressBar"; +import Select from "@/components/Low/Select"; +import Separator from "@/components/Low/Separator"; +import useExams from "@/hooks/useExams"; +import {useListSearch} from "@/hooks/useListSearch"; +import usePagination from "@/hooks/usePagination"; +import {Module} from "@/interfaces"; +import {EntityWithRoles} from "@/interfaces/entity"; +import {InstructorGender, Variant} from "@/interfaces/exam"; +import {Assignment} from "@/interfaces/results"; +import {Group, User} from "@/interfaces/user"; +import {sessionOptions} from "@/lib/session"; +import {mapBy, redirect, serialize} from "@/utils"; +import { requestUser } from "@/utils/api"; +import {getAssignment} from "@/utils/assignments.be"; +import {getEntitiesWithRoles} from "@/utils/entities.be"; +import {getGroups, getGroupsByEntities} from "@/utils/groups.be"; +import {checkAccess, doesEntityAllow, findAllowedEntities} from "@/utils/permissions"; +import {calculateAverageLevel} from "@/utils/score"; +import {getEntitiesUsers, getUsers} from "@/utils/users.be"; +import axios from "axios"; +import clsx from "clsx"; +import {withIronSessionSsr} from "iron-session/next"; +import {capitalize} from "lodash"; +import moment from "moment"; +import Head from "next/head"; +import Link from "next/link"; +import {useRouter} from "next/router"; +import {generate} from "random-words"; +import {useEffect, useMemo, useState} from "react"; +import ReactDatePicker from "react-datepicker"; +import {BsBook, BsCheckCircle, BsChevronLeft, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs"; +import {toast} from "react-toastify"; + +export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) => { + const user = await requestUser(req, res) + if (!user) return redirect("/login") + + res.setHeader("Cache-Control", "public, s-maxage=10, stale-while-revalidate=59"); + + const {id} = params as {id: string}; + const entityIDS = mapBy(user.entities, "id") || []; + + const assignment = await getAssignment(id); + if (!assignment) return redirect("/assignments") + + const entities = await (checkAccess(user, ["developer", "admin"]) ? getEntitiesWithRoles() : getEntitiesWithRoles(entityIDS)); + const entity = entities.find((e) => assignment.entity === assignment.entity) + + if (!entity) return redirect("/assignments") + if (!doesEntityAllow(user, entity, 'edit_assignment')) return redirect("/assignments") + + const allowedEntities = findAllowedEntities(user, entities, 'edit_assignment') + + const users = await (checkAccess(user, ["developer", "admin"]) ? getUsers() : getEntitiesUsers(mapBy(allowedEntities, 'id'))); + const groups = await (checkAccess(user, ["developer", "admin"]) ? getGroups() : getGroupsByEntities(mapBy(allowedEntities, 'id'))); + + return {props: serialize({user, users, entities: allowedEntities, assignment, groups})}; +}, sessionOptions); + +interface Props { + assignment: Assignment; + groups: Group[]; + user: User; + users: User[]; + entities: EntityWithRoles[]; +} + +const SIZE = 9; + +export default function AssignmentsPage({assignment, user, users, entities, groups}: Props) { + const [selectedModules, setSelectedModules] = useState(assignment.exams.map((e) => e.module)); + const [assignees, setAssignees] = useState(assignment.assignees); + const [teachers, setTeachers] = useState(assignment.teachers || []); + const [entity, setEntity] = useState(entities[0]?.id); + const [name, setName] = useState(assignment.name); + const [isLoading, setIsLoading] = useState(false); + + const [startDate, setStartDate] = useState(moment(assignment.startDate).toDate()); + const [endDate, setEndDate] = useState(moment(assignment.endDate).toDate()); + + const [variant, setVariant] = useState("full"); + const [instructorGender, setInstructorGender] = useState(assignment?.instructorGender || "varied"); + + const [generateMultiple, setGenerateMultiple] = useState(false); + const [released, setReleased] = useState(assignment.released || false); + + const [autoStart, setAutostart] = useState(assignment.autoStart || false); + const [autoStartDate, setAutoStartDate] = useState(moment(assignment.autoStartDate).toDate()); + + const [useRandomExams, setUseRandomExams] = useState(true); + const [examIDs, setExamIDs] = useState<{id: string; module: Module}[]>([]); + + const {exams} = useExams(); + const router = useRouter(); + + const classrooms = useMemo(() => groups.filter((e) => e.entity === entity), [entity, groups]); + + const userStudents = useMemo(() => users.filter((x) => x.type === "student"), [users]); + const userTeachers = useMemo(() => users.filter((x) => x.type === "teacher"), [users]); + + const {rows: filteredStudentsRows, renderSearch: renderStudentSearch} = useListSearch([["name"], ["email"]], userStudents); + const {rows: filteredTeachersRows, renderSearch: renderTeacherSearch} = useListSearch([["name"], ["email"]], userTeachers); + + const {items: studentRows, renderMinimal: renderStudentPagination} = usePagination(filteredStudentsRows, SIZE); + const {items: teacherRows, renderMinimal: renderTeacherPagination} = usePagination(filteredTeachersRows, SIZE); + + useEffect(() => { + setExamIDs((prev) => prev.filter((x) => selectedModules.includes(x.module))); + }, [selectedModules]); + + useEffect(() => { + setAssignees([]); + setTeachers([]); + }, [entity]); + + const toggleModule = (module: Module) => { + const modules = selectedModules.filter((x) => x !== module); + setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module])); + }; + + const toggleAssignee = (user: User) => { + setAssignees((prev) => (prev.includes(user.id) ? prev.filter((a) => a !== user.id) : [...prev, user.id])); + }; + + const toggleTeacher = (user: User) => { + setTeachers((prev) => (prev.includes(user.id) ? prev.filter((a) => a !== user.id) : [...prev, user.id])); + }; + + const createAssignment = () => { + setIsLoading(true); + + (assignment ? axios.patch : axios.post)(`/api/assignments${assignment.id}`, { + assignees, + name, + startDate, + examIDs: !useRandomExams ? examIDs : undefined, + endDate, + selectedModules, + generateMultiple, + entity, + teachers, + variant, + instructorGender, + released, + autoStart, + autoStartDate, + }) + .then(() => { + toast.success(`The assignment "${name}" has been updated successfully!`); + router.push(`/assignments/${assignment.id}`); + }) + .catch((e) => { + console.log(e); + toast.error("Something went wrong, please try again later!"); + }) + .finally(() => setIsLoading(false)); + }; + + const deleteAssignment = () => { + if (!confirm(`Are you sure you want to delete the "${assignment.name}" assignment?`)) return; + console.log("GOT HERE"); + setIsLoading(true); + + axios + .delete(`/api/assignments/${assignment.id}`) + .then(() => { + toast.success(`The assignment "${name}" has been deleted successfully!`); + router.push("/assignments"); + }) + .catch((e) => { + console.log(e); + toast.error("Something went wrong, please try again later!"); + }) + .finally(() => setIsLoading(false)); + }; + + const startAssignment = () => { + if (assignment) { + setIsLoading(true); + + axios + .post(`/api/assignments/${assignment.id}/start`) + .then(() => { + toast.success(`The assignment "${name}" has been started successfully!`); + router.push(`/assignments/${assignment.id}`); + }) + .catch((e) => { + console.log(e); + toast.error("Something went wrong, please try again later!"); + }) + .finally(() => setIsLoading(false)); + } + }; + + return ( + <> + + Edit {assignment.name} | EnCoach + + + + + +
+
+ + + +

Edit {assignment.name}

+
+ +
+
+
+
toggleModule("reading") : undefined} + className={clsx( + "w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer", + selectedModules.includes("reading") ? "border-mti-purple-light" : "border-mti-gray-platinum", + )}> +
+ +
+ Reading + {!selectedModules.includes("reading") && !selectedModules.includes("level") && ( +
+ )} + {selectedModules.includes("level") && } + {selectedModules.includes("reading") && } +
+
toggleModule("listening") : undefined} + className={clsx( + "w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer", + selectedModules.includes("listening") ? "border-mti-purple-light" : "border-mti-gray-platinum", + )}> +
+ +
+ Listening + {!selectedModules.includes("listening") && !selectedModules.includes("level") && ( +
+ )} + {selectedModules.includes("level") && } + {selectedModules.includes("listening") && } +
+
toggleModule("level") + : undefined + } + className={clsx( + "w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer", + selectedModules.includes("level") ? "border-mti-purple-light" : "border-mti-gray-platinum", + )}> +
+ +
+ Level + {!selectedModules.includes("level") && selectedModules.length === 0 && ( +
+ )} + {!selectedModules.includes("level") && selectedModules.length > 0 && } + {selectedModules.includes("level") && } +
+
toggleModule("writing") : undefined} + className={clsx( + "w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer", + selectedModules.includes("writing") ? "border-mti-purple-light" : "border-mti-gray-platinum", + )}> +
+ +
+ Writing + {!selectedModules.includes("writing") && !selectedModules.includes("level") && ( +
+ )} + {selectedModules.includes("level") && } + {selectedModules.includes("writing") && } +
+
toggleModule("speaking") : undefined} + className={clsx( + "w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer", + selectedModules.includes("speaking") ? "border-mti-purple-light" : "border-mti-gray-platinum", + )}> +
+ +
+ Speaking + {!selectedModules.includes("speaking") && !selectedModules.includes("level") && ( +
+ )} + {selectedModules.includes("level") && } + {selectedModules.includes("speaking") && } +
+
+ +
+ setName(e)} defaultValue={name} label="Assignment Name" required /> + (value ? setInstructorGender(value.value as InstructorGender) : null)} + disabled={!selectedModules.includes("speaking") || !!assignment} + options={[ + {value: "male", label: "Male"}, + {value: "female", label: "Female"}, + {value: "varied", label: "Varied"}, + ]} + /> +
+ )} + + {selectedModules.length > 0 && ( +
+ + Random Exams + + {!useRandomExams && ( +
+ {selectedModules.map((module) => ( +
+ + setName(e)} defaultValue={name} label="Assignment Name" required /> + (value ? setInstructorGender(value.value as InstructorGender) : null)} + disabled={!selectedModules.includes("speaking")} + options={[ + {value: "male", label: "Male"}, + {value: "female", label: "Female"}, + {value: "varied", label: "Varied"}, + ]} + /> +
+ )} + + {selectedModules.length > 0 && ( +
+ + Random Exams + + {!useRandomExams && ( +
+ {selectedModules.map((module) => ( +
+ + +
+
+ Entity: + ({ - value: u, - label: USER_TYPE_LABELS[u], - }))} - value={{ - value: selectedScreen, - label: USER_TYPE_LABELS[selectedScreen], - }} - onChange={(value) => (value ? setSelectedScreen(value.value) : setSelectedScreen("admin"))} - /> - - {selectedScreen === "student" && } - {selectedScreen === "teacher" && } - {selectedScreen === "corporate" && ( - - )} - {selectedScreen === "mastercorporate" && } - {selectedScreen === "agent" && } - {selectedScreen === "admin" && } - - )} - - )} - - ); +export default function Dashboard() { + return
; } diff --git a/src/pages/login.tsx b/src/pages/login.tsx index fe7d37ad..7510d2fc 100644 --- a/src/pages/login.tsx +++ b/src/pages/login.tsx @@ -15,30 +15,17 @@ import {useRouter} from "next/router"; import EmailVerification from "./(auth)/EmailVerification"; import {withIronSessionSsr} from "iron-session/next"; import {sessionOptions} from "@/lib/session"; +import { requestUser } from "@/utils/api"; +import { redirect } from "@/utils"; const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/g); -export const getServerSideProps = withIronSessionSsr(({req, res}) => { - const user = req.session.user; - - const envVariables: {[key: string]: string} = {}; - Object.keys(process.env) - .filter((x) => x.startsWith("NEXT_PUBLIC")) - .forEach((x: string) => { - envVariables[x] = process.env[x]!; - }); - - if (user) { - return { - redirect: { - destination: "/", - permanent: false, - }, - }; - } +export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { + const user = await requestUser(req, res) + if (user) return redirect("/") return { - props: {user: null, envVariables}, + props: {user: null}, }; }, sessionOptions); diff --git a/src/pages/payment-record.tsx b/src/pages/payment-record.tsx index 8d99512c..20b5c081 100644 --- a/src/pages/payment-record.tsx +++ b/src/pages/payment-record.tsx @@ -31,30 +31,19 @@ import {CSVLink} from "react-csv"; import {Tab} from "@headlessui/react"; import {useListSearch} from "@/hooks/useListSearch"; import {checkAccess, getTypesOfUser} from "@/utils/permissions"; +import { requestUser } from "@/utils/api"; +import { redirect } from "@/utils"; -export const getServerSideProps = withIronSessionSsr(({req, res}) => { - const user = req.session.user; - - if (!user) { - return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } +export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { + const user = await requestUser(req, res) + if (!user) return redirect("/login") if (shouldRedirectHome(user) || checkAccess(user, getTypesOfUser(["admin", "developer", "agent", "corporate", "mastercorporate"]))) { - return { - redirect: { - destination: "/", - permanent: false, - }, - }; + return redirect("/") } return { - props: {user: req.session.user}, + props: {user}, }; }, sessionOptions); diff --git a/src/pages/payment.tsx b/src/pages/payment.tsx index 78f3b573..4b80f906 100644 --- a/src/pages/payment.tsx +++ b/src/pages/payment.tsx @@ -5,32 +5,19 @@ import {sessionOptions} from "@/lib/session"; import useUser from "@/hooks/useUser"; import PaymentDue from "./(status)/PaymentDue"; import {useRouter} from "next/router"; +import { requestUser } from "@/utils/api"; +import { redirect } from "@/utils"; -export const getServerSideProps = withIronSessionSsr(({req, res}) => { - const user = req.session.user; - - const envVariables: {[key: string]: string} = {}; - Object.keys(process.env) - .filter((x) => x.startsWith("NEXT_PUBLIC")) - .forEach((x: string) => { - envVariables[x] = process.env[x]!; - }); - - if (!user) { - return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } +export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { + const user = await requestUser(req, res) + if (!user) return redirect("/login") return { - props: {user: req.session.user, envVariables}, + props: {user}, }; }, sessionOptions); -export default function Home({envVariables}: {envVariables: {[key: string]: string}}) { +export default function Home() { const {user} = useUser({redirectTo: "/login"}); const router = useRouter(); diff --git a/src/pages/permissions/[id].tsx b/src/pages/permissions/[id].tsx index 6e66f109..e7ac515c 100644 --- a/src/pages/permissions/[id].tsx +++ b/src/pages/permissions/[id].tsx @@ -16,6 +16,8 @@ import axios from "axios"; import {toast, ToastContainer} from "react-toastify"; import {Type as UserType} from "@/interfaces/user"; import {getGroups} from "@/utils/groups.be"; +import { requestUser } from "@/utils/api"; +import { redirect } from "@/utils"; interface BasicUser { id: string; name: string; @@ -28,36 +30,13 @@ interface PermissionWithBasicUsers { users: BasicUser[]; } -export const getServerSideProps = withIronSessionSsr(async (context) => { - const {req, params} = context; - const user = req.session.user; +export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) => { + const user = await requestUser(req, res) + if (!user) return redirect("/login") - if (!user) { - return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } + if (shouldRedirectHome(user)) return redirect("/") - if (shouldRedirectHome(user)) { - return { - redirect: { - destination: "/", - permanent: false, - }, - }; - } - - if (!params?.id) { - return { - redirect: { - destination: "/permissions", - permanent: false, - }, - }; - } + if (!params?.id) return redirect("/permissions") // Fetch data from external API const permission: Permission = await getPermissionDoc(params.id as string); @@ -100,7 +79,7 @@ export const getServerSideProps = withIronSessionSsr(async (context) => { id: params.id, users: usersData, }, - user: req.session.user, + user, users: filteredUsers, }, }; diff --git a/src/pages/permissions/index.tsx b/src/pages/permissions/index.tsx index 6e116600..4ca504b8 100644 --- a/src/pages/permissions/index.tsx +++ b/src/pages/permissions/index.tsx @@ -8,27 +8,14 @@ import {getPermissionDocs} from "@/utils/permissions.be"; import {User} from "@/interfaces/user"; import Layout from "@/components/High/Layout"; import PermissionList from "@/components/PermissionList"; +import { requestUser } from "@/utils/api"; +import { redirect } from "@/utils"; -export const getServerSideProps = withIronSessionSsr(async ({req}) => { - const user = req.session.user; +export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { + const user = await requestUser(req, res) + if (!user) return redirect("/login") - if (!user) { - return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } - - if (shouldRedirectHome(user)) { - return { - redirect: { - destination: "/", - permanent: false, - }, - }; - } + if (shouldRedirectHome(user)) return redirect("/") // Fetch data from external API const permissions: Permission[] = await getPermissionDocs(); @@ -51,7 +38,7 @@ export const getServerSideProps = withIronSessionSsr(async ({req}) => { const {users, ...rest} = p; return rest; }), - user: req.session.user, + user, }, }; }, sessionOptions); diff --git a/src/pages/profile.tsx b/src/pages/profile.tsx index d864670b..40e98fe9 100644 --- a/src/pages/profile.tsx +++ b/src/pages/profile.tsx @@ -29,7 +29,7 @@ import {BsCamera, BsQuestionCircleFill} from "react-icons/bs"; import {USER_TYPE_LABELS} from "@/resources/user"; import useGroups from "@/hooks/useGroups"; import useUsers from "@/hooks/useUsers"; -import {convertBase64} from "@/utils"; +import {convertBase64, redirect} from "@/utils"; import {Divider} from "primereact/divider"; import GenderInput from "@/components/High/GenderInput"; import EmploymentStatusInput from "@/components/High/EmploymentStatusInput"; @@ -46,27 +46,13 @@ import {checkAccess, getTypesOfUser} from "@/utils/permissions"; import {getParticipantGroups, getUserCorporate} from "@/utils/groups.be"; import {InferGetServerSidePropsType} from "next"; import {getUsers} from "@/utils/users.be"; +import { requestUser } from "@/utils/api"; export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { - const user = req.session.user; + const user = await requestUser(req, res) + if (!user) return redirect("/login") - if (!user) { - return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } - - if (shouldRedirectHome(user)) { - return { - redirect: { - destination: "/", - permanent: false, - }, - }; - } + if (shouldRedirectHome(user)) return redirect("/") return { props: { diff --git a/src/pages/record.tsx b/src/pages/record.tsx index c4d520d0..68c3edb4 100644 --- a/src/pages/record.tsx +++ b/src/pages/record.tsx @@ -3,7 +3,7 @@ import Head from "next/head"; import {withIronSessionSsr} from "iron-session/next"; import {sessionOptions} from "@/lib/session"; import {Stat, User} from "@/interfaces/user"; -import {useEffect, useRef, useState} from "react"; +import {useEffect, useMemo, useRef, useState} from "react"; import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser"; import {groupByDate} from "@/utils/stats"; import moment from "moment"; @@ -22,36 +22,36 @@ import RecordFilter from "@/components/Medium/RecordFilter"; import {useRouter} from "next/router"; import useTrainingContentStore from "@/stores/trainingContentStore"; import {Assignment} from "@/interfaces/results"; -import {getUsers} from "@/utils/users.be"; -import {getAssignments, getAssignmentsByAssigner} from "@/utils/assignments.be"; +import {getEntitiesUsers, getUsers} from "@/utils/users.be"; +import {getAssignments, getAssignmentsByAssigner, getEntitiesAssignments} from "@/utils/assignments.be"; import useGradingSystem from "@/hooks/useGrading"; +import { mapBy, redirect, serialize } from "@/utils"; +import { getEntitiesWithRoles } from "@/utils/entities.be"; +import { checkAccess } from "@/utils/permissions"; +import { getGroups, getGroupsByEntities } from "@/utils/groups.be"; +import { getGradingSystemByEntity } from "@/utils/grading.be"; +import { Grading } from "@/interfaces"; +import { EntityWithRoles } from "@/interfaces/entity"; +import { useListSearch } from "@/hooks/useListSearch"; +import CardList from "@/components/High/CardList"; +import { requestUser } from "@/utils/api"; export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { - const user = req.session.user; + const user = await requestUser(req, res) + if (!user) return redirect("/login") - if (!user) { - return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } + if (shouldRedirectHome(user)) return redirect("/") - if (shouldRedirectHome(user)) { - return { - redirect: { - destination: "/", - permanent: false, - }, - }; - } + const entityIDs = mapBy(user.entities, 'id') - const users = await getUsers(); - const assignments = await getAssignments(); + const entities = await getEntitiesWithRoles(checkAccess(user, ["admin", "developer"]) ? undefined : entityIDs) + const users = await (checkAccess(user, ["admin", "developer"]) ? getUsers() : getEntitiesUsers(mapBy(entities, 'id'))) + const groups = await (checkAccess(user, ["admin", "developer"]) ? getGroups() : getGroupsByEntities(mapBy(entities, 'id'))) + const assignments = await (checkAccess(user, ["admin", "developer"]) ? getAssignments() : getEntitiesAssignments(mapBy(entities, 'id'))) + const gradingSystems = await Promise.all(entityIDs.map(getGradingSystemByEntity)) return { - props: {user, users, assignments}, + props: serialize({user, users, assignments, entities, gradingSystems}), }; }, sessionOptions); @@ -61,9 +61,13 @@ interface Props { user: User; users: User[]; assignments: Assignment[]; + gradingSystems: Grading[] + entities: EntityWithRoles[] } -export default function History({user, users, assignments}: Props) { +const MAX_TRAINING_EXAMS = 10; + +export default function History({user, users, assignments, entities, gradingSystems}: Props) { const router = useRouter(); const [statsUserId, setStatsUserId, training, setTraining] = useRecordStore((state) => [ state.selectedUser, @@ -72,8 +76,6 @@ export default function History({user, users, assignments}: Props) { state.setTraining, ]); - // const [statsUserId, setStatsUserId] = useState(user.id); - const [groupedStats, setGroupedStats] = useState<{[key: string]: Stat[]}>(); const [filter, setFilter] = useState(); const {data: stats, isLoading: isStatsLoading} = useFilterRecordsByUser(statsUserId || user?.id); @@ -87,30 +89,32 @@ export default function History({user, users, assignments}: Props) { const setTimeSpent = useExamStore((state) => state.setTimeSpent); const renderPdfIcon = usePDFDownload("stats"); + const [selectedTrainingExams, setSelectedTrainingExams] = useState([]); + const setTrainingStats = useTrainingContentStore((state) => state.setStats); + + const groupedStats = useMemo(() => groupByDate( + stats.filter((x) => { + if ( + (x.module === "writing" || x.module === "speaking") && + !x.isDisabled && + !x.solutions.every((y) => Object.keys(y).includes("evaluation")) + ) + return false; + return true; + }), + ), [stats]) + useEffect(() => setStatsUserId(user.id), [setStatsUserId, user]); useEffect(() => { - if (stats && !isStatsLoading) { - setGroupedStats( - groupByDate( - stats.filter((x) => { - if ( - (x.module === "writing" || x.module === "speaking") && - !x.isDisabled && - !x.solutions.every((y) => Object.keys(y).includes("evaluation")) - ) - return false; - return true; - }), - ), - ); - } - }, [stats, isStatsLoading]); - - // useEffect(() => { - // // just set this initially - // if (!statsUserId) setStatsUserId(user.id); - // }, []); + const handleRouteChange = (url: string) => { + setTraining(false); + }; + router.events.on("routeChangeStart", handleRouteChange); + return () => { + router.events.off("routeChangeStart", handleRouteChange); + }; + }, [router.events, setTraining]); const filterStatsByDate = (stats: {[key: string]: Stat[]}) => { if (filter && filter !== "assignments") { @@ -139,39 +143,29 @@ export default function History({user, users, assignments}: Props) { return stats; }; - const MAX_TRAINING_EXAMS = 10; - const [selectedTrainingExams, setSelectedTrainingExams] = useState([]); - const setTrainingStats = useTrainingContentStore((state) => state.setStats); +const handleTrainingContentSubmission = () => { + if (groupedStats) { + const groupedStatsByDate = filterStatsByDate(groupedStats); + const allStats = Object.keys(groupedStatsByDate); + const selectedStats = selectedTrainingExams.reduce>((accumulator, moduleAndTimestamp) => { + const timestamp = moduleAndTimestamp.split("-")[1]; + if (allStats.includes(timestamp) && !accumulator.hasOwnProperty(timestamp)) { + accumulator[timestamp] = groupedStatsByDate[timestamp]; + } + return accumulator; + }, {}); + setTrainingStats(Object.values(selectedStats).flat()); + router.push("/training"); + } +}; - const handleTrainingContentSubmission = () => { - if (groupedStats) { - const groupedStatsByDate = filterStatsByDate(groupedStats); - const allStats = Object.keys(groupedStatsByDate); - const selectedStats = selectedTrainingExams.reduce>((accumulator, moduleAndTimestamp) => { - const timestamp = moduleAndTimestamp.split("-")[1]; - if (allStats.includes(timestamp) && !accumulator.hasOwnProperty(timestamp)) { - accumulator[timestamp] = groupedStatsByDate[timestamp]; - } - return accumulator; - }, {}); - setTrainingStats(Object.values(selectedStats).flat()); - router.push("/training"); - } - }; - - useEffect(() => { - const handleRouteChange = (url: string) => { - setTraining(false); - }; - router.events.on("routeChangeStart", handleRouteChange); - return () => { - router.events.off("routeChangeStart", handleRouteChange); - }; - }, [router.events, setTraining]); + const filteredStats = useMemo(() => + Object.keys(filterStatsByDate(groupedStats)) + .sort((a, b) => parseInt(b) - parseInt(a)), + // eslint-disable-next-line react-hooks/exhaustive-deps + [groupedStats, filter]) const customContent = (timestamp: string) => { - if (!groupedStats) return <>; - const dateStats = groupedStats[timestamp]; return ( @@ -212,7 +206,7 @@ export default function History({user, users, assignments}: Props) { {user && ( - + {training && (
@@ -231,14 +225,12 @@ export default function History({user, users, assignments}: Props) {
)} - {groupedStats && Object.keys(groupedStats).length > 0 && !isStatsLoading && ( -
- {Object.keys(filterStatsByDate(groupedStats)) - .sort((a, b) => parseInt(b) - parseInt(a)) - .map(customContent)} -
+ + + {filteredStats.length > 0 && !isStatsLoading && ( + )} - {groupedStats && Object.keys(groupedStats).length === 0 && !isStatsLoading && ( + {filteredStats.length === 0 && !isStatsLoading && ( No record to display... )} {isStatsLoading && ( diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index e3dce976..9bdb0307 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -1,156 +1,149 @@ /* eslint-disable @next/next/no-img-element */ import Head from "next/head"; -import {withIronSessionSsr} from "iron-session/next"; -import {sessionOptions} from "@/lib/session"; -import {ToastContainer} from "react-toastify"; +import { withIronSessionSsr } from "iron-session/next"; +import { sessionOptions } from "@/lib/session"; +import { ToastContainer } from "react-toastify"; import Layout from "@/components/High/Layout"; import CodeGenerator from "./(admin)/CodeGenerator"; import ExamLoader from "./(admin)/ExamLoader"; -import {Tab} from "@headlessui/react"; +import { Tab } from "@headlessui/react"; import clsx from "clsx"; import Lists from "./(admin)/Lists"; import BatchCodeGenerator from "./(admin)/BatchCodeGenerator"; -import {shouldRedirectHome} from "@/utils/navigation.disabled"; +import { shouldRedirectHome } from "@/utils/navigation.disabled"; import ExamGenerator from "./(admin)/ExamGenerator"; import BatchCreateUser from "./(admin)/BatchCreateUser"; -import {checkAccess, getTypesOfUser} from "@/utils/permissions"; +import { checkAccess, getTypesOfUser } from "@/utils/permissions"; import usePermissions from "@/hooks/usePermissions"; -import {useState} from "react"; +import { useState } from "react"; import Modal from "@/components/Modal"; import IconCard from "@/dashboards/IconCard"; -import {BsCode, BsCodeSquare, BsGearFill, BsPeopleFill, BsPersonFill} from "react-icons/bs"; +import { BsCode, BsCodeSquare, BsGearFill, BsPeopleFill, BsPersonFill } from "react-icons/bs"; import UserCreator from "./(admin)/UserCreator"; import CorporateGradingSystem from "./(admin)/CorporateGradingSystem"; import useGradingSystem from "@/hooks/useGrading"; -import {CEFR_STEPS} from "@/resources/grading"; -import {User} from "@/interfaces/user"; -import {getUserPermissions} from "@/utils/permissions.be"; -import {Permission, PermissionType} from "@/interfaces/permissions"; -import {getUsers} from "@/utils/users.be"; +import { CEFR_STEPS } from "@/resources/grading"; +import { User } from "@/interfaces/user"; +import { getUserPermissions } from "@/utils/permissions.be"; +import { Permission, PermissionType } from "@/interfaces/permissions"; +import { getUsers } from "@/utils/users.be"; import useUsers from "@/hooks/useUsers"; +import { getEntitiesWithRoles, getEntityWithRoles } from "@/utils/entities.be"; +import { mapBy, serialize, redirect } from "@/utils"; +import { EntityWithRoles } from "@/interfaces/entity"; +import { requestUser } from "@/utils/api"; -export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { - const user = req.session.user; - if (!user) { - return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } +export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { + const user = await requestUser(req, res) + if (!user) return redirect("/login") - if (shouldRedirectHome(user) || !checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"])) { - return { - redirect: { - destination: "/", - permanent: false, - }, - }; - } + if (shouldRedirectHome(user) || !checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"])) + return redirect("/") - const permissions = await getUserPermissions(user.id); + const permissions = await getUserPermissions(user.id); + const entities = await getEntitiesWithRoles(mapBy(user.entities, 'id')) || [] - return { - props: {user, permissions}, - }; + return { + props: serialize({ user, permissions, entities }), + }; }, sessionOptions); interface Props { - user: User; - permissions: PermissionType[]; + user: User; + permissions: PermissionType[]; + entities: EntityWithRoles[] } -export default function Admin({user, permissions}: Props) { - const {gradingSystem, mutate} = useGradingSystem(); - const {users} = useUsers(); +export default function Admin({ user, entities, permissions }: Props) { + const { gradingSystem, mutate } = useGradingSystem(); + const { users } = useUsers(); - const [modalOpen, setModalOpen] = useState(); + const [modalOpen, setModalOpen] = useState(); - return ( - <> - - Settings Panel | EnCoach - - - - - - - setModalOpen(undefined)}> - setModalOpen(undefined)} /> - - setModalOpen(undefined)}> - setModalOpen(undefined)} /> - - setModalOpen(undefined)}> - setModalOpen(undefined)} /> - - setModalOpen(undefined)}> - setModalOpen(undefined)} /> - - setModalOpen(undefined)}> - { - mutate({user: user.id, steps}); - setModalOpen(undefined); - }} - /> - + return ( + <> + + Settings Panel | EnCoach + + + + + + + setModalOpen(undefined)}> + setModalOpen(undefined)} /> + + setModalOpen(undefined)}> + setModalOpen(undefined)} /> + + setModalOpen(undefined)}> + setModalOpen(undefined)} /> + + setModalOpen(undefined)}> + setModalOpen(undefined)} /> + + setModalOpen(undefined)}> + { + mutate({ user: user.id, steps }); + setModalOpen(undefined); + }} + /> + -
- - {checkAccess(user, getTypesOfUser(["teacher"]), permissions, "viewCodes") && ( -
- setModalOpen("createCode")} - /> - setModalOpen("batchCreateCode")} - /> - setModalOpen("createUser")} - /> - setModalOpen("batchCreateUser")} - /> - {checkAccess(user, ["admin", "corporate", "developer", "mastercorporate"]) && ( - setModalOpen("gradingSystem")} - /> - )} -
- )} -
-
- -
-
- - ); +
+ + {checkAccess(user, getTypesOfUser(["teacher"]), permissions, "viewCodes") && ( +
+ setModalOpen("createCode")} + /> + setModalOpen("batchCreateCode")} + /> + setModalOpen("createUser")} + /> + setModalOpen("batchCreateUser")} + /> + {checkAccess(user, ["admin", "corporate", "developer", "mastercorporate"]) && ( + setModalOpen("gradingSystem")} + /> + )} +
+ )} +
+
+ +
+
+ + ); } diff --git a/src/pages/stats.tsx b/src/pages/stats.tsx index 4c45090f..36e5a944 100644 --- a/src/pages/stats.tsx +++ b/src/pages/stats.tsx @@ -17,48 +17,52 @@ import {calculateAverageLevel, calculateBandScore} from "@/utils/score"; import {countExamModules, countFullExams, MODULE_ARRAY, sortByModule} from "@/utils/moduleUtils"; import {Chart} from "react-chartjs-2"; import useUsers from "@/hooks/useUsers"; -import Select from "react-select"; import useGroups from "@/hooks/useGroups"; import DatePicker from "react-datepicker"; import {shouldRedirectHome} from "@/utils/navigation.disabled"; import ProfileSummary from "@/components/ProfileSummary"; import moment from "moment"; -import {Stat} from "@/interfaces/user"; +import {Group, Stat, User} from "@/interfaces/user"; import {Divider} from "primereact/divider"; import Badge from "@/components/Low/Badge"; +import { mapBy, redirect, serialize } from "@/utils"; +import { getEntitiesWithRoles } from "@/utils/entities.be"; +import { checkAccess } from "@/utils/permissions"; +import { getEntitiesUsers, getUsers } from "@/utils/users.be"; +import { EntityWithRoles } from "@/interfaces/entity"; +import { getGroups, getGroupsByEntities } from "@/utils/groups.be"; +import Select from "@/components/Low/Select"; +import { requestUser } from "@/utils/api"; ChartJS.register(LinearScale, CategoryScale, PointElement, LineElement, LineController, Legend, Tooltip); const COLORS = ["#1EB3FF", "#FF790A", "#3D9F11", "#EF5DA8", "#414288"]; -export const getServerSideProps = withIronSessionSsr(({req, res}) => { - const user = req.session.user; +export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { + const user = await requestUser(req, res) + if (!user) return redirect("/login") - if (!user) { - return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } + if (shouldRedirectHome(user)) return redirect("/") - if (shouldRedirectHome(user)) { - return { - redirect: { - destination: "/", - permanent: false, - }, - }; - } + const entityIDs = mapBy(user.entities, 'id') + const entities = await getEntitiesWithRoles(checkAccess(user, ["admin", "developer"]) ? undefined : entityIDs) + const users = await (checkAccess(user, ["admin", "developer"]) ? getUsers() : getEntitiesUsers(mapBy(entities, 'id'))) + const groups = await (checkAccess(user, ["admin", "developer"]) ? getGroups() : getGroupsByEntities(mapBy(entities, 'id'))) return { - props: {user: req.session.user}, + props: serialize({user, entities, users, groups}), }; }, sessionOptions); -export default function Stats() { - const [statsUserId, setStatsUserId] = useState(); +interface Props { + user: User + users: User[] + entities: EntityWithRoles[] + groups: Group[] +} + +export default function Stats({ user, entities, users, groups }: Props) { + const [statsUserId, setStatsUserId] = useState(user.id); const [startDate, setStartDate] = useState(moment(new Date()).subtract(1, "weeks").toDate()); const [endDate, setEndDate] = useState(new Date()); const [initialStatDate, setInitialStatDate] = useState(); @@ -69,15 +73,8 @@ export default function Stats() { const [dailyScoreDate, setDailyScoreDate] = useState(new Date()); const [intervalDates, setIntervalDates] = useState([]); - const {user} = useUser({redirectTo: "/login"}); - const {users} = useUsers(); - const {groups} = useGroups({admin: user?.id}); const {data: stats} = useFilterRecordsByUser(statsUserId, !statsUserId); - useEffect(() => { - if (user) setStatsUserId(user.id); - }, [user]); - useEffect(() => { setInitialStatDate( stats @@ -190,16 +187,7 @@ export default function Stats() { className="w-full" options={users.map((x) => ({value: x.id, label: `${x.name} - ${x.email}`}))} defaultValue={{value: user.id, label: `${user.name} - ${user.email}`}} - onChange={(value) => setStatsUserId(value?.value)} - menuPortalTarget={document?.body} - styles={{ - menuPortal: (base) => ({...base, zIndex: 9999}), - option: (styles, state) => ({ - ...styles, - backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", - color: state.isFocused ? "black" : styles.color, - }), - }} + onChange={(value) => setStatsUserId(value?.value || user.id)} /> )} {["corporate", "teacher", "mastercorporate"].includes(user.type) && groups.length > 0 && ( @@ -209,16 +197,7 @@ export default function Stats() { .filter((x) => groups.flatMap((y) => y.participants).includes(x.id)) .map((x) => ({value: x.id, label: `${x.name} - ${x.email}`}))} defaultValue={{value: user.id, label: `${user.name} - ${user.email}`}} - onChange={(value) => setStatsUserId(value?.value)} - menuPortalTarget={document?.body} - styles={{ - menuPortal: (base) => ({...base, zIndex: 9999}), - option: (styles, state) => ({ - ...styles, - backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", - color: state.isFocused ? "black" : styles.color, - }), - }} + onChange={(value) => setStatsUserId(value?.value || user.id)} /> )} diff --git a/src/pages/tickets.tsx b/src/pages/tickets.tsx index fb6e2b47..21f2a6cc 100644 --- a/src/pages/tickets.tsx +++ b/src/pages/tickets.tsx @@ -16,32 +16,20 @@ import Head from "next/head"; import {useEffect, useState} from "react"; import {BsArrowDown, BsArrowUp} from "react-icons/bs"; import {ToastContainer} from "react-toastify"; +import { requestUser } from "@/utils/api"; +import { redirect } from "@/utils"; const columnHelper = createColumnHelper(); -export const getServerSideProps = withIronSessionSsr(({req, res}) => { - const user = req.session.user; +export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { + const user = await requestUser(req, res) + if (!user) return redirect("/login") - if (!user) { - return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } - - if (shouldRedirectHome(user) || !["admin", "developer", "agent"].includes(user.type)) { - return { - redirect: { - destination: "/", - permanent: false, - }, - }; - } + if (shouldRedirectHome(user) || !["admin", "developer", "agent"].includes(user.type)) + return redirect("/") return { - props: {user: req.session.user}, + props: {user}, }; }, sessionOptions); diff --git a/src/pages/training/[id]/index.tsx b/src/pages/training/[id]/index.tsx index 69aab218..d7502277 100644 --- a/src/pages/training/[id]/index.tsx +++ b/src/pages/training/[id]/index.tsx @@ -31,30 +31,17 @@ import {uniqBy} from "lodash"; import {getExamById} from "@/utils/exams"; import {convertToUserSolutions} from "@/utils/stats"; import {sortByModule} from "@/utils/moduleUtils"; +import { requestUser } from "@/utils/api"; +import { redirect, serialize } from "@/utils"; -export const getServerSideProps = withIronSessionSsr(({req, res}) => { - const user = req.session.user; +export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { + const user = await requestUser(req, res) + if (!user) return redirect("/login") - if (!user) { - return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } - - if (shouldRedirectHome(user)) { - return { - redirect: { - destination: "/", - permanent: false, - }, - }; - } + if (shouldRedirectHome(user)) redirect("/") return { - props: {user: req.session.user}, + props: serialize({user}), }; }, sessionOptions); diff --git a/src/pages/training/index.tsx b/src/pages/training/index.tsx index 461ca135..c59765e3 100644 --- a/src/pages/training/index.tsx +++ b/src/pages/training/index.tsx @@ -20,34 +20,30 @@ import TrainingScore from "@/training/TrainingScore"; import ModuleBadge from "@/components/ModuleBadge"; import RecordFilter from "@/components/Medium/RecordFilter"; import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser"; +import { mapBy, redirect, serialize } from "@/utils"; +import { getEntitiesWithRoles } from "@/utils/entities.be"; +import { getAssignmentsByAssignee } from "@/utils/assignments.be"; +import { getEntitiesUsers } from "@/utils/users.be"; +import { EntityWithRoles } from "@/interfaces/entity"; +import { Assignment } from "@/interfaces/results"; +import { requestUser } from "@/utils/api"; -export const getServerSideProps = withIronSessionSsr(({req, res}) => { - const user = req.session.user; +export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { + const user = await requestUser(req, res) + if (!user) return redirect("/login") - if (!user) { - return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } + if (shouldRedirectHome(user)) return redirect("/") - if (shouldRedirectHome(user)) { - return { - redirect: { - destination: "/", - permanent: false, - }, - }; - } + const entityIDs = mapBy(user.entities, 'id') + const entities = await getEntitiesWithRoles(entityIDs) + const users = await getEntitiesUsers(entityIDs) return { - props: {user: req.session.user}, + props: serialize({user, users, entities}), }; }, sessionOptions); -const Training: React.FC<{user: User}> = ({user}) => { +const Training: React.FC<{user: User, entities: EntityWithRoles[], users: User[] }> = ({user, entities, users}) => { const [recordUserId, setRecordTraining] = useRecordStore((state) => [state.selectedUser, state.setTraining]); const [filter, setFilter] = useState<"months" | "weeks" | "days" | "assignments">(); @@ -193,7 +189,7 @@ const Training: React.FC<{user: User}> = ({user}) => {
) : ( <> - + {user.type === "student" && ( <>
diff --git a/src/pages/list/users.tsx b/src/pages/users/index.tsx similarity index 54% rename from src/pages/list/users.tsx rename to src/pages/users/index.tsx index 26069756..bbf73b40 100644 --- a/src/pages/list/users.tsx +++ b/src/pages/users/index.tsx @@ -1,46 +1,42 @@ import Layout from "@/components/High/Layout"; import useUser from "@/hooks/useUser"; import useUsers from "@/hooks/useUsers"; +import { Type, User } from "@/interfaces/user"; import {sessionOptions} from "@/lib/session"; import useFilterStore from "@/stores/listFilterStore"; +import { redirect, serialize } from "@/utils"; +import { requestUser } from "@/utils/api"; +import { shouldRedirectHome } from "@/utils/navigation.disabled"; import {withIronSessionSsr} from "iron-session/next"; import Head from "next/head"; import {useRouter} from "next/router"; import {useEffect} from "react"; -import {BsArrowLeft} from "react-icons/bs"; +import {BsArrowLeft, BsChevronLeft} from "react-icons/bs"; import {ToastContainer} from "react-toastify"; import UserList from "../(admin)/Lists/UserList"; -export const getServerSideProps = withIronSessionSsr(({req, res}) => { - const user = req.session.user; +export const getServerSideProps = withIronSessionSsr(async ({req, res, query}) => { + const user = await requestUser(req, res) + if (!user) return redirect("/login") - const envVariables: {[key: string]: string} = {}; - Object.keys(process.env) - .filter((x) => x.startsWith("NEXT_PUBLIC")) - .forEach((x: string) => { - envVariables[x] = process.env[x]!; - }); + if (shouldRedirectHome(user)) return redirect("/") - if (!user) { - return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } + const {type} = query as {type?: Type} return { - props: {user: req.session.user, envVariables}, + props: serialize({user, type}), }; }, sessionOptions); -export default function UsersListPage() { - const {user} = useUser(); +interface Props { + user: User + type?: Type +} +export default function UsersListPage({ user, type }: Props) { const [filters, clearFilters] = useFilterStore((state) => [state.userFilters, state.clearUserFilters]); - const router = useRouter(); + const router = useRouter() return ( <> @@ -55,28 +51,26 @@ export default function UsersListPage() { - {user && ( f.filter)} renderHeader={(total) => ( -
-
+
-

Users ({total})

+ className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"> + + +

Users ({ total })

)} />
- )} ); } diff --git a/src/pages/users/performance.tsx b/src/pages/users/performance.tsx new file mode 100644 index 00000000..0ccd1773 --- /dev/null +++ b/src/pages/users/performance.tsx @@ -0,0 +1,90 @@ +import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser"; +import useGroups from "@/hooks/useGroups"; +import useUsers, {userHashStudent} from "@/hooks/useUsers"; +import {Group, Stat, StudentUser, User} from "@/interfaces/user"; +import {getUserCompanyName} from "@/resources/user"; +import clsx from "clsx"; +import {useRouter} from "next/router"; +import {BsArrowLeft, BsArrowRepeat, BsChevronLeft} from "react-icons/bs"; +import { mapBy, serialize } from "@/utils"; +import {withIronSessionSsr} from "iron-session/next"; +import { getEntitiesUsers, getUsers } from "@/utils/users.be"; +import { sessionOptions } from "@/lib/session"; +import { checkAccess } from "@/utils/permissions"; +import { getEntities } from "@/utils/entities.be"; +import { Entity } from "@/interfaces/entity"; +import { getParticipantGroups, getParticipantsGroups } from "@/utils/groups.be"; +import StudentPerformanceList from "../(admin)/Lists/StudentPerformanceList"; +import Head from "next/head"; +import { ToastContainer } from "react-toastify"; +import Layout from "@/components/High/Layout"; +import { requestUser } from "@/utils/api"; +import { redirect } from "@/utils"; + +export const getServerSideProps = withIronSessionSsr(async ({req, res, query}) => { + const user = await requestUser(req, res) + if (!user) return redirect("/login") + + const entityIDs = mapBy(user.entities, 'id') + + const entities = await getEntities(checkAccess(user, ["admin", 'developer']) ? undefined : entityIDs) + const students = await (checkAccess(user, ["admin", 'developer']) + ? getUsers({type: 'student'}) + : getEntitiesUsers(entityIDs, {type: 'student'}) + ) + const groups = await getParticipantsGroups(mapBy(students, 'id')) + + return { + props: serialize({user, students, entities, groups}), + }; +}, sessionOptions); + +interface Props { + user: User; + students: StudentUser[] + entities: Entity[] + groups: Group[] +} + +const StudentPerformance = ({user, students, entities, groups}: Props) => { + const {data: stats} = useFilterRecordsByUser(); + + const router = useRouter(); + + const performanceStudents = students.map((u) => ({ + ...u, + group: groups.find((x) => x.participants.includes(u.id))?.name || "N/A", + entitiesLabel: mapBy(u.entities, 'id').map((id) => entities.find((e) => e.id === id)?.label).filter((e) => !!e).join(', '), + })); + + return ( + <> + + EnCoach + + + + + + + +
+ +

Student Performance ({ students.length })

+
+ +
+ + ); +}; + +export default StudentPerformance; diff --git a/src/pages/v1/index.tsx b/src/pages/v1/index.tsx new file mode 100644 index 00000000..514b4091 --- /dev/null +++ b/src/pages/v1/index.tsx @@ -0,0 +1,199 @@ +/* eslint-disable @next/next/no-img-element */ +import Head from "next/head"; +import Navbar from "@/components/Navbar"; +import { BsFileEarmarkText, BsPencil, BsStar, BsBook, BsHeadphones, BsPen, BsMegaphone } from "react-icons/bs"; +import { withIronSessionSsr } from "iron-session/next"; +import { sessionOptions } from "@/lib/session"; +import { useEffect, useState } from "react"; +import { averageScore, groupBySession, totalExams } from "@/utils/stats"; +import useUser from "@/hooks/useUser"; +import Diagnostic from "@/components/Diagnostic"; +import { ToastContainer } from "react-toastify"; +import { capitalize } from "lodash"; +import { Module } from "@/interfaces"; +import ProgressBar from "@/components/Low/ProgressBar"; +import Layout from "@/components/High/Layout"; +import { calculateAverageLevel } from "@/utils/score"; +import axios from "axios"; +import DemographicInformationInput from "@/components/DemographicInformationInput"; +import moment from "moment"; +import Link from "next/link"; +import { MODULE_ARRAY } from "@/utils/moduleUtils"; +import ProfileSummary from "@/components/ProfileSummary"; +import StudentDashboard from "@/dashboards/Student"; +import AdminDashboard from "@/dashboards/Admin"; +import CorporateDashboard from "@/dashboards/Corporate"; +import TeacherDashboard from "@/dashboards/Teacher"; +import AgentDashboard from "@/dashboards/Agent"; +import MasterCorporateDashboard from "@/dashboards/MasterCorporate"; +import PaymentDue from "../(status)/PaymentDue"; +import { useRouter } from "next/router"; +import { PayPalScriptProvider } from "@paypal/react-paypal-js"; +import { CorporateUser, MasterCorporateUser, Type, User, userTypes } from "@/interfaces/user"; +import Select from "react-select"; +import { USER_TYPE_LABELS } from "@/resources/user"; +import { checkAccess, getTypesOfUser } from "@/utils/permissions"; +import { getUserCorporate } from "@/utils/groups.be"; +import { getUsers } from "@/utils/users.be"; +import { requestUser } from "@/utils/api"; +import { redirect, serialize } from "@/utils"; + +export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { + const user = await requestUser(req, res) + if (!user) return redirect("/login") + + const linkedCorporate = (await getUserCorporate(user.id)) || null; + + return { + props: serialize({ user, linkedCorporate }), + }; +}, sessionOptions); + +interface Props { + user: User; + linkedCorporate?: CorporateUser | MasterCorporateUser; +} + +export default function Home({ user: propsUser, linkedCorporate }: Props) { + const [user, setUser] = useState(propsUser); + const [showDiagnostics, setShowDiagnostics] = useState(false); + const [showDemographicInput, setShowDemographicInput] = useState(false); + const [selectedScreen, setSelectedScreen] = useState("admin"); + + const { mutateUser } = useUser({ redirectTo: "/login" }); + const router = useRouter(); + + useEffect(() => { + if (user) { + // setShowDemographicInput(!user.demographicInformation || !user.demographicInformation.country || !user.demographicInformation.phone); + setShowDiagnostics(user.isFirstLogin && user.type === "student"); + } + }, [user]); + + const checkIfUserExpired = () => { + const expirationDate = user!.subscriptionExpirationDate; + + if (expirationDate === null || expirationDate === undefined) return false; + if (moment(expirationDate).isAfter(moment(new Date()))) return false; + + return true; + }; + + if (user && (user.status === "paymentDue" || user.status === "disabled" || checkIfUserExpired())) { + return ( + <> + + EnCoach + + + + + {user.status === "disabled" && ( + +
+ Your account has been disabled! + Please contact an administrator if you believe this to be a mistake. +
+
+ )} + {(user.status === "paymentDue" || checkIfUserExpired()) && } + + ); + } + + if (user && showDemographicInput) { + return ( + <> + + EnCoach + + + + + + { + setUser(user); + mutateUser(user); + }} + user={user} + /> + + + ); + } + + if (user && showDiagnostics) { + return ( + <> + + EnCoach + + + + + + setShowDiagnostics(false)} /> + + + ); + } + + return ( + <> + + EnCoach + + + + + + {user && ( + + {checkAccess(user, ["student"]) && } + {checkAccess(user, ["teacher"]) && } + {checkAccess(user, ["corporate"]) && } + {checkAccess(user, ["mastercorporate"]) && } + {checkAccess(user, ["agent"]) && } + {checkAccess(user, ["admin"]) && } + {checkAccess(user, ["developer"]) && ( + <> +