Merge branch 'develop' into ENCOA-77_GenerationTitle

This commit is contained in:
Joao Ramos
2024-08-13 21:30:50 +01:00
47 changed files with 5877 additions and 5333 deletions

643
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -25,6 +25,7 @@
"@types/node": "18.13.0", "@types/node": "18.13.0",
"@types/react": "18.0.27", "@types/react": "18.0.27",
"@types/react-dom": "18.0.10", "@types/react-dom": "18.0.10",
"@use-gesture/react": "^10.3.1",
"axios": "^1.3.5", "axios": "^1.3.5",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"chart.js": "^4.2.1", "chart.js": "^4.2.1",
@@ -67,14 +68,12 @@
"react-phone-number-input": "^3.3.6", "react-phone-number-input": "^3.3.6",
"react-player": "^2.12.0", "react-player": "^2.12.0",
"react-select": "^5.7.5", "react-select": "^5.7.5",
"react-slick": "^0.30.2",
"react-string-replace": "^1.1.0", "react-string-replace": "^1.1.0",
"react-toastify": "^9.1.2", "react-toastify": "^9.1.2",
"react-tooltip": "^5.27.1", "react-tooltip": "^5.27.1",
"react-xarrows": "^2.0.2", "react-xarrows": "^2.0.2",
"read-excel-file": "^5.7.1", "read-excel-file": "^5.7.1",
"short-unique-id": "5.0.2", "short-unique-id": "5.0.2",
"slick-carousel": "^1.8.1",
"stripe": "^13.10.0", "stripe": "^13.10.0",
"swr": "^2.1.3", "swr": "^2.1.3",
"tailwind-scrollbar-hide": "^1.1.7", "tailwind-scrollbar-hide": "^1.1.7",
@@ -94,7 +93,6 @@
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@types/react-csv": "^1.1.10", "@types/react-csv": "^1.1.10",
"@types/react-datepicker": "^4.15.1", "@types/react-datepicker": "^4.15.1",
"@types/react-slick": "^0.23.13",
"@types/uuid": "^9.0.1", "@types/uuid": "^9.0.1",
"@types/wavesurfer.js": "^6.0.6", "@types/wavesurfer.js": "^6.0.6",
"@wixc3/react-board": "^2.2.0", "@wixc3/react-board": "^2.2.0",

View File

@@ -2,7 +2,7 @@ import React, { useState, ReactNode, useRef, useEffect } from 'react';
import { animated, useSpring } from '@react-spring/web'; import { animated, useSpring } from '@react-spring/web';
interface DropdownProps { interface DropdownProps {
title: string; title: ReactNode;
open?: boolean; open?: boolean;
className?: string; className?: string;
contentWrapperClassName?: string; contentWrapperClassName?: string;

View File

@@ -1,33 +1,34 @@
import {SpeakingExercise} from "@/interfaces/exam"; import { SpeakingExercise } from "@/interfaces/exam";
import {CommonProps} from "."; import { CommonProps } from ".";
import {Fragment, useEffect, useState} from "react"; import { Fragment, useEffect, useState } from "react";
import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill} from "react-icons/bs"; import { BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill } from "react-icons/bs";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import Button from "../Low/Button"; import Button from "../Low/Button";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {downloadBlob} from "@/utils/evaluation"; import { downloadBlob } from "@/utils/evaluation";
import axios from "axios"; import axios from "axios";
import Modal from "../Modal"; import Modal from "../Modal";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false}); const Waveform = dynamic(() => import("../Waveform"), { ssr: false });
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), { const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
ssr: false, ssr: false,
}); });
export default function Speaking({id, title, text, video_url, type, prompts, suffix, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) { export default function Speaking({ id, title, text, video_url, type, prompts, suffix, userSolutions, onNext, onBack }: SpeakingExercise & CommonProps) {
const [recordingDuration, setRecordingDuration] = useState(0); const [recordingDuration, setRecordingDuration] = useState(0);
const [isRecording, setIsRecording] = useState(false); const [isRecording, setIsRecording] = useState(false);
const [mediaBlob, setMediaBlob] = useState<string>(); const [mediaBlob, setMediaBlob] = useState<string>();
const [audioURL, setAudioURL] = useState<string>(); const [audioURL, setAudioURL] = useState<string>();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isPromptsModalOpen, setIsPromptsModalOpen] = useState(false); const [isPromptsModalOpen, setIsPromptsModalOpen] = useState(false);
const [inputText, setInputText] = useState("");
const hasExamEnded = useExamStore((state) => state.hasExamEnded); const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const saveToStorage = async () => { const saveToStorage = async () => {
if (mediaBlob && mediaBlob.startsWith("blob")) { if (mediaBlob && mediaBlob.startsWith("blob")) {
const blobBuffer = await downloadBlob(mediaBlob); const blobBuffer = await downloadBlob(mediaBlob);
const audioFile = new File([blobBuffer], "audio.wav", {type: "audio/wav"}); const audioFile = new File([blobBuffer], "audio.wav", { type: "audio/wav" });
const seed = Math.random().toString().replace("0.", ""); const seed = Math.random().toString().replace("0.", "");
@@ -41,8 +42,8 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
}, },
}; };
const response = await axios.post<{path: string}>("/api/storage/insert", formData, config); const response = await axios.post<{ path: string }>("/api/storage/insert", formData, config);
if (audioURL) await axios.post("/api/storage/delete", {path: audioURL}); if (audioURL) await axios.post("/api/storage/delete", { path: audioURL });
return response.data.path; return response.data.path;
} }
@@ -51,7 +52,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
useEffect(() => { useEffect(() => {
if (userSolutions.length > 0) { if (userSolutions.length > 0) {
const {solution} = userSolutions[0] as {solution?: string}; const { solution } = userSolutions[0] as { solution?: string };
if (solution && !mediaBlob) setMediaBlob(solution); if (solution && !mediaBlob) setMediaBlob(solution);
if (solution && !solution.startsWith("blob")) setAudioURL(solution); if (solution && !solution.startsWith("blob")) setAudioURL(solution);
} }
@@ -78,8 +79,8 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
const next = async () => { const next = async () => {
onNext({ onNext({
exercise: id, exercise: id,
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [], solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [],
score: {correct: 0, total: 100, missing: 0}, score: { correct: 0, total: 100, missing: 0 },
type, type,
}); });
}; };
@@ -87,12 +88,33 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
const back = async () => { const back = async () => {
onBack({ onBack({
exercise: id, exercise: id,
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [], solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [],
score: {correct: 0, total: 100, missing: 0}, score: { correct: 0, total: 100, missing: 0 },
type, type,
}); });
}; };
const handleNoteWriting = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newText = e.target.value;
const words = newText.match(/\S+/g);
const wordCount = words ? words.length : 0;
if (wordCount <= 100) {
setInputText(newText);
} else {
let count = 0;
let lastIndex = 0;
const matches = newText.matchAll(/\S+/g);
for (const match of matches) {
count++;
if (count > 100) break;
lastIndex = match.index! + match[0].length;
}
setInputText(newText.slice(0, lastIndex));
}
};
return ( return (
<div className="flex flex-col h-full w-full gap-9"> <div className="flex flex-col h-full w-full gap-9">
<Modal title="Prompts" className="!w-96 aspect-square" isOpen={isPromptsModalOpen} onClose={() => setIsPromptsModalOpen(false)}> <Modal title="Prompts" className="!w-96 aspect-square" isOpen={isPromptsModalOpen} onClose={() => setIsPromptsModalOpen(false)}>
@@ -112,7 +134,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
<div className="flex flex-col gap-0"> <div className="flex flex-col gap-0">
<span className="font-semibold">{title}</span> <span className="font-semibold">{title}</span>
{prompts.length > 0 && ( {prompts.length > 0 && (
<span className="font-semibold">You should talk for at least 30 seconds for your answer to be valid.</span> <span className="font-semibold">You should talk for at least 1 minute and 30 seconds for your answer to be valid.</span>
)} )}
</div> </div>
{!video_url && ( {!video_url && (
@@ -138,10 +160,24 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
</div> </div>
</div> </div>
{prompts && prompts.length > 0 && (
<div className="w-full h-full flex flex-col gap-4">
<textarea
onContextMenu={(e) => e.preventDefault()}
className="w-full h-full min-h-[200px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
onChange={handleNoteWriting}
value={inputText}
placeholder="Write your notes here..."
spellCheck={false}
/>
<span className="text-base self-end text-mti-gray-cool">Word Count: {(inputText.match(/\S+/g) || []).length}/100</span>
</div>
)}
<ReactMediaRecorder <ReactMediaRecorder
audio audio
onStop={(blob) => setMediaBlob(blob)} onStop={(blob) => setMediaBlob(blob)}
render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => ( render={({ status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl }) => (
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center"> <div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
<p className="text-base font-normal">Record your answer:</p> <p className="text-base font-normal">Record your answer:</p>
<div className="flex gap-8 items-center justify-center py-8"> <div className="flex gap-8 items-center justify-center py-8">

View File

@@ -1,22 +1,16 @@
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;
@@ -24,23 +18,20 @@ interface Props {
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
@@ -54,7 +45,7 @@ export default function TicketDisplay({ user, ticket, onClose }: Props) {
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) => {
@@ -73,7 +64,7 @@ export default function TicketDisplay({ user, ticket, onClose }: Props) {
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) => {
@@ -87,43 +78,29 @@ export default function TicketDisplay({ user, ticket, onClose }: Props) {
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
</label>
<Select <Select
options={Object.keys(TicketStatusLabel).map((x) => ({ options={Object.keys(TicketStatusLabel).map((x) => ({
value: x, value: x,
label: TicketStatusLabel[x as keyof typeof TicketStatusLabel], label: TicketStatusLabel[x as keyof typeof TicketStatusLabel],
}))} }))}
value={{ value: status, label: TicketStatusLabel[status] }} value={{value: status, label: TicketStatusLabel[status]}}
onChange={(value) => onChange={(value) => setStatus((value?.value as TicketStatus) ?? undefined)}
setStatus((value?.value as TicketStatus) ?? undefined)
}
placeholder="Status..." placeholder="Status..."
/> />
</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">Type</label>
Type
</label>
<Select <Select
options={Object.keys(TicketTypeLabel).map((x) => ({ options={Object.keys(TicketTypeLabel).map((x) => ({
value: x, value: x,
label: TicketTypeLabel[x as keyof typeof TicketTypeLabel], label: TicketTypeLabel[x as keyof typeof TicketTypeLabel],
}))} }))}
value={{ value: type, label: TicketTypeLabel[type] }} value={{value: type, label: TicketTypeLabel[type]}}
onChange={(value) => setType(value!.value as TicketType)} onChange={(value) => setType(value!.value as TicketType)}
placeholder="Type..." placeholder="Type..."
/> />
@@ -131,12 +108,10 @@ export default function TicketDisplay({ user, ticket, onClose }: Props) {
</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
</label>
<Select <Select
options={[ options={[
{ value: "me", label: "Assign to me" }, {value: "me", label: "Assign to me"},
...users ...users
.filter((x) => checkAccess(x, ["admin", "developer", "agent"])) .filter((x) => checkAccess(x, ["admin", "developer", "agent"]))
.map((u) => ({ .map((u) => ({
@@ -153,52 +128,20 @@ export default function TicketDisplay({ user, ticket, onClose }: Props) {
} }
: null : null
} }
onChange={(value) => onChange={(value) => (value ? setAssignedTo(value.value === "me" ? user.id : value.value) : setAssignedTo(null))}
value
? setAssignedTo(value.value === "me" ? user.id : value.value)
: setAssignedTo(null)
}
placeholder="Assignee..." placeholder="Assignee..."
isClearable isClearable
/> />
</div> </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"
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>
<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"
name="reporter"
onChange={() => null}
value={reporter.name}
disabled
/>
<Input
label="Reporter's E-mail"
type="text"
name="reporter"
onChange={() => null}
value={reporter.email}
disabled
/>
<Input <Input
label="Reporter's Type" label="Reporter's Type"
type="text" type="text"
@@ -218,34 +161,15 @@ export default function TicketDisplay({ user, ticket, onClose }: Props) {
/> />
<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"
color="red"
className="w-full md:max-w-[200px]"
variant="outline"
onClick={del}
isLoading={isLoading}
>
Delete Delete
</Button> </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"
color="red"
className="w-full md:max-w-[200px]"
variant="outline"
onClick={onClose}
isLoading={isLoading}
>
Cancel Cancel
</Button> </Button>
<Button <Button type="button" className="w-full md:max-w-[200px]" isLoading={isLoading} onClick={submit}>
type="button"
className="w-full md:max-w-[200px]"
isLoading={isLoading}
onClick={submit}
>
Update Update
</Button> </Button>
</div> </div>

View File

@@ -0,0 +1,168 @@
import React, { useRef, useEffect, useState, useCallback, ReactNode } from 'react';
import { useSpring, animated } from '@react-spring/web';
import { useDrag } from '@use-gesture/react';
import clsx from 'clsx';
interface InfiniteCarouselProps {
children: React.ReactNode;
height: string;
speed?: number;
gap?: number;
overlay?: ReactNode;
overlayFunc?: (index: number) => void;
overlayClassName?: string;
}
const InfiniteCarousel: React.FC<InfiniteCarouselProps> = ({
children,
height,
speed = 20000,
gap = 16,
overlay = undefined,
overlayFunc = undefined,
overlayClassName = ""
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState<number>(0);
const itemCount = React.Children.count(children);
const [isDragging, setIsDragging] = useState<boolean>(false);
const [itemWidth, setItemWidth] = useState<number>(0);
const [isInfinite, setIsInfinite] = useState<boolean>(true);
const dragStartX = useRef<number>(0);
useEffect(() => {
const handleResize = () => {
if (containerRef.current) {
const containerWidth = containerRef.current.clientWidth;
setContainerWidth(containerWidth);
const firstChild = containerRef.current.firstElementChild?.firstElementChild as HTMLElement;
if (firstChild) {
const childWidth = firstChild.offsetWidth;
setItemWidth(childWidth);
const totalContentWidth = (childWidth + gap) * itemCount - gap;
setIsInfinite(totalContentWidth > containerWidth);
}
}
};
handleResize();
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [gap, itemCount]);
const totalWidth = (itemWidth + gap) * itemCount;
const [{ x }, api] = useSpring(() => ({
from: { x: 0 },
to: { x: -totalWidth },
config: { duration: speed },
loop: true,
}));
const startAnimation = useCallback(() => {
if (isInfinite) {
api.start({
from: { x: x.get() },
to: { x: x.get() - totalWidth },
config: { duration: speed },
loop: true,
});
} else {
api.stop();
api.start({ x: 0, immediate: true });
}
}, [api, x, totalWidth, speed, isInfinite]);
useEffect(() => {
if (containerWidth > 0 && !isDragging) {
startAnimation();
}
}, [containerWidth, isDragging, startAnimation]);
const bind = useDrag(({ down, movement: [mx], first }) => {
if (!isInfinite) return;
if (first) {
setIsDragging(true);
api.stop();
dragStartX.current = x.get();
}
if (down) {
let newX = dragStartX.current + mx;
newX = ((newX % totalWidth) + totalWidth) % totalWidth;
if (newX > 0) newX -= totalWidth;
api.start({ x: newX, immediate: true });
} else {
setIsDragging(false);
startAnimation();
}
}, {
filterTaps: true,
from: () => [x.get(), 0],
});
return (
<div
className="overflow-hidden relative select-none"
style={{ height, touchAction: 'pan-y' }}
ref={containerRef}
{...(isInfinite ? bind() : {})}
>
<animated.div
className="flex"
style={{
display: 'flex',
willChange: 'transform',
transform: isInfinite
? x.to((x) => `translate3d(${x}px, 0, 0)`)
: 'none',
gap: `${gap}px`,
width: 'fit-content',
}}
>
{React.Children.map(children, (child, i) => (
<div
key={i}
className="flex-shrink-0 relative"
>
{overlay !== undefined && overlayFunc !== undefined && (
<div className={clsx('absolute', overlayClassName)} onClick={() => overlayFunc(i)}>
{overlay}
</div>
)}
<div
className="select-none"
style={{ pointerEvents: 'none' }}
>
{child}
</div>
</div>
))}
{isInfinite && React.Children.map(children, (child, i) => (
<div
key={`clone-${i}`}
className="flex-shrink-0 relative"
>
{overlay !== undefined && overlayFunc !== undefined && (
<div className={clsx('absolute', overlayClassName)} onClick={() => overlayFunc(i)}>
{overlay}
</div>
)}
<div
className="select-none"
style={{ pointerEvents: 'none' }}
>
{child}
</div>
</div>
))}
</animated.div>
</div>
);
};
export default InfiniteCarousel;

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,12 +171,15 @@ 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"]), permissions, "viewRecords") && (
<Nav disabled={disableNavigation} Icon={CiDumbbell} label="Training" path={path} keyPath="/training" isMinimized={true} />
)}
{checkAccess(user, getTypesOfUser(["student"])) && ( {checkAccess(user, getTypesOfUser(["student"])) && (
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Settings" path={path} keyPath="/settings" isMinimized={true} /> <Nav disabled={disableNavigation} Icon={BsShieldFill} label="Settings" path={path} keyPath="/settings" isMinimized={true} />
)} )}

View File

@@ -1,17 +1,17 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import {InteractiveSpeakingExercise} from "@/interfaces/exam"; import { InteractiveSpeakingExercise } from "@/interfaces/exam";
import {CommonProps} from "."; import { CommonProps } from ".";
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import Button from "../Low/Button"; import Button from "../Low/Button";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import axios from "axios"; import axios from "axios";
import {speakingReverseMarking} from "@/utils/score"; import { speakingReverseMarking } from "@/utils/score";
import {Tab} from "@headlessui/react"; import { Tab } from "@headlessui/react";
import clsx from "clsx"; import clsx from "clsx";
import Modal from "../Modal"; import Modal from "../Modal";
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer"; import ReactDiffViewer, { DiffMethod } from "react-diff-viewer";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false}); const Waveform = dynamic(() => import("../Waveform"), { ssr: false });
export default function InteractiveSpeaking({ export default function InteractiveSpeaking({
id, id,
@@ -24,15 +24,22 @@ export default function InteractiveSpeaking({
onBack, onBack,
}: InteractiveSpeakingExercise & CommonProps) { }: InteractiveSpeakingExercise & CommonProps) {
const [solutionsURL, setSolutionsURL] = useState<string[]>([]); const [solutionsURL, setSolutionsURL] = useState<string[]>([]);
const [diffNumber, setDiffNumber] = useState<0 | 1 | 2 | 3>(0); const [diffNumber, setDiffNumber] = useState(0);
const tooltips: { [key: string]: string } = {
"Grammatical Range and Accuracy": "Assesses the variety and correctness of grammatical structures used. A higher score indicates a wide range of complex and accurate grammar; a lower score suggests the need for more basic grammar practice.",
"Fluency and Coherence": "Evaluates smoothness and logical flow of speech. A higher score means natural, effortless speech and clear idea progression; a lower score indicates frequent pauses and difficulty in maintaining coherence.",
"Pronunciation": "Measures clarity and accuracy of spoken words. A higher score reflects clear, well-articulated speech with correct intonation; a lower score shows challenges in being understood.",
"Lexical Resource": "Looks at the range and appropriateness of vocabulary. A higher score demonstrates a rich and precise vocabulary; a lower score suggests limited vocabulary usage and appropriateness.",
};
useEffect(() => { useEffect(() => {
if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) { if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) {
Promise.all(userSolutions[0].solution.map((x) => axios.post(`/api/speaking`, {path: x.answer}, {responseType: "arraybuffer"}))).then( Promise.all(userSolutions[0].solution.map((x) => axios.post(`/api/speaking`, { path: x.answer }, { responseType: "arraybuffer" }))).then(
(values) => { (values) => {
setSolutionsURL( setSolutionsURL(
values.map(({data}) => { values.map(({ data }) => {
const blob = new Blob([data], {type: "audio/wav"}); const blob = new Blob([data], { type: "audio/wav" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
return url; return url;
@@ -64,13 +71,13 @@ export default function InteractiveSpeaking({
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif', fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
padding: "32px 28px", padding: "32px 28px",
}, },
marker: {display: "none"}, marker: { display: "none" },
diffRemoved: {padding: "32px 28px"}, diffRemoved: { padding: "32px 28px" },
diffAdded: {padding: "32px 28px"}, diffAdded: { padding: "32px 28px" },
wordRemoved: {padding: "0px", display: "initial"}, wordRemoved: { padding: "0px", display: "initial" },
wordAdded: {padding: "0px", display: "initial"}, wordAdded: { padding: "0px", display: "initial" },
wordDiff: {padding: "0px", display: "initial"}, wordDiff: { padding: "0px", display: "initial" },
}} }}
oldValue={userSolutions[0].evaluation[`transcript_${diffNumber}`]?.replaceAll("\\n", "\n")} oldValue={userSolutions[0].evaluation[`transcript_${diffNumber}`]?.replaceAll("\\n", "\n")}
newValue={userSolutions[0].evaluation[`fixed_text_${diffNumber}`]?.replaceAll("\\n", "\n")} newValue={userSolutions[0].evaluation[`fixed_text_${diffNumber}`]?.replaceAll("\\n", "\n")}
@@ -115,13 +122,13 @@ export default function InteractiveSpeaking({
{userSolutions && {userSolutions &&
userSolutions.length > 0 && userSolutions.length > 0 &&
userSolutions[0].evaluation && userSolutions[0].evaluation &&
userSolutions[0].evaluation[`transcript_${(index + 1) as 1 | 2 | 3}`] && userSolutions[0].evaluation[`transcript_${(index + 1)}`] &&
userSolutions[0].evaluation[`fixed_text_${(index + 1) as 1 | 2 | 3}`] && ( userSolutions[0].evaluation[`fixed_text_${(index + 1)}`] && (
<Button <Button
className="w-full max-w-[180px] !py-2 self-center" className="w-full max-w-[180px] !py-2 self-center"
color="pink" color="pink"
variant="outline" variant="outline"
onClick={() => setDiffNumber((index + 1) as 1 | 2 | 3)}> onClick={() => setDiffNumber((index + 1))}>
View Correction View Correction
</Button> </Button>
)} )}
@@ -132,23 +139,36 @@ export default function InteractiveSpeaking({
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && ( {userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
<div className="flex flex-col gap-4 w-full"> <div className="flex flex-col gap-4 w-full">
<div className="flex gap-4 px-1"> <div className="flex gap-4 px-1">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => { {Object.keys(userSolutions[0].evaluation!.task_response).map((key, index) => {
const taskResponse = userSolutions[0].evaluation!.task_response[key]; const taskResponse = userSolutions[0].evaluation!.task_response[key];
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade; const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
return ( return (
<div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2")} key={key}> <div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 tooltip tooltip-bottom",
index === 0 && "tooltip-right"
)} key={key} data-tip={tooltips[key] || "No additional information available"}>
{key}: Level {grade} {key}: Level {grade}
</div> </div>
); );
})} })}
</div> </div>
{userSolutions[0].evaluation && {userSolutions[0].evaluation &&
Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).length === 3 ? ( Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).length > 0 ? (
<Tab.Group> <Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1"> <Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
<Tab <Tab
className={({selected}) => className={({ selected }) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
)
}>
General Feedback
</Tab>
<Tab
className={({ selected }) =>
clsx( clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80", "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2", "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
@@ -158,8 +178,10 @@ export default function InteractiveSpeaking({
}> }>
Evaluation Evaluation
</Tab> </Tab>
{Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).map((key, index) => (
<Tab <Tab
className={({selected}) => key={key}
className={({ selected }) =>
clsx( clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80", "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2", "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
@@ -167,61 +189,11 @@ export default function InteractiveSpeaking({
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking", selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
) )
}> }>
Recommended Answer (Prompt 1) Recommended Answer<br />(Prompt {index + 1})
</Tab>
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
)
}>
Recommended Answer (Prompt 2)
</Tab>
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
)
}>
Recommended Answer (Prompt 3)
</Tab>
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
)
}>
Global Overview
</Tab> </Tab>
))}
</Tab.List> </Tab.List>
<Tab.Panels> <Tab.Panels>
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
</Tab.Panel>
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
{userSolutions[0].evaluation!.perfect_answer_1!.answer.replaceAll(/\s{2,}/g, "\n\n")}
</span>
</Tab.Panel>
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
{userSolutions[0].evaluation!.perfect_answer_2!.answer.replaceAll(/\s{2,}/g, "\n\n")}
</span>
</Tab.Panel>
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
{userSolutions[0].evaluation!.perfect_answer_3!.answer.replaceAll(/\s{2,}/g, "\n\n")}
</span>
</Tab.Panel>
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4"> <Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => { {Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
@@ -230,15 +202,25 @@ export default function InteractiveSpeaking({
return ( return (
<div key={key} className="flex flex-col gap-2"> <div key={key} className="flex flex-col gap-2">
<span className={"font-semibold"}> <div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 w-fit")} key={key}>
{key}: Level {grade} {key}: Level {grade}
</span> </div>
{typeof taskResponse !== "number" && <span>{taskResponse.comment}</span>} {typeof taskResponse !== "number" && <span className="px-2 py-2">{taskResponse.comment}</span>}
</div> </div>
); );
})} })}
</div> </div>
</Tab.Panel> </Tab.Panel>
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
</Tab.Panel>
{Object.keys(userSolutions[0].evaluation).filter((x) => x.startsWith("perfect_answer")).map((key, index) => (
<Tab.Panel key={key} className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
{userSolutions[0].evaluation![`perfect_answer_${(index + 1)}`].answer.replaceAll(/\s{2,}/g, "\n\n")}
</span>
</Tab.Panel>
))}
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>
) : ( ) : (
@@ -259,7 +241,7 @@ export default function InteractiveSpeaking({
onBack({ onBack({
exercise: id, exercise: id,
solutions: userSolutions, solutions: userSolutions,
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0}, score: { total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 },
type, type,
}) })
} }

View File

@@ -1,20 +1,20 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import {SpeakingExercise} from "@/interfaces/exam"; import { SpeakingExercise } from "@/interfaces/exam";
import {CommonProps} from "."; import { CommonProps } from ".";
import {Fragment, useEffect, useState} from "react"; import { Fragment, useEffect, useState } from "react";
import Button from "../Low/Button"; import Button from "../Low/Button";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import axios from "axios"; import axios from "axios";
import {speakingReverseMarking} from "@/utils/score"; import { speakingReverseMarking } from "@/utils/score";
import {Tab} from "@headlessui/react"; import { Tab } from "@headlessui/react";
import clsx from "clsx"; import clsx from "clsx";
import Modal from "../Modal"; import Modal from "../Modal";
import {BsQuestionCircleFill} from "react-icons/bs"; import { BsQuestionCircleFill } from "react-icons/bs";
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer"; import ReactDiffViewer, { DiffMethod } from "react-diff-viewer";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false}); const Waveform = dynamic(() => import("../Waveform"), { ssr: false });
export default function Speaking({id, type, title, video_url, text, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) { export default function Speaking({ id, type, title, video_url, text, prompts, userSolutions, onNext, onBack }: SpeakingExercise & CommonProps) {
const [solutionURL, setSolutionURL] = useState<string>(); const [solutionURL, setSolutionURL] = useState<string>();
const [showDiff, setShowDiff] = useState(false); const [showDiff, setShowDiff] = useState(false);
@@ -23,8 +23,8 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
const solution = userSolutions[0].solution; const solution = userSolutions[0].solution;
if (solution.startsWith("https://")) return setSolutionURL(solution); if (solution.startsWith("https://")) return setSolutionURL(solution);
axios.post(`/api/speaking`, {path: userSolutions[0].solution}, {responseType: "arraybuffer"}).then(({data}) => { axios.post(`/api/speaking`, { path: userSolutions[0].solution }, { responseType: "arraybuffer" }).then(({ data }) => {
const blob = new Blob([data], {type: "audio/wav"}); const blob = new Blob([data], { type: "audio/wav" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
setSolutionURL(url); setSolutionURL(url);
@@ -32,6 +32,13 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
} }
}, [userSolutions]); }, [userSolutions]);
const tooltips: { [key: string]: string } = {
"Grammatical Range and Accuracy": "Assesses the variety and correctness of grammatical structures used. A higher score indicates a wide range of complex and accurate grammar; a lower score suggests the need for more basic grammar practice.",
"Fluency and Coherence": "Evaluates smoothness and logical flow of speech. A higher score means natural, effortless speech and clear idea progression; a lower score indicates frequent pauses and difficulty in maintaining coherence.",
"Pronunciation": "Measures clarity and accuracy of spoken words. A higher score reflects clear, well-articulated speech with correct intonation; a lower score shows challenges in being understood.",
"Lexical Resource": "Looks at the range and appropriateness of vocabulary. A higher score demonstrates a rich and precise vocabulary; a lower score suggests limited vocabulary usage and appropriateness.",
};
return ( return (
<> <>
<Modal title="Correction" isOpen={showDiff} onClose={() => setShowDiff(false)}> <Modal title="Correction" isOpen={showDiff} onClose={() => setShowDiff(false)}>
@@ -51,13 +58,13 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif', fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
padding: "32px 28px", padding: "32px 28px",
}, },
marker: {display: "none"}, marker: { display: "none" },
diffRemoved: {padding: "32px 28px"}, diffRemoved: { padding: "32px 28px" },
diffAdded: {padding: "32px 28px"}, diffAdded: { padding: "32px 28px" },
wordRemoved: {padding: "0px", display: "initial"}, wordRemoved: { padding: "0px", display: "initial" },
wordAdded: {padding: "0px", display: "initial"}, wordAdded: { padding: "0px", display: "initial" },
wordDiff: {padding: "0px", display: "initial"}, wordDiff: { padding: "0px", display: "initial" },
}} }}
oldValue={userSolutions[0].evaluation.transcript_1.replaceAll("\\n", "\n")} oldValue={userSolutions[0].evaluation.transcript_1.replaceAll("\\n", "\n")}
newValue={userSolutions[0].evaluation.fixed_text_1.replaceAll("\\n", "\n")} newValue={userSolutions[0].evaluation.fixed_text_1.replaceAll("\\n", "\n")}
@@ -126,12 +133,14 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && ( {userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
<div className="flex flex-col gap-4 w-full"> <div className="flex flex-col gap-4 w-full">
<div className="flex gap-4 px-1"> <div className="flex gap-4 px-1">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => { {Object.keys(userSolutions[0].evaluation!.task_response).map((key, index) => {
const taskResponse = userSolutions[0].evaluation!.task_response[key]; const taskResponse = userSolutions[0].evaluation!.task_response[key];
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade; const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
return ( return (
<div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2")} key={key}> <div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 tooltip tooltip-bottom",
index === 0 && "tooltip-right"
)} key={key} data-tip={tooltips[key] || "No additional information available"}>
{key}: Level {grade} {key}: Level {grade}
</div> </div>
); );
@@ -142,7 +151,18 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
<Tab.Group> <Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1"> <Tab.List className="flex space-x-1 rounded-xl bg-ielts-speaking/20 p-1">
<Tab <Tab
className={({selected}) => className={({ selected }) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
)
}>
General Feedback
</Tab>
<Tab
className={({ selected }) =>
clsx( clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80", "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2", "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
@@ -153,7 +173,7 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
Evaluation Evaluation
</Tab> </Tab>
<Tab <Tab
className={({selected}) => className={({ selected }) =>
clsx( clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80", "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2", "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
@@ -163,30 +183,9 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
}> }>
Recommended Answer Recommended Answer
</Tab> </Tab>
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-speaking/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-speaking focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-speaking",
)
}>
Global Overview
</Tab>
</Tab.List> </Tab.List>
<Tab.Panels> <Tab.Panels>
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4"> {/* General Feedback */}
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
</Tab.Panel>
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
{userSolutions[0].evaluation!.perfect_answer &&
userSolutions[0].evaluation!.perfect_answer.replaceAll(/\s{2,}/g, "\n\n")}
{userSolutions[0].evaluation!.perfect_answer_1 &&
userSolutions[0].evaluation!.perfect_answer_1.replaceAll(/\s{2,}/g, "\n\n")}
</span>
</Tab.Panel>
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4"> <Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => { {Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
@@ -195,15 +194,28 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
return ( return (
<div key={key} className="flex flex-col gap-2"> <div key={key} className="flex flex-col gap-2">
<span className={"font-semibold"}> <div className={clsx("bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2 w-fit")} key={key}>
{key}: Level {grade} {key}: Level {grade}
</span> </div>
{typeof taskResponse !== "number" && <span>{taskResponse.comment}</span>} {typeof taskResponse !== "number" && <span className="px-2 py-2">{taskResponse.comment}</span>}
</div> </div>
); );
})} })}
</div> </div>
</Tab.Panel> </Tab.Panel>
{/* Evaluation */}
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
</Tab.Panel>
{/* Recommended Answer */}
<Tab.Panel className="w-full bg-ielts-speaking/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
{userSolutions[0].evaluation!.perfect_answer &&
userSolutions[0].evaluation!.perfect_answer.replaceAll(/\s{2,}/g, "\n\n")}
{userSolutions[0].evaluation!.perfect_answer_1 &&
userSolutions[0].evaluation!.perfect_answer_1.replaceAll(/\s{2,}/g, "\n\n")}
</span>
</Tab.Panel>
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>
) : ( ) : (
@@ -224,7 +236,7 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
onBack({ onBack({
exercise: id, exercise: id,
solutions: userSolutions, solutions: userSolutions,
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0}, score: { total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 },
type, type,
}) })
} }

View File

@@ -1,23 +1,30 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import {WritingExercise} from "@/interfaces/exam"; import { WritingExercise } from "@/interfaces/exam";
import {CommonProps} from "."; import { CommonProps } from ".";
import {Fragment, useEffect, useState} from "react"; import { Fragment, useEffect, useState } from "react";
import Button from "../Low/Button"; import Button from "../Low/Button";
import {Dialog, Tab, Transition} from "@headlessui/react"; import { Dialog, Tab, Transition } from "@headlessui/react";
import {writingReverseMarking} from "@/utils/score"; import { writingReverseMarking } from "@/utils/score";
import clsx from "clsx"; import clsx from "clsx";
import ReactDiffViewer, {DiffMethod} from "react-diff-viewer"; import ReactDiffViewer, { DiffMethod } from "react-diff-viewer";
import useUser from "@/hooks/useUser"; import useUser from "@/hooks/useUser";
import AIDetection from "../AIDetection"; import AIDetection from "../AIDetection";
export default function Writing({id, type, prompt, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) { export default function Writing({ id, type, prompt, attachment, userSolutions, onNext, onBack }: WritingExercise & CommonProps) {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [showDiff, setShowDiff] = useState(false); const [showDiff, setShowDiff] = useState(false);
const {user} = useUser(); const { user } = useUser();
const aiEval = userSolutions && userSolutions.length > 0 ? userSolutions[0].evaluation?.ai_detection : undefined; const aiEval = userSolutions && userSolutions.length > 0 ? userSolutions[0].evaluation?.ai_detection : undefined;
const tooltips: { [key: string]: string } = {
"Lexical Resource": "Assesses the diversity and accuracy of vocabulary used. A higher score indicates varied and precise word choice; a lower score points to limited vocabulary and inaccuracies.",
"Task Achievement": "Evaluates how well the task requirements are fulfilled. A higher score means all parts of the task are addressed thoroughly; a lower score shows incomplete or inadequate task response.",
"Coherence and Cohesion": "Measures logical organization and flow of writing. A higher score reflects well-structured and connected ideas; a lower score indicates disorganized writing and poor linkage between ideas.",
"Grammatical Range and Accuracy": "Looks at the range and precision of grammatical structures. A higher score shows varied and accurate grammar use; a lower score suggests frequent errors and limited range.",
};
return ( return (
<> <>
{attachment && ( {attachment && (
@@ -92,13 +99,13 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif', fontFamily: '"Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif',
padding: "32px 28px", padding: "32px 28px",
}, },
marker: {display: "none"}, marker: { display: "none" },
diffRemoved: {padding: "32px 28px"}, diffRemoved: { padding: "32px 28px" },
diffAdded: {padding: "32px 28px"}, diffAdded: { padding: "32px 28px" },
wordRemoved: {padding: "0px", display: "initial"}, wordRemoved: { padding: "0px", display: "initial" },
wordAdded: {padding: "0px", display: "initial"}, wordAdded: { padding: "0px", display: "initial" },
wordDiff: {padding: "0px", display: "initial"}, wordDiff: { padding: "0px", display: "initial" },
}} }}
oldValue={userSolutions[0].solution.replaceAll("\\n", "\n")} oldValue={userSolutions[0].solution.replaceAll("\\n", "\n")}
newValue={userSolutions[0].evaluation!.fixed_text!.replaceAll("\\n", "\n")} newValue={userSolutions[0].evaluation!.fixed_text!.replaceAll("\\n", "\n")}
@@ -123,12 +130,15 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && ( {userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && (
<div className="flex flex-col gap-4 w-full"> <div className="flex flex-col gap-4 w-full">
<div className="flex gap-4 px-1"> <div className="flex gap-4 px-1">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => { {Object.keys(userSolutions[0].evaluation!.task_response).map((key, index) => {
const taskResponse = userSolutions[0].evaluation!.task_response[key]; const taskResponse = userSolutions[0].evaluation!.task_response[key];
const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade; const grade: number = typeof taskResponse === "number" ? taskResponse : taskResponse.grade;
return ( return (
<div className={clsx("bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2")} key={key}> <div className={clsx(
"bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2 tooltip tooltip-bottom",
index === 0 && "tooltip-right"
)} key={key} data-tip={tooltips[key] || "No additional information available"}>
{key}: Level {grade} {key}: Level {grade}
</div> </div>
); );
@@ -138,7 +148,18 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
<Tab.Group> <Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-ielts-writing/20 p-1"> <Tab.List className="flex space-x-1 rounded-xl bg-ielts-writing/20 p-1">
<Tab <Tab
className={({selected}) => className={({ selected }) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
)
}>
General Feedback
</Tab>
<Tab
className={({ selected }) =>
clsx( clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80", "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2", "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
@@ -149,7 +170,7 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
Evaluation Evaluation
</Tab> </Tab>
<Tab <Tab
className={({selected}) => className={({ selected }) =>
clsx( clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80", "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2", "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
@@ -159,20 +180,9 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
}> }>
Recommended Answer Recommended Answer
</Tab> </Tab>
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-ielts-writing",
)
}>
Global Overview
</Tab>
{aiEval && user?.type !== "student" && ( {aiEval && user?.type !== "student" && (
<Tab <Tab
className={({selected}) => className={({ selected }) =>
clsx( clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80", "w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-writing/80",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2", "ring-white ring-opacity-60 ring-offset-2 ring-offset-ielts-writing focus:outline-none focus:ring-2",
@@ -185,14 +195,7 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
)} )}
</Tab.List> </Tab.List>
<Tab.Panels> <Tab.Panels>
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4"> {/* Global */}
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
</Tab.Panel>
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
{userSolutions[0].evaluation!.perfect_answer.replaceAll(/\s{2,}/g, "\n\n").replaceAll("\\n", "\n")}
</span>
</Tab.Panel>
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4"> <Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => { {Object.keys(userSolutions[0].evaluation!.task_response).map((key) => {
@@ -201,15 +204,25 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
return ( return (
<div key={key} className="flex flex-col gap-2"> <div key={key} className="flex flex-col gap-2">
<span className={"font-semibold"}> <div className={clsx("bg-ielts-writing text-ielts-writing-light rounded-xl px-4 py-2 w-fit")} key={key}>
{key}: Level {grade} {key}: Level {grade}
</span> </div>
{typeof taskResponse !== "number" && <span>{taskResponse.comment}</span>} {typeof taskResponse !== "number" && <span className="px-2 py-2">{taskResponse.comment}</span>}
</div> </div>
); );
})} })}
</div> </div>
</Tab.Panel> </Tab.Panel>
{/* Evaluation */}
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<span className="w-full h-full min-h-fit cursor-text">{userSolutions[0].evaluation!.comment}</span>
</Tab.Panel>
{/* Recommended Answer */}
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<span className="w-full h-full min-h-fit cursor-text whitespace-pre-wrap">
{userSolutions[0].evaluation!.perfect_answer.replaceAll(/\s{2,}/g, "\n\n").replaceAll("\\n", "\n")}
</span>
</Tab.Panel>
{aiEval && user?.type !== "student" && ( {aiEval && user?.type !== "student" && (
<Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4"> <Tab.Panel className="w-full bg-ielts-writing/10 h-fit rounded-xl p-6 flex flex-col gap-4">
<AIDetection {...aiEval} /> <AIDetection {...aiEval} />
@@ -235,7 +248,7 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
onBack({ onBack({
exercise: id, exercise: id,
solutions: userSolutions, solutions: userSolutions,
score: {total: 100, missing: 0, correct: writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0}, score: { total: 100, missing: 0, correct: writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 },
type, type,
}) })
} }

View File

@@ -68,6 +68,9 @@ const aggregateScoresByModule = (stats: Stat[]): { module: Module; total: number
}; };
interface StatsGridItemProps { interface StatsGridItemProps {
width?: string | undefined;
height?: string | undefined;
examNumber?: number | undefined;
stats: Stat[]; stats: Stat[];
timestamp: string | number; timestamp: string | number;
user: User, user: User,
@@ -75,6 +78,7 @@ interface StatsGridItemProps {
users: User[]; users: User[];
training?: boolean, training?: boolean,
selectedTrainingExams?: string[]; selectedTrainingExams?: string[];
maxTrainingExams?: number;
setSelectedTrainingExams?: React.Dispatch<React.SetStateAction<string[]>>; setSelectedTrainingExams?: React.Dispatch<React.SetStateAction<string[]>>;
setExams: (exams: Exam[]) => void; setExams: (exams: Exam[]) => void;
setShowSolutions: (show: boolean) => void; setShowSolutions: (show: boolean) => void;
@@ -100,7 +104,11 @@ const StatsGridItem: React.FC<StatsGridItemProps> = ({
setSelectedModules, setSelectedModules,
setInactivity, setInactivity,
setTimeSpent, setTimeSpent,
renderPdfIcon renderPdfIcon,
width = undefined,
height = undefined,
examNumber = undefined,
maxTrainingExams = undefined
}) => { }) => {
const router = useRouter(); const router = useRouter();
const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0); const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
@@ -126,16 +134,22 @@ const StatsGridItem: React.FC<StatsGridItemProps> = ({
const { timeSpent, inactivity, session } = stats[0]; const { timeSpent, inactivity, session } = stats[0];
const selectExam = () => { const selectExam = () => {
if (training && !isDisabled && typeof setSelectedTrainingExams !== "undefined" && typeof timestamp == "string") { if (training && !isDisabled && typeof maxTrainingExams !== "undefined" && typeof setSelectedTrainingExams !== "undefined" && typeof timestamp == "string") {
setSelectedTrainingExams(prevExams => { setSelectedTrainingExams(prevExams => {
const index = prevExams.indexOf(timestamp); const uniqueExams = [...new Set(stats.map(stat => `${stat.module}-${stat.date}`))];
const indexes = uniqueExams.map(exam => prevExams.indexOf(exam)).filter(index => index !== -1);
if (index !== -1) { if (indexes.length > 0) {
const newExams = [...prevExams]; const newExams = [...prevExams];
indexes.sort((a, b) => b - a).forEach(index => {
newExams.splice(index, 1); newExams.splice(index, 1);
});
return newExams; return newExams;
} else { } else {
return [...prevExams, timestamp]; if (prevExams.length + uniqueExams.length <= maxTrainingExams) {
return [...prevExams, ...uniqueExams];
} else {
return prevExams;
}
} }
}); });
} else { } else {
@@ -190,6 +204,8 @@ const StatsGridItem: React.FC<StatsGridItemProps> = ({
</span> </span>
{renderPdfIcon(session, textColor, textColor)} {renderPdfIcon(session, textColor, textColor)}
</div> </div>
{examNumber === undefined ? (
<>
{aiUsage >= 50 && user.type !== "student" && ( {aiUsage >= 50 && user.type !== "student" && (
<div className={clsx( <div className={clsx(
"ml-auto border px-1 rounded w-fit mr-1", "ml-auto border px-1 rounded w-fit mr-1",
@@ -201,11 +217,20 @@ const StatsGridItem: React.FC<StatsGridItemProps> = ({
<span className="text-xs">AI Usage</span> <span className="text-xs">AI Usage</span>
</div> </div>
)} )}
</>
) : (
<div className='flex justify-end'>
<span className="font-semibold bg-gray-200 text-gray-800 px-2.5 py-0.5 rounded-full mt-0.5">{examNumber}</span>
</div>
)}
</div> </div>
</div> </div>
<div className="w-full flex flex-col gap-1"> <div className="w-full flex flex-col gap-1">
<div className="grid grid-cols-4 gap-2 place-items-start w-full -md:mt-2"> <div className={clsx(
"grid grid-cols-4 gap-2 place-items-start w-full -md:mt-2",
examNumber !== undefined && "pr-10"
)}>
{aggregatedLevels.map(({ module, level }) => ( {aggregatedLevels.map(({ module, level }) => (
<ModuleBadge key={module} module={module} level={level} /> <ModuleBadge key={module} module={module} level={level} />
))} ))}
@@ -230,9 +255,13 @@ const StatsGridItem: React.FC<StatsGridItemProps> = ({
correct / total >= 0.7 && "hover:border-mti-purple", correct / total >= 0.7 && "hover:border-mti-purple",
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red", correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
correct / total < 0.3 && "hover:border-mti-rose", correct / total < 0.3 && "hover:border-mti-rose",
typeof selectedTrainingExams !== "undefined" && typeof timestamp === "string" && selectedTrainingExams.includes(timestamp) && "border-2 border-slate-600", typeof selectedTrainingExams !== "undefined" && typeof timestamp === "string" && selectedTrainingExams.some(exam => exam.includes(timestamp)) && "border-2 border-slate-600",
)} )}
onClick={selectExam} onClick={examNumber === undefined ? selectExam : undefined}
style={{
...(width !== undefined && { width }),
...(height !== undefined && { height }),
}}
data-tip="This exam is still being evaluated..." data-tip="This exam is still being evaluated..."
role="button"> role="button">
{content} {content}
@@ -246,6 +275,10 @@ const StatsGridItem: React.FC<StatsGridItemProps> = ({
correct / total < 0.3 && "hover:border-mti-rose", correct / total < 0.3 && "hover:border-mti-rose",
)} )}
data-tip="Your screen size is too small to view previous exams." data-tip="Your screen size is too small to view previous exams."
style={{
...(width !== undefined && { width }),
...(height !== undefined && { height }),
}}
role="button"> role="button">
{content} {content}
</div> </div>

View File

@@ -17,9 +17,10 @@ const TrainingScore: React.FC<TrainingScoreProps> = ({
const scores = trainingContent.exams.map(exam => exam.score); const scores = trainingContent.exams.map(exam => exam.score);
const highestScore = Math.max(...scores); const highestScore = Math.max(...scores);
const lowestScore = Math.min(...scores); const lowestScore = Math.min(...scores);
const averageScore = scores.length > 0 let averageScore = scores.length > 0
? scores.reduce((sum, score) => sum + score, 0) / scores.length ? scores.reduce((sum, score) => sum + score, 0) / scores.length
: 0; : 0;
averageScore = Math.round(averageScore);
const containerClasses = clsx( const containerClasses = clsx(
"flex flex-row mb-4", "flex flex-row mb-4",
@@ -76,7 +77,7 @@ const TrainingScore: React.FC<TrainingScoreProps> = ({
</div> </div>
</div> </div>
{gridView && ( {gridView && (
<div className="flex flex-col items-center justify-center gap-2"> <div className="flex flex-col items-center justify-center gap-2 -lg:hidden">
<div className="flex w-14 h-14 bg-[#F5F5F5] items-center justify-center rounded-xl border border-[#DBDBDB]"> <div className="flex w-14 h-14 bg-[#F5F5F5] items-center justify-center rounded-xl border border-[#DBDBDB]">
<GiLightBulb color={"#FFCC00"} size={28} /> <GiLightBulb color={"#FFCC00"} size={28} />
</div> </div>

View File

@@ -1,27 +1,15 @@
import useStats from "@/hooks/useStats"; import useStats from "@/hooks/useStats";
import { import {CorporateInformation, CorporateUser, EMPLOYMENT_STATUS, User, Type} from "@/interfaces/user";
CorporateInformation, import {groupBySession, averageScore} from "@/utils/stats";
CorporateUser, import {RadioGroup} from "@headlessui/react";
EMPLOYMENT_STATUS,
User,
Type,
} from "@/interfaces/user";
import { groupBySession, averageScore } from "@/utils/stats";
import { RadioGroup } from "@headlessui/react";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import moment from "moment"; import moment from "moment";
import { Divider } from "primereact/divider"; import {Divider} from "primereact/divider";
import { useEffect, useState } from "react"; import {useEffect, useState} from "react";
import ReactDatePicker from "react-datepicker"; import ReactDatePicker from "react-datepicker";
import { import {BsFileEarmarkText, BsPencil, BsPerson, BsPersonAdd, BsStar} from "react-icons/bs";
BsFileEarmarkText, import {toast} from "react-toastify";
BsPencil,
BsPerson,
BsPersonAdd,
BsStar,
} from "react-icons/bs";
import { toast } from "react-toastify";
import Button from "./Low/Button"; import Button from "./Low/Button";
import Checkbox from "./Low/Checkbox"; import Checkbox from "./Low/Checkbox";
import CountrySelect from "./Low/CountrySelect"; import CountrySelect from "./Low/CountrySelect";
@@ -29,23 +17,21 @@ import Input from "./Low/Input";
import ProfileSummary from "./ProfileSummary"; import ProfileSummary from "./ProfileSummary";
import Select from "react-select"; import Select from "react-select";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import { USER_TYPE_LABELS } from "@/resources/user"; import {USER_TYPE_LABELS} from "@/resources/user";
import { CURRENCIES } from "@/resources/paypal"; import {CURRENCIES} from "@/resources/paypal";
import useCodes from "@/hooks/useCodes"; import useCodes from "@/hooks/useCodes";
import { checkAccess, getTypesOfUser } from "@/utils/permissions"; import {checkAccess, getTypesOfUser} from "@/utils/permissions";
import { PERMISSIONS } from "@/constants/userPermissions"; import {PERMISSIONS} from "@/constants/userPermissions";
import { PermissionType } from "@/interfaces/permissions"; import {PermissionType} from "@/interfaces/permissions";
import usePermissions from "@/hooks/usePermissions";
const expirationDateColor = (date: Date) => { const expirationDateColor = (date: Date) => {
const momentDate = moment(date); const momentDate = moment(date);
const today = moment(new Date()); const today = moment(new Date());
if (today.add(1, "days").isAfter(momentDate)) if (today.add(1, "days").isAfter(momentDate)) return "!bg-mti-red-ultralight border-mti-red-light";
return "!bg-mti-red-ultralight border-mti-red-light"; if (today.add(3, "days").isAfter(momentDate)) return "!bg-mti-rose-ultralight border-mti-rose-light";
if (today.add(3, "days").isAfter(momentDate)) if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light";
return "!bg-mti-rose-ultralight border-mti-rose-light";
if (today.add(7, "days").isAfter(momentDate))
return "!bg-mti-orange-ultralight border-mti-orange-light";
}; };
interface Props { interface Props {
@@ -81,86 +67,40 @@ const USER_TYPE_OPTIONS = Object.keys(USER_TYPE_LABELS).map((type) => ({
label: USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS], label: USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS],
})); }));
const CURRENCIES_OPTIONS = CURRENCIES.map(({ label, currency }) => ({ const CURRENCIES_OPTIONS = CURRENCIES.map(({label, currency}) => ({
value: currency, value: currency,
label, label,
})); }));
const UserCard = ({ const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers, onViewCorporate, disabled = false, disabledFields = {}}: Props) => {
user, const [expiryDate, setExpiryDate] = useState<Date | null | undefined>(user.subscriptionExpirationDate);
loggedInUser,
onClose,
onViewStudents,
onViewTeachers,
onViewCorporate,
disabled = false,
disabledFields = {},
}: Props) => {
const [expiryDate, setExpiryDate] = useState<Date | null | undefined>(
user.subscriptionExpirationDate
);
const [type, setType] = useState(user.type); const [type, setType] = useState(user.type);
const [status, setStatus] = useState(user.status); const [status, setStatus] = useState(user.status);
const [referralAgentLabel, setReferralAgentLabel] = useState<string>(); const [referralAgentLabel, setReferralAgentLabel] = useState<string>();
const [position, setPosition] = useState<string | undefined>( const [position, setPosition] = useState<string | undefined>(user.type === "corporate" ? user.demographicInformation?.position : undefined);
user.type === "corporate" const [passport_id, setPassportID] = useState<string | undefined>(user.type === "student" ? user.demographicInformation?.passport_id : undefined);
? user.demographicInformation?.position
: undefined
);
const [passport_id, setPassportID] = useState<string | undefined>(
user.type === "student"
? user.demographicInformation?.passport_id
: undefined
);
const [referralAgent, setReferralAgent] = useState( const [referralAgent, setReferralAgent] = useState(user.type === "corporate" ? user.corporateInformation?.referralAgent : undefined);
user.type === "corporate"
? user.corporateInformation?.referralAgent
: undefined
);
const [companyName, setCompanyName] = useState( const [companyName, setCompanyName] = useState(
user.type === "corporate" user.type === "corporate"
? user.corporateInformation?.companyInformation.name ? user.corporateInformation?.companyInformation.name
: user.type === "agent" : user.type === "agent"
? user.agentInformation?.companyName ? user.agentInformation?.companyName
: undefined : undefined,
);
const [arabName, setArabName] = useState(
user.type === "agent" ? user.agentInformation?.companyArabName : undefined
); );
const [arabName, setArabName] = useState(user.type === "agent" ? user.agentInformation?.companyArabName : undefined);
const [commercialRegistration, setCommercialRegistration] = useState( const [commercialRegistration, setCommercialRegistration] = useState(
user.type === "agent" user.type === "agent" ? user.agentInformation?.commercialRegistration : undefined,
? user.agentInformation?.commercialRegistration
: undefined
); );
const [userAmount, setUserAmount] = useState( const [userAmount, setUserAmount] = useState(user.type === "corporate" ? user.corporateInformation?.companyInformation.userAmount : undefined);
user.type === "corporate" const [paymentValue, setPaymentValue] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.value : undefined);
? user.corporateInformation?.companyInformation.userAmount const [paymentCurrency, setPaymentCurrency] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.currency : "EUR");
: undefined const [monthlyDuration, setMonthlyDuration] = useState(user.type === "corporate" ? user.corporateInformation?.monthlyDuration : undefined);
); const [commissionValue, setCommission] = useState(user.type === "corporate" ? user.corporateInformation?.payment?.commission : undefined);
const [paymentValue, setPaymentValue] = useState( const {stats} = useStats(user.id);
user.type === "corporate" const {users} = useUsers();
? user.corporateInformation?.payment?.value const {codes} = useCodes(user.id);
: undefined const {permissions} = usePermissions(loggedInUser.id);
);
const [paymentCurrency, setPaymentCurrency] = useState(
user.type === "corporate"
? user.corporateInformation?.payment?.currency
: "EUR"
);
const [monthlyDuration, setMonthlyDuration] = useState(
user.type === "corporate"
? user.corporateInformation?.monthlyDuration
: undefined
);
const [commissionValue, setCommission] = useState(
user.type === "corporate"
? user.corporateInformation?.payment?.commission
: undefined
);
const { stats } = useStats(user.id);
const { users } = useUsers();
const { codes } = useCodes(user.id);
useEffect(() => { useEffect(() => {
if (users && users.length > 0) { if (users && users.length > 0) {
@@ -176,14 +116,11 @@ const UserCard = ({
const updateUser = () => { const updateUser = () => {
if (user.type === "corporate" && (!paymentValue || paymentValue < 0)) if (user.type === "corporate" && (!paymentValue || paymentValue < 0))
return toast.error( return toast.error("Please set a price for the user's package before updating!");
"Please set a price for the user's package before updating!" if (!confirm(`Are you sure you want to update ${user.name}'s account?`)) return;
);
if (!confirm(`Are you sure you want to update ${user.name}'s account?`))
return;
axios axios
.post<{ user?: User; ok?: boolean }>(`/api/users/update?id=${user.id}`, { .post<{user?: User; ok?: boolean}>(`/api/users/update?id=${user.id}`, {
...user, ...user,
subscriptionExpirationDate: expiryDate, subscriptionExpirationDate: expiryDate,
type, type,
@@ -208,9 +145,7 @@ const UserCard = ({
payment: { payment: {
value: paymentValue, value: paymentValue,
currency: paymentCurrency, currency: paymentCurrency,
...(referralAgent === "" ...(referralAgent === "" ? {} : {commission: commissionValue}),
? {}
: { commission: commissionValue }),
}, },
} }
: undefined, : undefined,
@@ -220,15 +155,13 @@ const UserCard = ({
onClose(true); onClose(true);
}) })
.catch(() => { .catch(() => {
toast.error("Something went wrong!", { toastId: "update-error" }); toast.error("Something went wrong!", {toastId: "update-error"});
}); });
}; };
const generalProfileItems = [ const generalProfileItems = [
{ {
icon: ( icon: <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
<BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />
),
value: Object.keys(groupBySession(stats)).length, value: Object.keys(groupBySession(stats)).length,
label: "Exams", label: "Exams",
}, },
@@ -248,16 +181,12 @@ const UserCard = ({
user.type === "corporate" user.type === "corporate"
? [ ? [
{ {
icon: ( icon: <BsPerson className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
<BsPerson className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />
),
value: codes.length, value: codes.length,
label: "Users Used", label: "Users Used",
}, },
{ {
icon: ( icon: <BsPersonAdd className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
<BsPersonAdd className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />
),
value: user.corporateInformation.companyInformation.userAmount, value: user.corporateInformation.companyInformation.userAmount,
label: "Number of Users", label: "Number of Users",
}, },
@@ -270,14 +199,7 @@ const UserCard = ({
}; };
return ( return (
<> <>
<ProfileSummary <ProfileSummary user={user} items={user.type === "corporate" ? corporateProfileItems : generalProfileItems} />
user={user}
items={
user.type === "corporate"
? corporateProfileItems
: generalProfileItems
}
/>
{user.type === "agent" && ( {user.type === "agent" && (
<> <>
@@ -347,9 +269,7 @@ const UserCard = ({
disabled={disabled} disabled={disabled}
/> />
<div className="flex flex-col gap-3 w-full lg:col-span-3"> <div className="flex flex-col gap-3 w-full lg:col-span-3">
<label className="font-normal text-base text-mti-gray-dim"> <label className="font-normal text-base text-mti-gray-dim">Pricing</label>
Pricing
</label>
<div className="w-full grid grid-cols-6 gap-2"> <div className="w-full grid grid-cols-6 gap-2">
<Input <Input
name="paymentValue" name="paymentValue"
@@ -362,17 +282,14 @@ const UserCard = ({
<Select <Select
className={clsx( className={clsx(
"px-4 py-4 col-span-3 w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none", "px-4 py-4 col-span-3 w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none",
disabled && disabled && "!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
"!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed"
)} )}
options={CURRENCIES_OPTIONS} options={CURRENCIES_OPTIONS}
value={CURRENCIES_OPTIONS.find( value={CURRENCIES_OPTIONS.find((c) => c.value === paymentCurrency)}
(c) => c.value === paymentCurrency
)}
onChange={(value) => setPaymentCurrency(value?.value)} onChange={(value) => setPaymentCurrency(value?.value)}
menuPortalTarget={document?.body} menuPortalTarget={document?.body}
styles={{ styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }), menuPortal: (base) => ({...base, zIndex: 9999}),
control: (styles) => ({ control: (styles) => ({
...styles, ...styles,
paddingLeft: "4px", paddingLeft: "4px",
@@ -384,11 +301,7 @@ const UserCard = ({
}), }),
option: (styles, state) => ({ option: (styles, state) => ({
...styles, ...styles,
backgroundColor: state.isFocused backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
? "#D5D9F0"
: state.isSelected
? "#7872BF"
: "white",
color: state.isFocused ? "black" : styles.color, color: state.isFocused ? "black" : styles.color,
}), }),
}} }}
@@ -399,22 +312,16 @@ const UserCard = ({
</div> </div>
<div className="flex gap-3 w-full"> <div className="flex gap-3 w-full">
<div className="flex flex-col gap-3 w-8/12"> <div className="flex flex-col gap-3 w-8/12">
<label className="font-normal text-base text-mti-gray-dim"> <label className="font-normal text-base text-mti-gray-dim">Country Manager</label>
Country Manager
</label>
{referralAgentLabel && ( {referralAgentLabel && (
<Select <Select
className={clsx( className={clsx(
"px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none", "px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none",
(checkAccess( (checkAccess(loggedInUser, getTypesOfUser(["developer", "admin"])) || disabledFields.countryManager) &&
loggedInUser, "!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
getTypesOfUser(["developer", "admin"])
) ||
disabledFields.countryManager) &&
"!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed"
)} )}
options={[ options={[
{ value: "", label: "No referral" }, {value: "", label: "No referral"},
...users ...users
.filter((u) => u.type === "agent") .filter((u) => u.type === "agent")
.map((x) => ({ .map((x) => ({
@@ -429,7 +336,7 @@ const UserCard = ({
menuPortalTarget={document?.body} menuPortalTarget={document?.body}
onChange={(value) => setReferralAgent(value?.value)} onChange={(value) => setReferralAgent(value?.value)}
styles={{ styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }), menuPortal: (base) => ({...base, zIndex: 9999}),
control: (styles) => ({ control: (styles) => ({
...styles, ...styles,
paddingLeft: "4px", paddingLeft: "4px",
@@ -441,30 +348,19 @@ const UserCard = ({
}), }),
option: (styles, state) => ({ option: (styles, state) => ({
...styles, ...styles,
backgroundColor: state.isFocused backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
? "#D5D9F0"
: state.isSelected
? "#7872BF"
: "white",
color: state.isFocused ? "black" : styles.color, color: state.isFocused ? "black" : styles.color,
}), }),
}} }}
// editing country manager should only be available for dev/admin // editing country manager should only be available for dev/admin
isDisabled={ isDisabled={checkAccess(loggedInUser, getTypesOfUser(["developer", "admin"])) || disabledFields.countryManager}
checkAccess(
loggedInUser,
getTypesOfUser(["developer", "admin"])
) || disabledFields.countryManager
}
/> />
)} )}
</div> </div>
<div className="flex flex-col gap-3 w-4/12"> <div className="flex flex-col gap-3 w-4/12">
{referralAgent !== "" && loggedInUser.type !== "corporate" ? ( {referralAgent !== "" && loggedInUser.type !== "corporate" ? (
<> <>
<label className="font-normal text-base text-mti-gray-dim"> <label className="font-normal text-base text-mti-gray-dim">Commission</label>
Commission
</label>
<Input <Input
name="commissionValue" name="commissionValue"
onChange={(e) => setCommission(e ? parseInt(e) : undefined)} onChange={(e) => setCommission(e ? parseInt(e) : undefined)}
@@ -506,13 +402,8 @@ const UserCard = ({
<div className="flex flex-col md:flex-row gap-8 w-full"> <div className="flex flex-col md:flex-row gap-8 w-full">
<div className="flex flex-col gap-3 w-full"> <div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim"> <label className="font-normal text-base text-mti-gray-dim">Country</label>
Country <CountrySelect disabled value={user.demographicInformation?.country} />
</label>
<CountrySelect
disabled
value={user.demographicInformation?.country}
/>
</div> </div>
<Input <Input
type="tel" type="tel"
@@ -532,11 +423,7 @@ const UserCard = ({
label="Passport/National ID" label="Passport/National ID"
onChange={() => null} onChange={() => null}
placeholder="Enter National ID or Passport number" placeholder="Enter National ID or Passport number"
value={ value={user.type === "student" ? user.demographicInformation?.passport_id : undefined}
user.type === "student"
? user.demographicInformation?.passport_id
: undefined
}
disabled disabled
required required
/> />
@@ -545,26 +432,22 @@ const UserCard = ({
<div className="flex flex-col md:flex-row gap-8 w-full"> <div className="flex flex-col md:flex-row gap-8 w-full">
{user.type !== "corporate" && user.type !== "mastercorporate" && ( {user.type !== "corporate" && user.type !== "mastercorporate" && (
<div className="relative flex flex-col gap-3 w-full"> <div className="relative flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim"> <label className="font-normal text-base text-mti-gray-dim">Employment Status</label>
Employment Status
</label>
<RadioGroup <RadioGroup
value={user.demographicInformation?.employment} value={user.demographicInformation?.employment}
className="grid grid-cols-2 items-center gap-4 place-items-center" className="grid grid-cols-2 items-center gap-4 place-items-center"
disabled={disabled} disabled={disabled}>
> {EMPLOYMENT_STATUS.map(({status, label}) => (
{EMPLOYMENT_STATUS.map(({ status, label }) => (
<RadioGroup.Option value={status} key={status}> <RadioGroup.Option value={status} key={status}>
{({ checked }) => ( {({checked}) => (
<span <span
className={clsx( className={clsx(
"px-6 py-4 w-40 md:w-48 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer", "px-6 py-4 w-40 md:w-48 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
!checked !checked
? "bg-white border-mti-gray-platinum" ? "bg-white border-mti-gray-platinum"
: "bg-mti-purple-light border-mti-purple-dark text-white" : "bg-mti-purple-light border-mti-purple-dark text-white",
)} )}>
>
{label} {label}
</span> </span>
)} )}
@@ -587,55 +470,49 @@ const UserCard = ({
)} )}
<div className="flex flex-col gap-8 w-full"> <div className="flex flex-col gap-8 w-full">
<div className="relative flex flex-col gap-3 w-full"> <div className="relative flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim"> <label className="font-normal text-base text-mti-gray-dim">Gender</label>
Gender
</label>
<RadioGroup <RadioGroup
value={user.demographicInformation?.gender} value={user.demographicInformation?.gender}
className="flex flex-row gap-4 justify-between" className="flex flex-row gap-4 justify-between"
disabled={disabled} disabled={disabled}>
>
<RadioGroup.Option value="male"> <RadioGroup.Option value="male">
{({ checked }) => ( {({checked}) => (
<span <span
className={clsx( className={clsx(
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer", "px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
!checked !checked
? "bg-white border-mti-gray-platinum" ? "bg-white border-mti-gray-platinum"
: "bg-mti-purple-light border-mti-purple-dark text-white" : "bg-mti-purple-light border-mti-purple-dark text-white",
)} )}>
>
Male Male
</span> </span>
)} )}
</RadioGroup.Option> </RadioGroup.Option>
<RadioGroup.Option value="female"> <RadioGroup.Option value="female">
{({ checked }) => ( {({checked}) => (
<span <span
className={clsx( className={clsx(
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer", "px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
!checked !checked
? "bg-white border-mti-gray-platinum" ? "bg-white border-mti-gray-platinum"
: "bg-mti-purple-light border-mti-purple-dark text-white" : "bg-mti-purple-light border-mti-purple-dark text-white",
)} )}>
>
Female Female
</span> </span>
)} )}
</RadioGroup.Option> </RadioGroup.Option>
<RadioGroup.Option value="other"> <RadioGroup.Option value="other">
{({ checked }) => ( {({checked}) => (
<span <span
className={clsx( className={clsx(
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer", "px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
!checked !checked
? "bg-white border-mti-gray-platinum" ? "bg-white border-mti-gray-platinum"
: "bg-mti-purple-light border-mti-purple-dark text-white" : "bg-mti-purple-light border-mti-purple-dark text-white",
)} )}>
>
Other Other
</span> </span>
)} )}
@@ -644,20 +521,11 @@ const UserCard = ({
</div> </div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<label className="font-normal text-base text-mti-gray-dim"> <label className="font-normal text-base text-mti-gray-dim">Expiry Date</label>
Expiry Date
</label>
<Checkbox <Checkbox
isChecked={!!expiryDate} isChecked={!!expiryDate}
onChange={(checked) => onChange={(checked) => setExpiryDate(checked ? user.subscriptionExpirationDate || new Date() : null)}
setExpiryDate( disabled={disabled}>
checked
? user.subscriptionExpirationDate || new Date()
: null
)
}
disabled={disabled}
>
Enabled Enabled
</Checkbox> </Checkbox>
</div> </div>
@@ -666,12 +534,9 @@ const UserCard = ({
className={clsx( className={clsx(
"p-6 w-full flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer", "p-6 w-full flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
!expiryDate !expiryDate ? "!bg-mti-green-ultralight !border-mti-green-light" : expirationDateColor(expiryDate),
? "!bg-mti-green-ultralight !border-mti-green-light" "bg-white border-mti-gray-platinum",
: expirationDateColor(expiryDate), )}>
"bg-white border-mti-gray-platinum"
)}
>
{!expiryDate && "Unlimited"} {!expiryDate && "Unlimited"}
{expiryDate && moment(expiryDate).format("DD/MM/YYYY")} {expiryDate && moment(expiryDate).format("DD/MM/YYYY")}
</div> </div>
@@ -682,14 +547,12 @@ const UserCard = ({
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer", "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", "hover:border-mti-purple tooltip",
expirationDateColor(expiryDate), expirationDateColor(expiryDate),
"transition duration-300 ease-in-out" "transition duration-300 ease-in-out",
)} )}
filterDate={(date) => filterDate={(date) =>
moment(date).isAfter(new Date()) && moment(date).isAfter(new Date()) &&
(loggedInUser.subscriptionExpirationDate (loggedInUser.subscriptionExpirationDate
? moment(date).isBefore( ? moment(date).isBefore(moment(loggedInUser.subscriptionExpirationDate))
moment(loggedInUser.subscriptionExpirationDate)
)
: true) : true)
} }
dateFormat="dd/MM/yyyy" dateFormat="dd/MM/yyyy"
@@ -706,17 +569,13 @@ const UserCard = ({
<Divider className="w-full !m-0" /> <Divider className="w-full !m-0" />
<div className="flex flex-col md:flex-row gap-8 w-full"> <div className="flex flex-col md:flex-row gap-8 w-full">
<div className="flex flex-col gap-3 w-full"> <div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim"> <label className="font-normal text-base text-mti-gray-dim">Status</label>
Status
</label>
<Select <Select
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none" className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
options={USER_STATUS_OPTIONS} options={USER_STATUS_OPTIONS}
menuPortalTarget={document?.body} menuPortalTarget={document?.body}
value={USER_STATUS_OPTIONS.find((o) => o.value === status)} value={USER_STATUS_OPTIONS.find((o) => o.value === status)}
onChange={(value) => onChange={(value) => setStatus(value?.value as typeof user.status)}
setStatus(value?.value as typeof user.status)
}
styles={{ styles={{
control: (styles) => ({ control: (styles) => ({
...styles, ...styles,
@@ -727,14 +586,10 @@ const UserCard = ({
outline: "none", outline: "none",
}, },
}), }),
menuPortal: (base) => ({ ...base, zIndex: 9999 }), menuPortal: (base) => ({...base, zIndex: 9999}),
option: (styles, state) => ({ option: (styles, state) => ({
...styles, ...styles,
backgroundColor: state.isFocused backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
? "#D5D9F0"
: state.isSelected
? "#7872BF"
: "white",
color: state.isFocused ? "black" : styles.color, color: state.isFocused ? "black" : styles.color,
}), }),
}} }}
@@ -742,17 +597,13 @@ const UserCard = ({
/> />
</div> </div>
<div className="flex flex-col gap-3 w-full"> <div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim"> <label className="font-normal text-base text-mti-gray-dim">Type</label>
Type
</label>
<Select <Select
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none" className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
options={USER_TYPE_OPTIONS} options={USER_TYPE_OPTIONS}
menuPortalTarget={document?.body} menuPortalTarget={document?.body}
value={USER_TYPE_OPTIONS.find((o) => o.value === type)} value={USER_TYPE_OPTIONS.find((o) => o.value === type)}
onChange={(value) => onChange={(value) => setType(value?.value as typeof user.type)}
setType(value?.value as typeof user.type)
}
styles={{ styles={{
control: (styles) => ({ control: (styles) => ({
...styles, ...styles,
@@ -763,14 +614,10 @@ const UserCard = ({
outline: "none", outline: "none",
}, },
}), }),
menuPortal: (base) => ({ ...base, zIndex: 9999 }), menuPortal: (base) => ({...base, zIndex: 9999}),
option: (styles, state) => ({ option: (styles, state) => ({
...styles, ...styles,
backgroundColor: state.isFocused backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
? "#D5D9F0"
: state.isSelected
? "#7872BF"
: "white",
color: state.isFocused ? "black" : styles.color, color: state.isFocused ? "black" : styles.color,
}), }),
}} }}
@@ -785,56 +632,29 @@ const UserCard = ({
<div className="flex gap-4 justify-between mt-4 w-full"> <div className="flex gap-4 justify-between mt-4 w-full">
<div className="self-start flex gap-4 justify-start items-center w-full"> <div className="self-start flex gap-4 justify-start items-center w-full">
{onViewCorporate && ["student", "teacher"].includes(user.type) && ( {onViewCorporate && ["student", "teacher"].includes(user.type) && (
<Button <Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewCorporate}>
className="w-full max-w-[200px]"
variant="outline"
color="rose"
onClick={onViewCorporate}
>
View Corporate View Corporate
</Button> </Button>
)} )}
{onViewStudents && ["corporate", "teacher"].includes(user.type) && ( {onViewStudents && ["corporate", "teacher"].includes(user.type) && (
<Button <Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewStudents}>
className="w-full max-w-[200px]"
variant="outline"
color="rose"
onClick={onViewStudents}
>
View Students View Students
</Button> </Button>
)} )}
{onViewTeachers && ["student", "corporate"].includes(user.type) && ( {onViewTeachers && ["student", "corporate"].includes(user.type) && (
<Button <Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewTeachers}>
className="w-full max-w-[200px]"
variant="outline"
color="rose"
onClick={onViewTeachers}
>
View Teachers View Teachers
</Button> </Button>
)} )}
</div> </div>
<div className="self-end flex gap-4 w-full justify-end"> <div className="self-end flex gap-4 w-full justify-end">
<Button <Button className="w-full max-w-[200px]" variant="outline" onClick={onClose}>
className="w-full max-w-[200px]"
variant="outline"
onClick={onClose}
>
Close Close
</Button> </Button>
<Button <Button
disabled={ disabled={disabled || !checkAccess(loggedInUser, updateUserPermission.list, permissions, updateUserPermission.perm)}
disabled ||
!checkAccess(
loggedInUser,
updateUserPermission.list,
updateUserPermission.perm
)
}
onClick={updateUser} onClick={updateUser}
className="w-full max-w-[200px]" className="w-full max-w-[200px]">
>
Update Update
</Button> </Button>
</div> </div>

View File

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

View File

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

View File

@@ -2,11 +2,11 @@
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,
BsClipboard2Data, BsClipboard2Data,
@@ -23,35 +23,46 @@ import {
BsPersonBadge, BsPersonBadge,
BsPersonCheck, BsPersonCheck,
BsPeople, BsPeople,
BsArrowRepeat,
BsPlus,
BsEnvelopePaper,
} 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 useFilterStore from "@/stores/listFilterStore"; import useFilterStore from "@/stores/listFilterStore";
import { useRouter } from "next/router"; import {useRouter} from "next/router";
import useCodes from "@/hooks/useCodes"; import useCodes from "@/hooks/useCodes";
import { getUserCorporate } from "@/utils/groups"; import {getUserCorporate} from "@/utils/groups";
import useAssignments from "@/hooks/useAssignments";
import {Assignment} from "@/interfaces/results";
import AssignmentView from "./AssignmentView";
import AssignmentCreator from "./AssignmentCreator";
import clsx from "clsx";
import AssignmentCard from "./AssignmentCard";
interface Props { interface Props {
user: CorporateUser; user: CorporateUser;
} }
export default function CorporateDashboard({ user }: Props) { export default function CorporateDashboard({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 [corporateUserToShow, setCorporateUserToShow] = const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>();
useState<CorporateUser>(); const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
const { stats } = useStats(); const {stats} = useStats();
const { users, reload } = useUsers(); const {users, reload} = useUsers();
const { codes } = useCodes(user.id); const {codes} = useCodes(user.id);
const { groups } = useGroups(user.id); const {groups} = useGroups(user.id);
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({corporate: user.id});
const appendUserFilters = useFilterStore((state) => state.appendUserFilter); const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
const router = useRouter(); const router = useRouter();
@@ -65,26 +76,16 @@ export default function CorporateDashboard({ user }: Props) {
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" && const teacherFilter = (user: User) => user.type === "teacher" && groups.flatMap((g) => g.participants).includes(user.id);
groups.flatMap((g) => g.participants).includes(user.id);
const teacherFilter = (user: User) =>
user.type === "teacher" &&
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
src={displayUser.profilePicture}
alt={displayUser.name}
className="rounded-full w-10 h-10"
/>
<div className="flex flex-col gap-1 items-start"> <div className="flex flex-col gap-1 items-start">
<span>{displayUser.name}</span> <span>{displayUser.name}</span>
<span className="text-sm opacity-75">{displayUser.email}</span> <span className="text-sm opacity-75">{displayUser.email}</span>
@@ -110,8 +111,7 @@ export default function CorporateDashboard({ user }: Props) {
<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>
@@ -140,8 +140,7 @@ export default function CorporateDashboard({ user }: Props) {
<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>
@@ -153,22 +152,18 @@ export default function CorporateDashboard({ user }: Props) {
}; };
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"> <h2 className="text-2xl font-semibold">Groups ({groups.filter(filter).length})</h2>
Groups ({groups.filter(filter).length})
</h2>
</div> </div>
<GroupList user={user} /> <GroupList user={user} />
@@ -176,6 +171,122 @@ export default function CorporateDashboard({ user }: Props) {
); );
}; };
const AssignmentsPage = () => {
const activeFilter = (a: Assignment) =>
moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment()) && a.assignees.length > a.results.length;
const pastFilter = (a: Assignment) => (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 (
<>
<AssignmentView
isOpen={!!selectedAssignment && !isCreatingAssignment}
onClose={() => {
setSelectedAssignment(undefined);
setIsCreatingAssignment(false);
reloadAssignments();
}}
assignment={selectedAssignment}
/>
<AssignmentCreator
assignment={selectedAssignment}
groups={groups.filter((x) => x.admin === user.id || x.participants.includes(user.id))}
users={users.filter(
(x) =>
x.type === "student" &&
(!!selectedUser
? groups
.filter((g) => g.admin === selectedUser.id)
.flatMap((g) => g.participants)
.includes(x.id) || false
: groups.flatMap((g) => g.participants).includes(x.id)),
)}
assigner={user.id}
isCreating={isCreatingAssignment}
cancelCreation={() => {
setIsCreatingAssignment(false);
setSelectedAssignment(undefined);
reloadAssignments();
}}
/>
<div className="w-full flex justify-between items-center">
<div
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">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<div
onClick={reloadAssignments}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<span>Reload</span>
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
</div>
</div>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Active Assignments ({assignments.filter(activeFilter).length})</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(activeFilter).map((a) => (
<AssignmentCard {...a} onClick={() => setSelectedAssignment(a)} key={a.id} />
))}
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Planned Assignments ({assignments.filter(futureFilter).length})</h2>
<div className="flex flex-wrap gap-2">
<div
onClick={() => setIsCreatingAssignment(true)}
className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300">
<BsPlus className="text-6xl" />
<span className="text-lg">New Assignment</span>
</div>
{assignments.filter(futureFilter).map((a) => (
<AssignmentCard
{...a}
onClick={() => {
setSelectedAssignment(a);
setIsCreatingAssignment(true);
}}
key={a.id}
/>
))}
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastFilter).length})</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(pastFilter).map((a) => (
<AssignmentCard
{...a}
onClick={() => setSelectedAssignment(a)}
key={a.id}
allowDownload
reload={reloadAssignments}
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 averageLevelCalculator = (studentStats: Stat[]) => { const averageLevelCalculator = (studentStats: Stat[]) => {
const formattedStats = studentStats const formattedStats = studentStats
.map((s) => ({ .map((s) => ({
@@ -186,15 +297,10 @@ export default function CorporateDashboard({ user }: Props) {
.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,
@@ -210,14 +316,10 @@ export default function CorporateDashboard({ user }: Props) {
<> <>
{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>
{corporateUserToShow?.corporateInformation?.companyInformation
.name || corporateUserToShow.name}
</b>
</div> </div>
)} )}
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:justify-between text-center"> <section className="grid grid-cols-5 -md:grid-cols-2 gap-4 text-center">
<IconCard <IconCard
onClick={() => setPage("students")} onClick={() => setPage("students")}
Icon={BsPersonFill} Icon={BsPersonFill}
@@ -235,48 +337,40 @@ export default function CorporateDashboard({ user }: Props) {
<IconCard <IconCard
Icon={BsClipboard2Data} Icon={BsClipboard2Data}
label="Exams Performed" label="Exams Performed"
value={ value={stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)).length}
stats.filter((s) =>
groups.flatMap((g) => g.participants).includes(s.user)
).length
}
color="purple" color="purple"
/> />
<IconCard <IconCard
Icon={BsPaperclip} Icon={BsPaperclip}
label="Average Level" label="Average Level"
value={averageLevelCalculator( value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
stats.filter((s) =>
groups.flatMap((g) => g.participants).includes(s.user)
)
).toFixed(1)}
color="purple"
/>
<IconCard
onClick={() => setPage("groups")}
Icon={BsPeople}
label="Groups"
value={groups.length}
color="purple" color="purple"
/> />
<IconCard onClick={() => setPage("groups")} Icon={BsPeople} label="Groups" value={groups.length} color="purple" />
<IconCard <IconCard
Icon={BsPersonCheck} Icon={BsPersonCheck}
label="User Balance" label="User Balance"
value={`${codes.length}/${ value={`${codes.length}/${user.corporateInformation?.companyInformation?.userAmount || 0}`}
user.corporateInformation?.companyInformation?.userAmount || 0
}`}
color="purple" color="purple"
/> />
<IconCard <IconCard
Icon={BsClock} Icon={BsClock}
label="Expiration Date" label="Expiration Date"
value={ value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
user.subscriptionExpirationDate
? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy")
: "Unlimited"
}
color="rose" color="rose"
/> />
<button
disabled={isAssignmentsLoading}
onClick={() => setPage("assignments")}
className="bg-white col-span-2 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">
{isAssignmentsLoading ? "Loading..." : assignments.filter((a) => !a.archived).length}
</span>
</span>
</button>
</section> </section>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between"> <section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
@@ -307,11 +401,7 @@ export default function CorporateDashboard({ user }: Props) {
<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) =>
calculateAverageLevel(b.levels) -
calculateAverageLevel(a.levels)
)
.map((x) => ( .map((x) => (
<UserDisplay key={x.id} {...x} /> <UserDisplay key={x.id} {...x} />
))} ))}
@@ -324,8 +414,7 @@ export default function CorporateDashboard({ user }: Props) {
.filter(studentFilter) .filter(studentFilter)
.sort( .sort(
(a, b) => (a, b) =>
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
Object.keys(groupByExam(getStatsByStudent(a))).length
) )
.map((x) => ( .map((x) => (
<UserDisplay key={x.id} {...x} /> <UserDisplay key={x.id} {...x} />
@@ -349,8 +438,7 @@ export default function CorporateDashboard({ user }: Props) {
if (shouldReload) reload(); if (shouldReload) reload();
}} }}
onViewStudents={ onViewStudents={
selectedUser.type === "corporate" || selectedUser.type === "corporate" || selectedUser.type === "teacher"
selectedUser.type === "teacher"
? () => { ? () => {
appendUserFilters({ appendUserFilters({
id: "view-students", id: "view-students",
@@ -360,11 +448,7 @@ export default function CorporateDashboard({ user }: Props) {
id: "belongs-to-admin", id: "belongs-to-admin",
filter: (x: User) => filter: (x: User) =>
groups groups
.filter( .filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
(g) =>
g.admin === selectedUser.id ||
g.participants.includes(selectedUser.id)
)
.flatMap((g) => g.participants) .flatMap((g) => g.participants)
.includes(x.id), .includes(x.id),
}); });
@@ -374,8 +458,7 @@ export default function CorporateDashboard({ user }: Props) {
: undefined : undefined
} }
onViewTeachers={ onViewTeachers={
selectedUser.type === "corporate" || selectedUser.type === "corporate" || selectedUser.type === "student"
selectedUser.type === "student"
? () => { ? () => {
appendUserFilters({ appendUserFilters({
id: "view-teachers", id: "view-teachers",
@@ -385,11 +468,7 @@ export default function CorporateDashboard({ user }: Props) {
id: "belongs-to-admin", id: "belongs-to-admin",
filter: (x: User) => filter: (x: User) =>
groups groups
.filter( .filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
(g) =>
g.admin === selectedUser.id ||
g.participants.includes(selectedUser.id)
)
.flatMap((g) => g.participants) .flatMap((g) => g.participants)
.includes(x.id), .includes(x.id),
}); });
@@ -407,6 +486,7 @@ export default function CorporateDashboard({ user }: Props) {
{page === "students" && <StudentsList />} {page === "students" && <StudentsList />}
{page === "teachers" && <TeachersList />} {page === "teachers" && <TeachersList />}
{page === "groups" && <GroupsList />} {page === "groups" && <GroupsList />}
{page === "assignments" && <AssignmentsPage />}
{page === "" && <DefaultDashboard />} {page === "" && <DefaultDashboard />}
</> </>
); );

View File

@@ -2,11 +2,11 @@
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 { Group, MasterCorporateUser, Stat, User } from "@/interfaces/user"; import {Group, MasterCorporateUser, Stat, User} from "@/interfaces/user";
import UserList from "@/pages/(admin)/Lists/UserList"; import UserList from "@/pages/(admin)/Lists/UserList";
import { dateSorter } from "@/utils"; import {dateSorter} from "@/utils";
import moment from "moment"; import moment from "moment";
import { useEffect, useState } from "react"; import {useEffect, useState} from "react";
import { import {
BsArrowLeft, BsArrowLeft,
BsClipboard2Data, BsClipboard2Data,
@@ -17,42 +17,49 @@ import {
BsPersonCheck, BsPersonCheck,
BsPeople, BsPeople,
BsBank, BsBank,
BsEnvelopePaper,
BsArrowRepeat,
BsPlus,
} 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 useFilterStore from "@/stores/listFilterStore"; import useFilterStore from "@/stores/listFilterStore";
import { useRouter } from "next/router"; import {useRouter} from "next/router";
import useCodes from "@/hooks/useCodes"; import useCodes from "@/hooks/useCodes";
import useAssignments from "@/hooks/useAssignments";
import {Assignment} from "@/interfaces/results";
import AssignmentView from "./AssignmentView";
import AssignmentCreator from "./AssignmentCreator";
import clsx from "clsx";
import AssignmentCard from "./AssignmentCard";
interface Props { interface Props {
user: MasterCorporateUser; user: MasterCorporateUser;
} }
export default function MasterCorporateDashboard({ user }: Props) { export default function MasterCorporateDashboard({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 [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
const { stats } = useStats(); const {stats} = useStats();
const { users, reload } = useUsers(); const {users, reload} = useUsers();
const { codes } = useCodes(user.id); const {codes} = useCodes(user.id);
const { groups } = useGroups(user.id, user.type); const {groups} = useGroups(user.id, user.type);
const masterCorporateUserGroups = [ const masterCorporateUserGroups = [...new Set(groups.filter((u) => u.admin === user.id).flatMap((g) => g.participants))];
...new Set( const corporateUserGroups = [...new Set(groups.flatMap((g) => g.participants))];
groups.filter((u) => u.admin === user.id).flatMap((g) => g.participants)
), const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({corporate: user.id});
];
const corporateUserGroups = [
...new Set(groups.flatMap((g) => g.participants)),
];
const appendUserFilters = useFilterStore((state) => state.appendUserFilter); const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
const router = useRouter(); const router = useRouter();
@@ -61,24 +68,16 @@ export default function MasterCorporateDashboard({ user }: Props) {
setShowModal(!!selectedUser && page === ""); setShowModal(!!selectedUser && page === "");
}, [selectedUser, page]); }, [selectedUser, page]);
const studentFilter = (user: User) => const studentFilter = (user: User) => user.type === "student" && corporateUserGroups.includes(user.id);
user.type === "student" && corporateUserGroups.includes(user.id); const teacherFilter = (user: User) => user.type === "teacher" && corporateUserGroups.includes(user.id);
const teacherFilter = (user: User) =>
user.type === "teacher" && corporateUserGroups.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
src={displayUser.profilePicture}
alt={displayUser.name}
className="rounded-full w-10 h-10"
/>
<div className="flex flex-col gap-1 items-start"> <div className="flex flex-col gap-1 items-start">
<span>{displayUser.name}</span> <span>{displayUser.name}</span>
<span className="text-sm opacity-75">{displayUser.email}</span> <span className="text-sm opacity-75">{displayUser.email}</span>
@@ -88,10 +87,7 @@ export default function MasterCorporateDashboard({ user }: Props) {
const StudentsList = () => { const StudentsList = () => {
const filter = (x: User) => const filter = (x: User) =>
x.type === "student" && x.type === "student" && (!!selectedUser ? corporateUserGroups.includes(x.id) || false : corporateUserGroups.includes(x.id));
(!!selectedUser
? corporateUserGroups.includes(x.id) || false
: corporateUserGroups.includes(x.id));
return ( return (
<UserList <UserList
@@ -101,8 +97,7 @@ export default function MasterCorporateDashboard({ user }: Props) {
<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>
@@ -115,10 +110,7 @@ export default function MasterCorporateDashboard({ user }: Props) {
const TeachersList = () => { const TeachersList = () => {
const filter = (x: User) => const filter = (x: User) =>
x.type === "teacher" && x.type === "teacher" && (!!selectedUser ? corporateUserGroups.includes(x.id) || false : corporateUserGroups.includes(x.id));
(!!selectedUser
? corporateUserGroups.includes(x.id) || false
: corporateUserGroups.includes(x.id));
return ( return (
<UserList <UserList
@@ -128,8 +120,7 @@ export default function MasterCorporateDashboard({ user }: Props) {
<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>
@@ -141,10 +132,7 @@ export default function MasterCorporateDashboard({ user }: Props) {
}; };
const corporateUserFilter = (x: User) => const corporateUserFilter = (x: User) =>
x.type === "corporate" && x.type === "corporate" && (!!selectedUser ? masterCorporateUserGroups.includes(x.id) || false : masterCorporateUserGroups.includes(x.id));
(!!selectedUser
? masterCorporateUserGroups.includes(x.id) || false
: masterCorporateUserGroups.includes(x.id));
const CorporateList = () => { const CorporateList = () => {
return ( return (
@@ -155,8 +143,7 @@ export default function MasterCorporateDashboard({ user }: Props) {
<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>
@@ -173,14 +160,11 @@ export default function MasterCorporateDashboard({ user }: Props) {
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div <div
onClick={() => setPage("")} onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300" className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
>
<BsArrowLeft className="text-xl" /> <BsArrowLeft className="text-xl" />
<span>Back</span> <span>Back</span>
</div> </div>
<h2 className="text-2xl font-semibold"> <h2 className="text-2xl font-semibold">Groups ({groups.length})</h2>
Groups ({groups.length})
</h2>
</div> </div>
<GroupList user={user} /> <GroupList user={user} />
@@ -188,6 +172,122 @@ export default function MasterCorporateDashboard({ user }: Props) {
); );
}; };
const AssignmentsPage = () => {
const activeFilter = (a: Assignment) =>
moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment()) && a.assignees.length > a.results.length;
const pastFilter = (a: Assignment) => (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 (
<>
<AssignmentView
isOpen={!!selectedAssignment && !isCreatingAssignment}
onClose={() => {
setSelectedAssignment(undefined);
setIsCreatingAssignment(false);
reloadAssignments();
}}
assignment={selectedAssignment}
/>
<AssignmentCreator
assignment={selectedAssignment}
groups={groups.filter((x) => x.admin === user.id || x.participants.includes(user.id))}
users={users.filter(
(x) =>
x.type === "student" &&
(!!selectedUser
? groups
.filter((g) => g.admin === selectedUser.id)
.flatMap((g) => g.participants)
.includes(x.id) || false
: groups.flatMap((g) => g.participants).includes(x.id)),
)}
assigner={user.id}
isCreating={isCreatingAssignment}
cancelCreation={() => {
setIsCreatingAssignment(false);
setSelectedAssignment(undefined);
reloadAssignments();
}}
/>
<div className="w-full flex justify-between items-center">
<div
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">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<div
onClick={reloadAssignments}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<span>Reload</span>
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
</div>
</div>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Active Assignments ({assignments.filter(activeFilter).length})</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(activeFilter).map((a) => (
<AssignmentCard {...a} onClick={() => setSelectedAssignment(a)} key={a.id} />
))}
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Planned Assignments ({assignments.filter(futureFilter).length})</h2>
<div className="flex flex-wrap gap-2">
<div
onClick={() => setIsCreatingAssignment(true)}
className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300">
<BsPlus className="text-6xl" />
<span className="text-lg">New Assignment</span>
</div>
{assignments.filter(futureFilter).map((a) => (
<AssignmentCard
{...a}
onClick={() => {
setSelectedAssignment(a);
setIsCreatingAssignment(true);
}}
key={a.id}
/>
))}
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastFilter).length})</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(pastFilter).map((a) => (
<AssignmentCard
{...a}
onClick={() => setSelectedAssignment(a)}
key={a.id}
allowDownload
reload={reloadAssignments}
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 averageLevelCalculator = (studentStats: Stat[]) => { const averageLevelCalculator = (studentStats: Stat[]) => {
const formattedStats = studentStats const formattedStats = studentStats
.map((s) => ({ .map((s) => ({
@@ -198,15 +298,10 @@ export default function MasterCorporateDashboard({ user }: Props) {
.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,
@@ -238,46 +333,26 @@ export default function MasterCorporateDashboard({ user }: Props) {
<IconCard <IconCard
Icon={BsClipboard2Data} Icon={BsClipboard2Data}
label="Exams Performed" label="Exams Performed"
value={ value={stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)).length}
stats.filter((s) =>
groups.flatMap((g) => g.participants).includes(s.user)
).length
}
color="purple" color="purple"
/> />
<IconCard <IconCard
Icon={BsPaperclip} Icon={BsPaperclip}
label="Average Level" label="Average Level"
value={averageLevelCalculator( value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
stats.filter((s) =>
groups.flatMap((g) => g.participants).includes(s.user)
)
).toFixed(1)}
color="purple"
/>
<IconCard
onClick={() => setPage("groups")}
Icon={BsPeople}
label="Groups"
value={groups.length}
color="purple" color="purple"
/> />
<IconCard onClick={() => setPage("groups")} Icon={BsPeople} label="Groups" value={groups.length} color="purple" />
<IconCard <IconCard
Icon={BsPersonCheck} Icon={BsPersonCheck}
label="User Balance" label="User Balance"
value={`${codes.length}/${ value={`${codes.length}/${user.corporateInformation?.companyInformation?.userAmount || 0}`}
user.corporateInformation?.companyInformation?.userAmount || 0
}`}
color="purple" color="purple"
/> />
<IconCard <IconCard
Icon={BsClock} Icon={BsClock}
label="Expiration Date" label="Expiration Date"
value={ value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
user.subscriptionExpirationDate
? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy")
: "Unlimited"
}
color="rose" color="rose"
/> />
<IconCard <IconCard
@@ -287,6 +362,18 @@ export default function MasterCorporateDashboard({ user }: Props) {
color="purple" color="purple"
onClick={() => setPage("corporate")} onClick={() => setPage("corporate")}
/> />
<button
disabled={isAssignmentsLoading}
onClick={() => setPage("assignments")}
className="bg-white col-span-2 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">
{isAssignmentsLoading ? "Loading..." : assignments.filter((a) => !a.archived).length}
</span>
</span>
</button>
</section> </section>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between"> <section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
@@ -317,11 +404,7 @@ export default function MasterCorporateDashboard({ user }: Props) {
<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) =>
calculateAverageLevel(b.levels) -
calculateAverageLevel(a.levels)
)
.map((x) => ( .map((x) => (
<UserDisplay key={x.id} {...x} /> <UserDisplay key={x.id} {...x} />
))} ))}
@@ -334,8 +417,7 @@ export default function MasterCorporateDashboard({ user }: Props) {
.filter(studentFilter) .filter(studentFilter)
.sort( .sort(
(a, b) => (a, b) =>
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
Object.keys(groupByExam(getStatsByStudent(a))).length
) )
.map((x) => ( .map((x) => (
<UserDisplay key={x.id} {...x} /> <UserDisplay key={x.id} {...x} />
@@ -359,8 +441,7 @@ export default function MasterCorporateDashboard({ user }: Props) {
if (shouldReload) reload(); if (shouldReload) reload();
}} }}
onViewStudents={ onViewStudents={
selectedUser.type === "corporate" || selectedUser.type === "corporate" || selectedUser.type === "teacher"
selectedUser.type === "teacher"
? () => { ? () => {
appendUserFilters({ appendUserFilters({
id: "view-students", id: "view-students",
@@ -370,11 +451,7 @@ export default function MasterCorporateDashboard({ user }: Props) {
id: "belongs-to-admin", id: "belongs-to-admin",
filter: (x: User) => filter: (x: User) =>
groups groups
.filter( .filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
(g) =>
g.admin === selectedUser.id ||
g.participants.includes(selectedUser.id)
)
.flatMap((g) => g.participants) .flatMap((g) => g.participants)
.includes(x.id), .includes(x.id),
}); });
@@ -384,8 +461,7 @@ export default function MasterCorporateDashboard({ user }: Props) {
: undefined : undefined
} }
onViewTeachers={ onViewTeachers={
selectedUser.type === "corporate" || selectedUser.type === "corporate" || selectedUser.type === "student"
selectedUser.type === "student"
? () => { ? () => {
appendUserFilters({ appendUserFilters({
id: "view-teachers", id: "view-teachers",
@@ -395,11 +471,7 @@ export default function MasterCorporateDashboard({ user }: Props) {
id: "belongs-to-admin", id: "belongs-to-admin",
filter: (x: User) => filter: (x: User) =>
groups groups
.filter( .filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
(g) =>
g.admin === selectedUser.id ||
g.participants.includes(selectedUser.id)
)
.flatMap((g) => g.participants) .flatMap((g) => g.participants)
.includes(x.id), .includes(x.id),
}); });
@@ -418,6 +490,7 @@ export default function MasterCorporateDashboard({ user }: Props) {
{page === "teachers" && <TeachersList />} {page === "teachers" && <TeachersList />}
{page === "groups" && <GroupsList />} {page === "groups" && <GroupsList />}
{page === "corporate" && <CorporateList />} {page === "corporate" && <CorporateList />}
{page === "assignments" && <AssignmentsPage />}
{page === "" && <DefaultDashboard />} {page === "" && <DefaultDashboard />}
</> </>
); );

View File

@@ -2,11 +2,11 @@
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,
@@ -31,43 +31,41 @@ import {
} 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 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 === "");
@@ -77,23 +75,15 @@ export default function TeacherDashboard({ user }: Props) {
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
src={displayUser.profilePicture}
alt={displayUser.name}
className="rounded-full w-10 h-10"
/>
<div className="flex flex-col gap-1 items-start"> <div className="flex flex-col gap-1 items-start">
<span>{displayUser.name}</span> <span>{displayUser.name}</span>
<span className="text-sm opacity-75">{displayUser.email}</span> <span className="text-sm opacity-75">{displayUser.email}</span>
@@ -119,8 +109,7 @@ export default function TeacherDashboard({ user }: Props) {
<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>
@@ -132,22 +121,18 @@ export default function TeacherDashboard({ user }: Props) {
}; };
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"> <h2 className="text-2xl font-semibold">Groups ({groups.filter(filter).length})</h2>
Groups ({groups.filter(filter).length})
</h2>
</div> </div>
<GroupList user={user} /> <GroupList user={user} />
@@ -165,15 +150,10 @@ export default function TeacherDashboard({ user }: Props) {
.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,
@@ -187,16 +167,10 @@ export default function TeacherDashboard({ user }: Props) {
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 pastFilter = (a: Assignment) =>
(moment(a.endDate).isBefore(moment()) ||
a.assignees.length === a.results.length) &&
!a.archived;
const archivedFilter = (a: Assignment) => a.archived; const archivedFilter = (a: Assignment) => a.archived;
const futureFilter = (a: Assignment) => const futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment());
moment(a.startDate).isAfter(moment());
return ( return (
<> <>
@@ -211,9 +185,7 @@ export default function TeacherDashboard({ user }: Props) {
/> />
<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( users={users.filter(
(x) => (x) =>
x.type === "student" && x.type === "student" &&
@@ -222,7 +194,7 @@ export default function TeacherDashboard({ user }: Props) {
.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)),
)} )}
assigner={user.id} assigner={user.id}
isCreating={isCreatingAssignment} isCreating={isCreatingAssignment}
@@ -235,47 +207,31 @@ export default function TeacherDashboard({ user }: Props) {
<div className="w-full flex justify-between items-center"> <div className="w-full flex justify-between items-center">
<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>
<div <div
onClick={reloadAssignments} onClick={reloadAssignments}
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">
>
<span>Reload</span> <span>Reload</span>
<BsArrowRepeat <BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
className={clsx(
"text-xl",
isAssignmentsLoading && "animate-spin"
)}
/>
</div> </div>
</div> </div>
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold"> <h2 className="text-2xl font-semibold">Active Assignments ({assignments.filter(activeFilter).length})</h2>
Active Assignments ({assignments.filter(activeFilter).length})
</h2>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{assignments.filter(activeFilter).map((a) => ( {assignments.filter(activeFilter).map((a) => (
<AssignmentCard <AssignmentCard {...a} onClick={() => setSelectedAssignment(a)} key={a.id} />
{...a}
onClick={() => setSelectedAssignment(a)}
key={a.id}
/>
))} ))}
</div> </div>
</section> </section>
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold"> <h2 className="text-2xl font-semibold">Planned Assignments ({assignments.filter(futureFilter).length})</h2>
Planned Assignments ({assignments.filter(futureFilter).length})
</h2>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<div <div
onClick={() => setIsCreatingAssignment(true)} onClick={() => setIsCreatingAssignment(true)}
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" className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300">
>
<BsPlus className="text-6xl" /> <BsPlus className="text-6xl" />
<span className="text-lg">New Assignment</span> <span className="text-lg">New Assignment</span>
</div> </div>
@@ -292,9 +248,7 @@ export default function TeacherDashboard({ user }: Props) {
</div> </div>
</section> </section>
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold"> <h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastFilter).length})</h2>
Past Assignments ({assignments.filter(pastFilter).length})
</h2>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{assignments.filter(pastFilter).map((a) => ( {assignments.filter(pastFilter).map((a) => (
<AssignmentCard <AssignmentCard
@@ -309,9 +263,7 @@ export default function TeacherDashboard({ user }: Props) {
</div> </div>
</section> </section>
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold"> <h2 className="text-2xl font-semibold">Archived Assignments ({assignments.filter(archivedFilter).length})</h2>
Archived Assignments ({assignments.filter(archivedFilter).length})
</h2>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{assignments.filter(archivedFilter).map((a) => ( {assignments.filter(archivedFilter).map((a) => (
<AssignmentCard <AssignmentCard
@@ -333,19 +285,14 @@ export default function TeacherDashboard({ user }: Props) {
<> <>
{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>
{corporateUserToShow?.corporateInformation?.companyInformation
.name || corporateUserToShow.name}
</b>
</div> </div>
)} )}
<section <section
className={clsx( className={clsx(
"flex -lg:flex-wrap gap-4 items-center -lg:justify-center lg:justify-start text-center", "flex -lg:flex-wrap gap-4 items-center -lg:justify-center lg:justify-start text-center",
!!corporateUserToShow && "mt-12 xl:mt-6" !!corporateUserToShow && "mt-12 xl:mt-6",
)} )}>
>
<IconCard <IconCard
onClick={() => setPage("students")} onClick={() => setPage("students")}
Icon={BsPersonFill} Icon={BsPersonFill}
@@ -356,40 +303,25 @@ export default function TeacherDashboard({ user }: Props) {
<IconCard <IconCard
Icon={BsClipboard2Data} Icon={BsClipboard2Data}
label="Exams Performed" label="Exams Performed"
value={ value={stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)).length}
stats.filter((s) =>
groups.flatMap((g) => g.participants).includes(s.user)
).length
}
color="purple" color="purple"
/> />
<IconCard <IconCard
Icon={BsPaperclip} Icon={BsPaperclip}
label="Average Level" label="Average Level"
value={averageLevelCalculator( value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
stats.filter((s) =>
groups.flatMap((g) => g.participants).includes(s.user)
)
).toFixed(1)}
color="purple" color="purple"
/> />
<IconCard {checkAccess(user, ["teacher", "developer"], permissions, "viewGroup") && (
Icon={BsPeople} <IconCard Icon={BsPeople} label="Groups" value={groups.length} color="purple" onClick={() => setPage("groups")} />
label="Groups" )}
value={groups.length}
color="purple"
onClick={() => setPage("groups")}
/>
<div <div
onClick={() => setPage("assignments")} 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" 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" /> <BsEnvelopePaper className="text-6xl text-mti-purple-light" />
<span className="flex flex-col gap-1 items-center text-xl"> <span className="flex flex-col gap-1 items-center text-xl">
<span className="text-lg">Assignments</span> <span className="text-lg">Assignments</span>
<span className="font-semibold text-mti-purple-light"> <span className="font-semibold text-mti-purple-light">{assignments.filter((a) => !a.archived).length}</span>
{assignments.filter((a) => !a.archived).length}
</span>
</span> </span>
</div> </div>
</section> </section>
@@ -411,11 +343,7 @@ export default function TeacherDashboard({ user }: Props) {
<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) =>
calculateAverageLevel(b.levels) -
calculateAverageLevel(a.levels)
)
.map((x) => ( .map((x) => (
<UserDisplay key={x.id} {...x} /> <UserDisplay key={x.id} {...x} />
))} ))}
@@ -428,8 +356,7 @@ export default function TeacherDashboard({ user }: Props) {
.filter(studentFilter) .filter(studentFilter)
.sort( .sort(
(a, b) => (a, b) =>
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
Object.keys(groupByExam(getStatsByStudent(a))).length
) )
.map((x) => ( .map((x) => (
<UserDisplay key={x.id} {...x} /> <UserDisplay key={x.id} {...x} />
@@ -453,16 +380,9 @@ export default function TeacherDashboard({ user }: Props) {
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")
: undefined
}
onViewTeachers={
selectedUser.type === "corporate"
? () => setPage("teachers")
: undefined
} }
onViewTeachers={selectedUser.type === "corporate" ? () => setPage("teachers") : undefined}
user={selectedUser} user={selectedUser}
/> />
</div> </div>

View File

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

View File

@@ -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,19 +1,24 @@
import {Module} from "."; import { Module } from ".";
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam; export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
export type Variant = "full" | "partial"; export type Variant = "full" | "partial";
export type InstructorGender = "male" | "female" | "varied"; export type InstructorGender = "male" | "female" | "varied";
export type Difficulty = "easy" | "medium" | "hard"; export type Difficulty = "easy" | "medium" | "hard";
export interface ReadingExam { interface ExamBase {
parts: ReadingPart[];
id: string; id: string;
module: "reading"; module: Module;
minTimer: number; minTimer: number;
type: "academic" | "general";
isDiagnostic: boolean; isDiagnostic: boolean;
variant?: Variant; variant?: Variant;
difficulty?: Difficulty; difficulty?: Difficulty;
createdBy?: string; // option as it has been added later
createdAt?: string; // option as it has been added later
}
export interface ReadingExam extends ExamBase {
module: "reading";
parts: ReadingPart[];
type: "academic" | "general";
} }
export interface ReadingPart { export interface ReadingPart {
@@ -24,14 +29,9 @@ export interface ReadingPart {
exercises: Exercise[]; exercises: Exercise[];
} }
export interface LevelExam { export interface LevelExam extends ExamBase {
module: "level"; module: "level";
id: string;
parts: LevelPart[]; parts: LevelPart[];
minTimer: number;
isDiagnostic: boolean;
variant?: Variant;
difficulty?: Difficulty;
} }
export interface LevelPart { export interface LevelPart {
@@ -39,14 +39,9 @@ export interface LevelPart {
exercises: Exercise[]; exercises: Exercise[];
} }
export interface ListeningExam { export interface ListeningExam extends ExamBase {
parts: ListeningPart[]; parts: ListeningPart[];
id: string;
module: "listening"; module: "listening";
minTimer: number;
isDiagnostic: boolean;
variant?: Variant;
difficulty?: Difficulty;
} }
export interface ListeningPart { export interface ListeningPart {
@@ -72,14 +67,9 @@ export interface UserSolution {
isDisabled?: boolean; isDisabled?: boolean;
} }
export interface WritingExam { export interface WritingExam extends ExamBase {
module: "writing"; module: "writing";
id: string;
exercises: WritingExercise[]; exercises: WritingExercise[];
minTimer: number;
isDiagnostic: boolean;
variant?: Variant;
difficulty?: Difficulty;
} }
interface WordCounter { interface WordCounter {
@@ -87,15 +77,10 @@ interface WordCounter {
limit: number; limit: number;
} }
export interface SpeakingExam { export interface SpeakingExam extends ExamBase {
id: string;
module: "speaking"; module: "speaking";
exercises: (SpeakingExercise | InteractiveSpeakingExercise)[]; exercises: (SpeakingExercise | InteractiveSpeakingExercise)[];
minTimer: number;
isDiagnostic: boolean;
variant?: Variant;
instructorGender: InstructorGender; instructorGender: InstructorGender;
difficulty?: Difficulty;
} }
export type Exercise = export type Exercise =
@@ -115,17 +100,21 @@ export interface Evaluation {
misspelled_pairs?: {correction: string | null; misspelled: string}[]; misspelled_pairs?: {correction: string | null; misspelled: string}[];
} }
interface InteractiveSpeakingEvaluation extends Evaluation {
perfect_answer_1?: {answer: string}; type InteractivePerfectAnswerKey = `perfect_answer_${number}`;
transcript_1?: string; type InteractiveTranscriptKey = `transcript_${number}`;
fixed_text_1?: string; type InteractiveFixedTextKey = `fixed_text_${number}`;
perfect_answer_2?: {answer: string};
transcript_2?: string; type InteractivePerfectAnswerType = { [key in InteractivePerfectAnswerKey]: { answer: string } };
fixed_text_2?: string; type InteractiveTranscriptType = { [key in InteractiveTranscriptKey]?: string };
perfect_answer_3?: {answer: string}; type InteractiveFixedTextType = { [key in InteractiveFixedTextKey]?: string };
transcript_3?: string;
fixed_text_3?: string; interface InteractiveSpeakingEvaluation extends Evaluation,
} InteractivePerfectAnswerType,
InteractiveTranscriptType,
InteractiveFixedTextType
{}
interface SpeakingEvaluation extends CommonEvaluation { interface SpeakingEvaluation extends CommonEvaluation {
perfect_answer_1?: string; perfect_answer_1?: string;

View File

@@ -26,18 +26,27 @@ export const permissions = [
"viewCorporate", "viewCorporate",
"viewCountryManager", "viewCountryManager",
"viewAdmin", "viewAdmin",
"viewGroup",
"viewCodes",
// edit data // edit data
"editStudent", "editStudent",
"editTeacher", "editTeacher",
"editCorporate", "editCorporate",
"editCountryManager", "editCountryManager",
"editAdmin", "editAdmin",
"editGroup",
// delete data // delete data
"deleteStudent", "deleteStudent",
"deleteTeacher", "deleteTeacher",
"deleteCorporate", "deleteCorporate",
"deleteCountryManager", "deleteCountryManager",
"deleteAdmin", "deleteAdmin",
"deleteGroup",
"deleteCodes",
// create options
"createGroup",
"createCodes",
"all",
] as const; ] as const;
export type PermissionType = (typeof permissions)[keyof typeof permissions]; export type PermissionType = (typeof permissions)[keyof typeof permissions];

View File

@@ -1,29 +1,28 @@
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 } 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",
@@ -47,46 +46,28 @@ const USER_TYPE_PERMISSIONS: {
}, },
admin: { admin: {
perm: "createCodeAdmin", perm: "createCodeAdmin",
list: [ list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"],
"student",
"teacher",
"agent",
"corporate",
"admin",
"mastercorporate",
],
}, },
developer: { developer: {
perm: undefined, perm: undefined,
list: [ list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"],
"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 [isLoading, setIsLoading] = useState(false);
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 [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
const [type, setType] = useState<Type>("student"); const [type, setType] = useState<Type>("student");
const [showHelp, setShowHelp] = useState(false); const [showHelp, setShowHelp] = useState(false);
const { 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",
@@ -104,14 +85,7 @@ export default function BatchCodeGenerator({ user }: { user: User }) {
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,
lastName,
country,
passport_id,
email,
...phone
] = row as string[];
return EMAIL_REGEX.test(email.toString().trim()) return EMAIL_REGEX.test(email.toString().trim())
? { ? {
email: email.toString().trim().toLowerCase(), email: email.toString().trim().toLowerCase(),
@@ -121,12 +95,12 @@ export default function BatchCodeGenerator({ user }: { user: User }) {
: undefined; : undefined;
}) })
.filter((x) => !!x) as typeof infos, .filter((x) => !!x) as typeof infos,
(x) => x.email (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();
} }
@@ -134,7 +108,7 @@ export default function BatchCodeGenerator({ user }: { user: User }) {
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();
} }
@@ -144,41 +118,24 @@ export default function BatchCodeGenerator({ user }: { user: User }) {
}, [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 const existingUsers = infos
.filter((x) => users.map((u) => u.email).includes(x.email)) .filter((x) => users.map((u) => u.email).includes(x.email))
.map((i) => users.find((u) => u.email === i.email)) .map((i) => users.find((u) => u.email === i.email))
.filter((x) => !!x && x.type === "student") as User[]; .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 =
existingUsers.length > 0
? `invite ${existingUsers.length} registered student(s)`
: undefined;
if ( if (
!confirm( !confirm(
`You are about to ${[newUsersSentence, existingUsersSentence] `You are about to ${[newUsersSentence, existingUsersSentence].filter((x) => !!x).join(" and ")}, are you sure you want to continue?`,
.filter((x) => !!x)
.join(" and ")}, are you sure you want to continue?`
) )
) )
return; 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) =>
await axios.post(`/api/invites`, { to: u.id, from: user.id })
)
)
.then(() =>
toast.success(
`Successfully invited ${existingUsers.length} registered student(s)!`
)
)
.finally(() => { .finally(() => {
if (newUsers.length === 0) setIsLoading(false); if (newUsers.length === 0) setIsLoading(false);
}); });
@@ -193,30 +150,30 @@ export default function BatchCodeGenerator({ user }: { user: User }) {
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;
} }
@@ -232,30 +189,18 @@ export default function BatchCodeGenerator({ user }: { user: User }) {
return ( return (
<> <>
<Modal <Modal isOpen={showHelp} onClose={() => setShowHelp(false)} title="Excel File Format">
isOpen={showHelp}
onClose={() => setShowHelp(false)}
title="Excel File Format"
>
<div className="mt-4 flex flex-col gap-2"> <div className="mt-4 flex flex-col gap-2">
<span>Please upload an Excel file with the following format:</span> <span>Please upload an Excel file with the following format:</span>
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr> <tr>
<th className="border border-neutral-200 px-2 py-1"> <th className="border border-neutral-200 px-2 py-1">First Name</th>
First Name <th className="border border-neutral-200 px-2 py-1">Last Name</th>
</th>
<th className="border border-neutral-200 px-2 py-1">
Last Name
</th>
<th className="border border-neutral-200 px-2 py-1">Country</th> <th className="border border-neutral-200 px-2 py-1">Country</th>
<th className="border border-neutral-200 px-2 py-1"> <th className="border border-neutral-200 px-2 py-1">Passport/National ID</th>
Passport/National ID
</th>
<th className="border border-neutral-200 px-2 py-1">E-mail</th> <th className="border border-neutral-200 px-2 py-1">E-mail</th>
<th className="border border-neutral-200 px-2 py-1"> <th className="border border-neutral-200 px-2 py-1">Phone Number</th>
Phone Number
</th>
</tr> </tr>
</thead> </thead>
</table> </table>
@@ -264,50 +209,27 @@ export default function BatchCodeGenerator({ user }: { user: User }) {
<ul> <ul>
<li>- All incorrect e-mails will be ignored;</li> <li>- All incorrect e-mails will be ignored;</li>
<li>- All already registered e-mails will be ignored;</li> <li>- All already registered e-mails will be ignored;</li>
<li> <li>- You may have a header row with the format above, however, it is not necessary;</li>
- You may have a header row with the format above, however, it <li>- All of the e-mails in the file will receive an e-mail to join EnCoach with the role selected below.</li>
is not necessary;
</li>
<li>
- All of the e-mails in the file will receive an e-mail to join
EnCoach with the role selected below.
</li>
</ul> </ul>
</span> </span>
</div> </div>
</Modal> </Modal>
<div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4"> <div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
<div className="flex items-end justify-between"> <div className="flex items-end justify-between">
<label className="text-mti-gray-dim text-base font-normal"> <label className="text-mti-gray-dim text-base font-normal">Choose an Excel file</label>
Choose an Excel file <div className="tooltip cursor-pointer" data-tip="Excel File Format" onClick={() => setShowHelp(true)}>
</label>
<div
className="tooltip cursor-pointer"
data-tip="Excel File Format"
onClick={() => setShowHelp(true)}
>
<BsQuestionCircleFill /> <BsQuestionCircleFill />
</div> </div>
</div> </div>
<Button <Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}>
onClick={openFilePicker}
isLoading={isLoading}
disabled={isLoading}
>
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"} {filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
</Button> </Button>
{user && {user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
<> <>
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center"> <div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
<label className="text-mti-gray-dim text-base font-normal"> <label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
Expiry Date <Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled} disabled={!!user.subscriptionExpirationDate}>
</label>
<Checkbox
isChecked={isExpiryDateEnabled}
onChange={setIsExpiryDateEnabled}
disabled={!!user.subscriptionExpirationDate}
>
Enabled Enabled
</Checkbox> </Checkbox>
</div> </div>
@@ -316,13 +238,11 @@ export default function BatchCodeGenerator({ user }: { user: User }) {
className={clsx( className={clsx(
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none", "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", "hover:border-mti-purple tooltip",
"transition duration-300 ease-in-out" "transition duration-300 ease-in-out",
)} )}
filterDate={(date) => filterDate={(date) =>
moment(date).isAfter(new Date()) && moment(date).isAfter(new Date()) &&
(user.subscriptionExpirationDate (user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true)
? moment(date).isBefore(user.subscriptionExpirationDate)
: true)
} }
dateFormat="dd/MM/yyyy" dateFormat="dd/MM/yyyy"
selected={expiryDate} selected={expiryDate}
@@ -331,19 +251,16 @@ export default function BatchCodeGenerator({ user }: { user: User }) {
)} )}
</> </>
)} )}
<label className="text-mti-gray-dim text-base font-normal"> <label className="text-mti-gray-dim text-base font-normal">Select the type of user they should be</label>
Select the type of user they should be
</label>
{user && ( {user && (
<select <select
defaultValue="student" defaultValue="student"
onChange={(e) => setType(e.target.value as typeof user.type)} 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" 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) {Object.keys(USER_TYPE_LABELS)
.filter((x) => { .filter((x) => {
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type]; const {list, perm} = USER_TYPE_PERMISSIONS[x as Type];
return checkAccess(user, list, perm); return checkAccess(user, getTypesOfUser(list), permissions, perm);
}) })
.map((type) => ( .map((type) => (
<option key={type} value={type}> <option key={type} value={type}>
@@ -352,14 +269,11 @@ export default function BatchCodeGenerator({ user }: { user: User }) {
))} ))}
</select> </select>
)} )}
<Button {checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "createCodes") && (
onClick={generateAndInvite} <Button onClick={generateAndInvite} disabled={infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)}>
disabled={
infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)
}
>
Generate & Send Generate & Send
</Button> </Button>
)}
</div> </div>
</> </>
); );

View File

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

View File

@@ -1,21 +1,22 @@
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",
@@ -39,38 +40,22 @@ const USER_TYPE_PERMISSIONS: {
}, },
admin: { admin: {
perm: "createCodeAdmin", perm: "createCodeAdmin",
list: [ list: ["student", "teacher", "agent", "corporate", "admin", "mastercorporate"],
"student",
"teacher",
"agent",
"corporate",
"admin",
"mastercorporate",
],
}, },
developer: { developer: {
perm: undefined, perm: undefined,
list: [ list: ["student", "teacher", "agent", "corporate", "admin", "developer", "mastercorporate"],
"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 [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
const [type, setType] = useState<Type>("student"); const [type, setType] = useState<Type>("student");
const {permissions} = usePermissions(user?.id || "");
useEffect(() => { useEffect(() => {
if (!isExpiryDateEnabled) setExpiryDate(null); if (!isExpiryDateEnabled) setExpiryDate(null);
@@ -81,8 +66,8 @@ export default function CodeGenerator({ user }: { user: User }) {
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",
@@ -92,12 +77,12 @@ export default function CodeGenerator({ user }: { user: User }) {
} }
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;
} }
@@ -109,19 +94,16 @@ export default function CodeGenerator({ user }: { user: User }) {
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
</label>
{user && ( {user && (
<select <select
defaultValue="student" defaultValue="student"
onChange={(e) => setType(e.target.value as typeof user.type)} onChange={(e) => setType(e.target.value as typeof user.type)}
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white" className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
>
{Object.keys(USER_TYPE_LABELS) {Object.keys(USER_TYPE_LABELS)
.filter((x) => { .filter((x) => {
const { list, perm } = USER_TYPE_PERMISSIONS[x as Type]; const {list, perm} = USER_TYPE_PERMISSIONS[x as Type];
return checkAccess(user, list, perm); return checkAccess(user, list, permissions, perm);
}) })
.map((type) => ( .map((type) => (
<option key={type} value={type}> <option key={type} value={type}>
@@ -130,18 +112,11 @@ export default function CodeGenerator({ user }: { user: User }) {
))} ))}
</select> </select>
)} )}
{user && {user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
checkAccess(user, ["developer", "admin", "corporate"]) && (
<> <>
<div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center"> <div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
<label className="text-mti-gray-dim text-base font-normal"> <label className="text-mti-gray-dim text-base font-normal">Expiry Date</label>
Expiry Date <Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled} disabled={!!user.subscriptionExpirationDate}>
</label>
<Checkbox
isChecked={isExpiryDateEnabled}
onChange={setIsExpiryDateEnabled}
disabled={!!user.subscriptionExpirationDate}
>
Enabled Enabled
</Checkbox> </Checkbox>
</div> </div>
@@ -150,13 +125,11 @@ export default function CodeGenerator({ user }: { user: User }) {
className={clsx( className={clsx(
"flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none", "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", "hover:border-mti-purple tooltip",
"transition duration-300 ease-in-out" "transition duration-300 ease-in-out",
)} )}
filterDate={(date) => filterDate={(date) =>
moment(date).isAfter(new Date()) && moment(date).isAfter(new Date()) &&
(user.subscriptionExpirationDate (user.subscriptionExpirationDate ? moment(date).isBefore(user.subscriptionExpirationDate) : true)
? moment(date).isBefore(user.subscriptionExpirationDate)
: true)
} }
dateFormat="dd/MM/yyyy" dateFormat="dd/MM/yyyy"
selected={expiryDate} selected={expiryDate}
@@ -165,33 +138,25 @@ export default function CodeGenerator({ user }: { user: User }) {
)} )}
</> </>
)} )}
<Button {checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "createCodes") && (
onClick={() => generateCode(type)} <Button onClick={() => generateCode(type)} disabled={isExpiryDateEnabled ? !expiryDate : false}>
disabled={isExpiryDateEnabled ? !expiryDate : false}
>
Generate Generate
</Button> </Button>
<label className="font-normal text-base text-mti-gray-dim"> )}
Generated Code: <label className="font-normal text-base text-mti-gray-dim">Generated Code:</label>
</label>
<div <div
className={clsx( className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer", "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", "hover:border-mti-purple tooltip",
"transition duration-300 ease-in-out" "transition duration-300 ease-in-out",
)} )}
data-tip="Click to copy" data-tip="Click to copy"
onClick={() => { onClick={() => {
if (generatedCode) navigator.clipboard.writeText(generatedCode); if (generatedCode) navigator.clipboard.writeText(generatedCode);
}} }}>
>
{generatedCode} {generatedCode}
</div> </div>
{generatedCode && ( {generatedCode && <span className="text-sm text-mti-gray-dim font-light">Give this code to the user to complete their registration</span>}
<span className="text-sm text-mti-gray-dim font-light">
Give this code to the user to complete their registration
</span>
)}
</div> </div>
); );
} }

View File

@@ -4,25 +4,22 @@ 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 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(() => {
@@ -31,30 +28,24 @@ const CreatorCell = ({ id, users }: { id: string; users: User[] }) => {
return ( return (
<> <>
{(creatorUser?.type === "corporate" {(creatorUser?.type === "corporate" ? creatorUser?.corporateInformation?.companyInformation?.name : creatorUser?.name || "N/A") || "N/A"}{" "}
? creatorUser?.corporateInformation?.companyInformation?.name
: creatorUser?.name || "N/A") || "N/A"}{" "}
{creatorUser && `(${USER_TYPE_LABELS[creatorUser.type]})`} {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< const {permissions} = usePermissions(user?.id || "");
"in-use" | "unused"
>();
// const [filteredCodes, setFilteredCodes] = useState<Code[]>([]); // const [filteredCodes, setFilteredCodes] = useState<Code[]>([]);
const { users } = useUsers(); const {users} = useUsers();
const { codes, reload } = useCodes( const {codes, reload} = useCodes(user?.type === "corporate" ? user?.id : undefined);
user?.type === "corporate" ? user?.id : undefined
);
const [startDate, setStartDate] = useState<Date | null>(moment("01/01/2023").toDate()); 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());
@@ -62,9 +53,9 @@ export default function CodeList({ user }: { user: User }) {
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;
} }
} }
@@ -79,25 +70,17 @@ export default function CodeList({ user }: { user: User }) {
}, [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));
@@ -125,8 +108,7 @@ export default function CodeList({ user }: { user: User }) {
}; };
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}`)
@@ -147,6 +129,8 @@ export default function CodeList({ user }: { user: User }) {
.finally(reload); .finally(reload);
}; };
const allowedToDelete = checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "deleteCodes");
const defaultColumns = [ const defaultColumns = [
columnHelper.accessor("code", { columnHelper.accessor("code", {
id: "codeCheckbox", id: "codeCheckbox",
@@ -154,21 +138,15 @@ export default function CodeList({ user }: { user: User }) {
<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> </Checkbox>
), ),
cell: (info) => cell: (info) =>
!info.row.original.userId ? ( !info.row.original.userId ? (
<Checkbox <Checkbox isChecked={selectedCodes.includes(info.getValue())} onChange={() => toggleCode(info.getValue())}>
isChecked={selectedCodes.includes(info.getValue())}
onChange={() => toggleCode(info.getValue())}
>
{""} {""}
</Checkbox> </Checkbox>
) : null, ) : null,
@@ -179,8 +157,7 @@ export default function CodeList({ user }: { user: User }) {
}), }),
columnHelper.accessor("creationDate", { columnHelper.accessor("creationDate", {
header: "Creation Date", header: "Creation Date",
cell: (info) => cell: (info) => (info.getValue() ? moment(info.getValue()).format("DD/MM/YYYY") : "N/A"),
info.getValue() ? moment(info.getValue()).format("DD/MM/YYYY") : "N/A",
}), }),
columnHelper.accessor("email", { columnHelper.accessor("email", {
header: "Invited E-mail", header: "Invited E-mail",
@@ -206,15 +183,11 @@ export default function CodeList({ user }: { user: User }) {
{ {
header: "", header: "",
id: "actions", id: "actions",
cell: ({ row }: { row: { original: Code } }) => { cell: ({row}: {row: {original: Code}}) => {
return ( return (
<div className="flex gap-4"> <div className="flex gap-4">
{!row.original.userId && ( {allowedToDelete && !row.original.userId && (
<div <div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteCode(row.original)}>
data-tip="Delete"
className="cursor-pointer tooltip"
onClick={() => deleteCode(row.original)}
>
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" /> <BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div> </div>
)} )}
@@ -244,8 +217,7 @@ export default function CodeList({ user }: { user: User }) {
? { ? {
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,
@@ -253,37 +225,25 @@ export default function CodeList({ user }: { user: User }) {
: null : null
} }
options={users options={users
.filter((x) => .filter((x) => ["admin", "developer", "corporate"].includes(x.type))
["admin", "developer", "corporate"].includes(x.type)
)
.map((x) => ({ .map((x) => ({
label: `${ label: `${x.type === "corporate" ? x.corporateInformation?.companyInformation?.name || x.name : x.name} (${
x.type === "corporate" USER_TYPE_LABELS[x.type]
? x.corporateInformation?.companyInformation?.name || x.name })`,
: x.name
} (${USER_TYPE_LABELS[x.type]})`,
value: x.id, value: x.id,
user: x, user: x,
}))} }))}
onChange={(value) => onChange={(value) => setFilteredCorporate(value ? users.find((x) => x.id === value?.value) : undefined)}
setFilteredCorporate(
value ? users.find((x) => x.id === value?.value) : undefined
)
}
/> />
<Select <Select
className="!w-96 !py-1" className="!w-96 !py-1"
placeholder="Availability" placeholder="Availability"
isClearable isClearable
options={[ options={[
{ label: "In Use", value: "in-use" }, {label: "In Use", value: "in-use"},
{ label: "Unused", value: "unused" }, {label: "Unused", value: "unused"},
]} ]}
onChange={(value) => onChange={(value) => setFilterAvailability(value ? (value.value as typeof filterAvailability) : undefined)}
setFilterAvailability(
value ? (value.value as typeof filterAvailability) : undefined
)
}
/> />
<ReactDatePicker <ReactDatePicker
dateFormat="dd/MM/yyyy" dateFormat="dd/MM/yyyy"
@@ -293,9 +253,7 @@ export default function CodeList({ user }: { user: User }) {
endDate={endDate} endDate={endDate}
selectsRange selectsRange
showMonthDropdown showMonthDropdown
filterDate={(date: Date) => filterDate={(date: Date) => moment(date).isSameOrBefore(moment(new Date()))}
moment(date).isSameOrBefore(moment(new Date()))
}
onChange={([initialDate, finalDate]: [Date, Date]) => { onChange={([initialDate, finalDate]: [Date, Date]) => {
setStartDate(initialDate ?? moment("01/01/2023").toDate()); setStartDate(initialDate ?? moment("01/01/2023").toDate());
if (finalDate) { if (finalDate) {
@@ -308,6 +266,7 @@ export default function CodeList({ user }: { user: User }) {
}} }}
/> />
</div> </div>
{allowedToDelete && (
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
<span>{selectedCodes.length} code(s) selected</span> <span>{selectedCodes.length} code(s) selected</span>
<Button <Button
@@ -315,11 +274,11 @@ export default function CodeList({ user }: { user: User }) {
variant="outline" variant="outline"
color="red" color="red"
className="!py-1 px-10" className="!py-1 px-10"
onClick={() => deleteCodes(selectedCodes)} onClick={() => deleteCodes(selectedCodes)}>
>
Delete Delete
</Button> </Button>
</div> </div>
)}
</div> </div>
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full"> <table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
<thead> <thead>
@@ -327,12 +286,7 @@ export default function CodeList({ user }: { user: User }) {
<tr key={headerGroup.id}> <tr key={headerGroup.id}>
{headerGroup.headers.map((header) => ( {headerGroup.headers.map((header) => (
<th className="p-4 text-left" key={header.id}> <th className="p-4 text-left" key={header.id}>
{header.isPlaceholder {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th> </th>
))} ))}
</tr> </tr>
@@ -340,10 +294,7 @@ export default function CodeList({ user }: { user: User }) {
</thead> </thead>
<tbody className="px-2"> <tbody className="px-2">
{table.getRowModel().rows.map((row) => ( {table.getRowModel().rows.map((row) => (
<tr <tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2"
key={row.id}
>
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<td className="px-4 py-2" key={cell.id}> <td className="px-4 py-2" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}

View File

@@ -1,21 +1,27 @@
import {PERMISSIONS} from "@/constants/userPermissions"; import { useMemo } from "react";
import { PERMISSIONS } from "@/constants/userPermissions";
import useExams from "@/hooks/useExams"; import useExams from "@/hooks/useExams";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import {Module} from "@/interfaces"; import { Module } from "@/interfaces";
import {Exam} from "@/interfaces/exam"; import { Exam } from "@/interfaces/exam";
import {Type, User} from "@/interfaces/user"; import { Type, User } from "@/interfaces/user";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {getExamById} from "@/utils/exams"; import { getExamById } from "@/utils/exams";
import {countExercises} from "@/utils/moduleUtils"; import { countExercises } from "@/utils/moduleUtils";
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table"; import {
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import {capitalize} from "lodash"; import { capitalize } from "lodash";
import {useRouter} from "next/router"; import { useRouter } from "next/router";
import {BsCheck, BsTrash, BsUpload} from "react-icons/bs"; import { BsCheck, BsTrash, BsUpload } from "react-icons/bs";
import {toast} from "react-toastify"; import { toast } from "react-toastify";
const CLASSES: {[key in Module]: string} = { const CLASSES: { [key in Module]: string } = {
reading: "text-ielts-reading", reading: "text-ielts-reading",
listening: "text-ielts-listening", listening: "text-ielts-listening",
speaking: "text-ielts-speaking", speaking: "text-ielts-speaking",
@@ -25,8 +31,25 @@ const CLASSES: {[key in Module]: string} = {
const columnHelper = createColumnHelper<Exam>(); const columnHelper = createColumnHelper<Exam>();
export default function ExamList({user}: {user: User}) { export default function ExamList({ user }: { user: User }) {
const {exams, reload} = useExams(); const { exams, reload } = useExams();
const { users } = useUsers();
const parsedExams = useMemo(() => {
return exams.map((exam) => {
if (exam.createdBy) {
const user = users.find((u) => u.id === exam.createdBy);
if (!user) return exam;
return {
...exam,
createdBy: user.type === "developer" ? "system" : user.name,
};
}
return exam;
});
}, [exams, users]);
const setExams = useExamStore((state) => state.setExams); const setExams = useExamStore((state) => state.setExams);
const setSelectedModules = useExamStore((state) => state.setSelectedModules); const setSelectedModules = useExamStore((state) => state.setSelectedModules);
@@ -36,9 +59,12 @@ export default function ExamList({user}: {user: User}) {
const loadExam = async (module: Module, examId: string) => { const loadExam = async (module: Module, examId: string) => {
const exam = await getExamById(module, examId.trim()); const exam = await getExamById(module, examId.trim());
if (!exam) { if (!exam) {
toast.error("Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID", { toast.error(
"Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID",
{
toastId: "invalid-exam-id", toastId: "invalid-exam-id",
}); }
);
return; return;
} }
@@ -50,7 +76,12 @@ export default function ExamList({user}: {user: User}) {
}; };
const deleteExam = async (exam: Exam) => { const deleteExam = async (exam: Exam) => {
if (!confirm(`Are you sure you want to delete this ${capitalize(exam.module)} exam?`)) return; if (
!confirm(
`Are you sure you want to delete this ${capitalize(exam.module)} exam?`
)
)
return;
axios axios
.delete(`/api/exam/${exam.module}/${exam.id}`) .delete(`/api/exam/${exam.module}/${exam.id}`)
@@ -72,7 +103,11 @@ export default function ExamList({user}: {user: User}) {
}; };
const getTotalExercises = (exam: Exam) => { const getTotalExercises = (exam: Exam) => {
if (exam.module === "reading" || exam.module === "listening" || exam.module === "level") { if (
exam.module === "reading" ||
exam.module === "listening" ||
exam.module === "level"
) {
return countExercises(exam.parts.flatMap((x) => x.exercises)); return countExercises(exam.parts.flatMap((x) => x.exercises));
} }
@@ -86,7 +121,11 @@ export default function ExamList({user}: {user: User}) {
}), }),
columnHelper.accessor("module", { columnHelper.accessor("module", {
header: "Module", header: "Module",
cell: (info) => <span className={CLASSES[info.getValue()]}>{capitalize(info.getValue())}</span>, cell: (info) => (
<span className={CLASSES[info.getValue()]}>
{capitalize(info.getValue())}
</span>
),
}), }),
columnHelper.accessor((x) => getTotalExercises(x), { columnHelper.accessor((x) => getTotalExercises(x), {
header: "Exercises", header: "Exercises",
@@ -96,20 +135,42 @@ export default function ExamList({user}: {user: User}) {
header: "Timer", header: "Timer",
cell: (info) => <>{info.getValue()} minute(s)</>, cell: (info) => <>{info.getValue()} minute(s)</>,
}), }),
columnHelper.accessor("createdAt", {
header: "Created At",
cell: (info) => {
const value = info.getValue();
if (value) {
return new Date(value).toLocaleDateString();
}
return null;
},
}),
columnHelper.accessor("createdBy", {
header: "Created By",
cell: (info) => info.getValue(),
}),
{ {
header: "", header: "",
id: "actions", id: "actions",
cell: ({row}: {row: {original: Exam}}) => { cell: ({ row }: { row: { original: Exam } }) => {
return ( return (
<div className="flex gap-4"> <div className="flex gap-4">
<div <div
data-tip="Load exam" data-tip="Load exam"
className="cursor-pointer tooltip" className="cursor-pointer tooltip"
onClick={async () => await loadExam(row.original.module, row.original.id)}> onClick={async () =>
await loadExam(row.original.module, row.original.id)
}
>
<BsUpload className="hover:text-mti-purple-light transition ease-in-out duration-300" /> <BsUpload className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div> </div>
{PERMISSIONS.examManagement.delete.includes(user.type) && ( {PERMISSIONS.examManagement.delete.includes(user.type) && (
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteExam(row.original)}> <div
data-tip="Delete"
className="cursor-pointer tooltip"
onClick={() => deleteExam(row.original)}
>
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" /> <BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div> </div>
)} )}
@@ -120,7 +181,7 @@ export default function ExamList({user}: {user: User}) {
]; ];
const table = useReactTable({ const table = useReactTable({
data: exams, data: parsedExams,
columns: defaultColumns, columns: defaultColumns,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
}); });
@@ -132,7 +193,12 @@ export default function ExamList({user}: {user: User}) {
<tr key={headerGroup.id}> <tr key={headerGroup.id}>
{headerGroup.headers.map((header) => ( {headerGroup.headers.map((header) => (
<th className="p-4 text-left" key={header.id}> <th className="p-4 text-left" key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} {header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th> </th>
))} ))}
</tr> </tr>
@@ -140,7 +206,10 @@ export default function ExamList({user}: {user: User}) {
</thead> </thead>
<tbody className="px-2"> <tbody className="px-2">
{table.getRowModel().rows.map((row) => ( {table.getRowModel().rows.map((row) => (
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}> <tr
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2"
key={row.id}
>
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<td className="px-4 py-2" key={cell.id}> <td className="px-4 py-2" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}

View File

@@ -14,29 +14,31 @@ 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 usePermissions from "@/hooks/usePermissions";
const columnHelper = createColumnHelper<Group>(); const columnHelper = createColumnHelper<Group>();
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/); const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
const LinkedCorporate = ({userId, users, groups}: {userId: string, users: User[], groups: Group[]}) => { const LinkedCorporate = ({userId, users, groups}: {userId: string; users: User[]; groups: Group[]}) => {
const [companyName, setCompanyName] = useState(""); const [companyName, setCompanyName] = useState("");
const [isLoading, setIsLoading] = useState(false); 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)) return setCompanyName(user.corporateInformation?.companyInformation?.name || user.name) if (isCorporateUser(user)) return setCompanyName(user.corporateInformation?.companyInformation?.name || user.name);
if (isAgentUser(user)) return setCompanyName(user.agentInformation?.companyName || user.name) if (isAgentUser(user)) return setCompanyName(user.agentInformation?.companyName || user.name);
const belongingGroups = groups.filter((x) => x.participants.includes(userId)) const belongingGroups = groups.filter((x) => x.participants.includes(userId));
const belongingGroupsAdmins = belongingGroups.map((x) => users.find((u) => u.id === x.admin)).filter((x) => !!x && isCorporateUser(x)) 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(admin.corporateInformation?.companyInformation.name || admin.name) setCompanyName(admin.corporateInformation?.companyInformation.name || admin.name);
}, [userId, users, groups]); }, [userId, users, groups]);
return isLoading ? <span className="animate-pulse">Loading...</span> : <>{companyName}</>; return isLoading ? <span className="animate-pulse">Loading...</span> : <>{companyName}</>;
@@ -196,11 +198,13 @@ export default function GroupList({user}: {user: User}) {
const [editingGroup, setEditingGroup] = useState<Group>(); const [editingGroup, setEditingGroup] = useState<Group>();
const [filterByUser, setFilterByUser] = useState(false); const [filterByUser, setFilterByUser] = useState(false);
const {permissions} = usePermissions(user?.id || "");
const {users} = useUsers(); const {users} = useUsers();
const {groups, reload} = useGroups(user && filterTypes.includes(user?.type) ? user.id : undefined, user?.type); const {groups, reload} = useGroups(user && filterTypes.includes(user?.type) ? user.id : undefined, user?.type);
useEffect(() => { useEffect(() => {
if (user && (['corporate', 'teacher', 'mastercorporate'].includes(user.type))) { if (user && ["corporate", "teacher", "mastercorporate"].includes(user.type)) {
setFilterByUser(true); setFilterByUser(true);
} }
}, [user]); }, [user]);
@@ -250,14 +254,14 @@ export default function GroupList({user}: {user: User}) {
cell: ({row}: {row: {original: Group}}) => { cell: ({row}: {row: {original: Group}}) => {
return ( return (
<> <>
{user && (user.type === "developer" || user.type === "admin" || user.id === row.original.admin) && ( {user && (checkAccess(user, ["developer", "admin"]) || user.id === row.original.admin) && (
<div className="flex gap-2"> <div className="flex gap-2">
{(!row.original.disableEditing || ["developer", "admin"].includes(user.type)) && ( {(!row.original.disableEditing || checkAccess(user, ["developer", "admin"]), "editGroup") && (
<div data-tip="Edit" className="tooltip cursor-pointer" onClick={() => setEditingGroup(row.original)}> <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" /> <BsPencil className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
</div> </div>
)} )}
{(!row.original.disableEditing || ["developer", "admin"].includes(user.type)) && ( {(!row.original.disableEditing || checkAccess(user, ["developer", "admin"]), "deleteGroup") && (
<div data-tip="Delete" className="tooltip cursor-pointer" onClick={() => deleteGroup(row.original)}> <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" /> <BsTrash className="hover:text-mti-purple-light transition duration-300 ease-in-out" />
</div> </div>
@@ -327,11 +331,13 @@ export default function GroupList({user}: {user: User}) {
</tbody> </tbody>
</table> </table>
{checkAccess(user, ["teacher", "corporate", "mastercorporate", "admin", "developer"], permissions, "createGroup") && (
<button <button
onClick={() => setIsCreating(true)} onClick={() => setIsCreating(true)}
className="bg-mti-purple-light hover:bg-mti-purple w-full py-2 text-white transition duration-300 ease-in-out"> className="bg-mti-purple-light hover:bg-mti-purple w-full py-2 text-white transition duration-300 ease-in-out">
New Group New Group
</button> </button>
)}
</div> </div>
); );
} }

View File

@@ -1,66 +1,36 @@
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import { PERMISSIONS } from "@/constants/userPermissions"; import {PERMISSIONS} from "@/constants/userPermissions";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import { Type, User, userTypes, CorporateUser, Group } from "@/interfaces/user"; import {Type, User, userTypes, CorporateUser, Group} from "@/interfaces/user";
import { Popover, Transition } from "@headlessui/react"; import {Popover, Transition} from "@headlessui/react";
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 clsx from "clsx"; import clsx from "clsx";
import { capitalize, reverse } from "lodash"; import {capitalize, reverse} from "lodash";
import moment from "moment"; import moment from "moment";
import { Fragment, useEffect, useState } from "react"; import {Fragment, useEffect, useState} from "react";
import { import {BsArrowDown, BsArrowDownUp, BsArrowUp, BsCheck, BsCheckCircle, BsEye, BsFillExclamationOctagonFill, BsPerson, BsTrash} from "react-icons/bs";
BsArrowDown, import {toast} from "react-toastify";
BsArrowDownUp, import {countries, TCountries} from "countries-list";
BsArrowUp,
BsCheck,
BsCheckCircle,
BsEye,
BsFillExclamationOctagonFill,
BsPerson,
BsTrash,
} from "react-icons/bs";
import { toast } from "react-toastify";
import { countries, TCountries } from "countries-list";
import countryCodes from "country-codes-list"; import countryCodes from "country-codes-list";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import UserCard from "@/components/UserCard"; import UserCard from "@/components/UserCard";
import { import {getUserCompanyName, isAgentUser, USER_TYPE_LABELS} from "@/resources/user";
getUserCompanyName,
isAgentUser,
USER_TYPE_LABELS,
} from "@/resources/user";
import useFilterStore from "@/stores/listFilterStore"; import useFilterStore from "@/stores/listFilterStore";
import { useRouter } from "next/router"; import {useRouter} from "next/router";
import { isCorporateUser } from "@/resources/user"; import {isCorporateUser} from "@/resources/user";
import { useListSearch } from "@/hooks/useListSearch"; import {useListSearch} from "@/hooks/useListSearch";
import { getUserCorporate } from "@/utils/groups"; import {getUserCorporate} from "@/utils/groups";
import { asyncSorter } from "@/utils"; import {asyncSorter} from "@/utils";
import { exportListToExcel, UserListRow } from "@/utils/users"; import {exportListToExcel, UserListRow} from "@/utils/users";
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 columnHelper = createColumnHelper<User>(); const columnHelper = createColumnHelper<User>();
const searchFields = [ const searchFields = [["name"], ["email"], ["corporateInformation", "companyInformation", "name"]];
["name"],
["email"],
["corporateInformation", "companyInformation", "name"],
];
const CompanyNameCell = ({ const CompanyNameCell = ({users, user, groups}: {user: User; users: User[]; groups: Group[]}) => {
users,
user,
groups,
}: {
user: User;
users: User[];
groups: Group[];
}) => {
const [companyName, setCompanyName] = useState(""); const [companyName, setCompanyName] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -69,11 +39,7 @@ const CompanyNameCell = ({
setCompanyName(name); setCompanyName(name);
}, [user, users, groups]); }, [user, users, groups]);
return isLoading ? ( return isLoading ? <span className="animate-pulse">Loading...</span> : <>{companyName}</>;
<span className="animate-pulse">Loading...</span>
) : (
<>{companyName}</>
);
}; };
export default function UserList({ export default function UserList({
@@ -85,18 +51,14 @@ export default function UserList({
filters?: ((user: User) => boolean)[]; filters?: ((user: User) => boolean)[];
renderHeader?: (total: number) => JSX.Element; renderHeader?: (total: number) => JSX.Element;
}) { }) {
const [showDemographicInformation, setShowDemographicInformation] = const [showDemographicInformation, setShowDemographicInformation] = useState(false);
useState(false);
const [sorter, setSorter] = useState<string>(); const [sorter, setSorter] = useState<string>();
const [displayUsers, setDisplayUsers] = useState<User[]>([]); const [displayUsers, setDisplayUsers] = useState<User[]>([]);
const [selectedUser, setSelectedUser] = useState<User>(); const [selectedUser, setSelectedUser] = useState<User>();
const { users, reload } = useUsers(); const {users, reload} = useUsers();
const { groups } = useGroups( const {permissions} = usePermissions(user?.id || "");
user && ["corporate", "teacher", "mastercorporate"].includes(user?.type) const {groups} = useGroups(user && ["corporate", "teacher", "mastercorporate"].includes(user?.type) ? user.id : undefined);
? user.id
: undefined
);
const appendUserFilters = useFilterStore((state) => state.appendUserFilter); const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
const router = useRouter(); const router = useRouter();
@@ -105,13 +67,10 @@ export default function UserList({
const momentDate = moment(date); const momentDate = moment(date);
const today = moment(new Date()); const today = moment(new Date());
if (today.isAfter(momentDate)) if (today.isAfter(momentDate)) return "!text-mti-red-light font-bold line-through";
return "!text-mti-red-light font-bold line-through";
if (today.add(1, "weeks").isAfter(momentDate)) return "!text-mti-red-light"; if (today.add(1, "weeks").isAfter(momentDate)) return "!text-mti-red-light";
if (today.add(2, "weeks").isAfter(momentDate)) if (today.add(2, "weeks").isAfter(momentDate)) return "!text-mti-rose-light";
return "!text-mti-rose-light"; if (today.add(1, "months").isAfter(momentDate)) return "!text-mti-orange-light";
if (today.add(1, "months").isAfter(momentDate))
return "!text-mti-orange-light";
}; };
useEffect(() => { useEffect(() => {
@@ -119,19 +78,11 @@ export default function UserList({
if (user && users) { if (user && users) {
const filterUsers = const filterUsers =
user.type === "corporate" || user.type === "teacher" user.type === "corporate" || user.type === "teacher"
? users.filter((u) => ? users.filter((u) => groups.flatMap((g) => g.participants).includes(u.id))
groups.flatMap((g) => g.participants).includes(u.id)
)
: users; : users;
const filteredUsers = filters.reduce( const filteredUsers = filters.reduce((d, f) => d.filter(f), filterUsers);
(d, f) => d.filter(f), const sortedUsers = await asyncSorter<User>(filteredUsers, sortFunction);
filterUsers
);
const sortedUsers = await asyncSorter<User>(
filteredUsers,
sortFunction
);
setDisplayUsers([...sortedUsers]); setDisplayUsers([...sortedUsers]);
} }
@@ -140,33 +91,25 @@ export default function UserList({
}, [user, users, sorter, groups]); }, [user, users, sorter, groups]);
const deleteAccount = (user: User) => { const deleteAccount = (user: User) => {
if (!confirm(`Are you sure you want to delete ${user.name}'s account?`)) if (!confirm(`Are you sure you want to delete ${user.name}'s account?`)) return;
return;
axios axios
.delete<{ ok: boolean }>(`/api/user?id=${user.id}`) .delete<{ok: boolean}>(`/api/user?id=${user.id}`)
.then(() => { .then(() => {
toast.success("User deleted successfully!"); toast.success("User deleted successfully!");
reload(); reload();
}) })
.catch(() => { .catch(() => {
toast.error("Something went wrong!", { toastId: "delete-error" }); toast.error("Something went wrong!", {toastId: "delete-error"});
}) })
.finally(reload); .finally(reload);
}; };
const updateAccountType = (user: User, type: Type) => { const updateAccountType = (user: User, type: Type) => {
if ( if (!confirm(`Are you sure you want to update ${user.name}'s account from ${capitalize(user.type)} to ${capitalize(type)}?`)) return;
!confirm(
`Are you sure you want to update ${
user.name
}'s account from ${capitalize(user.type)} to ${capitalize(type)}?`
)
)
return;
axios axios
.post<{ user?: User; ok?: boolean }>(`/api/users/update?id=${user.id}`, { .post<{user?: User; ok?: boolean}>(`/api/users/update?id=${user.id}`, {
...user, ...user,
type, type,
}) })
@@ -175,13 +118,13 @@ export default function UserList({
reload(); reload();
}) })
.catch(() => { .catch(() => {
toast.error("Something went wrong!", { toastId: "update-error" }); toast.error("Something went wrong!", {toastId: "update-error"});
}); });
}; };
const verifyAccount = (user: User) => { const verifyAccount = (user: User) => {
axios axios
.post<{ user?: User; ok?: boolean }>(`/api/users/update?id=${user.id}`, { .post<{user?: User; ok?: boolean}>(`/api/users/update?id=${user.id}`, {
...user, ...user,
isVerified: true, isVerified: true,
}) })
@@ -190,48 +133,42 @@ export default function UserList({
reload(); reload();
}) })
.catch(() => { .catch(() => {
toast.error("Something went wrong!", { toastId: "update-error" }); toast.error("Something went wrong!", {toastId: "update-error"});
}); });
}; };
const toggleDisableAccount = (user: User) => { const toggleDisableAccount = (user: User) => {
if ( if (
!confirm( !confirm(
`Are you sure you want to ${ `Are you sure you want to ${user.status === "disabled" ? "enable" : "disable"} ${
user.status === "disabled" ? "enable" : "disable"
} ${
user.name user.name
}'s account? This change is usually related to their payment state.` }'s account? This change is usually related to their payment state.`,
) )
) )
return; return;
axios axios
.post<{ user?: User; ok?: boolean }>(`/api/users/update?id=${user.id}`, { .post<{user?: User; ok?: boolean}>(`/api/users/update?id=${user.id}`, {
...user, ...user,
status: user.status === "disabled" ? "active" : "disabled", status: user.status === "disabled" ? "active" : "disabled",
}) })
.then(() => { .then(() => {
toast.success( toast.success(`User ${user.status === "disabled" ? "enabled" : "disabled"} successfully!`);
`User ${
user.status === "disabled" ? "enabled" : "disabled"
} successfully!`
);
reload(); reload();
}) })
.catch(() => { .catch(() => {
toast.error("Something went wrong!", { toastId: "update-error" }); toast.error("Something went wrong!", {toastId: "update-error"});
}); });
}; };
const SorterArrow = ({ name }: { name: string }) => { const SorterArrow = ({name}: {name: string}) => {
if (sorter === name) return <BsArrowUp />; if (sorter === name) return <BsArrowUp />;
if (sorter === reverseString(name)) return <BsArrowDown />; if (sorter === reverseString(name)) return <BsArrowDown />;
return <BsArrowDownUp />; return <BsArrowDownUp />;
}; };
const actionColumn = ({ row }: { row: { original: User } }) => { const actionColumn = ({row}: {row: {original: User}}) => {
const updateUserPermission = PERMISSIONS.updateUser[row.original.type] as { const updateUserPermission = PERMISSIONS.updateUser[row.original.type] as {
list: Type[]; list: Type[];
perm: PermissionType; perm: PermissionType;
@@ -242,11 +179,7 @@ export default function UserList({
}; };
return ( return (
<div className="flex gap-4"> <div className="flex gap-4">
{checkAccess( {checkAccess(user, updateUserPermission.list, permissions, updateUserPermission.perm) && (
user,
updateUserPermission.list,
updateUserPermission.perm
) && (
<Popover className="relative"> <Popover className="relative">
<Popover.Button> <Popover.Button>
<div data-tip="Change Type" className="cursor-pointer tooltip"> <div data-tip="Change Type" className="cursor-pointer tooltip">
@@ -260,48 +193,31 @@ export default function UserList({
enterTo="opacity-100 translate-y-0" enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150" leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0" leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1" leaveTo="opacity-0 translate-y-1">
>
<Popover.Panel className="absolute z-10 w-screen right-1/2 translate-x-1/3 max-w-sm"> <Popover.Panel className="absolute z-10 w-screen right-1/2 translate-x-1/3 max-w-sm">
<div className="bg-white p-4 rounded-lg grid grid-cols-2 gap-2 w-full drop-shadow-xl"> <div className="bg-white p-4 rounded-lg grid grid-cols-2 gap-2 w-full drop-shadow-xl">
<Button <Button
onClick={() => updateAccountType(row.original, "student")} onClick={() => updateAccountType(row.original, "student")}
className="text-sm !py-2 !px-4" className="text-sm !py-2 !px-4"
disabled={ disabled={row.original.type === "student" || !PERMISSIONS.generateCode["student"].includes(user.type)}>
row.original.type === "student" ||
!PERMISSIONS.generateCode["student"].includes(user.type)
}
>
Student Student
</Button> </Button>
<Button <Button
onClick={() => updateAccountType(row.original, "teacher")} onClick={() => updateAccountType(row.original, "teacher")}
className="text-sm !py-2 !px-4" className="text-sm !py-2 !px-4"
disabled={ disabled={row.original.type === "teacher" || !PERMISSIONS.generateCode["teacher"].includes(user.type)}>
row.original.type === "teacher" ||
!PERMISSIONS.generateCode["teacher"].includes(user.type)
}
>
Teacher Teacher
</Button> </Button>
<Button <Button
onClick={() => updateAccountType(row.original, "corporate")} onClick={() => updateAccountType(row.original, "corporate")}
className="text-sm !py-2 !px-4" className="text-sm !py-2 !px-4"
disabled={ disabled={row.original.type === "corporate" || !PERMISSIONS.generateCode["corporate"].includes(user.type)}>
row.original.type === "corporate" ||
!PERMISSIONS.generateCode["corporate"].includes(user.type)
}
>
Corporate Corporate
</Button> </Button>
<Button <Button
onClick={() => updateAccountType(row.original, "admin")} onClick={() => updateAccountType(row.original, "admin")}
className="text-sm !py-2 !px-4" className="text-sm !py-2 !px-4"
disabled={ disabled={row.original.type === "admin" || !PERMISSIONS.generateCode["admin"].includes(user.type)}>
row.original.type === "admin" ||
!PERMISSIONS.generateCode["admin"].includes(user.type)
}
>
Admin Admin
</Button> </Button>
</div> </div>
@@ -309,34 +225,16 @@ export default function UserList({
</Transition> </Transition>
</Popover> </Popover>
)} )}
{!row.original.isVerified && {!row.original.isVerified && checkAccess(user, updateUserPermission.list, permissions, updateUserPermission.perm) && (
checkAccess( <div data-tip="Verify User" className="cursor-pointer tooltip" onClick={() => verifyAccount(row.original)}>
user,
updateUserPermission.list,
updateUserPermission.perm
) && (
<div
data-tip="Verify User"
className="cursor-pointer tooltip"
onClick={() => verifyAccount(row.original)}
>
<BsCheck className="hover:text-mti-purple-light transition ease-in-out duration-300" /> <BsCheck className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div> </div>
)} )}
{checkAccess( {checkAccess(user, updateUserPermission.list, permissions, updateUserPermission.perm) && (
user,
updateUserPermission.list,
updateUserPermission.perm
) && (
<div <div
data-tip={ data-tip={row.original.status === "disabled" ? "Enable User" : "Disable User"}
row.original.status === "disabled"
? "Enable User"
: "Disable User"
}
className="cursor-pointer tooltip" className="cursor-pointer tooltip"
onClick={() => toggleDisableAccount(row.original)} onClick={() => toggleDisableAccount(row.original)}>
>
{row.original.status === "disabled" ? ( {row.original.status === "disabled" ? (
<BsCheckCircle className="hover:text-mti-purple-light transition ease-in-out duration-300" /> <BsCheckCircle className="hover:text-mti-purple-light transition ease-in-out duration-300" />
) : ( ) : (
@@ -344,16 +242,8 @@ export default function UserList({
)} )}
</div> </div>
)} )}
{checkAccess( {checkAccess(user, deleteUserPermission.list, permissions, deleteUserPermission.perm) && (
user, <div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteAccount(row.original)}>
deleteUserPermission.list,
deleteUserPermission.perm
) && (
<div
data-tip="Delete"
className="cursor-pointer tooltip"
onClick={() => deleteAccount(row.original)}
>
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" /> <BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div> </div>
)} )}
@@ -364,60 +254,39 @@ export default function UserList({
const demographicColumns = [ const demographicColumns = [
columnHelper.accessor("name", { columnHelper.accessor("name", {
header: ( header: (
<button <button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "name"))}>
className="flex gap-2 items-center"
onClick={() => setSorter((prev) => selectSorter(prev, "name"))}
>
<span>Name</span> <span>Name</span>
<SorterArrow name="name" /> <SorterArrow name="name" />
</button> </button>
) as any, ) as any,
cell: ({ row, getValue }) => ( cell: ({row, getValue}) => (
<div <div
className={clsx( className={clsx(
PERMISSIONS.updateExpiryDate[row.original.type]?.includes( PERMISSIONS.updateExpiryDate[row.original.type]?.includes(user.type) &&
user.type "underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer",
) &&
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer"
)} )}
onClick={() => onClick={() => (PERMISSIONS.updateExpiryDate[row.original.type]?.includes(user.type) ? setSelectedUser(row.original) : null)}>
PERMISSIONS.updateExpiryDate[row.original.type]?.includes(user.type)
? setSelectedUser(row.original)
: null
}
>
{getValue()} {getValue()}
</div> </div>
), ),
}), }),
columnHelper.accessor("demographicInformation.country", { columnHelper.accessor("demographicInformation.country", {
header: ( header: (
<button <button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "country"))}>
className="flex gap-2 items-center"
onClick={() => setSorter((prev) => selectSorter(prev, "country"))}
>
<span>Country</span> <span>Country</span>
<SorterArrow name="country" /> <SorterArrow name="country" />
</button> </button>
) as any, ) as any,
cell: (info) => cell: (info) =>
info.getValue() info.getValue()
? `${ ? `${countryCodes.findOne("countryCode" as any, info.getValue()).flag} ${
countryCodes.findOne("countryCode" as any, info.getValue()).flag
} ${
countries[info.getValue() as unknown as keyof TCountries].name countries[info.getValue() as unknown as keyof TCountries].name
} (+${ } (+${countryCodes.findOne("countryCode" as any, info.getValue()).countryCallingCode})`
countryCodes.findOne("countryCode" as any, info.getValue())
.countryCallingCode
})`
: "Not available", : "Not available",
}), }),
columnHelper.accessor("demographicInformation.phone", { columnHelper.accessor("demographicInformation.phone", {
header: ( header: (
<button <button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "phone"))}>
className="flex gap-2 items-center"
onClick={() => setSorter((prev) => selectSorter(prev, "phone"))}
>
<span>Phone</span> <span>Phone</span>
<SorterArrow name="phone" /> <SorterArrow name="phone" />
</button> </button>
@@ -427,35 +296,22 @@ export default function UserList({
}), }),
columnHelper.accessor( columnHelper.accessor(
(x) => (x) =>
x.type === "corporate" || x.type === "mastercorporate" x.type === "corporate" || x.type === "mastercorporate" ? x.demographicInformation?.position : x.demographicInformation?.employment,
? x.demographicInformation?.position
: x.demographicInformation?.employment,
{ {
id: "employment", id: "employment",
header: ( header: (
<button <button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "employment"))}>
className="flex gap-2 items-center"
onClick={() =>
setSorter((prev) => selectSorter(prev, "employment"))
}
>
<span>Employment/Position</span> <span>Employment/Position</span>
<SorterArrow name="employment" /> <SorterArrow name="employment" />
</button> </button>
) as any, ) as any,
cell: (info) => cell: (info) => (info.row.original.type === "corporate" ? info.getValue() : capitalize(info.getValue())) || "Not available",
(info.row.original.type === "corporate"
? info.getValue()
: capitalize(info.getValue())) || "Not available",
enableSorting: true, enableSorting: true,
} },
), ),
columnHelper.accessor("demographicInformation.gender", { columnHelper.accessor("demographicInformation.gender", {
header: ( header: (
<button <button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "gender"))}>
className="flex gap-2 items-center"
onClick={() => setSorter((prev) => selectSorter(prev, "gender"))}
>
<span>Gender</span> <span>Gender</span>
<SorterArrow name="gender" /> <SorterArrow name="gender" />
</button> </button>
@@ -465,10 +321,7 @@ export default function UserList({
}), }),
{ {
header: ( header: (
<span <span className="cursor-pointer" onClick={() => setShowDemographicInformation((prev) => !prev)}>
className="cursor-pointer"
onClick={() => setShowDemographicInformation((prev) => !prev)}
>
Switch Switch
</span> </span>
), ),
@@ -480,69 +333,43 @@ export default function UserList({
const defaultColumns = [ const defaultColumns = [
columnHelper.accessor("name", { columnHelper.accessor("name", {
header: ( header: (
<button <button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "name"))}>
className="flex gap-2 items-center"
onClick={() => setSorter((prev) => selectSorter(prev, "name"))}
>
<span>Name</span> <span>Name</span>
<SorterArrow name="name" /> <SorterArrow name="name" />
</button> </button>
) as any, ) as any,
cell: ({ row, getValue }) => ( cell: ({row, getValue}) => (
<div <div
className={clsx( className={clsx(
PERMISSIONS.updateExpiryDate[row.original.type]?.includes( PERMISSIONS.updateExpiryDate[row.original.type]?.includes(user.type) &&
user.type "underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer",
) &&
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer"
)} )}
onClick={() => onClick={() => (PERMISSIONS.updateExpiryDate[row.original.type]?.includes(user.type) ? setSelectedUser(row.original) : null)}>
PERMISSIONS.updateExpiryDate[row.original.type]?.includes(user.type) {row.original.type === "corporate" ? row.original.corporateInformation?.companyInformation?.name || getValue() : getValue()}
? setSelectedUser(row.original)
: null
}
>
{row.original.type === "corporate"
? row.original.corporateInformation?.companyInformation?.name ||
getValue()
: getValue()}
</div> </div>
), ),
}), }),
columnHelper.accessor("email", { columnHelper.accessor("email", {
header: ( header: (
<button <button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "email"))}>
className="flex gap-2 items-center"
onClick={() => setSorter((prev) => selectSorter(prev, "email"))}
>
<span>E-mail</span> <span>E-mail</span>
<SorterArrow name="email" /> <SorterArrow name="email" />
</button> </button>
) as any, ) as any,
cell: ({ row, getValue }) => ( cell: ({row, getValue}) => (
<div <div
className={clsx( className={clsx(
PERMISSIONS.updateExpiryDate[row.original.type]?.includes( PERMISSIONS.updateExpiryDate[row.original.type]?.includes(user.type) &&
user.type "underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer",
) &&
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer"
)} )}
onClick={() => onClick={() => (PERMISSIONS.updateExpiryDate[row.original.type]?.includes(user.type) ? setSelectedUser(row.original) : null)}>
PERMISSIONS.updateExpiryDate[row.original.type]?.includes(user.type)
? setSelectedUser(row.original)
: null
}
>
{getValue()} {getValue()}
</div> </div>
), ),
}), }),
columnHelper.accessor("type", { columnHelper.accessor("type", {
header: ( header: (
<button <button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "type"))}>
className="flex gap-2 items-center"
onClick={() => setSorter((prev) => selectSorter(prev, "type"))}
>
<span>Type</span> <span>Type</span>
<SorterArrow name="type" /> <SorterArrow name="type" />
</button> </button>
@@ -551,54 +378,29 @@ export default function UserList({
}), }),
columnHelper.accessor("corporateInformation.companyInformation.name", { columnHelper.accessor("corporateInformation.companyInformation.name", {
header: ( header: (
<button <button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "companyName"))}>
className="flex gap-2 items-center"
onClick={() => setSorter((prev) => selectSorter(prev, "companyName"))}
>
<span>Company Name</span> <span>Company Name</span>
<SorterArrow name="companyName" /> <SorterArrow name="companyName" />
</button> </button>
) as any, ) as any,
cell: (info) => ( cell: (info) => <CompanyNameCell user={info.row.original} users={users} groups={groups} />,
<CompanyNameCell
user={info.row.original}
users={users}
groups={groups}
/>
),
}), }),
columnHelper.accessor("subscriptionExpirationDate", { columnHelper.accessor("subscriptionExpirationDate", {
header: ( header: (
<button <button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "expiryDate"))}>
className="flex gap-2 items-center"
onClick={() => setSorter((prev) => selectSorter(prev, "expiryDate"))}
>
<span>Expiry Date</span> <span>Expiry Date</span>
<SorterArrow name="expiryDate" /> <SorterArrow name="expiryDate" />
</button> </button>
) as any, ) as any,
cell: (info) => ( cell: (info) => (
<span <span className={clsx(info.getValue() ? expirationDateColor(moment(info.getValue()).toDate()) : "")}>
className={clsx( {!info.getValue() ? "No expiry date" : moment(info.getValue()).format("DD/MM/YYYY")}
info.getValue()
? expirationDateColor(moment(info.getValue()).toDate())
: ""
)}
>
{!info.getValue()
? "No expiry date"
: moment(info.getValue()).format("DD/MM/YYYY")}
</span> </span>
), ),
}), }),
columnHelper.accessor("isVerified", { columnHelper.accessor("isVerified", {
header: ( header: (
<button <button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "verification"))}>
className="flex gap-2 items-center"
onClick={() =>
setSorter((prev) => selectSorter(prev, "verification"))
}
>
<span>Verification</span> <span>Verification</span>
<SorterArrow name="verification" /> <SorterArrow name="verification" />
</button> </button>
@@ -609,9 +411,8 @@ export default function UserList({
className={clsx( className={clsx(
"w-6 h-6 rounded-md flex items-center justify-center border border-mti-purple-light bg-white", "w-6 h-6 rounded-md flex items-center justify-center border border-mti-purple-light bg-white",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
info.getValue() && "!bg-mti-purple-light " info.getValue() && "!bg-mti-purple-light ",
)} )}>
>
<BsCheck color="white" className="w-full h-full" /> <BsCheck color="white" className="w-full h-full" />
</div> </div>
</div> </div>
@@ -619,10 +420,7 @@ export default function UserList({
}), }),
{ {
header: ( header: (
<span <span className="cursor-pointer" onClick={() => setShowDemographicInformation((prev) => !prev)}>
className="cursor-pointer"
onClick={() => setShowDemographicInformation((prev) => !prev)}
>
Switch Switch
</span> </span>
), ),
@@ -642,21 +440,15 @@ export default function UserList({
const sortFunction = async (a: User, b: User) => { const sortFunction = async (a: User, b: User) => {
if (sorter === "name" || sorter === reverseString("name")) if (sorter === "name" || sorter === reverseString("name"))
return sorter === "name" return sorter === "name" ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name);
? a.name.localeCompare(b.name)
: b.name.localeCompare(a.name);
if (sorter === "email" || sorter === reverseString("email")) if (sorter === "email" || sorter === reverseString("email"))
return sorter === "email" return sorter === "email" ? a.email.localeCompare(b.email) : b.email.localeCompare(a.email);
? a.email.localeCompare(b.email)
: b.email.localeCompare(a.email);
if (sorter === "type" || sorter === reverseString("type")) if (sorter === "type" || sorter === reverseString("type"))
return sorter === "type" return sorter === "type"
? userTypes.findIndex((t) => a.type === t) - ? userTypes.findIndex((t) => a.type === t) - userTypes.findIndex((t) => b.type === t)
userTypes.findIndex((t) => b.type === t) : userTypes.findIndex((t) => b.type === t) - userTypes.findIndex((t) => a.type === t);
: userTypes.findIndex((t) => b.type === t) -
userTypes.findIndex((t) => a.type === t);
if (sorter === "verification" || sorter === reverseString("verification")) if (sorter === "verification" || sorter === reverseString("verification"))
return sorter === "verification" return sorter === "verification"
@@ -664,138 +456,75 @@ export default function UserList({
: b.isVerified.toString().localeCompare(a.isVerified.toString()); : b.isVerified.toString().localeCompare(a.isVerified.toString());
if (sorter === "expiryDate" || sorter === reverseString("expiryDate")) { if (sorter === "expiryDate" || sorter === reverseString("expiryDate")) {
if (!a.subscriptionExpirationDate && b.subscriptionExpirationDate) if (!a.subscriptionExpirationDate && b.subscriptionExpirationDate) return sorter === "expiryDate" ? -1 : 1;
return sorter === "expiryDate" ? -1 : 1; if (a.subscriptionExpirationDate && !b.subscriptionExpirationDate) return sorter === "expiryDate" ? 1 : -1;
if (a.subscriptionExpirationDate && !b.subscriptionExpirationDate) if (!a.subscriptionExpirationDate && !b.subscriptionExpirationDate) return 0;
return sorter === "expiryDate" ? 1 : -1; if (moment(a.subscriptionExpirationDate).isAfter(b.subscriptionExpirationDate)) return sorter === "expiryDate" ? -1 : 1;
if (!a.subscriptionExpirationDate && !b.subscriptionExpirationDate) if (moment(b.subscriptionExpirationDate).isAfter(a.subscriptionExpirationDate)) return sorter === "expiryDate" ? 1 : -1;
return 0;
if (
moment(a.subscriptionExpirationDate).isAfter(
b.subscriptionExpirationDate
)
)
return sorter === "expiryDate" ? -1 : 1;
if (
moment(b.subscriptionExpirationDate).isAfter(
a.subscriptionExpirationDate
)
)
return sorter === "expiryDate" ? 1 : -1;
return 0; return 0;
} }
if (sorter === "country" || sorter === reverseString("country")) { if (sorter === "country" || sorter === reverseString("country")) {
if ( if (!a.demographicInformation?.country && b.demographicInformation?.country) return sorter === "country" ? -1 : 1;
!a.demographicInformation?.country && if (a.demographicInformation?.country && !b.demographicInformation?.country) return sorter === "country" ? 1 : -1;
b.demographicInformation?.country if (!a.demographicInformation?.country && !b.demographicInformation?.country) return 0;
)
return sorter === "country" ? -1 : 1;
if (
a.demographicInformation?.country &&
!b.demographicInformation?.country
)
return sorter === "country" ? 1 : -1;
if (
!a.demographicInformation?.country &&
!b.demographicInformation?.country
)
return 0;
return sorter === "country" return sorter === "country"
? a.demographicInformation!.country.localeCompare( ? a.demographicInformation!.country.localeCompare(b.demographicInformation!.country)
b.demographicInformation!.country : b.demographicInformation!.country.localeCompare(a.demographicInformation!.country);
)
: b.demographicInformation!.country.localeCompare(
a.demographicInformation!.country
);
} }
if (sorter === "phone" || sorter === reverseString("phone")) { if (sorter === "phone" || sorter === reverseString("phone")) {
if (!a.demographicInformation?.phone && b.demographicInformation?.phone) if (!a.demographicInformation?.phone && b.demographicInformation?.phone) return sorter === "phone" ? -1 : 1;
return sorter === "phone" ? -1 : 1; if (a.demographicInformation?.phone && !b.demographicInformation?.phone) return sorter === "phone" ? 1 : -1;
if (a.demographicInformation?.phone && !b.demographicInformation?.phone) if (!a.demographicInformation?.phone && !b.demographicInformation?.phone) return 0;
return sorter === "phone" ? 1 : -1;
if (!a.demographicInformation?.phone && !b.demographicInformation?.phone)
return 0;
return sorter === "phone" return sorter === "phone"
? a.demographicInformation!.phone.localeCompare( ? a.demographicInformation!.phone.localeCompare(b.demographicInformation!.phone)
b.demographicInformation!.phone : b.demographicInformation!.phone.localeCompare(a.demographicInformation!.phone);
)
: b.demographicInformation!.phone.localeCompare(
a.demographicInformation!.phone
);
} }
if (sorter === "employment" || sorter === reverseString("employment")) { if (sorter === "employment" || sorter === reverseString("employment")) {
const aSortingItem = const aSortingItem =
a.type === "corporate" || a.type === "mastercorporate" a.type === "corporate" || a.type === "mastercorporate" ? a.demographicInformation?.position : a.demographicInformation?.employment;
? a.demographicInformation?.position
: a.demographicInformation?.employment;
const bSortingItem = const bSortingItem =
b.type === "corporate" || b.type === "mastercorporate" b.type === "corporate" || b.type === "mastercorporate" ? b.demographicInformation?.position : b.demographicInformation?.employment;
? b.demographicInformation?.position
: b.demographicInformation?.employment;
if (!aSortingItem && bSortingItem) if (!aSortingItem && bSortingItem) return sorter === "employment" ? -1 : 1;
return sorter === "employment" ? -1 : 1; if (aSortingItem && !bSortingItem) return sorter === "employment" ? 1 : -1;
if (aSortingItem && !bSortingItem)
return sorter === "employment" ? 1 : -1;
if (!aSortingItem && !bSortingItem) return 0; if (!aSortingItem && !bSortingItem) return 0;
return sorter === "employment" return sorter === "employment" ? aSortingItem!.localeCompare(bSortingItem!) : bSortingItem!.localeCompare(aSortingItem!);
? aSortingItem!.localeCompare(bSortingItem!)
: bSortingItem!.localeCompare(aSortingItem!);
} }
if (sorter === "gender" || sorter === reverseString("gender")) { if (sorter === "gender" || sorter === reverseString("gender")) {
if (!a.demographicInformation?.gender && b.demographicInformation?.gender) if (!a.demographicInformation?.gender && b.demographicInformation?.gender) return sorter === "employment" ? -1 : 1;
return sorter === "employment" ? -1 : 1; if (a.demographicInformation?.gender && !b.demographicInformation?.gender) return sorter === "employment" ? 1 : -1;
if (a.demographicInformation?.gender && !b.demographicInformation?.gender) if (!a.demographicInformation?.gender && !b.demographicInformation?.gender) return 0;
return sorter === "employment" ? 1 : -1;
if (
!a.demographicInformation?.gender &&
!b.demographicInformation?.gender
)
return 0;
return sorter === "gender" return sorter === "gender"
? a.demographicInformation!.gender.localeCompare( ? a.demographicInformation!.gender.localeCompare(b.demographicInformation!.gender)
b.demographicInformation!.gender : b.demographicInformation!.gender.localeCompare(a.demographicInformation!.gender);
)
: b.demographicInformation!.gender.localeCompare(
a.demographicInformation!.gender
);
} }
if (sorter === "companyName" || sorter === reverseString("companyName")) { if (sorter === "companyName" || sorter === reverseString("companyName")) {
const aCorporateName = getUserCompanyName(a, users, groups); const aCorporateName = getUserCompanyName(a, users, groups);
const bCorporateName = getUserCompanyName(b, users, groups); const bCorporateName = getUserCompanyName(b, users, groups);
if (!aCorporateName && bCorporateName) if (!aCorporateName && bCorporateName) return sorter === "companyName" ? -1 : 1;
return sorter === "companyName" ? -1 : 1; if (aCorporateName && !bCorporateName) return sorter === "companyName" ? 1 : -1;
if (aCorporateName && !bCorporateName)
return sorter === "companyName" ? 1 : -1;
if (!aCorporateName && !bCorporateName) return 0; if (!aCorporateName && !bCorporateName) return 0;
return sorter === "companyName" return sorter === "companyName" ? aCorporateName.localeCompare(bCorporateName) : bCorporateName.localeCompare(aCorporateName);
? aCorporateName.localeCompare(bCorporateName)
: bCorporateName.localeCompare(aCorporateName);
} }
return a.id.localeCompare(b.id); return a.id.localeCompare(b.id);
}; };
const { rows: filteredRows, renderSearch } = useListSearch<User>( const {rows: filteredRows, renderSearch} = useListSearch<User>(searchFields, displayUsers);
searchFields,
displayUsers
);
const table = useReactTable({ const table = useReactTable({
data: filteredRows, data: filteredRows,
columns: (!showDemographicInformation columns: (!showDemographicInformation ? defaultColumns : demographicColumns) as any,
? defaultColumns
: demographicColumns) as any,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
}); });
@@ -803,7 +532,7 @@ export default function UserList({
const csv = exportListToExcel(filteredRows, users, groups); const csv = exportListToExcel(filteredRows, users, groups);
const element = document.createElement("a"); const element = document.createElement("a");
const file = new Blob([csv], { type: "text/csv" }); const file = new Blob([csv], {type: "text/csv"});
element.href = URL.createObjectURL(file); element.href = URL.createObjectURL(file);
element.download = "users.csv"; element.download = "users.csv";
document.body.appendChild(element); document.body.appendChild(element);
@@ -816,19 +545,13 @@ export default function UserList({
const belongsToAdminFilter = (x: User) => { const belongsToAdminFilter = (x: User) => {
if (!selectedUser) return false; if (!selectedUser) return false;
return groups return groups
.filter( .filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
(g) =>
g.admin === selectedUser.id ||
g.participants.includes(selectedUser.id)
)
.flatMap((g) => g.participants) .flatMap((g) => g.participants)
.includes(x.id); .includes(x.id);
}; };
const viewStudentFilterBelongsToAdmin = (x: User) => const viewStudentFilterBelongsToAdmin = (x: User) => x.type === "student" && belongsToAdminFilter(x);
x.type === "student" && belongsToAdminFilter(x); const viewTeacherFilterBelongsToAdmin = (x: User) => x.type === "teacher" && belongsToAdminFilter(x);
const viewTeacherFilterBelongsToAdmin = (x: User) =>
x.type === "teacher" && belongsToAdminFilter(x);
const renderUserCard = (selectedUser: User) => { const renderUserCard = (selectedUser: User) => {
const studentsFromAdmin = users.filter(viewStudentFilterBelongsToAdmin); const studentsFromAdmin = users.filter(viewStudentFilterBelongsToAdmin);
@@ -838,9 +561,7 @@ export default function UserList({
<UserCard <UserCard
loggedInUser={user} loggedInUser={user}
onViewStudents={ onViewStudents={
(selectedUser.type === "corporate" || (selectedUser.type === "corporate" || selectedUser.type === "teacher") && studentsFromAdmin.length > 0
selectedUser.type === "teacher") &&
studentsFromAdmin.length > 0
? () => { ? () => {
appendUserFilters({ appendUserFilters({
id: "view-students", id: "view-students",
@@ -856,9 +577,7 @@ export default function UserList({
: undefined : undefined
} }
onViewTeachers={ onViewTeachers={
(selectedUser.type === "corporate" || (selectedUser.type === "corporate" || selectedUser.type === "student") && teachersFromAdmin.length > 0
selectedUser.type === "student") &&
teachersFromAdmin.length > 0
? () => { ? () => {
appendUserFilters({ appendUserFilters({
id: "view-teachers", id: "view-teachers",
@@ -907,20 +626,13 @@ export default function UserList({
<> <>
{renderHeader && renderHeader(displayUsers.length)} {renderHeader && renderHeader(displayUsers.length)}
<div className="w-full"> <div className="w-full">
<Modal <Modal isOpen={!!selectedUser} onClose={() => setSelectedUser(undefined)}>
isOpen={!!selectedUser}
onClose={() => setSelectedUser(undefined)}
>
{selectedUser && renderUserCard(selectedUser)} {selectedUser && renderUserCard(selectedUser)}
</Modal> </Modal>
<div className="w-full flex flex-col gap-2"> <div className="w-full flex flex-col gap-2">
<div className="w-full flex gap-2 items-end"> <div className="w-full flex gap-2 items-end">
{renderSearch()} {renderSearch()}
<Button <Button className="w-full max-w-[200px] mb-1" variant="outline" onClick={downloadExcel}>
className="w-full max-w-[200px] mb-1"
variant="outline"
onClick={downloadExcel}
>
Download List Download List
</Button> </Button>
</div> </div>
@@ -930,12 +642,7 @@ export default function UserList({
<tr key={headerGroup.id}> <tr key={headerGroup.id}>
{headerGroup.headers.map((header) => ( {headerGroup.headers.map((header) => (
<th className="py-4 px-4 text-left" key={header.id}> <th className="py-4 px-4 text-left" key={header.id}>
{header.isPlaceholder {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th> </th>
))} ))}
</tr> </tr>
@@ -943,16 +650,10 @@ export default function UserList({
</thead> </thead>
<tbody className="px-2"> <tbody className="px-2">
{table.getRowModel().rows.map((row) => ( {table.getRowModel().rows.map((row) => (
<tr <tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2"
key={row.id}
>
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<td className="px-4 py-2 items-center w-fit" key={cell.id}> <td className="px-4 py-2 items-center w-fit" key={cell.id}>
{flexRender( {flexRender(cell.column.columnDef.cell, cell.getContext())}
cell.column.columnDef.cell,
cell.getContext()
)}
</td> </td>
))} ))}
</tr> </tr>

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,100 +7,86 @@ 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 usePermissions from "@/hooks/usePermissions";
export default function Lists({user}: {user: User}) {
const {permissions} = usePermissions(user?.id || "");
export default function Lists({ user }: { user: User }) {
return ( return (
<Tab.Group> <Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-mti-purple-ultralight/40 p-1"> <Tab.List className="flex space-x-1 rounded-xl bg-mti-purple-ultralight/40 p-1">
<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 selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
? "bg-white shadow"
: "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
) )
} }>
>
User List User List
</Tab> </Tab>
{user?.type === "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 selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
? "bg-white shadow"
: "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
) )
} }>
>
Exam List Exam List
</Tab> </Tab>
)} )}
<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 selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
? "bg-white shadow"
: "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
) )
} }>
>
Group List Group List
</Tab> </Tab>
{user && ["developer", "admin", "corporate"].includes(user.type) && ( {checkAccess(user, ["developer", "admin", "corporate"]) && (
<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 selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
? "bg-white shadow"
: "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
) )
} }>
>
Code List Code List
</Tab> </Tab>
)} )}
{user && ["developer", "admin"].includes(user.type) && ( {checkAccess(user, ["developer", "admin"]) && (
<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 selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
? "bg-white shadow"
: "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
) )
} }>
>
Package List Package List
</Tab> </Tab>
)} )}
{user && ["developer", "admin"].includes(user.type) && ( {checkAccess(user, ["developer", "admin"]) && (
<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 selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
? "bg-white shadow"
: "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
) )
} }>
>
Discount List Discount List
</Tab> </Tab>
)} )}
@@ -109,7 +95,7 @@ export default function Lists({ user }: { user: User }) {
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide"> <Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<UserList user={user} /> <UserList user={user} />
</Tab.Panel> </Tab.Panel>
{user?.type === "developer" && ( {checkAccess(user, ["developer"]) && (
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide"> <Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<ExamList user={user} /> <ExamList user={user} />
</Tab.Panel> </Tab.Panel>
@@ -117,17 +103,17 @@ export default function Lists({ user }: { user: User }) {
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide"> <Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<GroupList user={user} /> <GroupList user={user} />
</Tab.Panel> </Tab.Panel>
{user && ["developer", "admin", "corporate"].includes(user.type) && ( {checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "viewCodes") && (
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide"> <Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<CodeList user={user} /> <CodeList user={user} />
</Tab.Panel> </Tab.Panel>
)} )}
{user && ["developer", "admin"].includes(user.type) && ( {checkAccess(user, ["developer", "admin"]) && (
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide"> <Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<PackageList user={user} /> <PackageList user={user} />
</Tab.Panel> </Tab.Panel>
)} )}
{user && ["developer", "admin"].includes(user.type) && ( {checkAccess(user, ["developer", "admin"]) && (
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide"> <Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<DiscountList user={user} /> <DiscountList user={user} />
</Tab.Panel> </Tab.Panel>

View File

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

View File

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

129
src/pages/api/make_user.ts Normal file
View File

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

View File

@@ -1,9 +1,10 @@
// 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);
@@ -11,20 +12,35 @@ 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};
const {users} = req.body;
try { try {
await setDoc(doc(db, "permissions", id), { users }, { merge: true }); await setDoc(doc(db, "permissions", id), {users}, {merge: true});
return res.status(200).json({ ok: true }); return res.status(200).json({ok: true});
} catch (err) { } catch (err) {
console.error(err); console.error(err);
return res.status(500).json({ ok: false }); 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);
@@ -32,15 +22,15 @@ async function user(req: NextApiRequest, res: NextApiResponse) {
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;
} }
@@ -48,24 +38,16 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
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(
collection(db, "groups"),
where("participants", "array-contains", id)
)
);
await Promise.all([ await Promise.all([
...userParticipantGroup.docs ...userParticipantGroup.docs
.filter((x) => (x.data() as Group).admin === user.id) .filter((x) => (x.data() as Group).admin === user.id)
@@ -74,12 +56,10 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
await setDoc( await setDoc(
x.ref, x.ref,
{ {
participants: x participants: x.data().participants.filter((y: string) => y !== id),
.data()
.participants.filter((y: string) => y !== id),
}, },
{ merge: true } {merge: true},
) ),
), ),
]); ]);
@@ -88,26 +68,18 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
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)),
@@ -120,8 +92,8 @@ async function del(req: NextApiRequest, res: NextApiResponse) {
{ {
participants: x.data().participants.filter((y: string) => y !== id), participants: x.data().participants.filter((y: string) => y !== id),
}, },
{ merge: true } {merge: true},
) ),
), ),
]); ]);
} }
@@ -136,19 +108,13 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
const user = docUser.data() as User; const user = docUser.data() as User;
const permissionDocs = await getPermissionDocs();
const userWithPermissions = {
...user,
permissions: getPermissions(req.session.user.id, permissionDocs),
};
req.session.user = { req.session.user = {
...userWithPermissions, ...user,
id: req.session.user.id, id: req.session.user.id,
}; };
await req.session.save(); 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

@@ -21,7 +21,6 @@ import { uuidv4 } from "@firebase/util";
import { usePDFDownload } from "@/hooks/usePDFDownload"; import { usePDFDownload } from "@/hooks/usePDFDownload";
import useRecordStore from "@/stores/recordStore"; import useRecordStore from "@/stores/recordStore";
import useTrainingContentStore from "@/stores/trainingContentStore"; import useTrainingContentStore from "@/stores/trainingContentStore";
import Button from "@/components/Low/Button";
import StatsGridItem from "@/components/StatGridItem"; import StatsGridItem from "@/components/StatGridItem";
@@ -148,10 +147,12 @@ export default function History({ user }: { user: User }) {
const handleTrainingContentSubmission = () => { const handleTrainingContentSubmission = () => {
if (groupedStats) { if (groupedStats) {
const allStats = Object.keys(filterStatsByDate(groupedStats)); const groupedStatsByDate = filterStatsByDate(groupedStats);
const selectedStats = selectedTrainingExams.reduce<Record<string, Stat[]>>((accumulator, timestamp) => { const allStats = Object.keys(groupedStatsByDate);
if (allStats.includes(timestamp)) { const selectedStats = selectedTrainingExams.reduce<Record<string, Stat[]>>((accumulator, moduleAndTimestamp) => {
accumulator[timestamp] = filterStatsByDate(groupedStats)[timestamp]; const timestamp = moduleAndTimestamp.split("-")[1];
if (allStats.includes(timestamp) && !accumulator.hasOwnProperty(timestamp)) {
accumulator[timestamp] = groupedStatsByDate[timestamp];
} }
return accumulator; return accumulator;
}, {}); }, {});
@@ -177,6 +178,7 @@ export default function History({ user }: { user: User }) {
training={training} training={training}
selectedTrainingExams={selectedTrainingExams} selectedTrainingExams={selectedTrainingExams}
setSelectedTrainingExams={setSelectedTrainingExams} setSelectedTrainingExams={setSelectedTrainingExams}
maxTrainingExams={MAX_TRAINING_EXAMS}
setExams={setExams} setExams={setExams}
setShowSolutions={setShowSolutions} setShowSolutions={setShowSolutions}
setUserSolutions={setUserSolutions} setUserSolutions={setUserSolutions}
@@ -323,7 +325,8 @@ export default function History({ user }: { user: User }) {
)} )}
{(training && ( {(training && (
<div className="flex flex-row"> <div className="flex flex-row">
<div className="font-semibold text-2xl mr-4">Select up to 10 exams {`(${selectedTrainingExams.length}/${MAX_TRAINING_EXAMS})`}</div> <div className="font-semibold text-2xl mr-4">Select up to 10 exercises
{`(${selectedTrainingExams.length}/${MAX_TRAINING_EXAMS})`}</div>
<button <button
className={clsx( className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light ml-4 disabled:cursor-not-allowed", "bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light ml-4 disabled:cursor-not-allowed",
@@ -385,6 +388,11 @@ export default function History({ user }: { user: User }) {
{groupedStats && Object.keys(groupedStats).length === 0 && !isStatsLoading && ( {groupedStats && Object.keys(groupedStats).length === 0 && !isStatsLoading && (
<span className="font-semibold ml-1">No record to display...</span> <span className="font-semibold ml-1">No record to display...</span>
)} )}
{isStatsLoading && (
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-12">
<span className="loading loading-infinity w-32 bg-mti-green-light" />
</div>
)}
</Layout> </Layout>
)} )}
</> </>

View File

@@ -13,10 +13,12 @@ import Lists from "./(admin)/Lists";
import BatchCodeGenerator from "./(admin)/BatchCodeGenerator"; import BatchCodeGenerator from "./(admin)/BatchCodeGenerator";
import {shouldRedirectHome} from "@/utils/navigation.disabled"; import {shouldRedirectHome} from "@/utils/navigation.disabled";
import ExamGenerator from "./(admin)/ExamGenerator"; import ExamGenerator from "./(admin)/ExamGenerator";
import BatchCreateUser from "./(admin)/BatchCreateUser";
import {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;
if (!user || !user.isVerified) { if (!user || !user.isVerified) {
return { return {
redirect: { redirect: {
@@ -42,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 (
<> <>
@@ -57,9 +60,10 @@ export default function Admin() {
<ToastContainer /> <ToastContainer />
{user && ( {user && (
<Layout user={user} className="gap-6"> <Layout user={user} className="gap-6">
<section className="w-full flex -md:flex-col -xl:gap-2 gap-8 justify-between"> <section className="w-full grid grid-cols-2 -md:grid-cols-1 gap-8">
<ExamLoader /> <ExamLoader />
{user.type !== "teacher" && ( <BatchCreateUser user={user} />
{checkAccess(user, getTypesOfUser(["teacher"]), permissions, "viewCodes") && (
<> <>
<CodeGenerator user={user} /> <CodeGenerator user={user} />
<BatchCodeGenerator user={user} /> <BatchCodeGenerator user={user} />

View File

@@ -24,6 +24,12 @@ import { usePDFDownload } from "@/hooks/usePDFDownload";
import useAssignments from '@/hooks/useAssignments'; import useAssignments from '@/hooks/useAssignments';
import useUsers from '@/hooks/useUsers'; import useUsers from '@/hooks/useUsers';
import Dropdown from "@/components/Dropdown"; import Dropdown from "@/components/Dropdown";
import InfiniteCarousel from '@/components/InfiniteCarousel';
import { LuExternalLink } from "react-icons/lu";
import { uniqBy } from 'lodash';
import { getExamById } from '@/utils/exams';
import { convertToUserSolutions } from '@/utils/stats';
import { sortByModule } from '@/utils/moduleUtils';
export const getServerSideProps = withIronSessionSsr(({ req, res }) => { export const getServerSideProps = withIronSessionSsr(({ req, res }) => {
const user = req.session.user; const user = req.session.user;
@@ -68,12 +74,9 @@ const TrainingContent: React.FC<{ user: User }> = ({ user }) => {
const { assignments } = useAssignments({}); const { assignments } = useAssignments({});
const { users } = useUsers(); const { users } = useUsers();
const router = useRouter(); const router = useRouter();
const { id } = router.query; const { id } = router.query;
useEffect(() => { useEffect(() => {
const fetchTrainingContent = async () => { const fetchTrainingContent = async () => {
if (!id || typeof id !== 'string') return; if (!id || typeof id !== 'string') return;
@@ -118,6 +121,32 @@ const TrainingContent: React.FC<{ user: User }> = ({ user }) => {
setCurrentTipIndex((prevIndex) => (prevIndex - 1)); setCurrentTipIndex((prevIndex) => (prevIndex - 1));
}; };
const goToExam = (examNumber: number) => {
const stats = trainingContent?.exams[examNumber].stats!;
const examPromises = uniqBy(stats, "exam").map((stat) => {
return getExamById(stat.module, stat.exam);
});
const { timeSpent, inactivity } = stats[0];
Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) {
if (!!timeSpent) setTimeSpent(timeSpent);
if (!!inactivity) setInactivity(inactivity);
setUserSolutions(convertToUserSolutions(stats));
setShowSolutions(true);
setExams(exams.map((x) => x!).sort(sortByModule));
setSelectedModules(
exams
.map((x) => x!)
.sort(sortByModule)
.map((x) => x!.module),
);
router.push("/exercises");
}
});
}
return ( return (
<> <>
<Head> <Head>
@@ -137,13 +166,25 @@ const TrainingContent: React.FC<{ user: User }> = ({ user }) => {
<span className="loading loading-infinity w-32 bg-mti-green-light" /> <span className="loading loading-infinity w-32 bg-mti-green-light" />
</div> </div>
) : (trainingContent && ( ) : (trainingContent && (
<div className="flex flex-col gap-10"> <div className="flex flex-col gap-8">
<div className="flex h-screen flex-col gap-4"> <div className="flex flex-row items-center">
<div className='flex flex-row h-[15%] gap-4'> <span className="bg-gray-200 text-gray-800 px-3 py-0.5 rounded-full font-semibold text-lg mr-2">{trainingContent.exams.length}</span>
{/*<Carousel itemsPerFrame={4} itemsPerScroll={4}>*/} <span>Exams Selected</span>
</div>
<div className='h-[15vh] mb-4'>
<InfiniteCarousel height="150px"
overlay={
<LuExternalLink size={20} />
}
overlayFunc={goToExam}
overlayClassName='bottom-6 right-5 cursor-pointer'
>
{trainingContent.exams.map((exam, examIndex) => ( {trainingContent.exams.map((exam, examIndex) => (
<StatsGridItem <StatsGridItem
key={`exam-${examIndex}`} key={`exam-${examIndex}`}
width='380px'
height='150px'
examNumber={examIndex + 1}
stats={exam.stats || []} stats={exam.stats || []}
timestamp={exam.date} timestamp={exam.date}
user={user} user={user}
@@ -158,11 +199,11 @@ const TrainingContent: React.FC<{ user: User }> = ({ user }) => {
renderPdfIcon={renderPdfIcon} renderPdfIcon={renderPdfIcon}
/> />
))} ))}
{/* </Carousel> */} </InfiniteCarousel>
</div> </div>
<div className='flex flex-col h-[75%]' style={{ maxHeight: '85%' }}> <div className='flex flex-col'>
<div className='flex flex-row gap-10 -md:flex-col'> <div className='flex flex-row gap-10 -md:flex-col h-full'>
<div className="rounded-3xl p-6 w-1/2 shadow-training-inset -md:w-full max-h-full"> <div className="flex flex-col rounded-3xl p-6 w-1/2 shadow-training-inset -md:w-full max-h-full">
<div className="flex flex-row items-center mb-6 gap-1"> <div className="flex flex-row items-center mb-6 gap-1">
<MdOutlinePlaylistAddCheckCircle color={"#40A1EA"} size={26} /> <MdOutlinePlaylistAddCheckCircle color={"#40A1EA"} size={26} />
<h2 className={`text-xl font-semibold text-[#40A1EA]`}>General Evaluation</h2> <h2 className={`text-xl font-semibold text-[#40A1EA]`}>General Evaluation</h2>
@@ -183,11 +224,14 @@ const TrainingContent: React.FC<{ user: User }> = ({ user }) => {
</svg> </svg>
<h3 className="text-xl font-semibold text-[#40A1EA]">Performance Breakdown by Exam:</h3> <h3 className="text-xl font-semibold text-[#40A1EA]">Performance Breakdown by Exam:</h3>
</div> </div>
<ul> <ul className='overflow-auto scrollbar-hide flex-grow'>
{trainingContent.exams.flatMap((exam, index) => ( {trainingContent.exams.flatMap((exam, index) => (
<li key={index} className="flex flex-col mb-2 bg-[#22E1B30F] p-4 rounded-xl border"> <li key={index} className="flex flex-col mb-2 bg-[#22E1B30F] p-4 rounded-xl border">
<div className="flex flex-row font-semibold border-b-2 border-[#D9D9D929] text-[#22E1B3] mb-2"> <div className="flex flex-row font-semibold border-b-2 border-[#D9D9D929] text-[#22E1B3] mb-2">
<span className="border-r-2 border-[#D9D9D929] pr-2">Exam {index + 1}</span> <div className='flex items-center border-r-2 border-[#D9D9D929] pr-2'>
<span className='mr-1'>Exam</span>
<span className="font-semibold bg-gray-200 text-gray-800 px-2 rounded-full text-sm">{index + 1}</span>
</div>
<span className="pl-2">{exam.score}%</span> <span className="pl-2">{exam.score}%</span>
</div> </div>
<div className="flex flex-row items-center gap-2"> <div className="flex flex-row items-center gap-2">
@@ -198,39 +242,8 @@ const TrainingContent: React.FC<{ user: User }> = ({ user }) => {
))} ))}
</ul> </ul>
</div> </div>
<div className="rounded-3xl p-6 w-1/2 shadow-training-inset -md:w-full"> <div className="flex flex-col rounded-3xl p-6 w-1/2 shadow-training-inset -md:w-full">
<div className="flex flex-row items-center mb-4 gap-1">
<MdOutlineSelfImprovement color={"#40A1EA"} size={24} />
<h2 className={`text-xl font-semibold text-[#40A1EA]`}>Subjects that Need Improvement</h2>
</div>
<div className="bg-[#FBFBFB] border rounded-xl p-4 max-h-[500px] overflow-y-auto scrollbar-hide">
<div className='flex flex-col'> <div className='flex flex-col'>
<div className="flex flex-row items-center gap-1 mb-4">
<div className="flex items-center justify-center w-[48px] h-[48px]">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_112_445" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
<rect width="24" height="24" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_112_445)">
<path d="M6 17H11V15H6V17ZM16 17H18V15H16V17ZM6 13H11V11H6V13ZM16 13H18V7H16V13ZM6 9H11V7H6V9ZM4 21C3.45 21 2.97917 20.8042 2.5875 20.4125C2.19583 20.0208 2 19.55 2 19V5C2 4.45 2.19583 3.97917 2.5875 3.5875C2.97917 3.19583 3.45 3 4 3H20C20.55 3 21.0208 3.19583 21.4125 3.5875C21.8042 3.97917 22 4.45 22 5V19C22 19.55 21.8042 20.0208 21.4125 20.4125C21.0208 20.8042 20.55 21 20 21H4ZM4 19H20V5H4V19Z" fill="#1C1B1F" />
</g>
</svg>
</div>
<h3 className="text-lg font-semibold">Detailed Breakdown</h3>
</div>
<ul className="space-y-4 pb-2">
{trainingContent.exams.map((exam, index) => (
<li key={index} className="border rounded-lg bg-white">
<Dropdown title={`Exam ${index + 1}`}>
<span>{exam.detailed_summary}</span>
</Dropdown>
</li>
))}
</ul>
</div>
</div>
<div className="w-full h-px bg-[#D9D9D929] my-6"></div>
<div className="flex flex-row items-center mb-4 gap-1"> <div className="flex flex-row items-center mb-4 gap-1">
<AiOutlineFileSearch color="#40A1EA" size={24} /> <AiOutlineFileSearch color="#40A1EA" size={24} />
<h3 className="text-xl font-semibold text-[#40A1EA]">Identified Weak Areas</h3> <h3 className="text-xl font-semibold text-[#40A1EA]">Identified Weak Areas</h3>
@@ -238,7 +251,7 @@ const TrainingContent: React.FC<{ user: User }> = ({ user }) => {
<Tab.Group> <Tab.Group>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Tab.List> <Tab.List>
<div className="flex flex-row gap-6"> <div className="flex flex-row gap-6 overflow-x-auto pb-1 training-scrollbar">
{trainingContent.weak_areas.map((x, index) => ( {trainingContent.weak_areas.map((x, index) => (
<Tab <Tab
key={index} key={index}
@@ -268,10 +281,47 @@ const TrainingContent: React.FC<{ user: User }> = ({ user }) => {
</div> </div>
</Tab.Group> </Tab.Group>
</div> </div>
<div className="w-full h-px bg-[#D9D9D929] my-6"></div>
<div className="flex flex-row items-center mb-4 gap-1">
<MdOutlineSelfImprovement color={"#40A1EA"} size={24} />
<h2 className={`text-xl font-semibold text-[#40A1EA]`}>Subjects that Need Improvement</h2>
</div>
<div className="flex flex-grow bg-[#FBFBFB] border rounded-xl p-4">
<div className='flex flex-col'>
<div className="flex flex-row items-center gap-1 mb-4">
<div className="flex items-center justify-center w-[48px] h-[48px]">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_112_445" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
<rect width="24" height="24" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_112_445)">
<path d="M6 17H11V15H6V17ZM16 17H18V15H16V17ZM6 13H11V11H6V13ZM16 13H18V7H16V13ZM6 9H11V7H6V9ZM4 21C3.45 21 2.97917 20.8042 2.5875 20.4125C2.19583 20.0208 2 19.55 2 19V5C2 4.45 2.19583 3.97917 2.5875 3.5875C2.97917 3.19583 3.45 3 4 3H20C20.55 3 21.0208 3.19583 21.4125 3.5875C21.8042 3.97917 22 4.45 22 5V19C22 19.55 21.8042 20.0208 21.4125 20.4125C21.0208 20.8042 20.55 21 20 21H4ZM4 19H20V5H4V19Z" fill="#1C1B1F" />
</g>
</svg>
</div>
<h3 className="text-lg font-semibold">Detailed Breakdown</h3>
</div>
<ul className="flex flex-col flex-grow space-y-4 pb-2 overflow-y-auto scrollbar-hide">
{trainingContent.exams.map((exam, index) => (
<li key={index} className="border rounded-lg bg-white">
<Dropdown title={
<div className='flex flex-row items-center'>
<span className="mr-1">Exam</span>
<span className="font-semibold bg-gray-200 text-gray-800 px-2 rounded-full text-sm mt-0.5">{index + 1}</span>
</div>
} open={index == 0}>
<span>{exam.detailed_summary}</span>
</Dropdown>
</li>
))}
</ul>
</div> </div>
</div> </div>
</div> </div>
<div className="flex"> </div>
</div>
<div className="flex -md:hidden">
<div className="rounded-3xl p-6 shadow-training-inset w-full"> <div className="rounded-3xl p-6 shadow-training-inset w-full">
<div className="flex flex-col p-10"> <div className="flex flex-col p-10">
<Exercise key={currentTipIndex} {...trainingTips[currentTipIndex]} /> <Exercise key={currentTipIndex} {...trainingTips[currentTipIndex]} />
@@ -294,6 +344,7 @@ const TrainingContent: React.FC<{ user: User }> = ({ user }) => {
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
))} ))}

View File

@@ -389,7 +389,7 @@ const Training: React.FC<{ user: User }> = ({ user }) => {
</div> </div>
)} )}
{groupedByTrainingContent && Object.keys(groupedByTrainingContent).length > 0 && ( {groupedByTrainingContent && Object.keys(groupedByTrainingContent).length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 w-full gap-4 xl:gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-3 w-full gap-4 xl:gap-6">
{Object.keys(filterTrainingContentByDate(groupedByTrainingContent)) {Object.keys(filterTrainingContentByDate(groupedByTrainingContent))
.sort((a, b) => parseInt(b) - parseInt(a)) .sort((a, b) => parseInt(b) - parseInt(a))
.map(trainingContentContainer)} .map(trainingContentContainer)}

View File

@@ -4,14 +4,35 @@
@layer utilities { @layer utilities {
.scrollbar-hide { .scrollbar-hide {
-ms-overflow-style: none; /* IE and Edge */ -ms-overflow-style: none;
scrollbar-width: none; /* Firefox */ /* IE and Edge */
scrollbar-width: none;
/* Firefox */
} }
.scrollbar-hide::-webkit-scrollbar { .scrollbar-hide::-webkit-scrollbar {
display: none; /* Chrome, Safari and Opera */ display: none;
/* Chrome, Safari and Opera */
} }
} }
.training-scrollbar::-webkit-scrollbar {
@apply w-1.5;
}
.training-scrollbar::-webkit-scrollbar-track {
@apply bg-transparent;
}
.training-scrollbar::-webkit-scrollbar-thumb {
@apply bg-gray-400 hover:bg-gray-500 rounded-full transition-colors opacity-50 hover:opacity-75;
}
.training-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
}
:root { :root {
--max-width: 1100px; --max-width: 1100px;
--border-radius: 12px; --border-radius: 12px;

View File

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

View File

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

View File

@@ -1,11 +1,8 @@
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,
types: Type[],
permission?: PermissionType
) {
if (!user) { if (!user) {
return false; return false;
} }
@@ -29,7 +26,7 @@ export function checkAccess(
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;
} }
} }
@@ -41,5 +38,5 @@ export function getTypesOfUser(types: Type[]) {
// basicly generate a list of all types except the excluded ones // basicly generate a list of all types except the excluded ones
return userTypes.filter((userType) => { return userTypes.filter((userType) => {
return !types.includes(userType); return !types.includes(userType);
}) });
} }

View File

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

View File

@@ -25,7 +25,10 @@ export const exportListToExcel = (rowUsers: User[], users: User[], groups: Group
expiryDate: user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited", expiryDate: user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited",
country: user.demographicInformation?.country || "N/A", country: user.demographicInformation?.country || "N/A",
phone: user.demographicInformation?.phone || "N/A", phone: user.demographicInformation?.phone || "N/A",
employmentPosition: (user.type === "corporate" || user.type === "mastercorporate" ? user.demographicInformation?.position : user.demographicInformation?.employment) || "N/A", employmentPosition:
(user.type === "corporate" || user.type === "mastercorporate"
? user.demographicInformation?.position
: user.demographicInformation?.employment) || "N/A",
gender: user.demographicInformation?.gender ? capitalize(user.demographicInformation.gender) : "N/A", gender: user.demographicInformation?.gender ? capitalize(user.demographicInformation.gender) : "N/A",
verified: user.isVerified?.toString() || "FALSE", verified: user.isVerified?.toString() || "FALSE",
})); }));
@@ -34,3 +37,9 @@ export const exportListToExcel = (rowUsers: User[], users: User[], groups: Group
return `${header}\n${rowsString}`; return `${header}\n${rowsString}`;
}; };
export const getUserName = (user?: User) => {
if (!user) return "N/A";
if (user.type === "corporate" || user.type === "mastercorporate") return user.corporateInformation?.companyInformation?.name || user.name;
return user.name;
};

492
yarn.lock
View File

@@ -183,7 +183,7 @@
"@emotion/utils" "0.11.3" "@emotion/utils" "0.11.3"
"@emotion/weak-memoize" "0.2.5" "@emotion/weak-memoize" "0.2.5"
"@emotion/cache@^11.13.0": "@emotion/cache@^11.13.0", "@emotion/cache@^11.4.0":
version "11.13.1" version "11.13.1"
resolved "https://registry.npmjs.org/@emotion/cache/-/cache-11.13.1.tgz" resolved "https://registry.npmjs.org/@emotion/cache/-/cache-11.13.1.tgz"
integrity sha512-iqouYkuEblRcXmylXIwwOodiEK5Ifl7JcX7o6V4jI3iW4mLXX3dmt5xwBtIkJiQEXFAI+pC8X0i67yiPkH9Ucw== integrity sha512-iqouYkuEblRcXmylXIwwOodiEK5Ifl7JcX7o6V4jI3iW4mLXX3dmt5xwBtIkJiQEXFAI+pC8X0i67yiPkH9Ucw==
@@ -194,27 +194,16 @@
"@emotion/weak-memoize" "^0.4.0" "@emotion/weak-memoize" "^0.4.0"
stylis "4.2.0" stylis "4.2.0"
"@emotion/cache@^11.4.0":
version "11.13.1"
resolved "https://registry.npmjs.org/@emotion/cache/-/cache-11.13.1.tgz"
integrity sha512-iqouYkuEblRcXmylXIwwOodiEK5Ifl7JcX7o6V4jI3iW4mLXX3dmt5xwBtIkJiQEXFAI+pC8X0i67yiPkH9Ucw==
dependencies:
"@emotion/memoize" "^0.9.0"
"@emotion/sheet" "^1.4.0"
"@emotion/utils" "^1.4.0"
"@emotion/weak-memoize" "^0.4.0"
stylis "4.2.0"
"@emotion/hash@^0.9.2":
version "0.9.2"
resolved "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz"
integrity sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==
"@emotion/hash@0.8.0": "@emotion/hash@0.8.0":
version "0.8.0" version "0.8.0"
resolved "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz" resolved "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz"
integrity sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow== integrity sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==
"@emotion/hash@^0.9.2":
version "0.9.2"
resolved "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz"
integrity sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==
"@emotion/is-prop-valid@^0.8.2": "@emotion/is-prop-valid@^0.8.2":
version "0.8.8" version "0.8.8"
resolved "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz" resolved "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz"
@@ -222,16 +211,16 @@
dependencies: dependencies:
"@emotion/memoize" "0.7.4" "@emotion/memoize" "0.7.4"
"@emotion/memoize@^0.9.0":
version "0.9.0"
resolved "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz"
integrity sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==
"@emotion/memoize@0.7.4": "@emotion/memoize@0.7.4":
version "0.7.4" version "0.7.4"
resolved "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz" resolved "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz"
integrity sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw== integrity sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==
"@emotion/memoize@^0.9.0":
version "0.9.0"
resolved "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz"
integrity sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==
"@emotion/react@^11.8.1": "@emotion/react@^11.8.1":
version "11.13.0" version "11.13.0"
resolved "https://registry.npmjs.org/@emotion/react/-/react-11.13.0.tgz" resolved "https://registry.npmjs.org/@emotion/react/-/react-11.13.0.tgz"
@@ -257,7 +246,7 @@
"@emotion/utils" "0.11.3" "@emotion/utils" "0.11.3"
csstype "^2.5.7" csstype "^2.5.7"
"@emotion/serialize@^1.2.0": "@emotion/serialize@^1.2.0", "@emotion/serialize@^1.3.0":
version "1.3.0" version "1.3.0"
resolved "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.0.tgz" resolved "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.0.tgz"
integrity sha512-jACuBa9SlYajnpIVXB+XOXnfJHyckDfe6fOpORIM6yhBDlqGuExvDdZYHDQGoDf3bZXGv7tNr+LpLjJqiEQ6EA== integrity sha512-jACuBa9SlYajnpIVXB+XOXnfJHyckDfe6fOpORIM6yhBDlqGuExvDdZYHDQGoDf3bZXGv7tNr+LpLjJqiEQ6EA==
@@ -268,67 +257,56 @@
"@emotion/utils" "^1.4.0" "@emotion/utils" "^1.4.0"
csstype "^3.0.2" csstype "^3.0.2"
"@emotion/serialize@^1.3.0":
version "1.3.0"
resolved "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.0.tgz"
integrity sha512-jACuBa9SlYajnpIVXB+XOXnfJHyckDfe6fOpORIM6yhBDlqGuExvDdZYHDQGoDf3bZXGv7tNr+LpLjJqiEQ6EA==
dependencies:
"@emotion/hash" "^0.9.2"
"@emotion/memoize" "^0.9.0"
"@emotion/unitless" "^0.9.0"
"@emotion/utils" "^1.4.0"
csstype "^3.0.2"
"@emotion/sheet@^1.4.0":
version "1.4.0"
resolved "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz"
integrity sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==
"@emotion/sheet@0.9.4": "@emotion/sheet@0.9.4":
version "0.9.4" version "0.9.4"
resolved "https://registry.npmjs.org/@emotion/sheet/-/sheet-0.9.4.tgz" resolved "https://registry.npmjs.org/@emotion/sheet/-/sheet-0.9.4.tgz"
integrity sha512-zM9PFmgVSqBw4zL101Q0HrBVTGmpAxFZH/pYx/cjJT5advXguvcgjHFTCaIO3enL/xr89vK2bh0Mfyj9aa0ANA== integrity sha512-zM9PFmgVSqBw4zL101Q0HrBVTGmpAxFZH/pYx/cjJT5advXguvcgjHFTCaIO3enL/xr89vK2bh0Mfyj9aa0ANA==
"@emotion/sheet@^1.4.0":
version "1.4.0"
resolved "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz"
integrity sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==
"@emotion/stylis@0.8.5": "@emotion/stylis@0.8.5":
version "0.8.5" version "0.8.5"
resolved "https://registry.npmjs.org/@emotion/stylis/-/stylis-0.8.5.tgz" resolved "https://registry.npmjs.org/@emotion/stylis/-/stylis-0.8.5.tgz"
integrity sha512-h6KtPihKFn3T9fuIrwvXXUOwlx3rfUvfZIcP5a6rh8Y7zjE3O06hT5Ss4S/YI1AYhuZ1kjaE/5EaOOI2NqSylQ== integrity sha512-h6KtPihKFn3T9fuIrwvXXUOwlx3rfUvfZIcP5a6rh8Y7zjE3O06hT5Ss4S/YI1AYhuZ1kjaE/5EaOOI2NqSylQ==
"@emotion/unitless@^0.9.0":
version "0.9.0"
resolved "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.9.0.tgz"
integrity sha512-TP6GgNZtmtFaFcsOgExdnfxLLpRDla4Q66tnenA9CktvVSdNKDvMVuUah4QvWPIpNjrWsGg3qeGo9a43QooGZQ==
"@emotion/unitless@0.7.5": "@emotion/unitless@0.7.5":
version "0.7.5" version "0.7.5"
resolved "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz" resolved "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz"
integrity sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg== integrity sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==
"@emotion/unitless@^0.9.0":
version "0.9.0"
resolved "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.9.0.tgz"
integrity sha512-TP6GgNZtmtFaFcsOgExdnfxLLpRDla4Q66tnenA9CktvVSdNKDvMVuUah4QvWPIpNjrWsGg3qeGo9a43QooGZQ==
"@emotion/use-insertion-effect-with-fallbacks@^1.1.0": "@emotion/use-insertion-effect-with-fallbacks@^1.1.0":
version "1.1.0" version "1.1.0"
resolved "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.1.0.tgz" resolved "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.1.0.tgz"
integrity sha512-+wBOcIV5snwGgI2ya3u99D7/FJquOIniQT1IKyDsBmEgwvpxMNeS65Oib7OnE2d2aY+3BU4OiH+0Wchf8yk3Hw== integrity sha512-+wBOcIV5snwGgI2ya3u99D7/FJquOIniQT1IKyDsBmEgwvpxMNeS65Oib7OnE2d2aY+3BU4OiH+0Wchf8yk3Hw==
"@emotion/utils@^1.4.0":
version "1.4.0"
resolved "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.0.tgz"
integrity sha512-spEnrA1b6hDR/C68lC2M7m6ALPUHZC0lIY7jAS/B/9DuuO1ZP04eov8SMv/6fwRd8pzmsn2AuJEznRREWlQrlQ==
"@emotion/utils@0.11.3": "@emotion/utils@0.11.3":
version "0.11.3" version "0.11.3"
resolved "https://registry.npmjs.org/@emotion/utils/-/utils-0.11.3.tgz" resolved "https://registry.npmjs.org/@emotion/utils/-/utils-0.11.3.tgz"
integrity sha512-0o4l6pZC+hI88+bzuaX/6BgOvQVhbt2PfmxauVaYOGgbsAw14wdKyvMCZXnsnsHys94iadcF+RG/wZyx6+ZZBw== integrity sha512-0o4l6pZC+hI88+bzuaX/6BgOvQVhbt2PfmxauVaYOGgbsAw14wdKyvMCZXnsnsHys94iadcF+RG/wZyx6+ZZBw==
"@emotion/weak-memoize@^0.4.0": "@emotion/utils@^1.4.0":
version "0.4.0" version "1.4.0"
resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz" resolved "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.0.tgz"
integrity sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg== integrity sha512-spEnrA1b6hDR/C68lC2M7m6ALPUHZC0lIY7jAS/B/9DuuO1ZP04eov8SMv/6fwRd8pzmsn2AuJEznRREWlQrlQ==
"@emotion/weak-memoize@0.2.5": "@emotion/weak-memoize@0.2.5":
version "0.2.5" version "0.2.5"
resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz" resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz"
integrity sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA== integrity sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA==
"@emotion/weak-memoize@^0.4.0":
version "0.4.0"
resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz"
integrity sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==
"@eslint/eslintrc@^1.4.1": "@eslint/eslintrc@^1.4.1":
version "1.4.1" version "1.4.1"
resolved "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.4.1.tgz" resolved "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.4.1.tgz"
@@ -478,7 +456,7 @@
"@firebase/util" "1.9.3" "@firebase/util" "1.9.3"
tslib "^2.1.0" tslib "^2.1.0"
"@firebase/database-compat@^0.3.4", "@firebase/database-compat@0.3.4": "@firebase/database-compat@0.3.4", "@firebase/database-compat@^0.3.4":
version "0.3.4" version "0.3.4"
resolved "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-0.3.4.tgz" resolved "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-0.3.4.tgz"
integrity sha512-kuAW+l+sLMUKBThnvxvUZ+Q1ZrF/vFJ58iUY9kAcbX48U03nVzIF6Tmkf0p3WVQwMqiXguSgtOPIB6ZCeF+5Gg== integrity sha512-kuAW+l+sLMUKBThnvxvUZ+Q1ZrF/vFJ58iUY9kAcbX48U03nVzIF6Tmkf0p3WVQwMqiXguSgtOPIB6ZCeF+5Gg==
@@ -490,7 +468,7 @@
"@firebase/util" "1.9.3" "@firebase/util" "1.9.3"
tslib "^2.1.0" tslib "^2.1.0"
"@firebase/database-types@^0.10.4", "@firebase/database-types@0.10.4": "@firebase/database-types@0.10.4", "@firebase/database-types@^0.10.4":
version "0.10.4" version "0.10.4"
resolved "https://registry.npmjs.org/@firebase/database-types/-/database-types-0.10.4.tgz" resolved "https://registry.npmjs.org/@firebase/database-types/-/database-types-0.10.4.tgz"
integrity sha512-dPySn0vJ/89ZeBac70T+2tWWPiJXWbmRygYv0smT5TfE3hDrQ09eKMF3Y+vMlTdrMWq7mUdYW5REWPSGH4kAZQ== integrity sha512-dPySn0vJ/89ZeBac70T+2tWWPiJXWbmRygYv0smT5TfE3hDrQ09eKMF3Y+vMlTdrMWq7mUdYW5REWPSGH4kAZQ==
@@ -720,7 +698,7 @@
"@firebase/util@^1.9.7": "@firebase/util@^1.9.7":
version "1.9.7" version "1.9.7"
resolved "https://registry.yarnpkg.com/@firebase/util/-/util-1.9.7.tgz#c03b0ae065b3bba22800da0bd5314ef030848038" resolved "https://registry.npmjs.org/@firebase/util/-/util-1.9.7.tgz"
integrity sha512-fBVNH/8bRbYjqlbIhZ+lBtdAAS4WqZumx03K06/u7fJSpz1TGjEMm1ImvKD47w+xaFKIP2ori6z8BrbakRfjJA== integrity sha512-fBVNH/8bRbYjqlbIhZ+lBtdAAS4WqZumx03K06/u7fJSpz1TGjEMm1ImvKD47w+xaFKIP2ori6z8BrbakRfjJA==
dependencies: dependencies:
tslib "^2.1.0" tslib "^2.1.0"
@@ -966,6 +944,66 @@
resolved "https://registry.npmjs.org/@next/font/-/font-13.1.6.tgz" resolved "https://registry.npmjs.org/@next/font/-/font-13.1.6.tgz"
integrity sha512-AITjmeb1RgX1HKMCiA39ztx2mxeAyxl4ljv2UoSBUGAbFFMg8MO7YAvjHCgFhD39hL7YTbFjol04e/BPBH5RzQ== integrity sha512-AITjmeb1RgX1HKMCiA39ztx2mxeAyxl4ljv2UoSBUGAbFFMg8MO7YAvjHCgFhD39hL7YTbFjol04e/BPBH5RzQ==
"@next/swc-android-arm-eabi@13.1.6":
version "13.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-android-arm-eabi/-/swc-android-arm-eabi-13.1.6.tgz#d766dfc10e27814d947b20f052067c239913dbcc"
integrity sha512-F3/6Z8LH/pGlPzR1AcjPFxx35mPqjE5xZcf+IL+KgbW9tMkp7CYi1y7qKrEWU7W4AumxX/8OINnDQWLiwLasLQ==
"@next/swc-android-arm64@13.1.6":
version "13.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-android-arm64/-/swc-android-arm64-13.1.6.tgz#f37a98d5f18927d8c9970d750d516ac779465176"
integrity sha512-cMwQjnB8vrYkWyK/H0Rf2c2pKIH4RGjpKUDvbjVAit6SbwPDpmaijLio0LWFV3/tOnY6kvzbL62lndVA0mkYpw==
"@next/swc-darwin-arm64@13.1.6":
version "13.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.1.6.tgz#ec1b90fd9bf809d8b81004c5182e254dced4ad96"
integrity sha512-KKRQH4DDE4kONXCvFMNBZGDb499Hs+xcFAwvj+rfSUssIDrZOlyfJNy55rH5t2Qxed1e4K80KEJgsxKQN1/fyw==
"@next/swc-darwin-x64@13.1.6":
version "13.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-13.1.6.tgz#e869ac75d16995eee733a7d1550322d9051c1eb4"
integrity sha512-/uOky5PaZDoaU99ohjtNcDTJ6ks/gZ5ykTQDvNZDjIoCxFe3+t06bxsTPY6tAO6uEAw5f6vVFX5H5KLwhrkZCA==
"@next/swc-freebsd-x64@13.1.6":
version "13.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-freebsd-x64/-/swc-freebsd-x64-13.1.6.tgz#84a7b2e423a2904afc2edca21c2f1ba6b53fa4c1"
integrity sha512-qaEALZeV7to6weSXk3Br80wtFQ7cFTpos/q+m9XVRFggu+8Ib895XhMWdJBzew6aaOcMvYR6KQ6JmHA2/eMzWw==
"@next/swc-linux-arm-gnueabihf@13.1.6":
version "13.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-13.1.6.tgz#980eed1f655ff8a72187d8a6ef9e73ac39d20d23"
integrity sha512-OybkbC58A1wJ+JrJSOjGDvZzrVEQA4sprJejGqMwiZyLqhr9Eo8FXF0y6HL+m1CPCpPhXEHz/2xKoYsl16kNqw==
"@next/swc-linux-arm64-gnu@13.1.6":
version "13.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.1.6.tgz#87a71db21cded3f7c63d1d19079845c59813c53d"
integrity sha512-yCH+yDr7/4FDuWv6+GiYrPI9kcTAO3y48UmaIbrKy8ZJpi7RehJe3vIBRUmLrLaNDH3rY1rwoHi471NvR5J5NQ==
"@next/swc-linux-arm64-musl@13.1.6":
version "13.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.1.6.tgz#c5aac8619331b9fd030603bbe2b36052011e11de"
integrity sha512-ECagB8LGX25P9Mrmlc7Q/TQBb9rGScxHbv/kLqqIWs2fIXy6Y/EiBBiM72NTwuXUFCNrWR4sjUPSooVBJJ3ESQ==
"@next/swc-linux-x64-gnu@13.1.6":
version "13.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.1.6.tgz#9513d36d540bbfea575576746736054c31aacdea"
integrity sha512-GT5w2mruk90V/I5g6ScuueE7fqj/d8Bui2qxdw6lFxmuTgMeol5rnzAv4uAoVQgClOUO/MULilzlODg9Ib3Y4Q==
"@next/swc-linux-x64-musl@13.1.6":
version "13.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.1.6.tgz#d61fc6884899f5957251f4ce3f522e34a2c479b7"
integrity sha512-keFD6KvwOPzmat4TCnlnuxJCQepPN+8j3Nw876FtULxo8005Y9Ghcl7ACcR8GoiKoddAq8gxNBrpjoxjQRHeAQ==
"@next/swc-win32-arm64-msvc@13.1.6":
version "13.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.1.6.tgz#fac2077a8ae9768e31444c9ae90807e64117cda7"
integrity sha512-OwertslIiGQluFvHyRDzBCIB07qJjqabAmINlXUYt7/sY7Q7QPE8xVi5beBxX/rxTGPIbtyIe3faBE6Z2KywhQ==
"@next/swc-win32-ia32-msvc@13.1.6":
version "13.1.6"
resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.1.6.tgz#498bc11c91b4c482a625bf4b978f98ae91111e46"
integrity sha512-g8zowiuP8FxUR9zslPmlju7qYbs2XBtTLVSxVikPtUDQedhcls39uKYLvOOd1JZg0ehyhopobRoH1q+MHlIN/w==
"@next/swc-win32-x64-msvc@13.1.6": "@next/swc-win32-x64-msvc@13.1.6":
version "13.1.6" version "13.1.6"
resolved "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.1.6.tgz" resolved "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.1.6.tgz"
@@ -979,7 +1017,7 @@
"@nodelib/fs.stat" "2.0.5" "@nodelib/fs.stat" "2.0.5"
run-parallel "^1.1.9" run-parallel "^1.1.9"
"@nodelib/fs.stat@^2.0.2", "@nodelib/fs.stat@2.0.5": "@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2":
version "2.0.5" version "2.0.5"
resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz" resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz"
integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
@@ -1307,7 +1345,7 @@
resolved "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz" resolved "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz"
integrity sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg== integrity sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg==
"@swc/helpers@^0.4.2", "@swc/helpers@0.4.14": "@swc/helpers@0.4.14", "@swc/helpers@^0.4.2":
version "0.4.14" version "0.4.14"
resolved "https://registry.npmjs.org/@swc/helpers/-/helpers-0.4.14.tgz" resolved "https://registry.npmjs.org/@swc/helpers/-/helpers-0.4.14.tgz"
integrity sha512-4C7nX/dvpzB7za4Ql9K81xK3HPxCpHMgwTZVyf+9JQ6VUbn9jjZVN7/Nkdz/Ugzs2CSjqnL/UPXroiVBVHUWUw== integrity sha512-4C7nX/dvpzB7za4Ql9K81xK3HPxCpHMgwTZVyf+9JQ6VUbn9jjZVN7/Nkdz/Ugzs2CSjqnL/UPXroiVBVHUWUw==
@@ -1515,7 +1553,7 @@
resolved "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz" resolved "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz"
integrity sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA== integrity sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==
"@types/node@*", "@types/node@>=12.12.47", "@types/node@>=13.7.0", "@types/node@>=8.1.0", "@types/node@18.13.0": "@types/node@*", "@types/node@18.13.0", "@types/node@>=12.12.47", "@types/node@>=13.7.0", "@types/node@>=8.1.0":
version "18.13.0" version "18.13.0"
resolved "https://registry.npmjs.org/@types/node/-/node-18.13.0.tgz" resolved "https://registry.npmjs.org/@types/node/-/node-18.13.0.tgz"
integrity sha512-gC3TazRzGoOnoKAhUx+Q0t8S9Tzs74z7m0ipwGpSqQrleP14hKxP4/JUeEQcD3W1/aIpnWl8pHowI7WokuZpXg== integrity sha512-gC3TazRzGoOnoKAhUx+Q0t8S9Tzs74z7m0ipwGpSqQrleP14hKxP4/JUeEQcD3W1/aIpnWl8pHowI7WokuZpXg==
@@ -1591,13 +1629,6 @@
dependencies: dependencies:
"@types/react" "*" "@types/react" "*"
"@types/react-slick@^0.23.13":
version "0.23.13"
resolved "https://registry.npmjs.org/@types/react-slick/-/react-slick-0.23.13.tgz"
integrity sha512-bNZfDhe/L8t5OQzIyhrRhBr/61pfBcWaYJoq6UDqFtv5LMwfg4NsVDD2J8N01JqdAdxLjOt66OZEp6PX+dGs/A==
dependencies:
"@types/react" "*"
"@types/react-transition-group@^4.4.0", "@types/react-transition-group@^4.4.1": "@types/react-transition-group@^4.4.0", "@types/react-transition-group@^4.4.1":
version "4.4.5" version "4.4.5"
resolved "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.5.tgz" resolved "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.5.tgz"
@@ -1691,6 +1722,18 @@
"@typescript-eslint/types" "5.51.0" "@typescript-eslint/types" "5.51.0"
eslint-visitor-keys "^3.3.0" eslint-visitor-keys "^3.3.0"
"@use-gesture/core@10.3.1":
version "10.3.1"
resolved "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz"
integrity sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==
"@use-gesture/react@^10.3.1":
version "10.3.1"
resolved "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz"
integrity sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==
dependencies:
"@use-gesture/core" "10.3.1"
"@wixc3/board-core@^2.2.0": "@wixc3/board-core@^2.2.0":
version "2.2.0" version "2.2.0"
resolved "https://registry.npmjs.org/@wixc3/board-core/-/board-core-2.2.0.tgz" resolved "https://registry.npmjs.org/@wixc3/board-core/-/board-core-2.2.0.tgz"
@@ -2165,12 +2208,12 @@ chownr@^2.0.0:
resolved "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz" resolved "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz"
integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ== integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==
classnames@^2.2.5, classnames@^2.2.6, classnames@^2.3.0, classnames@^2.5.1: classnames@^2.2.6, classnames@^2.3.0, classnames@^2.5.1:
version "2.5.1" version "2.5.1"
resolved "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz" resolved "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz"
integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow== integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==
client-only@^0.0.1, client-only@0.0.1: client-only@0.0.1, client-only@^0.0.1:
version "0.0.1" version "0.0.1"
resolved "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz" resolved "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz"
integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA== integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==
@@ -2193,15 +2236,6 @@ cliui@^7.0.2:
strip-ansi "^6.0.0" strip-ansi "^6.0.0"
wrap-ansi "^7.0.0" wrap-ansi "^7.0.0"
cliui@^8.0.1:
version "8.0.1"
resolved "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz"
integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==
dependencies:
string-width "^4.2.0"
strip-ansi "^6.0.1"
wrap-ansi "^7.0.0"
clone@^2.1.2: clone@^2.1.2:
version "2.1.2" version "2.1.2"
resolved "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz" resolved "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz"
@@ -2226,16 +2260,16 @@ color-convert@^2.0.1:
dependencies: dependencies:
color-name "~1.1.4" color-name "~1.1.4"
color-name@^1.0.0, color-name@^1.1.4, color-name@~1.1.4:
version "1.1.4"
resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz"
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
color-name@1.1.3: color-name@1.1.3:
version "1.1.3" version "1.1.3"
resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz" resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz"
integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==
color-name@^1.0.0, color-name@^1.1.4, color-name@~1.1.4:
version "1.1.4"
resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz"
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
color-string@^1.9.1: color-string@^1.9.1:
version "1.9.1" version "1.9.1"
resolved "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz" resolved "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz"
@@ -2428,6 +2462,13 @@ date-fns@^2.0.1, date-fns@^2.30.0:
dependencies: dependencies:
"@babel/runtime" "^7.21.0" "@babel/runtime" "^7.21.0"
debug@4, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4:
version "4.3.4"
resolved "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz"
integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
dependencies:
ms "2.1.2"
debug@^3.2.7: debug@^3.2.7:
version "3.2.7" version "3.2.7"
resolved "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz" resolved "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz"
@@ -2435,13 +2476,6 @@ debug@^3.2.7:
dependencies: dependencies:
ms "^2.1.1" ms "^2.1.1"
debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@4:
version "4.3.4"
resolved "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz"
integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
dependencies:
ms "2.1.2"
decamelize@^1.2.0: decamelize@^1.2.0:
version "1.2.0" version "1.2.0"
resolved "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz" resolved "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz"
@@ -2611,7 +2645,7 @@ eastasianwidth@^0.2.0:
resolved "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz" resolved "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz"
integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==
ecdsa-sig-formatter@^1.0.11, ecdsa-sig-formatter@1.0.11: ecdsa-sig-formatter@1.0.11, ecdsa-sig-formatter@^1.0.11:
version "1.0.11" version "1.0.11"
resolved "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz" resolved "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz"
integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==
@@ -2666,11 +2700,6 @@ enhanced-resolve@^5.10.0:
graceful-fs "^4.2.4" graceful-fs "^4.2.4"
tapable "^2.2.0" tapable "^2.2.0"
enquire.js@^2.1.6:
version "2.1.6"
resolved "https://registry.npmjs.org/enquire.js/-/enquire.js-2.1.6.tgz"
integrity sha512-/KujNpO+PT63F7Hlpu4h3pE3TokKRHN26JYmQpPyjkRD/N57R7bPDNojMXdi7uveAKjYB7yQnartCxZnFWr0Xw==
ent@^2.2.0: ent@^2.2.0:
version "2.2.1" version "2.2.1"
resolved "https://registry.npmjs.org/ent/-/ent-2.2.1.tgz" resolved "https://registry.npmjs.org/ent/-/ent-2.2.1.tgz"
@@ -3329,6 +3358,11 @@ fs.realpath@^1.0.0:
resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz"
integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
fsevents@~2.3.2:
version "2.3.3"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
function-bind@^1.1.1, function-bind@^1.1.2: function-bind@^1.1.1, function-bind@^1.1.2:
version "1.1.2" version "1.1.2"
resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz" resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz"
@@ -3416,7 +3450,7 @@ get-tsconfig@^4.2.0:
resolved "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.4.0.tgz" resolved "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.4.0.tgz"
integrity sha512-0Gdjo/9+FzsYhXCEFueo2aY1z1tpXrxWZzP7k8ul9qt1U5o8rYJwTJYmaeHdrVosYIVYkOy2iwCJ9FdpocJhPQ== integrity sha512-0Gdjo/9+FzsYhXCEFueo2aY1z1tpXrxWZzP7k8ul9qt1U5o8rYJwTJYmaeHdrVosYIVYkOy2iwCJ9FdpocJhPQ==
glob-parent@^5.1.2: glob-parent@^5.1.2, glob-parent@~5.1.2:
version "5.1.2" version "5.1.2"
resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz" resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz"
integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
@@ -3430,12 +3464,29 @@ glob-parent@^6.0.2:
dependencies: dependencies:
is-glob "^4.0.3" is-glob "^4.0.3"
glob-parent@~5.1.2: glob@7.1.6:
version "5.1.2" version "7.1.6"
resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz" resolved "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz"
integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==
dependencies: dependencies:
is-glob "^4.0.1" fs.realpath "^1.0.0"
inflight "^1.0.4"
inherits "2"
minimatch "^3.0.4"
once "^1.3.0"
path-is-absolute "^1.0.0"
glob@7.1.7, glob@^7.1.3:
version "7.1.7"
resolved "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz"
integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==
dependencies:
fs.realpath "^1.0.0"
inflight "^1.0.4"
inherits "2"
minimatch "^3.0.4"
once "^1.3.0"
path-is-absolute "^1.0.0"
glob@^10.4.2: glob@^10.4.2:
version "10.4.5" version "10.4.5"
@@ -3449,18 +3500,6 @@ glob@^10.4.2:
package-json-from-dist "^1.0.0" package-json-from-dist "^1.0.0"
path-scurry "^1.11.1" path-scurry "^1.11.1"
glob@^7.1.3, glob@7.1.7:
version "7.1.7"
resolved "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz"
integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==
dependencies:
fs.realpath "^1.0.0"
inflight "^1.0.4"
inherits "2"
minimatch "^3.0.4"
once "^1.3.0"
path-is-absolute "^1.0.0"
glob@^8.0.0: glob@^8.0.0:
version "8.1.0" version "8.1.0"
resolved "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz" resolved "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz"
@@ -3472,18 +3511,6 @@ glob@^8.0.0:
minimatch "^5.0.1" minimatch "^5.0.1"
once "^1.3.0" once "^1.3.0"
glob@7.1.6:
version "7.1.6"
resolved "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz"
integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==
dependencies:
fs.realpath "^1.0.0"
inflight "^1.0.4"
inherits "2"
minimatch "^3.0.4"
once "^1.3.0"
path-is-absolute "^1.0.0"
globals@^11.1.0: globals@^11.1.0:
version "11.12.0" version "11.12.0"
resolved "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz" resolved "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz"
@@ -3777,7 +3804,7 @@ inflight@^1.0.4:
once "^1.3.0" once "^1.3.0"
wrappy "1" wrappy "1"
inherits@^2.0.3, inherits@~2.0.3, inherits@2: inherits@2, inherits@^2.0.3, inherits@~2.0.3:
version "2.0.4" version "2.0.4"
resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
@@ -4135,13 +4162,6 @@ json-stable-stringify-without-jsonify@^1.0.1:
resolved "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz" resolved "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz"
integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==
json2mq@^0.2.0:
version "0.2.0"
resolved "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz"
integrity sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==
dependencies:
string-convert "^0.2.0"
json5@^1.0.1: json5@^1.0.1:
version "1.0.2" version "1.0.2"
resolved "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz" resolved "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz"
@@ -4319,11 +4339,6 @@ lodash.clonedeep@^4.5.0:
resolved "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz" resolved "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz"
integrity sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ== integrity sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==
lodash.debounce@^4.0.8:
version "4.0.8"
resolved "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz"
integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==
lodash.includes@^4.3.0: lodash.includes@^4.3.0:
version "4.3.0" version "4.3.0"
resolved "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz" resolved "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz"
@@ -4386,18 +4401,18 @@ loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0:
dependencies: dependencies:
js-tokens "^3.0.0 || ^4.0.0" js-tokens "^3.0.0 || ^4.0.0"
lru-cache@^10.2.0: lru-cache@6.0.0, lru-cache@^6.0.0:
version "10.4.3"
resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz"
integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==
lru-cache@^6.0.0, lru-cache@6.0.0:
version "6.0.0" version "6.0.0"
resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz" resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz"
integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==
dependencies: dependencies:
yallist "^4.0.0" yallist "^4.0.0"
lru-cache@^10.2.0:
version "10.4.3"
resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz"
integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==
lru-memoizer@^2.2.0: lru-memoizer@^2.2.0:
version "2.3.0" version "2.3.0"
resolved "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz" resolved "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz"
@@ -4468,7 +4483,7 @@ micromatch@^4.0.4, micromatch@^4.0.5:
braces "^3.0.2" braces "^3.0.2"
picomatch "^2.3.1" picomatch "^2.3.1"
"mime-db@>= 1.43.0 < 2", mime-db@1.52.0: mime-db@1.52.0, "mime-db@>= 1.43.0 < 2":
version "1.52.0" version "1.52.0"
resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz" resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz"
integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
@@ -4553,7 +4568,7 @@ moment@^2.29.4:
resolved "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz" resolved "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz"
integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w== integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==
ms@^2.1.1, ms@2.1.2: ms@2.1.2, ms@^2.1.1:
version "2.1.2" version "2.1.2"
resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz" resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz"
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
@@ -4617,21 +4632,14 @@ node-addon-api@^5.0.0:
resolved "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz" resolved "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz"
integrity sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA== integrity sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==
node-fetch@^2.6.1, node-fetch@^2.6.7, node-fetch@2.6.7: node-fetch@2.6.7, node-fetch@^2.6.1, node-fetch@^2.6.7:
version "2.6.7" version "2.6.7"
resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz" resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz"
integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==
dependencies: dependencies:
whatwg-url "^5.0.0" whatwg-url "^5.0.0"
node-fetch@^2.6.12: node-fetch@^2.6.12, node-fetch@^2.6.9:
version "2.7.0"
resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz"
integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
dependencies:
whatwg-url "^5.0.0"
node-fetch@^2.6.9:
version "2.7.0" version "2.7.0"
resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz" resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz"
integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
@@ -4987,15 +4995,6 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.1.0, postcss-value-parser@^
resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz" resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz"
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
postcss@^8, postcss@^8.0.9, postcss@^8.4.21:
version "8.4.22"
resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.22.tgz"
integrity sha512-XseknLAfRHzVWjCEtdviapiBtfLdgyzExD50Rg2ePaucEesyh8Wv4VPdW0nbyDa1ydbrAxV19jvMT4+LFmcNUA==
dependencies:
nanoid "^3.3.6"
picocolors "^1.0.0"
source-map-js "^1.0.2"
postcss@8.4.14: postcss@8.4.14:
version "8.4.14" version "8.4.14"
resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.14.tgz" resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.14.tgz"
@@ -5005,6 +5004,15 @@ postcss@8.4.14:
picocolors "^1.0.0" picocolors "^1.0.0"
source-map-js "^1.0.2" source-map-js "^1.0.2"
postcss@^8, postcss@^8.0.9, postcss@^8.4.21:
version "8.4.22"
resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.22.tgz"
integrity sha512-XseknLAfRHzVWjCEtdviapiBtfLdgyzExD50Rg2ePaucEesyh8Wv4VPdW0nbyDa1ydbrAxV19jvMT4+LFmcNUA==
dependencies:
nanoid "^3.3.6"
picocolors "^1.0.0"
source-map-js "^1.0.2"
prelude-ls@^1.2.1: prelude-ls@^1.2.1:
version "1.2.1" version "1.2.1"
resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz" resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz"
@@ -5038,15 +5046,6 @@ promise-polyfill@^8.3.0:
resolved "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz" resolved "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz"
integrity sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg== integrity sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==
prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
version "15.8.1"
resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
dependencies:
loose-envify "^1.4.0"
object-assign "^4.1.1"
react-is "^16.13.1"
prop-types@15.7.2: prop-types@15.7.2:
version "15.7.2" version "15.7.2"
resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz" resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz"
@@ -5056,6 +5055,15 @@ prop-types@15.7.2:
object-assign "^4.1.1" object-assign "^4.1.1"
react-is "^16.8.1" react-is "^16.8.1"
prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
version "15.8.1"
resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
dependencies:
loose-envify "^1.4.0"
object-assign "^4.1.1"
react-is "^16.13.1"
proto3-json-serializer@^1.0.0: proto3-json-serializer@^1.0.0:
version "1.1.1" version "1.1.1"
resolved "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-1.1.1.tgz" resolved "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-1.1.1.tgz"
@@ -5079,6 +5087,24 @@ protobufjs-cli@1.1.1:
tmp "^0.2.1" tmp "^0.2.1"
uglify-js "^3.7.7" uglify-js "^3.7.7"
protobufjs@7.2.4:
version "7.2.4"
resolved "https://registry.npmjs.org/protobufjs/-/protobufjs-7.2.4.tgz"
integrity sha512-AT+RJgD2sH8phPmCf7OUZR8xGdcJRga4+1cOaXJ64hvcSkVhNcRHOwIxUatPH15+nj59WAGTDv3LSGZPEQbJaQ==
dependencies:
"@protobufjs/aspromise" "^1.1.2"
"@protobufjs/base64" "^1.1.2"
"@protobufjs/codegen" "^2.0.4"
"@protobufjs/eventemitter" "^1.1.0"
"@protobufjs/fetch" "^1.1.0"
"@protobufjs/float" "^1.0.2"
"@protobufjs/inquire" "^1.1.0"
"@protobufjs/path" "^1.1.2"
"@protobufjs/pool" "^1.1.0"
"@protobufjs/utf8" "^1.1.0"
"@types/node" ">=13.7.0"
long "^5.0.0"
protobufjs@^6.11.3: protobufjs@^6.11.3:
version "6.11.3" version "6.11.3"
resolved "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.3.tgz" resolved "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.3.tgz"
@@ -5134,24 +5160,6 @@ protobufjs@^7.2.5:
"@types/node" ">=13.7.0" "@types/node" ">=13.7.0"
long "^5.0.0" long "^5.0.0"
protobufjs@7.2.4:
version "7.2.4"
resolved "https://registry.npmjs.org/protobufjs/-/protobufjs-7.2.4.tgz"
integrity sha512-AT+RJgD2sH8phPmCf7OUZR8xGdcJRga4+1cOaXJ64hvcSkVhNcRHOwIxUatPH15+nj59WAGTDv3LSGZPEQbJaQ==
dependencies:
"@protobufjs/aspromise" "^1.1.2"
"@protobufjs/base64" "^1.1.2"
"@protobufjs/codegen" "^2.0.4"
"@protobufjs/eventemitter" "^1.1.0"
"@protobufjs/fetch" "^1.1.0"
"@protobufjs/float" "^1.0.2"
"@protobufjs/inquire" "^1.1.0"
"@protobufjs/path" "^1.1.2"
"@protobufjs/pool" "^1.1.0"
"@protobufjs/utf8" "^1.1.0"
"@types/node" ">=13.7.0"
long "^5.0.0"
proxy-from-env@^1.1.0: proxy-from-env@^1.1.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz" resolved "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz"
@@ -5355,17 +5363,6 @@ react-select@^5.7.5:
react-transition-group "^4.3.0" react-transition-group "^4.3.0"
use-isomorphic-layout-effect "^1.1.2" use-isomorphic-layout-effect "^1.1.2"
react-slick@^0.30.2:
version "0.30.2"
resolved "https://registry.npmjs.org/react-slick/-/react-slick-0.30.2.tgz"
integrity sha512-XvQJi7mRHuiU3b9irsqS9SGIgftIfdV5/tNcURTb5LdIokRA5kIIx3l4rlq2XYHfxcSntXapoRg/GxaVOM1yfg==
dependencies:
classnames "^2.2.5"
enquire.js "^2.1.6"
json2mq "^0.2.0"
lodash.debounce "^4.0.8"
resize-observer-polyfill "^1.5.0"
react-string-replace@^1.1.0: react-string-replace@^1.1.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.npmjs.org/react-string-replace/-/react-string-replace-1.1.0.tgz" resolved "https://registry.npmjs.org/react-string-replace/-/react-string-replace-1.1.0.tgz"
@@ -5506,11 +5503,6 @@ requizzle@^0.2.3:
dependencies: dependencies:
lodash "^4.17.21" lodash "^4.17.21"
resize-observer-polyfill@^1.5.0:
version "1.5.1"
resolved "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz"
integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==
resolve-from@^4.0.0: resolve-from@^4.0.0:
version "4.0.0" version "4.0.0"
resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz" resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz"
@@ -5571,7 +5563,7 @@ run-parallel@^1.1.9:
dependencies: dependencies:
queue-microtask "^1.2.2" queue-microtask "^1.2.2"
safe-buffer@^5.0.1, safe-buffer@>=5.1.0, safe-buffer@~5.2.0: safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@~5.2.0:
version "5.2.1" version "5.2.1"
resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz" resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz"
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
@@ -5720,11 +5712,6 @@ slash@^4.0.0:
resolved "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz" resolved "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz"
integrity sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew== integrity sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==
slick-carousel@^1.8.1:
version "1.8.1"
resolved "https://registry.npmjs.org/slick-carousel/-/slick-carousel-1.8.1.tgz"
integrity sha512-XB9Ftrf2EEKfzoQXt3Nitrt/IPbT+f1fgqBdoxO3W/+JYvtEOW6EgxnWfr9GH6nmULv7Y2tPmEX3koxThVmebA==
source-map-js@^1.0.2: source-map-js@^1.0.2:
version "1.0.2" version "1.0.2"
resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz" resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz"
@@ -5759,35 +5746,7 @@ stream-shift@^1.0.2:
resolved "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz" resolved "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz"
integrity sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ== integrity sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==
string_decoder@^1.1.1: "string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "1.3.0"
resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz"
integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==
dependencies:
safe-buffer "~5.2.0"
string_decoder@~1.1.1:
version "1.1.1"
resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz"
integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==
dependencies:
safe-buffer "~5.1.0"
string-convert@^0.2.0:
version "0.2.1"
resolved "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz"
integrity sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3" version "4.2.3"
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -5837,14 +5796,21 @@ string.prototype.trimstart@^1.0.6:
define-properties "^1.1.4" define-properties "^1.1.4"
es-abstract "^1.20.4" es-abstract "^1.20.4"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1": string_decoder@^1.1.1:
version "6.0.1" version "1.3.0"
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==
dependencies: dependencies:
ansi-regex "^5.0.1" safe-buffer "~5.2.0"
strip-ansi@^6.0.0, strip-ansi@^6.0.1: string_decoder@~1.1.1:
version "1.1.1"
resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz"
integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==
dependencies:
safe-buffer "~5.1.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1" version "6.0.1"
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -6221,7 +6187,7 @@ use-isomorphic-layout-effect@^1.1.2:
resolved "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz" resolved "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz"
integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA== integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==
use-sync-external-store@^1.2.0, use-sync-external-store@1.2.0: use-sync-external-store@1.2.0, use-sync-external-store@^1.2.0:
version "1.2.0" version "1.2.0"
resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz" resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz"
integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==
@@ -6370,7 +6336,6 @@ wordwrap@^1.0.0:
integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
name wrap-ansi-cjs
version "7.0.0" version "7.0.0"
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@@ -6388,15 +6353,6 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0" string-width "^4.1.0"
strip-ansi "^6.0.0" strip-ansi "^6.0.0"
wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^8.1.0: wrap-ansi@^8.1.0:
version "8.1.0" version "8.1.0"
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz"
@@ -6449,11 +6405,6 @@ yargs-parser@^20.2.2:
resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz" resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz"
integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==
yargs-parser@^21.1.1:
version "21.1.1"
resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz"
integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==
yargs@^15.3.1: yargs@^15.3.1:
version "15.4.1" version "15.4.1"
resolved "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz" resolved "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz"
@@ -6484,19 +6435,6 @@ yargs@^16.2.0:
y18n "^5.0.5" y18n "^5.0.5"
yargs-parser "^20.2.2" yargs-parser "^20.2.2"
yargs@^17.7.2:
version "17.7.2"
resolved "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz"
integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==
dependencies:
cliui "^8.0.1"
escalade "^3.1.1"
get-caller-file "^2.0.5"
require-directory "^2.1.1"
string-width "^4.2.3"
y18n "^5.0.5"
yargs-parser "^21.1.1"
yocto-queue@^0.1.0: yocto-queue@^0.1.0:
version "0.1.0" version "0.1.0"
resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz" resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz"