Merge branch 'group-entity-permissions-revamping' into develop
This commit is contained in:
34
src/components/High/CardList.tsx
Normal file
34
src/components/High/CardList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
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";
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ export default function Layout({user, children, className, bgColor="bg-white", n
|
|||||||
|
|
||||||
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 />
|
||||||
<Navbar
|
<Navbar
|
||||||
path={router.pathname}
|
path={router.pathname}
|
||||||
user={user}
|
user={user}
|
||||||
|
|||||||
107
src/components/High/Table.tsx
Normal file
107
src/components/High/Table.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,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(() => {
|
||||||
@@ -28,6 +29,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
|
||||||
@@ -66,5 +69,6 @@ export default function Select({value, defaultValue, options, placeholder, disab
|
|||||||
isDisabled={disabled}
|
isDisabled={disabled}
|
||||||
isClearable={isClearable}
|
isClearable={isClearable}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
3
src/components/Low/Separator.tsx
Normal file
3
src/components/Low/Separator.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
const Separator = () => <div className="w-full h-[1px] bg-mti-gray-platinum rounded-full" />;
|
||||||
|
|
||||||
|
export default Separator;
|
||||||
17
src/components/Low/Tooltip.tsx
Normal file
17
src/components/Low/Tooltip.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
src/components/Medium/InviteWithUserCard.tsx
Normal file
67
src/components/Medium/InviteWithUserCard.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import {Invite, InviteWithUsers} from "@/interfaces/invite";
|
||||||
|
import {User} from "@/interfaces/user";
|
||||||
|
import {getUserName} from "@/utils/users";
|
||||||
|
import axios from "axios";
|
||||||
|
import {useMemo, useState} from "react";
|
||||||
|
import {BsArrowRepeat} from "react-icons/bs";
|
||||||
|
import {toast} from "react-toastify";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
invite: InviteWithUsers;
|
||||||
|
reload: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InviteWithUserCard({invite, reload}: Props) {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const name = useMemo(() => (!invite.from ? null : getUserName(invite.from)), [invite.from]);
|
||||||
|
|
||||||
|
const decide = (decision: "accept" | "decline") => {
|
||||||
|
if (!confirm(`Are you sure you want to ${decision} this invite?`)) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
axios
|
||||||
|
.get(`/api/invites/${decision}/${invite.id}`)
|
||||||
|
.then(() => {
|
||||||
|
toast.success(`Successfully ${decision === "accept" ? "accepted" : "declined"} the invite!`, {toastId: "success"});
|
||||||
|
reload();
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
toast.success(`Something went wrong, please try again later!`, {
|
||||||
|
toastId: "error",
|
||||||
|
});
|
||||||
|
reload();
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-mti-gray-anti-flash flex min-w-[200px] flex-col gap-6 rounded-xl border p-4 text-black">
|
||||||
|
<span>Invited by {name}</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {IconType} from "react-icons";
|
import { IconType } from "react-icons";
|
||||||
import {MdSpaceDashboard} from "react-icons/md";
|
import { MdSpaceDashboard } from "react-icons/md";
|
||||||
import {
|
import {
|
||||||
BsFileEarmarkText,
|
BsFileEarmarkText,
|
||||||
BsClockHistory,
|
BsClockHistory,
|
||||||
@@ -15,20 +15,20 @@ import {
|
|||||||
BsFileLock,
|
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 { SlPencil } from "react-icons/sl";
|
||||||
import {FaAward} from "react-icons/fa";
|
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 { 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";
|
||||||
interface Props {
|
interface Props {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -49,7 +49,7 @@ interface NavProps {
|
|||||||
badge?: number;
|
badge?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Nav = ({Icon, label, path, keyPath, disabled = false, isMinimized = false, badge}: NavProps) => {
|
const Nav = ({ Icon, label, path, keyPath, disabled = false, isMinimized = false, badge }: NavProps) => {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={!disabled ? keyPath : ""}
|
href={!disabled ? keyPath : ""}
|
||||||
@@ -57,7 +57,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,13 +76,13 @@ 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, navDisabled = false, focusMode = false, user, onFocusLayerMouseEnter, className }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]);
|
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]);
|
||||||
|
|
||||||
const {totalAssignedTickets} = useTicketsListener(user.id);
|
const { totalAssignedTickets } = useTicketsListener(user.id);
|
||||||
const {permissions} = usePermissions(user.id);
|
const { permissions } = usePermissions(user.id);
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
axios.post("/api/logout").finally(() => {
|
axios.post("/api/logout").finally(() => {
|
||||||
@@ -100,7 +100,7 @@ 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} />
|
||||||
)}
|
)}
|
||||||
@@ -110,8 +110,15 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
|||||||
{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} />
|
||||||
@@ -160,16 +167,6 @@ 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={true} />
|
||||||
@@ -187,9 +184,6 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
|||||||
{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={true} />
|
||||||
)}
|
)}
|
||||||
{checkAccess(user, getTypesOfUser(["student"])) && (
|
|
||||||
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Permissions" path={path} keyPath="/permissions" isMinimized={true} />
|
|
||||||
)}
|
|
||||||
{checkAccess(user, ["developer"]) && (
|
{checkAccess(user, ["developer"]) && (
|
||||||
<>
|
<>
|
||||||
<Nav
|
<Nav
|
||||||
@@ -227,7 +221,7 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
|
|||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={1}
|
tabIndex={1}
|
||||||
onClick={focusMode ? () => {} : logout}
|
onClick={focusMode ? () => { } : logout}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
|
"hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
|
||||||
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
|
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
|
||||||
|
|||||||
30
src/components/UserDisplayList.tsx
Normal file
30
src/components/UserDisplayList.tsx
Normal 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>)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,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);
|
||||||
@@ -65,26 +64,26 @@ export default function AssignmentCard({
|
|||||||
const uniqModules = uniqBy(exams, (x) => x.module);
|
const uniqModules = uniqBy(exams, (x) => x.module);
|
||||||
|
|
||||||
const shouldRenderPDF = () => {
|
const shouldRenderPDF = () => {
|
||||||
if(released && allowDownload) {
|
if (released && allowDownload) {
|
||||||
// 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
|
||||||
|
|||||||
@@ -2,38 +2,32 @@ import Button from "@/components/Low/Button";
|
|||||||
import ProgressBar from "@/components/Low/ProgressBar";
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import { Module } from "@/interfaces";
|
import {Module} from "@/interfaces";
|
||||||
import { Assignment } from "@/interfaces/results";
|
import {Assignment} from "@/interfaces/results";
|
||||||
import { Stat, User } from "@/interfaces/user";
|
import {Stat, User} from "@/interfaces/user";
|
||||||
import useExamStore from "@/stores/examStore";
|
import useExamStore from "@/stores/examStore";
|
||||||
import { getExamById } from "@/utils/exams";
|
import {getExamById} from "@/utils/exams";
|
||||||
import { sortByModule } from "@/utils/moduleUtils";
|
import {sortByModule} from "@/utils/moduleUtils";
|
||||||
import { calculateBandScore } from "@/utils/score";
|
import {calculateBandScore} from "@/utils/score";
|
||||||
import { convertToUserSolutions } from "@/utils/stats";
|
import {convertToUserSolutions} from "@/utils/stats";
|
||||||
import { getUserName } from "@/utils/users";
|
import {getUserName} from "@/utils/users";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
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,
|
import {toast} from "react-toastify";
|
||||||
BsClipboard,
|
import {futureAssignmentFilter} from "@/utils/assignments";
|
||||||
BsHeadphones,
|
|
||||||
BsMegaphone,
|
|
||||||
BsPen,
|
|
||||||
} from "react-icons/bs";
|
|
||||||
import { toast } from "react-toastify";
|
|
||||||
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,28 +72,17 @@ 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};
|
||||||
} = {
|
} = {
|
||||||
reading: {
|
reading: {
|
||||||
total: 0,
|
total: 0,
|
||||||
@@ -144,25 +121,13 @@ export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
|
|||||||
|
|
||||||
return Object.keys(scores)
|
return Object.keys(scores)
|
||||||
.filter((x) => scores[x as Module].total > 0)
|
.filter((x) => scores[x as Module].total > 0)
|
||||||
.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,7 +148,7 @@ 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("/exercises");
|
||||||
}
|
}
|
||||||
@@ -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,22 +171,16 @@ 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>
|
||||||
|
|
||||||
<div className="flex w-full flex-col gap-1">
|
<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">
|
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
|
||||||
{aggregatedLevels.map(({ module, level }) => (
|
{aggregatedLevels.map(({module, level}) => (
|
||||||
<div
|
<div
|
||||||
key={module}
|
key={module}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -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,17 +275,14 @@ 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">
|
||||||
<span className="text-xl font-bold">Average Scores</span>
|
<span className="text-xl font-bold">Average Scores</span>
|
||||||
<div className="-md:mt-2 flex w-full items-center gap-4">
|
<div className="-md:mt-2 flex w-full items-center gap-4">
|
||||||
{assignment &&
|
{assignment &&
|
||||||
uniqBy(assignment.exams, (x) => x.module).map(({ module }) => (
|
uniqBy(assignment.exams, (x) => x.module).map(({module}) => (
|
||||||
<div
|
<div
|
||||||
data-tip={capitalize(module)}
|
data-tip={capitalize(module)}
|
||||||
key={module}
|
key={module}
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
16
src/email/templates/resetPassword.handlebars
Normal file
16
src/email/templates/resetPassword.handlebars
Normal 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>
|
||||||
23
src/hooks/useEntities.tsx
Normal file
23
src/hooks/useEntities.tsx
Normal 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 };
|
||||||
|
}
|
||||||
23
src/hooks/useEntitiesGroups.tsx
Normal file
23
src/hooks/useEntitiesGroups.tsx
Normal 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 };
|
||||||
|
}
|
||||||
23
src/hooks/useEntitiesUsers.tsx
Normal file
23
src/hooks/useEntitiesUsers.tsx
Normal 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 };
|
||||||
|
}
|
||||||
16
src/hooks/useEntityPermissions.tsx
Normal file
16
src/hooks/useEntityPermissions.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
|
import { User } from "@/interfaces/user";
|
||||||
|
import { RolePermission } from "@/resources/entityPermissions";
|
||||||
|
import { mapBy } from "@/utils";
|
||||||
|
import { doesEntityAllow, findAllowedEntities } from "@/utils/permissions";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
export const useAllowedEntities = (user: User, entities: EntityWithRoles[], permission: RolePermission) => {
|
||||||
|
const allowedEntityIds = useMemo(() => findAllowedEntities(user, entities, permission), [user, entities, permission])
|
||||||
|
return allowedEntityIds
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useEntityPermission = (user: User, entity: EntityWithRoles, permission: RolePermission) => {
|
||||||
|
const isAllowed = useMemo(() => doesEntityAllow(user, entity, permission), [user, entity, permission])
|
||||||
|
return isAllowed
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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};
|
||||||
}
|
}
|
||||||
|
|||||||
29
src/interfaces/entity.ts
Normal file
29
src/interfaces/entity.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export interface Entity {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Role {
|
||||||
|
id: string;
|
||||||
|
entityID: string;
|
||||||
|
permissions: string[];
|
||||||
|
label: string;
|
||||||
|
isDefault?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntityWithRoles extends Entity {
|
||||||
|
roles: Role[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WithLabeledEntities<T> = 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;
|
||||||
@@ -8,5 +8,6 @@ export interface Step {
|
|||||||
|
|
||||||
export interface Grading {
|
export interface Grading {
|
||||||
user: string;
|
user: string;
|
||||||
|
entity?: string;
|
||||||
steps: Step[];
|
steps: Step[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
|
import {User} from "./user";
|
||||||
|
|
||||||
export interface Invite {
|
export interface Invite {
|
||||||
id: string;
|
id: string;
|
||||||
from: string;
|
from: string;
|
||||||
to: string;
|
to: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InviteWithUsers extends Omit<Invite, "from"> {
|
||||||
|
from?: User;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -3,49 +3,30 @@ import Input from "@/components/Low/Input";
|
|||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import useGroups from "@/hooks/useGroups";
|
import useGroups from "@/hooks/useGroups";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import {CorporateUser, Group, User} from "@/interfaces/user";
|
import { CorporateUser, Group, User } from "@/interfaces/user";
|
||||||
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {capitalize, uniq} from "lodash";
|
import { capitalize, uniq } from "lodash";
|
||||||
import {useEffect, useMemo, useState} from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {BsPencil, BsQuestionCircleFill, BsTrash} from "react-icons/bs";
|
import { BsPencil, BsQuestionCircleFill, BsTrash } from "react-icons/bs";
|
||||||
import Select from "react-select";
|
import Select from "react-select";
|
||||||
import {toast} from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import readXlsxFile from "read-excel-file";
|
import readXlsxFile from "read-excel-file";
|
||||||
import {useFilePicker} from "use-file-picker";
|
import { useFilePicker } from "use-file-picker";
|
||||||
import {getUserCorporate} from "@/utils/groups";
|
import { getUserCorporate } from "@/utils/groups";
|
||||||
import {isAgentUser, isCorporateUser, USER_TYPE_LABELS} from "@/resources/user";
|
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[];
|
||||||
@@ -53,22 +34,22 @@ interface CreateDialogProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
|
const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => {
|
||||||
const [name, setName] = useState<string | undefined>(group?.name || undefined);
|
const [name, setName] = useState<string | undefined>(group?.name || undefined);
|
||||||
const [admin, setAdmin] = useState<string>(group?.admin || user.id);
|
const [admin, setAdmin] = useState<string>(group?.admin || user.id);
|
||||||
const [participants, setParticipants] = useState<string[]>(group?.participants || []);
|
const [participants, setParticipants] = useState<string[]>(group?.participants || []);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const {openFilePicker, filesContent, clear} = useFilePicker({
|
const { openFilePicker, filesContent, clear } = useFilePicker({
|
||||||
accept: ".xlsx",
|
accept: ".xlsx",
|
||||||
multiple: false,
|
multiple: false,
|
||||||
readAs: "ArrayBuffer",
|
readAs: "ArrayBuffer",
|
||||||
});
|
});
|
||||||
|
|
||||||
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]);
|
||||||
@@ -108,7 +89,7 @@ const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
|
|||||||
user.type !== "teacher"
|
user.type !== "teacher"
|
||||||
? "Added all teachers and students found in the file you've provided!"
|
? "Added all teachers and students found in the file you've provided!"
|
||||||
: "Added all students found in the file you've provided!",
|
: "Added all students found in the file you've provided!",
|
||||||
{toastId: "upload-success"},
|
{ toastId: "upload-success" },
|
||||||
);
|
);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
});
|
});
|
||||||
@@ -125,7 +106,7 @@ const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
(group ? axios.patch : axios.post)(group ? `/api/groups/${group.id}` : "/api/groups", {name, admin, participants})
|
(group ? axios.patch : axios.post)(group ? `/api/groups/${group.id}` : "/api/groups", { name, admin, participants })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(`Group "${name}" ${group ? "edited" : "created"} successfully`);
|
toast.success(`Group "${name}" ${group ? "edited" : "created"} successfully`);
|
||||||
return true;
|
return true;
|
||||||
@@ -163,13 +144,13 @@ const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
|
|||||||
value: x,
|
value: x,
|
||||||
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
|
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
|
||||||
}))}
|
}))}
|
||||||
options={availableUsers.map((x) => ({value: x.id, label: `${x.email} - ${x.name}`}))}
|
options={availableUsers.map((x) => ({ value: x.id, label: `${x.email} - ${x.name}` }))}
|
||||||
onChange={(value) => setParticipants(value.map((x) => x.value))}
|
onChange={(value) => setParticipants(value.map((x) => x.value))}
|
||||||
isMulti
|
isMulti
|
||||||
isSearchable
|
isSearchable
|
||||||
menuPortalTarget={document?.body}
|
menuPortalTarget={document?.body}
|
||||||
styles={{
|
styles={{
|
||||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||||
control: (styles) => ({
|
control: (styles) => ({
|
||||||
...styles,
|
...styles,
|
||||||
backgroundColor: "white",
|
backgroundColor: "white",
|
||||||
@@ -198,34 +179,21 @@ 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>();
|
||||||
const [viewingAllParticipants, setViewingAllParticipants] = useState<string>();
|
const [viewingAllParticipants, setViewingAllParticipants] = useState<string>();
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.delete<{ok: boolean}>(`/api/groups/${group.id}`)
|
.delete<{ ok: boolean }>(`/api/groups/${group.id}`)
|
||||||
.then(() => toast.success(`Group "${group.name}" deleted successfully`))
|
.then(() => toast.success(`Group "${group.name}" deleted successfully`))
|
||||||
.catch(() => toast.error("Something went wrong, please try again later!"))
|
.catch(() => toast.error("Something went wrong, please try again later!"))
|
||||||
.finally(reload);
|
.finally(reload);
|
||||||
@@ -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",
|
||||||
@@ -281,7 +249,7 @@ export default function GroupList({user}: {user: User}) {
|
|||||||
{
|
{
|
||||||
header: "",
|
header: "",
|
||||||
id: "actions",
|
id: "actions",
|
||||||
cell: ({row}: {row: {original: Group}}) => {
|
cell: ({ row }: { row: { original: Group } }) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{user && (checkAccess(user, ["developer", "admin"]) || user.id === row.original.admin) && (
|
{user && (checkAccess(user, ["developer", "admin"]) || user.id === row.original.admin) && (
|
||||||
@@ -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
|
||||||
|
|||||||
111
src/pages/(admin)/Lists/StudentPerformanceList.tsx
Normal file
111
src/pages/(admin)/Lists/StudentPerformanceList.tsx
Normal 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;
|
||||||
@@ -1,52 +1,32 @@
|
|||||||
import Button from "@/components/Low/Button";
|
import { PERMISSIONS } from "@/constants/userPermissions";
|
||||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
import { Type, User } from "@/interfaces/user";
|
||||||
import useGroups from "@/hooks/useGroups";
|
import { createColumnHelper } from "@tanstack/react-table";
|
||||||
import useUsers from "@/hooks/useUsers";
|
|
||||||
import {Type, User, userTypes, CorporateUser, Group} from "@/interfaces/user";
|
|
||||||
import {Popover, Transition} from "@headlessui/react";
|
|
||||||
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
|
|
||||||
import 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,26 @@ 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 { balance } = useUserBalance();
|
||||||
const {users: corporates} = useUsers(corporatesHash);
|
|
||||||
|
|
||||||
const totalUsers = useMemo(() => [...users, ...corporates], [users, corporates]);
|
const isAdmin = useMemo(() => ["admin", "developer"].includes(user?.type), [user?.type])
|
||||||
|
|
||||||
const {permissions} = usePermissions(user?.id || "");
|
const entitiesEditStudents = useAllowedEntities(user, entities, "edit_students")
|
||||||
const {balance} = useUserBalance();
|
const entitiesDeleteStudents = useAllowedEntities(user, entities, "delete_students")
|
||||||
const {groups} = useGroups({
|
|
||||||
admin: user && ["corporate", "teacher", "mastercorporate"].includes(user?.type) ? user.id : undefined,
|
const entitiesEditTeachers = useAllowedEntities(user, entities, "edit_teachers")
|
||||||
userType: user?.type,
|
const entitiesDeleteTeachers = useAllowedEntities(user, entities, "delete_teachers")
|
||||||
});
|
|
||||||
|
const entitiesEditCorporates = useAllowedEntities(user, entities, "edit_corporates")
|
||||||
|
const entitiesDeleteCorporates = useAllowedEntities(user, entities, "delete_corporates")
|
||||||
|
|
||||||
|
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,36 +74,26 @@ 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 displayUsers = useMemo(() => filters.length > 0 ? filters.reduce((d, f) => d.filter(f), users) : users, [filters, users])
|
||||||
(async () => {
|
|
||||||
if (users && users.length > 0) {
|
|
||||||
const filteredUsers = filters.reduce((d, f) => d.filter(f), users);
|
|
||||||
// const sortedUsers = await asyncSorter<User>(filteredUsers, sortFunction);
|
|
||||||
|
|
||||||
setDisplayUsers([...filteredUsers]);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [users, sorter]);
|
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.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" });
|
||||||
})
|
})
|
||||||
.finally(reload);
|
.finally(reload);
|
||||||
};
|
};
|
||||||
|
|
||||||
const verifyAccount = (user: User) => {
|
const verifyAccount = (user: User) => {
|
||||||
axios
|
axios
|
||||||
.post<{user?: User; ok?: boolean}>(`/api/users/update?id=${user.id}`, {
|
.post<{ user?: User; ok?: boolean }>(`/api/users/update?id=${user.id}`, {
|
||||||
...user,
|
...user,
|
||||||
isVerified: true,
|
isVerified: true,
|
||||||
})
|
})
|
||||||
@@ -134,22 +102,21 @@ export default function UserList({
|
|||||||
reload();
|
reload();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Something went wrong!", {toastId: "update-error"});
|
toast.error("Something went wrong!", { toastId: "update-error" });
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
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.`,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.post<{user?: User; ok?: boolean}>(`/api/users/update?id=${user.id}`, {
|
.post<{ user?: User; ok?: boolean }>(`/api/users/update?id=${user.id}`, {
|
||||||
...user,
|
...user,
|
||||||
status: user.status === "disabled" ? "active" : "disabled",
|
status: user.status === "disabled" ? "active" : "disabled",
|
||||||
})
|
})
|
||||||
@@ -158,34 +125,45 @@ export default function UserList({
|
|||||||
reload();
|
reload();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Something went wrong!", {toastId: "update-error"});
|
toast.error("Something went wrong!", { toastId: "update-error" });
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
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 canEdit = canEditUser(row.original)
|
||||||
|
const canDelete = canDeleteUser(row.original)
|
||||||
|
|
||||||
const actionColumn = ({row}: {row: {original: User}}) => {
|
|
||||||
const updateUserPermission = PERMISSIONS.updateUser[row.original.type] as {
|
|
||||||
list: Type[];
|
|
||||||
perm: PermissionType;
|
|
||||||
};
|
|
||||||
const deleteUserPermission = PERMISSIONS.deleteUser[row.original.type] as {
|
|
||||||
list: Type[];
|
|
||||||
perm: PermissionType;
|
|
||||||
};
|
|
||||||
return (
|
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 +175,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 +186,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"))}>
|
cell: ({ row, getValue }) => (
|
||||||
<span>Name</span>
|
|
||||||
<SorterArrow name="name" />
|
|
||||||
</button>
|
|
||||||
) as any,
|
|
||||||
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 +218,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 +240,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"))}>
|
cell: ({ row, getValue }) => (
|
||||||
<span>Name</span>
|
|
||||||
<SorterArrow name="name" />
|
|
||||||
</button>
|
|
||||||
) as any,
|
|
||||||
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"))}>
|
cell: ({ row, getValue }) => (
|
||||||
<span>E-mail</span>
|
|
||||||
<SorterArrow name="email" />
|
|
||||||
</button>
|
|
||||||
) as any,
|
|
||||||
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 +294,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,131 +316,15 @@ 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" });
|
||||||
element.href = URL.createObjectURL(file);
|
element.href = URL.createObjectURL(file);
|
||||||
element.download = "users.csv";
|
element.download = "users.csv";
|
||||||
document.body.appendChild(element);
|
document.body.appendChild(element);
|
||||||
@@ -537,16 +334,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 +361,7 @@ export default function UserList({
|
|||||||
filter: belongsToAdminFilter,
|
filter: belongsToAdminFilter,
|
||||||
});
|
});
|
||||||
|
|
||||||
router.push("/list/users");
|
router.push("/users");
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
@@ -586,7 +377,7 @@ export default function UserList({
|
|||||||
filter: belongsToAdminFilter,
|
filter: belongsToAdminFilter,
|
||||||
});
|
});
|
||||||
|
|
||||||
router.push("/list/users");
|
router.push("/users");
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
@@ -599,14 +390,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 +409,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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,28 +1,30 @@
|
|||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
import Checkbox from "@/components/Low/Checkbox";
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
import {PERMISSIONS} from "@/constants/userPermissions";
|
import { PERMISSIONS } from "@/constants/userPermissions";
|
||||||
import {CorporateUser, TeacherUser, Type, User} from "@/interfaces/user";
|
import { CorporateUser, TeacherUser, Type, User } from "@/interfaces/user";
|
||||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {capitalize, uniqBy} from "lodash";
|
import { capitalize, uniqBy } from "lodash";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {useEffect, useState} from "react";
|
import { useEffect, useState } from "react";
|
||||||
import ReactDatePicker from "react-datepicker";
|
import ReactDatePicker from "react-datepicker";
|
||||||
import {toast} from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import ShortUniqueId from "short-unique-id";
|
import ShortUniqueId from "short-unique-id";
|
||||||
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
|
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
||||||
import {PermissionType} from "@/interfaces/permissions";
|
import { PermissionType } from "@/interfaces/permissions";
|
||||||
import usePermissions from "@/hooks/usePermissions";
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import CountrySelect from "@/components/Low/CountrySelect";
|
import CountrySelect from "@/components/Low/CountrySelect";
|
||||||
import useGroups from "@/hooks/useGroups";
|
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[] };
|
||||||
} = {
|
} = {
|
||||||
student: {
|
student: {
|
||||||
perm: "createCodeStudent",
|
perm: "createCodeStudent",
|
||||||
@@ -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
|
|
||||||
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>
|
<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(
|
||||||
@@ -235,7 +220,7 @@ export default function UserCreator({user, users, permissions, onFinish}: Props)
|
|||||||
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
|
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
|
||||||
{Object.keys(USER_TYPE_LABELS)
|
{Object.keys(USER_TYPE_LABELS)
|
||||||
.filter((x) => {
|
.filter((x) => {
|
||||||
const {list, perm} = USER_TYPE_PERMISSIONS[x as Type];
|
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
|
||||||
return checkAccess(user, getTypesOfUser(list), permissions, perm);
|
return checkAccess(user, getTypesOfUser(list), permissions, perm);
|
||||||
})
|
})
|
||||||
.map((type) => (
|
.map((type) => (
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -25,7 +24,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
async function GET(req: NextApiRequest, res: NextApiResponse) {
|
async function GET(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const {id} = req.query;
|
const {id} = req.query;
|
||||||
|
|
||||||
const snapshot = await db.collection("assignments").findOne({ id: id as string });
|
const snapshot = await db.collection("assignments").findOne({id: id as string});
|
||||||
|
|
||||||
if (snapshot) {
|
if (snapshot) {
|
||||||
res.status(200).json({...snapshot, id: snapshot.id});
|
res.status(200).json({...snapshot, id: snapshot.id});
|
||||||
@@ -35,9 +34,7 @@ async function GET(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
async function DELETE(req: NextApiRequest, res: NextApiResponse) {
|
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: {assigner: req.session.user?.id, ...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});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,11 +140,10 @@ 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;
|
||||||
|
|
||||||
const name = body.name;
|
const name = body.name;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import { FirebaseScrypt } from 'firebase-scrypt';
|
import { FirebaseScrypt } from 'firebase-scrypt';
|
||||||
import { firebaseAuthScryptParams } from "@/firebase";
|
import { firebaseAuthScryptParams } from "@/firebase";
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
@@ -11,13 +11,13 @@ export default withIronSessionApiRoute(handler, sessionOptions);
|
|||||||
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);
|
||||||
|
|
||||||
return res.status(404).json({ok: false});
|
return res.status(404).json({ ok: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const maker = req.session.user;
|
const maker = req.session.user;
|
||||||
if (!maker) {
|
if (!maker) {
|
||||||
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 scrypt = new FirebaseScrypt(firebaseAuthScryptParams)
|
const scrypt = new FirebaseScrypt(firebaseAuthScryptParams)
|
||||||
@@ -36,6 +36,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 +47,9 @@ 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);
|
||||||
|
|
||||||
|
currentUser.entities = [{ id: currentUser.entity!, role: "90ce8f08-08c8-41e4-9848-f1500ddc3930" }]
|
||||||
|
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;
|
||||||
|
|||||||
59
src/pages/api/entities/[id]/index.ts
Normal file
59
src/pages/api/entities/[id]/index.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
|
import {sessionOptions} from "@/lib/session";
|
||||||
|
import {deleteEntity, getEntity, getEntityWithRoles} from "@/utils/entities.be";
|
||||||
|
import client from "@/lib/mongodb";
|
||||||
|
import {Entity} from "@/interfaces/entity";
|
||||||
|
import { doesEntityAllow } from "@/utils/permissions";
|
||||||
|
import { getUser } from "@/utils/users.be";
|
||||||
|
import { requestUser } from "@/utils/api";
|
||||||
|
|
||||||
|
const db = client.db(process.env.MONGODB_DB);
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method === "get") return await get(req, res);
|
||||||
|
if (req.method === "PATCH") return await patch(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const user = await requestUser(req, res)
|
||||||
|
if (!user) return res.status(401).json({ ok: false });
|
||||||
|
|
||||||
|
const {id, showRoles} = req.query as {id: string; showRoles: string};
|
||||||
|
|
||||||
|
const entity = await (!!showRoles ? getEntityWithRoles : getEntity)(id);
|
||||||
|
res.status(200).json(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function del(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const user = await requestUser(req, res)
|
||||||
|
if (!user) return res.status(401).json({ ok: false });
|
||||||
|
|
||||||
|
const { id } = req.query as { id: string };
|
||||||
|
|
||||||
|
const entity = await getEntityWithRoles(id)
|
||||||
|
if (!entity) return res.status(404).json({ok: false})
|
||||||
|
|
||||||
|
if (!doesEntityAllow(user, entity, "delete_entity_role")) return res.status(403).json({ok: false})
|
||||||
|
|
||||||
|
await deleteEntity(entity)
|
||||||
|
return res.status(200).json({ok: true});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patch(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const user = await requestUser(req, res)
|
||||||
|
if (!user) return res.status(401).json({ ok: false });
|
||||||
|
|
||||||
|
const {id} = req.query as {id: string};
|
||||||
|
|
||||||
|
if (!user.entities.map((x) => x.id).includes(id)) {
|
||||||
|
return res.status(403).json({ok: false});
|
||||||
|
}
|
||||||
|
|
||||||
|
const entity = await db.collection<Entity>("entities").updateOne({id}, {$set: {label: req.body.label}});
|
||||||
|
|
||||||
|
return res.status(200).json({ok: entity.acknowledged});
|
||||||
|
}
|
||||||
65
src/pages/api/entities/[id]/users.ts
Normal file
65
src/pages/api/entities/[id]/users.ts
Normal 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();
|
||||||
|
}
|
||||||
29
src/pages/api/entities/groups.ts
Normal file
29
src/pages/api/entities/groups.ts
Normal 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);
|
||||||
|
}
|
||||||
44
src/pages/api/entities/index.ts
Normal file
44
src/pages/api/entities/index.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
|
import type {NextApiRequest, NextApiResponse} from "next";
|
||||||
|
import {withIronSessionApiRoute} from "iron-session/next";
|
||||||
|
import {sessionOptions} from "@/lib/session";
|
||||||
|
import {createEntity, getEntities, getEntitiesWithRoles} from "@/utils/entities.be";
|
||||||
|
import {Entity} from "@/interfaces/entity";
|
||||||
|
import {v4} from "uuid";
|
||||||
|
import { requestUser } from "@/utils/api";
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method === "GET") return await get(req, res);
|
||||||
|
if (req.method === "POST") return await post(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const user = await requestUser(req, res)
|
||||||
|
if (!user) return res.status(401).json({ ok: false });
|
||||||
|
|
||||||
|
const {showRoles} = req.query as {showRoles: string};
|
||||||
|
|
||||||
|
const getFn = showRoles ? getEntitiesWithRoles : getEntities;
|
||||||
|
|
||||||
|
if (["admin", "developer"].includes(user.type)) return res.status(200).json(await getFn());
|
||||||
|
res.status(200).json(await getFn(user.entities.map((x) => x.id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const user = await requestUser(req, res)
|
||||||
|
if (!user) return res.status(401).json({ ok: false });
|
||||||
|
|
||||||
|
if (!["admin", "developer"].includes(user.type)) {
|
||||||
|
return res.status(403).json({ok: false});
|
||||||
|
}
|
||||||
|
|
||||||
|
const entity: Entity = {
|
||||||
|
id: v4(),
|
||||||
|
label: req.body.label,
|
||||||
|
};
|
||||||
|
|
||||||
|
await createEntity(entity)
|
||||||
|
return res.status(200).json(entity);
|
||||||
|
}
|
||||||
63
src/pages/api/entities/users.ts
Normal file
63
src/pages/api/entities/users.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
// 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>
|
|
||||||
) {
|
res.status(200).json({name: "John Doe"});
|
||||||
res.status(200).json({ name: 'John Doe' })
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import type {NextApiRequest, NextApiResponse} from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import {app} from "@/firebase";
|
import { app } from "@/firebase";
|
||||||
import {withIronSessionApiRoute} from "iron-session/next";
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import {v4} from "uuid";
|
import { v4 } from "uuid";
|
||||||
import {CorporateUser, Group, Type, User} from "@/interfaces/user";
|
import { CorporateUser, Group, Type, User } from "@/interfaces/user";
|
||||||
import {createUserWithEmailAndPassword, getAuth} from "firebase/auth";
|
import { createUserWithEmailAndPassword, getAuth } from "firebase/auth";
|
||||||
import ShortUniqueId from "short-unique-id";
|
import ShortUniqueId from "short-unique-id";
|
||||||
import {getGroup, getGroups, getUserCorporate, getUserGroups, getUserNamedGroup} from "@/utils/groups.be";
|
import { getGroup, getGroups, getUserCorporate, getUserGroups, getUserNamedGroup } from "@/utils/groups.be";
|
||||||
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";
|
||||||
|
|
||||||
const DEFAULT_DESIRED_LEVELS = {
|
const DEFAULT_DESIRED_LEVELS = {
|
||||||
@@ -30,50 +30,41 @@ 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);
|
||||||
|
|
||||||
return res.status(404).json({ok: false});
|
return res.status(404).json({ ok: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const maker = req.session.user;
|
const maker = req.session.user;
|
||||||
if (!maker) {
|
if (!maker) {
|
||||||
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 user = {
|
const user = {
|
||||||
...req.body,
|
...req.body,
|
||||||
bio: "",
|
bio: "",
|
||||||
@@ -82,11 +73,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: "90ce8f08-08c8-41e4-9848-f1500ddc3930" }],
|
||||||
subscriptionExpirationDate: expiryDate || null,
|
subscriptionExpirationDate: expiryDate || null,
|
||||||
...((maker.type === "corporate" || maker.type === "mastercorporate") && type === "corporate"
|
...((maker.type === "corporate" || maker.type === "mastercorporate") && type === "corporate"
|
||||||
? {
|
? {
|
||||||
@@ -113,115 +105,22 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
userId,
|
userId,
|
||||||
email: email.toLowerCase(),
|
email: email.toLowerCase(),
|
||||||
name: req.body.name,
|
name: req.body.name,
|
||||||
...(!!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] } });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Returning - ${email}`);
|
console.log(`Returning - ${email}`);
|
||||||
return res.status(200).json({ok: true});
|
return res.status(200).json({ ok: true });
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (error.code.includes("email-already-in-use")) return res.status(403).json({error, message: "E-mail is already in the platform."});
|
if (error.code.includes("email-already-in-use")) return res.status(403).json({ error, message: "E-mail is already in the platform." });
|
||||||
|
|
||||||
console.log(`Failing - ${email}`);
|
console.log(`Failing - ${email}`);
|
||||||
console.log(error);
|
console.log(error);
|
||||||
return res.status(401).json({error});
|
return res.status(401).json({ error });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
79
src/pages/api/roles/[id]/index.ts
Normal file
79
src/pages/api/roles/[id]/index.ts
Normal 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});
|
||||||
|
}
|
||||||
40
src/pages/api/roles/[id]/users.ts
Normal file
40
src/pages/api/roles/[id]/users.ts
Normal 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});
|
||||||
|
}
|
||||||
50
src/pages/api/roles/index.ts
Normal file
50
src/pages/api/roles/index.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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()}});
|
||||||
|
|
||||||
|
|||||||
410
src/pages/assignments/[id].tsx
Normal file
410
src/pages/assignments/[id].tsx
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
import Button from "@/components/Low/Button";
|
||||||
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import useUsers from "@/hooks/useUsers";
|
||||||
|
import {Module} from "@/interfaces";
|
||||||
|
import {Assignment} from "@/interfaces/results";
|
||||||
|
import {Group, Stat, User} from "@/interfaces/user";
|
||||||
|
import useExamStore from "@/stores/examStore";
|
||||||
|
import {getExamById} from "@/utils/exams";
|
||||||
|
import {sortByModule} from "@/utils/moduleUtils";
|
||||||
|
import {calculateBandScore} from "@/utils/score";
|
||||||
|
import {convertToUserSolutions} from "@/utils/stats";
|
||||||
|
import {getUserName} from "@/utils/users";
|
||||||
|
import axios from "axios";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {capitalize, uniqBy} from "lodash";
|
||||||
|
import moment from "moment";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
import {BsBook, BsChevronLeft, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
|
||||||
|
import {toast} from "react-toastify";
|
||||||
|
import {futureAssignmentFilter} from "@/utils/assignments";
|
||||||
|
import {withIronSessionSsr} from "iron-session/next";
|
||||||
|
import {checkAccess, doesEntityAllow} from "@/utils/permissions";
|
||||||
|
import {mapBy, redirect, serialize} from "@/utils";
|
||||||
|
import {getAssignment} from "@/utils/assignments.be";
|
||||||
|
import {getEntitiesUsers, getEntityUsers, getUsers} from "@/utils/users.be";
|
||||||
|
import {getEntitiesWithRoles, getEntityWithRoles} from "@/utils/entities.be";
|
||||||
|
import {getGroups, getGroupsByEntities, getGroupsByEntity} from "@/utils/groups.be";
|
||||||
|
import {sessionOptions} from "@/lib/session";
|
||||||
|
import {EntityWithRoles} from "@/interfaces/entity";
|
||||||
|
import Head from "next/head";
|
||||||
|
import Layout from "@/components/High/Layout";
|
||||||
|
import Separator from "@/components/Low/Separator";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { requestUser } from "@/utils/api";
|
||||||
|
import { useEntityPermission } from "@/hooks/useEntityPermissions";
|
||||||
|
|
||||||
|
export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) => {
|
||||||
|
const user = await requestUser(req, res)
|
||||||
|
if (!user) return redirect("/login")
|
||||||
|
|
||||||
|
if (!checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"]))
|
||||||
|
return redirect("/assignments")
|
||||||
|
|
||||||
|
res.setHeader("Cache-Control", "public, s-maxage=10, stale-while-revalidate=59");
|
||||||
|
|
||||||
|
const {id} = params as {id: string};
|
||||||
|
|
||||||
|
const assignment = await getAssignment(id);
|
||||||
|
if (!assignment) return redirect("/assignments")
|
||||||
|
|
||||||
|
const entity = await getEntityWithRoles(assignment.entity || "")
|
||||||
|
if (!entity) return redirect("/assignments")
|
||||||
|
|
||||||
|
if (!doesEntityAllow(user, entity, 'view_assignments')) return redirect("/assignments")
|
||||||
|
|
||||||
|
const users = await (checkAccess(user, ["developer", "admin"]) ? getUsers() : getEntityUsers(entity.id));
|
||||||
|
|
||||||
|
return {props: serialize({user, users, entity, assignment})};
|
||||||
|
}, sessionOptions);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: User;
|
||||||
|
users: User[];
|
||||||
|
assignment: Assignment;
|
||||||
|
entity: EntityWithRoles
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AssignmentView({user, users, entity, assignment}: Props) {
|
||||||
|
const canDeleteAssignment = useEntityPermission(user, entity, 'delete_assignment')
|
||||||
|
const canStartAssignment = useEntityPermission(user, entity, 'start_assignment')
|
||||||
|
|
||||||
|
const setExams = useExamStore((state) => state.setExams);
|
||||||
|
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
|
||||||
|
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
|
||||||
|
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const deleteAssignment = async () => {
|
||||||
|
if (!canDeleteAssignment) return
|
||||||
|
if (!confirm("Are you sure you want to delete this assignment?")) return;
|
||||||
|
|
||||||
|
axios
|
||||||
|
.delete(`/api/assignments/${assignment?.id}`)
|
||||||
|
.then(() => toast.success(`Successfully deleted the assignment "${assignment?.name}".`))
|
||||||
|
.catch(() => toast.error("Something went wrong, please try again later."))
|
||||||
|
.finally(() => router.push("/assignments"));
|
||||||
|
};
|
||||||
|
|
||||||
|
const startAssignment = () => {
|
||||||
|
if (!canStartAssignment) return
|
||||||
|
if (!confirm("Are you sure you want to start this assignment?")) return;
|
||||||
|
|
||||||
|
axios
|
||||||
|
.post(`/api/assignments/${assignment.id}/start`)
|
||||||
|
.then(() => {
|
||||||
|
toast.success(`The assignment "${assignment.name}" has been started successfully!`);
|
||||||
|
router.replace(router.asPath);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.log(e);
|
||||||
|
toast.error("Something went wrong, please try again later!");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTimestamp = (timestamp: string) => {
|
||||||
|
const date = moment(parseInt(timestamp));
|
||||||
|
const formatter = "YYYY/MM/DD - HH:mm";
|
||||||
|
|
||||||
|
return date.format(formatter);
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateAverageModuleScore = (module: Module) => {
|
||||||
|
if (!assignment) return -1;
|
||||||
|
|
||||||
|
const resultModuleBandScores = assignment.results.map((r) => {
|
||||||
|
const moduleStats = r.stats.filter((s) => s.module === module);
|
||||||
|
|
||||||
|
const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0);
|
||||||
|
const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0);
|
||||||
|
return calculateBandScore(correct, total, module, r.type);
|
||||||
|
});
|
||||||
|
|
||||||
|
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / assignment.results.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => {
|
||||||
|
const scores: {
|
||||||
|
[key in Module]: {total: number; missing: number; correct: number};
|
||||||
|
} = {
|
||||||
|
reading: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
listening: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
writing: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
speaking: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
level: {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
missing: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
stats.forEach((x) => {
|
||||||
|
scores[x.module!] = {
|
||||||
|
total: scores[x.module!].total + x.score.total,
|
||||||
|
correct: scores[x.module!].correct + x.score.correct,
|
||||||
|
missing: scores[x.module!].missing + x.score.missing,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.keys(scores)
|
||||||
|
.filter((x) => scores[x as Module].total > 0)
|
||||||
|
.map((x) => ({module: x as Module, ...scores[x as Module]}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const customContent = (stats: Stat[], user: string, focus: "academic" | "general") => {
|
||||||
|
const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
|
||||||
|
const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0);
|
||||||
|
const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0);
|
||||||
|
|
||||||
|
const aggregatedLevels = aggregatedScores.map((x) => ({
|
||||||
|
module: x.module,
|
||||||
|
level: calculateBandScore(x.correct, x.total, x.module, focus),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const timeSpent = stats[0].timeSpent;
|
||||||
|
|
||||||
|
const selectExam = () => {
|
||||||
|
const examPromises = uniqBy(stats, "exam").map((stat) => getExamById(stat.module, stat.exam));
|
||||||
|
|
||||||
|
Promise.all(examPromises).then((exams) => {
|
||||||
|
if (exams.every((x) => !!x)) {
|
||||||
|
setUserSolutions(convertToUserSolutions(stats));
|
||||||
|
setShowSolutions(true);
|
||||||
|
setExams(exams.map((x) => x!).sort(sortByModule));
|
||||||
|
setSelectedModules(
|
||||||
|
exams
|
||||||
|
.map((x) => x!)
|
||||||
|
.sort(sortByModule)
|
||||||
|
.map((x) => x!.module),
|
||||||
|
);
|
||||||
|
router.push("/exercises");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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="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>
|
||||||
|
<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>
|
||||||
|
<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">
|
||||||
|
{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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
606
src/pages/assignments/creator/[id].tsx
Normal file
606
src/pages/assignments/creator/[id].tsx
Normal file
@@ -0,0 +1,606 @@
|
|||||||
|
import Layout from "@/components/High/Layout";
|
||||||
|
import Button from "@/components/Low/Button";
|
||||||
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
|
import Input from "@/components/Low/Input";
|
||||||
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
|
import Select from "@/components/Low/Select";
|
||||||
|
import Separator from "@/components/Low/Separator";
|
||||||
|
import useExams from "@/hooks/useExams";
|
||||||
|
import {useListSearch} from "@/hooks/useListSearch";
|
||||||
|
import usePagination from "@/hooks/usePagination";
|
||||||
|
import {Module} from "@/interfaces";
|
||||||
|
import {EntityWithRoles} from "@/interfaces/entity";
|
||||||
|
import {InstructorGender, Variant} from "@/interfaces/exam";
|
||||||
|
import {Assignment} from "@/interfaces/results";
|
||||||
|
import {Group, User} from "@/interfaces/user";
|
||||||
|
import {sessionOptions} from "@/lib/session";
|
||||||
|
import {mapBy, redirect, serialize} from "@/utils";
|
||||||
|
import { requestUser } from "@/utils/api";
|
||||||
|
import {getAssignment} from "@/utils/assignments.be";
|
||||||
|
import {getEntitiesWithRoles} from "@/utils/entities.be";
|
||||||
|
import {getGroups, getGroupsByEntities} from "@/utils/groups.be";
|
||||||
|
import {checkAccess, doesEntityAllow, findAllowedEntities} from "@/utils/permissions";
|
||||||
|
import {calculateAverageLevel} from "@/utils/score";
|
||||||
|
import {getEntitiesUsers, getUsers} from "@/utils/users.be";
|
||||||
|
import axios from "axios";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {withIronSessionSsr} from "iron-session/next";
|
||||||
|
import {capitalize} from "lodash";
|
||||||
|
import moment from "moment";
|
||||||
|
import Head from "next/head";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
import {generate} from "random-words";
|
||||||
|
import {useEffect, useMemo, useState} from "react";
|
||||||
|
import ReactDatePicker from "react-datepicker";
|
||||||
|
import {BsBook, BsCheckCircle, BsChevronLeft, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
|
||||||
|
import {toast} from "react-toastify";
|
||||||
|
|
||||||
|
export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) => {
|
||||||
|
const user = await requestUser(req, res)
|
||||||
|
if (!user) return redirect("/login")
|
||||||
|
|
||||||
|
res.setHeader("Cache-Control", "public, s-maxage=10, stale-while-revalidate=59");
|
||||||
|
|
||||||
|
const {id} = params as {id: string};
|
||||||
|
const entityIDS = mapBy(user.entities, "id") || [];
|
||||||
|
|
||||||
|
const assignment = await getAssignment(id);
|
||||||
|
if (!assignment) return redirect("/assignments")
|
||||||
|
|
||||||
|
const entities = await (checkAccess(user, ["developer", "admin"]) ? getEntitiesWithRoles() : getEntitiesWithRoles(entityIDS));
|
||||||
|
const entity = entities.find((e) => assignment.entity === assignment.entity)
|
||||||
|
|
||||||
|
if (!entity) return redirect("/assignments")
|
||||||
|
if (!doesEntityAllow(user, entity, 'edit_assignment')) return redirect("/assignments")
|
||||||
|
|
||||||
|
const allowedEntities = findAllowedEntities(user, entities, 'edit_assignment')
|
||||||
|
|
||||||
|
const users = await (checkAccess(user, ["developer", "admin"]) ? getUsers() : getEntitiesUsers(mapBy(allowedEntities, 'id')));
|
||||||
|
const groups = await (checkAccess(user, ["developer", "admin"]) ? getGroups() : getGroupsByEntities(mapBy(allowedEntities, 'id')));
|
||||||
|
|
||||||
|
return {props: serialize({user, users, entities: allowedEntities, assignment, groups})};
|
||||||
|
}, sessionOptions);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
assignment: Assignment;
|
||||||
|
groups: Group[];
|
||||||
|
user: User;
|
||||||
|
users: User[];
|
||||||
|
entities: EntityWithRoles[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIZE = 9;
|
||||||
|
|
||||||
|
export default function AssignmentsPage({assignment, user, users, entities, groups}: Props) {
|
||||||
|
const [selectedModules, setSelectedModules] = useState<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>(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));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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'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
|
||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
552
src/pages/assignments/creator/index.tsx
Normal file
552
src/pages/assignments/creator/index.tsx
Normal file
@@ -0,0 +1,552 @@
|
|||||||
|
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 {getEntitiesWithRoles} from "@/utils/entities.be";
|
||||||
|
import {getGroups, getGroupsByEntities} from "@/utils/groups.be";
|
||||||
|
import {checkAccess, 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}) => {
|
||||||
|
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 (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, groups})};
|
||||||
|
}, sessionOptions);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
assignment: Assignment;
|
||||||
|
groups: 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 === 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);
|
||||||
|
|
||||||
|
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'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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
238
src/pages/assignments/index.tsx
Normal file
238
src/pages/assignments/index.tsx
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
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 {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, 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 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>
|
||||||
|
{Object.keys(groupBy(corporateAssignments, (x) => x.corporate?.id)).map((x) => (
|
||||||
|
<div key={x}>
|
||||||
|
<span className="font-semibold">{getUserCompanyName(users.find((u) => u.id === x)!, users, groups)}: </span>
|
||||||
|
<span>
|
||||||
|
{groupBy(corporateAssignments, (x) => x.corporate?.id)[x].reduce((acc, curr) => curr.results.length + acc, 0)}/
|
||||||
|
{groupBy(corporateAssignments, (x) => x.corporate?.id)[x].reduce((acc, curr) => curr.exams.length + acc, 0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</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} 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}
|
||||||
|
onClick={
|
||||||
|
entitiesAllowEdit.length > 0
|
||||||
|
? () => router.push(`/assignments/creator/${a.id}`)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
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}
|
||||||
|
onClick={() => router.push(`/assignments/${a.id}`)}
|
||||||
|
key={a.id}
|
||||||
|
allowDownload
|
||||||
|
allowArchive
|
||||||
|
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}
|
||||||
|
onClick={() => router.push(`/assignments/${a.id}`)}
|
||||||
|
key={a.id}
|
||||||
|
allowDownload
|
||||||
|
allowArchive
|
||||||
|
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}
|
||||||
|
allowDownload
|
||||||
|
allowUnarchive
|
||||||
|
allowExcelDownload
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</Layout>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
316
src/pages/classrooms/[id].tsx
Normal file
316
src/pages/classrooms/[id].tsx
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
/* 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 { 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 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, 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 allowGroupEdit = useMemo(() => checkAccess(user, ["admin", "developer", "mastercorporate"]) || user.id === group.admin.id, [user, group]);
|
||||||
|
|
||||||
|
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 group:", group.name);
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
axios
|
||||||
|
.patch(`/api/groups/${group.id}`, {name})
|
||||||
|
.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 deleteGroup = () => {
|
||||||
|
if (!canDeleteClassroom) return;
|
||||||
|
if (!confirm("Are you sure you want to delete this group?")) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
axios
|
||||||
|
.delete(`/api/groups/${group.id}`)
|
||||||
|
.then(() => {
|
||||||
|
toast.success("This group 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 flex-col gap-3">
|
||||||
|
<div className="flex items-end 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">{group.name}</h2>
|
||||||
|
</div>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<BsFillPersonVcardFill className="text-xl" /> {getUserName(group.admin)}
|
||||||
|
</span>
|
||||||
|
</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 Group</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 Group</span>
|
||||||
|
</button>
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
190
src/pages/classrooms/create.tsx
Normal file
190
src/pages/classrooms/create.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
/* 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} 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("/")
|
||||||
|
|
||||||
|
const linkedUsers = await getLinkedUsers(user.id, user.type);
|
||||||
|
const entities = await getEntitiesWithRoles(mapBy(user.entities, "id"));
|
||||||
|
const allowedEntities = findAllowedEntities(user, entities, "create_classroom")
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: serialize({user, entities: allowedEntities, users: linkedUsers.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 {rows, renderSearch} = useListSearch<User>([["name"], ["corporateInformation", "companyInformation", "name"]], users);
|
||||||
|
const {items, renderMinimal} = usePagination<User>(rows, 16);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
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>
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
src/pages/classrooms/index.tsx
Normal file
121
src/pages/classrooms/index.tsx
Normal 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} 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(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 Group</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
187
src/pages/dashboard/admin.tsx
Normal file
187
src/pages/dashboard/admin.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
/* 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} label="Classrooms" value={groups.length} color="purple" />
|
||||||
|
<IconCard Icon={BsPeopleFill} 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
201
src/pages/dashboard/corporate.tsx
Normal file
201
src/pages/dashboard/corporate.tsx
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
/* 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 { 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 users = await getEntitiesUsers(entityIDS);
|
||||||
|
const entities = await getEntitiesWithRoles(entityIDS);
|
||||||
|
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} 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
193
src/pages/dashboard/developer.tsx
Normal file
193
src/pages/dashboard/developer.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
/* 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("/entities")}
|
||||||
|
label="Classrooms"
|
||||||
|
value={groups.length}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<IconCard Icon={BsPeopleFill} 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/pages/dashboard/index.tsx
Normal file
16
src/pages/dashboard/index.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import {User} from "@/interfaces/user";
|
||||||
|
import {sessionOptions} from "@/lib/session";
|
||||||
|
import { requestUser } from "@/utils/api";
|
||||||
|
import {withIronSessionSsr} from "iron-session/next";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
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>;
|
||||||
|
}
|
||||||
193
src/pages/dashboard/mastercorporate.tsx
Normal file
193
src/pages/dashboard/mastercorporate.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
/* 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 { 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 users = await getEntitiesUsers(entityIDS);
|
||||||
|
const entities = await getEntitiesWithRoles(entityIDS);
|
||||||
|
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 onClick={() => router.push("/classrooms")} Icon={BsPeople} label="Classrooms" value={groups.length} color="purple" />
|
||||||
|
<IconCard Icon={BsPeopleFill} 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
285
src/pages/dashboard/student.tsx
Normal file
285
src/pages/dashboard/student.tsx
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
/* 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 {InviteWithUsers} 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 {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 {convertInvitersToUsers, 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 {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: InviteWithUsers[];
|
||||||
|
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 allAssignments = await getAssignmentsByAssignee(user.id, {archived: false});
|
||||||
|
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(convertInvitersToUsers));
|
||||||
|
const assignments = allAssignments.filter(activeAssignmentFilter);
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
if (exams.every((x) => !!x)) {
|
||||||
|
setUserSolutions([]);
|
||||||
|
setShowSolutions(false);
|
||||||
|
setExams(exams.map((x) => x!).sort(sortByModule));
|
||||||
|
setSelectedModules(
|
||||||
|
exams
|
||||||
|
.map((x) => x!)
|
||||||
|
.sort(sortByModule)
|
||||||
|
.map((x) => x!.module),
|
||||||
|
);
|
||||||
|
setAssignment(assignment);
|
||||||
|
|
||||||
|
router.push("/exercises");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const studentAssignments = assignments.filter(activeAssignmentFilter);
|
||||||
|
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Bio */}
|
||||||
|
<section className="flex flex-col gap-1 md:gap-3">
|
||||||
|
<span className="text-lg font-bold">Bio</span>
|
||||||
|
<span className="text-mti-gray-taupe">
|
||||||
|
{user.bio || "Your bio will appear here, you can change it by clicking on your name in the top right corner."}
|
||||||
|
</span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Assignments */}
|
||||||
|
<section className="flex flex-col gap-1 md:gap-3">
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
src/pages/dashboard/teacher.tsx
Normal file
158
src/pages/dashboard/teacher.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
/* 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 } 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";
|
||||||
|
|
||||||
|
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 users = await getEntitiesUsers(entityIDS);
|
||||||
|
const entities = await getEntitiesWithRoles(entityIDS);
|
||||||
|
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} 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
372
src/pages/entities/[id]/index.tsx
Normal file
372
src/pages/entities/[id]/index.tsx
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
/* 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, 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} from "@/utils/users";
|
||||||
|
import {getEntityUsers, getLinkedUsers, getSpecificUsers} 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 getLinkedUsers(user.id, user.type);
|
||||||
|
const entityUsers = await getEntityUsers(id);
|
||||||
|
|
||||||
|
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.users,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}, 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"], ["corporateInformation", "companyInformation", "name"], ["role", "label"], ["type"]]}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</Layout>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
354
src/pages/entities/[id]/roles/[role].tsx
Normal file
354
src/pages/entities/[id]/roles/[role].tsx
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
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: "Generate Reading", key: "generate_reading"},
|
||||||
|
{label: "Delete Reading", key: "delete_reading"},
|
||||||
|
{label: "Generate Listening", key: "generate_listening"},
|
||||||
|
{label: "Delete Listening", key: "delete_listening"},
|
||||||
|
{label: "Generate Writing", key: "generate_writing"},
|
||||||
|
{label: "Delete Writing", key: "delete_writing"},
|
||||||
|
{label: "Generate Speaking", key: "generate_speaking"},
|
||||||
|
{label: "Delete Speaking", key: "delete_speaking"},
|
||||||
|
{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: string) => 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-2 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
src/pages/entities/[id]/roles/index.tsx
Normal file
158
src/pages/entities/[id]/roles/index.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
src/pages/entities/index.tsx
Normal file
106
src/pages/entities/index.tsx
Normal 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(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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,30 +6,17 @@ 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 { 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)) return redirect("/")
|
||||||
return {
|
|
||||||
redirect: {
|
|
||||||
destination: "/login",
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldRedirectHome(user)) {
|
|
||||||
return {
|
|
||||||
redirect: {
|
|
||||||
destination: "/",
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {user: req.session.user},
|
props: serialize({user}),
|
||||||
};
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
|
|||||||
@@ -6,30 +6,17 @@ 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 { 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)) return redirect("/")
|
||||||
return {
|
|
||||||
redirect: {
|
|
||||||
destination: "/login",
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldRedirectHome(user)) {
|
|
||||||
return {
|
|
||||||
redirect: {
|
|
||||||
destination: "/",
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {user: req.session.user},
|
props: serialize({user}),
|
||||||
};
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
|
|||||||
@@ -23,30 +23,18 @@ import LevelGeneration from "./(generation)/LevelGeneration";
|
|||||||
import SpeakingGeneration from "./(generation)/SpeakingGeneration";
|
import SpeakingGeneration from "./(generation)/SpeakingGeneration";
|
||||||
import {checkAccess} from "@/utils/permissions";
|
import {checkAccess} from "@/utils/permissions";
|
||||||
import {User} from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
|
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);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,213 +1,16 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
import {User} from "@/interfaces/user";
|
||||||
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 {sessionOptions} from "@/lib/session";
|
||||||
import {useEffect, useState} from "react";
|
import { redirect } from "@/utils";
|
||||||
import {averageScore, groupBySession, totalExams} from "@/utils/stats";
|
import { requestUser } from "@/utils/api";
|
||||||
import useUser from "@/hooks/useUser";
|
import {withIronSessionSsr} from "iron-session/next";
|
||||||
import Diagnostic from "@/components/Diagnostic";
|
|
||||||
import {ToastContainer} from "react-toastify";
|
|
||||||
import {capitalize} from "lodash";
|
|
||||||
import {Module} from "@/interfaces";
|
|
||||||
import ProgressBar from "@/components/Low/ProgressBar";
|
|
||||||
import Layout from "@/components/High/Layout";
|
|
||||||
import {calculateAverageLevel} from "@/utils/score";
|
|
||||||
import axios from "axios";
|
|
||||||
import DemographicInformationInput from "@/components/DemographicInformationInput";
|
|
||||||
import moment from "moment";
|
|
||||||
import Link from "next/link";
|
|
||||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
|
||||||
import ProfileSummary from "@/components/ProfileSummary";
|
|
||||||
import StudentDashboard from "@/dashboards/Student";
|
|
||||||
import AdminDashboard from "@/dashboards/Admin";
|
|
||||||
import CorporateDashboard from "@/dashboards/Corporate";
|
|
||||||
import TeacherDashboard from "@/dashboards/Teacher";
|
|
||||||
import AgentDashboard from "@/dashboards/Agent";
|
|
||||||
import MasterCorporateDashboard from "@/dashboards/MasterCorporate";
|
|
||||||
import PaymentDue from "./(status)/PaymentDue";
|
|
||||||
import {useRouter} from "next/router";
|
|
||||||
import {PayPalScriptProvider} from "@paypal/react-paypal-js";
|
|
||||||
import {CorporateUser, MasterCorporateUser, Type, User, userTypes} from "@/interfaces/user";
|
|
||||||
import Select from "react-select";
|
|
||||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
|
||||||
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
|
|
||||||
import {getUserCorporate} from "@/utils/groups.be";
|
|
||||||
import {getUsers} from "@/utils/users.be";
|
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({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")
|
||||||
|
|
||||||
const envVariables: {[key: string]: string} = {};
|
return redirect(`/dashboard/${user.type}`)
|
||||||
Object.keys(process.env)
|
|
||||||
.filter((x) => x.startsWith("NEXT_PUBLIC"))
|
|
||||||
.forEach((x: string) => {
|
|
||||||
envVariables[x] = process.env[x]!;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return {
|
|
||||||
redirect: {
|
|
||||||
destination: "/login",
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const linkedCorporate = (await getUserCorporate(user.id)) || null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
props: {user, envVariables, linkedCorporate},
|
|
||||||
};
|
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
interface Props {
|
export default function Dashboard() {
|
||||||
user: User;
|
return <div></div>;
|
||||||
envVariables: {[key: string]: string};
|
|
||||||
linkedCorporate?: CorporateUser | MasterCorporateUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Home({user: propsUser, linkedCorporate}: Props) {
|
|
||||||
const [user, setUser] = useState(propsUser);
|
|
||||||
const [showDiagnostics, setShowDiagnostics] = useState(false);
|
|
||||||
const [showDemographicInput, setShowDemographicInput] = useState(false);
|
|
||||||
const [selectedScreen, setSelectedScreen] = useState<Type>("admin");
|
|
||||||
|
|
||||||
const {mutateUser} = useUser({redirectTo: "/login"});
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (user) {
|
|
||||||
setShowDemographicInput(!user.demographicInformation || !user.demographicInformation.country || !user.demographicInformation.phone);
|
|
||||||
setShowDiagnostics(user.isFirstLogin && user.type === "student");
|
|
||||||
}
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
const checkIfUserExpired = () => {
|
|
||||||
const expirationDate = user!.subscriptionExpirationDate;
|
|
||||||
|
|
||||||
if (expirationDate === null || expirationDate === undefined) return false;
|
|
||||||
if (moment(expirationDate).isAfter(moment(new Date()))) return false;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (user && (user.status === "paymentDue" || user.status === "disabled" || checkIfUserExpired())) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<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>
|
|
||||||
{user.status === "disabled" && (
|
|
||||||
<Layout user={user} navDisabled>
|
|
||||||
<div className="flex flex-col items-center justify-center text-center w-full gap-4">
|
|
||||||
<span className="font-bold text-lg">Your account has been disabled!</span>
|
|
||||||
<span>Please contact an administrator if you believe this to be a mistake.</span>
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
)}
|
|
||||||
{(user.status === "paymentDue" || checkIfUserExpired()) && <PaymentDue hasExpired user={user} reload={router.reload} />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user && showDemographicInput) {
|
|
||||||
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>
|
|
||||||
<Layout user={user} navDisabled>
|
|
||||||
<DemographicInformationInput
|
|
||||||
mutateUser={(user) => {
|
|
||||||
setUser(user);
|
|
||||||
mutateUser(user);
|
|
||||||
}}
|
|
||||||
user={user}
|
|
||||||
/>
|
|
||||||
</Layout>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user && showDiagnostics) {
|
|
||||||
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>
|
|
||||||
<Layout user={user} navDisabled>
|
|
||||||
<Diagnostic user={user} onFinish={() => setShowDiagnostics(false)} />
|
|
||||||
</Layout>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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}>
|
|
||||||
{checkAccess(user, ["student"]) && <StudentDashboard linkedCorporate={linkedCorporate} user={user} />}
|
|
||||||
{checkAccess(user, ["teacher"]) && <TeacherDashboard linkedCorporate={linkedCorporate} user={user} />}
|
|
||||||
{checkAccess(user, ["corporate"]) && <CorporateDashboard linkedCorporate={linkedCorporate} user={user as CorporateUser} />}
|
|
||||||
{checkAccess(user, ["mastercorporate"]) && <MasterCorporateDashboard user={user as MasterCorporateUser} />}
|
|
||||||
{checkAccess(user, ["agent"]) && <AgentDashboard user={user} />}
|
|
||||||
{checkAccess(user, ["admin"]) && <AdminDashboard user={user} />}
|
|
||||||
{checkAccess(user, ["developer"]) && (
|
|
||||||
<>
|
|
||||||
<Select
|
|
||||||
options={userTypes.map((u) => ({
|
|
||||||
value: u,
|
|
||||||
label: USER_TYPE_LABELS[u],
|
|
||||||
}))}
|
|
||||||
value={{
|
|
||||||
value: selectedScreen,
|
|
||||||
label: USER_TYPE_LABELS[selectedScreen],
|
|
||||||
}}
|
|
||||||
onChange={(value) => (value ? setSelectedScreen(value.value) : setSelectedScreen("admin"))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{selectedScreen === "student" && <StudentDashboard linkedCorporate={linkedCorporate} user={user} />}
|
|
||||||
{selectedScreen === "teacher" && <TeacherDashboard linkedCorporate={linkedCorporate} user={user} />}
|
|
||||||
{selectedScreen === "corporate" && (
|
|
||||||
<CorporateDashboard linkedCorporate={linkedCorporate} user={user as unknown as CorporateUser} />
|
|
||||||
)}
|
|
||||||
{selectedScreen === "mastercorporate" && <MasterCorporateDashboard user={user as unknown as MasterCorporateUser} />}
|
|
||||||
{selectedScreen === "agent" && <AgentDashboard user={user} />}
|
|
||||||
{selectedScreen === "admin" && <AdminDashboard user={user} />}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Layout>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,30 +15,17 @@ import {useRouter} from "next/router";
|
|||||||
import EmailVerification from "./(auth)/EmailVerification";
|
import EmailVerification from "./(auth)/EmailVerification";
|
||||||
import {withIronSessionSsr} from "iron-session/next";
|
import {withIronSessionSsr} from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
|
import { requestUser } from "@/utils/api";
|
||||||
|
import { redirect } from "@/utils";
|
||||||
|
|
||||||
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/g);
|
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/g);
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
|
||||||
const user = req.session.user;
|
const user = await requestUser(req, res)
|
||||||
|
if (user) return redirect("/")
|
||||||
const envVariables: {[key: string]: string} = {};
|
|
||||||
Object.keys(process.env)
|
|
||||||
.filter((x) => x.startsWith("NEXT_PUBLIC"))
|
|
||||||
.forEach((x: string) => {
|
|
||||||
envVariables[x] = process.env[x]!;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (user) {
|
|
||||||
return {
|
|
||||||
redirect: {
|
|
||||||
destination: "/",
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {user: null, envVariables},
|
props: {user: null},
|
||||||
};
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
|
|||||||
@@ -31,30 +31,19 @@ import {CSVLink} from "react-csv";
|
|||||||
import {Tab} from "@headlessui/react";
|
import {Tab} from "@headlessui/react";
|
||||||
import {useListSearch} from "@/hooks/useListSearch";
|
import {useListSearch} from "@/hooks/useListSearch";
|
||||||
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
|
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
|
||||||
|
import { requestUser } from "@/utils/api";
|
||||||
|
import { redirect } from "@/utils";
|
||||||
|
|
||||||
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) {
|
|
||||||
return {
|
|
||||||
redirect: {
|
|
||||||
destination: "/login",
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldRedirectHome(user) || checkAccess(user, getTypesOfUser(["admin", "developer", "agent", "corporate", "mastercorporate"]))) {
|
if (shouldRedirectHome(user) || checkAccess(user, getTypesOfUser(["admin", "developer", "agent", "corporate", "mastercorporate"]))) {
|
||||||
return {
|
return redirect("/")
|
||||||
redirect: {
|
|
||||||
destination: "/",
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {user: req.session.user},
|
props: {user},
|
||||||
};
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
|
|||||||
@@ -5,32 +5,19 @@ import {sessionOptions} from "@/lib/session";
|
|||||||
import useUser from "@/hooks/useUser";
|
import useUser from "@/hooks/useUser";
|
||||||
import PaymentDue from "./(status)/PaymentDue";
|
import PaymentDue from "./(status)/PaymentDue";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
|
import { requestUser } from "@/utils/api";
|
||||||
|
import { redirect } from "@/utils";
|
||||||
|
|
||||||
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")
|
||||||
const envVariables: {[key: string]: string} = {};
|
|
||||||
Object.keys(process.env)
|
|
||||||
.filter((x) => x.startsWith("NEXT_PUBLIC"))
|
|
||||||
.forEach((x: string) => {
|
|
||||||
envVariables[x] = process.env[x]!;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return {
|
|
||||||
redirect: {
|
|
||||||
destination: "/login",
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {user: req.session.user, envVariables},
|
props: {user},
|
||||||
};
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
export default function Home({envVariables}: {envVariables: {[key: string]: string}}) {
|
export default function Home() {
|
||||||
const {user} = useUser({redirectTo: "/login"});
|
const {user} = useUser({redirectTo: "/login"});
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import axios from "axios";
|
|||||||
import {toast, ToastContainer} from "react-toastify";
|
import {toast, ToastContainer} from "react-toastify";
|
||||||
import {Type as UserType} from "@/interfaces/user";
|
import {Type as UserType} from "@/interfaces/user";
|
||||||
import {getGroups} from "@/utils/groups.be";
|
import {getGroups} from "@/utils/groups.be";
|
||||||
|
import { requestUser } from "@/utils/api";
|
||||||
|
import { redirect } from "@/utils";
|
||||||
interface BasicUser {
|
interface BasicUser {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -28,36 +30,13 @@ interface PermissionWithBasicUsers {
|
|||||||
users: BasicUser[];
|
users: BasicUser[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async (context) => {
|
export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) => {
|
||||||
const {req, params} = context;
|
const user = await requestUser(req, res)
|
||||||
const user = req.session.user;
|
if (!user) return redirect("/login")
|
||||||
|
|
||||||
if (!user) {
|
if (shouldRedirectHome(user)) return redirect("/")
|
||||||
return {
|
|
||||||
redirect: {
|
|
||||||
destination: "/login",
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldRedirectHome(user)) {
|
if (!params?.id) return redirect("/permissions")
|
||||||
return {
|
|
||||||
redirect: {
|
|
||||||
destination: "/",
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!params?.id) {
|
|
||||||
return {
|
|
||||||
redirect: {
|
|
||||||
destination: "/permissions",
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch data from external API
|
// Fetch data from external API
|
||||||
const permission: Permission = await getPermissionDoc(params.id as string);
|
const permission: Permission = await getPermissionDoc(params.id as string);
|
||||||
@@ -100,7 +79,7 @@ export const getServerSideProps = withIronSessionSsr(async (context) => {
|
|||||||
id: params.id,
|
id: params.id,
|
||||||
users: usersData,
|
users: usersData,
|
||||||
},
|
},
|
||||||
user: req.session.user,
|
user,
|
||||||
users: filteredUsers,
|
users: filteredUsers,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,27 +8,14 @@ import {getPermissionDocs} from "@/utils/permissions.be";
|
|||||||
import {User} from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
import Layout from "@/components/High/Layout";
|
import Layout from "@/components/High/Layout";
|
||||||
import PermissionList from "@/components/PermissionList";
|
import PermissionList from "@/components/PermissionList";
|
||||||
|
import { requestUser } from "@/utils/api";
|
||||||
|
import { redirect } from "@/utils";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({req}) => {
|
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)) return redirect("/")
|
||||||
return {
|
|
||||||
redirect: {
|
|
||||||
destination: "/login",
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldRedirectHome(user)) {
|
|
||||||
return {
|
|
||||||
redirect: {
|
|
||||||
destination: "/",
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch data from external API
|
// Fetch data from external API
|
||||||
const permissions: Permission[] = await getPermissionDocs();
|
const permissions: Permission[] = await getPermissionDocs();
|
||||||
@@ -51,7 +38,7 @@ export const getServerSideProps = withIronSessionSsr(async ({req}) => {
|
|||||||
const {users, ...rest} = p;
|
const {users, ...rest} = p;
|
||||||
return rest;
|
return rest;
|
||||||
}),
|
}),
|
||||||
user: req.session.user,
|
user,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {BsCamera, BsQuestionCircleFill} from "react-icons/bs";
|
|||||||
import {USER_TYPE_LABELS} from "@/resources/user";
|
import {USER_TYPE_LABELS} from "@/resources/user";
|
||||||
import useGroups from "@/hooks/useGroups";
|
import useGroups from "@/hooks/useGroups";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import {convertBase64} from "@/utils";
|
import {convertBase64, redirect} from "@/utils";
|
||||||
import {Divider} from "primereact/divider";
|
import {Divider} from "primereact/divider";
|
||||||
import GenderInput from "@/components/High/GenderInput";
|
import GenderInput from "@/components/High/GenderInput";
|
||||||
import EmploymentStatusInput from "@/components/High/EmploymentStatusInput";
|
import EmploymentStatusInput from "@/components/High/EmploymentStatusInput";
|
||||||
@@ -46,27 +46,13 @@ import {checkAccess, getTypesOfUser} from "@/utils/permissions";
|
|||||||
import {getParticipantGroups, getUserCorporate} from "@/utils/groups.be";
|
import {getParticipantGroups, getUserCorporate} from "@/utils/groups.be";
|
||||||
import {InferGetServerSidePropsType} from "next";
|
import {InferGetServerSidePropsType} from "next";
|
||||||
import {getUsers} from "@/utils/users.be";
|
import {getUsers} from "@/utils/users.be";
|
||||||
|
import { requestUser } from "@/utils/api";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({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)) return redirect("/")
|
||||||
return {
|
|
||||||
redirect: {
|
|
||||||
destination: "/login",
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldRedirectHome(user)) {
|
|
||||||
return {
|
|
||||||
redirect: {
|
|
||||||
destination: "/",
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Head from "next/head";
|
|||||||
import {withIronSessionSsr} from "iron-session/next";
|
import {withIronSessionSsr} from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import {Stat, User} from "@/interfaces/user";
|
import {Stat, User} from "@/interfaces/user";
|
||||||
import {useEffect, useRef, useState} from "react";
|
import {useEffect, useMemo, useRef, useState} from "react";
|
||||||
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||||
import {groupByDate} from "@/utils/stats";
|
import {groupByDate} from "@/utils/stats";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
@@ -22,36 +22,36 @@ import RecordFilter from "@/components/Medium/RecordFilter";
|
|||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import useTrainingContentStore from "@/stores/trainingContentStore";
|
import useTrainingContentStore from "@/stores/trainingContentStore";
|
||||||
import {Assignment} from "@/interfaces/results";
|
import {Assignment} from "@/interfaces/results";
|
||||||
import {getUsers} from "@/utils/users.be";
|
import {getEntitiesUsers, getUsers} from "@/utils/users.be";
|
||||||
import {getAssignments, getAssignmentsByAssigner} from "@/utils/assignments.be";
|
import {getAssignments, getAssignmentsByAssigner, getEntitiesAssignments} from "@/utils/assignments.be";
|
||||||
import useGradingSystem from "@/hooks/useGrading";
|
import useGradingSystem from "@/hooks/useGrading";
|
||||||
|
import { mapBy, redirect, serialize } from "@/utils";
|
||||||
|
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
||||||
|
import { checkAccess } from "@/utils/permissions";
|
||||||
|
import { getGroups, getGroupsByEntities } from "@/utils/groups.be";
|
||||||
|
import { getGradingSystemByEntity } from "@/utils/grading.be";
|
||||||
|
import { Grading } from "@/interfaces";
|
||||||
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
|
import { useListSearch } from "@/hooks/useListSearch";
|
||||||
|
import CardList from "@/components/High/CardList";
|
||||||
|
import { requestUser } from "@/utils/api";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
|
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)) return redirect("/")
|
||||||
return {
|
|
||||||
redirect: {
|
|
||||||
destination: "/login",
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldRedirectHome(user)) {
|
const entityIDs = mapBy(user.entities, 'id')
|
||||||
return {
|
|
||||||
redirect: {
|
|
||||||
destination: "/",
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const users = await getUsers();
|
const entities = await getEntitiesWithRoles(checkAccess(user, ["admin", "developer"]) ? undefined : entityIDs)
|
||||||
const assignments = await getAssignments();
|
const users = await (checkAccess(user, ["admin", "developer"]) ? getUsers() : getEntitiesUsers(mapBy(entities, 'id')))
|
||||||
|
const groups = await (checkAccess(user, ["admin", "developer"]) ? getGroups() : getGroupsByEntities(mapBy(entities, 'id')))
|
||||||
|
const assignments = await (checkAccess(user, ["admin", "developer"]) ? getAssignments() : getEntitiesAssignments(mapBy(entities, 'id')))
|
||||||
|
const gradingSystems = await Promise.all(entityIDs.map(getGradingSystemByEntity))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {user, users, assignments},
|
props: serialize({user, users, assignments, entities, gradingSystems}),
|
||||||
};
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
@@ -61,9 +61,13 @@ interface Props {
|
|||||||
user: User;
|
user: User;
|
||||||
users: User[];
|
users: User[];
|
||||||
assignments: Assignment[];
|
assignments: Assignment[];
|
||||||
|
gradingSystems: Grading[]
|
||||||
|
entities: EntityWithRoles[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function History({user, users, assignments}: Props) {
|
const MAX_TRAINING_EXAMS = 10;
|
||||||
|
|
||||||
|
export default function History({user, users, assignments, entities, gradingSystems}: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [statsUserId, setStatsUserId, training, setTraining] = useRecordStore((state) => [
|
const [statsUserId, setStatsUserId, training, setTraining] = useRecordStore((state) => [
|
||||||
state.selectedUser,
|
state.selectedUser,
|
||||||
@@ -72,8 +76,6 @@ export default function History({user, users, assignments}: Props) {
|
|||||||
state.setTraining,
|
state.setTraining,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// const [statsUserId, setStatsUserId] = useState<string | undefined>(user.id);
|
|
||||||
const [groupedStats, setGroupedStats] = useState<{[key: string]: Stat[]}>();
|
|
||||||
const [filter, setFilter] = useState<Filter>();
|
const [filter, setFilter] = useState<Filter>();
|
||||||
|
|
||||||
const {data: stats, isLoading: isStatsLoading} = useFilterRecordsByUser<Stat[]>(statsUserId || user?.id);
|
const {data: stats, isLoading: isStatsLoading} = useFilterRecordsByUser<Stat[]>(statsUserId || user?.id);
|
||||||
@@ -87,12 +89,10 @@ export default function History({user, users, assignments}: Props) {
|
|||||||
const setTimeSpent = useExamStore((state) => state.setTimeSpent);
|
const setTimeSpent = useExamStore((state) => state.setTimeSpent);
|
||||||
const renderPdfIcon = usePDFDownload("stats");
|
const renderPdfIcon = usePDFDownload("stats");
|
||||||
|
|
||||||
useEffect(() => setStatsUserId(user.id), [setStatsUserId, user]);
|
const [selectedTrainingExams, setSelectedTrainingExams] = useState<string[]>([]);
|
||||||
|
const setTrainingStats = useTrainingContentStore((state) => state.setStats);
|
||||||
|
|
||||||
useEffect(() => {
|
const groupedStats = useMemo(() => groupByDate(
|
||||||
if (stats && !isStatsLoading) {
|
|
||||||
setGroupedStats(
|
|
||||||
groupByDate(
|
|
||||||
stats.filter((x) => {
|
stats.filter((x) => {
|
||||||
if (
|
if (
|
||||||
(x.module === "writing" || x.module === "speaking") &&
|
(x.module === "writing" || x.module === "speaking") &&
|
||||||
@@ -102,15 +102,19 @@ export default function History({user, users, assignments}: Props) {
|
|||||||
return false;
|
return false;
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
),
|
), [stats])
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [stats, isStatsLoading]);
|
|
||||||
|
|
||||||
// useEffect(() => {
|
useEffect(() => setStatsUserId(user.id), [setStatsUserId, user]);
|
||||||
// // just set this initially
|
|
||||||
// if (!statsUserId) setStatsUserId(user.id);
|
useEffect(() => {
|
||||||
// }, []);
|
const handleRouteChange = (url: string) => {
|
||||||
|
setTraining(false);
|
||||||
|
};
|
||||||
|
router.events.on("routeChangeStart", handleRouteChange);
|
||||||
|
return () => {
|
||||||
|
router.events.off("routeChangeStart", handleRouteChange);
|
||||||
|
};
|
||||||
|
}, [router.events, setTraining]);
|
||||||
|
|
||||||
const filterStatsByDate = (stats: {[key: string]: Stat[]}) => {
|
const filterStatsByDate = (stats: {[key: string]: Stat[]}) => {
|
||||||
if (filter && filter !== "assignments") {
|
if (filter && filter !== "assignments") {
|
||||||
@@ -139,11 +143,7 @@ export default function History({user, users, assignments}: Props) {
|
|||||||
return stats;
|
return stats;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_TRAINING_EXAMS = 10;
|
const handleTrainingContentSubmission = () => {
|
||||||
const [selectedTrainingExams, setSelectedTrainingExams] = useState<string[]>([]);
|
|
||||||
const setTrainingStats = useTrainingContentStore((state) => state.setStats);
|
|
||||||
|
|
||||||
const handleTrainingContentSubmission = () => {
|
|
||||||
if (groupedStats) {
|
if (groupedStats) {
|
||||||
const groupedStatsByDate = filterStatsByDate(groupedStats);
|
const groupedStatsByDate = filterStatsByDate(groupedStats);
|
||||||
const allStats = Object.keys(groupedStatsByDate);
|
const allStats = Object.keys(groupedStatsByDate);
|
||||||
@@ -157,21 +157,15 @@ export default function History({user, users, assignments}: Props) {
|
|||||||
setTrainingStats(Object.values(selectedStats).flat());
|
setTrainingStats(Object.values(selectedStats).flat());
|
||||||
router.push("/training");
|
router.push("/training");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const filteredStats = useMemo(() =>
|
||||||
const handleRouteChange = (url: string) => {
|
Object.keys(filterStatsByDate(groupedStats))
|
||||||
setTraining(false);
|
.sort((a, b) => parseInt(b) - parseInt(a)),
|
||||||
};
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
router.events.on("routeChangeStart", handleRouteChange);
|
[groupedStats, filter])
|
||||||
return () => {
|
|
||||||
router.events.off("routeChangeStart", handleRouteChange);
|
|
||||||
};
|
|
||||||
}, [router.events, setTraining]);
|
|
||||||
|
|
||||||
const customContent = (timestamp: string) => {
|
const customContent = (timestamp: string) => {
|
||||||
if (!groupedStats) return <></>;
|
|
||||||
|
|
||||||
const dateStats = groupedStats[timestamp];
|
const dateStats = groupedStats[timestamp];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -212,7 +206,7 @@ export default function History({user, users, assignments}: Props) {
|
|||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
{user && (
|
{user && (
|
||||||
<Layout user={user}>
|
<Layout user={user}>
|
||||||
<RecordFilter user={user} filterState={{filter: filter, setFilter: setFilter}}>
|
<RecordFilter user={user} users={users} entities={entities} filterState={{filter: filter, setFilter: setFilter}}>
|
||||||
{training && (
|
{training && (
|
||||||
<div className="flex flex-row">
|
<div className="flex flex-row">
|
||||||
<div className="font-semibold text-2xl mr-4">
|
<div className="font-semibold text-2xl mr-4">
|
||||||
@@ -231,14 +225,12 @@ export default function History({user, users, assignments}: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</RecordFilter>
|
</RecordFilter>
|
||||||
{groupedStats && Object.keys(groupedStats).length > 0 && !isStatsLoading && (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 w-full gap-4 xl:gap-6">
|
|
||||||
{Object.keys(filterStatsByDate(groupedStats))
|
{filteredStats.length > 0 && !isStatsLoading && (
|
||||||
.sort((a, b) => parseInt(b) - parseInt(a))
|
<CardList list={filteredStats} renderCard={customContent} searchFields={[]} pageSize={30} className="lg:!grid-cols-3" />
|
||||||
.map(customContent)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{groupedStats && Object.keys(groupedStats).length === 0 && !isStatsLoading && (
|
{filteredStats.length === 0 && !isStatsLoading && (
|
||||||
<span className="font-semibold ml-1">No record to display...</span>
|
<span className="font-semibold ml-1">No record to display...</span>
|
||||||
)}
|
)}
|
||||||
{isStatsLoading && (
|
{isStatsLoading && (
|
||||||
|
|||||||
@@ -1,69 +1,62 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import {withIronSessionSsr} from "iron-session/next";
|
import { withIronSessionSsr } from "iron-session/next";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import { sessionOptions } from "@/lib/session";
|
||||||
import {ToastContainer} from "react-toastify";
|
import { ToastContainer } from "react-toastify";
|
||||||
import Layout from "@/components/High/Layout";
|
import Layout from "@/components/High/Layout";
|
||||||
import CodeGenerator from "./(admin)/CodeGenerator";
|
import CodeGenerator from "./(admin)/CodeGenerator";
|
||||||
import ExamLoader from "./(admin)/ExamLoader";
|
import ExamLoader from "./(admin)/ExamLoader";
|
||||||
import {Tab} from "@headlessui/react";
|
import { Tab } from "@headlessui/react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import Lists from "./(admin)/Lists";
|
import Lists from "./(admin)/Lists";
|
||||||
import BatchCodeGenerator from "./(admin)/BatchCodeGenerator";
|
import BatchCodeGenerator from "./(admin)/BatchCodeGenerator";
|
||||||
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||||
import ExamGenerator from "./(admin)/ExamGenerator";
|
import ExamGenerator from "./(admin)/ExamGenerator";
|
||||||
import BatchCreateUser from "./(admin)/BatchCreateUser";
|
import BatchCreateUser from "./(admin)/BatchCreateUser";
|
||||||
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
|
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
||||||
import usePermissions from "@/hooks/usePermissions";
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
import {useState} from "react";
|
import { useState } from "react";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import IconCard from "@/dashboards/IconCard";
|
import IconCard from "@/dashboards/IconCard";
|
||||||
import {BsCode, BsCodeSquare, BsGearFill, BsPeopleFill, BsPersonFill} from "react-icons/bs";
|
import { BsCode, BsCodeSquare, BsGearFill, BsPeopleFill, BsPersonFill } from "react-icons/bs";
|
||||||
import UserCreator from "./(admin)/UserCreator";
|
import UserCreator from "./(admin)/UserCreator";
|
||||||
import CorporateGradingSystem from "./(admin)/CorporateGradingSystem";
|
import CorporateGradingSystem from "./(admin)/CorporateGradingSystem";
|
||||||
import useGradingSystem from "@/hooks/useGrading";
|
import useGradingSystem from "@/hooks/useGrading";
|
||||||
import {CEFR_STEPS} from "@/resources/grading";
|
import { CEFR_STEPS } from "@/resources/grading";
|
||||||
import {User} from "@/interfaces/user";
|
import { User } from "@/interfaces/user";
|
||||||
import {getUserPermissions} from "@/utils/permissions.be";
|
import { getUserPermissions } from "@/utils/permissions.be";
|
||||||
import {Permission, PermissionType} from "@/interfaces/permissions";
|
import { Permission, PermissionType } from "@/interfaces/permissions";
|
||||||
import {getUsers} from "@/utils/users.be";
|
import { getUsers } from "@/utils/users.be";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
|
import { getEntitiesWithRoles, getEntityWithRoles } from "@/utils/entities.be";
|
||||||
|
import { mapBy, serialize, redirect } from "@/utils";
|
||||||
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
|
import { requestUser } from "@/utils/api";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||||
const user = req.session.user;
|
const user = await requestUser(req, res)
|
||||||
if (!user) {
|
if (!user) return redirect("/login")
|
||||||
return {
|
|
||||||
redirect: {
|
|
||||||
destination: "/login",
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldRedirectHome(user) || !checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"])) {
|
if (shouldRedirectHome(user) || !checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"]))
|
||||||
return {
|
return redirect("/")
|
||||||
redirect: {
|
|
||||||
destination: "/",
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const permissions = await getUserPermissions(user.id);
|
const permissions = await getUserPermissions(user.id);
|
||||||
|
const entities = await getEntitiesWithRoles(mapBy(user.entities, 'id')) || []
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {user, permissions},
|
props: serialize({ user, permissions, entities }),
|
||||||
};
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
permissions: PermissionType[];
|
permissions: PermissionType[];
|
||||||
|
entities: EntityWithRoles[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Admin({user, permissions}: Props) {
|
export default function Admin({ user, entities, permissions }: Props) {
|
||||||
const {gradingSystem, mutate} = useGradingSystem();
|
const { gradingSystem, mutate } = useGradingSystem();
|
||||||
const {users} = useUsers();
|
const { users } = useUsers();
|
||||||
|
|
||||||
const [modalOpen, setModalOpen] = useState<string>();
|
const [modalOpen, setModalOpen] = useState<string>();
|
||||||
|
|
||||||
@@ -90,14 +83,14 @@ export default function Admin({user, permissions}: Props) {
|
|||||||
<CodeGenerator user={user} permissions={permissions} onFinish={() => setModalOpen(undefined)} />
|
<CodeGenerator user={user} permissions={permissions} onFinish={() => setModalOpen(undefined)} />
|
||||||
</Modal>
|
</Modal>
|
||||||
<Modal isOpen={modalOpen === "createUser"} onClose={() => setModalOpen(undefined)}>
|
<Modal isOpen={modalOpen === "createUser"} onClose={() => setModalOpen(undefined)}>
|
||||||
<UserCreator user={user} users={users} permissions={permissions} onFinish={() => setModalOpen(undefined)} />
|
<UserCreator user={user} entities={entities} users={users} permissions={permissions} onFinish={() => setModalOpen(undefined)} />
|
||||||
</Modal>
|
</Modal>
|
||||||
<Modal isOpen={modalOpen === "gradingSystem"} onClose={() => setModalOpen(undefined)}>
|
<Modal isOpen={modalOpen === "gradingSystem"} onClose={() => setModalOpen(undefined)}>
|
||||||
<CorporateGradingSystem
|
<CorporateGradingSystem
|
||||||
user={user}
|
user={user}
|
||||||
defaultSteps={gradingSystem?.steps || CEFR_STEPS}
|
defaultSteps={gradingSystem?.steps || CEFR_STEPS}
|
||||||
mutate={(steps) => {
|
mutate={(steps) => {
|
||||||
mutate({user: user.id, steps});
|
mutate({ user: user.id, steps });
|
||||||
setModalOpen(undefined);
|
setModalOpen(undefined);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -17,48 +17,52 @@ import {calculateAverageLevel, calculateBandScore} from "@/utils/score";
|
|||||||
import {countExamModules, countFullExams, MODULE_ARRAY, sortByModule} from "@/utils/moduleUtils";
|
import {countExamModules, countFullExams, MODULE_ARRAY, sortByModule} from "@/utils/moduleUtils";
|
||||||
import {Chart} from "react-chartjs-2";
|
import {Chart} from "react-chartjs-2";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import Select from "react-select";
|
|
||||||
import useGroups from "@/hooks/useGroups";
|
import useGroups from "@/hooks/useGroups";
|
||||||
import DatePicker from "react-datepicker";
|
import DatePicker from "react-datepicker";
|
||||||
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
import {shouldRedirectHome} from "@/utils/navigation.disabled";
|
||||||
import ProfileSummary from "@/components/ProfileSummary";
|
import ProfileSummary from "@/components/ProfileSummary";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {Stat} from "@/interfaces/user";
|
import {Group, Stat, User} from "@/interfaces/user";
|
||||||
import {Divider} from "primereact/divider";
|
import {Divider} from "primereact/divider";
|
||||||
import Badge from "@/components/Low/Badge";
|
import Badge from "@/components/Low/Badge";
|
||||||
|
import { mapBy, redirect, serialize } from "@/utils";
|
||||||
|
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
||||||
|
import { checkAccess } from "@/utils/permissions";
|
||||||
|
import { getEntitiesUsers, getUsers } from "@/utils/users.be";
|
||||||
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
|
import { getGroups, getGroupsByEntities } from "@/utils/groups.be";
|
||||||
|
import Select from "@/components/Low/Select";
|
||||||
|
import { requestUser } from "@/utils/api";
|
||||||
|
|
||||||
ChartJS.register(LinearScale, CategoryScale, PointElement, LineElement, LineController, Legend, Tooltip);
|
ChartJS.register(LinearScale, CategoryScale, PointElement, LineElement, LineController, Legend, Tooltip);
|
||||||
|
|
||||||
const COLORS = ["#1EB3FF", "#FF790A", "#3D9F11", "#EF5DA8", "#414288"];
|
const COLORS = ["#1EB3FF", "#FF790A", "#3D9F11", "#EF5DA8", "#414288"];
|
||||||
|
|
||||||
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)) return redirect("/")
|
||||||
return {
|
|
||||||
redirect: {
|
|
||||||
destination: "/login",
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldRedirectHome(user)) {
|
const entityIDs = mapBy(user.entities, 'id')
|
||||||
return {
|
const entities = await getEntitiesWithRoles(checkAccess(user, ["admin", "developer"]) ? undefined : entityIDs)
|
||||||
redirect: {
|
const users = await (checkAccess(user, ["admin", "developer"]) ? getUsers() : getEntitiesUsers(mapBy(entities, 'id')))
|
||||||
destination: "/",
|
const groups = await (checkAccess(user, ["admin", "developer"]) ? getGroups() : getGroupsByEntities(mapBy(entities, 'id')))
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {user: req.session.user},
|
props: serialize({user, entities, users, groups}),
|
||||||
};
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
export default function Stats() {
|
interface Props {
|
||||||
const [statsUserId, setStatsUserId] = useState<string>();
|
user: User
|
||||||
|
users: User[]
|
||||||
|
entities: EntityWithRoles[]
|
||||||
|
groups: Group[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Stats({ user, entities, users, groups }: Props) {
|
||||||
|
const [statsUserId, setStatsUserId] = useState<string>(user.id);
|
||||||
const [startDate, setStartDate] = useState<Date | null>(moment(new Date()).subtract(1, "weeks").toDate());
|
const [startDate, setStartDate] = useState<Date | null>(moment(new Date()).subtract(1, "weeks").toDate());
|
||||||
const [endDate, setEndDate] = useState<Date | null>(new Date());
|
const [endDate, setEndDate] = useState<Date | null>(new Date());
|
||||||
const [initialStatDate, setInitialStatDate] = useState<Date>();
|
const [initialStatDate, setInitialStatDate] = useState<Date>();
|
||||||
@@ -69,15 +73,8 @@ export default function Stats() {
|
|||||||
const [dailyScoreDate, setDailyScoreDate] = useState<Date | null>(new Date());
|
const [dailyScoreDate, setDailyScoreDate] = useState<Date | null>(new Date());
|
||||||
const [intervalDates, setIntervalDates] = useState<Date[]>([]);
|
const [intervalDates, setIntervalDates] = useState<Date[]>([]);
|
||||||
|
|
||||||
const {user} = useUser({redirectTo: "/login"});
|
|
||||||
const {users} = useUsers();
|
|
||||||
const {groups} = useGroups({admin: user?.id});
|
|
||||||
const {data: stats} = useFilterRecordsByUser<Stat[]>(statsUserId, !statsUserId);
|
const {data: stats} = useFilterRecordsByUser<Stat[]>(statsUserId, !statsUserId);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (user) setStatsUserId(user.id);
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setInitialStatDate(
|
setInitialStatDate(
|
||||||
stats
|
stats
|
||||||
@@ -190,16 +187,7 @@ export default function Stats() {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
options={users.map((x) => ({value: x.id, label: `${x.name} - ${x.email}`}))}
|
options={users.map((x) => ({value: x.id, label: `${x.name} - ${x.email}`}))}
|
||||||
defaultValue={{value: user.id, label: `${user.name} - ${user.email}`}}
|
defaultValue={{value: user.id, label: `${user.name} - ${user.email}`}}
|
||||||
onChange={(value) => setStatsUserId(value?.value)}
|
onChange={(value) => setStatsUserId(value?.value || user.id)}
|
||||||
menuPortalTarget={document?.body}
|
|
||||||
styles={{
|
|
||||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
|
||||||
option: (styles, state) => ({
|
|
||||||
...styles,
|
|
||||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
|
||||||
color: state.isFocused ? "black" : styles.color,
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{["corporate", "teacher", "mastercorporate"].includes(user.type) && groups.length > 0 && (
|
{["corporate", "teacher", "mastercorporate"].includes(user.type) && groups.length > 0 && (
|
||||||
@@ -209,16 +197,7 @@ export default function Stats() {
|
|||||||
.filter((x) => groups.flatMap((y) => y.participants).includes(x.id))
|
.filter((x) => groups.flatMap((y) => y.participants).includes(x.id))
|
||||||
.map((x) => ({value: x.id, label: `${x.name} - ${x.email}`}))}
|
.map((x) => ({value: x.id, label: `${x.name} - ${x.email}`}))}
|
||||||
defaultValue={{value: user.id, label: `${user.name} - ${user.email}`}}
|
defaultValue={{value: user.id, label: `${user.name} - ${user.email}`}}
|
||||||
onChange={(value) => setStatsUserId(value?.value)}
|
onChange={(value) => setStatsUserId(value?.value || user.id)}
|
||||||
menuPortalTarget={document?.body}
|
|
||||||
styles={{
|
|
||||||
menuPortal: (base) => ({...base, zIndex: 9999}),
|
|
||||||
option: (styles, state) => ({
|
|
||||||
...styles,
|
|
||||||
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
|
|
||||||
color: state.isFocused ? "black" : styles.color,
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -16,32 +16,20 @@ import Head from "next/head";
|
|||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {BsArrowDown, BsArrowUp} from "react-icons/bs";
|
import {BsArrowDown, BsArrowUp} from "react-icons/bs";
|
||||||
import {ToastContainer} from "react-toastify";
|
import {ToastContainer} from "react-toastify";
|
||||||
|
import { requestUser } from "@/utils/api";
|
||||||
|
import { redirect } from "@/utils";
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<TicketWithCorporate>();
|
const columnHelper = createColumnHelper<TicketWithCorporate>();
|
||||||
|
|
||||||
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) || !["admin", "developer", "agent"].includes(user.type))
|
||||||
return {
|
return redirect("/")
|
||||||
redirect: {
|
|
||||||
destination: "/login",
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldRedirectHome(user) || !["admin", "developer", "agent"].includes(user.type)) {
|
|
||||||
return {
|
|
||||||
redirect: {
|
|
||||||
destination: "/",
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {user: req.session.user},
|
props: {user},
|
||||||
};
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
|
|||||||
@@ -31,30 +31,17 @@ import {uniqBy} from "lodash";
|
|||||||
import {getExamById} from "@/utils/exams";
|
import {getExamById} from "@/utils/exams";
|
||||||
import {convertToUserSolutions} from "@/utils/stats";
|
import {convertToUserSolutions} from "@/utils/stats";
|
||||||
import {sortByModule} from "@/utils/moduleUtils";
|
import {sortByModule} from "@/utils/moduleUtils";
|
||||||
|
import { requestUser } from "@/utils/api";
|
||||||
|
import { redirect, serialize } from "@/utils";
|
||||||
|
|
||||||
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)) redirect("/")
|
||||||
return {
|
|
||||||
redirect: {
|
|
||||||
destination: "/login",
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldRedirectHome(user)) {
|
|
||||||
return {
|
|
||||||
redirect: {
|
|
||||||
destination: "/",
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {user: req.session.user},
|
props: serialize({user}),
|
||||||
};
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
|
|||||||
@@ -20,34 +20,30 @@ import TrainingScore from "@/training/TrainingScore";
|
|||||||
import ModuleBadge from "@/components/ModuleBadge";
|
import ModuleBadge from "@/components/ModuleBadge";
|
||||||
import RecordFilter from "@/components/Medium/RecordFilter";
|
import RecordFilter from "@/components/Medium/RecordFilter";
|
||||||
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||||
|
import { mapBy, redirect, serialize } from "@/utils";
|
||||||
|
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
||||||
|
import { getAssignmentsByAssignee } from "@/utils/assignments.be";
|
||||||
|
import { getEntitiesUsers } from "@/utils/users.be";
|
||||||
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
|
import { Assignment } from "@/interfaces/results";
|
||||||
|
import { requestUser } from "@/utils/api";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
|
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)) return redirect("/")
|
||||||
return {
|
|
||||||
redirect: {
|
|
||||||
destination: "/login",
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldRedirectHome(user)) {
|
const entityIDs = mapBy(user.entities, 'id')
|
||||||
return {
|
const entities = await getEntitiesWithRoles(entityIDs)
|
||||||
redirect: {
|
const users = await getEntitiesUsers(entityIDs)
|
||||||
destination: "/",
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {user: req.session.user},
|
props: serialize({user, users, entities}),
|
||||||
};
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
const Training: React.FC<{user: User}> = ({user}) => {
|
const Training: React.FC<{user: User, entities: EntityWithRoles[], users: User[] }> = ({user, entities, users}) => {
|
||||||
const [recordUserId, setRecordTraining] = useRecordStore((state) => [state.selectedUser, state.setTraining]);
|
const [recordUserId, setRecordTraining] = useRecordStore((state) => [state.selectedUser, state.setTraining]);
|
||||||
const [filter, setFilter] = useState<"months" | "weeks" | "days" | "assignments">();
|
const [filter, setFilter] = useState<"months" | "weeks" | "days" | "assignments">();
|
||||||
|
|
||||||
@@ -193,7 +189,7 @@ const Training: React.FC<{user: User}> = ({user}) => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<RecordFilter user={user} filterState={{filter: filter, setFilter: setFilter}} assignments={false}>
|
<RecordFilter users={users} entities={entities} user={user} filterState={{filter: filter, setFilter: setFilter}} assignments={false}>
|
||||||
{user.type === "student" && (
|
{user.type === "student" && (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
|
|||||||
@@ -1,46 +1,42 @@
|
|||||||
import Layout from "@/components/High/Layout";
|
import Layout from "@/components/High/Layout";
|
||||||
import useUser from "@/hooks/useUser";
|
import useUser from "@/hooks/useUser";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
|
import { Type, User } from "@/interfaces/user";
|
||||||
import {sessionOptions} from "@/lib/session";
|
import {sessionOptions} from "@/lib/session";
|
||||||
import useFilterStore from "@/stores/listFilterStore";
|
import useFilterStore from "@/stores/listFilterStore";
|
||||||
|
import { redirect, serialize } from "@/utils";
|
||||||
|
import { requestUser } from "@/utils/api";
|
||||||
|
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||||
import {withIronSessionSsr} from "iron-session/next";
|
import {withIronSessionSsr} from "iron-session/next";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import {useRouter} from "next/router";
|
import {useRouter} from "next/router";
|
||||||
import {useEffect} from "react";
|
import {useEffect} from "react";
|
||||||
import {BsArrowLeft} from "react-icons/bs";
|
import {BsArrowLeft, BsChevronLeft} from "react-icons/bs";
|
||||||
import {ToastContainer} from "react-toastify";
|
import {ToastContainer} from "react-toastify";
|
||||||
import UserList from "../(admin)/Lists/UserList";
|
import UserList from "../(admin)/Lists/UserList";
|
||||||
|
|
||||||
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)
|
||||||
|
if (!user) return redirect("/login")
|
||||||
|
|
||||||
const envVariables: {[key: string]: string} = {};
|
if (shouldRedirectHome(user)) return redirect("/")
|
||||||
Object.keys(process.env)
|
|
||||||
.filter((x) => x.startsWith("NEXT_PUBLIC"))
|
|
||||||
.forEach((x: string) => {
|
|
||||||
envVariables[x] = process.env[x]!;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
const {type} = query as {type?: Type}
|
||||||
return {
|
|
||||||
redirect: {
|
|
||||||
destination: "/login",
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {user: req.session.user, envVariables},
|
props: serialize({user, type}),
|
||||||
};
|
};
|
||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
export default function UsersListPage() {
|
interface Props {
|
||||||
const {user} = useUser();
|
user: User
|
||||||
|
type?: Type
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UsersListPage({ user, type }: Props) {
|
||||||
const [filters, clearFilters] = useFilterStore((state) => [state.userFilters, state.clearUserFilters]);
|
const [filters, clearFilters] = useFilterStore((state) => [state.userFilters, state.clearUserFilters]);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -55,28 +51,26 @@ export default function UsersListPage() {
|
|||||||
</Head>
|
</Head>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
|
|
||||||
{user && (
|
|
||||||
<Layout user={user}>
|
<Layout user={user}>
|
||||||
<UserList
|
<UserList
|
||||||
user={user}
|
user={user}
|
||||||
|
type={type}
|
||||||
filters={filters.map((f) => f.filter)}
|
filters={filters.map((f) => f.filter)}
|
||||||
renderHeader={(total) => (
|
renderHeader={(total) => (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
clearFilters();
|
clearFilters()
|
||||||
router.back();
|
router.back()
|
||||||
}}
|
}}
|
||||||
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
|
||||||
<BsArrowLeft className="text-xl" />
|
<BsChevronLeft />
|
||||||
<span>Back</span>
|
</button>
|
||||||
</div>
|
<h2 className="font-bold text-2xl">Users ({ total })</h2>
|
||||||
<h2 className="text-2xl font-semibold">Users ({total})</h2>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Layout>
|
</Layout>
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
90
src/pages/users/performance.tsx
Normal file
90
src/pages/users/performance.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
|
||||||
|
import useGroups from "@/hooks/useGroups";
|
||||||
|
import useUsers, {userHashStudent} from "@/hooks/useUsers";
|
||||||
|
import {Group, Stat, StudentUser, User} from "@/interfaces/user";
|
||||||
|
import {getUserCompanyName} from "@/resources/user";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {useRouter} from "next/router";
|
||||||
|
import {BsArrowLeft, BsArrowRepeat, BsChevronLeft} from "react-icons/bs";
|
||||||
|
import { mapBy, serialize } from "@/utils";
|
||||||
|
import {withIronSessionSsr} from "iron-session/next";
|
||||||
|
import { getEntitiesUsers, getUsers } from "@/utils/users.be";
|
||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
import { checkAccess } from "@/utils/permissions";
|
||||||
|
import { getEntities } from "@/utils/entities.be";
|
||||||
|
import { Entity } from "@/interfaces/entity";
|
||||||
|
import { getParticipantGroups, getParticipantsGroups } from "@/utils/groups.be";
|
||||||
|
import StudentPerformanceList from "../(admin)/Lists/StudentPerformanceList";
|
||||||
|
import Head from "next/head";
|
||||||
|
import { ToastContainer } from "react-toastify";
|
||||||
|
import Layout from "@/components/High/Layout";
|
||||||
|
import { requestUser } from "@/utils/api";
|
||||||
|
import { redirect } from "@/utils";
|
||||||
|
|
||||||
|
export const getServerSideProps = withIronSessionSsr(async ({req, res, query}) => {
|
||||||
|
const user = await requestUser(req, res)
|
||||||
|
if (!user) return redirect("/login")
|
||||||
|
|
||||||
|
const entityIDs = mapBy(user.entities, 'id')
|
||||||
|
|
||||||
|
const entities = await getEntities(checkAccess(user, ["admin", 'developer']) ? undefined : entityIDs)
|
||||||
|
const students = await (checkAccess(user, ["admin", 'developer'])
|
||||||
|
? getUsers({type: 'student'})
|
||||||
|
: getEntitiesUsers(entityIDs, {type: 'student'})
|
||||||
|
)
|
||||||
|
const groups = await getParticipantsGroups(mapBy(students, 'id'))
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: serialize({user, students, entities, groups}),
|
||||||
|
};
|
||||||
|
}, sessionOptions);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: User;
|
||||||
|
students: StudentUser[]
|
||||||
|
entities: Entity[]
|
||||||
|
groups: Group[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const StudentPerformance = ({user, students, entities, groups}: Props) => {
|
||||||
|
const {data: stats} = useFilterRecordsByUser<Stat[]>();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const performanceStudents = students.map((u) => ({
|
||||||
|
...u,
|
||||||
|
group: groups.find((x) => x.participants.includes(u.id))?.name || "N/A",
|
||||||
|
entitiesLabel: mapBy(u.entities, 'id').map((id) => entities.find((e) => e.id === id)?.label).filter((e) => !!e).join(', '),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<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="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
router.back()
|
||||||
|
}}
|
||||||
|
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
|
||||||
|
<BsChevronLeft />
|
||||||
|
</button>
|
||||||
|
<h2 className="font-bold text-2xl">Student Performance ({ students.length })</h2>
|
||||||
|
</div>
|
||||||
|
<StudentPerformanceList items={performanceStudents} stats={stats} />
|
||||||
|
</Layout>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StudentPerformance;
|
||||||
199
src/pages/v1/index.tsx
Normal file
199
src/pages/v1/index.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import Head from "next/head";
|
||||||
|
import Navbar from "@/components/Navbar";
|
||||||
|
import { BsFileEarmarkText, BsPencil, BsStar, BsBook, BsHeadphones, BsPen, BsMegaphone } from "react-icons/bs";
|
||||||
|
import { withIronSessionSsr } from "iron-session/next";
|
||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { averageScore, groupBySession, totalExams } from "@/utils/stats";
|
||||||
|
import useUser from "@/hooks/useUser";
|
||||||
|
import Diagnostic from "@/components/Diagnostic";
|
||||||
|
import { ToastContainer } from "react-toastify";
|
||||||
|
import { capitalize } from "lodash";
|
||||||
|
import { Module } from "@/interfaces";
|
||||||
|
import ProgressBar from "@/components/Low/ProgressBar";
|
||||||
|
import Layout from "@/components/High/Layout";
|
||||||
|
import { calculateAverageLevel } from "@/utils/score";
|
||||||
|
import axios from "axios";
|
||||||
|
import DemographicInformationInput from "@/components/DemographicInformationInput";
|
||||||
|
import moment from "moment";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { MODULE_ARRAY } from "@/utils/moduleUtils";
|
||||||
|
import ProfileSummary from "@/components/ProfileSummary";
|
||||||
|
import StudentDashboard from "@/dashboards/Student";
|
||||||
|
import AdminDashboard from "@/dashboards/Admin";
|
||||||
|
import CorporateDashboard from "@/dashboards/Corporate";
|
||||||
|
import TeacherDashboard from "@/dashboards/Teacher";
|
||||||
|
import AgentDashboard from "@/dashboards/Agent";
|
||||||
|
import MasterCorporateDashboard from "@/dashboards/MasterCorporate";
|
||||||
|
import PaymentDue from "../(status)/PaymentDue";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { PayPalScriptProvider } from "@paypal/react-paypal-js";
|
||||||
|
import { CorporateUser, MasterCorporateUser, Type, User, userTypes } from "@/interfaces/user";
|
||||||
|
import Select from "react-select";
|
||||||
|
import { USER_TYPE_LABELS } from "@/resources/user";
|
||||||
|
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
||||||
|
import { getUserCorporate } from "@/utils/groups.be";
|
||||||
|
import { getUsers } from "@/utils/users.be";
|
||||||
|
import { requestUser } from "@/utils/api";
|
||||||
|
import { redirect, serialize } from "@/utils";
|
||||||
|
|
||||||
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||||
|
const user = await requestUser(req, res)
|
||||||
|
if (!user) return redirect("/login")
|
||||||
|
|
||||||
|
const linkedCorporate = (await getUserCorporate(user.id)) || null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: serialize({ user, linkedCorporate }),
|
||||||
|
};
|
||||||
|
}, sessionOptions);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: User;
|
||||||
|
linkedCorporate?: CorporateUser | MasterCorporateUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home({ user: propsUser, linkedCorporate }: Props) {
|
||||||
|
const [user, setUser] = useState(propsUser);
|
||||||
|
const [showDiagnostics, setShowDiagnostics] = useState(false);
|
||||||
|
const [showDemographicInput, setShowDemographicInput] = useState(false);
|
||||||
|
const [selectedScreen, setSelectedScreen] = useState<Type>("admin");
|
||||||
|
|
||||||
|
const { mutateUser } = useUser({ redirectTo: "/login" });
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
// setShowDemographicInput(!user.demographicInformation || !user.demographicInformation.country || !user.demographicInformation.phone);
|
||||||
|
setShowDiagnostics(user.isFirstLogin && user.type === "student");
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const checkIfUserExpired = () => {
|
||||||
|
const expirationDate = user!.subscriptionExpirationDate;
|
||||||
|
|
||||||
|
if (expirationDate === null || expirationDate === undefined) return false;
|
||||||
|
if (moment(expirationDate).isAfter(moment(new Date()))) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (user && (user.status === "paymentDue" || user.status === "disabled" || checkIfUserExpired())) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
{user.status === "disabled" && (
|
||||||
|
<Layout user={user} navDisabled>
|
||||||
|
<div className="flex flex-col items-center justify-center text-center w-full gap-4">
|
||||||
|
<span className="font-bold text-lg">Your account has been disabled!</span>
|
||||||
|
<span>Please contact an administrator if you believe this to be a mistake.</span>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
)}
|
||||||
|
{(user.status === "paymentDue" || checkIfUserExpired()) && <PaymentDue hasExpired user={user} reload={router.reload} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user && showDemographicInput) {
|
||||||
|
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>
|
||||||
|
<Layout user={user} navDisabled>
|
||||||
|
<DemographicInformationInput
|
||||||
|
mutateUser={(user) => {
|
||||||
|
setUser(user);
|
||||||
|
mutateUser(user);
|
||||||
|
}}
|
||||||
|
user={user}
|
||||||
|
/>
|
||||||
|
</Layout>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user && showDiagnostics) {
|
||||||
|
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>
|
||||||
|
<Layout user={user} navDisabled>
|
||||||
|
<Diagnostic user={user} onFinish={() => setShowDiagnostics(false)} />
|
||||||
|
</Layout>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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}>
|
||||||
|
{checkAccess(user, ["student"]) && <StudentDashboard linkedCorporate={linkedCorporate} user={user} />}
|
||||||
|
{checkAccess(user, ["teacher"]) && <TeacherDashboard linkedCorporate={linkedCorporate} user={user} />}
|
||||||
|
{checkAccess(user, ["corporate"]) && <CorporateDashboard linkedCorporate={linkedCorporate} user={user as CorporateUser} />}
|
||||||
|
{checkAccess(user, ["mastercorporate"]) && <MasterCorporateDashboard user={user as MasterCorporateUser} />}
|
||||||
|
{checkAccess(user, ["agent"]) && <AgentDashboard user={user} />}
|
||||||
|
{checkAccess(user, ["admin"]) && <AdminDashboard user={user} />}
|
||||||
|
{checkAccess(user, ["developer"]) && (
|
||||||
|
<>
|
||||||
|
<Select
|
||||||
|
options={userTypes.map((u) => ({
|
||||||
|
value: u,
|
||||||
|
label: USER_TYPE_LABELS[u],
|
||||||
|
}))}
|
||||||
|
value={{
|
||||||
|
value: selectedScreen,
|
||||||
|
label: USER_TYPE_LABELS[selectedScreen],
|
||||||
|
}}
|
||||||
|
onChange={(value) => (value ? setSelectedScreen(value.value) : setSelectedScreen("admin"))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selectedScreen === "student" && <StudentDashboard linkedCorporate={linkedCorporate} user={user} />}
|
||||||
|
{selectedScreen === "teacher" && <TeacherDashboard linkedCorporate={linkedCorporate} user={user} />}
|
||||||
|
{selectedScreen === "corporate" && (
|
||||||
|
<CorporateDashboard linkedCorporate={linkedCorporate} user={user as unknown as CorporateUser} />
|
||||||
|
)}
|
||||||
|
{selectedScreen === "mastercorporate" && <MasterCorporateDashboard user={user as unknown as MasterCorporateUser} />}
|
||||||
|
{selectedScreen === "agent" && <AgentDashboard user={user} />}
|
||||||
|
{selectedScreen === "admin" && <AdminDashboard user={user} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/resources/entityPermissions.ts
Normal file
45
src/resources/entityPermissions.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
export type RolePermission =
|
||||||
|
"view_students" |
|
||||||
|
"view_teachers" |
|
||||||
|
"view_corporates" |
|
||||||
|
"view_mastercorporates" |
|
||||||
|
"edit_students" |
|
||||||
|
"edit_teachers" |
|
||||||
|
"edit_corporates" |
|
||||||
|
"edit_mastercorporates" |
|
||||||
|
"delete_students" |
|
||||||
|
"delete_teachers" |
|
||||||
|
"delete_corporates" |
|
||||||
|
"delete_mastercorporates" |
|
||||||
|
"generate_reading" |
|
||||||
|
"delete_reading" |
|
||||||
|
"generate_listening" |
|
||||||
|
"delete_listening" |
|
||||||
|
"generate_writing" |
|
||||||
|
"delete_writing" |
|
||||||
|
"generate_speaking" |
|
||||||
|
"delete_speaking" |
|
||||||
|
"generate_level" |
|
||||||
|
"delete_level" |
|
||||||
|
"view_classrooms" |
|
||||||
|
"create_classroom" |
|
||||||
|
"rename_classrooms" |
|
||||||
|
"add_to_classroom" |
|
||||||
|
"remove_from_classroom" |
|
||||||
|
"delete_classroom" |
|
||||||
|
"view_entities" | "rename_entity" |
|
||||||
|
"add_to_entity" |
|
||||||
|
"remove_from_entity" |
|
||||||
|
"delete_entity" |
|
||||||
|
"view_entity_roles" |
|
||||||
|
"create_entity_role" |
|
||||||
|
"rename_entity_role" |
|
||||||
|
"edit_role_permissions" |
|
||||||
|
"assign_to_role" |
|
||||||
|
"delete_entity_role" |
|
||||||
|
"view_assignments" |
|
||||||
|
"create_assignment" |
|
||||||
|
"edit_assignment" |
|
||||||
|
"delete_assignment" |
|
||||||
|
"start_assignment" |
|
||||||
|
"archive_assignment";
|
||||||
@@ -1,37 +1,37 @@
|
|||||||
export const AVATARS = [
|
export const AVATARS = [
|
||||||
{
|
{
|
||||||
name: "Matthew Noah",
|
name: "Gia",
|
||||||
id: "5912afa7c77c47d3883af3d874047aaf",
|
id: "gia.business",
|
||||||
gender: "male",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Vera Cerise",
|
|
||||||
id: "9e58d96a383e4568a7f1e49df549e0e4",
|
|
||||||
gender: "female",
|
gender: "female",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Edward Tony",
|
name: "Vadim",
|
||||||
id: "d2cdd9c0379a4d06ae2afb6e5039bd0c",
|
id: "vadim.business",
|
||||||
gender: "male",
|
gender: "male",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Tanya Molly",
|
name: "Orhan",
|
||||||
id: "045cb5dcd00042b3a1e4f3bc1c12176b",
|
id: "orhan.business",
|
||||||
gender: "female",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Kayla Abbi",
|
|
||||||
id: "1ae1e5396cc444bfad332155fdb7a934",
|
|
||||||
gender: "female",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Jerome Ryan",
|
|
||||||
id: "0ee6aa7cc1084063a630ae514fccaa31",
|
|
||||||
gender: "male",
|
gender: "male",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Tyler Christopher",
|
name: "Flora",
|
||||||
id: "5772cff935844516ad7eeff21f839e43",
|
id: "flora.business",
|
||||||
|
gender: "female",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Scarlett",
|
||||||
|
id: "scarlett.business",
|
||||||
|
gender: "female",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Parker",
|
||||||
|
id: "parker.casual",
|
||||||
|
gender: "male",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Ethan",
|
||||||
|
id: "ethan.business",
|
||||||
gender: "male",
|
gender: "male",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
16
src/utils/api.ts
Normal file
16
src/utils/api.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { User } from "@/interfaces/user";
|
||||||
|
import { IncomingMessage, ServerResponse } from "http";
|
||||||
|
import { IronSession } from "iron-session";
|
||||||
|
import { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { getUser } from "./users.be";
|
||||||
|
|
||||||
|
|
||||||
|
export async function requestUser(req: NextApiRequest | IncomingMessage, res: NextApiResponse | ServerResponse): Promise<User | undefined> {
|
||||||
|
if (!req.session.user) return undefined
|
||||||
|
const user = await getUser(req.session.user.id)
|
||||||
|
|
||||||
|
req.session.user = user
|
||||||
|
req.session.save()
|
||||||
|
|
||||||
|
return user
|
||||||
|
}
|
||||||
@@ -18,10 +18,22 @@ export const getAssignmentsByAssigner = async (id: string, startDate?: Date, end
|
|||||||
|
|
||||||
return await db.collection("assignments").find<Assignment>(query).toArray();
|
return await db.collection("assignments").find<Assignment>(query).toArray();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAssignments = async () => {
|
export const getAssignments = async () => {
|
||||||
return await db.collection("assignments").find<Assignment>({}).toArray();
|
return await db.collection("assignments").find<Assignment>({}).toArray();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getAssignment = async (id: string) => {
|
||||||
|
return await db.collection("assignments").findOne<Assignment>({id});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAssignmentsByAssignee = async (id: string, filter?: {[key in keyof Partial<Assignment>]: any}) => {
|
||||||
|
return await db
|
||||||
|
.collection("assignments")
|
||||||
|
.find<Assignment>({assignees: [id], ...(!filter ? {} : filter)})
|
||||||
|
.toArray();
|
||||||
|
};
|
||||||
|
|
||||||
export const getAssignmentsByAssignerBetweenDates = async (id: string, startDate: Date, endDate: Date) => {
|
export const getAssignmentsByAssignerBetweenDates = async (id: string, startDate: Date, endDate: Date) => {
|
||||||
return await db.collection("assignments").find<Assignment>({assigner: id}).toArray();
|
return await db.collection("assignments").find<Assignment>({assigner: id}).toArray();
|
||||||
};
|
};
|
||||||
@@ -37,6 +49,17 @@ export const getAssignmentsByAssigners = async (ids: string[], startDate?: Date,
|
|||||||
.toArray();
|
.toArray();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getEntityAssignments = async (id: string) => {
|
||||||
|
return await db.collection("assignments").find<Assignment>({entity: id}).toArray();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getEntitiesAssignments = async (ids: string[]) => {
|
||||||
|
return await db
|
||||||
|
.collection("assignments")
|
||||||
|
.find<Assignment>({entity: {$in: ids}})
|
||||||
|
.toArray();
|
||||||
|
};
|
||||||
|
|
||||||
export const getAssignmentsForCorporates = async (userType: Type, idsList: string[], startDate?: Date, endDate?: Date) => {
|
export const getAssignmentsForCorporates = async (userType: Type, idsList: string[], startDate?: Date, endDate?: Date) => {
|
||||||
const assigners = await Promise.all(
|
const assigners = await Promise.all(
|
||||||
idsList.map(async (id) => {
|
idsList.map(async (id) => {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user