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

This commit is contained in:
Carlos Mesquita
2024-08-20 19:37:06 +01:00
37 changed files with 5344 additions and 1817 deletions

View File

@@ -54,4 +54,4 @@ EXPOSE 3000
ENV PORT 3000 ENV PORT 3000
ENV HOSTNAME localhost ENV HOSTNAME localhost
CMD ["node", "server.js"] CMD HOSTNAME="0.0.0.0" node server.js

2943
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -25,8 +25,8 @@
"@react-spring/web": "^9.7.4", "@react-spring/web": "^9.7.4",
"@tanstack/react-table": "^8.10.1", "@tanstack/react-table": "^8.10.1",
"@types/node": "18.13.0", "@types/node": "18.13.0",
"@types/react": "18.0.27", "@types/react": "^18.3.3",
"@types/react-dom": "18.0.10", "@types/react-dom": "^18.3.0",
"@use-gesture/react": "^10.3.1", "@use-gesture/react": "^10.3.1",
"axios": "^1.3.5", "axios": "^1.3.5",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
@@ -39,6 +39,7 @@
"daisyui": "^3.1.5", "daisyui": "^3.1.5",
"eslint": "8.33.0", "eslint": "8.33.0",
"eslint-config-next": "13.1.6", "eslint-config-next": "13.1.6",
"exceljs": "^4.4.0",
"express-handlebars": "^7.1.2", "express-handlebars": "^7.1.2",
"firebase": "9.19.1", "firebase": "9.19.1",
"firebase-admin": "^11.10.1", "firebase-admin": "^11.10.1",

View File

@@ -1,10 +1,10 @@
import { Module } from "@/interfaces"; import {Module} from "@/interfaces";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import { moduleLabels } from "@/utils/moduleUtils"; import {moduleLabels} from "@/utils/moduleUtils";
import clsx from "clsx"; import clsx from "clsx";
import { motion } from "framer-motion"; import {motion} from "framer-motion";
import { ReactNode, useEffect, useState } from "react"; import {ReactNode, useEffect, useState} from "react";
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsStopwatch } from "react-icons/bs"; import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsStopwatch} from "react-icons/bs";
import ProgressBar from "../Low/ProgressBar"; import ProgressBar from "../Low/ProgressBar";
import TimerEndedModal from "../TimerEndedModal"; import TimerEndedModal from "../TimerEndedModal";
import Timer from "./Timer"; import Timer from "./Timer";
@@ -24,7 +24,7 @@ export default function ModuleTitle({
minTimer, module, label, exerciseIndex, totalExercises, disableTimer = false, partLabel, showTimer = true minTimer, module, label, exerciseIndex, totalExercises, disableTimer = false, partLabel, showTimer = true
}: Props) { }: Props) {
const moduleIcon: { [key in Module]: ReactNode } = { const moduleIcon: {[key in Module]: ReactNode} = {
reading: <BsBook className="text-ielts-reading w-6 h-6" />, reading: <BsBook className="text-ielts-reading w-6 h-6" />,
listening: <BsHeadphones className="text-ielts-listening w-6 h-6" />, listening: <BsHeadphones className="text-ielts-listening w-6 h-6" />,
writing: <BsPen className="text-ielts-writing w-6 h-6" />, writing: <BsPen className="text-ielts-writing w-6 h-6" />,
@@ -36,11 +36,25 @@ export default function ModuleTitle({
<> <>
{showTimer && <Timer minTimer={minTimer} disableTimer={disableTimer} />} {showTimer && <Timer minTimer={minTimer} disableTimer={disableTimer} />}
<div className="w-full"> <div className="w-full">
{partLabel && <div className="text-3xl space-y-4">{partLabel.split('\n\n').map((line, index) => { {partLabel && (
if(index == 0) return <p className="font-bold">{line}</p> <div className="text-3xl space-y-4">
else return <p className="text-2xl font-semibold">{line}</p> {partLabel.split("\n\n").map((line, index) => {
})}</div>} if (index == 0)
<div className={clsx("flex gap-6 w-full h-fit items-center", partLabel ? "mt-10" : "mt-5" )}> return (
<p key={index} className="font-bold">
{line}
</p>
);
else
return (
<p key={index} className="text-2xl font-semibold">
{line}
</p>
);
})}
</div>
)}
<div className={clsx("flex gap-6 w-full h-fit items-center", partLabel ? "mt-10" : "mt-5")}>
<div className="w-12 h-12 bg-mti-gray-smoke flex items-center justify-center rounded-lg">{moduleIcon[module]}</div> <div className="w-12 h-12 bg-mti-gray-smoke flex items-center justify-center rounded-lg">{moduleIcon[module]}</div>
<div className="flex flex-col gap-3 w-full"> <div className="flex flex-col gap-3 w-full">
<div className="w-full flex justify-between"> <div className="w-full flex justify-between">

View File

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

View File

@@ -28,6 +28,7 @@ export default function FillBlanksSolutions({
const total = text.match(/({{\d+}})/g)?.length || 0; const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = correctUserSolutions!.filter((x) => { const correct = correctUserSolutions!.filter((x) => {
const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution; const solution = solutions.find((y) => x.id.toString() === y.id.toString())?.solution;
console.log(solution);
if (!solution) return false; if (!solution) return false;
const option = words.find((w) => { const option = words.find((w) => {
@@ -36,7 +37,7 @@ export default function FillBlanksSolutions({
} else if ('letter' in w) { } else if ('letter' in w) {
return w.word.toLowerCase() === x.solution.toLowerCase(); return w.word.toLowerCase() === x.solution.toLowerCase();
} else { } else {
return w.id === x.id; return w.id.toString() === x.id.toString();
} }
}); });
if (!option) return false; if (!option) return false;

View File

@@ -49,15 +49,11 @@ function Blank({
{userSolution && !isUserSolutionCorrect() && ( {userSolution && !isUserSolutionCorrect() && (
<div <div
className="py-2 px-3 rounded-2xl w-fit focus:outline-none my-2 bg-mti-rose-ultralight text-mti-rose-light" className="py-2 px-3 rounded-2xl w-fit focus:outline-none my-2 bg-mti-rose-ultralight text-mti-rose-light"
placeholder={id}
contentEditable={disabled}> contentEditable={disabled}>
{userSolution} {userSolution}
</div> </div>
)} )}
<div <div className={clsx("py-2 px-3 rounded-2xl w-fit focus:outline-none my-2", getSolutionStyling())} contentEditable={disabled}>
className={clsx("py-2 px-3 rounded-2xl w-fit focus:outline-none my-2", getSolutionStyling())}
placeholder={id}
contentEditable={disabled}>
{!solutions ? userInput : solutions.join(" / ")} {!solutions ? userInput : solutions.join(" / ")}
</div> </div>
</span> </span>

View File

@@ -1,287 +1,280 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, {useState, useEffect, useRef, useCallback} from "react";
import { animated } from '@react-spring/web'; import {animated} from "@react-spring/web";
import { FaRegCirclePlay, FaRegCircleStop } from "react-icons/fa6"; import {FaRegCirclePlay, FaRegCircleStop} from "react-icons/fa6";
import HighlightContent from '../HighlightContent'; import HighlightContent from "../HighlightContent";
import { ITrainingTip, SegmentRef, TimelineEvent } from './TrainingInterfaces'; import {ITrainingTip, SegmentRef, TimelineEvent} from "./TrainingInterfaces";
const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => { const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
const [isAutoPlaying, setIsAutoPlaying] = useState<boolean>(false); const [isAutoPlaying, setIsAutoPlaying] = useState<boolean>(false);
const [currentTime, setCurrentTime] = useState<number>(0); const [currentTime, setCurrentTime] = useState<number>(0);
const [walkthroughHtml, setWalkthroughHtml] = useState<string>(''); const [walkthroughHtml, setWalkthroughHtml] = useState<string>("");
const [highlightedPhrases, setHighlightedPhrases] = useState<string[]>([]); const [highlightedPhrases, setHighlightedPhrases] = useState<string[]>([]);
const [isPlaying, setIsPlaying] = useState<boolean>(false); const [isPlaying, setIsPlaying] = useState<boolean>(false);
const timelineRef = useRef<TimelineEvent[]>([]); const timelineRef = useRef<TimelineEvent[]>([]);
const animationRef = useRef<number | null>(null); const animationRef = useRef<number | null>(null);
const segmentsRef = useRef<SegmentRef[]>([]); const segmentsRef = useRef<SegmentRef[]>([]);
const toggleAutoPlay = useCallback(() => { const toggleAutoPlay = useCallback(() => {
setIsAutoPlaying((prev) => { setIsAutoPlaying((prev) => {
if (!prev && currentTime === getMaxTime()) { if (!prev && currentTime === getMaxTime()) {
setCurrentTime(0); setCurrentTime(0);
} }
return !prev; return !prev;
}); });
}, [currentTime]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentTime]);
const handleAnimationComplete = useCallback(() => { const handleAnimationComplete = useCallback(() => {
setIsAutoPlaying(false); setIsAutoPlaying(false);
}, []); }, []);
const handleResetAnimation = useCallback((newTime: number) => { const handleResetAnimation = useCallback((newTime: number) => {
setCurrentTime(newTime); setCurrentTime(newTime);
}, []); }, []);
const getMaxTime = (): number => { const getMaxTime = (): number => {
return tip.exercise?.segments.reduce((sum, segment) => return (
sum + segment.wordDelay * segment.html.split(/\s+/).length + segment.holdDelay, 0 tip.exercise?.segments.reduce((sum, segment) => sum + segment.wordDelay * segment.html.split(/\s+/).length + segment.holdDelay, 0) ?? 0
) ?? 0; );
}; };
useEffect(() => { useEffect(() => {
const timeline: TimelineEvent[] = []; const timeline: TimelineEvent[] = [];
let currentTimePosition = 0; let currentTimePosition = 0;
segmentsRef.current = []; segmentsRef.current = [];
tip.exercise?.segments.forEach((segment, index) => { tip.exercise?.segments.forEach((segment, index) => {
const parser = new DOMParser(); const parser = new DOMParser();
const doc = parser.parseFromString(segment.html, 'text/html'); const doc = parser.parseFromString(segment.html, "text/html");
const words: string[] = []; const words: string[] = [];
const walkTree = (node: Node) => { const walkTree = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) { if (node.nodeType === Node.TEXT_NODE) {
words.push(...(node.textContent?.split(/\s+/).filter(word => word.length > 0) || [])); words.push(...(node.textContent?.split(/\s+/).filter((word) => word.length > 0) || []));
} else if (node.nodeType === Node.ELEMENT_NODE) { } else if (node.nodeType === Node.ELEMENT_NODE) {
Array.from(node.childNodes).forEach(walkTree); Array.from(node.childNodes).forEach(walkTree);
} }
}; };
walkTree(doc.body); walkTree(doc.body);
const textDuration = words.length * segment.wordDelay; const textDuration = words.length * segment.wordDelay;
segmentsRef.current.push({ segmentsRef.current.push({
...segment, ...segment,
words: words, words: words,
startTime: currentTimePosition, startTime: currentTimePosition,
endTime: currentTimePosition + textDuration endTime: currentTimePosition + textDuration,
}); });
timeline.push({ timeline.push({
type: 'text', type: "text",
start: currentTimePosition, start: currentTimePosition,
end: currentTimePosition + textDuration, end: currentTimePosition + textDuration,
segmentIndex: index segmentIndex: index,
}); });
currentTimePosition += textDuration; currentTimePosition += textDuration;
timeline.push({ timeline.push({
type: 'highlight', type: "highlight",
start: currentTimePosition, start: currentTimePosition,
end: currentTimePosition + segment.holdDelay, end: currentTimePosition + segment.holdDelay,
content: segment.highlight, content: segment.highlight,
segmentIndex: index segmentIndex: index,
}); });
currentTimePosition += segment.holdDelay; currentTimePosition += segment.holdDelay;
}); });
timelineRef.current = timeline; timelineRef.current = timeline;
}, [tip.exercise?.segments]); }, [tip.exercise?.segments]);
const updateText = useCallback(() => { const updateText = useCallback(() => {
const currentEvent = timelineRef.current.find( const currentEvent = timelineRef.current.find((event) => currentTime >= event.start && currentTime < event.end);
event => currentTime >= event.start && currentTime < event.end
);
if (currentEvent) { if (currentEvent) {
if (currentEvent.type === 'text') { if (currentEvent.type === "text") {
const segment = segmentsRef.current[currentEvent.segmentIndex]; const segment = segmentsRef.current[currentEvent.segmentIndex];
const elapsedTime = currentTime - currentEvent.start; const elapsedTime = currentTime - currentEvent.start;
const wordsToShow = Math.min(Math.floor(elapsedTime / segment.wordDelay), segment.words.length); const wordsToShow = Math.min(Math.floor(elapsedTime / segment.wordDelay), segment.words.length);
const previousSegmentsHtml = segmentsRef.current const previousSegmentsHtml = segmentsRef.current
.slice(0, currentEvent.segmentIndex) .slice(0, currentEvent.segmentIndex)
.map(seg => seg.html) .map((seg) => seg.html)
.join(''); .join("");
const parser = new DOMParser(); const parser = new DOMParser();
const doc = parser.parseFromString(segment.html, 'text/html'); const doc = parser.parseFromString(segment.html, "text/html");
let wordCount = 0; let wordCount = 0;
const walkTree = (node: Node, action: (node: Node) => void): boolean => { const walkTree = (node: Node, action: (node: Node) => void): boolean => {
if (node.nodeType === Node.TEXT_NODE && node.textContent) { if (node.nodeType === Node.TEXT_NODE && node.textContent) {
const words = node.textContent.split(/(\s+)/).filter(word => word.length > 0); const words = node.textContent.split(/(\s+)/).filter((word) => word.length > 0);
if (wordCount + words.filter(w => !/\s+/.test(w)).length <= wordsToShow) { if (wordCount + words.filter((w) => !/\s+/.test(w)).length <= wordsToShow) {
action(node.cloneNode(true)); action(node.cloneNode(true));
wordCount += words.filter(w => !/\s+/.test(w)).length; wordCount += words.filter((w) => !/\s+/.test(w)).length;
} else { } else {
const remainingWords = wordsToShow - wordCount; const remainingWords = wordsToShow - wordCount;
const newTextContent = words.reduce((acc, word) => { const newTextContent = words.reduce(
if (!/\s+/.test(word) && acc.nonSpaceWords < remainingWords) { (acc, word) => {
acc.text += word; if (!/\s+/.test(word) && acc.nonSpaceWords < remainingWords) {
acc.nonSpaceWords++; acc.text += word;
} else if (/\s+/.test(word) || acc.nonSpaceWords < remainingWords) { acc.nonSpaceWords++;
acc.text += word; } else if (/\s+/.test(word) || acc.nonSpaceWords < remainingWords) {
} acc.text += word;
return acc; }
}, { text: '', nonSpaceWords: 0 }).text; return acc;
const newNode = node.cloneNode(false); },
newNode.textContent = newTextContent; {text: "", nonSpaceWords: 0},
action(newNode); ).text;
wordCount = wordsToShow; const newNode = node.cloneNode(false);
} newNode.textContent = newTextContent;
} else if (node.nodeType === Node.ELEMENT_NODE) { action(newNode);
const clone = node.cloneNode(false); wordCount = wordsToShow;
action(clone); }
Array.from(node.childNodes).some(child => { } else if (node.nodeType === Node.ELEMENT_NODE) {
return walkTree(child, childNode => (clone as Node).appendChild(childNode)); const clone = node.cloneNode(false);
}); action(clone);
} Array.from(node.childNodes).some((child) => {
return wordCount >= wordsToShow; return walkTree(child, (childNode) => (clone as Node).appendChild(childNode));
}; });
const fragment = document.createDocumentFragment(); }
walkTree(doc.body, node => fragment.appendChild(node)); return wordCount >= wordsToShow;
};
const fragment = document.createDocumentFragment();
walkTree(doc.body, (node) => fragment.appendChild(node));
const serializer = new XMLSerializer(); const serializer = new XMLSerializer();
const currentSegmentHtml = Array.from(fragment.childNodes) const currentSegmentHtml = Array.from(fragment.childNodes)
.map(node => serializer.serializeToString(node)) .map((node) => serializer.serializeToString(node))
.join(''); .join("");
const newHtml = previousSegmentsHtml + currentSegmentHtml; const newHtml = previousSegmentsHtml + currentSegmentHtml;
setWalkthroughHtml(newHtml); setWalkthroughHtml(newHtml);
setHighlightedPhrases([]); setHighlightedPhrases([]);
} else if (currentEvent.type === 'highlight') { } else if (currentEvent.type === "highlight") {
const newHtml = segmentsRef.current const newHtml = segmentsRef.current
.slice(0, currentEvent.segmentIndex + 1) .slice(0, currentEvent.segmentIndex + 1)
.map(seg => seg.html) .map((seg) => seg.html)
.join(''); .join("");
setWalkthroughHtml(newHtml); setWalkthroughHtml(newHtml);
setHighlightedPhrases(currentEvent.content || []); setHighlightedPhrases(currentEvent.content || []);
} }
} }
}, [currentTime]); }, [currentTime]);
useEffect(() => { useEffect(() => {
updateText(); updateText();
}, [currentTime, updateText]); }, [currentTime, updateText]);
useEffect(() => { useEffect(() => {
if (isAutoPlaying) { if (isAutoPlaying) {
const lastEvent = timelineRef.current[timelineRef.current.length - 1]; const lastEvent = timelineRef.current[timelineRef.current.length - 1];
if (lastEvent && currentTime >= lastEvent.end) { if (lastEvent && currentTime >= lastEvent.end) {
setCurrentTime(0); setCurrentTime(0);
} }
setIsPlaying(true); setIsPlaying(true);
} else { } else {
setIsPlaying(false); setIsPlaying(false);
} }
}, [isAutoPlaying, currentTime]); }, [isAutoPlaying, currentTime]);
useEffect(() => { useEffect(() => {
const animate = () => { const animate = () => {
if (isPlaying) { if (isPlaying) {
setCurrentTime((prevTime) => { setCurrentTime((prevTime) => {
const newTime = prevTime + 50; const newTime = prevTime + 50;
const lastEvent = timelineRef.current[timelineRef.current.length - 1]; const lastEvent = timelineRef.current[timelineRef.current.length - 1];
if (lastEvent && newTime >= lastEvent.end) { if (lastEvent && newTime >= lastEvent.end) {
setIsPlaying(false); setIsPlaying(false);
handleAnimationComplete(); handleAnimationComplete();
return lastEvent.end; return lastEvent.end;
} }
return newTime; return newTime;
}); });
} }
animationRef.current = requestAnimationFrame(animate); animationRef.current = requestAnimationFrame(animate);
}; };
animationRef.current = requestAnimationFrame(animate); animationRef.current = requestAnimationFrame(animate);
return () => { return () => {
if (animationRef.current) { if (animationRef.current) {
cancelAnimationFrame(animationRef.current); cancelAnimationFrame(animationRef.current);
} }
}; };
}, [isPlaying, handleAnimationComplete]); }, [isPlaying, handleAnimationComplete]);
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newTime = parseInt(e.target.value, 10); const newTime = parseInt(e.target.value, 10);
setCurrentTime(newTime); setCurrentTime(newTime);
handleResetAnimation(newTime); handleResetAnimation(newTime);
}; };
const handleSliderMouseDown = () => { const handleSliderMouseDown = () => {
setIsPlaying(false); setIsPlaying(false);
}; };
const handleSliderMouseUp = () => { const handleSliderMouseUp = () => {
if (isAutoPlaying) { if (isAutoPlaying) {
setIsPlaying(true); setIsPlaying(true);
} }
}; };
if (tip.standalone || !tip.exercise) { if (tip.standalone || !tip.exercise) {
return ( return (
<div className="container mx-auto"> <div className="container mx-auto">
<h1 className='text-xl font-bold text-red-600'>The exercise for this tip is not available yet!</h1> <h1 className="text-xl font-bold text-red-600">The exercise for this tip is not available yet!</h1>
<div className="tip-container bg-blue-100 p-4 rounded-lg shadow-md mb-4 mt-10"> <div className="tip-container bg-blue-100 p-4 rounded-lg shadow-md mb-4 mt-10">
<h3 className="text-xl font-semibold text-blue-800 mb-2">{tip.tipCategory}</h3> <h3 className="text-xl font-semibold text-blue-800 mb-2">{tip.tipCategory}</h3>
<div className="text-gray-700" dangerouslySetInnerHTML={{ __html: tip.tipHtml }} /> <div className="text-gray-700" dangerouslySetInnerHTML={{__html: tip.tipHtml}} />
</div> </div>
</div> </div>
); );
} }
return (
return ( <div className="container mx-auto">
<div className="container mx-auto"> <div className="tip-container bg-blue-100 p-4 rounded-lg shadow-md mb-4">
<div className="tip-container bg-blue-100 p-4 rounded-lg shadow-md mb-4"> <h3 className="text-xl font-semibold text-blue-800 mb-2">{tip.tipCategory}</h3>
<h3 className="text-xl font-semibold text-blue-800 mb-2">{tip.tipCategory}</h3> <div className="text-gray-700" dangerouslySetInnerHTML={{__html: tip.tipHtml}} />
<div className="text-gray-700" dangerouslySetInnerHTML={{ __html: tip.tipHtml }} /> </div>
</div> <div className="flex flex-col space-y-4">
<div className='flex flex-col space-y-4'> <div className="flex flex-row items-center space-x-4 py-4">
<div className='flex flex-row items-center space-x-4 py-4'> <button
<button onClick={toggleAutoPlay}
onClick={toggleAutoPlay} className="p-2 bg-blue-500 text-white rounded-full transition-colors duration-200 hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
className="p-2 bg-blue-500 text-white rounded-full transition-colors duration-200 hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50" aria-label={isAutoPlaying ? "Pause" : "Play"}>
aria-label={isAutoPlaying ? 'Pause' : 'Play'} {isAutoPlaying ? <FaRegCircleStop className="w-6 h-6" /> : <FaRegCirclePlay className="w-6 h-6" />}
> </button>
{isAutoPlaying ? ( <input
<FaRegCircleStop className="w-6 h-6" /> type="range"
) : ( min="0"
<FaRegCirclePlay className="w-6 h-6" /> max={timelineRef.current.length > 0 ? timelineRef.current[timelineRef.current.length - 1].end : 0}
)} value={currentTime}
</button> onChange={handleSliderChange}
<input onMouseDown={handleSliderMouseDown}
type="range" onMouseUp={handleSliderMouseUp}
min="0" onTouchStart={handleSliderMouseDown}
max={timelineRef.current.length > 0 ? timelineRef.current[timelineRef.current.length - 1].end : 0} onTouchEnd={handleSliderMouseUp}
value={currentTime} className="flex-grow"
onChange={handleSliderChange} />
onMouseDown={handleSliderMouseDown} </div>
onMouseUp={handleSliderMouseUp} <div className="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4">
onTouchStart={handleSliderMouseDown} <div className="flex-1 bg-white p-6 rounded-lg shadow">
onTouchEnd={handleSliderMouseUp} {/*<h2 className="text-xl font-bold mb-4">Question</h2>*/}
className='flex-grow' <div className="mb-4" dangerouslySetInnerHTML={{__html: tip.exercise.question}} />
/> <HighlightContent html={tip.exercise.highlightable} highlightPhrases={highlightedPhrases} />
</div> </div>
<div className='flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4'> <div className="flex-1">
<div className='flex-1 bg-white p-6 rounded-lg shadow'> <div className="bg-gray-50 rounded-lg shadow">
{/*<h2 className="text-xl font-bold mb-4">Question</h2>*/} <div className="p-6 space-y-4">
<div className="mb-4" dangerouslySetInnerHTML={{ __html: tip.exercise.question }} /> <animated.div dangerouslySetInnerHTML={{__html: walkthroughHtml}} />
<HighlightContent html={tip.exercise.highlightable} highlightPhrases={highlightedPhrases} /> </div>
</div> </div>
<div className='flex-1'> </div>
<div className='bg-gray-50 rounded-lg shadow'> </div>
<div className='p-6 space-y-4'> </div>
<animated.div </div>
dangerouslySetInnerHTML={{ __html: walkthroughHtml }} );
/>
</div>
</div>
</div>
</div>
</div>
</div>
);
}; };
export default ExerciseWalkthrough; export default ExerciseWalkthrough;

View File

@@ -20,6 +20,7 @@ interface Props {
reload?: Function; reload?: Function;
allowArchive?: boolean; allowArchive?: boolean;
allowUnarchive?: boolean; allowUnarchive?: boolean;
allowExcelDownload?: boolean;
} }
export default function AssignmentCard({ export default function AssignmentCard({
@@ -37,9 +38,11 @@ export default function AssignmentCard({
reload, reload,
allowArchive, allowArchive,
allowUnarchive, allowUnarchive,
allowExcelDownload,
users, users,
}: Assignment & Props) { }: Assignment & Props) {
const renderPdfIcon = usePDFDownload("assignments"); const renderPdfIcon = usePDFDownload("assignments");
const renderExcelIcon = usePDFDownload("assignments", "excel");
const renderArchiveIcon = useAssignmentArchive(id, reload); const renderArchiveIcon = useAssignmentArchive(id, reload);
const renderUnarchiveIcon = useAssignmentUnarchive(id, reload); const renderUnarchiveIcon = useAssignmentUnarchive(id, reload);
@@ -64,6 +67,7 @@ export default function AssignmentCard({
<h3 className="text-xl font-semibold">{name}</h3> <h3 className="text-xl font-semibold">{name}</h3>
<div className="flex gap-2"> <div className="flex gap-2">
{allowDownload && renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")} {allowDownload && renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
{allowExcelDownload && renderExcelIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
{allowArchive && !archived && renderArchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")} {allowArchive && !archived && renderArchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
{allowUnarchive && archived && renderUnarchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")} {allowUnarchive && archived && renderUnarchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
</div> </div>

View File

@@ -377,6 +377,7 @@ export default function CorporateDashboard({user}: Props) {
allowDownload allowDownload
reload={reloadAssignments} reload={reloadAssignments}
allowArchive allowArchive
allowExcelDownload
/> />
))} ))}
</div> </div>
@@ -393,6 +394,7 @@ export default function CorporateDashboard({user}: Props) {
allowDownload allowDownload
reload={reloadAssignments} reload={reloadAssignments}
allowUnarchive allowUnarchive
allowExcelDownload
/> />
))} ))}
</div> </div>

View File

@@ -22,6 +22,7 @@ import {
BsPlus, BsPlus,
BsPersonFillGear, BsPersonFillGear,
BsFilter, BsFilter,
BsDatabase,
} from "react-icons/bs"; } from "react-icons/bs";
import UserCard from "@/components/UserCard"; import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
@@ -50,6 +51,7 @@ import {groupBy, uniq, uniqBy} from "lodash";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
import {Menu, MenuButton, MenuItem, MenuItems} from "@headlessui/react"; import {Menu, MenuButton, MenuItem, MenuItems} from "@headlessui/react";
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover"; import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover";
import MasterStatistical from "./MasterStatistical";
interface Props { interface Props {
user: MasterCorporateUser; user: MasterCorporateUser;
@@ -308,6 +310,7 @@ export default function MasterCorporateDashboard({user}: Props) {
const {groups} = useGroups({admin: user.id, userType: user.type}); const {groups} = useGroups({admin: user.id, userType: user.type});
const masterCorporateUserGroups = [...new Set(groups.filter((u) => u.admin === user.id).flatMap((g) => g.participants))]; const masterCorporateUserGroups = [...new Set(groups.filter((u) => u.admin === user.id).flatMap((g) => g.participants))];
const corporateUserGroups = [...new Set(groups.flatMap((g) => g.participants))]; const corporateUserGroups = [...new Set(groups.flatMap((g) => g.participants))];
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({corporate: user.id}); const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({corporate: user.id});
@@ -332,7 +335,6 @@ export default function MasterCorporateDashboard({user}: Props) {
const studentFilter = (user: User) => user.type === "student" && corporateUserGroups.includes(user.id); const studentFilter = (user: User) => 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) => stats.filter((s) => s.user === user.id); const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
const UserDisplay = (displayUser: User) => ( const UserDisplay = (displayUser: User) => (
@@ -434,6 +436,19 @@ 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());
const StudentPerformancePage = () => { const StudentPerformancePage = () => {
const students = users const students = users
.filter((x) => x.type === "student" && groups.flatMap((g) => g.participants).includes(x.id)) .filter((x) => x.type === "student" && groups.flatMap((g) => g.participants).includes(x.id))
@@ -571,6 +586,7 @@ export default function MasterCorporateDashboard({user}: Props) {
allowDownload allowDownload
reload={reloadAssignments} reload={reloadAssignments}
allowArchive allowArchive
allowExcelDownload
/> />
))} ))}
</div> </div>
@@ -587,6 +603,7 @@ export default function MasterCorporateDashboard({user}: Props) {
allowDownload allowDownload
reload={reloadAssignments} reload={reloadAssignments}
allowUnarchive allowUnarchive
allowExcelDownload
/> />
))} ))}
</div> </div>
@@ -595,6 +612,29 @@ export default function MasterCorporateDashboard({user}: Props) {
); );
}; };
const MasterStatisticalPage = () => {
return (
<>
<div className="flex flex-col gap-4">
<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>
<h2 className="text-2xl font-semibold">Master Statistical</h2>
</div>
<MasterStatistical
users={masterCorporateUserGroups.reduce((accm: CorporateUser[], id) => {
const user = users.find((u) => u.id === id) as CorporateUser;
if (user) return [...accm, user];
return accm;
}, [])}
/>
</>
);
};
const DefaultDashboard = () => ( const DefaultDashboard = () => (
<> <>
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:justify-between text-center"> <section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:justify-between text-center">
@@ -654,6 +694,13 @@ export default function MasterCorporateDashboard({user}: Props) {
color="purple" color="purple"
onClick={() => setPage("studentsPerformance")} onClick={() => setPage("studentsPerformance")}
/> />
{/* <IconCard
Icon={BsDatabase}
label="Master Statistical"
// value={masterCorporateUserGroups.length}
color="purple"
onClick={() => setPage("statistical")}
/> */}
<button <button
disabled={isAssignmentsLoading} disabled={isAssignmentsLoading}
onClick={() => setPage("assignments")} onClick={() => setPage("assignments")}
@@ -784,6 +831,7 @@ export default function MasterCorporateDashboard({user}: Props) {
{page === "corporate" && <CorporateList />} {page === "corporate" && <CorporateList />}
{page === "assignments" && <AssignmentsPage />} {page === "assignments" && <AssignmentsPage />}
{page === "studentsPerformance" && <StudentPerformancePage />} {page === "studentsPerformance" && <StudentPerformancePage />}
{page === "statistical" && <MasterStatisticalPage />}
{page === "" && <DefaultDashboard />} {page === "" && <DefaultDashboard />}
</> </>
); );

View File

@@ -0,0 +1,34 @@
import React from "react";
import {CorporateUser} from "@/interfaces/user";
import {BsBank, BsPersonFill} from "react-icons/bs";
import IconCard from "./IconCard";
import useAssignmentsCorporates from "@/hooks/useAssignmentCorporates";
interface Props {
users: CorporateUser[];
}
const MasterStatistical = (props: Props) => {
const {users} = props;
const usersList = React.useMemo(() => users.map((x) => x.id), [users]);
const {assignments} = useAssignmentsCorporates({corporates: usersList});
return (
<div className="flex flex-wrap gap-2 items-center text-center">
<IconCard Icon={BsBank} label="Consolidate" value={0} color="purple" onClick={() => console.log("clicked")} />
{users.map((group) => (
<IconCard
key={group.id}
Icon={BsBank}
label={group.corporateInformation?.companyInformation?.name}
value={0}
color="purple"
onClick={() => console.log("clicked", group)}
/>
))}
<IconCard onClick={() => console.log("clicked")} Icon={BsPersonFill} label="Consolidate Highest Student" color="purple" />
</div>
);
};
export default MasterStatistical;

View File

@@ -121,7 +121,7 @@ export default function TeacherDashboard({user}: Props) {
}; };
const GroupsList = () => { const GroupsList = () => {
const filter = (x: Group) => x.admin === user.id || x.participants.includes(user.id); const filter = (x: Group) => x.admin === user.id;
return ( return (
<> <>
@@ -260,6 +260,7 @@ export default function TeacherDashboard({user}: Props) {
allowDownload allowDownload
reload={reloadAssignments} reload={reloadAssignments}
allowArchive allowArchive
allowExcelDownload
/> />
))} ))}
</div> </div>
@@ -276,6 +277,7 @@ export default function TeacherDashboard({user}: Props) {
allowDownload allowDownload
reload={reloadAssignments} reload={reloadAssignments}
allowUnarchive allowUnarchive
allowExcelDownload
/> />
))} ))}
</div> </div>
@@ -316,7 +318,13 @@ export default function TeacherDashboard({user}: Props) {
color="purple" color="purple"
/> />
{checkAccess(user, ["teacher", "developer"], permissions, "viewGroup") && ( {checkAccess(user, ["teacher", "developer"], permissions, "viewGroup") && (
<IconCard Icon={BsPeople} label="Groups" value={groups.length} color="purple" onClick={() => setPage("groups")} /> <IconCard
Icon={BsPeople}
label="Groups"
value={groups.filter((x) => x.admin === user.id).length}
color="purple"
onClick={() => setPage("groups")}
/>
)} )}
<div <div
onClick={() => setPage("assignments")} onClick={() => setPage("assignments")}

View File

@@ -12,6 +12,7 @@ import { use, useEffect, useState } from "react";
import TextComponent from "./TextComponent"; import TextComponent from "./TextComponent";
import PartDivider from "./PartDivider"; import PartDivider from "./PartDivider";
import Timer from "@/components/Medium/Timer"; import Timer from "@/components/Medium/Timer";
import { Stat } from "@/interfaces/user";
interface Props { interface Props {
exam: LevelExam; exam: LevelExam;

View File

@@ -133,7 +133,7 @@ export default function Selection({user, page, onStart, disableSelection = false
<BsArrowRepeat className={clsx("text-xl", isLoading && "animate-spin")} /> <BsArrowRepeat className={clsx("text-xl", isLoading && "animate-spin")} />
</div> </div>
</div> </div>
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll"> <span className="text-mti-gray-taupe flex gap-8 overflow-x-auto pb-2">
{sessions {sessions
.sort((a, b) => moment(b.date).diff(moment(a.date))) .sort((a, b) => moment(b.date).diff(moment(a.date)))
.map((session) => ( .map((session) => (

View File

@@ -0,0 +1,34 @@
import { Assignment } from "@/interfaces/results";
import axios from "axios";
import { useEffect, useState } from "react";
export default function useAssignmentsCorporates({
corporates,
}: {
corporates: string[];
}) {
const [assignments, setAssignments] = useState<Assignment[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = () => {
if (corporates.length === 0) {
setAssignments([]);
return;
}
setIsLoading(true);
axios
.get<Assignment[]>(
`/api/assignments/corporate?ids=${corporates.join(",")}`
)
.then(async (response) => {
setAssignments(response.data);
})
.finally(() => setIsLoading(false));
};
useEffect(getData, [corporates]);
return { assignments, isLoading, isError, reload: getData };
}

View File

@@ -10,7 +10,7 @@ export default function useAssignments({assigner, assignees, corporate}: {assign
const getData = () => { const getData = () => {
setIsLoading(true); setIsLoading(true);
axios axios
.get<Assignment[]>(!corporate ? "/api/assignments" : `/api/assignments/corporate?id=${corporate}`) .get<Assignment[]>(!corporate ? "/api/assignments" : `/api/assignments/corporate/${corporate}`)
.then(async (response) => { .then(async (response) => {
if (assigner) { if (assigner) {
setAssignments(response.data.filter((a) => a.assigner === assigner)); setAssignments(response.data.filter((a) => a.assigner === assigner));

View File

@@ -1,15 +1,16 @@
import React from "react"; import React from "react";
import axios from "axios"; import axios from "axios";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { BsFilePdf } from "react-icons/bs"; import { BsFilePdf, BsFileExcel} from "react-icons/bs";
type DownloadingPdf = { type DownloadingPdf = {
[key: string]: boolean; [key: string]: boolean;
}; };
type PdfEndpoint = "stats" | "assignments"; type PdfEndpoint = "stats" | "assignments";
type FileType = "pdf" | "excel";
export const usePDFDownload = (endpoint: PdfEndpoint) => { export const usePDFDownload = (endpoint: PdfEndpoint, file: FileType = 'pdf') => {
const [downloadingPdf, setDownloadingPdf] = React.useState<DownloadingPdf>( const [downloadingPdf, setDownloadingPdf] = React.useState<DownloadingPdf>(
{} {}
); );
@@ -17,7 +18,7 @@ export const usePDFDownload = (endpoint: PdfEndpoint) => {
const triggerDownload = async (id: string) => { const triggerDownload = async (id: string) => {
try { try {
setDownloadingPdf((prev) => ({ ...prev, [id]: true })); setDownloadingPdf((prev) => ({ ...prev, [id]: true }));
const res = await axios.post(`/api/${endpoint}/${id}/export`); const res = await axios.post(`/api/${endpoint}/${id}/export/${file}`);
toast.success("Report ready!"); toast.success("Report ready!");
const link = document.createElement("a"); const link = document.createElement("a");
link.href = res.data; link.href = res.data;
@@ -45,8 +46,11 @@ export const usePDFDownload = (endpoint: PdfEndpoint) => {
<span className={`${loadingClasses} loading loading-infinity w-6`} /> <span className={`${loadingClasses} loading loading-infinity w-6`} />
); );
} }
const Icon = file === "excel" ? BsFileExcel : BsFilePdf;
return ( return (
<BsFilePdf <Icon
className={`${downloadClasses} text-2xl cursor-pointer`} className={`${downloadClasses} text-2xl cursor-pointer`}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();

View File

@@ -14,7 +14,7 @@ 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, USER_TYPE_LABELS} from "@/resources/user";
import {checkAccess} from "@/utils/permissions"; import {checkAccess} from "@/utils/permissions";
import usePermissions from "@/hooks/usePermissions"; import usePermissions from "@/hooks/usePermissions";
@@ -109,7 +109,7 @@ const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => {
const submit = () => { const submit = () => {
setIsLoading(true); setIsLoading(true);
if (name !== group?.name && (name === "Students" || name === "Teachers")) { if (name !== group?.name && (name?.trim() === "Students" || name?.trim() === "Teachers" || name?.trim() === "Corporate")) {
toast.error("That group name is reserved and cannot be used, please enter another one."); toast.error("That group name is reserved and cannot be used, please enter another one.");
setIsLoading(false); setIsLoading(false);
return; return;
@@ -202,7 +202,6 @@ const filterTypes = ["corporate", "teacher", "mastercorporate"];
export default function GroupList({user}: {user: User}) { export default function GroupList({user}: {user: User}) {
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [editingGroup, setEditingGroup] = useState<Group>(); const [editingGroup, setEditingGroup] = useState<Group>();
const [filterByUser, setFilterByUser] = useState(false);
const {permissions} = usePermissions(user?.id || ""); const {permissions} = usePermissions(user?.id || "");
@@ -210,14 +209,13 @@ export default function GroupList({user}: {user: User}) {
const {groups, reload} = useGroups({ const {groups, reload} = useGroups({
admin: user && filterTypes.includes(user?.type) ? user.id : undefined, admin: user && filterTypes.includes(user?.type) ? user.id : undefined,
userType: user?.type, userType: user?.type,
adminAdmins: user?.type === "teacher" ? user?.id : undefined,
}); });
useEffect(() => { const {groups: corporateGroups} = useGroups({
if (user && ["corporate", "teacher", "mastercorporate"].includes(user.type)) { admin: user && filterTypes.includes(user?.type) ? user.id : undefined,
setFilterByUser(true); userType: user?.type,
} adminAdmins: user?.id,
}, [user]); });
const deleteGroup = (group: Group) => { const deleteGroup = (group: Group) => {
if (!confirm(`Are you sure you want to delete "${group.name}"?`)) return; if (!confirm(`Are you sure you want to delete "${group.name}"?`)) return;
@@ -241,7 +239,7 @@ export default function GroupList({user}: {user: User}) {
columnHelper.accessor("admin", { columnHelper.accessor("admin", {
header: "Admin", header: "Admin",
cell: (info) => ( cell: (info) => (
<div className="tooltip" data-tip={capitalize(users.find((x) => x.id === info.getValue())?.type)}> <div className="tooltip" data-tip={USER_TYPE_LABELS[users.find((x) => x.id === info.getValue())?.type || "student"]}>
{users.find((x) => x.id === info.getValue())?.name} {users.find((x) => x.id === info.getValue())?.name}
</div> </div>
), ),
@@ -304,13 +302,14 @@ export default function GroupList({user}: {user: User}) {
user={user} user={user}
onClose={closeModal} onClose={closeModal}
users={ users={
user?.type === "corporate" || user?.type === "teacher" checkAccess(user, ["corporate", "teacher", "mastercorporate"])
? users.filter( ? users.filter(
(u) => (u) =>
groups groups
.filter((g) => g.admin === user.id) .filter((g) => g.admin === user.id)
.flatMap((g) => g.participants) .flatMap((g) => g.participants)
.includes(u.id) || groups.flatMap((g) => g.participants).includes(u.id), .includes(u.id) ||
(user?.type === "teacher" ? corporateGroups : groups).flatMap((g) => g.participants).includes(u.id),
) )
: users : users
} }

View File

@@ -58,7 +58,10 @@ export default function UserList({
const {users, reload} = useUsers(); const {users, reload} = useUsers();
const {permissions} = usePermissions(user?.id || ""); const {permissions} = usePermissions(user?.id || "");
const {groups} = useGroups({admin: user && ["corporate", "teacher", "mastercorporate"].includes(user?.type) ? user.id : undefined}); const {groups} = useGroups({
admin: user && ["corporate", "teacher", "mastercorporate"].includes(user?.type) ? user.id : undefined,
userType: user?.type,
});
const appendUserFilters = useFilterStore((state) => state.appendUserFilter); const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
const router = useRouter(); const router = useRouter();
@@ -76,10 +79,9 @@ export default function UserList({
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (user && users) { if (user && users) {
const filterUsers = const filterUsers = ["corporate", "teacher", "mastercorporate"].includes(user.type)
user.type === "corporate" || user.type === "teacher" ? users.filter((u) => groups.flatMap((g) => g.participants).includes(u.id))
? users.filter((u) => groups.flatMap((g) => g.participants).includes(u.id)) : users;
: users;
const filteredUsers = filters.reduce((d, f) => d.filter(f), filterUsers); const filteredUsers = filters.reduce((d, f) => d.filter(f), filterUsers);
const sortedUsers = await asyncSorter<User>(filteredUsers, sortFunction); const sortedUsers = await asyncSorter<User>(filteredUsers, sortFunction);

View File

@@ -1,5 +1,5 @@
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import {Tab} from "@headlessui/react"; import {Tab, TabGroup, TabList, TabPanel, TabPanels} 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";
@@ -14,8 +14,8 @@ export default function Lists({user}: {user: User}) {
const {permissions} = usePermissions(user?.id || ""); const {permissions} = usePermissions(user?.id || "");
return ( return (
<Tab.Group> <TabGroup>
<Tab.List className="flex space-x-1 rounded-xl bg-mti-purple-ultralight/40 p-1"> <TabList className="flex space-x-1 rounded-xl bg-mti-purple-ultralight/40 p-1">
<Tab <Tab
className={({selected}) => className={({selected}) =>
clsx( clsx(
@@ -90,35 +90,35 @@ export default function Lists({user}: {user: User}) {
Discount List Discount List
</Tab> </Tab>
)} )}
</Tab.List> </TabList>
<Tab.Panels className="mt-2"> <TabPanels className="mt-2">
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide"> <TabPanel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<UserList user={user} /> <UserList user={user} />
</Tab.Panel> </TabPanel>
{checkAccess(user, ["developer"]) && ( {checkAccess(user, ["developer", "admin", "corporate", "mastercorporate", "teacher"]) && (
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide"> <TabPanel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<ExamList user={user} /> <ExamList user={user} />
</Tab.Panel> </TabPanel>
)} )}
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide"> <TabPanel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<GroupList user={user} /> <GroupList user={user} />
</Tab.Panel> </TabPanel>
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "viewCodes") && ( {checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "viewCodes") && (
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide"> <TabPanel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<CodeList user={user} /> <CodeList user={user} />
</Tab.Panel> </TabPanel>
)} )}
{checkAccess(user, ["developer", "admin"]) && ( {checkAccess(user, ["developer", "admin"]) && (
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide"> <TabPanel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<PackageList user={user} /> <PackageList user={user} />
</Tab.Panel> </TabPanel>
)} )}
{checkAccess(user, ["developer", "admin"]) && ( {checkAccess(user, ["developer", "admin"]) && (
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide"> <TabPanel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<DiscountList user={user} /> <DiscountList user={user} />
</Tab.Panel> </TabPanel>
)} )}
</Tab.Panels> </TabPanels>
</Tab.Group> </TabGroup>
); );
} }

View File

@@ -0,0 +1,442 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { app, storage } from "@/firebase";
import {
getFirestore,
doc,
getDoc,
updateDoc,
getDocs,
query,
collection,
where,
documentId,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { ref, uploadBytes, getDownloadURL } from "firebase/storage";
import { CorporateUser, MasterCorporateUser } from "@/interfaces/user";
import { User } from "@/interfaces/user";
import { Module } from "@/interfaces";
import moment from "moment-timezone";
import ExcelJS from "exceljs";
import { getStudentGroupsForUsersWithoutAdmin } from "@/utils/groups.be";
import { getSpecificUsers, getUser } from "@/utils/users.be";
import { getUserName } from "@/utils/users";
interface GroupScoreSummaryHelper {
score: [number, number];
label: string;
sessions: string[];
}
interface AssignmentData {
assigner: string;
assignees: string[];
results: any;
exams: { module: Module }[];
startDate: string;
excel: {
path: string;
version: string;
};
name: string;
}
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
// if (req.method === "GET") return get(req, res);
if (req.method === "POST") return await post(req, res);
}
function logWorksheetData(worksheet: any) {
worksheet.eachRow((row: any, rowNumber: number) => {
console.log(`Row ${rowNumber}:`);
row.eachCell((cell: any, colNumber: number) => {
console.log(` Cell ${colNumber}: ${cell.value}`);
});
});
}
function commonExcel({
data,
userName,
users,
sectionName,
customTable,
customTableHeaders,
renderCustomTableData,
}: {
data: AssignmentData;
userName: string;
users: User[];
sectionName: string;
customTable: string[][];
customTableHeaders: string[];
renderCustomTableData: (data: any) => string[];
}) {
const allStats = data.results.flatMap((r: any) => r.stats);
const uniqueExercises = [...new Set(allStats.map((s: any) => s.exercise))];
const assigneesData = data.assignees
.map((assignee: string) => {
const userStats = allStats.filter((s: any) => s.user === assignee);
const dates = userStats.map((s: any) => moment(s.date));
return {
userId: assignee,
user: users.find((u) => u.id === assignee),
...userStats.reduce(
(acc: any, curr: any) => {
return {
...acc,
correct: acc.correct + curr.score.correct,
missing: acc.missing + curr.score.missing,
total: acc.total + curr.score.total,
};
},
{ correct: 0, missing: 0, total: 0 }
),
firstDate: moment.min(...dates),
lastDate: moment.max(...dates),
stats: userStats,
};
})
.sort((a, b) => b.correct - a.correct);
const results = assigneesData.map((r: any) => r.correct);
const highestScore = Math.max(...results);
const lowestScore = Math.min(...results);
const averageScore = results.reduce((a, b) => a + b, 0) / results.length;
const firstDate = moment.min(assigneesData.map((r: any) => r.firstDate));
const lastDate = moment.max(assigneesData.map((r: any) => r.lastDate));
const firstSectionData = [
{
label: sectionName,
value: userName,
},
{
label: "Report Download date :",
value: moment().format("DD/MM/YYYY"),
},
{ label: "Test Information :", value: data.name },
{
label: "Date of Test :",
value: moment(data.startDate).format("DD/MM/YYYY"),
},
{ label: "Number of Candidates :", value: data.assignees.length },
{ label: "Highest score :", value: highestScore },
{ label: "Lowest score :", value: lowestScore },
{ label: "Average score :", value: averageScore },
{ label: "", value: "" },
{
label: "Date and time of First submission :",
value: firstDate.format("DD/MM/YYYY"),
},
{
label: "Date and time of Last submission :",
value: lastDate.format("DD/MM/YYYY"),
},
];
// Create a new workbook and add a worksheet
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet("Report Data");
// Populate the worksheet with the data
firstSectionData.forEach(({ label, value }, index) => {
worksheet.getCell(`A${index + 1}`).value = label; // First column (labels)
worksheet.getCell(`B${index + 1}`).value = value; // Second column (values)
});
// added empty arrays to force row spacings
const customTableAndLine = [[],...customTable, []];
customTableAndLine.forEach((row: string[], index) => {
worksheet.addRow(row);
});
// Define the static part of the headers (before "Test Sections")
const staticHeaders = [
"Sr N",
"Candidate ID",
"First and Last Name",
"Passport/ID",
"Email ID",
"Gender",
...customTableHeaders,
];
// Define additional headers after "Test Sections"
const additionalHeaders = ["Time Spent", "Date", "Score"];
// Calculate the dynamic columns based on the testSectionsArray
const testSectionHeaders = uniqueExercises.map(
(section, index) => `Part ${index + 1}`
);
const tableColumnHeadersFirstPart = [
...staticHeaders,
...uniqueExercises.map((a) => "Test Sections"),
];
// Add the main header row, merging static columns and "Test Sections"
const tableColumnHeaders = [
...tableColumnHeadersFirstPart,
...additionalHeaders,
];
worksheet.addRow(tableColumnHeaders);
// 1 headers rows
const startIndexTable = firstSectionData.length + customTableAndLine.length + 1;
// // Merge "Test Sections" over dynamic number of columns
// const tableColumns = staticHeaders.length + numberOfTestSections;
// K10:M12 = 10,11,12,13
// horizontally group Test Sections
worksheet.mergeCells(
startIndexTable,
staticHeaders.length + 1,
startIndexTable,
tableColumnHeadersFirstPart.length
);
// Add the dynamic second and third header rows for test sections and sub-columns
worksheet.addRow([
...Array(staticHeaders.length).fill(""),
...testSectionHeaders,
"",
"",
"",
]);
worksheet.addRow([
...Array(staticHeaders.length).fill(""),
...uniqueExercises.map(() => "Grammar & Vocabulary"),
"",
"",
"",
]);
worksheet.addRow([
...Array(staticHeaders.length).fill(""),
...uniqueExercises.map(
(exercise) => allStats.find((s: any) => s.exercise === exercise).type
),
"",
"",
"",
]);
// vertically group based on the part, exercise and type
staticHeaders.forEach((header, index) => {
worksheet.mergeCells(startIndexTable, index + 1, startIndexTable + 3, index + 1);
});
assigneesData.forEach((data, index) => {
worksheet.addRow([
index + 1,
data.userId,
data.user.name,
data.user.demographicInformation?.passportId,
data.user.email,
data.user.demographicInformation?.gender,
...renderCustomTableData(data),
...uniqueExercises.map((exercise) => {
const score = data.stats.find(
(s: any) => s.exercise === exercise && s.user === data.userId
).score;
return `${score.correct}/${score.total}`;
}),
`${Math.ceil(
data.stats.reduce((acc: number, curr: any) => acc + curr.timeSpent, 0) /
60
)} minutes`,
data.lastDate.format("DD/MM/YYYY HH:mm"),
data.correct,
]);
});
worksheet.addRow([""]);
worksheet.addRow([""]);
for (let i = 0; i < tableColumnHeaders.length; i++) {
worksheet.getColumn(i + 1).width = 30;
}
// Apply styles to the headers
[startIndexTable].forEach((rowNumber) => {
worksheet.getRow(rowNumber).eachCell((cell) => {
if (cell.value) {
cell.fill = {
type: "pattern",
pattern: "solid",
fgColor: { argb: "FFBFBFBF" }, // Grey color for headers
};
cell.font = { bold: true };
cell.alignment = { vertical: "middle", horizontal: "center" };
}
});
});
worksheet.addRow(["Printed by: Confidential Information"]);
worksheet.addRow(["info@encoach.com"]);
// Convert workbook to Buffer (Node.js) or Blob (Browser)
return workbook.xlsx.writeBuffer();
}
function corporateAssignment(
user: CorporateUser,
data: AssignmentData,
users: User[]
) {
return commonExcel({
data,
userName: user.corporateInformation?.companyInformation?.name || "",
users,
sectionName: "Corporate Name :",
customTable: [],
customTableHeaders: [],
renderCustomTableData: () => [],
});
}
async function mastercorporateAssignment(
user: MasterCorporateUser,
data: AssignmentData,
users: User[]
) {
const userGroups = await getStudentGroupsForUsersWithoutAdmin(
user.id,
data.assignees
);
const adminUsers = [...new Set(userGroups.map((g) => g.admin))];
const userGroupsParticipants = userGroups.flatMap((g) => g.participants);
const adminsData = await getSpecificUsers(adminUsers);
const companiesData = adminsData.map((user) => {
const name = getUserName(user);
const users = userGroupsParticipants
.filter((p) => data.assignees.includes(p));
const stats = data.results
.flatMap((r: any) => r.stats)
.filter((s: any) => users.includes(s.user));
const correct = stats.reduce((acc: number, s: any) => acc + s.score.correct, 0);
const total = stats.reduce(
(acc: number, curr: any) => acc + curr.score.total,
0
);
return {
name,
correct,
total,
};
});
const customTable = [
...companiesData,
{
name: "Total",
correct: companiesData.reduce((acc, curr) => acc + curr.correct, 0),
total: companiesData.reduce((acc, curr) => acc + curr.total, 0),
},
].map((c) => [c.name, `${c.correct}/${c.total}`])
const customTableHeaders = [{ name: "Corporate", helper: (data: any) => data.user.corporateName}];
return commonExcel({
data,
userName: user.corporateInformation?.companyInformation?.name || "",
users: users.map((u) => {
const userGroup = userGroups.find((g) => g.participants.includes(u.id));
const admin = adminsData.find((a) => a.id === userGroup?.admin);
return {
...u,
corporateName: getUserName(admin),
}
}),
sectionName: "Master Corporate Name :",
customTable: [['Corporate Summary'], ...customTable],
customTableHeaders: customTableHeaders.map((h) => h.name),
renderCustomTableData: (data) => customTableHeaders.map((h) => h.helper(data)),
});
}
async function post(req: NextApiRequest, res: NextApiResponse) {
// verify if it's a logged user that is trying to export
if (req.session.user) {
const { id } = req.query as { id: string };
const docSnap = await getDoc(doc(db, "assignments", id));
const data = docSnap.data() as AssignmentData;
if (!data) {
res.status(400).end();
return;
}
// if (
// data.excel &&
// data.excel.path &&
// data.excel.version === process.env.EXCEL_VERSION
// ) {
// // if it does, return the excel url
// const fileRef = ref(storage, data.excel.path);
// const url = await getDownloadURL(fileRef);
// res.status(200).end(url);
// return;
// }
const docsSnap = await getDocs(
query(collection(db, "users"), where(documentId(), "in", data.assignees))
);
const users = docsSnap.docs.map((d) => ({
...d.data(),
id: d.id,
})) as User[];
const docUser = await getDoc(doc(db, "users", data.assigner));
if (docUser.exists()) {
// we'll need the user in order to get the user data (name, email, focus, etc);
const user = docUser.data() as User;
// generate the file ref for storage
const fileName = `${Date.now().toString()}.xlsx`;
const refName = `assignment_report/${fileName}`;
const fileRef = ref(storage, refName);
const getExcelFn = () => {
switch (user.type) {
case "teacher":
case "corporate":
return corporateAssignment(user as CorporateUser, data, users);
case "mastercorporate":
return mastercorporateAssignment(user as MasterCorporateUser, data, users);
default:
throw new Error("Invalid user type");
}
};
const buffer = await getExcelFn();
// upload the pdf to storage
const snapshot = await uploadBytes(fileRef, buffer, {
contentType:
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
// update the stats entries with the pdf url to prevent duplication
await updateDoc(docSnap.ref, {
excel: {
path: refName,
version: process.env.EXCEL_VERSION,
},
});
const url = await getDownloadURL(fileRef);
res.status(200).end(url);
return;
}
}
res.status(401).json({ message: "Unauthorized" });
}

View File

@@ -0,0 +1,34 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { getAllAssignersByCorporate } from "@/utils/groups.be";
import { getAssignmentsByAssigners } from "@/utils/assignments.be";
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 await GET(req, res);
res.status(404).json({ ok: false });
}
async function GET(req: NextApiRequest, res: NextApiResponse) {
const { ids } = req.query as { ids: string };
try {
const idsList = ids.split(",");
const assigners = await Promise.all(idsList.map(getAllAssignersByCorporate));
const assignmentList = [...assigners.flat(), ...idsList];
const assignments = await getAssignmentsByAssigners(assignmentList);
res.status(200).json(assignments);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
}

View File

@@ -1,118 +1,68 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import { app } from "@/firebase"; import {app} from "@/firebase";
import { import {getFirestore, collection, getDocs, setDoc, doc, query, where} from "firebase/firestore";
getFirestore, import {withIronSessionApiRoute} from "iron-session/next";
collection, import {sessionOptions} from "@/lib/session";
getDocs, import {Group} from "@/interfaces/user";
setDoc, import {v4} from "uuid";
doc, import {updateExpiryDateOnGroup, getGroupsForUser} from "@/utils/groups.be";
query, import {uniqBy} from "lodash";
where,
} from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { Group } from "@/interfaces/user";
import { v4 } from "uuid";
import { updateExpiryDateOnGroup } from "@/utils/groups.be";
const db = getFirestore(app); const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res.status(401).json({ ok: false }); res.status(401).json({ok: false});
return; return;
} }
if (req.method === "GET") await get(req, res); if (req.method === "GET") await get(req, res);
if (req.method === "POST") await post(req, res); if (req.method === "POST") await post(req, res);
} }
const getGroupsForUser = async (admin: string, participant: string) => {
try {
const queryConstraints = [
...(admin ? [where("admin", "==", admin)] : []),
...(participant
? [where("participants", "array-contains", participant)]
: []),
];
const snapshot = await getDocs(
queryConstraints.length > 0
? query(collection(db, "groups"), ...queryConstraints)
: collection(db, "groups")
);
const groups = snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})) as Group[];
return groups;
} catch (e) {
console.error(e);
return [];
}
};
async function get(req: NextApiRequest, res: NextApiResponse) { async function get(req: NextApiRequest, res: NextApiResponse) {
const { admin, participant } = req.query as { const {admin, participant} = req.query as {
admin: string; admin: string;
participant: string; participant: string;
}; };
if (req.session?.user?.type === "mastercorporate") { if (req.session?.user?.type === "mastercorporate") {
try { try {
const masterCorporateGroups = await getGroupsForUser(admin, participant); const masterCorporateGroups = await getGroupsForUser(admin, participant);
const corporatesFromMaster = masterCorporateGroups const corporatesFromMaster = masterCorporateGroups.filter((g) => g.name.trim() === "Corporate").flatMap((g) => g.participants);
.filter((g) => g.name === "Corporate")
.flatMap((g) => g.participants);
if (corporatesFromMaster.length === 0) { if (corporatesFromMaster.length === 0) return res.status(200).json(masterCorporateGroups);
res.status(200).json([]);
return;
}
Promise.all(
corporatesFromMaster.map((c) => getGroupsForUser(c, participant))
)
.then((groups) => {
res.status(200).json([...masterCorporateGroups, ...groups.flat()]);
return;
})
.catch((e) => {
console.error(e);
res.status(500).json({ ok: false });
return;
});
} catch (e) {
console.error(e);
res.status(500).json({ ok: false });
return;
}
return;
}
try { const groups = await Promise.all(corporatesFromMaster.map((c) => getGroupsForUser(c, participant)));
const groups = await getGroupsForUser(admin, participant); return res.status(200).json([...masterCorporateGroups, ...uniqBy(groups.flat(), "id")]);
res.status(200).json(groups); } catch (e) {
} catch (e) { console.error(e);
console.error(e); res.status(500).json({ok: false});
res.status(500).json({ ok: false }); return;
} }
}
try {
const groups = await getGroupsForUser(admin, participant);
res.status(200).json(groups);
} catch (e) {
console.error(e);
res.status(500).json({ok: false});
}
} }
async function post(req: NextApiRequest, res: NextApiResponse) { async function post(req: NextApiRequest, res: NextApiResponse) {
const body = req.body as Group; const body = req.body as Group;
await Promise.all( await Promise.all(body.participants.map(async (p) => await updateExpiryDateOnGroup(p, body.admin)));
body.participants.map(
async (p) => await updateExpiryDateOnGroup(p, body.admin)
)
);
await setDoc(doc(db, "groups", v4()), { await setDoc(doc(db, "groups", v4()), {
name: body.name, name: body.name,
admin: body.admin, admin: body.admin,
participants: body.participants, participants: body.participants,
}); });
res.status(200).json({ ok: true }); res.status(200).json({ok: true});
} }

View File

@@ -1,19 +1,19 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Head from "next/head"; import Head from "next/head";
import { withIronSessionSsr } from "iron-session/next"; import {withIronSessionSsr} from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import useUser from "@/hooks/useUser"; import useUser from "@/hooks/useUser";
import { toast, ToastContainer } from "react-toastify"; import {toast, ToastContainer} from "react-toastify";
import Layout from "@/components/High/Layout"; import Layout from "@/components/High/Layout";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import {shouldRedirectHome} from "@/utils/navigation.disabled";
import { useState } from "react"; import {useState} from "react";
import { Module } from "@/interfaces"; import {Module} from "@/interfaces";
import { RadioGroup, Tab } from "@headlessui/react"; import {RadioGroup, Tab} from "@headlessui/react";
import clsx from "clsx"; import clsx from "clsx";
import { MODULE_ARRAY } from "@/utils/moduleUtils"; import {MODULE_ARRAY} from "@/utils/moduleUtils";
import { capitalize } from "lodash"; import {capitalize} from "lodash";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import { Exercise, ReadingPart } from "@/interfaces/exam"; import {Exercise, ReadingPart} from "@/interfaces/exam";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import axios from "axios"; import axios from "axios";
import ReadingGeneration from "./(generation)/ReadingGeneration"; import ReadingGeneration from "./(generation)/ReadingGeneration";
@@ -21,121 +21,114 @@ import ListeningGeneration from "./(generation)/ListeningGeneration";
import WritingGeneration from "./(generation)/WritingGeneration"; import WritingGeneration from "./(generation)/WritingGeneration";
import LevelGeneration from "./(generation)/LevelGeneration"; import LevelGeneration from "./(generation)/LevelGeneration";
import SpeakingGeneration from "./(generation)/SpeakingGeneration"; import SpeakingGeneration from "./(generation)/SpeakingGeneration";
import { checkAccess, getTypesOfUser } from "@/utils/permissions"; import {checkAccess} from "@/utils/permissions";
export const getServerSideProps = withIronSessionSsr(({ req, res }) => { export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user; const user = req.session.user;
if (!user || !user.isVerified) { if (!user || !user.isVerified) {
return { return {
redirect: { redirect: {
destination: "/login", destination: "/login",
permanent: false, permanent: false,
}, },
}; };
} }
if ( if (shouldRedirectHome(user) || !checkAccess(user, ["admin", "mastercorporate", "developer", "corporate"])) {
shouldRedirectHome(user) || return {
checkAccess(user, getTypesOfUser(["developer"])) redirect: {
) { destination: "/",
return { permanent: false,
redirect: { },
destination: "/", };
permanent: false, }
},
};
}
return { return {
props: { user: req.session.user }, props: {user: req.session.user},
}; };
}, sessionOptions); }, sessionOptions);
export default function Generation() { export default function Generation() {
const [module, setModule] = useState<Module>("reading"); const [module, setModule] = useState<Module>("reading");
const { user } = useUser({ redirectTo: "/login" }); const {user} = useUser({redirectTo: "/login"});
const [title, setTitle] = useState<string>(""); const [title, setTitle] = useState<string>("");
return ( return (
<> <>
<Head> <Head>
<title>Exam Generation | EnCoach</title> <title>Exam Generation | EnCoach</title>
<meta <meta
name="description" name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/> />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
{user && ( {user && (
<Layout user={user} className="gap-6"> <Layout user={user} className="gap-6">
<h1 className="text-2xl font-semibold">Exam Generation</h1> <h1 className="text-2xl font-semibold">Exam Generation</h1>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Input <Input
type="text" type="text"
placeholder="Insert a title here" placeholder="Insert a title here"
name="title" name="title"
label="Title" label="Title"
onChange={setTitle} onChange={setTitle}
roundness="xl" roundness="xl"
defaultValue={title} defaultValue={title}
required required
/> />
<label className="font-normal text-base text-mti-gray-dim"> <label className="font-normal text-base text-mti-gray-dim">Module</label>
Module <RadioGroup
</label> value={module}
<RadioGroup onChange={setModule}
value={module} className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between">
onChange={setModule} {[...MODULE_ARRAY].map((x) => (
className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between" <RadioGroup.Option value={x} key={x}>
> {({checked}) => (
{[...MODULE_ARRAY].map((x) => ( <span
<RadioGroup.Option value={x} key={x}> className={clsx(
{({ checked }) => ( "px-6 py-4 w-64 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
<span "transition duration-300 ease-in-out",
className={clsx( x === "reading" &&
"px-6 py-4 w-64 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer", (!checked
"transition duration-300 ease-in-out", ? "bg-white border-mti-gray-platinum"
x === "reading" && : "bg-ielts-reading/70 border-ielts-reading text-white"),
(!checked x === "listening" &&
? "bg-white border-mti-gray-platinum" (!checked
: "bg-ielts-reading/70 border-ielts-reading text-white"), ? "bg-white border-mti-gray-platinum"
x === "listening" && : "bg-ielts-listening/70 border-ielts-listening text-white"),
(!checked x === "writing" &&
? "bg-white border-mti-gray-platinum" (!checked
: "bg-ielts-listening/70 border-ielts-listening text-white"), ? "bg-white border-mti-gray-platinum"
x === "writing" && : "bg-ielts-writing/70 border-ielts-writing text-white"),
(!checked x === "speaking" &&
? "bg-white border-mti-gray-platinum" (!checked
: "bg-ielts-writing/70 border-ielts-writing text-white"), ? "bg-white border-mti-gray-platinum"
x === "speaking" && : "bg-ielts-speaking/70 border-ielts-speaking text-white"),
(!checked x === "level" &&
? "bg-white border-mti-gray-platinum" (!checked
: "bg-ielts-speaking/70 border-ielts-speaking text-white"), ? "bg-white border-mti-gray-platinum"
x === "level" && : "bg-ielts-level/70 border-ielts-level text-white"),
(!checked )}>
? "bg-white border-mti-gray-platinum" {capitalize(x)}
: "bg-ielts-level/70 border-ielts-level text-white") </span>
)} )}
> </RadioGroup.Option>
{capitalize(x)} ))}
</span> </RadioGroup>
)} </div>
</RadioGroup.Option> {module === "reading" && <ReadingGeneration id={title} />}
))} {module === "listening" && <ListeningGeneration id={title} />}
</RadioGroup> {module === "writing" && <WritingGeneration id={title} />}
</div> {module === "speaking" && <SpeakingGeneration id={title} />}
{module === "reading" && <ReadingGeneration id={title} />} {module === "level" && <LevelGeneration id={title} />}
{module === "listening" && <ListeningGeneration id={title} />} </Layout>
{module === "writing" && <WritingGeneration id={title} />} )}
{module === "speaking" && <SpeakingGeneration id={title} />} </>
{module === "level" && <LevelGeneration id={title} />} );
</Layout>
)}
</>
);
} }

View File

@@ -1,209 +1,209 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Head from "next/head"; import Head from "next/head";
import { useState } from "react"; import {useEffect, useState} from "react";
import { withIronSessionSsr } from "iron-session/next"; import {withIronSessionSsr} from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import {shouldRedirectHome} from "@/utils/navigation.disabled";
import { Permission, PermissionType } from "@/interfaces/permissions"; import {Permission, PermissionType} from "@/interfaces/permissions";
import { getPermissionDoc } from "@/utils/permissions.be"; import {getPermissionDoc} from "@/utils/permissions.be";
import { User } from "@/interfaces/user"; import {User} from "@/interfaces/user";
import Layout from "@/components/High/Layout"; import Layout from "@/components/High/Layout";
import { getUsers } from "@/utils/users.be"; import {getUsers} from "@/utils/users.be";
import { BsTrash } from "react-icons/bs"; import {BsTrash} from "react-icons/bs";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import axios from "axios"; import axios from "axios";
import { toast, ToastContainer } from "react-toastify"; import {toast, ToastContainer} from "react-toastify";
import {Type as UserType} from '@/interfaces/user' import {Type as UserType} from "@/interfaces/user";
import {getGroups} from "@/utils/groups.be";
interface BasicUser { interface BasicUser {
id: string; id: string;
name: string; name: string;
type: UserType type: UserType;
} }
interface PermissionWithBasicUsers { interface PermissionWithBasicUsers {
id: string; id: string;
type: PermissionType; type: PermissionType;
users: BasicUser[]; users: BasicUser[];
} }
export const getServerSideProps = withIronSessionSsr(async (context) => { export const getServerSideProps = withIronSessionSsr(async (context) => {
const { req, params } = context; const {req, params} = context;
const user = req.session.user; const user = req.session.user;
if (!user || !user.isVerified) { if (!user || !user.isVerified) {
return { return {
redirect: { redirect: {
destination: "/login", destination: "/login",
permanent: false, permanent: false,
}, },
}; };
} }
if (shouldRedirectHome(user)) { if (shouldRedirectHome(user)) {
return { return {
redirect: { redirect: {
destination: "/", destination: "/",
permanent: false, permanent: false,
}, },
}; };
} }
if (!params?.id) { if (!params?.id) {
return { return {
redirect: { redirect: {
destination: "/permissions", destination: "/permissions",
permanent: false, permanent: false,
}, },
}; };
} }
// Fetch data from external API // Fetch data from external API
const permission: Permission = await getPermissionDoc(params.id as string); const permission: Permission = await getPermissionDoc(params.id as string);
const allUserData: User[] = await getUsers(); const allUserData: User[] = await getUsers();
const groups = await getGroups();
const users = allUserData.map((u) => ({
id: u.id,
name: u.name,
type: u.type
})) as BasicUser[];
// const res = await fetch("api/permissions");
// const permissions: Permission[] = await res.json();
// Pass data to the page via props
const usersData: BasicUser[] = permission.users.reduce(
(acc: BasicUser[], userId) => {
const user = users.find((u) => u.id === userId) as BasicUser;
if (user) {
acc.push(user);
}
return acc;
},
[]
);
return { const userGroups = groups.filter((x) => x.admin === user.id);
props: { const filteredGroups =
// permissions: permissions.map((p) => ({ id: p.id, type: p.type })), user.type === "corporate"
permission: { ? userGroups
...permission, : user.type === "mastercorporate"
id: params.id, ? groups.filter((x) => userGroups.flatMap((y) => y.participants).includes(x.admin))
users: usersData, : groups;
},
user: req.session.user, const users = allUserData.map((u) => ({
users, id: u.id,
}, name: u.name,
}; type: u.type,
})) as BasicUser[];
const filteredUsers = ["mastercorporate", "corporate"].includes(user.type)
? users.filter((u) => filteredGroups.flatMap((g) => g.participants).includes(u.id))
: users;
// const res = await fetch("api/permissions");
// const permissions: Permission[] = await res.json();
// Pass data to the page via props
const usersData: BasicUser[] = permission.users.reduce((acc: BasicUser[], userId) => {
const user = filteredUsers.find((u) => u.id === userId) as BasicUser;
if (!!user) acc.push(user);
return acc;
}, []);
return {
props: {
// permissions: permissions.map((p) => ({ id: p.id, type: p.type })),
permission: {
...permission,
id: params.id,
users: usersData,
},
user: req.session.user,
users: filteredUsers,
},
};
}, sessionOptions); }, sessionOptions);
interface Props { interface Props {
permission: PermissionWithBasicUsers; permission: PermissionWithBasicUsers;
user: User; user: User;
users: BasicUser[]; users: BasicUser[];
} }
export default function Page(props: Props) { export default function Page(props: Props) {
const { permission, user, users } = props; const {permission, user, users} = props;
const [selectedUsers, setSelectedUsers] = useState<string[]>(() =>
permission.users.map((u) => u.id)
);
const onChange = (value: any) => { const [selectedUsers, setSelectedUsers] = useState<string[]>(() => permission.users.map((u) => u.id));
setSelectedUsers((prev) => {
if (value?.value) {
return [...prev, value?.value];
}
return prev;
});
};
const removeUser = (id: string) => {
setSelectedUsers((prev) => prev.filter((u) => u !== id));
};
const update = async () => { const onChange = (value: any) => {
setSelectedUsers((prev) => {
try { if (value?.value) {
await axios.patch(`/api/permissions/${permission.id}`, { return [...prev, value?.value];
users: selectedUsers, }
}); return prev;
toast.success("Permission updated"); });
} catch (err) { };
toast.error("Failed to update permission"); const removeUser = (id: string) => {
} setSelectedUsers((prev) => prev.filter((u) => u !== id));
}; };
return ( const update = async () => {
<> try {
<Head> await axios.patch(`/api/permissions/${permission.id}`, {
<title>EnCoach</title> users: selectedUsers,
<meta });
name="description" toast.success("Permission updated");
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." } catch (err) {
/> toast.error("Failed to update permission");
<meta name="viewport" content="width=device-width, initial-scale=1" /> }
<link rel="icon" href="/favicon.ico" /> };
</Head>
<ToastContainer /> return (
<Layout user={user} className="gap-6"> <>
<h1 className="text-2xl font-semibold"> <Head>
Permission: {permission.type as string} <title>EnCoach</title>
</h1> <meta
<div className="flex gap-3"> name="description"
<Select content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
value={null} />
options={users <meta name="viewport" content="width=device-width, initial-scale=1" />
.filter((u) => !selectedUsers.includes(u.id)) <link rel="icon" href="/favicon.ico" />
.map((u) => ({ </Head>
label: `${u?.type}-${u?.name}`, <ToastContainer />
value: u.id, <Layout user={user} className="gap-6">
}))} <div className="flex flex-col gap-6 w-full h-[88vh] overflow-y-scroll scrollbar-hide rounded-xl">
onChange={onChange} <h1 className="text-2xl font-semibold">Permission: {permission.type as string}</h1>
/> <div className="flex gap-3">
<Button onClick={update}>Update</Button> <Select
</div> value={null}
<div className="flex flex-row justify-between"> options={users
<div className="flex flex-col gap-3"> .filter((u) => !selectedUsers.includes(u.id))
<h2>Blacklisted Users</h2> .map((u) => ({
<div className="flex gap-3 flex-wrap"> label: `${u?.type}-${u?.name}`,
{selectedUsers.map((userId) => { value: u.id,
const user = users.find((u) => u.id === userId); }))}
return ( onChange={onChange}
<div />
className="flex p-4 rounded-xl w-auto bg-mti-purple-light text-white gap-4" <Button onClick={update}>Update</Button>
key={userId} </div>
> <div className="flex flex-row justify-between">
<span className="text-base first-letter:uppercase">{user?.type}-{user?.name}</span> <div className="flex flex-col gap-3">
<BsTrash <h2>Blacklisted Users</h2>
style={{ cursor: "pointer" }} <div className="flex gap-3 flex-wrap">
onClick={() => removeUser(userId)} {selectedUsers.map((userId) => {
size={20} const user = users.find((u) => u.id === userId);
/> return (
</div> <div className="flex p-4 rounded-xl w-auto bg-mti-purple-light text-white gap-4" key={userId}>
); <span className="text-base first-letter:uppercase">
})} {user?.type}-{user?.name}
</div> </span>
</div> <BsTrash style={{cursor: "pointer"}} onClick={() => removeUser(userId)} size={20} />
<div className="flex flex-col gap-3"> </div>
<h2>Whitelisted Users</h2> );
<div className="flex flex-col gap-3 flex-wrap"> })}
{users.filter(user => !selectedUsers.includes(user.id)).map((user) => { </div>
return ( </div>
<div <div className="flex flex-col gap-3">
className="flex p-4 rounded-xl w-auto bg-mti-purple-light text-white gap-4" <h2>Whitelisted Users</h2>
key={user.id} <div className="flex flex-col gap-3 flex-wrap">
> {users
<span className="text-base first-letter:uppercase">{user?.type}-{user?.name}</span> .filter((user) => !selectedUsers.includes(user.id))
</div> .map((user) => {
); return (
})} <div className="flex p-4 rounded-xl w-auto bg-mti-purple-light text-white gap-4" key={user.id}>
</div> <span className="text-base first-letter:uppercase">
</div> {user?.type}-{user?.name}
</div> </span>
</Layout> </div>
</> );
); })}
</div>
</div>
</div>
</div>
</Layout>
</>
);
} }

View File

@@ -1,78 +1,86 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Head from "next/head"; import Head from "next/head";
import { withIronSessionSsr } from "iron-session/next"; import {withIronSessionSsr} from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import {shouldRedirectHome} from "@/utils/navigation.disabled";
import { Permission } from "@/interfaces/permissions"; import {Permission} from "@/interfaces/permissions";
import { getPermissionDocs } from "@/utils/permissions.be"; import {getPermissionDocs} from "@/utils/permissions.be";
import { User } from "@/interfaces/user"; import {User} from "@/interfaces/user";
import Layout from "@/components/High/Layout"; import Layout from "@/components/High/Layout";
import PermissionList from '@/components/PermissionList' import PermissionList from "@/components/PermissionList";
export const getServerSideProps = withIronSessionSsr(async ({ req }) => { export const getServerSideProps = withIronSessionSsr(async ({req}) => {
const user = req.session.user; const user = req.session.user;
if (!user || !user.isVerified) { if (!user || !user.isVerified) {
return { return {
redirect: { redirect: {
destination: "/login", destination: "/login",
permanent: false, permanent: false,
}, },
}; };
} }
if (shouldRedirectHome(user)) { if (shouldRedirectHome(user)) {
return { return {
redirect: { redirect: {
destination: "/", destination: "/",
permanent: false, permanent: false,
}, },
}; };
} }
// Fetch data from external API // Fetch data from external API
const permissions: Permission[] = await getPermissionDocs(); const permissions: Permission[] = await getPermissionDocs();
const filteredPermissions = permissions.filter((p) => {
const permissionType = p.type.toString().toLowerCase();
if (user.type === "corporate") return !permissionType.includes("corporate") && !permissionType.includes("admin");
if (user.type === "mastercorporate") return !permissionType.includes("mastercorporate") && !permissionType.includes("admin");
// const res = await fetch("api/permissions"); return true;
// const permissions: Permission[] = await res.json(); });
// Pass data to the page via props
return { // const res = await fetch("api/permissions");
props: { // const permissions: Permission[] = await res.json();
// permissions: permissions.map((p) => ({ id: p.id, type: p.type })), // Pass data to the page via props
permissions: permissions.map((p) => { return {
const { users, ...rest } = p; props: {
return rest; // permissions: permissions.map((p) => ({ id: p.id, type: p.type })),
}), permissions: filteredPermissions.map((p) => {
user: req.session.user, const {users, ...rest} = p;
}, return rest;
}; }),
user: req.session.user,
},
};
}, sessionOptions); }, sessionOptions);
interface Props { interface Props {
permissions: Permission[]; permissions: Permission[];
user: User; user: User;
} }
export default function Page(props: Props) { export default function Page(props: Props) {
const { permissions, user } = props; const {permissions, user} = props;
return (
<> return (
<Head> <>
<title>EnCoach</title> <Head>
<meta <title>EnCoach</title>
name="description" <meta
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." name="description"
/> content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
<meta name="viewport" content="width=device-width, initial-scale=1" /> />
<link rel="icon" href="/favicon.ico" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
</Head> <link rel="icon" href="/favicon.ico" />
<Layout user={user} className="gap-6"> </Head>
<h1 className="text-2xl font-semibold">Permissions</h1> <Layout user={user} className="gap-6">
<div className="flex gap-3 flex-wrap"> <h1 className="text-2xl font-semibold">Permissions</h1>
<PermissionList permissions={permissions} /> <div className="flex gap-3 flex-wrap overflow-y-scroll scrollbar-hide h-[80vh] rounded-xl">
</div> <PermissionList permissions={permissions} />
</Layout> </div>
</> </Layout>
); </>
);
} }

View File

@@ -1,357 +1,371 @@
import { useEffect, useState } from 'react'; import {useEffect, useState} from "react";
import { useRouter } from 'next/router'; import {useRouter} from "next/router";
import axios from 'axios'; import axios from "axios";
import { Tab } from "@headlessui/react"; import {Tab} from "@headlessui/react";
import { AiOutlineFileSearch } from "react-icons/ai"; import {AiOutlineFileSearch} from "react-icons/ai";
import { MdOutlinePlaylistAddCheckCircle, MdOutlineSelfImprovement } from "react-icons/md"; import {MdOutlinePlaylistAddCheckCircle, MdOutlineSelfImprovement} from "react-icons/md";
import { BsChatLeftDots } from "react-icons/bs"; import {BsChatLeftDots} from "react-icons/bs";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import clsx from "clsx"; import clsx from "clsx";
import Exercise from "@/training/Exercise"; import Exercise from "@/training/Exercise";
import TrainingScore from "@/training/TrainingScore"; import TrainingScore from "@/training/TrainingScore";
import { ITrainingContent, ITrainingTip } from "@/training/TrainingInterfaces"; import {ITrainingContent, ITrainingTip} from "@/training/TrainingInterfaces";
import { Stat, User } from '@/interfaces/user'; import {Stat, User} from "@/interfaces/user";
import Head from "next/head"; import Head from "next/head";
import Layout from "@/components/High/Layout"; import Layout from "@/components/High/Layout";
import { ToastContainer } from 'react-toastify'; import {ToastContainer} from "react-toastify";
import { withIronSessionSsr } from "iron-session/next"; import {withIronSessionSsr} from "iron-session/next";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import {shouldRedirectHome} from "@/utils/navigation.disabled";
import { sessionOptions } from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import qs from 'qs'; import qs from "qs";
import StatsGridItem from '@/components/StatGridItem'; import StatsGridItem from "@/components/StatGridItem";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import { usePDFDownload } from "@/hooks/usePDFDownload"; 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 InfiniteCarousel from "@/components/InfiniteCarousel";
import { LuExternalLink } from "react-icons/lu"; import {LuExternalLink} from "react-icons/lu";
import { uniqBy } from 'lodash'; import {uniqBy} from "lodash";
import { getExamById } from '@/utils/exams'; import {getExamById} from "@/utils/exams";
import { convertToUserSolutions } from '@/utils/stats'; import {convertToUserSolutions} from "@/utils/stats";
import { sortByModule } from '@/utils/moduleUtils'; import {sortByModule} from "@/utils/moduleUtils";
export const getServerSideProps = withIronSessionSsr(({ req, res }) => { export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user; const user = req.session.user;
if (!user || !user.isVerified) { if (!user || !user.isVerified) {
return { return {
redirect: { redirect: {
destination: "/login", destination: "/login",
permanent: false, permanent: false,
}, },
}; };
} }
if (shouldRedirectHome(user)) { if (shouldRedirectHome(user)) {
return { return {
redirect: { redirect: {
destination: "/", destination: "/",
permanent: false, permanent: false,
}, },
}; };
} }
return { return {
props: { user: req.session.user }, props: {user: req.session.user},
}; };
}, sessionOptions); }, sessionOptions);
const TrainingContent: React.FC<{ user: User }> = ({ user }) => { const TrainingContent: React.FC<{user: User}> = ({user}) => {
// Record stuff // Record stuff
const setExams = useExamStore((state) => state.setExams); const setExams = useExamStore((state) => state.setExams);
const setShowSolutions = useExamStore((state) => state.setShowSolutions); const setShowSolutions = useExamStore((state) => state.setShowSolutions);
const setUserSolutions = useExamStore((state) => state.setUserSolutions); const setUserSolutions = useExamStore((state) => state.setUserSolutions);
const setSelectedModules = useExamStore((state) => state.setSelectedModules); const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const setInactivity = useExamStore((state) => state.setInactivity); const setInactivity = useExamStore((state) => state.setInactivity);
const setTimeSpent = useExamStore((state) => state.setTimeSpent); const setTimeSpent = useExamStore((state) => state.setTimeSpent);
const renderPdfIcon = usePDFDownload("stats"); const renderPdfIcon = usePDFDownload("stats");
const [trainingContent, setTrainingContent] = useState<ITrainingContent | null>(null); const [trainingContent, setTrainingContent] = useState<ITrainingContent | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [trainingTips, setTrainingTips] = useState<ITrainingTip[]>([]); const [trainingTips, setTrainingTips] = useState<ITrainingTip[]>([]);
const [currentTipIndex, setCurrentTipIndex] = useState(0); const [currentTipIndex, setCurrentTipIndex] = useState(0);
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;
try { try {
setLoading(true); setLoading(true);
const response = await axios.get<ITrainingContent>(`/api/training/${id}`); const response = await axios.get<ITrainingContent>(`/api/training/${id}`);
const trainingContent = response.data; const trainingContent = response.data;
const withExamsStats = { const withExamsStats = {
...trainingContent, ...trainingContent,
exams: await Promise.all(trainingContent.exams.map(async (exam) => { exams: await Promise.all(
const stats = await Promise.all(exam.stat_ids.map(async (statId) => { trainingContent.exams.map(async (exam) => {
const statResponse = await axios.get<Stat>(`/api/stats/${statId}`); const stats = await Promise.all(
return statResponse.data; exam.stat_ids.map(async (statId) => {
})); const statResponse = await axios.get<Stat>(`/api/stats/${statId}`);
return { ...exam, stats }; return statResponse.data;
})) }),
}; );
return {...exam, stats};
}),
),
};
const tips = await axios.get<ITrainingTip[]>('/api/training/walkthrough', { const tips = await axios.get<ITrainingTip[]>("/api/training/walkthrough", {
params: { ids: trainingContent.tip_ids }, params: {ids: trainingContent.tip_ids},
paramsSerializer: params => qs.stringify(params, { arrayFormat: 'repeat' }) paramsSerializer: (params) => qs.stringify(params, {arrayFormat: "repeat"}),
}); });
setTrainingTips(tips.data); setTrainingTips(tips.data);
setTrainingContent(withExamsStats); setTrainingContent(withExamsStats);
} catch (error) { } catch (error) {
router.push('/training'); router.push("/training");
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
fetchTrainingContent(); fetchTrainingContent();
}, [id]); }, [id, router]);
const handleNext = () => { const handleNext = () => {
setCurrentTipIndex((prevIndex) => (prevIndex + 1)); setCurrentTipIndex((prevIndex) => prevIndex + 1);
}; };
const handlePrevious = () => { const handlePrevious = () => {
setCurrentTipIndex((prevIndex) => (prevIndex - 1)); setCurrentTipIndex((prevIndex) => prevIndex - 1);
}; };
const goToExam = (examNumber: number) => { const goToExam = (examNumber: number) => {
const stats = trainingContent?.exams[examNumber].stats!; const stats = trainingContent?.exams[examNumber].stats!;
const examPromises = uniqBy(stats, "exam").map((stat) => { const examPromises = uniqBy(stats, "exam").map((stat) => {
return getExamById(stat.module, stat.exam); return getExamById(stat.module, stat.exam);
}); });
const { timeSpent, inactivity } = stats[0]; const {timeSpent, inactivity} = stats[0];
Promise.all(examPromises).then((exams) => { Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) { if (exams.every((x) => !!x)) {
if (!!timeSpent) setTimeSpent(timeSpent); if (!!timeSpent) setTimeSpent(timeSpent);
if (!!inactivity) setInactivity(inactivity); if (!!inactivity) setInactivity(inactivity);
setUserSolutions(convertToUserSolutions(stats)); setUserSolutions(convertToUserSolutions(stats));
setShowSolutions(true); setShowSolutions(true);
setExams(exams.map((x) => x!).sort(sortByModule)); setExams(exams.map((x) => x!).sort(sortByModule));
setSelectedModules( setSelectedModules(
exams exams
.map((x) => x!) .map((x) => x!)
.sort(sortByModule) .sort(sortByModule)
.map((x) => x!.module), .map((x) => x!.module),
); );
router.push("/exercises"); router.push("/exercises");
} }
}); });
} };
return ( return (
<> <>
<Head> <Head>
<title>Training | EnCoach</title> <title>Training | EnCoach</title>
<meta <meta
name="description" name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/> />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<Layout user={user}> <Layout user={user}>
{loading ? ( {loading ? (
<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"> <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" /> <span className="loading loading-infinity w-32 bg-mti-green-light" />
</div> </div>
) : (trainingContent && ( ) : (
<div className="flex flex-col gap-8"> trainingContent && (
<div className="flex flex-row items-center"> <div className="flex flex-col gap-8">
<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> <div className="flex flex-row items-center">
<span>Exams Selected</span> <span className="bg-gray-200 text-gray-800 px-3 py-0.5 rounded-full font-semibold text-lg mr-2">
</div> {trainingContent.exams.length}
<div className='h-[15vh] mb-4'> </span>
<InfiniteCarousel height="150px" <span>Exams Selected</span>
overlay={ </div>
<LuExternalLink size={20} /> <div className="h-[15vh] mb-4">
} <InfiniteCarousel
overlayFunc={goToExam} height="150px"
overlayClassName='bottom-6 right-5 cursor-pointer' overlay={<LuExternalLink size={20} />}
> overlayFunc={goToExam}
{trainingContent.exams.map((exam, examIndex) => ( overlayClassName="bottom-6 right-5 cursor-pointer">
<StatsGridItem {trainingContent.exams.map((exam, examIndex) => (
key={`exam-${examIndex}`} <StatsGridItem
width='380px' key={`exam-${examIndex}`}
height='150px' width="380px"
examNumber={examIndex + 1} height="150px"
stats={exam.stats || []} examNumber={examIndex + 1}
timestamp={exam.date} stats={exam.stats || []}
user={user} timestamp={exam.date}
assignments={assignments} user={user}
users={users} assignments={assignments}
setExams={setExams} users={users}
setShowSolutions={setShowSolutions} setExams={setExams}
setUserSolutions={setUserSolutions} setShowSolutions={setShowSolutions}
setSelectedModules={setSelectedModules} setUserSolutions={setUserSolutions}
setInactivity={setInactivity} setSelectedModules={setSelectedModules}
setTimeSpent={setTimeSpent} setInactivity={setInactivity}
renderPdfIcon={renderPdfIcon} setTimeSpent={setTimeSpent}
/> renderPdfIcon={renderPdfIcon}
))} />
</InfiniteCarousel> ))}
</div> </InfiniteCarousel>
<div className='flex flex-col'> </div>
<div className='flex flex-row gap-10 -md:flex-col h-full'> <div className="flex flex-col">
<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 gap-10 -md:flex-col h-full">
<div className="flex flex-row items-center mb-6 gap-1"> <div className="flex flex-col rounded-3xl p-6 w-1/2 shadow-training-inset -md:w-full max-h-full">
<MdOutlinePlaylistAddCheckCircle color={"#40A1EA"} size={26} /> <div className="flex flex-row items-center mb-6 gap-1">
<h2 className={`text-xl font-semibold text-[#40A1EA]`}>General Evaluation</h2> <MdOutlinePlaylistAddCheckCircle color={"#40A1EA"} size={26} />
</div> <h2 className={`text-xl font-semibold text-[#40A1EA]`}>General Evaluation</h2>
<TrainingScore </div>
trainingContent={trainingContent} <TrainingScore trainingContent={trainingContent} gridView={false} />
gridView={false} <div className="w-full h-px bg-[#D9D9D929] my-6"></div>
/> <div className="flex flex-row gap-2 items-center mb-6">
<div className="w-full h-px bg-[#D9D9D929] my-6"></div> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<div className="flex flex-row gap-2 items-center mb-6"> <mask id="mask0_112_168" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <rect width="24" height="24" fill="#D9D9D9" />
<mask id="mask0_112_168" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"> </mask>
<rect width="24" height="24" fill="#D9D9D9" /> <g mask="url(#mask0_112_168)">
</mask> <path
<g mask="url(#mask0_112_168)"> d="M4 21C3.45 21 2.97917 20.8042 2.5875 20.4125C2.19583 20.0208 2 19.55 2 19V7H4V19H19V21H4ZM8 17C7.45 17 6.97917 16.8042 6.5875 16.4125C6.19583 16.0208 6 15.55 6 15V3H23V15C23 15.55 22.8042 16.0208 22.4125 16.4125C22.0208 16.8042 21.55 17 21 17H8ZM8 15H21V5H8V15ZM10 12H14V7H10V12ZM15 12H19V10H15V12ZM15 9H19V7H15V9Z"
<path d="M4 21C3.45 21 2.97917 20.8042 2.5875 20.4125C2.19583 20.0208 2 19.55 2 19V7H4V19H19V21H4ZM8 17C7.45 17 6.97917 16.8042 6.5875 16.4125C6.19583 16.0208 6 15.55 6 15V3H23V15C23 15.55 22.8042 16.0208 22.4125 16.4125C22.0208 16.8042 21.55 17 21 17H8ZM8 15H21V5H8V15ZM10 12H14V7H10V12ZM15 12H19V10H15V12ZM15 9H19V7H15V9Z" fill="#53B2F9" /> fill="#53B2F9"
</g> />
</svg> </g>
<h3 className="text-xl font-semibold text-[#40A1EA]">Performance Breakdown by Exam:</h3> </svg>
</div> <h3 className="text-xl font-semibold text-[#40A1EA]">Performance Breakdown by Exam:</h3>
<ul className='overflow-auto scrollbar-hide flex-grow'> </div>
{trainingContent.exams.flatMap((exam, index) => ( <ul className="overflow-auto scrollbar-hide flex-grow">
<li key={index} className="flex flex-col mb-2 bg-[#22E1B30F] p-4 rounded-xl border"> {trainingContent.exams.flatMap((exam, index) => (
<div className="flex flex-row font-semibold border-b-2 border-[#D9D9D929] text-[#22E1B3] mb-2"> <li key={index} className="flex flex-col mb-2 bg-[#22E1B30F] p-4 rounded-xl border">
<div className='flex items-center border-r-2 border-[#D9D9D929] pr-2'> <div className="flex flex-row font-semibold border-b-2 border-[#D9D9D929] text-[#22E1B3] mb-2">
<span className='mr-1'>Exam</span> <div className="flex items-center border-r-2 border-[#D9D9D929] pr-2">
<span className="font-semibold bg-gray-200 text-gray-800 px-2 rounded-full text-sm">{index + 1}</span> <span className="mr-1">Exam</span>
</div> <span className="font-semibold bg-gray-200 text-gray-800 px-2 rounded-full text-sm">
<span className="pl-2">{exam.score}%</span> {index + 1}
</div> </span>
<div className="flex flex-row items-center gap-2"> </div>
<BsChatLeftDots size={16} /> <span className="pl-2">{exam.score}%</span>
<p className="text-sm">{exam.performance_comment}</p> </div>
</div> <div className="flex flex-row items-center gap-2">
</li> <BsChatLeftDots size={16} />
))} <p className="text-sm">{exam.performance_comment}</p>
</ul> </div>
</div> </li>
<div className="flex flex-col rounded-3xl p-6 w-1/2 shadow-training-inset -md:w-full"> ))}
<div className='flex flex-col'> </ul>
<div className="flex flex-row items-center mb-4 gap-1"> </div>
<AiOutlineFileSearch color="#40A1EA" size={24} /> <div className="flex flex-col rounded-3xl p-6 w-1/2 shadow-training-inset -md:w-full">
<h3 className="text-xl font-semibold text-[#40A1EA]">Identified Weak Areas</h3> <div className="flex flex-col">
</div> <div className="flex flex-row items-center mb-4 gap-1">
<Tab.Group> <AiOutlineFileSearch color="#40A1EA" size={24} />
<div className="flex flex-col gap-4"> <h3 className="text-xl font-semibold text-[#40A1EA]">Identified Weak Areas</h3>
<Tab.List> </div>
<div className="flex flex-row gap-6 overflow-x-auto pb-1 training-scrollbar"> <Tab.Group>
{trainingContent.weak_areas.map((x, index) => ( <div className="flex flex-col gap-4">
<Tab <Tab.List>
key={index} <div className="flex flex-row gap-6 overflow-x-auto pb-1 training-scrollbar">
className={({ selected }) => {trainingContent.weak_areas.map((x, index) => (
clsx( <Tab
'text-[#53B2F9] pb-2 border-b-2', key={index}
'focus:outline-none', className={({selected}) =>
selected ? 'border-[#1B78BE]' : 'border-[#1B78BE0F]' clsx(
) "text-[#53B2F9] pb-2 border-b-2",
} "focus:outline-none",
> selected ? "border-[#1B78BE]" : "border-[#1B78BE0F]",
{x.area} )
</Tab> }>
))} {x.area}
</div> </Tab>
</Tab.List> ))}
<Tab.Panels> </div>
{trainingContent.weak_areas.map((x, index) => ( </Tab.List>
<Tab.Panel <Tab.Panels>
key={index} {trainingContent.weak_areas.map((x, index) => (
className="p-3 bg-[#FBFBFB] rounded-lg border border-[#0000000F]" <Tab.Panel key={index} className="p-3 bg-[#FBFBFB] rounded-lg border border-[#0000000F]">
> <p>{x.comment}</p>
<p>{x.comment}</p> </Tab.Panel>
</Tab.Panel> ))}
))} </Tab.Panels>
</Tab.Panels> </div>
</div> </Tab.Group>
</Tab.Group> </div>
</div> <div className="w-full h-px bg-[#D9D9D929] my-6"></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"> <MdOutlineSelfImprovement color={"#40A1EA"} size={24} />
<MdOutlineSelfImprovement color={"#40A1EA"} size={24} /> <h2 className={`text-xl font-semibold text-[#40A1EA]`}>Subjects that Need Improvement</h2>
<h2 className={`text-xl font-semibold text-[#40A1EA]`}>Subjects that Need Improvement</h2> </div>
</div>
<div className="flex flex-grow bg-[#FBFBFB] border rounded-xl p-4"> <div className="flex flex-grow bg-[#FBFBFB] border rounded-xl p-4">
<div className='flex flex-col'> <div className="flex flex-col">
<div className="flex flex-row items-center gap-1 mb-4"> <div className="flex flex-row items-center gap-1 mb-4">
<div className="flex items-center justify-center w-[48px] h-[48px]"> <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"> <svg
<mask id="mask0_112_445" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"> width="24"
<rect width="24" height="24" fill="#D9D9D9" /> height="24"
</mask> viewBox="0 0 24 24"
<g mask="url(#mask0_112_445)"> fill="none"
<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" /> xmlns="http://www.w3.org/2000/svg">
</g> <mask id="mask0_112_445" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
</svg> <rect width="24" height="24" fill="#D9D9D9" />
</div> </mask>
<h3 className="text-lg font-semibold">Detailed Breakdown</h3> <g mask="url(#mask0_112_445)">
</div> <path
<ul className="flex flex-col flex-grow space-y-4 pb-2 overflow-y-auto scrollbar-hide"> 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"
{trainingContent.exams.map((exam, index) => ( fill="#1C1B1F"
<li key={index} className="border rounded-lg bg-white"> />
<Dropdown title={ </g>
<div className='flex flex-row items-center'> </svg>
<span className="mr-1">Exam</span> </div>
<span className="font-semibold bg-gray-200 text-gray-800 px-2 rounded-full text-sm mt-0.5">{index + 1}</span> <h3 className="text-lg font-semibold">Detailed Breakdown</h3>
</div> </div>
} open={index == 0}> <ul className="flex flex-col flex-grow space-y-4 pb-2 overflow-y-auto scrollbar-hide">
<span>{exam.detailed_summary}</span> {trainingContent.exams.map((exam, index) => (
</Dropdown> <li key={index} className="border rounded-lg bg-white">
</li> <Dropdown
))} title={
</ul> <div className="flex flex-row items-center">
</div> <span className="mr-1">Exam</span>
</div> <span className="font-semibold bg-gray-200 text-gray-800 px-2 rounded-full text-sm mt-0.5">
</div> {index + 1}
</div> </span>
</div> </div>
<div className="flex -md:hidden"> }
<div className="rounded-3xl p-6 shadow-training-inset w-full"> open={index == 0}>
<div className="flex flex-col p-10"> <span>{exam.detailed_summary}</span>
<Exercise key={currentTipIndex} {...trainingTips[currentTipIndex]} /> </Dropdown>
</div> </li>
<div className="self-end flex justify-between w-full gap-8 bottom-8 left-0 px-8"> ))}
<Button </ul>
color="purple" </div>
variant="outline" </div>
onClick={handlePrevious} </div>
disabled={currentTipIndex == 0} </div>
className="max-w-[200px] self-end w-full"> </div>
Previous <div className="flex -md:hidden">
</Button> <div className="rounded-3xl p-6 shadow-training-inset w-full">
<Button <div className="flex flex-col p-10">
color="purple" <Exercise key={currentTipIndex} {...trainingTips[currentTipIndex]} />
disabled={currentTipIndex == (trainingTips.length - 1)} </div>
onClick={handleNext} <div className="self-end flex justify-between w-full gap-8 bottom-8 left-0 px-8">
className="max-w-[200px] self-end w-full"> <Button
Next color="purple"
</Button> variant="outline"
</div> onClick={handlePrevious}
</div> disabled={currentTipIndex == 0}
className="max-w-[200px] self-end w-full">
</div> Previous
</div> </Button>
))} <Button
</Layout> color="purple"
</> disabled={currentTipIndex == trainingTips.length - 1}
); onClick={handleNext}
} className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
</div>
</div>
</div>
)
)}
</Layout>
</>
);
};
export default TrainingContent; export default TrainingContent;

View File

@@ -84,6 +84,7 @@ const Training: React.FC<{user: User}> = ({user}) => {
return () => { return () => {
router.events.off("routeChangeStart", handleRouteChange); router.events.off("routeChangeStart", handleRouteChange);
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router.events, setTrainingStats]); }, [router.events, setTrainingStats]);
useEffect(() => { useEffect(() => {
@@ -104,6 +105,7 @@ const Training: React.FC<{user: User}> = ({user}) => {
} }
}); });
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isNewContentLoading]); }, [isNewContentLoading]);
useEffect(() => { useEffect(() => {

View File

@@ -1,4 +1,90 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer utilities {
.scrollbar-hide {
-ms-overflow-style: none;
/* IE and Edge */
scrollbar-width: none;
/* Firefox */
}
.scrollbar-hide::-webkit-scrollbar {
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 {
--max-width: 1100px;
--border-radius: 12px;
--font-mono: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro",
"Fira Mono", "Droid Sans Mono", "Courier New", monospace;
--foreground-rgb: 53, 51, 56;
--background-start-rgb: 245, 245, 245;
--background-end-rgb: 245, 245, 245;
--primary-glow: conic-gradient(from 180deg at 50% 50%, #16abff33 0deg, #0885ff33 55deg, #54d6ff33 120deg, #0071ff33 160deg, transparent 360deg);
--secondary-glow: radial-gradient(rgba(255, 255, 255, 1), rgba(255, 255, 255, 0));
--tile-start-rgb: 239, 245, 249;
--tile-end-rgb: 228, 232, 233;
--tile-border: conic-gradient(#00000080, #00000040, #00000030, #00000020, #00000010, #00000010, #00000080);
--callout-rgb: 238, 240, 241;
--callout-border-rgb: 172, 175, 176;
--card-rgb: 180, 185, 188;
--card-border-rgb: 131, 134, 135;
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
html {
min-height: 100vh !important;
height: 100%;
max-width: 100vw;
overflow-x: hidden;
overflow-y: auto;
font-family: "Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif;
}
body {
min-height: 100vh !important;
height: 100%;
max-width: 100vw;
overflow-x: hidden;
font-family: "Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif;
}
body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(to bottom, transparent, rgb(var(--background-end-rgb))) rgb(var(--background-start-rgb));
}
a {
color: inherit;
text-decoration: none;
}

View File

@@ -9,6 +9,11 @@ export const getAssignmentsByAssigner = async (id: string) => {
return docs.map((x) => ({...x.data(), id: x.id})) as Assignment[]; return docs.map((x) => ({...x.data(), id: x.id})) as Assignment[];
}; };
export const getAssignmentsByAssignerBetweenDates = async (id: string, startDate: Date, endDate: Date) => {
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[]) => { export const getAssignmentsByAssigners = async (ids: string[]) => {
return (await Promise.all(ids.map(getAssignmentsByAssigner))).flat(); return (await Promise.all(ids.map(getAssignmentsByAssigner))).flat();
}; };

View File

@@ -33,6 +33,11 @@ export const updateExpiryDateOnGroup = async (participantID: string, corporateID
return; return;
}; };
export const getGroups = async () => {
const groupDocs = await getDocs(collection(db, "groups"));
return groupDocs.docs.map((x) => ({...x.data(), id: x.id})) as Group[];
};
export const getUserGroups = async (id: string): Promise<Group[]> => { export const getUserGroups = async (id: string): Promise<Group[]> => {
const groupDocs = await getDocs(query(collection(db, "groups"), where("admin", "==", id))); const groupDocs = await getDocs(query(collection(db, "groups"), where("admin", "==", id)));
return groupDocs.docs.map((x) => ({...x.data(), id})) as Group[]; return groupDocs.docs.map((x) => ({...x.data(), id})) as Group[];
@@ -49,3 +54,54 @@ export const getAllAssignersByCorporate = async (corporateID: string): Promise<s
return teacherPromises.filter((x) => !!x).flat() as string[]; return teacherPromises.filter((x) => !!x).flat() as string[];
}; };
export const getGroupsForUser = async (admin: string, participant: string) => {
try {
const queryConstraints = [
...(admin ? [where("admin", "==", admin)] : []),
...(participant
? [where("participants", "array-contains", participant)]
: []),
];
const snapshot = await getDocs(
queryConstraints.length > 0
? query(collection(db, "groups"), ...queryConstraints)
: collection(db, "groups")
);
const groups = snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})) as Group[];
return groups;
} catch (e) {
console.error(e);
return [];
}
};
export const getStudentGroupsForUsersWithoutAdmin = async (admin: string, participants: string[]) => {
try {
const queryConstraints = [
...(admin ? [where("admin", "!=", admin)] : []),
...(participants
? [where("participants", "array-contains-any", participants)]
: []),
where("name", "==", "Students"),
];
const snapshot = await getDocs(
queryConstraints.length > 0
? query(collection(db, "groups"), ...queryConstraints)
: collection(db, "groups")
);
const groups = snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})) as Group[];
return groups;
} catch (e) {
console.error(e);
return [];
}
};

View File

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

1077
yarn.lock

File diff suppressed because it is too large Load Diff