Merge branch 'develop' of https://bitbucket.org/ecropdev/ielts-ui into feature/level-file-upload

This commit is contained in:
Carlos Mesquita
2024-08-19 16:53:39 +01:00
58 changed files with 7815 additions and 8224 deletions

View File

@@ -1,255 +1,179 @@
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import { import {Ticket, TicketStatus, TicketStatusLabel, TicketType, TicketTypeLabel} from "@/interfaces/ticket";
Ticket, import {User} from "@/interfaces/user";
TicketStatus, import {USER_TYPE_LABELS} from "@/resources/user";
TicketStatusLabel,
TicketType,
TicketTypeLabel,
} from "@/interfaces/ticket";
import { User } from "@/interfaces/user";
import { USER_TYPE_LABELS } from "@/resources/user";
import axios from "axios"; import axios from "axios";
import moment from "moment"; import moment from "moment";
import { useState } from "react"; import {useState} from "react";
import { toast } from "react-toastify"; import {toast} from "react-toastify";
import ShortUniqueId from "short-unique-id"; import ShortUniqueId from "short-unique-id";
import Button from "../Low/Button"; import Button from "../Low/Button";
import Input from "../Low/Input"; import Input from "../Low/Input";
import Select from "../Low/Select"; import Select from "../Low/Select";
import { checkAccess } from "@/utils/permissions"; import {checkAccess} from "@/utils/permissions";
interface Props { interface Props {
user: User; user: User;
ticket: Ticket; ticket: Ticket;
onClose: () => void; onClose: () => void;
} }
export default function TicketDisplay({ user, ticket, onClose }: Props) { export default function TicketDisplay({user, ticket, onClose}: Props) {
const [subject] = useState(ticket.subject); const [subject] = useState(ticket.subject);
const [type, setType] = useState<TicketType>(ticket.type); const [type, setType] = useState<TicketType>(ticket.type);
const [description] = useState(ticket.description); const [description] = useState(ticket.description);
const [reporter] = useState(ticket.reporter); const [reporter] = useState(ticket.reporter);
const [reportedFrom] = useState(ticket.reportedFrom); const [reportedFrom] = useState(ticket.reportedFrom);
const [status, setStatus] = useState(ticket.status); const [status, setStatus] = useState(ticket.status);
const [assignedTo, setAssignedTo] = useState<string | null>( const [assignedTo, setAssignedTo] = useState<string | null>(ticket.assignedTo || null);
ticket.assignedTo || null, const [isLoading, setIsLoading] = useState(false);
);
const [isLoading, setIsLoading] = useState(false);
const { users } = useUsers(); const {users} = useUsers();
const submit = () => { const submit = () => {
if (!type) if (!type) return toast.error("Please choose a type!", {toastId: "missing-type"});
return toast.error("Please choose a type!", { toastId: "missing-type" });
setIsLoading(true); setIsLoading(true);
axios axios
.patch(`/api/tickets/${ticket.id}`, { .patch(`/api/tickets/${ticket.id}`, {
subject, subject,
type, type,
description, description,
reporter, reporter,
reportedFrom, reportedFrom,
status, status,
assignedTo, assignedTo,
}) })
.then(() => { .then(() => {
toast.success(`The ticket has been updated!`, { toastId: "submitted" }); toast.success(`The ticket has been updated!`, {toastId: "submitted"});
onClose(); onClose();
}) })
.catch((e) => { .catch((e) => {
console.error(e); console.error(e);
toast.error("Something went wrong, please try again later!", { toast.error("Something went wrong, please try again later!", {
toastId: "error", toastId: "error",
}); });
}) })
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
const del = () => { const del = () => {
if (!confirm("Are you sure you want to delete this ticket?")) return; if (!confirm("Are you sure you want to delete this ticket?")) return;
setIsLoading(true); setIsLoading(true);
axios axios
.delete(`/api/tickets/${ticket.id}`) .delete(`/api/tickets/${ticket.id}`)
.then(() => { .then(() => {
toast.success(`The ticket has been deleted!`, { toastId: "submitted" }); toast.success(`The ticket has been deleted!`, {toastId: "submitted"});
onClose(); onClose();
}) })
.catch((e) => { .catch((e) => {
console.error(e); console.error(e);
toast.error("Something went wrong, please try again later!", { toast.error("Something went wrong, please try again later!", {
toastId: "error", toastId: "error",
}); });
}) })
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
return ( return (
<form className="flex flex-col gap-4 pt-8"> <form className="flex flex-col gap-4 pt-8">
<Input <Input label="Subject" type="text" name="subject" placeholder="Subject..." value={subject} onChange={(e) => null} disabled />
label="Subject"
type="text"
name="subject"
placeholder="Subject..."
value={subject}
onChange={(e) => null}
disabled
/>
<div className="-md:flex-col flex w-full items-center gap-4"> <div className="-md:flex-col flex w-full items-center gap-4">
<div className="flex w-full flex-col gap-3"> <div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal"> <label className="text-mti-gray-dim text-base font-normal">Status</label>
Status <Select
</label> options={Object.keys(TicketStatusLabel).map((x) => ({
<Select value: x,
options={Object.keys(TicketStatusLabel).map((x) => ({ label: TicketStatusLabel[x as keyof typeof TicketStatusLabel],
value: x, }))}
label: TicketStatusLabel[x as keyof typeof TicketStatusLabel], value={{value: status, label: TicketStatusLabel[status]}}
}))} onChange={(value) => setStatus((value?.value as TicketStatus) ?? undefined)}
value={{ value: status, label: TicketStatusLabel[status] }} placeholder="Status..."
onChange={(value) => />
setStatus((value?.value as TicketStatus) ?? undefined) </div>
} <div className="flex w-full flex-col gap-3">
placeholder="Status..." <label className="text-mti-gray-dim text-base font-normal">Type</label>
/> <Select
</div> options={Object.keys(TicketTypeLabel).map((x) => ({
<div className="flex w-full flex-col gap-3"> value: x,
<label className="text-mti-gray-dim text-base font-normal"> label: TicketTypeLabel[x as keyof typeof TicketTypeLabel],
Type }))}
</label> value={{value: type, label: TicketTypeLabel[type]}}
<Select onChange={(value) => setType(value!.value as TicketType)}
options={Object.keys(TicketTypeLabel).map((x) => ({ placeholder="Type..."
value: x, />
label: TicketTypeLabel[x as keyof typeof TicketTypeLabel], </div>
}))} </div>
value={{ value: type, label: TicketTypeLabel[type] }}
onChange={(value) => setType(value!.value as TicketType)}
placeholder="Type..."
/>
</div>
</div>
<div className="flex w-full flex-col gap-3"> <div className="flex w-full flex-col gap-3">
<label className="text-mti-gray-dim text-base font-normal"> <label className="text-mti-gray-dim text-base font-normal">Assignee</label>
Assignee <Select
</label> options={[
<Select {value: "me", label: "Assign to me"},
options={[ ...users
{ value: "me", label: "Assign to me" }, .filter((x) => checkAccess(x, ["admin", "developer", "agent"]))
...users .map((u) => ({
.filter((x) => checkAccess(x, ["admin", "developer", "agent"])) value: u.id,
.map((u) => ({ label: `${u.name} - ${u.email}`,
value: u.id, })),
label: `${u.name} - ${u.email}`, ]}
})), disabled={checkAccess(user, ["agent"])}
]} value={
disabled={checkAccess(user, ["agent"])} assignedTo
value={ ? {
assignedTo value: assignedTo,
? { label: `${users.find((u) => u.id === assignedTo)?.name} - ${users.find((u) => u.id === assignedTo)?.email}`,
value: assignedTo, }
label: `${users.find((u) => u.id === assignedTo)?.name} - ${users.find((u) => u.id === assignedTo)?.email}`, : null
} }
: null onChange={(value) => (value ? setAssignedTo(value.value === "me" ? user.id : value.value) : setAssignedTo(null))}
} placeholder="Assignee..."
onChange={(value) => isClearable
value />
? setAssignedTo(value.value === "me" ? user.id : value.value) </div>
: setAssignedTo(null)
}
placeholder="Assignee..."
isClearable
/>
</div>
<div className="-md:flex-col flex w-full items-center gap-4"> <div className="-md:flex-col flex w-full items-center gap-4">
<Input <Input label="Reported From" type="text" name="reportedFrom" onChange={() => null} value={reportedFrom} disabled />
label="Reported From" <Input label="Date" type="text" name="date" onChange={() => null} value={moment(ticket.date).format("DD/MM/YYYY - HH:mm")} disabled />
type="text" </div>
name="reportedFrom"
onChange={() => null}
value={reportedFrom}
disabled
/>
<Input
label="Date"
type="text"
name="date"
onChange={() => null}
value={moment(ticket.date).format("DD/MM/YYYY - HH:mm")}
disabled
/>
</div>
<div className="-md:flex-col flex w-full items-center gap-4"> <div className="-md:flex-col flex w-full items-center gap-4">
<Input <Input label="Reporter's Name" type="text" name="reporter" onChange={() => null} value={reporter.name} disabled />
label="Reporter's Name" <Input label="Reporter's E-mail" type="text" name="reporter" onChange={() => null} value={reporter.email} disabled />
type="text" <Input
name="reporter" label="Reporter's Type"
onChange={() => null} type="text"
value={reporter.name} name="reporterType"
disabled onChange={() => null}
/> value={USER_TYPE_LABELS[reporter.type]}
<Input disabled
label="Reporter's E-mail" />
type="text" </div>
name="reporter"
onChange={() => null}
value={reporter.email}
disabled
/>
<Input
label="Reporter's Type"
type="text"
name="reporterType"
onChange={() => null}
value={USER_TYPE_LABELS[reporter.type]}
disabled
/>
</div>
<textarea <textarea
className="input border-mti-gray-platinum h-full min-h-[300px] w-full cursor-text rounded-3xl border bg-white px-7 py-8" className="input border-mti-gray-platinum h-full min-h-[300px] w-full cursor-text rounded-3xl border bg-white px-7 py-8"
placeholder="Write your ticket's description here..." placeholder="Write your ticket's description here..."
contentEditable={false} contentEditable={false}
value={description} value={description}
spellCheck spellCheck
/> />
<div className="-md:flex-col-reverse mt-2 flex w-full items-center justify-between gap-4"> <div className="-md:flex-col-reverse mt-2 flex w-full items-center justify-between gap-4">
<Button <Button type="button" color="red" className="w-full md:max-w-[200px]" variant="outline" onClick={del} isLoading={isLoading}>
type="button" Delete
color="red" </Button>
className="w-full md:max-w-[200px]"
variant="outline"
onClick={del}
isLoading={isLoading}
>
Delete
</Button>
<div className="-md:flex-col-reverse flex w-full items-center justify-end gap-4"> <div className="-md:flex-col-reverse flex w-full items-center justify-end gap-4">
<Button <Button type="button" color="red" className="w-full md:max-w-[200px]" variant="outline" onClick={onClose} isLoading={isLoading}>
type="button" Cancel
color="red" </Button>
className="w-full md:max-w-[200px]" <Button type="button" className="w-full md:max-w-[200px]" isLoading={isLoading} onClick={submit}>
variant="outline" Update
onClick={onClose} </Button>
isLoading={isLoading} </div>
> </div>
Cancel </form>
</Button> );
<Button
type="button"
className="w-full md:max-w-[200px]"
isLoading={isLoading}
onClick={submit}
>
Update
</Button>
</div>
</div>
</form>
);
} }

36
src/components/List.tsx Normal file
View File

@@ -0,0 +1,36 @@
import {Column, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
export default function List<T>({data, columns}: {data: T[]; columns: any[]}) {
const table = useReactTable({
data,
columns: columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<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="p-4 text-left" 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="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" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}

View File

@@ -1,62 +1,105 @@
import {Permission} from "@/interfaces/permissions"; import React from "react";
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table"; import { Permission } from "@/interfaces/permissions";
import {
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
Row,
} from "@tanstack/react-table";
import Link from "next/link"; import Link from "next/link";
import {convertCamelCaseToReadable} from "@/utils/string"; import { convertCamelCaseToReadable } from "@/utils/string";
interface Props { interface Props {
permissions: Permission[]; permissions: Permission[];
} }
const columnHelper = createColumnHelper<Permission>(); const columnHelper = createColumnHelper<Permission>();
const defaultColumns = [ const defaultColumns = [
columnHelper.accessor("type", { columnHelper.accessor("type", {
header: () => <span>Type</span>, header: () => <span>Type</span>,
cell: ({row, getValue}) => ( cell: ({ row, getValue }) => (
<Link <Link
href={`/permissions/${row.original.id}`} href={`/permissions/${row.original.id}`}
key={row.id} key={row.id}
className="underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer"> className="underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer"
{convertCamelCaseToReadable(getValue() as string)} >
</Link> {convertCamelCaseToReadable(getValue() as string)}
), </Link>
}), ),
}),
]; ];
export default function PermissionList({permissions}: Props) { export default function PermissionList({ permissions }: Props) {
const table = useReactTable({ const table = useReactTable({
data: permissions, data: permissions,
columns: defaultColumns, columns: defaultColumns,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
}); });
return (
<div className="w-full"> const groupedData: { [key: string]: Row<Permission>[] } = table
<div className="w-full flex flex-col gap-2"> .getRowModel()
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full"> .rows.reduce((groups: { [key: string]: Row<Permission>[] }, row) => {
<thead> const parent = row.original.topic;
{table.getHeaderGroups().map((headerGroup) => ( if (!groups[parent]) {
<tr key={headerGroup.id}> groups[parent] = [];
{headerGroup.headers.map((header) => ( }
<th className="py-4 px-4 text-left" key={header.id}> groups[parent].push(row);
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} return groups;
</th> }, {});
))}
</tr> return (
))} <div className="w-full">
</thead> <div className="w-full flex flex-col gap-2">
<tbody className="px-2"> <table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
{table.getRowModel().rows.map((row) => ( <thead>
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}> {table.getHeaderGroups().map((headerGroup) => (
{row.getVisibleCells().map((cell) => ( <tr key={headerGroup.id}>
<td className="px-4 py-2 items-center w-fit" key={cell.id}> {headerGroup.headers.map((header) => (
{flexRender(cell.column.columnDef.cell, cell.getContext())} <th className="py-4 px-4 text-left" key={header.id}>
</td> {header.isPlaceholder
))} ? null
</tr> : flexRender(
))} header.column.columnDef.header,
</tbody> header.getContext()
</table> )}
</div> </th>
</div> ))}
); </tr>
))}
</thead>
<tbody className="px-2">
{Object.keys(groupedData).map((parent) => (
<React.Fragment key={parent}>
<tr>
<td className="px-2 py-2 items-center w-fit">
<strong>{parent}</strong>
</td>
</tr>
{groupedData[parent].map((row, i) => (
<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>
))}
</React.Fragment>
))}
</tbody>
</table>
</div>
</div>
);
} }

View File

@@ -13,8 +13,9 @@ import {
BsCurrencyDollar, BsCurrencyDollar,
BsClipboardData, BsClipboardData,
BsFileLock, BsFileLock,
BsPeople,
} from "react-icons/bs"; } from "react-icons/bs";
import { CiDumbbell } from "react-icons/ci"; import {CiDumbbell} from "react-icons/ci";
import {RiLogoutBoxFill} from "react-icons/ri"; import {RiLogoutBoxFill} from "react-icons/ri";
import {SlPencil} from "react-icons/sl"; import {SlPencil} from "react-icons/sl";
import {FaAward} from "react-icons/fa"; import {FaAward} from "react-icons/fa";
@@ -28,6 +29,7 @@ import usePreferencesStore from "@/stores/preferencesStore";
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import useTicketsListener from "@/hooks/useTicketsListener"; import useTicketsListener from "@/hooks/useTicketsListener";
import {checkAccess, getTypesOfUser} from "@/utils/permissions"; import {checkAccess, getTypesOfUser} from "@/utils/permissions";
import usePermissions from "@/hooks/usePermissions";
interface Props { interface Props {
path: string; path: string;
navDisabled?: boolean; navDisabled?: boolean;
@@ -80,6 +82,7 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]); const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]);
const {totalAssignedTickets} = useTicketsListener(user.id); const {totalAssignedTickets} = useTicketsListener(user.id);
const {permissions} = usePermissions(user.id);
const logout = async () => { const logout = async () => {
axios.post("/api/logout").finally(() => { axios.post("/api/logout").finally(() => {
@@ -98,22 +101,25 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
)}> )}>
<div className="-xl:hidden flex-col gap-3 xl:flex"> <div className="-xl:hidden flex-col gap-3 xl:flex">
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={isMinimized} /> <Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={isMinimized} />
{checkAccess(user, ["student", "teacher", "developer"], "viewExams") && ( {checkAccess(user, ["student", "teacher", "developer"], permissions, "viewExams") && (
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={isMinimized} /> <Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={isMinimized} />
)} )}
{checkAccess(user, ["student", "teacher", "developer"], "viewExercises") && ( {checkAccess(user, ["student", "teacher", "developer"], permissions, "viewExercises") && (
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={isMinimized} /> <Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={isMinimized} />
)} )}
{checkAccess(user, getTypesOfUser(["agent"]), "viewStats") && ( {checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewStats") && (
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} /> <Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
)} )}
{checkAccess(user, getTypesOfUser(["agent"]), "viewRecords") && ( {checkAccess(user, ["developer", "admin", "teacher", "student"], permissions) && (
<Nav disabled={disableNavigation} Icon={BsPeople} label="Groups" path={path} keyPath="/groups" isMinimized={isMinimized} />
)}
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} /> <Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
)} )}
{checkAccess(user, getTypesOfUser(["agent"]), "viewRecords") && ( {checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
<Nav disabled={disableNavigation} Icon={CiDumbbell} label="Training" path={path} keyPath="/training" isMinimized={isMinimized} /> <Nav disabled={disableNavigation} Icon={CiDumbbell} label="Training" path={path} keyPath="/training" isMinimized={isMinimized} />
)} )}
{checkAccess(user, ["admin", "developer", "agent", "corporate", "mastercorporate"], "viewPaymentRecords") && ( {checkAccess(user, ["admin", "developer", "agent", "corporate", "mastercorporate"], permissions, "viewPaymentRecords") && (
<Nav <Nav
disabled={disableNavigation} disabled={disableNavigation}
Icon={BsCurrencyDollar} Icon={BsCurrencyDollar}
@@ -133,7 +139,7 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
isMinimized={isMinimized} isMinimized={isMinimized}
/> />
)} )}
{checkAccess(user, ["admin", "developer", "agent"], "viewTickets") && ( {checkAccess(user, ["admin", "developer", "agent"], permissions, "viewTickets") && (
<Nav <Nav
disabled={disableNavigation} disabled={disableNavigation}
Icon={BsClipboardData} Icon={BsClipboardData}
@@ -144,38 +150,38 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
badge={totalAssignedTickets} badge={totalAssignedTickets}
/> />
)} )}
{checkAccess(user, ["developer", "admin"]) && ( {checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
<> <Nav
<Nav disabled={disableNavigation}
disabled={disableNavigation} Icon={BsCloudFill}
Icon={BsCloudFill} label="Generation"
label="Generation" path={path}
path={path} keyPath="/generation"
keyPath="/generation" isMinimized={isMinimized}
isMinimized={isMinimized} />
/> )}
<Nav {checkAccess(user, ["developer", "admin", "corporate", "mastercorporate", "agent"]) && (
disabled={disableNavigation} <Nav
Icon={BsFileLock} disabled={disableNavigation}
label="Permissions" Icon={BsFileLock}
path={path} label="Permissions"
keyPath="/permissions" path={path}
isMinimized={isMinimized} keyPath="/permissions"
/> isMinimized={isMinimized}
</> />
)} )}
</div> </div>
<div className="-xl:flex flex-col gap-3 xl:hidden"> <div className="-xl:flex flex-col gap-3 xl:hidden">
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={true} /> <Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={true} />
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={true} /> <Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={true} />
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={true} /> <Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={true} />
{checkAccess(user, getTypesOfUser(["agent"]), "viewStats") && ( {checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewStats") && (
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={true} /> <Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={true} />
)} )}
{checkAccess(user, getTypesOfUser(["agent"]), "viewRecords") && ( {checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={true} /> <Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={true} />
)} )}
{checkAccess(user, getTypesOfUser(["agent"]), "viewRecords") && ( {checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
<Nav disabled={disableNavigation} Icon={CiDumbbell} label="Training" path={path} keyPath="/training" isMinimized={true} /> <Nav disabled={disableNavigation} Icon={CiDumbbell} label="Training" path={path} keyPath="/training" isMinimized={true} />
)} )}
{checkAccess(user, getTypesOfUser(["student"])) && ( {checkAccess(user, getTypesOfUser(["student"])) && (

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2,17 +2,12 @@
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import useStats from "@/hooks/useStats"; import useStats from "@/hooks/useStats";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import { User } from "@/interfaces/user"; import {User} from "@/interfaces/user";
import UserList from "@/pages/(admin)/Lists/UserList"; import UserList from "@/pages/(admin)/Lists/UserList";
import { dateSorter } from "@/utils"; import {dateSorter} from "@/utils";
import moment from "moment"; import moment from "moment";
import { useEffect, useState } from "react"; import {useEffect, useState} from "react";
import { import {BsArrowLeft, BsPersonFill, BsBank, BsCurrencyDollar} from "react-icons/bs";
BsArrowLeft,
BsPersonFill,
BsBank,
BsCurrencyDollar,
} from "react-icons/bs";
import UserCard from "@/components/UserCard"; import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
@@ -20,276 +15,235 @@ import IconCard from "./IconCard";
import usePaymentStatusUsers from "@/hooks/usePaymentStatusUsers"; import usePaymentStatusUsers from "@/hooks/usePaymentStatusUsers";
interface Props { interface Props {
user: User; user: User;
} }
export default function AgentDashboard({ user }: Props) { export default function AgentDashboard({user}: Props) {
const [page, setPage] = useState(""); const [page, setPage] = useState("");
const [selectedUser, setSelectedUser] = useState<User>(); const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const { stats } = useStats(); const {stats} = useStats();
const { users, reload } = useUsers(); const {users, reload} = useUsers();
const { groups } = useGroups(user.id); const {pending, done} = usePaymentStatusUsers();
const { pending, done } = usePaymentStatusUsers();
useEffect(() => { useEffect(() => {
setShowModal(!!selectedUser && page === ""); setShowModal(!!selectedUser && page === "");
}, [selectedUser, page]); }, [selectedUser, page]);
const corporateFilter = (user: User) => user.type === "corporate"; const corporateFilter = (user: User) => user.type === "corporate";
const referredCorporateFilter = (x: User) => const referredCorporateFilter = (x: User) =>
x.type === "corporate" && x.type === "corporate" && !!x.corporateInformation && x.corporateInformation.referralAgent === user.id;
!!x.corporateInformation && const inactiveReferredCorporateFilter = (x: User) =>
x.corporateInformation.referralAgent === user.id; referredCorporateFilter(x) && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
const inactiveReferredCorporateFilter = (x: User) =>
referredCorporateFilter(x) &&
(x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
const UserDisplay = ({ const UserDisplay = ({displayUser, allowClick = true}: {displayUser: User; allowClick?: boolean}) => (
displayUser, <div
allowClick = true, onClick={() => allowClick && setSelectedUser(displayUser)}
}: { className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
displayUser: User; <img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
allowClick?: boolean; <div className="flex flex-col gap-1 items-start">
}) => ( <span>
<div {displayUser.type === "corporate"
onClick={() => allowClick && setSelectedUser(displayUser)} ? displayUser.corporateInformation?.companyInformation?.name || displayUser.name
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300" : displayUser.name}
> </span>
<img <span className="text-sm opacity-75">{displayUser.email}</span>
src={displayUser.profilePicture} </div>
alt={displayUser.name} </div>
className="rounded-full w-10 h-10" );
/>
<div className="flex flex-col gap-1 items-start">
<span>
{displayUser.type === "corporate"
? displayUser.corporateInformation?.companyInformation?.name ||
displayUser.name
: displayUser.name}
</span>
<span className="text-sm opacity-75">{displayUser.email}</span>
</div>
</div>
);
const ReferredCorporateList = () => { const ReferredCorporateList = () => {
return ( return (
<UserList <UserList
user={user} user={user}
filters={[referredCorporateFilter]} filters={[referredCorporateFilter]}
renderHeader={(total) => ( renderHeader={(total) => (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div <div
onClick={() => setPage("")} onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300" className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
> <BsArrowLeft className="text-xl" />
<BsArrowLeft className="text-xl" /> <span>Back</span>
<span>Back</span> </div>
</div> <h2 className="text-2xl font-semibold">Referred Corporate ({total})</h2>
<h2 className="text-2xl font-semibold"> </div>
Referred Corporate ({total}) )}
</h2> />
</div> );
)} };
/>
);
};
const InactiveReferredCorporateList = () => { const InactiveReferredCorporateList = () => {
return ( return (
<UserList <UserList
user={user} user={user}
filters={[inactiveReferredCorporateFilter]} filters={[inactiveReferredCorporateFilter]}
renderHeader={(total) => ( renderHeader={(total) => (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div <div
onClick={() => setPage("")} onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300" className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
> <BsArrowLeft className="text-xl" />
<BsArrowLeft className="text-xl" /> <span>Back</span>
<span>Back</span> </div>
</div> <h2 className="text-2xl font-semibold">Inactive Referred Corporate ({total})</h2>
<h2 className="text-2xl font-semibold"> </div>
Inactive Referred Corporate ({total}) )}
</h2> />
</div> );
)} };
/>
);
};
const CorporateList = () => { const CorporateList = () => {
const filter = (x: User) => x.type === "corporate"; const filter = (x: User) => x.type === "corporate";
return ( return (
<UserList <UserList
user={user} user={user}
filters={[filter]} filters={[filter]}
renderHeader={(total) => ( renderHeader={(total) => (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div <div
onClick={() => setPage("")} onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300" className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
> <BsArrowLeft className="text-xl" />
<BsArrowLeft className="text-xl" /> <span>Back</span>
<span>Back</span> </div>
</div> <h2 className="text-2xl font-semibold">Corporate ({total})</h2>
<h2 className="text-2xl font-semibold">Corporate ({total})</h2> </div>
</div> )}
)} />
/> );
); };
};
const CorporatePaidStatusList = ({ paid }: { paid: Boolean }) => { const CorporatePaidStatusList = ({paid}: {paid: Boolean}) => {
const list = paid ? done : pending; const list = paid ? done : pending;
const filter = (x: User) => x.type === "corporate" && list.includes(x.id); const filter = (x: User) => x.type === "corporate" && list.includes(x.id);
return ( return (
<UserList <UserList
user={user} user={user}
filters={[filter]} filters={[filter]}
renderHeader={(total) => ( renderHeader={(total) => (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div <div
onClick={() => setPage("")} onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300" className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
> <BsArrowLeft className="text-xl" />
<BsArrowLeft className="text-xl" /> <span>Back</span>
<span>Back</span> </div>
</div> <h2 className="text-2xl font-semibold">
<h2 className="text-2xl font-semibold"> {paid ? "Payment Done" : "Pending Payment"} ({total})
{paid ? "Payment Done" : "Pending Payment"} ({total}) </h2>
</h2> </div>
</div> )}
)} />
/> );
); };
};
const DefaultDashboard = () => ( const DefaultDashboard = () => (
<> <>
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:gap-4 text-center"> <section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:gap-4 text-center">
<IconCard <IconCard
onClick={() => setPage("referredCorporate")} onClick={() => setPage("referredCorporate")}
Icon={BsBank} Icon={BsBank}
label="Referred Corporate" label="Referred Corporate"
value={users.filter(referredCorporateFilter).length} value={users.filter(referredCorporateFilter).length}
color="purple" color="purple"
/> />
<IconCard <IconCard
onClick={() => setPage("inactiveReferredCorporate")} onClick={() => setPage("inactiveReferredCorporate")}
Icon={BsBank} Icon={BsBank}
label="Inactive Referred Corporate" label="Inactive Referred Corporate"
value={users.filter(inactiveReferredCorporateFilter).length} value={users.filter(inactiveReferredCorporateFilter).length}
color="rose" color="rose"
/> />
<IconCard <IconCard
onClick={() => setPage("corporate")} onClick={() => setPage("corporate")}
Icon={BsBank} Icon={BsBank}
label="Corporate" label="Corporate"
value={users.filter(corporateFilter).length} value={users.filter(corporateFilter).length}
color="purple" color="purple"
/> />
<IconCard <IconCard onClick={() => setPage("paymentdone")} Icon={BsCurrencyDollar} label="Payment Done" value={done.length} color="purple" />
onClick={() => setPage("paymentdone")} <IconCard
Icon={BsCurrencyDollar} onClick={() => setPage("paymentpending")}
label="Payment Done" Icon={BsCurrencyDollar}
value={done.length} label="Pending Payment"
color="purple" value={pending.length}
/> color="rose"
<IconCard />
onClick={() => setPage("paymentpending")} </section>
Icon={BsCurrencyDollar}
label="Pending Payment"
value={pending.length}
color="rose"
/>
</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 shadow flex flex-col rounded-xl w-full"> <div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest Referred Corporate</span> <span className="p-4">Latest Referred Corporate</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users {users
.filter(referredCorporateFilter) .filter(referredCorporateFilter)
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate")) .sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => ( .map((x) => (
<UserDisplay key={x.id} displayUser={x} /> <UserDisplay key={x.id} displayUser={x} />
))} ))}
</div> </div>
</div> </div>
<div className="bg-white shadow flex flex-col rounded-xl w-full"> <div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest corporate</span> <span className="p-4">Latest corporate</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users {users
.filter(corporateFilter) .filter(corporateFilter)
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate")) .sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => ( .map((x) => (
<UserDisplay key={x.id} displayUser={x} allowClick={false} /> <UserDisplay key={x.id} displayUser={x} allowClick={false} />
))} ))}
</div> </div>
</div> </div>
<div className="bg-white shadow flex flex-col rounded-xl w-full"> <div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Referenced corporate expiring in 1 month</span> <span className="p-4">Referenced corporate expiring in 1 month</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users {users
.filter( .filter(
(x) => (x) =>
referredCorporateFilter(x) && referredCorporateFilter(x) &&
moment().isAfter( moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
moment(x.subscriptionExpirationDate).subtract(30, "days") moment().isBefore(moment(x.subscriptionExpirationDate)),
) && )
moment().isBefore(moment(x.subscriptionExpirationDate)) .map((x) => (
) <UserDisplay key={x.id} displayUser={x} />
.map((x) => ( ))}
<UserDisplay key={x.id} displayUser={x} /> </div>
))} </div>
</div> </section>
</div> </>
</section> );
</>
);
return ( return (
<> <>
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}> <Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
<> <>
{selectedUser && ( {selectedUser && (
<div className="w-full flex flex-col gap-8"> <div className="w-full flex flex-col gap-8">
<UserCard <UserCard
loggedInUser={user} loggedInUser={user}
onClose={(shouldReload) => { onClose={(shouldReload) => {
setSelectedUser(undefined); setSelectedUser(undefined);
if (shouldReload) reload(); if (shouldReload) reload();
}} }}
onViewStudents={ onViewStudents={
selectedUser.type === "corporate" || selectedUser.type === "corporate" || selectedUser.type === "teacher" ? () => setPage("students") : undefined
selectedUser.type === "teacher" }
? () => setPage("students") onViewTeachers={selectedUser.type === "corporate" ? () => setPage("teachers") : undefined}
: undefined user={selectedUser}
} />
onViewTeachers={ </div>
selectedUser.type === "corporate" )}
? () => setPage("teachers") </>
: undefined </Modal>
} {page === "referredCorporate" && <ReferredCorporateList />}
user={selectedUser} {page === "corporate" && <CorporateList />}
/> {page === "inactiveReferredCorporate" && <InactiveReferredCorporateList />}
</div> {page === "paymentdone" && <CorporatePaidStatusList paid={true} />}
)} {page === "paymentpending" && <CorporatePaidStatusList paid={false} />}
</> {page === "" && <DefaultDashboard />}
</Modal> </>
{page === "referredCorporate" && <ReferredCorporateList />} );
{page === "corporate" && <CorporateList />}
{page === "inactiveReferredCorporate" && (
<InactiveReferredCorporateList />
)}
{page === "paymentdone" && <CorporatePaidStatusList paid={true} />}
{page === "paymentpending" && <CorporatePaidStatusList paid={false} />}
{page === "" && <DefaultDashboard />}
</>
);
} }

View File

@@ -10,6 +10,7 @@ import {usePDFDownload} from "@/hooks/usePDFDownload";
import {useAssignmentArchive} from "@/hooks/useAssignmentArchive"; import {useAssignmentArchive} from "@/hooks/useAssignmentArchive";
import {uniqBy} from "lodash"; import {uniqBy} from "lodash";
import {useAssignmentUnarchive} from "@/hooks/useAssignmentUnarchive"; import {useAssignmentUnarchive} from "@/hooks/useAssignmentUnarchive";
import {getUserName} from "@/utils/users";
interface Props { interface Props {
onClick?: () => void; onClick?: () => void;
@@ -35,6 +36,8 @@ export default function AssignmentCard({
allowArchive, allowArchive,
allowUnarchive, allowUnarchive,
}: Assignment & Props) { }: Assignment & Props) {
const {users} = useUsers();
const renderPdfIcon = usePDFDownload("assignments"); const renderPdfIcon = usePDFDownload("assignments");
const renderArchiveIcon = useAssignmentArchive(id, reload); const renderArchiveIcon = useAssignmentArchive(id, reload);
const renderUnarchiveIcon = useAssignmentUnarchive(id, reload); const renderUnarchiveIcon = useAssignmentUnarchive(id, reload);
@@ -72,11 +75,14 @@ export default function AssignmentCard({
textClassName={results.length / assignees.length < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"} textClassName={results.length / assignees.length < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"}
/> />
</div> </div>
<span className="flex justify-between gap-1"> <div className="flex flex-col gap-1">
<span>{moment(startDate).format("DD/MM/YY, HH:mm")}</span> <span className="flex justify-between gap-1">
<span>-</span> <span>{moment(startDate).format("DD/MM/YY, HH:mm")}</span>
<span>{moment(endDate).format("DD/MM/YY, HH:mm")}</span> <span>-</span>
</span> <span>{moment(endDate).format("DD/MM/YY, HH:mm")}</span>
</span>
<span>Assigner: {getUserName(users.find((x) => x.id === assigner))}</span>
</div>
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2"> <div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
{uniqBy(exams, (x) => x.module).map(({module}) => ( {uniqBy(exams, (x) => x.module).map(({module}) => (
<div <div

View File

@@ -371,7 +371,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
!startDate || !startDate ||
!endDate || !endDate ||
assignees.length === 0 || assignees.length === 0 ||
(!!examIDs && examIDs.length < selectedModules.length) (!useRandomExams && examIDs.length < selectedModules.length)
} }
className="w-full max-w-[200px]" className="w-full max-w-[200px]"
onClick={createAssignment} onClick={createAssignment}

View File

@@ -10,6 +10,7 @@ import {getExamById} from "@/utils/exams";
import {sortByModule} from "@/utils/moduleUtils"; import {sortByModule} from "@/utils/moduleUtils";
import {calculateBandScore} from "@/utils/score"; import {calculateBandScore} from "@/utils/score";
import {convertToUserSolutions} from "@/utils/stats"; import {convertToUserSolutions} from "@/utils/stats";
import {getUserName} from "@/utils/users";
import 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";
@@ -241,13 +242,16 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
<span>Start Date: {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}</span> <span>Start Date: {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}</span>
<span>End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}</span> <span>End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}</span>
</div> </div>
<span> <div className="flex flex-col gap-2">
Assignees:{" "} <span>
{users Assignees:{" "}
.filter((u) => assignment?.assignees.includes(u.id)) {users
.map((u) => `${u.name} (${u.email})`) .filter((u) => assignment?.assignees.includes(u.id))
.join(", ")} .map((u) => `${u.name} (${u.email})`)
</span> .join(", ")}
</span>
<span>Assigner: {getUserName(users.find((x) => x.id === assignment?.assigner))}</span>
</div>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span className="text-xl font-bold">Average Scores</span> <span className="text-xl font-bold">Average Scores</span>

File diff suppressed because it is too large Load Diff

View File

@@ -1,140 +1,108 @@
import React from "react"; import React from "react";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
import { User } from "@/interfaces/user"; import {User} from "@/interfaces/user";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
import ProgressBar from "@/components/Low/ProgressBar"; import ProgressBar from "@/components/Low/ProgressBar";
import { import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
BsBook, import {MODULE_ARRAY} from "@/utils/moduleUtils";
BsClipboard, import {capitalize} from "lodash";
BsHeadphones, import {getLevelLabel} from "@/utils/score";
BsMegaphone,
BsPen,
} from "react-icons/bs";
import { MODULE_ARRAY } from "@/utils/moduleUtils";
import { capitalize } from "lodash";
import { getLevelLabel } from "@/utils/score";
const Card = ({ user }: { user: User }) => { const Card = ({user}: {user: User}) => {
return ( return (
<div className="border-mti-gray-platinum flex flex-col h-fit w-full cursor-pointer flex-col gap-6 rounded-xl border bg-white p-4 transition duration-300 ease-in-out hover:drop-shadow"> <div className="border-mti-gray-platinum flex flex-col h-fit w-full cursor-pointer gap-6 rounded-xl border bg-white p-4 transition duration-300 ease-in-out hover:drop-shadow">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<h3 className="text-xl font-semibold">{user.name}</h3> <h3 className="text-xl font-semibold">{user.name}</h3>
</div> </div>
<div className="flex w-full gap-3 flex-wrap"> <div className="flex w-full gap-3 flex-wrap">
{MODULE_ARRAY.map((module) => { {MODULE_ARRAY.map((module) => {
const desiredLevel = user.desiredLevels[module] || 9; const desiredLevel = user.desiredLevels[module] || 9;
const level = user.levels[module] || 0; const level = user.levels[module] || 0;
return ( return (
<div <div className="border-mti-gray-anti-flash flex flex-col gap-2 rounded-xl border p-4 min-w-[250px]" key={module}>
className="border-mti-gray-anti-flash flex flex-col gap-2 rounded-xl border p-4 min-w-[250px]" <div className="flex items-center gap-2 md:gap-3">
key={module} <div className="bg-mti-gray-smoke flex h-8 w-8 items-center justify-center rounded-lg md:h-12 md:w-12 md:rounded-xl">
> {module === "reading" && <BsBook className="text-ielts-reading h-4 w-4 md:h-5 md:w-5" />}
<div className="flex items-center gap-2 md:gap-3"> {module === "listening" && <BsHeadphones className="text-ielts-listening h-4 w-4 md:h-5 md:w-5" />}
<div className="bg-mti-gray-smoke flex h-8 w-8 items-center justify-center rounded-lg md:h-12 md:w-12 md:rounded-xl"> {module === "writing" && <BsPen className="text-ielts-writing h-4 w-4 md:h-5 md:w-5" />}
{module === "reading" && ( {module === "speaking" && <BsMegaphone className="text-ielts-speaking h-4 w-4 md:h-5 md:w-5" />}
<BsBook className="text-ielts-reading h-4 w-4 md:h-5 md:w-5" /> {module === "level" && <BsClipboard className="text-ielts-level h-4 w-4 md:h-5 md:w-5" />}
)} </div>
{module === "listening" && ( <div className="flex w-full flex-col">
<BsHeadphones className="text-ielts-listening h-4 w-4 md:h-5 md:w-5" /> <span className="text-sm font-bold md:font-extrabold w-full">{capitalize(module)}</span>
)} <div className="text-mti-gray-dim text-sm font-normal">
{module === "writing" && ( {module === "level" && <span>English Level: {getLevelLabel(level).join(" / ")}</span>}
<BsPen className="text-ielts-writing h-4 w-4 md:h-5 md:w-5" /> {module !== "level" && (
)} <div className="flex flex-col">
{module === "speaking" && ( <span>Level {level} / Level 9</span>
<BsMegaphone className="text-ielts-speaking h-4 w-4 md:h-5 md:w-5" /> <span>Desired Level: {desiredLevel}</span>
)} </div>
{module === "level" && ( )}
<BsClipboard className="text-ielts-level h-4 w-4 md:h-5 md:w-5" /> </div>
)} </div>
</div> </div>
<div className="flex w-full flex-col"> <div className="md:pl-14">
<span className="text-sm font-bold md:font-extrabold w-full"> <ProgressBar
{capitalize(module)} color={module}
</span> label=""
<div className="text-mti-gray-dim text-sm font-normal"> mark={Math.round((desiredLevel * 100) / 9)}
{module === "level" && ( markLabel={`Desired Level: ${desiredLevel}`}
<span> percentage={Math.round((level * 100) / 9)}
English Level: {getLevelLabel(level).join(" / ")} className="h-2 w-full"
</span> />
)} </div>
{module !== "level" && ( </div>
<div className="flex flex-col"> );
<span>Level {level} / Level 9</span> })}
<span>Desired Level: {desiredLevel}</span> </div>
</div> </div>
)} );
</div>
</div>
</div>
<div className="md:pl-14">
<ProgressBar
color={module}
label=""
mark={Math.round((desiredLevel * 100) / 9)}
markLabel={`Desired Level: ${desiredLevel}`}
percentage={Math.round((level * 100) / 9)}
className="h-2 w-full"
/>
</div>
</div>
);
})}
</div>
</div>
);
}; };
const CorporateStudentsLevels = () => { const CorporateStudentsLevels = () => {
const { users } = useUsers(); const {users} = useUsers();
const { groups } = useGroups(); const {groups} = useGroups({});
const corporateUsers = users.filter((u) => u.type === "corporate") as User[]; const corporateUsers = users.filter((u) => u.type === "corporate") as User[];
const [corporateId, setCorporateId] = React.useState<string>(""); const [corporateId, setCorporateId] = React.useState<string>("");
const corporate = const corporate = corporateUsers.find((u) => u.id === corporateId) || corporateUsers[0];
corporateUsers.find((u) => u.id === corporateId) || corporateUsers[0];
const groupsFromCorporate = corporate const groupsFromCorporate = corporate ? groups.filter((g) => g.admin === corporate.id) : [];
? groups.filter((g) => g.admin === corporate.id)
: [];
const groupsParticipants = groupsFromCorporate const groupsParticipants = groupsFromCorporate
.flatMap((g) => g.participants) .flatMap((g) => g.participants)
.reduce((accm: User[], p) => { .reduce((accm: User[], p) => {
const user = users.find((u) => u.id === p) as User; const user = users.find((u) => u.id === p) as User;
if (user) { if (user) {
return [...accm, user]; return [...accm, user];
} }
return accm; return accm;
}, []); }, []);
return ( return (
<> <>
<Select <Select
options={corporateUsers.map((x: User) => ({ options={corporateUsers.map((x: User) => ({
value: x.id, value: x.id,
label: `${x.name} - ${x.email}`, label: `${x.name} - ${x.email}`,
}))} }))}
value={corporate ? { value: corporate.id, label: corporate.name } : null} value={corporate ? {value: corporate.id, label: corporate.name} : null}
onChange={(value) => setCorporateId(value?.value!)} onChange={(value) => setCorporateId(value?.value!)}
styles={{ styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }), menuPortal: (base) => ({...base, zIndex: 9999}),
option: (styles, state) => ({ option: (styles, state) => ({
...styles, ...styles,
backgroundColor: state.isFocused backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
? "#D5D9F0" color: state.isFocused ? "black" : styles.color,
: state.isSelected }),
? "#7872BF" }}
: "white", />
color: state.isFocused ? "black" : styles.color, {groupsParticipants.map((u) => (
}), <Card user={u} key={u.id} />
}} ))}
/> </>
{groupsParticipants.map((u) => ( );
<Card user={u} key={u.id} />
))}
</>
);
}; };
export default CorporateStudentsLevels; export default CorporateStudentsLevels;

File diff suppressed because it is too large Load Diff

View File

@@ -2,480 +2,397 @@
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import useStats from "@/hooks/useStats"; import useStats from "@/hooks/useStats";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import { CorporateUser, Group, Stat, User } from "@/interfaces/user"; import {CorporateUser, Group, Stat, User} from "@/interfaces/user";
import UserList from "@/pages/(admin)/Lists/UserList"; import UserList from "@/pages/(admin)/Lists/UserList";
import { dateSorter } from "@/utils"; import {dateSorter} from "@/utils";
import moment from "moment"; import moment from "moment";
import { useEffect, useState } from "react"; import {useEffect, useState} from "react";
import { import {
BsArrowLeft, BsArrowLeft,
BsArrowRepeat, BsArrowRepeat,
BsClipboard2Data, BsClipboard2Data,
BsClipboard2DataFill, BsClipboard2DataFill,
BsClipboard2Heart, BsClipboard2Heart,
BsClipboard2X, BsClipboard2X,
BsClipboardPulse, BsClipboardPulse,
BsClock, BsClock,
BsEnvelopePaper, BsEnvelopePaper,
BsGlobeCentralSouthAsia, BsGlobeCentralSouthAsia,
BsPaperclip, BsPaperclip,
BsPeople, BsPeople,
BsPerson, BsPerson,
BsPersonAdd, BsPersonAdd,
BsPersonFill, BsPersonFill,
BsPersonFillGear, BsPersonFillGear,
BsPersonGear, BsPersonGear,
BsPlus, BsPlus,
BsRepeat, BsRepeat,
BsRepeat1, BsRepeat1,
} from "react-icons/bs"; } from "react-icons/bs";
import UserCard from "@/components/UserCard"; import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
import { calculateAverageLevel, calculateBandScore } from "@/utils/score"; import {calculateAverageLevel, calculateBandScore} from "@/utils/score";
import { MODULE_ARRAY } from "@/utils/moduleUtils"; import {MODULE_ARRAY} from "@/utils/moduleUtils";
import { Module } from "@/interfaces"; import {Module} from "@/interfaces";
import { groupByExam } from "@/utils/stats"; import {groupByExam} from "@/utils/stats";
import IconCard from "./IconCard"; import IconCard from "./IconCard";
import GroupList from "@/pages/(admin)/Lists/GroupList"; import GroupList from "@/pages/(admin)/Lists/GroupList";
import useAssignments from "@/hooks/useAssignments"; import useAssignments from "@/hooks/useAssignments";
import { Assignment } from "@/interfaces/results"; import {Assignment} from "@/interfaces/results";
import AssignmentCard from "./AssignmentCard"; import AssignmentCard from "./AssignmentCard";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import clsx from "clsx"; import clsx from "clsx";
import ProgressBar from "@/components/Low/ProgressBar"; import ProgressBar from "@/components/Low/ProgressBar";
import AssignmentCreator from "./AssignmentCreator"; import AssignmentCreator from "./AssignmentCreator";
import AssignmentView from "./AssignmentView"; import AssignmentView from "./AssignmentView";
import { getUserCorporate } from "@/utils/groups"; import {getUserCorporate} from "@/utils/groups";
import { checkAccess } from "@/utils/permissions"; import {checkAccess} from "@/utils/permissions";
import usePermissions from "@/hooks/usePermissions";
interface Props { interface Props {
user: User; user: User;
} }
export default function TeacherDashboard({ user }: Props) { export default function TeacherDashboard({user}: Props) {
const [page, setPage] = useState(""); const [page, setPage] = useState("");
const [selectedUser, setSelectedUser] = useState<User>(); const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>(); const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false); const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
const [corporateUserToShow, setCorporateUserToShow] = const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>();
useState<CorporateUser>();
const { stats } = useStats(); const {stats} = useStats();
const { users, reload } = useUsers(); const {users, reload} = useUsers();
const { groups } = useGroups(user.id); const {groups} = useGroups({admin: user.id});
const { const {permissions} = usePermissions(user.id);
assignments, const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assigner: user.id});
isLoading: isAssignmentsLoading,
reload: reloadAssignments,
} = useAssignments({ assigner: user.id });
useEffect(() => { useEffect(() => {
setShowModal(!!selectedUser && page === ""); setShowModal(!!selectedUser && page === "");
}, [selectedUser, page]); }, [selectedUser, page]);
useEffect(() => { useEffect(() => {
getUserCorporate(user.id).then(setCorporateUserToShow); getUserCorporate(user.id).then(setCorporateUserToShow);
}, [user]); }, [user]);
const studentFilter = (user: User) => const studentFilter = (user: User) => user.type === "student" && groups.flatMap((g) => g.participants).includes(user.id);
user.type === "student" &&
groups.flatMap((g) => g.participants).includes(user.id);
const getStatsByStudent = (user: User) => const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
stats.filter((s) => s.user === user.id);
const UserDisplay = (displayUser: User) => ( const UserDisplay = (displayUser: User) => (
<div <div
onClick={() => setSelectedUser(displayUser)} onClick={() => setSelectedUser(displayUser)}
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300" 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" />
<img <div className="flex flex-col gap-1 items-start">
src={displayUser.profilePicture} <span>{displayUser.name}</span>
alt={displayUser.name} <span className="text-sm opacity-75">{displayUser.email}</span>
className="rounded-full w-10 h-10" </div>
/> </div>
<div className="flex flex-col gap-1 items-start"> );
<span>{displayUser.name}</span>
<span className="text-sm opacity-75">{displayUser.email}</span>
</div>
</div>
);
const StudentsList = () => { const StudentsList = () => {
const filter = (x: User) => const filter = (x: User) =>
x.type === "student" && x.type === "student" &&
(!!selectedUser (!!selectedUser
? groups ? groups
.filter((g) => g.admin === selectedUser.id) .filter((g) => g.admin === selectedUser.id)
.flatMap((g) => g.participants) .flatMap((g) => g.participants)
.includes(x.id) || false .includes(x.id) || false
: groups.flatMap((g) => g.participants).includes(x.id)); : groups.flatMap((g) => g.participants).includes(x.id));
return ( return (
<UserList <UserList
user={user} user={user}
filters={[filter]} filters={[filter]}
renderHeader={(total) => ( renderHeader={(total) => (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div <div
onClick={() => setPage("")} onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300" className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
> <BsArrowLeft className="text-xl" />
<BsArrowLeft className="text-xl" /> <span>Back</span>
<span>Back</span> </div>
</div> <h2 className="text-2xl font-semibold">Students ({total})</h2>
<h2 className="text-2xl font-semibold">Students ({total})</h2> </div>
</div> )}
)} />
/> );
); };
};
const GroupsList = () => { const GroupsList = () => {
const filter = (x: Group) => const filter = (x: Group) => x.admin === user.id || x.participants.includes(user.id);
x.admin === user.id || x.participants.includes(user.id);
return ( return (
<> <>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div <div
onClick={() => setPage("")} onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300" className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
> <BsArrowLeft className="text-xl" />
<BsArrowLeft className="text-xl" /> <span>Back</span>
<span>Back</span> </div>
</div> <h2 className="text-2xl font-semibold">Groups ({groups.filter(filter).length})</h2>
<h2 className="text-2xl font-semibold"> </div>
Groups ({groups.filter(filter).length})
</h2>
</div>
<GroupList user={user} /> <GroupList user={user} />
</> </>
); );
}; };
const averageLevelCalculator = (studentStats: Stat[]) => { const averageLevelCalculator = (studentStats: Stat[]) => {
const formattedStats = studentStats const formattedStats = studentStats
.map((s) => ({ .map((s) => ({
focus: users.find((u) => u.id === s.user)?.focus, focus: users.find((u) => u.id === s.user)?.focus,
score: s.score, score: s.score,
module: s.module, module: s.module,
})) }))
.filter((f) => !!f.focus); .filter((f) => !!f.focus);
const bandScores = formattedStats.map((s) => ({ const bandScores = formattedStats.map((s) => ({
module: s.module, module: s.module,
level: calculateBandScore( level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
s.score.correct, }));
s.score.total,
s.module,
s.focus!
),
}));
const levels: { [key in Module]: number } = { const levels: {[key in Module]: number} = {
reading: 0, reading: 0,
listening: 0, listening: 0,
writing: 0, writing: 0,
speaking: 0, speaking: 0,
level: 0, level: 0,
}; };
bandScores.forEach((b) => (levels[b.module] += b.level)); bandScores.forEach((b) => (levels[b.module] += b.level));
return calculateAverageLevel(levels); return calculateAverageLevel(levels);
}; };
const AssignmentsPage = () => { const AssignmentsPage = () => {
const activeFilter = (a: Assignment) => const activeFilter = (a: Assignment) =>
moment(a.endDate).isAfter(moment()) && moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment()) && a.assignees.length > a.results.length;
moment(a.startDate).isBefore(moment()) && const pastFilter = (a: Assignment) => (moment(a.endDate).isBefore(moment()) || a.assignees.length === a.results.length) && !a.archived;
a.assignees.length > a.results.length; const archivedFilter = (a: Assignment) => a.archived;
const pastFilter = (a: Assignment) => const futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment());
(moment(a.endDate).isBefore(moment()) ||
a.assignees.length === a.results.length) &&
!a.archived;
const archivedFilter = (a: Assignment) => a.archived;
const futureFilter = (a: Assignment) =>
moment(a.startDate).isAfter(moment());
return ( return (
<> <>
<AssignmentView <AssignmentView
isOpen={!!selectedAssignment && !isCreatingAssignment} isOpen={!!selectedAssignment && !isCreatingAssignment}
onClose={() => { onClose={() => {
setSelectedAssignment(undefined); setSelectedAssignment(undefined);
setIsCreatingAssignment(false); setIsCreatingAssignment(false);
reloadAssignments(); reloadAssignments();
}} }}
assignment={selectedAssignment} assignment={selectedAssignment}
/> />
<AssignmentCreator <AssignmentCreator
assignment={selectedAssignment} assignment={selectedAssignment}
groups={groups.filter( groups={groups.filter((x) => x.admin === user.id || x.participants.includes(user.id))}
(x) => x.admin === user.id || x.participants.includes(user.id) users={users.filter(
)} (x) =>
users={users.filter( x.type === "student" &&
(x) => (!!selectedUser
x.type === "student" && ? groups
(!!selectedUser .filter((g) => g.admin === selectedUser.id)
? groups .flatMap((g) => g.participants)
.filter((g) => g.admin === selectedUser.id) .includes(x.id) || false
.flatMap((g) => g.participants) : groups.flatMap((g) => g.participants).includes(x.id)),
.includes(x.id) || false )}
: groups.flatMap((g) => g.participants).includes(x.id)) assigner={user.id}
)} isCreating={isCreatingAssignment}
assigner={user.id} cancelCreation={() => {
isCreating={isCreatingAssignment} setIsCreatingAssignment(false);
cancelCreation={() => { setSelectedAssignment(undefined);
setIsCreatingAssignment(false); reloadAssignments();
setSelectedAssignment(undefined); }}
reloadAssignments(); />
}} <div className="w-full flex justify-between items-center">
/> <div
<div className="w-full flex justify-between items-center"> onClick={() => setPage("")}
<div className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
onClick={() => setPage("")} <BsArrowLeft className="text-xl" />
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300" <span>Back</span>
> </div>
<BsArrowLeft className="text-xl" /> <div
<span>Back</span> onClick={reloadAssignments}
</div> className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<div <span>Reload</span>
onClick={reloadAssignments} <BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300" </div>
> </div>
<span>Reload</span> <section className="flex flex-col gap-4">
<BsArrowRepeat <h2 className="text-2xl font-semibold">Active Assignments ({assignments.filter(activeFilter).length})</h2>
className={clsx( <div className="flex flex-wrap gap-2">
"text-xl", {assignments.filter(activeFilter).map((a) => (
isAssignmentsLoading && "animate-spin" <AssignmentCard {...a} onClick={() => setSelectedAssignment(a)} key={a.id} />
)} ))}
/> </div>
</div> </section>
</div> <section className="flex flex-col gap-4">
<section className="flex flex-col gap-4"> <h2 className="text-2xl font-semibold">Planned Assignments ({assignments.filter(futureFilter).length})</h2>
<h2 className="text-2xl font-semibold"> <div className="flex flex-wrap gap-2">
Active Assignments ({assignments.filter(activeFilter).length}) <div
</h2> onClick={() => setIsCreatingAssignment(true)}
<div className="flex flex-wrap gap-2"> className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300">
{assignments.filter(activeFilter).map((a) => ( <BsPlus className="text-6xl" />
<AssignmentCard <span className="text-lg">New Assignment</span>
{...a} </div>
onClick={() => setSelectedAssignment(a)} {assignments.filter(futureFilter).map((a) => (
key={a.id} <AssignmentCard
/> {...a}
))} onClick={() => {
</div> setSelectedAssignment(a);
</section> setIsCreatingAssignment(true);
<section className="flex flex-col gap-4"> }}
<h2 className="text-2xl font-semibold"> key={a.id}
Planned Assignments ({assignments.filter(futureFilter).length}) />
</h2> ))}
<div className="flex flex-wrap gap-2"> </div>
<div </section>
onClick={() => setIsCreatingAssignment(true)} <section className="flex flex-col gap-4">
className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300" <h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastFilter).length})</h2>
> <div className="flex flex-wrap gap-2">
<BsPlus className="text-6xl" /> {assignments.filter(pastFilter).map((a) => (
<span className="text-lg">New Assignment</span> <AssignmentCard
</div> {...a}
{assignments.filter(futureFilter).map((a) => ( onClick={() => setSelectedAssignment(a)}
<AssignmentCard key={a.id}
{...a} allowDownload
onClick={() => { reload={reloadAssignments}
setSelectedAssignment(a); allowArchive
setIsCreatingAssignment(true); />
}} ))}
key={a.id} </div>
/> </section>
))} <section className="flex flex-col gap-4">
</div> <h2 className="text-2xl font-semibold">Archived Assignments ({assignments.filter(archivedFilter).length})</h2>
</section> <div className="flex flex-wrap gap-2">
<section className="flex flex-col gap-4"> {assignments.filter(archivedFilter).map((a) => (
<h2 className="text-2xl font-semibold"> <AssignmentCard
Past Assignments ({assignments.filter(pastFilter).length}) {...a}
</h2> onClick={() => setSelectedAssignment(a)}
<div className="flex flex-wrap gap-2"> key={a.id}
{assignments.filter(pastFilter).map((a) => ( allowDownload
<AssignmentCard reload={reloadAssignments}
{...a} allowUnarchive
onClick={() => setSelectedAssignment(a)} />
key={a.id} ))}
allowDownload </div>
reload={reloadAssignments} </section>
allowArchive </>
/> );
))} };
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">
Archived Assignments ({assignments.filter(archivedFilter).length})
</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(archivedFilter).map((a) => (
<AssignmentCard
{...a}
onClick={() => setSelectedAssignment(a)}
key={a.id}
allowDownload
reload={reloadAssignments}
allowUnarchive
/>
))}
</div>
</section>
</>
);
};
const DefaultDashboard = () => ( const DefaultDashboard = () => (
<> <>
{corporateUserToShow && ( {corporateUserToShow && (
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1"> <div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1">
Linked to:{" "} Linked to: <b>{corporateUserToShow?.corporateInformation?.companyInformation.name || corporateUserToShow.name}</b>
<b> </div>
{corporateUserToShow?.corporateInformation?.companyInformation )}
.name || corporateUserToShow.name} <section
</b> className={clsx(
</div> "flex -lg:flex-wrap gap-4 items-center -lg:justify-center lg:justify-start text-center",
)} !!corporateUserToShow && "mt-12 xl:mt-6",
<section )}>
className={clsx( <IconCard
"flex -lg:flex-wrap gap-4 items-center -lg:justify-center lg:justify-start text-center", onClick={() => setPage("students")}
!!corporateUserToShow && "mt-12 xl:mt-6" Icon={BsPersonFill}
)} label="Students"
> value={users.filter(studentFilter).length}
<IconCard color="purple"
onClick={() => setPage("students")} />
Icon={BsPersonFill} <IconCard
label="Students" Icon={BsClipboard2Data}
value={users.filter(studentFilter).length} label="Exams Performed"
color="purple" value={stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)).length}
/> color="purple"
<IconCard />
Icon={BsClipboard2Data} <IconCard
label="Exams Performed" Icon={BsPaperclip}
value={ label="Average Level"
stats.filter((s) => value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
groups.flatMap((g) => g.participants).includes(s.user) color="purple"
).length />
} {checkAccess(user, ["teacher", "developer"], permissions, "viewGroup") && (
color="purple" <IconCard Icon={BsPeople} label="Groups" value={groups.length} color="purple" onClick={() => setPage("groups")} />
/> )}
<IconCard <div
Icon={BsPaperclip} onClick={() => setPage("assignments")}
label="Average Level" className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
value={averageLevelCalculator( <BsEnvelopePaper className="text-6xl text-mti-purple-light" />
stats.filter((s) => <span className="flex flex-col gap-1 items-center text-xl">
groups.flatMap((g) => g.participants).includes(s.user) <span className="text-lg">Assignments</span>
) <span className="font-semibold text-mti-purple-light">{assignments.filter((a) => !a.archived).length}</span>
).toFixed(1)} </span>
color="purple" </div>
/> </section>
{checkAccess(user, ["teacher", "developer"], "viewGroup") && (
<IconCard
Icon={BsPeople}
label="Groups"
value={groups.length}
color="purple"
onClick={() => setPage("groups")}
/>
)}
<div
onClick={() => setPage("assignments")}
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300"
>
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
<span className="flex flex-col gap-1 items-center text-xl">
<span className="text-lg">Assignments</span>
<span className="font-semibold text-mti-purple-light">
{assignments.filter((a) => !a.archived).length}
</span>
</span>
</div>
</section>
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 w-full justify-between"> <section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 w-full justify-between">
<div className="bg-white shadow flex flex-col rounded-xl w-full"> <div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest students</span> <span className="p-4">Latest students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users {users
.filter(studentFilter) .filter(studentFilter)
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate")) .sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => ( .map((x) => (
<UserDisplay key={x.id} {...x} /> <UserDisplay key={x.id} {...x} />
))} ))}
</div> </div>
</div> </div>
<div className="bg-white shadow flex flex-col rounded-xl w-full"> <div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest level students</span> <span className="p-4">Highest level students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users {users
.filter(studentFilter) .filter(studentFilter)
.sort( .sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
(a, b) => .map((x) => (
calculateAverageLevel(b.levels) - <UserDisplay key={x.id} {...x} />
calculateAverageLevel(a.levels) ))}
) </div>
.map((x) => ( </div>
<UserDisplay key={x.id} {...x} /> <div className="bg-white shadow flex flex-col rounded-xl w-full">
))} <span className="p-4">Highest exam count students</span>
</div> <div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
</div> {users
<div className="bg-white shadow flex flex-col rounded-xl w-full"> .filter(studentFilter)
<span className="p-4">Highest exam count students</span> .sort(
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide"> (a, b) =>
{users Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
.filter(studentFilter) )
.sort( .map((x) => (
(a, b) => <UserDisplay key={x.id} {...x} />
Object.keys(groupByExam(getStatsByStudent(b))).length - ))}
Object.keys(groupByExam(getStatsByStudent(a))).length </div>
) </div>
.map((x) => ( </section>
<UserDisplay key={x.id} {...x} /> </>
))} );
</div>
</div>
</section>
</>
);
return ( return (
<> <>
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}> <Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
<> <>
{selectedUser && ( {selectedUser && (
<div className="w-full flex flex-col gap-8"> <div className="w-full flex flex-col gap-8">
<UserCard <UserCard
loggedInUser={user} loggedInUser={user}
onClose={(shouldReload) => { onClose={(shouldReload) => {
setSelectedUser(undefined); setSelectedUser(undefined);
if (shouldReload) reload(); if (shouldReload) reload();
}} }}
onViewStudents={ onViewStudents={
selectedUser.type === "corporate" || selectedUser.type === "corporate" || selectedUser.type === "teacher" ? () => setPage("students") : undefined
selectedUser.type === "teacher" }
? () => setPage("students") onViewTeachers={selectedUser.type === "corporate" ? () => setPage("teachers") : undefined}
: undefined user={selectedUser}
} />
onViewTeachers={ </div>
selectedUser.type === "corporate" )}
? () => setPage("teachers") </>
: undefined </Modal>
} {page === "students" && <StudentsList />}
user={selectedUser} {page === "groups" && <GroupsList />}
/> {page === "assignments" && <AssignmentsPage />}
</div> {page === "" && <DefaultDashboard />}
)} </>
</> );
</Modal>
{page === "students" && <StudentsList />}
{page === "groups" && <GroupsList />}
{page === "assignments" && <AssignmentsPage />}
{page === "" && <DefaultDashboard />}
</>
);
} }

View File

@@ -1,442 +1,310 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import { useState } from "react"; import {useState} from "react";
import { Module } from "@/interfaces"; import {Module} from "@/interfaces";
import clsx from "clsx"; import clsx from "clsx";
import { User } from "@/interfaces/user"; import {User} from "@/interfaces/user";
import ProgressBar from "@/components/Low/ProgressBar"; import ProgressBar from "@/components/Low/ProgressBar";
import { import {BsArrowRepeat, BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
BsArrowRepeat, import {totalExamsByModule} from "@/utils/stats";
BsBook,
BsCheck,
BsCheckCircle,
BsClipboard,
BsHeadphones,
BsMegaphone,
BsPen,
BsXCircle,
} from "react-icons/bs";
import { totalExamsByModule } from "@/utils/stats";
import useStats from "@/hooks/useStats"; import useStats from "@/hooks/useStats";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import { calculateAverageLevel } from "@/utils/score"; import {calculateAverageLevel} from "@/utils/score";
import { sortByModuleName } from "@/utils/moduleUtils"; import {sortByModuleName} from "@/utils/moduleUtils";
import { capitalize } from "lodash"; import {capitalize} from "lodash";
import ProfileSummary from "@/components/ProfileSummary"; import ProfileSummary from "@/components/ProfileSummary";
import { Variant } from "@/interfaces/exam"; import {Variant} from "@/interfaces/exam";
import useSessions, { Session } from "@/hooks/useSessions"; import useSessions, {Session} from "@/hooks/useSessions";
import SessionCard from "@/components/Medium/SessionCard"; import SessionCard from "@/components/Medium/SessionCard";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import moment from "moment"; import moment from "moment";
interface Props { interface Props {
user: User; user: User;
page: "exercises" | "exams"; page: "exercises" | "exams";
onStart: ( onStart: (modules: Module[], avoidRepeated: boolean, variant: Variant) => void;
modules: Module[], disableSelection?: boolean;
avoidRepeated: boolean,
variant: Variant,
) => void;
disableSelection?: boolean;
} }
export default function Selection({ export default function Selection({user, page, onStart, disableSelection = false}: Props) {
user, const [selectedModules, setSelectedModules] = useState<Module[]>([]);
page, const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
onStart, const [variant, setVariant] = useState<Variant>("full");
disableSelection = false,
}: Props) {
const [selectedModules, setSelectedModules] = useState<Module[]>([]);
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
const [variant, setVariant] = useState<Variant>("full");
const { stats } = useStats(user?.id); const {stats} = useStats(user?.id);
const { sessions, isLoading, reload } = useSessions(user.id); const {sessions, isLoading, reload} = useSessions(user.id);
const state = useExamStore((state) => state); const state = useExamStore((state) => state);
const toggleModule = (module: Module) => { const toggleModule = (module: Module) => {
const modules = selectedModules.filter((x) => x !== module); const modules = selectedModules.filter((x) => x !== module);
setSelectedModules((prev) => setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module]));
prev.includes(module) ? modules : [...modules, module], };
);
};
const loadSession = async (session: Session) => { const loadSession = async (session: Session) => {
state.setSelectedModules(session.selectedModules); state.setSelectedModules(session.selectedModules);
state.setExam(session.exam); state.setExam(session.exam);
state.setExams(session.exams); state.setExams(session.exams);
state.setSessionId(session.sessionId); state.setSessionId(session.sessionId);
state.setAssignment(session.assignment); state.setAssignment(session.assignment);
state.setExerciseIndex(session.exerciseIndex); state.setExerciseIndex(session.exerciseIndex);
state.setPartIndex(session.partIndex); state.setPartIndex(session.partIndex);
state.setModuleIndex(session.moduleIndex); state.setModuleIndex(session.moduleIndex);
state.setTimeSpent(session.timeSpent); state.setTimeSpent(session.timeSpent);
state.setUserSolutions(session.userSolutions); state.setUserSolutions(session.userSolutions);
state.setShowSolutions(false); state.setShowSolutions(false);
state.setQuestionIndex(session.questionIndex); state.setQuestionIndex(session.questionIndex);
}; };
return ( return (
<> <>
<div className="relative flex h-full w-full flex-col gap-8 md:gap-16"> <div className="relative flex h-full w-full flex-col gap-8 md:gap-16">
{user && ( {user && (
<ProfileSummary <ProfileSummary
user={user} user={user}
items={[ items={[
{ {
icon: ( icon: <BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" />,
<BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" /> label: "Reading",
), value: totalExamsByModule(stats, "reading"),
label: "Reading", tooltip: "The amount of reading exams performed.",
value: totalExamsByModule(stats, "reading"), },
tooltip: "The amount of reading exams performed.", {
}, icon: <BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" />,
{ label: "Listening",
icon: ( value: totalExamsByModule(stats, "listening"),
<BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" /> tooltip: "The amount of listening exams performed.",
), },
label: "Listening", {
value: totalExamsByModule(stats, "listening"), icon: <BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" />,
tooltip: "The amount of listening exams performed.", label: "Writing",
}, value: totalExamsByModule(stats, "writing"),
{ tooltip: "The amount of writing exams performed.",
icon: ( },
<BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" /> {
), icon: <BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" />,
label: "Writing", label: "Speaking",
value: totalExamsByModule(stats, "writing"), value: totalExamsByModule(stats, "speaking"),
tooltip: "The amount of writing exams performed.", tooltip: "The amount of speaking exams performed.",
}, },
{ {
icon: ( icon: <BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />,
<BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" /> label: "Level",
), value: totalExamsByModule(stats, "level"),
label: "Speaking", tooltip: "The amount of level exams performed.",
value: totalExamsByModule(stats, "speaking"), },
tooltip: "The amount of speaking exams performed.", ]}
}, />
{ )}
icon: (
<BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />
),
label: "Level",
value: totalExamsByModule(stats, "level"),
tooltip: "The amount of level exams performed.",
},
]}
/>
)}
<section className="flex flex-col gap-3"> <section className="flex flex-col gap-3">
<span className="text-lg font-bold">About {capitalize(page)}</span> <span className="text-lg font-bold">About {capitalize(page)}</span>
<span className="text-mti-gray-taupe"> <span className="text-mti-gray-taupe">
{page === "exercises" && ( {page === "exercises" && (
<> <>
In the realm of language acquisition, practice makes perfect, In the realm of language acquisition, practice makes perfect, and our exercises are the key to unlocking your full
and our exercises are the key to unlocking your full potential. potential. Dive into a world of interactive and engaging exercises that cater to diverse learning styles. From grammar
Dive into a world of interactive and engaging exercises that drills that build a strong foundation to vocabulary challenges that broaden your lexicon, our exercises are carefully
cater to diverse learning styles. From grammar drills that build designed to make learning English both enjoyable and effective. Whether you&apos;re looking to reinforce specific
a strong foundation to vocabulary challenges that broaden your skills or embark on a holistic language journey, our exercises are your companions in the pursuit of excellence.
lexicon, our exercises are carefully designed to make learning Embrace the joy of learning as you navigate through a variety of activities that cater to every facet of language
English both enjoyable and effective. Whether you&apos;re acquisition. Your linguistic adventure starts here!
looking to reinforce specific skills or embark on a holistic </>
language journey, our exercises are your companions in the )}
pursuit of excellence. Embrace the joy of learning as you {page === "exams" && (
navigate through a variety of activities that cater to every <>
facet of language acquisition. Your linguistic adventure starts Welcome to the heart of success on your English language journey! Our exams are crafted with precision to assess and
here! enhance your language skills. Each test is a passport to your linguistic prowess, designed to challenge and elevate
</> your abilities. Whether you&apos;re a beginner or a seasoned learner, our exams cater to all levels, providing a
)} comprehensive evaluation of your reading, writing, speaking, and listening skills. Prepare to embark on a journey of
{page === "exams" && ( self-discovery and language mastery as you navigate through our thoughtfully curated exams. Your success is not just a
<> destination; it&apos;s a testament to your dedication and our commitment to empowering you with the English language.
Welcome to the heart of success on your English language </>
journey! Our exams are crafted with precision to assess and )}
enhance your language skills. Each test is a passport to your </span>
linguistic prowess, designed to challenge and elevate your </section>
abilities. Whether you&apos;re a beginner or a seasoned learner,
our exams cater to all levels, providing a comprehensive
evaluation of your reading, writing, speaking, and listening
skills. Prepare to embark on a journey of self-discovery and
language mastery as you navigate through our thoughtfully
curated exams. Your success is not just a destination; it&apos;s
a testament to your dedication and our commitment to empowering
you with the English language.
</>
)}
</span>
</section>
{sessions.length > 0 && ( {sessions.length > 0 && (
<section className="flex flex-col gap-3 md:gap-3"> <section className="flex flex-col gap-3 md:gap-3">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div <div
onClick={reload} onClick={reload}
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out" className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out">
> <span className="text-mti-black text-lg font-bold">Unfinished Sessions</span>
<span className="text-mti-black text-lg font-bold"> <BsArrowRepeat className={clsx("text-xl", isLoading && "animate-spin")} />
Unfinished Sessions </div>
</span> </div>
<BsArrowRepeat <span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
className={clsx("text-xl", isLoading && "animate-spin")} {sessions
/> .sort((a, b) => moment(b.date).diff(moment(a.date)))
</div> .map((session) => (
</div> <SessionCard session={session} key={session.sessionId} reload={reload} loadSession={loadSession} />
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll"> ))}
{sessions </span>
.sort((a, b) => moment(b.date).diff(moment(a.date))) </section>
.map((session) => ( )}
<SessionCard
session={session}
key={session.sessionId}
reload={reload}
loadSession={loadSession}
/>
))}
</span>
</section>
)}
<section className="-lg:flex-col -lg:items-center -lg:gap-12 mt-4 flex w-full justify-between gap-8"> <section className="-lg:flex-col -lg:items-center -lg:gap-12 mt-4 flex w-full justify-between gap-8">
<div <div
onClick={ onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
!disableSelection && !selectedModules.includes("level") className={clsx(
? () => toggleModule("reading") "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
: undefined selectedModules.includes("reading") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
} )}>
className={clsx( <div className="bg-ielts-reading absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", <BsBook className="h-7 w-7 text-white" />
selectedModules.includes("reading") || disableSelection </div>
? "border-mti-purple-light" <span className="font-semibold">Reading:</span>
: "border-mti-gray-platinum", <p className="text-left text-xs">
)} Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English.
> </p>
<div className="bg-ielts-reading absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full"> {!selectedModules.includes("reading") && !selectedModules.includes("level") && !disableSelection && (
<BsBook className="h-7 w-7 text-white" /> <div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
</div> )}
<span className="font-semibold">Reading:</span> {(selectedModules.includes("reading") || disableSelection) && (
<p className="text-left text-xs"> <BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
Expand your vocabulary, improve your reading comprehension and )}
improve your ability to interpret texts in English. {selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
</p> </div>
{!selectedModules.includes("reading") && <div
!selectedModules.includes("level") && onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("listening") : undefined}
!disableSelection && ( className={clsx(
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" /> "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
)} selectedModules.includes("listening") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
{(selectedModules.includes("reading") || disableSelection) && ( )}>
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" /> <div className="bg-ielts-listening absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
)} <BsHeadphones className="h-7 w-7 text-white" />
{selectedModules.includes("level") && ( </div>
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" /> <span className="font-semibold">Listening:</span>
)} <p className="text-left text-xs">
</div> Improve your ability to follow conversations in English and your ability to understand different accents and intonations.
<div </p>
onClick={ {!selectedModules.includes("listening") && !selectedModules.includes("level") && !disableSelection && (
!disableSelection && !selectedModules.includes("level") <div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
? () => toggleModule("listening") )}
: undefined {(selectedModules.includes("listening") || disableSelection) && (
} <BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
className={clsx( )}
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", {selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
selectedModules.includes("listening") || disableSelection </div>
? "border-mti-purple-light" <div
: "border-mti-gray-platinum", onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("writing") : undefined}
)} className={clsx(
> "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
<div className="bg-ielts-listening absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full"> selectedModules.includes("writing") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
<BsHeadphones className="h-7 w-7 text-white" /> )}>
</div> <div className="bg-ielts-writing absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
<span className="font-semibold">Listening:</span> <BsPen className="h-7 w-7 text-white" />
<p className="text-left text-xs"> </div>
Improve your ability to follow conversations in English and your <span className="font-semibold">Writing:</span>
ability to understand different accents and intonations. <p className="text-left text-xs">
</p> Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays.
{!selectedModules.includes("listening") && </p>
!selectedModules.includes("level") && {!selectedModules.includes("writing") && !selectedModules.includes("level") && !disableSelection && (
!disableSelection && ( <div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" /> )}
)} {(selectedModules.includes("writing") || disableSelection) && (
{(selectedModules.includes("listening") || disableSelection) && ( <BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" /> )}
)} {selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
{selectedModules.includes("level") && ( </div>
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" /> <div
)} onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("speaking") : undefined}
</div> className={clsx(
<div "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
onClick={ selectedModules.includes("speaking") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
!disableSelection && !selectedModules.includes("level") )}>
? () => toggleModule("writing") <div className="bg-ielts-speaking absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
: undefined <BsMegaphone className="h-7 w-7 text-white" />
} </div>
className={clsx( <span className="font-semibold">Speaking:</span>
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", <p className="text-left text-xs">
selectedModules.includes("writing") || disableSelection You&apos;ll have access to interactive dialogs, pronunciation exercises and speech recordings.
? "border-mti-purple-light" </p>
: "border-mti-gray-platinum", {!selectedModules.includes("speaking") && !selectedModules.includes("level") && !disableSelection && (
)} <div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
> )}
<div className="bg-ielts-writing absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full"> {(selectedModules.includes("speaking") || disableSelection) && (
<BsPen className="h-7 w-7 text-white" /> <BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
</div> )}
<span className="font-semibold">Writing:</span> {selectedModules.includes("level") && <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />}
<p className="text-left text-xs"> </div>
Allow you to practice writing in a variety of formats, from simple {!disableSelection && (
paragraphs to complex essays. <div
</p> onClick={selectedModules.length === 0 || selectedModules.includes("level") ? () => toggleModule("level") : undefined}
{!selectedModules.includes("writing") && className={clsx(
!selectedModules.includes("level") && "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out",
!disableSelection && ( selectedModules.includes("level") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" /> )}>
)} <div className="bg-ielts-level absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full">
{(selectedModules.includes("writing") || disableSelection) && ( <BsClipboard className="h-7 w-7 text-white" />
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" /> </div>
)} <span className="font-semibold">Level:</span>
{selectedModules.includes("level") && ( <p className="text-left text-xs">You&apos;ll be able to test your english level with multiple choice questions.</p>
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" /> {!selectedModules.includes("level") && selectedModules.length === 0 && !disableSelection && (
)} <div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" />
</div> )}
<div {(selectedModules.includes("level") || disableSelection) && (
onClick={ <BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" />
!disableSelection && !selectedModules.includes("level") )}
? () => toggleModule("speaking") {!selectedModules.includes("level") && selectedModules.length > 0 && (
: undefined <BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
} )}
className={clsx( </div>
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", )}
selectedModules.includes("speaking") || disableSelection </section>
? "border-mti-purple-light" <div className="-md:flex-col -md:gap-4 -md:justify-center flex w-full items-center md:justify-between">
: "border-mti-gray-platinum", <div className="flex w-full flex-col items-center gap-3">
)} <div
> className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
<div className="bg-ielts-speaking absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full"> onClick={() => setAvoidRepeatedExams((prev) => !prev)}>
<BsMegaphone className="h-7 w-7 text-white" /> <input type="checkbox" className="hidden" />
</div> <div
<span className="font-semibold">Speaking:</span> className={clsx(
<p className="text-left text-xs"> "border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
You&apos;ll have access to interactive dialogs, pronunciation "transition duration-300 ease-in-out",
exercises and speech recordings. avoidRepeatedExams && "!bg-mti-purple-light ",
</p> )}>
{!selectedModules.includes("speaking") && <BsCheck color="white" className="h-full w-full" />
!selectedModules.includes("level") && </div>
!disableSelection && ( <span className="tooltip" data-tip="If possible, the platform will choose exams not yet done.">
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" /> Avoid Repeated Questions
)} </span>
{(selectedModules.includes("speaking") || disableSelection) && ( </div>
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" /> <div
)} className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
{selectedModules.includes("level") && ( onClick={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" /> <input type="checkbox" className="hidden" />
)} <div
</div> className={clsx(
{!disableSelection && ( "border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
<div "transition duration-300 ease-in-out",
onClick={ variant === "full" && "!bg-mti-purple-light ",
selectedModules.length === 0 || )}>
selectedModules.includes("level") <BsCheck color="white" className="h-full w-full" />
? () => toggleModule("level") </div>
: undefined <span>Full length exams</span>
} </div>
className={clsx( </div>
"bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", <div className="tooltip w-full" data-tip={`Your screen size is too small to do ${page}`}>
selectedModules.includes("level") || disableSelection <Button color="purple" className="w-full max-w-xs px-12 md:hidden" disabled>
? "border-mti-purple-light" Start Exam
: "border-mti-gray-platinum", </Button>
)} </div>
> <Button
<div className="bg-ielts-level absolute top-0 flex h-16 w-16 -translate-y-1/2 items-center justify-center rounded-full"> onClick={() =>
<BsClipboard className="h-7 w-7 text-white" /> onStart(
</div> !disableSelection ? selectedModules.sort(sortByModuleName) : ["reading", "listening", "writing", "speaking"],
<span className="font-semibold">Level:</span> avoidRepeatedExams,
<p className="text-left text-xs"> variant,
You&apos;ll be able to test your english level with multiple )
choice questions. }
</p> color="purple"
{!selectedModules.includes("level") && className="-md:hidden w-full max-w-xs px-12 md:self-end"
selectedModules.length === 0 && disabled={selectedModules.length === 0 && !disableSelection}>
!disableSelection && ( Start Exam
<div className="border-mti-gray-platinum mt-4 h-8 w-8 rounded-full border" /> </Button>
)} </div>
{(selectedModules.includes("level") || disableSelection) && ( </div>
<BsCheckCircle className="text-mti-purple-light mt-4 h-8 w-8" /> </>
)} );
{!selectedModules.includes("level") &&
selectedModules.length > 0 && (
<BsXCircle className="text-mti-red-light mt-4 h-8 w-8" />
)}
</div>
)}
</section>
<div className="-md:flex-col -md:gap-4 -md:justify-center flex w-full items-center md:justify-between">
<div className="flex w-full flex-col items-center gap-3">
<div
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
onClick={() => setAvoidRepeatedExams((prev) => !prev)}
>
<input type="checkbox" className="hidden" />
<div
className={clsx(
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
"transition duration-300 ease-in-out",
avoidRepeatedExams && "!bg-mti-purple-light ",
)}
>
<BsCheck color="white" className="h-full w-full" />
</div>
<span
className="tooltip"
data-tip="If possible, the platform will choose exams not yet done."
>
Avoid Repeated Questions
</span>
</div>
<div
className="text-mti-gray-dim -md:justify-center flex w-full cursor-pointer items-center gap-3 text-sm"
// onClick={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}>
>
<input type="checkbox" className="hidden" disabled />
<div
className={clsx(
"border-mti-purple-light flex h-6 w-6 items-center justify-center rounded-md border bg-white",
"transition duration-300 ease-in-out",
variant === "full" && "!bg-mti-purple-light ",
)}
>
<BsCheck color="white" className="h-full w-full" />
</div>
<span>Full length exams</span>
</div>
</div>
<div
className="tooltip w-full"
data-tip={`Your screen size is too small to do ${page}`}
>
<Button
color="purple"
className="w-full max-w-xs px-12 md:hidden"
disabled
>
Start Exam
</Button>
</div>
<Button
onClick={() =>
onStart(
!disableSelection
? selectedModules.sort(sortByModuleName)
: ["reading", "listening", "writing", "speaking"],
avoidRepeatedExams,
variant,
)
}
color="purple"
className="-md:hidden w-full max-w-xs px-12 md:self-end"
disabled={selectedModules.length === 0 && !disableSelection}
>
Start Exam
</Button>
</div>
</div>
</>
);
} }

View File

@@ -2,7 +2,7 @@ import {Assignment} from "@/interfaces/results";
import axios from "axios"; import axios from "axios";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
export default function useAssignments({assigner, assignees}: {assigner?: string; assignees?: string}) { export default function useAssignments({assigner, assignees, corporate}: {assigner?: string; assignees?: string; corporate?: string}) {
const [assignments, setAssignments] = useState<Assignment[]>([]); const [assignments, setAssignments] = useState<Assignment[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false); const [isError, setIsError] = useState(false);
@@ -10,12 +10,13 @@ export default function useAssignments({assigner, assignees}: {assigner?: string
const getData = () => { const getData = () => {
setIsLoading(true); setIsLoading(true);
axios axios
.get<Assignment[]>("/api/assignments") .get<Assignment[]>(!corporate ? "/api/assignments" : `/api/assignments/corporate?id=${corporate}`)
.then((response) => { .then(async (response) => {
if (assigner) { if (assigner) {
setAssignments(response.data.filter((a) => a.assigner === assigner)); setAssignments(response.data.filter((a) => a.assigner === assigner));
return; return;
} }
if (assignees) { if (assignees) {
setAssignments(response.data.filter((a) => a.assignees.filter((x) => assignees.includes(x)).length > 0)); setAssignments(response.data.filter((a) => a.assignees.filter((x) => assignees.includes(x)).length > 0));
return; return;
@@ -26,7 +27,7 @@ export default function useAssignments({assigner, assignees}: {assigner?: string
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
useEffect(getData, [assignees, assigner]); useEffect(getData, [assignees, assigner, corporate]);
return {assignments, isLoading, isError, reload: getData}; return {assignments, isLoading, isError, reload: getData};
} }

View File

@@ -2,32 +2,40 @@ import {Group, User} from "@/interfaces/user";
import axios from "axios"; import axios from "axios";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
export default function useGroups(admin?: string, userType?: string) { interface Props {
admin?: string;
userType?: string;
adminAdmins?: string;
}
export default function useGroups({admin, userType, adminAdmins}: Props) {
const [groups, setGroups] = useState<Group[]>([]); const [groups, setGroups] = useState<Group[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false); const [isError, setIsError] = useState(false);
const isMasterType = userType?.startsWith('master'); const isMasterType = userType?.startsWith("master");
const getData = () => { const getData = () => {
setIsLoading(true); setIsLoading(true);
const url = admin ? `/api/groups?admin=${admin}` : "/api/groups"; const url = admin && !adminAdmins ? `/api/groups?admin=${admin}` : "/api/groups";
axios axios
.get<Group[]>(url) .get<Group[]>(url)
.then((response) => { .then((response) => {
if(isMasterType) { if (isMasterType) return setGroups(response.data);
return setGroups(response.data);
}
const filter = (g: Group) => g.admin === admin || g.participants.includes(admin || "");
const filteredGroups = admin ? response.data.filter(filter) : response.data; const filterByAdmins = !!adminAdmins
? [adminAdmins, ...response.data.filter((g) => g.participants.includes(adminAdmins)).flatMap((g) => g.admin)]
: [admin];
const adminFilter = (g: Group) => filterByAdmins.includes(g.admin) || g.participants.includes(admin || "");
const filteredGroups = !!admin || !!adminAdmins ? response.data.filter(adminFilter) : response.data;
return setGroups(admin ? filteredGroups.map((g) => ({...g, disableEditing: g.disableEditing || g.admin !== admin})) : filteredGroups); return setGroups(admin ? filteredGroups.map((g) => ({...g, disableEditing: g.disableEditing || g.admin !== admin})) : filteredGroups);
}) })
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
useEffect(getData, [admin, isMasterType]); useEffect(getData, [admin, adminAdmins, isMasterType]);
return {groups, isLoading, isError, reload: getData}; return {groups, isLoading, isError, reload: getData};
} }

View File

@@ -27,6 +27,10 @@ export function useListSearch<T>(fields: string[][], rows: T[]) {
if (typeof value === "string") { if (typeof value === "string") {
return value.toLowerCase().includes(searchText); return value.toLowerCase().includes(searchText);
} }
if (typeof value === "number") {
return (value as Number).toString().includes(searchText);
}
}); });
}); });
}, [fields, rows, text]); }, [fields, rows, text]);

View File

@@ -0,0 +1,28 @@
import {Exam} from "@/interfaces/exam";
import {Permission, PermissionType} from "@/interfaces/permissions";
import {ExamState} from "@/stores/examStore";
import axios from "axios";
import {useEffect, useState} from "react";
export default function usePermissions(user: string) {
const [permissions, setPermissions] = useState<PermissionType[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = () => {
setIsLoading(true);
axios
.get<Permission[]>(`/api/permissions`)
.then((response) => {
const permissionTypes = response.data
.filter((x) => !x.users.includes(user))
.reduce((acc, curr) => [...acc, curr.type], [] as PermissionType[]);
setPermissions(permissionTypes);
})
.finally(() => setIsLoading(false));
};
useEffect(getData, [user]);
return {permissions, isLoading, isError, reload: getData};
}

View File

@@ -1,57 +1,89 @@
export const markets = ["au", "br", "de"] as const; export interface PermissionTopic {
topic: string;
list: string[];
}
export const permissions = [ export const permissions = [
// generate codes are basicly invites {
"createCodeStudent", topic: "Manage Corporate",
"createCodeTeacher", list: [
"createCodeCorporate", "viewCorporate",
"createCodeCountryManager", "editCorporate",
"createCodeAdmin", "deleteCorporate",
// exams "createCodeCorporate",
"createReadingExam", ],
"createListeningExam", },
"createWritingExam", {
"createSpeakingExam", topic: "Manage Admin",
"createLevelExam", list: ["viewAdmin", "editAdmin", "deleteAdmin", "createCodeAdmin"],
// view pages },
"viewExams", {
"viewExercises", topic: "Manage Student",
"viewRecords", list: ["viewStudent", "editStudent", "deleteStudent", "createCodeStudent"],
"viewStats", },
"viewTickets", {
"viewPaymentRecords", topic: "Manage Teacher",
// view data list: ["viewTeacher", "editTeacher", "deleteTeacher", "createCodeTeacher"],
"viewStudent", },
"viewTeacher", {
"viewCorporate", topic: "Manage Country Manager",
"viewCountryManager", list: [
"viewAdmin", "viewCountryManager",
"viewGroup", "editCountryManager",
"viewCodes", "deleteCountryManager",
// edit data "createCodeCountryManager",
"editStudent", ],
"editTeacher", },
"editCorporate", {
"editCountryManager", topic: "Manage Exams",
"editAdmin", list: [
"editGroup", "createReadingExam",
// delete data "createListeningExam",
"deleteStudent", "createWritingExam",
"deleteTeacher", "createSpeakingExam",
"deleteCorporate", "createLevelExam",
"deleteCountryManager", ],
"deleteAdmin", },
"deleteGroup", {
"deleteCodes", topic: "View Pages",
// create options list: [
"createGroup", "viewExams",
"createCodes" "viewExercises",
"viewRecords",
"viewStats",
"viewTickets",
"viewPaymentRecords",
],
},
{
topic: "Manage Group",
list: ["viewGroup", "editGroup", "deleteGroup", "createGroup"],
},
{
topic: "Manage Codes",
list: ["viewCodes", "deleteCodes", "createCodes"],
},
{
topic: "Others",
list: ["all"],
},
] as const; ] as const;
export type PermissionType = (typeof permissions)[keyof typeof permissions]; const permissionsList = [
...new Set(
permissions.reduce(
(accm: string[], permission) => [...accm, ...permission.list],
[]
)
),
];
export type PermissionType =
(typeof permissionsList)[keyof typeof permissionsList];
export interface Permission { export interface Permission {
id: string; id: string;
type: PermissionType; type: PermissionType;
topic: string;
users: string[]; users: string[];
} }

View File

@@ -1,190 +1,162 @@
import { Module } from "."; import {Module} from ".";
import { InstructorGender, ShuffleMap } from "./exam"; import {InstructorGender, ShuffleMap} from "./exam";
import { PermissionType } from "./permissions"; import {PermissionType} from "./permissions";
export type User = export type User = StudentUser | TeacherUser | CorporateUser | AgentUser | AdminUser | DeveloperUser | MasterCorporateUser;
| StudentUser
| TeacherUser
| CorporateUser
| AgentUser
| AdminUser
| DeveloperUser
| MasterCorporateUser;
export type UserStatus = "active" | "disabled" | "paymentDue"; export type UserStatus = "active" | "disabled" | "paymentDue";
export interface BasicUser { export interface BasicUser {
email: string; email: string;
name: string; name: string;
profilePicture: string; profilePicture: string;
id: string; id: string;
isFirstLogin: boolean; isFirstLogin: boolean;
focus: "academic" | "general"; focus: "academic" | "general";
levels: { [key in Module]: number }; levels: {[key in Module]: number};
desiredLevels: { [key in Module]: number }; desiredLevels: {[key in Module]: number};
type: Type; type: Type;
bio: string; bio: string;
isVerified: boolean; isVerified: boolean;
subscriptionExpirationDate?: null | Date; subscriptionExpirationDate?: null | Date;
registrationDate?: Date; registrationDate?: Date;
status: UserStatus; status: UserStatus;
permissions: PermissionType[], permissions: PermissionType[];
lastLogin?: Date;
} }
export interface StudentUser extends BasicUser { export interface StudentUser extends BasicUser {
type: "student"; type: "student";
preferredGender?: InstructorGender; preferredGender?: InstructorGender;
demographicInformation?: DemographicInformation; demographicInformation?: DemographicInformation;
preferredTopics?: string[]; preferredTopics?: string[];
} }
export interface TeacherUser extends BasicUser { export interface TeacherUser extends BasicUser {
type: "teacher"; type: "teacher";
demographicInformation?: DemographicInformation; demographicInformation?: DemographicInformation;
} }
export interface CorporateUser extends BasicUser { export interface CorporateUser extends BasicUser {
type: "corporate"; type: "corporate";
corporateInformation: CorporateInformation; corporateInformation: CorporateInformation;
demographicInformation?: DemographicCorporateInformation; demographicInformation?: DemographicCorporateInformation;
} }
export interface MasterCorporateUser extends BasicUser { export interface MasterCorporateUser extends BasicUser {
type: "mastercorporate"; type: "mastercorporate";
corporateInformation: CorporateInformation; corporateInformation: CorporateInformation;
demographicInformation?: DemographicCorporateInformation; demographicInformation?: DemographicCorporateInformation;
} }
export interface AgentUser extends BasicUser { export interface AgentUser extends BasicUser {
type: "agent"; type: "agent";
agentInformation: AgentInformation; agentInformation: AgentInformation;
demographicInformation?: DemographicInformation; demographicInformation?: DemographicInformation;
} }
export interface AdminUser extends BasicUser { export interface AdminUser extends BasicUser {
type: "admin"; type: "admin";
demographicInformation?: DemographicInformation; demographicInformation?: DemographicInformation;
} }
export interface DeveloperUser extends BasicUser { export interface DeveloperUser extends BasicUser {
type: "developer"; type: "developer";
preferredGender?: InstructorGender; preferredGender?: InstructorGender;
demographicInformation?: DemographicInformation; demographicInformation?: DemographicInformation;
preferredTopics?: string[]; preferredTopics?: string[];
} }
export interface CorporateInformation { export interface CorporateInformation {
companyInformation: CompanyInformation; companyInformation: CompanyInformation;
monthlyDuration: number; monthlyDuration: number;
payment?: { payment?: {
value: number; value: number;
currency: string; currency: string;
commission: number; commission: number;
}; };
referralAgent?: string; referralAgent?: string;
} }
export interface AgentInformation { export interface AgentInformation {
companyName: string; companyName: string;
commercialRegistration: string; commercialRegistration: string;
companyArabName?: string; companyArabName?: string;
} }
export interface CompanyInformation { export interface CompanyInformation {
name: string; name: string;
userAmount: number; userAmount: number;
} }
export interface DemographicInformation { export interface DemographicInformation {
country: string; country: string;
phone: string; phone: string;
gender: Gender; gender: Gender;
employment: EmploymentStatus; employment: EmploymentStatus;
passport_id?: string; passport_id?: string;
timezone?: string; timezone?: string;
} }
export interface DemographicCorporateInformation { export interface DemographicCorporateInformation {
country: string; country: string;
phone: string; phone: string;
gender: Gender; gender: Gender;
position: string; position: string;
timezone?: string; timezone?: string;
} }
export type Gender = "male" | "female" | "other"; export type Gender = "male" | "female" | "other";
export type EmploymentStatus = export type EmploymentStatus = "employed" | "student" | "self-employed" | "unemployed" | "retired" | "other";
| "employed" export const EMPLOYMENT_STATUS: {status: EmploymentStatus; label: string}[] = [
| "student" {status: "student", label: "Student"},
| "self-employed" {status: "employed", label: "Employed"},
| "unemployed" {status: "unemployed", label: "Unemployed"},
| "retired" {status: "self-employed", label: "Self-employed"},
| "other"; {status: "retired", label: "Retired"},
export const EMPLOYMENT_STATUS: { status: EmploymentStatus; label: string }[] = {status: "other", label: "Other"},
[ ];
{ status: "student", label: "Student" },
{ status: "employed", label: "Employed" },
{ status: "unemployed", label: "Unemployed" },
{ status: "self-employed", label: "Self-employed" },
{ status: "retired", label: "Retired" },
{ status: "other", label: "Other" },
];
export interface Stat { export interface Stat {
id: string; id: string;
user: string; user: string;
exam: string; exam: string;
exercise: string; exercise: string;
session: string; session: string;
date: number; date: number;
module: Module; module: Module;
solutions: any[]; solutions: any[];
type: string; type: string;
timeSpent?: number; timeSpent?: number;
inactivity?: number; inactivity?: number;
assignment?: string; assignment?: string;
score: { score: {
correct: number; correct: number;
total: number; total: number;
missing: number; missing: number;
}; };
isDisabled?: boolean; isDisabled?: boolean;
shuffleMaps?: ShuffleMap[]; shuffleMaps?: ShuffleMap[];
} }
export interface Group { export interface Group {
admin: string; admin: string;
name: string; name: string;
participants: string[]; participants: string[];
id: string; id: string;
disableEditing?: boolean; disableEditing?: boolean;
} }
export interface Code { export interface Code {
code: string; code: string;
creator: string; creator: string;
expiryDate: Date; expiryDate: Date;
type: Type; type: Type;
creationDate?: string; creationDate?: string;
userId?: string; userId?: string;
email?: string; email?: string;
name?: string; name?: string;
passport_id?: string; passport_id?: string;
} }
export type Type = export type Type = "student" | "teacher" | "corporate" | "admin" | "developer" | "agent" | "mastercorporate";
| "student" export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent", "mastercorporate"];
| "teacher"
| "corporate"
| "admin"
| "developer"
| "agent"
| "mastercorporate";
export const userTypes: Type[] = [
"student",
"teacher",
"corporate",
"admin",
"developer",
"agent",
"mastercorporate",
];

View File

@@ -1,377 +1,280 @@
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 useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import { Type, User } from "@/interfaces/user"; import {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 { useFilePicker } from "use-file-picker"; import {useFilePicker} from "use-file-picker";
import readXlsxFile from "read-excel-file"; import readXlsxFile from "read-excel-file";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import { BsFileEarmarkEaselFill, BsQuestionCircleFill } from "react-icons/bs"; import {BsFileEarmarkEaselFill, BsQuestionCircleFill} from "react-icons/bs";
import { checkAccess, getTypesOfUser } from "@/utils/permissions"; import {checkAccess, getTypesOfUser} from "@/utils/permissions";
import { PermissionType } from "@/interfaces/permissions"; import {PermissionType} from "@/interfaces/permissions";
const EMAIL_REGEX = new RegExp( import usePermissions from "@/hooks/usePermissions";
/^[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 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: [], list: [],
}, },
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: [ list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"],
"student", },
"teacher", developer: {
"agent", perm: undefined,
"corporate", list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"],
"admin", },
"mastercorporate",
],
},
developer: {
perm: undefined,
list: [
"student",
"teacher",
"agent",
"corporate",
"admin",
"developer",
"mastercorporate",
],
},
}; };
export default function BatchCodeGenerator({ user }: { user: User }) { export default function BatchCodeGenerator({user}: {user: User}) {
const [infos, setInfos] = useState< const [infos, setInfos] = useState<{email: string; name: string; passport_id: string}[]>([]);
{ email: string; name: string; passport_id: string }[] const [isLoading, setIsLoading] = useState(false);
>([]); const [expiryDate, setExpiryDate] = useState<Date | null>(
const [isLoading, setIsLoading] = useState(false); user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null,
const [expiryDate, setExpiryDate] = useState<Date | null>( );
user?.subscriptionExpirationDate const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
? moment(user.subscriptionExpirationDate).toDate() const [type, setType] = useState<Type>("student");
: null const [showHelp, setShowHelp] = useState(false);
);
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
const [type, setType] = useState<Type>("student");
const [showHelp, setShowHelp] = useState(false);
const { users } = useUsers(); const {users} = useUsers();
const {permissions} = usePermissions(user?.id || "");
const { openFilePicker, filesContent, clear } = useFilePicker({ const {openFilePicker, filesContent, clear} = useFilePicker({
accept: ".xlsx", accept: ".xlsx",
multiple: false, multiple: false,
readAs: "ArrayBuffer", readAs: "ArrayBuffer",
}); });
useEffect(() => { useEffect(() => {
if (!isExpiryDateEnabled) setExpiryDate(null); if (!isExpiryDateEnabled) setExpiryDate(null);
}, [isExpiryDateEnabled]); }, [isExpiryDateEnabled]);
useEffect(() => { useEffect(() => {
if (filesContent.length > 0) { if (filesContent.length > 0) {
const file = filesContent[0]; const file = filesContent[0];
readXlsxFile(file.content).then((rows) => { readXlsxFile(file.content).then((rows) => {
try { try {
const information = uniqBy( const information = uniqBy(
rows rows
.map((row) => { .map((row) => {
const [ const [firstName, lastName, country, passport_id, email, ...phone] = row as string[];
firstName, return EMAIL_REGEX.test(email.toString().trim())
lastName, ? {
country, email: email.toString().trim().toLowerCase(),
passport_id, name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
email, passport_id: passport_id?.toString().trim() || undefined,
...phone }
] = row as string[]; : undefined;
return EMAIL_REGEX.test(email.toString().trim()) })
? { .filter((x) => !!x) as typeof infos,
email: email.toString().trim().toLowerCase(), (x) => x.email,
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(), );
passport_id: passport_id?.toString().trim() || undefined,
}
: undefined;
})
.filter((x) => !!x) as typeof infos,
(x) => x.email
);
if (information.length === 0) { if (information.length === 0) {
toast.error( toast.error(
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!" "Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!",
); );
return clear(); return clear();
} }
setInfos(information); setInfos(information);
} catch { } catch {
toast.error( toast.error(
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!" "Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!",
); );
return clear(); return clear();
} }
}); });
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [filesContent]); }, [filesContent]);
const generateAndInvite = async () => { const generateAndInvite = async () => {
const newUsers = infos.filter( const newUsers = infos.filter((x) => !users.map((u) => u.email).includes(x.email));
(x) => !users.map((u) => u.email).includes(x.email) const existingUsers = infos
); .filter((x) => users.map((u) => u.email).includes(x.email))
const existingUsers = infos .map((i) => users.find((u) => u.email === i.email))
.filter((x) => users.map((u) => u.email).includes(x.email)) .filter((x) => !!x && x.type === "student") as User[];
.map((i) => users.find((u) => u.email === i.email))
.filter((x) => !!x && x.type === "student") as User[];
const newUsersSentence = const newUsersSentence = newUsers.length > 0 ? `generate ${newUsers.length} code(s)` : undefined;
newUsers.length > 0 ? `generate ${newUsers.length} code(s)` : undefined; const existingUsersSentence = existingUsers.length > 0 ? `invite ${existingUsers.length} registered student(s)` : undefined;
const existingUsersSentence = if (
existingUsers.length > 0 !confirm(
? `invite ${existingUsers.length} registered student(s)` `You are about to ${[newUsersSentence, existingUsersSentence].filter((x) => !!x).join(" and ")}, are you sure you want to continue?`,
: undefined; )
if ( )
!confirm( return;
`You are about to ${[newUsersSentence, existingUsersSentence]
.filter((x) => !!x)
.join(" and ")}, are you sure you want to continue?`
)
)
return;
setIsLoading(true); setIsLoading(true);
Promise.all( Promise.all(existingUsers.map(async (u) => await axios.post(`/api/invites`, {to: u.id, from: user.id})))
existingUsers.map( .then(() => toast.success(`Successfully invited ${existingUsers.length} registered student(s)!`))
async (u) => .finally(() => {
await axios.post(`/api/invites`, { to: u.id, from: user.id }) if (newUsers.length === 0) setIsLoading(false);
) });
)
.then(() =>
toast.success(
`Successfully invited ${existingUsers.length} registered student(s)!`
)
)
.finally(() => {
if (newUsers.length === 0) setIsLoading(false);
});
if (newUsers.length > 0) generateCode(type, newUsers); if (newUsers.length > 0) generateCode(type, newUsers);
setInfos([]); setInfos([]);
}; };
const generateCode = (type: Type, informations: typeof infos) => { const generateCode = (type: Type, informations: typeof infos) => {
const uid = new ShortUniqueId(); const uid = new ShortUniqueId();
const codes = informations.map(() => uid.randomUUID(6)); const codes = informations.map(() => uid.randomUUID(6));
setIsLoading(true); setIsLoading(true);
axios axios
.post<{ ok: boolean; valid?: number; reason?: string }>("/api/code", { .post<{ok: boolean; valid?: number; reason?: string}>("/api/code", {
type, type,
codes, codes,
infos: informations, infos: informations,
expiryDate, expiryDate,
}) })
.then(({ data, status }) => { .then(({data, status}) => {
if (data.ok) { if (data.ok) {
toast.success( toast.success(
`Successfully generated${ `Successfully generated${data.valid ? ` ${data.valid}/${informations.length}` : ""} ${capitalize(
data.valid ? ` ${data.valid}/${informations.length}` : "" type,
} ${capitalize(type)} codes and they have been notified by e-mail!`, )} codes and they have been notified by e-mail!`,
{ toastId: "success" } {toastId: "success"},
); );
return; return;
} }
if (status === 403) { if (status === 403) {
toast.error(data.reason, { toastId: "forbidden" }); toast.error(data.reason, {toastId: "forbidden"});
} }
}) })
.catch(({ response: { status, data } }) => { .catch(({response: {status, data}}) => {
if (status === 403) { if (status === 403) {
toast.error(data.reason, { toastId: "forbidden" }); toast.error(data.reason, {toastId: "forbidden"});
return; return;
} }
toast.error(`Something went wrong, please try again later!`, { toast.error(`Something went wrong, please try again later!`, {
toastId: "error", toastId: "error",
}); });
}) })
.finally(() => { .finally(() => {
setIsLoading(false); setIsLoading(false);
return clear(); return clear();
}); });
}; };
return ( return (
<> <>
<Modal <Modal isOpen={showHelp} onClose={() => setShowHelp(false)} title="Excel File Format">
isOpen={showHelp} <div className="mt-4 flex flex-col gap-2">
onClose={() => setShowHelp(false)} <span>Please upload an Excel file with the following format:</span>
title="Excel File Format" <table className="w-full">
> <thead>
<div className="mt-4 flex flex-col gap-2"> <tr>
<span>Please upload an Excel file with the following format:</span> <th className="border border-neutral-200 px-2 py-1">First Name</th>
<table className="w-full"> <th className="border border-neutral-200 px-2 py-1">Last Name</th>
<thead> <th className="border border-neutral-200 px-2 py-1">Country</th>
<tr> <th className="border border-neutral-200 px-2 py-1">Passport/National ID</th>
<th className="border border-neutral-200 px-2 py-1"> <th className="border border-neutral-200 px-2 py-1">E-mail</th>
First Name <th className="border border-neutral-200 px-2 py-1">Phone Number</th>
</th> </tr>
<th className="border border-neutral-200 px-2 py-1"> </thead>
Last Name </table>
</th> <span className="mt-4">
<th className="border border-neutral-200 px-2 py-1">Country</th> <b>Notes:</b>
<th className="border border-neutral-200 px-2 py-1"> <ul>
Passport/National ID <li>- All incorrect e-mails will be ignored;</li>
</th> <li>- All already registered e-mails will be ignored;</li>
<th className="border border-neutral-200 px-2 py-1">E-mail</th> <li>- You may have a header row with the format above, however, it is not necessary;</li>
<th className="border border-neutral-200 px-2 py-1"> <li>- All of the e-mails in the file will receive an e-mail to join EnCoach with the role selected below.</li>
Phone Number </ul>
</th> </span>
</tr> </div>
</thead> </Modal>
</table> <div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
<span className="mt-4"> <div className="flex items-end justify-between">
<b>Notes:</b> <label className="text-mti-gray-dim text-base font-normal">Choose an Excel file</label>
<ul> <div className="tooltip cursor-pointer" data-tip="Excel File Format" onClick={() => setShowHelp(true)}>
<li>- All incorrect e-mails will be ignored;</li> <BsQuestionCircleFill />
<li>- All already registered e-mails will be ignored;</li> </div>
<li> </div>
- You may have a header row with the format above, however, it <Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}>
is not necessary; {filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
</li> </Button>
<li> {user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
- All of the e-mails in the file will receive an e-mail to join <>
EnCoach with the role selected below. <div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
</li> <label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
</ul> <Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled} disabled={!!user.subscriptionExpirationDate}>
</span> Enabled
</div> </Checkbox>
</Modal> </div>
<div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4"> {isExpiryDateEnabled && (
<div className="flex items-end justify-between"> <ReactDatePicker
<label className="text-mti-gray-dim text-base font-normal"> className={clsx(
Choose an Excel file "flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
</label> "hover:border-mti-purple tooltip",
<div "transition duration-300 ease-in-out",
className="tooltip cursor-pointer" )}
data-tip="Excel File Format" filterDate={(date) =>
onClick={() => setShowHelp(true)} moment(date).isAfter(new Date()) &&
> (user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true)
<BsQuestionCircleFill /> }
</div> dateFormat="dd/MM/yyyy"
</div> selected={expiryDate}
<Button onChange={(date) => setExpiryDate(date)}
onClick={openFilePicker} />
isLoading={isLoading} )}
disabled={isLoading} </>
> )}
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"} <label className="text-mti-gray-dim text-base font-normal">Select the type of user they should be</label>
</Button> {user && (
{user && <select
checkAccess(user, [ defaultValue="student"
"developer", onChange={(e) => setType(e.target.value as typeof user.type)}
"admin", className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none">
"corporate", {Object.keys(USER_TYPE_LABELS)
"mastercorporate", .filter((x) => {
]) && ( const {list, perm} = USER_TYPE_PERMISSIONS[x as Type];
<> return checkAccess(user, getTypesOfUser(list), permissions, perm);
<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"> .map((type) => (
Expiry Date <option key={type} value={type}>
</label> {USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
<Checkbox </option>
isChecked={isExpiryDateEnabled} ))}
onChange={setIsExpiryDateEnabled} </select>
disabled={!!user.subscriptionExpirationDate} )}
> {checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "createCodes") && (
Enabled <Button onClick={generateAndInvite} disabled={infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)}>
</Checkbox> Generate & Send
</div> </Button>
{isExpiryDateEnabled && ( )}
<ReactDatePicker </div>
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)}
/>
)}
</>
)}
<label className="text-mti-gray-dim text-base font-normal">
Select the type of user they should be
</label>
{user && (
<select
defaultValue="student"
onChange={(e) => setType(e.target.value as typeof user.type)}
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none"
>
{Object.keys(USER_TYPE_LABELS)
.filter((x) => {
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type];
return checkAccess(user, getTypesOfUser(list), perm);
})
.map((type) => (
<option key={type} value={type}>
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
</option>
))}
</select>
)}
{checkAccess(
user,
["developer", "admin", "corporate", "mastercorporate"],
"createCodes"
) && (
<Button
onClick={generateAndInvite}
disabled={
infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)
}
>
Generate & Send
</Button>
)}
</div>
</>
);
} }

View File

@@ -1,243 +1,230 @@
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import { Type as UserType, User } from "@/interfaces/user"; import {Type as UserType, User} from "@/interfaces/user";
import axios from "axios"; import axios from "axios";
import { uniqBy } from "lodash"; import {uniqBy} from "lodash";
import { useEffect, useState } from "react"; import {useEffect, useState} from "react";
import { toast } from "react-toastify"; import {toast} from "react-toastify";
import { useFilePicker } from "use-file-picker"; import {useFilePicker} from "use-file-picker";
import readXlsxFile from "read-excel-file"; import readXlsxFile from "read-excel-file";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import { BsQuestionCircleFill } from "react-icons/bs"; import {BsQuestionCircleFill} from "react-icons/bs";
import { PermissionType } from "@/interfaces/permissions"; import {PermissionType} from "@/interfaces/permissions";
const EMAIL_REGEX = new RegExp( import moment from "moment";
/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/ import {checkAccess} from "@/utils/permissions";
); import Checkbox from "@/components/Low/Checkbox";
import ReactDatePicker from "react-datepicker";
import clsx from "clsx";
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
type Type = Exclude<UserType, "admin" | "developer" | "agent" | "mastercorporate"> type Type = Exclude<UserType, "admin" | "developer" | "agent" | "mastercorporate">;
const USER_TYPE_LABELS: {[key in Type]: string} = { const USER_TYPE_LABELS: {[key in Type]: string} = {
student: "Student", student: "Student",
teacher: "Teacher", teacher: "Teacher",
corporate: "Corporate", corporate: "Corporate",
}; };
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: [],
}, },
corporate: { corporate: {
perm: "createCodeCorporate", perm: "createCodeCorporate",
list: ["student", "teacher"], list: ["student", "teacher"],
}, },
}; };
export default function BatchCreateUser({ user }: { user: User }) { export default function BatchCreateUser({user}: {user: User}) {
const [infos, setInfos] = useState< const [infos, setInfos] = useState<
{ email: string; name: string; passport_id:string, type: Type, demographicInformation: { {
country: string, email: string;
passport_id:string, name: string;
phone: string passport_id: string;
} }[] type: Type;
>([]); demographicInformation: {
const [isLoading, setIsLoading] = useState(false); country: string;
const [type, setType] = useState<Type>("student"); passport_id: string;
const [showHelp, setShowHelp] = useState(false); phone: string;
};
}[]
>([]);
const [isLoading, setIsLoading] = useState(false);
const [expiryDate, setExpiryDate] = useState<Date | null>(
user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null,
);
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
const [type, setType] = useState<Type>("student");
const [showHelp, setShowHelp] = useState(false);
const { users } = useUsers(); const {users} = useUsers();
const { openFilePicker, filesContent, clear } = useFilePicker({ const {openFilePicker, filesContent, clear} = useFilePicker({
accept: ".xlsx", accept: ".xlsx",
multiple: false, multiple: false,
readAs: "ArrayBuffer", readAs: "ArrayBuffer",
}); });
useEffect(() => {
if (!isExpiryDateEnabled) setExpiryDate(null);
}, [isExpiryDateEnabled]);
useEffect(() => { useEffect(() => {
if (filesContent.length > 0) { if (filesContent.length > 0) {
const file = filesContent[0]; const file = filesContent[0];
readXlsxFile(file.content).then((rows) => { readXlsxFile(file.content).then((rows) => {
try { try {
const information = uniqBy( const information = uniqBy(
rows rows
.map((row) => { .map((row) => {
const [ const [firstName, lastName, country, passport_id, email, phone, group] = row as string[];
firstName, return EMAIL_REGEX.test(email.toString().trim())
lastName, ? {
country, email: email.toString().trim().toLowerCase(),
passport_id, name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
email, type: type,
phone, passport_id: passport_id?.toString().trim() || undefined,
group groupName: group,
] = row as string[]; demographicInformation: {
return EMAIL_REGEX.test(email.toString().trim()) country: country,
? { passport_id: passport_id?.toString().trim() || undefined,
email: email.toString().trim().toLowerCase(), phone,
name: `${firstName ?? ""} ${lastName ?? ""}`.trim().toLowerCase(), },
type: type, }
passport_id: passport_id?.toString().trim() || undefined, : undefined;
groupName: group, })
demographicInformation: { .filter((x) => !!x) as typeof infos,
country: country, (x) => x.email,
passport_id: passport_id?.toString().trim() || undefined, );
phone,
}
}
: undefined;
})
.filter((x) => !!x) as typeof infos,
(x) => x.email
);
if (information.length === 0) { if (information.length === 0) {
toast.error( toast.error(
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!" "Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!",
); );
return clear(); return clear();
} }
setInfos(information); setInfos(information);
} catch { } catch {
toast.error( toast.error(
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!" "Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!",
); );
return clear(); return clear();
} }
}); });
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [filesContent]); }, [filesContent]);
const makeUsers = async () => { const makeUsers = async () => {
const newUsers = infos.filter( const newUsers = infos.filter((x) => !users.map((u) => u.email).includes(x.email));
(x) => !users.map((u) => u.email).includes(x.email) if (!confirm(`You are about to add ${newUsers.length}, are you sure you want to continue?`)) return;
);
const confirmed = confirm(
`You are about to add ${newUsers.length}, are you sure you want to continue?`
)
if (!confirmed)
return;
if (newUsers.length > 0)
{
setIsLoading(true);
Promise.all(newUsers.map(async (user) => {
await axios.post("/api/make_user", user)
})).then((res) =>{
toast.success(
`Successfully added ${newUsers.length} user(s)!`
)}).finally(() => {
return clear();
})
}
setIsLoading(false);
setInfos([]);
};
if (newUsers.length > 0) {
setIsLoading(true);
return ( try {
<> for (const newUser of newUsers) await axios.post("/api/make_user", {...newUser, type, expiryDate});
<Modal toast.success(`Successfully added ${newUsers.length} user(s)!`);
isOpen={showHelp} } catch {
onClose={() => setShowHelp(false)} toast.error("Something went wrong, please try again later!");
title="Excel File Format" } finally {
> setIsLoading(false);
<div className="mt-4 flex flex-col gap-2"> setInfos([]);
<span>Please upload an Excel file with the following format:</span> clear();
<table className="w-full"> }
<thead> }
<tr> };
<th className="border border-neutral-200 px-2 py-1">
First Name
</th>
<th className="border border-neutral-200 px-2 py-1">
Last Name
</th>
<th className="border border-neutral-200 px-2 py-1">Country</th>
<th className="border border-neutral-200 px-2 py-1">
Passport/National ID
</th>
<th className="border border-neutral-200 px-2 py-1">E-mail</th>
<th className="border border-neutral-200 px-2 py-1">
Phone Number
</th>
<th className="border border-neutral-200 px-2 py-1">
Group Name
</th>
</tr>
</thead>
</table>
<span className="mt-4">
<b>Notes:</b>
<ul>
<li>- All incorrect e-mails will be ignored;</li>
<li>- All already registered e-mails will be ignored;</li>
<li>
- You may have a header row with the format above, however, it
is not necessary;
</li>
<li>
- All of the e-mails in the file will receive an e-mail to join
EnCoach with the role selected below.
</li>
</ul>
</span>
</div>
</Modal>
<div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
<div className="flex items-end justify-between">
<label className="text-mti-gray-dim text-base font-normal">
Choose an Excel file
</label>
<div
className="tooltip cursor-pointer"
data-tip="Excel File Format"
onClick={() => setShowHelp(true)}
>
<BsQuestionCircleFill />
</div>
</div>
<Button
onClick={openFilePicker}
isLoading={isLoading}
disabled={isLoading}
>
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
</Button>
<label className="text-mti-gray-dim text-base font-normal">
Select the type of user they should be
</label>
{user && (
<select
defaultValue="student"
onChange={(e) => setType(e.target.value as Type)}
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none"
>
{Object.keys(USER_TYPE_LABELS) return (
.map((type) => ( <>
<option key={type} value={type}> <Modal isOpen={showHelp} onClose={() => setShowHelp(false)} title="Excel File Format">
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]} <div className="mt-4 flex flex-col gap-2">
</option> <span>Please upload an Excel file with the following format:</span>
))} <table className="w-full">
</select> <thead>
)} <tr>
<Button <th className="border border-neutral-200 px-2 py-1">First Name</th>
className="my-auto" <th className="border border-neutral-200 px-2 py-1">Last Name</th>
onClick={makeUsers} <th className="border border-neutral-200 px-2 py-1">Country</th>
disabled={ <th className="border border-neutral-200 px-2 py-1">Passport/National ID</th>
infos.length === 0 <th className="border border-neutral-200 px-2 py-1">E-mail</th>
} <th className="border border-neutral-200 px-2 py-1">Phone Number</th>
> <th className="border border-neutral-200 px-2 py-1">Group Name</th>
Create </tr>
</Button> </thead>
</div> </table>
</> <span className="mt-4">
); <b>Notes:</b>
<ul>
<li>- All incorrect e-mails will be ignored;</li>
<li>- All already registered e-mails will be ignored;</li>
<li>- You may have a header row with the format above, however, it is not necessary;</li>
<li>- All of the e-mails in the file will receive an e-mail to join EnCoach with the role selected below.</li>
</ul>
</span>
</div>
</Modal>
<div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
<div className="flex items-end justify-between">
<label className="text-mti-gray-dim text-base font-normal">Choose an Excel file</label>
<div className="tooltip cursor-pointer" data-tip="Excel File Format" onClick={() => setShowHelp(true)}>
<BsQuestionCircleFill />
</div>
</div>
<Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}>
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
</Button>
{user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
<>
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
<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)}
/>
)}
</>
)}
<label className="text-mti-gray-dim text-base font-normal">Select the type of user they should be</label>
{user && (
<select
defaultValue="student"
onChange={(e) => setType(e.target.value as Type)}
className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none">
{Object.keys(USER_TYPE_LABELS).map((type) => (
<option key={type} value={type}>
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
</option>
))}
</select>
)}
<Button className="my-auto" onClick={makeUsers} disabled={infos.length === 0}>
Create
</Button>
</div>
</>
);
} }

View File

@@ -1,198 +1,162 @@
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 { Type, User } from "@/interfaces/user"; import {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 } from "lodash"; import {capitalize} 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 } from "@/utils/permissions"; import {checkAccess} from "@/utils/permissions";
import { PermissionType } from "@/interfaces/permissions"; import {PermissionType} from "@/interfaces/permissions";
import usePermissions from "@/hooks/usePermissions";
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: [], list: [],
}, },
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: [ list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"],
"student", },
"teacher", developer: {
"agent", perm: undefined,
"corporate", list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"],
"admin", },
"mastercorporate",
],
},
developer: {
perm: undefined,
list: [
"student",
"teacher",
"agent",
"corporate",
"admin",
"developer",
"mastercorporate",
],
},
}; };
export default function CodeGenerator({ user }: { user: User }) { export default function CodeGenerator({user}: {user: User}) {
const [generatedCode, setGeneratedCode] = useState<string>(); const [generatedCode, setGeneratedCode] = useState<string>();
const [expiryDate, setExpiryDate] = useState<Date | null>( const [expiryDate, setExpiryDate] = useState<Date | null>(
user?.subscriptionExpirationDate user?.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).toDate() : null,
? moment(user.subscriptionExpirationDate).toDate() );
: null const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
); const [type, setType] = useState<Type>("student");
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true); const {permissions} = usePermissions(user?.id || "");
const [type, setType] = useState<Type>("student");
useEffect(() => { useEffect(() => {
if (!isExpiryDateEnabled) setExpiryDate(null); if (!isExpiryDateEnabled) setExpiryDate(null);
}, [isExpiryDateEnabled]); }, [isExpiryDateEnabled]);
const generateCode = (type: Type) => { const generateCode = (type: Type) => {
const uid = new ShortUniqueId(); const uid = new ShortUniqueId();
const code = uid.randomUUID(6); const code = uid.randomUUID(6);
axios axios
.post("/api/code", { type, codes: [code], expiryDate }) .post("/api/code", {type, codes: [code], expiryDate})
.then(({ data, status }) => { .then(({data, status}) => {
if (data.ok) { if (data.ok) {
toast.success(`Successfully generated a ${capitalize(type)} code!`, { toast.success(`Successfully generated a ${capitalize(type)} code!`, {
toastId: "success", toastId: "success",
}); });
setGeneratedCode(code); setGeneratedCode(code);
return; return;
} }
if (status === 403) { if (status === 403) {
toast.error(data.reason, { toastId: "forbidden" }); toast.error(data.reason, {toastId: "forbidden"});
} }
}) })
.catch(({ response: { status, data } }) => { .catch(({response: {status, data}}) => {
if (status === 403) { if (status === 403) {
toast.error(data.reason, { toastId: "forbidden" }); toast.error(data.reason, {toastId: "forbidden"});
return; return;
} }
toast.error(`Something went wrong, please try again later!`, { toast.error(`Something went wrong, please try again later!`, {
toastId: "error", toastId: "error",
}); });
}); });
}; };
return ( return (
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl"> <div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
<label className="font-normal text-base text-mti-gray-dim"> <label className="font-normal text-base text-mti-gray-dim">User Code Generator</label>
User Code Generator {user && (
</label> <select
{user && ( defaultValue="student"
<select onChange={(e) => setType(e.target.value as typeof user.type)}
defaultValue="student" 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">
onChange={(e) => setType(e.target.value as typeof user.type)} {Object.keys(USER_TYPE_LABELS)
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" .filter((x) => {
> const {list, perm} = USER_TYPE_PERMISSIONS[x as Type];
{Object.keys(USER_TYPE_LABELS) return checkAccess(user, list, permissions, perm);
.filter((x) => { })
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type]; .map((type) => (
return checkAccess(user, list, perm); <option key={type} value={type}>
}) {USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
.map((type) => ( </option>
<option key={type} value={type}> ))}
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]} </select>
</option> )}
))} {user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
</select> <>
)} <div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
{user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && ( <label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
<> <Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled} disabled={!!user.subscriptionExpirationDate}>
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center"> Enabled
<label className="text-mti-gray-dim text-base font-normal"> </Checkbox>
Expiry Date </div>
</label> {isExpiryDateEnabled && (
<Checkbox <ReactDatePicker
isChecked={isExpiryDateEnabled} className={clsx(
onChange={setIsExpiryDateEnabled} "flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
disabled={!!user.subscriptionExpirationDate} "hover:border-mti-purple tooltip",
> "transition duration-300 ease-in-out",
Enabled )}
</Checkbox> filterDate={(date) =>
</div> moment(date).isAfter(new Date()) &&
{isExpiryDateEnabled && ( (user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true)
<ReactDatePicker }
className={clsx( dateFormat="dd/MM/yyyy"
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none", selected={expiryDate}
"hover:border-mti-purple tooltip", onChange={(date) => setExpiryDate(date)}
"transition duration-300 ease-in-out" />
)} )}
filterDate={(date) => </>
moment(date).isAfter(new Date()) && )}
(user.subscriptionExpirationDate {checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "createCodes") && (
? moment(date).isBefore(user.subscriptionExpirationDate) <Button onClick={() => generateCode(type)} disabled={isExpiryDateEnabled ? !expiryDate : false}>
: true) Generate
} </Button>
dateFormat="dd/MM/yyyy" )}
selected={expiryDate} <label className="font-normal text-base text-mti-gray-dim">Generated Code:</label>
onChange={(date) => setExpiryDate(date)} <div
/> className={clsx(
)} "p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
</> "hover:border-mti-purple tooltip",
)} "transition duration-300 ease-in-out",
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], 'createCodes') && ( )}
<Button data-tip="Click to copy"
onClick={() => generateCode(type)} onClick={() => {
disabled={isExpiryDateEnabled ? !expiryDate : false} if (generatedCode) navigator.clipboard.writeText(generatedCode);
> }}>
Generate {generatedCode}
</Button> </div>
)} {generatedCode && <span className="text-sm text-mti-gray-dim font-light">Give this code to the user to complete their registration</span>}
<label className="font-normal text-base text-mti-gray-dim"> </div>
Generated Code: );
</label>
<div
className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip",
"transition duration-300 ease-in-out"
)}
data-tip="Click to copy"
onClick={() => {
if (generatedCode) navigator.clipboard.writeText(generatedCode);
}}
>
{generatedCode}
</div>
{generatedCode && (
<span className="text-sm text-mti-gray-dim font-light">
Give this code to the user to complete their registration
</span>
)}
</div>
);
} }

View File

@@ -4,364 +4,306 @@ import Select from "@/components/Low/Select";
import useCodes from "@/hooks/useCodes"; import useCodes from "@/hooks/useCodes";
import useUser from "@/hooks/useUser"; import useUser from "@/hooks/useUser";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import { Code, User } from "@/interfaces/user"; import {Code, User} from "@/interfaces/user";
import { USER_TYPE_LABELS } from "@/resources/user"; import {USER_TYPE_LABELS} from "@/resources/user";
import { import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import axios from "axios"; import axios from "axios";
import moment from "moment"; import moment from "moment";
import { useEffect, useState, useMemo } from "react"; import {useEffect, useState, useMemo} from "react";
import { BsTrash } from "react-icons/bs"; import {BsTrash} from "react-icons/bs";
import { toast } from "react-toastify"; import {toast} from "react-toastify";
import ReactDatePicker from "react-datepicker"; import ReactDatePicker from "react-datepicker";
import clsx from "clsx"; import clsx from "clsx";
import { checkAccess } from "@/utils/permissions"; import {checkAccess} from "@/utils/permissions";
import usePermissions from "@/hooks/usePermissions";
const columnHelper = createColumnHelper<Code>(); const columnHelper = createColumnHelper<Code>();
const CreatorCell = ({ id, users }: { id: string; users: User[] }) => { const CreatorCell = ({id, users}: {id: string; users: User[]}) => {
const [creatorUser, setCreatorUser] = useState<User>(); const [creatorUser, setCreatorUser] = useState<User>();
useEffect(() => { useEffect(() => {
setCreatorUser(users.find((x) => x.id === id)); setCreatorUser(users.find((x) => x.id === id));
}, [id, users]); }, [id, users]);
return ( return (
<> <>
{(creatorUser?.type === "corporate" {(creatorUser?.type === "corporate" ? creatorUser?.corporateInformation?.companyInformation?.name : creatorUser?.name || "N/A") || "N/A"}{" "}
? creatorUser?.corporateInformation?.companyInformation?.name {creatorUser && `(${USER_TYPE_LABELS[creatorUser.type]})`}
: creatorUser?.name || "N/A") || "N/A"}{" "} </>
{creatorUser && `(${USER_TYPE_LABELS[creatorUser.type]})`} );
</>
);
}; };
export default function CodeList({ user }: { user: User }) { export default function CodeList({user}: {user: User}) {
const [selectedCodes, setSelectedCodes] = useState<string[]>([]); const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
const [filteredCorporate, setFilteredCorporate] = useState<User | undefined>( const [filteredCorporate, setFilteredCorporate] = useState<User | undefined>(user?.type === "corporate" ? user : undefined);
user?.type === "corporate" ? user : undefined const [filterAvailability, setFilterAvailability] = useState<"in-use" | "unused">();
);
const [filterAvailability, setFilterAvailability] = useState<
"in-use" | "unused"
>();
// const [filteredCodes, setFilteredCodes] = useState<Code[]>([]); const {permissions} = usePermissions(user?.id || "");
const { users } = useUsers(); // const [filteredCodes, setFilteredCodes] = useState<Code[]>([]);
const { codes, reload } = useCodes(
user?.type === "corporate" ? user?.id : undefined
);
const [startDate, setStartDate] = useState<Date | null>(moment("01/01/2023").toDate()); const {users} = useUsers();
const {codes, reload} = useCodes(user?.type === "corporate" ? user?.id : undefined);
const [startDate, setStartDate] = useState<Date | null>(moment("01/01/2023").toDate());
const [endDate, setEndDate] = useState<Date | null>(moment().endOf("day").toDate()); const [endDate, setEndDate] = useState<Date | null>(moment().endOf("day").toDate());
const filteredCodes = useMemo(() => { const filteredCodes = useMemo(() => {
return codes.filter((x) => { return codes.filter((x) => {
// TODO: if the expiry date is missing, it does not make sense to filter by date // TODO: if the expiry date is missing, it does not make sense to filter by date
// so we need to find a way to handle this edge case // so we need to find a way to handle this edge case
if(startDate && endDate && x.expiryDate) { if (startDate && endDate && x.expiryDate) {
const date = moment(x.expiryDate); const date = moment(x.expiryDate);
if(date.isBefore(startDate) || date.isAfter(endDate)) { if (date.isBefore(startDate) || date.isAfter(endDate)) {
return false; return false;
} }
} }
if (filteredCorporate && x.creator !== filteredCorporate.id) return false; if (filteredCorporate && x.creator !== filteredCorporate.id) return false;
if (filterAvailability) { if (filterAvailability) {
if (filterAvailability === "in-use" && !x.userId) return false; if (filterAvailability === "in-use" && !x.userId) return false;
if (filterAvailability === "unused" && x.userId) return false; if (filterAvailability === "unused" && x.userId) return false;
} }
return true; return true;
}); });
}, [codes, startDate, endDate, filteredCorporate, filterAvailability]); }, [codes, startDate, endDate, filteredCorporate, filterAvailability]);
const toggleCode = (id: string) => { const toggleCode = (id: string) => {
setSelectedCodes((prev) => setSelectedCodes((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id] };
);
};
const toggleAllCodes = (checked: boolean) => { const toggleAllCodes = (checked: boolean) => {
if (checked) if (checked) return setSelectedCodes(filteredCodes.filter((x) => !x.userId).map((x) => x.code));
return setSelectedCodes(
filteredCodes.filter((x) => !x.userId).map((x) => x.code)
);
return setSelectedCodes([]); return setSelectedCodes([]);
}; };
const deleteCodes = async (codes: string[]) => { const deleteCodes = async (codes: string[]) => {
if ( if (!confirm(`Are you sure you want to delete these ${codes.length} code(s)?`)) return;
!confirm(`Are you sure you want to delete these ${codes.length} code(s)?`)
)
return;
const params = new URLSearchParams(); const params = new URLSearchParams();
codes.forEach((code) => params.append("code", code)); codes.forEach((code) => params.append("code", code));
axios axios
.delete(`/api/code?${params.toString()}`) .delete(`/api/code?${params.toString()}`)
.then(() => { .then(() => {
toast.success(`Deleted the codes!`); toast.success(`Deleted the codes!`);
setSelectedCodes([]); setSelectedCodes([]);
}) })
.catch((reason) => { .catch((reason) => {
if (reason.response.status === 404) { if (reason.response.status === 404) {
toast.error("Code not found!"); toast.error("Code not found!");
return; return;
} }
if (reason.response.status === 403) { if (reason.response.status === 403) {
toast.error("You do not have permission to delete this code!"); toast.error("You do not have permission to delete this code!");
return; return;
} }
toast.error("Something went wrong, please try again later."); toast.error("Something went wrong, please try again later.");
}) })
.finally(reload); .finally(reload);
}; };
const deleteCode = async (code: Code) => { const deleteCode = async (code: Code) => {
if (!confirm(`Are you sure you want to delete this "${code.code}" code?`)) if (!confirm(`Are you sure you want to delete this "${code.code}" code?`)) return;
return;
axios axios
.delete(`/api/code/${code.code}`) .delete(`/api/code/${code.code}`)
.then(() => toast.success(`Deleted the "${code.code}" exam`)) .then(() => toast.success(`Deleted the "${code.code}" exam`))
.catch((reason) => { .catch((reason) => {
if (reason.response.status === 404) { if (reason.response.status === 404) {
toast.error("Code not found!"); toast.error("Code not found!");
return; return;
} }
if (reason.response.status === 403) { if (reason.response.status === 403) {
toast.error("You do not have permission to delete this code!"); toast.error("You do not have permission to delete this code!");
return; return;
} }
toast.error("Something went wrong, please try again later."); toast.error("Something went wrong, please try again later.");
}) })
.finally(reload); .finally(reload);
}; };
const allowedToDelete = checkAccess( const allowedToDelete = checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "deleteCodes");
user,
["developer", "admin", "corporate", "mastercorporate"],
"deleteCodes"
);
const defaultColumns = [ const defaultColumns = [
columnHelper.accessor("code", { columnHelper.accessor("code", {
id: "codeCheckbox", id: "codeCheckbox",
header: () => ( header: () => (
<Checkbox <Checkbox
disabled={filteredCodes.filter((x) => !x.userId).length === 0} disabled={filteredCodes.filter((x) => !x.userId).length === 0}
isChecked={ isChecked={
selectedCodes.length === selectedCodes.length === filteredCodes.filter((x) => !x.userId).length && filteredCodes.filter((x) => !x.userId).length > 0
filteredCodes.filter((x) => !x.userId).length && }
filteredCodes.filter((x) => !x.userId).length > 0 onChange={(checked) => toggleAllCodes(checked)}>
} {""}
onChange={(checked) => toggleAllCodes(checked)} </Checkbox>
> ),
{""} cell: (info) =>
</Checkbox> !info.row.original.userId ? (
), <Checkbox isChecked={selectedCodes.includes(info.getValue())} onChange={() => toggleCode(info.getValue())}>
cell: (info) => {""}
!info.row.original.userId ? ( </Checkbox>
<Checkbox ) : null,
isChecked={selectedCodes.includes(info.getValue())} }),
onChange={() => toggleCode(info.getValue())} columnHelper.accessor("code", {
> header: "Code",
{""} cell: (info) => info.getValue(),
</Checkbox> }),
) : null, columnHelper.accessor("creationDate", {
}), header: "Creation Date",
columnHelper.accessor("code", { cell: (info) => (info.getValue() ? moment(info.getValue()).format("DD/MM/YYYY") : "N/A"),
header: "Code", }),
cell: (info) => info.getValue(), columnHelper.accessor("email", {
}), header: "Invited E-mail",
columnHelper.accessor("creationDate", { cell: (info) => info.getValue() || "N/A",
header: "Creation Date", }),
cell: (info) => columnHelper.accessor("creator", {
info.getValue() ? moment(info.getValue()).format("DD/MM/YYYY") : "N/A", header: "Creator",
}), cell: (info) => <CreatorCell id={info.getValue()} users={users} />,
columnHelper.accessor("email", { }),
header: "Invited E-mail", columnHelper.accessor("userId", {
cell: (info) => info.getValue() || "N/A", header: "Availability",
}), cell: (info) =>
columnHelper.accessor("creator", { info.getValue() ? (
header: "Creator", <span className="flex gap-1 items-center text-mti-green">
cell: (info) => <CreatorCell id={info.getValue()} users={users} />, <div className="w-2 h-2 rounded-full bg-mti-green" /> In Use
}), </span>
columnHelper.accessor("userId", { ) : (
header: "Availability", <span className="flex gap-1 items-center text-mti-red">
cell: (info) => <div className="w-2 h-2 rounded-full bg-mti-red" /> Unused
info.getValue() ? ( </span>
<span className="flex gap-1 items-center text-mti-green"> ),
<div className="w-2 h-2 rounded-full bg-mti-green" /> In Use }),
</span> {
) : ( header: "",
<span className="flex gap-1 items-center text-mti-red"> id: "actions",
<div className="w-2 h-2 rounded-full bg-mti-red" /> Unused cell: ({row}: {row: {original: Code}}) => {
</span> return (
), <div className="flex gap-4">
}), {allowedToDelete && !row.original.userId && (
{ <div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteCode(row.original)}>
header: "", <BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
id: "actions", </div>
cell: ({ row }: { row: { original: Code } }) => { )}
return ( </div>
<div className="flex gap-4"> );
{allowedToDelete && !row.original.userId && ( },
<div },
data-tip="Delete" ];
className="cursor-pointer tooltip"
onClick={() => deleteCode(row.original)}
>
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div>
)}
</div>
);
},
},
];
const table = useReactTable({ const table = useReactTable({
data: filteredCodes, data: filteredCodes,
columns: defaultColumns, columns: defaultColumns,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
}); });
return ( return (
<> <>
<div className="flex items-center justify-between pb-4 pt-1"> <div className="flex items-center justify-between pb-4 pt-1">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Select <Select
className="!w-96 !py-1" className="!w-96 !py-1"
disabled={user?.type === "corporate"} disabled={user?.type === "corporate"}
isClearable isClearable
placeholder="Corporate" placeholder="Corporate"
value={ value={
filteredCorporate filteredCorporate
? { ? {
label: `${ label: `${
filteredCorporate.type === "corporate" filteredCorporate.type === "corporate"
? filteredCorporate.corporateInformation ? filteredCorporate.corporateInformation?.companyInformation?.name || filteredCorporate.name
?.companyInformation?.name || filteredCorporate.name : filteredCorporate.name
: filteredCorporate.name } (${USER_TYPE_LABELS[filteredCorporate.type]})`,
} (${USER_TYPE_LABELS[filteredCorporate.type]})`, value: filteredCorporate.id,
value: filteredCorporate.id, }
} : null
: null }
} options={users
options={users .filter((x) => ["admin", "developer", "corporate"].includes(x.type))
.filter((x) => .map((x) => ({
["admin", "developer", "corporate"].includes(x.type) label: `${x.type === "corporate" ? x.corporateInformation?.companyInformation?.name || x.name : x.name} (${
) USER_TYPE_LABELS[x.type]
.map((x) => ({ })`,
label: `${ value: x.id,
x.type === "corporate" user: x,
? x.corporateInformation?.companyInformation?.name || x.name }))}
: x.name onChange={(value) => setFilteredCorporate(value ? users.find((x) => x.id === value?.value) : undefined)}
} (${USER_TYPE_LABELS[x.type]})`, />
value: x.id, <Select
user: x, className="!w-96 !py-1"
}))} placeholder="Availability"
onChange={(value) => isClearable
setFilteredCorporate( options={[
value ? users.find((x) => x.id === value?.value) : undefined {label: "In Use", value: "in-use"},
) {label: "Unused", value: "unused"},
} ]}
/> onChange={(value) => setFilterAvailability(value ? (value.value as typeof filterAvailability) : undefined)}
<Select />
className="!w-96 !py-1" <ReactDatePicker
placeholder="Availability" dateFormat="dd/MM/yyyy"
isClearable className="px-4 py-6 w-full text-sm text-center font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed rounded-full border border-mti-gray-platinum focus:outline-none"
options={[ selected={startDate}
{ label: "In Use", value: "in-use" }, startDate={startDate}
{ label: "Unused", value: "unused" }, endDate={endDate}
]} selectsRange
onChange={(value) => showMonthDropdown
setFilterAvailability( filterDate={(date: Date) => moment(date).isSameOrBefore(moment(new Date()))}
value ? (value.value as typeof filterAvailability) : undefined onChange={([initialDate, finalDate]: [Date, Date]) => {
) setStartDate(initialDate ?? moment("01/01/2023").toDate());
} if (finalDate) {
/> // basicly selecting a final day works as if I'm selecting the first
<ReactDatePicker // minute of that day. this way it covers the whole day
dateFormat="dd/MM/yyyy" setEndDate(moment(finalDate).endOf("day").toDate());
className="px-4 py-6 w-full text-sm text-center font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed rounded-full border border-mti-gray-platinum focus:outline-none" return;
selected={startDate} }
startDate={startDate} setEndDate(null);
endDate={endDate} }}
selectsRange />
showMonthDropdown </div>
filterDate={(date: Date) => {allowedToDelete && (
moment(date).isSameOrBefore(moment(new Date())) <div className="flex gap-4 items-center">
} <span>{selectedCodes.length} code(s) selected</span>
onChange={([initialDate, finalDate]: [Date, Date]) => { <Button
setStartDate(initialDate ?? moment("01/01/2023").toDate()); disabled={selectedCodes.length === 0}
if (finalDate) { variant="outline"
// basicly selecting a final day works as if I'm selecting the first color="red"
// minute of that day. this way it covers the whole day className="!py-1 px-10"
setEndDate(moment(finalDate).endOf("day").toDate()); onClick={() => deleteCodes(selectedCodes)}>
return; Delete
} </Button>
setEndDate(null); </div>
}} )}
/> </div>
</div> <table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
{allowedToDelete && ( <thead>
<div className="flex gap-4 items-center"> {table.getHeaderGroups().map((headerGroup) => (
<span>{selectedCodes.length} code(s) selected</span> <tr key={headerGroup.id}>
<Button {headerGroup.headers.map((header) => (
disabled={selectedCodes.length === 0} <th className="p-4 text-left" key={header.id}>
variant="outline" {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
color="red" </th>
className="!py-1 px-10" ))}
onClick={() => deleteCodes(selectedCodes)} </tr>
> ))}
Delete </thead>
</Button> <tbody className="px-2">
</div> {table.getRowModel().rows.map((row) => (
)} <tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
</div> {row.getVisibleCells().map((cell) => (
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full"> <td className="px-4 py-2" key={cell.id}>
<thead> {flexRender(cell.column.columnDef.cell, cell.getContext())}
{table.getHeaderGroups().map((headerGroup) => ( </td>
<tr key={headerGroup.id}> ))}
{headerGroup.headers.map((header) => ( </tr>
<th className="p-4 text-left" key={header.id}> ))}
{header.isPlaceholder </tbody>
? null </table>
: flexRender( </>
header.column.columnDef.header, );
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2">
{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" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</>
);
} }

View File

@@ -3,474 +3,351 @@ 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 { import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
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, useState } from "react"; import {useEffect, 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 } from "@/resources/user"; import {isAgentUser, isCorporateUser} from "@/resources/user";
import { checkAccess } from "@/utils/permissions"; import {checkAccess} from "@/utils/permissions";
import usePermissions from "@/hooks/usePermissions";
const columnHelper = createColumnHelper<Group>(); const columnHelper = createColumnHelper<Group>();
const EMAIL_REGEX = new RegExp( const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/
);
const LinkedCorporate = ({ const LinkedCorporate = ({userId, users, groups}: {userId: string; users: User[]; groups: Group[]}) => {
userId, const [companyName, setCompanyName] = useState("");
users, const [isLoading, setIsLoading] = useState(false);
groups,
}: {
userId: string;
users: User[];
groups: Group[];
}) => {
const [companyName, setCompanyName] = useState("");
const [isLoading, setIsLoading] = useState(false);
useEffect(() => { useEffect(() => {
const user = users.find((u) => u.id === userId); const user = users.find((u) => u.id === userId);
if (!user) return setCompanyName(""); if (!user) return setCompanyName("");
if (isCorporateUser(user)) if (isCorporateUser(user)) return setCompanyName(user.corporateInformation?.companyInformation?.name || user.name);
return setCompanyName( if (isAgentUser(user)) return setCompanyName(user.agentInformation?.companyName || user.name);
user.corporateInformation?.companyInformation?.name || user.name
);
if (isAgentUser(user))
return setCompanyName(user.agentInformation?.companyName || user.name);
const belongingGroups = groups.filter((x) => const belongingGroups = groups.filter((x) => x.participants.includes(userId));
x.participants.includes(userId) const belongingGroupsAdmins = belongingGroups.map((x) => users.find((u) => u.id === x.admin)).filter((x) => !!x && isCorporateUser(x));
);
const belongingGroupsAdmins = belongingGroups
.map((x) => users.find((u) => u.id === x.admin))
.filter((x) => !!x && isCorporateUser(x));
if (belongingGroupsAdmins.length === 0) return setCompanyName(""); if (belongingGroupsAdmins.length === 0) return setCompanyName("");
const admin = belongingGroupsAdmins[0] as CorporateUser; const admin = belongingGroupsAdmins[0] as CorporateUser;
setCompanyName( setCompanyName(admin.corporateInformation?.companyInformation.name || admin.name);
admin.corporateInformation?.companyInformation.name || admin.name }, [userId, users, groups]);
);
}, [userId, users, groups]);
return isLoading ? ( return isLoading ? <span className="animate-pulse">Loading...</span> : <>{companyName}</>;
<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>( const [name, setName] = useState<string | undefined>(group?.name || undefined);
group?.name || undefined const [admin, setAdmin] = useState<string>(group?.admin || user.id);
); const [participants, setParticipants] = useState<string[]>(group?.participants || []);
const [admin, setAdmin] = useState<string>(group?.admin || user.id); const [isLoading, setIsLoading] = useState(false);
const [participants, setParticipants] = useState<string[]>(
group?.participants || []
);
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",
}); });
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) && return EMAIL_REGEX.test(email) && !users.map((u) => u.email).includes(email) ? email.toString().trim() : undefined;
!users.map((u) => u.email).includes(email) })
? email.toString().trim() .filter((x) => !!x),
: undefined; );
})
.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)] const emailUsers = [...new Set(emails)].map((x) => users.find((y) => y.email.toLowerCase() === x)).filter((x) => x !== undefined);
.map((x) => users.find((y) => y.email.toLowerCase() === x)) const filteredUsers = emailUsers.filter(
.filter((x) => x !== undefined); (x) =>
const filteredUsers = emailUsers.filter( ((user.type === "developer" || user.type === "admin" || user.type === "corporate" || user.type === "mastercorporate") &&
(x) => (x?.type === "student" || x?.type === "teacher")) ||
((user.type === "developer" || (user.type === "teacher" && x?.type === "student"),
user.type === "admin" || );
user.type === "corporate" ||
user.type === "mastercorporate") &&
(x?.type === "student" || x?.type === "teacher")) ||
(user.type === "teacher" && x?.type === "student")
);
setParticipants(filteredUsers.filter((x) => !!x).map((x) => x!.id)); 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 === "Students" || name === "Teachers")) { if (name !== group?.name && (name === "Students" || name === "Teachers")) {
toast.error( toast.error("That group name is reserved and cannot be used, please enter another one.");
"That group name is reserved and cannot be used, please enter another one." setIsLoading(false);
); return;
setIsLoading(false); }
return;
}
(group ? axios.patch : axios.post)( (group ? axios.patch : axios.post)(group ? `/api/groups/${group.id}` : "/api/groups", {name, admin, participants})
group ? `/api/groups/${group.id}` : "/api/groups", .then(() => {
{ name, admin, participants } toast.success(`Group "${name}" ${group ? "edited" : "created"} successfully`);
) return true;
.then(() => { })
toast.success( .catch(() => {
`Group "${name}" ${group ? "edited" : "created"} successfully` toast.error("Something went wrong, please try again later!");
); return false;
return true; })
}) .finally(() => {
.catch(() => { setIsLoading(false);
toast.error("Something went wrong, please try again later!"); onClose();
return false; });
}) };
.finally(() => {
setIsLoading(false);
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 <Input name="name" type="text" label="Name" defaultValue={name} onChange={setName} required disabled={group?.disableEditing} />
name="name" <div className="flex w-full flex-col gap-3">
type="text" <div className="flex items-center gap-2">
label="Name" <label className="text-mti-gray-dim text-base font-normal">Participants</label>
defaultValue={name} <div className="tooltip" data-tip="The Excel file should only include a column with the desired e-mails.">
onChange={setName} <BsQuestionCircleFill />
required </div>
disabled={group?.disableEditing} </div>
/> <div className="flex w-full gap-8">
<div className="flex w-full flex-col gap-3"> <Select
<div className="flex items-center gap-2"> className="w-full"
<label className="text-mti-gray-dim text-base font-normal"> value={participants.map((x) => ({
Participants value: x,
</label> label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
<div }))}
className="tooltip" placeholder="Participants..."
data-tip="The Excel file should only include a column with the desired e-mails." defaultValue={participants.map((x) => ({
> value: x,
<BsQuestionCircleFill /> label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
</div> }))}
</div> options={users
<div className="flex w-full gap-8"> .filter((x) =>
<Select user.type === "teacher"
className="w-full" ? x.type === "student"
value={participants.map((x) => ({ : user.type === "corporate"
value: x, ? x.type === "student" || x.type === "teacher"
label: `${users.find((y) => y.id === x)?.email} - ${ : x.type === "student" || x.type === "teacher" || x.type === "corporate",
users.find((y) => y.id === x)?.name )
}`, .map((x) => ({value: x.id, label: `${x.email} - ${x.name}`}))}
}))} onChange={(value) => setParticipants(value.map((x) => x.value))}
placeholder="Participants..." isMulti
defaultValue={participants.map((x) => ({ isSearchable
value: x, menuPortalTarget={document?.body}
label: `${users.find((y) => y.id === x)?.email} - ${ styles={{
users.find((y) => y.id === x)?.name menuPortal: (base) => ({...base, zIndex: 9999}),
}`, control: (styles) => ({
}))} ...styles,
options={users backgroundColor: "white",
.filter((x) => borderRadius: "999px",
user.type === "teacher" padding: "1rem 1.5rem",
? x.type === "student" zIndex: "40",
: x.type === "student" || x.type === "teacher" }),
) }}
.map((x) => ({ value: x.id, label: `${x.email} - ${x.name}` }))} />
onChange={(value) => setParticipants(value.map((x) => x.value))} {user.type !== "teacher" && (
isMulti <Button className="w-full max-w-[300px]" onClick={openFilePicker} isLoading={isLoading} variant="outline">
isSearchable {filesContent.length === 0 ? "Upload participants Excel file" : filesContent[0].name}
menuPortalTarget={document?.body} </Button>
styles={{ )}
menuPortal: (base) => ({ ...base, zIndex: 9999 }), </div>
control: (styles) => ({ </div>
...styles, </div>
backgroundColor: "white", <div className="mt-8 flex w-full items-center justify-end gap-8">
borderRadius: "999px", <Button variant="outline" color="red" className="w-full max-w-[200px]" isLoading={isLoading} onClick={onClose}>
padding: "1rem 1.5rem", Cancel
zIndex: "40", </Button>
}), <Button className="w-full max-w-[200px]" onClick={submit} isLoading={isLoading} disabled={!name}>
}} Submit
/> </Button>
{user.type !== "teacher" && ( </div>
<Button </div>
className="w-full max-w-[300px]" );
onClick={openFilePicker}
isLoading={isLoading}
variant="outline"
>
{filesContent.length === 0
? "Upload participants Excel file"
: filesContent[0].name}
</Button>
)}
</div>
</div>
</div>
<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}
>
Cancel
</Button>
<Button
className="w-full max-w-[200px]"
onClick={submit}
isLoading={isLoading}
disabled={!name}
>
Submit
</Button>
</div>
</div>
);
}; };
const filterTypes = ["corporate", "teacher", "mastercorporate"]; const filterTypes = ["corporate", "teacher", "mastercorporate"];
export default function GroupList({ user }: { user: User }) { export default function GroupList({user}: {user: User}) {
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [editingGroup, setEditingGroup] = useState<Group>(); const [editingGroup, setEditingGroup] = useState<Group>();
const [filterByUser, setFilterByUser] = useState(false); const [filterByUser, setFilterByUser] = useState(false);
const { users } = useUsers(); const {permissions} = usePermissions(user?.id || "");
const { groups, reload } = useGroups(
user && filterTypes.includes(user?.type) ? user.id : undefined,
user?.type
);
useEffect(() => { const {users} = useUsers();
if ( const {groups, reload} = useGroups({
user && admin: user && filterTypes.includes(user?.type) ? user.id : undefined,
["corporate", "teacher", "mastercorporate"].includes(user.type) userType: user?.type,
) { adminAdmins: user?.type === "teacher" ? user?.id : undefined,
setFilterByUser(true); });
}
}, [user]);
const deleteGroup = (group: Group) => { useEffect(() => {
if (!confirm(`Are you sure you want to delete "${group.name}"?`)) return; if (user && ["corporate", "teacher", "mastercorporate"].includes(user.type)) {
setFilterByUser(true);
}
}, [user]);
axios const deleteGroup = (group: Group) => {
.delete<{ ok: boolean }>(`/api/groups/${group.id}`) if (!confirm(`Are you sure you want to delete "${group.name}"?`)) return;
.then(() => toast.success(`Group "${group.name}" deleted successfully`))
.catch(() => toast.error("Something went wrong, please try again later!"))
.finally(reload);
};
const defaultColumns = [ axios
columnHelper.accessor("id", { .delete<{ok: boolean}>(`/api/groups/${group.id}`)
header: "ID", .then(() => toast.success(`Group "${group.name}" deleted successfully`))
cell: (info) => info.getValue(), .catch(() => toast.error("Something went wrong, please try again later!"))
}), .finally(reload);
columnHelper.accessor("name", { };
header: "Name",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("admin", {
header: "Admin",
cell: (info) => (
<div
className="tooltip"
data-tip={capitalize(
users.find((x) => x.id === info.getValue())?.type
)}
>
{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) =>
info
.getValue()
.map((x) => users.find((y) => y.id === x)?.name)
.join(", "),
}),
{
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({ const defaultColumns = [
data: groups, columnHelper.accessor("id", {
columns: defaultColumns, header: "ID",
getCoreRowModel: getCoreRowModel(), 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={capitalize(users.find((x) => x.id === info.getValue())?.type)}>
{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) =>
info
.getValue()
.map((x) => users.find((y) => y.id === x)?.name)
.join(", "),
}),
{
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 closeModal = () => { const table = useReactTable({
setIsCreating(false); data: groups,
setEditingGroup(undefined); columns: defaultColumns,
reload(); getCoreRowModel: getCoreRowModel(),
}; });
return ( const closeModal = () => {
<div className="h-full w-full rounded-xl"> setIsCreating(false);
<Modal setEditingGroup(undefined);
isOpen={isCreating || !!editingGroup} reload();
onClose={closeModal} };
title={editingGroup ? `Editing ${editingGroup.name}` : "New Group"}
>
<CreatePanel
group={editingGroup}
user={user}
onClose={closeModal}
users={
user?.type === "corporate" || user?.type === "teacher"
? users.filter(
(u) =>
groups
.filter((g) => g.admin === user.id)
.flatMap((g) => g.participants)
.includes(u.id) ||
groups.flatMap((g) => g.participants).includes(u.id)
)
: users
}
/>
</Modal>
<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( return (
user, <div className="h-full w-full rounded-xl">
["teacher", "corporate", "mastercorporate", "admin", "developer"], <Modal isOpen={isCreating || !!editingGroup} onClose={closeModal} title={editingGroup ? `Editing ${editingGroup.name}` : "New Group"}>
"createGroup" <CreatePanel
) && ( group={editingGroup}
<button user={user}
onClick={() => setIsCreating(true)} onClose={closeModal}
className="bg-mti-purple-light hover:bg-mti-purple w-full py-2 text-white transition duration-300 ease-in-out" users={
> user?.type === "corporate" || user?.type === "teacher"
New Group ? users.filter(
</button> (u) =>
)} groups
</div> .filter((g) => g.admin === user.id)
); .flatMap((g) => g.participants)
.includes(u.id) || groups.flatMap((g) => g.participants).includes(u.id),
)
: users
}
/>
</Modal>
<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,5 +1,5 @@
import { User } from "@/interfaces/user"; import {User} from "@/interfaces/user";
import { Tab } from "@headlessui/react"; import {Tab} from "@headlessui/react";
import clsx from "clsx"; import clsx from "clsx";
import CodeList from "./CodeList"; import CodeList from "./CodeList";
import DiscountList from "./DiscountList"; import DiscountList from "./DiscountList";
@@ -7,137 +7,118 @@ import ExamList from "./ExamList";
import GroupList from "./GroupList"; import GroupList from "./GroupList";
import PackageList from "./PackageList"; import PackageList from "./PackageList";
import UserList from "./UserList"; import UserList from "./UserList";
import { checkAccess } from "@/utils/permissions"; import {checkAccess} from "@/utils/permissions";
import usePermissions from "@/hooks/usePermissions";
export default function Lists({ user }: { user: User }) { export default function Lists({user}: {user: User}) {
return ( const {permissions} = usePermissions(user?.id || "");
<Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-mti-purple-ultralight/40 p-1"> return (
<Tab <Tab.Group>
className={({ selected }) => <Tab.List className="flex space-x-1 rounded-xl bg-mti-purple-ultralight/40 p-1">
clsx( <Tab
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light", className={({selected}) =>
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2", clsx(
"transition duration-300 ease-in-out", "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
selected "ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
? "bg-white shadow" "transition duration-300 ease-in-out",
: "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark" selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
) )
} }>
> User List
User List </Tab>
</Tab> {checkAccess(user, ["developer", "admin", "corporate", "mastercorporate", "teacher"]) && (
{checkAccess(user, ["developer"]) && ( <Tab
<Tab className={({selected}) =>
className={({ selected }) => clsx(
clsx( "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light", "ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2", "transition duration-300 ease-in-out",
"transition duration-300 ease-in-out", selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
selected )
? "bg-white shadow" }>
: "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark" Exam List
) </Tab>
} )}
> <Tab
Exam List className={({selected}) =>
</Tab> clsx(
)} "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
<Tab "ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
className={({ selected }) => "transition duration-300 ease-in-out",
clsx( selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light", )
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2", }>
"transition duration-300 ease-in-out", Group List
selected </Tab>
? "bg-white shadow" {checkAccess(user, ["developer", "admin", "corporate"]) && (
: "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark" <Tab
) className={({selected}) =>
} clsx(
> "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
Group List "ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
</Tab> "transition duration-300 ease-in-out",
{checkAccess(user, ["developer", "admin", "corporate"]) && ( selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
<Tab )
className={({ selected }) => }>
clsx( Code List
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light", </Tab>
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2", )}
"transition duration-300 ease-in-out", {checkAccess(user, ["developer", "admin"]) && (
selected <Tab
? "bg-white shadow" className={({selected}) =>
: "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark" clsx(
) "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
} "ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
> "transition duration-300 ease-in-out",
Code List selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
</Tab> )
)} }>
{checkAccess(user, ["developer", "admin"]) && ( Package List
<Tab </Tab>
className={({ selected }) => )}
clsx( {checkAccess(user, ["developer", "admin"]) && (
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light", <Tab
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2", className={({selected}) =>
"transition duration-300 ease-in-out", clsx(
selected "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
? "bg-white shadow" "ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
: "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark" "transition duration-300 ease-in-out",
) selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
} )
> }>
Package List Discount List
</Tab> </Tab>
)} )}
{checkAccess(user, ["developer", "admin"]) && ( </Tab.List>
<Tab <Tab.Panels className="mt-2">
className={({ selected }) => <Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
clsx( <UserList user={user} />
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light", </Tab.Panel>
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2", {checkAccess(user, ["developer"]) && (
"transition duration-300 ease-in-out", <Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
selected <ExamList user={user} />
? "bg-white shadow" </Tab.Panel>
: "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark" )}
) <Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
} <GroupList user={user} />
> </Tab.Panel>
Discount List {checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "viewCodes") && (
</Tab> <Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
)} <CodeList user={user} />
</Tab.List> </Tab.Panel>
<Tab.Panels className="mt-2"> )}
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide"> {checkAccess(user, ["developer", "admin"]) && (
<UserList user={user} /> <Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
</Tab.Panel> <PackageList user={user} />
{checkAccess(user, ["developer"]) && ( </Tab.Panel>
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide"> )}
<ExamList user={user} /> {checkAccess(user, ["developer", "admin"]) && (
</Tab.Panel> <Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
)} <DiscountList user={user} />
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide"> </Tab.Panel>
<GroupList user={user} /> )}
</Tab.Panel> </Tab.Panels>
{checkAccess( </Tab.Group>
user, );
["developer", "admin", "corporate", "mastercorporate"],
"viewCodes"
) && (
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<CodeList user={user} />
</Tab.Panel>
)}
{checkAccess(user, ["developer", "admin"]) && (
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<PackageList user={user} />
</Tab.Panel>
)}
{checkAccess(user, ["developer", "admin"]) && (
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<DiscountList user={user} />
</Tab.Panel>
)}
</Tab.Panels>
</Tab.Group>
);
} }

View File

@@ -230,7 +230,11 @@ const TaskTab = ({section, setSection}: {section: LevelSection; setSection: (sec
); );
}; };
const LevelGeneration = () => { interface Props {
id: string;
}
const LevelGeneration = ({ id } : Props) => {
const [generatedExam, setGeneratedExam] = useState<LevelExam>(); const [generatedExam, setGeneratedExam] = useState<LevelExam>();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [resultingExam, setResultingExam] = useState<LevelExam>(); const [resultingExam, setResultingExam] = useState<LevelExam>();
@@ -420,10 +424,16 @@ const LevelGeneration = () => {
return; return;
} }
if(!id) {
toast.error("Please insert a title before submitting");
return;
}
setIsLoading(true); setIsLoading(true);
const exam = { const exam = {
...generatedExam, ...generatedExam,
id,
parts: generatedExam.parts.map((p, i) => ({...p, exercises: parts[i].part!.exercises})), parts: generatedExam.parts.map((p, i) => ({...p, exercises: parts[i].part!.exercises})),
}; };

View File

@@ -228,7 +228,11 @@ interface ListeningPart {
| string; | string;
} }
const ListeningGeneration = () => { interface Props {
id: string;
}
const ListeningGeneration = ({ id } : Props) => {
const [part1, setPart1] = useState<ListeningPart>(); const [part1, setPart1] = useState<ListeningPart>();
const [part2, setPart2] = useState<ListeningPart>(); const [part2, setPart2] = useState<ListeningPart>();
const [part3, setPart3] = useState<ListeningPart>(); const [part3, setPart3] = useState<ListeningPart>();
@@ -258,11 +262,16 @@ const ListeningGeneration = () => {
console.log({parts}); console.log({parts});
if (parts.length === 0) return toast.error("Please generate at least one section!"); if (parts.length === 0) return toast.error("Please generate at least one section!");
if(!id) {
toast.error("Please insert a title before submitting");
return;
}
setIsLoading(true); setIsLoading(true);
axios axios
.post(`/api/exam/listening/generate/listening`, { .post(`/api/exam/listening/generate/listening`, {
id: generate({minLength: 4, maxLength: 8, min: 3, max: 5, join: " ", formatter: capitalize}), id,
parts, parts,
minTimer, minTimer,
difficulty, difficulty,

View File

@@ -258,7 +258,11 @@ const PartTab = ({
); );
}; };
const ReadingGeneration = () => { interface Props {
id: string;
}
const ReadingGeneration = ({ id } : Props) => {
const [part1, setPart1] = useState<ReadingPart>(); const [part1, setPart1] = useState<ReadingPart>();
const [part2, setPart2] = useState<ReadingPart>(); const [part2, setPart2] = useState<ReadingPart>();
const [part3, setPart3] = useState<ReadingPart>(); const [part3, setPart3] = useState<ReadingPart>();
@@ -300,13 +304,18 @@ const ReadingGeneration = () => {
return; return;
} }
if(!id) {
toast.error("Please insert a title before submitting");
return;
}
setIsLoading(true); setIsLoading(true);
const exam: ReadingExam = { const exam: ReadingExam = {
parts, parts,
isDiagnostic: false, isDiagnostic: false,
minTimer, minTimer,
module: "reading", module: "reading",
id: generate({minLength: 4, maxLength: 8, min: 3, max: 5, join: " ", formatter: capitalize}), id,
type: "academic", type: "academic",
variant: parts.length === 3 ? "full" : "partial", variant: parts.length === 3 ? "full" : "partial",
difficulty, difficulty,
@@ -328,7 +337,7 @@ const ReadingGeneration = () => {
}) })
.catch((error) => { .catch((error) => {
console.log(error); console.log(error);
toast.error("Something went wrong while generating, please try again later."); toast.error(error.response.data.error || "Something went wrong while generating, please try again later.");
}) })
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };

View File

@@ -221,7 +221,11 @@ interface SpeakingPart {
avatar?: (typeof AVATARS)[number]; avatar?: (typeof AVATARS)[number];
} }
const SpeakingGeneration = () => { interface Props {
id: string;
}
const SpeakingGeneration = ({ id } : Props) => {
const [part1, setPart1] = useState<SpeakingPart>(); const [part1, setPart1] = useState<SpeakingPart>();
const [part2, setPart2] = useState<SpeakingPart>(); const [part2, setPart2] = useState<SpeakingPart>();
const [part3, setPart3] = useState<SpeakingPart>(); const [part3, setPart3] = useState<SpeakingPart>();
@@ -243,6 +247,11 @@ const SpeakingGeneration = () => {
const submitExam = () => { const submitExam = () => {
if (!part1?.result && !part2?.result && !part3?.result) return toast.error("Please generate at least one task!"); if (!part1?.result && !part2?.result && !part3?.result) return toast.error("Please generate at least one task!");
if(!id) {
toast.error("Please insert a title before submitting");
return;
}
setIsLoading(true); setIsLoading(true);
const genders = [part1?.gender, part2?.gender, part3?.gender].filter((x) => !!x); const genders = [part1?.gender, part2?.gender, part3?.gender].filter((x) => !!x);
@@ -256,7 +265,7 @@ const SpeakingGeneration = () => {
})); }));
const exam: SpeakingExam = { const exam: SpeakingExam = {
id: generate({minLength: 4, maxLength: 8, min: 3, max: 5, join: " ", formatter: capitalize}), id,
isDiagnostic: false, isDiagnostic: false,
exercises: exercises as (SpeakingExercise | InteractiveSpeakingExercise)[], exercises: exercises as (SpeakingExercise | InteractiveSpeakingExercise)[],
minTimer, minTimer,

View File

@@ -75,7 +75,11 @@ const TaskTab = ({task, index, difficulty, setTask}: {task?: string; difficulty:
); );
}; };
const WritingGeneration = () => { interface Props {
id: string;
}
const WritingGeneration = ({ id } : Props) => {
const [task1, setTask1] = useState<string>(); const [task1, setTask1] = useState<string>();
const [task2, setTask2] = useState<string>(); const [task2, setTask2] = useState<string>();
const [minTimer, setMinTimer] = useState(60); const [minTimer, setMinTimer] = useState(60);
@@ -116,6 +120,11 @@ const WritingGeneration = () => {
return; return;
} }
if(!id) {
toast.error("Please insert a title before submitting");
return;
}
const exercise1 = task1 const exercise1 = task1
? ({ ? ({
id: v4(), id: v4(),
@@ -152,7 +161,7 @@ const WritingGeneration = () => {
minTimer, minTimer,
module: "writing", module: "writing",
exercises: [...(exercise1 ? [exercise1] : []), ...(exercise2 ? [exercise2] : [])], exercises: [...(exercise1 ? [exercise1] : []), ...(exercise2 ? [exercise2] : [])],
id: generate({minLength: 4, maxLength: 8, min: 3, max: 5, join: " ", formatter: capitalize}), id,
variant: exercise1 && exercise2 ? "full" : "partial", variant: exercise1 && exercise2 ? "full" : "partial",
difficulty, difficulty,
}; };

View File

@@ -31,7 +31,7 @@ export default function PaymentDue({user, hasExpired = false, reload}: Props) {
const {packages} = usePackages(); const {packages} = usePackages();
const {discounts} = useDiscounts(); const {discounts} = useDiscounts();
const {users} = useUsers(); const {users} = useUsers();
const {groups} = useGroups(); const {groups} = useGroups({});
const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user?.id}); const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user?.id});
useEffect(() => { useEffect(() => {

View File

@@ -0,0 +1,40 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, collection, getDocs, query, where, setDoc, doc, getDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {uuidv4} from "@firebase/util";
import {Module} from "@/interfaces";
import {getExams} from "@/utils/exams.be";
import {Exam, InstructorGender, Variant} from "@/interfaces/exam";
import {capitalize, flatten, uniqBy} from "lodash";
import {User} from "@/interfaces/user";
import moment from "moment";
import {sendEmail} from "@/email";
import {getAllAssignersByCorporate} from "@/utils/groups.be";
import {getAssignmentsByAssigners} from "@/utils/assignments.be";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
if (req.method === "GET") return GET(req, res);
res.status(404).json({ok: false});
}
async function GET(req: NextApiRequest, res: NextApiResponse) {
const {id} = req.query as {id: string};
const assigners = await getAllAssignersByCorporate(id);
const assignments = await getAssignmentsByAssigners([...assigners, id]);
res.status(200).json(assignments);
}

View File

@@ -1,174 +1,160 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import { app } from "@/firebase"; import {app} from "@/firebase";
import { import {getFirestore, setDoc, doc, query, collection, where, getDocs, getDoc, deleteDoc} from "firebase/firestore";
getFirestore, import {withIronSessionApiRoute} from "iron-session/next";
setDoc, import {sessionOptions} from "@/lib/session";
doc, import {Code, Group, Type} from "@/interfaces/user";
query, import {PERMISSIONS} from "@/constants/userPermissions";
collection, import {uuidv4} from "@firebase/util";
where, import {prepareMailer, prepareMailOptions} from "@/email";
getDocs,
getDoc,
deleteDoc,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { Code, Type } from "@/interfaces/user";
import { PERMISSIONS } from "@/constants/userPermissions";
import { uuidv4 } from "@firebase/util";
import { prepareMailer, prepareMailOptions } from "@/email";
const db = getFirestore(app); const db = getFirestore(app);
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 === "GET") return get(req, res); if (req.method === "GET") return get(req, res);
if (req.method === "POST") return post(req, res); if (req.method === "POST") return post(req, res);
if (req.method === "DELETE") return del(req, res); if (req.method === "DELETE") return del(req, res);
return res.status(404).json({ ok: false }); return res.status(404).json({ok: false});
} }
async function get(req: NextApiRequest, res: NextApiResponse) { async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res res.status(401).json({ok: false, reason: "You must be logged in to generate a code!"});
.status(401) return;
.json({ ok: false, reason: "You must be logged in to generate a code!" }); }
return;
}
const { creator } = req.query as { creator?: string }; const {creator} = req.query as {creator?: string};
const q = query( const q = query(collection(db, "codes"), where("creator", "==", creator || ""));
collection(db, "codes"), const snapshot = await getDocs(creator ? q : collection(db, "codes"));
where("creator", "==", creator || ""),
);
const snapshot = await getDocs(creator ? q : collection(db, "codes"));
res.status(200).json(snapshot.docs.map((doc) => doc.data())); res.status(200).json(snapshot.docs.map((doc) => doc.data()));
} }
async function post(req: NextApiRequest, res: NextApiResponse) { async function post(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res res.status(401).json({ok: false, reason: "You must be logged in to generate a code!"});
.status(401) return;
.json({ ok: false, reason: "You must be logged in to generate a code!" }); }
return;
}
const { type, codes, infos, expiryDate } = req.body as { const {type, codes, infos, expiryDate} = req.body as {
type: Type; type: Type;
codes: string[]; codes: string[];
infos?: { email: string; name: string; passport_id?: string }[]; infos?: {email: string; name: string; passport_id?: string}[];
expiryDate: null | Date; expiryDate: null | Date;
}; };
const permission = PERMISSIONS.generateCode[type]; const permission = PERMISSIONS.generateCode[type];
if (!permission.includes(req.session.user.type)) { if (!permission.includes(req.session.user.type)) {
res.status(403).json({ res.status(403).json({
ok: false, ok: false,
reason: reason: "Your account type does not have permissions to generate a code for that type of user!",
"Your account type does not have permissions to generate a code for that type of user!", });
}); return;
return; }
}
const codesGeneratedByUserSnapshot = await getDocs( const codesGeneratedByUserSnapshot = await getDocs(query(collection(db, "codes"), where("creator", "==", req.session.user.id)));
query(collection(db, "codes"), where("creator", "==", req.session.user.id)), const creatorGroupsSnapshot = await getDocs(query(collection(db, "groups"), where("admin", "==", req.session.user.id)));
);
const userCodes = codesGeneratedByUserSnapshot.docs.map((x) => ({
...x.data(),
}));
if (req.session.user.type === "corporate") { const creatorGroups = (
const totalCodes = codesGeneratedByUserSnapshot.docs.length + codes.length; creatorGroupsSnapshot.docs.map((x) => ({
const allowedCodes = ...x.data(),
req.session.user.corporateInformation?.companyInformation.userAmount || 0; })) as Group[]
).filter((x) => x.name === "Students" || x.name === "Teachers" || x.name === "Corporate");
if (totalCodes > allowedCodes) { const usersInGroups = creatorGroups.flatMap((x) => x.participants);
res.status(403).json({ const userCodes = codesGeneratedByUserSnapshot.docs.map((x) => ({
ok: false, ...x.data(),
reason: `You have or would have exceeded your amount of allowed codes, you currently are allowed to generate ${ })) as Code[];
allowedCodes - codesGeneratedByUserSnapshot.docs.length
} codes.`,
});
return;
}
}
const codePromises = codes.map(async (code, index) => { if (req.session.user.type === "corporate") {
const codeRef = doc(db, "codes", code); const totalCodes = userCodes.filter((x) => !x.userId || !usersInGroups.includes(x.userId)).length + usersInGroups.length + codes.length;
let codeInformation = { const allowedCodes = req.session.user.corporateInformation?.companyInformation.userAmount || 0;
type,
code,
creator: req.session.user!.id,
creationDate: new Date().toISOString(),
expiryDate,
};
if (infos && infos.length > index) { if (totalCodes > allowedCodes) {
const { email, name, passport_id } = infos[index]; res.status(403).json({
const previousCode = userCodes.find((x) => x.email === email) as Code; ok: false,
reason: `You have or would have exceeded your amount of allowed codes, you currently are allowed to generate ${
allowedCodes - codesGeneratedByUserSnapshot.docs.length
} codes.`,
});
return;
}
}
const transport = prepareMailer(); const codePromises = codes.map(async (code, index) => {
const mailOptions = prepareMailOptions( const codeRef = doc(db, "codes", code);
{ let codeInformation = {
type, type,
code: previousCode ? previousCode.code : code, code,
environment: process.env.ENVIRONMENT, creator: req.session.user!.id,
}, creationDate: new Date().toISOString(),
[email.toLowerCase().trim()], expiryDate,
"EnCoach Registration", };
"main",
);
try { if (infos && infos.length > index) {
await transport.sendMail(mailOptions); const {email, name, passport_id} = infos[index];
const previousCode = userCodes.find((x) => x.email === email) as Code;
if (!previousCode) { const transport = prepareMailer();
await setDoc( const mailOptions = prepareMailOptions(
codeRef, {
{ type,
...codeInformation, code: previousCode ? previousCode.code : code,
email: email.trim().toLowerCase(), environment: process.env.ENVIRONMENT,
name: name.trim(), },
...(passport_id ? { passport_id: passport_id.trim() } : {}), [email.toLowerCase().trim()],
}, "EnCoach Registration",
{ merge: true }, "main",
); );
}
return true; try {
} catch (e) { await transport.sendMail(mailOptions);
return false;
}
} else {
await setDoc(codeRef, codeInformation);
}
});
Promise.all(codePromises).then((results) => { if (!previousCode) {
res.status(200).json({ ok: true, valid: results.filter((x) => x).length }); await setDoc(
}); codeRef,
{
...codeInformation,
email: email.trim().toLowerCase(),
name: name.trim(),
...(passport_id ? {passport_id: passport_id.trim()} : {}),
},
{merge: true},
);
}
return true;
} catch (e) {
return false;
}
} else {
await setDoc(codeRef, codeInformation);
}
});
Promise.all(codePromises).then((results) => {
res.status(200).json({ok: true, valid: results.filter((x) => x).length});
});
} }
async function del(req: NextApiRequest, res: NextApiResponse) { async function del(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res res.status(401).json({ok: false, reason: "You must be logged in to generate a code!"});
.status(401) return;
.json({ ok: false, reason: "You must be logged in to generate a code!" }); }
return;
}
const codes = req.query.code as string[]; const codes = req.query.code as string[];
for (const code of codes) { for (const code of codes) {
const snapshot = await getDoc(doc(db, "codes", code as string)); const snapshot = await getDoc(doc(db, "codes", code as string));
if (!snapshot.exists()) continue; if (!snapshot.exists()) continue;
await deleteDoc(snapshot.ref); await deleteDoc(snapshot.ref);
} }
res.status(200).json({ codes }); res.status(200).json({codes});
} }

View File

@@ -1,54 +1,89 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import {app} from "@/firebase"; import { app } from "@/firebase";
import {getFirestore, setDoc, doc} from "firebase/firestore"; import {
import {withIronSessionApiRoute} from "iron-session/next"; getFirestore,
import {sessionOptions} from "@/lib/session"; setDoc,
import {Exam, InstructorGender, Variant} from "@/interfaces/exam"; doc,
import {getExams} from "@/utils/exams.be"; runTransaction,
import {Module} from "@/interfaces"; collection,
query,
where,
getDocs,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { Exam, InstructorGender, Variant } from "@/interfaces/exam";
import { getExams } from "@/utils/exams.be";
import { Module } from "@/interfaces";
const db = getFirestore(app); const db = getFirestore(app);
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 === "GET") return await GET(req, res); if (req.method === "GET") return await GET(req, res);
if (req.method === "POST") return await POST(req, res); if (req.method === "POST") return await POST(req, res);
res.status(404).json({ok: false}); res.status(404).json({ ok: false });
} }
async function GET(req: NextApiRequest, res: NextApiResponse) { async function GET(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res.status(401).json({ok: false}); res.status(401).json({ ok: false });
return; return;
} }
const {module, avoidRepeated, variant, instructorGender} = req.query as { const { module, avoidRepeated, variant, instructorGender } = req.query as {
module: Module; module: Module;
avoidRepeated: string; avoidRepeated: string;
variant?: Variant; variant?: Variant;
instructorGender?: InstructorGender; instructorGender?: InstructorGender;
}; };
const exams: Exam[] = await getExams(db, module, avoidRepeated, req.session.user.id, variant, instructorGender); const exams: Exam[] = await getExams(
res.status(200).json(exams); db,
module,
avoidRepeated,
req.session.user.id,
variant,
instructorGender
);
res.status(200).json(exams);
} }
async function POST(req: NextApiRequest, res: NextApiResponse) { async function POST(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res.status(401).json({ok: false}); res.status(401).json({ ok: false });
return; return;
} }
if (req.session.user.type !== "developer") { if (req.session.user.type !== "developer") {
res.status(403).json({ok: false}); res.status(403).json({ ok: false });
return; return;
} }
const {module} = req.query as {module: string}; const { module } = req.query as { module: string };
const exam = {...req.body, module: module, createdBy: req.session.user.id, createdAt: new Date().toISOString()}; try {
await setDoc(doc(db, module, req.body.id), exam); const exam = {
...req.body,
module: module,
createdBy: req.session.user.id,
createdAt: new Date().toISOString(),
};
await runTransaction(db, async (transaction) => {
const docRef = doc(db, module, req.body.id);
const docSnap = await transaction.get(docRef);
res.status(200).json(exam); if (docSnap.exists()) {
throw new Error("Name already exists");
}
const newDocRef = doc(db, module, req.body.id);
transaction.set(newDocRef, exam);
});
res.status(200).json(exam);
} catch (error) {
console.error("Transaction failed: ", error);
res.status(500).json({ ok: false, error: (error as any).message });
}
} }

View File

@@ -1,20 +1,8 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import { app } from "@/firebase"; import {app} from "@/firebase";
import { import {getFirestore, setDoc, doc, query, collection, where, getDocs, getDoc, deleteDoc, limit, updateDoc} from "firebase/firestore";
getFirestore, import {withIronSessionApiRoute} from "iron-session/next";
setDoc, import {sessionOptions} from "@/lib/session";
doc,
query,
collection,
where,
getDocs,
getDoc,
deleteDoc,
limit,
updateDoc,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import {v4} from "uuid"; import {v4} from "uuid";
import {Group} from "@/interfaces/user"; import {Group} from "@/interfaces/user";
import {createUserWithEmailAndPassword, getAuth} from "firebase/auth"; import {createUserWithEmailAndPassword, getAuth} from "firebase/auth";
@@ -39,114 +27,108 @@ const db = getFirestore(app);
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 return res.status(401).json({ok: false, reason: "You must be logged in to make user!"});
.status(401) }
.json({ ok: false, reason: "You must be logged in to make user!" }); const {email, passport_id, type, groupName, expiryDate} = req.body as {
} email: string;
const { email, passport_id, type, groupName } = req.body as { passport_id: string;
email: string; type: string;
passport_id: string; groupName: string;
type: string, expiryDate: null | Date;
groupName: string };
}; // cleaning data
// cleaning data delete req.body.passport_id;
delete req.body.passport_id; delete req.body.groupName;
delete req.body.groupName; delete req.body.expiryDate;
await createUserWithEmailAndPassword(auth, email.toLowerCase(), passport_id) await createUserWithEmailAndPassword(auth, email.toLowerCase(), passport_id)
.then(async (userCredentials) => { .then(async (userCredentials) => {
const userId = userCredentials.user.uid; const userId = userCredentials.user.uid;
const user = { const user = {
...req.body, ...req.body,
bio: "", bio: "",
type: type, type: type,
focus: "academic", focus: "academic",
status: "paymentDue", status: "active",
desiredLevels: DEFAULT_DESIRED_LEVELS, desiredLevels: DEFAULT_DESIRED_LEVELS,
levels: DEFAULT_LEVELS, levels: DEFAULT_LEVELS,
isFirstLogin: false, isFirstLogin: false,
isVerified: true isVerified: true,
}; registrationDate: new Date(),
await setDoc(doc(db, "users", userId), user); subscriptionExpirationDate: expiryDate || null,
if (type === "corporate") { };
const defaultTeachersGroup: Group = { await setDoc(doc(db, "users", userId), user);
admin: userId, if (type === "corporate") {
id: v4(), const defaultTeachersGroup: Group = {
name: "Teachers", admin: userId,
participants: [], id: v4(),
disableEditing: true, name: "Teachers",
}; participants: [],
disableEditing: true,
};
const defaultStudentsGroup: Group = { const defaultStudentsGroup: Group = {
admin: userId, admin: userId,
id: v4(), id: v4(),
name: "Students", name: "Students",
participants: [], participants: [],
disableEditing: true, disableEditing: true,
}; };
const defaultCorporateGroup: Group = { const defaultCorporateGroup: Group = {
admin: userId, admin: userId,
id: v4(), id: v4(),
name: "Corporate", name: "Corporate",
participants: [], participants: [],
disableEditing: true, disableEditing: true,
}; };
await setDoc(doc(db, "groups", defaultTeachersGroup.id), defaultTeachersGroup);
await setDoc(doc(db, "groups", defaultStudentsGroup.id), defaultStudentsGroup);
await setDoc(doc(db, "groups", defaultCorporateGroup.id), defaultCorporateGroup);
}
await setDoc(doc(db, "groups", defaultTeachersGroup.id), defaultTeachersGroup); if (typeof groupName === "string" && groupName.trim().length > 0) {
await setDoc(doc(db, "groups", defaultStudentsGroup.id), defaultStudentsGroup); const q = query(collection(db, "groups"), where("admin", "==", maker.id), where("name", "==", groupName.trim()), limit(1));
await setDoc(doc(db, "groups", defaultCorporateGroup.id), defaultCorporateGroup); const snapshot = await getDocs(q);
}
if(typeof groupName === 'string' && groupName.trim().length > 0){ if (snapshot.empty) {
const values = {
id: v4(),
admin: maker.id,
name: groupName.trim(),
participants: [userId],
disableEditing: false,
};
const q = query(collection(db, "groups"), where("admin", "==", maker.id), where("name", "==", groupName.trim()), limit(1)) await setDoc(doc(db, "groups", values.id), values);
const snapshot = await getDocs(q) } else {
const doc = snapshot.docs[0];
const participants: string[] = doc.get("participants");
if(snapshot.empty){ if (!participants.includes(userId)) {
const values = { updateDoc(doc.ref, {
id: v4(), participants: [...participants, userId],
admin: maker.id, });
name: groupName.trim(), }
participants: [userId], }
disableEditing: false, }
}
await setDoc(doc(db, "groups", values.id) , values)
}else{
const doc = snapshot.docs[0]
const participants : string[] = doc.get('participants');
if(!participants.includes(userId)){
updateDoc(doc.ref, {
participants: [...participants, userId]
})
}
}
}
})
.catch((error) => {
console.log(error);
return res.status(401).json({error});
});
return res.status(200).json({ ok: true });
console.log(`Returning - ${email}`);
return res.status(200).json({ok: true});
})
.catch((error) => {
console.log(`Failing - ${email}`);
console.log(error);
return res.status(401).json({error});
});
} }

View File

@@ -1,30 +1,46 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import { app } from "@/firebase"; import {app} from "@/firebase";
import { getFirestore, doc, setDoc } from "firebase/firestore"; import {getFirestore, doc, setDoc, getDoc} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {getPermissionDoc} from "@/utils/permissions.be";
const db = getFirestore(app); const db = getFirestore(app);
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 === "PATCH") return patch(req, res); if (req.method === "PATCH") return patch(req, res);
if (req.method === "GET") return get(req, res);
}
async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
const {id} = req.query as {id: string};
const permissionDoc = await getPermissionDoc(id);
return res.status(200).json({allowed: permissionDoc.users.includes(req.session.user.id)});
} }
async function patch(req: NextApiRequest, res: NextApiResponse) { async function patch(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res.status(401).json({ ok: false }); res.status(401).json({ok: false});
return; return;
} }
const { id } = req.query as { id: string };
const { users } = req.body; const {id} = req.query as {id: string};
try { const {users} = req.body;
await setDoc(doc(db, "permissions", id), { users }, { merge: true });
return res.status(200).json({ ok: true }); try {
} catch (err) { await setDoc(doc(db, "permissions", id), {users}, {merge: true});
console.error(err); return res.status(200).json({ok: true});
return res.status(500).json({ ok: false }); } catch (err) {
} console.error(err);
return res.status(500).json({ok: false});
}
} }

View File

@@ -53,7 +53,10 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
// based on the admin of each group, verify if it exists and it's of type corporate // based on the admin of each group, verify if it exists and it's of type corporate
const groupsAdmins = [...new Set(groups.map((g) => g.admin).filter((id) => id))]; const groupsAdmins = [...new Set(groups.map((g) => g.admin).filter((id) => id))];
const adminsSnapshot = await getDocs(query(collection(db, "users"), where("id", "in", groupsAdmins), where("type", "==", "corporate"))); const adminsSnapshot =
groupsAdmins.length > 0
? await getDocs(query(collection(db, "users"), where("id", "in", groupsAdmins), where("type", "==", "corporate")))
: {docs: []};
const admins = adminsSnapshot.docs.map((doc) => doc.data()); const admins = adminsSnapshot.docs.map((doc) => doc.data());
const docsWithAdmins = docs.map((d) => { const docsWithAdmins = docs.map((d) => {

View File

@@ -1,22 +1,12 @@
import { PERMISSIONS } from "@/constants/userPermissions"; import {PERMISSIONS} from "@/constants/userPermissions";
import { app, adminApp } from "@/firebase"; import {app, adminApp} from "@/firebase";
import { Group, User } from "@/interfaces/user"; import {Group, User} from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import { import {collection, deleteDoc, doc, getDoc, getDocs, getFirestore, query, setDoc, where} from "firebase/firestore";
collection, import {getAuth} from "firebase-admin/auth";
deleteDoc, import {withIronSessionApiRoute} from "iron-session/next";
doc, import {NextApiRequest, NextApiResponse} from "next";
getDoc, import {getPermissions, getPermissionDocs} from "@/utils/permissions.be";
getDocs,
getFirestore,
query,
setDoc,
where,
} from "firebase/firestore";
import { getAuth } from "firebase-admin/auth";
import { withIronSessionApiRoute } from "iron-session/next";
import { NextApiRequest, NextApiResponse } from "next";
import { getPermissions, getPermissionDocs } from "@/utils/permissions.be";
const db = getFirestore(app); const db = getFirestore(app);
const auth = getAuth(adminApp); const auth = getAuth(adminApp);
@@ -24,132 +14,110 @@ const auth = getAuth(adminApp);
export default withIronSessionApiRoute(user, sessionOptions); export default withIronSessionApiRoute(user, sessionOptions);
async function user(req: NextApiRequest, res: NextApiResponse) { async function user(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return get(req, res); if (req.method === "GET") return get(req, res);
if (req.method === "DELETE") return del(req, res); if (req.method === "DELETE") return del(req, res);
res.status(404).json(undefined); res.status(404).json(undefined);
} }
async function del(req: NextApiRequest, res: NextApiResponse) { async function del(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res.status(401).json({ ok: false }); res.status(401).json({ok: false});
return; return;
} }
const { id } = req.query as { id: string }; const {id} = req.query as {id: string};
const docUser = await getDoc(doc(db, "users", req.session.user.id)); const docUser = await getDoc(doc(db, "users", req.session.user.id));
if (!docUser.exists()) { if (!docUser.exists()) {
res.status(401).json({ ok: false }); res.status(401).json({ok: false});
return; return;
} }
const user = docUser.data() as User; const user = docUser.data() as User;
const docTargetUser = await getDoc(doc(db, "users", id)); const docTargetUser = await getDoc(doc(db, "users", id));
if (!docTargetUser.exists()) { if (!docTargetUser.exists()) {
res.status(404).json({ ok: false }); res.status(404).json({ok: false});
return; return;
} }
const targetUser = { ...docTargetUser.data(), id: docTargetUser.id } as User; const targetUser = {...docTargetUser.data(), id: docTargetUser.id} as User;
if ( if (user.type === "corporate" && (targetUser.type === "student" || targetUser.type === "teacher")) {
user.type === "corporate" && res.json({ok: true});
(targetUser.type === "student" || targetUser.type === "teacher")
) {
res.json({ ok: true });
const userParticipantGroup = await getDocs( const userParticipantGroup = await getDocs(query(collection(db, "groups"), where("participants", "array-contains", id)));
query( await Promise.all([
collection(db, "groups"), ...userParticipantGroup.docs
where("participants", "array-contains", id) .filter((x) => (x.data() as Group).admin === user.id)
) .map(
); async (x) =>
await Promise.all([ await setDoc(
...userParticipantGroup.docs x.ref,
.filter((x) => (x.data() as Group).admin === user.id) {
.map( participants: x.data().participants.filter((y: string) => y !== id),
async (x) => },
await setDoc( {merge: true},
x.ref, ),
{ ),
participants: x ]);
.data()
.participants.filter((y: string) => y !== id),
},
{ merge: true }
)
),
]);
return; return;
} }
const permission = PERMISSIONS.deleteUser[targetUser.type]; const permission = PERMISSIONS.deleteUser[targetUser.type];
if (!permission.list.includes(user.type)) { if (!permission.list.includes(user.type)) {
res.status(403).json({ ok: false }); res.status(403).json({ok: false});
return; return;
} }
res.json({ ok: true }); res.json({ok: true});
await auth.deleteUser(id); await auth.deleteUser(id);
await deleteDoc(doc(db, "users", id)); await deleteDoc(doc(db, "users", id));
const userCodeDocs = await getDocs( const userCodeDocs = await getDocs(query(collection(db, "codes"), where("userId", "==", id)));
query(collection(db, "codes"), where("userId", "==", id)) const userParticipantGroup = await getDocs(query(collection(db, "groups"), where("participants", "array-contains", id)));
); const userGroupAdminDocs = await getDocs(query(collection(db, "groups"), where("admin", "==", id)));
const userParticipantGroup = await getDocs( const userStatsDocs = await getDocs(query(collection(db, "stats"), where("user", "==", id)));
query(collection(db, "groups"), where("participants", "array-contains", id))
);
const userGroupAdminDocs = await getDocs(
query(collection(db, "groups"), where("admin", "==", id))
);
const userStatsDocs = await getDocs(
query(collection(db, "stats"), where("user", "==", id))
);
await Promise.all([ await Promise.all([
...userCodeDocs.docs.map(async (x) => await deleteDoc(x.ref)), ...userCodeDocs.docs.map(async (x) => await deleteDoc(x.ref)),
...userGroupAdminDocs.docs.map(async (x) => await deleteDoc(x.ref)), ...userGroupAdminDocs.docs.map(async (x) => await deleteDoc(x.ref)),
...userStatsDocs.docs.map(async (x) => await deleteDoc(x.ref)), ...userStatsDocs.docs.map(async (x) => await deleteDoc(x.ref)),
...userParticipantGroup.docs.map( ...userParticipantGroup.docs.map(
async (x) => async (x) =>
await setDoc( await setDoc(
x.ref, x.ref,
{ {
participants: x.data().participants.filter((y: string) => y !== id), participants: x.data().participants.filter((y: string) => y !== id),
}, },
{ merge: true } {merge: true},
) ),
), ),
]); ]);
} }
async function get(req: NextApiRequest, res: NextApiResponse) { async function get(req: NextApiRequest, res: NextApiResponse) {
if (req.session.user) { if (req.session.user) {
const docUser = await getDoc(doc(db, "users", req.session.user.id)); const docUser = await getDoc(doc(db, "users", req.session.user.id));
if (!docUser.exists()) { if (!docUser.exists()) {
res.status(401).json(undefined); res.status(401).json(undefined);
return; return;
} }
const user = docUser.data() as User; const user = docUser.data() as User;
await setDoc(docUser.ref, {lastLogin: new Date().toISOString()}, {merge: true});
const permissionDocs = await getPermissionDocs(); req.session.user = {
...user,
id: req.session.user.id,
lastLogin: new Date(),
};
await req.session.save();
const userWithPermissions = { res.json({...user, id: req.session.user.id});
...user, } else {
permissions: getPermissions(req.session.user.id, permissionDocs), res.status(401).json(undefined);
}; }
req.session.user = {
...userWithPermissions,
id: req.session.user.id,
};
await req.session.save();
res.json({ ...userWithPermissions, id: req.session.user.id });
} else {
res.status(401).json(undefined);
}
} }

View File

@@ -57,6 +57,7 @@ export default function Generation() {
const { user } = useUser({ redirectTo: "/login" }); const { user } = useUser({ redirectTo: "/login" });
const [title, setTitle] = useState<string>("");
return ( return (
<> <>
<Head> <Head>
@@ -73,6 +74,17 @@ export default function Generation() {
<Layout user={user} className="gap-6"> <Layout user={user} className="gap-6">
<h1 className="text-2xl font-semibold">Exam Generation</h1> <h1 className="text-2xl font-semibold">Exam Generation</h1>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Input
type="text"
placeholder="Insert a title here"
name="title"
label="Title"
onChange={setTitle}
roundness="xl"
defaultValue={title}
required
/>
<label className="font-normal text-base text-mti-gray-dim"> <label className="font-normal text-base text-mti-gray-dim">
Module Module
</label> </label>
@@ -117,11 +129,11 @@ export default function Generation() {
))} ))}
</RadioGroup> </RadioGroup>
</div> </div>
{module === "reading" && <ReadingGeneration />} {module === "reading" && <ReadingGeneration id={title} />}
{module === "listening" && <ListeningGeneration />} {module === "listening" && <ListeningGeneration id={title} />}
{module === "writing" && <WritingGeneration />} {module === "writing" && <WritingGeneration id={title} />}
{module === "speaking" && <SpeakingGeneration />} {module === "speaking" && <SpeakingGeneration id={title} />}
{module === "level" && <LevelGeneration />} {module === "level" && <LevelGeneration id={title} />}
</Layout> </Layout>
)} )}
</> </>

119
src/pages/groups.tsx Normal file
View File

@@ -0,0 +1,119 @@
/* eslint-disable @next/next/no-img-element */
import Head from "next/head";
import Navbar from "@/components/Navbar";
import {BsFileEarmarkText, BsPencil, BsStar, BsBook, BsHeadphones, BsPen, BsMegaphone} from "react-icons/bs";
import {withIronSessionSsr} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {useEffect, useState} from "react";
import useStats from "@/hooks/useStats";
import {averageScore, groupBySession, totalExams} from "@/utils/stats";
import useUser from "@/hooks/useUser";
import Diagnostic from "@/components/Diagnostic";
import {ToastContainer} from "react-toastify";
import {capitalize} from "lodash";
import {Module} from "@/interfaces";
import ProgressBar from "@/components/Low/ProgressBar";
import Layout from "@/components/High/Layout";
import {calculateAverageLevel} from "@/utils/score";
import axios from "axios";
import DemographicInformationInput from "@/components/DemographicInformationInput";
import moment from "moment";
import Link from "next/link";
import {MODULE_ARRAY} from "@/utils/moduleUtils";
import ProfileSummary from "@/components/ProfileSummary";
import StudentDashboard from "@/dashboards/Student";
import AdminDashboard from "@/dashboards/Admin";
import CorporateDashboard from "@/dashboards/Corporate";
import TeacherDashboard from "@/dashboards/Teacher";
import AgentDashboard from "@/dashboards/Agent";
import MasterCorporateDashboard from "@/dashboards/MasterCorporate";
import PaymentDue from "./(status)/PaymentDue";
import {useRouter} from "next/router";
import {PayPalScriptProvider} from "@paypal/react-paypal-js";
import {CorporateUser, MasterCorporateUser, Type, User, userTypes} from "@/interfaces/user";
import Select from "react-select";
import {USER_TYPE_LABELS} from "@/resources/user";
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
import {shouldRedirectHome} from "@/utils/navigation.disabled";
import useGroups from "@/hooks/useGroups";
import useUsers from "@/hooks/useUsers";
import {getUserName} from "@/utils/users";
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user;
if (!user || !user.isVerified) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
if (shouldRedirectHome(user)) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
return {
props: {user: req.session.user},
};
}, sessionOptions);
interface Props {
user: User;
envVariables: {[key: string]: string};
}
export default function Home(props: Props) {
const {user, mutateUser} = useUser({redirectTo: "/login"});
const {groups} = useGroups({});
const {users} = useUsers();
const router = useRouter();
useEffect(() => {
console.log(groups);
}, [groups]);
return (
<>
<Head>
<title>EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
{user && (
<Layout user={user}>
<div className="w-full h-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{groups
.filter((x) => x.participants.includes(user.id))
.map((group) => (
<div key={group.id} className="p-4 border rounded-xl flex flex-col gap-2">
<span>
<b>Group: </b>
{group.name}
</span>
<span>
<b>Admin: </b>
{getUserName(users.find((x) => x.id === group.admin))}
</span>
<b>Participants: </b>
<span>{group.participants.map((x) => getUserName(users.find((u) => u.id === x))).join(", ")}</span>
</div>
))}
</div>
</Layout>
)}
</>
);
}

View File

@@ -390,7 +390,7 @@ interface PaypalPaymentWithUserData extends PaypalPayment {
email: string; email: string;
} }
const paypalFilterRows = [["email"], ["name"]]; const paypalFilterRows = [["email"], ["name"], ["orderId"], ["value"]];
export default function PaymentRecord() { export default function PaymentRecord() {
const [selectedCorporateUser, setSelectedCorporateUser] = useState<User>(); const [selectedCorporateUser, setSelectedCorporateUser] = useState<User>();
const [selectedAgentUser, setSelectedAgentUser] = useState<User>(); const [selectedAgentUser, setSelectedAgentUser] = useState<User>();
@@ -414,6 +414,14 @@ export default function PaymentRecord() {
const [endDate, setEndDate] = useState<Date | null>( const [endDate, setEndDate] = useState<Date | null>(
moment().endOf("day").toDate() moment().endOf("day").toDate()
); );
const [startDatePaymob, setStartDatePaymob] = useState<Date | null>(
moment("01/01/2023").toDate()
);
const [endDatePaymob, setEndDatePaymob] = useState<Date | null>(
moment().endOf("day").toDate()
);
const [paid, setPaid] = useState<Boolean | null>(IS_PAID_OPTIONS[0].value); const [paid, setPaid] = useState<Boolean | null>(IS_PAID_OPTIONS[0].value);
const [commissionTransfer, setCommissionTransfer] = useState<Boolean | null>( const [commissionTransfer, setCommissionTransfer] = useState<Boolean | null>(
IS_FILE_SUBMITTED_OPTIONS[0].value IS_FILE_SUBMITTED_OPTIONS[0].value
@@ -866,11 +874,16 @@ export default function PaymentRecord() {
const updatedPaypalPayments = useMemo( const updatedPaypalPayments = useMemo(
() => () =>
paypalPayments.map((p) => { paypalPayments
const user = users.find((x) => x.id === p.userId) as User; .filter((p) => {
return { ...p, name: user?.name, email: user?.email }; const date = moment(p.createdAt);
}), return date.isAfter(startDatePaymob) && date.isBefore(endDatePaymob);
[paypalPayments, users] })
.map((p) => {
const user = users.find((x) => x.id === p.userId) as User;
return { ...p, name: user?.name, email: user?.email };
}),
[paypalPayments, users, startDatePaymob, endDatePaymob]
); );
const paypalColumns = [ const paypalColumns = [
@@ -1469,6 +1482,44 @@ export default function PaymentRecord() {
{renderTable(table as Table<Payment>)} {renderTable(table as Table<Payment>)}
</Tab.Panel> </Tab.Panel>
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide flex flex-col gap-8"> <Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide flex flex-col gap-8">
<div
className={clsx(
"grid grid-cols-1 md:grid-cols-2 gap-8 w-full",
user.type !== "corporate" && "lg:grid-cols-3"
)}
>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">
Date
</label>
<ReactDatePicker
dateFormat="dd/MM/yyyy"
className="px-4 py-6 w-full text-sm text-center font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed rounded-full border border-mti-gray-platinum focus:outline-none"
selected={startDatePaymob}
startDate={startDatePaymob}
endDate={endDatePaymob}
selectsRange
showMonthDropdown
filterDate={(date: Date) =>
moment(date).isSameOrBefore(moment(new Date()))
}
onChange={([initialDate, finalDate]: [Date, Date]) => {
setStartDatePaymob(
initialDate ?? moment("01/01/2023").toDate()
);
if (finalDate) {
// basicly selecting a final day works as if I'm selecting the first
// minute of that day. this way it covers the whole day
setEndDatePaymob(
moment(finalDate).endOf("day").toDate()
);
return;
}
setEndDatePaymob(null);
}}
/>
</div>
</div>
{renderSearch()} {renderSearch()}
{renderTable(paypalTable as Table<PaypalPaymentWithUserData>)} {renderTable(paypalTable as Table<PaypalPaymentWithUserData>)}
</Tab.Panel> </Tab.Panel>

View File

@@ -55,9 +55,7 @@ interface Props {
} }
export default function Page(props: Props) { export default function Page(props: Props) {
const { permissions, user } = props; const { permissions, user } = props;
return ( return (
<> <>
<Head> <Head>

File diff suppressed because it is too large Load Diff

View File

@@ -1,30 +1,29 @@
/* 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 { Stat, User } from "@/interfaces/user"; import {Stat, User} from "@/interfaces/user";
import { useEffect, useRef, useState } from "react"; import {useEffect, useRef, useState} from "react";
import useStats from "@/hooks/useStats"; import useStats from "@/hooks/useStats";
import { groupByDate } from "@/utils/stats"; import {groupByDate} from "@/utils/stats";
import moment from "moment"; import moment from "moment";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import { ToastContainer } from "react-toastify"; import {ToastContainer} from "react-toastify";
import { useRouter } from "next/router"; import {useRouter} from "next/router";
import Layout from "@/components/High/Layout"; import Layout from "@/components/High/Layout";
import clsx from "clsx"; import clsx from "clsx";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import {shouldRedirectHome} from "@/utils/navigation.disabled";
import useAssignments from "@/hooks/useAssignments"; import useAssignments from "@/hooks/useAssignments";
import { uuidv4 } from "@firebase/util"; import {uuidv4} from "@firebase/util";
import { usePDFDownload } from "@/hooks/usePDFDownload"; import {usePDFDownload} from "@/hooks/usePDFDownload";
import useRecordStore from "@/stores/recordStore"; import useRecordStore from "@/stores/recordStore";
import useTrainingContentStore from "@/stores/trainingContentStore"; import useTrainingContentStore from "@/stores/trainingContentStore";
import StatsGridItem from "@/components/StatGridItem"; import StatsGridItem from "@/components/StatGridItem";
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
export const getServerSideProps = withIronSessionSsr(({ req, res }) => {
const user = req.session.user; const user = req.session.user;
if (!user || !user.isVerified) { if (!user || !user.isVerified) {
@@ -46,7 +45,7 @@ export const getServerSideProps = withIronSessionSsr(({ req, res }) => {
} }
return { return {
props: { user: req.session.user }, props: {user: req.session.user},
}; };
}, sessionOptions); }, sessionOptions);
@@ -55,16 +54,21 @@ const defaultSelectableCorporate = {
label: "All", label: "All",
}; };
export default function History({ user }: { user: User }) { export default function History({user}: {user: User}) {
const [statsUserId, setStatsUserId, training, setTraining] = useRecordStore((state) => [state.selectedUser, state.setSelectedUser, state.training, state.setTraining]); const [statsUserId, setStatsUserId, training, setTraining] = useRecordStore((state) => [
state.selectedUser,
state.setSelectedUser,
state.training,
state.setTraining,
]);
// const [statsUserId, setStatsUserId] = useState<string | undefined>(user.id); // const [statsUserId, setStatsUserId] = useState<string | undefined>(user.id);
const [groupedStats, setGroupedStats] = useState<{ [key: string]: Stat[] }>(); const [groupedStats, setGroupedStats] = useState<{[key: string]: Stat[]}>();
const [filter, setFilter] = useState<"months" | "weeks" | "days" | "assignments">(); const [filter, setFilter] = useState<"months" | "weeks" | "days" | "assignments">();
const { assignments } = useAssignments({}); const {assignments} = useAssignments({});
const { users } = useUsers(); const {users} = useUsers();
const { stats, isLoading: isStatsLoading } = useStats(statsUserId); const {stats, isLoading: isStatsLoading} = useStats(statsUserId);
const { groups: allGroups } = useGroups(); const {groups: allGroups} = useGroups({});
const groups = allGroups.filter((x) => x.admin === user.id); const groups = allGroups.filter((x) => x.admin === user.id);
@@ -104,12 +108,12 @@ export default function History({ user }: { user: User }) {
setFilter((prev) => (prev === value ? undefined : value)); setFilter((prev) => (prev === value ? undefined : value));
}; };
const filterStatsByDate = (stats: { [key: string]: Stat[] }) => { const filterStatsByDate = (stats: {[key: string]: Stat[]}) => {
if (filter && filter !== "assignments") { if (filter && filter !== "assignments") {
const filterDate = moment() const filterDate = moment()
.subtract({ [filter as string]: 1 }) .subtract({[filter as string]: 1})
.format("x"); .format("x");
const filteredStats: { [key: string]: Stat[] } = {}; const filteredStats: {[key: string]: Stat[]} = {};
Object.keys(stats).forEach((timestamp) => { Object.keys(stats).forEach((timestamp) => {
if (timestamp >= filterDate) filteredStats[timestamp] = stats[timestamp]; if (timestamp >= filterDate) filteredStats[timestamp] = stats[timestamp];
@@ -118,7 +122,7 @@ export default function History({ user }: { user: User }) {
} }
if (filter && filter === "assignments") { if (filter && filter === "assignments") {
const filteredStats: { [key: string]: Stat[] } = {}; const filteredStats: {[key: string]: Stat[]} = {};
Object.keys(stats).forEach((timestamp) => { Object.keys(stats).forEach((timestamp) => {
if (stats[timestamp].map((s) => s.assignment === undefined).includes(false)) if (stats[timestamp].map((s) => s.assignment === undefined).includes(false))
@@ -137,13 +141,13 @@ export default function History({ user }: { user: User }) {
useEffect(() => { useEffect(() => {
const handleRouteChange = (url: string) => { const handleRouteChange = (url: string) => {
setTraining(false) setTraining(false);
} };
router.events.on('routeChangeStart', handleRouteChange) router.events.on("routeChangeStart", handleRouteChange);
return () => { return () => {
router.events.off('routeChangeStart', handleRouteChange) router.events.off("routeChangeStart", handleRouteChange);
} };
}, [router.events, setTraining]) }, [router.events, setTraining]);
const handleTrainingContentSubmission = () => { const handleTrainingContentSubmission = () => {
if (groupedStats) { if (groupedStats) {
@@ -156,11 +160,10 @@ export default function History({ user }: { user: User }) {
} }
return accumulator; return accumulator;
}, {}); }, {});
setTrainingStats(Object.values(selectedStats).flat()) setTrainingStats(Object.values(selectedStats).flat());
router.push("/training"); router.push("/training");
} }
} };
const customContent = (timestamp: string) => { const customContent = (timestamp: string) => {
if (!groupedStats) return <></>; if (!groupedStats) return <></>;
@@ -240,13 +243,13 @@ export default function History({ user }: { user: User }) {
const selectedUser = getSelectedUser(); const selectedUser = getSelectedUser();
const selectedUserSelectValue = selectedUser const selectedUserSelectValue = selectedUser
? { ? {
value: selectedUser.id, value: selectedUser.id,
label: `${selectedUser.name} - ${selectedUser.email}`, label: `${selectedUser.name} - ${selectedUser.email}`,
} }
: { : {
value: "", value: "",
label: "", label: "",
}; };
return ( return (
<> <>
<Head> <Head>
@@ -272,7 +275,7 @@ export default function History({ user }: { user: User }) {
value={selectableCorporates.find((x) => x.value === selectedCorporate)} value={selectableCorporates.find((x) => x.value === selectedCorporate)}
onChange={(value) => setSelectedCorporate(value?.value || "")} onChange={(value) => setSelectedCorporate(value?.value || "")}
styles={{ styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }), menuPortal: (base) => ({...base, zIndex: 9999}),
option: (styles, state) => ({ option: (styles, state) => ({
...styles, ...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
@@ -289,7 +292,7 @@ export default function History({ user }: { user: User }) {
value={selectedUserSelectValue} value={selectedUserSelectValue}
onChange={(value) => setStatsUserId(value?.value!)} onChange={(value) => setStatsUserId(value?.value!)}
styles={{ styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }), menuPortal: (base) => ({...base, zIndex: 9999}),
option: (styles, state) => ({ option: (styles, state) => ({
...styles, ...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
@@ -313,7 +316,7 @@ export default function History({ user }: { user: User }) {
value={selectedUserSelectValue} value={selectedUserSelectValue}
onChange={(value) => setStatsUserId(value?.value!)} onChange={(value) => setStatsUserId(value?.value!)}
styles={{ styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }), menuPortal: (base) => ({...base, zIndex: 9999}),
option: (styles, state) => ({ option: (styles, state) => ({
...styles, ...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
@@ -323,10 +326,12 @@ export default function History({ user }: { user: User }) {
/> />
</> </>
)} )}
{(training && ( {training && (
<div className="flex flex-row"> <div className="flex flex-row">
<div className="font-semibold text-2xl mr-4">Select up to 10 exercises <div className="font-semibold text-2xl mr-4">
{`(${selectedTrainingExams.length}/${MAX_TRAINING_EXAMS})`}</div> Select up to 10 exercises
{`(${selectedTrainingExams.length}/${MAX_TRAINING_EXAMS})`}
</div>
<button <button
className={clsx( className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light ml-4 disabled:cursor-not-allowed", "bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light ml-4 disabled:cursor-not-allowed",
@@ -337,7 +342,7 @@ export default function History({ user }: { user: User }) {
Submit Submit
</button> </button>
</div> </div>
))} )}
</div> </div>
<div className="flex gap-4 w-full justify-center xl:justify-end"> <div className="flex gap-4 w-full justify-center xl:justify-end">
<button <button

View File

@@ -14,7 +14,8 @@ 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";
export const getServerSideProps = withIronSessionSsr(({req, res}) => { export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user; const user = req.session.user;
@@ -27,7 +28,7 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => {
}; };
} }
if (shouldRedirectHome(user) || !["developer", "admin", "corporate", "agent", "mastercorporate"].includes(user.type)) { if (shouldRedirectHome(user) || !checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"])) {
return { return {
redirect: { redirect: {
destination: "/", destination: "/",
@@ -43,6 +44,7 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => {
export default function Admin() { export default function Admin() {
const {user} = useUser({redirectTo: "/login"}); const {user} = useUser({redirectTo: "/login"});
const {permissions} = usePermissions(user?.id || "");
return ( return (
<> <>
@@ -58,13 +60,13 @@ export default function Admin() {
<ToastContainer /> <ToastContainer />
{user && ( {user && (
<Layout user={user} className="gap-6"> <Layout user={user} className="gap-6">
<section className="w-full flex -md:flex-col -xl:gap-2 gap-8 justify-between"> <section className="w-full grid grid-cols-2 -md:grid-cols-1 gap-8">
<ExamLoader /> <ExamLoader />
<BatchCreateUser user={user} /> <BatchCreateUser user={user} />
{checkAccess(user, getTypesOfUser(["teacher"]), 'viewCodes') && ( {checkAccess(user, getTypesOfUser(["teacher"]), permissions, "viewCodes") && (
<> <>
<CodeGenerator user={user} /> <CodeGenerator user={user} />
<BatchCodeGenerator user={user} /> <BatchCodeGenerator user={user} />
</> </>
)} )}
</section> </section>

View File

@@ -71,7 +71,7 @@ export default function Stats() {
const {user} = useUser({redirectTo: "/login"}); const {user} = useUser({redirectTo: "/login"});
const {users} = useUsers(); const {users} = useUsers();
const {groups} = useGroups(user?.id); const {groups} = useGroups({admin: user?.id});
const {stats} = useStats(statsUserId, !statsUserId); const {stats} = useStats(statsUserId, !statsUserId);
useEffect(() => { useEffect(() => {
@@ -202,7 +202,7 @@ export default function Stats() {
}} }}
/> />
)} )}
{(["corporate", "teacher", "mastercorporate"].includes(user.type) ) && groups.length > 0 && ( {["corporate", "teacher", "mastercorporate"].includes(user.type) && groups.length > 0 && (
<Select <Select
className="w-full" className="w-full"
options={users options={users

View File

@@ -1,14 +1,14 @@
/* 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 { Stat, User } from "@/interfaces/user"; import {Stat, User} from "@/interfaces/user";
import { ToastContainer } from "react-toastify"; import {ToastContainer} from "react-toastify";
import Layout from "@/components/High/Layout"; import Layout from "@/components/High/Layout";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import {shouldRedirectHome} from "@/utils/navigation.disabled";
import { use, useEffect, useState } from "react"; import {use, useEffect, useState} from "react";
import clsx from "clsx"; import clsx from "clsx";
import { FaPlus } from "react-icons/fa"; import {FaPlus} from "react-icons/fa";
import useRecordStore from "@/stores/recordStore"; import useRecordStore from "@/stores/recordStore";
import router from "next/router"; import router from "next/router";
import useTrainingContentStore from "@/stores/trainingContentStore"; import useTrainingContentStore from "@/stores/trainingContentStore";
@@ -16,390 +16,388 @@ import axios from "axios";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
import { ITrainingContent } from "@/training/TrainingInterfaces"; import {ITrainingContent} from "@/training/TrainingInterfaces";
import moment from "moment"; import moment from "moment";
import { uuidv4 } from "@firebase/util"; import {uuidv4} from "@firebase/util";
import TrainingScore from "@/training/TrainingScore"; import TrainingScore from "@/training/TrainingScore";
import ModuleBadge from "@/components/ModuleBadge"; import ModuleBadge from "@/components/ModuleBadge";
export const getServerSideProps = withIronSessionSsr(({ req, res }) => { export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user; const user = req.session.user;
if (!user || !user.isVerified) { if (!user || !user.isVerified) {
return { return {
redirect: { redirect: {
destination: "/login", destination: "/login",
permanent: false, permanent: false,
}, },
}; };
} }
if (shouldRedirectHome(user)) { if (shouldRedirectHome(user)) {
return { return {
redirect: { redirect: {
destination: "/", destination: "/",
permanent: false, permanent: false,
}, },
}; };
} }
return { return {
props: { user: req.session.user }, props: {user: req.session.user},
}; };
}, sessionOptions); }, sessionOptions);
const defaultSelectableCorporate = { const defaultSelectableCorporate = {
value: "", value: "",
label: "All", label: "All",
}; };
const Training: React.FC<{ user: User }> = ({ user }) => { const Training: React.FC<{user: User}> = ({user}) => {
// Record stuff // Record stuff
const { users } = useUsers(); const {users} = useUsers();
const [selectedCorporate, setSelectedCorporate] = useState<string>(defaultSelectableCorporate.value); const [selectedCorporate, setSelectedCorporate] = useState<string>(defaultSelectableCorporate.value);
const [statsUserId, setStatsUserId, setRecordTraining] = useRecordStore((state) => [state.selectedUser, state.setSelectedUser, state.setTraining]); const [statsUserId, setStatsUserId, setRecordTraining] = useRecordStore((state) => [
const { groups: allGroups } = useGroups(); state.selectedUser,
const groups = allGroups.filter((x) => x.admin === user.id); state.setSelectedUser,
const [filter, setFilter] = useState<"months" | "weeks" | "days">(); state.setTraining,
]);
const {groups: allGroups} = useGroups({});
const groups = allGroups.filter((x) => x.admin === user.id);
const [filter, setFilter] = useState<"months" | "weeks" | "days">();
const toggleFilter = (value: "months" | "weeks" | "days") => { const toggleFilter = (value: "months" | "weeks" | "days") => {
setFilter((prev) => (prev === value ? undefined : value)); setFilter((prev) => (prev === value ? undefined : value));
}; };
const [stats, setTrainingStats] = useTrainingContentStore((state) => [state.stats, state.setStats]); const [stats, setTrainingStats] = useTrainingContentStore((state) => [state.stats, state.setStats]);
const [trainingContent, setTrainingContent] = useState<ITrainingContent[]>([]); const [trainingContent, setTrainingContent] = useState<ITrainingContent[]>([]);
const [isNewContentLoading, setIsNewContentLoading] = useState(stats.length != 0); const [isNewContentLoading, setIsNewContentLoading] = useState(stats.length != 0);
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
const [groupedByTrainingContent, setGroupedByTrainingContent] = useState<{ [key: string]: ITrainingContent }>(); const [groupedByTrainingContent, setGroupedByTrainingContent] = useState<{[key: string]: ITrainingContent}>();
useEffect(() => { useEffect(() => {
const handleRouteChange = (url: string) => { const handleRouteChange = (url: string) => {
setTrainingStats([]) setTrainingStats([]);
} };
router.events.on('routeChangeStart', handleRouteChange) router.events.on("routeChangeStart", handleRouteChange);
return () => { return () => {
router.events.off('routeChangeStart', handleRouteChange) router.events.off("routeChangeStart", handleRouteChange);
} };
}, [router.events, setTrainingStats]) }, [router.events, setTrainingStats]);
useEffect(() => { useEffect(() => {
const postStats = async () => { const postStats = async () => {
try { try {
const response = await axios.post<{ id: string }>(`/api/training`, stats); const response = await axios.post<{id: string}>(`/api/training`, stats);
return response.data.id; return response.data.id;
} catch (error) { } catch (error) {
setIsNewContentLoading(false); setIsNewContentLoading(false);
} }
}; };
if (isNewContentLoading) { if (isNewContentLoading) {
postStats().then(id => { postStats().then((id) => {
setTrainingStats([]); setTrainingStats([]);
if (id) { if (id) {
router.push(`/training/${id}`) router.push(`/training/${id}`);
} }
}); });
} }
}, [isNewContentLoading]) }, [isNewContentLoading]);
useEffect(() => { useEffect(() => {
const loadTrainingContent = async () => { const loadTrainingContent = async () => {
try { try {
const response = await axios.get<ITrainingContent[]>('/api/training'); const response = await axios.get<ITrainingContent[]>("/api/training");
setTrainingContent(response.data); setTrainingContent(response.data);
setIsLoading(false); setIsLoading(false);
} catch (error) { } catch (error) {
setTrainingContent([]); setTrainingContent([]);
setIsLoading(false); setIsLoading(false);
} }
}; };
loadTrainingContent(); loadTrainingContent();
}, []); }, []);
const handleNewTrainingContent = () => { const handleNewTrainingContent = () => {
setRecordTraining(true); setRecordTraining(true);
router.push('/record') router.push("/record");
} };
const filterTrainingContentByDate = (trainingContent: {[key: string]: ITrainingContent}) => {
if (filter) {
const filterDate = moment()
.subtract({[filter as string]: 1})
.format("x");
const filteredTrainingContent: {[key: string]: ITrainingContent} = {};
const filterTrainingContentByDate = (trainingContent: { [key: string]: ITrainingContent }) => { Object.keys(trainingContent).forEach((timestamp) => {
if (filter) { if (timestamp >= filterDate) filteredTrainingContent[timestamp] = trainingContent[timestamp];
const filterDate = moment() });
.subtract({ [filter as string]: 1 }) return filteredTrainingContent;
.format("x"); }
const filteredTrainingContent: { [key: string]: ITrainingContent } = {}; return trainingContent;
};
Object.keys(trainingContent).forEach((timestamp) => { useEffect(() => {
if (timestamp >= filterDate) filteredTrainingContent[timestamp] = trainingContent[timestamp]; if (trainingContent.length > 0) {
}); const grouped = trainingContent.reduce((acc, content) => {
return filteredTrainingContent; acc[content.created_at] = content;
} return acc;
return trainingContent; }, {} as {[key: number]: ITrainingContent});
};
useEffect(() => { setGroupedByTrainingContent(grouped);
if (trainingContent.length > 0) { }
const grouped = trainingContent.reduce((acc, content) => { }, [trainingContent]);
acc[content.created_at] = content;
return acc;
}, {} as { [key: number]: ITrainingContent });
setGroupedByTrainingContent(grouped); // Record Stuff
} const selectableCorporates = [
}, [trainingContent]) defaultSelectableCorporate,
...users
.filter((x) => x.type === "corporate")
.map((x) => ({
value: x.id,
label: `${x.name} - ${x.email}`,
})),
];
const getUsersList = (): User[] => {
if (selectedCorporate) {
// get groups for that corporate
const selectedCorporateGroups = allGroups.filter((x) => x.admin === selectedCorporate);
// Record Stuff // get the teacher ids for that group
const selectableCorporates = [ const selectedCorporateGroupsParticipants = selectedCorporateGroups.flatMap((x) => x.participants);
defaultSelectableCorporate,
...users
.filter((x) => x.type === "corporate")
.map((x) => ({
value: x.id,
label: `${x.name} - ${x.email}`,
})),
];
const getUsersList = (): User[] => { // // search for groups for these teachers
if (selectedCorporate) { // const teacherGroups = allGroups.filter((x) => {
// get groups for that corporate // return selectedCorporateGroupsParticipants.includes(x.admin);
const selectedCorporateGroups = allGroups.filter((x) => x.admin === selectedCorporate); // });
// get the teacher ids for that group // const usersList = [
const selectedCorporateGroupsParticipants = selectedCorporateGroups.flatMap((x) => x.participants); // ...selectedCorporateGroupsParticipants,
// ...teacherGroups.flatMap((x) => x.participants),
// ];
const userListWithUsers = selectedCorporateGroupsParticipants.map((x) => users.find((y) => y.id === x)) as User[];
return userListWithUsers.filter((x) => x);
}
// // search for groups for these teachers return users || [];
// const teacherGroups = allGroups.filter((x) => { };
// return selectedCorporateGroupsParticipants.includes(x.admin);
// });
// const usersList = [ const corporateFilteredUserList = getUsersList();
// ...selectedCorporateGroupsParticipants, const getSelectedUser = () => {
// ...teacherGroups.flatMap((x) => x.participants), if (selectedCorporate) {
// ]; const userInCorporate = corporateFilteredUserList.find((x) => x.id === statsUserId);
const userListWithUsers = selectedCorporateGroupsParticipants.map((x) => users.find((y) => y.id === x)) as User[]; return userInCorporate || corporateFilteredUserList[0];
return userListWithUsers.filter((x) => x); }
}
return users || []; return users.find((x) => x.id === statsUserId) || user;
}; };
const corporateFilteredUserList = getUsersList(); const selectedUser = getSelectedUser();
const getSelectedUser = () => { const selectedUserSelectValue = selectedUser
if (selectedCorporate) { ? {
const userInCorporate = corporateFilteredUserList.find((x) => x.id === statsUserId); value: selectedUser.id,
return userInCorporate || corporateFilteredUserList[0]; label: `${selectedUser.name} - ${selectedUser.email}`,
} }
: {
value: "",
label: "",
};
return users.find((x) => x.id === statsUserId) || user; const formatTimestamp = (timestamp: string) => {
}; const date = moment(parseInt(timestamp));
const formatter = "YYYY/MM/DD - HH:mm";
const selectedUser = getSelectedUser(); return date.format(formatter);
const selectedUserSelectValue = selectedUser };
? {
value: selectedUser.id,
label: `${selectedUser.name} - ${selectedUser.email}`,
}
: {
value: "",
label: "",
};
const formatTimestamp = (timestamp: string) => { const selectTrainingContent = (trainingContent: ITrainingContent) => {
const date = moment(parseInt(timestamp)); router.push(`/training/${trainingContent.id}`);
const formatter = "YYYY/MM/DD - HH:mm"; };
return date.format(formatter); const trainingContentContainer = (timestamp: string) => {
}; if (!groupedByTrainingContent) return <></>;
const trainingContent: ITrainingContent = groupedByTrainingContent[timestamp];
const uniqueModules = [...new Set(trainingContent.exams.map((exam) => exam.module))];
const selectTrainingContent = (trainingContent: ITrainingContent) => { return (
router.push(`/training/${trainingContent.id}`) <>
}; <div
key={uuidv4()}
className={clsx(
"flex flex-col justify-between gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:hidden",
)}
onClick={() => selectTrainingContent(trainingContent)}
role="button">
<div className="w-full flex justify-between -md:items-center 2xl:items-center">
<div className="flex flex-col md:gap-1 -md:gap-2 2xl:gap-2">
<span className="font-medium">{formatTimestamp(timestamp)}</span>
</div>
<div className="flex flex-col gap-2">
<div className="w-full flex flex-row gap-1">
{uniqueModules.map((module) => (
<ModuleBadge key={module} module={module} />
))}
</div>
</div>
</div>
<TrainingScore trainingContent={trainingContent} gridView={true} />
</div>
</>
);
};
return (
<>
<Head>
<title>Training | 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 />
const trainingContentContainer = (timestamp: string) => { <Layout user={user}>
if (!groupedByTrainingContent) return <></>; {isNewContentLoading || isLoading ? (
const trainingContent: ITrainingContent = groupedByTrainingContent[timestamp]; <div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-12">
const uniqueModules = [...new Set(trainingContent.exams.map(exam => exam.module))]; <span className="loading loading-infinity w-32 bg-mti-green-light" />
{isNewContentLoading && (
<span className="text-center text-2xl font-bold text-mti-green-light">Assessing your exams, please be patient...</span>
)}
</div>
) : (
<>
<div className="w-full flex -xl:flex-col -xl:gap-4 justify-between items-center">
<div className="xl:w-3/4">
{(user.type === "developer" || user.type === "admin") && (
<>
<label className="font-normal text-base text-mti-gray-dim">Corporate</label>
return ( <Select
<> options={selectableCorporates}
<div value={selectableCorporates.find((x) => x.value === selectedCorporate)}
key={uuidv4()} onChange={(value) => setSelectedCorporate(value?.value || "")}
className={clsx( styles={{
"flex flex-col justify-between gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:hidden" menuPortal: (base) => ({...base, zIndex: 9999}),
)} option: (styles, state) => ({
onClick={() => selectTrainingContent(trainingContent)} ...styles,
role="button"> backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
<div className="w-full flex justify-between -md:items-center 2xl:items-center"> color: state.isFocused ? "black" : styles.color,
<div className="flex flex-col md:gap-1 -md:gap-2 2xl:gap-2"> }),
<span className="font-medium">{formatTimestamp(timestamp)}</span> }}></Select>
</div> <label className="font-normal text-base text-mti-gray-dim">User</label>
<div className="flex flex-col gap-2">
<div className="w-full flex flex-row gap-1">
{uniqueModules.map((module) => (
<ModuleBadge key={module} module={module} />
))}
</div>
</div>
</div>
<TrainingScore
trainingContent={trainingContent}
gridView={true}
/>
</div>
</>
);
};
return ( <Select
<> options={corporateFilteredUserList.map((x) => ({
<Head> value: x.id,
<title>Training | EnCoach</title> label: `${x.name} - ${x.email}`,
<meta }))}
name="description" value={selectedUserSelectValue}
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." onChange={(value) => setStatsUserId(value?.value!)}
/> styles={{
<meta name="viewport" content="width=device-width, initial-scale=1" /> menuPortal: (base) => ({...base, zIndex: 9999}),
<link rel="icon" href="/favicon.ico" /> option: (styles, state) => ({
</Head> ...styles,
<ToastContainer /> backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
/>
</>
)}
{(user.type === "corporate" || user.type === "teacher") && groups.length > 0 && (
<>
<label className="font-normal text-base text-mti-gray-dim">User</label>
<Layout user={user}> <Select
{(isNewContentLoading || isLoading ? ( options={users
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-12"> .filter((x) => groups.flatMap((y) => y.participants).includes(x.id))
<span className="loading loading-infinity w-32 bg-mti-green-light" /> .map((x) => ({
{isNewContentLoading && (<span className="text-center text-2xl font-bold text-mti-green-light"> value: x.id,
Assessing your exams, please be patient... label: `${x.name} - ${x.email}`,
</span>)} }))}
</div> value={selectedUserSelectValue}
) : ( onChange={(value) => setStatsUserId(value?.value!)}
<> styles={{
<div className="w-full flex -xl:flex-col -xl:gap-4 justify-between items-center"> menuPortal: (base) => ({...base, zIndex: 9999}),
<div className="xl:w-3/4"> option: (styles, state) => ({
{(user.type === "developer" || user.type === "admin") && ( ...styles,
<> backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
<label className="font-normal text-base text-mti-gray-dim">Corporate</label> color: state.isFocused ? "black" : styles.color,
}),
<Select }}
options={selectableCorporates} />
value={selectableCorporates.find((x) => x.value === selectedCorporate)} </>
onChange={(value) => setSelectedCorporate(value?.value || "")} )}
styles={{ {user.type === "student" && (
menuPortal: (base) => ({ ...base, zIndex: 9999 }), <>
option: (styles, state) => ({ <div className="flex items-center">
...styles, <div className="font-semibold text-2xl">Generate New Training Material</div>
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", <button
color: state.isFocused ? "black" : styles.color, className={clsx(
}), "bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light ml-4",
}}></Select> "transition duration-300 ease-in-out",
<label className="font-normal text-base text-mti-gray-dim">User</label> )}
onClick={handleNewTrainingContent}>
<Select <FaPlus />
options={corporateFilteredUserList.map((x) => ({ </button>
value: x.id, </div>
label: `${x.name} - ${x.email}`, </>
}))} )}
value={selectedUserSelectValue} </div>
onChange={(value) => setStatsUserId(value?.value!)} <div className="flex gap-4 w-full justify-center xl:justify-end">
styles={{ <button
menuPortal: (base) => ({ ...base, zIndex: 9999 }), className={clsx(
option: (styles, state) => ({ "bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
...styles, "transition duration-300 ease-in-out",
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", filter === "months" && "!bg-mti-purple-light !text-white",
color: state.isFocused ? "black" : styles.color, )}
}), onClick={() => toggleFilter("months")}>
}} Last month
/> </button>
</> <button
)} className={clsx(
{(user.type === "corporate" || user.type === "teacher") && groups.length > 0 && ( "bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
<> "transition duration-300 ease-in-out",
<label className="font-normal text-base text-mti-gray-dim">User</label> filter === "weeks" && "!bg-mti-purple-light !text-white",
)}
<Select onClick={() => toggleFilter("weeks")}>
options={users Last week
.filter((x) => groups.flatMap((y) => y.participants).includes(x.id)) </button>
.map((x) => ({ <button
value: x.id, className={clsx(
label: `${x.name} - ${x.email}`, "bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
}))} "transition duration-300 ease-in-out",
value={selectedUserSelectValue} filter === "days" && "!bg-mti-purple-light !text-white",
onChange={(value) => setStatsUserId(value?.value!)} )}
styles={{ onClick={() => toggleFilter("days")}>
menuPortal: (base) => ({ ...base, zIndex: 9999 }), Last day
option: (styles, state) => ({ </button>
...styles, </div>
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", </div>
color: state.isFocused ? "black" : styles.color, {trainingContent.length == 0 && (
}), <div className="flex flex-grow justify-center items-center">
}} <span className="font-semibold ml-1">No training content to display...</span>
/> </div>
</> )}
)} {groupedByTrainingContent && Object.keys(groupedByTrainingContent).length > 0 && (
{(user.type === "student" && ( <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-3 w-full gap-4 xl:gap-6">
<> {Object.keys(filterTrainingContentByDate(groupedByTrainingContent))
<div className="flex items-center"> .sort((a, b) => parseInt(b) - parseInt(a))
<div className="font-semibold text-2xl">Generate New Training Material</div> .map(trainingContentContainer)}
<button </div>
className={clsx( )}
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light ml-4", </>
"transition duration-300 ease-in-out", )}
)} </Layout>
onClick={handleNewTrainingContent}> </>
<FaPlus /> );
</button> };
</div>
</>
))}
</div>
<div className="flex gap-4 w-full justify-center xl:justify-end">
<button
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out",
filter === "months" && "!bg-mti-purple-light !text-white",
)}
onClick={() => toggleFilter("months")}>
Last month
</button>
<button
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out",
filter === "weeks" && "!bg-mti-purple-light !text-white",
)}
onClick={() => toggleFilter("weeks")}>
Last week
</button>
<button
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out",
filter === "days" && "!bg-mti-purple-light !text-white",
)}
onClick={() => toggleFilter("days")}>
Last day
</button>
</div>
</div>
{trainingContent.length == 0 && (
<div className="flex flex-grow justify-center items-center">
<span className="font-semibold ml-1">No training content to display...</span>
</div>
)}
{groupedByTrainingContent && Object.keys(groupedByTrainingContent).length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-3 w-full gap-4 xl:gap-6">
{Object.keys(filterTrainingContentByDate(groupedByTrainingContent))
.sort((a, b) => parseInt(b) - parseInt(a))
.map(trainingContentContainer)}
</div>
)}
</>
))}
</Layout>
</>
);
}
export default Training; export default Training;

View File

@@ -5,17 +5,17 @@ export const USER_TYPE_LABELS: {[key in Type]: string} = {
teacher: "Teacher", teacher: "Teacher",
corporate: "Corporate", corporate: "Corporate",
agent: "Country Manager", agent: "Country Manager",
admin: "Admin", admin: "Super Admin",
developer: "Developer", developer: "Developer",
mastercorporate: "Master Corporate" mastercorporate: "Master Corporate",
}; };
export function isCorporateUser(user: User): user is CorporateUser { export function isCorporateUser(user: User): user is CorporateUser {
return (user as CorporateUser).corporateInformation !== undefined; return (user as CorporateUser)?.corporateInformation !== undefined;
} }
export function isAgentUser(user: User): user is AgentUser { export function isAgentUser(user: User): user is AgentUser {
return (user as AgentUser).agentInformation !== undefined; return (user as AgentUser)?.agentInformation !== undefined;
} }
export function getUserCompanyName(user: User, users: User[], groups: Group[]) { export function getUserCompanyName(user: User, users: User[], groups: Group[]) {
@@ -30,3 +30,15 @@ export function getUserCompanyName(user: User, users: User[], groups: Group[]) {
const admin = belongingGroupsAdmins[0] as CorporateUser; const admin = belongingGroupsAdmins[0] as CorporateUser;
return admin.corporateInformation?.companyInformation.name || admin.name; return admin.corporateInformation?.companyInformation.name || admin.name;
} }
export function getCorporateUser(user: User, users: User[], groups: Group[]) {
if (isCorporateUser(user)) return user;
const belongingGroups = groups.filter((x) => x.participants.includes(user.id));
const belongingGroupsAdmins = belongingGroups.map((x) => users.find((u) => u.id === x.admin)).filter((x) => !!x && isCorporateUser(x));
if (belongingGroupsAdmins.length === 0) return undefined;
const admin = belongingGroupsAdmins[0] as CorporateUser;
return admin;
}

View File

@@ -0,0 +1,14 @@
import {app} from "@/firebase";
import {Assignment} from "@/interfaces/results";
import {collection, getDocs, getFirestore, query, where} from "firebase/firestore";
const db = getFirestore(app);
export const getAssignmentsByAssigner = async (id: string) => {
const {docs} = await getDocs(query(collection(db, "assignments"), where("assigner", "==", id)));
return docs.map((x) => ({...x.data(), id: x.id})) as Assignment[];
};
export const getAssignmentsByAssigners = async (ids: string[]) => {
return (await Promise.all(ids.map(getAssignmentsByAssigner))).flat();
};

View File

@@ -1,53 +1,51 @@
import { app } from "@/firebase"; import {app} from "@/firebase";
import { CorporateUser, StudentUser, TeacherUser } from "@/interfaces/user"; import {CorporateUser, Group, StudentUser, TeacherUser} from "@/interfaces/user";
import { doc, getDoc, getFirestore, setDoc } from "firebase/firestore"; import {collection, doc, getDoc, getDocs, getFirestore, query, setDoc, where} from "firebase/firestore";
import moment from "moment"; import moment from "moment";
import {getUser} from "./users.be";
const db = getFirestore(app); const db = getFirestore(app);
export const updateExpiryDateOnGroup = async ( export const updateExpiryDateOnGroup = async (participantID: string, corporateID: string) => {
participantID: string, const corporateRef = await getDoc(doc(db, "users", corporateID));
corporateID: string, const participantRef = await getDoc(doc(db, "users", participantID));
) => {
const corporateRef = await getDoc(doc(db, "users", corporateID));
const participantRef = await getDoc(doc(db, "users", participantID));
if (!corporateRef.exists() || !participantRef.exists()) return; if (!corporateRef.exists() || !participantRef.exists()) return;
const corporate = { const corporate = {
...corporateRef.data(), ...corporateRef.data(),
id: corporateRef.id, id: corporateRef.id,
} as CorporateUser; } as CorporateUser;
const participant = { ...participantRef.data(), id: participantRef.id } as const participant = {...participantRef.data(), id: participantRef.id} as StudentUser | TeacherUser;
| StudentUser
| TeacherUser;
if ( if (corporate.type !== "corporate" || (participant.type !== "student" && participant.type !== "teacher")) return;
corporate.type !== "corporate" ||
(participant.type !== "student" && participant.type !== "teacher")
)
return;
if ( if (!corporate.subscriptionExpirationDate || !participant.subscriptionExpirationDate) {
!corporate.subscriptionExpirationDate || return await setDoc(doc(db, "users", participant.id), {subscriptionExpirationDate: null}, {merge: true});
!participant.subscriptionExpirationDate }
) {
return await setDoc(
doc(db, "users", participant.id),
{ subscriptionExpirationDate: null },
{ merge: true },
);
}
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 setDoc( return await setDoc(doc(db, "users", participant.id), {subscriptionExpirationDate: corporateDate.toISOString()}, {merge: true});
doc(db, "users", participant.id),
{ subscriptionExpirationDate: corporateDate.toISOString() },
{ merge: true },
);
return; return;
};
export const getUserGroups = async (id: string): Promise<Group[]> => {
const groupDocs = await getDocs(query(collection(db, "groups"), where("admin", "==", id)));
return groupDocs.docs.map((x) => ({...x.data(), id})) as Group[];
};
export const getAllAssignersByCorporate = async (corporateID: string): Promise<string[]> => {
const groups = await getUserGroups(corporateID);
const groupUsers = (await Promise.all(groups.map(async (g) => await Promise.all(g.participants.map(getUser))))).flat();
const teacherPromises = await Promise.all(
groupUsers.map(async (u) =>
u.type === "teacher" ? u.id : u.type === "corporate" ? [...(await getAllAssignersByCorporate(u.id)), u.id] : undefined,
),
);
return teacherPromises.filter((x) => !!x).flat() as string[];
}; };

View File

@@ -16,12 +16,13 @@ import {
Permission, Permission,
PermissionType, PermissionType,
permissions, permissions,
PermissionTopic,
} from "@/interfaces/permissions"; } from "@/interfaces/permissions";
import {v4} from "uuid"; import { v4 } from "uuid";
const db = getFirestore(app); const db = getFirestore(app);
async function createPermission(type: string) { async function createPermission(topic: string, type: string) {
const permData = doc(db, "permissions", v4()); const permData = doc(db, "permissions", v4());
const permDoc = await getDoc(permData); const permDoc = await getDoc(permData);
if (permDoc.exists()) { if (permDoc.exists()) {
@@ -30,9 +31,14 @@ async function createPermission(type: string) {
await setDoc(permData, { await setDoc(permData, {
type, type,
topic,
users: [], users: [],
}); });
} }
interface PermissionsHelperList {
topic: string;
type: string;
}
export function getPermissions(userId: string | undefined, docs: Permission[]) { export function getPermissions(userId: string | undefined, docs: Permission[]) {
if (!userId) { if (!userId) {
return []; return [];
@@ -52,9 +58,19 @@ export function getPermissions(userId: string | undefined, docs: Permission[]) {
} }
export async function bootstrap() { export async function bootstrap() {
await permissions.forEach(async (type) => { await permissions
await createPermission(type); .reduce((accm: PermissionsHelperList[], permissionData) => {
}); return [
...accm,
...permissionData.list.map((type) => ({
topic: permissionData.topic,
type,
})),
];
}, [])
.forEach(async ({ topic, type }) => {
await createPermission(topic, type);
});
} }
export async function getPermissionDoc(id: string) { export async function getPermissionDoc(id: string) {

View File

@@ -1,45 +1,42 @@
import { PermissionType } from "@/interfaces/permissions"; import {PermissionType} from "@/interfaces/permissions";
import { User, Type, userTypes } from "@/interfaces/user"; import {User, Type, userTypes} from "@/interfaces/user";
import axios from "axios";
export function checkAccess( export function checkAccess(user: User, types: Type[], permissions?: PermissionType[], permission?: PermissionType) {
user: User, if (!user) {
types: Type[], return false;
permission?: PermissionType }
) {
if (!user) {
return false;
}
// if(user.type === '') { // if(user.type === '') {
if (!user.type) { if (!user.type) {
console.warn("User type is empty"); console.warn("User type is empty");
return false; return false;
} }
if (types.length === 0) { if (types.length === 0) {
console.warn("No types provided"); console.warn("No types provided");
return false; return false;
} }
if (!types.includes(user.type)) { if (!types.includes(user.type)) {
return false; return false;
} }
// we may not want a permission check as most screens dont even havr a specific permission // we may not want a permission check as most screens dont even havr a specific permission
if (permission) { if (permission) {
// this works more like a blacklist // this works more like a blacklist
// therefore if we don't find the permission here, he can't do it // therefore if we don't find the permission here, he can't do it
if (!(user.permissions || []).includes(permission)) { if (!(permissions || []).includes(permission)) {
return false; return false;
} }
} }
return true; return true;
} }
export function getTypesOfUser(types: Type[]) { export function getTypesOfUser(types: Type[]) {
// basicly generate a list of all types except the excluded ones // basicly generate a list of all types except the excluded ones
return userTypes.filter((userType) => { return userTypes.filter((userType) => {
return !types.includes(userType); return !types.includes(userType);
}) });
} }

View File

@@ -1,5 +1,6 @@
import {Module} from "@/interfaces"; import {Module} from "@/interfaces";
import {LevelScore} from "@/constants/ielts"; import {LevelScore} from "@/constants/ielts";
import {Stat, User} from "@/interfaces/user";
type Type = "academic" | "general"; type Type = "academic" | "general";
@@ -178,3 +179,28 @@ export const getLevelLabel = (level: number) => {
return ["Proficiency", "C2"]; return ["Proficiency", "C2"];
}; };
export const averageLevelCalculator = (users: User[], studentStats: Stat[]) => {
const formattedStats = studentStats
.map((s) => ({
focus: users.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);
};

View File

@@ -1,14 +1,20 @@
import { app } from "@/firebase"; import {app} from "@/firebase";
import { collection, getDocs, getFirestore } from "firebase/firestore"; import {collection, doc, getDoc, getDocs, getFirestore} from "firebase/firestore";
import { User } from "@/interfaces/user"; import {User} from "@/interfaces/user";
const db = getFirestore(app); const db = getFirestore(app);
export async function getUsers() { export async function getUsers() {
const snapshot = await getDocs(collection(db, "users")); const snapshot = await getDocs(collection(db, "users"));
return snapshot.docs.map((doc) => ({ return snapshot.docs.map((doc) => ({
id: doc.id, id: doc.id,
...doc.data(), ...doc.data(),
})) as User[]; })) as User[];
}
export async function getUser(id: string) {
const userDoc = await getDoc(doc(db, "users", id));
return {...userDoc.data(), id} as User;
} }

View File

@@ -25,7 +25,10 @@ export const exportListToExcel = (rowUsers: User[], users: User[], groups: Group
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: (user.type === "corporate" || user.type === "mastercorporate" ? user.demographicInformation?.position : user.demographicInformation?.employment) || "N/A", employmentPosition:
(user.type === "corporate" || user.type === "mastercorporate"
? user.demographicInformation?.position
: 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",
})); }));
@@ -34,3 +37,9 @@ export const exportListToExcel = (rowUsers: User[], users: User[], groups: Group
return `${header}\n${rowsString}`; return `${header}\n${rowsString}`;
}; };
export const getUserName = (user?: User) => {
if (!user) return "N/A";
if (user.type === "corporate" || user.type === "mastercorporate") return user.corporateInformation?.companyInformation?.name || user.name;
return user.name;
};