Solved an issue where, for developers, because of the amount of permissions, the cookie was too big, so I separated the permissions logic into a hook

This commit is contained in:
Tiago Ribeiro
2024-08-12 19:35:11 +01:00
parent cb489bf0ca
commit 58300e32ff
17 changed files with 3060 additions and 4025 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>
);
} }

View File

@@ -14,7 +14,7 @@ import {
BsClipboardData, BsClipboardData,
BsFileLock, BsFileLock,
} 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 +28,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 +81,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 +100,22 @@ 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, 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 +135,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}
@@ -169,13 +171,13 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
<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

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(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

@@ -0,0 +1,29 @@
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[]);
console.log(response.data, permissionTypes);
setPermissions(permissionTypes);
})
.finally(() => setIsLoading(false));
};
useEffect(getData, [user]);
return {permissions, isLoading, isError, reload: getData};
}

View File

@@ -1,57 +1,58 @@
export const markets = ["au", "br", "de"] as const; export const markets = ["au", "br", "de"] as const;
export const permissions = [ export const permissions = [
// generate codes are basicly invites // generate codes are basicly invites
"createCodeStudent", "createCodeStudent",
"createCodeTeacher", "createCodeTeacher",
"createCodeCorporate", "createCodeCorporate",
"createCodeCountryManager", "createCodeCountryManager",
"createCodeAdmin", "createCodeAdmin",
// exams // exams
"createReadingExam", "createReadingExam",
"createListeningExam", "createListeningExam",
"createWritingExam", "createWritingExam",
"createSpeakingExam", "createSpeakingExam",
"createLevelExam", "createLevelExam",
// view pages // view pages
"viewExams", "viewExams",
"viewExercises", "viewExercises",
"viewRecords", "viewRecords",
"viewStats", "viewStats",
"viewTickets", "viewTickets",
"viewPaymentRecords", "viewPaymentRecords",
// view data // view data
"viewStudent", "viewStudent",
"viewTeacher", "viewTeacher",
"viewCorporate", "viewCorporate",
"viewCountryManager", "viewCountryManager",
"viewAdmin", "viewAdmin",
"viewGroup", "viewGroup",
"viewCodes", "viewCodes",
// edit data // edit data
"editStudent", "editStudent",
"editTeacher", "editTeacher",
"editCorporate", "editCorporate",
"editCountryManager", "editCountryManager",
"editAdmin", "editAdmin",
"editGroup", "editGroup",
// delete data // delete data
"deleteStudent", "deleteStudent",
"deleteTeacher", "deleteTeacher",
"deleteCorporate", "deleteCorporate",
"deleteCountryManager", "deleteCountryManager",
"deleteAdmin", "deleteAdmin",
"deleteGroup", "deleteGroup",
"deleteCodes", "deleteCodes",
// create options // create options
"createGroup", "createGroup",
"createCodes" "createCodes",
"all",
] as const; ] as const;
export type PermissionType = (typeof permissions)[keyof typeof permissions]; export type PermissionType = (typeof permissions)[keyof typeof permissions];
export interface Permission { export interface Permission {
id: string; id: string;
type: PermissionType; type: PermissionType;
users: string[]; users: string[];
} }

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,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,341 @@ 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) => (user.type === "teacher" ? x.type === "student" : x.type === "student" || x.type === "teacher"))
<Select .map((x) => ({value: x.id, label: `${x.email} - ${x.name}`}))}
className="w-full" onChange={(value) => setParticipants(value.map((x) => x.value))}
value={participants.map((x) => ({ isMulti
value: x, isSearchable
label: `${users.find((y) => y.id === x)?.email} - ${ menuPortalTarget={document?.body}
users.find((y) => y.id === x)?.name styles={{
}`, menuPortal: (base) => ({...base, zIndex: 9999}),
}))} control: (styles) => ({
placeholder="Participants..." ...styles,
defaultValue={participants.map((x) => ({ backgroundColor: "white",
value: x, borderRadius: "999px",
label: `${users.find((y) => y.id === x)?.email} - ${ padding: "1rem 1.5rem",
users.find((y) => y.id === x)?.name zIndex: "40",
}`, }),
}))} }}
options={users />
.filter((x) => {user.type !== "teacher" && (
user.type === "teacher" <Button className="w-full max-w-[300px]" onClick={openFilePicker} isLoading={isLoading} variant="outline">
? x.type === "student" {filesContent.length === 0 ? "Upload participants Excel file" : filesContent[0].name}
: x.type === "student" || x.type === "teacher" </Button>
) )}
.map((x) => ({ value: x.id, label: `${x.email} - ${x.name}` }))} </div>
onChange={(value) => setParticipants(value.map((x) => x.value))} </div>
isMulti </div>
isSearchable <div className="mt-8 flex w-full items-center justify-end gap-8">
menuPortalTarget={document?.body} <Button variant="outline" color="red" className="w-full max-w-[200px]" isLoading={isLoading} onClick={onClose}>
styles={{ Cancel
menuPortal: (base) => ({ ...base, zIndex: 9999 }), </Button>
control: (styles) => ({ <Button className="w-full max-w-[200px]" onClick={submit} isLoading={isLoading} disabled={!name}>
...styles, Submit
backgroundColor: "white", </Button>
borderRadius: "999px", </div>
padding: "1rem 1.5rem", </div>
zIndex: "40", );
}),
}}
/>
{user.type !== "teacher" && (
<Button
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 && filterTypes.includes(user?.type) ? user.id : undefined, user?.type);
user &&
["corporate", "teacher", "mastercorporate"].includes(user.type)
) {
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"]) && (
{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

@@ -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,108 @@ 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;
const permissionDocs = await getPermissionDocs();
const userWithPermissions = { req.session.user = {
...user, ...user,
permissions: getPermissions(req.session.user.id, permissionDocs), id: req.session.user.id,
}; };
req.session.user = { await req.session.save();
...userWithPermissions,
id: req.session.user.id,
};
await req.session.save();
res.json({ ...userWithPermissions, id: req.session.user.id }); res.json({...user, id: req.session.user.id});
} else { } else {
res.status(401).json(undefined); res.status(401).json(undefined);
} }
} }

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;
@@ -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 (
<> <>
@@ -60,11 +62,11 @@ export default function Admin() {
<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 flex -md:flex-col -xl:gap-2 gap-8 justify-between">
<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

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