Merged in features-03-Sep-24 (pull request #89)

ENCOA-146 + Bug fixing

Approved-by: Tiago Ribeiro
This commit is contained in:
João Ramos
2024-09-04 08:10:12 +00:00
committed by Tiago Ribeiro
6 changed files with 731 additions and 445 deletions

View File

@@ -47,7 +47,8 @@ export default function AssignmentCreator({isCreating, assignment, user, groups,
}), }),
); );
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [startDate, setStartDate] = useState<Date | null>(assignment ? moment(assignment.startDate).toDate() : new Date()); const [startDate, setStartDate] = useState<Date | null>(assignment ? moment(assignment.startDate).toDate() : moment().add(1, 'hour').toDate());
const [endDate, setEndDate] = useState<Date | null>( const [endDate, setEndDate] = useState<Date | null>(
assignment ? moment(assignment.endDate).toDate() : moment().hours(23).minutes(59).add(8, "day").toDate(), assignment ? moment(assignment.endDate).toDate() : moment().hours(23).minutes(59).add(8, "day").toDate(),
); );
@@ -55,7 +56,10 @@ export default function AssignmentCreator({isCreating, assignment, user, groups,
const [instructorGender, setInstructorGender] = useState<InstructorGender>(assignment?.instructorGender || "varied"); const [instructorGender, setInstructorGender] = useState<InstructorGender>(assignment?.instructorGender || "varied");
// creates a new exam for each assignee or just one exam for all assignees // creates a new exam for each assignee or just one exam for all assignees
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false); const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
const [released, setReleased] = useState<boolean>(false); const [released, setReleased] = useState<boolean>(assignment?.released || false);
const [autoStart, setAutostart] = useState<boolean>(assignment?.autoStart || false);
const [autoStartDate, setAutoStartDate] = useState<Date | null>(assignment ? moment(assignment.autoStartDate).toDate() : new Date());
const [useRandomExams, setUseRandomExams] = useState(true); const [useRandomExams, setUseRandomExams] = useState(true);
const [examIDs, setExamIDs] = useState<{id: string; module: Module}[]>([]); const [examIDs, setExamIDs] = useState<{id: string; module: Module}[]>([]);
@@ -90,6 +94,8 @@ export default function AssignmentCreator({isCreating, assignment, user, groups,
variant, variant,
instructorGender, instructorGender,
released, released,
autoStart,
autoStartDate,
}) })
.then(() => { .then(() => {
toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`); toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`);
@@ -233,7 +239,7 @@ export default function AssignmentCreator({isCreating, assignment, user, groups,
<div className="w-full grid -md:grid-cols-1 md:grid-cols-2 gap-8"> <div className="w-full grid -md:grid-cols-1 md:grid-cols-2 gap-8">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label className="font-normal text-base text-mti-gray-dim">Start Date *</label> <label className="font-normal text-base text-mti-gray-dim">Limit Start Date *</label>
<ReactDatePicker <ReactDatePicker
className={clsx( className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer", "p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
@@ -264,6 +270,23 @@ export default function AssignmentCreator({isCreating, assignment, user, groups,
onChange={(date) => setEndDate(date)} onChange={(date) => setEndDate(date)}
/> />
</div> </div>
{autoStart && (<div className="flex flex-col gap-2">
<label className="font-normal text-base text-mti-gray-dim">Automatic Start Date *</label>
<ReactDatePicker
className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip z-10",
"transition duration-300 ease-in-out",
)}
popperClassName="!z-20"
filterTime={(date) => moment(date).isSameOrAfter(new Date())}
dateFormat="dd/MM/yyyy HH:mm"
selected={autoStartDate}
showTimeSelect
onChange={(date) => setAutoStartDate(date)}
/>
</div>
)}
</div> </div>
{selectedModules.includes("speaking") && ( {selectedModules.includes("speaking") && (
@@ -380,7 +403,10 @@ export default function AssignmentCreator({isCreating, assignment, user, groups,
Generate different exams Generate different exams
</Checkbox> </Checkbox>
<Checkbox isChecked={released} onChange={() => setReleased((d) => !d)}> <Checkbox isChecked={released} onChange={() => setReleased((d) => !d)}>
Release automatically Auto release results
</Checkbox>
<Checkbox isChecked={autoStart} onChange={() => setAutostart((d) => !d)}>
Auto start exam
</Checkbox> </Checkbox>
</div> </div>
<div className="flex gap-4 w-full justify-end"> <div className="flex gap-4 w-full justify-end">

View File

@@ -2,329 +2,433 @@ import Button from "@/components/Low/Button";
import ProgressBar from "@/components/Low/ProgressBar"; import ProgressBar from "@/components/Low/ProgressBar";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import {Module} from "@/interfaces"; import { Module } from "@/interfaces";
import {Assignment} from "@/interfaces/results"; import { Assignment } from "@/interfaces/results";
import {Stat, User} from "@/interfaces/user"; import { Stat, User } from "@/interfaces/user";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {getExamById} from "@/utils/exams"; import { getExamById } from "@/utils/exams";
import {sortByModule} from "@/utils/moduleUtils"; import { sortByModule } from "@/utils/moduleUtils";
import {calculateBandScore} from "@/utils/score"; import { calculateBandScore } from "@/utils/score";
import {convertToUserSolutions} from "@/utils/stats"; import { convertToUserSolutions } from "@/utils/stats";
import {getUserName} from "@/utils/users"; import { getUserName } from "@/utils/users";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import {capitalize, uniqBy} from "lodash"; import { capitalize, uniqBy } from "lodash";
import moment from "moment"; import moment from "moment";
import {useRouter} from "next/router"; import { useRouter } from "next/router";
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs"; import {
import {toast} from "react-toastify"; BsBook,
BsClipboard,
BsHeadphones,
BsMegaphone,
BsPen,
} from "react-icons/bs";
import { toast } from "react-toastify";
import { futureAssignmentFilter } from "@/utils/assignments";
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
assignment?: Assignment; assignment?: Assignment;
onClose: () => void; onClose: () => void;
} }
export default function AssignmentView({isOpen, assignment, onClose}: Props) { export default function AssignmentView({ isOpen, assignment, onClose }: Props) {
const {users} = useUsers(); const { users } = useUsers();
const router = useRouter(); const router = useRouter();
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 deleteAssignment = async () => { const deleteAssignment = async () => {
if (!confirm("Are you sure you want to delete this assignment?")) return; if (!confirm("Are you sure you want to delete this assignment?")) return;
axios axios
.delete(`/api/assignments/${assignment?.id}`) .delete(`/api/assignments/${assignment?.id}`)
.then(() => toast.success(`Successfully deleted the assignment "${assignment?.name}".`)) .then(() =>
.catch(() => toast.error("Something went wrong, please try again later.")) toast.success(
.finally(onClose); `Successfully deleted the assignment "${assignment?.name}".`
}; )
)
.catch(() => toast.error("Something went wrong, please try again later."))
.finally(onClose);
};
const startAssignment = () => { const startAssignment = () => {
if (assignment) { if (assignment) {
axios axios
.post(`/api/assignments/${assignment.id}/start`) .post(`/api/assignments/${assignment.id}/start`)
.then(() => { .then(() => {
toast.success(`The assignment "${assignment.name}" has been started successfully!`); toast.success(
}) `The assignment "${assignment.name}" has been started successfully!`
.catch((e) => { );
console.log(e); })
toast.error("Something went wrong, please try again later!"); .catch((e) => {
}); console.log(e);
} toast.error("Something went wrong, please try again later!");
}; });
}
};
const formatTimestamp = (timestamp: string) => { const formatTimestamp = (timestamp: string) => {
const date = moment(parseInt(timestamp)); const date = moment(parseInt(timestamp));
const formatter = "YYYY/MM/DD - HH:mm"; const formatter = "YYYY/MM/DD - HH:mm";
return date.format(formatter); return date.format(formatter);
}; };
const calculateAverageModuleScore = (module: Module) => { const calculateAverageModuleScore = (module: Module) => {
if (!assignment) return -1; if (!assignment) return -1;
const resultModuleBandScores = assignment.results.map((r) => { const resultModuleBandScores = assignment.results.map((r) => {
const moduleStats = r.stats.filter((s) => s.module === module); const moduleStats = r.stats.filter((s) => s.module === module);
const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0); const correct = moduleStats.reduce(
const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0); (acc, curr) => acc + curr.score.correct,
return calculateBandScore(correct, total, module, r.type); 0
}); );
const total = moduleStats.reduce(
(acc, curr) => acc + curr.score.total,
0
);
return calculateBandScore(correct, total, module, r.type);
});
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / assignment.results.length; return resultModuleBandScores.length === 0
}; ? -1
: resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) /
assignment.results.length;
};
const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => { const aggregateScoresByModule = (
const scores: { stats: Stat[]
[key in Module]: {total: number; missing: number; correct: number}; ): { module: Module; total: number; missing: number; correct: number }[] => {
} = { const scores: {
reading: { [key in Module]: { total: number; missing: number; correct: number };
total: 0, } = {
correct: 0, reading: {
missing: 0, total: 0,
}, correct: 0,
listening: { missing: 0,
total: 0, },
correct: 0, listening: {
missing: 0, total: 0,
}, correct: 0,
writing: { missing: 0,
total: 0, },
correct: 0, writing: {
missing: 0, total: 0,
}, correct: 0,
speaking: { missing: 0,
total: 0, },
correct: 0, speaking: {
missing: 0, total: 0,
}, correct: 0,
level: { missing: 0,
total: 0, },
correct: 0, level: {
missing: 0, total: 0,
}, correct: 0,
}; missing: 0,
},
};
stats.forEach((x) => { stats.forEach((x) => {
scores[x.module!] = { scores[x.module!] = {
total: scores[x.module!].total + x.score.total, total: scores[x.module!].total + x.score.total,
correct: scores[x.module!].correct + x.score.correct, correct: scores[x.module!].correct + x.score.correct,
missing: scores[x.module!].missing + x.score.missing, missing: scores[x.module!].missing + x.score.missing,
}; };
}); });
return Object.keys(scores) return Object.keys(scores)
.filter((x) => scores[x as Module].total > 0) .filter((x) => scores[x as Module].total > 0)
.map((x) => ({module: x as Module, ...scores[x as Module]})); .map((x) => ({ module: x as Module, ...scores[x as Module] }));
}; };
const customContent = (stats: Stat[], user: string, focus: "academic" | "general") => { const customContent = (
const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0); stats: Stat[],
const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0); user: string,
const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0); focus: "academic" | "general"
) => {
const correct = stats.reduce(
(accumulator, current) => accumulator + current.score.correct,
0
);
const total = stats.reduce(
(accumulator, current) => accumulator + current.score.total,
0
);
const aggregatedScores = aggregateScoresByModule(stats).filter(
(x) => x.total > 0
);
const aggregatedLevels = aggregatedScores.map((x) => ({ const aggregatedLevels = aggregatedScores.map((x) => ({
module: x.module, module: x.module,
level: calculateBandScore(x.correct, x.total, x.module, focus), level: calculateBandScore(x.correct, x.total, x.module, focus),
})); }));
const timeSpent = stats[0].timeSpent; const timeSpent = stats[0].timeSpent;
const selectExam = () => { const selectExam = () => {
const examPromises = uniqBy(stats, "exam").map((stat) => getExamById(stat.module, stat.exam)); const examPromises = uniqBy(stats, "exam").map((stat) =>
getExamById(stat.module, stat.exam)
);
Promise.all(examPromises).then((exams) => { Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) { if (exams.every((x) => !!x)) {
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");
} }
}); });
}; };
const content = ( const content = (
<> <>
<div className="-md:items-center flex w-full justify-between 2xl:items-center"> <div className="-md:items-center flex w-full justify-between 2xl:items-center">
<div className="-md:gap-2 -md:items-center flex md:flex-col md:gap-1 2xl:flex-row 2xl:items-center 2xl:gap-2"> <div className="-md:gap-2 -md:items-center flex md:flex-col md:gap-1 2xl:flex-row 2xl:items-center 2xl:gap-2">
<span className="font-medium">{formatTimestamp(stats[0].date.toString())}</span> <span className="font-medium">
{timeSpent && ( {formatTimestamp(stats[0].date.toString())}
<> </span>
<span className="md:hidden 2xl:flex"> </span> {timeSpent && (
<span className="text-sm">{Math.floor(timeSpent / 60)} minutes</span> <>
</> <span className="md:hidden 2xl:flex"> </span>
)} <span className="text-sm">
</div> {Math.floor(timeSpent / 60)} minutes
<span </span>
className={clsx( </>
correct / total >= 0.7 && "text-mti-purple", )}
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red", </div>
correct / total < 0.3 && "text-mti-rose", <span
)}> className={clsx(
Level{" "} correct / total >= 0.7 && "text-mti-purple",
{(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)} correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
</span> correct / total < 0.3 && "text-mti-rose"
</div> )}
>
Level{" "}
{(
aggregatedLevels.reduce(
(accumulator, current) => accumulator + current.level,
0
) / aggregatedLevels.length
).toFixed(1)}
</span>
</div>
<div className="flex w-full flex-col gap-1"> <div className="flex w-full flex-col gap-1">
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2"> <div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
{aggregatedLevels.map(({module, level}) => ( {aggregatedLevels.map(({ module, level }) => (
<div <div
key={module} key={module}
className={clsx( className={clsx(
"-md:px-4 flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4", "-md:px-4 flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
module === "reading" && "bg-ielts-reading", module === "reading" && "bg-ielts-reading",
module === "listening" && "bg-ielts-listening", module === "listening" && "bg-ielts-listening",
module === "writing" && "bg-ielts-writing", module === "writing" && "bg-ielts-writing",
module === "speaking" && "bg-ielts-speaking", module === "speaking" && "bg-ielts-speaking",
module === "level" && "bg-ielts-level", module === "level" && "bg-ielts-level"
)}> )}
{module === "reading" && <BsBook className="h-4 w-4" />} >
{module === "listening" && <BsHeadphones className="h-4 w-4" />} {module === "reading" && <BsBook className="h-4 w-4" />}
{module === "writing" && <BsPen className="h-4 w-4" />} {module === "listening" && <BsHeadphones className="h-4 w-4" />}
{module === "speaking" && <BsMegaphone className="h-4 w-4" />} {module === "writing" && <BsPen className="h-4 w-4" />}
{module === "level" && <BsClipboard className="h-4 w-4" />} {module === "speaking" && <BsMegaphone className="h-4 w-4" />}
<span className="text-sm">{level.toFixed(1)}</span> {module === "level" && <BsClipboard className="h-4 w-4" />}
</div> <span className="text-sm">{level.toFixed(1)}</span>
))} </div>
</div> ))}
</div> </div>
</> </div>
); </>
);
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span> <span>
{(() => { {(() => {
const student = users.find((u) => u.id === user); const student = users.find((u) => u.id === user);
return `${student?.name} (${student?.email})`; return `${student?.name} (${student?.email})`;
})()} })()}
</span> </span>
<div <div
key={user} key={user}
className={clsx( className={clsx(
"border-mti-gray-platinum -md:hidden flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out", "border-mti-gray-platinum -md:hidden flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out",
correct / total >= 0.7 && "hover:border-mti-purple", correct / total >= 0.7 && "hover:border-mti-purple",
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red", correct / total >= 0.3 &&
correct / total < 0.3 && "hover:border-mti-rose", correct / total < 0.7 &&
)} "hover:border-mti-red",
onClick={selectExam} correct / total < 0.3 && "hover:border-mti-rose"
role="button"> )}
{content} onClick={selectExam}
</div> role="button"
<div >
key={user} {content}
className={clsx( </div>
"border-mti-gray-platinum -md:tooltip flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out md:hidden", <div
correct / total >= 0.7 && "hover:border-mti-purple", key={user}
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red", className={clsx(
correct / total < 0.3 && "hover:border-mti-rose", "border-mti-gray-platinum -md:tooltip flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out md:hidden",
)} correct / total >= 0.7 && "hover:border-mti-purple",
data-tip="Your screen size is too small to view previous exams." correct / total >= 0.3 &&
role="button"> correct / total < 0.7 &&
{content} "hover:border-mti-red",
</div> correct / total < 0.3 && "hover:border-mti-rose"
</div> )}
); data-tip="Your screen size is too small to view previous exams."
}; role="button"
>
{content}
</div>
</div>
);
};
return ( const shouldRenderStart = () => {
<Modal isOpen={isOpen} onClose={onClose} title={assignment?.name}> if (assignment) {
<div className="mt-4 flex w-full flex-col gap-4"> if (futureAssignmentFilter(assignment)) {
<ProgressBar return true;
color="purple" }
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`} }
className="h-6"
textClassName={
(assignment?.results.length || 0) / (assignment?.assignees.length || 1) < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"
}
percentage={((assignment?.results.length || 0) / (assignment?.assignees.length || 1)) * 100}
/>
<div className="flex items-start gap-8">
<div className="flex flex-col gap-2">
<span>Start Date: {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}</span>
<span>End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}</span>
</div>
<div className="flex flex-col gap-2">
<span>
Assignees:{" "}
{users
.filter((u) => assignment?.assignees.includes(u.id))
.map((u) => `${u.name} (${u.email})`)
.join(", ")}
</span>
<span>Assigner: {getUserName(users.find((x) => x.id === assignment?.assigner))}</span>
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-xl font-bold">Average Scores</span>
<div className="-md:mt-2 flex w-full items-center gap-4">
{assignment &&
uniqBy(assignment.exams, (x) => x.module).map(({module}) => (
<div
data-tip={capitalize(module)}
key={module}
className={clsx(
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
module === "reading" && "bg-ielts-reading",
module === "listening" && "bg-ielts-listening",
module === "writing" && "bg-ielts-writing",
module === "speaking" && "bg-ielts-speaking",
module === "level" && "bg-ielts-level",
)}>
{module === "reading" && <BsBook className="h-4 w-4" />}
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
{module === "writing" && <BsPen className="h-4 w-4" />}
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
{module === "level" && <BsClipboard className="h-4 w-4" />}
{calculateAverageModuleScore(module) > -1 && (
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
)}
</div>
))}
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-xl font-bold">
Results ({assignment?.results.length}/{assignment?.assignees.length})
</span>
<div>
{assignment && assignment?.results.length > 0 && (
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 xl:gap-6">
{assignment.results.map((r) => customContent(r.stats, r.user, r.type))}
</div>
)}
{assignment && assignment?.results.length === 0 && <span className="ml-1 font-semibold">No results yet...</span>}
</div>
</div>
<div className="flex gap-4 w-full items-center justify-end"> return false;
{assignment && (assignment.results.length === assignment.assignees.length || moment().isAfter(moment(assignment.endDate))) && ( };
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={deleteAssignment}>
Delete return (
</Button> <Modal isOpen={isOpen} onClose={onClose} title={assignment?.name}>
)} <div className="mt-4 flex w-full flex-col gap-4">
{assignment && (assignment.results.length === 0 || moment().isAfter(moment(assignment.startDate))) && ( <ProgressBar
<Button variant="outline" color="green" className="w-full max-w-[200px]" onClick={startAssignment}> color="purple"
Start label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
</Button> className="h-6"
)} textClassName={
<Button onClick={onClose} className="w-full max-w-[200px]"> (assignment?.results.length || 0) /
Close (assignment?.assignees.length || 1) <
</Button> 0.5
</div> ? "!text-mti-gray-dim font-light"
</div> : "text-white"
</Modal> }
); percentage={
((assignment?.results.length || 0) /
(assignment?.assignees.length || 1)) *
100
}
/>
<div className="flex items-start gap-8">
<div className="flex flex-col gap-2">
<span>
Start Date:{" "}
{moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}
</span>
<span>
End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}
</span>
</div>
<div className="flex flex-col gap-2">
<span>
Assignees:{" "}
{users
.filter((u) => assignment?.assignees.includes(u.id))
.map((u) => `${u.name} (${u.email})`)
.join(", ")}
</span>
<span>
Assigner:{" "}
{getUserName(users.find((x) => x.id === assignment?.assigner))}
</span>
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-xl font-bold">Average Scores</span>
<div className="-md:mt-2 flex w-full items-center gap-4">
{assignment &&
uniqBy(assignment.exams, (x) => x.module).map(({ module }) => (
<div
data-tip={capitalize(module)}
key={module}
className={clsx(
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
module === "reading" && "bg-ielts-reading",
module === "listening" && "bg-ielts-listening",
module === "writing" && "bg-ielts-writing",
module === "speaking" && "bg-ielts-speaking",
module === "level" && "bg-ielts-level"
)}
>
{module === "reading" && <BsBook className="h-4 w-4" />}
{module === "listening" && (
<BsHeadphones className="h-4 w-4" />
)}
{module === "writing" && <BsPen className="h-4 w-4" />}
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
{module === "level" && <BsClipboard className="h-4 w-4" />}
{calculateAverageModuleScore(module) > -1 && (
<span className="text-sm">
{calculateAverageModuleScore(module).toFixed(1)}
</span>
)}
</div>
))}
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-xl font-bold">
Results ({assignment?.results.length}/{assignment?.assignees.length}
)
</span>
<div>
{assignment && assignment?.results.length > 0 && (
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 xl:gap-6">
{assignment.results.map((r) =>
customContent(r.stats, r.user, r.type)
)}
</div>
)}
{assignment && assignment?.results.length === 0 && (
<span className="ml-1 font-semibold">No results yet...</span>
)}
</div>
</div>
<div className="flex gap-4 w-full items-center justify-end">
{assignment &&
(assignment.results.length === assignment.assignees.length ||
moment().isAfter(moment(assignment.endDate))) && (
<Button
variant="outline"
color="red"
className="w-full max-w-[200px]"
onClick={deleteAssignment}
>
Delete
</Button>
)}
{/** if the assignment is not deemed as active yet, display start */}
{shouldRenderStart() && (
<Button
variant="outline"
color="green"
className="w-full max-w-[200px]"
onClick={startAssignment}
>
Start
</Button>
)}
<Button onClick={onClose} className="w-full max-w-[200px]">
Close
</Button>
</div>
</div>
</Modal>
);
} }

View File

@@ -27,6 +27,7 @@ import {useRouter} from "next/router";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import {BsArrowRepeat, BsBook, BsClipboard, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar} from "react-icons/bs"; import {BsArrowRepeat, BsBook, BsClipboard, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar} from "react-icons/bs";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
import { activeAssignmentFilter } from "@/utils/assignments";
interface Props { interface Props {
user: User; user: User;
@@ -69,6 +70,9 @@ export default function StudentDashboard({user, users, linkedCorporate}: Props)
}); });
}; };
const studentAssignments = assignments
.filter(activeAssignmentFilter);
return ( return (
<> <>
{linkedCorporate && ( {linkedCorporate && (
@@ -119,10 +123,9 @@ export default function StudentDashboard({user, users, linkedCorporate}: Props)
</div> </div>
</div> </div>
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll"> <span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
{assignments.filter((a) => moment(a.endDate).isSameOrAfter(moment())).length === 0 && {studentAssignments.length === 0 &&
"Assignments will appear here. It seems that for now there are no assignments for you."} "Assignments will appear here. It seems that for now there are no assignments for you."}
{assignments {studentAssignments
.filter((a) => moment(a.endDate).isSameOrAfter(moment()))
.sort((a, b) => moment(a.startDate).diff(b.startDate)) .sort((a, b) => moment(a.startDate).diff(b.startDate))
.map((assignment) => ( .map((assignment) => (
<div <div
@@ -170,12 +173,12 @@ export default function StudentDashboard({user, users, linkedCorporate}: Props)
<div <div
className="tooltip flex h-full w-full items-center justify-end pl-8 md:hidden" className="tooltip flex h-full w-full items-center justify-end pl-8 md:hidden"
data-tip="Your screen size is too small to perform an assignment"> data-tip="Your screen size is too small to perform an assignment">
<Button disabled={!assignment.start} className="h-full w-full !rounded-xl" variant="outline"> <Button className="h-full w-full !rounded-xl" variant="outline">
Start Start
</Button> </Button>
</div> </div>
<Button <Button
disabled={!assignment.start}
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl" className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
onClick={() => startAssignment(assignment)} onClick={() => startAssignment(assignment)}
variant="outline"> variant="outline">

View File

@@ -1,149 +1,233 @@
import {Assignment} from "@/interfaces/results"; import { Assignment } from "@/interfaces/results";
import {CorporateUser, Group, User} from "@/interfaces/user"; import { CorporateUser, Group, User } from "@/interfaces/user";
import {getUserCompanyName} from "@/resources/user"; import { getUserCompanyName } from "@/resources/user";
import {activeAssignmentFilter, archivedAssignmentFilter, futureAssignmentFilter, pastAssignmentFilter} from "@/utils/assignments"; import {
activeAssignmentFilter,
archivedAssignmentFilter,
futureAssignmentFilter,
pastAssignmentFilter,
startHasExpiredAssignmentFilter,
} from "@/utils/assignments";
import clsx from "clsx"; import clsx from "clsx";
import {groupBy} from "lodash"; import { groupBy } from "lodash";
import {useState} from "react"; import { useState } from "react";
import {BsArrowLeft, BsArrowRepeat, BsPlus} from "react-icons/bs"; import { BsArrowLeft, BsArrowRepeat, BsPlus } from "react-icons/bs";
import AssignmentCard from "../AssignmentCard"; import AssignmentCard from "../AssignmentCard";
import AssignmentCreator from "../AssignmentCreator"; import AssignmentCreator from "../AssignmentCreator";
import AssignmentView from "../AssignmentView"; import AssignmentView from "../AssignmentView";
interface Props { interface Props {
assignments: Assignment[]; assignments: Assignment[];
corporateAssignments?: ({corporate?: CorporateUser} & Assignment)[]; corporateAssignments?: ({ corporate?: CorporateUser } & Assignment)[];
groups: Group[]; groups: Group[];
users: User[]; users: User[];
isLoading: boolean; isLoading: boolean;
user: User; user: User;
onBack: () => void; onBack: () => void;
reloadAssignments: () => void; reloadAssignments: () => void;
} }
export default function AssignmentsPage({assignments, corporateAssignments, user, groups, users, isLoading, onBack, reloadAssignments}: Props) { export default function AssignmentsPage({
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>(); assignments,
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false); corporateAssignments,
user,
groups,
users,
isLoading,
onBack,
reloadAssignments,
}: Props) {
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
return ( const displayAssignmentView = !!selectedAssignment && !isCreatingAssignment;
<>
<AssignmentView const assignmentsPastExpiredStart = assignments.filter(startHasExpiredAssignmentFilter);
isOpen={!!selectedAssignment && !isCreatingAssignment}
onClose={() => { return (
setSelectedAssignment(undefined); <>
setIsCreatingAssignment(false); {displayAssignmentView && (
reloadAssignments(); <AssignmentView
}} isOpen={displayAssignmentView}
assignment={selectedAssignment} onClose={() => {
/> setSelectedAssignment(undefined);
<AssignmentCreator setIsCreatingAssignment(false);
assignment={selectedAssignment} reloadAssignments();
groups={groups} }}
users={users} assignment={selectedAssignment}
user={user} />
isCreating={isCreatingAssignment} )}
cancelCreation={() => { {/** I'll be using this is creating assingment as a workaround for a key to trigger a new rendering */}
setIsCreatingAssignment(false); {isCreatingAssignment && (
setSelectedAssignment(undefined); <AssignmentCreator
reloadAssignments(); assignment={selectedAssignment}
}} groups={groups}
/> users={users}
<div className="w-full flex justify-between items-center"> user={user}
<div isCreating={isCreatingAssignment}
onClick={onBack} cancelCreation={() => {
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> setIsCreatingAssignment(false);
<BsArrowLeft className="text-xl" /> setSelectedAssignment(undefined);
<span>Back</span> reloadAssignments();
</div> }}
<div />
onClick={reloadAssignments} )}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> <div className="w-full flex justify-between items-center">
<span>Reload</span> <div
<BsArrowRepeat className={clsx("text-xl", isLoading && "animate-spin")} /> onClick={onBack}
</div> className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
</div> >
<div className="flex flex-col gap-2"> <BsArrowLeft className="text-xl" />
<span className="text-lg font-bold">Active Assignments Status</span> <span>Back</span>
<div className="flex items-center gap-4"> </div>
<span> <div
<b>Total:</b> {assignments.filter(activeAssignmentFilter).reduce((acc, curr) => acc + curr.results.length, 0)}/ onClick={reloadAssignments}
{assignments.filter(activeAssignmentFilter).reduce((acc, curr) => curr.exams.length + acc, 0)} className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"
</span> >
{Object.keys(groupBy(corporateAssignments, (x) => x.corporate?.id)).map((x) => ( <span>Reload</span>
<div key={x}> <BsArrowRepeat
<span className="font-semibold">{getUserCompanyName(users.find((u) => u.id === x)!, users, groups)}: </span> className={clsx("text-xl", isLoading && "animate-spin")}
<span> />
{groupBy(corporateAssignments, (x) => x.corporate?.id)[x].reduce((acc, curr) => curr.results.length + acc, 0)}/ </div>
{groupBy(corporateAssignments, (x) => x.corporate?.id)[x].reduce((acc, curr) => curr.exams.length + acc, 0)} </div>
</span> <div className="flex flex-col gap-2">
</div> <span className="text-lg font-bold">Active Assignments Status</span>
))} <div className="flex items-center gap-4">
</div> <span>
</div> <b>Total:</b>{" "}
<section className="flex flex-col gap-4"> {assignments
<h2 className="text-2xl font-semibold">Active Assignments ({assignments.filter(activeAssignmentFilter).length})</h2> .filter(activeAssignmentFilter)
<div className="flex flex-wrap gap-2"> .reduce((acc, curr) => acc + curr.results.length, 0)}
{assignments.filter(activeAssignmentFilter).map((a) => ( /
<AssignmentCard {...a} users={users} onClick={() => setSelectedAssignment(a)} key={a.id} /> {assignments
))} .filter(activeAssignmentFilter)
</div> .reduce((acc, curr) => curr.exams.length + acc, 0)}
</section> </span>
<section className="flex flex-col gap-4"> {Object.keys(
<h2 className="text-2xl font-semibold">Planned Assignments ({assignments.filter(futureAssignmentFilter).length})</h2> groupBy(corporateAssignments, (x) => x.corporate?.id)
<div className="flex flex-wrap gap-2"> ).map((x) => (
<div <div key={x}>
onClick={() => setIsCreatingAssignment(true)} <span className="font-semibold">
className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300"> {getUserCompanyName(
<BsPlus className="text-6xl" /> users.find((u) => u.id === x)!,
<span className="text-lg">New Assignment</span> users,
</div> groups
{assignments.filter(futureAssignmentFilter).map((a) => ( )}
<AssignmentCard :{" "}
{...a} </span>
users={users} <span>
onClick={() => { {groupBy(corporateAssignments, (x) => x.corporate?.id)[
setSelectedAssignment(a); x
setIsCreatingAssignment(true); ].reduce((acc, curr) => curr.results.length + acc, 0)}
}} /
key={a.id} {groupBy(corporateAssignments, (x) => x.corporate?.id)[
/> x
))} ].reduce((acc, curr) => curr.exams.length + acc, 0)}
</div> </span>
</section> </div>
<section className="flex flex-col gap-4"> ))}
<h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastAssignmentFilter).length})</h2> </div>
<div className="flex flex-wrap gap-2"> </div>
{assignments.filter(pastAssignmentFilter).map((a) => ( <section className="flex flex-col gap-4">
<AssignmentCard <h2 className="text-2xl font-semibold">
{...a} Active Assignments (
users={users} {assignments.filter(activeAssignmentFilter).length})
onClick={() => setSelectedAssignment(a)} </h2>
key={a.id} <div className="flex flex-wrap gap-2">
allowDownload {assignments.filter(activeAssignmentFilter).map((a) => (
reload={reloadAssignments} <AssignmentCard
allowArchive {...a}
allowExcelDownload users={users}
/> onClick={() => setSelectedAssignment(a)}
))} key={a.id}
</div> />
</section> ))}
<section className="flex flex-col gap-4"> </div>
<h2 className="text-2xl font-semibold">Archived Assignments ({assignments.filter(archivedAssignmentFilter).length})</h2> </section>
<div className="flex flex-wrap gap-2"> <section className="flex flex-col gap-4">
{assignments.filter(archivedAssignmentFilter).map((a) => ( <h2 className="text-2xl font-semibold">
<AssignmentCard Planned Assignments (
{...a} {assignments.filter(futureAssignmentFilter).length})
users={users} </h2>
onClick={() => setSelectedAssignment(a)} <div className="flex flex-wrap gap-2">
key={a.id} <div
allowDownload onClick={() => setIsCreatingAssignment(true)}
reload={reloadAssignments} className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300"
allowUnarchive >
allowExcelDownload <BsPlus className="text-6xl" />
/> <span className="text-lg">New Assignment</span>
))} </div>
</div> {assignments.filter(futureAssignmentFilter).map((a) => (
</section> <AssignmentCard
</> {...a}
); users={users}
onClick={() => {
setSelectedAssignment(a);
setIsCreatingAssignment(true);
}}
key={a.id}
/>
))}
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">
Past Assignments ({assignments.filter(pastAssignmentFilter).length})
</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(pastAssignmentFilter).map((a) => (
<AssignmentCard
{...a}
users={users}
onClick={() => setSelectedAssignment(a)}
key={a.id}
allowDownload
reload={reloadAssignments}
allowArchive
allowExcelDownload
/>
))}
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">
Assignments start expired ({assignmentsPastExpiredStart.length})
</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(startHasExpiredAssignmentFilter).map((a) => (
<AssignmentCard
{...a}
users={users}
onClick={() => setSelectedAssignment(a)}
key={a.id}
allowDownload
reload={reloadAssignments}
allowArchive
allowExcelDownload
/>
))}
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">
Archived Assignments (
{assignments.filter(archivedAssignmentFilter).length})
</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(archivedAssignmentFilter).map((a) => (
<AssignmentCard
{...a}
users={users}
onClick={() => setSelectedAssignment(a)}
key={a.id}
allowDownload
reload={reloadAssignments}
allowUnarchive
allowExcelDownload
/>
))}
</div>
</section>
</>
);
} }

View File

@@ -32,6 +32,8 @@ export interface Assignment {
// unless start is active, the assignment is not visible to the assignees // unless start is active, the assignment is not visible to the assignees
// start date now works as a limit time to start the exam // start date now works as a limit time to start the exam
start?: boolean; start?: boolean;
autoStartDate?: Date;
autoStart?: boolean;
} }
export type AssignmentWithCorporateId = Assignment & {corporateId: string}; export type AssignmentWithCorporateId = Assignment & {corporateId: string};

View File

@@ -1,10 +1,77 @@
import moment from "moment"; import moment from "moment";
import {Assignment} from "@/interfaces/results"; import {Assignment} from "@/interfaces/results";
export const futureAssignmentFilter = (a: Assignment) => moment(a.startDate).isAfter(moment()) && !a.archived; // export const futureAssignmentFilter = (a: Assignment) => {
// if(a.archived) return false;
// if(a.start) return false;
export const pastAssignmentFilter = (a: Assignment) => moment(a.endDate).isBefore(moment()) && !a.archived; // const currentDate = moment();
// const startDate = moment(a.startDate);
// if(currentDate.isAfter(startDate)) return false;
// if(a.autoStart && a.autoStartDate) {
// return moment(a.autoStartDate).isAfter(currentDate);
// }
// return false;
// }
export const futureAssignmentFilter = (a: Assignment) => {
const currentDate = moment();
if(moment(a.endDate).isBefore(currentDate)) return false;
if(a.archived) return false;
if(a.autoStart && a.autoStartDate && moment(a.autoStartDate).isBefore(currentDate)) return false;
if(!a.start) {
if(moment(a.startDate).isBefore(currentDate)) return false;
return true;
}
return false;
}
export const pastAssignmentFilter = (a: Assignment) => {
const currentDate = moment();
if(a.archived) {
return false;
}
return moment(a.endDate).isBefore(currentDate);
}
export const archivedAssignmentFilter = (a: Assignment) => a.archived; export const archivedAssignmentFilter = (a: Assignment) => a.archived;
export const activeAssignmentFilter = (a: Assignment) => moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment()); export const activeAssignmentFilter = (a: Assignment) => {
const currentDate = moment();
if(moment(a.endDate).isBefore(currentDate)) return false;
if(a.archived) return false;
if(a.start) return true;
if(a.autoStart && a.autoStartDate) {
return moment(a.autoStartDate).isBefore(currentDate);
}
// if(currentDate.isAfter(moment(a.startDate))) return true;
return false;
};
// export const unstartedAssignmentFilter = (a: Assignment) => {
// const currentDate = moment();
// if(moment(a.endDate).isBefore(currentDate)) return false;
// if(a.archived) return false;
// if(a.autoStart && a.autoStartDate && moment(a.autoStartDate).isBefore(currentDate)) return false;
// if(!a.start) {
// if(moment(a.startDate).isBefore(currentDate)) return false;
// return true;
// }
// return false;
// }
export const startHasExpiredAssignmentFilter = (a: Assignment) => {
const currentDate = moment();
if(a.archived) return false;
if(a.start) return false;
if(currentDate.isAfter(moment(a.startDate)) && currentDate.isBefore(moment(a.endDate))) return true;
return false;
}