Continued updating the code to work with entities better

This commit is contained in:
Tiago Ribeiro
2024-10-07 15:49:58 +01:00
parent b5200c88fc
commit 1ef4efcacf
36 changed files with 2489 additions and 3012 deletions

View File

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

View File

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

View File

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

View File

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

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

@@ -0,0 +1,23 @@
import { EntityWithRoles } from "@/interfaces/entity";
import { Discount } from "@/interfaces/paypal";
import { Code, Group, User } from "@/interfaces/user";
import axios from "axios";
import { useEffect, useState } from "react";
export default function useEntities(creator?: string) {
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, [creator]);
return { entities, isLoading, isError, reload: getData };
}

View File

@@ -0,0 +1,23 @@
import { EntityWithRoles, WithEntity, WithLabeledEntities } from "@/interfaces/entity";
import { Discount } from "@/interfaces/paypal";
import { Code, Group, Type, User } from "@/interfaces/user";
import axios from "axios";
import { useEffect, useState } from "react";
export default function useEntitiesGroups() {
const [groups, setGroups] = useState<WithEntity<Group>[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = () => {
setIsLoading(true);
axios
.get<WithEntity<Group>[]>(`/api/entities/groups`)
.then((response) => setGroups(response.data))
.finally(() => setIsLoading(false));
};
useEffect(getData, []);
return { groups, isLoading, isError, reload: getData };
}

View File

@@ -0,0 +1,23 @@
import { EntityWithRoles, WithLabeledEntities } from "@/interfaces/entity";
import { Discount } from "@/interfaces/paypal";
import { Code, Group, Type, User } from "@/interfaces/user";
import axios from "axios";
import { useEffect, useState } from "react";
export default function useEntitiesUsers(type?: Type) {
const [users, setUsers] = useState<WithLabeledEntities<User>[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = () => {
setIsLoading(true);
axios
.get<WithLabeledEntities<User>[]>(`/api/entities/users${type ? "?type=" + type : ""}`)
.then((response) => setUsers(response.data))
.finally(() => setIsLoading(false));
};
useEffect(getData, [type]);
return { users, isLoading, isError, reload: getData };
}

View File

@@ -1,19 +1,28 @@
export interface Entity { export interface Entity {
id: string; id: string;
label: string; label: string;
} }
export interface Role { export interface Role {
id: string; id: string;
entityID: string; entityID: string;
permissions: string[]; permissions: string[];
label: string; label: string;
} }
export interface EntityWithRoles extends Entity { export interface EntityWithRoles extends Entity {
roles: Role[]; roles: Role[];
} };
export type WithEntity<T> = T extends {entities: {id: string; role: string}[]} export type WithLabeledEntities<T> = T extends { entities: { id: string; role: string }[] }
? Omit<T, "entities"> & {entities: {entity?: Entity; role?: Role}[]} ? Omit<T, "entities"> & { entities: { id: string; label?: string; role: string, roleLabel?: string }[] }
: T; : T;
export type WithEntity<T> = T extends { entity?: string }
? Omit<T, "entity"> & { entity: Entity }
: T;
export type WithEntities<T> = T extends { entities: { id: string; role: string }[] }
? Omit<T, "entities"> & { entities: { entity?: Entity; role?: Role }[] }
: T;

View File

@@ -3,373 +3,300 @@ 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[];
group?: Group; group?: Group;
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]);
useEffect(() => { useEffect(() => {
if (filesContent.length > 0) { if (filesContent.length > 0) {
setIsLoading(true); setIsLoading(true);
const file = filesContent[0]; const file = filesContent[0];
readXlsxFile(file.content).then((rows) => { readXlsxFile(file.content).then((rows) => {
const emails = uniq( const emails = uniq(
rows rows
.map((row) => { .map((row) => {
const [email] = row as string[]; const [email] = row as string[];
return EMAIL_REGEX.test(email) && !users.map((u) => u.email).includes(email) ? email.toString().trim() : undefined; return EMAIL_REGEX.test(email) && !users.map((u) => u.email).includes(email) ? email.toString().trim() : undefined;
}) })
.filter((x) => !!x), .filter((x) => !!x),
); );
if (emails.length === 0) { if (emails.length === 0) {
toast.error("Please upload an Excel file containing e-mails!"); toast.error("Please upload an Excel file containing e-mails!");
clear(); clear();
setIsLoading(false); setIsLoading(false);
return; return;
} }
const emailUsers = [...new Set(emails)].map((x) => users.find((y) => y.email.toLowerCase() === x)).filter((x) => x !== undefined); const emailUsers = [...new Set(emails)].map((x) => users.find((y) => y.email.toLowerCase() === x)).filter((x) => x !== undefined);
const filteredUsers = emailUsers.filter( const filteredUsers = emailUsers.filter(
(x) => (x) =>
((user.type === "developer" || user.type === "admin" || user.type === "corporate" || user.type === "mastercorporate") && ((user.type === "developer" || user.type === "admin" || user.type === "corporate" || user.type === "mastercorporate") &&
(x?.type === "student" || x?.type === "teacher")) || (x?.type === "student" || x?.type === "teacher")) ||
(user.type === "teacher" && x?.type === "student"), (user.type === "teacher" && x?.type === "student"),
); );
setParticipants(filteredUsers.filter((x) => !!x).map((x) => x!.id)); setParticipants(filteredUsers.filter((x) => !!x).map((x) => x!.id));
toast.success( toast.success(
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);
}); });
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [filesContent, user.type, users]); }, [filesContent, user.type, users]);
const submit = () => { const submit = () => {
setIsLoading(true); setIsLoading(true);
if (name !== group?.name && (name?.trim() === "Students" || name?.trim() === "Teachers" || name?.trim() === "Corporate")) { if (name !== group?.name && (name?.trim() === "Students" || name?.trim() === "Teachers" || name?.trim() === "Corporate")) {
toast.error("That group name is reserved and cannot be used, please enter another one."); toast.error("That group name is reserved and cannot be used, please enter another one.");
setIsLoading(false); setIsLoading(false);
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;
}) })
.catch(() => { .catch(() => {
toast.error("Something went wrong, please try again later!"); toast.error("Something went wrong, please try again later!");
return false; return false;
}) })
.finally(() => { .finally(() => {
setIsLoading(false); setIsLoading(false);
onClose(); onClose();
}); });
}; };
return ( return (
<div className="mt-4 flex w-full flex-col gap-12 px-4 py-2"> <div className="mt-4 flex w-full flex-col gap-12 px-4 py-2">
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<Input name="name" type="text" label="Name" defaultValue={name} onChange={setName} required disabled={group?.disableEditing} /> <Input name="name" type="text" label="Name" defaultValue={name} onChange={setName} required disabled={group?.disableEditing} />
<div className="flex w-full flex-col gap-3"> <div className="flex w-full flex-col gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-mti-gray-dim text-base font-normal">Participants</label> <label className="text-mti-gray-dim text-base font-normal">Participants</label>
<div className="tooltip" data-tip="The Excel file should only include a column with the desired e-mails."> <div className="tooltip" data-tip="The Excel file should only include a column with the desired e-mails.">
<BsQuestionCircleFill /> <BsQuestionCircleFill />
</div> </div>
</div> </div>
<div className="flex w-full gap-8"> <div className="flex w-full gap-8">
<Select <Select
className="w-full" className="w-full"
value={participants.map((x) => ({ value={participants.map((x) => ({
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}`,
}))} }))}
placeholder="Participants..." placeholder="Participants..."
defaultValue={participants.map((x) => ({ defaultValue={participants.map((x) => ({
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",
padding: "1rem 1.5rem", padding: "1rem 1.5rem",
zIndex: "40", zIndex: "40",
}), }),
}} }}
/> />
{user.type !== "teacher" && ( {user.type !== "teacher" && (
<Button className="w-full max-w-[300px] h-fit" onClick={openFilePicker} isLoading={isLoading} variant="outline"> <Button className="w-full max-w-[300px] h-fit" onClick={openFilePicker} isLoading={isLoading} variant="outline">
{filesContent.length === 0 ? "Upload participants Excel file" : filesContent[0].name} {filesContent.length === 0 ? "Upload participants Excel file" : filesContent[0].name}
</Button> </Button>
)} )}
</div> </div>
</div> </div>
</div> </div>
<div className="mt-8 flex w-full items-center justify-end gap-8"> <div className="mt-8 flex w-full items-center justify-end gap-8">
<Button variant="outline" color="red" className="w-full max-w-[200px]" isLoading={isLoading} onClick={onClose}> <Button variant="outline" color="red" className="w-full max-w-[200px]" isLoading={isLoading} onClick={onClose}>
Cancel Cancel
</Button> </Button>
<Button className="w-full max-w-[200px]" onClick={submit} isLoading={isLoading} disabled={!name}> <Button className="w-full max-w-[200px]" onClick={submit} isLoading={isLoading} disabled={!name}>
Submit Submit
</Button> </Button>
</div> </div>
</div> </div>
); );
}; };
const filterTypes = ["corporate", "teacher", "mastercorporate"]; export default function GroupList({ user }: { user: User }) {
const [isCreating, setIsCreating] = useState(false);
const [editingGroup, setEditingGroup] = useState<Group>();
const [viewingAllParticipants, setViewingAllParticipants] = useState<string>();
export default function GroupList({user}: {user: User}) { const { permissions } = usePermissions(user?.id || "");
const [isCreating, setIsCreating] = useState(false);
const [editingGroup, setEditingGroup] = useState<Group>();
const [viewingAllParticipants, setViewingAllParticipants] = useState<string>();
const {permissions} = usePermissions(user?.id || ""); const { users } = useEntitiesUsers();
const { groups, reload } = useEntitiesGroups();
const {users} = useUsers(); const deleteGroup = (group: Group) => {
const {groups, reload} = useGroups({ if (!confirm(`Are you sure you want to delete "${group.name}"?`)) return;
admin: user && filterTypes.includes(user?.type) ? user.id : undefined,
userType: user?.type,
});
const {groups: corporateGroups} = useGroups({ axios
admin: user && filterTypes.includes(user?.type) ? user.id : undefined, .delete<{ ok: boolean }>(`/api/groups/${group.id}`)
userType: user?.type, .then(() => toast.success(`Group "${group.name}" deleted successfully`))
adminAdmins: user?.id, .catch(() => toast.error("Something went wrong, please try again later!"))
}); .finally(reload);
};
const {rows: filteredRows, renderSearch} = useListSearch<Group>(searchFields, groups); const defaultColumns = [
columnHelper.accessor("id", {
header: "ID",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("name", {
header: "Name",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("admin", {
header: "Admin",
cell: (info) => (
<div className="tooltip" data-tip={USER_TYPE_LABELS[users.find((x) => x.id === info.getValue())?.type || "student"]}>
{users.find((x) => x.id === info.getValue())?.name}
</div>
),
}),
columnHelper.accessor("entity.label", {
header: "Entity",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("participants", {
header: "Participants",
cell: (info) => (
<span>
{info
.getValue()
.slice(0, viewingAllParticipants === info.row.original.id ? undefined : 5)
.map((x) => users.find((y) => y.id === x)?.name)
.join(", ")}
{info.getValue().length > 5 && viewingAllParticipants !== info.row.original.id && (
<button
className="text-mti-purple-light font-bold hover:text-mti-purple-dark transition ease-in-out duration-300"
onClick={() => setViewingAllParticipants(info.row.original.id)}>
, View More
</button>
)}
{info.getValue().length > 5 && viewingAllParticipants === info.row.original.id && (
<button
className="text-mti-purple-light font-bold hover:text-mti-purple-dark transition ease-in-out duration-300"
onClick={() => setViewingAllParticipants(undefined)}>
, View Less
</button>
)}
</span>
),
}),
{
header: "",
id: "actions",
cell: ({ row }: { row: { original: Group } }) => {
return (
<>
{user && (checkAccess(user, ["developer", "admin"]) || user.id === row.original.admin) && (
<div className="flex gap-2">
{(!row.original.disableEditing || checkAccess(user, ["developer", "admin"]), "editGroup") && (
<div data-tip="Edit" className="tooltip cursor-pointer" onClick={() => setEditingGroup(row.original)}>
<BsPencil className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
</div>
)}
{(!row.original.disableEditing || checkAccess(user, ["developer", "admin"]), "deleteGroup") && (
<div data-tip="Delete" className="tooltip cursor-pointer" onClick={() => deleteGroup(row.original)}>
<BsTrash className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
</div>
)}
</div>
)}
</>
);
},
},
];
const deleteGroup = (group: Group) => { const closeModal = () => {
if (!confirm(`Are you sure you want to delete "${group.name}"?`)) return; setIsCreating(false);
setEditingGroup(undefined);
reload();
};
axios return (
.delete<{ok: boolean}>(`/api/groups/${group.id}`) <div className="h-full w-full rounded-xl flex flex-col gap-4">
.then(() => toast.success(`Group "${group.name}" deleted successfully`)) <Modal isOpen={isCreating || !!editingGroup} onClose={closeModal} title={editingGroup ? `Editing ${editingGroup.name}` : "New Group"}>
.catch(() => toast.error("Something went wrong, please try again later!")) <CreatePanel
.finally(reload); group={editingGroup}
}; user={user}
onClose={closeModal}
users={users}
/>
</Modal>
<Table data={groups} columns={defaultColumns} searchFields={searchFields} />
const defaultColumns = [ {checkAccess(user, ["teacher", "corporate", "mastercorporate", "admin", "developer"], permissions, "createGroup") && (
columnHelper.accessor("id", { <button
header: "ID", onClick={() => setIsCreating(true)}
cell: (info) => info.getValue(), className="bg-mti-purple-light hover:bg-mti-purple w-full py-2 text-white transition duration-300 ease-in-out">
}), New Group
columnHelper.accessor("name", { </button>
header: "Name", )}
cell: (info) => info.getValue(), </div>
}), );
columnHelper.accessor("admin", {
header: "Admin",
cell: (info) => (
<div className="tooltip" data-tip={USER_TYPE_LABELS[users.find((x) => x.id === info.getValue())?.type || "student"]}>
{users.find((x) => x.id === info.getValue())?.name}
</div>
),
}),
columnHelper.accessor("admin", {
header: "Linked Corporate",
cell: (info) => <LinkedCorporate userId={info.getValue()} users={users} groups={groups} />,
}),
columnHelper.accessor("participants", {
header: "Participants",
cell: (info) => (
<span>
{info
.getValue()
.slice(0, viewingAllParticipants === info.row.original.id ? undefined : 5)
.map((x) => users.find((y) => y.id === x)?.name)
.join(", ")}
{info.getValue().length > 5 && viewingAllParticipants !== info.row.original.id && (
<button
className="text-mti-purple-light font-bold hover:text-mti-purple-dark transition ease-in-out duration-300"
onClick={() => setViewingAllParticipants(info.row.original.id)}>
, View More
</button>
)}
{info.getValue().length > 5 && viewingAllParticipants === info.row.original.id && (
<button
className="text-mti-purple-light font-bold hover:text-mti-purple-dark transition ease-in-out duration-300"
onClick={() => setViewingAllParticipants(undefined)}>
, View Less
</button>
)}
</span>
),
}),
{
header: "",
id: "actions",
cell: ({row}: {row: {original: Group}}) => {
return (
<>
{user && (checkAccess(user, ["developer", "admin"]) || user.id === row.original.admin) && (
<div className="flex gap-2">
{(!row.original.disableEditing || checkAccess(user, ["developer", "admin"]), "editGroup") && (
<div data-tip="Edit" className="tooltip cursor-pointer" onClick={() => setEditingGroup(row.original)}>
<BsPencil className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
</div>
)}
{(!row.original.disableEditing || checkAccess(user, ["developer", "admin"]), "deleteGroup") && (
<div data-tip="Delete" className="tooltip cursor-pointer" onClick={() => deleteGroup(row.original)}>
<BsTrash className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
</div>
)}
</div>
)}
</>
);
},
},
];
const table = useReactTable({
data: filteredRows,
columns: defaultColumns,
getCoreRowModel: getCoreRowModel(),
});
const closeModal = () => {
setIsCreating(false);
setEditingGroup(undefined);
reload();
};
return (
<div className="h-full w-full rounded-xl flex flex-col gap-4">
<Modal isOpen={isCreating || !!editingGroup} onClose={closeModal} title={editingGroup ? `Editing ${editingGroup.name}` : "New Group"}>
<CreatePanel
group={editingGroup}
user={user}
onClose={closeModal}
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>
{renderSearch()}
<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") && (
<button
onClick={() => setIsCreating(true)}
className="bg-mti-purple-light hover:bg-mti-purple w-full py-2 text-white transition duration-300 ease-in-out">
New Group
</button>
)}
</div>
);
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,288 +1,273 @@
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",
list: [], list: [],
}, },
teacher: { teacher: {
perm: "createCodeTeacher", perm: "createCodeTeacher",
list: [], list: [],
}, },
agent: { agent: {
perm: "createCodeCountryManager", perm: "createCodeCountryManager",
list: ["student", "teacher", "corporate", "mastercorporate"], list: ["student", "teacher", "corporate", "mastercorporate"],
}, },
corporate: { corporate: {
perm: "createCodeCorporate", perm: "createCodeCorporate",
list: ["student", "teacher"], list: ["student", "teacher"],
}, },
mastercorporate: { mastercorporate: {
perm: undefined, perm: undefined,
list: ["student", "teacher", "corporate"], list: ["student", "teacher", "corporate"],
}, },
admin: { admin: {
perm: "createCodeAdmin", perm: "createCodeAdmin",
list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"], list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"],
}, },
developer: { developer: {
perm: undefined, perm: undefined,
list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"], list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"],
}, },
}; };
interface Props { interface Props {
user: User; user: User;
users: User[]; users: User[];
permissions: PermissionType[]; entities: EntityWithRoles[]
onFinish: () => void; permissions: PermissionType[];
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>();
const [passportID, setPassportID] = useState<string>(); const [passportID, setPassportID] = useState<string>();
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 [password, setPassword] = useState<string>();
const [selectedCorporate, setSelectedCorporate] = useState<string | null>(); const [confirmPassword, setConfirmPassword] = useState<string>();
const [password, setPassword] = useState<string>(); const [expiryDate, setExpiryDate] = useState<Date | null>(
const [confirmPassword, setConfirmPassword] = useState<string>(); user?.subscriptionExpirationDate ? moment(user?.subscriptionExpirationDate).toDate() : null,
const [expiryDate, setExpiryDate] = useState<Date | null>( );
user?.subscriptionExpirationDate ? moment(user?.subscriptionExpirationDate).toDate() : null, const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
); const [isLoading, setIsLoading] = useState(false);
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true); const [type, setType] = useState<Type>("student");
const [isLoading, setIsLoading] = useState(false); const [position, setPosition] = useState<string>();
const [type, setType] = useState<Type>("student"); const [entity, setEntity] = useState((entities || [])[0]?.id || undefined)
const [position, setPosition] = useState<string>();
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(() => { const createUser = () => {
setAvailableCorporates( if (!name || name.trim().length === 0) return toast.error("Please enter a valid name!");
uniqBy( if (!email || email.trim().length === 0) return toast.error("Please enter a valid e-mail address!");
users.filter((u) => u.type === "corporate" && groups.flatMap((g) => g.participants).includes(u.id)), if (users.map((x) => x.email).includes(email.trim())) return toast.error("That e-mail is already in use!");
"id", if (!password || password.trim().length < 6) return toast.error("Please enter a valid password!");
), if (password !== confirmPassword) return toast.error("The passwords do not match!");
);
}, [users, groups]);
const createUser = () => { setIsLoading(true);
if (!name || name.trim().length === 0) return toast.error("Please enter a valid name!");
if (!email || email.trim().length === 0) return toast.error("Please enter a valid e-mail address!");
if (users.map((x) => x.email).includes(email.trim())) return toast.error("That e-mail is already in use!");
if (!password || password.trim().length < 6) return toast.error("Please enter a valid password!");
if (password !== confirmPassword) return toast.error("The passwords do not match!");
setIsLoading(true); const body = {
name,
email,
password,
groupID: group,
entity,
type,
studentID: type === "student" ? studentID : undefined,
expiryDate,
demographicInformation: {
passport_id: type === "student" ? passportID : undefined,
phone,
country,
position,
},
};
const body = { axios
name, .post("/api/make_user", body)
email, .then(() => {
password, toast.success("That user has been created!");
groupID: group, onFinish();
corporate: selectedCorporate || user.id,
type,
studentID: type === "student" ? studentID : undefined,
expiryDate,
demographicInformation: {
passport_id: type === "student" ? passportID : undefined,
phone,
country,
position,
},
};
axios setName("");
.post("/api/make_user", body) setEmail("");
.then(() => { setPhone("");
toast.success("That user has been created!"); setPassportID("");
onFinish(); setStudentID("");
setCountry(user?.demographicInformation?.country);
setGroup(null);
setEntity((entities || [])[0]?.id || undefined)
setExpiryDate(user?.subscriptionExpirationDate ? moment(user?.subscriptionExpirationDate).toDate() : null);
setIsExpiryDateEnabled(true);
setType("student");
setPosition(undefined);
})
.catch((error) => {
const data = error?.response?.data;
if (!!data?.message) return toast.error(data.message);
toast.error("Something went wrong! Please try again later!");
})
.finally(() => setIsLoading(false));
};
setName(""); return (
setEmail(""); <div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
setPhone(""); <div className="grid grid-cols-2 gap-4">
setPassportID(""); <Input required label="Name" value={name} onChange={setName} type="text" name="name" placeholder="Name" />
setStudentID(""); <Input label="E-mail" required value={email} onChange={setEmail} type="email" name="email" placeholder="E-mail" />
setCountry(user?.demographicInformation?.country);
setGroup(null);
setSelectedCorporate(null);
setExpiryDate(user?.subscriptionExpirationDate ? moment(user?.subscriptionExpirationDate).toDate() : null);
setIsExpiryDateEnabled(true);
setType("student");
setPosition(undefined);
})
.catch((error) => {
const data = error?.response?.data;
if (!!data?.message) return toast.error(data.message);
toast.error("Something went wrong! Please try again later!");
})
.finally(() => setIsLoading(false));
};
return ( <Input type="password" name="password" label="Password" value={password} onChange={setPassword} placeholder="Password" required />
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl"> <Input
<div className="grid grid-cols-2 gap-4"> type="password"
<Input required label="Name" value={name} onChange={setName} type="text" name="name" placeholder="Name" /> name="confirmPassword"
<Input label="E-mail" required value={email} onChange={setEmail} type="email" name="email" placeholder="E-mail" /> label="Confirm Password"
value={confirmPassword}
onChange={setConfirmPassword}
placeholder="ConfirmPassword"
required
/>
<Input type="password" name="password" label="Password" value={password} onChange={setPassword} placeholder="Password" required /> <div className="flex flex-col gap-4">
<Input <label className="font-normal text-base text-mti-gray-dim">Country *</label>
type="password" <CountrySelect value={country} onChange={setCountry} />
name="confirmPassword" </div>
label="Confirm Password"
value={confirmPassword}
onChange={setConfirmPassword}
placeholder="ConfirmPassword"
required
/>
<div className="flex flex-col gap-4"> <Input type="tel" name="phone" label="Phone number" value={phone} onChange={setPhone} placeholder="Phone number" required />
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
<CountrySelect value={country} onChange={setCountry} />
</div>
<Input type="tel" name="phone" label="Phone number" value={phone} onChange={setPhone} placeholder="Phone number" required /> {type === "student" && (
<>
<Input
type="text"
name="passport_id"
label="Passport/National ID"
onChange={setPassportID}
value={passportID}
placeholder="National ID or Passport number"
required
/>
<Input type="text" name="studentID" label="Student ID" onChange={setStudentID} value={studentID} placeholder="Student ID" />
</>
)}
{type === "student" && ( <div className={clsx("flex flex-col gap-4")}>
<> <label className="font-normal text-base text-mti-gray-dim">Entity</label>
<Input <Select
type="text" defaultValue={{ value: (entities || [])[0]?.id, label: (entities || [])[0]?.label }}
name="passport_id" options={entities.map((e) => ({ value: e.id, label: e.label }))}
label="Passport/National ID" onChange={(e) => setEntity(e?.value || undefined)}
onChange={setPassportID} isClearable={checkAccess(user, ["admin", "developer"])}
value={passportID} />
placeholder="National ID or Passport number" </div>
required
/>
<Input type="text" name="studentID" label="Student ID" onChange={setStudentID} value={studentID} placeholder="Student ID" />
</>
)}
{["student", "teacher"].includes(type) && !["corporate", "teacher"].includes(user?.type) && ( {["corporate", "mastercorporate"].includes(type) && (
<div className={clsx("flex flex-col gap-4")}> <Input type="text" name="department" label="Department" onChange={setPosition} value={position} placeholder="Department" />
<label className="font-normal text-base text-mti-gray-dim">Corporate</label> )}
<Select
options={availableCorporates.map((u) => ({value: u.id, label: getUserName(u)}))}
isClearable
onChange={(e) => setSelectedCorporate(e?.value || undefined)}
/>
</div>
)}
{["corporate", "mastercorporate"].includes(type) && ( <div className={clsx("flex flex-col gap-4")}>
<Input type="text" name="department" label="Department" onChange={setPosition} value={position} placeholder="Department" /> <label className="font-normal text-base text-mti-gray-dim">Group</label>
)} <Select
options={groups
.filter((x) => x.entity?.id === entity)
.map((g) => ({ value: g.id, label: g.name }))}
onChange={(e) => setGroup(e?.value || undefined)}
isClearable
/>
</div>
{!(type === "corporate" && user.type === "corporate") && ( <div
<div className={clsx(
className={clsx( "flex flex-col gap-4",
"flex flex-col gap-4", !checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && "col-span-2",
(!["student", "teacher"].includes(type) || ["corporate", "teacher"].includes(user?.type)) && )}>
!["corporate", "mastercorporate"].includes(type) && <label className="font-normal text-base text-mti-gray-dim">Type</label>
"col-span-2", {user && (
)}> <select
<label className="font-normal text-base text-mti-gray-dim">Group</label> defaultValue="student"
<Select value={type}
options={groups onChange={(e) => setType(e.target.value as Type)}
.filter((x) => (!selectedCorporate ? true : x.admin === selectedCorporate)) 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">
.map((g) => ({value: g.id, label: g.name}))} {Object.keys(USER_TYPE_LABELS)
onChange={(e) => setGroup(e?.value || undefined)} .filter((x) => {
/> const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
</div> return checkAccess(user, getTypesOfUser(list), permissions, perm);
)} })
.map((type) => (
<option key={type} value={type}>
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
</option>
))}
</select>
)}
</div>
<div <div className="flex flex-col gap-4">
className={clsx( {user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
"flex flex-col gap-4", <>
!checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && "col-span-2", <div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
)}> <label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
<label className="font-normal text-base text-mti-gray-dim">Type</label> <Checkbox
{user && ( isChecked={isExpiryDateEnabled}
<select onChange={setIsExpiryDateEnabled}
defaultValue="student" disabled={!!user?.subscriptionExpirationDate}>
value={type} Enabled
onChange={(e) => setType(e.target.value as Type)} </Checkbox>
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"> </div>
{Object.keys(USER_TYPE_LABELS) {isExpiryDateEnabled && (
.filter((x) => { <ReactDatePicker
const {list, perm} = USER_TYPE_PERMISSIONS[x as Type]; className={clsx(
return checkAccess(user, getTypesOfUser(list), permissions, perm); "flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
}) "hover:border-mti-purple tooltip",
.map((type) => ( "transition duration-300 ease-in-out",
<option key={type} value={type}> )}
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]} filterDate={(date) =>
</option> moment(date).isAfter(new Date()) &&
))} (user?.subscriptionExpirationDate ? moment(date).isBefore(user?.subscriptionExpirationDate) : true)
</select> }
)} dateFormat="dd/MM/yyyy"
</div> selected={expiryDate}
onChange={(date) => setExpiryDate(date)}
/>
)}
</>
)}
</div>
</div>
<div className="flex flex-col gap-4"> <Button onClick={createUser} isLoading={isLoading} disabled={(isExpiryDateEnabled ? !expiryDate : false) || isLoading}>
{user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && ( Create User
<> </Button>
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center"> </div>
<label className="text-mti-gray-dim text-base font-normal">Expiry Date</label> );
<Checkbox
isChecked={isExpiryDateEnabled}
onChange={setIsExpiryDateEnabled}
disabled={!!user?.subscriptionExpirationDate}>
Enabled
</Checkbox>
</div>
{isExpiryDateEnabled && (
<ReactDatePicker
className={clsx(
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
"hover:border-mti-purple tooltip",
"transition duration-300 ease-in-out",
)}
filterDate={(date) =>
moment(date).isAfter(new Date()) &&
(user?.subscriptionExpirationDate ? moment(date).isBefore(user?.subscriptionExpirationDate) : true)
}
dateFormat="dd/MM/yyyy"
selected={expiryDate}
onChange={(date) => setExpiryDate(date)}
/>
)}
</>
)}
</div>
</div>
<Button onClick={createUser} isLoading={isLoading} disabled={(isExpiryDateEnabled ? !expiryDate : false) || isLoading}>
Create User
</Button>
</div>
);
} }

View File

@@ -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';
@@ -9,53 +9,58 @@ import axios from "axios";
export default withIronSessionApiRoute(handler, sessionOptions); 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)
const users = req.body.users as { const users = req.body.users as {
email: string; email: string;
name: string; name: string;
type: string; type: string;
passport_id: string; passport_id: string;
groupName?: string; groupName?: string;
corporate?: string; corporate?: string;
studentID?: string; studentID?: string;
expiryDate?: string; expiryDate?: string;
demographicInformation: { demographicInformation: {
country?: string; country?: string;
passport_id?: string; passport_id?: string;
phone: string; phone: string;
}; };
passwordHash: string | undefined; entity?: string
passwordSalt: string | undefined; entities: { id: string, role: string }[]
}[]; passwordHash: string | undefined;
passwordSalt: string | undefined;
}[];
const usersWithPasswordHashes = await Promise.all(users.map(async (user) => { const usersWithPasswordHashes = await Promise.all(users.map(async (user) => {
const currentUser = { ...user }; const currentUser = { ...user };
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.email = currentUser.email.toLowerCase(); currentUser.entities = [{ id: currentUser.entity!, role: "90ce8f08-08c8-41e4-9848-f1500ddc3930" }]
currentUser.passwordHash = hash; delete currentUser.entity
currentUser.passwordSalt = salt;
return currentUser;
}));
const backendRequest = await axios.post(`${process.env.BACKEND_URL}/batch_users`, { makerID: maker.id, users: usersWithPasswordHashes }, { currentUser.email = currentUser.email.toLowerCase();
headers: { currentUser.passwordHash = hash;
Authorization: `Bearer ${process.env.BACKEND_JWT}`, currentUser.passwordSalt = salt;
}, return currentUser;
}); }));
return res.status(backendRequest.status).json(backendRequest.data) const backendRequest = await axios.post(`${process.env.BACKEND_URL}/batch_users`, { makerID: maker.id, users: usersWithPasswordHashes }, {
headers: {
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
},
});
return res.status(backendRequest.status).json(backendRequest.data)
} }

View File

@@ -0,0 +1,32 @@
// 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, getUsers } from "@/utils/users.be";
import { Group, User } from "@/interfaces/user";
import { getGroups, getGroupsByEntities } from "@/utils/groups.be";
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) {
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
const user = req.session.user;
const groups: WithEntity<Group>[] = ["admin", "developer"].includes(user.type)
? await getGroups()
: await getGroupsByEntities(mapBy(user.entities || [], 'id'))
res.status(200).json(groups);
}

View File

@@ -0,0 +1,47 @@
// 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, WithLabeledEntities } from "@/interfaces/entity";
import { v4 } from "uuid";
import { mapBy } from "@/utils";
import { getEntitiesUsers, getUsers } from "@/utils/users.be";
import { User } from "@/interfaces/user";
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) {
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
const user = req.session.user;
const { type } = req.query as { type: string }
const entities = await getEntitiesWithRoles(mapBy(user.entities || [], 'id'))
const filter = !type ? undefined : { type }
const users = ["admin", "developer"].includes(user.type)
? await getUsers(filter)
: await getEntitiesUsers(mapBy(entities, 'id') as string[], filter)
const usersWithEntities: WithLabeledEntities<User>[] = users.map((u) => {
return {
...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 }
})
}
})
res.status(200).json(usersWithEntities);
}

View File

@@ -1,28 +1,28 @@
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 = {
reading: 9, reading: 9,
listening: 9, listening: 9,
writing: 9, writing: 9,
speaking: 9, speaking: 9,
}; };
const DEFAULT_LEVELS = { const DEFAULT_LEVELS = {
reading: 0, reading: 0,
listening: 0, listening: 0,
writing: 0, writing: 0,
speaking: 0, speaking: 0,
}; };
const auth = getAuth(app); const auth = getAuth(app);
@@ -30,198 +30,97 @@ 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 {
email: string;
password?: string;
passport_id: string;
type: string;
entity: string;
groupID?: string;
corporate?: string;
expiryDate: null | Date;
};
const {email, passport_id, password, type, groupID, expiryDate, corporate} = req.body as { // cleaning data
email: string; delete req.body.passport_id;
password?: string; delete req.body.groupID;
passport_id: string; delete req.body.expiryDate;
type: string; delete req.body.password;
groupID?: string; delete req.body.corporate;
corporate?: string; delete req.body.entity
expiryDate: null | Date;
};
// cleaning data
delete req.body.passport_id;
delete req.body.groupID;
delete req.body.expiryDate;
delete req.body.password;
delete req.body.corporate;
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 = {
...req.body,
bio: "",
id: userId,
type: type,
focus: "academic",
status: "active",
desiredLevels: DEFAULT_DESIRED_LEVELS,
profilePicture: "/defaultAvatar.png",
levels: DEFAULT_LEVELS,
isFirstLogin: false,
isVerified: true,
registrationDate: new Date(),
entities: [{ id: entity, role: "90ce8f08-08c8-41e4-9848-f1500ddc3930" }],
subscriptionExpirationDate: expiryDate || null,
...((maker.type === "corporate" || maker.type === "mastercorporate") && type === "corporate"
? {
corporateInformation: {
companyInformation: {
name: maker.corporateInformation?.companyInformation?.name || "N/A",
userAmount: 0,
},
},
}
: {}),
};
const user = { const uid = new ShortUniqueId();
...req.body, const code = uid.randomUUID(6);
bio: "",
id: userId,
type: type,
focus: "academic",
status: "active",
desiredLevels: DEFAULT_DESIRED_LEVELS,
profilePicture,
levels: DEFAULT_LEVELS,
isFirstLogin: false,
isVerified: true,
registrationDate: new Date(),
subscriptionExpirationDate: expiryDate || null,
...((maker.type === "corporate" || maker.type === "mastercorporate") && type === "corporate"
? {
corporateInformation: {
companyInformation: {
name: maker.corporateInformation?.companyInformation?.name || "N/A",
userAmount: 0,
},
},
}
: {}),
};
const uid = new ShortUniqueId(); await db.collection("users").insertOne(user);
const code = uid.randomUUID(6); await db.collection("codes").insertOne({
code,
creator: maker.id,
expiryDate,
type,
creationDate: new Date(),
userId,
email: email.toLowerCase(),
name: req.body.name,
...(!!passport_id ? { passport_id } : {}),
});
await db.collection("users").insertOne(user); if (!!groupID) {
await db.collection("codes").insertOne({ const group = await getGroup(groupID);
code, if (!!group) await db.collection("groups").updateOne({ id: group.id }, { $set: { participants: [...group.participants, userId] } });
creator: maker.id, }
expiryDate,
type,
creationDate: new Date(),
userId,
email: email.toLowerCase(),
name: req.body.name,
...(!!passport_id ? {passport_id} : {}),
});
if (type === "corporate") { console.log(`Returning - ${email}`);
const students = maker.type === "corporate" ? await getUsersOfType(maker.id, "student") : []; return res.status(200).json({ ok: true });
const teachers = maker.type === "corporate" ? await getUsersOfType(maker.id, "teacher") : []; })
.catch((error) => {
if (error.code.includes("email-already-in-use")) return res.status(403).json({ error, message: "E-mail is already in the platform." });
const defaultTeachersGroup: Group = { console.log(`Failing - ${email}`);
admin: userId, console.log(error);
id: v4(), return res.status(401).json({ error });
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) {
const group = await getGroup(groupID);
if (!!group) await db.collection("groups").updateOne({id: group.id}, {$set: {participants: [...group.participants, userId]}});
}
console.log(`Returning - ${email}`);
return res.status(200).json({ok: true});
})
.catch((error) => {
if (error.code.includes("email-already-in-use")) return res.status(403).json({error, message: "E-mail is already in the platform."});
console.log(`Failing - ${email}`);
console.log(error);
return res.status(401).json({error});
});
} }

View File

@@ -7,7 +7,8 @@ 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";
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 +42,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});
@@ -62,11 +49,10 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
await db.collection("stats").deleteMany({user: targetUser.id}); await db.collection("stats").deleteMany({user: targetUser.id});
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});
} }

View File

@@ -0,0 +1,195 @@
/* 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, serialize } from "@/utils";
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 = req.session.user as User | undefined;
if (!user) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
if (!checkAccess(user, ["admin", "developer"]))
return {
redirect: {
destination: "/dashboard",
permanent: false,
},
};
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("/list/users?type=student")}
Icon={BsPersonFill}
label="Students"
value={students.length}
color="purple"
/>
<IconCard
onClick={() => router.push("/list/users?type=teacher")}
Icon={BsPencilSquare}
label="Teachers"
value={teachers.length}
color="purple"
/>
<IconCard
Icon={BsBank}
onClick={() => router.push("/list/users?type=corporate")}
label="Corporates"
value={corporates.length}
color="purple"
/>
<IconCard
Icon={BsBank}
onClick={() => router.push("/list/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} label="Student Performance" value={students.length} color="purple" />
<IconCard
Icon={BsEnvelopePaper}
onClick={() => router.push("/assignments")}
label="Assignments"
value={assignments.filter((a) => !a.archived).length}
color="purple"
/>
</section>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<UserDisplayList
users={students.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))}
title="Latest Students"
/>
<UserDisplayList
users={teachers.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))}
title="Latest Teachers"
/>
<UserDisplayList
users={students.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))}
title="Highest level students"
/>
<UserDisplayList
users={
students
.sort(
(a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length,
)
}
title="Highest exam count students"
/>
</section>
</Layout>
</>
);
}

View File

@@ -1,225 +0,0 @@
/* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
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, serialize} from "@/utils";
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 = req.session.user as User | undefined;
if (!user) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
if (!checkAccess(user, ["admin", "developer"]))
return {
redirect: {
destination: "/dashboard",
permanent: false,
},
};
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);
};
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("/lists/users?type=student")}
Icon={BsPersonFill}
label="Students"
value={students.length}
color="purple"
/>
<IconCard
onClick={() => router.push("/lists/users?type=teacher")}
Icon={BsPencilSquare}
label="Teachers"
value={teachers.length}
color="purple"
/>
<IconCard
Icon={BsBank}
onClick={() => router.push("/lists/users?type=corporate")}
label="Corporates"
value={corporates.length}
color="purple"
/>
<IconCard
Icon={BsBank}
onClick={() => router.push("/lists/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} 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">
<div className="bg-white border shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white border shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest teachers</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{teachers
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white border shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest level students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white border shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest exam count students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort(
(a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length,
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
</section>
</Layout>
</>
);
}

View File

@@ -1,5 +1,6 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout"; import Layout from "@/components/High/Layout";
import UserDisplayList from "@/components/UserDisplayList";
import IconCard from "@/dashboards/IconCard"; import IconCard from "@/dashboards/IconCard";
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
@@ -136,14 +137,14 @@ export default function Dashboard({ user, users, entities, assignments, stats, g
)} )}
<section className="grid grid-cols-5 -md:grid-cols-2 place-items-center gap-4 text-center"> <section className="grid grid-cols-5 -md:grid-cols-2 place-items-center gap-4 text-center">
<IconCard <IconCard
onClick={() => router.push("/lists/users?type=student")} onClick={() => router.push("/list/users?type=student")}
Icon={BsPersonFill} Icon={BsPersonFill}
label="Students" label="Students"
value={students.length} value={students.length}
color="purple" color="purple"
/> />
<IconCard <IconCard
onClick={() => router.push("/lists/users?type=teacher")} onClick={() => router.push("/list/users?type=teacher")}
Icon={BsPencilSquare} Icon={BsPencilSquare}
label="Teachers" label="Teachers"
value={teachers.length} value={teachers.length}
@@ -178,50 +179,29 @@ export default function Dashboard({ user, users, entities, assignments, stats, g
</div> </div>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between"> <section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<div className="bg-white border shadow flex flex-col rounded-xl w-full"> <UserDisplayList
<span className="p-4">Latest students</span> users={students.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))}
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> title="Latest Students"
{students />
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate")) <UserDisplayList
.map((x) => ( users={teachers.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))}
<UserDisplay key={x.id} {...x} /> title="Latest Teachers"
))} />
</div> <UserDisplayList
</div> users={students.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))}
<div className="bg-white border shadow flex flex-col rounded-xl w-full"> title="Highest level students"
<span className="p-4">Latest teachers</span> />
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> <UserDisplayList
{teachers users={
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate")) students
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white border shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest level students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white border shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest exam count students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort( .sort(
(a, b) => (a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length - Object.keys(groupByExam(filterBy(stats, "user", b))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length, Object.keys(groupByExam(filterBy(stats, "user", a))).length,
) )
.map((x) => ( }
<UserDisplay key={x.id} {...x} /> title="Highest exam count students"
))} />
</div>
</div>
</section> </section>
</Layout> </Layout>
</> </>

View 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, serialize } from "@/utils";
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 = req.session.user as User | undefined;
if (!user) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
if (!checkAccess(user, ["admin", "developer"]))
return {
redirect: {
destination: "/dashboard",
permanent: false,
},
};
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("/list/users?type=student")}
Icon={BsPersonFill}
label="Students"
value={students.length}
color="purple"
/>
<IconCard
onClick={() => router.push("/list/users?type=teacher")}
Icon={BsPencilSquare}
label="Teachers"
value={teachers.length}
color="purple"
/>
<IconCard
Icon={BsBank}
onClick={() => router.push("/list/users?type=corporate")}
label="Corporates"
value={corporates.length}
color="purple"
/>
<IconCard
Icon={BsBank}
onClick={() => router.push("/list/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} label="Student Performance" value={students.length} color="purple" />
<IconCard
Icon={BsEnvelopePaper}
onClick={() => router.push("/assignments")}
label="Assignments"
value={assignments.filter((a) => !a.archived).length}
color="purple"
/>
</section>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<UserDisplayList
users={students.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))}
title="Latest Students"
/>
<UserDisplayList
users={teachers.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))}
title="Latest Teachers"
/>
<UserDisplayList
users={students.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))}
title="Highest level students"
/>
<UserDisplayList
users={
students
.sort(
(a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length,
)
}
title="Highest exam count students"
/>
</section>
</Layout>
</>
);
}

View File

@@ -1,225 +0,0 @@
/* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
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, serialize} from "@/utils";
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 = req.session.user as User | undefined;
if (!user) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
if (!checkAccess(user, ["admin", "developer"]))
return {
redirect: {
destination: "/dashboard",
permanent: false,
},
};
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);
};
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("/lists/users?type=student")}
Icon={BsPersonFill}
label="Students"
value={students.length}
color="purple"
/>
<IconCard
onClick={() => router.push("/lists/users?type=teacher")}
Icon={BsPencilSquare}
label="Teachers"
value={teachers.length}
color="purple"
/>
<IconCard
Icon={BsBank}
onClick={() => router.push("/lists/users?type=corporate")}
label="Corporates"
value={corporates.length}
color="purple"
/>
<IconCard
Icon={BsBank}
onClick={() => router.push("/lists/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} 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">
<div className="bg-white border shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white border shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest teachers</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{teachers
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white border shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest level students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white border shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest exam count students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort(
(a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length,
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
</section>
</Layout>
</>
);
}

View File

@@ -1,5 +1,6 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout"; import Layout from "@/components/High/Layout";
import UserDisplayList from "@/components/UserDisplayList";
import IconCard from "@/dashboards/IconCard"; import IconCard from "@/dashboards/IconCard";
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
@@ -133,21 +134,21 @@ export default function Dashboard({ user, users, entities, assignments, stats, g
<Layout user={user}> <Layout user={user}>
<section className="grid grid-cols-5 place-items-center -md:grid-cols-2 gap-4 text-center"> <section className="grid grid-cols-5 place-items-center -md:grid-cols-2 gap-4 text-center">
<IconCard <IconCard
onClick={() => router.push("/lists/users?type=student")} onClick={() => router.push("/list/users?type=student")}
Icon={BsPersonFill} Icon={BsPersonFill}
label="Students" label="Students"
value={students.length} value={students.length}
color="purple" color="purple"
/> />
<IconCard <IconCard
onClick={() => router.push("/lists/users?type=teacher")} onClick={() => router.push("/list/users?type=teacher")}
Icon={BsPencilSquare} Icon={BsPencilSquare}
label="Teachers" label="Teachers"
value={teachers.length} value={teachers.length}
color="purple" color="purple"
/> />
<IconCard <IconCard
onClick={() => router.push("/lists/users?type=corporate")} Icon={BsBank} label="Corporate Accounts" value={corporates.length} color="purple" /> onClick={() => router.push("/list/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 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={BsPeopleFill} label="Entities" value={entities.length} color="purple" />
<IconCard Icon={BsClipboard2Data} label="Exams Performed" value={uniqBy(stats, "exam").length} color="purple" /> <IconCard Icon={BsClipboard2Data} label="Exams Performed" value={uniqBy(stats, "exam").length} color="purple" />
@@ -169,50 +170,29 @@ export default function Dashboard({ user, users, entities, assignments, stats, g
</section> </section>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between"> <section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<div className="bg-white border shadow flex flex-col rounded-xl w-full"> <UserDisplayList
<span className="p-4">Latest students</span> users={students.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))}
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> title="Latest Students"
{students />
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate")) <UserDisplayList
.map((x) => ( users={teachers.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))}
<UserDisplay key={x.id} {...x} /> title="Latest Teachers"
))} />
</div> <UserDisplayList
</div> users={students.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))}
<div className="bg-white border shadow flex flex-col rounded-xl w-full"> title="Highest level students"
<span className="p-4">Latest teachers</span> />
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> <UserDisplayList
{teachers users={
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate")) students
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white border shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest level students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white border shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest exam count students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort( .sort(
(a, b) => (a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length - Object.keys(groupByExam(filterBy(stats, "user", b))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length, Object.keys(groupByExam(filterBy(stats, "user", a))).length,
) )
.map((x) => ( }
<UserDisplay key={x.id} {...x} /> title="Highest exam count students"
))} />
</div>
</div>
</section> </section>
</Layout> </Layout>
</> </>

View File

@@ -0,0 +1,170 @@
/* 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, 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";
interface Props {
user: User;
users: User[];
entities: EntityWithRoles[];
assignments: Assignment[];
stats: Stat[];
groups: Group[];
}
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = req.session.user as User | undefined;
if (!user) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
if (!checkAccess(user, ["admin", "developer", "teacher"]))
return {
redirect: {
destination: "/dashboard",
permanent: false,
},
};
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>
</>
);
}

View File

@@ -1,184 +0,0 @@
/* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout";
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, 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";
interface Props {
user: User;
users: User[];
entities: EntityWithRoles[];
assignments: Assignment[];
stats: Stat[];
groups: Group[];
}
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
const user = req.session.user as User | undefined;
if (!user) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
if (!checkAccess(user, ["admin", "developer", "teacher"]))
return {
redirect: {
destination: "/dashboard",
permanent: false,
},
};
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">
<div className="bg-white border shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white border shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest level students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white border shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest exam count students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{students
.sort(
(a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length,
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
</section>
</Layout>
</>
);
}

View File

@@ -1,26 +1,21 @@
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 { serialize } from "@/utils";
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(({req, res, query}) => {
const user = req.session.user; const user = req.session.user;
const envVariables: {[key: string]: string} = {};
Object.keys(process.env)
.filter((x) => x.startsWith("NEXT_PUBLIC"))
.forEach((x: string) => {
envVariables[x] = process.env[x]!;
});
if (!user) { if (!user) {
return { return {
redirect: { redirect: {
@@ -30,17 +25,22 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => {
}; };
} }
const {type} = query as {type?: Type}
return { return {
props: {user: req.session.user, envVariables}, props: serialize({user: req.session.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 (
<> <>
@@ -59,19 +59,19 @@ export default function UsersListPage() {
<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>
)} )}
/> />

View File

@@ -1,259 +0,0 @@
import Layout from "@/components/High/Layout";
import List from "@/components/List";
import useUser from "@/hooks/useUser";
import useUsers from "@/hooks/useUsers";
import {Type, User} from "@/interfaces/user";
import {sessionOptions} from "@/lib/session";
import useFilterStore from "@/stores/listFilterStore";
import {mapBy, serialize} from "@/utils";
import {getEntitiesUsers, getEntityUsers} from "@/utils/users.be";
import {createColumnHelper} from "@tanstack/react-table";
import clsx from "clsx";
import {withIronSessionSsr} from "iron-session/next";
import Head from "next/head";
import {useRouter} from "next/router";
import {useEffect, useState} from "react";
import {BsArrowLeft, BsCheck, BsChevronLeft} from "react-icons/bs";
import {ToastContainer} from "react-toastify";
import UserList from "../(admin)/Lists/UserList";
import {countries, TCountries} from "countries-list";
import countryCodes from "country-codes-list";
import {capitalize} from "lodash";
import moment from "moment";
import {USER_TYPE_LABELS} from "@/resources/user";
import {getEntities, getEntitiesWithRoles} from "@/utils/entities.be";
import {EntityWithRoles} from "@/interfaces/entity";
const columnHelper = createColumnHelper<User>();
const expirationDateColor = (date: Date) => {
const momentDate = moment(date);
const today = moment(new Date());
if (today.isAfter(momentDate)) return "!text-mti-red-light font-bold line-through";
if (today.add(1, "weeks").isAfter(momentDate)) return "!text-mti-red-light";
if (today.add(2, "weeks").isAfter(momentDate)) return "!text-mti-rose-light";
if (today.add(1, "months").isAfter(momentDate)) return "!text-mti-orange-light";
};
const SEARCH_FIELDS = [["name"], ["email"], ["corporateInformation", "companyInformation", "name"]];
export const getServerSideProps = withIronSessionSsr(async ({req, res, query}) => {
const user = req.session.user as User | undefined;
if (!user)
return {
redirect: {
destination: "/login",
permanent: false,
},
};
const {type} = query as {type?: Type};
const users = await getEntitiesUsers(mapBy(user.entities, "id"));
const entities = await getEntitiesWithRoles();
const filters: ((u: User) => boolean)[] = [];
if (type) filters.push((u) => u.type === type);
return {
props: serialize({user, users: filters.reduce((d, f) => d.filter(f), users), entities}),
};
}, sessionOptions);
interface Props {
user: User;
users: User[];
entities: EntityWithRoles[];
}
export default function UsersList({user, users, entities}: Props) {
const [showDemographicInformation, setShowDemographicInformation] = useState(false);
const router = useRouter();
const defaultColumns = [
columnHelper.accessor("name", {
header: (
<button className="flex gap-2 items-center">
<span>Name</span>
</button>
) as any,
cell: ({row, getValue}) => <div>{getValue()}</div>,
}),
columnHelper.accessor("email", {
header: (
<button className="flex gap-2 items-center">
<span>E-mail</span>
</button>
) as any,
cell: ({row, getValue}) => <div>{getValue()}</div>,
}),
columnHelper.accessor("type", {
header: (
<button className="flex gap-2 items-center">
<span>Type</span>
</button>
) as any,
cell: (info) => USER_TYPE_LABELS[info.getValue()],
}),
columnHelper.accessor("studentID", {
header: (
<button className="flex gap-2 items-center">
<span>Student ID</span>
</button>
) as any,
cell: (info) => info.getValue() || "N/A",
}),
columnHelper.accessor("entities", {
header: (
<button className="flex gap-2 items-center">
<span>Entities</span>
</button>
) as any,
cell: ({getValue}) =>
getValue()
.map((e) => entities.find((x) => x.id === e.id)?.label)
.join(", "),
}),
columnHelper.accessor("subscriptionExpirationDate", {
header: (
<button className="flex gap-2 items-center">
<span>Expiration</span>
</button>
) as any,
cell: (info) => (
<span className={clsx(info.getValue() ? expirationDateColor(moment(info.getValue()).toDate()) : "")}>
{!info.getValue() ? "No expiry date" : moment(info.getValue()).format("DD/MM/YYYY")}
</span>
),
}),
columnHelper.accessor("isVerified", {
header: (
<button className="flex gap-2 items-center">
<span>Verified</span>
</button>
) as any,
cell: (info) => (
<div className="flex gap-3 items-center text-mti-gray-dim text-sm self-center">
<div
className={clsx(
"w-6 h-6 rounded-md flex items-center justify-center border border-mti-purple-light bg-white",
"transition duration-300 ease-in-out",
info.getValue() && "!bg-mti-purple-light ",
)}>
<BsCheck color="white" className="w-full h-full" />
</div>
</div>
),
}),
{
header: (
<span className="cursor-pointer" onClick={() => setShowDemographicInformation((prev) => !prev)}>
Switch
</span>
),
id: "actions",
cell: () => "",
},
];
const demographicColumns = [
columnHelper.accessor("name", {
header: (
<button className="flex gap-2 items-center">
<span>Name</span>
</button>
) as any,
cell: ({row, getValue}) => <div>{getValue()}</div>,
}),
columnHelper.accessor("demographicInformation.country", {
header: (
<button className="flex gap-2 items-center">
<span>Country</span>
</button>
) as any,
cell: (info) =>
info.getValue()
? `${countryCodes.findOne("countryCode" as any, info.getValue())?.flag} ${
countries[info.getValue() as unknown as keyof TCountries]?.name
} (+${countryCodes.findOne("countryCode" as any, info.getValue())?.countryCallingCode})`
: "N/A",
}),
columnHelper.accessor("demographicInformation.phone", {
header: (
<button className="flex gap-2 items-center">
<span>Phone</span>
</button>
) as any,
cell: (info) => info.getValue() || "N/A",
enableSorting: true,
}),
columnHelper.accessor(
(x) =>
x.type === "corporate" || x.type === "mastercorporate" ? x.demographicInformation?.position : x.demographicInformation?.employment,
{
id: "employment",
header: (
<button className="flex gap-2 items-center">
<span>Employment</span>
</button>
) as any,
cell: (info) => (info.row.original.type === "corporate" ? info.getValue() : capitalize(info.getValue())) || "N/A",
enableSorting: true,
},
),
columnHelper.accessor("lastLogin", {
header: (
<button className="flex gap-2 items-center">
<span>Last Login</span>
</button>
) as any,
cell: (info) => (!!info.getValue() ? moment(info.getValue()).format("YYYY-MM-DD HH:mm") : "N/A"),
}),
columnHelper.accessor("demographicInformation.gender", {
header: (
<button className="flex gap-2 items-center">
<span>Gender</span>
</button>
) as any,
cell: (info) => capitalize(info.getValue()) || "N/A",
enableSorting: true,
}),
{
header: (
<span className="cursor-pointer" onClick={() => setShowDemographicInformation((prev) => !prev)}>
Switch
</span>
),
id: "actions",
cell: () => "",
},
];
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} className="!gap-6">
<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">User List</h2>
</div>
<List<User> data={users} searchFields={SEARCH_FIELDS} columns={showDemographicInformation ? demographicColumns : defaultColumns} />
</Layout>
</>
);
}

View File

@@ -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,9 +22,18 @@ 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, 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";
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
const user = req.session.user; const user = req.session.user;
@@ -47,11 +56,16 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
}; };
} }
const users = await getUsers(); const entityIDs = mapBy(user.entities, 'id')
const assignments = await getAssignments();
const entities = await getEntitiesWithRoles(checkAccess(user, ["admin", "developer"]) ? undefined : entityIDs)
const users = await (checkAccess(user, ["admin", "developer"]) ? getUsers() : getEntitiesUsers(mapBy(entities, 'id')))
const groups = await (checkAccess(user, ["admin", "developer"]) ? getGroups() : getGroupsByEntities(mapBy(entities, 'id')))
const assignments = await (checkAccess(user, ["admin", "developer"]) ? getAssignments() : getEntitiesAssignments(mapBy(entities, 'id')))
const gradingSystems = await Promise.all(entityIDs.map(getGradingSystemByEntity))
return { return {
props: {user, users, assignments}, props: serialize({user, users, assignments, entities, gradingSystems}),
}; };
}, sessionOptions); }, sessionOptions);
@@ -61,9 +75,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 +90,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,30 +103,32 @@ 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");
const [selectedTrainingExams, setSelectedTrainingExams] = useState<string[]>([]);
const setTrainingStats = useTrainingContentStore((state) => state.setStats);
const groupedStats = useMemo(() => groupByDate(
stats.filter((x) => {
if (
(x.module === "writing" || x.module === "speaking") &&
!x.isDisabled &&
!x.solutions.every((y) => Object.keys(y).includes("evaluation"))
)
return false;
return true;
}),
), [stats])
useEffect(() => setStatsUserId(user.id), [setStatsUserId, user]); useEffect(() => setStatsUserId(user.id), [setStatsUserId, user]);
useEffect(() => { useEffect(() => {
if (stats && !isStatsLoading) { const handleRouteChange = (url: string) => {
setGroupedStats( setTraining(false);
groupByDate( };
stats.filter((x) => { router.events.on("routeChangeStart", handleRouteChange);
if ( return () => {
(x.module === "writing" || x.module === "speaking") && router.events.off("routeChangeStart", handleRouteChange);
!x.isDisabled && };
!x.solutions.every((y) => Object.keys(y).includes("evaluation")) }, [router.events, setTraining]);
)
return false;
return true;
}),
),
);
}
}, [stats, isStatsLoading]);
// useEffect(() => {
// // just set this initially
// if (!statsUserId) setStatsUserId(user.id);
// }, []);
const filterStatsByDate = (stats: {[key: string]: Stat[]}) => { const filterStatsByDate = (stats: {[key: string]: Stat[]}) => {
if (filter && filter !== "assignments") { if (filter && filter !== "assignments") {
@@ -139,39 +157,29 @@ export default function History({user, users, assignments}: Props) {
return stats; return stats;
}; };
const MAX_TRAINING_EXAMS = 10; const handleTrainingContentSubmission = () => {
const [selectedTrainingExams, setSelectedTrainingExams] = useState<string[]>([]); if (groupedStats) {
const setTrainingStats = useTrainingContentStore((state) => state.setStats); const groupedStatsByDate = filterStatsByDate(groupedStats);
const allStats = Object.keys(groupedStatsByDate);
const selectedStats = selectedTrainingExams.reduce<Record<string, Stat[]>>((accumulator, moduleAndTimestamp) => {
const timestamp = moduleAndTimestamp.split("-")[1];
if (allStats.includes(timestamp) && !accumulator.hasOwnProperty(timestamp)) {
accumulator[timestamp] = groupedStatsByDate[timestamp];
}
return accumulator;
}, {});
setTrainingStats(Object.values(selectedStats).flat());
router.push("/training");
}
};
const handleTrainingContentSubmission = () => { const filteredStats = useMemo(() =>
if (groupedStats) { Object.keys(filterStatsByDate(groupedStats))
const groupedStatsByDate = filterStatsByDate(groupedStats); .sort((a, b) => parseInt(b) - parseInt(a)),
const allStats = Object.keys(groupedStatsByDate); // eslint-disable-next-line react-hooks/exhaustive-deps
const selectedStats = selectedTrainingExams.reduce<Record<string, Stat[]>>((accumulator, moduleAndTimestamp) => { [groupedStats, filter])
const timestamp = moduleAndTimestamp.split("-")[1];
if (allStats.includes(timestamp) && !accumulator.hasOwnProperty(timestamp)) {
accumulator[timestamp] = groupedStatsByDate[timestamp];
}
return accumulator;
}, {});
setTrainingStats(Object.values(selectedStats).flat());
router.push("/training");
}
};
useEffect(() => {
const handleRouteChange = (url: string) => {
setTraining(false);
};
router.events.on("routeChangeStart", handleRouteChange);
return () => {
router.events.off("routeChangeStart", handleRouteChange);
};
}, [router.events, setTraining]);
const customContent = (timestamp: string) => { const customContent = (timestamp: string) => {
if (!groupedStats) return <></>;
const dateStats = groupedStats[timestamp]; const dateStats = groupedStats[timestamp];
return ( return (
@@ -212,7 +220,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 +239,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 && (

View File

@@ -1,156 +1,161 @@
/* 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 } from "@/utils";
import { EntityWithRoles } from "@/interfaces/entity";
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = req.session.user; const user = req.session.user as User;
if (!user) { if (!user) {
return { return {
redirect: { redirect: {
destination: "/login", destination: "/login",
permanent: false, 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: "/", destination: "/",
permanent: false, 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>();
return ( return (
<> <>
<Head> <Head>
<title>Settings Panel | EnCoach</title> <title>Settings Panel | EnCoach</title>
<meta <meta
name="description" name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/> />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<Layout user={user} className="gap-6"> <Layout user={user} className="gap-6">
<Modal isOpen={modalOpen === "batchCreateUser"} onClose={() => setModalOpen(undefined)}> <Modal isOpen={modalOpen === "batchCreateUser"} onClose={() => setModalOpen(undefined)}>
<BatchCreateUser user={user} users={users} permissions={permissions} onFinish={() => setModalOpen(undefined)} /> <BatchCreateUser user={user} users={users} permissions={permissions} onFinish={() => setModalOpen(undefined)} />
</Modal> </Modal>
<Modal isOpen={modalOpen === "batchCreateCode"} onClose={() => setModalOpen(undefined)}> <Modal isOpen={modalOpen === "batchCreateCode"} onClose={() => setModalOpen(undefined)}>
<BatchCodeGenerator user={user} users={users} permissions={permissions} onFinish={() => setModalOpen(undefined)} /> <BatchCodeGenerator user={user} users={users} permissions={permissions} onFinish={() => setModalOpen(undefined)} />
</Modal> </Modal>
<Modal isOpen={modalOpen === "createCode"} onClose={() => setModalOpen(undefined)}> <Modal isOpen={modalOpen === "createCode"} onClose={() => setModalOpen(undefined)}>
<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);
}} }}
/> />
</Modal> </Modal>
<section className="w-full grid grid-cols-2 -md:grid-cols-1 gap-8"> <section className="w-full grid grid-cols-2 -md:grid-cols-1 gap-8">
<ExamLoader /> <ExamLoader />
{checkAccess(user, getTypesOfUser(["teacher"]), permissions, "viewCodes") && ( {checkAccess(user, getTypesOfUser(["teacher"]), permissions, "viewCodes") && (
<div className="w-full grid grid-cols-2 gap-4"> <div className="w-full grid grid-cols-2 gap-4">
<IconCard <IconCard
Icon={BsCode} Icon={BsCode}
label="Generate Single Code" label="Generate Single Code"
color="purple" color="purple"
className="w-full h-full" className="w-full h-full"
onClick={() => setModalOpen("createCode")} onClick={() => setModalOpen("createCode")}
/> />
<IconCard <IconCard
Icon={BsCodeSquare} Icon={BsCodeSquare}
label="Generate Codes in Batch" label="Generate Codes in Batch"
color="purple" color="purple"
className="w-full h-full" className="w-full h-full"
onClick={() => setModalOpen("batchCreateCode")} onClick={() => setModalOpen("batchCreateCode")}
/> />
<IconCard <IconCard
Icon={BsPersonFill} Icon={BsPersonFill}
label="Create Single User" label="Create Single User"
color="purple" color="purple"
className="w-full h-full" className="w-full h-full"
onClick={() => setModalOpen("createUser")} onClick={() => setModalOpen("createUser")}
/> />
<IconCard <IconCard
Icon={BsPeopleFill} Icon={BsPeopleFill}
label="Create Users in Batch" label="Create Users in Batch"
color="purple" color="purple"
className="w-full h-full" className="w-full h-full"
onClick={() => setModalOpen("batchCreateUser")} onClick={() => setModalOpen("batchCreateUser")}
/> />
{checkAccess(user, ["admin", "corporate", "developer", "mastercorporate"]) && ( {checkAccess(user, ["admin", "corporate", "developer", "mastercorporate"]) && (
<IconCard <IconCard
Icon={BsGearFill} Icon={BsGearFill}
label="Grading System" label="Grading System"
color="purple" color="purple"
className="w-full h-full col-span-2" className="w-full h-full col-span-2"
onClick={() => setModalOpen("gradingSystem")} onClick={() => setModalOpen("gradingSystem")}
/> />
)} )}
</div> </div>
)} )}
</section> </section>
<section className="w-full"> <section className="w-full">
<Lists user={user} users={users} permissions={permissions} /> <Lists user={user} users={users} permissions={permissions} />
</section> </section>
</Layout> </Layout>
</> </>
); );
} }

View File

@@ -17,22 +17,28 @@ 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, 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";
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 = req.session.user as User;
if (!user) { if (!user) {
return { return {
@@ -52,13 +58,25 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => {
}; };
} }
const entityIDs = mapBy(user.entities, 'id')
const entities = await getEntitiesWithRoles(checkAccess(user, ["admin", "developer"]) ? undefined : entityIDs)
const users = await (checkAccess(user, ["admin", "developer"]) ? getUsers() : getEntitiesUsers(mapBy(entities, 'id')))
const groups = await (checkAccess(user, ["admin", "developer"]) ? getGroups() : getGroupsByEntities(mapBy(entities, 'id')))
return { 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 +87,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 +201,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 +211,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,
}),
}}
/> />
)} )}
</> </>

View File

@@ -20,9 +20,15 @@ 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, 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";
export const getServerSideProps = withIronSessionSsr(({req, res}) => { export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
const user = req.session.user; const user = req.session.user as User;
if (!user) { if (!user) {
return { return {
@@ -42,12 +48,16 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => {
}; };
} }
const entityIDs = mapBy(user.entities, 'id')
const entities = await getEntitiesWithRoles(entityIDs)
const users = await getEntitiesUsers(entityIDs)
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 +203,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">

View File

@@ -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",
}, },
]; ];

View File

@@ -1,132 +1,171 @@
import {app} from "@/firebase"; import { app } from "@/firebase";
import {Assignment} from "@/interfaces/results"; import { WithEntity } from "@/interfaces/entity";
import {CorporateUser, Group, GroupWithUsers, MasterCorporateUser, StudentUser, TeacherUser, Type, User} from "@/interfaces/user"; import { Assignment } from "@/interfaces/results";
import { CorporateUser, Group, GroupWithUsers, MasterCorporateUser, StudentUser, TeacherUser, Type, User } from "@/interfaces/user";
import client from "@/lib/mongodb"; import client from "@/lib/mongodb";
import moment from "moment"; import moment from "moment";
import {getLinkedUsers, getUser} from "./users.be"; import { getLinkedUsers, getUser } from "./users.be";
import {getSpecificUsers} from "./users.be"; import { getSpecificUsers } from "./users.be";
const db = client.db(process.env.MONGODB_DB); const db = client.db(process.env.MONGODB_DB);
const addEntityToGroupPipeline = [
{
$lookup: {
from: "entities",
localField: "entity",
foreignField: "id",
as: "entity"
}
},
{
$addFields: {
entity: { $arrayElemAt: ["$entity", 0] }
}
},
{
$addFields: {
entity: {
$cond: {
if: { $isArray: "$entity" },
then: undefined,
else: "$entity"
}
}
}
}
]
export const updateExpiryDateOnGroup = async (participantID: string, corporateID: string) => { export const updateExpiryDateOnGroup = async (participantID: string, corporateID: string) => {
const corporate = await db.collection("users").findOne<User>({id: corporateID}); const corporate = await db.collection("users").findOne<User>({ id: corporateID });
const participant = await db.collection("users").findOne<User>({id: participantID}); const participant = await db.collection("users").findOne<User>({ id: participantID });
if (!corporate || !participant) return; if (!corporate || !participant) return;
if (corporate.type !== "corporate" || (participant.type !== "student" && participant.type !== "teacher")) return; if (corporate.type !== "corporate" || (participant.type !== "student" && participant.type !== "teacher")) return;
if (!corporate.subscriptionExpirationDate || !participant.subscriptionExpirationDate) if (!corporate.subscriptionExpirationDate || !participant.subscriptionExpirationDate)
return await db.collection("users").updateOne({id: participant.id}, {$set: {subscriptionExpirationDate: null}}); return await db.collection("users").updateOne({ id: participant.id }, { $set: { subscriptionExpirationDate: null } });
const corporateDate = moment(corporate.subscriptionExpirationDate); const corporateDate = moment(corporate.subscriptionExpirationDate);
const participantDate = moment(participant.subscriptionExpirationDate); const participantDate = moment(participant.subscriptionExpirationDate);
if (corporateDate.isAfter(participantDate)) if (corporateDate.isAfter(participantDate))
return await db.collection("users").updateOne({id: participant.id}, {$set: {subscriptionExpirationDate: corporateDate.toISOString()}}); return await db.collection("users").updateOne({ id: participant.id }, { $set: { subscriptionExpirationDate: corporateDate.toISOString() } });
return; return;
}; };
export const getUserCorporate = async (id: string) => { export const getUserCorporate = async (id: string) => {
const user = await getUser(id); const user = await getUser(id);
if (!user) return undefined; if (!user) return undefined;
if (["admin", "developer"].includes(user.type)) return undefined; if (["admin", "developer"].includes(user.type)) return undefined;
if (user.type === "mastercorporate") return user; if (user.type === "mastercorporate") return user;
const groups = await getParticipantGroups(id); const groups = await getParticipantGroups(id);
const admins = await Promise.all(groups.map((x) => x.admin).map(getUser)); const admins = await Promise.all(groups.map((x) => x.admin).map(getUser));
const corporates = admins const corporates = admins
.filter((x) => (user.type === "corporate" ? x?.type === "mastercorporate" : x?.type === "corporate")) .filter((x) => (user.type === "corporate" ? x?.type === "mastercorporate" : x?.type === "corporate"))
.filter((x) => !!x) as User[]; .filter((x) => !!x) as User[];
if (corporates.length === 0) return undefined; if (corporates.length === 0) return undefined;
return corporates.shift() as CorporateUser | MasterCorporateUser; return corporates.shift() as CorporateUser | MasterCorporateUser;
}; };
export const getGroup = async (id: string) => { export const getGroup = async (id: string) => {
return await db.collection("groups").findOne<Group>({id}); return await db.collection("groups").findOne<Group>({ id });
}; };
export const getGroups = async () => { export const getGroups = async (): Promise<WithEntity<Group>[]> => {
return await db.collection("groups").find<Group>({}).toArray(); return await db.collection("groups")
.aggregate<WithEntity<Group>>(addEntityToGroupPipeline).toArray()
}; };
export const getParticipantGroups = async (id: string) => { export const getParticipantGroups = async (id: string) => {
return await db.collection("groups").find<Group>({participants: id}).toArray(); return await db.collection("groups").find<Group>({ participants: id }).toArray();
}; };
export const getUserGroups = async (id: string): Promise<Group[]> => { export const getUserGroups = async (id: string): Promise<Group[]> => {
return await db.collection("groups").find<Group>({admin: id}).toArray(); return await db.collection("groups").find<Group>({ admin: id }).toArray();
}; };
export const getUserNamedGroup = async (id: string, name: string) => { export const getUserNamedGroup = async (id: string, name: string) => {
return await db.collection("groups").findOne<Group>({admin: id, name}); return await db.collection("groups").findOne<Group>({ admin: id, name });
}; };
export const removeParticipantFromGroup = async (id: string, user: string) => {
return await db.collection("groups").updateOne({id}, {
// @ts-expect-error
$pull: {
participants: user
}
})
}
export const getUsersGroups = async (ids: string[]) => { export const getUsersGroups = async (ids: string[]) => {
return await db return await db
.collection("groups") .collection("groups")
.find<Group>({admin: {$in: ids}}) .find<Group>({ admin: { $in: ids } })
.toArray(); .toArray();
}; };
export const convertToUsers = (group: Group, users: User[]): GroupWithUsers => export const convertToUsers = (group: Group, users: User[]): GroupWithUsers =>
Object.assign(group, { Object.assign(group, {
admin: users.find((u) => u.id === group.admin), admin: users.find((u) => u.id === group.admin),
participants: group.participants.map((p) => users.find((u) => u.id === p)).filter((x) => !!x) as User[], participants: group.participants.map((p) => users.find((u) => u.id === p)).filter((x) => !!x) as User[],
}); });
export const getAllAssignersByCorporate = async (corporateID: string, type: Type): Promise<string[]> => { export const getAllAssignersByCorporate = async (corporateID: string, type: Type): Promise<string[]> => {
const linkedTeachers = await getLinkedUsers(corporateID, type, "teacher"); const linkedTeachers = await getLinkedUsers(corporateID, type, "teacher");
const linkedCorporates = await getLinkedUsers(corporateID, type, "corporate"); const linkedCorporates = await getLinkedUsers(corporateID, type, "corporate");
return [...linkedTeachers.users.map((x) => x.id), ...linkedCorporates.users.map((x) => x.id)]; return [...linkedTeachers.users.map((x) => x.id), ...linkedCorporates.users.map((x) => x.id)];
}; };
export const getGroupsForEntities = async (ids: string[]) => export const getGroupsForEntities = async (ids: string[]) =>
await db await db
.collection("groups") .collection("groups")
.find<Group>({entity: {$in: ids}}) .find<Group>({ entity: { $in: ids } })
.toArray(); .toArray();
export const getGroupsForUser = async (admin?: string, participant?: string) => { export const getGroupsForUser = async (admin?: string, participant?: string) => {
if (admin && participant) return await db.collection("groups").find<Group>({admin, participant}).toArray(); if (admin && participant) return await db.collection("groups").find<Group>({ admin, participant }).toArray();
if (admin) return await getUserGroups(admin); if (admin) return await getUserGroups(admin);
if (participant) return await getParticipantGroups(participant); if (participant) return await getParticipantGroups(participant);
return await getGroups(); return await getGroups();
}; };
export const getStudentGroupsForUsersWithoutAdmin = async (admin: string, participants: string[]) => { export const getStudentGroupsForUsersWithoutAdmin = async (admin: string, participants: string[]) => {
return await db return await db
.collection("groups") .collection("groups")
.find<Group>({...(admin ? {admin: {$ne: admin}} : {}), ...(participants ? {participants} : {})}) .find<Group>({ ...(admin ? { admin: { $ne: admin } } : {}), ...(participants ? { participants } : {}) })
.toArray(); .toArray();
}; };
export const getCorporateNameForStudent = async (studentID: string) => { export const getCorporateNameForStudent = async (studentID: string) => {
const groups = await getStudentGroupsForUsersWithoutAdmin("", [studentID]); const groups = await getStudentGroupsForUsersWithoutAdmin("", [studentID]);
if (groups.length === 0) return ""; if (groups.length === 0) return "";
const adminUserIds = [...new Set(groups.map((g) => g.admin))]; const adminUserIds = [...new Set(groups.map((g) => g.admin))];
const adminUsersData = (await getSpecificUsers(adminUserIds)).filter((x) => !!x) as User[]; const adminUsersData = (await getSpecificUsers(adminUserIds)).filter((x) => !!x) as User[];
if (adminUsersData.length === 0) return ""; if (adminUsersData.length === 0) return "";
const admins = adminUsersData.filter((x) => x.type === "corporate"); const admins = adminUsersData.filter((x) => x.type === "corporate");
if (admins.length > 0) { if (admins.length > 0) {
return (admins[0] as CorporateUser).corporateInformation.companyInformation.name; return (admins[0] as CorporateUser).corporateInformation.companyInformation.name;
} }
return ""; return "";
}; };
export const getGroupsByEntity = async (id: string) => await db.collection("groups").find<Group>({entity: id}).toArray(); export const getGroupsByEntity = async (id: string) => await db.collection("groups").find<Group>({ entity: id }).toArray();
export const getGroupsByEntities = async (ids: string[]) => export const getGroupsByEntities = async (ids: string[]): Promise<WithEntity<Group>[]> =>
await db await db.collection("groups")
.collection("groups") .aggregate<WithEntity<Group>>([
.find<Group>({entity: {$in: ids}}) { $match: { entity: { $in: ids } } },
.toArray(); ...addEntityToGroupPipeline
]).toArray()

View File

@@ -44,7 +44,7 @@ export const convertBase64 = (file: File) => {
}); });
}; };
export const mapBy = <T>(obj: T[] | undefined, key: keyof T) => (obj || []).map((i) => i[key]); export const mapBy = <T, K extends keyof T>(obj: T[] | undefined, key: K) => (obj || []).map((i) => i[key] as T[K]);
export const filterBy = <T>(obj: T[], key: keyof T, value: any) => obj.filter((i) => i[key] === value); export const filterBy = <T>(obj: T[], key: keyof T, value: any) => obj.filter((i) => i[key] === value);
export const serialize = <T>(obj: T): T => JSON.parse(JSON.stringify(obj)); export const serialize = <T>(obj: T): T => JSON.parse(JSON.stringify(obj));

View File

@@ -1,140 +1,140 @@
import {CorporateUser, Type, User} from "@/interfaces/user"; import { CorporateUser, Type, User } from "@/interfaces/user";
import {getGroupsForUser, getParticipantGroups, getUserGroups, getUsersGroups} from "./groups.be"; import { getGroupsForUser, getParticipantGroups, getUserGroups, getUsersGroups } from "./groups.be";
import {uniq} from "lodash"; import { uniq } from "lodash";
import {getUserCodes} from "./codes.be"; import { getUserCodes } from "./codes.be";
import client from "@/lib/mongodb"; import client from "@/lib/mongodb";
import {WithEntity} from "@/interfaces/entity"; import { WithEntities } from "@/interfaces/entity";
import {getEntity} from "./entities.be"; import { getEntity } from "./entities.be";
import {getRole} from "./roles.be"; import { getRole } from "./roles.be";
const db = client.db(process.env.MONGODB_DB); const db = client.db(process.env.MONGODB_DB);
export async function getUsers() { export async function getUsers(filter?: object) {
return await db return await db
.collection("users") .collection("users")
.find<User>({}, {projection: {_id: 0}}) .find<User>(filter || {}, { projection: { _id: 0 } })
.toArray(); .toArray();
} }
export async function getUserWithEntity(id: string): Promise<WithEntity<User> | undefined> { export async function getUserWithEntity(id: string): Promise<WithEntities<User> | undefined> {
const user = await db.collection("users").findOne<User>({id: id}, {projection: {_id: 0}}); const user = await db.collection("users").findOne<User>({ id: id }, { projection: { _id: 0 } });
if (!user) return undefined; if (!user) return undefined;
const entities = await Promise.all( const entities = await Promise.all(
user.entities.map(async (e) => { user.entities.map(async (e) => {
const entity = await getEntity(e.id); const entity = await getEntity(e.id);
const role = await getRole(e.role); const role = await getRole(e.role);
return {entity, role}; return { entity, role };
}), }),
); );
return {...user, entities}; return { ...user, entities };
} }
export async function getUser(id: string): Promise<User | undefined> { export async function getUser(id: string): Promise<User | undefined> {
const user = await db.collection("users").findOne<User>({id: id}, {projection: {_id: 0}}); const user = await db.collection("users").findOne<User>({ id: id }, { projection: { _id: 0 } });
return !!user ? user : undefined; return !!user ? user : undefined;
} }
export async function getSpecificUsers(ids: string[]) { export async function getSpecificUsers(ids: string[]) {
if (ids.length === 0) return []; if (ids.length === 0) return [];
return await db return await db
.collection("users") .collection("users")
.find<User>({id: {$in: ids}}, {projection: {_id: 0}}) .find<User>({ id: { $in: ids } }, { projection: { _id: 0 } })
.toArray(); .toArray();
} }
export async function getEntityUsers(id: string, limit?: number) { export async function getEntityUsers(id: string, limit?: number) {
return await db return await db
.collection("users") .collection("users")
.find<User>({"entities.id": id}) .find<User>({ "entities.id": id })
.limit(limit || 0) .limit(limit || 0)
.toArray(); .toArray();
} }
export async function countEntityUsers(id: string) { export async function countEntityUsers(id: string) {
return await db.collection("users").countDocuments({"entities.id": id}); return await db.collection("users").countDocuments({ "entities.id": id });
} }
export async function getEntitiesUsers(ids: string[], limit?: number) { export async function getEntitiesUsers(ids: string[], filter?: object, limit?: number) {
return await db return await db
.collection("users") .collection("users")
.find<User>({"entities.id": {$in: ids}}) .find<User>({ "entities.id": { $in: ids }, ...(filter || {}) })
.limit(limit || 0) .limit(limit || 0)
.toArray(); .toArray();
} }
export async function countEntitiesUsers(ids: string[]) { export async function countEntitiesUsers(ids: string[]) {
return await db.collection("users").countDocuments({"entities.id": {$in: ids}}); return await db.collection("users").countDocuments({ "entities.id": { $in: ids } });
} }
export async function getLinkedUsers( export async function getLinkedUsers(
userID?: string, userID?: string,
userType?: Type, userType?: Type,
type?: Type, type?: Type,
page?: number, page?: number,
size?: number, size?: number,
sort?: string, sort?: string,
direction?: "asc" | "desc", direction?: "asc" | "desc",
) { ) {
const filters = { const filters = {
...(!!type ? {type} : {}), ...(!!type ? { type } : {}),
}; };
if (!userID || userType === "admin" || userType === "developer") { if (!userID || userType === "admin" || userType === "developer") {
const users = await db const users = await db
.collection("users") .collection("users")
.find<User>(filters) .find<User>(filters)
.sort(sort ? {[sort]: direction === "desc" ? -1 : 1} : {}) .sort(sort ? { [sort]: direction === "desc" ? -1 : 1 } : {})
.skip(page && size ? page * size : 0) .skip(page && size ? page * size : 0)
.limit(size || 0) .limit(size || 0)
.toArray(); .toArray();
const total = await db.collection("users").countDocuments(filters); const total = await db.collection("users").countDocuments(filters);
return {users, total}; return { users, total };
} }
const adminGroups = await getUserGroups(userID); const adminGroups = await getUserGroups(userID);
const groups = await getUsersGroups(adminGroups.flatMap((x) => x.participants)); const groups = await getUsersGroups(adminGroups.flatMap((x) => x.participants));
const belongingGroups = await getParticipantGroups(userID); const belongingGroups = await getParticipantGroups(userID);
const participants = uniq([ const participants = uniq([
...adminGroups.flatMap((x) => x.participants), ...adminGroups.flatMap((x) => x.participants),
...(userType === "mastercorporate" ? groups.flat().flatMap((x) => x.participants) : []), ...(userType === "mastercorporate" ? groups.flat().flatMap((x) => x.participants) : []),
...(userType === "teacher" ? belongingGroups.flatMap((x) => x.participants) : []), ...(userType === "teacher" ? belongingGroups.flatMap((x) => x.participants) : []),
]); ]);
// [FirebaseError: Invalid Query. A non-empty array is required for 'in' filters.] { // [FirebaseError: Invalid Query. A non-empty array is required for 'in' filters.] {
if (participants.length === 0) return {users: [], total: 0}; if (participants.length === 0) return { users: [], total: 0 };
const users = await db const users = await db
.collection("users") .collection("users")
.find<User>({...filters, id: {$in: participants}}) .find<User>({ ...filters, id: { $in: participants } })
.skip(page && size ? page * size : 0) .skip(page && size ? page * size : 0)
.limit(size || 0) .limit(size || 0)
.toArray(); .toArray();
const total = await db.collection("users").countDocuments({...filters, id: {$in: participants}}); const total = await db.collection("users").countDocuments({ ...filters, id: { $in: participants } });
return {users, total}; return { users, total };
} }
export async function getUserBalance(user: User) { export async function getUserBalance(user: User) {
const codes = await getUserCodes(user.id); const codes = await getUserCodes(user.id);
if (user.type !== "corporate" && user.type !== "mastercorporate") return codes.length; if (user.type !== "corporate" && user.type !== "mastercorporate") return codes.length;
const groups = await getGroupsForUser(user.id); const groups = await getGroupsForUser(user.id);
const participants = uniq(groups.flatMap((x) => x.participants)); const participants = uniq(groups.flatMap((x) => x.participants));
if (user.type === "corporate") return participants.length + codes.filter((x) => !participants.includes(x.userId || "")).length; if (user.type === "corporate") return participants.length + codes.filter((x) => !participants.includes(x.userId || "")).length;
const participantUsers = await Promise.all(participants.map(getUser)); const participantUsers = await Promise.all(participants.map(getUser));
const corporateUsers = participantUsers.filter((x) => x?.type === "corporate") as CorporateUser[]; const corporateUsers = participantUsers.filter((x) => x?.type === "corporate") as CorporateUser[];
return ( return (
corporateUsers.reduce((acc, curr) => acc + curr.corporateInformation?.companyInformation?.userAmount || 0, 0) + corporateUsers.reduce((acc, curr) => acc + curr.corporateInformation?.companyInformation?.userAmount || 0, 0) +
corporateUsers.length + corporateUsers.length +
codes.filter((x) => !participants.includes(x.userId || "") && !corporateUsers.map((u) => u.id).includes(x.userId || "")).length codes.filter((x) => !participants.includes(x.userId || "") && !corporateUsers.map((u) => u.id).includes(x.userId || "")).length
); );
} }

View File

@@ -1,45 +1,46 @@
import {Group, User} from "@/interfaces/user"; import { WithLabeledEntities } from "@/interfaces/entity";
import {getUserCompanyName, USER_TYPE_LABELS} from "@/resources/user"; import { Group, User } from "@/interfaces/user";
import {capitalize} from "lodash"; import { getUserCompanyName, USER_TYPE_LABELS } from "@/resources/user";
import { capitalize } from "lodash";
import moment from "moment"; import moment from "moment";
export interface UserListRow { export interface UserListRow {
name: string; name: string;
email: string; email: string;
type: string; type: string;
companyName: string; entities: string;
expiryDate: string; expiryDate: string;
verified: string; verified: string;
country: string; country: string;
phone: string; phone: string;
employmentPosition: string; employmentPosition: string;
gender: string; gender: string;
} }
export const exportListToExcel = (rowUsers: User[], users: User[], groups: Group[]) => { export const exportListToExcel = (rowUsers: WithLabeledEntities<User>[]) => {
const rows: UserListRow[] = rowUsers.map((user) => ({ const rows: UserListRow[] = rowUsers.map((user) => ({
name: user.name, name: user.name,
email: user.email, email: user.email,
type: USER_TYPE_LABELS[user.type], type: USER_TYPE_LABELS[user.type],
companyName: getUserCompanyName(user, users, groups), entities: user.entities.map((e) => e.label).join(', '),
expiryDate: user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited", expiryDate: user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited",
country: user.demographicInformation?.country || "N/A", country: user.demographicInformation?.country || "N/A",
phone: user.demographicInformation?.phone || "N/A", phone: user.demographicInformation?.phone || "N/A",
employmentPosition: employmentPosition:
(user.type === "corporate" || user.type === "mastercorporate" (user.type === "corporate" || user.type === "mastercorporate"
? user.demographicInformation?.position ? user.demographicInformation?.position
: user.demographicInformation?.employment) || "N/A", : user.demographicInformation?.employment) || "N/A",
gender: user.demographicInformation?.gender ? capitalize(user.demographicInformation.gender) : "N/A", gender: user.demographicInformation?.gender ? capitalize(user.demographicInformation.gender) : "N/A",
verified: user.isVerified?.toString() || "FALSE", verified: user.isVerified?.toString() || "FALSE",
})); }));
const header = "Name,Email,Type,Company Name,Expiry Date,Country,Phone,Employment/Department,Gender,Verification"; const header = "Name,Email,Type,Entities,Expiry Date,Country,Phone,Employment/Department,Gender,Verification";
const rowsString = rows.map((x) => Object.values(x).join(",")).join("\n"); const rowsString = rows.map((x) => Object.values(x).join(",")).join("\n");
return `${header}\n${rowsString}`; return `${header}\n${rowsString}`;
}; };
export const getUserName = (user?: User) => { export const getUserName = (user?: User) => {
if (!user) return "N/A"; if (!user) return "N/A";
if (user.type === "corporate" || user.type === "mastercorporate") return user.corporateInformation?.companyInformation?.name || user.name; if (user.type === "corporate" || user.type === "mastercorporate") return user.corporateInformation?.companyInformation?.name || user.name;
return user.name; return user.name;
}; };