Merge with develop

This commit is contained in:
Carlos-Mesquita
2024-11-06 10:59:26 +00:00
135 changed files with 9517 additions and 3617 deletions

View File

@@ -43,7 +43,7 @@ export default function Diagnostic({onFinish}: Props) {
if (exams.every((x) => !!x)) { if (exams.every((x) => !!x)) {
setExams(exams.map((x) => x!)); setExams(exams.map((x) => x!));
setSelectedModules(exams.map((x) => x!.module)); setSelectedModules(exams.map((x) => x!.module));
router.push("/exercises"); router.push("/exam");
} }
}); });
}; };

View File

@@ -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<T> {
list: T[];
searchFields: string[][];
pageSize?: number;
firstCard?: () => ReactNode;
renderCard: (item: T) => ReactNode;
className?: string
}
export default function CardList<T>({list, searchFields, renderCard, firstCard, className, pageSize = 20}: Props<T>) {
const {rows, renderSearch} = useListSearch(searchFields, list);
const {items, page, render, renderMinimal} = usePagination(rows, pageSize);
return (
<section className="flex flex-col gap-4 w-full">
<div className="w-full flex items-center gap-4">
{searchFields.length > 0 && renderSearch()}
{searchFields.length > 0 ? renderMinimal() : render()}
</div>
<div className={clsx("w-full h-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4", className)}>
{page === 0 && !!firstCard && firstCard()}
{items.map(renderCard)}
</div>
</section>
);
}

View File

@@ -1,25 +1,41 @@
import useEntities from "@/hooks/useEntities";
import { EntityWithRoles } from "@/interfaces/entity";
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import clsx from "clsx"; import clsx from "clsx";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import BottomBar from "../BottomBar"; import { ToastContainer } from "react-toastify";
import Navbar from "../Navbar"; import Navbar from "../Navbar";
import Sidebar from "../Sidebar"; import Sidebar from "../Sidebar";
interface Props { interface Props {
user: User; user: User;
entities?: EntityWithRoles[]
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
navDisabled?: boolean; navDisabled?: boolean;
focusMode?: boolean; focusMode?: boolean;
hideSidebar?: boolean
bgColor?: string; bgColor?: string;
onFocusLayerMouseEnter?: () => void; onFocusLayerMouseEnter?: () => void;
} }
export default function Layout({user, children, className, bgColor="bg-white", navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) { export default function Layout({
user,
children,
className,
bgColor="bg-white",
hideSidebar,
navDisabled = false,
focusMode = false,
onFocusLayerMouseEnter
}: Props) {
const router = useRouter(); const router = useRouter();
const {entities} = useEntities()
return ( return (
<main className={clsx("w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke relative")}> <main className={clsx("w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke relative")}>
<ToastContainer />
{!hideSidebar && (
<Navbar <Navbar
path={router.pathname} path={router.pathname}
user={user} user={user}
@@ -27,7 +43,9 @@ export default function Layout({user, children, className, bgColor="bg-white", n
focusMode={focusMode} focusMode={focusMode}
onFocusLayerMouseEnter={onFocusLayerMouseEnter} onFocusLayerMouseEnter={onFocusLayerMouseEnter}
/> />
<div className="h-full w-full flex gap-2"> )}
<div className={clsx("h-full w-full flex gap-2")}>
{!hideSidebar && (
<Sidebar <Sidebar
path={router.pathname} path={router.pathname}
navDisabled={navDisabled} navDisabled={navDisabled}
@@ -35,11 +53,14 @@ export default function Layout({user, children, className, bgColor="bg-white", n
onFocusLayerMouseEnter={onFocusLayerMouseEnter} onFocusLayerMouseEnter={onFocusLayerMouseEnter}
className="-md:hidden" className="-md:hidden"
user={user} user={user}
entities={entities}
/> />
)}
<div <div
className={clsx( className={clsx(
`w-full min-h-full md:mr-8 ${bgColor} shadow-md rounded-2xl p-4 xl:p-10 pb-8 flex flex-col gap-8 relative overflow-hidden mt-2`, `w-full min-h-full ${bgColor} shadow-md rounded-2xl p-4 xl:p-10 pb-8 flex flex-col gap-8 relative overflow-hidden mt-2`,
bgColor !== "bg-white" ? "justify-center" : "h-fit", bgColor !== "bg-white" ? "justify-center" : "h-fit",
hideSidebar ? "md:mx-8" : "md:mr-8",
className, className,
)}> )}>
{children} {children}

View File

@@ -0,0 +1,107 @@
import { useListSearch } from "@/hooks/useListSearch"
import { ColumnDef, flexRender, getCoreRowModel, getPaginationRowModel, getSortedRowModel, PaginationState, useReactTable } from "@tanstack/react-table"
import clsx from "clsx"
import { useState } from "react"
import { BsArrowDown, BsArrowUp } from "react-icons/bs"
import Button from "../Low/Button"
interface Props<T> {
data: T[]
columns: ColumnDef<any, any>[]
searchFields: string[][]
size?: number
onDownload?: (rows: T[]) => void
}
export default function Table<T>({ data, columns, searchFields, size = 16, onDownload }: Props<T>) {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 16,
})
const { rows, renderSearch } = useListSearch<T>(searchFields, data);
const table = useReactTable({
data: rows,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
state: {
pagination
}
});
return (
<div className="w-full flex flex-col gap-2">
<div className="w-full flex gap-2 items-end">
{renderSearch()}
{onDownload && (
<Button className="w-full max-w-[200px] mb-1" variant="outline" onClick={() => onDownload(rows)}>
Download List
</Button>
)
}
</div>
<div className="w-full flex gap-2 justify-between items-center">
<div className="flex items-center gap-4 w-fit">
<Button className="w-[200px] h-fit" disabled={!table.getCanPreviousPage()} onClick={() => table.previousPage()}>
Previous Page
</Button>
</div>
<div className="flex items-center gap-4 w-fit">
<span className="flex items-center gap-1">
<div>Page</div>
<strong>
{table.getState().pagination.pageIndex + 1} of{' '}
{table.getPageCount().toLocaleString()}
</strong>
<div>| Total: {table.getRowCount().toLocaleString()}</div>
</span>
<Button className="w-[200px]" disabled={!table.getCanNextPage()} onClick={() => table.nextPage()}>
Next Page
</Button>
</div>
</div>
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th className="py-4 px-4 text-left" key={header.id} colSpan={header.colSpan}>
<div
className={clsx(header.column.getCanSort() && 'cursor-pointer select-none', 'flex items-center gap-2')}
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{{
asc: <BsArrowUp />,
desc: <BsArrowDown />,
}[header.column.getIsSorted() as string] ?? null}
</div>
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2 w-full">
{table.getRowModel().rows.map((row) => (
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2 items-center w-fit" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@@ -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 {Column, flexRender, getCoreRowModel, getSortedRowModel, useReactTable} from "@tanstack/react-table";
import clsx from "clsx";
import {useMemo, useState} from "react"; import {useMemo, useState} from "react";
import Button from "./Low/Button"; import Button from "./Low/Button";
const SIZE = 25; const SIZE = 25;
export default function List<T>({data, columns}: {data: T[]; columns: any[]}) { export default function List<T>({
const [page, setPage] = useState(0); 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({ const table = useReactTable({
data: items, data: items,
@@ -17,19 +30,10 @@ export default function List<T>({data, columns}: {data: T[]; columns: any[]}) {
}); });
return ( return (
<div className="w-full h-full flex flex-col gap-2"> <div className="w-full h-full flex flex-col gap-6">
<div className="w-full flex gap-2 justify-between"> <div className={clsx("w-full flex items-center gap-4", searchFields.length === 0 && "justify-end")}>
<Button className="w-full max-w-[200px]" disabled={page === 0} onClick={() => setPage((prev) => prev - 1)}> {searchFields.length > 0 && renderSearch()}
Previous Page {renderMinimal()}
</Button>
<div className="flex items-center gap-4 w-fit">
<span className="opacity-80">
{page * SIZE + 1} - {(page + 1) * SIZE > data.length ? data.length : (page + 1) * SIZE} / {data.length}
</span>
<Button className="w-[200px]" disabled={(page + 1) * SIZE >= data.length} onClick={() => setPage((prev) => prev + 1)}>
Next Page
</Button>
</div>
</div> </div>
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full"> <table className="rounded-xl bg-mti-purple-ultralight/40 w-full">

View File

@@ -5,7 +5,7 @@ import {BsCheck} from "react-icons/bs";
interface Props { interface Props {
isChecked: boolean; isChecked: boolean;
onChange: (isChecked: boolean) => void; onChange: (isChecked: boolean) => void;
children: ReactNode; children?: ReactNode;
disabled?: boolean; disabled?: boolean;
} }

View File

@@ -13,9 +13,10 @@ interface Props {
isClearable?: boolean; isClearable?: boolean;
styles?: StylesConfig<Option, boolean, GroupBase<Option>>; styles?: StylesConfig<Option, boolean, GroupBase<Option>>;
className?: string; 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<HTMLElement>(); const [target, setTarget] = useState<HTMLElement>();
useEffect(() => { useEffect(() => {
@@ -23,6 +24,8 @@ export default function Select({value, defaultValue, options, placeholder, disab
}, []); }, []);
return ( return (
<div className="w-full flex flex-col gap-3">
{label && <label className="font-normal text-base text-mti-gray-dim">{label}</label>}
<ReactSelect <ReactSelect
className={ className={
styles styles
@@ -61,5 +64,6 @@ export default function Select({value, defaultValue, options, placeholder, disab
isDisabled={disabled} isDisabled={disabled}
isClearable={isClearable} isClearable={isClearable}
/> />
</div>
); );
} }

View File

@@ -0,0 +1,3 @@
const Separator = () => <div className="w-full h-[1px] bg-mti-gray-platinum rounded-full" />;
export default Separator;

View File

@@ -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 (
<div className={clsx(!disabled && "tooltip", className)} data-tip={tooltip}>
{children}
</div>
);
}

View File

@@ -0,0 +1,67 @@
import {Invite, InviteWithEntity} 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: InviteWithEntity;
reload: () => void;
}
export default function InviteWithUserCard({invite, reload}: Props) {
const [isLoading, setIsLoading] = useState(false);
const name = useMemo(() => (!invite.entity ? null : invite.entity.label), [invite.entity]);
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 (
<div className="border-mti-gray-anti-flash flex min-w-[200px] flex-col gap-6 rounded-xl border p-4 text-black">
<span>Invited to <b>{name}</b></span>
<div className="flex items-center gap-2">
<button
onClick={() => decide("accept")}
disabled={isLoading}
className="bg-mti-green-ultralight hover:bg-mti-green-light w-24 rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed">
{!isLoading && "Accept"}
{isLoading && (
<div className="flex items-center justify-center">
<BsArrowRepeat className="animate-spin text-white" size={25} />
</div>
)}
</button>
<button
onClick={() => decide("decline")}
disabled={isLoading}
className="bg-mti-red-ultralight hover:bg-mti-red-light w-24 rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed">
{!isLoading && "Decline"}
{isLoading && (
<div className="flex items-center justify-center">
<BsArrowRepeat className="animate-spin text-white" size={25} />
</div>
)}
</button>
</div>
</div>
);
}

View File

@@ -1,11 +1,13 @@
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import { checkAccess } from "@/utils/permissions"; import { checkAccess } from "@/utils/permissions";
import Select from "../Low/Select"; import Select from "../Low/Select";
import { ReactNode, useEffect, useState } from "react"; import { ReactNode, useEffect, useMemo, useState } from "react";
import clsx from "clsx"; import clsx from "clsx";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
import useRecordStore from "@/stores/recordStore"; import useRecordStore from "@/stores/recordStore";
import { EntityWithRoles } from "@/interfaces/entity";
import { mapBy } from "@/utils";
type TimeFilter = "months" | "weeks" | "days"; type TimeFilter = "months" | "weeks" | "days";
@@ -13,6 +15,8 @@ type Filter = TimeFilter | "assignments" | undefined;
interface Props { interface Props {
user: User; user: User;
entities: EntityWithRoles[]
users: User[]
filterState: { filterState: {
filter: Filter, filter: Filter,
setFilter: React.Dispatch<React.SetStateAction<Filter>> setFilter: React.Dispatch<React.SetStateAction<Filter>>
@@ -28,83 +32,41 @@ const defaultSelectableCorporate = {
const RecordFilter: React.FC<Props> = ({ const RecordFilter: React.FC<Props> = ({
user, user,
entities,
users,
filterState, filterState,
assignments = true, assignments = true,
children children
}) => { }) => {
const { filter, setFilter } = filterState; const { filter, setFilter } = filterState;
const [statsUserId, setStatsUserId] = useRecordStore((state) => [ const [entity, setEntity] = useState<string>()
const [, setStatsUserId] = useRecordStore((state) => [
state.selectedUser, state.selectedUser,
state.setSelectedUser state.setSelectedUser
]); ]);
const { users } = useUsers(); const entityUsers = useMemo(() => !entity ? users : users.filter(u => mapBy(u.entities, 'id').includes(entity)), [users, entity])
const { groups: allGroups } = useGroups({});
const { groups } = useGroups({ admin: user?.id, userType: user?.type }); useEffect(() => setStatsUserId(user.id), [setStatsUserId, user.id])
const toggleFilter = (value: "months" | "weeks" | "days" | "assignments") => { const toggleFilter = (value: "months" | "weeks" | "days" | "assignments") => {
setFilter((prev) => (prev === value ? undefined : value)); 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<string>(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 ( return (
<div className="w-full flex -xl:flex-col -xl:gap-4 justify-between items-center"> <div className="w-full flex -xl:flex-col -xl:gap-4 justify-between items-center">
<div className="xl:w-3/4"> <div className="xl:w-3/4 flex gap-2">
{checkAccess(user, ["developer", "admin", "mastercorporate"]) && !children && ( {checkAccess(user, ["developer", "admin", "mastercorporate"]) && !children && (
<> <>
<label className="font-normal text-base text-mti-gray-dim">Corporate</label> <div className="flex flex-col gap-2 w-full">
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
<Select <Select
options={selectableCorporates} options={entities.map((e) => ({value: e.id, label: e.label}))}
value={selectableCorporates.find((x) => x.value === selectedCorporate)} onChange={(value) => setEntity(value?.value || undefined)}
onChange={(value) => setSelectedCorporate(value?.value || "")} isClearable
styles={{ styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }), menuPortal: (base) => ({ ...base, zIndex: 9999 }),
option: (styles, state) => ({ option: (styles, state) => ({
@@ -112,15 +74,17 @@ const RecordFilter: React.FC<Props> = ({
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color, color: state.isFocused ? "black" : styles.color,
}), }),
}}></Select> }} />
</div>
<div className="flex flex-col gap-2 w-full">
<label className="font-normal text-base text-mti-gray-dim">User</label> <label className="font-normal text-base text-mti-gray-dim">User</label>
<Select <Select
options={corporateFilteredUserList.map((x) => ({ options={entityUsers.map((x) => ({
value: x.id, value: x.id,
label: `${x.name} - ${x.email}`, label: `${x.name} - ${x.email}`,
}))} }))}
value={selectedUserSelectValue} defaultValue={{value: user.id, label: `${user.name} - ${user.email}`}}
onChange={(value) => setStatsUserId(value?.value!)} onChange={(value) => setStatsUserId(value?.value!)}
styles={{ styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }), menuPortal: (base) => ({ ...base, zIndex: 9999 }),
@@ -131,20 +95,20 @@ const RecordFilter: React.FC<Props> = ({
}), }),
}} }}
/> />
</div>
</> </>
)} )}
{(user.type === "corporate" || user.type === "teacher") && groups.length > 0 && !children && ( {(user.type === "corporate" || user.type === "teacher") && !children && (
<> <div className="flex flex-col gap-2">
<label className="font-normal text-base text-mti-gray-dim">User</label> <label className="font-normal text-base text-mti-gray-dim">User</label>
<Select <Select
options={users options={users
.filter((x) => groups.flatMap((y) => y.participants).includes(x.id))
.map((x) => ({ .map((x) => ({
value: x.id, value: x.id,
label: `${x.name} - ${x.email}`, label: `${x.name} - ${x.email}`,
}))} }))}
value={selectedUserSelectValue} defaultValue={{value: user.id, label: `${user.name} - ${user.email}`}}
onChange={(value) => setStatsUserId(value?.value!)} onChange={(value) => setStatsUserId(value?.value!)}
styles={{ styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }), menuPortal: (base) => ({ ...base, zIndex: 9999 }),
@@ -155,7 +119,7 @@ const RecordFilter: React.FC<Props> = ({
}), }),
}} }}
/> />
</> </div>
)} )}
{children} {children}
</div> </div>

View File

@@ -182,7 +182,7 @@ const StatsGridItem: React.FC<StatsGridItemProps> = ({
.sort(sortByModule) .sort(sortByModule)
.map((x) => x!.module), .map((x) => x!.module),
); );
router.push("/exercises"); router.push("/exam");
} }
}); });
} }

View File

@@ -105,16 +105,6 @@ export default function MobileMenu({
> >
Exams Exams
</Link> </Link>
<Link
href={disableNavigation ? "" : "/exercises"}
className={clsx(
"w-fit transition duration-300 ease-in-out",
path === "/exercises" &&
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
)}
>
Exercises
</Link>
</> </>
)} )}
<Link <Link

View File

@@ -12,24 +12,24 @@ import {
BsCloudFill, BsCloudFill,
BsCurrencyDollar, BsCurrencyDollar,
BsClipboardData, BsClipboardData,
BsFileLock,
BsPeople, BsPeople,
} from "react-icons/bs"; } from "react-icons/bs";
import { CiDumbbell } from "react-icons/ci"; import { CiDumbbell } from "react-icons/ci";
import { RiLogoutBoxFill } from "react-icons/ri"; import { RiLogoutBoxFill } from "react-icons/ri";
import {SlPencil} from "react-icons/sl";
import {FaAward} from "react-icons/fa";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import axios from "axios"; import axios from "axios";
import FocusLayer from "@/components/FocusLayer"; import FocusLayer from "@/components/FocusLayer";
import { preventNavigation } from "@/utils/navigation.disabled"; import { preventNavigation } from "@/utils/navigation.disabled";
import {useEffect, useState} from "react";
import usePreferencesStore from "@/stores/preferencesStore"; import usePreferencesStore from "@/stores/preferencesStore";
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import useTicketsListener from "@/hooks/useTicketsListener"; import useTicketsListener from "@/hooks/useTicketsListener";
import { checkAccess, getTypesOfUser } from "@/utils/permissions"; import { checkAccess, getTypesOfUser } from "@/utils/permissions";
import usePermissions from "@/hooks/usePermissions"; import usePermissions from "@/hooks/usePermissions";
import { EntityWithRoles } from "@/interfaces/entity";
import { useAllowedEntitiesSomePermissions } from "@/hooks/useEntityPermissions";
import { useMemo } from "react";
interface Props { interface Props {
path: string; path: string;
navDisabled?: boolean; navDisabled?: boolean;
@@ -37,6 +37,7 @@ interface Props {
onFocusLayerMouseEnter?: () => void; onFocusLayerMouseEnter?: () => void;
className?: string; className?: string;
user: User; user: User;
entities?: EntityWithRoles[]
} }
interface NavProps { interface NavProps {
@@ -57,7 +58,7 @@ const Nav = ({Icon, label, path, keyPath, disabled = false, isMinimized = false,
"flex items-center gap-4 rounded-full p-4 text-gray-500 hover:text-white", "flex items-center gap-4 rounded-full p-4 text-gray-500 hover:text-white",
"transition-all duration-300 ease-in-out relative", "transition-all duration-300 ease-in-out relative",
disabled ? "hover:bg-mti-gray-dim cursor-not-allowed" : "hover:bg-mti-purple-light cursor-pointer", disabled ? "hover:bg-mti-gray-dim cursor-not-allowed" : "hover:bg-mti-purple-light cursor-pointer",
path === keyPath && "bg-mti-purple-light text-white", path.startsWith(keyPath) && "bg-mti-purple-light text-white",
isMinimized ? "w-fit" : "w-full min-w-[200px] px-8 2xl:min-w-[220px]", isMinimized ? "w-fit" : "w-full min-w-[200px] px-8 2xl:min-w-[220px]",
)}> )}>
<Icon size={24} /> <Icon size={24} />
@@ -76,14 +77,28 @@ const Nav = ({Icon, label, path, keyPath, disabled = false, isMinimized = false,
); );
}; };
export default function Sidebar({path, navDisabled = false, focusMode = false, user, onFocusLayerMouseEnter, className}: Props) { export default function Sidebar({
path,
entities = [],
navDisabled = false,
focusMode = false,
user,
onFocusLayerMouseEnter,
className
}: Props) {
const router = useRouter(); const router = useRouter();
const isAdmin = useMemo(() => ['developer', 'admin'].includes(user?.type), [user?.type])
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]); const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]);
const { totalAssignedTickets } = useTicketsListener(user.id); const { totalAssignedTickets } = useTicketsListener(user.id);
const { permissions } = usePermissions(user.id); const { permissions } = usePermissions(user.id);
const entitiesAllowGeneration = useAllowedEntitiesSomePermissions(user, entities, [
"generate_reading", "generate_listening", "generate_writing", "generate_speaking", "generate_level"
])
const logout = async () => { const logout = async () => {
axios.post("/api/logout").finally(() => { axios.post("/api/logout").finally(() => {
setTimeout(() => router.reload(), 500); setTimeout(() => router.reload(), 500);
@@ -100,18 +115,22 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
className, className,
)}> )}>
<div className="-xl:hidden flex-col gap-3 xl:flex"> <div className="-xl:hidden flex-col gap-3 xl:flex">
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={isMinimized} /> <Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/dashboard" isMinimized={isMinimized} />
{checkAccess(user, ["student", "teacher", "developer"], permissions, "viewExams") && ( {checkAccess(user, ["student", "teacher", "developer"], permissions, "viewExams") && (
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={isMinimized} /> <Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={isMinimized} />
)} )}
{checkAccess(user, ["student", "teacher", "developer"], permissions, "viewExercises") && (
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={isMinimized} />
)}
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewStats") && ( {checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewStats") && (
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} /> <Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
)} )}
{checkAccess(user, ["developer", "admin", "teacher", "student"], permissions) && ( {checkAccess(user, ["developer", "admin", "mastercorporate", "corporate", "teacher", "student"], permissions) && (
<Nav disabled={disableNavigation} Icon={BsPeople} label="Groups" path={path} keyPath="/groups" isMinimized={isMinimized} /> <Nav
disabled={disableNavigation}
Icon={BsPeople}
label="Classrooms"
path={path}
keyPath="/classrooms"
isMinimized={isMinimized}
/>
)} )}
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && ( {checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} /> <Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
@@ -150,7 +169,7 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
badge={totalAssignedTickets} badge={totalAssignedTickets}
/> />
)} )}
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && ( {(entitiesAllowGeneration.length > 0 || isAdmin) && (
<Nav <Nav
disabled={disableNavigation} disabled={disableNavigation}
Icon={BsCloudFill} Icon={BsCloudFill}
@@ -160,55 +179,31 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
isMinimized={isMinimized} isMinimized={isMinimized}
/> />
)} )}
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate", "agent"]) && (
<Nav
disabled={disableNavigation}
Icon={BsFileLock}
label="Permissions"
path={path}
keyPath="/permissions"
isMinimized={isMinimized}
/>
)}
</div> </div>
<div className="-xl:flex flex-col gap-3 xl:hidden"> <div className="-xl:flex flex-col gap-3 xl:hidden">
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={true} /> <Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized />
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={true} /> <Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized />
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={true} />
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewStats") && ( {checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewStats") && (
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={true} /> <Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized />
)} )}
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && ( {checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={true} /> <Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized />
)} )}
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && ( {checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
<Nav disabled={disableNavigation} Icon={CiDumbbell} label="Training" path={path} keyPath="/training" isMinimized={true} /> <Nav disabled={disableNavigation} Icon={CiDumbbell} label="Training" path={path} keyPath="/training" isMinimized />
)} )}
{checkAccess(user, getTypesOfUser(["student"])) && ( {checkAccess(user, getTypesOfUser(["student"])) && (
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Settings" path={path} keyPath="/settings" isMinimized={true} /> <Nav disabled={disableNavigation} Icon={BsShieldFill} label="Settings" path={path} keyPath="/settings" isMinimized />
)} )}
{checkAccess(user, getTypesOfUser(["student"])) && ( {entitiesAllowGeneration.length > 0 && (
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Permissions" path={path} keyPath="/permissions" isMinimized={true} />
)}
{checkAccess(user, ["developer"]) && (
<>
<Nav <Nav
disabled={disableNavigation} disabled={disableNavigation}
Icon={BsCloudFill} Icon={BsCloudFill}
label="Generation" label="Generation"
path={path} path={path}
keyPath="/generation" keyPath="/generation"
isMinimized={true} isMinimized
/> />
<Nav
disabled={disableNavigation}
Icon={BsFileLock}
label="Permissions"
path={path}
keyPath="/permissions"
isMinimized={true}
/>
</>
)} )}
</div> </div>

View File

@@ -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) => (
<div className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
<div className="flex flex-col gap-1 items-start">
<span>{displayUser.name}</span>
<span className="text-sm opacity-75">{displayUser.email}</span>
</div>
</div>
);
export default function UserDisplayList({ title, users }: Props) {
return (<div className="bg-white border shadow flex flex-col rounded-xl w-full">
<span className="p-4">{title}</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.slice(0, 10)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>)
}

View File

@@ -581,7 +581,7 @@ export default function AdminDashboard({user}: Props) {
.includes(x.id), .includes(x.id),
}); });
router.push("/list/users"); router.push("/users");
} }
: undefined : undefined
} }
@@ -601,7 +601,7 @@ export default function AdminDashboard({user}: Props) {
.includes(x.id), .includes(x.id),
}); });
router.push("/list/users"); router.push("/users");
} }
: undefined : undefined
} }
@@ -621,7 +621,7 @@ export default function AdminDashboard({user}: Props) {
.includes(x.id), .includes(x.id),
}); });
router.push("/list/users"); router.push("/users");
} }
: undefined : undefined
} }

View File

@@ -13,6 +13,7 @@ import {useAssignmentUnarchive} from "@/hooks/useAssignmentUnarchive";
import {useAssignmentRelease} from "@/hooks/useAssignmentRelease"; import {useAssignmentRelease} from "@/hooks/useAssignmentRelease";
import {getUserName} from "@/utils/users"; import {getUserName} from "@/utils/users";
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import { EntityWithRoles } from "@/interfaces/entity";
interface Props { interface Props {
users: User[]; users: User[];
@@ -22,6 +23,7 @@ interface Props {
allowArchive?: boolean; allowArchive?: boolean;
allowUnarchive?: boolean; allowUnarchive?: boolean;
allowExcelDownload?: boolean; allowExcelDownload?: boolean;
entityObj?: EntityWithRoles
} }
export default function AssignmentCard({ export default function AssignmentCard({
@@ -30,6 +32,7 @@ export default function AssignmentCard({
assigner, assigner,
startDate, startDate,
endDate, endDate,
entityObj,
assignees, assignees,
results, results,
exams, exams,
@@ -49,7 +52,6 @@ export default function AssignmentCard({
const renderUnarchiveIcon = useAssignmentUnarchive(id, reload); const renderUnarchiveIcon = useAssignmentUnarchive(id, reload);
const renderReleaseIcon = useAssignmentRelease(id, reload); const renderReleaseIcon = useAssignmentRelease(id, reload);
const calculateAverageModuleScore = (module: Module) => { const calculateAverageModuleScore = (module: Module) => {
const resultModuleBandScores = results.map((r) => { const resultModuleBandScores = results.map((r) => {
const moduleStats = r.stats.filter((s) => s.module === module); const moduleStats = r.stats.filter((s) => s.module === module);
@@ -69,22 +71,22 @@ export default function AssignmentCard({
// in order to be downloadable, the assignment has to be released // in order to be downloadable, the assignment has to be released
// the component should have the allowDownload prop // the component should have the allowDownload prop
// and the assignment should not have the level module // and the assignment should not have the level module
return uniqModules.every(({ module }) => module !== 'level'); return uniqModules.every(({module}) => module !== "level");
} }
return false; return false;
} };
const shouldRenderExcel = () => { const shouldRenderExcel = () => {
if (released && allowExcelDownload) { if (released && allowExcelDownload) {
// in order to be downloadable, the assignment has to be released // in order to be downloadable, the assignment has to be released
// the component should have the allowExcelDownload prop // the component should have the allowExcelDownload prop
// and the assignment should have the level module // and the assignment should have the level module
return uniqModules.some(({ module }) => module === 'level'); return uniqModules.some(({module}) => module === "level");
} }
return false; return false;
} };
return ( return (
<div <div
@@ -116,6 +118,7 @@ export default function AssignmentCard({
<span>{moment(endDate).format("DD/MM/YY, HH:mm")}</span> <span>{moment(endDate).format("DD/MM/YY, HH:mm")}</span>
</span> </span>
<span>Assigner: {getUserName(users.find((x) => x.id === assigner))}</span> <span>Assigner: {getUserName(users.find((x) => x.id === assigner))}</span>
{entityObj && <span>Entity: {entityObj.label}</span>}
</div> </div>
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2"> <div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
{uniqModules.map(({module}) => ( {uniqModules.map(({module}) => (

View File

@@ -16,24 +16,18 @@ import clsx from "clsx";
import {capitalize, uniqBy} from "lodash"; import {capitalize, uniqBy} from "lodash";
import moment from "moment"; import moment from "moment";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import { import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
BsBook,
BsClipboard,
BsHeadphones,
BsMegaphone,
BsPen,
} from "react-icons/bs";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
import {futureAssignmentFilter} from "@/utils/assignments"; import {futureAssignmentFilter} from "@/utils/assignments";
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
users: User[];
assignment?: Assignment; assignment?: Assignment;
onClose: () => void; onClose: () => void;
} }
export default function AssignmentView({ isOpen, assignment, onClose }: Props) { export default function AssignmentView({isOpen, users, assignment, onClose}: Props) {
const { users } = useUsers();
const router = useRouter(); const router = useRouter();
const setExams = useExamStore((state) => state.setExams); const setExams = useExamStore((state) => state.setExams);
@@ -46,11 +40,7 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
axios axios
.delete(`/api/assignments/${assignment?.id}`) .delete(`/api/assignments/${assignment?.id}`)
.then(() => .then(() => toast.success(`Successfully deleted the assignment "${assignment?.name}".`))
toast.success(
`Successfully deleted the assignment "${assignment?.name}".`
)
)
.catch(() => toast.error("Something went wrong, please try again later.")) .catch(() => toast.error("Something went wrong, please try again later."))
.finally(onClose); .finally(onClose);
}; };
@@ -60,9 +50,7 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
axios axios
.post(`/api/assignments/${assignment.id}/start`) .post(`/api/assignments/${assignment.id}/start`)
.then(() => { .then(() => {
toast.success( toast.success(`The assignment "${assignment.name}" has been started successfully!`);
`The assignment "${assignment.name}" has been started successfully!`
);
}) })
.catch((e) => { .catch((e) => {
console.log(e); console.log(e);
@@ -84,26 +72,15 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
const resultModuleBandScores = assignment.results.map((r) => { const resultModuleBandScores = assignment.results.map((r) => {
const moduleStats = r.stats.filter((s) => s.module === module); const moduleStats = r.stats.filter((s) => s.module === module);
const correct = moduleStats.reduce( const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0);
(acc, curr) => acc + curr.score.correct, const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0);
0
);
const total = moduleStats.reduce(
(acc, curr) => acc + curr.score.total,
0
);
return calculateBandScore(correct, total, module, r.type); return calculateBandScore(correct, total, module, r.type);
}); });
return resultModuleBandScores.length === 0 return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / assignment.results.length;
? -1
: resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) /
assignment.results.length;
}; };
const aggregateScoresByModule = ( const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => {
stats: Stat[]
): { module: Module; total: number; missing: number; correct: number }[] => {
const scores: { const scores: {
[key in Module]: {total: number; missing: number; correct: number}; [key in Module]: {total: number; missing: number; correct: number};
} = { } = {
@@ -147,22 +124,10 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
.map((x) => ({module: x as Module, ...scores[x as Module]})); .map((x) => ({module: x as Module, ...scores[x as Module]}));
}; };
const customContent = ( const customContent = (stats: Stat[], user: string, focus: "academic" | "general") => {
stats: Stat[], const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
user: string, const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0);
focus: "academic" | "general" const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0);
) => {
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) => ({ const aggregatedLevels = aggregatedScores.map((x) => ({
module: x.module, module: x.module,
@@ -172,9 +137,7 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
const timeSpent = stats[0].timeSpent; const timeSpent = stats[0].timeSpent;
const selectExam = () => { const selectExam = () => {
const examPromises = uniqBy(stats, "exam").map((stat) => const examPromises = uniqBy(stats, "exam").map((stat) => getExamById(stat.module, stat.exam));
getExamById(stat.module, stat.exam)
);
Promise.all(examPromises).then((exams) => { Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) { if (exams.every((x) => !!x)) {
@@ -185,9 +148,9 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
exams exams
.map((x) => x!) .map((x) => x!)
.sort(sortByModule) .sort(sortByModule)
.map((x) => x!.module) .map((x) => x!.module),
); );
router.push("/exercises"); router.push("/exam");
} }
}); });
}; };
@@ -196,15 +159,11 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
<> <>
<div className="-md:items-center flex w-full justify-between 2xl:items-center"> <div className="-md:items-center flex w-full justify-between 2xl:items-center">
<div className="-md:gap-2 -md:items-center flex md:flex-col md:gap-1 2xl:flex-row 2xl:items-center 2xl:gap-2"> <div className="-md:gap-2 -md:items-center flex md:flex-col md:gap-1 2xl:flex-row 2xl:items-center 2xl:gap-2">
<span className="font-medium"> <span className="font-medium">{formatTimestamp(stats[0].date.toString())}</span>
{formatTimestamp(stats[0].date.toString())}
</span>
{timeSpent && ( {timeSpent && (
<> <>
<span className="md:hidden 2xl:flex"> </span> <span className="md:hidden 2xl:flex"> </span>
<span className="text-sm"> <span className="text-sm">{Math.floor(timeSpent / 60)} minutes</span>
{Math.floor(timeSpent / 60)} minutes
</span>
</> </>
)} )}
</div> </div>
@@ -212,16 +171,10 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
className={clsx( className={clsx(
correct / total >= 0.7 && "text-mti-purple", correct / total >= 0.7 && "text-mti-purple",
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red", correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
correct / total < 0.3 && "text-mti-rose" correct / total < 0.3 && "text-mti-rose",
)} )}>
>
Level{" "} Level{" "}
{( {(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)}
aggregatedLevels.reduce(
(accumulator, current) => accumulator + current.level,
0
) / aggregatedLevels.length
).toFixed(1)}
</span> </span>
</div> </div>
@@ -236,9 +189,8 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
module === "listening" && "bg-ielts-listening", module === "listening" && "bg-ielts-listening",
module === "writing" && "bg-ielts-writing", module === "writing" && "bg-ielts-writing",
module === "speaking" && "bg-ielts-speaking", module === "speaking" && "bg-ielts-speaking",
module === "level" && "bg-ielts-level" module === "level" && "bg-ielts-level",
)} )}>
>
{module === "reading" && <BsBook className="h-4 w-4" />} {module === "reading" && <BsBook className="h-4 w-4" />}
{module === "listening" && <BsHeadphones className="h-4 w-4" />} {module === "listening" && <BsHeadphones className="h-4 w-4" />}
{module === "writing" && <BsPen className="h-4 w-4" />} {module === "writing" && <BsPen className="h-4 w-4" />}
@@ -265,14 +217,11 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
className={clsx( className={clsx(
"border-mti-gray-platinum -md:hidden flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out", "border-mti-gray-platinum -md:hidden flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out",
correct / total >= 0.7 && "hover:border-mti-purple", correct / total >= 0.7 && "hover:border-mti-purple",
correct / total >= 0.3 && correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
correct / total < 0.7 && correct / total < 0.3 && "hover:border-mti-rose",
"hover:border-mti-red",
correct / total < 0.3 && "hover:border-mti-rose"
)} )}
onClick={selectExam} onClick={selectExam}
role="button" role="button">
>
{content} {content}
</div> </div>
<div <div
@@ -280,14 +229,11 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
className={clsx( className={clsx(
"border-mti-gray-platinum -md:tooltip flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out md:hidden", "border-mti-gray-platinum -md:tooltip flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out md:hidden",
correct / total >= 0.7 && "hover:border-mti-purple", correct / total >= 0.7 && "hover:border-mti-purple",
correct / total >= 0.3 && correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
correct / total < 0.7 && correct / total < 0.3 && "hover:border-mti-rose",
"hover:border-mti-red",
correct / total < 0.3 && "hover:border-mti-rose"
)} )}
data-tip="Your screen size is too small to view previous exams." data-tip="Your screen size is too small to view previous exams."
role="button" role="button">
>
{content} {content}
</div> </div>
</div> </div>
@@ -312,27 +258,14 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`} label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
className="h-6" className="h-6"
textClassName={ textClassName={
(assignment?.results.length || 0) / (assignment?.results.length || 0) / (assignment?.assignees.length || 1) < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"
(assignment?.assignees.length || 1) <
0.5
? "!text-mti-gray-dim font-light"
: "text-white"
}
percentage={
((assignment?.results.length || 0) /
(assignment?.assignees.length || 1)) *
100
} }
percentage={((assignment?.results.length || 0) / (assignment?.assignees.length || 1)) * 100}
/> />
<div className="flex items-start gap-8"> <div className="flex items-start gap-8">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span> <span>Start Date: {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}</span>
Start Date:{" "} <span>End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}</span>
{moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}
</span>
<span>
End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}
</span>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span> <span>
@@ -342,10 +275,7 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
.map((u) => `${u.name} (${u.email})`) .map((u) => `${u.name} (${u.email})`)
.join(", ")} .join(", ")}
</span> </span>
<span> <span>Assigner: {getUserName(users.find((x) => x.id === assignment?.assigner))}</span>
Assigner:{" "}
{getUserName(users.find((x) => x.id === assignment?.assigner))}
</span>
</div> </div>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -362,20 +292,15 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
module === "listening" && "bg-ielts-listening", module === "listening" && "bg-ielts-listening",
module === "writing" && "bg-ielts-writing", module === "writing" && "bg-ielts-writing",
module === "speaking" && "bg-ielts-speaking", module === "speaking" && "bg-ielts-speaking",
module === "level" && "bg-ielts-level" module === "level" && "bg-ielts-level",
)} )}>
>
{module === "reading" && <BsBook className="h-4 w-4" />} {module === "reading" && <BsBook className="h-4 w-4" />}
{module === "listening" && ( {module === "listening" && <BsHeadphones className="h-4 w-4" />}
<BsHeadphones className="h-4 w-4" />
)}
{module === "writing" && <BsPen className="h-4 w-4" />} {module === "writing" && <BsPen className="h-4 w-4" />}
{module === "speaking" && <BsMegaphone className="h-4 w-4" />} {module === "speaking" && <BsMegaphone className="h-4 w-4" />}
{module === "level" && <BsClipboard className="h-4 w-4" />} {module === "level" && <BsClipboard className="h-4 w-4" />}
{calculateAverageModuleScore(module) > -1 && ( {calculateAverageModuleScore(module) > -1 && (
<span className="text-sm"> <span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
{calculateAverageModuleScore(module).toFixed(1)}
</span>
)} )}
</div> </div>
))} ))}
@@ -383,44 +308,27 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span className="text-xl font-bold"> <span className="text-xl font-bold">
Results ({assignment?.results.length}/{assignment?.assignees.length} Results ({assignment?.results.length}/{assignment?.assignees.length})
)
</span> </span>
<div> <div>
{assignment && assignment?.results.length > 0 && ( {assignment && assignment?.results.length > 0 && (
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 xl:gap-6"> <div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 xl:gap-6">
{assignment.results.map((r) => {assignment.results.map((r) => customContent(r.stats, r.user, r.type))}
customContent(r.stats, r.user, r.type)
)}
</div> </div>
)} )}
{assignment && assignment?.results.length === 0 && ( {assignment && assignment?.results.length === 0 && <span className="ml-1 font-semibold">No results yet...</span>}
<span className="ml-1 font-semibold">No results yet...</span>
)}
</div> </div>
</div> </div>
<div className="flex gap-4 w-full items-center justify-end"> <div className="flex gap-4 w-full items-center justify-end">
{assignment && {assignment && (assignment.results.length === assignment.assignees.length || moment().isAfter(moment(assignment.endDate))) && (
(assignment.results.length === assignment.assignees.length || <Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={deleteAssignment}>
moment().isAfter(moment(assignment.endDate))) && (
<Button
variant="outline"
color="red"
className="w-full max-w-[200px]"
onClick={deleteAssignment}
>
Delete Delete
</Button> </Button>
)} )}
{/** if the assignment is not deemed as active yet, display start */} {/** if the assignment is not deemed as active yet, display start */}
{shouldRenderStart() && ( {shouldRenderStart() && (
<Button <Button variant="outline" color="green" className="w-full max-w-[200px]" onClick={startAssignment}>
variant="outline"
color="green"
className="w-full max-w-[200px]"
onClick={startAssignment}
>
Start Start
</Button> </Button>
)} )}

View File

@@ -1,61 +1,40 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser"; 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 {CorporateUser, Group, MasterCorporateUser, Stat, User} from "@/interfaces/user";
import UserList from "@/pages/(admin)/Lists/UserList"; import UserList from "@/pages/(admin)/Lists/UserList";
import {dateSorter} from "@/utils"; import {dateSorter, mapBy} from "@/utils";
import moment from "moment"; import moment from "moment";
import {useEffect, useMemo, useState} from "react"; import {useEffect, useMemo, useState} from "react";
import { import {
BsArrowLeft, BsArrowLeft,
BsClipboard2Data, BsClipboard2Data,
BsClipboard2DataFill,
BsClock, BsClock,
BsGlobeCentralSouthAsia,
BsPaperclip, BsPaperclip,
BsPerson,
BsPersonAdd,
BsPersonFill, BsPersonFill,
BsPersonFillGear, BsPersonFillGear,
BsPersonGear,
BsPencilSquare, BsPencilSquare,
BsPersonBadge,
BsPersonCheck, BsPersonCheck,
BsPeople, BsPeople,
BsArrowRepeat,
BsPlus,
BsEnvelopePaper, BsEnvelopePaper,
BsDatabase, BsDatabase,
} from "react-icons/bs"; } from "react-icons/bs";
import UserCard from "@/components/UserCard"; import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
import {averageLevelCalculator, calculateAverageLevel, calculateBandScore} from "@/utils/score"; import {calculateAverageLevel, calculateBandScore} from "@/utils/score";
import {MODULE_ARRAY} from "@/utils/moduleUtils";
import {Module} from "@/interfaces"; import {Module} from "@/interfaces";
import {groupByExam} from "@/utils/stats"; import {groupByExam} from "@/utils/stats";
import IconCard from "../IconCard"; import IconCard from "../IconCard";
import GroupList from "@/pages/(admin)/Lists/GroupList"; import GroupList from "@/pages/(admin)/Lists/GroupList";
import useFilterStore from "@/stores/listFilterStore"; import useFilterStore from "@/stores/listFilterStore";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import useCodes from "@/hooks/useCodes";
import {getUserCorporate} from "@/utils/groups";
import useAssignments from "@/hooks/useAssignments"; 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 useUserBalance from "@/hooks/useUserBalance";
import AssignmentsPage from "../views/AssignmentsPage"; import AssignmentsPage from "../views/AssignmentsPage";
import StudentPerformancePage from "./StudentPerformancePage"; import StudentPerformancePage from "./StudentPerformancePage";
import MasterStatistical from "../MasterCorporate/MasterStatistical";
import MasterStatisticalPage from "./MasterStatisticalPage"; import MasterStatisticalPage from "./MasterStatisticalPage";
import {getEntitiesUsers} from "@/utils/users.be";
interface Props { interface Props {
user: CorporateUser; 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 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(() => { useEffect(() => {
setShowModal(!!selectedUser && router.asPath === "/#"); setShowModal(!!selectedUser && router.asPath === "/#");
}, [selectedUser, router.asPath]); }, [selectedUser, router.asPath]);
@@ -251,7 +217,7 @@ export default function CorporateDashboard({user, linkedCorporate}: Props) {
.includes(x.id), .includes(x.id),
}); });
router.push("/list/users"); router.push("/users");
} }
: undefined : undefined
} }
@@ -271,7 +237,7 @@ export default function CorporateDashboard({user, linkedCorporate}: Props) {
.includes(x.id), .includes(x.id),
}); });
router.push("/list/users"); router.push("/users");
} }
: undefined : undefined
} }

View File

@@ -22,10 +22,10 @@ export default function IconCard({Icon, label, value, color, tooltip, onClick, c
}; };
return ( return (
<div <button
onClick={onClick} onClick={onClick}
className={clsx( className={clsx(
"bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center text-center w-52 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300", "bg-white border rounded-xl shadow p-4 flex flex-col gap-4 items-center text-center w-full h-52 justify-center cursor-pointer hover:shadow-lg hover:border-mti-purple-dark transition ease-in-out duration-300",
tooltip && "tooltip tooltip-bottom", tooltip && "tooltip tooltip-bottom",
isSelected && `border border-solid border-${colorClasses[color]}`, isSelected && `border border-solid border-${colorClasses[color]}`,
className, className,
@@ -38,6 +38,6 @@ export default function IconCard({Icon, label, value, color, tooltip, onClick, c
{isLoading ? "..." : value} {isLoading ? "..." : value}
</span> </span>
</span> </span>
</div> </button>
); );
} }

View File

@@ -276,7 +276,7 @@ export default function MasterCorporateDashboard({user}: Props) {
.includes(x.id), .includes(x.id),
}); });
router.push("/list/users"); router.push("/users");
} }
: undefined : undefined
} }
@@ -296,7 +296,7 @@ export default function MasterCorporateDashboard({user}: Props) {
.includes(x.id), .includes(x.id),
}); });
router.push("/list/users"); router.push("/users");
} }
: undefined : undefined
} }

View File

@@ -71,7 +71,7 @@ export default function StudentDashboard({user, linkedCorporate}: Props) {
); );
setAssignment(assignment); setAssignment(assignment);
router.push("/exercises"); router.push("/exam");
} }
}); });
}; };

View File

@@ -202,7 +202,7 @@ export default function TeacherDashboard({user, linkedCorporate}: Props) {
.includes(x.id), .includes(x.id),
}); });
router.push("/list/users"); router.push("/users");
} }
: undefined : undefined
} }
@@ -222,7 +222,7 @@ export default function TeacherDashboard({user, linkedCorporate}: Props) {
.includes(x.id), .includes(x.id),
}); });
router.push("/list/users"); router.push("/users");
} }
: undefined : undefined
} }

View File

@@ -42,6 +42,7 @@ export default function AssignmentsPage({assignments, corporateAssignments, user
{displayAssignmentView && ( {displayAssignmentView && (
<AssignmentView <AssignmentView
isOpen={displayAssignmentView} isOpen={displayAssignmentView}
users={users}
onClose={() => { onClose={() => {
setSelectedAssignment(undefined); setSelectedAssignment(undefined);
setIsCreatingAssignment(false); setIsCreatingAssignment(false);

View File

@@ -19,7 +19,7 @@
</p> </p>
<br /> <br />
<p>Don't forget to do it before its end date!</p> <p>Don't forget to do it before its end date!</p>
<p>Click <b><a href="https://{{environment}}.encoach.com">here</a></b> to open the EnCoach Platform!</p> <p>Click <b><a href="https://{{environment}}.encoach.com/exam?assignment={{assignment.id}}">here</a></b> to open the assignment on EnCoach!</p>
<br /> <br />
<p>Thanks,</p> <p>Thanks,</p>
<p>Your EnCoach team</p> <p>Your EnCoach team</p>

View File

@@ -8,6 +8,7 @@
"assignees": [], "assignees": [],
"modules": "Reading and Writing", "modules": "Reading and Writing",
"startDate": "24/12/2023", "startDate": "24/12/2023",
"endDate": "27/01/2024" "endDate": "27/01/2024",
"id": "123"
} }
} }

View File

@@ -13,7 +13,7 @@
<span>Hello {{name}},</span> <span>Hello {{name}},</span>
<br/> <br/>
<br/> <br/>
<span>You have been invited to join {{corporateName}}'s group!</span> <span>You have been invited to join the {{entity}} entity!</span>
<br /> <br />
<br/> <br/>
<span>Please access the platform to accept or decline the invite.</span> <span>Please access the platform to accept or decline the invite.</span>

View File

@@ -0,0 +1,16 @@
<html>
<head>
<style>
</style>
</head>
<body>
<p>Hi {{name}},</p>
<p>You requested to reset your password.</p>
<p> Please, click the link below to reset your password</p>
<a href="https://{{link}}">Reset Password</a>
</body>
</html>

View File

@@ -1,5 +1,5 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import {useState} from "react"; import {useMemo, useState} from "react";
import {Module} from "@/interfaces"; import {Module} from "@/interfaces";
import clsx from "clsx"; import clsx from "clsx";
import {Stat, User} from "@/interfaces/user"; import {Stat, User} from "@/interfaces/user";
@@ -22,10 +22,9 @@ interface Props {
user: User; user: User;
page: "exercises" | "exams"; page: "exercises" | "exams";
onStart: (modules: Module[], avoidRepeated: boolean, variant: Variant) => void; onStart: (modules: Module[], avoidRepeated: boolean, variant: Variant) => void;
disableSelection?: boolean;
} }
export default function Selection({user, page, onStart, disableSelection = false}: Props) { export default function Selection({user, page, onStart}: Props) {
const [selectedModules, setSelectedModules] = useState<Module[]>([]); const [selectedModules, setSelectedModules] = useState<Module[]>([]);
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true); const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
const [variant, setVariant] = useState<Variant>("full"); const [variant, setVariant] = useState<Variant>("full");
@@ -40,6 +39,10 @@ export default function Selection({user, page, onStart, disableSelection = false
setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module])); setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module]));
}; };
const isCompleteExam = useMemo(() =>
["reading", "listening", "writing", "speaking"].every(m => selectedModules.includes(m as Module)), [selectedModules]
)
const loadSession = async (session: Session) => { const loadSession = async (session: Session) => {
state.setShuffles(session.userSolutions.map((x) => ({exerciseID: x.exercise, shuffles: x.shuffleMaps ? x.shuffleMaps : []}))); state.setShuffles(session.userSolutions.map((x) => ({exerciseID: x.exercise, shuffles: x.shuffleMaps ? x.shuffleMaps : []})));
state.setSelectedModules(session.selectedModules); state.setSelectedModules(session.selectedModules);
@@ -146,10 +149,10 @@ export default function Selection({user, page, onStart, disableSelection = false
<section className="-lg:flex-col -lg:items-center -lg:gap-12 mt-4 flex w-full justify-between gap-8"> <section className="-lg:flex-col -lg:items-center -lg:gap-12 mt-4 flex w-full justify-between gap-8">
<div <div
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("reading") : undefined} onClick={!selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
className={clsx( className={clsx(
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
selectedModules.includes("reading") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum", selectedModules.includes("reading") ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}> )}>
<div className="bg-ielts-reading absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full"> <div className="bg-ielts-reading absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
<BsBook className="h-7 w-7 text-white" /> <BsBook className="h-7 w-7 text-white" />
@@ -158,19 +161,19 @@ export default function Selection({user, page, onStart, disableSelection = false
<p className="text-left text-xs"> <p className="text-left text-xs">
Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English. Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English.
</p> </p>
{!selectedModules.includes("reading") && !selectedModules.includes("level") && !disableSelection && ( {!selectedModules.includes("reading") && !selectedModules.includes("level") && (
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" /> <div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
)} )}
{(selectedModules.includes("reading") || disableSelection) && ( {(selectedModules.includes("reading")) && (
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" /> <BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
)} )}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />} {selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
</div> </div>
<div <div
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("listening") : undefined} onClick={!selectedModules.includes("level") ? () => toggleModule("listening") : undefined}
className={clsx( className={clsx(
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
selectedModules.includes("listening") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum", selectedModules.includes("listening") ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}> )}>
<div className="bg-ielts-listening absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full"> <div className="bg-ielts-listening absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
<BsHeadphones className="h-7 w-7 text-white" /> <BsHeadphones className="h-7 w-7 text-white" />
@@ -179,19 +182,19 @@ export default function Selection({user, page, onStart, disableSelection = false
<p className="text-left text-xs"> <p className="text-left text-xs">
Improve your ability to follow conversations in English and your ability to understand different accents and intonations. Improve your ability to follow conversations in English and your ability to understand different accents and intonations.
</p> </p>
{!selectedModules.includes("listening") && !selectedModules.includes("level") && !disableSelection && ( {!selectedModules.includes("listening") && !selectedModules.includes("level") && (
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" /> <div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
)} )}
{(selectedModules.includes("listening") || disableSelection) && ( {(selectedModules.includes("listening")) && (
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" /> <BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
)} )}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />} {selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
</div> </div>
<div <div
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("writing") : undefined} onClick={!selectedModules.includes("level") ? () => toggleModule("writing") : undefined}
className={clsx( className={clsx(
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
selectedModules.includes("writing") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum", selectedModules.includes("writing") ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}> )}>
<div className="bg-ielts-writing absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full"> <div className="bg-ielts-writing absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
<BsPen className="h-7 w-7 text-white" /> <BsPen className="h-7 w-7 text-white" />
@@ -200,19 +203,19 @@ export default function Selection({user, page, onStart, disableSelection = false
<p className="text-left text-xs"> <p className="text-left text-xs">
Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays. Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays.
</p> </p>
{!selectedModules.includes("writing") && !selectedModules.includes("level") && !disableSelection && ( {!selectedModules.includes("writing") && !selectedModules.includes("level") && (
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" /> <div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
)} )}
{(selectedModules.includes("writing") || disableSelection) && ( {(selectedModules.includes("writing")) && (
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" /> <BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
)} )}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />} {selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
</div> </div>
<div <div
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("speaking") : undefined} onClick={!selectedModules.includes("level") ? () => toggleModule("speaking") : undefined}
className={clsx( className={clsx(
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
selectedModules.includes("speaking") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum", selectedModules.includes("speaking") ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}> )}>
<div className="bg-ielts-speaking absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full"> <div className="bg-ielts-speaking absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
<BsMegaphone className="h-7 w-7 text-white" /> <BsMegaphone className="h-7 w-7 text-white" />
@@ -221,37 +224,35 @@ export default function Selection({user, page, onStart, disableSelection = false
<p className="text-left text-xs"> <p className="text-left text-xs">
You&apos;ll have access to interactive dialogs, pronunciation exercises and speech recordings. You&apos;ll have access to interactive dialogs, pronunciation exercises and speech recordings.
</p> </p>
{!selectedModules.includes("speaking") && !selectedModules.includes("level") && !disableSelection && ( {!selectedModules.includes("speaking") && !selectedModules.includes("level") && (
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" /> <div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
)} )}
{(selectedModules.includes("speaking") || disableSelection) && ( {(selectedModules.includes("speaking")) && (
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" /> <BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
)} )}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />} {selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
</div> </div>
{!disableSelection && (
<div <div
onClick={selectedModules.length === 0 || selectedModules.includes("level") ? () => toggleModule("level") : undefined} onClick={selectedModules.length === 0 || selectedModules.includes("level") ? () => toggleModule("level") : undefined}
className={clsx( className={clsx(
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
selectedModules.includes("level") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum", selectedModules.includes("level") ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}> )}>
<div className="bg-ielts-level absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full"> <div className="bg-ielts-level absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
<BsClipboard className="h-7 w-7 text-white" /> <BsClipboard className="h-7 w-7 text-white" />
</div> </div>
<span className="font-semibold">Level:</span> <span className="font-semibold">Level:</span>
<p className="text-left text-xs">You&apos;ll be able to test your english level with multiple choice questions.</p> <p className="text-left text-xs">You&apos;ll be able to test your english level with multiple choice questions.</p>
{!selectedModules.includes("level") && selectedModules.length === 0 && !disableSelection && ( {!selectedModules.includes("level") && selectedModules.length === 0 && (
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" /> <div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
)} )}
{(selectedModules.includes("level") || disableSelection) && ( {(selectedModules.includes("level")) && (
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" /> <BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
)} )}
{!selectedModules.includes("level") && selectedModules.length > 0 && ( {!selectedModules.includes("level") && selectedModules.length > 0 && (
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" /> <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
)} )}
</div> </div>
)}
</section> </section>
<div className="-md:flex-col -md:gap-4 -md:justify-center flex w-full items-center md:justify-between"> <div className="-md:flex-col -md:gap-4 -md:justify-center flex w-full items-center md:justify-between">
<div className="flex w-full flex-col items-center gap-3"> <div className="flex w-full flex-col items-center gap-3">
@@ -291,21 +292,31 @@ export default function Selection({user, page, onStart, disableSelection = false
Start Exam Start Exam
</Button> </Button>
</div> </div>
<div className="flex items-center gap-4 w-full">
<Button
color="green"
variant={isCompleteExam ? "solid" : "outline"}
onClick={() => isCompleteExam ? setSelectedModules([]) : setSelectedModules(["reading", "listening", "writing", "speaking"])}
className="-md:hidden w-full max-w-xs px-12 md:self-end"
>
Complete Exam
</Button>
<Button <Button
onClick={() => onClick={() =>
onStart( onStart(
!disableSelection ? selectedModules.sort(sortByModuleName) : ["reading", "listening", "writing", "speaking"], selectedModules.sort(sortByModuleName),
avoidRepeatedExams, avoidRepeatedExams,
variant, variant,
) )
} }
color="purple" color="purple"
className="-md:hidden w-full max-w-xs px-12 md:self-end" className="-md:hidden w-full max-w-xs px-12 md:self-end"
disabled={selectedModules.length === 0 && !disableSelection}> disabled={selectedModules.length === 0}>
Start Exam Start Exam
</Button> </Button>
</div> </div>
</div> </div>
</div>
</> </>
); );
} }

View File

@@ -27,6 +27,12 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state); const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state); const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
useEffect(() => {
if (hasExamEnded && exerciseIndex === -1) {
setExerciseIndex(exerciseIndex + 1);
}
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0)); const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
const nextExercise = (solution?: UserSolution) => { const nextExercise = (solution?: UserSolution) => {

View File

@@ -32,6 +32,12 @@ export default function Writing({ exam, showSolutions = false, preview = false,
const [seenParts, setSeenParts] = useState<Set<number>>(new Set(showSolutions ? exam.exercises.map((_, index) => index) : [])); const [seenParts, setSeenParts] = useState<Set<number>>(new Set(showSolutions ? exam.exercises.map((_, index) => index) : []));
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.exercises[0].intro === "string" && exam.exercises[0].intro !== ""); const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.exercises[0].intro === "string" && exam.exercises[0].intro !== "");
useEffect(() => {
if (hasExamEnded && exerciseIndex === -1) {
setExerciseIndex(exerciseIndex + 1);
}
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0)); const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
useEffect(() => { useEffect(() => {

23
src/hooks/useEntities.tsx Normal file
View File

@@ -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<EntityWithRoles[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = () => {
setIsLoading(true);
axios
.get<EntityWithRoles[]>("/api/entities?showRoles=true")
.then((response) => setEntities(response.data))
.finally(() => setIsLoading(false));
};
useEffect(getData, []);
return { entities, isLoading, isError, reload: getData };
}

View File

@@ -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<WithEntity<Group>[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = () => {
setIsLoading(true);
axios
.get<WithEntity<Group>[]>(`/api/entities/groups`)
.then((response) => setGroups(response.data))
.finally(() => setIsLoading(false));
};
useEffect(getData, []);
return { groups, isLoading, isError, reload: getData };
}

View File

@@ -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<WithLabeledEntities<User>[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = () => {
setIsLoading(true);
axios
.get<WithLabeledEntities<User>[]>(`/api/entities/users${type ? "?type=" + type : ""}`)
.then((response) => setUsers(response.data))
.finally(() => setIsLoading(false));
};
useEffect(getData, [type]);
return { users, isLoading, isError, reload: getData };
}

View File

@@ -0,0 +1,24 @@
import { EntityWithRoles } from "@/interfaces/entity";
import { User } from "@/interfaces/user";
import { RolePermission } from "@/resources/entityPermissions";
import { mapBy } from "@/utils";
import { doesEntityAllow, findAllowedEntities, findAllowedEntitiesSomePermissions } from "@/utils/permissions";
import { isAdmin } from "@/utils/users";
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 useAllowedEntitiesSomePermissions = (user: User, entities: EntityWithRoles[], permissions: RolePermission[]) => {
const allowedEntityIds = useMemo(() => findAllowedEntitiesSomePermissions(user, entities, permissions), [user, entities, permissions])
return allowedEntityIds
}
export const useEntityPermission = (user: User, entity?: EntityWithRoles, permission?: RolePermission) => {
if (isAdmin(user)) return true
if (!entity || !permission) return false
return doesEntityAllow(user, entity, permission)
}

View File

@@ -5,7 +5,7 @@ import {search} from "@/utils/search";
export function useListSearch<T>(fields: string[][], rows: T[]) { export function useListSearch<T>(fields: string[][], rows: T[]) {
const [text, setText] = useState(""); const [text, setText] = useState("");
const renderSearch = () => <Input label="Search" type="text" name="search" onChange={setText} placeholder="Enter search text" value={text} />; const renderSearch = () => <Input type="text" name="search" onChange={setText} placeholder="Enter search text" value={text} />;
const updatedRows = useMemo(() => { const updatedRows = useMemo(() => {
if (text.length > 0) return search(text, fields, rows); if (text.length > 0) return search(text, fields, rows);

View File

@@ -1,5 +1,7 @@
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import {useMemo, useState} from "react"; import {useMemo, useState} from "react";
import {BiChevronLeft} from "react-icons/bi";
import {BsChevronDoubleLeft, BsChevronDoubleRight, BsChevronLeft, BsChevronRight} from "react-icons/bs";
export default function usePagination<T>(list: T[], size = 25) { export default function usePagination<T>(list: T[], size = 25) {
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
@@ -24,5 +26,35 @@ export default function usePagination<T>(list: T[], size = 25) {
</div> </div>
); );
return {page, items, setPage, render}; const renderMinimal = () => (
<div className="flex gap-4 items-center">
<div className="flex gap-2 items-center">
<button disabled={page === 0} onClick={() => setPage(0)} className="disabled:opacity-60 disabled:cursor-not-allowed">
<BsChevronDoubleLeft />
</button>
<button disabled={page === 0} onClick={() => setPage((prev) => prev - 1)} className="disabled:opacity-60 disabled:cursor-not-allowed">
<BsChevronLeft />
</button>
</div>
<span className="opacity-80 w-32 text-center">
{page * size + 1} - {(page + 1) * size > list.length ? list.length : (page + 1) * size} / {list.length}
</span>
<div className="flex gap-2 items-center">
<button
disabled={(page + 1) * size >= list.length}
onClick={() => setPage((prev) => prev + 1)}
className="disabled:opacity-60 disabled:cursor-not-allowed">
<BsChevronRight />
</button>
<button
disabled={(page + 1) * size >= list.length}
onClick={() => setPage(Math.floor(list.length / size))}
className="disabled:opacity-60 disabled:cursor-not-allowed">
<BsChevronDoubleRight />
</button>
</div>
</div>
);
return {page, items, setPage, render, renderMinimal};
} }

31
src/interfaces/entity.ts Normal file
View File

@@ -0,0 +1,31 @@
import { RolePermission } from "@/resources/entityPermissions";
export interface Entity {
id: string;
label: string;
}
export interface Role {
id: string;
entityID: string;
permissions: RolePermission[];
label: string;
isDefault?: boolean
}
export interface EntityWithRoles extends Entity {
roles: Role[];
};
export type WithLabeledEntities<T> = T extends { entities: { id: string; role: string }[] }
? Omit<T, "entities"> & { entities: { id: string; label?: string; role: string, roleLabel?: string }[] }
: T;
export type WithEntity<T> = T extends { entity?: string }
? Omit<T, "entity"> & { entity: Entity }
: T;
export type WithEntities<T> = T extends { entities: { id: string; role: string }[] }
? Omit<T, "entities"> & { entities: { entity?: Entity; role?: Role }[] }
: T;

View File

@@ -8,5 +8,6 @@ export interface Step {
export interface Grading { export interface Grading {
user: string; user: string;
entity?: string;
steps: Step[]; steps: Step[];
} }

View File

@@ -1,5 +1,12 @@
import { Entity } from "./entity";
export interface Invite { export interface Invite {
id: string; id: string;
from: string; entity: string;
from: string
to: string; to: string;
} }
export interface InviteWithEntity extends Omit<Invite, "entity"> {
entity?: Entity;
}

View File

@@ -34,6 +34,7 @@ export interface Assignment {
start?: boolean; start?: boolean;
autoStartDate?: Date; autoStartDate?: Date;
autoStart?: boolean; autoStart?: boolean;
entity?: string;
} }
export type AssignmentWithCorporateId = Assignment & {corporateId: string}; export type AssignmentWithCorporateId = Assignment & {corporateId: string};

View File

@@ -22,6 +22,7 @@ export interface BasicUser {
status: UserStatus; status: UserStatus;
permissions: PermissionType[]; permissions: PermissionType[];
lastLogin?: Date; lastLogin?: Date;
entities: {id: string; role: string}[];
} }
export interface StudentUser extends BasicUser { export interface StudentUser extends BasicUser {
@@ -149,6 +150,12 @@ export interface Group {
participants: string[]; participants: string[];
id: string; id: string;
disableEditing?: boolean; disableEditing?: boolean;
entity?: string;
}
export interface GroupWithUsers extends Omit<Group, "participants" | "admin"> {
admin: User;
participants: User[];
} }
export interface Code { export interface Code {
@@ -166,3 +173,5 @@ export interface Code {
export type Type = "student" | "teacher" | "corporate" | "admin" | "developer" | "agent" | "mastercorporate"; export type Type = "student" | "teacher" | "corporate" | "admin" | "developer" | "agent" | "mastercorporate";
export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent", "mastercorporate"]; export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent", "mastercorporate"];
export type WithUser<T> = T extends {participants: string[]} ? Omit<T, "participants"> & {participants: User[]} : T;

View File

@@ -3,12 +3,13 @@ import { Type as UserType} from "@/interfaces/user";
export type Type = Exclude<UserType, "admin" | "developer" | "agent" | "mastercorporate">; export type Type = Exclude<UserType, "admin" | "developer" | "agent" | "mastercorporate">;
export interface UserImport { export interface UserImport {
id: string;
email: string; email: string;
name: string; name: string;
passport_id: string; passport_id: string;
type: Type; type: Type;
groupName: string; groupName: string;
corporate: string; entity: string;
studentID: string; studentID: string;
demographicInformation: { demographicInformation: {
country: string; country: string;

View File

@@ -40,13 +40,9 @@ const columns = [
cell: info => info.getValue(), cell: info => info.getValue(),
header: () => 'Phone Number', header: () => 'Phone Number',
}), }),
columnHelper.accessor('corporate', {
cell: info => info.getValue(),
header: () => 'Corporate (e-mail)',
}),
columnHelper.accessor('groupName', { columnHelper.accessor('groupName', {
cell: info => info.getValue(), cell: info => info.getValue(),
header: () => 'Group Name', header: () => 'Classroom Name',
}), }),
columnHelper.accessor('demographicInformation.country', { columnHelper.accessor('demographicInformation.country', {
cell: info => info.getValue(), cell: info => info.getValue(),

View File

@@ -17,6 +17,8 @@ import countryCodes from "country-codes-list";
import { User, Type as UserType } from "@/interfaces/user"; import { User, Type as UserType } from "@/interfaces/user";
import { Type, UserImport } from "./IUserImport"; import { Type, UserImport } from "./IUserImport";
import UserTable from "./UserTable"; import UserTable from "./UserTable";
import { EntityWithRoles } from "@/interfaces/entity";
import Select from "@/components/Low/Select";
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/); const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
@@ -62,10 +64,11 @@ const USER_TYPE_PERMISSIONS: {
interface Props { interface Props {
user: User; user: User;
permissions: PermissionType[]; permissions: PermissionType[];
entities: EntityWithRoles[]
onFinish: () => void; onFinish: () => void;
} }
export default function BatchCreateUser({ user, permissions, onFinish }: Props) { export default function BatchCreateUser({ user, entities, permissions, onFinish }: Props) {
const [infos, setInfos] = useState<UserImport[]>([]); const [infos, setInfos] = useState<UserImport[]>([]);
const [duplicatedUsers, setDuplicatedUsers] = useState<UserImport[]>([]); const [duplicatedUsers, setDuplicatedUsers] = useState<UserImport[]>([]);
@@ -78,6 +81,7 @@ export default function BatchCreateUser({ user, permissions, onFinish }: Props)
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true); const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
const [type, setType] = useState<Type>("student"); const [type, setType] = useState<Type>("student");
const [showHelp, setShowHelp] = useState(false); const [showHelp, setShowHelp] = useState(false);
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined)
const { openFilePicker, filesContent, clear } = useFilePicker({ const { openFilePicker, filesContent, clear } = useFilePicker({
accept: ".xlsx", accept: ".xlsx",
@@ -97,7 +101,7 @@ export default function BatchCreateUser({ user, permissions, onFinish }: Props)
const information = uniqBy( const information = uniqBy(
rows rows
.map((row) => { .map((row) => {
const [firstName, lastName, studentID, passport_id, email, phone, corporate, group, country] = row as string[]; const [firstName, lastName, studentID, passport_id, email, phone, group, country] = row as string[];
const countryItem = const countryItem =
countryCodes.findOne("countryCode" as any, country.toUpperCase()) || countryCodes.findOne("countryCode" as any, country.toUpperCase()) ||
countryCodes.all().find((x) => x.countryNameEn.toLowerCase() === country.toLowerCase()); countryCodes.all().find((x) => x.countryNameEn.toLowerCase() === country.toLowerCase());
@@ -109,8 +113,8 @@ export default function BatchCreateUser({ user, permissions, onFinish }: Props)
type: type, type: type,
passport_id: passport_id?.toString().trim() || undefined, passport_id: passport_id?.toString().trim() || undefined,
groupName: group, groupName: group,
corporate,
studentID, studentID,
entity,
demographicInformation: { demographicInformation: {
country: countryItem?.countryCode, country: countryItem?.countryCode,
passport_id: passport_id?.toString().trim() || undefined, passport_id: passport_id?.toString().trim() || undefined,
@@ -131,7 +135,9 @@ export default function BatchCreateUser({ user, permissions, onFinish }: Props)
} }
setInfos(information); setInfos(information);
} catch { } catch(e) {
console.log(e)
toast.error( toast.error(
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!", "Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!",
); );
@@ -170,22 +176,37 @@ export default function BatchCreateUser({ user, permissions, onFinish }: Props)
}, [infos]); }, [infos]);
const makeUsers = async () => { const makeUsers = async () => {
if (!confirm(`You are about to add ${newUsers.length} user${newUsers.length !== 1 ? 's' : ''}, are you sure you want to continue?`)) return; const newUsersSentence = newUsers.length > 0 ? `create ${newUsers.length} user(s)` : undefined;
const existingUsersSentence = duplicatedUsers.length > 0 ? `invite ${duplicatedUsers.length} registered student(s)` : undefined;
if (!confirm(`You are about to ${[newUsersSentence, existingUsersSentence].filter((x) => !!x).join(" and ")}, are you sure you want to continue?`))
return;
/*Promise.all(duplicatedUsers.map(async (u) => await axios.post(`/api/invites`, {to: u.id, entity, from: user.id})))
.then(() => toast.success(`Successfully invited ${duplicatedUsers.length} registered student(s)!`))
.finally(() => {
if (newUsers.length === 0) setIsLoading(false);
});
*/
if (newUsers.length > 0) { if (newUsers.length > 0) {
setIsLoading(true); setIsLoading(true);
try { try {
//await axios.post("/api/batch_users", {users: newUsers.map((user) => ({...user, type, expiryDate}))}); await axios.post("/api/batch_users", {users: newUsers.map((user) => ({...user, type, expiryDate}))});
toast.success(`Successfully added ${newUsers.length} user(s)!`); toast.success(`Successfully added ${newUsers.length} user(s)!`);
onFinish(); onFinish();
} catch { } catch(e) {
console.error(e)
toast.error("Something went wrong, please try again later!"); toast.error("Something went wrong, please try again later!");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
setInfos([]); setInfos([]);
clear(); clear();
} }
} else {
setIsLoading(false);
setInfos([]);
clear();
} }
}; };
@@ -203,8 +224,7 @@ export default function BatchCreateUser({ user, permissions, onFinish }: Props)
<th className="border border-neutral-200 px-2 py-1">Passport/National ID</th> <th className="border border-neutral-200 px-2 py-1">Passport/National ID</th>
<th className="border border-neutral-200 px-2 py-1">E-mail</th> <th className="border border-neutral-200 px-2 py-1">E-mail</th>
<th className="border border-neutral-200 px-2 py-1">Phone Number</th> <th className="border border-neutral-200 px-2 py-1">Phone Number</th>
{user?.type !== "corporate" && <th className="border border-neutral-200 px-2 py-1">Corporate (e-mail)</th>} <th className="border border-neutral-200 px-2 py-1">Classroom Name</th>
<th className="border border-neutral-200 px-2 py-1">Group Name</th>
<th className="border border-neutral-200 px-2 py-1">Country</th> <th className="border border-neutral-200 px-2 py-1">Country</th>
</tr> </tr>
</thead> </thead>
@@ -221,6 +241,8 @@ export default function BatchCreateUser({ user, permissions, onFinish }: Props)
</div> </div>
</Modal> </Modal>
<div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4"> <div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
<div className="grid grid-cols-2 -md:grid-cols-1 gap-4">
<div className="flex flex-col gap-4">
<div className="flex items-end justify-between"> <div className="flex items-end justify-between">
<label className="text-mti-gray-dim text-base font-normal">Choose an Excel file</label> <label className="text-mti-gray-dim text-base font-normal">Choose an Excel file</label>
<div className="tooltip cursor-pointer" data-tip="Excel File Format" onClick={() => setShowHelp(true)}> <div className="tooltip cursor-pointer" data-tip="Excel File Format" onClick={() => setShowHelp(true)}>
@@ -230,6 +252,17 @@ export default function BatchCreateUser({ user, permissions, onFinish }: Props)
<Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}> <Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}>
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"} {filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
</Button> </Button>
</div>
<div className={clsx("flex flex-col gap-4")}>
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
<Select
defaultValue={{ value: (entities || [])[0]?.id, label: (entities || [])[0]?.label }}
options={entities.map((e) => ({ value: e.id, label: e.label }))}
onChange={(e) => setEntity(e?.value || undefined)}
isClearable={checkAccess(user, ["admin", "developer"])}
/>
</div>
</div>
{user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && ( {user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
<> <>
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center"> <div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">

View File

@@ -39,7 +39,7 @@ export default function ExamLoader() {
setExams([exam]); setExams([exam]);
setSelectedModules([selectedModule]); setSelectedModules([selectedModule]);
router.push("/exercises"); router.push("/exam");
} }
setIsLoading(false); setIsLoading(false);

View File

@@ -29,7 +29,7 @@ const CreatorCell = ({id, users}: {id: string; users: User[]}) => {
return ( return (
<> <>
{(creatorUser?.type === "corporate" ? creatorUser?.corporateInformation?.companyInformation?.name : creatorUser?.name || "N/A") || "N/A"}{" "} {(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 filteredCorporate
? { ? {
label: `${ label: `${
filteredCorporate.type === "corporate" filteredCorporate?.type === "corporate"
? filteredCorporate.corporateInformation?.companyInformation?.name || filteredCorporate.name ? filteredCorporate.corporateInformation?.companyInformation?.name || filteredCorporate.name
: filteredCorporate.name : filteredCorporate.name
} (${USER_TYPE_LABELS[filteredCorporate.type]})`, } (${USER_TYPE_LABELS[filteredCorporate?.type]})`,
value: filteredCorporate.id, value: filteredCorporate.id,
} }
: null : null

View File

@@ -18,34 +18,15 @@ import {isAgentUser, isCorporateUser, USER_TYPE_LABELS} from "@/resources/user";
import { checkAccess } from "@/utils/permissions"; import { checkAccess } from "@/utils/permissions";
import usePermissions from "@/hooks/usePermissions"; 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 searchFields = [["name"]];
const columnHelper = createColumnHelper<Group>(); const columnHelper = createColumnHelper<WithEntity<Group>>();
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/); 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 ? <span className="animate-pulse">Loading...</span> : <>{companyName}</>;
};
interface CreateDialogProps { interface CreateDialogProps {
user: User; user: User;
users: User[]; users: User[];
@@ -66,9 +47,9 @@ const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
}); });
const availableUsers = useMemo(() => { const availableUsers = useMemo(() => {
if (user.type === "teacher") return users.filter((x) => ["student"].includes(x.type)); 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 === "corporate") return users.filter((x) => ["teacher", "student"].includes(x.type));
if (user.type === "mastercorporate") return users.filter((x) => ["corporate", "teacher", "student"].includes(x.type)); if (user?.type === "mastercorporate") return users.filter((x) => ["corporate", "teacher", "student"].includes(x.type));
return users; return users;
}, [user, users]); }, [user, users]);
@@ -198,8 +179,6 @@ const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
); );
}; };
const filterTypes = ["corporate", "teacher", "mastercorporate"];
export default function GroupList({ user }: { user: User }) { export default function GroupList({ user }: { user: User }) {
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [editingGroup, setEditingGroup] = useState<Group>(); const [editingGroup, setEditingGroup] = useState<Group>();
@@ -207,19 +186,8 @@ export default function GroupList({user}: {user: User}) {
const { permissions } = usePermissions(user?.id || ""); const { permissions } = usePermissions(user?.id || "");
const {users} = useUsers(); const { users } = useEntitiesUsers();
const {groups, reload} = useGroups({ const { groups, reload } = useEntitiesGroups();
admin: user && filterTypes.includes(user?.type) ? user.id : undefined,
userType: user?.type,
});
const {groups: corporateGroups} = useGroups({
admin: user && filterTypes.includes(user?.type) ? user.id : undefined,
userType: user?.type,
adminAdmins: user?.id,
});
const {rows: filteredRows, renderSearch} = useListSearch<Group>(searchFields, groups);
const deleteGroup = (group: Group) => { const deleteGroup = (group: Group) => {
if (!confirm(`Are you sure you want to delete "${group.name}"?`)) return; if (!confirm(`Are you sure you want to delete "${group.name}"?`)) return;
@@ -248,9 +216,9 @@ export default function GroupList({user}: {user: User}) {
</div> </div>
), ),
}), }),
columnHelper.accessor("admin", { columnHelper.accessor("entity.label", {
header: "Linked Corporate", header: "Entity",
cell: (info) => <LinkedCorporate userId={info.getValue()} users={users} groups={groups} />, cell: (info) => info.getValue(),
}), }),
columnHelper.accessor("participants", { columnHelper.accessor("participants", {
header: "Participants", header: "Participants",
@@ -304,12 +272,6 @@ export default function GroupList({user}: {user: User}) {
}, },
]; ];
const table = useReactTable({
data: filteredRows,
columns: defaultColumns,
getCoreRowModel: getCoreRowModel(),
});
const closeModal = () => { const closeModal = () => {
setIsCreating(false); setIsCreating(false);
setEditingGroup(undefined); setEditingGroup(undefined);
@@ -323,45 +285,10 @@ export default function GroupList({user}: {user: User}) {
group={editingGroup} group={editingGroup}
user={user} user={user}
onClose={closeModal} onClose={closeModal}
users={ users={users}
checkAccess(user, ["corporate", "teacher", "mastercorporate"])
? users.filter(
(u) =>
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
}
/> />
</Modal> </Modal>
{renderSearch()} <Table data={groups} columns={defaultColumns} searchFields={searchFields} />
<table className="bg-mti-purple-ultralight/40 w-full rounded-xl">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th className="py-4" key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2">
{table.getRowModel().rows.map((row) => (
<tr className="even:bg-mti-purple-ultralight/40 rounded-lg py-2 odd:bg-white" key={row.id}>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
{checkAccess(user, ["teacher", "corporate", "mastercorporate", "admin", "developer"], permissions, "createGroup") && ( {checkAccess(user, ["teacher", "corporate", "mastercorporate", "admin", "developer"], permissions, "createGroup") && (
<button <button

View File

@@ -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<StudentPerformanceItem>();
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 (
<div className="flex flex-col gap-4 w-full h-full">
<Checkbox isChecked={isShowingAmount} onChange={setIsShowingAmount}>
Show Utilization
</Checkbox>
<Table<StudentPerformanceItem>
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"]]}
/>
</div>
);
};
export default StudentPerformanceList;

View File

@@ -1,52 +1,32 @@
import Button from "@/components/Low/Button";
import { PERMISSIONS } from "@/constants/userPermissions"; import { PERMISSIONS } from "@/constants/userPermissions";
import useGroups from "@/hooks/useGroups"; import { Type, User } from "@/interfaces/user";
import useUsers from "@/hooks/useUsers"; import { createColumnHelper } from "@tanstack/react-table";
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 axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import {capitalize, reverse} from "lodash"; import { capitalize } from "lodash";
import moment from "moment"; import moment from "moment";
import {Fragment, useEffect, useState, useMemo} from "react"; import { useEffect, useMemo, useState } from "react";
import {BsArrowDown, BsArrowDownUp, BsArrowUp, BsCheck, BsCheckCircle, BsEye, BsFillExclamationOctagonFill, BsPerson, BsTrash} from "react-icons/bs"; import { BsCheck, BsCheckCircle, BsFillExclamationOctagonFill, BsTrash } from "react-icons/bs";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { countries, TCountries } from "countries-list"; import { countries, TCountries } from "countries-list";
import countryCodes from "country-codes-list"; import countryCodes from "country-codes-list";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import UserCard from "@/components/UserCard"; 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 useFilterStore from "@/stores/listFilterStore";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import {isCorporateUser} from "@/resources/user"; import { mapBy } from "@/utils";
import {useListSearch} from "@/hooks/useListSearch"; import { exportListToExcel } from "@/utils/users";
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 usePermissions from "@/hooks/usePermissions"; import usePermissions from "@/hooks/usePermissions";
import useUserBalance from "@/hooks/useUserBalance"; import useUserBalance from "@/hooks/useUserBalance";
import usePagination from "@/hooks/usePagination"; import useEntitiesUsers from "@/hooks/useEntitiesUsers";
const columnHelper = createColumnHelper<User>(); import { WithLabeledEntities } from "@/interfaces/entity";
const searchFields = [["name"], ["email"], ["corporateInformation", "companyInformation", "name"]]; import Table from "@/components/High/Table";
import useEntities from "@/hooks/useEntities";
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
const corporatesHash = { const columnHelper = createColumnHelper<WithLabeledEntities<User>>();
type: "corporate", const searchFields = [["name"], ["email"], ["entities", ""]];
};
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 ? <span className="animate-pulse">Loading...</span> : <>{companyName}</>;
};
export default function UserList({ export default function UserList({
user, user,
@@ -60,28 +40,30 @@ export default function UserList({
renderHeader?: (total: number) => JSX.Element; renderHeader?: (total: number) => JSX.Element;
}) { }) {
const [showDemographicInformation, setShowDemographicInformation] = useState(false); const [showDemographicInformation, setShowDemographicInformation] = useState(false);
const [sorter, setSorter] = useState<string>();
const [displayUsers, setDisplayUsers] = useState<User[]>([]);
const [selectedUser, setSelectedUser] = useState<User>(); const [selectedUser, setSelectedUser] = useState<User>();
const userHash = useMemo( const { users, reload } = useEntitiesUsers(type)
() => ({ const { entities } = useEntities()
type,
}),
[type],
);
const {users, total, isLoading, reload} = useUsers(userHash);
const {users: corporates} = useUsers(corporatesHash);
const totalUsers = useMemo(() => [...users, ...corporates], [users, corporates]);
const {permissions} = usePermissions(user?.id || "");
const { balance } = useUserBalance(); const { balance } = useUserBalance();
const {groups} = useGroups({
admin: user && ["corporate", "teacher", "mastercorporate"].includes(user?.type) ? user.id : undefined, const isAdmin = useMemo(() => ["admin", "developer"].includes(user?.type), [user?.type])
userType: user?.type,
}); const entitiesViewStudents = useAllowedEntities(user, entities, "view_students")
const entitiesEditStudents = useAllowedEntities(user, entities, "edit_students")
const entitiesDeleteStudents = useAllowedEntities(user, entities, "delete_students")
const entitiesViewTeachers = useAllowedEntities(user, entities, "view_teachers")
const entitiesEditTeachers = useAllowedEntities(user, entities, "edit_teachers")
const entitiesDeleteTeachers = useAllowedEntities(user, entities, "delete_teachers")
const entitiesViewCorporates = useAllowedEntities(user, entities, "view_corporates")
const entitiesEditCorporates = useAllowedEntities(user, entities, "edit_corporates")
const entitiesDeleteCorporates = useAllowedEntities(user, entities, "delete_corporates")
const entitiesViewMasterCorporates = useAllowedEntities(user, entities, "view_mastercorporates")
const entitiesEditMasterCorporates = useAllowedEntities(user, entities, "edit_mastercorporates")
const entitiesDeleteMasterCorporates = useAllowedEntities(user, entities, "delete_mastercorporates")
const appendUserFilters = useFilterStore((state) => state.appendUserFilter); const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
const router = useRouter(); const router = useRouter();
@@ -96,17 +78,23 @@ export default function UserList({
if (today.add(1, "months").isAfter(momentDate)) return "!text-mti-orange-light"; if (today.add(1, "months").isAfter(momentDate)) return "!text-mti-orange-light";
}; };
useEffect(() => { const allowedUsers = useMemo(() => users.filter((u) => {
(async () => { if (isAdmin) return true
if (users && users.length > 0) { if (u.id === user?.id) return false
const filteredUsers = filters.reduce((d, f) => d.filter(f), users);
// const sortedUsers = await asyncSorter<User>(filteredUsers, sortFunction);
setDisplayUsers([...filteredUsers]); switch (u.type) {
case "student": return mapBy((u.entities || []), 'id').some((id) => mapBy(entitiesViewStudents, 'id').includes(id))
case "teacher": return mapBy((u.entities || []), 'id').some((id) => mapBy(entitiesViewTeachers, 'id').includes(id))
case 'corporate': return mapBy((u.entities || []), 'id').some((id) => mapBy(entitiesViewCorporates, 'id').includes(id))
case 'mastercorporate': return mapBy((u.entities || []), 'id').some((id) => mapBy(entitiesViewMasterCorporates, 'id').includes(id))
default: return false
} }
})(); })
// eslint-disable-next-line react-hooks/exhaustive-deps , [entitiesViewCorporates, entitiesViewMasterCorporates, entitiesViewStudents, entitiesViewTeachers, isAdmin, user?.id, users])
}, [users, sorter]);
const displayUsers = useMemo(() =>
filters.length > 0 ? filters.reduce((d, f) => d.filter(f), allowedUsers) : allowedUsers,
[filters, allowedUsers])
const deleteAccount = (user: User) => { const deleteAccount = (user: User) => {
if (!confirm(`Are you sure you want to delete ${user.name}'s account?`)) return; if (!confirm(`Are you sure you want to delete ${user.name}'s account?`)) return;
@@ -115,7 +103,7 @@ export default function UserList({
.delete<{ ok: boolean }>(`/api/user?id=${user.id}`) .delete<{ ok: boolean }>(`/api/user?id=${user.id}`)
.then(() => { .then(() => {
toast.success("User deleted successfully!"); toast.success("User deleted successfully!");
reload(); reload()
}) })
.catch(() => { .catch(() => {
toast.error("Something went wrong!", { toastId: "delete-error" }); toast.error("Something went wrong!", { toastId: "delete-error" });
@@ -141,8 +129,7 @@ export default function UserList({
const toggleDisableAccount = (user: User) => { const toggleDisableAccount = (user: User) => {
if ( if (
!confirm( !confirm(
`Are you sure you want to ${user.status === "disabled" ? "enable" : "disable"} ${ `Are you sure you want to ${user.status === "disabled" ? "enable" : "disable"} ${user.name
user.name
}'s account? This change is usually related to their payment state.`, }'s account? This change is usually related to their payment state.`,
) )
) )
@@ -162,30 +149,42 @@ export default function UserList({
}); });
}; };
const SorterArrow = ({name}: {name: string}) => { const getEditPermission = (type: Type) => {
if (sorter === name) return <BsArrowUp />; if (type === "student") return entitiesEditStudents
if (sorter === reverseString(name)) return <BsArrowDown />; if (type === "teacher") return entitiesEditTeachers
if (type === "corporate") return entitiesEditCorporates
if (type === "mastercorporate") return entitiesEditMasterCorporates
return <BsArrowDownUp />; return []
}; }
const getDeletePermission = (type: Type) => {
if (type === "student") return entitiesDeleteStudents
if (type === "teacher") return entitiesDeleteTeachers
if (type === "corporate") return entitiesDeleteCorporates
if (type === "mastercorporate") return entitiesDeleteMasterCorporates
return []
}
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))
const actionColumn = ({ row }: { row: { original: User } }) => { const actionColumn = ({ row }: { row: { original: User } }) => {
const updateUserPermission = PERMISSIONS.updateUser[row.original.type] as { const canEdit = canEditUser(row.original)
list: Type[]; const canDelete = canDeleteUser(row.original)
perm: PermissionType;
};
const deleteUserPermission = PERMISSIONS.deleteUser[row.original.type] as {
list: Type[];
perm: PermissionType;
};
return ( return (
<div className="flex gap-4"> <div className="flex gap-4">
{!row.original.isVerified && checkAccess(user, updateUserPermission.list, permissions, updateUserPermission.perm) && ( {!row.original.isVerified && canEdit && (
<div data-tip="Verify User" className="cursor-pointer tooltip" onClick={() => verifyAccount(row.original)}> <div data-tip="Verify User" className="cursor-pointer tooltip" onClick={() => verifyAccount(row.original)}>
<BsCheck className="hover:text-mti-purple-light transition ease-in-out duration-300" /> <BsCheck className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div> </div>
)} )}
{checkAccess(user, updateUserPermission.list, permissions, updateUserPermission.perm) && ( {canEdit && (
<div <div
data-tip={row.original.status === "disabled" ? "Enable User" : "Disable User"} data-tip={row.original.status === "disabled" ? "Enable User" : "Disable User"}
className="cursor-pointer tooltip" className="cursor-pointer tooltip"
@@ -197,7 +196,7 @@ export default function UserList({
)} )}
</div> </div>
)} )}
{checkAccess(user, deleteUserPermission.list, permissions, deleteUserPermission.perm) && ( {canDelete && (
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteAccount(row.original)}> <div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteAccount(row.original)}>
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" /> <BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div> </div>
@@ -208,46 +207,30 @@ export default function UserList({
const demographicColumns = [ const demographicColumns = [
columnHelper.accessor("name", { columnHelper.accessor("name", {
header: ( header: "Name",
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "name"))}>
<span>Name</span>
<SorterArrow name="name" />
</button>
) as any,
cell: ({ row, getValue }) => ( cell: ({ row, getValue }) => (
<div <div
className={clsx( className={clsx(
checkAccess(user, ["admin", "corporate", "developer", "mastercorporate"]) && canEditUser(row.original) &&
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer", "underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer",
)} )}
onClick={() => onClick={() =>
checkAccess(user, ["admin", "corporate", "developer", "mastercorporate"]) ? setSelectedUser(row.original) : null canEditUser(row.original) ? setSelectedUser(row.original) : null
}> }>
{getValue()} {getValue()}
</div> </div>
), ),
}), }),
columnHelper.accessor("demographicInformation.country", { columnHelper.accessor("demographicInformation.country", {
header: ( header: "Country",
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "country"))}>
<span>Country</span>
<SorterArrow name="country" />
</button>
) as any,
cell: (info) => cell: (info) =>
info.getValue() info.getValue()
? `${countryCodes.findOne("countryCode" as any, info.getValue())?.flag} ${ ? `${countryCodes.findOne("countryCode" as any, info.getValue())?.flag} ${countries[info.getValue() as unknown as keyof TCountries]?.name
countries[info.getValue() as unknown as keyof TCountries]?.name
} (+${countryCodes.findOne("countryCode" as any, info.getValue())?.countryCallingCode})` } (+${countryCodes.findOne("countryCode" as any, info.getValue())?.countryCallingCode})`
: "N/A", : "N/A",
}), }),
columnHelper.accessor("demographicInformation.phone", { columnHelper.accessor("demographicInformation.phone", {
header: ( header: "Phone",
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "phone"))}>
<span>Phone</span>
<SorterArrow name="phone" />
</button>
) as any,
cell: (info) => info.getValue() || "N/A", cell: (info) => info.getValue() || "N/A",
enableSorting: true, enableSorting: true,
}), }),
@@ -256,32 +239,17 @@ export default function UserList({
x.type === "corporate" || x.type === "mastercorporate" ? x.demographicInformation?.position : x.demographicInformation?.employment, x.type === "corporate" || x.type === "mastercorporate" ? x.demographicInformation?.position : x.demographicInformation?.employment,
{ {
id: "employment", id: "employment",
header: ( header: "Employment",
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "employment"))}>
<span>Employment</span>
<SorterArrow name="employment" />
</button>
) as any,
cell: (info) => (info.row.original.type === "corporate" ? info.getValue() : capitalize(info.getValue())) || "N/A", cell: (info) => (info.row.original.type === "corporate" ? info.getValue() : capitalize(info.getValue())) || "N/A",
enableSorting: true, enableSorting: true,
}, },
), ),
columnHelper.accessor("lastLogin", { columnHelper.accessor("lastLogin", {
header: ( header: "Last Login",
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "lastLogin"))}>
<span>Last Login</span>
<SorterArrow name="lastLogin" />
</button>
) as any,
cell: (info) => (!!info.getValue() ? moment(info.getValue()).format("YYYY-MM-DD HH:mm") : "N/A"), cell: (info) => (!!info.getValue() ? moment(info.getValue()).format("YYYY-MM-DD HH:mm") : "N/A"),
}), }),
columnHelper.accessor("demographicInformation.gender", { columnHelper.accessor("demographicInformation.gender", {
header: ( header: "Gender",
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "gender"))}>
<span>Gender</span>
<SorterArrow name="gender" />
</button>
) as any,
cell: (info) => capitalize(info.getValue()) || "N/A", cell: (info) => capitalize(info.getValue()) || "N/A",
enableSorting: true, enableSorting: true,
}), }),
@@ -293,82 +261,53 @@ export default function UserList({
), ),
id: "actions", id: "actions",
cell: actionColumn, cell: actionColumn,
sortable: false
}, },
]; ];
const defaultColumns = [ const defaultColumns = [
columnHelper.accessor("name", { columnHelper.accessor("name", {
header: ( header: "Name",
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "name"))}>
<span>Name</span>
<SorterArrow name="name" />
</button>
) as any,
cell: ({ row, getValue }) => ( cell: ({ row, getValue }) => (
<div <div
className={clsx( className={clsx(
checkAccess(user, ["admin", "corporate", "developer", "mastercorporate"]) && canEditUser(row.original) &&
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer", "underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer",
)} )}
onClick={() => onClick={() =>
checkAccess(user, ["admin", "corporate", "developer", "mastercorporate"]) ? setSelectedUser(row.original) : null canEditUser(row.original) ? setSelectedUser(row.original) : null
}> }>
{getValue()} {getValue()}
</div> </div>
), ),
}), }),
columnHelper.accessor("email", { columnHelper.accessor("email", {
header: ( header: "E-mail",
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "email"))}>
<span>E-mail</span>
<SorterArrow name="email" />
</button>
) as any,
cell: ({ row, getValue }) => ( cell: ({ row, getValue }) => (
<div <div
className={clsx( className={clsx(
PERMISSIONS.updateExpiryDate[row.original.type]?.includes(user.type) && canEditUser(row.original) &&
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer", "underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer",
)} )}
onClick={() => (PERMISSIONS.updateExpiryDate[row.original.type]?.includes(user.type) ? setSelectedUser(row.original) : null)}> onClick={() => (canEditUser(row.original) ? setSelectedUser(row.original) : null)}>
{getValue()} {getValue()}
</div> </div>
), ),
}), }),
columnHelper.accessor("type", { columnHelper.accessor("type", {
header: ( header: "Type",
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "type"))}>
<span>Type</span>
<SorterArrow name="type" />
</button>
) as any,
cell: (info) => USER_TYPE_LABELS[info.getValue()], cell: (info) => USER_TYPE_LABELS[info.getValue()],
}), }),
columnHelper.accessor("studentID", { columnHelper.accessor("studentID", {
header: ( header: "Student ID",
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "studentID"))}>
<span>Student ID</span>
<SorterArrow name="studentID" />
</button>
) as any,
cell: (info) => info.getValue() || "N/A", cell: (info) => info.getValue() || "N/A",
}), }),
columnHelper.accessor("corporateInformation.companyInformation.name", { columnHelper.accessor("entities", {
header: ( header: "Entities",
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "companyName"))}> cell: ({ getValue }) => mapBy(getValue(), 'label').join(', '),
<span>Company</span>
<SorterArrow name="companyName" />
</button>
) as any,
cell: (info) => <CompanyNameCell user={info.row.original} users={totalUsers} groups={groups} />,
}), }),
columnHelper.accessor("subscriptionExpirationDate", { columnHelper.accessor("subscriptionExpirationDate", {
header: ( header: "Expiration",
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "expiryDate"))}>
<span>Expiration</span>
<SorterArrow name="expiryDate" />
</button>
) as any,
cell: (info) => ( cell: (info) => (
<span className={clsx(info.getValue() ? expirationDateColor(moment(info.getValue()).toDate()) : "")}> <span className={clsx(info.getValue() ? expirationDateColor(moment(info.getValue()).toDate()) : "")}>
{!info.getValue() ? "No expiry date" : moment(info.getValue()).format("DD/MM/YYYY")} {!info.getValue() ? "No expiry date" : moment(info.getValue()).format("DD/MM/YYYY")}
@@ -376,12 +315,7 @@ export default function UserList({
), ),
}), }),
columnHelper.accessor("isVerified", { columnHelper.accessor("isVerified", {
header: ( header: "Verified",
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "verification"))}>
<span>Verified</span>
<SorterArrow name="verification" />
</button>
) as any,
cell: (info) => ( cell: (info) => (
<div className="flex gap-3 items-center text-mti-gray-dim text-sm self-center"> <div className="flex gap-3 items-center text-mti-gray-dim text-sm self-center">
<div <div
@@ -403,128 +337,12 @@ export default function UserList({
), ),
id: "actions", id: "actions",
cell: actionColumn, cell: actionColumn,
sortable: false
}, },
]; ];
const reverseString = (str: string) => reverse(str.split("")).join(""); const downloadExcel = (rows: WithLabeledEntities<User>[]) => {
const csv = exportListToExcel(rows);
const selectSorter = (previous: string | undefined, name: string) => {
if (!previous) return name;
if (previous === name) return reverseString(name);
return undefined;
};
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);
if (sorter === "email" || sorter === reverseString("email"))
return sorter === "email" ? a.email.localeCompare(b.email) : b.email.localeCompare(a.email);
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);
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");
if (sorter === "verification" || sorter === reverseString("verification"))
return sorter === "verification"
? a.isVerified.toString().localeCompare(b.isVerified.toString())
: b.isVerified.toString().localeCompare(a.isVerified.toString());
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;
}
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;
}
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;
return sorter === "country"
? a.demographicInformation!.country.localeCompare(b.demographicInformation!.country)
: b.demographicInformation!.country.localeCompare(a.demographicInformation!.country);
}
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;
return sorter === "phone"
? a.demographicInformation!.phone.localeCompare(b.demographicInformation!.phone)
: b.demographicInformation!.phone.localeCompare(a.demographicInformation!.phone);
}
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<User>(searchFields, displayUsers);
const {items, setPage, render: renderPagination} = usePagination<User>(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 element = document.createElement("a");
const file = new Blob([csv], { type: "text/csv" }); const file = new Blob([csv], { type: "text/csv" });
@@ -537,16 +355,10 @@ export default function UserList({
const viewStudentFilter = (x: User) => x.type === "student"; const viewStudentFilter = (x: User) => x.type === "student";
const viewTeacherFilter = (x: User) => x.type === "teacher"; const viewTeacherFilter = (x: User) => x.type === "teacher";
const belongsToAdminFilter = (x: User) => { const belongsToAdminFilter = (x: User) => x.entities.some(({ id }) => mapBy(selectedUser?.entities || [], 'id').includes(id));
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 viewStudentFilterBelongsToAdmin = (x: User) => viewStudentFilter(x) && belongsToAdminFilter(x);
const viewTeacherFilterBelongsToAdmin = (x: User) => x.type === "teacher" && belongsToAdminFilter(x); const viewTeacherFilterBelongsToAdmin = (x: User) => viewTeacherFilter(x) && belongsToAdminFilter(x);
const renderUserCard = (selectedUser: User) => { const renderUserCard = (selectedUser: User) => {
const studentsFromAdmin = users.filter(viewStudentFilterBelongsToAdmin); const studentsFromAdmin = users.filter(viewStudentFilterBelongsToAdmin);
@@ -570,7 +382,7 @@ export default function UserList({
filter: belongsToAdminFilter, filter: belongsToAdminFilter,
}); });
router.push("/list/users"); router.push("/users");
} }
: undefined : undefined
} }
@@ -586,7 +398,7 @@ export default function UserList({
filter: belongsToAdminFilter, filter: belongsToAdminFilter,
}); });
router.push("/list/users"); router.push("/users");
} }
: undefined : undefined
} }
@@ -599,14 +411,10 @@ export default function UserList({
}); });
appendUserFilters({ appendUserFilters({
id: "belongs-to-admin", id: "belongs-to-admin",
filter: (x: User) => filter: belongsToAdminFilter
groups
.filter((g) => g.participants.includes(selectedUser.id))
.flatMap((g) => [g.admin, ...g.participants])
.includes(x.id),
}); });
router.push("/list/users"); router.push("/users");
} }
: undefined : undefined
} }
@@ -622,44 +430,17 @@ export default function UserList({
return ( return (
<> <>
{renderHeader && renderHeader(total)} {renderHeader && renderHeader(displayUsers.length)}
<div className="w-full"> <div className="w-full">
<Modal isOpen={!!selectedUser} onClose={() => setSelectedUser(undefined)}> <Modal isOpen={!!selectedUser} onClose={() => setSelectedUser(undefined)}>
{selectedUser && renderUserCard(selectedUser)} {selectedUser && renderUserCard(selectedUser)}
</Modal> </Modal>
<div className="w-full flex flex-col gap-2"> <Table<WithLabeledEntities<User>>
<div className="w-full flex gap-2 items-end"> data={displayUsers}
{renderSearch()} columns={(!showDemographicInformation ? defaultColumns : demographicColumns) as any}
<Button className="w-full max-w-[200px] mb-1" variant="outline" onClick={downloadExcel}> searchFields={searchFields}
Download List onDownload={downloadExcel}
</Button> />
</div>
{renderPagination()}
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th className="py-4 px-4 text-left" key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2 w-full">
{table.getRowModel().rows.map((row) => (
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2 items-center w-fit" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div> </div>
</> </>
); );

View File

@@ -8,16 +8,24 @@ import GroupList from "./GroupList";
import PackageList from "./PackageList"; import PackageList from "./PackageList";
import UserList from "./UserList"; import UserList from "./UserList";
import {checkAccess} from "@/utils/permissions"; import {checkAccess} from "@/utils/permissions";
import usePermissions from "@/hooks/usePermissions";
import {PermissionType} from "@/interfaces/permissions"; import {PermissionType} from "@/interfaces/permissions";
import { EntityWithRoles } from "@/interfaces/entity";
import { useAllowedEntitiesSomePermissions } from "@/hooks/useEntityPermissions";
import { useMemo } from "react";
interface Props { interface Props {
user: User; user: User;
users: User[]; entities: EntityWithRoles[]
permissions: PermissionType[]; permissions: PermissionType[];
} }
export default function Lists({user, users, permissions}: Props) { export default function Lists({user, entities = [], permissions}: Props) {
const entitiesViewExams = useAllowedEntitiesSomePermissions(user, entities, [
'view_reading', 'view_listening', 'view_writing', 'view_speaking', 'view_level'
])
const canViewExams = useMemo(() => entitiesViewExams.length > 0, [entitiesViewExams])
return ( return (
<TabGroup> <TabGroup>
<TabList className="flex space-x-1 rounded-xl bg-mti-purple-ultralight/40 p-1"> <TabList className="flex space-x-1 rounded-xl bg-mti-purple-ultralight/40 p-1">
@@ -32,7 +40,7 @@ export default function Lists({user, users, permissions}: Props) {
}> }>
User List User List
</Tab> </Tab>
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate", "teacher"]) && ( {canViewExams && (
<Tab <Tab
className={({selected}) => className={({selected}) =>
clsx( clsx(
@@ -45,17 +53,6 @@ export default function Lists({user, users, permissions}: Props) {
Exam List Exam List
</Tab> </Tab>
)} )}
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
)
}>
Group List
</Tab>
{checkAccess(user, ["developer", "admin", "corporate"]) && ( {checkAccess(user, ["developer", "admin", "corporate"]) && (
<Tab <Tab
className={({selected}) => className={({selected}) =>
@@ -100,14 +97,11 @@ export default function Lists({user, users, permissions}: Props) {
<TabPanel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide"> <TabPanel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<UserList user={user} /> <UserList user={user} />
</TabPanel> </TabPanel>
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate", "teacher"]) && ( {canViewExams && (
<TabPanel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide"> <TabPanel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<ExamList user={user} /> <ExamList user={user} entities={entities} />
</TabPanel> </TabPanel>
)} )}
<TabPanel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<GroupList user={user} />
</TabPanel>
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "viewCodes") && ( {checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "viewCodes") && (
<TabPanel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide"> <TabPanel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<CodeList user={user} /> <CodeList user={user} />

View File

@@ -20,6 +20,8 @@ import useGroups from "@/hooks/useGroups";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import { getUserName } from "@/utils/users"; import { getUserName } from "@/utils/users";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
import { EntityWithRoles } from "@/interfaces/entity";
import useEntitiesGroups from "@/hooks/useEntitiesGroups";
const USER_TYPE_PERMISSIONS: { const USER_TYPE_PERMISSIONS: {
[key in Type]: { perm: PermissionType | undefined; list: Type[] }; [key in Type]: { perm: PermissionType | undefined; list: Type[] };
@@ -57,11 +59,12 @@ const USER_TYPE_PERMISSIONS: {
interface Props { interface Props {
user: User; user: User;
users: User[]; users: User[];
entities: EntityWithRoles[]
permissions: PermissionType[]; permissions: PermissionType[];
onFinish: () => void; onFinish: () => void;
} }
export default function UserCreator({user, users, permissions, onFinish}: Props) { export default function UserCreator({ user, users, entities = [], permissions, onFinish }: Props) {
const [name, setName] = useState<string>(); const [name, setName] = useState<string>();
const [email, setEmail] = useState<string>(); const [email, setEmail] = useState<string>();
const [phone, setPhone] = useState<string>(); const [phone, setPhone] = useState<string>();
@@ -69,8 +72,6 @@ export default function UserCreator({user, users, permissions, onFinish}: Props)
const [studentID, setStudentID] = useState<string>(); const [studentID, setStudentID] = useState<string>();
const [country, setCountry] = useState(user?.demographicInformation?.country); const [country, setCountry] = useState(user?.demographicInformation?.country);
const [group, setGroup] = useState<string | null>(); const [group, setGroup] = useState<string | null>();
const [availableCorporates, setAvailableCorporates] = useState<User[]>([]);
const [selectedCorporate, setSelectedCorporate] = useState<string | null>();
const [password, setPassword] = useState<string>(); const [password, setPassword] = useState<string>();
const [confirmPassword, setConfirmPassword] = useState<string>(); const [confirmPassword, setConfirmPassword] = useState<string>();
const [expiryDate, setExpiryDate] = useState<Date | null>( const [expiryDate, setExpiryDate] = useState<Date | null>(
@@ -80,22 +81,14 @@ export default function UserCreator({user, users, permissions, onFinish}: Props)
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [type, setType] = useState<Type>("student"); const [type, setType] = useState<Type>("student");
const [position, setPosition] = useState<string>(); const [position, setPosition] = useState<string>();
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(() => { useEffect(() => {
if (!isExpiryDateEnabled) setExpiryDate(null); if (!isExpiryDateEnabled) setExpiryDate(null);
}, [isExpiryDateEnabled]); }, [isExpiryDateEnabled]);
useEffect(() => {
setAvailableCorporates(
uniqBy(
users.filter((u) => u.type === "corporate" && groups.flatMap((g) => g.participants).includes(u.id)),
"id",
),
);
}, [users, groups]);
const createUser = () => { const createUser = () => {
if (!name || name.trim().length === 0) return toast.error("Please enter a valid name!"); 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 (!email || email.trim().length === 0) return toast.error("Please enter a valid e-mail address!");
@@ -110,7 +103,7 @@ export default function UserCreator({user, users, permissions, onFinish}: Props)
email, email,
password, password,
groupID: group, groupID: group,
corporate: selectedCorporate || user.id, entity,
type, type,
studentID: type === "student" ? studentID : undefined, studentID: type === "student" ? studentID : undefined,
expiryDate, expiryDate,
@@ -135,7 +128,7 @@ export default function UserCreator({user, users, permissions, onFinish}: Props)
setStudentID(""); setStudentID("");
setCountry(user?.demographicInformation?.country); setCountry(user?.demographicInformation?.country);
setGroup(null); setGroup(null);
setSelectedCorporate(null); setEntity((entities || [])[0]?.id || undefined)
setExpiryDate(user?.subscriptionExpirationDate ? moment(user?.subscriptionExpirationDate).toDate() : null); setExpiryDate(user?.subscriptionExpirationDate ? moment(user?.subscriptionExpirationDate).toDate() : null);
setIsExpiryDateEnabled(true); setIsExpiryDateEnabled(true);
setType("student"); setType("student");
@@ -188,38 +181,30 @@ export default function UserCreator({user, users, permissions, onFinish}: Props)
</> </>
)} )}
{["student", "teacher"].includes(type) && !["corporate", "teacher"].includes(user?.type) && (
<div className={clsx("flex flex-col gap-4")}> <div className={clsx("flex flex-col gap-4")}>
<label className="font-normal text-base text-mti-gray-dim">Corporate</label> <label className="font-normal text-base text-mti-gray-dim">Entity</label>
<Select <Select
options={availableCorporates.map((u) => ({value: u.id, label: getUserName(u)}))} defaultValue={{ value: (entities || [])[0]?.id, label: (entities || [])[0]?.label }}
isClearable options={entities.map((e) => ({ value: e.id, label: e.label }))}
onChange={(e) => setSelectedCorporate(e?.value || undefined)} onChange={(e) => setEntity(e?.value || undefined)}
isClearable={checkAccess(user, ["admin", "developer"])}
/> />
</div> </div>
)}
{["corporate", "mastercorporate"].includes(type) && ( {["corporate", "mastercorporate"].includes(type) && (
<Input type="text" name="department" label="Department" onChange={setPosition} value={position} placeholder="Department" /> <Input type="text" name="department" label="Department" onChange={setPosition} value={position} placeholder="Department" />
)} )}
{!(type === "corporate" && user.type === "corporate") && ( <div className={clsx("flex flex-col gap-4")}>
<div <label className="font-normal text-base text-mti-gray-dim">Classroom</label>
className={clsx(
"flex flex-col gap-4",
(!["student", "teacher"].includes(type) || ["corporate", "teacher"].includes(user?.type)) &&
!["corporate", "mastercorporate"].includes(type) &&
"col-span-2",
)}>
<label className="font-normal text-base text-mti-gray-dim">Group</label>
<Select <Select
options={groups options={groups
.filter((x) => (!selectedCorporate ? true : x.admin === selectedCorporate)) .filter((x) => x.entity?.id === entity)
.map((g) => ({ value: g.id, label: g.name }))} .map((g) => ({ value: g.id, label: g.name }))}
onChange={(e) => setGroup(e?.value || undefined)} onChange={(e) => setGroup(e?.value || undefined)}
isClearable
/> />
</div> </div>
)}
<div <div
className={clsx( className={clsx(

View File

@@ -25,13 +25,16 @@ import useSessions from "@/hooks/useSessions";
import ShortUniqueId from "short-unique-id"; import ShortUniqueId from "short-unique-id";
import clsx from "clsx"; import clsx from "clsx";
import useGradingSystem from "@/hooks/useGrading"; import useGradingSystem from "@/hooks/useGrading";
import { Assignment } from "@/interfaces/results";
import { mapBy } from "@/utils";
interface Props { interface Props {
page: "exams" | "exercises"; page: "exams" | "exercises";
user: User; user: User;
hideSidebar?: boolean
} }
export default function ExamPage({page, user}: Props) { export default function ExamPage({page, user, hideSidebar = false}: Props) {
const [variant, setVariant] = useState<Variant>("full"); const [variant, setVariant] = useState<Variant>("full");
const [avoidRepeated, setAvoidRepeated] = useState(false); const [avoidRepeated, setAvoidRepeated] = useState(false);
const [hasBeenUploaded, setHasBeenUploaded] = useState(false); const [hasBeenUploaded, setHasBeenUploaded] = useState(false);
@@ -210,7 +213,7 @@ export default function ExamPage({page, user}: Props) {
}, [setModuleIndex, showSolutions]); }, [setModuleIndex, showSolutions]);
useEffect(() => { useEffect(() => {
(async () => { console.log(selectedModules)
if (selectedModules.length > 0 && exams.length > 0 && moduleIndex < selectedModules.length) { if (selectedModules.length > 0 && exams.length > 0 && moduleIndex < selectedModules.length) {
const nextExam = exams[moduleIndex]; const nextExam = exams[moduleIndex];
@@ -218,7 +221,6 @@ export default function ExamPage({page, user}: Props) {
if (exerciseIndex === -1 && !["reading", "listening"].includes(nextExam?.module)) setExerciseIndex(0); if (exerciseIndex === -1 && !["reading", "listening"].includes(nextExam?.module)) setExerciseIndex(0);
setExam(nextExam ? updateExamWithUserSolutions(nextExam) : undefined); setExam(nextExam ? updateExamWithUserSolutions(nextExam) : undefined);
} }
})();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedModules, moduleIndex, exams]); }, [selectedModules, moduleIndex, exams]);
@@ -441,7 +443,6 @@ export default function ExamPage({page, user}: Props) {
<Selection <Selection
page={page} page={page}
user={user!} user={user!}
disableSelection={page === "exams"}
onStart={(modules: Module[], avoid: boolean, variant: Variant) => { onStart={(modules: Module[], avoid: boolean, variant: Variant) => {
setModuleIndex(0); setModuleIndex(0);
setAvoidRepeated(avoid); setAvoidRepeated(avoid);
@@ -520,6 +521,7 @@ export default function ExamPage({page, user}: Props) {
<Layout <Layout
user={user} user={user}
bgColor={bgColor} bgColor={bgColor}
hideSidebar={hideSidebar}
className="justify-between" className="justify-between"
focusMode={selectedModules.length !== 0 && !showSolutions && moduleIndex < selectedModules.length} focusMode={selectedModules.length !== 0 && !showSolutions && moduleIndex < selectedModules.length}
onFocusLayerMouseEnter={() => setShowAbandonPopup(true)}> onFocusLayerMouseEnter={() => setShowAbandonPopup(true)}>

View File

@@ -19,7 +19,7 @@ export default function App({Component, pageProps}: AppProps) {
const router = useRouter(); const router = useRouter();
useEffect(() => { useEffect(() => {
if (router.pathname !== "/exercises") reset(); if (router.pathname !== "/exam" && router.pathname !== "/exercises") reset();
}, [router.pathname, reset]); }, [router.pathname, reset]);
useEffect(() => { useEffect(() => {

View File

@@ -4,7 +4,6 @@ import client from "@/lib/mongodb";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
const db = client.db(process.env.MONGODB_DB); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -35,9 +34,7 @@ async function GET(req: NextApiRequest, res: NextApiResponse) {
async function DELETE(req: NextApiRequest, res: NextApiResponse) { async function DELETE(req: NextApiRequest, res: NextApiResponse) {
const {id} = req.query; const {id} = req.query;
await db.collection("assignments").deleteOne( await db.collection("assignments").deleteOne({id});
{ id: id as string }
);
res.status(200).json({ok: true}); res.status(200).json({ok: true});
} }
@@ -45,10 +42,7 @@ async function DELETE(req: NextApiRequest, res: NextApiResponse) {
async function PATCH(req: NextApiRequest, res: NextApiResponse) { async function PATCH(req: NextApiRequest, res: NextApiResponse) {
const {id} = req.query; const {id} = req.query;
await db.collection("assignments").updateOne( await db.collection("assignments").updateOne({id: id as string}, {$set: {...req.body}});
{ id: id as string },
{ $set: {assigner: req.session.user?.id, ...req.body} }
);
res.status(200).json({ok: true}); res.status(200).json({ok: true});
} }

View File

@@ -128,8 +128,10 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
return; return;
} }
const id = uuidv4();
await db.collection("assignments").insertOne({ await db.collection("assignments").insertOne({
id: uuidv4(), id,
assigner: req.session.user?.id, assigner: req.session.user?.id,
assignees, assignees,
results: [], results: [],
@@ -138,10 +140,9 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
...body, ...body,
}); });
res.status(200).json({ok: true}); res.status(200).json({ok: true, id});
for (const assigneeID of assignees) { for (const assigneeID of assignees) {
const assignee = await db.collection("users").findOne<User>({id: assigneeID}); const assignee = await db.collection("users").findOne<User>({id: assigneeID});
if (!assignee) continue; if (!assignee) continue;
@@ -163,6 +164,7 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
endDate, endDate,
modules: examModulesLabel, modules: examModulesLabel,
assigner: teacher.name, assigner: teacher.name,
id
}, },
environment: process.env.ENVIRONMENT, environment: process.env.ENVIRONMENT,
}, },

View File

@@ -5,6 +5,8 @@ import { FirebaseScrypt } from 'firebase-scrypt';
import { firebaseAuthScryptParams } from "@/firebase"; import { firebaseAuthScryptParams } from "@/firebase";
import crypto from 'crypto'; import crypto from 'crypto';
import axios from "axios"; import axios from "axios";
import { getEntityWithRoles } from "@/utils/entities.be";
import { findBy } from "@/utils";
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -36,6 +38,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
passport_id?: string; passport_id?: string;
phone: string; phone: string;
}; };
entity?: string
entities: { id: string, role: string }[]
passwordHash: string | undefined; passwordHash: string | undefined;
passwordSalt: string | undefined; passwordSalt: string | undefined;
}[]; }[];
@@ -45,6 +49,12 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const salt = crypto.randomBytes(16).toString('base64'); const salt = crypto.randomBytes(16).toString('base64');
const hash = await scrypt.hash(user.passport_id, salt); const hash = await scrypt.hash(user.passport_id, salt);
const entity = await getEntityWithRoles(currentUser.entity!)
const defaultRole = findBy(entity?.roles || [], "isDefault", true)
currentUser.entities = [{ id: entity?.id || "", role: defaultRole?.id || "" }]
delete currentUser.entity
currentUser.email = currentUser.email.toLowerCase(); currentUser.email = currentUser.email.toLowerCase();
currentUser.passwordHash = hash; currentUser.passwordHash = hash;
currentUser.passwordSalt = salt; currentUser.passwordSalt = salt;

View File

@@ -0,0 +1,61 @@
// 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);
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, 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") && !["admin", "developer"].includes(user.type))
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<Entity>("entities").updateOne({id}, {$set: {label: req.body.label}});
return res.status(200).json({ok: entity.acknowledged});
}

View File

@@ -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();
}

View File

@@ -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<Group>[] = ["admin", "developer"].includes(user.type)
? await getGroups()
: await getGroupsByEntities(mapBy(user.entities || [], 'id'))
res.status(200).json(groups);
}

View File

@@ -0,0 +1,52 @@
// 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 {addUsersToEntity, addUserToEntity, 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,
};
const members = req.body.members as string[] | undefined || []
console.log(members)
const roles = await createEntity(entity)
console.log(roles)
await addUserToEntity(user.id, entity.id, roles.admin.id)
if (members.length > 0) await addUsersToEntity(members, entity.id, roles.default.id)
return res.status(200).json(entity);
}

View File

@@ -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<User>[]
res.status(200).json(filteredUsers);
}

View File

@@ -5,6 +5,7 @@ import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {Group} from "@/interfaces/user"; import {Group} from "@/interfaces/user";
import {updateExpiryDateOnGroup} from "@/utils/groups.be"; import {updateExpiryDateOnGroup} from "@/utils/groups.be";
import { requestUser } from "@/utils/api";
const db = client.db(process.env.MONGODB_DB); 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) { async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { const user = await requestUser(req, res)
res.status(401).json({ok: false}); if (!user) return res.status(401).json({ ok: false });
return;
}
const {id} = req.query as {id: string}; 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) { async function del(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { const user = await requestUser(req, res)
res.status(401).json({ok: false}); if (!user) return res.status(401).json({ ok: false });
return;
}
const {id} = req.query as {id: string}; const {id} = req.query as {id: string};
const group = await db.collection("groups").findOne<Group>({id: id}); const group = await db.collection("groups").findOne<Group>({id: id});
@@ -49,7 +46,6 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
return; return;
} }
const user = req.session.user;
if (user.type === "admin" || user.type === "developer" || user.id === group.admin) { if (user.type === "admin" || user.type === "developer" || user.id === group.admin) {
await db.collection("groups").deleteOne({id: id}); 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) { async function patch(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { const user = await requestUser(req, res)
res.status(401).json({ok: false}); if (!user) return res.status(401).json({ ok: false });
return;
}
const {id} = req.query as {id: string}; const {id} = req.query as {id: string};
@@ -74,14 +68,20 @@ async function patch(req: NextApiRequest, res: NextApiResponse) {
return; return;
} }
const user = req.session.user; if (
if (user.type === "admin" || user.type === "developer" || user.id === group.admin) { user.type === "admin" ||
if ("participants" in req.body) { 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)); 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 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}); res.status(200).json({ok: true});
return; return;

View File

@@ -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 Promise.all(body.participants.map(async (p) => await updateExpiryDateOnGroup(p, body.admin)));
await db.collection("groups").insertOne({ const id = v4();
id: v4(), await db.collection<Group>("groups").insertOne({
id,
name: body.name, name: body.name,
admin: body.admin, admin: body.admin,
participants: body.participants, participants: body.participants,
}) entity: body.entity,
res.status(200).json({ok: true}); });
res.status(200).json({ok: true, id});
} }

View File

@@ -1,13 +1,16 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // 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 = { type Data = {
name: string name: string;
} };
export default function handler( export default async function handler(req: NextApiRequest, res: NextApiResponse<Data>) {
req: NextApiRequest, // await db.collection("users").updateMany({}, {$set: {entities: []}});
res: NextApiResponse<Data> await db.collection("invites").deleteMany({});
) {
res.status(200).json({ name: 'John Doe' }) res.status(200).json({name: "John Doe"});
} }

View File

@@ -4,6 +4,7 @@ import client from "@/lib/mongodb";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { Invite } from "@/interfaces/invite"; import { Invite } from "@/interfaces/invite";
import { requestUser } from "@/utils/api";
const db = client.db(process.env.MONGODB_DB); 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) { async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { const user = await requestUser(req, res)
res.status(401).json({ ok: false }); if (!user) return res.status(401).json({ ok: false });
return;
}
const { id } = req.query as { id: string }; 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) { async function del(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { const user = await requestUser(req, res)
res.status(401).json({ ok: false }); if (!user) return res.status(401).json({ ok: false });
return;
}
const { id } = req.query as { id: string }; const { id } = req.query as { id: string };
@@ -48,7 +45,6 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
return; return;
} }
const user = req.session.user;
if (user.type === "admin" || user.type === "developer") { if (user.type === "admin" || user.type === "developer") {
await db.collection("invites").deleteOne({ id: id }); await db.collection("invites").deleteOne({ id: id });
res.status(200).json({ ok: true }); res.status(200).json({ ok: true });
@@ -59,13 +55,10 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
} }
async function patch(req: NextApiRequest, res: NextApiResponse) { async function patch(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { const user = await requestUser(req, res)
res.status(401).json({ ok: false }); if (!user) return res.status(401).json({ ok: false });
return;
}
const { id } = req.query as { id: string }; const { id } = req.query as { id: string };
const user = req.session.user;
if (user.type === "admin" || user.type === "developer") { if (user.type === "admin" || user.type === "developer") {
await db.collection("invites").updateOne( await db.collection("invites").updateOne(

View File

@@ -8,6 +8,8 @@ import { CorporateUser, Group, User } from "@/interfaces/user";
import { v4 } from "uuid"; import { v4 } from "uuid";
import { sendEmail } from "@/email"; import { sendEmail } from "@/email";
import { updateExpiryDateOnGroup } from "@/utils/groups.be"; import { updateExpiryDateOnGroup } from "@/utils/groups.be";
import { addUserToEntity, getEntity, getEntityWithRoles } from "@/utils/entities.be";
import { findBy } from "@/utils";
const db = client.db(process.env.MONGODB_DB); const db = client.db(process.env.MONGODB_DB);
@@ -19,72 +21,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
res.status(404).json(undefined); res.status(404).json(undefined);
} }
async function addToInviterGroup(user: User, invitedBy: User) {
const invitedByGroups = await db.collection("groups").find<Group>({ admin: invitedBy.id }).toArray();
const typeGroupName = user.type === "student" ? "Students" : user.type === "teacher" ? "Teachers" : undefined;
if (typeGroupName) {
const typeGroup: Group = invitedByGroups.find((g) => g.name === typeGroupName) || {
id: v4(),
admin: invitedBy.id,
name: typeGroupName,
participants: [],
disableEditing: true,
};
await db.collection("groups").updateOne(
{ id: typeGroup.id },
{
$set: {
...typeGroup,
participants: [...typeGroup.participants.filter((x) => x !== user.id), user.id],
},
},
{ upsert: true }
);
}
const invitationsGroup: Group = invitedByGroups.find((g) => g.name === "Invited") || {
id: v4(),
admin: invitedBy.id,
name: "Invited",
participants: [],
disableEditing: true,
};
await db.collection("groups").updateOne(
{ id: invitationsGroup.id },
{
$set: {
...invitationsGroup,
participants: [...invitationsGroup.participants.filter((x) => x !== user.id), user.id],
}
},
{ upsert: true }
);
}
async function deleteFromPreviousCorporateGroups(user: User, invitedBy: User) {
const corporatesRef = await db.collection("users").find<CorporateUser>({ type: "corporate" }).toArray();
const corporates = corporatesRef.filter((x) => x.id !== invitedBy.id);
const userGroups = await db.collection("groups").find<Group>({
participants: user.id
}).toArray();
const corporateGroups = userGroups.filter((x) => corporates.map((c) => c.id).includes(x.admin));
await Promise.all(
corporateGroups.map(async (group) => {
await db.collection("groups").updateOne(
{ id: group.id },
{ $set: { participants: group.participants.filter((x) => x !== user.id) } },
{ upsert: true }
);
}),
);
}
async function get(req: NextApiRequest, res: NextApiResponse) { async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res.status(401).json({ ok: false }); res.status(401).json({ ok: false });
@@ -102,10 +38,11 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
const invitedBy = await db.collection("users").findOne<User>({ id: invite.from}); const invitedBy = await db.collection("users").findOne<User>({ id: invite.from});
if (!invitedBy) return res.status(404).json({ ok: false }); if (!invitedBy) return res.status(404).json({ ok: false });
await updateExpiryDateOnGroup(invite.to, invite.from); const inviteEntity = await getEntityWithRoles(invite.entity)
if (!inviteEntity) return res.status(404).json({ ok: false });
if (invitedBy.type === "corporate") await deleteFromPreviousCorporateGroups(req.session.user, invitedBy); const defaultRole = findBy(inviteEntity.roles, 'isDefault', true)!
await addToInviterGroup(req.session.user, invitedBy); await addUserToEntity(invite.to, inviteEntity.id, defaultRole.id)
try { try {
await sendEmail( await sendEmail(

View File

@@ -8,6 +8,8 @@ import client from "@/lib/mongodb";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import ShortUniqueId from "short-unique-id"; import ShortUniqueId from "short-unique-id";
import { Entity } from "@/interfaces/entity";
import { getEntity } from "@/utils/entities.be";
const db = client.db(process.env.MONGODB_DB); const db = client.db(process.env.MONGODB_DB);
@@ -36,20 +38,19 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const invited = await db.collection("users").findOne<User>({ id: body.to}); const invited = await db.collection("users").findOne<User>({ id: body.to});
if (!invited) return res.status(404).json({ok: false}); if (!invited) return res.status(404).json({ok: false});
const invitedBy = await db.collection("users").findOne<User>({ id: body.from}); const entity = await getEntity(body.entity)
if (!invitedBy) return res.status(404).json({ok: false}); if (!entity) return res.status(404).json({ok: false});
try { try {
await sendEmail( await sendEmail(
"receivedInvite", "receivedInvite",
{ {
name: invited.name, name: invited.name,
corporateName: entity: entity.label,
invitedBy.type === "corporate" ? invitedBy.corporateInformation?.companyInformation?.name || invitedBy.name : invitedBy.name,
environment: process.env.ENVIRONMENT, environment: process.env.ENVIRONMENT,
}, },
[invited.email], [invited.email],
"You have been invited to a group!", "You have been invited to an entity!",
); );
} catch (e) { } catch (e) {
console.log(e); console.log(e);

View File

@@ -10,6 +10,8 @@ import {getGroup, getGroups, getUserCorporate, getUserGroups, getUserNamedGroup}
import { uniq } from "lodash"; import { uniq } from "lodash";
import { getSpecificUsers, getUser } from "@/utils/users.be"; import { getSpecificUsers, getUser } from "@/utils/users.be";
import client from "@/lib/mongodb"; import client from "@/lib/mongodb";
import { getEntityWithRoles } from "@/utils/entities.be";
import { findBy } from "@/utils";
const DEFAULT_DESIRED_LEVELS = { const DEFAULT_DESIRED_LEVELS = {
reading: 9, reading: 9,
@@ -30,14 +32,6 @@ const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); 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) { async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") return post(req, res); if (req.method === "POST") return post(req, res);
@@ -50,29 +44,31 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
return res.status(401).json({ ok: false, reason: "You must be logged in to make user!" }); 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 {
const {email, passport_id, password, type, groupID, expiryDate, corporate} = req.body as {
email: string; email: string;
password?: string; password?: string;
passport_id: string; passport_id: string;
type: string; type: string;
entity: string;
groupID?: string; groupID?: string;
corporate?: string; corporate?: string;
expiryDate: null | Date; expiryDate: null | Date;
}; };
// cleaning data // cleaning data
delete req.body.passport_id; delete req.body.passport_id;
delete req.body.groupID; delete req.body.groupID;
delete req.body.expiryDate; delete req.body.expiryDate;
delete req.body.password; delete req.body.password;
delete req.body.corporate; delete req.body.corporate;
delete req.body.entity
await createUserWithEmailAndPassword(auth, email.toLowerCase(), !!password ? password : passport_id) await createUserWithEmailAndPassword(auth, email.toLowerCase(), !!password ? password : passport_id)
.then(async (userCredentials) => { .then(async (userCredentials) => {
const userId = userCredentials.user.uid; const userId = userCredentials.user.uid;
const profilePicture = !corporateCorporate ? "/defaultAvatar.png" : corporateCorporate.profilePicture; const entityWithRole = await getEntityWithRoles(entity)
const defaultRole = findBy(entityWithRole?.roles || [], "isDefault", true)
const user = { const user = {
...req.body, ...req.body,
@@ -82,11 +78,12 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
focus: "academic", focus: "academic",
status: "active", status: "active",
desiredLevels: DEFAULT_DESIRED_LEVELS, desiredLevels: DEFAULT_DESIRED_LEVELS,
profilePicture, profilePicture: "/defaultAvatar.png",
levels: DEFAULT_LEVELS, levels: DEFAULT_LEVELS,
isFirstLogin: false, isFirstLogin: false,
isVerified: true, isVerified: true,
registrationDate: new Date(), registrationDate: new Date(),
entities: [{ id: entity, role: defaultRole?.id || "" }],
subscriptionExpirationDate: expiryDate || null, subscriptionExpirationDate: expiryDate || null,
...((maker.type === "corporate" || maker.type === "mastercorporate") && type === "corporate" ...((maker.type === "corporate" || maker.type === "mastercorporate") && type === "corporate"
? { ? {
@@ -116,99 +113,6 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
...(!!passport_id ? { passport_id } : {}), ...(!!passport_id ? { passport_id } : {}),
}); });
if (type === "corporate") {
const students = maker.type === "corporate" ? await getUsersOfType(maker.id, "student") : [];
const teachers = maker.type === "corporate" ? await getUsersOfType(maker.id, "teacher") : [];
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<CorporateUser>({email: corporate.trim().toLowerCase()});
if (!!corporateUser) {
await db.collection("codes").updateOne({code}, {$set: {creator: corporateUser.id}});
const typeGroup = await db
.collection("groups")
.findOne<Group>({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) { if (!!groupID) {
const group = await getGroup(groupID); const group = await getGroup(groupID);
if (!!group) await db.collection("groups").updateOne({ id: group.id }, { $set: { participants: [...group.participants, userId] } }); if (!!group) await db.collection("groups").updateOne({ id: group.id }, { $set: { participants: [...group.participants, userId] } });

View File

@@ -7,6 +7,7 @@ import {Group} from "@/interfaces/user";
import {Payment} from "@/interfaces/paypal"; import {Payment} from "@/interfaces/paypal";
import {deleteObject, ref} from "firebase/storage"; import {deleteObject, ref} from "firebase/storage";
import client from "@/lib/mongodb"; import client from "@/lib/mongodb";
import { requestUser } from "@/utils/api";
const db = client.db(process.env.MONGODB_DB); 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) { async function del(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { const user = await requestUser(req, res)
res.status(401).json({ok: false}); if (!user) return res.status(401).json({ ok: false });
return;
}
const {id} = req.query as {id: string}; const {id} = req.query as {id: string};
const payment = await db.collection("payments").findOne<Payment>({id}); const payment = await db.collection("payments").findOne<Payment>({id});
if (!payment) return res.status(404).json({ok: false}); if (!payment) return res.status(404).json({ok: false});
const user = req.session.user;
if (user.type === "admin" || user.type === "developer") { if (user.type === "admin" || user.type === "developer") {
if (payment.commissionTransfer) await deleteObject(ref(storage, payment.commissionTransfer)); if (payment.commissionTransfer) await deleteObject(ref(storage, payment.commissionTransfer));
if (payment.corporateTransfer) await deleteObject(ref(storage, payment.corporateTransfer)); 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) { async function patch(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { const user = await requestUser(req, res)
res.status(401).json({ok: false}); if (!user) return res.status(401).json({ ok: false });
return;
}
const {id} = req.query as {id: string}; const {id} = req.query as {id: string};
const payment = await db.collection("payments").findOne<Payment>({id}); const payment = await db.collection("payments").findOne<Payment>({id});
if (!payment) return res.status(404).json({ok: false}); if (!payment) return res.status(404).json({ok: false});
const user = req.session.user;
if (user.type === "admin" || user.type === "developer") { if (user.type === "admin" || user.type === "developer") {
await db.collection("payments").updateOne({id: payment.id}, {$set: req.body}); await db.collection("payments").updateOne({id: payment.id}, {$set: req.body});

View File

@@ -11,6 +11,7 @@ import { OrderResponseBody } from "@paypal/paypal-js";
import { getAccessToken } from "@/utils/paypal"; import { getAccessToken } from "@/utils/paypal";
import moment from "moment"; import moment from "moment";
import { Group } from "@/interfaces/user"; import { Group } from "@/interfaces/user";
import { requestUser } from "@/utils/api";
const db = client.db(process.env.MONGODB_DB); const db = client.db(process.env.MONGODB_DB);
@@ -25,6 +26,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!accessToken) if (!accessToken)
return res.status(401).json({ ok: false, reason: "Authorization failed!" }); 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 { const { id, duration, duration_unit, trackingId } = req.body as {
id: string; id: string;
duration: number; duration: number;

View File

@@ -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<Entity>("roles").updateOne({ id }, { $set: {label} });
if (!!permissions) await db.collection<Entity>("roles").updateOne({ id }, { $set: {permissions} });
return res.status(200).json({ok: true});
}

View File

@@ -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});
}

View File

@@ -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);
}

View File

@@ -6,6 +6,7 @@ import { sessionOptions } from "@/lib/session";
import { Stat } from "@/interfaces/user"; import { Stat } from "@/interfaces/user";
import { Assignment } from "@/interfaces/results"; import { Assignment } from "@/interfaces/results";
import { groupBy } from "lodash"; import { groupBy } from "lodash";
import { requestUser } from "@/utils/api";
const db = client.db(process.env.MONGODB_DB); 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) { async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { const user = await requestUser(req, res)
res.status(401).json({ ok: false }); if (!user) return res.status(401).json({ ok: false });
return;
}
const snapshot = await db.collection("stats").find<Stat>({}).toArray(); const snapshot = await db.collection("stats").find<Stat>({}).toArray();
res.status(200).json(snapshot); res.status(200).json(snapshot);
} }
async function post(req: NextApiRequest, res: NextApiResponse) { async function post(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { const user = await requestUser(req, res)
res.status(401).json({ ok: false }); if (!user) return res.status(401).json({ ok: false });
return;
}
const stats = req.body as Stat[]; const stats = req.body as Stat[];
stats.forEach(async (stat) => await db.collection("stats").updateOne( stats.forEach(async (stat) => await db.collection("stats").updateOne(
@@ -59,7 +57,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
$set: { $set: {
results: [ results: [
...assignmentSnapshot ? assignmentSnapshot.results : [], ...assignmentSnapshot ? assignmentSnapshot.results : [],
{ user: req.session.user?.id, type: req.session.user?.focus, stats: assignmentStats }, { user: user.id, type: user.focus, stats: assignmentStats },
], ],
} }
} }

View File

@@ -10,21 +10,23 @@ import client from "@/lib/mongodb";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {groupBy} from "lodash"; import {groupBy} from "lodash";
import {NextApiRequest, NextApiResponse} from "next"; import {NextApiRequest, NextApiResponse} from "next";
import { requestUser } from "@/utils/api";
const db = client.db(process.env.MONGODB_DB); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(update, sessionOptions); export default withIronSessionApiRoute(update, sessionOptions);
async function update(req: NextApiRequest, res: NextApiResponse) { async function update(req: NextApiRequest, res: NextApiResponse) {
if (req.session.user) { const user = await requestUser(req, res)
const docUser = await db.collection("users").findOne({ id: req.session.user.id }); if (user) {
const docUser = await db.collection("users").findOne({ id: user.id });
if (!docUser) { if (!docUser) {
res.status(401).json(undefined); res.status(401).json(undefined);
return; return;
} }
const stats = await db.collection("stats").find<Stat>({ user: req.session.user.id }).toArray(); const stats = await db.collection("stats").find<Stat>({ user: user.id }).toArray();
const groupedStats = groupBySession(stats); const groupedStats = groupBySession(stats);
const sessionLevels: {[key in Module]: {correct: number; total: number}}[] = Object.keys(groupedStats).map((key) => { const sessionLevels: {[key in Module]: {correct: number; total: number}}[] = Object.keys(groupedStats).map((key) => {
@@ -91,15 +93,15 @@ async function update(req: NextApiRequest, res: NextApiResponse) {
const levels = { const levels = {
reading: calculateBandScore(readingLevel.correct, readingLevel.total, "reading", req.session.user.focus), reading: calculateBandScore(readingLevel.correct, readingLevel.total, "reading", user.focus),
listening: calculateBandScore(listeningLevel.correct, listeningLevel.total, "listening", req.session.user.focus), listening: calculateBandScore(listeningLevel.correct, listeningLevel.total, "listening", user.focus),
writing: calculateBandScore(writingLevel.correct, writingLevel.total, "writing", req.session.user.focus), writing: calculateBandScore(writingLevel.correct, writingLevel.total, "writing", user.focus),
speaking: calculateBandScore(speakingLevel.correct, speakingLevel.total, "speaking", req.session.user.focus), speaking: calculateBandScore(speakingLevel.correct, speakingLevel.total, "speaking", user.focus),
level: calculateBandScore(levelLevel.correct, levelLevel.total, "level", req.session.user.focus), level: calculateBandScore(levelLevel.correct, levelLevel.total, "level", user.focus),
}; };
await db.collection("users").updateOne( await db.collection("users").updateOne(
{ id: req.session.user.id}, { id: user.id},
{ $set: {levels} } { $set: {levels} }
); );

View File

@@ -7,7 +7,9 @@ import {withIronSessionApiRoute} from "iron-session/next";
import {NextApiRequest, NextApiResponse} from "next"; import {NextApiRequest, NextApiResponse} from "next";
import {getPermissions, getPermissionDocs} from "@/utils/permissions.be"; import {getPermissions, getPermissionDocs} from "@/utils/permissions.be";
import client from "@/lib/mongodb"; 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 auth = getAuth(adminApp);
const db = client.db(process.env.MONGODB_DB); const db = client.db(process.env.MONGODB_DB);
@@ -41,20 +43,6 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
return; 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 auth.deleteUser(id);
await db.collection("users").deleteOne({id: targetUser.id}); await db.collection("users").deleteOne({id: targetUser.id});
await db.collection("codes").deleteMany({userId: targetUser.id}); await db.collection("codes").deleteMany({userId: targetUser.id});
@@ -63,9 +51,8 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
const groups = await getParticipantGroups(targetUser.id); const groups = await getParticipantGroups(targetUser.id);
await Promise.all( await Promise.all(
groups.map( groups
async (x) => await db.collection("groups").updateOne({id: x.id}, {$set: {participants: x.participants.filter((y: string) => y !== id)}}), .map(async (g) => await removeParticipantFromGroup(g.id, targetUser.id)),
),
); );
res.json({ok: true}); res.json({ok: true});
@@ -73,11 +60,8 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
async function get(req: NextApiRequest, res: NextApiResponse) { async function get(req: NextApiRequest, res: NextApiResponse) {
if (req.session.user) { if (req.session.user) {
const user = await db.collection("users").findOne<User>({id: req.session.user.id}); const user = await getUser(req.session.user.id)
if (!user) { if (!user) return res.status(401).json(undefined);
res.status(401).json(undefined);
return;
}
await db.collection("users").updateOne({id: user.id}, {$set: {lastLogin: new Date().toISOString()}}); await db.collection("users").updateOne({id: user.id}, {$set: {lastLogin: new Date().toISOString()}});

View File

@@ -0,0 +1,453 @@
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, BsBuilding, 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){
const users = await getUsers()
return {props: serialize({user, users, assignment})};
}
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("/exam");
}
});
};
const content = (
<>
<div className="-md:items-center flex w-full justify-between 2xl:items-center">
<div className="-md:gap-2 -md:items-center flex md:flex-col md:gap-1 2xl:flex-row 2xl:items-center 2xl:gap-2">
<span className="font-medium">{formatTimestamp(stats[0].date.toString())}</span>
{timeSpent && (
<>
<span className="md:hidden 2xl:flex"> </span>
<span className="text-sm">{Math.floor(timeSpent / 60)} minutes</span>
</>
)}
</div>
<span
className={clsx(
correct / total >= 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)}
</span>
</div>
<div className="flex w-full flex-col gap-1">
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
{aggregatedLevels.map(({module, level}) => (
<div
key={module}
className={clsx(
"-md:px-4 flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
module === "reading" && "bg-ielts-reading",
module === "listening" && "bg-ielts-listening",
module === "writing" && "bg-ielts-writing",
module === "speaking" && "bg-ielts-speaking",
module === "level" && "bg-ielts-level",
)}>
{module === "reading" && <BsBook className="h-4 w-4" />}
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
{module === "writing" && <BsPen className="h-4 w-4" />}
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
{module === "level" && <BsClipboard className="h-4 w-4" />}
<span className="text-sm">{level.toFixed(1)}</span>
</div>
))}
</div>
</div>
</>
);
return (
<div className="flex flex-col gap-2">
<span>
{(() => {
const student = users.find((u) => u.id === user);
return `${student?.name} (${student?.email})`;
})()}
</span>
<div
key={user}
className={clsx(
"border-mti-gray-platinum -md:hidden flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out",
correct / total >= 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}
</div>
<div
key={user}
className={clsx(
"border-mti-gray-platinum -md:tooltip flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out md:hidden",
correct / total >= 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}
</div>
</div>
);
};
const shouldRenderStart = () => {
if (assignment) {
if (futureAssignmentFilter(assignment)) {
return true;
}
}
return false;
};
const removeInactiveAssignees = () => {
const mappedResults = mapBy(assignment.results, 'user')
const inactiveAssignees = assignment.assignees.filter((a) => !mappedResults.includes(a))
const activeAssignees = assignment.assignees.filter((a) => mappedResults.includes(a))
if (!confirm(`Are you sure you want to remove ${inactiveAssignees.length} assignees?`)) return
axios
.patch(`/api/assignments/${assignment.id}`, {assignees: activeAssignees})
.then(() => {
toast.success(`The assignment "${assignment.name}" has been updated successfully!`);
router.replace(router.asPath);
})
.catch((e) => {
console.log(e);
toast.error("Something went wrong, please try again later!");
});
}
const copyLink = async () => {
const origin = window.location.origin
await navigator.clipboard.writeText(`${origin}/exam?assignment=${assignment.id}`)
toast.success("The URL to the assignment has been copied to your clipboard!")
}
return (
<>
<Head>
<title>{assignment.name} | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<Layout user={user}>
<div className="flex flex-col gap-4">
<div className="w-full flex items-center justify-between">
<div className="flex items-center gap-2">
<Link href="/assignments" className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
<BsChevronLeft />
</Link>
<h2 className="font-bold text-2xl">{assignment.name}</h2>
</div>
{!!entity && (
<span className="flex items-center gap-2">
<BsBuilding className="text-xl" /> {entity.label}
</span>
)}
</div>
<Separator />
</div>
<div className="mt-4 flex w-full flex-col gap-4">
<ProgressBar
color="purple"
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
className="h-6"
textClassName={
(assignment?.results.length || 0) / (assignment?.assignees.length || 1) < 0.5
? "!text-mti-gray-dim font-light"
: "text-white"
}
percentage={((assignment?.results.length || 0) / (assignment?.assignees.length || 1)) * 100}
/>
<div className="flex items-start gap-8">
<div className="flex flex-col gap-2">
<span>Start Date: {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}</span>
<span>End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}</span>
</div>
<div className="flex flex-col gap-2">
<span>
Assignees:{" "}
{users
.filter((u) => assignment?.assignees.includes(u.id))
.map((u) => `${u.name} (${u.email})`)
.join(", ")}
</span>
<span>Assigner: {getUserName(users.find((x) => x.id === assignment?.assigner))}</span>
</div>
</div>
{assignment.assignees.length !== 0 && assignment.results.length !== assignment.assignees.length && (
<Button onClick={removeInactiveAssignees} variant="outline">Remove Inactive Assignees</Button>
)}
<div className="flex flex-col gap-2">
<span className="text-xl font-bold">Average Scores</span>
<div className="-md:mt-2 flex w-full items-center gap-4">
{assignment &&
uniqBy(assignment.exams, (x) => x.module).map(({module}) => (
<div
data-tip={capitalize(module)}
key={module}
className={clsx(
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
module === "reading" && "bg-ielts-reading",
module === "listening" && "bg-ielts-listening",
module === "writing" && "bg-ielts-writing",
module === "speaking" && "bg-ielts-speaking",
module === "level" && "bg-ielts-level",
)}>
{module === "reading" && <BsBook className="h-4 w-4" />}
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
{module === "writing" && <BsPen className="h-4 w-4" />}
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
{module === "level" && <BsClipboard className="h-4 w-4" />}
{calculateAverageModuleScore(module) > -1 && (
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
)}
</div>
))}
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-xl font-bold">
Results ({assignment?.results.length}/{assignment?.assignees.length})
</span>
<div>
{assignment && assignment?.results.length > 0 && (
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 xl:gap-6">
{assignment.results.map((r) => customContent(r.stats, r.user, r.type))}
</div>
)}
{assignment && assignment?.results.length === 0 && <span className="ml-1 font-semibold">No results yet...</span>}
</div>
</div>
<div className="flex gap-4 w-full items-center justify-end">
<Button variant="outline" color="purple" className="w-full max-w-[200px]" onClick={copyLink}>
Copy Link
</Button>
{assignment &&
(assignment.results.length === assignment.assignees.length || moment().isAfter(moment(assignment.endDate))) && (
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={deleteAssignment}>
Delete
</Button>
)}
{/** if the assignment is not deemed as active yet, display start */}
{shouldRenderStart() && (
<Button variant="outline" color="green" className="w-full max-w-[200px]" onClick={startAssignment}>
Start
</Button>
)}
<Button onClick={() => router.push("/assignments")} className="w-full max-w-[200px]">
Close
</Button>
</div>
</div>
</Layout>
</>
);
}

View File

@@ -0,0 +1,615 @@
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) => e.id === 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<Module[]>(assignment.exams.map((e) => e.module));
const [assignees, setAssignees] = useState<string[]>(assignment.assignees);
const [teachers, setTeachers] = useState<string[]>(assignment.teachers || []);
const [entity, setEntity] = useState<string | undefined>(assignment.entity || entities[0]?.id);
const [name, setName] = useState(assignment.name);
const [isLoading, setIsLoading] = useState(false);
const [startDate, setStartDate] = useState<Date | null>(moment(assignment.startDate).toDate());
const [endDate, setEndDate] = useState<Date | null>(moment(assignment.endDate).toDate());
const [variant, setVariant] = useState<Variant>("full");
const [instructorGender, setInstructorGender] = useState<InstructorGender>(assignment?.instructorGender || "varied");
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
const [released, setReleased] = useState<boolean>(assignment.released || false);
const [autoStart, setAutostart] = useState<boolean>(assignment.autoStart || false);
const [autoStartDate, setAutoStartDate] = useState<Date | null>(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));
}
};
const copyLink = async () => {
const origin = window.location.origin
await navigator.clipboard.writeText(`${origin}/exam?assignment=${assignment.id}`)
toast.success("The URL to the assignment has been copied to your clipboard!")
}
return (
<>
<Head>
<title>Edit {assignment.name} | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<Layout user={user}>
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<Link href="/assignments" className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
<BsChevronLeft />
</Link>
<h2 className="font-bold text-2xl">Edit {assignment.name}</h2>
</div>
<Separator />
</div>
<div className="w-full flex flex-col gap-4">
<section className="w-full grid -md:grid-cols-1 md:grid-cols-3 place-items-center -md:flex-col -md:items-center -md:gap-12 justify-between gap-8 mt-8 px-8">
<div
onClick={!selectedModules.includes("level") ? () => 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",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsBook className="text-white w-7 h-7" />
</div>
<span className="ml-8 font-semibold">Reading</span>
{!selectedModules.includes("reading") && !selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("reading") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
</div>
<div
onClick={!selectedModules.includes("level") ? () => 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",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsHeadphones className="text-white w-7 h-7" />
</div>
<span className="ml-8 font-semibold">Listening</span>
{!selectedModules.includes("listening") && !selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("listening") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
</div>
<div
onClick={
(!selectedModules.includes("level") && selectedModules.length === 0) || selectedModules.includes("level")
? () => 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",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-level top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsClipboard className="text-white w-7 h-7" />
</div>
<span className="ml-8 font-semibold">Level</span>
{!selectedModules.includes("level") && selectedModules.length === 0 && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)}
{!selectedModules.includes("level") && selectedModules.length > 0 && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("level") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
</div>
<div
onClick={!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",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsPen className="text-white w-7 h-7" />
</div>
<span className="ml-8 font-semibold">Writing</span>
{!selectedModules.includes("writing") && !selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("writing") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
</div>
<div
onClick={!selectedModules.includes("level") ? () => 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",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsMegaphone className="text-white w-7 h-7" />
</div>
<span className="ml-8 font-semibold">Speaking</span>
{!selectedModules.includes("speaking") && !selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("speaking") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
</div>
</section>
<div className="w-full grid -md:grid-cols-1 md:grid-cols-2 gap-8">
<Input type="text" name="name" onChange={(e) => setName(e)} defaultValue={name} label="Assignment Name" required />
<Select
label="Entity"
options={entities.map((e) => ({value: e.id, label: e.label}))}
onChange={(v) => setEntity(v ? v.value! : undefined)}
defaultValue={{value: entities[0]?.id, label: entities[0]?.label}}
/>
</div>
<div className="w-full grid -md:grid-cols-1 md:grid-cols-2 gap-8">
<div className="flex flex-col gap-2">
<label className="font-normal text-base text-mti-gray-dim">Limit Start Date *</label>
<ReactDatePicker
className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip z-10",
"transition duration-300 ease-in-out",
)}
popperClassName="!z-20"
filterTime={(date) => moment(date).isSameOrAfter(new Date())}
dateFormat="dd/MM/yyyy HH:mm"
selected={startDate}
showTimeSelect
onChange={(date) => setStartDate(date)}
/>
</div>
<div className="flex flex-col gap-2">
<label className="font-normal text-base text-mti-gray-dim">End Date *</label>
<ReactDatePicker
className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip z-10",
"transition duration-300 ease-in-out",
)}
popperClassName="!z-20"
filterTime={(date) => moment(date).isAfter(startDate)}
dateFormat="dd/MM/yyyy HH:mm"
selected={endDate}
showTimeSelect
onChange={(date) => setEndDate(date)}
/>
</div>
{autoStart && (
<div className="flex flex-col gap-2">
<label className="font-normal text-base text-mti-gray-dim">Automatic Start Date *</label>
<ReactDatePicker
className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip z-10",
"transition duration-300 ease-in-out",
)}
popperClassName="!z-20"
filterTime={(date) => moment(date).isSameOrAfter(new Date())}
dateFormat="dd/MM/yyyy HH:mm"
selected={autoStartDate}
showTimeSelect
onChange={(date) => setAutoStartDate(date)}
/>
</div>
)}
</div>
{selectedModules.includes("speaking") && (
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Speaking Instructor&apos;s Gender</label>
<Select
value={{
value: instructorGender,
label: capitalize(instructorGender),
}}
onChange={(value) => (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"},
]}
/>
</div>
)}
{selectedModules.length > 0 && (
<div className="flex flex-col gap-3 w-full">
<Checkbox isChecked={useRandomExams} onChange={setUseRandomExams}>
Random Exams
</Checkbox>
{!useRandomExams && (
<div className="grid md:grid-cols-2 w-full gap-4">
{selectedModules.map((module) => (
<div key={module} className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">{capitalize(module)} Exam</label>
<Select
value={{
value: examIDs.find((e) => e.module === module)?.id || null,
label: examIDs.find((e) => e.module === module)?.id || "",
}}
onChange={(value) =>
value
? setExamIDs((prev) => [
...prev.filter((x) => x.module !== module),
{id: value.value!, module},
])
: setExamIDs((prev) => prev.filter((x) => x.module !== module))
}
options={exams
.filter((x) => !x.isDiagnostic && x.module === module)
.map((x) => ({value: x.id, label: x.id}))}
/>
</div>
))}
</div>
)}
</div>
)}
<section className="w-full flex flex-col gap-4">
<span className="font-semibold">Assignees ({assignees.length} selected)</span>
<div className="grid grid-cols-5 gap-4">
{classrooms.map((g) => (
<button
key={g.id}
onClick={() => {
const groupStudentIds = users.filter((u) => g.participants.includes(u.id)).map((u) => u.id);
if (groupStudentIds.every((u) => assignees.includes(u))) {
setAssignees((prev) => prev.filter((a) => !groupStudentIds.includes(a)));
} else {
setAssignees((prev) => [...prev.filter((a) => !groupStudentIds.includes(a)), ...groupStudentIds]);
}
}}
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out",
users.filter((u) => g.participants.includes(u.id)).every((u) => assignees.includes(u.id)) &&
"!bg-mti-purple-light !text-white",
)}>
{g.name}
</button>
))}
</div>
<div className="w-full flex items-center gap-4">
{renderStudentSearch()}
{renderStudentPagination()}
</div>
<div className="flex flex-wrap -md:justify-center gap-4">
{studentRows.map((user) => (
<div
onClick={() => toggleAssignee(user)}
className={clsx(
"p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72",
"transition ease-in-out duration-300",
assignees.includes(user.id) ? "border-mti-purple" : "border-mti-gray-platinum",
)}
key={user.id}>
<span className="flex flex-col gap-0 justify-center">
<span className="font-semibold">{user.name}</span>
<span className="text-sm opacity-80">{user.email}</span>
</span>
<ProgressBar
color="purple"
textClassName="!text-mti-black/80"
label={`Level ${calculateAverageLevel(user.levels)}`}
percentage={(calculateAverageLevel(user.levels) / 9) * 100}
className="h-6"
/>
<span className="text-mti-black/80 text-sm whitespace-pre-wrap mt-2">
Groups:{" "}
{groups
.filter((g) => g.participants.includes(user.id))
.map((g) => g.name)
.join(", ")}
</span>
</div>
))}
</div>
</section>
{user.type !== "teacher" && (
<section className="w-full flex flex-col gap-3">
<span className="font-semibold">Teachers ({teachers.length} selected)</span>
<div className="grid grid-cols-5 gap-4">
{classrooms.map((g) => (
<button
key={g.id}
onClick={() => {
const groupStudentIds = users.filter((u) => g.participants.includes(u.id)).map((u) => u.id);
if (groupStudentIds.every((u) => teachers.includes(u))) {
setTeachers((prev) => prev.filter((a) => !groupStudentIds.includes(a)));
} else {
setTeachers((prev) => [...prev.filter((a) => !groupStudentIds.includes(a)), ...groupStudentIds]);
}
}}
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out",
users.filter((u) => g.participants.includes(u.id)).every((u) => teachers.includes(u.id)) &&
"!bg-mti-purple-light !text-white",
)}>
{g.name}
</button>
))}
</div>
<div className="w-full flex items-center gap-4">
{renderTeacherSearch()}
{renderTeacherPagination()}
</div>
<div className="flex flex-wrap -md:justify-center gap-4">
{teacherRows.map((user) => (
<div
onClick={() => toggleTeacher(user)}
className={clsx(
"p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72",
"transition ease-in-out duration-300",
teachers.includes(user.id) ? "border-mti-purple" : "border-mti-gray-platinum",
)}
key={user.id}>
<span className="flex flex-col gap-0 justify-center">
<span className="font-semibold">{user.name}</span>
<span className="text-sm opacity-80">{user.email}</span>
</span>
<span className="text-mti-black/80 text-sm whitespace-pre-wrap mt-2">
Groups:{" "}
{groups
.filter((g) => g.participants.includes(user.id))
.map((g) => g.name)
.join(", ")}
</span>
</div>
))}
</div>
</section>
)}
<div className="flex gap-4 w-full items-end">
<Checkbox isChecked={variant === "full"} onChange={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
Full length exams
</Checkbox>
<Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple((d) => !d)}>
Generate different exams
</Checkbox>
<Checkbox isChecked={released} onChange={() => setReleased((d) => !d)}>
Auto release results
</Checkbox>
<Checkbox isChecked={autoStart} onChange={() => setAutostart((d) => !d)}>
Auto start exam
</Checkbox>
</div>
<div className="flex gap-4 w-full justify-end">
<Button variant="outline" color="purple" className="w-full max-w-[200px]" onClick={copyLink}>
Copy Link
</Button>
<Button
className="w-full max-w-[200px]"
variant="outline"
onClick={() => router.push("/assignments")}
disabled={isLoading}
isLoading={isLoading}>
Cancel
</Button>
<Button
className="w-full max-w-[200px]"
color="green"
variant="outline"
onClick={startAssignment}
disabled={isLoading || moment().isAfter(startDate)}
isLoading={isLoading}>
Start
</Button>
<Button
className="w-full max-w-[200px]"
color="red"
variant="outline"
onClick={deleteAssignment}
disabled={isLoading}
isLoading={isLoading}>
Delete
</Button>
<Button
disabled={
selectedModules.length === 0 ||
!name ||
!startDate ||
!endDate ||
assignees.length === 0 ||
(!useRandomExams && examIDs.length < selectedModules.length)
}
className="w-full max-w-[200px]"
onClick={createAssignment}
isLoading={isLoading}>
Update
</Button>
</div>
</div>
</Layout>
</>
);
}

View File

@@ -0,0 +1,554 @@
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, WithEntity} 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 {getEntitiesWithRoles} from "@/utils/entities.be";
import {getGroups, getGroupsByEntities} from "@/utils/groups.be";
import {checkAccess, findAllowedEntities} from "@/utils/permissions";
import {calculateAverageLevel} from "@/utils/score";
import { isAdmin } from "@/utils/users";
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}) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
const entityIDS = mapBy(user.entities, "id") || [];
const entities = await (checkAccess(user, ["developer", "admin"]) ? getEntitiesWithRoles() : getEntitiesWithRoles(entityIDS));
const allowedEntities = findAllowedEntities(user, entities, 'create_assignment')
if (allowedEntities.length === 0) return redirect("/assignments")
const users = await (isAdmin(user) ? getUsers() : getEntitiesUsers(mapBy(allowedEntities, 'id')));
const groups = await (isAdmin(user) ? getGroups() : getGroupsByEntities(mapBy(allowedEntities, 'id')));
return {props: serialize({user, users, entities, groups})};
}, sessionOptions);
interface Props {
assignment: Assignment;
groups: WithEntity<Group>[];
user: User;
users: User[];
entities: EntityWithRoles[];
}
const SIZE = 9;
export default function AssignmentsPage({user, users, groups, entities}: Props) {
const [selectedModules, setSelectedModules] = useState<Module[]>([]);
const [assignees, setAssignees] = useState<string[]>([]);
const [teachers, setTeachers] = useState<string[]>([...(user.type === "teacher" ? [user.id] : [])]);
const [entity, setEntity] = useState<string | undefined>(entities[0]?.id);
const [name, setName] = useState(
generate({
minLength: 6,
maxLength: 8,
min: 2,
max: 3,
join: " ",
formatter: capitalize,
}),
);
const [isLoading, setIsLoading] = useState(false);
const [startDate, setStartDate] = useState<Date | null>(moment().add(1, "hour").toDate());
const [endDate, setEndDate] = useState<Date | null>(moment().hours(23).minutes(59).add(8, "day").toDate());
const [variant, setVariant] = useState<Variant>("full");
const [instructorGender, setInstructorGender] = useState<InstructorGender>("varied");
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
const [released, setReleased] = useState<boolean>(false);
const [autoStart, setAutostart] = useState<boolean>(false);
const [autoStartDate, setAutoStartDate] = useState<Date | null>(new Date());
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?.id === entity), [entity, groups]);
const allowedUsers = useMemo(() => users.filter((u) => mapBy(u.entities, 'id').includes(entity || "")), [users, entity])
const userStudents = useMemo(() => allowedUsers.filter((x) => x.type === "student"), [allowedUsers]);
const userTeachers = useMemo(() => allowedUsers.filter((x) => x.type === "teacher"), [allowedUsers]);
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);
axios
.post(`/api/assignments`, {
assignees,
name,
startDate,
examIDs: !useRandomExams ? examIDs : undefined,
endDate,
selectedModules,
generateMultiple,
entity,
teachers,
variant,
instructorGender,
released,
autoStart,
autoStartDate,
})
.then((result) => {
toast.success(`The assignment "${name}" has been created successfully!`);
router.push(`/assignments/${result.data.id}`);
})
.catch((e) => {
console.log(e);
toast.error("Something went wrong, please try again later!");
})
.finally(() => setIsLoading(false));
};
return (
<>
<Head>
<title>Create Assignment | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<Layout user={user}>
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<Link href="/assignments" className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
<BsChevronLeft />
</Link>
<h2 className="font-bold text-2xl">Create Assignment</h2>
</div>
<Separator />
</div>
<div className="w-full flex flex-col gap-4">
<section className="w-full grid -md:grid-cols-1 md:grid-cols-3 place-items-center -md:flex-col -md:items-center -md:gap-12 justify-between gap-8 mt-8 px-8">
<div
onClick={!selectedModules.includes("level") ? () => 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",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsBook className="text-white w-7 h-7" />
</div>
<span className="ml-8 font-semibold">Reading</span>
{!selectedModules.includes("reading") && !selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("reading") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
</div>
<div
onClick={!selectedModules.includes("level") ? () => 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",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsHeadphones className="text-white w-7 h-7" />
</div>
<span className="ml-8 font-semibold">Listening</span>
{!selectedModules.includes("listening") && !selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("listening") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
</div>
<div
onClick={
(!selectedModules.includes("level") && selectedModules.length === 0) || selectedModules.includes("level")
? () => 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",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-level top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsClipboard className="text-white w-7 h-7" />
</div>
<span className="ml-8 font-semibold">Level</span>
{!selectedModules.includes("level") && selectedModules.length === 0 && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)}
{!selectedModules.includes("level") && selectedModules.length > 0 && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("level") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
</div>
<div
onClick={!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",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsPen className="text-white w-7 h-7" />
</div>
<span className="ml-8 font-semibold">Writing</span>
{!selectedModules.includes("writing") && !selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("writing") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
</div>
<div
onClick={!selectedModules.includes("level") ? () => 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",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsMegaphone className="text-white w-7 h-7" />
</div>
<span className="ml-8 font-semibold">Speaking</span>
{!selectedModules.includes("speaking") && !selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("speaking") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
</div>
</section>
<div className="w-full grid -md:grid-cols-1 md:grid-cols-2 gap-8">
<Input type="text" name="name" onChange={(e) => setName(e)} defaultValue={name} label="Assignment Name" required />
<Select
label="Entity"
options={entities.map((e) => ({value: e.id, label: e.label}))}
onChange={(v) => setEntity(v ? v.value! : undefined)}
defaultValue={{value: entities[0]?.id, label: entities[0]?.label}}
/>
</div>
<div className="w-full grid -md:grid-cols-1 md:grid-cols-2 gap-8">
<div className="flex flex-col gap-2">
<label className="font-normal text-base text-mti-gray-dim">Limit Start Date *</label>
<ReactDatePicker
className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip z-10",
"transition duration-300 ease-in-out",
)}
popperClassName="!z-20"
filterTime={(date) => moment(date).isSameOrAfter(new Date())}
dateFormat="dd/MM/yyyy HH:mm"
selected={startDate}
showTimeSelect
onChange={(date) => setStartDate(date)}
/>
</div>
<div className="flex flex-col gap-2">
<label className="font-normal text-base text-mti-gray-dim">End Date *</label>
<ReactDatePicker
className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip z-10",
"transition duration-300 ease-in-out",
)}
popperClassName="!z-20"
filterTime={(date) => moment(date).isAfter(startDate)}
dateFormat="dd/MM/yyyy HH:mm"
selected={endDate}
showTimeSelect
onChange={(date) => setEndDate(date)}
/>
</div>
{autoStart && (
<div className="flex flex-col gap-2">
<label className="font-normal text-base text-mti-gray-dim">Automatic Start Date *</label>
<ReactDatePicker
className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip z-10",
"transition duration-300 ease-in-out",
)}
popperClassName="!z-20"
filterTime={(date) => moment(date).isSameOrAfter(new Date())}
dateFormat="dd/MM/yyyy HH:mm"
selected={autoStartDate}
showTimeSelect
onChange={(date) => setAutoStartDate(date)}
/>
</div>
)}
</div>
{selectedModules.includes("speaking") && (
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Speaking Instructor&apos;s Gender</label>
<Select
value={{
value: instructorGender,
label: capitalize(instructorGender),
}}
onChange={(value) => (value ? setInstructorGender(value.value as InstructorGender) : null)}
disabled={!selectedModules.includes("speaking")}
options={[
{value: "male", label: "Male"},
{value: "female", label: "Female"},
{value: "varied", label: "Varied"},
]}
/>
</div>
)}
{selectedModules.length > 0 && (
<div className="flex flex-col gap-3 w-full">
<Checkbox isChecked={useRandomExams} onChange={setUseRandomExams}>
Random Exams
</Checkbox>
{!useRandomExams && (
<div className="grid md:grid-cols-2 w-full gap-4">
{selectedModules.map((module) => (
<div key={module} className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">{capitalize(module)} Exam</label>
<Select
value={{
value: examIDs.find((e) => e.module === module)?.id || null,
label: examIDs.find((e) => e.module === module)?.id || "",
}}
onChange={(value) =>
value
? setExamIDs((prev) => [
...prev.filter((x) => x.module !== module),
{id: value.value!, module},
])
: setExamIDs((prev) => prev.filter((x) => x.module !== module))
}
options={exams
.filter((x) => !x.isDiagnostic && x.module === module)
.map((x) => ({value: x.id, label: x.id}))}
/>
</div>
))}
</div>
)}
</div>
)}
<section className="w-full flex flex-col gap-4">
<span className="font-semibold">Assignees ({assignees.length} selected)</span>
<div className="grid grid-cols-5 gap-4">
{classrooms.map((g) => (
<button
key={g.id}
onClick={() => {
const groupStudentIds = users.filter((u) => g.participants.includes(u.id)).map((u) => u.id);
if (groupStudentIds.every((u) => assignees.includes(u))) {
setAssignees((prev) => prev.filter((a) => !groupStudentIds.includes(a)));
} else {
setAssignees((prev) => [...prev.filter((a) => !groupStudentIds.includes(a)), ...groupStudentIds]);
}
}}
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out",
users.filter((u) => g.participants.includes(u.id)).every((u) => assignees.includes(u.id)) &&
"!bg-mti-purple-light !text-white",
)}>
{g.name}
</button>
))}
</div>
<div className="w-full flex items-center gap-4">
{renderStudentSearch()}
{renderStudentPagination()}
</div>
<div className="flex flex-wrap -md:justify-center gap-4">
{studentRows.map((user) => (
<div
onClick={() => toggleAssignee(user)}
className={clsx(
"p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72",
"transition ease-in-out duration-300",
assignees.includes(user.id) ? "border-mti-purple" : "border-mti-gray-platinum",
)}
key={user.id}>
<span className="flex flex-col gap-0 justify-center">
<span className="font-semibold">{user.name}</span>
<span className="text-sm opacity-80">{user.email}</span>
</span>
<ProgressBar
color="purple"
textClassName="!text-mti-black/80"
label={`Level ${calculateAverageLevel(user.levels)}`}
percentage={(calculateAverageLevel(user.levels) / 9) * 100}
className="h-6"
/>
<span className="text-mti-black/80 text-sm whitespace-pre-wrap mt-2">
Groups:{" "}
{groups
.filter((g) => g.participants.includes(user.id))
.map((g) => g.name)
.join(", ")}
</span>
</div>
))}
</div>
</section>
{user.type !== "teacher" && (
<section className="w-full flex flex-col gap-3">
<span className="font-semibold">Teachers ({teachers.length} selected)</span>
<div className="grid grid-cols-5 gap-4">
{classrooms.map((g) => (
<button
key={g.id}
onClick={() => {
const groupStudentIds = users.filter((u) => g.participants.includes(u.id)).map((u) => u.id);
if (groupStudentIds.every((u) => teachers.includes(u))) {
setTeachers((prev) => prev.filter((a) => !groupStudentIds.includes(a)));
} else {
setTeachers((prev) => [...prev.filter((a) => !groupStudentIds.includes(a)), ...groupStudentIds]);
}
}}
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out",
users.filter((u) => g.participants.includes(u.id)).every((u) => teachers.includes(u.id)) &&
"!bg-mti-purple-light !text-white",
)}>
{g.name}
</button>
))}
</div>
<div className="w-full flex items-center gap-4">
{renderTeacherSearch()}
{renderTeacherPagination()}
</div>
<div className="flex flex-wrap -md:justify-center gap-4">
{teacherRows.map((user) => (
<div
onClick={() => toggleTeacher(user)}
className={clsx(
"p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72",
"transition ease-in-out duration-300",
teachers.includes(user.id) ? "border-mti-purple" : "border-mti-gray-platinum",
)}
key={user.id}>
<span className="flex flex-col gap-0 justify-center">
<span className="font-semibold">{user.name}</span>
<span className="text-sm opacity-80">{user.email}</span>
</span>
<span className="text-mti-black/80 text-sm whitespace-pre-wrap mt-2">
Groups:{" "}
{groups
.filter((g) => g.participants.includes(user.id))
.map((g) => g.name)
.join(", ")}
</span>
</div>
))}
</div>
</section>
)}
<div className="flex gap-4 w-full items-end">
<Checkbox isChecked={variant === "full"} onChange={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
Full length exams
</Checkbox>
<Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple((d) => !d)}>
Generate different exams
</Checkbox>
<Checkbox isChecked={released} onChange={() => setReleased((d) => !d)}>
Auto release results
</Checkbox>
<Checkbox isChecked={autoStart} onChange={() => setAutostart((d) => !d)}>
Auto start exam
</Checkbox>
</div>
<div className="flex gap-4 w-full justify-end">
<Button
className="w-full max-w-[200px]"
variant="outline"
onClick={() => router.push("/assignments")}
disabled={isLoading}
isLoading={isLoading}>
Cancel
</Button>
<Button
disabled={
selectedModules.length === 0 ||
!name ||
!startDate ||
!endDate ||
!entity ||
assignees.length === 0 ||
(!useRandomExams && examIDs.length < selectedModules.length)
}
className="w-full max-w-[200px]"
onClick={createAssignment}
isLoading={isLoading}>
Create
</Button>
</div>
</div>
</Layout>
</>
);
}

View File

@@ -0,0 +1,234 @@
import Layout from "@/components/High/Layout";
import Separator from "@/components/Low/Separator";
import AssignmentCard from "@/dashboards/AssignmentCard";
import AssignmentView from "@/dashboards/AssignmentView";
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import {useListSearch} from "@/hooks/useListSearch";
import usePagination from "@/hooks/usePagination";
import { EntityWithRoles } from "@/interfaces/entity";
import {Assignment} from "@/interfaces/results";
import {CorporateUser, Group, User} from "@/interfaces/user";
import {sessionOptions} from "@/lib/session";
import {getUserCompanyName} from "@/resources/user";
import {findBy, mapBy, redirect, serialize} from "@/utils";
import { requestUser } from "@/utils/api";
import {
activeAssignmentFilter,
archivedAssignmentFilter,
futureAssignmentFilter,
pastAssignmentFilter,
startHasExpiredAssignmentFilter,
} from "@/utils/assignments";
import {getAssignments, getEntitiesAssignments} from "@/utils/assignments.be";
import {getEntitiesWithRoles} from "@/utils/entities.be";
import {getGroups, getGroupsByEntities} from "@/utils/groups.be";
import {checkAccess, findAllowedEntities} from "@/utils/permissions";
import {getEntitiesUsers, getUsers} from "@/utils/users.be";
import {withIronSessionSsr} from "iron-session/next";
import {groupBy} from "lodash";
import Head from "next/head";
import Link from "next/link";
import {useRouter} from "next/router";
import {useMemo, useState} from "react";
import {BsChevronLeft, BsPlus} from "react-icons/bs";
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
if (!checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"]))
return redirect("/")
const entityIDS = mapBy(user.entities, "id") || [];
const entities = await (checkAccess(user, ["developer", "admin"]) ? getEntitiesWithRoles() : getEntitiesWithRoles(entityIDS));
const allowedEntities = findAllowedEntities(user, entities, "view_assignments")
const users =
await (checkAccess(user, ["developer", "admin"]) ? getUsers() : getEntitiesUsers(mapBy(allowedEntities, 'id')));
const assignments =
await (checkAccess(user, ["developer", "admin"]) ? getAssignments() : getEntitiesAssignments(mapBy(allowedEntities, 'id')));
const groups =
await (checkAccess(user, ["developer", "admin"]) ? getGroups() : getGroupsByEntities(mapBy(allowedEntities, 'id')));
return {props: serialize({user, users, entities: allowedEntities, assignments, groups})};
}, sessionOptions);
const SEARCH_FIELDS = [["name"]];
interface Props {
assignments: Assignment[];
corporateAssignments?: ({corporate?: CorporateUser} & Assignment)[];
entities: EntityWithRoles[]
groups: Group[];
user: User;
users: User[];
}
export default function AssignmentsPage({assignments, corporateAssignments, entities, user, users, groups}: Props) {
const entitiesAllowCreate = useAllowedEntities(user, entities, 'create_assignment')
const entitiesAllowEdit = useAllowedEntities(user, entities, 'edit_assignment')
const entitiesAllowArchive = useAllowedEntities(user, entities, 'archive_assignment')
const activeAssignments = useMemo(() => assignments.filter(activeAssignmentFilter), [assignments]);
const plannedAssignments = useMemo(() => assignments.filter(futureAssignmentFilter), [assignments]);
const pastAssignments = useMemo(() => assignments.filter(pastAssignmentFilter), [assignments]);
const startExpiredAssignments = useMemo(() => assignments.filter(startHasExpiredAssignmentFilter), [assignments]);
const archivedAssignments = useMemo(() => assignments.filter(archivedAssignmentFilter), [assignments]);
const router = useRouter();
const {rows: activeRows, renderSearch: renderActive} = useListSearch(SEARCH_FIELDS, activeAssignments);
const {rows: plannedRows, renderSearch: renderPlanned} = useListSearch(SEARCH_FIELDS, plannedAssignments);
const {rows: pastRows, renderSearch: renderPast} = useListSearch(SEARCH_FIELDS, pastAssignments);
const {rows: expiredRows, renderSearch: renderExpired} = useListSearch(SEARCH_FIELDS, startExpiredAssignments);
const {rows: archivedRows, renderSearch: renderArchived} = useListSearch(SEARCH_FIELDS, archivedAssignments);
const {items: activeItems, renderMinimal: paginationActive} = usePagination(activeRows, 16);
const {items: plannedItems, renderMinimal: paginationPlanned} = usePagination(plannedRows, 16);
const {items: pastItems, renderMinimal: paginationPast} = usePagination(pastRows, 16);
const {items: expiredItems, renderMinimal: paginationExpired} = usePagination(expiredRows, 16);
const {items: archivedItems, renderMinimal: paginationArchived} = usePagination(archivedRows, 16);
return (
<>
<Head>
<title>Assignments | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<Layout user={user}>
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<Link href="/dashboard" className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
<BsChevronLeft />
</Link>
<h2 className="font-bold text-2xl">Assignments</h2>
</div>
<Separator />
</div>
<div className="flex flex-col gap-2">
<span className="text-lg font-bold">Active Assignments Status</span>
<div className="flex items-center gap-4">
<span>
<b>Total:</b> {activeAssignments.reduce((acc, curr) => acc + curr.results.length, 0)}/
{activeAssignments.reduce((acc, curr) => curr.exams.length + acc, 0)}
</span>
</div>
</div>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Active Assignments ({activeAssignments.length})</h2>
<div className="w-full flex items-center gap-4">
{renderActive()}
{paginationActive()}
</div>
<div className="flex flex-wrap gap-2">
{activeItems.map((a) => (
<AssignmentCard {...a} entityObj={findBy(entities, 'id', a.entity)} users={users} onClick={() => router.push(`/assignments/${a.id}`)} key={a.id} />
))}
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Planned Assignments ({plannedAssignments.length})</h2>
<div className="w-full flex items-center gap-4">
{renderPlanned()}
{paginationPlanned()}
</div>
<div className="flex flex-wrap gap-2">
<Link
href={entitiesAllowCreate.length > 0 ? "/assignments/creator" : ""}
className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300">
<BsPlus className="text-6xl" />
<span className="text-lg">New Assignment</span>
</Link>
{plannedItems.map((a) => (
<AssignmentCard
{...a}
users={users}
entityObj={findBy(entities, 'id', a.entity)}
onClick={
mapBy(entitiesAllowEdit, 'id').includes(a.entity || "")
? () => router.push(`/assignments/creator/${a.id}`)
: () => router.push(`/assignments/${a.id}`)
}
key={a.id}
/>
))}
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Past Assignments ({pastAssignments.length})</h2>
<div className="w-full flex items-center gap-4">
{renderPast()}
{paginationPast()}
</div>
<div className="flex flex-wrap gap-2">
{pastItems.map((a) => (
<AssignmentCard
{...a}
users={users}
entityObj={findBy(entities, 'id', a.entity)}
onClick={() => router.push(`/assignments/${a.id}`)}
key={a.id}
allowDownload
allowArchive={mapBy(entitiesAllowArchive, 'id').includes(a.entity || "")}
allowExcelDownload
/>
))}
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Assignments start expired ({startExpiredAssignments.length})</h2>
<div className="w-full flex items-center gap-4">
{renderExpired()}
{paginationExpired()}
</div>
<div className="flex flex-wrap gap-2">
{expiredItems.map((a) => (
<AssignmentCard
{...a}
users={users}
entityObj={findBy(entities, 'id', a.entity)}
onClick={() => router.push(`/assignments/${a.id}`)}
key={a.id}
allowDownload
allowArchive={mapBy(entitiesAllowArchive, 'id').includes(a.entity || "")}
allowExcelDownload
/>
))}
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Archived Assignments ({archivedAssignments.length})</h2>
<div className="w-full flex items-center gap-4">
{renderArchived()}
{paginationArchived()}
</div>
<div className="flex flex-wrap gap-2">
{archivedItems.map((a) => (
<AssignmentCard
{...a}
users={users}
onClick={() => router.push(`/assignments/${a.id}`)}
key={a.id}
entityObj={findBy(entities, 'id', a.entity)}
allowDownload
allowUnarchive
allowExcelDownload
/>
))}
</div>
</section>
</Layout>
</>
);
}

View File

@@ -0,0 +1,346 @@
/* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import Tooltip from "@/components/Low/Tooltip";
import { useEntityPermission } from "@/hooks/useEntityPermissions";
import {useListSearch} from "@/hooks/useListSearch";
import usePagination from "@/hooks/usePagination";
import { EntityWithRoles } from "@/interfaces/entity";
import {GroupWithUsers, User} from "@/interfaces/user";
import {sessionOptions} from "@/lib/session";
import {USER_TYPE_LABELS} from "@/resources/user";
import { filterBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api";
import { getEntitiesWithRoles, getEntityWithRoles } from "@/utils/entities.be";
import {convertToUsers, getGroup} from "@/utils/groups.be";
import {shouldRedirectHome} from "@/utils/navigation.disabled";
import {checkAccess, doesEntityAllow, findAllowedEntities, getTypesOfUser} from "@/utils/permissions";
import {getUserName} from "@/utils/users";
import {getEntityUsers, getLinkedUsers, getSpecificUsers} 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 {Divider} from "primereact/divider";
import {useEffect, useMemo, useState} from "react";
import {BsBuilding, BsChevronLeft, BsClockFill, BsEnvelopeFill, BsFillPersonVcardFill, BsPlus, BsStopwatchFill, BsTag, BsTrash, BsX} from "react-icons/bs";
import {toast, ToastContainer} from "react-toastify";
export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
if (shouldRedirectHome(user)) return redirect("/")
const {id} = params as {id: string};
const group = await getGroup(id);
if (!group || !group.entity) return redirect("/classrooms")
const entity = await getEntityWithRoles(group.entity)
if (!entity) return redirect("/classrooms")
const canView = doesEntityAllow(user, entity, "view_classrooms")
if (!canView) return redirect("/")
const linkedUsers = await getEntityUsers(entity.id)
const users = await getSpecificUsers([...group.participants, group.admin]);
const groupWithUser = convertToUsers(group, users);
return {
props: serialize({user, group: groupWithUser, users: linkedUsers, entity}),
};
}, sessionOptions);
interface Props {
user: User;
group: GroupWithUsers;
users: User[];
entity: EntityWithRoles
}
export default function Home({user, group, users, entity}: Props) {
const [isAdding, setIsAdding] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
const canAddParticipants = useEntityPermission(user, entity, "add_to_classroom")
const canRemoveParticipants = useEntityPermission(user, entity, "remove_from_classroom")
const canRenameClassroom = useEntityPermission(user, entity, "rename_classrooms")
const canDeleteClassroom = useEntityPermission(user, entity, "delete_classroom")
const nonParticipantUsers = useMemo(
() => users.filter((x) => ![...group.participants.map((g) => g.id), group.admin.id, user.id].includes(x.id)),
[users, group.participants, group.admin.id, user.id],
);
const {rows, renderSearch} = useListSearch<User>(
[["name"], ["corporateInformation", "companyInformation", "name"]],
isAdding ? nonParticipantUsers : group.participants,
);
const {items, renderMinimal} = usePagination<User>(rows, 20);
const router = useRouter();
const toggleUser = (u: User) => setSelectedUsers((prev) => (prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]));
const removeParticipants = () => {
if (selectedUsers.length === 0) return;
if (!canRemoveParticipants) return;
if (!confirm(`Are you sure you want to remove ${selectedUsers.length} participant${selectedUsers.length === 1 ? "" : "s"} from this group?`))
return;
setIsLoading(true);
axios
.patch(`/api/groups/${group.id}`, {participants: group.participants.map((x) => x.id).filter((x) => !selectedUsers.includes(x))})
.then(() => {
toast.success("The group has been updated successfully!");
router.replace(router.asPath);
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
};
const addParticipants = () => {
if (selectedUsers.length === 0) return;
if (!canAddParticipants || !isAdding) return;
if (!confirm(`Are you sure you want to add ${selectedUsers.length} participant${selectedUsers.length === 1 ? "" : "s"} to this group?`))
return;
setIsLoading(true);
axios
.patch(`/api/groups/${group.id}`, {participants: [...group.participants.map((x) => x.id), ...selectedUsers]})
.then(() => {
toast.success("The group has been updated successfully!");
router.replace(router.asPath);
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
};
const renameGroup = () => {
if (!canRenameClassroom) return;
const name = prompt("Rename this classroom:", group.name);
if (!name) return;
setIsLoading(true);
axios
.patch(`/api/groups/${group.id}`, {name})
.then(() => {
toast.success("The classroom has been updated successfully!");
router.replace(router.asPath);
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
};
const deleteGroup = () => {
if (!canDeleteClassroom) return;
if (!confirm("Are you sure you want to delete this classroom?")) return;
setIsLoading(true);
axios
.delete(`/api/groups/${group.id}`)
.then(() => {
toast.success("This classroom has been successfully deleted!");
router.replace("/classrooms");
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
};
useEffect(() => setSelectedUsers([]), [isAdding]);
return (
<>
<Head>
<title>{group.name} | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
{user && (
<Layout user={user}>
<section className="flex flex-col gap-0">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Link
href="/classrooms"
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
<BsChevronLeft />
</Link>
<h2 className="font-bold text-2xl">{group.name}</h2>
</div>
{!isAdding && (
<div className="flex items-center gap-2">
<button
onClick={renameGroup}
disabled={isLoading || !canRenameClassroom}
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
<BsTag />
<span className="text-xs">Rename Classroom</span>
</button>
<button
onClick={deleteGroup}
disabled={isLoading || !canDeleteClassroom}
className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
<BsTrash />
<span className="text-xs">Delete Classroom</span>
</button>
</div>
)}
</div>
<div className="flex flex-col gap-2">
<span className="flex items-center gap-2">
<BsBuilding className="text-xl" /> {entity.label}
</span>
<span className="flex items-center gap-2">
<BsFillPersonVcardFill className="text-xl" /> {getUserName(group.admin)}
</span>
</div>
</div>
<Divider />
<div className="flex items-center justify-between mb-4">
<span className="font-semibold text-xl">Participants</span>
{!isAdding && (
<div className="flex items-center gap-2">
<button
onClick={() => setIsAdding(true)}
disabled={isLoading || !canAddParticipants}
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
<BsPlus />
<span className="text-xs">Add Participants</span>
</button>
<button
onClick={removeParticipants}
disabled={selectedUsers.length === 0 || isLoading || !canRemoveParticipants}
className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
<BsTrash />
<span className="text-xs">Remove Participants</span>
</button>
</div>
)}
{isAdding && (
<div className="flex items-center gap-2">
<button
onClick={() => setIsAdding(false)}
disabled={isLoading}
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-rose bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
<BsX />
<span className="text-xs">Discard Selection</span>
</button>
<button
onClick={addParticipants}
disabled={selectedUsers.length === 0 || isLoading || !canAddParticipants}
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
<BsPlus />
<span className="text-xs">Add Participants</span>
</button>
</div>
)}
</div>
<div className="w-full flex items-center gap-4">
{renderSearch()}
{renderMinimal()}
</div>
<div className="flex items-center gap-2 mt-4">
{['student', 'teacher', 'corporate'].map((type) => (
<button
key={type}
onClick={() => {
const typeUsers = mapBy(filterBy(isAdding ? nonParticipantUsers : group.participants, 'type', type), 'id')
if (typeUsers.every((u) => selectedUsers.includes(u))) {
setSelectedUsers((prev) => prev.filter((a) => !typeUsers.includes(a)));
} else {
setSelectedUsers((prev) => [...prev.filter((a) => !typeUsers.includes(a)), ...typeUsers]);
}
}}
disabled={filterBy(isAdding ? nonParticipantUsers : group.participants, 'type', type).length === 0}
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out",
"disabled:grayscale disabled:hover:bg-mti-purple-ultralight disabled:hover:text-mti-purple disabled:cursor-not-allowed",
filterBy(isAdding ? nonParticipantUsers : group.participants, 'type', type).length > 0 &&
filterBy(isAdding ? nonParticipantUsers : group.participants, 'type', type).every((u) => selectedUsers.includes(u.id)) &&
"!bg-mti-purple-light !text-white",
)}>
{capitalize(type)}
</button>
))}
</div>
</section>
<section className="w-full h-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{items.map((u) => (
<button
onClick={() => toggleUser(u)}
disabled={isAdding ? !canAddParticipants : !canRemoveParticipants}
key={u.id}
className={clsx(
"p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer",
"hover:border-mti-purple transition ease-in-out duration-300",
selectedUsers.includes(u.id) && "border-mti-purple",
)}>
<div className="flex items-center gap-2">
<div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full">
<img src={u.profilePicture} alt={u.name} />
</div>
<div className="flex flex-col">
<span className="font-semibold">{getUserName(u)}</span>
<span className="opacity-80 text-sm">{USER_TYPE_LABELS[u.type]}</span>
</div>
</div>
<div className="flex flex-col gap-1">
<span className="flex items-center gap-2">
<Tooltip tooltip="E-mail address">
<BsEnvelopeFill />
</Tooltip>
{u.email}
</span>
<span className="flex items-center gap-2">
<Tooltip tooltip="Expiration Date">
<BsStopwatchFill />
</Tooltip>
{u.subscriptionExpirationDate ? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited"}
</span>
<span className="flex items-center gap-2">
<Tooltip tooltip="Last Login">
<BsClockFill />
</Tooltip>
{u.lastLogin ? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm") : "N/A"}
</span>
</div>
</button>
))}
</section>
</Layout>
)}
</>
);
}

View File

@@ -0,0 +1,223 @@
/* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import Input from "@/components/Low/Input";
import Select from "@/components/Low/Select";
import Tooltip from "@/components/Low/Tooltip";
import {useListSearch} from "@/hooks/useListSearch";
import usePagination from "@/hooks/usePagination";
import {Entity, EntityWithRoles} from "@/interfaces/entity";
import {User} from "@/interfaces/user";
import {sessionOptions} from "@/lib/session";
import {USER_TYPE_LABELS} from "@/resources/user";
import {filterBy, mapBy, redirect, serialize} from "@/utils";
import {getEntities, getEntitiesWithRoles} from "@/utils/entities.be";
import {shouldRedirectHome} from "@/utils/navigation.disabled";
import {getUserName, isAdmin} from "@/utils/users";
import {getEntitiesUsers, getLinkedUsers} from "@/utils/users.be";
import axios from "axios";
import clsx from "clsx";
import {withIronSessionSsr} from "iron-session/next";
import moment from "moment";
import Head from "next/head";
import Link from "next/link";
import {useRouter} from "next/router";
import {Divider} from "primereact/divider";
import {useEffect, useMemo, useState} from "react";
import {BsCheck, BsChevronLeft, BsClockFill, BsEnvelopeFill, BsStopwatchFill} from "react-icons/bs";
import {toast, ToastContainer} from "react-toastify";
import { requestUser } from "@/utils/api";
import { findAllowedEntities } from "@/utils/permissions";
import { capitalize } from "lodash";
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
if (shouldRedirectHome(user)) return redirect("/")
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : mapBy(user.entities, "id"));
const users = await getEntitiesUsers(mapBy(entities, 'id'))
const allowedEntities = findAllowedEntities(user, entities, "create_classroom")
return {
props: serialize({user, entities: allowedEntities, users: users.filter((x) => x.id !== user.id)}),
};
}, sessionOptions);
interface Props {
user: User;
users: User[];
entities: EntityWithRoles[];
}
export default function Home({user, users, entities}: Props) {
const [isLoading, setIsLoading] = useState(false);
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
const [name, setName] = useState("");
const [entity, setEntity] = useState<string | undefined>(entities[0]?.id);
const entityUsers = useMemo(() => !entity ? users : users.filter(u => mapBy(u.entities, 'id').includes(entity)), [entity, users])
const {rows, renderSearch} = useListSearch<User>(
[["name"], ["type"], ["corporateInformation", "companyInformation", "name"]], entityUsers
);
const {items, renderMinimal} = usePagination<User>(rows, 16);
const router = useRouter();
useEffect(() => setSelectedUsers([]), [entity])
const createGroup = () => {
if (!name.trim()) return;
if (!entity) return;
if (!confirm(`Are you sure you want to create this group with ${selectedUsers.length} participants?`)) return;
setIsLoading(true);
axios
.post<{id: string}>(`/api/groups`, {name, participants: selectedUsers, admin: user.id, entity})
.then((result) => {
toast.success("Your group has been created successfully!");
router.replace(`/classrooms/${result.data.id}`);
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
};
const toggleUser = (u: User) => setSelectedUsers((prev) => (prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]));
return (
<>
<Head>
<title>Create Group | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<Layout user={user}>
<section className="flex flex-col gap-0">
<div className="flex gap-3 justify-between">
<div className="flex items-center gap-2">
<Link
href="/classrooms"
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
<BsChevronLeft />
</Link>
<h2 className="font-bold text-2xl">Create Classroom</h2>
</div>
<div className="flex items-center gap-4">
<button
onClick={createGroup}
disabled={!name.trim() || !entity || isLoading}
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
<BsCheck />
<span className="text-xs">Create Classroom</span>
</button>
</div>
</div>
<Divider />
<div className="grid grid-cols-2 gap-4 place-items-end">
<div className="flex flex-col gap-4 w-full">
<span className="font-semibold text-xl">Classroom Name:</span>
<Input name="name" onChange={setName} type="text" placeholder="Classroom A" />
</div>
<div className="flex flex-col gap-4 w-full">
<span className="font-semibold text-xl">Entity:</span>
<Select
options={entities.map((e) => ({value: e.id, label: e.label}))}
onChange={(v) => setEntity(v ? v.value! : undefined)}
defaultValue={{value: entities[0]?.id, label: entities[0]?.label}}
/>
</div>
</div>
<Divider />
<div className="flex items-center justify-between mb-4">
<span className="font-semibold text-xl">Participants ({selectedUsers.length} selected):</span>
</div>
<div className="w-full flex items-center gap-4">
{renderSearch()}
{renderMinimal()}
</div>
<div className="flex items-center gap-2 mt-4">
{['student', 'teacher', 'corporate'].map((type) => (
<button
key={type}
onClick={() => {
const typeUsers = mapBy(filterBy(entityUsers, 'type', type), 'id')
if (typeUsers.every((u) => selectedUsers.includes(u))) {
setSelectedUsers((prev) => prev.filter((a) => !typeUsers.includes(a)));
} else {
setSelectedUsers((prev) => [...prev.filter((a) => !typeUsers.includes(a)), ...typeUsers]);
}
}}
disabled={filterBy(entityUsers, 'type', type).length === 0}
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out",
"disabled:grayscale disabled:hover:bg-mti-purple-ultralight disabled:hover:text-mti-purple disabled:cursor-not-allowed",
filterBy(entityUsers, 'type', type).length > 0 &&
filterBy(entityUsers, 'type', type).every((u) => selectedUsers.includes(u.id)) &&
"!bg-mti-purple-light !text-white",
)}>
{capitalize(type)}
</button>
))}
</div>
</section>
<section className="w-full h-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{items.map((u) => (
<button
onClick={() => toggleUser(u)}
disabled={isLoading}
key={u.id}
className={clsx(
"p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer",
"hover:border-mti-purple transition ease-in-out duration-300",
selectedUsers.includes(u.id) && "border-mti-purple",
)}>
<div className="flex items-center gap-2">
<div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full">
<img src={u.profilePicture} alt={u.name} />
</div>
<div className="flex flex-col">
<span className="font-semibold">{getUserName(u)}</span>
<span className="opacity-80 text-sm">{USER_TYPE_LABELS[u.type]}</span>
</div>
</div>
<div className="flex flex-col gap-1">
<span className="flex items-center gap-2">
<Tooltip tooltip="E-mail address">
<BsEnvelopeFill />
</Tooltip>
{u.email}
</span>
<span className="flex items-center gap-2">
<Tooltip tooltip="Expiration Date">
<BsStopwatchFill />
</Tooltip>
{u.subscriptionExpirationDate ? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited"}
</span>
<span className="flex items-center gap-2">
<Tooltip tooltip="Last Login">
<BsClockFill />
</Tooltip>
{u.lastLogin ? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm") : "N/A"}
</span>
</div>
</button>
))}
</section>
</Layout>
</>
);
}

View File

@@ -0,0 +1,121 @@
/* 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 Layout from "@/components/High/Layout";
import {GroupWithUsers, User} from "@/interfaces/user";
import {shouldRedirectHome} from "@/utils/navigation.disabled";
import {getUserName, isAdmin} from "@/utils/users";
import {convertToUsers, getGroupsForEntities} from "@/utils/groups.be";
import {getSpecificUsers} from "@/utils/users.be";
import Link from "next/link";
import {uniq} from "lodash";
import {BsPlus} from "react-icons/bs";
import CardList from "@/components/High/CardList";
import Separator from "@/components/Low/Separator";
import {mapBy, redirect, serialize} from "@/utils";
import { requestUser } from "@/utils/api";
import { findAllowedEntities } from "@/utils/permissions";
import { getEntities, getEntitiesWithRoles } from "@/utils/entities.be";
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import { EntityWithRoles } from "@/interfaces/entity";
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
if (shouldRedirectHome(user)) return redirect("/")
const entityIDS = mapBy(user.entities, "id");
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS)
const allowedEntities = findAllowedEntities(user, entities, "view_classrooms")
const groups = await getGroupsForEntities(mapBy(allowedEntities, 'id'));
const users = await getSpecificUsers(uniq(groups.flatMap((g) => [...g.participants.slice(0, 5), g.admin])));
const groupsWithUsers: GroupWithUsers[] = groups.map((g) => convertToUsers(g, users));
return {
props: serialize({user, groups: groupsWithUsers, entities: allowedEntities}),
};
}, sessionOptions);
const SEARCH_FIELDS = [
["name"],
["admin", "name"],
["admin", "email"],
["admin", "corporateInformation", "companyInformation", "name"],
["participants", "name"],
["participants", "email"],
["participants", "corporateInformation", "companyInformation", "name"],
];
interface Props {
user: User;
groups: GroupWithUsers[];
entities: EntityWithRoles[]
}
export default function Home({user, groups, entities}: Props) {
const entitiesAllowCreate = useAllowedEntities(user, entities, 'create_classroom')
const renderCard = (group: GroupWithUsers) => (
<Link
href={`/classrooms/${group.id}`}
key={group.id}
className="p-4 border rounded-xl flex flex-col gap-2 hover:border-mti-purple transition ease-in-out duration-300 text-left cursor-pointer">
<span>
<b>Group: </b>
{group.name}
</span>
<span>
<b>Admin: </b>
{getUserName(group.admin)}
</span>
<b>Participants ({group.participants.length}): </b>
<span>
{group.participants.slice(0, 5).map(getUserName).join(", ")}
{group.participants.length > 5 ? <span className="opacity-60"> and {group.participants.length - 5} more</span> : ""}
</span>
</Link>
);
const firstCard = () => (
<Link
href={`/classrooms/create`}
className="p-4 border hover:text-mti-purple rounded-xl flex flex-col items-center justify-center gap-0 hover:border-mti-purple transition ease-in-out duration-300 text-left cursor-pointer">
<BsPlus size={40} />
<span className="font-semibold">Create Classroom</span>
</Link>
);
return (
<>
<Head>
<title>Classrooms | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<Layout user={user} className="!gap-4">
<section className="flex flex-col gap-4 w-full h-full">
<div className="flex flex-col gap-4">
<h2 className="font-bold text-2xl">Classrooms</h2>
<Separator />
</div>
<CardList<GroupWithUsers>
list={groups}
searchFields={SEARCH_FIELDS}
renderCard={renderCard}
firstCard={entitiesAllowCreate.length === 0 ? undefined : firstCard}
/>
</section>
</Layout>
</>
);
}

View File

@@ -0,0 +1,198 @@
/* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import UserDisplayList from "@/components/UserDisplayList";
import IconCard from "@/dashboards/IconCard";
import { Module } from "@/interfaces";
import { EntityWithRoles } from "@/interfaces/entity";
import { Assignment } from "@/interfaces/results";
import { Group, Stat, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session";
import { dateSorter, filterBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api";
import { getAssignments, getEntitiesAssignments } from "@/utils/assignments.be";
import { getEntitiesWithRoles } from "@/utils/entities.be";
import { getGroups, getGroupsByEntities } from "@/utils/groups.be";
import { checkAccess } from "@/utils/permissions";
import { calculateAverageLevel, calculateBandScore } from "@/utils/score";
import { groupByExam } from "@/utils/stats";
import { getStatsByUsers } from "@/utils/stats.be";
import { getEntitiesUsers, getUsers } from "@/utils/users.be";
import { withIronSessionSsr } from "iron-session/next";
import { uniqBy } from "lodash";
import moment from "moment";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import { useMemo } from "react";
import {
BsBank,
BsClipboard2Data,
BsClock,
BsEnvelopePaper,
BsPaperclip,
BsPencilSquare,
BsPeople,
BsPeopleFill,
BsPersonFill,
BsPersonFillGear,
} from "react-icons/bs";
import { ToastContainer } from "react-toastify";
interface Props {
user: User;
users: User[];
entities: EntityWithRoles[];
assignments: Assignment[];
stats: Stat[];
groups: Group[];
}
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
if (!checkAccess(user, ["admin", "developer"])) return redirect("/")
const users = await getUsers();
const entities = await getEntitiesWithRoles();
const assignments = await getAssignments();
const stats = await getStatsByUsers(users.map((u) => u.id));
const groups = await getGroups();
return { props: serialize({ user, users, entities, assignments, stats, groups }) };
}, sessionOptions);
export default function Dashboard({ user, users, entities, assignments, stats, groups }: Props) {
const students = useMemo(() => users.filter((u) => u.type === "student"), [users]);
const teachers = useMemo(() => users.filter((u) => u.type === "teacher"), [users]);
const corporates = useMemo(() => users.filter((u) => u.type === "corporate"), [users]);
const masterCorporates = useMemo(() => users.filter((u) => u.type === "mastercorporate"), [users]);
const router = useRouter();
const averageLevelCalculator = (studentStats: Stat[]) => {
const formattedStats = studentStats
.map((s) => ({
focus: students.find((u) => u.id === s.user)?.focus,
score: s.score,
module: s.module,
}))
.filter((f) => !!f.focus);
const bandScores = formattedStats.map((s) => ({
module: s.module,
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
}));
const levels: { [key in Module]: number } = {
reading: 0,
listening: 0,
writing: 0,
speaking: 0,
level: 0,
};
bandScores.forEach((b) => (levels[b.module] += b.level));
return calculateAverageLevel(levels);
};
return (
<>
<Head>
<title>EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<Layout user={user}>
<section className="grid grid-cols-5 place-items-center -md:grid-cols-2 gap-4 text-center">
<IconCard
onClick={() => router.push("/users?type=student")}
Icon={BsPersonFill}
label="Students"
value={students.length}
color="purple"
/>
<IconCard
onClick={() => router.push("/users?type=teacher")}
Icon={BsPencilSquare}
label="Teachers"
value={teachers.length}
color="purple"
/>
<IconCard
Icon={BsBank}
onClick={() => router.push("/users?type=corporate")}
label="Corporates"
value={corporates.length}
color="purple"
/>
<IconCard
Icon={BsBank}
onClick={() => router.push("/users?type=mastercorporate")}
label="Master Corporates"
value={masterCorporates.length}
color="purple"
/>
<IconCard
Icon={BsPeople}
onClick={() => router.push("/classrooms")}
label="Classrooms"
value={groups.length}
color="purple"
/>
<IconCard Icon={BsPeopleFill}
onClick={() => router.push("/entities")}
label="Entities"
value={entities.length}
color="purple"
/>
<IconCard Icon={BsClipboard2Data} label="Exams Performed" value={uniqBy(stats, "exam").length} color="purple" />
<IconCard Icon={BsPaperclip} label="Average Level" value={averageLevelCalculator(stats).toFixed(1)} color="purple" />
<IconCard Icon={BsPersonFillGear}
onClick={() => router.push("/users/performance")}
label="Student Performance"
value={students.length}
color="purple"
/>
<IconCard
Icon={BsEnvelopePaper}
onClick={() => router.push("/assignments")}
label="Assignments"
value={assignments.filter((a) => !a.archived).length}
color="purple"
/>
</section>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<UserDisplayList
users={students.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))}
title="Latest Students"
/>
<UserDisplayList
users={teachers.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))}
title="Latest Teachers"
/>
<UserDisplayList
users={students.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))}
title="Highest level students"
/>
<UserDisplayList
users={
students
.sort(
(a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length,
)
}
title="Highest exam count students"
/>
</section>
</Layout>
</>
);
}

View File

@@ -0,0 +1,207 @@
/* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import UserDisplayList from "@/components/UserDisplayList";
import IconCard from "@/dashboards/IconCard";
import { Module } from "@/interfaces";
import { EntityWithRoles } from "@/interfaces/entity";
import { Assignment } from "@/interfaces/results";
import { Group, Stat, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session";
import { dateSorter, filterBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api";
import { getEntitiesAssignments } from "@/utils/assignments.be";
import { getEntitiesWithRoles } from "@/utils/entities.be";
import { getGroupsByEntities } from "@/utils/groups.be";
import { checkAccess } from "@/utils/permissions";
import { calculateAverageLevel, calculateBandScore } from "@/utils/score";
import { groupByExam } from "@/utils/stats";
import { getStatsByUsers } from "@/utils/stats.be";
import { filterAllowedUsers } from "@/utils/users.be";
import { getEntitiesUsers } from "@/utils/users.be";
import { withIronSessionSsr } from "iron-session/next";
import { uniqBy } from "lodash";
import moment from "moment";
import Head from "next/head";
import { useRouter } from "next/router";
import { useMemo } from "react";
import {
BsClipboard2Data,
BsClock,
BsEnvelopePaper,
BsPaperclip,
BsPencilSquare,
BsPeople,
BsPeopleFill,
BsPersonFill,
BsPersonFillGear,
} from "react-icons/bs";
import { ToastContainer } from "react-toastify";
interface Props {
user: User;
users: User[];
entities: EntityWithRoles[];
assignments: Assignment[];
stats: Stat[];
groups: Group[];
}
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
if (!checkAccess(user, ["admin", "developer", "corporate"])) return redirect("/")
const entityIDS = mapBy(user.entities, "id") || [];
const entities = await getEntitiesWithRoles(entityIDS);
const users = await filterAllowedUsers(user, entities)
const assignments = await getEntitiesAssignments(entityIDS);
const stats = await getStatsByUsers(users.map((u) => u.id));
const groups = await getGroupsByEntities(entityIDS);
return { props: serialize({ user, users, entities, assignments, stats, groups }) };
}, sessionOptions);
export default function Dashboard({ user, users, entities, assignments, stats, groups }: Props) {
const students = useMemo(() => users.filter((u) => u.type === "student"), [users]);
const teachers = useMemo(() => users.filter((u) => u.type === "teacher"), [users]);
const router = useRouter();
const averageLevelCalculator = (studentStats: Stat[]) => {
const formattedStats = studentStats
.map((s) => ({
focus: students.find((u) => u.id === s.user)?.focus,
score: s.score,
module: s.module,
}))
.filter((f) => !!f.focus);
const bandScores = formattedStats.map((s) => ({
module: s.module,
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
}));
const levels: { [key in Module]: number } = {
reading: 0,
listening: 0,
writing: 0,
speaking: 0,
level: 0,
};
bandScores.forEach((b) => (levels[b.module] += b.level));
return calculateAverageLevel(levels);
};
const UserDisplay = (displayUser: User) => (
<div className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
<div className="flex flex-col gap-1 items-start">
<span>{displayUser.name}</span>
<span className="text-sm opacity-75">{displayUser.email}</span>
</div>
</div>
);
return (
<>
<Head>
<title>EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<Layout user={user}>
<div className="w-full flex flex-col gap-4">
{entities.length > 0 && (
<div className="w-fit self-end bg-neutral-200 px-2 rounded-lg py-1">
<b>{mapBy(entities, "label")?.join(", ")}</b>
</div>
)}
<section className="grid grid-cols-5 -md:grid-cols-2 place-items-center gap-4 text-center">
<IconCard
onClick={() => router.push("/users?type=student")}
Icon={BsPersonFill}
label="Students"
value={students.length}
color="purple"
/>
<IconCard
onClick={() => router.push("/users?type=teacher")}
Icon={BsPencilSquare}
label="Teachers"
value={teachers.length}
color="purple"
/>
<IconCard
onClick={() => router.push("/classrooms")}
Icon={BsPeople}
label="Classrooms"
value={groups.length}
color="purple"
/>
<IconCard Icon={BsPeopleFill}
onClick={() => router.push("/entities")}
label="Entities"
value={entities.length}
color="purple"
/>
<IconCard Icon={BsClipboard2Data} label="Exams Performed" value={uniqBy(stats, "exam").length} color="purple" />
<IconCard Icon={BsPaperclip} label="Average Level" value={averageLevelCalculator(stats).toFixed(1)} color="purple" />
<IconCard Icon={BsPersonFillGear}
onClick={() => router.push("/users/performance")}
label="Student Performance"
value={students.length}
color="purple"
/>
<IconCard
Icon={BsClock}
label="Expiration Date"
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
color="rose"
/>
<IconCard
Icon={BsEnvelopePaper}
className="col-span-2"
onClick={() => router.push("/assignments")}
label="Assignments"
value={assignments.filter((a) => !a.archived).length}
color="purple"
/>
</section>
</div>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<UserDisplayList
users={students.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))}
title="Latest Students"
/>
<UserDisplayList
users={teachers.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))}
title="Latest Teachers"
/>
<UserDisplayList
users={students.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))}
title="Highest level students"
/>
<UserDisplayList
users={
students
.sort(
(a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length,
)
}
title="Highest exam count students"
/>
</section>
</Layout>
</>
);
}

View File

@@ -0,0 +1,198 @@
/* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import UserDisplayList from "@/components/UserDisplayList";
import IconCard from "@/dashboards/IconCard";
import { Module } from "@/interfaces";
import { EntityWithRoles } from "@/interfaces/entity";
import { Assignment } from "@/interfaces/results";
import { Group, Stat, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session";
import { dateSorter, filterBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api";
import { getAssignments, getEntitiesAssignments } from "@/utils/assignments.be";
import { getEntitiesWithRoles } from "@/utils/entities.be";
import { getGroups, getGroupsByEntities } from "@/utils/groups.be";
import { checkAccess } from "@/utils/permissions";
import { calculateAverageLevel, calculateBandScore } from "@/utils/score";
import { groupByExam } from "@/utils/stats";
import { getStatsByUsers } from "@/utils/stats.be";
import { getEntitiesUsers, getUsers } from "@/utils/users.be";
import { withIronSessionSsr } from "iron-session/next";
import { uniqBy } from "lodash";
import moment from "moment";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import { useMemo } from "react";
import {
BsBank,
BsClipboard2Data,
BsClock,
BsEnvelopePaper,
BsPaperclip,
BsPencilSquare,
BsPeople,
BsPeopleFill,
BsPersonFill,
BsPersonFillGear,
} from "react-icons/bs";
import { ToastContainer } from "react-toastify";
interface Props {
user: User;
users: User[];
entities: EntityWithRoles[];
assignments: Assignment[];
stats: Stat[];
groups: Group[];
}
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
if (!checkAccess(user, ["admin", "developer"])) return redirect("/")
const users = await getUsers();
const entities = await getEntitiesWithRoles();
const assignments = await getAssignments();
const stats = await getStatsByUsers(users.map((u) => u.id));
const groups = await getGroups();
return { props: serialize({ user, users, entities, assignments, stats, groups }) };
}, sessionOptions);
export default function Dashboard({ user, users, entities, assignments, stats, groups }: Props) {
const students = useMemo(() => users.filter((u) => u.type === "student"), [users]);
const teachers = useMemo(() => users.filter((u) => u.type === "teacher"), [users]);
const corporates = useMemo(() => users.filter((u) => u.type === "corporate"), [users]);
const masterCorporates = useMemo(() => users.filter((u) => u.type === "mastercorporate"), [users]);
const router = useRouter();
const averageLevelCalculator = (studentStats: Stat[]) => {
const formattedStats = studentStats
.map((s) => ({
focus: students.find((u) => u.id === s.user)?.focus,
score: s.score,
module: s.module,
}))
.filter((f) => !!f.focus);
const bandScores = formattedStats.map((s) => ({
module: s.module,
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
}));
const levels: { [key in Module]: number } = {
reading: 0,
listening: 0,
writing: 0,
speaking: 0,
level: 0,
};
bandScores.forEach((b) => (levels[b.module] += b.level));
return calculateAverageLevel(levels);
};
return (
<>
<Head>
<title>EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<Layout user={user}>
<section className="grid grid-cols-5 place-items-center -md:grid-cols-2 gap-4 text-center">
<IconCard
onClick={() => router.push("/users?type=student")}
Icon={BsPersonFill}
label="Students"
value={students.length}
color="purple"
/>
<IconCard
onClick={() => router.push("/users?type=teacher")}
Icon={BsPencilSquare}
label="Teachers"
value={teachers.length}
color="purple"
/>
<IconCard
Icon={BsBank}
onClick={() => router.push("/users?type=corporate")}
label="Corporates"
value={corporates.length}
color="purple"
/>
<IconCard
Icon={BsBank}
onClick={() => router.push("/users?type=mastercorporate")}
label="Master Corporates"
value={masterCorporates.length}
color="purple"
/>
<IconCard
Icon={BsPeople}
onClick={() => router.push("/classrooms")}
label="Classrooms"
value={groups.length}
color="purple"
/>
<IconCard Icon={BsPeopleFill}
onClick={() => router.push("/entities")}
label="Entities"
value={entities.length}
color="purple"
/>
<IconCard Icon={BsClipboard2Data} label="Exams Performed" value={uniqBy(stats, "exam").length} color="purple" />
<IconCard Icon={BsPaperclip} label="Average Level" value={averageLevelCalculator(stats).toFixed(1)} color="purple" />
<IconCard Icon={BsPersonFillGear}
onClick={() => router.push("/users/performance")}
label="Student Performance"
value={students.length}
color="purple"
/>
<IconCard
Icon={BsEnvelopePaper}
onClick={() => router.push("/assignments")}
label="Assignments"
value={assignments.filter((a) => !a.archived).length}
color="purple"
/>
</section>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<UserDisplayList
users={students.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))}
title="Latest Students"
/>
<UserDisplayList
users={teachers.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))}
title="Latest Teachers"
/>
<UserDisplayList
users={students.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))}
title="Highest level students"
/>
<UserDisplayList
users={
students
.sort(
(a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length,
)
}
title="Highest exam count students"
/>
</section>
</Layout>
</>
);
}

View File

@@ -0,0 +1,16 @@
import {User} from "@/interfaces/user";
import {sessionOptions} from "@/lib/session";
import { redirect } from "@/utils";
import { requestUser } from "@/utils/api";
import {withIronSessionSsr} from "iron-session/next";
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
return redirect(`/dashboard/${user.type}`)
}, sessionOptions);
export default function Dashboard() {
return <div></div>;
}

View File

@@ -0,0 +1,205 @@
/* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import UserDisplayList from "@/components/UserDisplayList";
import IconCard from "@/dashboards/IconCard";
import { Module } from "@/interfaces";
import { EntityWithRoles } from "@/interfaces/entity";
import { Assignment } from "@/interfaces/results";
import { Group, Stat, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session";
import { dateSorter, filterBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api";
import { getEntitiesAssignments } from "@/utils/assignments.be";
import { getEntitiesWithRoles } from "@/utils/entities.be";
import { getGroupsByEntities } from "@/utils/groups.be";
import { checkAccess, findAllowedEntities } from "@/utils/permissions";
import { calculateAverageLevel, calculateBandScore } from "@/utils/score";
import { groupByExam } from "@/utils/stats";
import { getStatsByUsers } from "@/utils/stats.be";
import { filterAllowedUsers } from "@/utils/users.be";
import { getEntitiesUsers } from "@/utils/users.be";
import { withIronSessionSsr } from "iron-session/next";
import { uniqBy } from "lodash";
import moment from "moment";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import { useMemo } from "react";
import {
BsBank,
BsClipboard2Data,
BsClock,
BsEnvelopePaper,
BsPaperclip,
BsPencilSquare,
BsPeople,
BsPeopleFill,
BsPersonFill,
BsPersonFillGear,
} from "react-icons/bs";
import { ToastContainer } from "react-toastify";
interface Props {
user: User;
users: User[];
entities: EntityWithRoles[];
assignments: Assignment[];
stats: Stat[];
groups: Group[];
}
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
if (!checkAccess(user, ["admin", "developer", "mastercorporate"]))
return redirect("/")
const entityIDS = mapBy(user.entities, "id") || [];
const entities = await getEntitiesWithRoles(entityIDS);
const users = await filterAllowedUsers(user, entities)
const assignments = await getEntitiesAssignments(entityIDS);
const stats = await getStatsByUsers(users.map((u) => u.id));
const groups = await getGroupsByEntities(entityIDS);
return { props: serialize({ user, users, entities, assignments, stats, groups }) };
}, sessionOptions);
export default function Dashboard({ user, users, entities, assignments, stats, groups }: Props) {
const students = useMemo(() => users.filter((u) => u.type === "student"), [users]);
const teachers = useMemo(() => users.filter((u) => u.type === "teacher"), [users]);
const corporates = useMemo(() => users.filter((u) => u.type === "corporate"), [users]);
const router = useRouter();
const averageLevelCalculator = (studentStats: Stat[]) => {
const formattedStats = studentStats
.map((s) => ({
focus: students.find((u) => u.id === s.user)?.focus,
score: s.score,
module: s.module,
}))
.filter((f) => !!f.focus);
const bandScores = formattedStats.map((s) => ({
module: s.module,
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
}));
const levels: { [key in Module]: number } = {
reading: 0,
listening: 0,
writing: 0,
speaking: 0,
level: 0,
};
bandScores.forEach((b) => (levels[b.module] += b.level));
return calculateAverageLevel(levels);
};
const UserDisplay = (displayUser: User) => (
<div className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
<div className="flex flex-col gap-1 items-start">
<span>{displayUser.name}</span>
<span className="text-sm opacity-75">{displayUser.email}</span>
</div>
</div>
);
return (
<>
<Head>
<title>EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<Layout user={user}>
<section className="grid grid-cols-5 place-items-center -md:grid-cols-2 gap-4 text-center">
<IconCard
onClick={() => router.push("/users?type=student")}
Icon={BsPersonFill}
label="Students"
value={students.length}
color="purple"
/>
<IconCard
onClick={() => router.push("/users?type=teacher")}
Icon={BsPencilSquare}
label="Teachers"
value={teachers.length}
color="purple"
/>
<IconCard
onClick={() => router.push("/users?type=corporate")} Icon={BsBank} label="Corporate Accounts" value={corporates.length} color="purple" />
<IconCard
Icon={BsPeople}
onClick={() => router.push("/classrooms")}
label="Classrooms"
value={groups.length}
color="purple"
/>
<IconCard Icon={BsPeopleFill}
onClick={() => router.push("/entities")}
label="Entities"
value={entities.length}
color="purple"
/>
<IconCard Icon={BsClipboard2Data} label="Exams Performed" value={uniqBy(stats, "exam").length} color="purple" />
<IconCard Icon={BsPaperclip} label="Average Level" value={averageLevelCalculator(stats).toFixed(1)} color="purple" />
<IconCard Icon={BsPersonFillGear}
onClick={() => router.push("/users/performance")}
label="Student Performance"
value={students.length}
color="purple"
/>
<IconCard
Icon={BsEnvelopePaper}
onClick={() => router.push("/assignments")}
label="Assignments"
value={assignments.filter((a) => !a.archived).length}
color="purple"
/>
<IconCard
Icon={BsClock}
label="Expiration Date"
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
color="rose"
/>
</section>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<UserDisplayList
users={students.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))}
title="Latest Students"
/>
<UserDisplayList
users={teachers.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))}
title="Latest Teachers"
/>
<UserDisplayList
users={students.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))}
title="Highest level students"
/>
<UserDisplayList
users={
students
.sort(
(a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length,
)
}
title="Highest exam count students"
/>
</section>
</Layout>
</>
);
}

View File

@@ -0,0 +1,278 @@
/* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import Button from "@/components/Low/Button";
import ProgressBar from "@/components/Low/ProgressBar";
import InviteWithUserCard from "@/components/Medium/InviteWithUserCard";
import ModuleBadge from "@/components/ModuleBadge";
import ProfileSummary from "@/components/ProfileSummary";
import {Session} from "@/hooks/useSessions";
import {Grading} from "@/interfaces";
import {EntityWithRoles} from "@/interfaces/entity";
import {Exam} from "@/interfaces/exam";
import { InviteWithEntity } from "@/interfaces/invite";
import {Assignment} from "@/interfaces/results";
import {Stat, User} from "@/interfaces/user";
import {sessionOptions} from "@/lib/session";
import useExamStore from "@/stores/examStore";
import {findBy, mapBy, redirect, serialize} from "@/utils";
import { requestUser } from "@/utils/api";
import {activeAssignmentFilter} from "@/utils/assignments";
import {getAssignmentsByAssignee} from "@/utils/assignments.be";
import {getEntitiesWithRoles, getEntityWithRoles} from "@/utils/entities.be";
import {getExamsByIds} from "@/utils/exams.be";
import {getGradingSystemByEntity} from "@/utils/grading.be";
import {convertInvitersToEntity, getInvitesByInvitee} from "@/utils/invites.be";
import {countExamModules, countFullExams, MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils";
import {checkAccess} from "@/utils/permissions";
import {getGradingLabel} from "@/utils/score";
import {getSessionsByUser} from "@/utils/sessions.be";
import {averageScore} from "@/utils/stats";
import {getStatsByUser} from "@/utils/stats.be";
import clsx from "clsx";
import {withIronSessionSsr} from "iron-session/next";
import {capitalize, uniqBy} from "lodash";
import moment from "moment";
import Head from "next/head";
import {useRouter} from "next/router";
import { useMemo } from "react";
import {BsBook, BsClipboard, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar} from "react-icons/bs";
import {ToastContainer} from "react-toastify";
interface Props {
user: User;
entities: EntityWithRoles[];
assignments: Assignment[];
stats: Stat[];
exams: Exam[];
sessions: Session[];
invites: InviteWithEntity[];
grading: Grading;
}
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
if (!checkAccess(user, ["admin", "developer", "student"]))
return redirect("/")
const entityIDS = mapBy(user.entities, "id") || [];
const entities = await getEntitiesWithRoles(entityIDS);
const assignments = await getAssignmentsByAssignee(user.id, {archived: {$ne: true}});
const stats = await getStatsByUser(user.id);
const sessions = await getSessionsByUser(user.id, 10);
const invites = await getInvitesByInvitee(user.id);
const grading = await getGradingSystemByEntity(entityIDS[0] || "");
const formattedInvites = await Promise.all(invites.map(convertInvitersToEntity));
const examIDs = uniqBy(
assignments.flatMap((a) =>
a.exams.filter((e) => e.assignee === user.id).map((e) => ({module: e.module, id: e.id, key: `${e.module}_${e.id}`})),
),
"key",
);
const exams = await getExamsByIds(examIDs);
return {props: serialize({user, entities, assignments, stats, exams, sessions, invites: formattedInvites, grading})};
}, sessionOptions);
export default function Dashboard({user, entities, assignments, stats, invites, grading, sessions, exams}: 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 setAssignment = useExamStore((state) => state.setAssignment);
const startAssignment = (assignment: Assignment) => {
const assignmentExams = exams.filter(e => {
const exam = findBy(assignment.exams, 'id', e.id)
return !!exam && exam.module === e.module
})
if (assignmentExams.every((x) => !!x)) {
setUserSolutions([]);
setShowSolutions(false);
setExams(assignmentExams.sort(sortByModule));
setSelectedModules(mapBy(assignmentExams.sort(sortByModule), 'module'));
setAssignment(assignment);
router.push("/exam");
}
};
const studentAssignments = useMemo(() => assignments.filter(activeAssignmentFilter), [assignments]);
return (
<>
<Head>
<title>EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<Layout user={user}>
{entities.length > 0 && (
<div className="absolute right-4 top-4 rounded-lg bg-neutral-200 px-2 py-1">
<b>{mapBy(entities, "label")?.join(", ")}</b>
</div>
)}
<ProfileSummary
user={user}
items={[
{
icon: <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
value: countFullExams(stats),
label: "Exams",
tooltip: "Number of all conducted completed exams",
},
{
icon: <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
value: countExamModules(stats),
label: "Modules",
tooltip: "Number of all exam modules performed including Level Test",
},
{
icon: <BsStar className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />,
value: `${stats.length > 0 ? averageScore(stats) : 0}%`,
label: "Average Score",
tooltip: "Average success rate for questions responded",
},
]}
/>
{/* Assignments */}
<section className="flex flex-col gap-1 md:gap-3">
<span className="text-mti-black text-lg font-bold">Assignments</span>
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
{studentAssignments.length === 0 && "Assignments will appear here. It seems that for now there are no assignments for you."}
{studentAssignments
.sort((a, b) => moment(a.startDate).diff(b.startDate))
.map((assignment) => (
<div
className={clsx(
"border-mti-gray-anti-flash flex min-w-[350px] flex-col gap-6 rounded-xl border p-4",
assignment.results.map((r) => r.user).includes(user.id) && "border-mti-green-light",
)}
key={assignment.id}>
<div className="flex flex-col gap-1">
<h3 className="text-mti-black/90 text-xl font-semibold">{assignment.name}</h3>
<span className="flex justify-between gap-1 text-lg">
<span>{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}</span>
<span>-</span>
<span>{moment(assignment.endDate).format("DD/MM/YY, HH:mm")}</span>
</span>
</div>
<div className="flex w-full items-center justify-between">
<div className="-md:mt-2 grid w-fit min-w-[140px] grid-cols-2 grid-rows-2 place-items-center justify-between gap-4">
{assignment.exams
.filter((e) => e.assignee === user.id)
.map((e) => e.module)
.sort(sortByModuleName)
.map((module) => (
<ModuleBadge className="scale-110 w-full" key={module} module={module} />
))}
</div>
{!assignment.results.map((r) => r.user).includes(user.id) && (
<>
<div
className="tooltip flex h-full w-full items-center justify-end pl-8 md:hidden"
data-tip="Your screen size is too small to perform an assignment">
<Button className="h-full w-full !rounded-xl" variant="outline">
Start
</Button>
</div>
<div
data-tip="You have already started this assignment!"
className={clsx(
"-md:hidden h-full w-full max-w-[50%] cursor-pointer",
sessions.filter((x) => x.assignment?.id === assignment.id).length > 0 && "tooltip",
)}>
<Button
className={clsx("w-full h-full !rounded-xl")}
onClick={() => startAssignment(assignment)}
variant="outline"
disabled={sessions.filter((x) => x.assignment?.id === assignment.id).length > 0}>
Start
</Button>
</div>
</>
)}
{assignment.results.map((r) => r.user).includes(user.id) && (
<Button
onClick={() => router.push("/record")}
color="green"
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
variant="outline">
Submitted
</Button>
)}
</div>
</div>
))}
</span>
</section>
{/* Invites */}
{invites.length > 0 && (
<section className="flex flex-col gap-1 md:gap-3">
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
{invites.map((invite) => (
<InviteWithUserCard key={invite.id} invite={invite} reload={() => router.replace(router.asPath)} />
))}
</span>
</section>
)}
{/* Score History */}
<section className="flex flex-col gap-3">
<span className="text-lg font-bold">Score History</span>
<div className="-md:grid-rows-4 grid gap-6 md:grid-cols-2">
{MODULE_ARRAY.map((module) => {
const desiredLevel = user.desiredLevels[module] || 9;
const level = user.levels[module] || 0;
return (
<div className="border-mti-gray-anti-flash flex flex-col gap-2 rounded-xl border p-4" key={module}>
<div className="flex items-center gap-2 md:gap-3">
<div className="bg-mti-gray-smoke flex h-8 w-8 items-center justify-center rounded-lg md:h-12 md:w-12 md:rounded-xl">
{module === "reading" && <BsBook className="text-ielts-reading h-4 w-4 md:h-5 md:w-5" />}
{module === "listening" && <BsHeadphones className="text-ielts-listening h-4 w-4 md:h-5 md:w-5" />}
{module === "writing" && <BsPen className="text-ielts-writing h-4 w-4 md:h-5 md:w-5" />}
{module === "speaking" && <BsMegaphone className="text-ielts-speaking h-4 w-4 md:h-5 md:w-5" />}
{module === "level" && <BsClipboard className="text-ielts-level h-4 w-4 md:h-5 md:w-5" />}
</div>
<div className="flex w-full justify-between">
<span className="text-sm font-bold md:font-extrabold">{capitalize(module)}</span>
<span className="text-mti-gray-dim text-sm font-normal">
{module === "level" && !!grading && `English Level: ${getGradingLabel(level, grading.steps)}`}
{module !== "level" && `Level ${level} / Level 9 (Desired Level: ${desiredLevel})`}
</span>
</div>
</div>
<div className="md:pl-14">
<ProgressBar
color={module}
label=""
mark={module === "level" ? undefined : Math.round((desiredLevel * 100) / 9)}
markLabel={`Desired Level: ${desiredLevel}`}
percentage={module === "level" ? level : Math.round((level * 100) / 9)}
className="h-2 w-full"
/>
</div>
</div>
);
})}
</div>
</section>
</Layout>
</>
);
}

View File

@@ -0,0 +1,166 @@
/* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import UserDisplayList from "@/components/UserDisplayList";
import IconCard from "@/dashboards/IconCard";
import { Module } from "@/interfaces";
import { EntityWithRoles } from "@/interfaces/entity";
import { Assignment } from "@/interfaces/results";
import { Group, Stat, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session";
import { dateSorter, filterBy, mapBy, redirect, serialize } from "@/utils";
import { getEntitiesAssignments } from "@/utils/assignments.be";
import { getEntitiesWithRoles } from "@/utils/entities.be";
import { getGroupsByEntities } from "@/utils/groups.be";
import { checkAccess, findAllowedEntities } from "@/utils/permissions";
import { calculateAverageLevel, calculateBandScore } from "@/utils/score";
import { groupByExam } from "@/utils/stats";
import { getStatsByUsers } from "@/utils/stats.be";
import { getEntitiesUsers } from "@/utils/users.be";
import { withIronSessionSsr } from "iron-session/next";
import { uniqBy } from "lodash";
import Head from "next/head";
import { useRouter } from "next/router";
import { useMemo } from "react";
import { BsClipboard2Data, BsEnvelopePaper, BsPaperclip, BsPeople, BsPersonFill } from "react-icons/bs";
import { ToastContainer } from "react-toastify";
import { requestUser } from "@/utils/api";
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import { filterAllowedUsers } from "@/utils/users.be";
interface Props {
user: User;
users: User[];
entities: EntityWithRoles[];
assignments: Assignment[];
stats: Stat[];
groups: Group[];
}
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
if (!checkAccess(user, ["admin", "developer", "teacher"]))
return redirect("/")
const entityIDS = mapBy(user.entities, "id") || [];
const entities = await getEntitiesWithRoles(entityIDS);
const users = await filterAllowedUsers(user, entities)
const assignments = await getEntitiesAssignments(entityIDS);
const stats = await getStatsByUsers(users.map((u) => u.id));
const groups = await getGroupsByEntities(entityIDS);
return { props: serialize({ user, users, entities, assignments, stats, groups }) };
}, sessionOptions);
export default function Dashboard({ user, users, entities, assignments, stats, groups }: Props) {
const students = useMemo(() => users.filter((u) => u.type === "student"), [users]);
const router = useRouter();
const averageLevelCalculator = (studentStats: Stat[]) => {
const formattedStats = studentStats
.map((s) => ({
focus: students.find((u) => u.id === s.user)?.focus,
score: s.score,
module: s.module,
}))
.filter((f) => !!f.focus);
const bandScores = formattedStats.map((s) => ({
module: s.module,
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
}));
const levels: { [key in Module]: number } = {
reading: 0,
listening: 0,
writing: 0,
speaking: 0,
level: 0,
};
bandScores.forEach((b) => (levels[b.module] += b.level));
return calculateAverageLevel(levels);
};
const UserDisplay = (displayUser: User) => (
<div className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
<div className="flex flex-col gap-1 items-start">
<span>{displayUser.name}</span>
<span className="text-sm opacity-75">{displayUser.email}</span>
</div>
</div>
);
return (
<>
<Head>
<title>EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<Layout user={user}>
<div className="w-full flex flex-col gap-4">
{entities.length > 0 && (
<div className="w-fit self-end bg-neutral-200 px-2 rounded-lg py-1">
<b>{mapBy(entities, "label")?.join(", ")}</b>
</div>
)}
<section className="grid grid-cols-5 -md:grid-cols-2 place-items-center gap-4 text-center">
<IconCard
Icon={BsPersonFill}
onClick={() => router.push("/users?type=student")}
label="Students"
value={students.length}
color="purple"
/>
<IconCard
onClick={() => router.push("/classrooms")}
Icon={BsPeople}
label="Classrooms"
value={groups.length}
color="purple"
/>
<IconCard Icon={BsClipboard2Data} label="Exams Performed" value={uniqBy(stats, "exam").length} color="purple" />
<IconCard Icon={BsPaperclip} label="Average Level" value={averageLevelCalculator(stats).toFixed(1)} color="purple" />
<IconCard
Icon={BsEnvelopePaper}
onClick={() => router.push("/assignments")}
label="Assignments"
value={assignments.filter((a) => !a.archived).length}
color="purple"
/>
</section>
</div>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<UserDisplayList
users={students.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))}
title="Latest Students"
/>
<UserDisplayList
users={students.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))}
title="Highest level students"
/>
<UserDisplayList
users={
students
.sort(
(a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length,
)
}
title="Highest exam count students"
/>
</section>
</Layout>
</>
);
}

View File

@@ -0,0 +1,373 @@
/* eslint-disable @next/next/no-img-element */
import CardList from "@/components/High/CardList";
import Layout from "@/components/High/Layout";
import Select from "@/components/Low/Select";
import Tooltip from "@/components/Low/Tooltip";
import { useEntityPermission } from "@/hooks/useEntityPermissions";
import {useListSearch} from "@/hooks/useListSearch";
import usePagination from "@/hooks/usePagination";
import {Entity, EntityWithRoles, Role} from "@/interfaces/entity";
import {GroupWithUsers, User} from "@/interfaces/user";
import {sessionOptions} from "@/lib/session";
import {USER_TYPE_LABELS} from "@/resources/user";
import { findBy, mapBy, redirect, serialize } from "@/utils";
import {getEntityWithRoles} from "@/utils/entities.be";
import {convertToUsers, getGroup} from "@/utils/groups.be";
import {shouldRedirectHome} from "@/utils/navigation.disabled";
import {checkAccess, doesEntityAllow, getTypesOfUser} from "@/utils/permissions";
import {getUserName, isAdmin} from "@/utils/users";
import {filterAllowedUsers, getEntitiesUsers, getEntityUsers, getLinkedUsers, getSpecificUsers, getUsers} from "@/utils/users.be";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import axios from "axios";
import clsx from "clsx";
import {withIronSessionSsr} from "iron-session/next";
import moment from "moment";
import Head from "next/head";
import Link from "next/link";
import {useRouter} from "next/router";
import {Divider} from "primereact/divider";
import {useEffect, useMemo, useState} from "react";
import {
BsChevronLeft,
BsClockFill,
BsEnvelopeFill,
BsFillPersonVcardFill,
BsPerson,
BsPlus,
BsSquare,
BsStopwatchFill,
BsTag,
BsTrash,
BsX,
} from "react-icons/bs";
import {toast} from "react-toastify";
export const getServerSideProps = withIronSessionSsr(async ({req, params}) => {
const user = req.session.user as User;
if (!user) return redirect("/login")
if (shouldRedirectHome(user)) return redirect("/")
const {id} = params as {id: string};
const entity = await getEntityWithRoles(id);
if (!entity) return redirect("/entities")
if (!doesEntityAllow(user, entity, "view_entities")) return redirect(`/entities`)
const linkedUsers = await (isAdmin(user) ? getUsers() : getEntitiesUsers(mapBy(user.entities, 'id'),
{$and: [{type: {$ne: "developer"}}, {type: {$ne: "admin"}}]}))
const entityUsers = await (isAdmin(user) ? getEntityUsers(id) : filterAllowedUsers(user, [entity]));
const usersWithRole = entityUsers.map((u) => {
const e = u.entities.find((e) => e.id === id);
return {...u, role: findBy(entity.roles, 'id', e?.role)};
});
return {
props: serialize({
user,
entity,
users: usersWithRole,
linkedUsers: linkedUsers.filter(x => x.id !== user.id && !mapBy(entityUsers, 'id').includes(x.id)),
}),
};
}, sessionOptions);
type UserWithRole = User & {role?: Role};
interface Props {
user: User;
entity: EntityWithRoles;
users: UserWithRole[];
linkedUsers: User[];
}
export default function Home({user, entity, users, linkedUsers}: Props) {
const [isAdding, setIsAdding] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
const router = useRouter();
const canRenameEntity = useEntityPermission(user, entity, "rename_entity")
const canViewRoles = useEntityPermission(user, entity, "view_entity_roles")
const canDeleteEntity = useEntityPermission(user, entity, "delete_entity")
const canAddMembers = useEntityPermission(user, entity, "add_to_entity")
const canRemoveMembers = useEntityPermission(user, entity, "remove_from_entity")
const canAssignRole = useEntityPermission(user, entity, "assign_to_role")
const toggleUser = (u: User) => setSelectedUsers((prev) => (prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]));
const removeParticipants = () => {
if (selectedUsers.length === 0) return;
if (!canRemoveMembers) return;
if (!confirm(`Are you sure you want to remove ${selectedUsers.length} member${selectedUsers.length === 1 ? "" : "s"} from this entity?`))
return;
setIsLoading(true);
axios
.patch(`/api/entities/${entity.id}/users`, {add: false, members: selectedUsers})
.then(() => {
toast.success("The entity has been updated successfully!");
router.replace(router.asPath);
setSelectedUsers([]);
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
};
const addParticipants = () => {
if (selectedUsers.length === 0) return;
if (!canAddMembers || !isAdding) return;
if (!confirm(`Are you sure you want to add ${selectedUsers.length} member${selectedUsers.length === 1 ? "" : "s"} to this entity?`)) return;
setIsLoading(true);
const defaultRole = findBy(entity.roles, 'isDefault', true)!
axios
.patch(`/api/entities/${entity.id}/users`, {add: true, members: selectedUsers, role: defaultRole.id})
.then(() => {
toast.success("The entity has been updated successfully!");
router.replace(router.asPath);
setIsAdding(false);
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
};
const renameGroup = () => {
if (!canRenameEntity) return;
const label = prompt("Rename this entity:", entity.label);
if (!label) return;
setIsLoading(true);
axios
.patch(`/api/entities/${entity.id}`, {label})
.then(() => {
toast.success("The entity has been updated successfully!");
router.replace(router.asPath);
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
};
const deleteGroup = () => {
if (!canDeleteEntity) return;
if (!confirm("Are you sure you want to delete this entity?")) return;
setIsLoading(true);
axios
.delete(`/api/entities/${entity.id}`)
.then(() => {
toast.success("This entity has been successfully deleted!");
router.replace("/entities");
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
};
const assignUsersToRole = (role: string) => {
if (!canAssignRole) return
if (selectedUsers.length === 0) return
setIsLoading(true);
axios
.post(`/api/roles/${role}/users`, {users: selectedUsers})
.then(() => {
toast.success("The role has been assigned successfully!");
router.replace(router.asPath);
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
}
const renderCard = (u: UserWithRole) => {
return (
<button
onClick={() => toggleUser(u)}
disabled={isAdding ? !canAddMembers : !canRemoveMembers}
key={u.id}
className={clsx(
"p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer",
"hover:border-mti-purple transition ease-in-out duration-300",
selectedUsers.includes(u.id) && "border-mti-purple",
)}>
<div className="flex items-center gap-2">
<div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full">
<img src={u.profilePicture} alt={u.name} />
</div>
<div className="flex flex-col">
<span className="font-semibold">{getUserName(u)}</span>
<span className="opacity-80 text-sm">
{USER_TYPE_LABELS[u.type]} {u.role && `- ${u.role.label}`}
</span>
</div>
</div>
<div className="flex flex-col gap-1">
<span className="flex items-center gap-2">
<Tooltip tooltip="E-mail address">
<BsEnvelopeFill />
</Tooltip>
{u.email}
</span>
<span className="flex items-center gap-2">
<Tooltip tooltip="Expiration Date">
<BsStopwatchFill />
</Tooltip>
{u.subscriptionExpirationDate ? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited"}
</span>
<span className="flex items-center gap-2">
<Tooltip tooltip="Last Login">
<BsClockFill />
</Tooltip>
{u.lastLogin ? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm") : "N/A"}
</span>
</div>
</button>
);
};
useEffect(() => setSelectedUsers([]), [isAdding]);
return (
<>
<Head>
<title>{entity.label} | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<Layout user={user}>
<section className="flex flex-col gap-0">
<div className="flex flex-col gap-3">
<div className="flex items-end justify-between">
<div className="flex items-center gap-2">
<Link
href="/entities"
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
<BsChevronLeft />
</Link>
<h2 className="font-bold text-2xl">{entity.label}</h2>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={renameGroup}
disabled={isLoading || !canRenameEntity}
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
<BsTag />
<span className="text-xs">Rename Entity</span>
</button>
<button
onClick={() => router.push(`/entities/${entity.id}/roles`)}
disabled={isLoading || !canViewRoles}
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
<BsPerson />
<span className="text-xs">Edit Roles</span>
</button>
<button
onClick={deleteGroup}
disabled={isLoading || !canDeleteEntity}
className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
<BsTrash />
<span className="text-xs">Delete Entity</span>
</button>
</div>
</div>
<Divider />
<div className="flex items-center justify-between mb-4">
<span className="font-semibold text-xl">Members ({users.length})</span>
{!isAdding && (
<div className="flex items-center gap-2">
<button
onClick={() => setIsAdding(true)}
disabled={isLoading || !canAddMembers}
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
<BsPlus />
<span className="text-xs">Add Members</span>
</button>
<Menu>
<MenuButton
disabled={isLoading || !canAssignRole || selectedUsers.length === 0}
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
<BsPerson />
<span className="text-xs">Assign Role</span>
</MenuButton>
<MenuItems anchor="bottom" className="bg-white rounded-xl shadow drop-shadow border mt-1 flex flex-col">
{entity.roles.map((role) => (
<MenuItem key={role.id}>
<button onClick={() => assignUsersToRole(role.id)} className="p-4 hover:bg-neutral-100 w-32">
{ role.label }
</button>
</MenuItem>
))}
</MenuItems>
</Menu>
<button
onClick={removeParticipants}
disabled={selectedUsers.length === 0 || isLoading || !canRemoveMembers}
className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
<BsTrash />
<span className="text-xs">Remove Members</span>
</button>
</div>
)}
{isAdding && (
<div className="flex items-center gap-2">
<button
onClick={() => setIsAdding(false)}
disabled={isLoading}
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-rose bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
<BsX />
<span className="text-xs">Discard Selection</span>
</button>
<button
onClick={addParticipants}
disabled={selectedUsers.length === 0 || isLoading || !canAddMembers}
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
<BsPlus />
<span className="text-xs">Add Members ({selectedUsers.length})</span>
</button>
</div>
)}
</div>
<CardList<User | UserWithRole>
list={isAdding ? linkedUsers : users}
renderCard={renderCard}
searchFields={[["name"], ["email"], ["corporateInformation", "companyInformation", "name"], ["role", "label"], ["type"]]}
/>
</section>
</Layout>
</>
);
}

View File

@@ -0,0 +1,359 @@
import Layout from "@/components/High/Layout";
import Checkbox from "@/components/Low/Checkbox";
import Separator from "@/components/Low/Separator";
import { useEntityPermission } from "@/hooks/useEntityPermissions";
import {EntityWithRoles, Role} from "@/interfaces/entity";
import {User} from "@/interfaces/user";
import {sessionOptions} from "@/lib/session";
import { RolePermission } from "@/resources/entityPermissions";
import { findBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api";
import {getEntityWithRoles} from "@/utils/entities.be";
import {shouldRedirectHome} from "@/utils/navigation.disabled";
import {doesEntityAllow} from "@/utils/permissions";
import {countEntityUsers} from "@/utils/users.be";
import axios from "axios";
import {withIronSessionSsr} from "iron-session/next";
import Head from "next/head";
import Link from "next/link";
import {useRouter} from "next/router";
import {Divider} from "primereact/divider";
import {useState} from "react";
import {
BsCheck,
BsChevronLeft,
BsTag,
BsTrash,
} from "react-icons/bs";
import {toast} from "react-toastify";
type PermissionLayout = {label: string, key: RolePermission}
const USER_MANAGEMENT: PermissionLayout[] = [
{label: "View Students", key: "view_students"},
{label: "View Teachers", key: "view_teachers"},
{label: "View Corporate Accounts", key: "view_corporates"},
{label: "View Master Corporate Accounts", key: "view_mastercorporates"},
{label: "Edit Students", key: "edit_students"},
{label: "Edit Teachers", key: "edit_teachers"},
{label: "Edit Corporate Accounts", key: "edit_corporates"},
{label: "Edit Master Corporate Accounts", key: "edit_mastercorporates"},
{label: "Delete Students", key: "delete_students"},
{label: "Delete Teachers", key: "delete_teachers"},
{label: "Delete Corporate Accounts", key: "delete_corporates"},
{label: "Delete Master Corporate Accounts", key: "delete_mastercorporates"},
]
const EXAM_MANAGEMENT: PermissionLayout[] = [
{label: "View Reading", key: "view_reading"},
{label: "Generate Reading", key: "generate_reading"},
{label: "Delete Reading", key: "delete_reading"},
{label: "View Listening", key: "view_listening"},
{label: "Generate Listening", key: "generate_listening"},
{label: "Delete Listening", key: "delete_listening"},
{label: "View Writing", key: "view_writing"},
{label: "Generate Writing", key: "generate_writing"},
{label: "Delete Writing", key: "delete_writing"},
{label: "View Speaking", key: "view_speaking"},
{label: "Generate Speaking", key: "generate_speaking"},
{label: "Delete Speaking", key: "delete_speaking"},
{label: "View Level", key: "view_level"},
{label: "Generate Level", key: "generate_level"},
{label: "Delete Level", key: "delete_level"},
]
const CLASSROOM_MANAGEMENT: PermissionLayout[] = [
{label: "View Classrooms", key: "view_classrooms"},
{label: "Create Classrooms", key: "create_classroom"},
{label: "Rename Classrooms", key: "rename_classrooms"},
{label: "Add to Classroom", key: "add_to_classroom"},
{label: "Remove from Classroom", key: "remove_from_classroom"},
{label: "Delete Classroom", key: "delete_classroom"},
]
const ENTITY_MANAGEMENT: PermissionLayout[] = [
{label: "View Entities", key: "view_entities"},
{label: "Rename Entity", key: "rename_entity"},
{label: "Add to Entity", key: "add_to_entity"},
{label: "Remove from Entity", key: "remove_from_entity"},
{label: "Delete Entity", key: "delete_entity"},
{label: "View Entity Roles", key: "view_entity_roles"},
{label: "Create Entity Role", key: "create_entity_role"},
{label: "Rename Entity Role", key: "rename_entity_role"},
{label: "Edit Role Permissions", key: "edit_role_permissions"},
{label: "Assign Role to User", key: "assign_to_role"},
{label: "Delete Entity Role", key: "delete_entity_role"},
]
const ASSIGNMENT_MANAGEMENT: PermissionLayout[] = [
{label: "View Assignments", key: "view_assignments"},
{label: "Create Assignments", key: "create_assignment"},
{label: "Start Assignments", key: "start_assignment"},
{label: "Delete Assignments", key: "delete_assignment"},
{label: "Archive Assignments", key: "archive_assignment"},
]
export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
if (shouldRedirectHome(user)) return redirect("/")
const {id, role} = params as {id: string, role: string};
if (!mapBy(user.entities, 'id').includes(id) && !["admin", "developer"].includes(user.type)) return redirect("/entities")
const entity = await getEntityWithRoles(id);
if (!entity) return redirect("/entities")
const entityRole = findBy(entity.roles, 'id', role)
if (!entityRole) return redirect(`/entities/${id}/roles`)
if (!doesEntityAllow(user, entity, "view_entity_roles")) return redirect(`/entities/${id}`)
const userCount = await countEntityUsers(id, { "entities.role": role });
return {
props: serialize({
user,
entity,
role: entityRole,
userCount,
}),
};
}, sessionOptions);
interface Props {
user: User;
entity: EntityWithRoles;
role: Role;
userCount: number;
}
export default function Role({user, entity, role, userCount}: Props) {
const [permissions, setPermissions] = useState(role.permissions)
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const canEditPermissions = useEntityPermission(user, entity, "edit_role_permissions")
const canRenameRole = useEntityPermission(user, entity, "rename_entity_role")
const canDeleteRole = useEntityPermission(user, entity, "delete_entity_role")
const renameRole = () => {
if (!canRenameRole) return;
const label = prompt("Rename this role:", role.label);
if (!label) return;
setIsLoading(true);
axios
.patch(`/api/roles/${role.id}`, {label})
.then(() => {
toast.success("The role has been updated successfully!");
router.replace(router.asPath);
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
};
const deleteRole = () => {
if (!canDeleteRole || role.isDefault) return;
if (!confirm("Are you sure you want to delete this role?")) return;
setIsLoading(true);
axios
.delete(`/api/roles/${role.id}`)
.then(() => {
toast.success("This role has been successfully deleted!");
router.replace(`/entities/${entity.id}/roles`);
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
};
const editPermissions = () => {
if (!canEditPermissions) return
setIsLoading(true);
axios
.patch(`/api/roles/${role.id}`, {permissions})
.then(() => {
toast.success("This role has been successfully updated!");
router.replace(router.asPath);
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
}
const togglePermissions = (p: RolePermission) => setPermissions(prev => prev.includes(p) ? prev.filter(x => x !== p) : [...prev, p])
return (
<>
<Head>
<title>{ role.label } | {entity.label} | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<Layout user={user}>
<section className="flex flex-col gap-0">
<div className="flex flex-col gap-3">
<div className="flex items-end justify-between">
<div className="flex items-center gap-2">
<Link
href={`/entities/${entity.id}/roles`}
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
<BsChevronLeft />
</Link>
<h2 className="font-bold text-2xl">{role.label} Role ({ userCount } users)</h2>
</div>
</div>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<button
onClick={renameRole}
disabled={isLoading || !canRenameRole}
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
<BsTag />
<span className="text-xs">Rename Role</span>
</button>
<button
onClick={deleteRole}
disabled={isLoading || !canDeleteRole || role.isDefault}
className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
<BsTrash />
<span className="text-xs">Delete Role</span>
</button>
</div>
<button
onClick={editPermissions}
disabled={isLoading || !canEditPermissions}
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
<BsCheck />
<span className="text-xs">Save Changes</span>
</button>
</div>
</div>
<Divider />
<section className="grid grid-cols-2 gap-16">
<div className="flex flex-col gap-4">
<div className="w-full flex items-center justify-between">
<b>User Management</b>
<Checkbox
isChecked={mapBy(USER_MANAGEMENT, 'key').every(k => permissions.includes(k))}
onChange={() => mapBy(USER_MANAGEMENT, 'key').forEach(togglePermissions)}
>
Select all
</Checkbox>
</div>
<Separator />
<div className="grid grid-cols-2 gap-4">
{USER_MANAGEMENT.map(({label, key}) => (
<Checkbox disabled={!canEditPermissions} key={key} isChecked={permissions.includes(key)} onChange={() => togglePermissions(key)}>
{ label }
</Checkbox>
)) }
</div>
</div>
<div className="flex flex-col gap-4">
<div className="w-full flex items-center justify-between">
<b>Exam Management</b>
<Checkbox
isChecked={mapBy(EXAM_MANAGEMENT, 'key').every(k => permissions.includes(k))}
onChange={() => mapBy(EXAM_MANAGEMENT, 'key').forEach(togglePermissions)}
>
Select all
</Checkbox>
</div>
<Separator />
<div className="grid grid-cols-3 gap-4">
{EXAM_MANAGEMENT.map(({label, key}) => (
<Checkbox disabled={!canEditPermissions} key={key} isChecked={permissions.includes(key)} onChange={() => togglePermissions(key)}>
{ label }
</Checkbox>
)) }
</div>
</div>
<div className="flex flex-col gap-4">
<div className="w-full flex items-center justify-between">
<b>Clasroom Management</b>
<Checkbox
isChecked={mapBy(CLASSROOM_MANAGEMENT, 'key').every(k => permissions.includes(k))}
onChange={() => mapBy(CLASSROOM_MANAGEMENT, 'key').forEach(togglePermissions)}
>
Select all
</Checkbox>
</div>
<Separator />
<div className="grid grid-cols-2 gap-4">
{CLASSROOM_MANAGEMENT.map(({label, key}) => (
<Checkbox disabled={!canEditPermissions} key={key} isChecked={permissions.includes(key)} onChange={() => togglePermissions(key)}>
{ label }
</Checkbox>
)) }
</div>
</div>
<div className="flex flex-col gap-4">
<div className="w-full flex items-center justify-between">
<b>Entity Management</b>
<Checkbox
isChecked={mapBy(ENTITY_MANAGEMENT, 'key').every(k => permissions.includes(k))}
onChange={() => mapBy(ENTITY_MANAGEMENT, 'key').forEach(togglePermissions)}
>
Select all
</Checkbox>
</div>
<Separator />
<div className="grid grid-cols-2 gap-4">
{ENTITY_MANAGEMENT.map(({label, key}) => (
<Checkbox disabled={!canEditPermissions} key={key} isChecked={permissions.includes(key)} onChange={() => togglePermissions(key)}>
{ label }
</Checkbox>
)) }
</div>
</div>
<div className="flex flex-col gap-4">
<div className="w-full flex items-center justify-between">
<b>Assignment Management</b>
<Checkbox
isChecked={mapBy(ASSIGNMENT_MANAGEMENT, 'key').every(k => permissions.includes(k))}
onChange={() => mapBy(ASSIGNMENT_MANAGEMENT, 'key').forEach(togglePermissions)}
>
Select all
</Checkbox>
</div>
<Separator />
<div className="grid grid-cols-2 gap-4">
{ASSIGNMENT_MANAGEMENT.map(({label, key}) => (
<Checkbox disabled={!canEditPermissions} key={key} isChecked={permissions.includes(key)} onChange={() => togglePermissions(key)}>
{ label }
</Checkbox>
)) }
</div>
</div>
</section>
</section>
</Layout>
</>
);
}

View File

@@ -0,0 +1,158 @@
/* eslint-disable @next/next/no-img-element */
import CardList from "@/components/High/CardList";
import Layout from "@/components/High/Layout";
import Tooltip from "@/components/Low/Tooltip";
import { useEntityPermission } from "@/hooks/useEntityPermissions";
import {useListSearch} from "@/hooks/useListSearch";
import usePagination from "@/hooks/usePagination";
import {Entity, EntityWithRoles, Role} from "@/interfaces/entity";
import {GroupWithUsers, User} from "@/interfaces/user";
import {sessionOptions} from "@/lib/session";
import {USER_TYPE_LABELS} from "@/resources/user";
import { redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api";
import {getEntityWithRoles} from "@/utils/entities.be";
import {convertToUsers, getGroup} from "@/utils/groups.be";
import {shouldRedirectHome} from "@/utils/navigation.disabled";
import {checkAccess, doesEntityAllow, getTypesOfUser} from "@/utils/permissions";
import {getUserName} from "@/utils/users";
import {getEntityUsers, getLinkedUsers, getSpecificUsers} from "@/utils/users.be";
import axios from "axios";
import clsx from "clsx";
import {withIronSessionSsr} from "iron-session/next";
import moment from "moment";
import Head from "next/head";
import Link from "next/link";
import {useRouter} from "next/router";
import {Divider} from "primereact/divider";
import {useEffect, useMemo, useState} from "react";
import {
BsChevronLeft,
BsClockFill,
BsEnvelopeFill,
BsFillPersonVcardFill,
BsPlus,
BsSquare,
BsStopwatchFill,
BsTag,
BsTrash,
BsX,
} from "react-icons/bs";
import {toast, ToastContainer} from "react-toastify";
export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
if (shouldRedirectHome(user)) return redirect("/")
const {id} = params as {id: string};
const entity = await getEntityWithRoles(id);
if (!entity) return redirect("/entities")
if (!doesEntityAllow(user, entity, "view_entity_roles")) return redirect(`/entities/${id}`)
const users = await getEntityUsers(id);
return {
props: serialize({
user,
entity,
roles: entity.roles,
users,
}),
};
}, sessionOptions);
interface Props {
user: User;
entity: EntityWithRoles;
roles: Role[];
users: User[];
}
export default function Home({user, entity, roles, users}: Props) {
const router = useRouter();
const canCreateRole = useEntityPermission(user, entity, "create_entity_role")
const createRole = () => {
if (!canCreateRole) return
const label = prompt("What is the name of this new role?")
if (!label) return
axios.post<Role>('/api/roles', {label, permissions: [], entityID: entity.id})
.then((result) => {
toast.success(`'${label}' role created successfully!`)
router.push(`/entities/${entity.id}/roles/${result.data.id}`)
})
.catch(() => {
toast.error("Something went wrong!")
})
}
const firstCard = () => (
<button
onClick={createRole}
className="p-4 border hover:text-mti-purple rounded-xl flex flex-col items-center justify-center gap-0 hover:border-mti-purple transition ease-in-out duration-300 text-left cursor-pointer">
<BsPlus size={40} />
<span className="font-semibold">Create Role</span>
</button>
);
const renderCard = (role: Role) => {
const usersWithRole = users.filter((x) => x.entities.map((x) => x.role).includes(role.id));
return (
<Link
href={`/entities/${entity.id}/roles/${role.id}`}
key={role.id}
className={clsx(
"p-4 pr-6 h-fit relative border rounded-xl flex flex-col gap-3 text-left cursor-pointer",
"hover:border-mti-purple transition ease-in-out duration-300",
)}>
<div className="flex flex-col">
<span className="font-semibold">{role.label}</span>
<span className="opacity-80 text-sm">{usersWithRole.length} members</span>
</div>
<b>{role.permissions.length} Permissions</b>
</Link>
);
};
return (
<>
<Head>
<title>{entity.label} | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<Layout user={user}>
<section className="flex flex-col gap-0">
<div className="flex flex-col gap-3">
<div className="flex items-end justify-between">
<div className="flex items-center gap-2">
<Link
href={`/entities/${entity.id}`}
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
<BsChevronLeft />
</Link>
<h2 className="font-bold text-2xl">{entity.label}</h2>
</div>
</div>
</div>
<Divider />
<span className="font-semibold text-xl mb-4">Roles</span>
<CardList list={roles} searchFields={[["label"]]} renderCard={renderCard} firstCard={canCreateRole ? firstCard : undefined} />
</section>
</Layout>
</>
);
}

View File

@@ -0,0 +1,176 @@
/* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
import Input from "@/components/Low/Input";
import Select from "@/components/Low/Select";
import Tooltip from "@/components/Low/Tooltip";
import {useListSearch} from "@/hooks/useListSearch";
import usePagination from "@/hooks/usePagination";
import {Entity, EntityWithRoles} from "@/interfaces/entity";
import {User} from "@/interfaces/user";
import {sessionOptions} from "@/lib/session";
import {USER_TYPE_LABELS} from "@/resources/user";
import {mapBy, redirect, serialize} from "@/utils";
import {getEntities, getEntitiesWithRoles} from "@/utils/entities.be";
import {shouldRedirectHome} from "@/utils/navigation.disabled";
import {getUserName} from "@/utils/users";
import {getLinkedUsers, getUsers} from "@/utils/users.be";
import axios from "axios";
import clsx from "clsx";
import {withIronSessionSsr} from "iron-session/next";
import moment from "moment";
import Head from "next/head";
import Link from "next/link";
import {useRouter} from "next/router";
import {Divider} from "primereact/divider";
import {useState} from "react";
import {BsCheck, BsChevronLeft, BsClockFill, BsEnvelopeFill, BsStopwatchFill} from "react-icons/bs";
import {toast, ToastContainer} from "react-toastify";
import { requestUser } from "@/utils/api";
import { findAllowedEntities } from "@/utils/permissions";
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
if (shouldRedirectHome(user)) return redirect("/")
if (!["admin", "developer"].includes(user.type)) return redirect("/entities")
const users = await getUsers()
return {
props: serialize({user, users: users.filter((x) => x.id !== user.id)}),
};
}, sessionOptions);
interface Props {
user: User;
users: User[];
}
export default function Home({user, users}: Props) {
const [isLoading, setIsLoading] = useState(false);
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
const [label, setLabel] = useState("");
const {rows, renderSearch} = useListSearch<User>([["name"], ["corporateInformation", "companyInformation", "name"]], users);
const {items, renderMinimal} = usePagination<User>(rows, 16);
const router = useRouter();
const createGroup = () => {
if (!label.trim()) return;
if (!confirm(`Are you sure you want to create this entity with ${selectedUsers.length} members?`)) return;
setIsLoading(true);
axios
.post<Entity>(`/api/entities`, {label, members: selectedUsers})
.then((result) => {
toast.success("Your entity has been created successfully!");
router.replace(`/entities/${result.data.id}`);
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
};
const toggleUser = (u: User) => setSelectedUsers((prev) => (prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]));
return (
<>
<Head>
<title>Create Entity | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<Layout user={user}>
<section className="flex flex-col gap-0">
<div className="flex gap-3 justify-between">
<div className="flex items-center gap-2">
<Link
href="/classrooms"
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
<BsChevronLeft />
</Link>
<h2 className="font-bold text-2xl">Create Entity</h2>
</div>
<div className="flex items-center gap-4">
<button
onClick={createGroup}
disabled={!label.trim() || isLoading}
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
<BsCheck />
<span className="text-xs">Create Entity</span>
</button>
</div>
</div>
<Divider />
<div className="flex flex-col gap-4 w-full">
<span className="font-semibold text-xl">Entity Label:</span>
<Input name="name" onChange={setLabel} type="text" placeholder="Entity A" />
</div>
<Divider />
<div className="flex items-center justify-between mb-4">
<span className="font-semibold text-xl">Members ({selectedUsers.length} selected):</span>
</div>
<div className="w-full flex items-center gap-4">
{renderSearch()}
{renderMinimal()}
</div>
</section>
<section className="w-full h-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{items.map((u) => (
<button
onClick={() => toggleUser(u)}
disabled={isLoading}
key={u.id}
className={clsx(
"p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer",
"hover:border-mti-purple transition ease-in-out duration-300",
selectedUsers.includes(u.id) && "border-mti-purple",
)}>
<div className="flex items-center gap-2">
<div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full">
<img src={u.profilePicture} alt={u.name} />
</div>
<div className="flex flex-col">
<span className="font-semibold">{getUserName(u)}</span>
<span className="opacity-80 text-sm">{USER_TYPE_LABELS[u.type]}</span>
</div>
</div>
<div className="flex flex-col gap-1">
<span className="flex items-center gap-2">
<Tooltip tooltip="E-mail address">
<BsEnvelopeFill />
</Tooltip>
{u.email}
</span>
<span className="flex items-center gap-2">
<Tooltip tooltip="Expiration Date">
<BsStopwatchFill />
</Tooltip>
{u.subscriptionExpirationDate ? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited"}
</span>
<span className="flex items-center gap-2">
<Tooltip tooltip="Last Login">
<BsClockFill />
</Tooltip>
{u.lastLogin ? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm") : "N/A"}
</span>
</div>
</button>
))}
</section>
</Layout>
</>
);
}

View File

@@ -0,0 +1,106 @@
/* 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 Layout from "@/components/High/Layout";
import {GroupWithUsers, User} from "@/interfaces/user";
import {shouldRedirectHome} from "@/utils/navigation.disabled";
import {getUserName} from "@/utils/users";
import {convertToUsers, getGroupsForUser} from "@/utils/groups.be";
import {countEntityUsers, getEntityUsers, getSpecificUsers} from "@/utils/users.be";
import {checkAccess, findAllowedEntities, getTypesOfUser} from "@/utils/permissions";
import Link from "next/link";
import {uniq} from "lodash";
import {BsPlus} from "react-icons/bs";
import CardList from "@/components/High/CardList";
import {getEntitiesWithRoles} from "@/utils/entities.be";
import {EntityWithRoles} from "@/interfaces/entity";
import Separator from "@/components/Low/Separator";
import { requestUser } from "@/utils/api";
import { mapBy, redirect, serialize } from "@/utils";
type EntitiesWithCount = {entity: EntityWithRoles; users: User[]; count: number};
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
if (shouldRedirectHome(user)) return redirect("/")
const entityIDs = mapBy(user.entities, 'id')
const entities = await getEntitiesWithRoles(["admin", "developer"].includes(user.type) ? undefined : entityIDs);
const allowedEntities = findAllowedEntities(user, entities, 'view_entities')
const entitiesWithCount = await Promise.all(
allowedEntities.map(async (e) => ({entity: e, count: await countEntityUsers(e.id), users: await getEntityUsers(e.id, 5)})),
);
return {
props: serialize({user, entities: entitiesWithCount}),
};
}, sessionOptions);
const SEARCH_FIELDS: string[][] = [["entity", "label"]];
interface Props {
user: User;
entities: EntitiesWithCount[];
}
export default function Home({user, entities}: Props) {
const renderCard = ({entity, users, count}: EntitiesWithCount) => (
<Link
href={`/entities/${entity.id}`}
key={entity.id}
className="p-4 border rounded-xl flex flex-col gap-2 hover:border-mti-purple transition ease-in-out duration-300 text-left cursor-pointer">
<span>
<b>Entity: </b>
{entity.label}
</span>
<b>Members ({count}): </b>
<span>
{users.map(getUserName).join(", ")}
{count > 5 ? <span className="opacity-60"> and {count - 5} more</span> : ""}
</span>
</Link>
);
const firstCard = () => (
<Link
href={`/entities/create`}
className="p-4 border hover:text-mti-purple rounded-xl flex flex-col items-center justify-center gap-0 hover:border-mti-purple transition ease-in-out duration-300 text-left cursor-pointer">
<BsPlus size={40} />
<span className="font-semibold">Create Entity</span>
</Link>
);
return (
<>
<Head>
<title>Entities | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
<Layout user={user} className="!gap-4">
<section className="flex flex-col gap-4 w-full h-full">
<div className="flex flex-col gap-4">
<h2 className="font-bold text-2xl">Entities</h2>
<Separator />
</div>
<CardList<EntitiesWithCount>
list={entities}
searchFields={SEARCH_FIELDS}
renderCard={renderCard}
firstCard={["admin", "developer"].includes(user.type) ? firstCard : undefined}
/>
</section>
</Layout>
</>
);
}

View File

@@ -6,38 +6,106 @@ import {shouldRedirectHome} from "@/utils/navigation.disabled";
import ExamPage from "./(exam)/ExamPage"; import ExamPage from "./(exam)/ExamPage";
import Head from "next/head"; import Head from "next/head";
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import { filterBy, findBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api";
import { getAssignment, getAssignments, getAssignmentsByAssignee } from "@/utils/assignments.be";
import { Assignment } from "@/interfaces/results";
import useExamStore from "@/stores/examStore";
import { useEffect } from "react";
import { Exam } from "@/interfaces/exam";
import { getExamsByIds } from "@/utils/exams.be";
import { sortByModule } from "@/utils/moduleUtils";
import { uniqBy } from "lodash";
import { useRouter } from "next/router";
import { getSessionByAssignment, getSessionsByUser } from "@/utils/sessions.be";
import { Session } from "@/hooks/useSessions";
import moment from "moment";
export const getServerSideProps = withIronSessionSsr(({req, res}) => { export const getServerSideProps = withIronSessionSsr(async ({req, res, query}) => {
const user = req.session.user; const user = await requestUser(req, res)
const destination = Buffer.from(req.url || "/").toString("base64")
if (!user) return redirect(`/login?destination=${destination}`)
if (shouldRedirectHome(user)) return redirect("/")
const {assignment: assignmentID} = query as {assignment?: string}
if (assignmentID) {
const assignment = await getAssignment(assignmentID)
if (!assignment) return redirect("/exam")
if (!assignment.assignees.includes(user.id) && !["admin", "developer"].includes(user.type))
return redirect("/exam")
if (filterBy(assignment.results, 'user', user.id).length > 0)
return redirect("/exam")
const exams = await getExamsByIds(uniqBy(assignment.exams, "id"))
const session = await getSessionByAssignment(assignmentID)
if (!user) {
return { return {
redirect: { props: serialize({user, assignment, exams, session: session ?? undefined})
destination: "/login",
permanent: false,
},
};
} }
if (shouldRedirectHome(user)) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
} }
return { return {
props: {user: req.session.user}, props: serialize({user}),
}; };
}, sessionOptions); }, sessionOptions);
interface Props { interface Props {
user: User; user: User;
assignment?: Assignment
exams?: Exam[]
session?: Session
} }
export default function Page({user}: Props) { export default function Page({user, assignment, exams = [], session}: Props) {
const router = useRouter()
const state = useExamStore((state) => state)
useEffect(() => {
if (assignment && exams.length > 0 && !state.assignment && !session) {
if (moment(assignment.startDate).isAfter(moment()) || moment(assignment.endDate).isBefore(moment())) return
state.setUserSolutions([]);
state.setShowSolutions(false);
state.setAssignment(assignment);
state.setExams(exams.sort(sortByModule));
state.setSelectedModules(
exams
.map((x) => x!)
.sort(sortByModule)
.map((x) => x!.module),
);
router.replace(router.asPath)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignment, exams, session])
useEffect(() => {
if (assignment && exams.length > 0 && !state.assignment && !!session) {
state.setShuffles(session.userSolutions.map((x) => ({exerciseID: x.exercise, shuffles: x.shuffleMaps ? x.shuffleMaps : []})));
state.setSelectedModules(session.selectedModules);
state.setExam(session.exam);
state.setExams(session.exams);
state.setSessionId(session.sessionId);
state.setAssignment(session.assignment);
state.setExerciseIndex(session.exerciseIndex);
state.setPartIndex(session.partIndex);
state.setModuleIndex(session.moduleIndex);
state.setTimeSpent(session.timeSpent);
state.setUserSolutions(session.userSolutions);
state.setShowSolutions(false);
state.setQuestionIndex(session.questionIndex);
router.replace(router.asPath)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignment, exams, session])
return ( return (
<> <>
<Head> <Head>
@@ -49,7 +117,7 @@ export default function Page({user}: Props) {
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ExamPage page="exams" user={user} /> <ExamPage page="exams" user={user} hideSidebar={!!assignment} />
</> </>
); );
} }

View File

@@ -6,42 +6,111 @@ import {shouldRedirectHome} from "@/utils/navigation.disabled";
import ExamPage from "./(exam)/ExamPage"; import ExamPage from "./(exam)/ExamPage";
import Head from "next/head"; import Head from "next/head";
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import { filterBy, findBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api";
import { getAssignment, getAssignments, getAssignmentsByAssignee } from "@/utils/assignments.be";
import { Assignment } from "@/interfaces/results";
import useExamStore from "@/stores/examStore";
import { useEffect } from "react";
import { Exam } from "@/interfaces/exam";
import { getExamsByIds } from "@/utils/exams.be";
import { sortByModule } from "@/utils/moduleUtils";
import { uniqBy } from "lodash";
import { useRouter } from "next/router";
import { getSessionByAssignment, getSessionsByUser } from "@/utils/sessions.be";
import { Session } from "@/hooks/useSessions";
import moment from "moment";
export const getServerSideProps = withIronSessionSsr(({req, res}) => { export const getServerSideProps = withIronSessionSsr(async ({req, res, query}) => {
const user = req.session.user; const user = await requestUser(req, res)
const destination = Buffer.from(req.url || "/").toString("base64")
if (!user) return redirect(`/login?destination=${destination}`)
if (shouldRedirectHome(user)) return redirect("/")
const {assignment: assignmentID} = query as {assignment?: string}
if (assignmentID) {
const assignment = await getAssignment(assignmentID)
if (!assignment) return redirect("/exam")
if (!["admin", "developer"].includes(user.type) && !assignment.assignees.includes(user.id)) return redirect("/exercises")
const exams = await getExamsByIds(uniqBy(assignment.exams, "id"))
const session = await getSessionByAssignment(assignmentID)
if (
filterBy(assignment.results, 'user', user.id) ||
moment(assignment.startDate).isBefore(moment()) ||
moment(assignment.endDate).isAfter(moment())
)
return redirect("/exam")
if (!user) {
return { return {
redirect: { props: serialize({user, assignment, exams, session})
destination: "/login",
permanent: false,
},
};
} }
if (shouldRedirectHome(user)) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
} }
return { return {
props: {user: req.session.user}, props: serialize({user}),
}; };
}, sessionOptions); }, sessionOptions);
interface Props { interface Props {
user: User; user: User;
assignment?: Assignment
exams?: Exam[]
session?: Session
} }
export default function Page({user}: Props) { export default function Page({user, assignment, exams = [], session}: Props) {
const router = useRouter()
const state = useExamStore((state) => state)
useEffect(() => {
if (assignment && exams.length > 0 && !state.assignment && !session) {
state.setUserSolutions([]);
state.setShowSolutions(false);
state.setAssignment(assignment);
state.setExams(exams.sort(sortByModule));
state.setSelectedModules(
exams
.map((x) => x!)
.sort(sortByModule)
.map((x) => x!.module),
);
router.replace(router.asPath)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignment, exams, session])
useEffect(() => {
if (assignment && exams.length > 0 && !state.assignment && !!session) {
state.setShuffles(session.userSolutions.map((x) => ({exerciseID: x.exercise, shuffles: x.shuffleMaps ? x.shuffleMaps : []})));
state.setSelectedModules(session.selectedModules);
state.setExam(session.exam);
state.setExams(session.exams);
state.setSessionId(session.sessionId);
state.setAssignment(session.assignment);
state.setExerciseIndex(session.exerciseIndex);
state.setPartIndex(session.partIndex);
state.setModuleIndex(session.moduleIndex);
state.setTimeSpent(session.timeSpent);
state.setUserSolutions(session.userSolutions);
state.setShowSolutions(false);
state.setQuestionIndex(session.questionIndex);
router.replace(router.asPath)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignment, exams, session])
return ( return (
<> <>
<Head> <Head>
<title>Exercises | EnCoach</title> <title>Exams | EnCoach</title>
<meta <meta
name="description" name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
@@ -49,7 +118,7 @@ export default function Page({user}: Props) {
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ExamPage page="exercises" user={user} /> <ExamPage page="exams" user={user} hideSidebar={!!assignment} />
</> </>
); );
} }

View File

@@ -16,30 +16,18 @@ import useExamEditorStore from "@/stores/examEditor";
import ExamEditorStore from "@/stores/examEditor/types"; import ExamEditorStore from "@/stores/examEditor/types";
import ExamEditor from "@/components/ExamEditor"; import ExamEditor from "@/components/ExamEditor";
import MultipleAudioUploader from "@/components/ExamEditor/Shared/AudioEdit"; import MultipleAudioUploader from "@/components/ExamEditor/Shared/AudioEdit";
import { redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api";
export const getServerSideProps = withIronSessionSsr(({ req, res }) => { 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) { if (shouldRedirectHome(user) || !checkAccess(user, ["admin", "mastercorporate", "developer", "corporate"]))
return { return redirect("/")
redirect: {
destination: "/login",
permanent: false,
},
};
}
if (shouldRedirectHome(user) || !checkAccess(user, ["admin", "mastercorporate", "developer", "corporate"])) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
return { return {
props: { user: req.session.user }, props: serialize({user}),
}; };
}, sessionOptions); }, sessionOptions);

View File

@@ -1,114 +0,0 @@
/* 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, Group, 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 {shouldRedirectHome} from "@/utils/navigation.disabled";
import useGroups from "@/hooks/useGroups";
import useUsers from "@/hooks/useUsers";
import {getUserName} from "@/utils/users";
import {getParticipantGroups, getUserGroups} from "@/utils/groups.be";
import {getUsers} from "@/utils/users.be";
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
const user = req.session.user;
if (!user) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
if (shouldRedirectHome(user)) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
const groups = await getParticipantGroups(user.id);
const users = await getUsers();
return {
props: {user, groups, users},
};
}, sessionOptions);
interface Props {
user: User;
groups: Group[];
users: User[];
}
export default function Home({user, groups, users}: Props) {
return (
<>
<Head>
<title>EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
{user && (
<Layout user={user}>
<div className="w-full h-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{groups
.filter((x) => x.participants.includes(user.id))
.map((group) => (
<div key={group.id} className="p-4 border rounded-xl flex flex-col gap-2">
<span>
<b>Group: </b>
{group.name}
</span>
<span>
<b>Admin: </b>
{getUserName(users.find((x) => x.id === group.admin))}
</span>
<b>Participants: </b>
<span>{group.participants.map((x) => getUserName(users.find((u) => u.id === x))).join(", ")}</span>
</div>
))}
</div>
</Layout>
)}
</>
);
}

Some files were not shown because too many files have changed in this diff Show More