Merged in refactor-getserverprops (pull request #142)

Refactor most getServerProps to make independent requests in parallel and projected the data only to return the necessary fields and changed some functions

Approved-by: Tiago Ribeiro
This commit is contained in:
Francisco Lima
2025-01-30 20:02:27 +00:00
committed by Tiago Ribeiro
36 changed files with 5796 additions and 4058 deletions

View File

@@ -1,7 +1,10 @@
import { Session } from "@/hooks/useSessions"; import { Session } from "@/hooks/useSessions";
import { Assignment } from "@/interfaces/results"; import { Assignment } from "@/interfaces/results";
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import { activeAssignmentFilter, futureAssignmentFilter } from "@/utils/assignments"; import {
activeAssignmentFilter,
futureAssignmentFilter,
} from "@/utils/assignments";
import { sortByModuleName } from "@/utils/moduleUtils"; import { sortByModuleName } from "@/utils/moduleUtils";
import clsx from "clsx"; import clsx from "clsx";
import moment from "moment"; import moment from "moment";
@@ -11,27 +14,38 @@ import Button from "../Low/Button";
import ModuleBadge from "../ModuleBadge"; import ModuleBadge from "../ModuleBadge";
interface Props { interface Props {
assignment: Assignment assignment: Assignment;
user: User user: User;
session?: Session session?: Session;
startAssignment: (assignment: Assignment) => void startAssignment: (assignment: Assignment) => void;
resumeAssignment: (session: Session) => void resumeAssignment: (session: Session) => void;
} }
export default function AssignmentCard({ user, assignment, session, startAssignment, resumeAssignment }: Props) { export default function AssignmentCard({
const router = useRouter() user,
assignment,
const hasBeenSubmitted = useMemo(() => assignment.results.map((r) => r.user).includes(user.id), [assignment.results, user.id]) session,
startAssignment,
resumeAssignment,
}: Props) {
const hasBeenSubmitted = useMemo(
() => assignment.results.map((r) => r.user).includes(user.id),
[assignment.results, user.id]
);
return ( return (
<div <div
className={clsx( className={clsx(
"border-mti-gray-anti-flash flex min-w-[350px] flex-col gap-6 rounded-xl border p-4", "border-mti-gray-anti-flash flex min-w-[350px] flex-col gap-6 rounded-xl border p-4",
assignment.results.map((r) => r.user).includes(user.id) && "border-mti-green-light", assignment.results.map((r) => r.user).includes(user.id) &&
"border-mti-green-light"
)} )}
key={assignment.id}> key={assignment.id}
>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h3 className="text-mti-black/90 text-xl font-semibold">{assignment.name}</h3> <h3 className="text-mti-black/90 text-xl font-semibold">
{assignment.name}
</h3>
<span className="flex justify-between gap-1 text-lg"> <span className="flex justify-between gap-1 text-lg">
<span>{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}</span> <span>{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}</span>
<span>-</span> <span>-</span>
@@ -45,7 +59,11 @@ export default function AssignmentCard({ user, assignment, session, startAssignm
.map((e) => e.module) .map((e) => e.module)
.sort(sortByModuleName) .sort(sortByModuleName)
.map((module) => ( .map((module) => (
<ModuleBadge className="scale-110 w-full" key={module} module={module} /> <ModuleBadge
className="scale-110 w-full"
key={module}
module={module}
/>
))} ))}
</div> </div>
{futureAssignmentFilter(assignment) && !hasBeenSubmitted && ( {futureAssignmentFilter(assignment) && !hasBeenSubmitted && (
@@ -53,7 +71,8 @@ export default function AssignmentCard({ user, assignment, session, startAssignm
color="rose" color="rose"
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl" className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
disabled disabled
variant="outline"> variant="outline"
>
Not yet started Not yet started
</Button> </Button>
)} )}
@@ -61,7 +80,8 @@ export default function AssignmentCard({ user, assignment, session, startAssignm
<> <>
<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 className="h-full w-full !rounded-xl" variant="outline"> <Button className="h-full w-full !rounded-xl" variant="outline">
Start Start
</Button> </Button>
@@ -71,12 +91,14 @@ export default function AssignmentCard({ user, assignment, session, startAssignm
data-tip="You have already started this assignment!" data-tip="You have already started this assignment!"
className={clsx( className={clsx(
"-md:hidden h-full w-full max-w-[50%] cursor-pointer", "-md:hidden h-full w-full max-w-[50%] cursor-pointer",
!!session && "tooltip", !!session && "tooltip"
)}> )}
>
<Button <Button
className={clsx("w-full h-full !rounded-xl")} className={clsx("w-full h-full !rounded-xl")}
onClick={() => startAssignment(assignment)} onClick={() => startAssignment(assignment)}
variant="outline"> variant="outline"
>
Start Start
</Button> </Button>
</div> </div>
@@ -85,12 +107,14 @@ export default function AssignmentCard({ user, assignment, session, startAssignm
<div <div
className={clsx( className={clsx(
"-md:hidden h-full w-full max-w-[50%] cursor-pointer" "-md:hidden h-full w-full max-w-[50%] cursor-pointer"
)}> )}
>
<Button <Button
className={clsx("w-full h-full !rounded-xl")} className={clsx("w-full h-full !rounded-xl")}
onClick={() => resumeAssignment(session)} onClick={() => resumeAssignment(session)}
color="green" color="green"
variant="outline"> variant="outline"
>
Resume Resume
</Button> </Button>
</div> </div>
@@ -102,11 +126,12 @@ export default function AssignmentCard({ user, assignment, session, startAssignm
color="green" color="green"
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl" className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
disabled disabled
variant="outline"> variant="outline"
>
Submitted Submitted
</Button> </Button>
)} )}
</div> </div>
</div> </div>
) );
} }

View File

@@ -61,7 +61,14 @@ export default function App({ Component, pageProps }: AppProps) {
return pageProps?.user ? ( return pageProps?.user ? (
<Layout user={pageProps.user} entities={entities} refreshPage={loading}> <Layout user={pageProps.user} entities={entities} refreshPage={loading}>
{loading ? <UserProfileSkeleton /> : <Component {...pageProps} />} {loading ? (
// TODO: Change this later to a better loading screen (example: skeletons for each page)
<div className="min-h-screen flex justify-center items-start">
<span className="loading loading-infinity w-32" />
</div>
) : (
<Component entities={entities} {...pageProps} />
)}
</Layout> </Layout>
) : ( ) : (
<Component {...pageProps} /> <Component {...pageProps} />

View File

@@ -13,7 +13,15 @@ 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, BsBuilding, BsChevronLeft, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs"; import {
BsBook,
BsBuilding,
BsChevronLeft,
BsClipboard,
BsHeadphones,
BsMegaphone,
BsPen,
} from "react-icons/bs";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { futureAssignmentFilter } from "@/utils/assignments"; import { futureAssignmentFilter } from "@/utils/assignments";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
@@ -31,69 +39,140 @@ import { requestUser } from "@/utils/api";
import { useEntityPermission } from "@/hooks/useEntityPermissions"; import { useEntityPermission } from "@/hooks/useEntityPermissions";
import { getGradingSystemByEntity } from "@/utils/grading.be"; import { getGradingSystemByEntity } from "@/utils/grading.be";
export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }) => { export const getServerSideProps = withIronSessionSsr(
const user = await requestUser(req, res) async ({ req, res, params }) => {
if (!user) return redirect("/login") const user = await requestUser(req, res);
if (!user) return redirect("/login");
if (!checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"])) if (
return redirect("/assignments") !checkAccess(user, [
"admin",
"developer",
"corporate",
"teacher",
"mastercorporate",
])
)
return redirect("/assignments");
res.setHeader("Cache-Control", "public, s-maxage=10, stale-while-revalidate=59"); res.setHeader(
"Cache-Control",
"public, s-maxage=10, stale-while-revalidate=59"
);
const { id } = params as { id: string }; const { id } = params as { id: string };
const assignment = await getAssignment(id); const assignment = await getAssignment(id);
if (!assignment) return redirect("/assignments") if (!assignment) return redirect("/assignments");
const entity = await getEntityWithRoles(assignment.entity || "") const entity = await getEntityWithRoles(assignment.entity || "");
if (!entity) { if (!entity) {
const users = await getUsers() const users = await getUsers(
{},
0,
{},
{
_id: 0,
id: 1,
name: 1,
email: 1,
}
);
return { props: serialize({ user, users, assignment }) }; return { props: serialize({ user, users, assignment }) };
} }
if (!doesEntityAllow(user, entity, 'view_assignments')) return redirect("/assignments") if (!doesEntityAllow(user, entity, "view_assignments"))
return redirect("/assignments");
const users = await (checkAccess(user, ["developer", "admin"]) ? getUsers() : getEntityUsers(entity.id)); const [users, gradingSystem] = await Promise.all([
const gradingSystem = await getGradingSystemByEntity(entity.id) await (checkAccess(user, ["developer", "admin"])
? getUsers(
{},
0,
{},
{
_id: 0,
id: 1,
name: 1,
email: 1,
}
)
: getEntityUsers(
entity.id,
0,
{},
{
_id: 0,
id: 1,
name: 1,
email: 1,
}
)),
getGradingSystemByEntity(entity.id),
]);
return { props: serialize({ user, users, entity, assignment, gradingSystem }) }; return {
}, sessionOptions); props: serialize({ user, users, entity, assignment, gradingSystem }),
};
},
sessionOptions
);
interface Props { interface Props {
user: User; user: User;
users: User[]; users: User[];
assignment: Assignment; assignment: Assignment;
entity?: EntityWithRoles entity?: EntityWithRoles;
gradingSystem?: Grading gradingSystem?: Grading;
} }
export default function AssignmentView({ user, users, entity, assignment, gradingSystem }: Props) { export default function AssignmentView({
const canDeleteAssignment = useEntityPermission(user, entity, 'delete_assignment') user,
const canStartAssignment = useEntityPermission(user, entity, 'start_assignment') users,
entity,
assignment,
gradingSystem,
}: Props) {
const canDeleteAssignment = useEntityPermission(
user,
entity,
"delete_assignment"
);
const canStartAssignment = useEntityPermission(
user,
entity,
"start_assignment"
);
const router = useRouter(); const router = useRouter();
const dispatch = useExamStore((state) => state.dispatch); const dispatch = useExamStore((state) => state.dispatch);
const deleteAssignment = async () => { const deleteAssignment = async () => {
if (!canDeleteAssignment) return if (!canDeleteAssignment) return;
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(() =>
toast.success(
`Successfully deleted the assignment "${assignment?.name}".`
)
)
.catch(() => toast.error("Something went wrong, please try again later.")) .catch(() => toast.error("Something went wrong, please try again later."))
.finally(() => router.push("/assignments")); .finally(() => router.push("/assignments"));
}; };
const startAssignment = () => { const startAssignment = () => {
if (!canStartAssignment) return if (!canStartAssignment) return;
if (!confirm("Are you sure you want to start this assignment?")) return; if (!confirm("Are you sure you want to start this assignment?")) return;
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!`
);
router.replace(router.asPath); router.replace(router.asPath);
}) })
.catch((e) => { .catch((e) => {
@@ -115,15 +194,26 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
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,
0
);
const total = moduleStats.reduce(
(acc, curr) => acc + curr.score.total,
0
);
return calculateBandScore(correct, total, module, r.type); 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 = (
stats: Stat[]
): { module: Module; total: number; missing: number; correct: number }[] => {
const scores: { const scores: {
[key in Module]: { total: number; missing: number; correct: number }; [key in Module]: { total: number; missing: number; correct: number };
} = { } = {
@@ -154,7 +244,9 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
}, },
}; };
stats.filter(x => !x.isPractice).forEach((x) => { stats
.filter((x) => !x.isPractice)
.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,
@@ -167,28 +259,53 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
.map((x) => ({ module: x as Module, ...scores[x as Module] })); .map((x) => ({ module: x as Module, ...scores[x as Module] }));
}; };
const levelAverage = (aggregatedLevels: { module: Module, level: number }[]) => const levelAverage = (
aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length aggregatedLevels: { module: Module; level: number }[]
) =>
aggregatedLevels.reduce(
(accumulator, current) => accumulator + current.level,
0
) / aggregatedLevels.length;
const renderLevelScore = (stats: Stat[], aggregatedLevels: { module: Module, level: number }[]) => { const renderLevelScore = (
const defaultLevelScore = levelAverage(aggregatedLevels).toFixed(1) stats: Stat[],
if (!stats.every(s => s.module === "level")) return defaultLevelScore aggregatedLevels: { module: Module; level: number }[]
if (!gradingSystem) return defaultLevelScore ) => {
const defaultLevelScore = levelAverage(aggregatedLevels).toFixed(1);
if (!stats.every((s) => s.module === "level")) return defaultLevelScore;
if (!gradingSystem) return defaultLevelScore;
const score = { const score = {
correct: stats.reduce((acc, curr) => acc + curr.score.correct, 0), correct: stats.reduce((acc, curr) => acc + curr.score.correct, 0),
total: stats.reduce((acc, curr) => acc + curr.score.total, 0) total: stats.reduce((acc, curr) => acc + curr.score.total, 0),
} };
const level: number = calculateBandScore(score.correct, score.total, "level", user.focus); const level: number = calculateBandScore(
score.correct,
score.total,
"level",
user.focus
);
return getGradingLabel(level, gradingSystem.steps) return getGradingLabel(level, gradingSystem.steps);
} };
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,
@@ -198,19 +315,22 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
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)) {
dispatch({ dispatch({
type: 'INIT_SOLUTIONS', payload: { type: "INIT_SOLUTIONS",
payload: {
exams: exams.map((x) => x!).sort(sortByModule), exams: exams.map((x) => x!).sort(sortByModule),
modules: exams modules: exams
.map((x) => x!) .map((x) => x!)
.sort(sortByModule) .sort(sortByModule)
.map((x) => x!.module), .map((x) => x!.module),
stats, stats,
} },
}); });
router.push("/exam"); router.push("/exam");
} }
@@ -221,11 +341,15 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
<> <>
<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">
{formatTimestamp(stats[0].date.toString())}
</span>
{timeSpent && ( {timeSpent && (
<> <>
<span className="md:hidden 2xl:flex"> </span> <span className="md:hidden 2xl:flex"> </span>
<span className="text-sm">{Math.floor(timeSpent / 60)} minutes</span> <span className="text-sm">
{Math.floor(timeSpent / 60)} minutes
</span>
</> </>
)} )}
</div> </div>
@@ -233,10 +357,10 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
className={clsx( className={clsx(
correct / total >= 0.7 && "text-mti-purple", correct / total >= 0.7 && "text-mti-purple",
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red", correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
correct / total < 0.3 && "text-mti-rose", correct / total < 0.3 && "text-mti-rose"
)}> )}
Level{' '} >
{renderLevelScore(stats, aggregatedLevels)} Level {renderLevelScore(stats, aggregatedLevels)}
</span> </span>
</div> </div>
@@ -251,8 +375,9 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
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 === "reading" && <BsBook className="h-4 w-4" />}
{module === "listening" && <BsHeadphones className="h-4 w-4" />} {module === "listening" && <BsHeadphones className="h-4 w-4" />}
{module === "writing" && <BsPen className="h-4 w-4" />} {module === "writing" && <BsPen className="h-4 w-4" />}
@@ -279,11 +404,14 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
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",
correct / total < 0.3 && "hover:border-mti-rose"
)} )}
onClick={selectExam} onClick={selectExam}
role="button"> role="button"
>
{content} {content}
</div> </div>
<div <div
@@ -291,11 +419,14 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
className={clsx( className={clsx(
"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", "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", 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",
correct / total < 0.3 && "hover:border-mti-rose"
)} )}
data-tip="Your screen size is too small to view previous exams." data-tip="Your screen size is too small to view previous exams."
role="button"> role="button"
>
{content} {content}
</div> </div>
</div> </div>
@@ -313,29 +444,46 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
}; };
const removeInactiveAssignees = () => { const removeInactiveAssignees = () => {
const mappedResults = mapBy(assignment.results, 'user') const mappedResults = mapBy(assignment.results, "user");
const inactiveAssignees = assignment.assignees.filter((a) => !mappedResults.includes(a)) const inactiveAssignees = assignment.assignees.filter(
const activeAssignees = assignment.assignees.filter((a) => mappedResults.includes(a)) (a) => !mappedResults.includes(a)
);
const activeAssignees = assignment.assignees.filter((a) =>
mappedResults.includes(a)
);
if (!confirm(`Are you sure you want to remove ${inactiveAssignees.length} assignees?`)) return if (
!confirm(
`Are you sure you want to remove ${inactiveAssignees.length} assignees?`
)
)
return;
axios axios
.patch(`/api/assignments/${assignment.id}`, { assignees: activeAssignees }) .patch(`/api/assignments/${assignment.id}`, {
assignees: activeAssignees,
})
.then(() => { .then(() => {
toast.success(`The assignment "${assignment.name}" has been updated successfully!`); toast.success(
`The assignment "${assignment.name}" has been updated successfully!`
);
router.replace(router.asPath); router.replace(router.asPath);
}) })
.catch((e) => { .catch((e) => {
console.log(e); console.log(e);
toast.error("Something went wrong, please try again later!"); toast.error("Something went wrong, please try again later!");
}); });
} };
const copyLink = async () => { const copyLink = async () => {
const origin = window.location.origin const origin = window.location.origin;
await navigator.clipboard.writeText(`${origin}/exam?assignment=${assignment.id}`) await navigator.clipboard.writeText(
toast.success("The URL to the assignment has been copied to your clipboard!") `${origin}/exam?assignment=${assignment.id}`
} );
toast.success(
"The URL to the assignment has been copied to your clipboard!"
);
};
return ( return (
<> <>
@@ -352,7 +500,10 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="w-full flex items-center justify-between"> <div className="w-full flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link href="/assignments" className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"> <Link
href="/assignments"
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
>
<BsChevronLeft /> <BsChevronLeft />
</Link> </Link>
<h2 className="font-bold text-2xl">{assignment.name}</h2> <h2 className="font-bold text-2xl">{assignment.name}</h2>
@@ -371,16 +522,28 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`} label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
className="h-6" className="h-6"
textClassName={ textClassName={
(assignment?.results.length || 0) / (assignment?.assignees.length || 1) < 0.5 (assignment?.results.length || 0) /
(assignment?.assignees.length || 1) <
0.5
? "!text-mti-gray-dim font-light" ? "!text-mti-gray-dim font-light"
: "text-white" : "text-white"
} }
percentage={((assignment?.results.length || 0) / (assignment?.assignees.length || 1)) * 100} percentage={
((assignment?.results.length || 0) /
(assignment?.assignees.length || 1)) *
100
}
/> />
<div className="flex items-start gap-8"> <div className="flex items-start gap-8">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span>Start Date: {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}</span> <span>
<span>End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}</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>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span> <span>
@@ -390,12 +553,18 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
.map((u) => `${u.name} (${u.email})`) .map((u) => `${u.name} (${u.email})`)
.join(", ")} .join(", ")}
</span> </span>
<span>Assigner: {getUserName(users.find((x) => x.id === assignment?.assigner))}</span> <span>
Assigner:{" "}
{getUserName(users.find((x) => x.id === assignment?.assigner))}
</span>
</div> </div>
</div> </div>
{assignment.assignees.length !== 0 && assignment.results.length !== assignment.assignees.length && ( {assignment.assignees.length !== 0 &&
<Button onClick={removeInactiveAssignees} variant="outline">Remove Inactive Assignees</Button> assignment.results.length !== assignment.assignees.length && (
<Button onClick={removeInactiveAssignees} variant="outline">
Remove Inactive Assignees
</Button>
)} )}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -412,15 +581,22 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
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 === "reading" && <BsBook className="h-4 w-4" />}
{module === "listening" && <BsHeadphones className="h-4 w-4" />} {module === "listening" && (
<BsHeadphones className="h-4 w-4" />
)}
{module === "writing" && <BsPen className="h-4 w-4" />} {module === "writing" && <BsPen className="h-4 w-4" />}
{module === "speaking" && <BsMegaphone className="h-4 w-4" />} {module === "speaking" && (
<BsMegaphone className="h-4 w-4" />
)}
{module === "level" && <BsClipboard className="h-4 w-4" />} {module === "level" && <BsClipboard className="h-4 w-4" />}
{calculateAverageModuleScore(module) > -1 && ( {calculateAverageModuleScore(module) > -1 && (
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span> <span className="text-sm">
{calculateAverageModuleScore(module).toFixed(1)}
</span>
)} )}
</div> </div>
))} ))}
@@ -428,35 +604,59 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span className="text-xl font-bold"> <span className="text-xl font-bold">
Results ({assignment?.results.length}/{assignment?.assignees.length}) Results ({assignment?.results.length}/
{assignment?.assignees.length})
</span> </span>
<div> <div>
{assignment && assignment?.results.length > 0 && ( {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"> <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))} {assignment.results.map((r) =>
customContent(r.stats, r.user, r.type)
)}
</div> </div>
)} )}
{assignment && assignment?.results.length === 0 && <span className="ml-1 font-semibold">No results yet...</span>} {assignment && assignment?.results.length === 0 && (
<span className="ml-1 font-semibold">No results yet...</span>
)}
</div> </div>
</div> </div>
<div className="flex gap-4 w-full items-center justify-end"> <div className="flex gap-4 w-full items-center justify-end">
<Button variant="outline" color="purple" className="w-full max-w-[200px]" onClick={copyLink}> <Button
variant="outline"
color="purple"
className="w-full max-w-[200px]"
onClick={copyLink}
>
Copy Link Copy Link
</Button> </Button>
{assignment && {assignment &&
(assignment.results.length === assignment.assignees.length || moment().isAfter(moment(assignment.endDate))) && ( (assignment.results.length === assignment.assignees.length ||
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={deleteAssignment}> moment().isAfter(moment(assignment.endDate))) && (
<Button
variant="outline"
color="red"
className="w-full max-w-[200px]"
onClick={deleteAssignment}
>
Delete Delete
</Button> </Button>
)} )}
{/** if the assignment is not deemed as active yet, display start */} {/** if the assignment is not deemed as active yet, display start */}
{shouldRenderStart() && ( {shouldRenderStart() && (
<Button variant="outline" color="green" className="w-full max-w-[200px]" onClick={startAssignment}> <Button
variant="outline"
color="green"
className="w-full max-w-[200px]"
onClick={startAssignment}
>
Start Start
</Button> </Button>
)} )}
<Button onClick={() => router.push("/assignments")} className="w-full max-w-[200px]"> <Button
onClick={() => router.push("/assignments")}
className="w-full max-w-[200px]"
>
Close Close
</Button> </Button>
</div> </div>

View File

@@ -18,7 +18,11 @@ import { requestUser } from "@/utils/api";
import { getAssignment } from "@/utils/assignments.be"; import { getAssignment } from "@/utils/assignments.be";
import { getEntitiesWithRoles } from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
import { getGroups, getGroupsByEntities } from "@/utils/groups.be"; import { getGroups, getGroupsByEntities } from "@/utils/groups.be";
import { checkAccess, doesEntityAllow, findAllowedEntities } from "@/utils/permissions"; import {
checkAccess,
doesEntityAllow,
findAllowedEntities,
} from "@/utils/permissions";
import { calculateAverageLevel } from "@/utils/score"; import { calculateAverageLevel } from "@/utils/score";
import { getEntitiesUsers, getUsers } from "@/utils/users.be"; import { getEntitiesUsers, getUsers } from "@/utils/users.be";
import axios from "axios"; import axios from "axios";
@@ -32,34 +36,91 @@ import { useRouter } from "next/router";
import { generate } from "random-words"; import { generate } from "random-words";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import ReactDatePicker from "react-datepicker"; import ReactDatePicker from "react-datepicker";
import { BsBook, BsCheckCircle, BsChevronLeft, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle } from "react-icons/bs"; import {
BsBook,
BsCheckCircle,
BsChevronLeft,
BsClipboard,
BsHeadphones,
BsMegaphone,
BsPen,
BsXCircle,
} from "react-icons/bs";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }) => { export const getServerSideProps = withIronSessionSsr(
const user = await requestUser(req, res) async ({ req, res, params }) => {
if (!user) return redirect("/login") const user = await requestUser(req, res);
if (!user) return redirect("/login");
res.setHeader("Cache-Control", "public, s-maxage=10, stale-while-revalidate=59"); res.setHeader(
"Cache-Control",
"public, s-maxage=10, stale-while-revalidate=59"
);
const { id } = params as { id: string }; const { id } = params as { id: string };
const entityIDS = mapBy(user.entities, "id") || []; const entityIDS = mapBy(user.entities, "id") || [];
const isAdmin = checkAccess(user, ["developer", "admin"]);
const assignment = await getAssignment(id); const [assignment, entities] = await Promise.all([
if (!assignment) return redirect("/assignments") getAssignment(id),
getEntitiesWithRoles(isAdmin ? undefined : entityIDS),
]);
const entities = await (checkAccess(user, ["developer", "admin"]) ? getEntitiesWithRoles() : getEntitiesWithRoles(entityIDS)); if (!assignment) return redirect("/assignments");
const entity = entities.find((e) => e.id === assignment.entity) const entity = entities.find((e) => e.id === assignment.entity);
if (!entity) return redirect("/assignments") if (!entity) return redirect("/assignments");
if (!doesEntityAllow(user, entity, 'edit_assignment')) return redirect("/assignments")
const allowedEntities = findAllowedEntities(user, entities, 'edit_assignment') if (!doesEntityAllow(user, entity, "edit_assignment"))
return redirect("/assignments");
const users = await (checkAccess(user, ["developer", "admin"]) ? getUsers() : getEntitiesUsers(mapBy(allowedEntities, 'id'))); const allowedEntities = findAllowedEntities(
const groups = await (checkAccess(user, ["developer", "admin"]) ? getGroups() : getGroupsByEntities(mapBy(allowedEntities, 'id'))); user,
entities,
"edit_assignment"
);
return { props: serialize({ user, users, entities: allowedEntities, assignment, groups }) }; const allowEntitiesIDs = mapBy(allowedEntities, "id");
}, sessionOptions);
const [users, groups] = await Promise.all([
isAdmin
? getUsers(
{},
0,
{},
{
_id: 0,
id: 1,
type: 1,
name: 1,
email: 1,
levels: 1,
}
)
: getEntitiesUsers(allowEntitiesIDs, {}, 0, {
_id: 0,
id: 1,
type: 1,
name: 1,
email: 1,
levels: 1,
}),
isAdmin ? getGroups() : getGroupsByEntities(allowEntitiesIDs),
]);
return {
props: serialize({
user,
users,
entities: allowedEntities,
assignment,
groups,
}),
};
},
sessionOptions
);
interface Props { interface Props {
assignment: Assignment; assignment: Assignment;
@@ -71,24 +132,44 @@ interface Props {
const SIZE = 9; const SIZE = 9;
export default function AssignmentsPage({ assignment, user, users, entities, groups }: Props) { export default function AssignmentsPage({
const [selectedModules, setSelectedModules] = useState<Module[]>(assignment.exams.map((e) => e.module)); assignment,
user,
users,
entities,
groups,
}: Props) {
const [selectedModules, setSelectedModules] = useState<Module[]>(
assignment.exams.map((e) => e.module)
);
const [assignees, setAssignees] = useState<string[]>(assignment.assignees); const [assignees, setAssignees] = useState<string[]>(assignment.assignees);
const [teachers, setTeachers] = useState<string[]>(assignment.teachers || []); const [teachers, setTeachers] = useState<string[]>(assignment.teachers || []);
const [entity, setEntity] = useState<string | undefined>(assignment.entity || entities[0]?.id); const [entity, setEntity] = useState<string | undefined>(
assignment.entity || entities[0]?.id
);
const [name, setName] = useState(assignment.name); const [name, setName] = useState(assignment.name);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [startDate, setStartDate] = useState<Date | null>(moment(assignment.startDate).toDate()); const [startDate, setStartDate] = useState<Date | null>(
const [endDate, setEndDate] = useState<Date | null>(moment(assignment.endDate).toDate()); moment(assignment.startDate).toDate()
);
const [endDate, setEndDate] = useState<Date | null>(
moment(assignment.endDate).toDate()
);
const [variant, setVariant] = useState<Variant>("full"); const [variant, setVariant] = useState<Variant>("full");
const [instructorGender, setInstructorGender] = useState<InstructorGender>(assignment?.instructorGender || "varied"); const [instructorGender, setInstructorGender] = useState<InstructorGender>(
assignment?.instructorGender || "varied"
);
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false); const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
const [released, setReleased] = useState<boolean>(assignment.released || false); const [released, setReleased] = useState<boolean>(
assignment.released || false
);
const [autoStart, setAutostart] = useState<boolean>(assignment.autoStart || false); const [autoStart, setAutostart] = useState<boolean>(
assignment.autoStart || false
);
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 }[]>([]);
@@ -96,19 +177,34 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
const { exams } = useExams(); const { exams } = useExams();
const router = useRouter(); const router = useRouter();
const classrooms = useMemo(() => groups.filter((e) => e.entity === entity), [entity, groups]); const classrooms = useMemo(
() => groups.filter((e) => e.entity === entity),
[entity, groups]
);
const userStudents = useMemo(() => users.filter((x) => x.type === "student"), [users]); const userStudents = useMemo(
const userTeachers = useMemo(() => users.filter((x) => x.type === "teacher"), [users]); () => users.filter((x) => x.type === "student"),
[users]
);
const userTeachers = useMemo(
() => users.filter((x) => x.type === "teacher"),
[users]
);
const { rows: filteredStudentsRows, renderSearch: renderStudentSearch } = useListSearch([["name"], ["email"]], userStudents); const { rows: filteredStudentsRows, renderSearch: renderStudentSearch } =
const { rows: filteredTeachersRows, renderSearch: renderTeacherSearch } = useListSearch([["name"], ["email"]], userTeachers); useListSearch([["name"], ["email"]], userStudents);
const { rows: filteredTeachersRows, renderSearch: renderTeacherSearch } =
useListSearch([["name"], ["email"]], userTeachers);
const { items: studentRows, renderMinimal: renderStudentPagination } = usePagination(filteredStudentsRows, SIZE); const { items: studentRows, renderMinimal: renderStudentPagination } =
const { items: teacherRows, renderMinimal: renderTeacherPagination } = usePagination(filteredTeachersRows, SIZE); usePagination(filteredStudentsRows, SIZE);
const { items: teacherRows, renderMinimal: renderTeacherPagination } =
usePagination(filteredTeachersRows, SIZE);
useEffect(() => { useEffect(() => {
setExamIDs((prev) => prev.filter((x) => selectedModules.includes(x.module))); setExamIDs((prev) =>
prev.filter((x) => selectedModules.includes(x.module))
);
}, [selectedModules]); }, [selectedModules]);
useEffect(() => { useEffect(() => {
@@ -118,21 +214,33 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
const toggleModule = (module: Module) => { const toggleModule = (module: Module) => {
const modules = selectedModules.filter((x) => x !== module); const modules = selectedModules.filter((x) => x !== module);
setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module])); setSelectedModules((prev) =>
prev.includes(module) ? modules : [...modules, module]
);
}; };
const toggleAssignee = (user: User) => { const toggleAssignee = (user: User) => {
setAssignees((prev) => (prev.includes(user.id) ? prev.filter((a) => a !== user.id) : [...prev, user.id])); setAssignees((prev) =>
prev.includes(user.id)
? prev.filter((a) => a !== user.id)
: [...prev, user.id]
);
}; };
const toggleTeacher = (user: User) => { const toggleTeacher = (user: User) => {
setTeachers((prev) => (prev.includes(user.id) ? prev.filter((a) => a !== user.id) : [...prev, user.id])); setTeachers((prev) =>
prev.includes(user.id)
? prev.filter((a) => a !== user.id)
: [...prev, user.id]
);
}; };
const createAssignment = () => { const createAssignment = () => {
setIsLoading(true); setIsLoading(true);
(assignment ? axios.patch : axios.post)(`/api/assignments/${assignment.id}`, { (assignment ? axios.patch : axios.post)(
`/api/assignments/${assignment.id}`,
{
assignees, assignees,
name, name,
startDate, startDate,
@@ -146,9 +254,12 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
instructorGender, instructorGender,
released, released,
autoStart, autoStart,
}) }
)
.then(() => { .then(() => {
toast.success(`The assignment "${name}" has been updated successfully!`); toast.success(
`The assignment "${name}" has been updated successfully!`
);
router.push(`/assignments/${assignment.id}`); router.push(`/assignments/${assignment.id}`);
}) })
.catch((e) => { .catch((e) => {
@@ -159,14 +270,21 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
}; };
const deleteAssignment = () => { const deleteAssignment = () => {
if (!confirm(`Are you sure you want to delete the "${assignment.name}" assignment?`)) return; if (
!confirm(
`Are you sure you want to delete the "${assignment.name}" assignment?`
)
)
return;
console.log("GOT HERE"); console.log("GOT HERE");
setIsLoading(true); setIsLoading(true);
axios axios
.delete(`/api/assignments/${assignment.id}`) .delete(`/api/assignments/${assignment.id}`)
.then(() => { .then(() => {
toast.success(`The assignment "${name}" has been deleted successfully!`); toast.success(
`The assignment "${name}" has been deleted successfully!`
);
router.push("/assignments"); router.push("/assignments");
}) })
.catch((e) => { .catch((e) => {
@@ -183,7 +301,9 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
axios axios
.post(`/api/assignments/${assignment.id}/start`) .post(`/api/assignments/${assignment.id}/start`)
.then(() => { .then(() => {
toast.success(`The assignment "${name}" has been started successfully!`); toast.success(
`The assignment "${name}" has been started successfully!`
);
router.push(`/assignments/${assignment.id}`); router.push(`/assignments/${assignment.id}`);
}) })
.catch((e) => { .catch((e) => {
@@ -195,10 +315,14 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
}; };
const copyLink = async () => { const copyLink = async () => {
const origin = window.location.origin const origin = window.location.origin;
await navigator.clipboard.writeText(`${origin}/exam?assignment=${assignment.id}`) await navigator.clipboard.writeText(
toast.success("The URL to the assignment has been copied to your clipboard!") `${origin}/exam?assignment=${assignment.id}`
} );
toast.success(
"The URL to the assignment has been copied to your clipboard!"
);
};
return ( return (
<> <>
@@ -214,7 +338,10 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
<> <>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link href="/assignments" className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"> <Link
href="/assignments"
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
>
<BsChevronLeft /> <BsChevronLeft />
</Link> </Link>
<h2 className="font-bold text-2xl">Edit {assignment.name}</h2> <h2 className="font-bold text-2xl">Edit {assignment.name}</h2>
@@ -224,109 +351,180 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
<div className="w-full flex flex-col gap-4"> <div className="w-full flex flex-col gap-4">
<section className="w-full grid -md:grid-cols-1 md:grid-cols-3 place-items-center -md:flex-col -md:items-center -md:gap-12 justify-between gap-8 mt-8 px-8"> <section className="w-full grid -md:grid-cols-1 md:grid-cols-3 place-items-center -md:flex-col -md:items-center -md:gap-12 justify-between gap-8 mt-8 px-8">
<div <div
onClick={!selectedModules.includes("level") ? () => toggleModule("reading") : undefined} onClick={
!selectedModules.includes("level")
? () => toggleModule("reading")
: undefined
}
className={clsx( className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer", "w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
selectedModules.includes("reading") ? "border-mti-purple-light" : "border-mti-gray-platinum", selectedModules.includes("reading")
)}> ? "border-mti-purple-light"
: "border-mti-gray-platinum"
)}
>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-1/2 -translate-y-1/2 left-0 -translate-x-1/2"> <div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsBook className="text-white w-7 h-7" /> <BsBook className="text-white w-7 h-7" />
</div> </div>
<span className="ml-8 font-semibold">Reading</span> <span className="ml-8 font-semibold">Reading</span>
{!selectedModules.includes("reading") && !selectedModules.includes("level") && ( {!selectedModules.includes("reading") &&
!selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" /> <div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)} )}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />} {selectedModules.includes("level") && (
{selectedModules.includes("reading") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />} <BsXCircle className="text-mti-red-light w-8 h-8" />
)}
{selectedModules.includes("reading") && (
<BsCheckCircle className="text-mti-purple-light w-8 h-8" />
)}
</div> </div>
<div <div
onClick={!selectedModules.includes("level") ? () => toggleModule("listening") : undefined} onClick={
!selectedModules.includes("level")
? () => toggleModule("listening")
: undefined
}
className={clsx( className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer", "w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
selectedModules.includes("listening") ? "border-mti-purple-light" : "border-mti-gray-platinum", selectedModules.includes("listening")
)}> ? "border-mti-purple-light"
: "border-mti-gray-platinum"
)}
>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-1/2 -translate-y-1/2 left-0 -translate-x-1/2"> <div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsHeadphones className="text-white w-7 h-7" /> <BsHeadphones className="text-white w-7 h-7" />
</div> </div>
<span className="ml-8 font-semibold">Listening</span> <span className="ml-8 font-semibold">Listening</span>
{!selectedModules.includes("listening") && !selectedModules.includes("level") && ( {!selectedModules.includes("listening") &&
!selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" /> <div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)} )}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />} {selectedModules.includes("level") && (
{selectedModules.includes("listening") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />} <BsXCircle className="text-mti-red-light w-8 h-8" />
)}
{selectedModules.includes("listening") && (
<BsCheckCircle className="text-mti-purple-light w-8 h-8" />
)}
</div> </div>
<div <div
onClick={ onClick={
(!selectedModules.includes("level") && selectedModules.length === 0) || selectedModules.includes("level") (!selectedModules.includes("level") &&
selectedModules.length === 0) ||
selectedModules.includes("level")
? () => toggleModule("level") ? () => toggleModule("level")
: undefined : undefined
} }
className={clsx( className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer", "w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
selectedModules.includes("level") ? "border-mti-purple-light" : "border-mti-gray-platinum", selectedModules.includes("level")
)}> ? "border-mti-purple-light"
: "border-mti-gray-platinum"
)}
>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-level top-1/2 -translate-y-1/2 left-0 -translate-x-1/2"> <div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-level top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsClipboard className="text-white w-7 h-7" /> <BsClipboard className="text-white w-7 h-7" />
</div> </div>
<span className="ml-8 font-semibold">Level</span> <span className="ml-8 font-semibold">Level</span>
{!selectedModules.includes("level") && selectedModules.length === 0 && ( {!selectedModules.includes("level") &&
selectedModules.length === 0 && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" /> <div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)} )}
{!selectedModules.includes("level") && selectedModules.length > 0 && <BsXCircle className="text-mti-red-light w-8 h-8" />} {!selectedModules.includes("level") &&
{selectedModules.includes("level") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />} selectedModules.length > 0 && (
<BsXCircle className="text-mti-red-light w-8 h-8" />
)}
{selectedModules.includes("level") && (
<BsCheckCircle className="text-mti-purple-light w-8 h-8" />
)}
</div> </div>
<div <div
onClick={!selectedModules.includes("level") ? () => toggleModule("writing") : undefined} onClick={
!selectedModules.includes("level")
? () => toggleModule("writing")
: undefined
}
className={clsx( className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer", "w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
selectedModules.includes("writing") ? "border-mti-purple-light" : "border-mti-gray-platinum", selectedModules.includes("writing")
)}> ? "border-mti-purple-light"
: "border-mti-gray-platinum"
)}
>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-1/2 -translate-y-1/2 left-0 -translate-x-1/2"> <div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsPen className="text-white w-7 h-7" /> <BsPen className="text-white w-7 h-7" />
</div> </div>
<span className="ml-8 font-semibold">Writing</span> <span className="ml-8 font-semibold">Writing</span>
{!selectedModules.includes("writing") && !selectedModules.includes("level") && ( {!selectedModules.includes("writing") &&
!selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" /> <div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)} )}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />} {selectedModules.includes("level") && (
{selectedModules.includes("writing") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />} <BsXCircle className="text-mti-red-light w-8 h-8" />
)}
{selectedModules.includes("writing") && (
<BsCheckCircle className="text-mti-purple-light w-8 h-8" />
)}
</div> </div>
<div <div
onClick={!selectedModules.includes("level") ? () => toggleModule("speaking") : undefined} onClick={
!selectedModules.includes("level")
? () => toggleModule("speaking")
: undefined
}
className={clsx( className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer", "w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
selectedModules.includes("speaking") ? "border-mti-purple-light" : "border-mti-gray-platinum", selectedModules.includes("speaking")
)}> ? "border-mti-purple-light"
: "border-mti-gray-platinum"
)}
>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-1/2 -translate-y-1/2 left-0 -translate-x-1/2"> <div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsMegaphone className="text-white w-7 h-7" /> <BsMegaphone className="text-white w-7 h-7" />
</div> </div>
<span className="ml-8 font-semibold">Speaking</span> <span className="ml-8 font-semibold">Speaking</span>
{!selectedModules.includes("speaking") && !selectedModules.includes("level") && ( {!selectedModules.includes("speaking") &&
!selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" /> <div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)} )}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />} {selectedModules.includes("level") && (
{selectedModules.includes("speaking") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />} <BsXCircle className="text-mti-red-light w-8 h-8" />
)}
{selectedModules.includes("speaking") && (
<BsCheckCircle className="text-mti-purple-light w-8 h-8" />
)}
</div> </div>
</section> </section>
<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">
<Input type="text" name="name" onChange={(e) => setName(e)} defaultValue={name} label="Assignment Name" required /> <Input
type="text"
name="name"
onChange={(e) => setName(e)}
defaultValue={name}
label="Assignment Name"
required
/>
<Select <Select
label="Entity" label="Entity"
options={entities.map((e) => ({ value: e.id, label: e.label }))} options={entities.map((e) => ({ value: e.id, label: e.label }))}
onChange={(v) => setEntity(v ? v.value! : undefined)} onChange={(v) => setEntity(v ? v.value! : undefined)}
defaultValue={{ value: entities[0]?.id, label: entities[0]?.label }} defaultValue={{
value: entities[0]?.id,
label: entities[0]?.label,
}}
/> />
</div> </div>
<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">Limit 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",
"hover:border-mti-purple tooltip z-10", "hover:border-mti-purple tooltip z-10",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out"
)} )}
popperClassName="!z-20" popperClassName="!z-20"
filterTime={(date) => moment(date).isSameOrAfter(new Date())} filterTime={(date) => moment(date).isSameOrAfter(new Date())}
@@ -337,12 +535,14 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
/> />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label className="font-normal text-base text-mti-gray-dim">End Date *</label> <label className="font-normal text-base text-mti-gray-dim">
End 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",
"hover:border-mti-purple tooltip z-10", "hover:border-mti-purple tooltip z-10",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out"
)} )}
popperClassName="!z-20" popperClassName="!z-20"
filterTime={(date) => moment(date).isAfter(startDate)} filterTime={(date) => moment(date).isAfter(startDate)}
@@ -356,13 +556,19 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
{selectedModules.includes("speaking") && ( {selectedModules.includes("speaking") && (
<div className="flex flex-col gap-3 w-full"> <div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Speaking Instructor&apos;s Gender</label> <label className="font-normal text-base text-mti-gray-dim">
Speaking Instructor&apos;s Gender
</label>
<Select <Select
value={{ value={{
value: instructorGender, value: instructorGender,
label: capitalize(instructorGender), label: capitalize(instructorGender),
}} }}
onChange={(value) => (value ? setInstructorGender(value.value as InstructorGender) : null)} onChange={(value) =>
value
? setInstructorGender(value.value as InstructorGender)
: null
}
disabled={!selectedModules.includes("speaking") || !!assignment} disabled={!selectedModules.includes("speaking") || !!assignment}
options={[ options={[
{ value: "male", label: "Male" }, { value: "male", label: "Male" },
@@ -382,11 +588,16 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
<div className="grid md:grid-cols-2 w-full gap-4"> <div className="grid md:grid-cols-2 w-full gap-4">
{selectedModules.map((module) => ( {selectedModules.map((module) => (
<div key={module} className="flex flex-col gap-3 w-full"> <div key={module} className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">{capitalize(module)} Exam</label> <label className="font-normal text-base text-mti-gray-dim">
{capitalize(module)} Exam
</label>
<Select <Select
value={{ value={{
value: examIDs.find((e) => e.module === module)?.id || null, value:
label: examIDs.find((e) => e.module === module)?.id || "", examIDs.find((e) => e.module === module)?.id ||
null,
label:
examIDs.find((e) => e.module === module)?.id || "",
}} }}
onChange={(value) => onChange={(value) =>
value value
@@ -394,7 +605,9 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
...prev.filter((x) => x.module !== module), ...prev.filter((x) => x.module !== module),
{ id: value.value!, module }, { id: value.value!, module },
]) ])
: setExamIDs((prev) => prev.filter((x) => x.module !== module)) : setExamIDs((prev) =>
prev.filter((x) => x.module !== module)
)
} }
options={exams options={exams
.filter((x) => !x.isDiagnostic && x.module === module) .filter((x) => !x.isDiagnostic && x.module === module)
@@ -408,25 +621,40 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
)} )}
<section className="w-full flex flex-col gap-4"> <section className="w-full flex flex-col gap-4">
<span className="font-semibold">Assignees ({assignees.length} selected)</span> <span className="font-semibold">
Assignees ({assignees.length} selected)
</span>
<div className="grid grid-cols-5 gap-4"> <div className="grid grid-cols-5 gap-4">
{classrooms.map((g) => ( {classrooms.map((g) => (
<button <button
key={g.id} key={g.id}
onClick={() => { onClick={() => {
const groupStudentIds = users.filter((u) => g.participants.includes(u.id)).map((u) => u.id); const groupStudentIds = users.reduce<string[]>((acc, u) => {
if (g.participants.includes(u.id)) {
acc.push(u.id);
}
return acc;
}, []);
if (groupStudentIds.every((u) => assignees.includes(u))) { if (groupStudentIds.every((u) => assignees.includes(u))) {
setAssignees((prev) => prev.filter((a) => !groupStudentIds.includes(a))); setAssignees((prev) =>
prev.filter((a) => !groupStudentIds.includes(a))
);
} else { } else {
setAssignees((prev) => [...prev.filter((a) => !groupStudentIds.includes(a)), ...groupStudentIds]); setAssignees((prev) => [
...prev.filter((a) => !groupStudentIds.includes(a)),
...groupStudentIds,
]);
} }
}} }}
className={clsx( className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light", "bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
users.filter((u) => g.participants.includes(u.id)).every((u) => assignees.includes(u.id)) && users
"!bg-mti-purple-light !text-white", .filter((u) => g.participants.includes(u.id))
)}> .every((u) => assignees.includes(u.id)) &&
"!bg-mti-purple-light !text-white"
)}
>
{g.name} {g.name}
</button> </button>
))} ))}
@@ -444,9 +672,12 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
className={clsx( className={clsx(
"p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72", "p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72",
"transition ease-in-out duration-300", "transition ease-in-out duration-300",
assignees.includes(user.id) ? "border-mti-purple" : "border-mti-gray-platinum", assignees.includes(user.id)
? "border-mti-purple"
: "border-mti-gray-platinum"
)} )}
key={user.id}> key={user.id}
>
<span className="flex flex-col gap-0 justify-center"> <span className="flex flex-col gap-0 justify-center">
<span className="font-semibold">{user.name}</span> <span className="font-semibold">{user.name}</span>
<span className="text-sm opacity-80">{user.email}</span> <span className="text-sm opacity-80">{user.email}</span>
@@ -472,25 +703,43 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
{user.type !== "teacher" && ( {user.type !== "teacher" && (
<section className="w-full flex flex-col gap-3"> <section className="w-full flex flex-col gap-3">
<span className="font-semibold">Teachers ({teachers.length} selected)</span> <span className="font-semibold">
Teachers ({teachers.length} selected)
</span>
<div className="grid grid-cols-5 gap-4"> <div className="grid grid-cols-5 gap-4">
{classrooms.map((g) => ( {classrooms.map((g) => (
<button <button
key={g.id} key={g.id}
onClick={() => { onClick={() => {
const groupStudentIds = users.filter((u) => g.participants.includes(u.id)).map((u) => u.id); const groupStudentIds = users.reduce<string[]>(
(acc, u) => {
if (g.participants.includes(u.id)) {
acc.push(u.id);
}
return acc;
},
[]
);
if (groupStudentIds.every((u) => teachers.includes(u))) { if (groupStudentIds.every((u) => teachers.includes(u))) {
setTeachers((prev) => prev.filter((a) => !groupStudentIds.includes(a))); setTeachers((prev) =>
prev.filter((a) => !groupStudentIds.includes(a))
);
} else { } else {
setTeachers((prev) => [...prev.filter((a) => !groupStudentIds.includes(a)), ...groupStudentIds]); setTeachers((prev) => [
...prev.filter((a) => !groupStudentIds.includes(a)),
...groupStudentIds,
]);
} }
}} }}
className={clsx( className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light", "bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
users.filter((u) => g.participants.includes(u.id)).every((u) => teachers.includes(u.id)) && users
"!bg-mti-purple-light !text-white", .filter((u) => g.participants.includes(u.id))
)}> .every((u) => teachers.includes(u.id)) &&
"!bg-mti-purple-light !text-white"
)}
>
{g.name} {g.name}
</button> </button>
))} ))}
@@ -508,9 +757,12 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
className={clsx( className={clsx(
"p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72", "p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72",
"transition ease-in-out duration-300", "transition ease-in-out duration-300",
teachers.includes(user.id) ? "border-mti-purple" : "border-mti-gray-platinum", teachers.includes(user.id)
? "border-mti-purple"
: "border-mti-gray-platinum"
)} )}
key={user.id}> key={user.id}
>
<span className="flex flex-col gap-0 justify-center"> <span className="flex flex-col gap-0 justify-center">
<span className="font-semibold">{user.name}</span> <span className="font-semibold">{user.name}</span>
<span className="text-sm opacity-80">{user.email}</span> <span className="text-sm opacity-80">{user.email}</span>
@@ -529,21 +781,40 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
)} )}
<div className="flex gap-4 w-full items-end"> <div className="flex gap-4 w-full items-end">
<Checkbox isChecked={variant === "full"} onChange={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}> <Checkbox
isChecked={variant === "full"}
onChange={() =>
setVariant((prev) => (prev === "full" ? "partial" : "full"))
}
>
Full length exams Full length exams
</Checkbox> </Checkbox>
<Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple((d) => !d)}> <Checkbox
isChecked={generateMultiple}
onChange={() => setGenerateMultiple((d) => !d)}
>
Generate different exams Generate different exams
</Checkbox> </Checkbox>
<Checkbox isChecked={released} onChange={() => setReleased((d) => !d)}> <Checkbox
isChecked={released}
onChange={() => setReleased((d) => !d)}
>
Auto release results Auto release results
</Checkbox> </Checkbox>
<Checkbox isChecked={autoStart} onChange={() => setAutostart((d) => !d)}> <Checkbox
isChecked={autoStart}
onChange={() => setAutostart((d) => !d)}
>
Auto start exam 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">
<Button variant="outline" color="purple" className="w-full max-w-[200px]" onClick={copyLink}> <Button
variant="outline"
color="purple"
className="w-full max-w-[200px]"
onClick={copyLink}
>
Copy Link Copy Link
</Button> </Button>
<Button <Button
@@ -551,7 +822,8 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
variant="outline" variant="outline"
onClick={() => router.push("/assignments")} onClick={() => router.push("/assignments")}
disabled={isLoading} disabled={isLoading}
isLoading={isLoading}> isLoading={isLoading}
>
Cancel Cancel
</Button> </Button>
<Button <Button
@@ -560,7 +832,8 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
variant="outline" variant="outline"
onClick={startAssignment} onClick={startAssignment}
disabled={isLoading || moment().isAfter(startDate)} disabled={isLoading || moment().isAfter(startDate)}
isLoading={isLoading}> isLoading={isLoading}
>
Start Start
</Button> </Button>
<Button <Button
@@ -569,7 +842,8 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
variant="outline" variant="outline"
onClick={deleteAssignment} onClick={deleteAssignment}
disabled={isLoading} disabled={isLoading}
isLoading={isLoading}> isLoading={isLoading}
>
Delete Delete
</Button> </Button>
<Button <Button
@@ -583,7 +857,8 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
} }
className="w-full max-w-[200px]" className="w-full max-w-[200px]"
onClick={createAssignment} onClick={createAssignment}
isLoading={isLoading}> isLoading={isLoading}
>
Update Update
</Button> </Button>
</div> </div>

View File

@@ -32,21 +32,61 @@ import { useRouter } from "next/router";
import { generate } from "random-words"; import { generate } from "random-words";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import ReactDatePicker from "react-datepicker"; import ReactDatePicker from "react-datepicker";
import { BsBook, BsCheckCircle, BsChevronLeft, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle } from "react-icons/bs"; import {
BsBook,
BsCheckCircle,
BsChevronLeft,
BsClipboard,
BsHeadphones,
BsMegaphone,
BsPen,
BsXCircle,
} from "react-icons/bs";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res);
if (!user) return redirect("/login") if (!user) return redirect("/login");
const entityIDS = mapBy(user.entities, "id") || []; const entityIDS = mapBy(user.entities, "id") || [];
const entities = await (checkAccess(user, ["developer", "admin"]) ? getEntitiesWithRoles() : getEntitiesWithRoles(entityIDS)); const entities = await (checkAccess(user, ["developer", "admin"])
? getEntitiesWithRoles()
: getEntitiesWithRoles(entityIDS));
const allowedEntities = findAllowedEntities(user, entities, 'create_assignment') const allowedEntities = findAllowedEntities(
if (allowedEntities.length === 0) return redirect("/assignments") user,
entities,
"create_assignment"
);
if (allowedEntities.length === 0) return redirect("/assignments");
const users = await (isAdmin(user) ? getUsers() : getEntitiesUsers(mapBy(allowedEntities, 'id'))); const [users, groups] = await Promise.all([
const groups = await (isAdmin(user) ? getGroups() : getGroupsByEntities(mapBy(allowedEntities, 'id'))); isAdmin(user)
? getUsers(
{},
0,
{},
{
_id: 0,
id: 1,
type: 1,
name: 1,
email: 1,
levels: 1,
}
)
: getEntitiesUsers(mapBy(allowedEntities, "id"), {}, 0, {
_id: 0,
id: 1,
type: 1,
name: 1,
email: 1,
levels: 1,
}),
isAdmin(user)
? getGroups()
: getGroupsByEntities(mapBy(allowedEntities, "id")),
]);
return { props: serialize({ user, users, entities, groups }) }; return { props: serialize({ user, users, entities, groups }) };
}, sessionOptions); }, sessionOptions);
@@ -61,10 +101,17 @@ interface Props {
const SIZE = 9; const SIZE = 9;
export default function AssignmentsPage({ user, users, groups, entities }: Props) { export default function AssignmentsPage({
user,
users,
groups,
entities,
}: Props) {
const [selectedModules, setSelectedModules] = useState<Module[]>([]); const [selectedModules, setSelectedModules] = useState<Module[]>([]);
const [assignees, setAssignees] = useState<string[]>([]); const [assignees, setAssignees] = useState<string[]>([]);
const [teachers, setTeachers] = useState<string[]>([...(user.type === "teacher" ? [user.id] : [])]); const [teachers, setTeachers] = useState<string[]>([
...(user.type === "teacher" ? [user.id] : []),
]);
const [entity, setEntity] = useState<string | undefined>(entities[0]?.id); const [entity, setEntity] = useState<string | undefined>(entities[0]?.id);
const [name, setName] = useState( const [name, setName] = useState(
generate({ generate({
@@ -74,14 +121,19 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
max: 3, max: 3,
join: " ", join: " ",
formatter: capitalize, formatter: capitalize,
}), })
); );
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [startDate, setStartDate] = useState<Date | null>(moment().add(1, "hour").toDate()); const [startDate, setStartDate] = useState<Date | null>(
moment().add(1, "hour").toDate()
);
const [endDate, setEndDate] = useState<Date | null>(moment().hours(23).minutes(59).add(8, "day").toDate()); const [endDate, setEndDate] = useState<Date | null>(
moment().hours(23).minutes(59).add(8, "day").toDate()
);
const [variant, setVariant] = useState<Variant>("full"); const [variant, setVariant] = useState<Variant>("full");
const [instructorGender, setInstructorGender] = useState<InstructorGender>("varied"); const [instructorGender, setInstructorGender] =
useState<InstructorGender>("varied");
const [generateMultiple, setGenerateMultiple] = useState<boolean>(false); const [generateMultiple, setGenerateMultiple] = useState<boolean>(false);
const [released, setReleased] = useState<boolean>(false); const [released, setReleased] = useState<boolean>(false);
@@ -94,20 +146,38 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
const { exams } = useExams(); const { exams } = useExams();
const router = useRouter(); const router = useRouter();
const classrooms = useMemo(() => groups.filter((e) => e.entity?.id === entity), [entity, groups]); const classrooms = useMemo(
const allowedUsers = useMemo(() => users.filter((u) => mapBy(u.entities, 'id').includes(entity || "")), [users, entity]) () => groups.filter((e) => e.entity?.id === entity),
[entity, groups]
);
const allowedUsers = useMemo(
() => users.filter((u) => mapBy(u.entities, "id").includes(entity || "")),
[users, entity]
);
const userStudents = useMemo(() => allowedUsers.filter((x) => x.type === "student"), [allowedUsers]); const userStudents = useMemo(
const userTeachers = useMemo(() => allowedUsers.filter((x) => x.type === "teacher"), [allowedUsers]); () => allowedUsers.filter((x) => x.type === "student"),
[allowedUsers]
);
const userTeachers = useMemo(
() => allowedUsers.filter((x) => x.type === "teacher"),
[allowedUsers]
);
const { rows: filteredStudentsRows, renderSearch: renderStudentSearch } = useListSearch([["name"], ["email"]], userStudents); const { rows: filteredStudentsRows, renderSearch: renderStudentSearch } =
const { rows: filteredTeachersRows, renderSearch: renderTeacherSearch } = useListSearch([["name"], ["email"]], userTeachers); useListSearch([["name"], ["email"]], userStudents);
const { rows: filteredTeachersRows, renderSearch: renderTeacherSearch } =
useListSearch([["name"], ["email"]], userTeachers);
const { items: studentRows, renderMinimal: renderStudentPagination } = usePagination(filteredStudentsRows, SIZE); const { items: studentRows, renderMinimal: renderStudentPagination } =
const { items: teacherRows, renderMinimal: renderTeacherPagination } = usePagination(filteredTeachersRows, SIZE); usePagination(filteredStudentsRows, SIZE);
const { items: teacherRows, renderMinimal: renderTeacherPagination } =
usePagination(filteredTeachersRows, SIZE);
useEffect(() => { useEffect(() => {
setExamIDs((prev) => prev.filter((x) => selectedModules.includes(x.module))); setExamIDs((prev) =>
prev.filter((x) => selectedModules.includes(x.module))
);
}, [selectedModules]); }, [selectedModules]);
useEffect(() => { useEffect(() => {
@@ -117,15 +187,25 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
const toggleModule = (module: Module) => { const toggleModule = (module: Module) => {
const modules = selectedModules.filter((x) => x !== module); const modules = selectedModules.filter((x) => x !== module);
setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module])); setSelectedModules((prev) =>
prev.includes(module) ? modules : [...modules, module]
);
}; };
const toggleAssignee = (user: User) => { const toggleAssignee = (user: User) => {
setAssignees((prev) => (prev.includes(user.id) ? prev.filter((a) => a !== user.id) : [...prev, user.id])); setAssignees((prev) =>
prev.includes(user.id)
? prev.filter((a) => a !== user.id)
: [...prev, user.id]
);
}; };
const toggleTeacher = (user: User) => { const toggleTeacher = (user: User) => {
setTeachers((prev) => (prev.includes(user.id) ? prev.filter((a) => a !== user.id) : [...prev, user.id])); setTeachers((prev) =>
prev.includes(user.id)
? prev.filter((a) => a !== user.id)
: [...prev, user.id]
);
}; };
const createAssignment = () => { const createAssignment = () => {
@@ -148,7 +228,9 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
autoStart, autoStart,
}) })
.then((result) => { .then((result) => {
toast.success(`The assignment "${name}" has been created successfully!`); toast.success(
`The assignment "${name}" has been created successfully!`
);
router.push(`/assignments/${result.data.id}`); router.push(`/assignments/${result.data.id}`);
}) })
.catch((e) => { .catch((e) => {
@@ -172,7 +254,10 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
<> <>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link href="/assignments" className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"> <Link
href="/assignments"
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
>
<BsChevronLeft /> <BsChevronLeft />
</Link> </Link>
<h2 className="font-bold text-2xl">Create Assignment</h2> <h2 className="font-bold text-2xl">Create Assignment</h2>
@@ -182,109 +267,180 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
<div className="w-full flex flex-col gap-4"> <div className="w-full flex flex-col gap-4">
<section className="w-full grid -md:grid-cols-1 md:grid-cols-3 place-items-center -md:flex-col -md:items-center -md:gap-12 justify-between gap-8 mt-8 px-8"> <section className="w-full grid -md:grid-cols-1 md:grid-cols-3 place-items-center -md:flex-col -md:items-center -md:gap-12 justify-between gap-8 mt-8 px-8">
<div <div
onClick={!selectedModules.includes("level") ? () => toggleModule("reading") : undefined} onClick={
!selectedModules.includes("level")
? () => toggleModule("reading")
: undefined
}
className={clsx( className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer", "w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
selectedModules.includes("reading") ? "border-mti-purple-light" : "border-mti-gray-platinum", selectedModules.includes("reading")
)}> ? "border-mti-purple-light"
: "border-mti-gray-platinum"
)}
>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-1/2 -translate-y-1/2 left-0 -translate-x-1/2"> <div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsBook className="text-white w-7 h-7" /> <BsBook className="text-white w-7 h-7" />
</div> </div>
<span className="ml-8 font-semibold">Reading</span> <span className="ml-8 font-semibold">Reading</span>
{!selectedModules.includes("reading") && !selectedModules.includes("level") && ( {!selectedModules.includes("reading") &&
!selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" /> <div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)} )}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />} {selectedModules.includes("level") && (
{selectedModules.includes("reading") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />} <BsXCircle className="text-mti-red-light w-8 h-8" />
)}
{selectedModules.includes("reading") && (
<BsCheckCircle className="text-mti-purple-light w-8 h-8" />
)}
</div> </div>
<div <div
onClick={!selectedModules.includes("level") ? () => toggleModule("listening") : undefined} onClick={
!selectedModules.includes("level")
? () => toggleModule("listening")
: undefined
}
className={clsx( className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer", "w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
selectedModules.includes("listening") ? "border-mti-purple-light" : "border-mti-gray-platinum", selectedModules.includes("listening")
)}> ? "border-mti-purple-light"
: "border-mti-gray-platinum"
)}
>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-1/2 -translate-y-1/2 left-0 -translate-x-1/2"> <div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsHeadphones className="text-white w-7 h-7" /> <BsHeadphones className="text-white w-7 h-7" />
</div> </div>
<span className="ml-8 font-semibold">Listening</span> <span className="ml-8 font-semibold">Listening</span>
{!selectedModules.includes("listening") && !selectedModules.includes("level") && ( {!selectedModules.includes("listening") &&
!selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" /> <div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)} )}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />} {selectedModules.includes("level") && (
{selectedModules.includes("listening") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />} <BsXCircle className="text-mti-red-light w-8 h-8" />
)}
{selectedModules.includes("listening") && (
<BsCheckCircle className="text-mti-purple-light w-8 h-8" />
)}
</div> </div>
<div <div
onClick={ onClick={
(!selectedModules.includes("level") && selectedModules.length === 0) || selectedModules.includes("level") (!selectedModules.includes("level") &&
selectedModules.length === 0) ||
selectedModules.includes("level")
? () => toggleModule("level") ? () => toggleModule("level")
: undefined : undefined
} }
className={clsx( className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer", "w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
selectedModules.includes("level") ? "border-mti-purple-light" : "border-mti-gray-platinum", selectedModules.includes("level")
)}> ? "border-mti-purple-light"
: "border-mti-gray-platinum"
)}
>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-level top-1/2 -translate-y-1/2 left-0 -translate-x-1/2"> <div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-level top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsClipboard className="text-white w-7 h-7" /> <BsClipboard className="text-white w-7 h-7" />
</div> </div>
<span className="ml-8 font-semibold">Level</span> <span className="ml-8 font-semibold">Level</span>
{!selectedModules.includes("level") && selectedModules.length === 0 && ( {!selectedModules.includes("level") &&
selectedModules.length === 0 && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" /> <div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)} )}
{!selectedModules.includes("level") && selectedModules.length > 0 && <BsXCircle className="text-mti-red-light w-8 h-8" />} {!selectedModules.includes("level") &&
{selectedModules.includes("level") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />} selectedModules.length > 0 && (
<BsXCircle className="text-mti-red-light w-8 h-8" />
)}
{selectedModules.includes("level") && (
<BsCheckCircle className="text-mti-purple-light w-8 h-8" />
)}
</div> </div>
<div <div
onClick={!selectedModules.includes("level") ? () => toggleModule("writing") : undefined} onClick={
!selectedModules.includes("level")
? () => toggleModule("writing")
: undefined
}
className={clsx( className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer", "w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
selectedModules.includes("writing") ? "border-mti-purple-light" : "border-mti-gray-platinum", selectedModules.includes("writing")
)}> ? "border-mti-purple-light"
: "border-mti-gray-platinum"
)}
>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-1/2 -translate-y-1/2 left-0 -translate-x-1/2"> <div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsPen className="text-white w-7 h-7" /> <BsPen className="text-white w-7 h-7" />
</div> </div>
<span className="ml-8 font-semibold">Writing</span> <span className="ml-8 font-semibold">Writing</span>
{!selectedModules.includes("writing") && !selectedModules.includes("level") && ( {!selectedModules.includes("writing") &&
!selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" /> <div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)} )}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />} {selectedModules.includes("level") && (
{selectedModules.includes("writing") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />} <BsXCircle className="text-mti-red-light w-8 h-8" />
)}
{selectedModules.includes("writing") && (
<BsCheckCircle className="text-mti-purple-light w-8 h-8" />
)}
</div> </div>
<div <div
onClick={!selectedModules.includes("level") ? () => toggleModule("speaking") : undefined} onClick={
!selectedModules.includes("level")
? () => toggleModule("speaking")
: undefined
}
className={clsx( className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer", "w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
selectedModules.includes("speaking") ? "border-mti-purple-light" : "border-mti-gray-platinum", selectedModules.includes("speaking")
)}> ? "border-mti-purple-light"
: "border-mti-gray-platinum"
)}
>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-1/2 -translate-y-1/2 left-0 -translate-x-1/2"> <div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsMegaphone className="text-white w-7 h-7" /> <BsMegaphone className="text-white w-7 h-7" />
</div> </div>
<span className="ml-8 font-semibold">Speaking</span> <span className="ml-8 font-semibold">Speaking</span>
{!selectedModules.includes("speaking") && !selectedModules.includes("level") && ( {!selectedModules.includes("speaking") &&
!selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" /> <div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)} )}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />} {selectedModules.includes("level") && (
{selectedModules.includes("speaking") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />} <BsXCircle className="text-mti-red-light w-8 h-8" />
)}
{selectedModules.includes("speaking") && (
<BsCheckCircle className="text-mti-purple-light w-8 h-8" />
)}
</div> </div>
</section> </section>
<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">
<Input type="text" name="name" onChange={(e) => setName(e)} defaultValue={name} label="Assignment Name" required /> <Input
type="text"
name="name"
onChange={(e) => setName(e)}
defaultValue={name}
label="Assignment Name"
required
/>
<Select <Select
label="Entity" label="Entity"
options={entities.map((e) => ({ value: e.id, label: e.label }))} options={entities.map((e) => ({ value: e.id, label: e.label }))}
onChange={(v) => setEntity(v ? v.value! : undefined)} onChange={(v) => setEntity(v ? v.value! : undefined)}
defaultValue={{ value: entities[0]?.id, label: entities[0]?.label }} defaultValue={{
value: entities[0]?.id,
label: entities[0]?.label,
}}
/> />
</div> </div>
<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">Limit 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",
"hover:border-mti-purple tooltip z-10", "hover:border-mti-purple tooltip z-10",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out"
)} )}
popperClassName="!z-20" popperClassName="!z-20"
filterTime={(date) => moment(date).isSameOrAfter(new Date())} filterTime={(date) => moment(date).isSameOrAfter(new Date())}
@@ -295,12 +451,14 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
/> />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label className="font-normal text-base text-mti-gray-dim">End Date *</label> <label className="font-normal text-base text-mti-gray-dim">
End 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",
"hover:border-mti-purple tooltip z-10", "hover:border-mti-purple tooltip z-10",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out"
)} )}
popperClassName="!z-20" popperClassName="!z-20"
filterTime={(date) => moment(date).isAfter(startDate)} filterTime={(date) => moment(date).isAfter(startDate)}
@@ -314,13 +472,19 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
{selectedModules.includes("speaking") && ( {selectedModules.includes("speaking") && (
<div className="flex flex-col gap-3 w-full"> <div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Speaking Instructor&apos;s Gender</label> <label className="font-normal text-base text-mti-gray-dim">
Speaking Instructor&apos;s Gender
</label>
<Select <Select
value={{ value={{
value: instructorGender, value: instructorGender,
label: capitalize(instructorGender), label: capitalize(instructorGender),
}} }}
onChange={(value) => (value ? setInstructorGender(value.value as InstructorGender) : null)} onChange={(value) =>
value
? setInstructorGender(value.value as InstructorGender)
: null
}
disabled={!selectedModules.includes("speaking")} disabled={!selectedModules.includes("speaking")}
options={[ options={[
{ value: "male", label: "Male" }, { value: "male", label: "Male" },
@@ -340,11 +504,16 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
<div className="grid md:grid-cols-2 w-full gap-4"> <div className="grid md:grid-cols-2 w-full gap-4">
{selectedModules.map((module) => ( {selectedModules.map((module) => (
<div key={module} className="flex flex-col gap-3 w-full"> <div key={module} className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">{capitalize(module)} Exam</label> <label className="font-normal text-base text-mti-gray-dim">
{capitalize(module)} Exam
</label>
<Select <Select
value={{ value={{
value: examIDs.find((e) => e.module === module)?.id || null, value:
label: examIDs.find((e) => e.module === module)?.id || "", examIDs.find((e) => e.module === module)?.id ||
null,
label:
examIDs.find((e) => e.module === module)?.id || "",
}} }}
onChange={(value) => onChange={(value) =>
value value
@@ -352,7 +521,9 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
...prev.filter((x) => x.module !== module), ...prev.filter((x) => x.module !== module),
{ id: value.value!, module }, { id: value.value!, module },
]) ])
: setExamIDs((prev) => prev.filter((x) => x.module !== module)) : setExamIDs((prev) =>
prev.filter((x) => x.module !== module)
)
} }
options={exams options={exams
.filter((x) => !x.isDiagnostic && x.module === module) .filter((x) => !x.isDiagnostic && x.module === module)
@@ -366,25 +537,40 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
)} )}
<section className="w-full flex flex-col gap-4"> <section className="w-full flex flex-col gap-4">
<span className="font-semibold">Assignees ({assignees.length} selected)</span> <span className="font-semibold">
Assignees ({assignees.length} selected)
</span>
<div className="grid grid-cols-5 gap-4"> <div className="grid grid-cols-5 gap-4">
{classrooms.map((g) => ( {classrooms.map((g) => (
<button <button
key={g.id} key={g.id}
onClick={() => { onClick={() => {
const groupStudentIds = users.filter((u) => g.participants.includes(u.id)).map((u) => u.id); const groupStudentIds = users.reduce<string[]>((acc, u) => {
if (g.participants.includes(u.id)) {
acc.push(u.id);
}
return acc;
}, []);
if (groupStudentIds.every((u) => assignees.includes(u))) { if (groupStudentIds.every((u) => assignees.includes(u))) {
setAssignees((prev) => prev.filter((a) => !groupStudentIds.includes(a))); setAssignees((prev) =>
prev.filter((a) => !groupStudentIds.includes(a))
);
} else { } else {
setAssignees((prev) => [...prev.filter((a) => !groupStudentIds.includes(a)), ...groupStudentIds]); setAssignees((prev) => [
...prev.filter((a) => !groupStudentIds.includes(a)),
...groupStudentIds,
]);
} }
}} }}
className={clsx( className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light", "bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
users.filter((u) => g.participants.includes(u.id)).every((u) => assignees.includes(u.id)) && users
"!bg-mti-purple-light !text-white", .filter((u) => g.participants.includes(u.id))
)}> .every((u) => assignees.includes(u.id)) &&
"!bg-mti-purple-light !text-white"
)}
>
{g.name} {g.name}
</button> </button>
))} ))}
@@ -402,9 +588,12 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
className={clsx( className={clsx(
"p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72", "p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72",
"transition ease-in-out duration-300", "transition ease-in-out duration-300",
assignees.includes(user.id) ? "border-mti-purple" : "border-mti-gray-platinum", assignees.includes(user.id)
? "border-mti-purple"
: "border-mti-gray-platinum"
)} )}
key={user.id}> key={user.id}
>
<span className="flex flex-col gap-0 justify-center"> <span className="flex flex-col gap-0 justify-center">
<span className="font-semibold">{user.name}</span> <span className="font-semibold">{user.name}</span>
<span className="text-sm opacity-80">{user.email}</span> <span className="text-sm opacity-80">{user.email}</span>
@@ -430,25 +619,43 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
{user.type !== "teacher" && ( {user.type !== "teacher" && (
<section className="w-full flex flex-col gap-3"> <section className="w-full flex flex-col gap-3">
<span className="font-semibold">Teachers ({teachers.length} selected)</span> <span className="font-semibold">
Teachers ({teachers.length} selected)
</span>
<div className="grid grid-cols-5 gap-4"> <div className="grid grid-cols-5 gap-4">
{classrooms.map((g) => ( {classrooms.map((g) => (
<button <button
key={g.id} key={g.id}
onClick={() => { onClick={() => {
const groupStudentIds = users.filter((u) => g.participants.includes(u.id)).map((u) => u.id); const groupStudentIds = users.reduce<string[]>(
(acc, u) => {
if (g.participants.includes(u.id)) {
acc.push(u.id);
}
return acc;
},
[]
);
if (groupStudentIds.every((u) => teachers.includes(u))) { if (groupStudentIds.every((u) => teachers.includes(u))) {
setTeachers((prev) => prev.filter((a) => !groupStudentIds.includes(a))); setTeachers((prev) =>
prev.filter((a) => !groupStudentIds.includes(a))
);
} else { } else {
setTeachers((prev) => [...prev.filter((a) => !groupStudentIds.includes(a)), ...groupStudentIds]); setTeachers((prev) => [
...prev.filter((a) => !groupStudentIds.includes(a)),
...groupStudentIds,
]);
} }
}} }}
className={clsx( className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light", "bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
users.filter((u) => g.participants.includes(u.id)).every((u) => teachers.includes(u.id)) && users
"!bg-mti-purple-light !text-white", .filter((u) => g.participants.includes(u.id))
)}> .every((u) => teachers.includes(u.id)) &&
"!bg-mti-purple-light !text-white"
)}
>
{g.name} {g.name}
</button> </button>
))} ))}
@@ -466,9 +673,12 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
className={clsx( className={clsx(
"p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72", "p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72",
"transition ease-in-out duration-300", "transition ease-in-out duration-300",
teachers.includes(user.id) ? "border-mti-purple" : "border-mti-gray-platinum", teachers.includes(user.id)
? "border-mti-purple"
: "border-mti-gray-platinum"
)} )}
key={user.id}> key={user.id}
>
<span className="flex flex-col gap-0 justify-center"> <span className="flex flex-col gap-0 justify-center">
<span className="font-semibold">{user.name}</span> <span className="font-semibold">{user.name}</span>
<span className="text-sm opacity-80">{user.email}</span> <span className="text-sm opacity-80">{user.email}</span>
@@ -487,16 +697,30 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
)} )}
<div className="flex gap-4 w-full items-end"> <div className="flex gap-4 w-full items-end">
<Checkbox isChecked={variant === "full"} onChange={() => setVariant((prev) => (prev === "full" ? "partial" : "full"))}> <Checkbox
isChecked={variant === "full"}
onChange={() =>
setVariant((prev) => (prev === "full" ? "partial" : "full"))
}
>
Full length exams Full length exams
</Checkbox> </Checkbox>
<Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple((d) => !d)}> <Checkbox
isChecked={generateMultiple}
onChange={() => setGenerateMultiple((d) => !d)}
>
Generate different exams Generate different exams
</Checkbox> </Checkbox>
<Checkbox isChecked={released} onChange={() => setReleased((d) => !d)}> <Checkbox
isChecked={released}
onChange={() => setReleased((d) => !d)}
>
Auto release results Auto release results
</Checkbox> </Checkbox>
<Checkbox isChecked={autoStart} onChange={() => setAutostart((d) => !d)}> <Checkbox
isChecked={autoStart}
onChange={() => setAutostart((d) => !d)}
>
Auto start exam Auto start exam
</Checkbox> </Checkbox>
</div> </div>
@@ -506,7 +730,8 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
variant="outline" variant="outline"
onClick={() => router.push("/assignments")} onClick={() => router.push("/assignments")}
disabled={isLoading} disabled={isLoading}
isLoading={isLoading}> isLoading={isLoading}
>
Cancel Cancel
</Button> </Button>
@@ -522,7 +747,8 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
} }
className="w-full max-w-[200px]" className="w-full max-w-[200px]"
onClick={createAssignment} onClick={createAssignment}
isLoading={isLoading}> isLoading={isLoading}
>
Create Create
</Button> </Button>
</div> </div>

View File

@@ -28,59 +28,137 @@ import { useMemo } from "react";
import { BsChevronLeft, BsPlus } from "react-icons/bs"; import { BsChevronLeft, BsPlus } from "react-icons/bs";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res);
if (!user) return redirect("/login") if (!user) return redirect("/login");
if (!checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"]))
return redirect("/")
if (
!checkAccess(user, [
"admin",
"developer",
"corporate",
"teacher",
"mastercorporate",
])
)
return redirect("/");
const isAdmin = checkAccess(user, ["developer", "admin"]);
const entityIDS = mapBy(user.entities, "id") || []; const entityIDS = mapBy(user.entities, "id") || [];
const entities = await (checkAccess(user, ["developer", "admin"]) ? getEntitiesWithRoles() : getEntitiesWithRoles(entityIDS)); const entities = await (isAdmin
? getEntitiesWithRoles()
: getEntitiesWithRoles(entityIDS));
const allowedEntities = findAllowedEntities(user, entities, "view_assignments") const allowedEntities = findAllowedEntities(
user,
entities,
"view_assignments"
);
const [users, assignments] = await Promise.all([
await (isAdmin
? getUsers({}, 0, {}, { _id: 0, id: 1, name: 1 })
: getEntitiesUsers(mapBy(allowedEntities, "id"), {}, 0, {
_id: 0,
id: 1,
name: 1,
})),
await (isAdmin
? getAssignments()
: getEntitiesAssignments(mapBy(allowedEntities, "id"))),
]);
const users = return {
await (checkAccess(user, ["developer", "admin"]) ? getUsers() : getEntitiesUsers(mapBy(allowedEntities, 'id'))); props: serialize({ user, users, entities: allowedEntities, assignments }),
};
const assignments =
await (checkAccess(user, ["developer", "admin"]) ? getAssignments() : getEntitiesAssignments(mapBy(allowedEntities, 'id')));
return { props: serialize({ user, users, entities: allowedEntities, assignments }) };
}, sessionOptions); }, sessionOptions);
const SEARCH_FIELDS = [["name"]]; const SEARCH_FIELDS = [["name"]];
interface Props { interface Props {
assignments: Assignment[]; assignments: Assignment[];
entities: EntityWithRoles[] entities: EntityWithRoles[];
user: User; user: User;
users: User[]; users: User[];
} }
export default function AssignmentsPage({ assignments, entities, user, users }: Props) { export default function AssignmentsPage({
const entitiesAllowCreate = useAllowedEntities(user, entities, 'create_assignment') assignments,
const entitiesAllowEdit = useAllowedEntities(user, entities, 'edit_assignment') entities,
const entitiesAllowArchive = useAllowedEntities(user, entities, 'archive_assignment') user,
users,
}: Props) {
const entitiesAllowCreate = useAllowedEntities(
user,
entities,
"create_assignment"
);
const entitiesAllowEdit = useAllowedEntities(
user,
entities,
"edit_assignment"
);
const entitiesAllowArchive = useAllowedEntities(
user,
entities,
"archive_assignment"
);
const activeAssignments = useMemo(() => assignments.filter(activeAssignmentFilter), [assignments]); const activeAssignments = useMemo(
const plannedAssignments = useMemo(() => assignments.filter(futureAssignmentFilter), [assignments]); () => assignments.filter(activeAssignmentFilter),
const pastAssignments = useMemo(() => assignments.filter(pastAssignmentFilter), [assignments]); [assignments]
const startExpiredAssignments = useMemo(() => assignments.filter(startHasExpiredAssignmentFilter), [assignments]); );
const archivedAssignments = useMemo(() => assignments.filter(archivedAssignmentFilter), [assignments]); const plannedAssignments = useMemo(
() => assignments.filter(futureAssignmentFilter),
[assignments]
);
const pastAssignments = useMemo(
() => assignments.filter(pastAssignmentFilter),
[assignments]
);
const startExpiredAssignments = useMemo(
() => assignments.filter(startHasExpiredAssignmentFilter),
[assignments]
);
const archivedAssignments = useMemo(
() => assignments.filter(archivedAssignmentFilter),
[assignments]
);
const router = useRouter(); const router = useRouter();
const { rows: activeRows, renderSearch: renderActive } = useListSearch(SEARCH_FIELDS, activeAssignments); const { rows: activeRows, renderSearch: renderActive } = useListSearch(
const { rows: plannedRows, renderSearch: renderPlanned } = useListSearch(SEARCH_FIELDS, plannedAssignments); SEARCH_FIELDS,
const { rows: pastRows, renderSearch: renderPast } = useListSearch(SEARCH_FIELDS, pastAssignments); activeAssignments
const { rows: expiredRows, renderSearch: renderExpired } = useListSearch(SEARCH_FIELDS, startExpiredAssignments); );
const { rows: archivedRows, renderSearch: renderArchived } = useListSearch(SEARCH_FIELDS, archivedAssignments); const { rows: plannedRows, renderSearch: renderPlanned } = useListSearch(
SEARCH_FIELDS,
plannedAssignments
);
const { rows: pastRows, renderSearch: renderPast } = useListSearch(
SEARCH_FIELDS,
pastAssignments
);
const { rows: expiredRows, renderSearch: renderExpired } = useListSearch(
SEARCH_FIELDS,
startExpiredAssignments
);
const { rows: archivedRows, renderSearch: renderArchived } = useListSearch(
SEARCH_FIELDS,
archivedAssignments
);
const { items: activeItems, renderMinimal: paginationActive } = usePagination(activeRows, 16); const { items: activeItems, renderMinimal: paginationActive } = usePagination(
const { items: plannedItems, renderMinimal: paginationPlanned } = usePagination(plannedRows, 16); activeRows,
const { items: pastItems, renderMinimal: paginationPast } = usePagination(pastRows, 16); 16
const { items: expiredItems, renderMinimal: paginationExpired } = usePagination(expiredRows, 16); );
const { items: archivedItems, renderMinimal: paginationArchived } = usePagination(archivedRows, 16); const { items: plannedItems, renderMinimal: paginationPlanned } =
usePagination(plannedRows, 16);
const { items: pastItems, renderMinimal: paginationPast } = usePagination(
pastRows,
16
);
const { items: expiredItems, renderMinimal: paginationExpired } =
usePagination(expiredRows, 16);
const { items: archivedItems, renderMinimal: paginationArchived } =
usePagination(archivedRows, 16);
return ( return (
<> <>
@@ -96,7 +174,10 @@ export default function AssignmentsPage({ assignments, entities, user, users }:
<> <>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link href="/dashboard" className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"> <Link
href="/dashboard"
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
>
<BsChevronLeft /> <BsChevronLeft />
</Link> </Link>
<h2 className="font-bold text-2xl">Assignments</h2> <h2 className="font-bold text-2xl">Assignments</h2>
@@ -107,35 +188,56 @@ export default function AssignmentsPage({ assignments, entities, user, users }:
<span className="text-lg font-bold">Active Assignments Status</span> <span className="text-lg font-bold">Active Assignments Status</span>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span> <span>
<b>Total:</b> {activeAssignments.reduce((acc, curr) => acc + curr.results.length, 0)}/ <b>Total:</b>{" "}
{activeAssignments.reduce((acc, curr) => curr.exams.length + acc, 0)} {activeAssignments.reduce(
(acc, curr) => acc + curr.results.length,
0
)}
/
{activeAssignments.reduce(
(acc, curr) => curr.exams.length + acc,
0
)}
</span> </span>
</div> </div>
</div> </div>
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Active Assignments ({activeAssignments.length})</h2> <h2 className="text-2xl font-semibold">
Active Assignments ({activeAssignments.length})
</h2>
<div className="w-full flex items-center gap-4"> <div className="w-full flex items-center gap-4">
{renderActive()} {renderActive()}
{paginationActive()} {paginationActive()}
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{activeItems.map((a) => ( {activeItems.map((a) => (
<AssignmentCard {...a} entityObj={findBy(entities, 'id', a.entity)} users={users} onClick={() => router.push(`/assignments/${a.id}`)} key={a.id} /> <AssignmentCard
{...a}
entityObj={findBy(entities, "id", a.entity)}
users={users}
onClick={() => router.push(`/assignments/${a.id}`)}
key={a.id}
/>
))} ))}
</div> </div>
</section> </section>
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Planned Assignments ({plannedAssignments.length})</h2> <h2 className="text-2xl font-semibold">
Planned Assignments ({plannedAssignments.length})
</h2>
<div className="w-full flex items-center gap-4"> <div className="w-full flex items-center gap-4">
{renderPlanned()} {renderPlanned()}
{paginationPlanned()} {paginationPlanned()}
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Link <Link
href={entitiesAllowCreate.length > 0 ? "/assignments/creator" : ""} href={
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"> entitiesAllowCreate.length > 0 ? "/assignments/creator" : ""
}
className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300"
>
<BsPlus className="text-6xl" /> <BsPlus className="text-6xl" />
<span className="text-lg">New Assignment</span> <span className="text-lg">New Assignment</span>
</Link> </Link>
@@ -143,9 +245,9 @@ export default function AssignmentsPage({ assignments, entities, user, users }:
<AssignmentCard <AssignmentCard
{...a} {...a}
users={users} users={users}
entityObj={findBy(entities, 'id', a.entity)} entityObj={findBy(entities, "id", a.entity)}
onClick={ onClick={
mapBy(entitiesAllowEdit, 'id').includes(a.entity || "") mapBy(entitiesAllowEdit, "id").includes(a.entity || "")
? () => router.push(`/assignments/creator/${a.id}`) ? () => router.push(`/assignments/creator/${a.id}`)
: () => router.push(`/assignments/${a.id}`) : () => router.push(`/assignments/${a.id}`)
} }
@@ -156,7 +258,9 @@ export default function AssignmentsPage({ assignments, entities, user, users }:
</section> </section>
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Past Assignments ({pastAssignments.length})</h2> <h2 className="text-2xl font-semibold">
Past Assignments ({pastAssignments.length})
</h2>
<div className="w-full flex items-center gap-4"> <div className="w-full flex items-center gap-4">
{renderPast()} {renderPast()}
{paginationPast()} {paginationPast()}
@@ -166,18 +270,22 @@ export default function AssignmentsPage({ assignments, entities, user, users }:
<AssignmentCard <AssignmentCard
{...a} {...a}
users={users} users={users}
entityObj={findBy(entities, 'id', a.entity)} entityObj={findBy(entities, "id", a.entity)}
onClick={() => router.push(`/assignments/${a.id}`)} onClick={() => router.push(`/assignments/${a.id}`)}
key={a.id} key={a.id}
allowDownload allowDownload
allowArchive={mapBy(entitiesAllowArchive, 'id').includes(a.entity || "")} allowArchive={mapBy(entitiesAllowArchive, "id").includes(
a.entity || ""
)}
allowExcelDownload allowExcelDownload
/> />
))} ))}
</div> </div>
</section> </section>
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Assignments start expired ({startExpiredAssignments.length})</h2> <h2 className="text-2xl font-semibold">
Assignments start expired ({startExpiredAssignments.length})
</h2>
<div className="w-full flex items-center gap-4"> <div className="w-full flex items-center gap-4">
{renderExpired()} {renderExpired()}
{paginationExpired()} {paginationExpired()}
@@ -187,18 +295,22 @@ export default function AssignmentsPage({ assignments, entities, user, users }:
<AssignmentCard <AssignmentCard
{...a} {...a}
users={users} users={users}
entityObj={findBy(entities, 'id', a.entity)} entityObj={findBy(entities, "id", a.entity)}
onClick={() => router.push(`/assignments/${a.id}`)} onClick={() => router.push(`/assignments/${a.id}`)}
key={a.id} key={a.id}
allowDownload allowDownload
allowArchive={mapBy(entitiesAllowArchive, 'id').includes(a.entity || "")} allowArchive={mapBy(entitiesAllowArchive, "id").includes(
a.entity || ""
)}
allowExcelDownload allowExcelDownload
/> />
))} ))}
</div> </div>
</section> </section>
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Archived Assignments ({archivedAssignments.length})</h2> <h2 className="text-2xl font-semibold">
Archived Assignments ({archivedAssignments.length})
</h2>
<div className="w-full flex items-center gap-4"> <div className="w-full flex items-center gap-4">
{renderArchived()} {renderArchived()}
{paginationArchived()} {paginationArchived()}
@@ -210,7 +322,7 @@ export default function AssignmentsPage({ assignments, entities, user, users }:
users={users} users={users}
onClick={() => router.push(`/assignments/${a.id}`)} onClick={() => router.push(`/assignments/${a.id}`)}
key={a.id} key={a.id}
entityObj={findBy(entities, 'id', a.entity)} entityObj={findBy(entities, "id", a.entity)}
allowDownload allowDownload
allowUnarchive allowUnarchive
allowExcelDownload allowExcelDownload

View File

@@ -18,47 +18,93 @@ import { getEntityUsers, getSpecificUsers } from "@/utils/users.be";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import { capitalize } from "lodash"; import { capitalize, last } from "lodash";
import moment from "moment"; import moment from "moment";
import Head from "next/head"; import Head from "next/head";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Divider } from "primereact/divider"; import { Divider } from "primereact/divider";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { BsBuilding, BsChevronLeft, BsClockFill, BsEnvelopeFill, BsFillPersonVcardFill, BsPlus, BsStopwatchFill, BsTag, BsTrash, BsX } from "react-icons/bs"; import {
BsBuilding,
BsChevronLeft,
BsClockFill,
BsEnvelopeFill,
BsFillPersonVcardFill,
BsPlus,
BsStopwatchFill,
BsTag,
BsTrash,
BsX,
} from "react-icons/bs";
import { toast, ToastContainer } from "react-toastify"; import { toast, ToastContainer } from "react-toastify";
export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }) => { export const getServerSideProps = withIronSessionSsr(
const user = await requestUser(req, res) async ({ req, res, params }) => {
if (!user) return redirect("/login") const user = await requestUser(req, res);
if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/") if (shouldRedirectHome(user)) return redirect("/");
const { id } = params as { id: string }; const { id } = params as { id: string };
const group = await getGroup(id); const group = await getGroup(id);
if (!group || !group.entity) return redirect("/classrooms") if (!group || !group.entity) return redirect("/classrooms");
const entity = await getEntityWithRoles(group.entity) const entity = await getEntityWithRoles(group.entity);
if (!entity) return redirect("/classrooms") if (!entity) return redirect("/classrooms");
const canView = doesEntityAllow(user, entity, "view_classrooms") const canView = doesEntityAllow(user, entity, "view_classrooms");
if (!canView) return redirect("/") if (!canView) return redirect("/");
const [linkedUsers, users] = await Promise.all([
getEntityUsers(
entity.id,
0,
{},
{
_id: 0,
id: 1,
name: 1,
email: 1,
corporateInformation: 1,
type: 1,
profilePicture: 1,
subscriptionExpirationDate: 1,
lastLogin: 1,
}
),
getSpecificUsers([...group.participants, group.admin], {
_id: 0,
id: 1,
name: 1,
email: 1,
corporateInformation: 1,
type: 1,
profilePicture: 1,
subscriptionExpirationDate: 1,
lastLogin: 1,
}),
]);
const linkedUsers = await getEntityUsers(entity.id)
const users = await getSpecificUsers([...group.participants, group.admin]);
const groupWithUser = convertToUsers(group, users); const groupWithUser = convertToUsers(group, users);
return { return {
props: serialize({ user, group: groupWithUser, users: linkedUsers.filter(x => isAdmin(user) ? true : !isAdmin(x)), entity }), props: serialize({
user,
group: groupWithUser,
users: linkedUsers.filter((x) => (isAdmin(user) ? true : !isAdmin(x))),
entity,
}),
}; };
}, sessionOptions); },
sessionOptions
);
interface Props { interface Props {
user: User; user: User;
group: GroupWithUsers; group: GroupWithUsers;
users: User[]; users: User[];
entity: EntityWithRoles entity: EntityWithRoles;
} }
export default function Home({ user, group, users, entity }: Props) { export default function Home({ user, group, users, entity }: Props) {
@@ -66,36 +112,73 @@ export default function Home({ user, group, users, entity }: Props) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [selectedUsers, setSelectedUsers] = useState<string[]>([]); const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
const canAddParticipants = useEntityPermission(user, entity, "add_to_classroom") const canAddParticipants = useEntityPermission(
const canRemoveParticipants = useEntityPermission(user, entity, "remove_from_classroom") user,
const canRenameClassroom = useEntityPermission(user, entity, "rename_classrooms") entity,
const canDeleteClassroom = useEntityPermission(user, entity, "delete_classroom") "add_to_classroom"
);
const canRemoveParticipants = useEntityPermission(
user,
entity,
"remove_from_classroom"
);
const canRenameClassroom = useEntityPermission(
user,
entity,
"rename_classrooms"
);
const canDeleteClassroom = useEntityPermission(
user,
entity,
"delete_classroom"
);
const nonParticipantUsers = useMemo( const nonParticipantUsers = useMemo(
() => users.filter((x) => ![...group.participants.map((g) => g.id), group.admin.id, user.id].includes(x.id)), () =>
[users, group.participants, group.admin.id, user.id], users.filter(
(x) =>
![
...group.participants.map((g) => g.id),
group.admin.id,
user.id,
].includes(x.id)
),
[users, group.participants, group.admin.id, user.id]
); );
const { rows, renderSearch } = useListSearch<User>( const { rows, renderSearch } = useListSearch<User>(
[["name"], ["corporateInformation", "companyInformation", "name"]], [["name"], ["corporateInformation", "companyInformation", "name"]],
isAdding ? nonParticipantUsers : group.participants, isAdding ? nonParticipantUsers : group.participants
); );
const { items, renderMinimal } = usePagination<User>(rows, 20); const { items, renderMinimal } = usePagination<User>(rows, 20);
const router = useRouter(); const router = useRouter();
const toggleUser = (u: User) => setSelectedUsers((prev) => (prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id])); const toggleUser = (u: User) =>
setSelectedUsers((prev) =>
prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]
);
const removeParticipants = () => { const removeParticipants = () => {
if (selectedUsers.length === 0) return; if (selectedUsers.length === 0) return;
if (!canRemoveParticipants) return; if (!canRemoveParticipants) return;
if (!confirm(`Are you sure you want to remove ${selectedUsers.length} participant${selectedUsers.length === 1 ? "" : "s"} from this group?`)) if (
!confirm(
`Are you sure you want to remove ${selectedUsers.length} participant${
selectedUsers.length === 1 ? "" : "s"
} from this group?`
)
)
return; return;
setIsLoading(true); setIsLoading(true);
axios axios
.patch(`/api/groups/${group.id}`, { participants: group.participants.map((x) => x.id).filter((x) => !selectedUsers.includes(x)) }) .patch(`/api/groups/${group.id}`, {
participants: group.participants
.map((x) => x.id)
.filter((x) => !selectedUsers.includes(x)),
})
.then(() => { .then(() => {
toast.success("The group has been updated successfully!"); toast.success("The group has been updated successfully!");
router.replace(router.asPath); router.replace(router.asPath);
@@ -110,13 +193,24 @@ export default function Home({ user, group, users, entity }: Props) {
const addParticipants = () => { const addParticipants = () => {
if (selectedUsers.length === 0) return; if (selectedUsers.length === 0) return;
if (!canAddParticipants || !isAdding) return; if (!canAddParticipants || !isAdding) return;
if (!confirm(`Are you sure you want to add ${selectedUsers.length} participant${selectedUsers.length === 1 ? "" : "s"} to this group?`)) if (
!confirm(
`Are you sure you want to add ${selectedUsers.length} participant${
selectedUsers.length === 1 ? "" : "s"
} to this group?`
)
)
return; return;
setIsLoading(true); setIsLoading(true);
axios axios
.patch(`/api/groups/${group.id}`, { participants: [...group.participants.map((x) => x.id), ...selectedUsers] }) .patch(`/api/groups/${group.id}`, {
participants: [
...group.participants.map((x) => x.id),
...selectedUsers,
],
})
.then(() => { .then(() => {
toast.success("The group has been updated successfully!"); toast.success("The group has been updated successfully!");
router.replace(router.asPath); router.replace(router.asPath);
@@ -189,7 +283,8 @@ export default function Home({ user, group, users, entity }: Props) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link <Link
href="/classrooms" href="/classrooms"
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"> className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
>
<BsChevronLeft /> <BsChevronLeft />
</Link> </Link>
<h2 className="font-bold text-2xl">{group.name}</h2> <h2 className="font-bold text-2xl">{group.name}</h2>
@@ -200,14 +295,16 @@ export default function Home({ user, group, users, entity }: Props) {
<button <button
onClick={renameGroup} onClick={renameGroup}
disabled={isLoading || !canRenameClassroom} disabled={isLoading || !canRenameClassroom}
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsTag /> <BsTag />
<span className="text-xs">Rename Classroom</span> <span className="text-xs">Rename Classroom</span>
</button> </button>
<button <button
onClick={deleteGroup} onClick={deleteGroup}
disabled={isLoading || !canDeleteClassroom} disabled={isLoading || !canDeleteClassroom}
className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsTrash /> <BsTrash />
<span className="text-xs">Delete Classroom</span> <span className="text-xs">Delete Classroom</span>
</button> </button>
@@ -219,7 +316,8 @@ export default function Home({ user, group, users, entity }: Props) {
<BsBuilding className="text-xl" /> {entity.label} <BsBuilding className="text-xl" /> {entity.label}
</span> </span>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<BsFillPersonVcardFill className="text-xl" /> {getUserName(group.admin)} <BsFillPersonVcardFill className="text-xl" />{" "}
{getUserName(group.admin)}
</span> </span>
</div> </div>
</div> </div>
@@ -231,14 +329,20 @@ export default function Home({ user, group, users, entity }: Props) {
<button <button
onClick={() => setIsAdding(true)} onClick={() => setIsAdding(true)}
disabled={isLoading || !canAddParticipants} disabled={isLoading || !canAddParticipants}
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsPlus /> <BsPlus />
<span className="text-xs">Add Participants</span> <span className="text-xs">Add Participants</span>
</button> </button>
<button <button
onClick={removeParticipants} onClick={removeParticipants}
disabled={selectedUsers.length === 0 || isLoading || !canRemoveParticipants} disabled={
className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> selectedUsers.length === 0 ||
isLoading ||
!canRemoveParticipants
}
className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsTrash /> <BsTrash />
<span className="text-xs">Remove Participants</span> <span className="text-xs">Remove Participants</span>
</button> </button>
@@ -249,14 +353,20 @@ export default function Home({ user, group, users, entity }: Props) {
<button <button
onClick={() => setIsAdding(false)} onClick={() => setIsAdding(false)}
disabled={isLoading} disabled={isLoading}
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-rose bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-rose bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsX /> <BsX />
<span className="text-xs">Discard Selection</span> <span className="text-xs">Discard Selection</span>
</button> </button>
<button <button
onClick={addParticipants} onClick={addParticipants}
disabled={selectedUsers.length === 0 || isLoading || !canAddParticipants} disabled={
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> selectedUsers.length === 0 ||
isLoading ||
!canAddParticipants
}
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsPlus /> <BsPlus />
<span className="text-xs">Add Participants</span> <span className="text-xs">Add Participants</span>
</button> </button>
@@ -268,26 +378,53 @@ export default function Home({ user, group, users, entity }: Props) {
{renderMinimal()} {renderMinimal()}
</div> </div>
<div className="flex items-center gap-2 mt-4"> <div className="flex items-center gap-2 mt-4">
{['student', 'teacher', 'corporate'].map((type) => ( {["student", "teacher", "corporate"].map((type) => (
<button <button
key={type} key={type}
onClick={() => { onClick={() => {
const typeUsers = mapBy(filterBy(isAdding ? nonParticipantUsers : group.participants, 'type', type), 'id') const typeUsers = mapBy(
filterBy(
isAdding ? nonParticipantUsers : group.participants,
"type",
type
),
"id"
);
if (typeUsers.every((u) => selectedUsers.includes(u))) { if (typeUsers.every((u) => selectedUsers.includes(u))) {
setSelectedUsers((prev) => prev.filter((a) => !typeUsers.includes(a))); setSelectedUsers((prev) =>
prev.filter((a) => !typeUsers.includes(a))
);
} else { } else {
setSelectedUsers((prev) => [...prev.filter((a) => !typeUsers.includes(a)), ...typeUsers]); setSelectedUsers((prev) => [
...prev.filter((a) => !typeUsers.includes(a)),
...typeUsers,
]);
} }
}} }}
disabled={filterBy(isAdding ? nonParticipantUsers : group.participants, 'type', type).length === 0} disabled={
filterBy(
isAdding ? nonParticipantUsers : group.participants,
"type",
type
).length === 0
}
className={clsx( className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light", "bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
"disabled:grayscale disabled:hover:bg-mti-purple-ultralight disabled:hover:text-mti-purple disabled:cursor-not-allowed", "disabled:grayscale disabled:hover:bg-mti-purple-ultralight disabled:hover:text-mti-purple disabled:cursor-not-allowed",
filterBy(isAdding ? nonParticipantUsers : group.participants, 'type', type).length > 0 && filterBy(
filterBy(isAdding ? nonParticipantUsers : group.participants, 'type', type).every((u) => selectedUsers.includes(u.id)) && isAdding ? nonParticipantUsers : group.participants,
"!bg-mti-purple-light !text-white", "type",
)}> type
).length > 0 &&
filterBy(
isAdding ? nonParticipantUsers : group.participants,
"type",
type
).every((u) => selectedUsers.includes(u.id)) &&
"!bg-mti-purple-light !text-white"
)}
>
{capitalize(type)} {capitalize(type)}
</button> </button>
))} ))}
@@ -298,20 +435,25 @@ export default function Home({ user, group, users, entity }: Props) {
{items.map((u) => ( {items.map((u) => (
<button <button
onClick={() => toggleUser(u)} onClick={() => toggleUser(u)}
disabled={isAdding ? !canAddParticipants : !canRemoveParticipants} disabled={
isAdding ? !canAddParticipants : !canRemoveParticipants
}
key={u.id} key={u.id}
className={clsx( className={clsx(
"p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer", "p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer",
"hover:border-mti-purple transition ease-in-out duration-300", "hover:border-mti-purple transition ease-in-out duration-300",
selectedUsers.includes(u.id) && "border-mti-purple", selectedUsers.includes(u.id) && "border-mti-purple"
)}> )}
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full"> <div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full">
<img src={u.profilePicture} alt={u.name} /> <img src={u.profilePicture} alt={u.name} />
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-semibold">{getUserName(u)}</span> <span className="font-semibold">{getUserName(u)}</span>
<span className="opacity-80 text-sm">{USER_TYPE_LABELS[u.type]}</span> <span className="opacity-80 text-sm">
{USER_TYPE_LABELS[u.type]}
</span>
</div> </div>
</div> </div>
@@ -326,13 +468,19 @@ export default function Home({ user, group, users, entity }: Props) {
<Tooltip tooltip="Expiration Date"> <Tooltip tooltip="Expiration Date">
<BsStopwatchFill /> <BsStopwatchFill />
</Tooltip> </Tooltip>
{u.subscriptionExpirationDate ? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited"} {u.subscriptionExpirationDate
? moment(u.subscriptionExpirationDate).format(
"DD/MM/YYYY"
)
: "Unlimited"}
</span> </span>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<Tooltip tooltip="Last Login"> <Tooltip tooltip="Last Login">
<BsClockFill /> <BsClockFill />
</Tooltip> </Tooltip>
{u.lastLogin ? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm") : "N/A"} {u.lastLogin
? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm")
: "N/A"}
</span> </span>
</div> </div>
</button> </button>

View File

@@ -22,24 +22,57 @@ import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Divider } from "primereact/divider"; import { Divider } from "primereact/divider";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import {BsCheck, BsChevronLeft, BsClockFill, BsEnvelopeFill, BsStopwatchFill} from "react-icons/bs"; import {
BsCheck,
BsChevronLeft,
BsClockFill,
BsEnvelopeFill,
BsStopwatchFill,
} from "react-icons/bs";
import { toast, ToastContainer } from "react-toastify"; import { toast, ToastContainer } from "react-toastify";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { findAllowedEntities } from "@/utils/permissions"; import { findAllowedEntities } from "@/utils/permissions";
import { capitalize } from "lodash"; import { capitalize } from "lodash";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res);
if (!user) return redirect("/login") if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/") if (shouldRedirectHome(user)) return redirect("/");
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : mapBy(user.entities, "id")); const entities = await getEntitiesWithRoles(
const users = await getEntitiesUsers(mapBy(entities, 'id')) isAdmin(user) ? undefined : mapBy(user.entities, "id")
const allowedEntities = findAllowedEntities(user, entities, "create_classroom") );
const users = await getEntitiesUsers(
mapBy(entities, "id"),
{
id: { $ne: user.id },
},
0,
{
_id: 0,
id: 1,
name: 1,
email: 1,
profilePicture: 1,
type: 1,
corporateInformation: 1,
lastLogin: 1,
subscriptionExpirationDate: 1,
}
);
const allowedEntities = findAllowedEntities(
user,
entities,
"create_classroom"
);
return { return {
props: serialize({user, entities: allowedEntities, users: users.filter((x) => x.id !== user.id)}), props: serialize({
user,
entities: allowedEntities,
users: users,
}),
}; };
}, sessionOptions); }, sessionOptions);
@@ -55,27 +88,48 @@ export default function Home({user, users, entities}: Props) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [entity, setEntity] = useState<string | undefined>(entities[0]?.id); const [entity, setEntity] = useState<string | undefined>(entities[0]?.id);
const entityUsers = useMemo(() => !entity ? users : users.filter(u => mapBy(u.entities, 'id').includes(entity)), [entity, users]) const entityUsers = useMemo(
() =>
!entity
? users
: users.filter((u) => mapBy(u.entities, "id").includes(entity)),
[entity, users]
);
const { rows, renderSearch } = useListSearch<User>( const { rows, renderSearch } = useListSearch<User>(
[["name"], ["type"], ["corporateInformation", "companyInformation", "name"]], entityUsers [
["name"],
["type"],
["corporateInformation", "companyInformation", "name"],
],
entityUsers
); );
const { items, renderMinimal } = usePagination<User>(rows, 16); const { items, renderMinimal } = usePagination<User>(rows, 16);
const router = useRouter(); const router = useRouter();
useEffect(() => setSelectedUsers([]), [entity]) useEffect(() => setSelectedUsers([]), [entity]);
const createGroup = () => { const createGroup = () => {
if (!name.trim()) return; if (!name.trim()) return;
if (!entity) return; if (!entity) return;
if (!confirm(`Are you sure you want to create this group with ${selectedUsers.length} participants?`)) return; if (
!confirm(
`Are you sure you want to create this group with ${selectedUsers.length} participants?`
)
)
return;
setIsLoading(true); setIsLoading(true);
axios axios
.post<{id: string}>(`/api/groups`, {name, participants: selectedUsers, admin: user.id, entity}) .post<{ id: string }>(`/api/groups`, {
name,
participants: selectedUsers,
admin: user.id,
entity,
})
.then((result) => { .then((result) => {
toast.success("Your group has been created successfully!"); toast.success("Your group has been created successfully!");
router.replace(`/classrooms/${result.data.id}`); router.replace(`/classrooms/${result.data.id}`);
@@ -87,7 +141,10 @@ export default function Home({user, users, entities}: Props) {
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
const toggleUser = (u: User) => setSelectedUsers((prev) => (prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id])); const toggleUser = (u: User) =>
setSelectedUsers((prev) =>
prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]
);
return ( return (
<> <>
@@ -107,7 +164,8 @@ export default function Home({user, users, entities}: Props) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link <Link
href="/classrooms" href="/classrooms"
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"> className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
>
<BsChevronLeft /> <BsChevronLeft />
</Link> </Link>
<h2 className="font-bold text-2xl">Create Classroom</h2> <h2 className="font-bold text-2xl">Create Classroom</h2>
@@ -116,7 +174,8 @@ export default function Home({user, users, entities}: Props) {
<button <button
onClick={createGroup} onClick={createGroup}
disabled={!name.trim() || !entity || isLoading} disabled={!name.trim() || !entity || isLoading}
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsCheck /> <BsCheck />
<span className="text-xs">Create Classroom</span> <span className="text-xs">Create Classroom</span>
</button> </button>
@@ -126,46 +185,67 @@ export default function Home({user, users, entities}: Props) {
<div className="grid grid-cols-2 gap-4 place-items-end"> <div className="grid grid-cols-2 gap-4 place-items-end">
<div className="flex flex-col gap-4 w-full"> <div className="flex flex-col gap-4 w-full">
<span className="font-semibold text-xl">Classroom Name:</span> <span className="font-semibold text-xl">Classroom Name:</span>
<Input name="name" onChange={setName} type="text" placeholder="Classroom A" /> <Input
name="name"
onChange={setName}
type="text"
placeholder="Classroom A"
/>
</div> </div>
<div className="flex flex-col gap-4 w-full"> <div className="flex flex-col gap-4 w-full">
<span className="font-semibold text-xl">Entity:</span> <span className="font-semibold text-xl">Entity:</span>
<Select <Select
options={entities.map((e) => ({ value: e.id, label: e.label }))} options={entities.map((e) => ({ value: e.id, label: e.label }))}
onChange={(v) => setEntity(v ? v.value! : undefined)} onChange={(v) => setEntity(v ? v.value! : undefined)}
defaultValue={{value: entities[0]?.id, label: entities[0]?.label}} defaultValue={{
value: entities[0]?.id,
label: entities[0]?.label,
}}
/> />
</div> </div>
</div> </div>
<Divider /> <Divider />
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<span className="font-semibold text-xl">Participants ({selectedUsers.length} selected):</span> <span className="font-semibold text-xl">
Participants ({selectedUsers.length} selected):
</span>
</div> </div>
<div className="w-full flex items-center gap-4"> <div className="w-full flex items-center gap-4">
{renderSearch()} {renderSearch()}
{renderMinimal()} {renderMinimal()}
</div> </div>
<div className="flex items-center gap-2 mt-4"> <div className="flex items-center gap-2 mt-4">
{['student', 'teacher', 'corporate'].map((type) => ( {["student", "teacher", "corporate"].map((type) => (
<button <button
key={type} key={type}
onClick={() => { onClick={() => {
const typeUsers = mapBy(filterBy(entityUsers, 'type', type), 'id') const typeUsers = mapBy(
filterBy(entityUsers, "type", type),
"id"
);
if (typeUsers.every((u) => selectedUsers.includes(u))) { if (typeUsers.every((u) => selectedUsers.includes(u))) {
setSelectedUsers((prev) => prev.filter((a) => !typeUsers.includes(a))); setSelectedUsers((prev) =>
prev.filter((a) => !typeUsers.includes(a))
);
} else { } else {
setSelectedUsers((prev) => [...prev.filter((a) => !typeUsers.includes(a)), ...typeUsers]); setSelectedUsers((prev) => [
...prev.filter((a) => !typeUsers.includes(a)),
...typeUsers,
]);
} }
}} }}
disabled={filterBy(entityUsers, 'type', type).length === 0} disabled={filterBy(entityUsers, "type", type).length === 0}
className={clsx( className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light", "bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
"disabled:grayscale disabled:hover:bg-mti-purple-ultralight disabled:hover:text-mti-purple disabled:cursor-not-allowed", "disabled:grayscale disabled:hover:bg-mti-purple-ultralight disabled:hover:text-mti-purple disabled:cursor-not-allowed",
filterBy(entityUsers, 'type', type).length > 0 && filterBy(entityUsers, "type", type).length > 0 &&
filterBy(entityUsers, 'type', type).every((u) => selectedUsers.includes(u.id)) && filterBy(entityUsers, "type", type).every((u) =>
"!bg-mti-purple-light !text-white", selectedUsers.includes(u.id)
)}> ) &&
"!bg-mti-purple-light !text-white"
)}
>
{capitalize(type)} {capitalize(type)}
</button> </button>
))} ))}
@@ -181,15 +261,18 @@ export default function Home({user, users, entities}: Props) {
className={clsx( className={clsx(
"p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer", "p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer",
"hover:border-mti-purple transition ease-in-out duration-300", "hover:border-mti-purple transition ease-in-out duration-300",
selectedUsers.includes(u.id) && "border-mti-purple", selectedUsers.includes(u.id) && "border-mti-purple"
)}> )}
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full"> <div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full">
<img src={u.profilePicture} alt={u.name} /> <img src={u.profilePicture} alt={u.name} />
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-semibold">{getUserName(u)}</span> <span className="font-semibold">{getUserName(u)}</span>
<span className="opacity-80 text-sm">{USER_TYPE_LABELS[u.type]}</span> <span className="opacity-80 text-sm">
{USER_TYPE_LABELS[u.type]}
</span>
</div> </div>
</div> </div>
@@ -204,13 +287,17 @@ export default function Home({user, users, entities}: Props) {
<Tooltip tooltip="Expiration Date"> <Tooltip tooltip="Expiration Date">
<BsStopwatchFill /> <BsStopwatchFill />
</Tooltip> </Tooltip>
{u.subscriptionExpirationDate ? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited"} {u.subscriptionExpirationDate
? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY")
: "Unlimited"}
</span> </span>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<Tooltip tooltip="Last Login"> <Tooltip tooltip="Last Login">
<BsClockFill /> <BsClockFill />
</Tooltip> </Tooltip>
{u.lastLogin ? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm") : "N/A"} {u.lastLogin
? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm")
: "N/A"}
</span> </span>
</div> </div>
</button> </button>

View File

@@ -27,22 +27,41 @@ import StudentClassroomTransfer from "@/components/Imports/StudentClassroomTrans
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res);
if (!user) return redirect("/login") if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/") if (shouldRedirectHome(user)) return redirect("/");
const entityIDS = mapBy(user.entities, "id"); const entityIDS = mapBy(user.entities, "id");
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS) const entities = await getEntitiesWithRoles(
const allowedEntities = findAllowedEntities(user, entities, "view_classrooms") isAdmin(user) ? undefined : entityIDS
);
const groups = await getGroupsForEntities(mapBy(allowedEntities, 'id')); const allowedEntities = findAllowedEntities(
user,
entities,
"view_classrooms"
);
const users = await getSpecificUsers(uniq(groups.flatMap((g) => [...g.participants, g.admin]))); const groups = await getGroupsForEntities(mapBy(allowedEntities, "id"));
const groupsWithUsers: GroupWithUsers[] = groups.map((g) => convertToUsers(g, users.filter(x => isAdmin(user) ? true : !isAdmin(x))));
const users = await getSpecificUsers(
uniq(groups.flatMap((g) => [...g.participants, g.admin])),
{ _id: 0, id: 1, name: 1, email: 1, corporateInformation: 1, type: 1 }
);
const groupsWithUsers: GroupWithUsers[] = groups.map((g) =>
convertToUsers(
g,
users.filter((x) => (isAdmin(user) ? true : !isAdmin(x)))
)
);
return { return {
props: serialize({ user, groups: groupsWithUsers, entities: allowedEntities }), props: serialize({
user,
groups: groupsWithUsers,
entities: allowedEntities,
}),
}; };
}, sessionOptions); }, sessionOptions);
@@ -59,39 +78,60 @@ const SEARCH_FIELDS = [
interface Props { interface Props {
user: User; user: User;
groups: GroupWithUsers[]; groups: GroupWithUsers[];
entities: EntityWithRoles[] entities: EntityWithRoles[];
} }
export default function Home({ user, groups, entities }: Props) { export default function Home({ user, groups, entities }: Props) {
const entitiesAllowCreate = useAllowedEntities(user, entities, 'create_classroom'); const entitiesAllowCreate = useAllowedEntities(
user,
entities,
"create_classroom"
);
const [showImport, setShowImport] = useState(false); const [showImport, setShowImport] = useState(false);
const renderCard = (group: GroupWithUsers) => ( const renderCard = (group: GroupWithUsers) => (
<Link <Link
href={`/classrooms/${group.id}`} href={`/classrooms/${group.id}`}
key={group.id} key={group.id}
className="p-4 border-2 border-mti-purple-light/20 rounded-xl flex gap-2 justify-between hover:border-mti-purple group transition ease-in-out duration-300 text-left cursor-pointer"> className="p-4 border-2 border-mti-purple-light/20 rounded-xl flex gap-2 justify-between hover:border-mti-purple group transition ease-in-out duration-300 text-left cursor-pointer"
>
<div className="flex flex-col gap-2 w-full"> <div className="flex flex-col gap-2 w-full">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<span className="bg-mti-purple text-white font-semibold px-2">Classroom</span> <span className="bg-mti-purple text-white font-semibold px-2">
Classroom
</span>
{group.name} {group.name}
</span> </span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<span className="bg-mti-purple text-white font-semibold px-2">Admin</span> <span className="bg-mti-purple text-white font-semibold px-2">
Admin
</span>
{getUserName(group.admin)} {getUserName(group.admin)}
</span> </span>
{!!group.entity && ( {!!group.entity && (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<span className="bg-mti-purple text-white font-semibold px-2">Entity</span> <span className="bg-mti-purple text-white font-semibold px-2">
{findBy(entities, 'id', group.entity)?.label} Entity
</span>
{findBy(entities, "id", group.entity)?.label}
</span> </span>
)} )}
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<span className="bg-mti-purple text-white font-semibold px-2">Participants</span> <span className="bg-mti-purple text-white font-semibold px-2">
<span className="bg-mti-purple-light/50 px-2">{group.participants.length}</span> Participants
</span>
<span className="bg-mti-purple-light/50 px-2">
{group.participants.length}
</span>
</span> </span>
<span> <span>
{group.participants.slice(0, 3).map(getUserName).join(", ")}{' '} {group.participants.slice(0, 3).map(getUserName).join(", ")}{" "}
{group.participants.length > 3 ? <span className="opacity-50 bg-mti-purple-light/50 px-1 text-sm">and {group.participants.length - 3} more</span> : ""} {group.participants.length > 3 ? (
<span className="opacity-50 bg-mti-purple-light/50 px-1 text-sm">
and {group.participants.length - 3} more
</span>
) : (
""
)}
</span> </span>
</div> </div>
<div className="w-fit"> <div className="w-fit">
@@ -103,7 +143,8 @@ export default function Home({ user, groups, entities }: Props) {
const firstCard = () => ( const firstCard = () => (
<Link <Link
href={`/classrooms/create`} href={`/classrooms/create`}
className="p-4 border-2 hover:text-mti-purple rounded-xl flex flex-col items-center justify-center gap-0 hover:border-mti-purple transition ease-in-out duration-300 text-left cursor-pointer"> className="p-4 border-2 hover:text-mti-purple rounded-xl flex flex-col items-center justify-center gap-0 hover:border-mti-purple transition ease-in-out duration-300 text-left cursor-pointer"
>
<BsPlus size={40} /> <BsPlus size={40} />
<span className="font-semibold">Create Classroom</span> <span className="font-semibold">Create Classroom</span>
</Link> </Link>
@@ -123,24 +164,33 @@ export default function Home({ user, groups, entities }: Props) {
<ToastContainer /> <ToastContainer />
<> <>
<section className="flex flex-col gap-4 w-full h-full"> <section className="flex flex-col gap-4 w-full h-full">
<Modal isOpen={showImport} onClose={() => setShowImport(false)} maxWidth="max-w-[85%]"> <Modal
<StudentClassroomTransfer user={user} entities={entities} onFinish={() => setShowImport(false)} /> isOpen={showImport}
onClose={() => setShowImport(false)}
maxWidth="max-w-[85%]"
>
<StudentClassroomTransfer
user={user}
entities={entities}
onFinish={() => setShowImport(false)}
/>
</Modal> </Modal>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex justify-between"> <div className="flex justify-between">
<h2 className="font-bold text-2xl">Classrooms</h2> <h2 className="font-bold text-2xl">Classrooms</h2>
{entitiesAllowCreate.length !== 0 && <button {entitiesAllowCreate.length !== 0 && (
<button
className={clsx( className={clsx(
"flex flex-row gap-3 items-center py-1.5 px-4 text-lg", "flex flex-row gap-3 items-center py-1.5 px-4 text-lg",
"bg-mti-purple-light border border-mti-purple-light rounded-xl text-white", "bg-mti-purple-light border border-mti-purple-light rounded-xl text-white",
"hover:bg-white hover:text-mti-purple-light transition duration-300 ease-in-out", "hover:bg-white hover:text-mti-purple-light transition duration-300 ease-in-out"
)} )}
onClick={() => setShowImport(true)} onClick={() => setShowImport(true)}
> >
<FaFileUpload className="w-5 h-5" /> <FaFileUpload className="w-5 h-5" />
Transfer Students Transfer Students
</button> </button>
} )}
</div> </div>
<Separator /> <Separator />
</div> </div>

View File

@@ -6,18 +6,13 @@ import { Stat, Type, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { filterBy, mapBy, redirect, serialize } from "@/utils"; import { filterBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { import { countEntitiesAssignments } from "@/utils/assignments.be";
countEntitiesAssignments,
} from "@/utils/assignments.be";
import { getEntities } from "@/utils/entities.be"; import { getEntities } from "@/utils/entities.be";
import { countGroups } from "@/utils/groups.be"; import { countGroups } from "@/utils/groups.be";
import { checkAccess } from "@/utils/permissions"; import { checkAccess } from "@/utils/permissions";
import { groupByExam } from "@/utils/stats"; import { groupByExam } from "@/utils/stats";
import { getStatsByUsers } from "@/utils/stats.be"; import { getStatsByUsers } from "@/utils/stats.be";
import { import { countUsersByTypes, getUsers } from "@/utils/users.be";
countUsersByTypes,
getUsers,
} from "@/utils/users.be";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import Head from "next/head"; import Head from "next/head";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@@ -49,49 +44,48 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
if (!user || !user.isVerified) return redirect("/login"); if (!user || !user.isVerified) return redirect("/login");
if (!checkAccess(user, ["admin", "developer"])) return redirect("/"); if (!checkAccess(user, ["admin", "developer"])) return redirect("/");
const [
const students = await getUsers( entities,
usersCount,
groupsCount,
students,
latestStudents,
latestTeachers,
] = await Promise.all([
getEntities(undefined, { _id: 0, id: 1, label: 1 }),
countUsersByTypes(["student", "teacher", "corporate", "mastercorporate"]),
countGroups(),
getUsers(
{ type: "student" }, { type: "student" },
10, 10,
{ {
averageLevel: -1, averageLevel: -1,
}, },
{ id: 1, name: 1, email: 1, profilePicture: 1 } { _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
); ),
getUsers(
const usersCount = await countUsersByTypes([
"student",
"teacher",
"corporate",
"mastercorporate",
]);
const latestStudents = await getUsers(
{ type: "student" }, { type: "student" },
10, 10,
{ {
registrationDate: -1, registrationDate: -1,
}, },
{ id: 1, name: 1, email: 1, profilePicture: 1 } { _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
); ),
const latestTeachers = await getUsers( getUsers(
{ type: "teacher" }, { type: "teacher" },
10, 10,
{ {
registrationDate: -1, registrationDate: -1,
}, },
{ id: 1, name: 1, email: 1, profilePicture: 1 } { _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
); ),
]);
const entities = await getEntities(undefined, { _id: 0, id: 1, label: 1 });
const assignmentsCount = await countEntitiesAssignments( const assignmentsCount = await countEntitiesAssignments(
mapBy(entities, "id"), mapBy(entities, "id"),
{ archived: { $ne: true } } { archived: { $ne: true } }
); );
const groupsCount = await countGroups();
const stats = await getStatsByUsers(mapBy(students, "id")); const stats = await getStatsByUsers(mapBy(students, "id"));
return { return {

View File

@@ -14,10 +14,7 @@ import {
groupAllowedEntitiesByPermissions, groupAllowedEntitiesByPermissions,
} from "@/utils/permissions"; } from "@/utils/permissions";
import { groupByExam } from "@/utils/stats"; import { groupByExam } from "@/utils/stats";
import { import { countAllowedUsers, getUsers } from "@/utils/users.be";
countAllowedUsers,
getUsers,
} from "@/utils/users.be";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import moment from "moment"; import moment from "moment";
import Head from "next/head"; import Head from "next/head";
@@ -35,6 +32,7 @@ import {
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import { useAllowedEntities } from "@/hooks/useEntityPermissions"; import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import { isAdmin } from "@/utils/users"; import { isAdmin } from "@/utils/users";
import { count } from "console";
interface Props { interface Props {
user: User; user: User;
@@ -71,37 +69,41 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const allowedStudentEntitiesIDS = mapBy(allowedStudentEntities, "id"); const allowedStudentEntitiesIDS = mapBy(allowedStudentEntities, "id");
const entitiesIDS = mapBy(entities, "id") || []; const entitiesIDS = mapBy(entities, "id") || [];
const [
const students = await getUsers( students,
latestStudents,
latestTeachers,
userCounts,
assignmentsCount,
groupsCount,
] = await Promise.all([
getUsers(
{ type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } }, { type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } },
10, 10,
{ averageLevel: -1 }, { averageLevel: -1 },
{ id: 1, name: 1, email: 1, profilePicture: 1 } { _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
); ),
const latestStudents = await getUsers( getUsers(
{ type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } }, { type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } },
10, 10,
{ registrationDate: -1 }, { registrationDate: -1 },
{ id: 1, name: 1, email: 1, profilePicture: 1 } { _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
); ),
const latestTeachers = await getUsers( getUsers(
{ {
type: "teacher", type: "teacher",
"entities.id": { $in: mapBy(allowedTeacherEntities, "id") }, "entities.id": { $in: mapBy(allowedTeacherEntities, "id") },
}, },
10, 10,
{ registrationDate: -1 }, { registrationDate: -1 },
{ id: 1, name: 1, email: 1, profilePicture: 1 } { _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
); ),
countAllowedUsers(user, entities),
const userCounts = await countAllowedUsers(user, entities); countEntitiesAssignments(entitiesIDS, {
archived: { $ne: true },
const assignmentsCount = await countEntitiesAssignments( }),
entitiesIDS, countGroupsByEntities(entitiesIDS),
{ archived: { $ne: true } } ]);
);
const groupsCount = await countGroupsByEntities(entitiesIDS);
return { return {
props: serialize({ props: serialize({

View File

@@ -6,17 +6,12 @@ import { Stat, Type, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { filterBy, mapBy, redirect, serialize } from "@/utils"; import { filterBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { import { countEntitiesAssignments } from "@/utils/assignments.be";
countEntitiesAssignments,
} from "@/utils/assignments.be";
import { getEntities } from "@/utils/entities.be"; import { getEntities } from "@/utils/entities.be";
import { countGroups } from "@/utils/groups.be"; import { countGroups } from "@/utils/groups.be";
import { checkAccess } from "@/utils/permissions"; import { checkAccess } from "@/utils/permissions";
import { groupByExam } from "@/utils/stats"; import { groupByExam } from "@/utils/stats";
import { import { countUsersByTypes, getUsers } from "@/utils/users.be";
countUsersByTypes,
getUsers,
} from "@/utils/users.be";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import Head from "next/head"; import Head from "next/head";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@@ -49,45 +44,41 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
if (!checkAccess(user, ["admin", "developer"])) return redirect("/"); if (!checkAccess(user, ["admin", "developer"])) return redirect("/");
const students = await getUsers( const [
students,
latestStudents,
latestTeachers,
usersCount,
entities,
groupsCount,
] = await Promise.all([
getUsers(
{ type: "student" }, { type: "student" },
10, 10,
{ { averageLevel: -1 },
averageLevel: -1, { _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
}, ),
{ id: 1, name: 1, email: 1, profilePicture: 1 } getUsers(
);
const usersCount = await countUsersByTypes([
"student",
"teacher",
"corporate",
"mastercorporate",
]);
const latestStudents = await getUsers(
{ type: "student" }, { type: "student" },
10, 10,
{ { registrationDate: -1 },
registrationDate: -1, { _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
}, ),
{id:1, name: 1, email: 1, profilePicture: 1 } getUsers(
);
const latestTeachers = await getUsers(
{ type: "teacher" }, { type: "teacher" },
10, 10,
{ { registrationDate: -1 },
registrationDate: -1, { _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
}, ),
{ id:1,name: 1, email: 1, profilePicture: 1 } countUsersByTypes(["student", "teacher", "corporate", "mastercorporate"]),
); getEntities(undefined, { _id: 0, id: 1, label: 1 }),
countGroups(),
]);
const entities = await getEntities(undefined, { _id: 0, id: 1, label: 1 });
const assignmentsCount = await countEntitiesAssignments( const assignmentsCount = await countEntitiesAssignments(
mapBy(entities, "id"), mapBy(entities, "id"),
{ archived: { $ne: true } } { archived: { $ne: true } }
); );
const groupsCount = await countGroups();
return { return {
props: serialize({ props: serialize({

View File

@@ -34,6 +34,7 @@ import {
} from "react-icons/bs"; } from "react-icons/bs";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import { isAdmin } from "@/utils/users"; import { isAdmin } from "@/utils/users";
import { count } from "console";
interface Props { interface Props {
user: User; user: User;
@@ -70,37 +71,39 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const entitiesIDS = mapBy(entities, "id") || []; const entitiesIDS = mapBy(entities, "id") || [];
const students = await getUsers( const [
students,
latestStudents,
latestTeachers,
userCounts,
assignmentsCount,
groupsCount,
] = await Promise.all([
getUsers(
{ type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } }, { type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } },
10, 10,
{ averageLevel: -1 }, { averageLevel: -1 },
{ id: 1, name: 1, email: 1, profilePicture: 1 } { _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
); ),
getUsers(
const latestStudents = await getUsers(
{ type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } }, { type: "student", "entities.id": { $in: allowedStudentEntitiesIDS } },
10, 10,
{ registrationDate: -1 }, { registrationDate: -1 },
{ id: 1, name: 1, email: 1, profilePicture: 1 } { _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
); ),
getUsers(
const latestTeachers = await getUsers(
{ {
type: "teacher", type: "teacher",
"entities.id": { $in: mapBy(allowedTeacherEntities, "id") }, "entities.id": { $in: mapBy(allowedTeacherEntities, "id") },
}, },
10, 10,
{ registrationDate: -1 }, { registrationDate: -1 },
{ id: 1, name: 1, email: 1, profilePicture: 1 } { _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
); ),
countAllowedUsers(user, entities),
const userCounts = await countAllowedUsers(user, entities); countEntitiesAssignments(entitiesIDS, { archived: { $ne: true } }),
countGroupsByEntities(entitiesIDS),
const assignmentsCount = await countEntitiesAssignments(entitiesIDS, { ]);
archived: { $ne: true },
});
const groupsCount = await countGroupsByEntities(entitiesIDS);
return { return {
props: serialize({ props: serialize({
@@ -127,6 +130,7 @@ export default function Dashboard({
stats = [], stats = [],
groupsCount, groupsCount,
}: Props) { }: Props) {
const totalCount = useMemo( const totalCount = useMemo(
() => () =>
userCounts.corporate + userCounts.corporate +

View File

@@ -5,7 +5,7 @@ import InviteWithUserCard from "@/components/Medium/InviteWithUserCard";
import ModuleBadge from "@/components/ModuleBadge"; import ModuleBadge from "@/components/ModuleBadge";
import ProfileSummary from "@/components/ProfileSummary"; import ProfileSummary from "@/components/ProfileSummary";
import { Session } from "@/hooks/useSessions"; import { Session } from "@/hooks/useSessions";
import { Grading } from "@/interfaces"; import { Grading, Module } from "@/interfaces";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { Exam } from "@/interfaces/exam"; import { Exam } from "@/interfaces/exam";
import { InviteWithEntity } from "@/interfaces/invite"; import { InviteWithEntity } from "@/interfaces/invite";
@@ -34,6 +34,7 @@ import { capitalize, uniqBy } from "lodash";
import moment from "moment"; import moment from "moment";
import Head from "next/head"; import Head from "next/head";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useMemo } from "react";
import { import {
BsBook, BsBook,
BsClipboard, BsClipboard,
@@ -65,42 +66,49 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
return redirect("/"); return redirect("/");
const entityIDS = mapBy(user.entities, "id") || []; const entityIDS = mapBy(user.entities, "id") || [];
const entities = await getEntities(entityIDS, { _id: 0, label: 1 });
const currentDate = moment().toISOString(); const currentDate = moment().toISOString();
const assignments = await getAssignmentsForStudent(user.id, currentDate);
const stats = await getDetailedStatsByUser(user.id, "stats");
const [assignments, stats, invites, grading] = await Promise.all([
getAssignmentsForStudent(user.id, currentDate),
getDetailedStatsByUser(user.id, "stats"),
getInvitesByInvitee(user.id),
getGradingSystemByEntity(entityIDS[0] || "", {
_id: 0,
steps: 1,
}),
]);
const assignmentsIDs = mapBy(assignments, "id"); const assignmentsIDs = mapBy(assignments, "id");
const sessions = await getSessionsByUser(user.id, 10, { const sessions = await getSessionsByUser(user.id, 10, {
["assignment.id"]: { $in: assignmentsIDs }, ["assignment.id"]: { $in: assignmentsIDs },
}); });
const invites = await getInvitesByInvitee(user.id);
const grading = await getGradingSystemByEntity(entityIDS[0] || "", {
_id: 0,
steps: 1,
});
const formattedInvites = await Promise.all( const formattedInvites = await Promise.all(
invites.map(convertInvitersToEntity) invites.map(convertInvitersToEntity)
); );
const examIDs = uniqBy( const examIDs = uniqBy(
assignments.flatMap((a) => assignments.reduce<{ module: Module; id: string; key: string }[]>(
a.exams.map((e: { module: string; id: string }) => ({ (acc, a) => {
a.exams.forEach((e: { module: Module; id: string }) => {
acc.push({
module: e.module, module: e.module,
id: e.id, id: e.id,
key: `${e.module}_${e.id}`, key: `${e.module}_${e.id}`,
})) });
});
return acc;
},
[]
), ),
"key" "key"
); );
const exams = examIDs.length > 0 ? await getExamsByIds(examIDs) : []; const exams = examIDs.length > 0 ? await getExamsByIds(examIDs) : [];
return { return {
props: serialize({ props: serialize({
user, user,
entities,
assignments, assignments,
stats, stats,
exams, exams,
@@ -145,6 +153,11 @@ export default function Dashboard({
} }
}; };
const entitiesLabels = useMemo(
() => (entities.length > 0 ? mapBy(entities, "label")?.join(", ") : ""),
[entities]
);
return ( return (
<> <>
<Head> <Head>
@@ -160,7 +173,7 @@ export default function Dashboard({
<> <>
{entities.length > 0 && ( {entities.length > 0 && (
<div className="rounded-lg bg-neutral-200 px-2 py-1 "> <div className="rounded-lg bg-neutral-200 px-2 py-1 ">
<b>{mapBy(entities, "label")?.join(", ")}</b> <b>{entitiesLabels}</b>
</div> </div>
)} )}

View File

@@ -27,6 +27,7 @@ import { requestUser } from "@/utils/api";
import { useAllowedEntities } from "@/hooks/useEntityPermissions"; import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import { getEntitiesUsers } from "@/utils/users.be"; import { getEntitiesUsers } from "@/utils/users.be";
import { isAdmin } from "@/utils/users"; import { isAdmin } from "@/utils/users";
import { useMemo } from "react";
interface Props { interface Props {
user: User; user: User;
@@ -52,7 +53,8 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const filteredEntities = findAllowedEntities(user, entities, "view_students"); const filteredEntities = findAllowedEntities(user, entities, "view_students");
const students = await getEntitiesUsers( const [students, assignments, groups] = await Promise.all([
getEntitiesUsers(
mapBy(filteredEntities, "id"), mapBy(filteredEntities, "id"),
{ {
type: "student", type: "student",
@@ -67,14 +69,13 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
levels: 1, levels: 1,
registrationDate: 1, registrationDate: 1,
} }
); ),
getEntitiesAssignments(entityIDS),
const assignments = await getEntitiesAssignments(entityIDS); getGroupsByEntities(entityIDS),
]);
const stats = await getStatsByUsers(students.map((u) => u.id)); const stats = await getStatsByUsers(students.map((u) => u.id));
const groups = await getGroupsByEntities(entityIDS);
return { return {
props: serialize({ user, students, entities, assignments, stats, groups }), props: serialize({ user, students, entities, assignments, stats, groups }),
}; };
@@ -100,6 +101,10 @@ export default function Dashboard({
entities, entities,
"view_student_performance" "view_student_performance"
); );
const entitiesLabels = useMemo(
() => mapBy(entities, "label")?.join(", "),
[entities]
);
return ( return (
<> <>
@@ -117,7 +122,7 @@ export default function Dashboard({
<div className="w-full flex flex-col gap-4"> <div className="w-full flex flex-col gap-4">
{entities.length > 0 && ( {entities.length > 0 && (
<div className="w-fit self-end bg-neutral-200 px-2 rounded-lg py-1"> <div className="w-fit self-end bg-neutral-200 px-2 rounded-lg py-1">
<b>{mapBy(entities, "label")?.join(", ")}</b> <b>{entitiesLabels}</b>
</div> </div>
)} )}
<section className="grid grid-cols-5 -md:grid-cols-2 place-items-center gap-4 text-center"> <section className="grid grid-cols-5 -md:grid-cols-2 place-items-center gap-4 text-center">

View File

@@ -14,7 +14,12 @@ import { getEntityWithRoles } from "@/utils/entities.be";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { doesEntityAllow } from "@/utils/permissions"; import { doesEntityAllow } from "@/utils/permissions";
import { getUserName, isAdmin } from "@/utils/users"; import { getUserName, isAdmin } from "@/utils/users";
import { filterAllowedUsers, getEntitiesUsers, getEntityUsers, getUsers } from "@/utils/users.be"; import {
filterAllowedUsers,
getEntitiesUsers,
getEntityUsers,
getUsers,
} from "@/utils/users.be";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react"; import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
@@ -42,14 +47,18 @@ import {
BsX, BsX,
} from "react-icons/bs"; } from "react-icons/bs";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import entities from "../../api/entities";
const expirationDateColor = (date: Date) => { const expirationDateColor = (date: Date) => {
const momentDate = moment(date); const momentDate = moment(date);
const today = moment(new Date()); const today = moment(new Date());
if (today.add(1, "days").isAfter(momentDate)) return "!bg-mti-red-ultralight border-mti-red-light"; if (today.add(1, "days").isAfter(momentDate))
if (today.add(3, "days").isAfter(momentDate)) return "!bg-mti-rose-ultralight border-mti-rose-light"; return "!bg-mti-red-ultralight border-mti-red-light";
if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light"; if (today.add(3, "days").isAfter(momentDate))
return "!bg-mti-rose-ultralight border-mti-rose-light";
if (today.add(7, "days").isAfter(momentDate))
return "!bg-mti-orange-ultralight border-mti-orange-light";
}; };
const CURRENCIES_OPTIONS = CURRENCIES.map(({ label, currency }) => ({ const CURRENCIES_OPTIONS = CURRENCIES.map(({ label, currency }) => ({
@@ -57,26 +66,62 @@ const CURRENCIES_OPTIONS = CURRENCIES.map(({ label, currency }) => ({
label, label,
})); }));
export const getServerSideProps = withIronSessionSsr(async ({ req, params }) => { const USER_DATA_SCHEMA = {
_id: 0,
id: 1,
name: 1,
type: 1,
profilePicture: 1,
email: 1,
lastLogin: 1,
subscriptionExpirationDate: 1,
entities: 1,
corporateInformation: 1,
};
export const getServerSideProps = withIronSessionSsr(
async ({ req, params }) => {
const user = req.session.user as User; const user = req.session.user as User;
if (!user) return redirect("/login") if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/") if (shouldRedirectHome(user)) return redirect("/");
const { id } = params as { id: string }; const { id } = params as { id: string };
const entity = await getEntityWithRoles(id); const entity = await getEntityWithRoles(id);
if (!entity) return redirect("/entities") if (!entity) return redirect("/entities");
if (!doesEntityAllow(user, entity, "view_entities")) return redirect(`/entities`) if (!doesEntityAllow(user, entity, "view_entities"))
return redirect(`/entities`);
const linkedUsers = await (isAdmin(user) ? getUsers() : getEntitiesUsers(mapBy(user.entities, 'id'), const [linkedUsers, entityUsers] = await Promise.all([
{ $and: [{ type: { $ne: "developer" } }, { type: { $ne: "admin" } }] })) isAdmin(user)
const entityUsers = await (isAdmin(user) ? getEntityUsers(id) : filterAllowedUsers(user, [entity])); ? getUsers({}, 0, {}, USER_DATA_SCHEMA)
: getEntitiesUsers(
mapBy(user.entities, "id"),
{
$and: [
{ type: { $ne: "developer" } },
{ type: { $ne: "admin" } },
],
},
0,
USER_DATA_SCHEMA
),
isAdmin(user)
? getEntityUsers(
id,
0,
{
id: { $ne: user.id },
},
USER_DATA_SCHEMA
)
: filterAllowedUsers(user, [entity], USER_DATA_SCHEMA),
]);
const usersWithRole = entityUsers.map((u) => { const usersWithRole = entityUsers.map((u) => {
const e = u.entities.find((e) => e.id === id); const e = u?.entities?.find((e) => e.id === id);
return { ...u, role: findBy(entity.roles, 'id', e?.role) }; return { ...u, role: findBy(entity.roles, "id", e?.role) };
}); });
return { return {
@@ -84,10 +129,14 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, params }) =>
user, user,
entity, entity,
users: usersWithRole, users: usersWithRole,
linkedUsers: linkedUsers.filter(x => x.id !== user.id && !mapBy(entityUsers, 'id').includes(x.id)), linkedUsers: linkedUsers.filter(
(x) => !mapBy(entityUsers, "id").includes(x.id)
),
}), }),
}; };
}, sessionOptions); },
sessionOptions
);
type UserWithRole = User & { role?: Role }; type UserWithRole = User & { role?: Role };
@@ -102,34 +151,52 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
const [isAdding, setIsAdding] = useState(false); const [isAdding, setIsAdding] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [selectedUsers, setSelectedUsers] = useState<string[]>([]); const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
const [expiryDate, setExpiryDate] = useState(entity?.expiryDate) const [expiryDate, setExpiryDate] = useState(entity?.expiryDate);
const [paymentPrice, setPaymentPrice] = useState(entity?.payment?.price) const [paymentPrice, setPaymentPrice] = useState(entity?.payment?.price);
const [paymentCurrency, setPaymentCurrency] = useState(entity?.payment?.currency) const [paymentCurrency, setPaymentCurrency] = useState(
entity?.payment?.currency
);
const router = useRouter(); const router = useRouter();
const canRenameEntity = useEntityPermission(user, entity, "rename_entity") const canRenameEntity = useEntityPermission(user, entity, "rename_entity");
const canViewRoles = useEntityPermission(user, entity, "view_entity_roles") const canViewRoles = useEntityPermission(user, entity, "view_entity_roles");
const canDeleteEntity = useEntityPermission(user, entity, "delete_entity") const canDeleteEntity = useEntityPermission(user, entity, "delete_entity");
const canAddMembers = useEntityPermission(user, entity, "add_to_entity") const canAddMembers = useEntityPermission(user, entity, "add_to_entity");
const canRemoveMembers = useEntityPermission(user, entity, "remove_from_entity") const canRemoveMembers = useEntityPermission(
user,
entity,
"remove_from_entity"
);
const canAssignRole = useEntityPermission(user, entity, "assign_to_role") const canAssignRole = useEntityPermission(user, entity, "assign_to_role");
const canPay = useEntityPermission(user, entity, 'pay_entity') const canPay = useEntityPermission(user, entity, "pay_entity");
const toggleUser = (u: User) => setSelectedUsers((prev) => (prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id])); const toggleUser = (u: User) =>
setSelectedUsers((prev) =>
prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]
);
const removeParticipants = () => { const removeParticipants = () => {
if (selectedUsers.length === 0) return; if (selectedUsers.length === 0) return;
if (!canRemoveMembers) return; if (!canRemoveMembers) return;
if (!confirm(`Are you sure you want to remove ${selectedUsers.length} member${selectedUsers.length === 1 ? "" : "s"} from this entity?`)) if (
!confirm(
`Are you sure you want to remove ${selectedUsers.length} member${
selectedUsers.length === 1 ? "" : "s"
} from this entity?`
)
)
return; return;
setIsLoading(true); setIsLoading(true);
axios axios
.patch(`/api/entities/${entity.id}/users`, { add: false, members: selectedUsers }) .patch(`/api/entities/${entity.id}/users`, {
add: false,
members: selectedUsers,
})
.then(() => { .then(() => {
toast.success("The entity has been updated successfully!"); toast.success("The entity has been updated successfully!");
router.replace(router.asPath); router.replace(router.asPath);
@@ -145,13 +212,24 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
const addParticipants = () => { const addParticipants = () => {
if (selectedUsers.length === 0) return; if (selectedUsers.length === 0) return;
if (!canAddMembers || !isAdding) return; if (!canAddMembers || !isAdding) return;
if (!confirm(`Are you sure you want to add ${selectedUsers.length} member${selectedUsers.length === 1 ? "" : "s"} to this entity?`)) return; if (
!confirm(
`Are you sure you want to add ${selectedUsers.length} member${
selectedUsers.length === 1 ? "" : "s"
} to this entity?`
)
)
return;
setIsLoading(true); setIsLoading(true);
const defaultRole = findBy(entity.roles, 'isDefault', true)! const defaultRole = findBy(entity.roles, "isDefault", true)!;
axios axios
.patch(`/api/entities/${entity.id}/users`, { add: true, members: selectedUsers, role: defaultRole.id }) .patch(`/api/entities/${entity.id}/users`, {
add: true,
members: selectedUsers,
role: defaultRole.id,
})
.then(() => { .then(() => {
toast.success("The entity has been updated successfully!"); toast.success("The entity has been updated successfully!");
router.replace(router.asPath); router.replace(router.asPath);
@@ -206,7 +284,9 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
setIsLoading(true); setIsLoading(true);
axios axios
.patch(`/api/entities/${entity.id}`, { payment: { price: paymentPrice, currency: paymentCurrency } }) .patch(`/api/entities/${entity.id}`, {
payment: { price: paymentPrice, currency: paymentCurrency },
})
.then(() => { .then(() => {
toast.success("The entity has been updated successfully!"); toast.success("The entity has been updated successfully!");
router.replace(router.asPath); router.replace(router.asPath);
@@ -221,9 +301,13 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
const editLicenses = () => { const editLicenses = () => {
if (!isAdmin(user)) return; if (!isAdmin(user)) return;
const licenses = prompt("Update the number of licenses:", (entity.licenses || 0).toString()); const licenses = prompt(
"Update the number of licenses:",
(entity.licenses || 0).toString()
);
if (!licenses) return; if (!licenses) return;
if (!parseInt(licenses) || parseInt(licenses) <= 0) return toast.error("Write a valid number of licenses!") if (!parseInt(licenses) || parseInt(licenses) <= 0)
return toast.error("Write a valid number of licenses!");
setIsLoading(true); setIsLoading(true);
axios axios
@@ -259,8 +343,8 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
}; };
const assignUsersToRole = (role: string) => { const assignUsersToRole = (role: string) => {
if (!canAssignRole) return if (!canAssignRole) return;
if (selectedUsers.length === 0) return if (selectedUsers.length === 0) return;
setIsLoading(true); setIsLoading(true);
axios axios
@@ -274,7 +358,7 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
toast.error("Something went wrong!"); toast.error("Something went wrong!");
}) })
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
} };
const renderCard = (u: UserWithRole) => { const renderCard = (u: UserWithRole) => {
return ( return (
@@ -285,8 +369,9 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
className={clsx( className={clsx(
"p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer", "p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer",
"hover:border-mti-purple transition ease-in-out duration-300", "hover:border-mti-purple transition ease-in-out duration-300",
selectedUsers.includes(u.id) && "border-mti-purple", selectedUsers.includes(u.id) && "border-mti-purple"
)}> )}
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full"> <div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full">
<img src={u.profilePicture} alt={u.name} /> <img src={u.profilePicture} alt={u.name} />
@@ -311,13 +396,17 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<Tooltip tooltip="Expiration Date"> <Tooltip tooltip="Expiration Date">
<BsStopwatchFill /> <BsStopwatchFill />
</Tooltip> </Tooltip>
{u.subscriptionExpirationDate ? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited"} {u.subscriptionExpirationDate
? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY")
: "Unlimited"}
</span> </span>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<Tooltip tooltip="Last Login"> <Tooltip tooltip="Last Login">
<BsClockFill /> <BsClockFill />
</Tooltip> </Tooltip>
{u.lastLogin ? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm") : "N/A"} {u.lastLogin
? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm")
: "N/A"}
</span> </span>
</div> </div>
</button> </button>
@@ -345,10 +434,14 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link <Link
href="/entities" href="/entities"
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"> className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
>
<BsChevronLeft /> <BsChevronLeft />
</Link> </Link>
<h2 className="font-bold text-2xl">{entity.label} {isAdmin(user) && `- ${entity.licenses || 0} licenses`}</h2> <h2 className="font-bold text-2xl">
{entity.label}{" "}
{isAdmin(user) && `- ${entity.licenses || 0} licenses`}
</h2>
</div> </div>
{!isAdmin(user) && canPay && ( {!isAdmin(user) && canPay && (
@@ -357,11 +450,15 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
className={clsx( className={clsx(
"p-2 w-full max-w-[200px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer", "p-2 w-full max-w-[200px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
!entity.expiryDate ? "!bg-mti-green-ultralight !border-mti-green-light" : expirationDateColor(entity.expiryDate), !entity.expiryDate
"bg-white border-mti-gray-platinum", ? "!bg-mti-green-ultralight !border-mti-green-light"
)}> : expirationDateColor(entity.expiryDate),
"bg-white border-mti-gray-platinum"
)}
>
{!entity.expiryDate && "Unlimited"} {!entity.expiryDate && "Unlimited"}
{entity.expiryDate && moment(entity.expiryDate).format("DD/MM/YYYY")} {entity.expiryDate &&
moment(entity.expiryDate).format("DD/MM/YYYY")}
</Link> </Link>
)} )}
</div> </div>
@@ -369,7 +466,8 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<button <button
onClick={renameGroup} onClick={renameGroup}
disabled={isLoading || !canRenameEntity} disabled={isLoading || !canRenameEntity}
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsTag /> <BsTag />
<span className="text-xs">Rename Entity</span> <span className="text-xs">Rename Entity</span>
</button> </button>
@@ -377,7 +475,8 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<button <button
onClick={editLicenses} onClick={editLicenses}
disabled={isLoading || !isAdmin(user)} disabled={isLoading || !isAdmin(user)}
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsHash /> <BsHash />
<span className="text-xs">Edit Licenses</span> <span className="text-xs">Edit Licenses</span>
</button> </button>
@@ -385,14 +484,16 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<button <button
onClick={() => router.push(`/entities/${entity.id}/roles`)} onClick={() => router.push(`/entities/${entity.id}/roles`)}
disabled={isLoading || !canViewRoles} disabled={isLoading || !canViewRoles}
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsPerson /> <BsPerson />
<span className="text-xs">Edit Roles</span> <span className="text-xs">Edit Roles</span>
</button> </button>
<button <button
onClick={deleteGroup} onClick={deleteGroup}
disabled={isLoading || !canDeleteEntity} disabled={isLoading || !canDeleteEntity}
className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsTrash /> <BsTrash />
<span className="text-xs">Delete Entity</span> <span className="text-xs">Delete Entity</span>
</button> </button>
@@ -410,8 +511,10 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
className={clsx( className={clsx(
"p-2 w-full max-w-[200px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer", "p-2 w-full max-w-[200px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip", "hover:border-mti-purple tooltip",
!expiryDate ? "!bg-mti-green-ultralight !border-mti-green-light" : expirationDateColor(expiryDate), !expiryDate
"transition duration-300 ease-in-out", ? "!bg-mti-green-ultralight !border-mti-green-light"
: expirationDateColor(expiryDate),
"transition duration-300 ease-in-out"
)} )}
filterDate={(date) => moment(date).isAfter(new Date())} filterDate={(date) => moment(date).isAfter(new Date())}
dateFormat="dd/MM/yyyy" dateFormat="dd/MM/yyyy"
@@ -425,8 +528,10 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
className={clsx( className={clsx(
"p-2 w-full max-w-[200px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer", "p-2 w-full max-w-[200px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
!expiryDate ? "!bg-mti-green-ultralight !border-mti-green-light" : expirationDateColor(expiryDate), !expiryDate
"bg-white border-mti-gray-platinum", ? "!bg-mti-green-ultralight !border-mti-green-light"
: expirationDateColor(expiryDate),
"bg-white border-mti-gray-platinum"
)} )}
> >
Unlimited Unlimited
@@ -435,17 +540,21 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<Checkbox <Checkbox
isChecked={!!expiryDate} isChecked={!!expiryDate}
onChange={(checked: boolean) => setExpiryDate(checked ? entity.expiryDate || new Date() : null)} onChange={(checked: boolean) =>
setExpiryDate(
checked ? entity.expiryDate || new Date() : null
)
}
> >
Enable expiry date Enable expiry date
</Checkbox> </Checkbox>
</div> </div>
<button <button
onClick={updateExpiryDate} onClick={updateExpiryDate}
disabled={expiryDate === entity.expiryDate} disabled={expiryDate === entity.expiryDate}
className="flex w-fit text-nowrap items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> className="flex w-fit text-nowrap items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsCheck /> <BsCheck />
<span className="text-xs">Apply Change</span> <span className="text-xs">Apply Change</span>
</button> </button>
@@ -457,25 +566,34 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<div className="w-full max-w-xl flex items-center gap-4"> <div className="w-full max-w-xl flex items-center gap-4">
<Input <Input
name="paymentValue" name="paymentValue"
onChange={(e) => setPaymentPrice(e ? parseInt(e) : undefined)} onChange={(e) =>
setPaymentPrice(e ? parseInt(e) : undefined)
}
type="number" type="number"
defaultValue={entity.payment?.price || 0} defaultValue={entity.payment?.price || 0}
thin thin
/> />
<Select <Select
className={clsx( className={clsx(
"px-4 !py-2 !w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none", "px-4 !py-2 !w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
)} )}
options={CURRENCIES_OPTIONS} options={CURRENCIES_OPTIONS}
value={CURRENCIES_OPTIONS.find((c) => c.value === paymentCurrency)} value={CURRENCIES_OPTIONS.find(
onChange={(value) => setPaymentCurrency(value?.value ?? undefined)} (c) => c.value === paymentCurrency
)}
onChange={(value) =>
setPaymentCurrency(value?.value ?? undefined)
}
/> />
</div> </div>
<button <button
onClick={updatePayment} onClick={updatePayment}
disabled={!paymentPrice || paymentPrice <= 0 || !paymentCurrency} disabled={
className="flex w-fit text-nowrap items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> !paymentPrice || paymentPrice <= 0 || !paymentCurrency
}
className="flex w-fit text-nowrap items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsCheck /> <BsCheck />
<span className="text-xs">Apply Change</span> <span className="text-xs">Apply Change</span>
</button> </button>
@@ -485,28 +603,40 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<Divider /> <Divider />
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<span className="font-semibold text-xl">Members ({users.length})</span> <span className="font-semibold text-xl">
Members ({users.length})
</span>
{!isAdding && ( {!isAdding && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={() => setIsAdding(true)} onClick={() => setIsAdding(true)}
disabled={isLoading || !canAddMembers} disabled={isLoading || !canAddMembers}
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsPlus /> <BsPlus />
<span className="text-xs">Add Members</span> <span className="text-xs">Add Members</span>
</button> </button>
<Menu> <Menu>
<MenuButton <MenuButton
disabled={isLoading || !canAssignRole || selectedUsers.length === 0} disabled={
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> isLoading || !canAssignRole || selectedUsers.length === 0
}
className="flex items-center gap-1 px-2 py-2 border rounded-full hover:bg-neutral-100 disabled:hover:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsPerson /> <BsPerson />
<span className="text-xs">Assign Role</span> <span className="text-xs">Assign Role</span>
</MenuButton> </MenuButton>
<MenuItems anchor="bottom" className="bg-white rounded-xl shadow drop-shadow border mt-1 flex flex-col"> <MenuItems
anchor="bottom"
className="bg-white rounded-xl shadow drop-shadow border mt-1 flex flex-col"
>
{entity.roles.map((role) => ( {entity.roles.map((role) => (
<MenuItem key={role.id}> <MenuItem key={role.id}>
<button onClick={() => assignUsersToRole(role.id)} className="p-4 hover:bg-neutral-100 w-32"> <button
onClick={() => assignUsersToRole(role.id)}
className="p-4 hover:bg-neutral-100 w-32"
>
{role.label} {role.label}
</button> </button>
</MenuItem> </MenuItem>
@@ -516,8 +646,11 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<button <button
onClick={removeParticipants} onClick={removeParticipants}
disabled={selectedUsers.length === 0 || isLoading || !canRemoveMembers} disabled={
className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> selectedUsers.length === 0 || isLoading || !canRemoveMembers
}
className="flex items-center gap-1 px-2 py-2 border border-mti-rose rounded-full bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsTrash /> <BsTrash />
<span className="text-xs">Remove Members</span> <span className="text-xs">Remove Members</span>
</button> </button>
@@ -528,16 +661,22 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<button <button
onClick={() => setIsAdding(false)} onClick={() => setIsAdding(false)}
disabled={isLoading} disabled={isLoading}
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-rose bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-rose bg-mti-rose-light text-white hover:bg-mti-rose-dark disabled:hover:bg-mti-rose-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsX /> <BsX />
<span className="text-xs">Discard Selection</span> <span className="text-xs">Discard Selection</span>
</button> </button>
<button <button
onClick={addParticipants} onClick={addParticipants}
disabled={selectedUsers.length === 0 || isLoading || !canAddMembers} disabled={
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> selectedUsers.length === 0 || isLoading || !canAddMembers
}
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsPlus /> <BsPlus />
<span className="text-xs">Add Members ({selectedUsers.length})</span> <span className="text-xs">
Add Members ({selectedUsers.length})
</span>
</button> </button>
</div> </div>
)} )}
@@ -546,7 +685,13 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<CardList<User | UserWithRole> <CardList<User | UserWithRole>
list={isAdding ? linkedUsers : users} list={isAdding ? linkedUsers : users}
renderCard={renderCard} renderCard={renderCard}
searchFields={[["name"], ["email"], ["corporateInformation", "companyInformation", "name"], ["role", "label"], ["type"]]} searchFields={[
["name"],
["email"],
["corporateInformation", "companyInformation", "name"],
["role", "label"],
["type"],
]}
/> />
</section> </section>
</> </>

View File

@@ -20,21 +20,41 @@ import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Divider } from "primereact/divider"; import { Divider } from "primereact/divider";
import { useState } from "react"; import { useState } from "react";
import { BsCheck, BsChevronLeft, BsClockFill, BsEnvelopeFill, BsStopwatchFill } from "react-icons/bs"; import {
BsCheck,
BsChevronLeft,
BsClockFill,
BsEnvelopeFill,
BsStopwatchFill,
} from "react-icons/bs";
import { toast, ToastContainer } from "react-toastify"; import { toast, ToastContainer } from "react-toastify";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res);
if (!user) return redirect("/login") if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/") if (shouldRedirectHome(user)) return redirect("/");
if (!["admin", "developer"].includes(user.type)) return redirect("/entities") if (!["admin", "developer"].includes(user.type)) return redirect("/entities");
const users = await getUsers() const users = await getUsers(
{ id: { $ne: user.id } },
0,
{},
{
_id: 0,
id: 1,
name: 1,
type: 1,
profilePicture: 1,
email: 1,
lastLogin: 1,
subscriptionExpirationDate: 1,
}
);
return { return {
props: serialize({ user, users: users.filter((x) => x.id !== user.id) }), props: serialize({ user, users }),
}; };
}, sessionOptions); }, sessionOptions);
@@ -49,19 +69,31 @@ export default function Home({ user, users }: Props) {
const [label, setLabel] = useState(""); const [label, setLabel] = useState("");
const [licenses, setLicenses] = useState(0); const [licenses, setLicenses] = useState(0);
const { rows, renderSearch } = useListSearch<User>([["name"], ["corporateInformation", "companyInformation", "name"]], users); const { rows, renderSearch } = useListSearch<User>(
[["name"], ["corporateInformation", "companyInformation", "name"]],
users
);
const { items, renderMinimal } = usePagination<User>(rows, 16); const { items, renderMinimal } = usePagination<User>(rows, 16);
const router = useRouter(); const router = useRouter();
const createGroup = () => { const createGroup = () => {
if (!label.trim()) return; if (!label.trim()) return;
if (!confirm(`Are you sure you want to create this entity with ${selectedUsers.length} members?`)) return; if (
!confirm(
`Are you sure you want to create this entity with ${selectedUsers.length} members?`
)
)
return;
setIsLoading(true); setIsLoading(true);
axios axios
.post<Entity>(`/api/entities`, { label, licenses, members: selectedUsers }) .post<Entity>(`/api/entities`, {
label,
licenses,
members: selectedUsers,
})
.then((result) => { .then((result) => {
toast.success("Your entity has been created successfully!"); toast.success("Your entity has been created successfully!");
router.replace(`/entities/${result.data.id}`); router.replace(`/entities/${result.data.id}`);
@@ -73,7 +105,10 @@ export default function Home({ user, users }: Props) {
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
const toggleUser = (u: User) => setSelectedUsers((prev) => (prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id])); const toggleUser = (u: User) =>
setSelectedUsers((prev) =>
prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]
);
return ( return (
<> <>
@@ -93,7 +128,8 @@ export default function Home({ user, users }: Props) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link <Link
href="/classrooms" href="/classrooms"
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"> className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
>
<BsChevronLeft /> <BsChevronLeft />
</Link> </Link>
<h2 className="font-bold text-2xl">Create Entity</h2> <h2 className="font-bold text-2xl">Create Entity</h2>
@@ -102,7 +138,8 @@ export default function Home({ user, users }: Props) {
<button <button
onClick={createGroup} onClick={createGroup}
disabled={!label.trim() || licenses <= 0 || isLoading} disabled={!label.trim() || licenses <= 0 || isLoading}
className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"> className="flex items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300"
>
<BsCheck /> <BsCheck />
<span className="text-xs">Create Entity</span> <span className="text-xs">Create Entity</span>
</button> </button>
@@ -112,17 +149,30 @@ export default function Home({ user, users }: Props) {
<div className="w-full grid grid-cols-2 gap-4"> <div className="w-full grid grid-cols-2 gap-4">
<div className="flex flex-col gap-4 w-full"> <div className="flex flex-col gap-4 w-full">
<span className="font-semibold text-xl">Entity Label:</span> <span className="font-semibold text-xl">Entity Label:</span>
<Input name="name" onChange={setLabel} type="text" placeholder="Entity A" /> <Input
name="name"
onChange={setLabel}
type="text"
placeholder="Entity A"
/>
</div> </div>
<div className="flex flex-col gap-4 w-full"> <div className="flex flex-col gap-4 w-full">
<span className="font-semibold text-xl">Licenses:</span> <span className="font-semibold text-xl">Licenses:</span>
<Input name="licenses" min={0} onChange={(v) => setLicenses(parseInt(v))} type="number" placeholder="12" /> <Input
name="licenses"
min={0}
onChange={(v) => setLicenses(parseInt(v))}
type="number"
placeholder="12"
/>
</div> </div>
</div> </div>
<Divider /> <Divider />
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<span className="font-semibold text-xl">Members ({selectedUsers.length} selected):</span> <span className="font-semibold text-xl">
Members ({selectedUsers.length} selected):
</span>
</div> </div>
<div className="w-full flex items-center gap-4"> <div className="w-full flex items-center gap-4">
{renderSearch()} {renderSearch()}
@@ -139,15 +189,18 @@ export default function Home({ user, users }: Props) {
className={clsx( className={clsx(
"p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer", "p-4 pr-6 h-48 relative border rounded-xl flex flex-col gap-3 justify-between text-left cursor-pointer",
"hover:border-mti-purple transition ease-in-out duration-300", "hover:border-mti-purple transition ease-in-out duration-300",
selectedUsers.includes(u.id) && "border-mti-purple", selectedUsers.includes(u.id) && "border-mti-purple"
)}> )}
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full"> <div className="min-w-[3rem] min-h-[3rem] w-12 h-12 border flex items-center justify-center overflow-hidden rounded-full">
<img src={u.profilePicture} alt={u.name} /> <img src={u.profilePicture} alt={u.name} />
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-semibold">{getUserName(u)}</span> <span className="font-semibold">{getUserName(u)}</span>
<span className="opacity-80 text-sm">{USER_TYPE_LABELS[u.type]}</span> <span className="opacity-80 text-sm">
{USER_TYPE_LABELS[u.type]}
</span>
</div> </div>
</div> </div>
@@ -162,13 +215,17 @@ export default function Home({ user, users }: Props) {
<Tooltip tooltip="Expiration Date"> <Tooltip tooltip="Expiration Date">
<BsStopwatchFill /> <BsStopwatchFill />
</Tooltip> </Tooltip>
{u.subscriptionExpirationDate ? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited"} {u.subscriptionExpirationDate
? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY")
: "Unlimited"}
</span> </span>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<Tooltip tooltip="Last Login"> <Tooltip tooltip="Last Login">
<BsClockFill /> <BsClockFill />
</Tooltip> </Tooltip>
{u.lastLogin ? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm") : "N/A"} {u.lastLogin
? moment(u.lastLogin).format("DD/MM/YYYY - HH:mm")
: "N/A"}
</span> </span>
</div> </div>
</button> </button>

View File

@@ -35,17 +35,35 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
); );
const allowedEntities = findAllowedEntities(user, entities, "view_entities"); const allowedEntities = findAllowedEntities(user, entities, "view_entities");
const entitiesWithCount = await Promise.all( const [counts, users] = await Promise.all([
allowedEntities.map(async (e) => ({ await Promise.all(
entity: e, allowedEntities.map(async (e) =>
count: await countEntityUsers(e.id, { countEntityUsers(e.id, {
type: { $in: ["student", "teacher", "corporate", "mastercorporate"] }, type: { $in: ["student", "teacher", "corporate", "mastercorporate"] },
}), })
users: await getEntityUsers(e.id, 5, { )
type: { $in: ["student", "teacher", "corporate", "mastercorporate"] }, ),
}), await Promise.all(
})) allowedEntities.map(async (e) =>
); getEntityUsers(
e.id,
5,
{
type: {
$in: ["student", "teacher", "corporate", "mastercorporate"],
},
},
{ name: 1 }
)
)
),
]);
const entitiesWithCount = allowedEntities.map<{
entity: EntityWithRoles;
users: User[];
count: number;
}>((e, i) => ({ entity: e, users: users[i], count: counts[i] }));
return { return {
props: serialize({ user, entities: entitiesWithCount }), props: serialize({ user, entities: entitiesWithCount }),

View File

@@ -21,77 +21,103 @@ import { getSessionByAssignment } from "@/utils/sessions.be";
import { Session } from "@/hooks/useSessions"; import { Session } from "@/hooks/useSessions";
import { activeAssignmentFilter } from "@/utils/assignments"; import { activeAssignmentFilter } from "@/utils/assignments";
export const getServerSideProps = withIronSessionSsr(async ({ req, res, query }) => { export const getServerSideProps = withIronSessionSsr(
const user = await requestUser(req, res) async ({ req, res, query }) => {
const loginDestination = Buffer.from(req.url || "/").toString("base64") const user = await requestUser(req, res);
if (!user) return redirect(`/login?destination=${loginDestination}`) const loginDestination = Buffer.from(req.url || "/").toString("base64");
if (!user) return redirect(`/login?destination=${loginDestination}`);
if (shouldRedirectHome(user)) return redirect("/") if (shouldRedirectHome(user)) return redirect("/");
const { assignment: assignmentID, destination } = query as { assignment?: string, destination?: string } const { assignment: assignmentID, destination } = query as {
const destinationURL = !!destination ? Buffer.from(destination, 'base64').toString() : undefined assignment?: string;
destination?: string;
};
const destinationURL = !!destination
? Buffer.from(destination, "base64").toString()
: undefined;
if (!!assignmentID) { if (!!assignmentID) {
const assignment = await getAssignment(assignmentID) const assignment = await getAssignment(assignmentID);
if (!assignment) return redirect(destinationURL || "/exam") if (!assignment) return redirect(destinationURL || "/exam");
if (!assignment.assignees.includes(user.id) && !["admin", "developer"].includes(user.type)) if (
return redirect(destinationURL || "/exam") !assignment.assignees.includes(user.id) &&
!["admin", "developer"].includes(user.type)
)
return redirect(destinationURL || "/exam");
if (filterBy(assignment.results, 'user', user.id).length > 0) if (filterBy(assignment.results, "user", user.id).length > 0)
return redirect(destinationURL || "/exam") return redirect(destinationURL || "/exam");
const exams = await getExamsByIds(uniqBy(assignment.exams, "id")) const [exams, session] = await Promise.all([
const session = await getSessionByAssignment(assignmentID) getExamsByIds(uniqBy(assignment.exams, "id")),
getSessionByAssignment(assignmentID),
]);
return { return {
props: serialize({ user, assignment, exams, destinationURL, session: session ?? undefined }) props: serialize({
} user,
assignment,
exams,
destinationURL,
session: session ?? undefined,
}),
};
} }
return { return {
props: serialize({ user, destinationURL }), props: serialize({ user, destinationURL }),
}; };
}, sessionOptions); },
sessionOptions
);
interface Props { interface Props {
user: User; user: User;
assignment?: Assignment assignment?: Assignment;
exams?: Exam[] exams?: Exam[];
session?: Session session?: Session;
destinationURL?: string destinationURL?: string;
} }
const Page: React.FC<Props> = ({ user, assignment, exams = [], destinationURL = "/exam", session }) => { const Page: React.FC<Props> = ({
const router = useRouter() user,
assignment,
exams = [],
destinationURL = "/exam",
session,
}) => {
const router = useRouter();
const { assignment: storeAssignment, dispatch } = useExamStore(); const { assignment: storeAssignment, dispatch } = useExamStore();
useEffect(() => { useEffect(() => {
if (assignment && exams.length > 0 && !storeAssignment && !session) { if (assignment && exams.length > 0 && !storeAssignment && !session) {
if (!activeAssignmentFilter(assignment)) return if (!activeAssignmentFilter(assignment)) return;
dispatch({ dispatch({
type: "INIT_EXAM", payload: { type: "INIT_EXAM",
payload: {
exams: exams.sort(sortByModule), exams: exams.sort(sortByModule),
modules: exams modules: exams
.map((x) => x!) .map((x) => x!)
.sort(sortByModule) .sort(sortByModule)
.map((x) => x!.module), .map((x) => x!.module),
assignment assignment,
} },
}); });
router.replace(router.asPath) router.replace(router.asPath);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignment, exams, session]) }, [assignment, exams, session]);
useEffect(() => { useEffect(() => {
if (assignment && exams.length > 0 && !storeAssignment && !!session) { if (assignment && exams.length > 0 && !storeAssignment && !!session) {
dispatch({ type: "SET_SESSION", payload: { session } }) dispatch({ type: "SET_SESSION", payload: { session } });
router.replace(router.asPath) router.replace(router.asPath);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignment, exams, session]) }, [assignment, exams, session]);
return ( return (
<> <>
@@ -104,10 +130,15 @@ const Page: React.FC<Props> = ({ user, assignment, exams = [], destinationURL =
<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>
<ExamPage page="exams" destination={destinationURL} user={user} hideSidebar={!!assignment || !!storeAssignment} /> <ExamPage
page="exams"
destination={destinationURL}
user={user}
hideSidebar={!!assignment || !!storeAssignment}
/>
</> </>
); );
} };
//Page.whyDidYouRender = true; //Page.whyDidYouRender = true;
export default Page; export default Page;

View File

@@ -21,79 +21,87 @@ import { getSessionByAssignment } from "@/utils/sessions.be";
import { Session } from "@/hooks/useSessions"; import { Session } from "@/hooks/useSessions";
import moment from "moment"; import moment from "moment";
export const getServerSideProps = withIronSessionSsr(async ({ req, res, query }) => { export const getServerSideProps = withIronSessionSsr(
const user = await requestUser(req, res) async ({ req, res, query }) => {
const destination = Buffer.from(req.url || "/").toString("base64") const user = await requestUser(req, res);
if (!user) return redirect(`/login?destination=${destination}`) const destination = Buffer.from(req.url || "/").toString("base64");
if (!user) return redirect(`/login?destination=${destination}`);
if (shouldRedirectHome(user)) return redirect("/") if (shouldRedirectHome(user)) return redirect("/");
const { assignment: assignmentID } = query as { assignment?: string } const { assignment: assignmentID } = query as { assignment?: string };
if (assignmentID) { if (assignmentID) {
const assignment = await getAssignment(assignmentID) const assignment = await getAssignment(assignmentID);
if (!assignment) return redirect("/exam")
if (!["admin", "developer"].includes(user.type) && !assignment.assignees.includes(user.id)) return redirect("/exercises")
const exams = await getExamsByIds(uniqBy(assignment.exams, "id"))
const session = await getSessionByAssignment(assignmentID)
if (!assignment) return redirect("/exam");
if ( if (
filterBy(assignment.results, 'user', user.id) || !["admin", "developer"].includes(user.type) &&
!assignment.assignees.includes(user.id)
)
return redirect("/exercises");
if (
filterBy(assignment.results, "user", user.id) ||
moment(assignment.startDate).isBefore(moment()) || moment(assignment.startDate).isBefore(moment()) ||
moment(assignment.endDate).isAfter(moment()) moment(assignment.endDate).isAfter(moment())
) )
return redirect("/exam") return redirect("/exam");
const [exams, session] = await Promise.all([
getExamsByIds(uniqBy(assignment.exams, "id")),
getSessionByAssignment(assignmentID),
]);
return { return {
props: serialize({ user, assignment, exams, session }) props: serialize({ user, assignment, exams, session }),
} };
} }
return { return {
props: serialize({ user }), props: serialize({ user }),
}; };
}, sessionOptions); },
sessionOptions
);
interface Props { interface Props {
user: User; user: User;
assignment?: Assignment assignment?: Assignment;
exams?: Exam[] exams?: Exam[];
session?: Session session?: Session;
} }
export default function Page({ user, assignment, exams = [], session }: Props) { export default function Page({ user, assignment, exams = [], session }: Props) {
const router = useRouter() const router = useRouter();
const { assignment: storeAssignment, dispatch } = useExamStore() const { assignment: storeAssignment, dispatch } = useExamStore();
useEffect(() => { useEffect(() => {
if (assignment && exams.length > 0 && !storeAssignment && !session) { if (assignment && exams.length > 0 && !storeAssignment && !session) {
dispatch({ dispatch({
type: "INIT_EXAM", payload: { type: "INIT_EXAM",
payload: {
exams: exams.sort(sortByModule), exams: exams.sort(sortByModule),
modules: exams modules: exams
.map((x) => x!) .map((x) => x!)
.sort(sortByModule) .sort(sortByModule)
.map((x) => x!.module), .map((x) => x!.module),
assignment assignment,
} },
}) });
router.replace(router.asPath) router.replace(router.asPath);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignment, exams, session]) }, [assignment, exams, session]);
useEffect(() => { useEffect(() => {
if (assignment && exams.length > 0 && !storeAssignment && !!session) { if (assignment && exams.length > 0 && !storeAssignment && !!session) {
dispatch({ type: "SET_SESSION", payload: { session } }); dispatch({ type: "SET_SESSION", payload: { session } });
router.replace(router.asPath) router.replace(router.asPath);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignment, exams, session]) }, [assignment, exams, session]);
return ( return (
<> <>

View File

@@ -4,7 +4,7 @@ import Button from "@/components/Low/Button";
import Separator from "@/components/Low/Separator"; import Separator from "@/components/Low/Separator";
import ProfileSummary from "@/components/ProfileSummary"; import ProfileSummary from "@/components/ProfileSummary";
import { Session } from "@/hooks/useSessions"; import { Session } from "@/hooks/useSessions";
import { Grading } from "@/interfaces"; import { Grading, Module } from "@/interfaces";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { Exam } from "@/interfaces/exam"; import { Exam } from "@/interfaces/exam";
import { InviteWithEntity } from "@/interfaces/invite"; import { InviteWithEntity } from "@/interfaces/invite";
@@ -12,14 +12,13 @@ import { Assignment } from "@/interfaces/results";
import { Stat, User } from "@/interfaces/user"; import { Stat, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import useExamStore from "@/stores/exam"; import useExamStore from "@/stores/exam";
import { filterBy, findBy, mapBy, redirect, serialize } from "@/utils"; import { findBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { import {
activeAssignmentFilter, activeAssignmentFilter,
futureAssignmentFilter, futureAssignmentFilter,
} from "@/utils/assignments"; } from "@/utils/assignments";
import { getAssignmentsByAssignee } from "@/utils/assignments.be"; import { getAssignmentsByAssignee } from "@/utils/assignments.be";
import { getEntitiesWithRoles } from "@/utils/entities.be";
import { getExamsByIds } from "@/utils/exams.be"; import { getExamsByIds } from "@/utils/exams.be";
import { sortByModule } from "@/utils/moduleUtils"; import { sortByModule } from "@/utils/moduleUtils";
import { checkAccess } from "@/utils/permissions"; import { checkAccess } from "@/utils/permissions";
@@ -53,32 +52,59 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
if (!checkAccess(user, ["admin", "developer", "student"])) if (!checkAccess(user, ["admin", "developer", "student"]))
return redirect("/"); return redirect("/");
const assignments = (await getAssignmentsByAssignee(
const entityIDS = mapBy(user.entities, "id") || []; user.id,
{
const entities = await getEntitiesWithRoles(entityIDS);
const assignments = await getAssignmentsByAssignee(user.id, {
archived: { $ne: true }, archived: { $ne: true },
}); },
const sessions = await getSessionsByUser(user.id, 0, { {
_id: 0,
id: 1,
name: 1,
startDate: 1,
endDate: 1,
exams: 1,
results: 1,
},
{
sort: { startDate: 1 },
}
)) as Assignment[];
const sessions = await getSessionsByUser(
user.id,
0,
{
"assignment.id": { $in: mapBy(assignments, "id") }, "assignment.id": { $in: mapBy(assignments, "id") },
}); },
{
_id: 0,
id: 1,
assignment: 1,
}
);
const examIDs = uniqBy( const examIDs = uniqBy(
assignments.flatMap((a) => assignments.reduce<{ module: Module; id: string; key: string }[]>(
filterBy(a.exams, "assignee", user.id).map( (acc, a) => {
(e: any) => ({ a.exams.forEach((e) => {
if (e.assignee === user.id)
acc.push({
module: e.module, module: e.module,
id: e.id, id: e.id,
key: `${e.module}_${e.id}`, key: `${e.module}_${e.id}`,
}) });
) });
return acc;
},
[]
), ),
"key" "key"
); );
const exams = await getExamsByIds(examIDs); const exams = await getExamsByIds(examIDs);
return { props: serialize({ user, entities, assignments, exams, sessions }) }; return { props: serialize({ user, assignments, exams, sessions }) };
}, sessionOptions); }, sessionOptions);
const destination = Buffer.from("/official-exam").toString("base64"); const destination = Buffer.from("/official-exam").toString("base64");
@@ -109,11 +135,12 @@ export default function OfficialExam({
}); });
if (assignmentExams.every((x) => !!x)) { if (assignmentExams.every((x) => !!x)) {
const sortedAssignmentExams = assignmentExams.sort(sortByModule);
dispatch({ dispatch({
type: "INIT_EXAM", type: "INIT_EXAM",
payload: { payload: {
exams: assignmentExams.sort(sortByModule), exams: sortedAssignmentExams,
modules: mapBy(assignmentExams.sort(sortByModule), "module"), modules: mapBy(sortedAssignmentExams, "module"),
assignment, assignment,
}, },
}); });
@@ -144,12 +171,16 @@ export default function OfficialExam({
[assignments] [assignments]
); );
const assignmentSessions = useMemo( const assignmentSessions = useMemo(() => {
() => const studentAssignmentsIDs = mapBy(studentAssignments, "id");
sessions.filter((s) => return sessions.filter((s) =>
mapBy(studentAssignments, "id").includes(s.assignment?.id || "") studentAssignmentsIDs.includes(s.assignment?.id || "")
), );
[sessions, studentAssignments] }, [sessions, studentAssignments]);
const entityLabels = useMemo(
() => mapBy(entities, "label")?.join(","),
[entities]
); );
return ( return (
@@ -167,7 +198,7 @@ export default function OfficialExam({
<> <>
{entities.length > 0 && ( {entities.length > 0 && (
<div className="absolute right-4 top-4 rounded-lg bg-neutral-200 px-2 py-1"> <div className="absolute right-4 top-4 rounded-lg bg-neutral-200 px-2 py-1">
<b>{mapBy(entities, "label")?.join(", ")}</b> <b>{entityLabels}</b>
</div> </div>
)} )}
@@ -191,9 +222,7 @@ export default function OfficialExam({
<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">
{studentAssignments.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."}
{studentAssignments {studentAssignments.map((a) => (
.sort((a, b) => moment(a.startDate).diff(b.startDate))
.map((a) => (
<AssignmentCard <AssignmentCard
key={a.id} key={a.id}
assignment={a} assignment={a}

View File

@@ -31,7 +31,7 @@ import { useListSearch } from "@/hooks/useListSearch";
import { checkAccess, findAllowedEntities, getTypesOfUser } from "@/utils/permissions"; import { checkAccess, findAllowedEntities, getTypesOfUser } from "@/utils/permissions";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { mapBy, redirect, serialize } from "@/utils"; import { mapBy, redirect, serialize } from "@/utils";
import { getEntities, getEntitiesWithRoles } from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
import { isAdmin } from "@/utils/users"; import { isAdmin } from "@/utils/users";
import { Entity, EntityWithRoles } from "@/interfaces/entity"; import { Entity, EntityWithRoles } from "@/interfaces/entity";

View File

@@ -21,11 +21,12 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
if (!user) return redirect("/login") if (!user) return redirect("/login")
const entityIDs = mapBy(user.entities, 'id') const entityIDs = mapBy(user.entities, 'id')
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDs)
const domain = user.email.split("@").pop() const domain = user.email.split("@").pop()
const discounts = await db.collection<Discount>("discounts").find({ domain }).toArray() const [entities, discounts, packages] = await Promise.all([
const packages = await db.collection<Package>("packages").find().toArray() getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDs),
db.collection<Discount>("discounts").find({ domain }).toArray(),
db.collection<Package>("packages").find().toArray(),
])
return { return {
props: serialize({ user, entities, discounts, packages }), props: serialize({ user, entities, discounts, packages }),

View File

@@ -18,6 +18,7 @@ import { Type as UserType } from "@/interfaces/user";
import { getGroups } from "@/utils/groups.be"; import { getGroups } from "@/utils/groups.be";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { redirect } from "@/utils"; import { redirect } from "@/utils";
import { G } from "@react-pdf/renderer";
interface BasicUser { interface BasicUser {
id: string; id: string;
name: string; name: string;
@@ -40,31 +41,25 @@ export const getServerSideProps = withIronSessionSsr(
if (!params?.id) return redirect("/permissions"); if (!params?.id) return redirect("/permissions");
// Fetch data from external API // Fetch data from external API
const permission: Permission = await getPermissionDoc(params.id as string); const [permission, users, groups] = await Promise.all([
getPermissionDoc(params.id as string),
const allUserData: User[] = await getUsers(); getUsers({}, 0, {}, { _id: 0, id: 1, name: 1, type: 1 }),
const groups = await getGroups(); getGroups(),
]);
const userGroups = groups.filter((x) => x.admin === user.id); const userGroups = groups.filter((x) => x.admin === user.id);
const userGroupsParticipants = userGroups.flatMap((x) => x.participants);
const filteredGroups = const filteredGroups =
user.type === "corporate" user.type === "corporate"
? userGroups ? userGroups
: user.type === "mastercorporate" : user.type === "mastercorporate"
? groups.filter((x) => ? groups.filter((x) => userGroupsParticipants.includes(x.admin))
userGroups.flatMap((y) => y.participants).includes(x.admin)
)
: groups; : groups;
const filteredGroupsParticipants = filteredGroups.flatMap(
const users = allUserData.map((u) => ({ (g) => g.participants
id: u.id, );
name: u.name,
type: u.type,
})) as BasicUser[];
const filteredUsers = ["mastercorporate", "corporate"].includes(user.type) const filteredUsers = ["mastercorporate", "corporate"].includes(user.type)
? users.filter((u) => ? users.filter((u) => filteredGroupsParticipants.includes(u.id))
filteredGroups.flatMap((g) => g.participants).includes(u.id)
)
: users; : users;
// const res = await fetch("api/permissions"); // const res = await fetch("api/permissions");
@@ -158,12 +153,14 @@ export default function Page(props: Props) {
<div className="flex gap-3"> <div className="flex gap-3">
<Select <Select
value={null} value={null}
options={users options={users.reduce<{ label: string; value: string }[]>(
.filter((u) => !selectedUsers.includes(u.id)) (acc, u) => {
.map((u) => ({ if (!selectedUsers.includes(u.id))
label: `${u?.type}-${u?.name}`, acc.push({ label: `${u?.type}-${u?.name}`, value: u.id });
value: u.id, return acc;
}))} },
[]
)}
onChange={onChange} onChange={onChange}
/> />
<Button onClick={update}>Update</Button> <Button onClick={update}>Update</Button>
@@ -195,9 +192,8 @@ export default function Page(props: Props) {
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<h2>Whitelisted Users</h2> <h2>Whitelisted Users</h2>
<div className="flex flex-col gap-3 flex-wrap"> <div className="flex flex-col gap-3 flex-wrap">
{users {users.map((user) => {
.filter((user) => !selectedUsers.includes(user.id)) if (!selectedUsers.includes(user.id))
.map((user) => {
return ( return (
<div <div
className="flex p-4 rounded-xl w-auto bg-mti-purple-light text-white gap-4" className="flex p-4 rounded-xl w-auto bg-mti-purple-light text-white gap-4"
@@ -208,6 +204,7 @@ export default function Page(props: Props) {
</span> </span>
</div> </div>
); );
return null;
})} })}
</div> </div>
</div> </div>

View File

@@ -53,23 +53,23 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
if (!user) return redirect("/login"); if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/"); if (shouldRedirectHome(user)) return redirect("/");
const linkedCorporate = (await getUserCorporate(user.id)) || null; const [linkedCorporate, groups, referralAgent] = await Promise.all([
const groups = ( getUserCorporate(user.id) || null,
await getParticipantGroups(user.id, { _id: 0, admin: 1 }) getParticipantGroups(user.id, { _id: 0, group: 1 }),
).map((group) => group.admin);
const referralAgent =
user.type === "corporate" && user.corporateInformation.referralAgent user.type === "corporate" && user.corporateInformation.referralAgent
? await getUser(user.corporateInformation.referralAgent, { ? getUser(user.corporateInformation.referralAgent, {
_id: 0, _id: 0,
name: 1, name: 1,
email: 1, email: 1,
demographicInformation: 1, demographicInformation: 1,
}) })
: null; : null,
]);
const groupsAdmin = groups.map((group) => group.admin);
const hasBenefitsFromUniversity = const hasBenefitsFromUniversity =
(await countUsers({ (await countUsers({
id: { $in: groups }, id: { $in: groupsAdmin },
type: "corporate", type: "corporate",
})) > 0; })) > 0;

View File

@@ -23,7 +23,9 @@ import { getAssignments, getEntitiesAssignments } from "@/utils/assignments.be";
import { findBy, mapBy, redirect, serialize } from "@/utils"; import { findBy, mapBy, redirect, serialize } from "@/utils";
import { getEntitiesWithRoles } from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
import { checkAccess } from "@/utils/permissions"; import { checkAccess } from "@/utils/permissions";
import { getGradingSystemByEntities, getGradingSystemByEntity } from "@/utils/grading.be"; import {
getGradingSystemByEntities,
} from "@/utils/grading.be";
import { Grading } from "@/interfaces"; import { Grading } from "@/interfaces";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import CardList from "@/components/High/CardList"; import CardList from "@/components/High/CardList";
@@ -33,23 +35,34 @@ import getPendingEvals from "@/utils/disabled.be";
import useEvaluationPolling from "@/hooks/useEvaluationPolling"; import useEvaluationPolling from "@/hooks/useEvaluationPolling";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res);
if (!user) return redirect("/login") if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/") if (shouldRedirectHome(user)) return redirect("/");
const entityIDs = mapBy(user.entities, 'id') const entityIDs = mapBy(user.entities, "id");
const isAdmin = checkAccess(user, ["admin", "developer"]) const isAdmin = checkAccess(user, ["admin", "developer"]);
const entities = await getEntitiesWithRoles(checkAccess(user, ["admin", "developer"]) ? undefined : entityIDs) const entities = await getEntitiesWithRoles(isAdmin ? undefined : entityIDs);
const entitiesIds = mapBy(entities, 'id') const entitiesIds = mapBy(entities, "id");
const users = await (isAdmin ? getUsers() : getEntitiesUsers(entitiesIds)) const [users, assignments, gradingSystems, pendingSessionIds] =
const assignments = await (isAdmin ? getAssignments() : getEntitiesAssignments(entitiesIds)) await Promise.all([
const gradingSystems = await getGradingSystemByEntities(entitiesIds) isAdmin ? getUsers() : getEntitiesUsers(entitiesIds),
const pendingSessionIds = await getPendingEvals(user.id); isAdmin ? getAssignments() : getEntitiesAssignments(entitiesIds),
getGradingSystemByEntities(entitiesIds),
getPendingEvals(user.id),
]);
return { return {
props: serialize({ user, users, assignments, entities, gradingSystems, isAdmin, pendingSessionIds }), props: serialize({
user,
users,
assignments,
entities,
gradingSystems,
isAdmin,
pendingSessionIds,
}),
}; };
}, sessionOptions); }, sessionOptions);
@@ -59,46 +72,67 @@ interface Props {
user: User; user: User;
users: User[]; users: User[];
assignments: Assignment[]; assignments: Assignment[];
entities: EntityWithRoles[] entities: EntityWithRoles[];
gradingSystems: Grading[] gradingSystems: Grading[];
pendingSessionIds: string[]; pendingSessionIds: string[];
isAdmin:boolean isAdmin: boolean;
} }
const MAX_TRAINING_EXAMS = 10; const MAX_TRAINING_EXAMS = 10;
export default function History({ user, users, assignments, entities, gradingSystems, isAdmin, pendingSessionIds }: Props) { export default function History({
user,
users,
assignments,
entities,
gradingSystems,
isAdmin,
pendingSessionIds,
}: Props) {
const router = useRouter(); const router = useRouter();
const [statsUserId, setStatsUserId, training, setTraining] = useRecordStore((state) => [ const [statsUserId, setStatsUserId, training, setTraining] = useRecordStore(
(state) => [
state.selectedUser, state.selectedUser,
state.setSelectedUser, state.setSelectedUser,
state.training, state.training,
state.setTraining, state.setTraining,
]); ]
);
const [filter, setFilter] = useState<Filter>(); const [filter, setFilter] = useState<Filter>();
const { data: stats, isLoading: isStatsLoading } = useFilterRecordsByUser<Stat[]>(statsUserId || user?.id); const { data: stats, isLoading: isStatsLoading } = useFilterRecordsByUser<
const allowedDownloadEntities = useAllowedEntities(user, entities, 'download_student_record') Stat[]
>(statsUserId || user?.id);
const allowedDownloadEntities = useAllowedEntities(
user,
entities,
"download_student_record"
);
const renderPdfIcon = usePDFDownload("stats"); const renderPdfIcon = usePDFDownload("stats");
const [selectedTrainingExams, setSelectedTrainingExams] = useState<string[]>([]); const [selectedTrainingExams, setSelectedTrainingExams] = useState<string[]>(
[]
);
const setTrainingStats = useTrainingContentStore((state) => state.setStats); const setTrainingStats = useTrainingContentStore((state) => state.setStats);
const groupedStats = useMemo(() => groupByDate( const groupedStats = useMemo(
() =>
groupByDate(
stats.filter((x) => { stats.filter((x) => {
if ( if (
( (x.module === "writing" || x.module === "speaking") &&
x.module === "writing" || x.module === "speaking") && !x.isDisabled &&
!x.isDisabled && Array.isArray(x.solutions) && Array.isArray(x.solutions) &&
!x.solutions.every((y) => Object.keys(y).includes("evaluation") !x.solutions.every((y) => Object.keys(y).includes("evaluation"))
)
) )
return false; return false;
return true; return true;
}), })
), [stats]) ),
[stats]
);
useEffect(() => setStatsUserId(user.id), [setStatsUserId, user]); useEffect(() => setStatsUserId(user.id), [setStatsUserId, user]);
@@ -120,7 +154,8 @@ export default function History({ user, users, assignments, entities, gradingSys
const filteredStats: { [key: string]: Stat[] } = {}; const filteredStats: { [key: string]: Stat[] } = {};
Object.keys(stats).forEach((timestamp) => { Object.keys(stats).forEach((timestamp) => {
if (timestamp >= filterDate) filteredStats[timestamp] = stats[timestamp]; if (timestamp >= filterDate)
filteredStats[timestamp] = stats[timestamp];
}); });
return filteredStats; return filteredStats;
} }
@@ -129,8 +164,14 @@ export default function History({ user, users, assignments, entities, gradingSys
const filteredStats: { [key: string]: Stat[] } = {}; const filteredStats: { [key: string]: Stat[] } = {};
Object.keys(stats).forEach((timestamp) => { Object.keys(stats).forEach((timestamp) => {
if (stats[timestamp].map((s) => s.assignment === undefined).includes(false)) if (
filteredStats[timestamp] = [...stats[timestamp].filter((s) => !!s.assignment)]; stats[timestamp]
.map((s) => s.assignment === undefined)
.includes(false)
)
filteredStats[timestamp] = [
...stats[timestamp].filter((s) => !!s.assignment),
];
}); });
return filteredStats; return filteredStats;
@@ -143,9 +184,14 @@ export default function History({ user, users, assignments, entities, gradingSys
if (groupedStats) { if (groupedStats) {
const groupedStatsByDate = filterStatsByDate(groupedStats); const groupedStatsByDate = filterStatsByDate(groupedStats);
const allStats = Object.keys(groupedStatsByDate); const allStats = Object.keys(groupedStatsByDate);
const selectedStats = selectedTrainingExams.reduce<Record<string, Stat[]>>((accumulator, moduleAndTimestamp) => { const selectedStats = selectedTrainingExams.reduce<
Record<string, Stat[]>
>((accumulator, moduleAndTimestamp) => {
const timestamp = moduleAndTimestamp.split("-")[1]; const timestamp = moduleAndTimestamp.split("-")[1];
if (allStats.includes(timestamp) && !accumulator.hasOwnProperty(timestamp)) { if (
allStats.includes(timestamp) &&
!accumulator.hasOwnProperty(timestamp)
) {
accumulator[timestamp] = groupedStatsByDate[timestamp]; accumulator[timestamp] = groupedStatsByDate[timestamp];
} }
return accumulator; return accumulator;
@@ -155,17 +201,22 @@ export default function History({ user, users, assignments, entities, gradingSys
} }
}; };
const filteredStats = useMemo(() => const filteredStats = useMemo(
Object.keys(filterStatsByDate(groupedStats)) () =>
.sort((a, b) => parseInt(b) - parseInt(a)), Object.keys(filterStatsByDate(groupedStats)).sort(
(a, b) => parseInt(b) - parseInt(a)
),
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[groupedStats, filter]) [groupedStats, filter]
);
const customContent = (timestamp: string) => { const customContent = (timestamp: string) => {
const dateStats = groupedStats[timestamp]; const dateStats = groupedStats[timestamp];
const statUser = findBy(users, 'id', dateStats[0]?.user) const statUser = findBy(users, "id", dateStats[0]?.user);
const canDownload = mapBy(statUser?.entities, 'id').some(e => mapBy(allowedDownloadEntities, 'id').includes(e)) const canDownload = mapBy(statUser?.entities, "id").some((e) =>
mapBy(allowedDownloadEntities, "id").includes(e)
);
return ( return (
<StatsGridItem <StatsGridItem
@@ -185,7 +236,11 @@ export default function History({ user, users, assignments, entities, gradingSys
); );
}; };
useEvaluationPolling(pendingSessionIds ? pendingSessionIds : [], "records", user.id); useEvaluationPolling(
pendingSessionIds ? pendingSessionIds : [],
"records",
user.id
);
return ( return (
<> <>
@@ -201,7 +256,12 @@ export default function History({ user, users, assignments, entities, gradingSys
<ToastContainer /> <ToastContainer />
{user && ( {user && (
<> <>
<RecordFilter user={user} isAdmin={isAdmin} entities={entities} filterState={{ filter: filter, setFilter: setFilter }}> <RecordFilter
user={user}
isAdmin={isAdmin}
entities={entities}
filterState={{ filter: filter, setFilter: setFilter }}
>
{training && ( {training && (
<div className="flex flex-row"> <div className="flex flex-row">
<div className="font-semibold text-2xl mr-4"> <div className="font-semibold text-2xl mr-4">
@@ -211,19 +271,25 @@ export default function History({ user, users, assignments, entities, gradingSys
<button <button
className={clsx( className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light ml-4 disabled:cursor-not-allowed", "bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light ml-4 disabled:cursor-not-allowed",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out"
)} )}
disabled={selectedTrainingExams.length == 0} disabled={selectedTrainingExams.length == 0}
onClick={handleTrainingContentSubmission}> onClick={handleTrainingContentSubmission}
>
Submit Submit
</button> </button>
</div> </div>
)} )}
</RecordFilter> </RecordFilter>
{filteredStats.length > 0 && !isStatsLoading && ( {filteredStats.length > 0 && !isStatsLoading && (
<CardList list={filteredStats} renderCard={customContent} searchFields={[]} pageSize={30} className="lg:!grid-cols-3" /> <CardList
list={filteredStats}
renderCard={customContent}
searchFields={[]}
pageSize={30}
className="lg:!grid-cols-3"
/>
)} )}
{filteredStats.length === 0 && !isStatsLoading && ( {filteredStats.length === 0 && !isStatsLoading && (
<span className="font-semibold ml-1">No record to display...</span> <span className="font-semibold ml-1">No record to display...</span>

View File

@@ -13,7 +13,13 @@ import { checkAccess, getTypesOfUser } from "@/utils/permissions";
import { useState } from "react"; import { useState } from "react";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import IconCard from "@/components/IconCard"; import IconCard from "@/components/IconCard";
import { BsCode, BsCodeSquare, BsGearFill, BsPeopleFill, BsPersonFill } from "react-icons/bs"; import {
BsCode,
BsCodeSquare,
BsGearFill,
BsPeopleFill,
BsPersonFill,
} from "react-icons/bs";
import UserCreator from "./(admin)/UserCreator"; import UserCreator from "./(admin)/UserCreator";
import CorporateGradingSystem from "./(admin)/CorporateGradingSystem"; import CorporateGradingSystem from "./(admin)/CorporateGradingSystem";
import { CEFR_STEPS } from "@/resources/grading"; import { CEFR_STEPS } from "@/resources/grading";
@@ -26,26 +32,55 @@ import { mapBy, serialize, redirect } from "@/utils";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { isAdmin } from "@/utils/users"; import { isAdmin } from "@/utils/users";
import { getGradingSystemByEntities, getGradingSystemByEntity } from "@/utils/grading.be"; import {
getGradingSystemByEntities,
getGradingSystemByEntity,
} from "@/utils/grading.be";
import { Grading } from "@/interfaces"; import { Grading } from "@/interfaces";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useAllowedEntities } from "@/hooks/useEntityPermissions"; import { useAllowedEntities } from "@/hooks/useEntityPermissions";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res);
if (!user) return redirect("/login") if (!user) return redirect("/login");
if (shouldRedirectHome(user) || !checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"])) if (
return redirect("/") shouldRedirectHome(user) ||
!checkAccess(user, [
const permissions = await getUserPermissions(user.id); "admin",
const entities = isAdmin(user) ? await getEntitiesWithRoles() : await getEntitiesWithRoles(mapBy(user.entities, 'id')) "developer",
const allUsers = await getUsers() "corporate",
const gradingSystems = await getGradingSystemByEntities(mapBy(entities, 'id')) "teacher",
const entitiesGrading = entities.map(e => gradingSystems.find(g => g.entity === e.id) || { entity: e.id, steps: CEFR_STEPS }) "mastercorporate",
])
)
return redirect("/");
const [permissions, entities, allUsers] = await Promise.all([
getUserPermissions(user.id),
isAdmin(user)
? await getEntitiesWithRoles()
: await getEntitiesWithRoles(mapBy(user.entities, "id")),
getUsers(),
]);
const gradingSystems = await getGradingSystemByEntities(
mapBy(entities, "id")
);
const entitiesGrading = entities.map(
(e) =>
gradingSystems.find((g) => g.entity === e.id) || {
entity: e.id,
steps: CEFR_STEPS,
}
);
return { return {
props: serialize({ user, permissions, entities, allUsers, entitiesGrading }), props: serialize({
user,
permissions,
entities,
allUsers,
entitiesGrading,
}),
}; };
}, sessionOptions); }, sessionOptions);
@@ -53,19 +88,45 @@ interface Props {
user: User; user: User;
permissions: PermissionType[]; permissions: PermissionType[];
entities: EntityWithRoles[]; entities: EntityWithRoles[];
allUsers: User[] allUsers: User[];
entitiesGrading: Grading[] entitiesGrading: Grading[];
} }
export default function Admin({ user, entities, permissions, allUsers, entitiesGrading }: Props) { export default function Admin({
user,
entities,
permissions,
allUsers,
entitiesGrading,
}: Props) {
const [modalOpen, setModalOpen] = useState<string>(); const [modalOpen, setModalOpen] = useState<string>();
const router = useRouter() const router = useRouter();
const entitiesAllowCreateUser = useAllowedEntities(user, entities, 'create_user') const entitiesAllowCreateUser = useAllowedEntities(
const entitiesAllowCreateUsers = useAllowedEntities(user, entities, 'create_user_batch') user,
const entitiesAllowCreateCode = useAllowedEntities(user, entities, 'create_code') entities,
const entitiesAllowCreateCodes = useAllowedEntities(user, entities, 'create_code_batch') "create_user"
const entitiesAllowEditGrading = useAllowedEntities(user, entities, 'edit_grading_system') );
const entitiesAllowCreateUsers = useAllowedEntities(
user,
entities,
"create_user_batch"
);
const entitiesAllowCreateCode = useAllowedEntities(
user,
entities,
"create_code"
);
const entitiesAllowCreateCodes = useAllowedEntities(
user,
entities,
"create_code_batch"
);
const entitiesAllowEditGrading = useAllowedEntities(
user,
entities,
"edit_grading_system"
);
return ( return (
<> <>
@@ -80,7 +141,11 @@ export default function Admin({ user, entities, permissions, allUsers, entitiesG
</Head> </Head>
<ToastContainer /> <ToastContainer />
<> <>
<Modal isOpen={modalOpen === "batchCreateUser"} onClose={() => setModalOpen(undefined)} maxWidth="max-w-[85%]"> <Modal
isOpen={modalOpen === "batchCreateUser"}
onClose={() => setModalOpen(undefined)}
maxWidth="max-w-[85%]"
>
<BatchCreateUser <BatchCreateUser
user={user} user={user}
entities={entitiesAllowCreateUser} entities={entitiesAllowCreateUser}
@@ -88,7 +153,10 @@ export default function Admin({ user, entities, permissions, allUsers, entitiesG
onFinish={() => setModalOpen(undefined)} onFinish={() => setModalOpen(undefined)}
/> />
</Modal> </Modal>
<Modal isOpen={modalOpen === "batchCreateCode"} onClose={() => setModalOpen(undefined)}> <Modal
isOpen={modalOpen === "batchCreateCode"}
onClose={() => setModalOpen(undefined)}
>
<BatchCodeGenerator <BatchCodeGenerator
entities={entitiesAllowCreateCodes} entities={entitiesAllowCreateCodes}
user={user} user={user}
@@ -97,7 +165,10 @@ export default function Admin({ user, entities, permissions, allUsers, entitiesG
onFinish={() => setModalOpen(undefined)} onFinish={() => setModalOpen(undefined)}
/> />
</Modal> </Modal>
<Modal isOpen={modalOpen === "createCode"} onClose={() => setModalOpen(undefined)}> <Modal
isOpen={modalOpen === "createCode"}
onClose={() => setModalOpen(undefined)}
>
<CodeGenerator <CodeGenerator
entities={entitiesAllowCreateCode} entities={entitiesAllowCreateCode}
user={user} user={user}
@@ -105,7 +176,10 @@ export default function Admin({ user, entities, permissions, allUsers, entitiesG
onFinish={() => setModalOpen(undefined)} onFinish={() => setModalOpen(undefined)}
/> />
</Modal> </Modal>
<Modal isOpen={modalOpen === "createUser"} onClose={() => setModalOpen(undefined)}> <Modal
isOpen={modalOpen === "createUser"}
onClose={() => setModalOpen(undefined)}
>
<UserCreator <UserCreator
user={user} user={user}
entities={entitiesAllowCreateUsers} entities={entitiesAllowCreateUsers}
@@ -114,7 +188,10 @@ export default function Admin({ user, entities, permissions, allUsers, entitiesG
onFinish={() => setModalOpen(undefined)} onFinish={() => setModalOpen(undefined)}
/> />
</Modal> </Modal>
<Modal isOpen={modalOpen === "gradingSystem"} onClose={() => setModalOpen(undefined)}> <Modal
isOpen={modalOpen === "gradingSystem"}
onClose={() => setModalOpen(undefined)}
>
<CorporateGradingSystem <CorporateGradingSystem
user={user} user={user}
entitiesGrading={entitiesGrading} entitiesGrading={entitiesGrading}
@@ -125,7 +202,12 @@ export default function Admin({ user, entities, permissions, allUsers, entitiesG
<section className="w-full grid grid-cols-2 -md:grid-cols-1 gap-8"> <section className="w-full grid grid-cols-2 -md:grid-cols-1 gap-8">
<ExamLoader /> <ExamLoader />
{checkAccess(user, getTypesOfUser(["teacher"]), permissions, "viewCodes") && ( {checkAccess(
user,
getTypesOfUser(["teacher"]),
permissions,
"viewCodes"
) && (
<div className="w-full grid grid-cols-2 gap-4"> <div className="w-full grid grid-cols-2 gap-4">
<IconCard <IconCard
Icon={BsCode} Icon={BsCode}
@@ -159,7 +241,12 @@ export default function Admin({ user, entities, permissions, allUsers, entitiesG
onClick={() => setModalOpen("batchCreateUser")} onClick={() => setModalOpen("batchCreateUser")}
disabled={entitiesAllowCreateUsers.length === 0} disabled={entitiesAllowCreateUsers.length === 0}
/> />
{checkAccess(user, ["admin", "corporate", "developer", "mastercorporate"]) && ( {checkAccess(user, [
"admin",
"corporate",
"developer",
"mastercorporate",
]) && (
<IconCard <IconCard
Icon={BsGearFill} Icon={BsGearFill}
label="Grading System" label="Grading System"

View File

@@ -28,67 +28,104 @@ import Head from "next/head";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import ReactDatePicker from "react-datepicker"; import ReactDatePicker from "react-datepicker";
import { import { BsBank, BsChevronLeft, BsX } from "react-icons/bs";
BsBank,
BsChevronLeft,
BsX,
} from "react-icons/bs";
interface Props { interface Props {
user: User; user: User;
students: StudentUser[]; students: StudentUser[];
entities: EntityWithRoles[]; entities: EntityWithRoles[];
assignments: Assignment[]; assignments: Assignment[];
sessions: Session[] sessions: Session[];
exams: Exam[] exams: Exam[];
} }
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res);
if (!user) return redirect("/login") if (!user) return redirect("/login");
const entityIDS = mapBy(user.entities, "id") || []; const entityIDS = mapBy(user.entities, "id") || [];
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS); const entities = await getEntitiesWithRoles(
const allowedEntities = findAllowedEntities(user, entities, 'view_entity_statistics') isAdmin(user) ? undefined : entityIDS
);
const allowedEntities = findAllowedEntities(
user,
entities,
"view_entity_statistics"
);
if (allowedEntities.length === 0) return redirect("/") if (allowedEntities.length === 0) return redirect("/");
const studentsAllowedEntities = findAllowedEntities(user, entities, 'view_students') const studentsAllowedEntities = findAllowedEntities(
const students = await getEntitiesUsers(mapBy(studentsAllowedEntities, 'id'), { type: "student" }) user,
entities,
"view_students"
);
const assignments = await getEntitiesAssignments(mapBy(entities, "id")); const [students, assignments] = await Promise.all([
const sessions = await getSessionsByAssignments(mapBy(assignments, 'id')) getEntitiesUsers(mapBy(studentsAllowedEntities, "id"), { type: "student" }),
const exams = await getExamsByIds(assignments.flatMap(a => a.exams)) getEntitiesAssignments(mapBy(entities, "id")),
]);
return { props: serialize({ user, students, entities: allowedEntities, assignments, sessions, exams }) }; const [sessions, exams] = await Promise.all([
getSessionsByAssignments(mapBy(assignments, "id")),
getExamsByIds(assignments.flatMap((a) => a.exams)),
]);
return {
props: serialize({
user,
students,
entities: allowedEntities,
assignments,
sessions,
exams,
}),
};
}, sessionOptions); }, sessionOptions);
interface Item { interface Item {
student: StudentUser student: StudentUser;
result?: AssignmentResult result?: AssignmentResult;
assignment: Assignment assignment: Assignment;
exams: Exam[] exams: Exam[];
entity: Entity entity: Entity;
session?: Session session?: Session;
} }
const columnHelper = createColumnHelper<Item>(); const columnHelper = createColumnHelper<Item>();
export default function Statistical({ user, students, entities, assignments, sessions, exams }: Props) { export default function Statistical({
user,
students,
entities,
assignments,
sessions,
exams,
}: Props) {
const [startDate, setStartDate] = useState<Date>(new Date()); const [startDate, setStartDate] = useState<Date>(new Date());
const [endDate, setEndDate] = useState<Date | null>(moment().add(1, 'month').toDate()); const [endDate, setEndDate] = useState<Date | null>(
const [selectedEntities, setSelectedEntities] = useState<string[]>([]) moment().add(1, "month").toDate()
const [isDownloading, setIsDownloading] = useState(false) );
const [selectedEntities, setSelectedEntities] = useState<string[]>([]);
const [isDownloading, setIsDownloading] = useState(false);
const entitiesAllowDownload = useAllowedEntities(user, entities, 'download_statistics_report') const entitiesAllowDownload = useAllowedEntities(
user,
entities,
"download_statistics_report"
);
const resetDateRange = () => { const resetDateRange = () => {
const orderedAssignments = orderBy(assignments, ['startDate'], ['asc']) const orderedAssignments = orderBy(assignments, ["startDate"], ["asc"]);
setStartDate(moment(orderedAssignments.shift()?.startDate || "2024-01-01T00:00:01.986Z").toDate()) setStartDate(
setEndDate(moment().add(1, 'month').toDate()) moment(
} orderedAssignments.shift()?.startDate || "2024-01-01T00:00:01.986Z"
).toDate()
);
setEndDate(moment().add(1, "month").toDate());
};
useEffect(resetDateRange, [assignments]) useEffect(resetDateRange, [assignments]);
const updateDateRange = (dates: [Date, Date | null]) => { const updateDateRange = (dates: [Date, Date | null]) => {
const [start, end] = dates; const [start, end] = dates;
@@ -96,75 +133,134 @@ export default function Statistical({ user, students, entities, assignments, ses
setEndDate(end); setEndDate(end);
}; };
const toggleEntity = (id: string) => setSelectedEntities(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]) const toggleEntity = (id: string) =>
setSelectedEntities((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
);
const renderAssignmentResolution = (entityID: string) => { const renderAssignmentResolution = (entityID: string) => {
const entityAssignments = filterBy(assignments, 'entity', entityID) const entityAssignments = filterBy(assignments, "entity", entityID);
const total = entityAssignments.reduce((acc, curr) => acc + curr.assignees.length, 0) const total = entityAssignments.reduce(
const results = entityAssignments.reduce((acc, curr) => acc + curr.results.length, 0) (acc, curr) => acc + curr.assignees.length,
0
);
const results = entityAssignments.reduce(
(acc, curr) => acc + curr.results.length,
0
);
return `${results}/${total}` return `${results}/${total}`;
} };
const totalAssignmentResolution = useMemo(() => { const totalAssignmentResolution = useMemo(() => {
const total = assignments.reduce((acc, curr) => acc + curr.assignees.length, 0) const total = assignments.reduce(
const results = assignments.reduce((acc, curr) => acc + curr.results.length, 0) (acc, curr) => acc + curr.assignees.length,
0
);
const results = assignments.reduce(
(acc, curr) => acc + curr.results.length,
0
);
return { results, total } return { results, total };
}, [assignments]) }, [assignments]);
const filteredAssignments = useMemo(() => { const filteredAssignments = useMemo(() => {
if (!startDate && !endDate) return assignments if (!startDate && !endDate) return assignments;
const startDateFiltered = startDate ? assignments.filter(a => moment(a.startDate).isSameOrAfter(moment(startDate))) : assignments const startDateFiltered = startDate
return endDate ? startDateFiltered.filter(a => moment(a.endDate).isSameOrBefore(moment(endDate))) : startDateFiltered ? assignments.filter((a) =>
}, [startDate, endDate, assignments]) moment(a.startDate).isSameOrAfter(moment(startDate))
const data: Item[] = useMemo(() =>
filteredAssignments.filter(a => selectedEntities.includes(a.entity || "")).flatMap(a => a.assignees.map(x => {
const result = findBy(a.results, 'user', x)
const student = findBy(students, 'id', x)
const entity = findBy(entities, 'id', a.entity)
const assignmentExams = exams.filter(e => a.exams.map(x => `${x.id}_${x.module}`).includes(`${e.id}_${e.module}`))
const session = sessions.find(s => s.assignment?.id === a.id && s.user === x)
if (!student) return undefined
return { student, result, assignment: a, exams: assignmentExams, session, entity }
})).filter(x => !!x) as Item[],
[students, selectedEntities, filteredAssignments, exams, sessions, entities]
) )
: assignments;
return endDate
? startDateFiltered.filter((a) =>
moment(a.endDate).isSameOrBefore(moment(endDate))
)
: startDateFiltered;
}, [startDate, endDate, assignments]);
const sortedData: Item[] = useMemo(() => data.sort((a, b) => { const data: Item[] = useMemo(
const aTotalScore = a.result?.stats.filter(x => !x.isPractice).reduce((acc, curr) => acc + curr.score.correct, 0) || 0 () =>
const bTotalScore = b.result?.stats.filter(x => !x.isPractice).reduce((acc, curr) => acc + curr.score.correct, 0) || 0 filteredAssignments
.filter((a) => selectedEntities.includes(a.entity || ""))
.flatMap((a) =>
a.assignees.map((x) => {
const result = findBy(a.results, "user", x);
const student = findBy(students, "id", x);
const entity = findBy(entities, "id", a.entity);
const assignmentExams = exams.filter((e) =>
a.exams
.map((x) => `${x.id}_${x.module}`)
.includes(`${e.id}_${e.module}`)
);
const session = sessions.find(
(s) => s.assignment?.id === a.id && s.user === x
);
return bTotalScore - aTotalScore if (!student) return undefined;
}), [data]) return {
student,
result,
assignment: a,
exams: assignmentExams,
session,
entity,
};
})
)
.filter((x) => !!x) as Item[],
[students, selectedEntities, filteredAssignments, exams, sessions, entities]
);
const sortedData: Item[] = useMemo(
() =>
data.sort((a, b) => {
const aTotalScore =
a.result?.stats
.filter((x) => !x.isPractice)
.reduce((acc, curr) => acc + curr.score.correct, 0) || 0;
const bTotalScore =
b.result?.stats
.filter((x) => !x.isPractice)
.reduce((acc, curr) => acc + curr.score.correct, 0) || 0;
return bTotalScore - aTotalScore;
}),
[data]
);
const downloadExcel = async () => { const downloadExcel = async () => {
setIsDownloading(true) setIsDownloading(true);
const request = await axios.post("/api/statistical", { const request = await axios.post(
entities: entities.filter(e => selectedEntities.includes(e.id)), "/api/statistical",
{
entities: entities.filter((e) => selectedEntities.includes(e.id)),
items: data, items: data,
assignments: filteredAssignments, assignments: filteredAssignments,
startDate, startDate,
endDate endDate,
}, { },
responseType: 'blob' {
}) responseType: "blob",
}
);
const href = URL.createObjectURL(request.data) const href = URL.createObjectURL(request.data);
const link = document.createElement('a'); const link = document.createElement("a");
link.href = href; link.href = href;
link.setAttribute('download', `statistical_${new Date().toISOString()}.xlsx`); link.setAttribute(
"download",
`statistical_${new Date().toISOString()}.xlsx`
);
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
URL.revokeObjectURL(href); URL.revokeObjectURL(href);
setIsDownloading(false) setIsDownloading(false);
} };
const columns = [ const columns = [
columnHelper.accessor("student.name", { columnHelper.accessor("student.name", {
@@ -194,19 +290,26 @@ export default function Statistical({ user, students, entities, assignments, ses
columnHelper.accessor("result", { columnHelper.accessor("result", {
header: "Progress", header: "Progress",
cell: (info) => { cell: (info) => {
const student = info.row.original.student const student = info.row.original.student;
const session = info.row.original.session const session = info.row.original.session;
if (!student.lastLogin) return <span className="text-mti-red-dark">Never logged in</span> if (!student.lastLogin)
if (info.getValue()) return <span className="text-mti-green font-semibold">Submitted</span> return <span className="text-mti-red-dark">Never logged in</span>;
if (!session) return <span className="text-mti-rose">Not started</span> if (info.getValue())
return (
<span className="text-mti-green font-semibold">Submitted</span>
);
if (!session) return <span className="text-mti-rose">Not started</span>;
return <span className="font-semibold"> return (
{capitalize(session.exam?.module || "")} Module, Part {session.partIndex + 1}, Exercise {session.exerciseIndex + 1} <span className="font-semibold">
{capitalize(session.exam?.module || "")} Module, Part{" "}
{session.partIndex + 1}, Exercise {session.exerciseIndex + 1}
</span> </span>
);
}, },
}) }),
] ];
return ( return (
<> <>
@@ -224,13 +327,18 @@ export default function Statistical({ user, students, entities, assignments, ses
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center justify-between w-full"> <div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link href="/dashboard" className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"> <Link
href="/dashboard"
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
>
<BsChevronLeft /> <BsChevronLeft />
</Link> </Link>
<h2 className="font-bold text-2xl">Statistical</h2> <h2 className="font-bold text-2xl">Statistical</h2>
</div> </div>
<Checkbox <Checkbox
onChange={value => setSelectedEntities(value ? mapBy(entities, 'id') : [])} onChange={(value) =>
setSelectedEntities(value ? mapBy(entities, "id") : [])
}
isChecked={selectedEntities.length === entities.length} isChecked={selectedEntities.length === entities.length}
> >
Select All Select All
@@ -241,13 +349,14 @@ export default function Statistical({ user, students, entities, assignments, ses
<section className="flex flex-col gap-3"> <section className="flex flex-col gap-3">
<div className="w-full flex items-center justify-between gap-4 flex-wrap"> <div className="w-full flex items-center justify-between gap-4 flex-wrap">
{entities.map(entity => ( {entities.map((entity) => (
<button <button
onClick={() => toggleEntity(entity.id)} onClick={() => toggleEntity(entity.id)}
className={clsx( className={clsx(
"flex flex-col items-center justify-between gap-3 border-2 drop-shadow rounded-xl bg-white p-8 px-2 w-48 h-52", "flex flex-col items-center justify-between gap-3 border-2 drop-shadow rounded-xl bg-white p-8 px-2 w-48 h-52",
"transition ease-in-out duration-300 hover:shadow-xl hover:border-mti-purple", "transition ease-in-out duration-300 hover:shadow-xl hover:border-mti-purple",
selectedEntities.includes(entity.id) && "border-mti-purple text-mti-purple" selectedEntities.includes(entity.id) &&
"border-mti-purple text-mti-purple"
)} )}
key={entity.id} key={entity.id}
> >
@@ -268,7 +377,7 @@ export default function Statistical({ user, students, entities, assignments, ses
className={clsx( className={clsx(
"p-6 px-12 w-full flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer", "p-6 px-12 w-full flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip", "hover:border-mti-purple tooltip",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out"
)} )}
dateFormat="dd/MM/yyyy" dateFormat="dd/MM/yyyy"
selectsRange selectsRange
@@ -278,13 +387,17 @@ export default function Statistical({ user, students, entities, assignments, ses
endDate={endDate} endDate={endDate}
/> />
{startDate !== null && endDate !== null && ( {startDate !== null && endDate !== null && (
<button onClick={resetDateRange} className="transition ease-in-out duration-300 rounded-full p-2 hover:bg-mti-gray-cool/10"> <button
onClick={resetDateRange}
className="transition ease-in-out duration-300 rounded-full p-2 hover:bg-mti-gray-cool/10"
>
<BsX size={24} /> <BsX size={24} />
</button> </button>
)} )}
</div> </div>
<span className="font-semibold text-lg pr-1"> <span className="font-semibold text-lg pr-1">
Total: {totalAssignmentResolution.results} / {totalAssignmentResolution.total} Total: {totalAssignmentResolution.results} /{" "}
{totalAssignmentResolution.total}
</span> </span>
</div> </div>
</section> </section>
@@ -293,13 +406,21 @@ export default function Statistical({ user, students, entities, assignments, ses
<Table <Table
columns={columns} columns={columns}
data={sortedData} data={sortedData}
searchFields={[["student", "name"], ["student", "email"], ["student", "studentID"], ["exams", "id"], ["assignment", "name"]]} searchFields={[
["student", "name"],
["student", "email"],
["student", "studentID"],
["exams", "id"],
["assignment", "name"],
]}
searchPlaceholder="Search by student, assignment or exam..." searchPlaceholder="Search by student, assignment or exam..."
onDownload={entitiesAllowDownload.length > 0 ? downloadExcel : undefined} onDownload={
entitiesAllowDownload.length > 0 ? downloadExcel : undefined
}
isDownloadLoading={isDownloading} isDownloadLoading={isDownloading}
/> />
)} )}
</> </>
</> </>
) );
} }

View File

@@ -32,10 +32,7 @@ import { capitalize } from "lodash";
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import ProgressBar from "@/components/Low/ProgressBar"; import ProgressBar from "@/components/Low/ProgressBar";
import { calculateBandScore } from "@/utils/score"; import { calculateBandScore } from "@/utils/score";
import { import { MODULE_ARRAY, sortByModule } from "@/utils/moduleUtils";
MODULE_ARRAY,
sortByModule,
} from "@/utils/moduleUtils";
import { Chart } from "react-chartjs-2"; import { Chart } from "react-chartjs-2";
import DatePicker from "react-datepicker"; import DatePicker from "react-datepicker";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
@@ -45,7 +42,6 @@ import { Stat, User } from "@/interfaces/user";
import { Divider } from "primereact/divider"; import { Divider } from "primereact/divider";
import Badge from "@/components/Low/Badge"; import Badge from "@/components/Low/Badge";
import { mapBy, redirect, serialize } from "@/utils"; import { mapBy, redirect, serialize } from "@/utils";
import { getEntities } from "@/utils/entities.be";
import { checkAccess } from "@/utils/permissions"; import { checkAccess } from "@/utils/permissions";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
@@ -69,19 +65,10 @@ const COLORS = ["#1EB3FF", "#FF790A", "#3D9F11", "#EF5DA8", "#414288"];
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res); const user = await requestUser(req, res);
if (!user) return redirect("/login"); if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/"); if (shouldRedirectHome(user)) return redirect("/");
const entityIDs = mapBy(user.entities, "id");
const isAdmin = checkAccess(user, ["admin", "developer"]); const isAdmin = checkAccess(user, ["admin", "developer"]);
const entities = await getEntities(isAdmin ? undefined : entityIDs, {
id: 1,
label: 1,
});
return { return {
props: serialize({ user, entities, isAdmin }), props: serialize({ user, isAdmin }),
}; };
}, sessionOptions); }, sessionOptions);

View File

@@ -20,38 +20,55 @@ import ModuleBadge from "@/components/ModuleBadge";
import RecordFilter from "@/components/Medium/RecordFilter"; import RecordFilter from "@/components/Medium/RecordFilter";
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser"; import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import { mapBy, redirect, serialize } from "@/utils"; import { mapBy, redirect, serialize } from "@/utils";
import { getEntitiesWithRoles } from "@/utils/entities.be";
import { getEntitiesUsers } from "@/utils/users.be"; import { getEntitiesUsers } from "@/utils/users.be";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { checkAccess } from "../../utils/permissions";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res);
if (!user) return redirect("/login") if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/") if (shouldRedirectHome(user)) return redirect("/");
const isAdmin = checkAccess(user, ["admin", "developer"]);
const entityIDs = mapBy(user.entities, 'id') const entityIDs = mapBy(user.entities, "id");
const entities = await getEntitiesWithRoles(entityIDs) const users = await getEntitiesUsers(entityIDs);
const users = await getEntitiesUsers(entityIDs)
return { return {
props: serialize({user, users, entities}), props: serialize({ user, users, isAdmin }),
}; };
}, sessionOptions); }, sessionOptions);
const Training: React.FC<{user: User, entities: EntityWithRoles[], users: User[] }> = ({user, entities, users}) => { const Training: React.FC<{
const [recordUserId, setRecordTraining] = useRecordStore((state) => [state.selectedUser, state.setTraining]); user: User;
const [filter, setFilter] = useState<"months" | "weeks" | "days" | "assignments">(); entities: EntityWithRoles[];
users: User[];
isAdmin: boolean;
}> = ({ user, entities, isAdmin }) => {
const [recordUserId, setRecordTraining] = useRecordStore((state) => [
state.selectedUser,
state.setTraining,
]);
const [filter, setFilter] = useState<
"months" | "weeks" | "days" | "assignments"
>();
const [stats, setTrainingStats] = useTrainingContentStore((state) => [state.stats, state.setStats]); const [stats, setTrainingStats] = useTrainingContentStore((state) => [
const [isNewContentLoading, setIsNewContentLoading] = useState(stats.length != 0); state.stats,
const [groupedByTrainingContent, setGroupedByTrainingContent] = useState<{[key: string]: ITrainingContent}>(); state.setStats,
]);
const [isNewContentLoading, setIsNewContentLoading] = useState(
stats.length != 0
);
const [groupedByTrainingContent, setGroupedByTrainingContent] = useState<{
[key: string]: ITrainingContent;
}>();
const {data: trainingContent, isLoading: areRecordsLoading} = useFilterRecordsByUser<ITrainingContent[]>( const { data: trainingContent, isLoading: areRecordsLoading } =
useFilterRecordsByUser<ITrainingContent[]>(
recordUserId || user?.id, recordUserId || user?.id,
undefined, undefined,
"training", "training"
); );
useEffect(() => { useEffect(() => {
@@ -68,7 +85,10 @@ const Training: React.FC<{user: User, entities: EntityWithRoles[], users: User[]
useEffect(() => { useEffect(() => {
const postStats = async () => { const postStats = async () => {
try { try {
const response = await axios.post<{id: string}>(`/api/training`, {userID: user.id, stats: stats}); const response = await axios.post<{ id: string }>(`/api/training`, {
userID: user.id,
stats: stats,
});
return response.data.id; return response.data.id;
} catch (error) { } catch (error) {
setIsNewContentLoading(false); setIsNewContentLoading(false);
@@ -91,7 +111,9 @@ const Training: React.FC<{user: User, entities: EntityWithRoles[], users: User[]
router.push("/record"); router.push("/record");
}; };
const filterTrainingContentByDate = (trainingContent: {[key: string]: ITrainingContent}) => { const filterTrainingContentByDate = (trainingContent: {
[key: string]: ITrainingContent;
}) => {
if (filter) { if (filter) {
const filterDate = moment() const filterDate = moment()
.subtract({ [filter as string]: 1 }) .subtract({ [filter as string]: 1 })
@@ -99,7 +121,8 @@ const Training: React.FC<{user: User, entities: EntityWithRoles[], users: User[]
const filteredTrainingContent: { [key: string]: ITrainingContent } = {}; const filteredTrainingContent: { [key: string]: ITrainingContent } = {};
Object.keys(trainingContent).forEach((timestamp) => { Object.keys(trainingContent).forEach((timestamp) => {
if (timestamp >= filterDate) filteredTrainingContent[timestamp] = trainingContent[timestamp]; if (timestamp >= filterDate)
filteredTrainingContent[timestamp] = trainingContent[timestamp];
}); });
return filteredTrainingContent; return filteredTrainingContent;
} }
@@ -133,18 +156,22 @@ const Training: React.FC<{user: User, entities: EntityWithRoles[], users: User[]
const trainingContentContainer = (timestamp: string) => { const trainingContentContainer = (timestamp: string) => {
if (!groupedByTrainingContent) return <></>; if (!groupedByTrainingContent) return <></>;
const trainingContent: ITrainingContent = groupedByTrainingContent[timestamp]; const trainingContent: ITrainingContent =
const uniqueModules = [...new Set(trainingContent.exams.map((exam) => exam.module))]; groupedByTrainingContent[timestamp];
const uniqueModules = [
...new Set(trainingContent.exams.map((exam) => exam.module)),
];
return ( return (
<> <>
<div <div
key={uuidv4()} key={uuidv4()}
className={clsx( className={clsx(
"flex flex-col justify-between gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:hidden", "flex flex-col justify-between gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:hidden"
)} )}
onClick={() => selectTrainingContent(trainingContent)} onClick={() => selectTrainingContent(trainingContent)}
role="button"> role="button"
>
<div className="w-full flex justify-between -md:items-center 2xl:items-center"> <div className="w-full flex justify-between -md:items-center 2xl:items-center">
<div className="flex flex-col md:gap-1 -md:gap-2 2xl:gap-2"> <div className="flex flex-col md:gap-1 -md:gap-2 2xl:gap-2">
<span className="font-medium">{formatTimestamp(timestamp)}</span> <span className="font-medium">{formatTimestamp(timestamp)}</span>
@@ -181,22 +208,33 @@ const Training: React.FC<{user: User, entities: EntityWithRoles[], users: User[]
<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" />
{isNewContentLoading && ( {isNewContentLoading && (
<span className="text-center text-2xl font-bold text-mti-green-light">Assessing your exams, please be patient...</span> <span className="text-center text-2xl font-bold text-mti-green-light">
Assessing your exams, please be patient...
</span>
)} )}
</div> </div>
) : ( ) : (
<> <>
<RecordFilter entities={entities} user={user} filterState={{filter: filter, setFilter: setFilter}} assignments={false}> <RecordFilter
entities={entities}
user={user}
isAdmin={isAdmin}
filterState={{ filter: filter, setFilter: setFilter }}
assignments={false}
>
{user.type === "student" && ( {user.type === "student" && (
<> <>
<div className="flex items-center"> <div className="flex items-center">
<div className="font-semibold text-2xl">Generate New Training Material</div> <div className="font-semibold text-2xl">
Generate New Training Material
</div>
<button <button
className={clsx( className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light ml-4", "bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light ml-4",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out"
)} )}
onClick={handleNewTrainingContent}> onClick={handleNewTrainingContent}
>
<FaPlus /> <FaPlus />
</button> </button>
</div> </div>
@@ -205,12 +243,18 @@ const Training: React.FC<{user: User, entities: EntityWithRoles[], users: User[]
</RecordFilter> </RecordFilter>
{trainingContent.length == 0 && ( {trainingContent.length == 0 && (
<div className="flex flex-grow justify-center items-center"> <div className="flex flex-grow justify-center items-center">
<span className="font-semibold ml-1">No training content to display...</span> <span className="font-semibold ml-1">
No training content to display...
</span>
</div> </div>
)} )}
{!areRecordsLoading && groupedByTrainingContent && Object.keys(groupedByTrainingContent).length > 0 && ( {!areRecordsLoading &&
groupedByTrainingContent &&
Object.keys(groupedByTrainingContent).length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-3 w-full gap-4 xl:gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-3 w-full gap-4 xl:gap-6">
{Object.keys(filterTrainingContentByDate(groupedByTrainingContent)) {Object.keys(
filterTrainingContentByDate(groupedByTrainingContent)
)
.sort((a, b) => parseInt(b) - parseInt(a)) .sort((a, b) => parseInt(b) - parseInt(a))
.map(trainingContentContainer)} .map(trainingContentContainer)}
</div> </div>

View File

@@ -17,21 +17,26 @@ import { requestUser } from "@/utils/api";
import { redirect } from "@/utils"; import { redirect } from "@/utils";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res);
if (!user) return redirect("/login") if (!user) return redirect("/login");
const entityIDs = mapBy(user.entities, 'id') const entityIDs = mapBy(user.entities, "id");
const entities = await getEntitiesWithRoles(checkAccess(user, ["admin", 'developer']) ? undefined : entityIDs) const entities = await getEntitiesWithRoles(
const allowedEntities = findAllowedEntities(user, entities, "view_student_performance") checkAccess(user, ["admin", "developer"]) ? undefined : entityIDs
);
const allowedEntities = findAllowedEntities(
user,
entities,
"view_student_performance"
);
if (allowedEntities.length === 0) return redirect("/") if (allowedEntities.length === 0) return redirect("/");
const students = await (checkAccess(user, ["admin", 'developer']) const students = await (checkAccess(user, ["admin", "developer"])
? getUsers({ type: 'student' }) ? getUsers({ type: "student" })
: getEntitiesUsers(mapBy(allowedEntities, 'id'), { type: 'student' }) : getEntitiesUsers(mapBy(allowedEntities, "id"), { type: "student" }));
) const groups = await getParticipantsGroups(mapBy(students, "id"));
const groups = await getParticipantsGroups(mapBy(students, 'id'))
return { return {
props: serialize({ user, students, entities, groups }), props: serialize({ user, students, entities, groups }),
@@ -40,9 +45,9 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
interface Props { interface Props {
user: User; user: User;
students: StudentUser[] students: StudentUser[];
entities: Entity[] entities: Entity[];
groups: Group[] groups: Group[];
} }
const StudentPerformance = ({ user, students, entities, groups }: Props) => { const StudentPerformance = ({ user, students, entities, groups }: Props) => {
@@ -53,7 +58,10 @@ const StudentPerformance = ({ user, students, entities, groups }: Props) => {
const performanceStudents = students.map((u) => ({ const performanceStudents = students.map((u) => ({
...u, ...u,
group: groups.find((x) => x.participants.includes(u.id))?.name || "N/A", group: groups.find((x) => x.participants.includes(u.id))?.name || "N/A",
entitiesLabel: mapBy(u.entities, 'id').map((id) => entities.find((e) => e.id === id)?.label).filter((e) => !!e).join(', '), entitiesLabel: mapBy(u.entities, "id")
.map((id) => entities.find((e) => e.id === id)?.label)
.filter((e) => !!e)
.join(", "),
})); }));
return ( return (
@@ -73,12 +81,15 @@ const StudentPerformance = ({ user, students, entities, groups }: Props) => {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={() => { onClick={() => {
router.back() router.back();
}} }}
className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"> className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"
>
<BsChevronLeft /> <BsChevronLeft />
</button> </button>
<h2 className="font-bold text-2xl">Student Performance ({students.length})</h2> <h2 className="font-bold text-2xl">
Student Performance ({students.length})
</h2>
</div> </div>
<StudentPerformanceList items={performanceStudents} stats={stats} /> <StudentPerformanceList items={performanceStudents} stats={stats} />
</> </>

View File

@@ -19,8 +19,8 @@ export const getAssignmentsByAssigner = async (id: string, startDate?: Date, end
return await db.collection("assignments").find<Assignment>(query).toArray(); return await db.collection("assignments").find<Assignment>(query).toArray();
}; };
export const getAssignments = async () => { export const getAssignments = async (projection = {}) => {
return await db.collection("assignments").find<Assignment>({}).toArray(); return await db.collection("assignments").find<Assignment>({}, { projection }).toArray();
}; };
export const getAssignment = async (id: string) => { export const getAssignment = async (id: string) => {

View File

@@ -9,7 +9,6 @@ const db = client.db(process.env.MONGODB_DB);
export const getEntityWithRoles = async (id: string): Promise<EntityWithRoles | undefined> => { export const getEntityWithRoles = async (id: string): Promise<EntityWithRoles | undefined> => {
const entity = await getEntity(id); const entity = await getEntity(id);
if (!entity) return undefined; if (!entity) return undefined;
const roles = await getRolesByEntity(id); const roles = await getRolesByEntity(id);
return { ...entity, roles }; return { ...entity, roles };
}; };

View File

@@ -1,11 +1,8 @@
import { collection, getDocs, query, where, setDoc, doc, Firestore, getDoc, and } from "firebase/firestore";
import { groupBy, shuffle } from "lodash"; import { groupBy, shuffle } from "lodash";
import { CEFRLevels, Difficulty, Exam, InstructorGender, SpeakingExam, Variant, WritingExam } from "@/interfaces/exam"; import { CEFRLevels, Exam, InstructorGender, SpeakingExam, Variant, WritingExam } from "@/interfaces/exam";
import { DeveloperUser, Stat, StudentUser, User } from "@/interfaces/user"; import { DeveloperUser, Stat, StudentUser, User } from "@/interfaces/user";
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import { getCorporateUser } from "@/resources/user"; import { Db } from "mongodb";
import { getUserCorporate } from "./groups.be";
import { Db, ObjectId } from "mongodb";
import client from "@/lib/mongodb"; import client from "@/lib/mongodb";
import { MODULE_ARRAY } from "./moduleUtils"; import { MODULE_ARRAY } from "./moduleUtils";
import { mapBy } from "."; import { mapBy } from ".";

View File

@@ -3,10 +3,10 @@ import client from "@/lib/mongodb";
const db = client.db(process.env.MONGODB_DB); const db = client.db(process.env.MONGODB_DB);
export const getSessionsByUser = async (id: string, limit = 0, filter = {}) => export const getSessionsByUser = async (id: string, limit = 0, filter = {}, projection = {}) =>
await db await db
.collection("sessions") .collection("sessions")
.find<Session>({ user: id, ...filter }) .find<Session>({ user: id, ...filter }, { projection })
.limit(limit || 0) .limit(limit || 0)
.toArray(); .toArray();

View File

@@ -129,19 +129,19 @@ export async function getUser(id: string, projection = {}): Promise<User | undef
return !!user ? user : undefined; return !!user ? user : undefined;
} }
export async function getSpecificUsers(ids: string[]) { export async function getSpecificUsers(ids: string[], projection = {}) {
if (ids.length === 0) return []; if (ids.length === 0) return [];
return await db return await db
.collection("users") .collection("users")
.find<User>({ id: { $in: ids } }, { projection: { _id: 0 } }) .find<User>({ id: { $in: ids } }, { projection: { _id: 0, ...projection } })
.toArray(); .toArray();
} }
export async function getEntityUsers(id: string, limit?: number, filter?: object) { export async function getEntityUsers(id: string, limit?: number, filter?: object, projection = {}) {
return await db return await db
.collection("users") .collection("users")
.find<User>({ "entities.id": id, ...(filter || {}) }) .find<User>({ "entities.id": id, ...(filter || {}) }, { projection: { _id: 0, ...projection } })
.limit(limit || 0) .limit(limit || 0)
.toArray(); .toArray();
} }
@@ -231,7 +231,7 @@ export async function getUserBalance(user: User) {
); );
} }
export const filterAllowedUsers = async (user: User, entities: EntityWithRoles[]) => { export const filterAllowedUsers = async (user: User, entities: EntityWithRoles[], projection = {}) => {
const { const {
["view_students"]: allowedStudentEntities, ["view_students"]: allowedStudentEntities,
@@ -244,12 +244,12 @@ export const filterAllowedUsers = async (user: User, entities: EntityWithRoles[]
'view_corporates', 'view_corporates',
'view_mastercorporates', 'view_mastercorporates',
]); ]);
const [students, teachers, corporates, masterCorporates] = await Promise.all([
getEntitiesUsers(mapBy(allowedStudentEntities, 'id'), { type: "student" }, 0, projection),
const students = await getEntitiesUsers(mapBy(allowedStudentEntities, 'id'), { type: "student" }) getEntitiesUsers(mapBy(allowedTeacherEntities, 'id'), { type: "teacher" }, 0, projection),
const teachers = await getEntitiesUsers(mapBy(allowedTeacherEntities, 'id'), { type: "teacher" }) getEntitiesUsers(mapBy(allowedCorporateEntities, 'id'), { type: "corporate" }, 0, projection),
const corporates = await getEntitiesUsers(mapBy(allowedCorporateEntities, 'id'), { type: "corporate" }) getEntitiesUsers(mapBy(allowedMasterCorporateEntities, 'id'), { type: "mastercorporate" }, 0, projection),
const masterCorporates = await getEntitiesUsers(mapBy(allowedMasterCorporateEntities, 'id'), { type: "mastercorporate" }) ])
return [...students, ...teachers, ...corporates, ...masterCorporates] return [...students, ...teachers, ...corporates, ...masterCorporates]
} }
@@ -266,11 +266,12 @@ export const countAllowedUsers = async (user: User, entities: EntityWithRoles[])
'view_corporates', 'view_corporates',
'view_mastercorporates', 'view_mastercorporates',
]); ]);
const [student, teacher, corporate, mastercorporate] = await Promise.all([
const student = await countEntitiesUsers(mapBy(allowedStudentEntities, 'id'), { type: "student" }) countEntitiesUsers(mapBy(allowedStudentEntities, 'id'), { type: "student" }),
const teacher = await countEntitiesUsers(mapBy(allowedTeacherEntities, 'id'), { type: "teacher" }) countEntitiesUsers(mapBy(allowedTeacherEntities, 'id'), { type: "teacher" }),
const corporate = await countEntitiesUsers(mapBy(allowedCorporateEntities, 'id'), { type: "corporate" }) countEntitiesUsers(mapBy(allowedCorporateEntities, 'id'), { type: "corporate" }),
const mastercorporate = await countEntitiesUsers(mapBy(allowedMasterCorporateEntities, 'id'), { type: "mastercorporate" }) countEntitiesUsers(mapBy(allowedMasterCorporateEntities, 'id'), { type: "mastercorporate" }),
])
return { student, teacher, corporate, mastercorporate } return { student, teacher, corporate, mastercorporate }
} }