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

View File

@@ -61,7 +61,14 @@ export default function App({ Component, pageProps }: AppProps) {
return pageProps?.user ? (
<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>
) : (
<Component {...pageProps} />

View File

@@ -13,7 +13,15 @@ import clsx from "clsx";
import { capitalize, uniqBy } from "lodash";
import moment from "moment";
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 { futureAssignmentFilter } from "@/utils/assignments";
import { withIronSessionSsr } from "iron-session/next";
@@ -31,69 +39,140 @@ import { requestUser } from "@/utils/api";
import { useEntityPermission } from "@/hooks/useEntityPermissions";
import { getGradingSystemByEntity } from "@/utils/grading.be";
export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
export const getServerSideProps = withIronSessionSsr(
async ({ req, res, params }) => {
const user = await requestUser(req, res);
if (!user) return redirect("/login");
if (!checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"]))
return redirect("/assignments")
if (
!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 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) {
const users = await getUsers()
const users = await getUsers(
{},
0,
{},
{
_id: 0,
id: 1,
name: 1,
email: 1,
}
);
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 gradingSystem = await getGradingSystemByEntity(entity.id)
const [users, gradingSystem] = await Promise.all([
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 }) };
}, sessionOptions);
return {
props: serialize({ user, users, entity, assignment, gradingSystem }),
};
},
sessionOptions
);
interface Props {
user: User;
users: User[];
assignment: Assignment;
entity?: EntityWithRoles
gradingSystem?: Grading
entity?: EntityWithRoles;
gradingSystem?: Grading;
}
export default function AssignmentView({ user, users, entity, assignment, gradingSystem }: Props) {
const canDeleteAssignment = useEntityPermission(user, entity, 'delete_assignment')
const canStartAssignment = useEntityPermission(user, entity, 'start_assignment')
export default function AssignmentView({
user,
users,
entity,
assignment,
gradingSystem,
}: Props) {
const canDeleteAssignment = useEntityPermission(
user,
entity,
"delete_assignment"
);
const canStartAssignment = useEntityPermission(
user,
entity,
"start_assignment"
);
const router = useRouter();
const dispatch = useExamStore((state) => state.dispatch);
const deleteAssignment = async () => {
if (!canDeleteAssignment) return
if (!canDeleteAssignment) return;
if (!confirm("Are you sure you want to delete this assignment?")) return;
axios
.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."))
.finally(() => router.push("/assignments"));
};
const startAssignment = () => {
if (!canStartAssignment) return
if (!canStartAssignment) return;
if (!confirm("Are you sure you want to start this assignment?")) return;
axios
.post(`/api/assignments/${assignment.id}/start`)
.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);
})
.catch((e) => {
@@ -115,15 +194,26 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
const resultModuleBandScores = assignment.results.map((r) => {
const moduleStats = r.stats.filter((s) => s.module === module);
const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0);
const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0);
const correct = moduleStats.reduce(
(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 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: {
[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!] = {
total: scores[x.module!].total + x.score.total,
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] }));
};
const levelAverage = (aggregatedLevels: { module: Module, level: number }[]) =>
aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length
const levelAverage = (
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 defaultLevelScore = levelAverage(aggregatedLevels).toFixed(1)
if (!stats.every(s => s.module === "level")) return defaultLevelScore
if (!gradingSystem) return defaultLevelScore
const renderLevelScore = (
stats: Stat[],
aggregatedLevels: { module: Module; level: number }[]
) => {
const defaultLevelScore = levelAverage(aggregatedLevels).toFixed(1);
if (!stats.every((s) => s.module === "level")) return defaultLevelScore;
if (!gradingSystem) return defaultLevelScore;
const score = {
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 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 customContent = (
stats: Stat[],
user: string,
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) => ({
module: x.module,
@@ -198,19 +315,22 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
const timeSpent = stats[0].timeSpent;
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) => {
if (exams.every((x) => !!x)) {
dispatch({
type: 'INIT_SOLUTIONS', payload: {
type: "INIT_SOLUTIONS",
payload: {
exams: exams.map((x) => x!).sort(sortByModule),
modules: exams
.map((x) => x!)
.sort(sortByModule)
.map((x) => x!.module),
stats,
}
},
});
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: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 && (
<>
<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>
@@ -233,10 +357,10 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
className={clsx(
correct / total >= 0.7 && "text-mti-purple",
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
correct / total < 0.3 && "text-mti-rose",
)}>
Level{' '}
{renderLevelScore(stats, aggregatedLevels)}
correct / total < 0.3 && "text-mti-rose"
)}
>
Level {renderLevelScore(stats, aggregatedLevels)}
</span>
</div>
@@ -251,8 +375,9 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
module === "listening" && "bg-ielts-listening",
module === "writing" && "bg-ielts-writing",
module === "speaking" && "bg-ielts-speaking",
module === "level" && "bg-ielts-level",
)}>
module === "level" && "bg-ielts-level"
)}
>
{module === "reading" && <BsBook className="h-4 w-4" />}
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
{module === "writing" && <BsPen className="h-4 w-4" />}
@@ -279,11 +404,14 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
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",
correct / total >= 0.7 && "hover:border-mti-purple",
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
correct / total < 0.3 && "hover:border-mti-rose",
correct / total >= 0.3 &&
correct / total < 0.7 &&
"hover:border-mti-red",
correct / total < 0.3 && "hover:border-mti-rose"
)}
onClick={selectExam}
role="button">
role="button"
>
{content}
</div>
<div
@@ -291,11 +419,14 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
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",
correct / total >= 0.7 && "hover:border-mti-purple",
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
correct / total < 0.3 && "hover:border-mti-rose",
correct / total >= 0.3 &&
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."
role="button">
role="button"
>
{content}
</div>
</div>
@@ -313,29 +444,46 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
};
const removeInactiveAssignees = () => {
const mappedResults = mapBy(assignment.results, 'user')
const inactiveAssignees = assignment.assignees.filter((a) => !mappedResults.includes(a))
const activeAssignees = assignment.assignees.filter((a) => mappedResults.includes(a))
const mappedResults = mapBy(assignment.results, "user");
const inactiveAssignees = assignment.assignees.filter(
(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
.patch(`/api/assignments/${assignment.id}`, { assignees: activeAssignees })
.patch(`/api/assignments/${assignment.id}`, {
assignees: activeAssignees,
})
.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);
})
.catch((e) => {
console.log(e);
toast.error("Something went wrong, please try again later!");
});
}
};
const copyLink = async () => {
const origin = window.location.origin
await navigator.clipboard.writeText(`${origin}/exam?assignment=${assignment.id}`)
toast.success("The URL to the assignment has been copied to your clipboard!")
}
const origin = window.location.origin;
await navigator.clipboard.writeText(
`${origin}/exam?assignment=${assignment.id}`
);
toast.success(
"The URL to the assignment has been copied to your clipboard!"
);
};
return (
<>
@@ -352,7 +500,10 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
<div className="flex flex-col gap-4">
<div className="w-full flex items-center justify-between">
<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 />
</Link>
<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`}
className="h-6"
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-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 flex-col gap-2">
<span>Start Date: {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}</span>
<span>End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}</span>
<span>
Start Date:{" "}
{moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}
</span>
<span>
End Date:{" "}
{moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}
</span>
</div>
<div className="flex flex-col gap-2">
<span>
@@ -390,12 +553,18 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
.map((u) => `${u.name} (${u.email})`)
.join(", ")}
</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>
{assignment.assignees.length !== 0 && assignment.results.length !== assignment.assignees.length && (
<Button onClick={removeInactiveAssignees} variant="outline">Remove Inactive Assignees</Button>
{assignment.assignees.length !== 0 &&
assignment.results.length !== assignment.assignees.length && (
<Button onClick={removeInactiveAssignees} variant="outline">
Remove Inactive Assignees
</Button>
)}
<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 === "writing" && "bg-ielts-writing",
module === "speaking" && "bg-ielts-speaking",
module === "level" && "bg-ielts-level",
)}>
module === "level" && "bg-ielts-level"
)}
>
{module === "reading" && <BsBook className="h-4 w-4" />}
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
{module === "listening" && (
<BsHeadphones 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" />}
{calculateAverageModuleScore(module) > -1 && (
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
<span className="text-sm">
{calculateAverageModuleScore(module).toFixed(1)}
</span>
)}
</div>
))}
@@ -428,35 +604,59 @@ export default function AssignmentView({ user, users, entity, assignment, gradin
</div>
<div className="flex flex-col gap-2">
<span className="text-xl font-bold">
Results ({assignment?.results.length}/{assignment?.assignees.length})
Results ({assignment?.results.length}/
{assignment?.assignees.length})
</span>
<div>
{assignment && assignment?.results.length > 0 && (
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 xl:gap-6">
{assignment.results.map((r) => customContent(r.stats, r.user, r.type))}
{assignment.results.map((r) =>
customContent(r.stats, r.user, r.type)
)}
</div>
)}
{assignment && assignment?.results.length === 0 && <span className="ml-1 font-semibold">No results yet...</span>}
{assignment && assignment?.results.length === 0 && (
<span className="ml-1 font-semibold">No results yet...</span>
)}
</div>
</div>
<div className="flex gap-4 w-full items-center justify-end">
<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
</Button>
{assignment &&
(assignment.results.length === assignment.assignees.length || moment().isAfter(moment(assignment.endDate))) && (
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={deleteAssignment}>
(assignment.results.length === assignment.assignees.length ||
moment().isAfter(moment(assignment.endDate))) && (
<Button
variant="outline"
color="red"
className="w-full max-w-[200px]"
onClick={deleteAssignment}
>
Delete
</Button>
)}
{/** if the assignment is not deemed as active yet, display start */}
{shouldRenderStart() && (
<Button variant="outline" color="green" className="w-full max-w-[200px]" onClick={startAssignment}>
<Button
variant="outline"
color="green"
className="w-full max-w-[200px]"
onClick={startAssignment}
>
Start
</Button>
)}
<Button onClick={() => router.push("/assignments")} className="w-full max-w-[200px]">
<Button
onClick={() => router.push("/assignments")}
className="w-full max-w-[200px]"
>
Close
</Button>
</div>

View File

@@ -18,7 +18,11 @@ import { requestUser } from "@/utils/api";
import { getAssignment } from "@/utils/assignments.be";
import { getEntitiesWithRoles } from "@/utils/entities.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 { getEntitiesUsers, getUsers } from "@/utils/users.be";
import axios from "axios";
@@ -32,34 +36,91 @@ import { useRouter } from "next/router";
import { generate } from "random-words";
import { useEffect, useMemo, useState } from "react";
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";
export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
export const getServerSideProps = withIronSessionSsr(
async ({ req, res, params }) => {
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 entityIDS = mapBy(user.entities, "id") || [];
const isAdmin = checkAccess(user, ["developer", "admin"]);
const assignment = await getAssignment(id);
if (!assignment) return redirect("/assignments")
const [assignment, entities] = await Promise.all([
getAssignment(id),
getEntitiesWithRoles(isAdmin ? undefined : entityIDS),
]);
const entities = await (checkAccess(user, ["developer", "admin"]) ? getEntitiesWithRoles() : getEntitiesWithRoles(entityIDS));
const entity = entities.find((e) => e.id === assignment.entity)
if (!assignment) return redirect("/assignments");
const entity = entities.find((e) => e.id === assignment.entity);
if (!entity) return redirect("/assignments")
if (!doesEntityAllow(user, entity, 'edit_assignment')) return redirect("/assignments")
if (!entity) 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 groups = await (checkAccess(user, ["developer", "admin"]) ? getGroups() : getGroupsByEntities(mapBy(allowedEntities, 'id')));
const allowedEntities = findAllowedEntities(
user,
entities,
"edit_assignment"
);
return { props: serialize({ user, users, entities: allowedEntities, assignment, groups }) };
}, sessionOptions);
const allowEntitiesIDs = mapBy(allowedEntities, "id");
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 {
assignment: Assignment;
@@ -71,24 +132,44 @@ interface Props {
const SIZE = 9;
export default function AssignmentsPage({ assignment, user, users, entities, groups }: Props) {
const [selectedModules, setSelectedModules] = useState<Module[]>(assignment.exams.map((e) => e.module));
export default function AssignmentsPage({
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 [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 [isLoading, setIsLoading] = useState(false);
const [startDate, setStartDate] = useState<Date | null>(moment(assignment.startDate).toDate());
const [endDate, setEndDate] = useState<Date | null>(moment(assignment.endDate).toDate());
const [startDate, setStartDate] = useState<Date | null>(
moment(assignment.startDate).toDate()
);
const [endDate, setEndDate] = useState<Date | null>(
moment(assignment.endDate).toDate()
);
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 [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 [examIDs, setExamIDs] = useState<{ id: string; module: Module }[]>([]);
@@ -96,19 +177,34 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
const { exams } = useExams();
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 userTeachers = useMemo(() => users.filter((x) => x.type === "teacher"), [users]);
const userStudents = useMemo(
() => 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: filteredTeachersRows, renderSearch: renderTeacherSearch } = useListSearch([["name"], ["email"]], userTeachers);
const { rows: filteredStudentsRows, renderSearch: renderStudentSearch } =
useListSearch([["name"], ["email"]], userStudents);
const { rows: filteredTeachersRows, renderSearch: renderTeacherSearch } =
useListSearch([["name"], ["email"]], userTeachers);
const { items: studentRows, renderMinimal: renderStudentPagination } = usePagination(filteredStudentsRows, SIZE);
const { items: teacherRows, renderMinimal: renderTeacherPagination } = usePagination(filteredTeachersRows, SIZE);
const { items: studentRows, renderMinimal: renderStudentPagination } =
usePagination(filteredStudentsRows, SIZE);
const { items: teacherRows, renderMinimal: renderTeacherPagination } =
usePagination(filteredTeachersRows, SIZE);
useEffect(() => {
setExamIDs((prev) => prev.filter((x) => selectedModules.includes(x.module)));
setExamIDs((prev) =>
prev.filter((x) => selectedModules.includes(x.module))
);
}, [selectedModules]);
useEffect(() => {
@@ -118,21 +214,33 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
const toggleModule = (module: 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) => {
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) => {
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 = () => {
setIsLoading(true);
(assignment ? axios.patch : axios.post)(`/api/assignments/${assignment.id}`, {
(assignment ? axios.patch : axios.post)(
`/api/assignments/${assignment.id}`,
{
assignees,
name,
startDate,
@@ -146,9 +254,12 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
instructorGender,
released,
autoStart,
})
}
)
.then(() => {
toast.success(`The assignment "${name}" has been updated successfully!`);
toast.success(
`The assignment "${name}" has been updated successfully!`
);
router.push(`/assignments/${assignment.id}`);
})
.catch((e) => {
@@ -159,14 +270,21 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
};
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");
setIsLoading(true);
axios
.delete(`/api/assignments/${assignment.id}`)
.then(() => {
toast.success(`The assignment "${name}" has been deleted successfully!`);
toast.success(
`The assignment "${name}" has been deleted successfully!`
);
router.push("/assignments");
})
.catch((e) => {
@@ -183,7 +301,9 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
axios
.post(`/api/assignments/${assignment.id}/start`)
.then(() => {
toast.success(`The assignment "${name}" has been started successfully!`);
toast.success(
`The assignment "${name}" has been started successfully!`
);
router.push(`/assignments/${assignment.id}`);
})
.catch((e) => {
@@ -195,10 +315,14 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
};
const copyLink = async () => {
const origin = window.location.origin
await navigator.clipboard.writeText(`${origin}/exam?assignment=${assignment.id}`)
toast.success("The URL to the assignment has been copied to your clipboard!")
}
const origin = window.location.origin;
await navigator.clipboard.writeText(
`${origin}/exam?assignment=${assignment.id}`
);
toast.success(
"The URL to the assignment has been copied to your clipboard!"
);
};
return (
<>
@@ -214,7 +338,10 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
<>
<div className="flex flex-col gap-4">
<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 />
</Link>
<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">
<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
onClick={!selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
onClick={
!selectedModules.includes("level")
? () => toggleModule("reading")
: undefined
}
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",
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">
<BsBook className="text-white w-7 h-7" />
</div>
<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" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("reading") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
{selectedModules.includes("level") && (
<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
onClick={!selectedModules.includes("level") ? () => toggleModule("listening") : undefined}
onClick={
!selectedModules.includes("level")
? () => toggleModule("listening")
: undefined
}
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",
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">
<BsHeadphones className="text-white w-7 h-7" />
</div>
<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" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("listening") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
{selectedModules.includes("level") && (
<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
onClick={
(!selectedModules.includes("level") && selectedModules.length === 0) || selectedModules.includes("level")
(!selectedModules.includes("level") &&
selectedModules.length === 0) ||
selectedModules.includes("level")
? () => toggleModule("level")
: undefined
}
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",
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">
<BsClipboard className="text-white w-7 h-7" />
</div>
<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" />
)}
{!selectedModules.includes("level") && 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" />}
{!selectedModules.includes("level") &&
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
onClick={!selectedModules.includes("level") ? () => toggleModule("writing") : undefined}
onClick={
!selectedModules.includes("level")
? () => toggleModule("writing")
: undefined
}
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",
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">
<BsPen className="text-white w-7 h-7" />
</div>
<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" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("writing") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
{selectedModules.includes("level") && (
<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
onClick={!selectedModules.includes("level") ? () => toggleModule("speaking") : undefined}
onClick={
!selectedModules.includes("level")
? () => toggleModule("speaking")
: undefined
}
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",
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">
<BsMegaphone className="text-white w-7 h-7" />
</div>
<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" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("speaking") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
{selectedModules.includes("level") && (
<BsXCircle className="text-mti-red-light w-8 h-8" />
)}
{selectedModules.includes("speaking") && (
<BsCheckCircle className="text-mti-purple-light w-8 h-8" />
)}
</div>
</section>
<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
label="Entity"
options={entities.map((e) => ({ value: e.id, label: e.label }))}
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 className="w-full grid -md:grid-cols-1 md:grid-cols-2 gap-8">
<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
className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip z-10",
"transition duration-300 ease-in-out",
"transition duration-300 ease-in-out"
)}
popperClassName="!z-20"
filterTime={(date) => moment(date).isSameOrAfter(new Date())}
@@ -337,12 +535,14 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
/>
</div>
<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
className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip z-10",
"transition duration-300 ease-in-out",
"transition duration-300 ease-in-out"
)}
popperClassName="!z-20"
filterTime={(date) => moment(date).isAfter(startDate)}
@@ -356,13 +556,19 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
{selectedModules.includes("speaking") && (
<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
value={{
value: 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}
options={[
{ 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">
{selectedModules.map((module) => (
<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
value={{
value: examIDs.find((e) => e.module === module)?.id || null,
label: examIDs.find((e) => e.module === module)?.id || "",
value:
examIDs.find((e) => e.module === module)?.id ||
null,
label:
examIDs.find((e) => e.module === module)?.id || "",
}}
onChange={(value) =>
value
@@ -394,7 +605,9 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
...prev.filter((x) => x.module !== module),
{ id: value.value!, module },
])
: setExamIDs((prev) => prev.filter((x) => x.module !== module))
: setExamIDs((prev) =>
prev.filter((x) => x.module !== module)
)
}
options={exams
.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">
<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">
{classrooms.map((g) => (
<button
key={g.id}
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))) {
setAssignees((prev) => prev.filter((a) => !groupStudentIds.includes(a)));
setAssignees((prev) =>
prev.filter((a) => !groupStudentIds.includes(a))
);
} else {
setAssignees((prev) => [...prev.filter((a) => !groupStudentIds.includes(a)), ...groupStudentIds]);
setAssignees((prev) => [
...prev.filter((a) => !groupStudentIds.includes(a)),
...groupStudentIds,
]);
}
}}
className={clsx(
"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",
users.filter((u) => g.participants.includes(u.id)).every((u) => assignees.includes(u.id)) &&
"!bg-mti-purple-light !text-white",
)}>
users
.filter((u) => g.participants.includes(u.id))
.every((u) => assignees.includes(u.id)) &&
"!bg-mti-purple-light !text-white"
)}
>
{g.name}
</button>
))}
@@ -444,9 +672,12 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
className={clsx(
"p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72",
"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="font-semibold">{user.name}</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" && (
<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">
{classrooms.map((g) => (
<button
key={g.id}
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))) {
setTeachers((prev) => prev.filter((a) => !groupStudentIds.includes(a)));
setTeachers((prev) =>
prev.filter((a) => !groupStudentIds.includes(a))
);
} else {
setTeachers((prev) => [...prev.filter((a) => !groupStudentIds.includes(a)), ...groupStudentIds]);
setTeachers((prev) => [
...prev.filter((a) => !groupStudentIds.includes(a)),
...groupStudentIds,
]);
}
}}
className={clsx(
"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",
users.filter((u) => g.participants.includes(u.id)).every((u) => teachers.includes(u.id)) &&
"!bg-mti-purple-light !text-white",
)}>
users
.filter((u) => g.participants.includes(u.id))
.every((u) => teachers.includes(u.id)) &&
"!bg-mti-purple-light !text-white"
)}
>
{g.name}
</button>
))}
@@ -508,9 +757,12 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
className={clsx(
"p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72",
"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="font-semibold">{user.name}</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">
<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
</Checkbox>
<Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple((d) => !d)}>
<Checkbox
isChecked={generateMultiple}
onChange={() => setGenerateMultiple((d) => !d)}
>
Generate different exams
</Checkbox>
<Checkbox isChecked={released} onChange={() => setReleased((d) => !d)}>
<Checkbox
isChecked={released}
onChange={() => setReleased((d) => !d)}
>
Auto release results
</Checkbox>
<Checkbox isChecked={autoStart} onChange={() => setAutostart((d) => !d)}>
<Checkbox
isChecked={autoStart}
onChange={() => setAutostart((d) => !d)}
>
Auto start exam
</Checkbox>
</div>
<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
</Button>
<Button
@@ -551,7 +822,8 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
variant="outline"
onClick={() => router.push("/assignments")}
disabled={isLoading}
isLoading={isLoading}>
isLoading={isLoading}
>
Cancel
</Button>
<Button
@@ -560,7 +832,8 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
variant="outline"
onClick={startAssignment}
disabled={isLoading || moment().isAfter(startDate)}
isLoading={isLoading}>
isLoading={isLoading}
>
Start
</Button>
<Button
@@ -569,7 +842,8 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
variant="outline"
onClick={deleteAssignment}
disabled={isLoading}
isLoading={isLoading}>
isLoading={isLoading}
>
Delete
</Button>
<Button
@@ -583,7 +857,8 @@ export default function AssignmentsPage({ assignment, user, users, entities, gro
}
className="w-full max-w-[200px]"
onClick={createAssignment}
isLoading={isLoading}>
isLoading={isLoading}
>
Update
</Button>
</div>

View File

@@ -32,21 +32,61 @@ import { useRouter } from "next/router";
import { generate } from "random-words";
import { useEffect, useMemo, useState } from "react";
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";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
const user = await requestUser(req, res);
if (!user) return redirect("/login");
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')
if (allowedEntities.length === 0) return redirect("/assignments")
const allowedEntities = findAllowedEntities(
user,
entities,
"create_assignment"
);
if (allowedEntities.length === 0) return redirect("/assignments");
const users = await (isAdmin(user) ? getUsers() : getEntitiesUsers(mapBy(allowedEntities, 'id')));
const groups = await (isAdmin(user) ? getGroups() : getGroupsByEntities(mapBy(allowedEntities, 'id')));
const [users, groups] = await Promise.all([
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 }) };
}, sessionOptions);
@@ -61,10 +101,17 @@ interface Props {
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 [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 [name, setName] = useState(
generate({
@@ -74,14 +121,19 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
max: 3,
join: " ",
formatter: capitalize,
}),
})
);
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 [instructorGender, setInstructorGender] = useState<InstructorGender>("varied");
const [instructorGender, setInstructorGender] =
useState<InstructorGender>("varied");
const [generateMultiple, setGenerateMultiple] = 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 router = useRouter();
const classrooms = useMemo(() => groups.filter((e) => e.entity?.id === entity), [entity, groups]);
const allowedUsers = useMemo(() => users.filter((u) => mapBy(u.entities, 'id').includes(entity || "")), [users, entity])
const classrooms = useMemo(
() => 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 userTeachers = useMemo(() => allowedUsers.filter((x) => x.type === "teacher"), [allowedUsers]);
const userStudents = useMemo(
() => 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: filteredTeachersRows, renderSearch: renderTeacherSearch } = useListSearch([["name"], ["email"]], userTeachers);
const { rows: filteredStudentsRows, renderSearch: renderStudentSearch } =
useListSearch([["name"], ["email"]], userStudents);
const { rows: filteredTeachersRows, renderSearch: renderTeacherSearch } =
useListSearch([["name"], ["email"]], userTeachers);
const { items: studentRows, renderMinimal: renderStudentPagination } = usePagination(filteredStudentsRows, SIZE);
const { items: teacherRows, renderMinimal: renderTeacherPagination } = usePagination(filteredTeachersRows, SIZE);
const { items: studentRows, renderMinimal: renderStudentPagination } =
usePagination(filteredStudentsRows, SIZE);
const { items: teacherRows, renderMinimal: renderTeacherPagination } =
usePagination(filteredTeachersRows, SIZE);
useEffect(() => {
setExamIDs((prev) => prev.filter((x) => selectedModules.includes(x.module)));
setExamIDs((prev) =>
prev.filter((x) => selectedModules.includes(x.module))
);
}, [selectedModules]);
useEffect(() => {
@@ -117,15 +187,25 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
const toggleModule = (module: 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) => {
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) => {
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 = () => {
@@ -148,7 +228,9 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
autoStart,
})
.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}`);
})
.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 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 />
</Link>
<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">
<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
onClick={!selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
onClick={
!selectedModules.includes("level")
? () => toggleModule("reading")
: undefined
}
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",
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">
<BsBook className="text-white w-7 h-7" />
</div>
<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" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("reading") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
{selectedModules.includes("level") && (
<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
onClick={!selectedModules.includes("level") ? () => toggleModule("listening") : undefined}
onClick={
!selectedModules.includes("level")
? () => toggleModule("listening")
: undefined
}
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",
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">
<BsHeadphones className="text-white w-7 h-7" />
</div>
<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" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("listening") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
{selectedModules.includes("level") && (
<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
onClick={
(!selectedModules.includes("level") && selectedModules.length === 0) || selectedModules.includes("level")
(!selectedModules.includes("level") &&
selectedModules.length === 0) ||
selectedModules.includes("level")
? () => toggleModule("level")
: undefined
}
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",
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">
<BsClipboard className="text-white w-7 h-7" />
</div>
<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" />
)}
{!selectedModules.includes("level") && 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" />}
{!selectedModules.includes("level") &&
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
onClick={!selectedModules.includes("level") ? () => toggleModule("writing") : undefined}
onClick={
!selectedModules.includes("level")
? () => toggleModule("writing")
: undefined
}
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",
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">
<BsPen className="text-white w-7 h-7" />
</div>
<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" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("writing") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
{selectedModules.includes("level") && (
<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
onClick={!selectedModules.includes("level") ? () => toggleModule("speaking") : undefined}
onClick={
!selectedModules.includes("level")
? () => toggleModule("speaking")
: undefined
}
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",
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">
<BsMegaphone className="text-white w-7 h-7" />
</div>
<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" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("speaking") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
{selectedModules.includes("level") && (
<BsXCircle className="text-mti-red-light w-8 h-8" />
)}
{selectedModules.includes("speaking") && (
<BsCheckCircle className="text-mti-purple-light w-8 h-8" />
)}
</div>
</section>
<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
label="Entity"
options={entities.map((e) => ({ value: e.id, label: e.label }))}
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 className="w-full grid -md:grid-cols-1 md:grid-cols-2 gap-8">
<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
className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip z-10",
"transition duration-300 ease-in-out",
"transition duration-300 ease-in-out"
)}
popperClassName="!z-20"
filterTime={(date) => moment(date).isSameOrAfter(new Date())}
@@ -295,12 +451,14 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
/>
</div>
<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
className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip z-10",
"transition duration-300 ease-in-out",
"transition duration-300 ease-in-out"
)}
popperClassName="!z-20"
filterTime={(date) => moment(date).isAfter(startDate)}
@@ -314,13 +472,19 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
{selectedModules.includes("speaking") && (
<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
value={{
value: 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")}
options={[
{ 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">
{selectedModules.map((module) => (
<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
value={{
value: examIDs.find((e) => e.module === module)?.id || null,
label: examIDs.find((e) => e.module === module)?.id || "",
value:
examIDs.find((e) => e.module === module)?.id ||
null,
label:
examIDs.find((e) => e.module === module)?.id || "",
}}
onChange={(value) =>
value
@@ -352,7 +521,9 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
...prev.filter((x) => x.module !== module),
{ id: value.value!, module },
])
: setExamIDs((prev) => prev.filter((x) => x.module !== module))
: setExamIDs((prev) =>
prev.filter((x) => x.module !== module)
)
}
options={exams
.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">
<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">
{classrooms.map((g) => (
<button
key={g.id}
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))) {
setAssignees((prev) => prev.filter((a) => !groupStudentIds.includes(a)));
setAssignees((prev) =>
prev.filter((a) => !groupStudentIds.includes(a))
);
} else {
setAssignees((prev) => [...prev.filter((a) => !groupStudentIds.includes(a)), ...groupStudentIds]);
setAssignees((prev) => [
...prev.filter((a) => !groupStudentIds.includes(a)),
...groupStudentIds,
]);
}
}}
className={clsx(
"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",
users.filter((u) => g.participants.includes(u.id)).every((u) => assignees.includes(u.id)) &&
"!bg-mti-purple-light !text-white",
)}>
users
.filter((u) => g.participants.includes(u.id))
.every((u) => assignees.includes(u.id)) &&
"!bg-mti-purple-light !text-white"
)}
>
{g.name}
</button>
))}
@@ -402,9 +588,12 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
className={clsx(
"p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72",
"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="font-semibold">{user.name}</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" && (
<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">
{classrooms.map((g) => (
<button
key={g.id}
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))) {
setTeachers((prev) => prev.filter((a) => !groupStudentIds.includes(a)));
setTeachers((prev) =>
prev.filter((a) => !groupStudentIds.includes(a))
);
} else {
setTeachers((prev) => [...prev.filter((a) => !groupStudentIds.includes(a)), ...groupStudentIds]);
setTeachers((prev) => [
...prev.filter((a) => !groupStudentIds.includes(a)),
...groupStudentIds,
]);
}
}}
className={clsx(
"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",
users.filter((u) => g.participants.includes(u.id)).every((u) => teachers.includes(u.id)) &&
"!bg-mti-purple-light !text-white",
)}>
users
.filter((u) => g.participants.includes(u.id))
.every((u) => teachers.includes(u.id)) &&
"!bg-mti-purple-light !text-white"
)}
>
{g.name}
</button>
))}
@@ -466,9 +673,12 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
className={clsx(
"p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72",
"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="font-semibold">{user.name}</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">
<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
</Checkbox>
<Checkbox isChecked={generateMultiple} onChange={() => setGenerateMultiple((d) => !d)}>
<Checkbox
isChecked={generateMultiple}
onChange={() => setGenerateMultiple((d) => !d)}
>
Generate different exams
</Checkbox>
<Checkbox isChecked={released} onChange={() => setReleased((d) => !d)}>
<Checkbox
isChecked={released}
onChange={() => setReleased((d) => !d)}
>
Auto release results
</Checkbox>
<Checkbox isChecked={autoStart} onChange={() => setAutostart((d) => !d)}>
<Checkbox
isChecked={autoStart}
onChange={() => setAutostart((d) => !d)}
>
Auto start exam
</Checkbox>
</div>
@@ -506,7 +730,8 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
variant="outline"
onClick={() => router.push("/assignments")}
disabled={isLoading}
isLoading={isLoading}>
isLoading={isLoading}
>
Cancel
</Button>
@@ -522,7 +747,8 @@ export default function AssignmentsPage({ user, users, groups, entities }: Props
}
className="w-full max-w-[200px]"
onClick={createAssignment}
isLoading={isLoading}>
isLoading={isLoading}
>
Create
</Button>
</div>

View File

@@ -28,59 +28,137 @@ import { useMemo } from "react";
import { BsChevronLeft, BsPlus } from "react-icons/bs";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
if (!checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"]))
return redirect("/")
const user = await requestUser(req, res);
if (!user) return redirect("/login");
if (
!checkAccess(user, [
"admin",
"developer",
"corporate",
"teacher",
"mastercorporate",
])
)
return redirect("/");
const isAdmin = checkAccess(user, ["developer", "admin"]);
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 =
await (checkAccess(user, ["developer", "admin"]) ? getUsers() : getEntitiesUsers(mapBy(allowedEntities, 'id')));
const assignments =
await (checkAccess(user, ["developer", "admin"]) ? getAssignments() : getEntitiesAssignments(mapBy(allowedEntities, 'id')));
return { props: serialize({ user, users, entities: allowedEntities, assignments }) };
return {
props: serialize({ user, users, entities: allowedEntities, assignments }),
};
}, sessionOptions);
const SEARCH_FIELDS = [["name"]];
interface Props {
assignments: Assignment[];
entities: EntityWithRoles[]
entities: EntityWithRoles[];
user: User;
users: User[];
}
export default function AssignmentsPage({ assignments, entities, user, users }: Props) {
const entitiesAllowCreate = useAllowedEntities(user, entities, 'create_assignment')
const entitiesAllowEdit = useAllowedEntities(user, entities, 'edit_assignment')
const entitiesAllowArchive = useAllowedEntities(user, entities, 'archive_assignment')
export default function AssignmentsPage({
assignments,
entities,
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 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 activeAssignments = useMemo(
() => assignments.filter(activeAssignmentFilter),
[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 { rows: activeRows, renderSearch: renderActive } = useListSearch(SEARCH_FIELDS, activeAssignments);
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 { rows: activeRows, renderSearch: renderActive } = useListSearch(
SEARCH_FIELDS,
activeAssignments
);
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: 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);
const { items: activeItems, renderMinimal: paginationActive } = usePagination(
activeRows,
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 (
<>
@@ -96,7 +174,10 @@ export default function AssignmentsPage({ assignments, entities, user, users }:
<>
<div className="flex flex-col gap-4">
<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 />
</Link>
<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>
<div className="flex items-center gap-4">
<span>
<b>Total:</b> {activeAssignments.reduce((acc, curr) => acc + curr.results.length, 0)}/
{activeAssignments.reduce((acc, curr) => curr.exams.length + acc, 0)}
<b>Total:</b>{" "}
{activeAssignments.reduce(
(acc, curr) => acc + curr.results.length,
0
)}
/
{activeAssignments.reduce(
(acc, curr) => curr.exams.length + acc,
0
)}
</span>
</div>
</div>
<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">
{renderActive()}
{paginationActive()}
</div>
<div className="flex flex-wrap gap-2">
{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>
</section>
<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">
{renderPlanned()}
{paginationPlanned()}
</div>
<div className="flex flex-wrap gap-2">
<Link
href={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">
href={
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" />
<span className="text-lg">New Assignment</span>
</Link>
@@ -143,9 +245,9 @@ export default function AssignmentsPage({ assignments, entities, user, users }:
<AssignmentCard
{...a}
users={users}
entityObj={findBy(entities, 'id', a.entity)}
entityObj={findBy(entities, "id", a.entity)}
onClick={
mapBy(entitiesAllowEdit, 'id').includes(a.entity || "")
mapBy(entitiesAllowEdit, "id").includes(a.entity || "")
? () => router.push(`/assignments/creator/${a.id}`)
: () => router.push(`/assignments/${a.id}`)
}
@@ -156,7 +258,9 @@ export default function AssignmentsPage({ assignments, entities, user, users }:
</section>
<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">
{renderPast()}
{paginationPast()}
@@ -166,18 +270,22 @@ export default function AssignmentsPage({ assignments, entities, user, users }:
<AssignmentCard
{...a}
users={users}
entityObj={findBy(entities, 'id', a.entity)}
entityObj={findBy(entities, "id", a.entity)}
onClick={() => router.push(`/assignments/${a.id}`)}
key={a.id}
allowDownload
allowArchive={mapBy(entitiesAllowArchive, 'id').includes(a.entity || "")}
allowArchive={mapBy(entitiesAllowArchive, "id").includes(
a.entity || ""
)}
allowExcelDownload
/>
))}
</div>
</section>
<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">
{renderExpired()}
{paginationExpired()}
@@ -187,18 +295,22 @@ export default function AssignmentsPage({ assignments, entities, user, users }:
<AssignmentCard
{...a}
users={users}
entityObj={findBy(entities, 'id', a.entity)}
entityObj={findBy(entities, "id", a.entity)}
onClick={() => router.push(`/assignments/${a.id}`)}
key={a.id}
allowDownload
allowArchive={mapBy(entitiesAllowArchive, 'id').includes(a.entity || "")}
allowArchive={mapBy(entitiesAllowArchive, "id").includes(
a.entity || ""
)}
allowExcelDownload
/>
))}
</div>
</section>
<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">
{renderArchived()}
{paginationArchived()}
@@ -210,7 +322,7 @@ export default function AssignmentsPage({ assignments, entities, user, users }:
users={users}
onClick={() => router.push(`/assignments/${a.id}`)}
key={a.id}
entityObj={findBy(entities, 'id', a.entity)}
entityObj={findBy(entities, "id", a.entity)}
allowDownload
allowUnarchive
allowExcelDownload

View File

@@ -18,47 +18,93 @@ import { getEntityUsers, getSpecificUsers } from "@/utils/users.be";
import axios from "axios";
import clsx from "clsx";
import { withIronSessionSsr } from "iron-session/next";
import { capitalize } from "lodash";
import { capitalize, last } from "lodash";
import moment from "moment";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import { Divider } from "primereact/divider";
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";
export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
export const getServerSideProps = withIronSessionSsr(
async ({ req, res, params }) => {
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 group = await getGroup(id);
if (!group || !group.entity) return redirect("/classrooms")
if (!group || !group.entity) return redirect("/classrooms");
const entity = await getEntityWithRoles(group.entity)
if (!entity) return redirect("/classrooms")
const entity = await getEntityWithRoles(group.entity);
if (!entity) return redirect("/classrooms");
const canView = doesEntityAllow(user, entity, "view_classrooms")
if (!canView) return redirect("/")
const canView = doesEntityAllow(user, entity, "view_classrooms");
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);
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 {
user: User;
group: GroupWithUsers;
users: User[];
entity: EntityWithRoles
entity: EntityWithRoles;
}
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 [selectedUsers, setSelectedUsers] = useState<string[]>([]);
const canAddParticipants = useEntityPermission(user, entity, "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 canAddParticipants = useEntityPermission(
user,
entity,
"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(
() => 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>(
[["name"], ["corporateInformation", "companyInformation", "name"]],
isAdding ? nonParticipantUsers : group.participants,
isAdding ? nonParticipantUsers : group.participants
);
const { items, renderMinimal } = usePagination<User>(rows, 20);
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 = () => {
if (selectedUsers.length === 0) 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;
setIsLoading(true);
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(() => {
toast.success("The group has been updated successfully!");
router.replace(router.asPath);
@@ -110,13 +193,24 @@ export default function Home({ user, group, users, entity }: Props) {
const addParticipants = () => {
if (selectedUsers.length === 0) 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;
setIsLoading(true);
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(() => {
toast.success("The group has been updated successfully!");
router.replace(router.asPath);
@@ -189,7 +283,8 @@ export default function Home({ user, group, users, entity }: Props) {
<div className="flex items-center gap-2">
<Link
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 />
</Link>
<h2 className="font-bold text-2xl">{group.name}</h2>
@@ -200,14 +295,16 @@ export default function Home({ user, group, users, entity }: Props) {
<button
onClick={renameGroup}
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 />
<span className="text-xs">Rename Classroom</span>
</button>
<button
onClick={deleteGroup}
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 />
<span className="text-xs">Delete Classroom</span>
</button>
@@ -219,7 +316,8 @@ export default function Home({ user, group, users, entity }: Props) {
<BsBuilding className="text-xl" /> {entity.label}
</span>
<span className="flex items-center gap-2">
<BsFillPersonVcardFill className="text-xl" /> {getUserName(group.admin)}
<BsFillPersonVcardFill className="text-xl" />{" "}
{getUserName(group.admin)}
</span>
</div>
</div>
@@ -231,14 +329,20 @@ export default function Home({ user, group, users, entity }: Props) {
<button
onClick={() => setIsAdding(true)}
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 />
<span className="text-xs">Add Participants</span>
</button>
<button
onClick={removeParticipants}
disabled={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">
disabled={
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 />
<span className="text-xs">Remove Participants</span>
</button>
@@ -249,14 +353,20 @@ export default function Home({ user, group, users, entity }: Props) {
<button
onClick={() => setIsAdding(false)}
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 />
<span className="text-xs">Discard Selection</span>
</button>
<button
onClick={addParticipants}
disabled={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">
disabled={
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 />
<span className="text-xs">Add Participants</span>
</button>
@@ -268,26 +378,53 @@ export default function Home({ user, group, users, entity }: Props) {
{renderMinimal()}
</div>
<div className="flex items-center gap-2 mt-4">
{['student', 'teacher', 'corporate'].map((type) => (
{["student", "teacher", "corporate"].map((type) => (
<button
key={type}
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))) {
setSelectedUsers((prev) => prev.filter((a) => !typeUsers.includes(a)));
setSelectedUsers((prev) =>
prev.filter((a) => !typeUsers.includes(a))
);
} 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(
"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",
"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(isAdding ? nonParticipantUsers : group.participants, 'type', type).every((u) => selectedUsers.includes(u.id)) &&
"!bg-mti-purple-light !text-white",
)}>
filterBy(
isAdding ? nonParticipantUsers : group.participants,
"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)}
</button>
))}
@@ -298,20 +435,25 @@ export default function Home({ user, group, users, entity }: Props) {
{items.map((u) => (
<button
onClick={() => toggleUser(u)}
disabled={isAdding ? !canAddParticipants : !canRemoveParticipants}
disabled={
isAdding ? !canAddParticipants : !canRemoveParticipants
}
key={u.id}
className={clsx(
"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",
selectedUsers.includes(u.id) && "border-mti-purple",
)}>
selectedUsers.includes(u.id) && "border-mti-purple"
)}
>
<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">
<img src={u.profilePicture} alt={u.name} />
</div>
<div className="flex flex-col">
<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>
@@ -326,13 +468,19 @@ export default function Home({ user, group, users, entity }: Props) {
<Tooltip tooltip="Expiration Date">
<BsStopwatchFill />
</Tooltip>
{u.subscriptionExpirationDate ? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited"}
{u.subscriptionExpirationDate
? moment(u.subscriptionExpirationDate).format(
"DD/MM/YYYY"
)
: "Unlimited"}
</span>
<span className="flex items-center gap-2">
<Tooltip tooltip="Last Login">
<BsClockFill />
</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>
</div>
</button>

View File

@@ -2,44 +2,77 @@
import Input from "@/components/Low/Input";
import Select from "@/components/Low/Select";
import Tooltip from "@/components/Low/Tooltip";
import {useListSearch} from "@/hooks/useListSearch";
import { useListSearch } from "@/hooks/useListSearch";
import usePagination from "@/hooks/usePagination";
import {EntityWithRoles} from "@/interfaces/entity";
import {User} from "@/interfaces/user";
import {sessionOptions} from "@/lib/session";
import {USER_TYPE_LABELS} from "@/resources/user";
import {filterBy, mapBy, redirect, serialize} from "@/utils";
import { getEntitiesWithRoles} from "@/utils/entities.be";
import {shouldRedirectHome} from "@/utils/navigation.disabled";
import {getUserName, isAdmin} from "@/utils/users";
import {getEntitiesUsers} from "@/utils/users.be";
import { EntityWithRoles } from "@/interfaces/entity";
import { User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session";
import { USER_TYPE_LABELS } from "@/resources/user";
import { filterBy, mapBy, redirect, serialize } from "@/utils";
import { getEntitiesWithRoles } from "@/utils/entities.be";
import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { getUserName, isAdmin } from "@/utils/users";
import { getEntitiesUsers } from "@/utils/users.be";
import axios from "axios";
import clsx from "clsx";
import {withIronSessionSsr} from "iron-session/next";
import { withIronSessionSsr } from "iron-session/next";
import moment from "moment";
import Head from "next/head";
import Link from "next/link";
import {useRouter} from "next/router";
import {Divider} from "primereact/divider";
import {useEffect, useMemo, useState} from "react";
import {BsCheck, BsChevronLeft, BsClockFill, BsEnvelopeFill, BsStopwatchFill} from "react-icons/bs";
import {toast, ToastContainer} from "react-toastify";
import { useRouter } from "next/router";
import { Divider } from "primereact/divider";
import { useEffect, useMemo, useState } from "react";
import {
BsCheck,
BsChevronLeft,
BsClockFill,
BsEnvelopeFill,
BsStopwatchFill,
} from "react-icons/bs";
import { toast, ToastContainer } from "react-toastify";
import { requestUser } from "@/utils/api";
import { findAllowedEntities } from "@/utils/permissions";
import { capitalize } from "lodash";
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res);
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 users = await getEntitiesUsers(mapBy(entities, 'id'))
const allowedEntities = findAllowedEntities(user, entities, "create_classroom")
const entities = await getEntitiesWithRoles(
isAdmin(user) ? undefined : mapBy(user.entities, "id")
);
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 {
props: serialize({user, entities: allowedEntities, users: users.filter((x) => x.id !== user.id)}),
props: serialize({
user,
entities: allowedEntities,
users: users,
}),
};
}, sessionOptions);
@@ -49,33 +82,54 @@ interface Props {
entities: EntityWithRoles[];
}
export default function Home({user, users, entities}: Props) {
export default function Home({ user, users, entities }: Props) {
const [isLoading, setIsLoading] = useState(false);
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
const [name, setName] = useState("");
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 {rows, renderSearch} = useListSearch<User>(
[["name"], ["type"], ["corporateInformation", "companyInformation", "name"]], entityUsers
const entityUsers = useMemo(
() =>
!entity
? users
: users.filter((u) => mapBy(u.entities, "id").includes(entity)),
[entity, users]
);
const {items, renderMinimal} = usePagination<User>(rows, 16);
const { rows, renderSearch } = useListSearch<User>(
[
["name"],
["type"],
["corporateInformation", "companyInformation", "name"],
],
entityUsers
);
const { items, renderMinimal } = usePagination<User>(rows, 16);
const router = useRouter();
useEffect(() => setSelectedUsers([]), [entity])
useEffect(() => setSelectedUsers([]), [entity]);
const createGroup = () => {
if (!name.trim()) 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);
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) => {
toast.success("Your group has been created successfully!");
router.replace(`/classrooms/${result.data.id}`);
@@ -87,7 +141,10 @@ export default function Home({user, users, entities}: Props) {
.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 (
<>
@@ -107,7 +164,8 @@ export default function Home({user, users, entities}: Props) {
<div className="flex items-center gap-2">
<Link
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 />
</Link>
<h2 className="font-bold text-2xl">Create Classroom</h2>
@@ -116,7 +174,8 @@ export default function Home({user, users, entities}: Props) {
<button
onClick={createGroup}
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 />
<span className="text-xs">Create Classroom</span>
</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="flex flex-col gap-4 w-full">
<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 className="flex flex-col gap-4 w-full">
<span className="font-semibold text-xl">Entity:</span>
<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)}
defaultValue={{value: entities[0]?.id, label: entities[0]?.label}}
defaultValue={{
value: entities[0]?.id,
label: entities[0]?.label,
}}
/>
</div>
</div>
<Divider />
<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 className="w-full flex items-center gap-4">
{renderSearch()}
{renderMinimal()}
</div>
<div className="flex items-center gap-2 mt-4">
{['student', 'teacher', 'corporate'].map((type) => (
{["student", "teacher", "corporate"].map((type) => (
<button
key={type}
onClick={() => {
const typeUsers = mapBy(filterBy(entityUsers, 'type', type), 'id')
const typeUsers = mapBy(
filterBy(entityUsers, "type", type),
"id"
);
if (typeUsers.every((u) => selectedUsers.includes(u))) {
setSelectedUsers((prev) => prev.filter((a) => !typeUsers.includes(a)));
setSelectedUsers((prev) =>
prev.filter((a) => !typeUsers.includes(a))
);
} 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(
"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",
"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).every((u) => selectedUsers.includes(u.id)) &&
"!bg-mti-purple-light !text-white",
)}>
filterBy(entityUsers, "type", type).length > 0 &&
filterBy(entityUsers, "type", type).every((u) =>
selectedUsers.includes(u.id)
) &&
"!bg-mti-purple-light !text-white"
)}
>
{capitalize(type)}
</button>
))}
@@ -181,15 +261,18 @@ export default function Home({user, users, entities}: Props) {
className={clsx(
"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",
selectedUsers.includes(u.id) && "border-mti-purple",
)}>
selectedUsers.includes(u.id) && "border-mti-purple"
)}
>
<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">
<img src={u.profilePicture} alt={u.name} />
</div>
<div className="flex flex-col">
<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>
@@ -204,13 +287,17 @@ export default function Home({user, users, entities}: Props) {
<Tooltip tooltip="Expiration Date">
<BsStopwatchFill />
</Tooltip>
{u.subscriptionExpirationDate ? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited"}
{u.subscriptionExpirationDate
? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY")
: "Unlimited"}
</span>
<span className="flex items-center gap-2">
<Tooltip tooltip="Last Login">
<BsClockFill />
</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>
</div>
</button>

View File

@@ -27,22 +27,41 @@ import StudentClassroomTransfer from "@/components/Imports/StudentClassroomTrans
import Modal from "@/components/Modal";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res)
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 entityIDS = mapBy(user.entities, "id");
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS)
const allowedEntities = findAllowedEntities(user, entities, "view_classrooms")
const entities = await getEntitiesWithRoles(
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 groupsWithUsers: GroupWithUsers[] = groups.map((g) => convertToUsers(g, users.filter(x => isAdmin(user) ? true : !isAdmin(x))));
const groups = await getGroupsForEntities(mapBy(allowedEntities, "id"));
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 {
props: serialize({ user, groups: groupsWithUsers, entities: allowedEntities }),
props: serialize({
user,
groups: groupsWithUsers,
entities: allowedEntities,
}),
};
}, sessionOptions);
@@ -59,39 +78,60 @@ const SEARCH_FIELDS = [
interface Props {
user: User;
groups: GroupWithUsers[];
entities: EntityWithRoles[]
entities: EntityWithRoles[];
}
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 renderCard = (group: GroupWithUsers) => (
<Link
href={`/classrooms/${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">
<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}
</span>
<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)}
</span>
{!!group.entity && (
<span className="flex items-center gap-1">
<span className="bg-mti-purple text-white font-semibold px-2">Entity</span>
{findBy(entities, 'id', group.entity)?.label}
<span className="bg-mti-purple text-white font-semibold px-2">
Entity
</span>
{findBy(entities, "id", group.entity)?.label}
</span>
)}
<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-light/50 px-2">{group.participants.length}</span>
<span className="bg-mti-purple text-white font-semibold px-2">
Participants
</span>
<span className="bg-mti-purple-light/50 px-2">
{group.participants.length}
</span>
</span>
<span>
{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.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>
) : (
""
)}
</span>
</div>
<div className="w-fit">
@@ -103,7 +143,8 @@ export default function Home({ user, groups, entities }: Props) {
const firstCard = () => (
<Link
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} />
<span className="font-semibold">Create Classroom</span>
</Link>
@@ -123,24 +164,33 @@ export default function Home({ user, groups, entities }: Props) {
<ToastContainer />
<>
<section className="flex flex-col gap-4 w-full h-full">
<Modal isOpen={showImport} onClose={() => setShowImport(false)} maxWidth="max-w-[85%]">
<StudentClassroomTransfer user={user} entities={entities} onFinish={() => setShowImport(false)} />
<Modal
isOpen={showImport}
onClose={() => setShowImport(false)}
maxWidth="max-w-[85%]"
>
<StudentClassroomTransfer
user={user}
entities={entities}
onFinish={() => setShowImport(false)}
/>
</Modal>
<div className="flex flex-col gap-4">
<div className="flex justify-between">
<h2 className="font-bold text-2xl">Classrooms</h2>
{entitiesAllowCreate.length !== 0 && <button
{entitiesAllowCreate.length !== 0 && (
<button
className={clsx(
"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",
"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)}
>
<FaFileUpload className="w-5 h-5" />
Transfer Students
</button>
}
)}
</div>
<Separator />
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,6 +27,7 @@ import { requestUser } from "@/utils/api";
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import { getEntitiesUsers } from "@/utils/users.be";
import { isAdmin } from "@/utils/users";
import { useMemo } from "react";
interface Props {
user: User;
@@ -52,7 +53,8 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const filteredEntities = findAllowedEntities(user, entities, "view_students");
const students = await getEntitiesUsers(
const [students, assignments, groups] = await Promise.all([
getEntitiesUsers(
mapBy(filteredEntities, "id"),
{
type: "student",
@@ -67,14 +69,13 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
levels: 1,
registrationDate: 1,
}
);
const assignments = await getEntitiesAssignments(entityIDS);
),
getEntitiesAssignments(entityIDS),
getGroupsByEntities(entityIDS),
]);
const stats = await getStatsByUsers(students.map((u) => u.id));
const groups = await getGroupsByEntities(entityIDS);
return {
props: serialize({ user, students, entities, assignments, stats, groups }),
};
@@ -100,6 +101,10 @@ export default function Dashboard({
entities,
"view_student_performance"
);
const entitiesLabels = useMemo(
() => mapBy(entities, "label")?.join(", "),
[entities]
);
return (
<>
@@ -117,7 +122,7 @@ export default function Dashboard({
<div className="w-full flex flex-col gap-4">
{entities.length > 0 && (
<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>
)}
<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 { doesEntityAllow } from "@/utils/permissions";
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 axios from "axios";
import clsx from "clsx";
@@ -42,14 +47,18 @@ import {
BsX,
} from "react-icons/bs";
import { toast } from "react-toastify";
import entities from "../../api/entities";
const expirationDateColor = (date: Date) => {
const momentDate = moment(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(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";
if (today.add(1, "days").isAfter(momentDate))
return "!bg-mti-red-ultralight border-mti-red-light";
if (today.add(3, "days").isAfter(momentDate))
return "!bg-mti-rose-ultralight border-mti-rose-light";
if (today.add(7, "days").isAfter(momentDate))
return "!bg-mti-orange-ultralight border-mti-orange-light";
};
const CURRENCIES_OPTIONS = CURRENCIES.map(({ label, currency }) => ({
@@ -57,26 +66,62 @@ const CURRENCIES_OPTIONS = CURRENCIES.map(({ label, currency }) => ({
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;
if (!user) return redirect("/login")
if (shouldRedirectHome(user)) return redirect("/")
if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/");
const { id } = params as { id: string };
const entity = await getEntityWithRoles(id);
if (!entity) return redirect("/entities")
if (!entity) return redirect("/entities");
if (!doesEntityAllow(user, entity, "view_entities")) return redirect(`/entities`)
const linkedUsers = await (isAdmin(user) ? getUsers() : getEntitiesUsers(mapBy(user.entities, 'id'),
{ $and: [{ type: { $ne: "developer" } }, { type: { $ne: "admin" } }] }))
const entityUsers = await (isAdmin(user) ? getEntityUsers(id) : filterAllowedUsers(user, [entity]));
if (!doesEntityAllow(user, entity, "view_entities"))
return redirect(`/entities`);
const [linkedUsers, entityUsers] = await Promise.all([
isAdmin(user)
? 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 e = u.entities.find((e) => e.id === id);
return { ...u, role: findBy(entity.roles, 'id', e?.role) };
const e = u?.entities?.find((e) => e.id === id);
return { ...u, role: findBy(entity.roles, "id", e?.role) };
});
return {
@@ -84,10 +129,14 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, params }) =>
user,
entity,
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 };
@@ -102,34 +151,52 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
const [isAdding, setIsAdding] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
const [expiryDate, setExpiryDate] = useState(entity?.expiryDate)
const [paymentPrice, setPaymentPrice] = useState(entity?.payment?.price)
const [paymentCurrency, setPaymentCurrency] = useState(entity?.payment?.currency)
const [expiryDate, setExpiryDate] = useState(entity?.expiryDate);
const [paymentPrice, setPaymentPrice] = useState(entity?.payment?.price);
const [paymentCurrency, setPaymentCurrency] = useState(
entity?.payment?.currency
);
const router = useRouter();
const canRenameEntity = useEntityPermission(user, entity, "rename_entity")
const canViewRoles = useEntityPermission(user, entity, "view_entity_roles")
const canDeleteEntity = useEntityPermission(user, entity, "delete_entity")
const canRenameEntity = useEntityPermission(user, entity, "rename_entity");
const canViewRoles = useEntityPermission(user, entity, "view_entity_roles");
const canDeleteEntity = useEntityPermission(user, entity, "delete_entity");
const canAddMembers = useEntityPermission(user, entity, "add_to_entity")
const canRemoveMembers = useEntityPermission(user, entity, "remove_from_entity")
const canAddMembers = useEntityPermission(user, entity, "add_to_entity");
const canRemoveMembers = useEntityPermission(
user,
entity,
"remove_from_entity"
);
const canAssignRole = useEntityPermission(user, entity, "assign_to_role")
const canPay = useEntityPermission(user, entity, 'pay_entity')
const canAssignRole = useEntityPermission(user, entity, "assign_to_role");
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 = () => {
if (selectedUsers.length === 0) 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;
setIsLoading(true);
axios
.patch(`/api/entities/${entity.id}/users`, { add: false, members: selectedUsers })
.patch(`/api/entities/${entity.id}/users`, {
add: false,
members: selectedUsers,
})
.then(() => {
toast.success("The entity has been updated successfully!");
router.replace(router.asPath);
@@ -145,13 +212,24 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
const addParticipants = () => {
if (selectedUsers.length === 0) 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);
const defaultRole = findBy(entity.roles, 'isDefault', true)!
const defaultRole = findBy(entity.roles, "isDefault", true)!;
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(() => {
toast.success("The entity has been updated successfully!");
router.replace(router.asPath);
@@ -206,7 +284,9 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
setIsLoading(true);
axios
.patch(`/api/entities/${entity.id}`, { payment: { price: paymentPrice, currency: paymentCurrency } })
.patch(`/api/entities/${entity.id}`, {
payment: { price: paymentPrice, currency: paymentCurrency },
})
.then(() => {
toast.success("The entity has been updated successfully!");
router.replace(router.asPath);
@@ -221,9 +301,13 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
const editLicenses = () => {
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 (!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);
axios
@@ -259,8 +343,8 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
};
const assignUsersToRole = (role: string) => {
if (!canAssignRole) return
if (selectedUsers.length === 0) return
if (!canAssignRole) return;
if (selectedUsers.length === 0) return;
setIsLoading(true);
axios
@@ -274,7 +358,7 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
}
};
const renderCard = (u: UserWithRole) => {
return (
@@ -285,8 +369,9 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
className={clsx(
"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",
selectedUsers.includes(u.id) && "border-mti-purple",
)}>
selectedUsers.includes(u.id) && "border-mti-purple"
)}
>
<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">
<img src={u.profilePicture} alt={u.name} />
@@ -311,13 +396,17 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<Tooltip tooltip="Expiration Date">
<BsStopwatchFill />
</Tooltip>
{u.subscriptionExpirationDate ? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited"}
{u.subscriptionExpirationDate
? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY")
: "Unlimited"}
</span>
<span className="flex items-center gap-2">
<Tooltip tooltip="Last Login">
<BsClockFill />
</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>
</div>
</button>
@@ -345,10 +434,14 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<div className="flex items-center gap-2">
<Link
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 />
</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>
{!isAdmin(user) && canPay && (
@@ -357,11 +450,15 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
className={clsx(
"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",
!entity.expiryDate ? "!bg-mti-green-ultralight !border-mti-green-light" : expirationDateColor(entity.expiryDate),
"bg-white border-mti-gray-platinum",
)}>
!entity.expiryDate
? "!bg-mti-green-ultralight !border-mti-green-light"
: expirationDateColor(entity.expiryDate),
"bg-white border-mti-gray-platinum"
)}
>
{!entity.expiryDate && "Unlimited"}
{entity.expiryDate && moment(entity.expiryDate).format("DD/MM/YYYY")}
{entity.expiryDate &&
moment(entity.expiryDate).format("DD/MM/YYYY")}
</Link>
)}
</div>
@@ -369,7 +466,8 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<button
onClick={renameGroup}
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 />
<span className="text-xs">Rename Entity</span>
</button>
@@ -377,7 +475,8 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<button
onClick={editLicenses}
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 />
<span className="text-xs">Edit Licenses</span>
</button>
@@ -385,14 +484,16 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<button
onClick={() => router.push(`/entities/${entity.id}/roles`)}
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 />
<span className="text-xs">Edit Roles</span>
</button>
<button
onClick={deleteGroup}
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 />
<span className="text-xs">Delete Entity</span>
</button>
@@ -410,8 +511,10 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
className={clsx(
"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",
!expiryDate ? "!bg-mti-green-ultralight !border-mti-green-light" : expirationDateColor(expiryDate),
"transition duration-300 ease-in-out",
!expiryDate
? "!bg-mti-green-ultralight !border-mti-green-light"
: expirationDateColor(expiryDate),
"transition duration-300 ease-in-out"
)}
filterDate={(date) => moment(date).isAfter(new Date())}
dateFormat="dd/MM/yyyy"
@@ -425,8 +528,10 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
className={clsx(
"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",
!expiryDate ? "!bg-mti-green-ultralight !border-mti-green-light" : expirationDateColor(expiryDate),
"bg-white border-mti-gray-platinum",
!expiryDate
? "!bg-mti-green-ultralight !border-mti-green-light"
: expirationDateColor(expiryDate),
"bg-white border-mti-gray-platinum"
)}
>
Unlimited
@@ -435,17 +540,21 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<Checkbox
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
</Checkbox>
</div>
<button
onClick={updateExpiryDate}
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 />
<span className="text-xs">Apply Change</span>
</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">
<Input
name="paymentValue"
onChange={(e) => setPaymentPrice(e ? parseInt(e) : undefined)}
onChange={(e) =>
setPaymentPrice(e ? parseInt(e) : undefined)
}
type="number"
defaultValue={entity.payment?.price || 0}
thin
/>
<Select
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}
value={CURRENCIES_OPTIONS.find((c) => c.value === paymentCurrency)}
onChange={(value) => setPaymentCurrency(value?.value ?? undefined)}
value={CURRENCIES_OPTIONS.find(
(c) => c.value === paymentCurrency
)}
onChange={(value) =>
setPaymentCurrency(value?.value ?? undefined)
}
/>
</div>
<button
onClick={updatePayment}
disabled={!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">
disabled={
!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 />
<span className="text-xs">Apply Change</span>
</button>
@@ -485,28 +603,40 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<Divider />
<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 && (
<div className="flex items-center gap-2">
<button
onClick={() => setIsAdding(true)}
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 />
<span className="text-xs">Add Members</span>
</button>
<Menu>
<MenuButton
disabled={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">
disabled={
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 />
<span className="text-xs">Assign Role</span>
</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) => (
<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}
</button>
</MenuItem>
@@ -516,8 +646,11 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<button
onClick={removeParticipants}
disabled={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">
disabled={
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 />
<span className="text-xs">Remove Members</span>
</button>
@@ -528,16 +661,22 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<button
onClick={() => setIsAdding(false)}
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 />
<span className="text-xs">Discard Selection</span>
</button>
<button
onClick={addParticipants}
disabled={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">
disabled={
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 />
<span className="text-xs">Add Members ({selectedUsers.length})</span>
<span className="text-xs">
Add Members ({selectedUsers.length})
</span>
</button>
</div>
)}
@@ -546,7 +685,13 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<CardList<User | UserWithRole>
list={isAdding ? linkedUsers : users}
renderCard={renderCard}
searchFields={[["name"], ["email"], ["corporateInformation", "companyInformation", "name"], ["role", "label"], ["type"]]}
searchFields={[
["name"],
["email"],
["corporateInformation", "companyInformation", "name"],
["role", "label"],
["type"],
]}
/>
</section>
</>

View File

@@ -20,21 +20,41 @@ import Link from "next/link";
import { useRouter } from "next/router";
import { Divider } from "primereact/divider";
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 { requestUser } from "@/utils/api";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
const user = await requestUser(req, res);
if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/")
if (!["admin", "developer"].includes(user.type)) return redirect("/entities")
if (shouldRedirectHome(user)) return redirect("/");
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 {
props: serialize({ user, users: users.filter((x) => x.id !== user.id) }),
props: serialize({ user, users }),
};
}, sessionOptions);
@@ -49,19 +69,31 @@ export default function Home({ user, users }: Props) {
const [label, setLabel] = useState("");
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 router = useRouter();
const createGroup = () => {
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);
axios
.post<Entity>(`/api/entities`, { label, licenses, members: selectedUsers })
.post<Entity>(`/api/entities`, {
label,
licenses,
members: selectedUsers,
})
.then((result) => {
toast.success("Your entity has been created successfully!");
router.replace(`/entities/${result.data.id}`);
@@ -73,7 +105,10 @@ export default function Home({ user, users }: Props) {
.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 (
<>
@@ -93,7 +128,8 @@ export default function Home({ user, users }: Props) {
<div className="flex items-center gap-2">
<Link
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 />
</Link>
<h2 className="font-bold text-2xl">Create Entity</h2>
@@ -102,7 +138,8 @@ export default function Home({ user, users }: Props) {
<button
onClick={createGroup}
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 />
<span className="text-xs">Create Entity</span>
</button>
@@ -112,17 +149,30 @@ export default function Home({ user, users }: Props) {
<div className="w-full grid grid-cols-2 gap-4">
<div className="flex flex-col gap-4 w-full">
<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 className="flex flex-col gap-4 w-full">
<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>
<Divider />
<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 className="w-full flex items-center gap-4">
{renderSearch()}
@@ -139,15 +189,18 @@ export default function Home({ user, users }: Props) {
className={clsx(
"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",
selectedUsers.includes(u.id) && "border-mti-purple",
)}>
selectedUsers.includes(u.id) && "border-mti-purple"
)}
>
<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">
<img src={u.profilePicture} alt={u.name} />
</div>
<div className="flex flex-col">
<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>
@@ -162,13 +215,17 @@ export default function Home({ user, users }: Props) {
<Tooltip tooltip="Expiration Date">
<BsStopwatchFill />
</Tooltip>
{u.subscriptionExpirationDate ? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited"}
{u.subscriptionExpirationDate
? moment(u.subscriptionExpirationDate).format("DD/MM/YYYY")
: "Unlimited"}
</span>
<span className="flex items-center gap-2">
<Tooltip tooltip="Last Login">
<BsClockFill />
</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>
</div>
</button>

View File

@@ -35,17 +35,35 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
);
const allowedEntities = findAllowedEntities(user, entities, "view_entities");
const entitiesWithCount = await Promise.all(
allowedEntities.map(async (e) => ({
entity: e,
count: await countEntityUsers(e.id, {
const [counts, users] = await Promise.all([
await Promise.all(
allowedEntities.map(async (e) =>
countEntityUsers(e.id, {
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 {
props: serialize({ user, entities: entitiesWithCount }),

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import Button from "@/components/Low/Button";
import Separator from "@/components/Low/Separator";
import ProfileSummary from "@/components/ProfileSummary";
import { Session } from "@/hooks/useSessions";
import { Grading } from "@/interfaces";
import { Grading, Module } from "@/interfaces";
import { EntityWithRoles } from "@/interfaces/entity";
import { Exam } from "@/interfaces/exam";
import { InviteWithEntity } from "@/interfaces/invite";
@@ -12,14 +12,13 @@ import { Assignment } from "@/interfaces/results";
import { Stat, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session";
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 {
activeAssignmentFilter,
futureAssignmentFilter,
} from "@/utils/assignments";
import { getAssignmentsByAssignee } from "@/utils/assignments.be";
import { getEntitiesWithRoles } from "@/utils/entities.be";
import { getExamsByIds } from "@/utils/exams.be";
import { sortByModule } from "@/utils/moduleUtils";
import { checkAccess } from "@/utils/permissions";
@@ -53,32 +52,59 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
if (!checkAccess(user, ["admin", "developer", "student"]))
return redirect("/");
const entityIDS = mapBy(user.entities, "id") || [];
const entities = await getEntitiesWithRoles(entityIDS);
const assignments = await getAssignmentsByAssignee(user.id, {
const assignments = (await getAssignmentsByAssignee(
user.id,
{
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") },
});
},
{
_id: 0,
id: 1,
assignment: 1,
}
);
const examIDs = uniqBy(
assignments.flatMap((a) =>
filterBy(a.exams, "assignee", user.id).map(
(e: any) => ({
assignments.reduce<{ module: Module; id: string; key: string }[]>(
(acc, a) => {
a.exams.forEach((e) => {
if (e.assignee === user.id)
acc.push({
module: e.module,
id: e.id,
key: `${e.module}_${e.id}`,
})
)
});
});
return acc;
},
[]
),
"key"
);
const exams = await getExamsByIds(examIDs);
return { props: serialize({ user, entities, assignments, exams, sessions }) };
return { props: serialize({ user, assignments, exams, sessions }) };
}, sessionOptions);
const destination = Buffer.from("/official-exam").toString("base64");
@@ -109,11 +135,12 @@ export default function OfficialExam({
});
if (assignmentExams.every((x) => !!x)) {
const sortedAssignmentExams = assignmentExams.sort(sortByModule);
dispatch({
type: "INIT_EXAM",
payload: {
exams: assignmentExams.sort(sortByModule),
modules: mapBy(assignmentExams.sort(sortByModule), "module"),
exams: sortedAssignmentExams,
modules: mapBy(sortedAssignmentExams, "module"),
assignment,
},
});
@@ -144,12 +171,16 @@ export default function OfficialExam({
[assignments]
);
const assignmentSessions = useMemo(
() =>
sessions.filter((s) =>
mapBy(studentAssignments, "id").includes(s.assignment?.id || "")
),
[sessions, studentAssignments]
const assignmentSessions = useMemo(() => {
const studentAssignmentsIDs = mapBy(studentAssignments, "id");
return sessions.filter((s) =>
studentAssignmentsIDs.includes(s.assignment?.id || "")
);
}, [sessions, studentAssignments]);
const entityLabels = useMemo(
() => mapBy(entities, "label")?.join(","),
[entities]
);
return (
@@ -167,7 +198,7 @@ export default function OfficialExam({
<>
{entities.length > 0 && (
<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>
)}
@@ -191,9 +222,7 @@ export default function OfficialExam({
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
{studentAssignments.length === 0 &&
"Assignments will appear here. It seems that for now there are no assignments for you."}
{studentAssignments
.sort((a, b) => moment(a.startDate).diff(b.startDate))
.map((a) => (
{studentAssignments.map((a) => (
<AssignmentCard
key={a.id}
assignment={a}

View File

@@ -31,7 +31,7 @@ import { useListSearch } from "@/hooks/useListSearch";
import { checkAccess, findAllowedEntities, getTypesOfUser } from "@/utils/permissions";
import { requestUser } from "@/utils/api";
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 { Entity, EntityWithRoles } from "@/interfaces/entity";

View File

@@ -21,11 +21,12 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
if (!user) return redirect("/login")
const entityIDs = mapBy(user.entities, 'id')
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDs)
const domain = user.email.split("@").pop()
const discounts = await db.collection<Discount>("discounts").find({ domain }).toArray()
const packages = await db.collection<Package>("packages").find().toArray()
const [entities, discounts, packages] = await Promise.all([
getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDs),
db.collection<Discount>("discounts").find({ domain }).toArray(),
db.collection<Package>("packages").find().toArray(),
])
return {
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 { requestUser } from "@/utils/api";
import { redirect } from "@/utils";
import { G } from "@react-pdf/renderer";
interface BasicUser {
id: string;
name: string;
@@ -40,31 +41,25 @@ export const getServerSideProps = withIronSessionSsr(
if (!params?.id) return redirect("/permissions");
// Fetch data from external API
const permission: Permission = await getPermissionDoc(params.id as string);
const allUserData: User[] = await getUsers();
const groups = await getGroups();
const [permission, users, groups] = await Promise.all([
getPermissionDoc(params.id as string),
getUsers({}, 0, {}, { _id: 0, id: 1, name: 1, type: 1 }),
getGroups(),
]);
const userGroups = groups.filter((x) => x.admin === user.id);
const userGroupsParticipants = userGroups.flatMap((x) => x.participants);
const filteredGroups =
user.type === "corporate"
? userGroups
: user.type === "mastercorporate"
? groups.filter((x) =>
userGroups.flatMap((y) => y.participants).includes(x.admin)
)
? groups.filter((x) => userGroupsParticipants.includes(x.admin))
: groups;
const users = allUserData.map((u) => ({
id: u.id,
name: u.name,
type: u.type,
})) as BasicUser[];
const filteredGroupsParticipants = filteredGroups.flatMap(
(g) => g.participants
);
const filteredUsers = ["mastercorporate", "corporate"].includes(user.type)
? users.filter((u) =>
filteredGroups.flatMap((g) => g.participants).includes(u.id)
)
? users.filter((u) => filteredGroupsParticipants.includes(u.id))
: users;
// const res = await fetch("api/permissions");
@@ -158,12 +153,14 @@ export default function Page(props: Props) {
<div className="flex gap-3">
<Select
value={null}
options={users
.filter((u) => !selectedUsers.includes(u.id))
.map((u) => ({
label: `${u?.type}-${u?.name}`,
value: u.id,
}))}
options={users.reduce<{ label: string; value: string }[]>(
(acc, u) => {
if (!selectedUsers.includes(u.id))
acc.push({ label: `${u?.type}-${u?.name}`, value: u.id });
return acc;
},
[]
)}
onChange={onChange}
/>
<Button onClick={update}>Update</Button>
@@ -195,9 +192,8 @@ export default function Page(props: Props) {
<div className="flex flex-col gap-3">
<h2>Whitelisted Users</h2>
<div className="flex flex-col gap-3 flex-wrap">
{users
.filter((user) => !selectedUsers.includes(user.id))
.map((user) => {
{users.map((user) => {
if (!selectedUsers.includes(user.id))
return (
<div
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>
</div>
);
return null;
})}
</div>
</div>

View File

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

View File

@@ -23,7 +23,9 @@ import { getAssignments, getEntitiesAssignments } from "@/utils/assignments.be";
import { findBy, mapBy, redirect, serialize } from "@/utils";
import { getEntitiesWithRoles } from "@/utils/entities.be";
import { checkAccess } from "@/utils/permissions";
import { getGradingSystemByEntities, getGradingSystemByEntity } from "@/utils/grading.be";
import {
getGradingSystemByEntities,
} from "@/utils/grading.be";
import { Grading } from "@/interfaces";
import { EntityWithRoles } from "@/interfaces/entity";
import CardList from "@/components/High/CardList";
@@ -33,23 +35,34 @@ import getPendingEvals from "@/utils/disabled.be";
import useEvaluationPolling from "@/hooks/useEvaluationPolling";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res)
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 entityIDs = mapBy(user.entities, 'id')
const isAdmin = checkAccess(user, ["admin", "developer"])
const entityIDs = mapBy(user.entities, "id");
const isAdmin = checkAccess(user, ["admin", "developer"]);
const entities = await getEntitiesWithRoles(checkAccess(user, ["admin", "developer"]) ? undefined : entityIDs)
const entitiesIds = mapBy(entities, 'id')
const users = await (isAdmin ? getUsers() : getEntitiesUsers(entitiesIds))
const assignments = await (isAdmin ? getAssignments() : getEntitiesAssignments(entitiesIds))
const gradingSystems = await getGradingSystemByEntities(entitiesIds)
const pendingSessionIds = await getPendingEvals(user.id);
const entities = await getEntitiesWithRoles(isAdmin ? undefined : entityIDs);
const entitiesIds = mapBy(entities, "id");
const [users, assignments, gradingSystems, pendingSessionIds] =
await Promise.all([
isAdmin ? getUsers() : getEntitiesUsers(entitiesIds),
isAdmin ? getAssignments() : getEntitiesAssignments(entitiesIds),
getGradingSystemByEntities(entitiesIds),
getPendingEvals(user.id),
]);
return {
props: serialize({ user, users, assignments, entities, gradingSystems, isAdmin, pendingSessionIds }),
props: serialize({
user,
users,
assignments,
entities,
gradingSystems,
isAdmin,
pendingSessionIds,
}),
};
}, sessionOptions);
@@ -59,46 +72,67 @@ interface Props {
user: User;
users: User[];
assignments: Assignment[];
entities: EntityWithRoles[]
gradingSystems: Grading[]
entities: EntityWithRoles[];
gradingSystems: Grading[];
pendingSessionIds: string[];
isAdmin:boolean
isAdmin: boolean;
}
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 [statsUserId, setStatsUserId, training, setTraining] = useRecordStore((state) => [
const [statsUserId, setStatsUserId, training, setTraining] = useRecordStore(
(state) => [
state.selectedUser,
state.setSelectedUser,
state.training,
state.setTraining,
]);
]
);
const [filter, setFilter] = useState<Filter>();
const { data: stats, isLoading: isStatsLoading } = useFilterRecordsByUser<Stat[]>(statsUserId || user?.id);
const allowedDownloadEntities = useAllowedEntities(user, entities, 'download_student_record')
const { data: stats, isLoading: isStatsLoading } = useFilterRecordsByUser<
Stat[]
>(statsUserId || user?.id);
const allowedDownloadEntities = useAllowedEntities(
user,
entities,
"download_student_record"
);
const renderPdfIcon = usePDFDownload("stats");
const [selectedTrainingExams, setSelectedTrainingExams] = useState<string[]>([]);
const [selectedTrainingExams, setSelectedTrainingExams] = useState<string[]>(
[]
);
const setTrainingStats = useTrainingContentStore((state) => state.setStats);
const groupedStats = useMemo(() => groupByDate(
const groupedStats = useMemo(
() =>
groupByDate(
stats.filter((x) => {
if (
(
x.module === "writing" || x.module === "speaking") &&
!x.isDisabled && Array.isArray(x.solutions) &&
!x.solutions.every((y) => Object.keys(y).includes("evaluation")
)
(x.module === "writing" || x.module === "speaking") &&
!x.isDisabled &&
Array.isArray(x.solutions) &&
!x.solutions.every((y) => Object.keys(y).includes("evaluation"))
)
return false;
return true;
}),
), [stats])
})
),
[stats]
);
useEffect(() => setStatsUserId(user.id), [setStatsUserId, user]);
@@ -120,7 +154,8 @@ export default function History({ user, users, assignments, entities, gradingSys
const filteredStats: { [key: string]: Stat[] } = {};
Object.keys(stats).forEach((timestamp) => {
if (timestamp >= filterDate) filteredStats[timestamp] = stats[timestamp];
if (timestamp >= filterDate)
filteredStats[timestamp] = stats[timestamp];
});
return filteredStats;
}
@@ -129,8 +164,14 @@ export default function History({ user, users, assignments, entities, gradingSys
const filteredStats: { [key: string]: Stat[] } = {};
Object.keys(stats).forEach((timestamp) => {
if (stats[timestamp].map((s) => s.assignment === undefined).includes(false))
filteredStats[timestamp] = [...stats[timestamp].filter((s) => !!s.assignment)];
if (
stats[timestamp]
.map((s) => s.assignment === undefined)
.includes(false)
)
filteredStats[timestamp] = [
...stats[timestamp].filter((s) => !!s.assignment),
];
});
return filteredStats;
@@ -143,9 +184,14 @@ export default function History({ user, users, assignments, entities, gradingSys
if (groupedStats) {
const groupedStatsByDate = filterStatsByDate(groupedStats);
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];
if (allStats.includes(timestamp) && !accumulator.hasOwnProperty(timestamp)) {
if (
allStats.includes(timestamp) &&
!accumulator.hasOwnProperty(timestamp)
) {
accumulator[timestamp] = groupedStatsByDate[timestamp];
}
return accumulator;
@@ -155,17 +201,22 @@ export default function History({ user, users, assignments, entities, gradingSys
}
};
const filteredStats = useMemo(() =>
Object.keys(filterStatsByDate(groupedStats))
.sort((a, b) => parseInt(b) - parseInt(a)),
const filteredStats = useMemo(
() =>
Object.keys(filterStatsByDate(groupedStats)).sort(
(a, b) => parseInt(b) - parseInt(a)
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[groupedStats, filter])
[groupedStats, filter]
);
const customContent = (timestamp: string) => {
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 (
<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 (
<>
@@ -201,7 +256,12 @@ export default function History({ user, users, assignments, entities, gradingSys
<ToastContainer />
{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 && (
<div className="flex flex-row">
<div className="font-semibold text-2xl mr-4">
@@ -211,19 +271,25 @@ export default function History({ user, users, assignments, entities, gradingSys
<button
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",
"transition duration-300 ease-in-out",
"transition duration-300 ease-in-out"
)}
disabled={selectedTrainingExams.length == 0}
onClick={handleTrainingContentSubmission}>
onClick={handleTrainingContentSubmission}
>
Submit
</button>
</div>
)}
</RecordFilter>
{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 && (
<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 Modal from "@/components/Modal";
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 CorporateGradingSystem from "./(admin)/CorporateGradingSystem";
import { CEFR_STEPS } from "@/resources/grading";
@@ -26,26 +32,55 @@ import { mapBy, serialize, redirect } from "@/utils";
import { EntityWithRoles } from "@/interfaces/entity";
import { requestUser } from "@/utils/api";
import { isAdmin } from "@/utils/users";
import { getGradingSystemByEntities, getGradingSystemByEntity } from "@/utils/grading.be";
import {
getGradingSystemByEntities,
getGradingSystemByEntity,
} from "@/utils/grading.be";
import { Grading } from "@/interfaces";
import { useRouter } from "next/router";
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
const user = await requestUser(req, res);
if (!user) return redirect("/login");
if (shouldRedirectHome(user) || !checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"]))
return redirect("/")
const permissions = await getUserPermissions(user.id);
const entities = isAdmin(user) ? await getEntitiesWithRoles() : await getEntitiesWithRoles(mapBy(user.entities, 'id'))
const allUsers = await 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 })
if (
shouldRedirectHome(user) ||
!checkAccess(user, [
"admin",
"developer",
"corporate",
"teacher",
"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 {
props: serialize({ user, permissions, entities, allUsers, entitiesGrading }),
props: serialize({
user,
permissions,
entities,
allUsers,
entitiesGrading,
}),
};
}, sessionOptions);
@@ -53,19 +88,45 @@ interface Props {
user: User;
permissions: PermissionType[];
entities: EntityWithRoles[];
allUsers: User[]
entitiesGrading: Grading[]
allUsers: User[];
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 router = useRouter()
const router = useRouter();
const entitiesAllowCreateUser = useAllowedEntities(user, entities, 'create_user')
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')
const entitiesAllowCreateUser = useAllowedEntities(
user,
entities,
"create_user"
);
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 (
<>
@@ -80,7 +141,11 @@ export default function Admin({ user, entities, permissions, allUsers, entitiesG
</Head>
<ToastContainer />
<>
<Modal isOpen={modalOpen === "batchCreateUser"} onClose={() => setModalOpen(undefined)} maxWidth="max-w-[85%]">
<Modal
isOpen={modalOpen === "batchCreateUser"}
onClose={() => setModalOpen(undefined)}
maxWidth="max-w-[85%]"
>
<BatchCreateUser
user={user}
entities={entitiesAllowCreateUser}
@@ -88,7 +153,10 @@ export default function Admin({ user, entities, permissions, allUsers, entitiesG
onFinish={() => setModalOpen(undefined)}
/>
</Modal>
<Modal isOpen={modalOpen === "batchCreateCode"} onClose={() => setModalOpen(undefined)}>
<Modal
isOpen={modalOpen === "batchCreateCode"}
onClose={() => setModalOpen(undefined)}
>
<BatchCodeGenerator
entities={entitiesAllowCreateCodes}
user={user}
@@ -97,7 +165,10 @@ export default function Admin({ user, entities, permissions, allUsers, entitiesG
onFinish={() => setModalOpen(undefined)}
/>
</Modal>
<Modal isOpen={modalOpen === "createCode"} onClose={() => setModalOpen(undefined)}>
<Modal
isOpen={modalOpen === "createCode"}
onClose={() => setModalOpen(undefined)}
>
<CodeGenerator
entities={entitiesAllowCreateCode}
user={user}
@@ -105,7 +176,10 @@ export default function Admin({ user, entities, permissions, allUsers, entitiesG
onFinish={() => setModalOpen(undefined)}
/>
</Modal>
<Modal isOpen={modalOpen === "createUser"} onClose={() => setModalOpen(undefined)}>
<Modal
isOpen={modalOpen === "createUser"}
onClose={() => setModalOpen(undefined)}
>
<UserCreator
user={user}
entities={entitiesAllowCreateUsers}
@@ -114,7 +188,10 @@ export default function Admin({ user, entities, permissions, allUsers, entitiesG
onFinish={() => setModalOpen(undefined)}
/>
</Modal>
<Modal isOpen={modalOpen === "gradingSystem"} onClose={() => setModalOpen(undefined)}>
<Modal
isOpen={modalOpen === "gradingSystem"}
onClose={() => setModalOpen(undefined)}
>
<CorporateGradingSystem
user={user}
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">
<ExamLoader />
{checkAccess(user, getTypesOfUser(["teacher"]), permissions, "viewCodes") && (
{checkAccess(
user,
getTypesOfUser(["teacher"]),
permissions,
"viewCodes"
) && (
<div className="w-full grid grid-cols-2 gap-4">
<IconCard
Icon={BsCode}
@@ -159,7 +241,12 @@ export default function Admin({ user, entities, permissions, allUsers, entitiesG
onClick={() => setModalOpen("batchCreateUser")}
disabled={entitiesAllowCreateUsers.length === 0}
/>
{checkAccess(user, ["admin", "corporate", "developer", "mastercorporate"]) && (
{checkAccess(user, [
"admin",
"corporate",
"developer",
"mastercorporate",
]) && (
<IconCard
Icon={BsGearFill}
label="Grading System"

View File

@@ -28,67 +28,104 @@ import Head from "next/head";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import ReactDatePicker from "react-datepicker";
import {
BsBank,
BsChevronLeft,
BsX,
} from "react-icons/bs";
import { BsBank, BsChevronLeft, BsX } from "react-icons/bs";
interface Props {
user: User;
students: StudentUser[];
entities: EntityWithRoles[];
assignments: Assignment[];
sessions: Session[]
exams: Exam[]
sessions: Session[];
exams: Exam[];
}
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
const user = await requestUser(req, res);
if (!user) return redirect("/login");
const entityIDS = mapBy(user.entities, "id") || [];
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS);
const allowedEntities = findAllowedEntities(user, entities, 'view_entity_statistics')
const entities = await getEntitiesWithRoles(
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 students = await getEntitiesUsers(mapBy(studentsAllowedEntities, 'id'), { type: "student" })
const studentsAllowedEntities = findAllowedEntities(
user,
entities,
"view_students"
);
const assignments = await getEntitiesAssignments(mapBy(entities, "id"));
const sessions = await getSessionsByAssignments(mapBy(assignments, 'id'))
const exams = await getExamsByIds(assignments.flatMap(a => a.exams))
const [students, assignments] = await Promise.all([
getEntitiesUsers(mapBy(studentsAllowedEntities, "id"), { type: "student" }),
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);
interface Item {
student: StudentUser
result?: AssignmentResult
assignment: Assignment
exams: Exam[]
entity: Entity
session?: Session
student: StudentUser;
result?: AssignmentResult;
assignment: Assignment;
exams: Exam[];
entity: Entity;
session?: Session;
}
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 [endDate, setEndDate] = useState<Date | null>(moment().add(1, 'month').toDate());
const [selectedEntities, setSelectedEntities] = useState<string[]>([])
const [isDownloading, setIsDownloading] = useState(false)
const [endDate, setEndDate] = useState<Date | null>(
moment().add(1, "month").toDate()
);
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 orderedAssignments = orderBy(assignments, ['startDate'], ['asc'])
setStartDate(moment(orderedAssignments.shift()?.startDate || "2024-01-01T00:00:01.986Z").toDate())
setEndDate(moment().add(1, 'month').toDate())
}
const orderedAssignments = orderBy(assignments, ["startDate"], ["asc"]);
setStartDate(
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 [start, end] = dates;
@@ -96,75 +133,134 @@ export default function Statistical({ user, students, entities, assignments, ses
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 entityAssignments = filterBy(assignments, 'entity', entityID)
const total = entityAssignments.reduce((acc, curr) => acc + curr.assignees.length, 0)
const results = entityAssignments.reduce((acc, curr) => acc + curr.results.length, 0)
const entityAssignments = filterBy(assignments, "entity", entityID);
const total = entityAssignments.reduce(
(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 total = assignments.reduce((acc, curr) => acc + curr.assignees.length, 0)
const results = assignments.reduce((acc, curr) => acc + curr.results.length, 0)
const total = assignments.reduce(
(acc, curr) => acc + curr.assignees.length,
0
);
const results = assignments.reduce(
(acc, curr) => acc + curr.results.length,
0
);
return { results, total }
}, [assignments])
return { results, total };
}, [assignments]);
const filteredAssignments = useMemo(() => {
if (!startDate && !endDate) return assignments
const startDateFiltered = startDate ? assignments.filter(a => moment(a.startDate).isSameOrAfter(moment(startDate))) : assignments
return endDate ? startDateFiltered.filter(a => moment(a.endDate).isSameOrBefore(moment(endDate))) : startDateFiltered
}, [startDate, endDate, assignments])
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]
if (!startDate && !endDate) return assignments;
const startDateFiltered = startDate
? assignments.filter((a) =>
moment(a.startDate).isSameOrAfter(moment(startDate))
)
: 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 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
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
);
return bTotalScore - aTotalScore
}), [data])
if (!student) return undefined;
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 () => {
setIsDownloading(true)
setIsDownloading(true);
const request = await axios.post("/api/statistical", {
entities: entities.filter(e => selectedEntities.includes(e.id)),
const request = await axios.post(
"/api/statistical",
{
entities: entities.filter((e) => selectedEntities.includes(e.id)),
items: data,
assignments: filteredAssignments,
startDate,
endDate
}, {
responseType: 'blob'
})
endDate,
},
{
responseType: "blob",
}
);
const href = URL.createObjectURL(request.data)
const link = document.createElement('a');
const href = URL.createObjectURL(request.data);
const link = document.createElement("a");
link.href = href;
link.setAttribute('download', `statistical_${new Date().toISOString()}.xlsx`);
link.setAttribute(
"download",
`statistical_${new Date().toISOString()}.xlsx`
);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(href);
setIsDownloading(false)
}
setIsDownloading(false);
};
const columns = [
columnHelper.accessor("student.name", {
@@ -194,19 +290,26 @@ export default function Statistical({ user, students, entities, assignments, ses
columnHelper.accessor("result", {
header: "Progress",
cell: (info) => {
const student = info.row.original.student
const session = info.row.original.session
const student = info.row.original.student;
const session = info.row.original.session;
if (!student.lastLogin) return <span className="text-mti-red-dark">Never logged in</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>
if (!student.lastLogin)
return <span className="text-mti-red-dark">Never logged in</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">
{capitalize(session.exam?.module || "")} Module, Part {session.partIndex + 1}, Exercise {session.exerciseIndex + 1}
return (
<span className="font-semibold">
{capitalize(session.exam?.module || "")} Module, Part{" "}
{session.partIndex + 1}, Exercise {session.exerciseIndex + 1}
</span>
);
},
})
]
}),
];
return (
<>
@@ -224,13 +327,18 @@ export default function Statistical({ user, students, entities, assignments, ses
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between w-full">
<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 />
</Link>
<h2 className="font-bold text-2xl">Statistical</h2>
</div>
<Checkbox
onChange={value => setSelectedEntities(value ? mapBy(entities, 'id') : [])}
onChange={(value) =>
setSelectedEntities(value ? mapBy(entities, "id") : [])
}
isChecked={selectedEntities.length === entities.length}
>
Select All
@@ -241,13 +349,14 @@ export default function Statistical({ user, students, entities, assignments, ses
<section className="flex flex-col gap-3">
<div className="w-full flex items-center justify-between gap-4 flex-wrap">
{entities.map(entity => (
{entities.map((entity) => (
<button
onClick={() => toggleEntity(entity.id)}
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",
"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}
>
@@ -268,7 +377,7 @@ export default function Statistical({ user, students, entities, assignments, ses
className={clsx(
"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",
"transition duration-300 ease-in-out",
"transition duration-300 ease-in-out"
)}
dateFormat="dd/MM/yyyy"
selectsRange
@@ -278,13 +387,17 @@ export default function Statistical({ user, students, entities, assignments, ses
endDate={endDate}
/>
{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} />
</button>
)}
</div>
<span className="font-semibold text-lg pr-1">
Total: {totalAssignmentResolution.results} / {totalAssignmentResolution.total}
Total: {totalAssignmentResolution.results} /{" "}
{totalAssignmentResolution.total}
</span>
</div>
</section>
@@ -293,13 +406,21 @@ export default function Statistical({ user, students, entities, assignments, ses
<Table
columns={columns}
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..."
onDownload={entitiesAllowDownload.length > 0 ? downloadExcel : undefined}
onDownload={
entitiesAllowDownload.length > 0 ? downloadExcel : undefined
}
isDownloadLoading={isDownloading}
/>
)}
</>
</>
)
);
}

View File

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

View File

@@ -1,57 +1,74 @@
/* eslint-disable @next/next/no-img-element */
import Head from "next/head";
import {withIronSessionSsr} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {User} from "@/interfaces/user";
import {ToastContainer} from "react-toastify";
import {shouldRedirectHome} from "@/utils/navigation.disabled";
import {useEffect, useState} from "react";
import { withIronSessionSsr } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { User } from "@/interfaces/user";
import { ToastContainer } from "react-toastify";
import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { useEffect, useState } from "react";
import clsx from "clsx";
import {FaPlus} from "react-icons/fa";
import { FaPlus } from "react-icons/fa";
import useRecordStore from "@/stores/recordStore";
import router from "next/router";
import useTrainingContentStore from "@/stores/trainingContentStore";
import axios from "axios";
import {ITrainingContent} from "@/training/TrainingInterfaces";
import { ITrainingContent } from "@/training/TrainingInterfaces";
import moment from "moment";
import {uuidv4} from "@firebase/util";
import { uuidv4 } from "@firebase/util";
import TrainingScore from "@/training/TrainingScore";
import ModuleBadge from "@/components/ModuleBadge";
import RecordFilter from "@/components/Medium/RecordFilter";
import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser";
import { mapBy, redirect, serialize } from "@/utils";
import { getEntitiesWithRoles } from "@/utils/entities.be";
import { getEntitiesUsers } from "@/utils/users.be";
import { EntityWithRoles } from "@/interfaces/entity";
import { requestUser } from "@/utils/api";
import { checkAccess } from "../../utils/permissions";
export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res);
if (!user) return redirect("/login");
if (shouldRedirectHome(user)) return redirect("/")
const entityIDs = mapBy(user.entities, 'id')
const entities = await getEntitiesWithRoles(entityIDs)
const users = await getEntitiesUsers(entityIDs)
if (shouldRedirectHome(user)) return redirect("/");
const isAdmin = checkAccess(user, ["admin", "developer"]);
const entityIDs = mapBy(user.entities, "id");
const users = await getEntitiesUsers(entityIDs);
return {
props: serialize({user, users, entities}),
props: serialize({ user, users, isAdmin }),
};
}, sessionOptions);
const Training: React.FC<{user: User, entities: EntityWithRoles[], users: User[] }> = ({user, entities, users}) => {
const [recordUserId, setRecordTraining] = useRecordStore((state) => [state.selectedUser, state.setTraining]);
const [filter, setFilter] = useState<"months" | "weeks" | "days" | "assignments">();
const Training: React.FC<{
user: User;
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 [isNewContentLoading, setIsNewContentLoading] = useState(stats.length != 0);
const [groupedByTrainingContent, setGroupedByTrainingContent] = useState<{[key: string]: ITrainingContent}>();
const [stats, setTrainingStats] = useTrainingContentStore((state) => [
state.stats,
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,
undefined,
"training",
"training"
);
useEffect(() => {
@@ -68,7 +85,10 @@ const Training: React.FC<{user: User, entities: EntityWithRoles[], users: User[]
useEffect(() => {
const postStats = async () => {
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;
} catch (error) {
setIsNewContentLoading(false);
@@ -91,15 +111,18 @@ const Training: React.FC<{user: User, entities: EntityWithRoles[], users: User[]
router.push("/record");
};
const filterTrainingContentByDate = (trainingContent: {[key: string]: ITrainingContent}) => {
const filterTrainingContentByDate = (trainingContent: {
[key: string]: ITrainingContent;
}) => {
if (filter) {
const filterDate = moment()
.subtract({[filter as string]: 1})
.subtract({ [filter as string]: 1 })
.format("x");
const filteredTrainingContent: {[key: string]: ITrainingContent} = {};
const filteredTrainingContent: { [key: string]: ITrainingContent } = {};
Object.keys(trainingContent).forEach((timestamp) => {
if (timestamp >= filterDate) filteredTrainingContent[timestamp] = trainingContent[timestamp];
if (timestamp >= filterDate)
filteredTrainingContent[timestamp] = trainingContent[timestamp];
});
return filteredTrainingContent;
}
@@ -111,7 +134,7 @@ const Training: React.FC<{user: User, entities: EntityWithRoles[], users: User[]
const grouped = trainingContent.reduce((acc, content) => {
acc[content.created_at] = content;
return acc;
}, {} as {[key: number]: ITrainingContent});
}, {} as { [key: number]: ITrainingContent });
setGroupedByTrainingContent(grouped);
} else {
@@ -133,18 +156,22 @@ const Training: React.FC<{user: User, entities: EntityWithRoles[], users: User[]
const trainingContentContainer = (timestamp: string) => {
if (!groupedByTrainingContent) return <></>;
const trainingContent: ITrainingContent = groupedByTrainingContent[timestamp];
const uniqueModules = [...new Set(trainingContent.exams.map((exam) => exam.module))];
const trainingContent: ITrainingContent =
groupedByTrainingContent[timestamp];
const uniqueModules = [
...new Set(trainingContent.exams.map((exam) => exam.module)),
];
return (
<>
<div
key={uuidv4()}
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)}
role="button">
role="button"
>
<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">
<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">
<span className="loading loading-infinity w-32 bg-mti-green-light" />
{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>
) : (
<>
<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" && (
<>
<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
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",
"transition duration-300 ease-in-out",
"transition duration-300 ease-in-out"
)}
onClick={handleNewTrainingContent}>
onClick={handleNewTrainingContent}
>
<FaPlus />
</button>
</div>
@@ -205,12 +243,18 @@ const Training: React.FC<{user: User, entities: EntityWithRoles[], users: User[]
</RecordFilter>
{trainingContent.length == 0 && (
<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>
)}
{!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">
{Object.keys(filterTrainingContentByDate(groupedByTrainingContent))
{Object.keys(
filterTrainingContentByDate(groupedByTrainingContent)
)
.sort((a, b) => parseInt(b) - parseInt(a))
.map(trainingContentContainer)}
</div>

View File

@@ -17,21 +17,26 @@ import { requestUser } from "@/utils/api";
import { redirect } from "@/utils";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
const user = await requestUser(req, res);
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 allowedEntities = findAllowedEntities(user, entities, "view_student_performance")
const entities = await getEntitiesWithRoles(
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'])
? getUsers({ type: 'student' })
: getEntitiesUsers(mapBy(allowedEntities, 'id'), { type: 'student' })
)
const groups = await getParticipantsGroups(mapBy(students, 'id'))
const students = await (checkAccess(user, ["admin", "developer"])
? getUsers({ type: "student" })
: getEntitiesUsers(mapBy(allowedEntities, "id"), { type: "student" }));
const groups = await getParticipantsGroups(mapBy(students, "id"));
return {
props: serialize({ user, students, entities, groups }),
@@ -40,9 +45,9 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
interface Props {
user: User;
students: StudentUser[]
entities: Entity[]
groups: Group[]
students: StudentUser[];
entities: Entity[];
groups: Group[];
}
const StudentPerformance = ({ user, students, entities, groups }: Props) => {
@@ -53,7 +58,10 @@ const StudentPerformance = ({ user, students, entities, groups }: Props) => {
const performanceStudents = students.map((u) => ({
...u,
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 (
@@ -73,12 +81,15 @@ const StudentPerformance = ({ user, students, entities, groups }: Props) => {
<div className="flex items-center gap-2">
<button
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 />
</button>
<h2 className="font-bold text-2xl">Student Performance ({students.length})</h2>
<h2 className="font-bold text-2xl">
Student Performance ({students.length})
</h2>
</div>
<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();
};
export const getAssignments = async () => {
return await db.collection("assignments").find<Assignment>({}).toArray();
export const getAssignments = async (projection = {}) => {
return await db.collection("assignments").find<Assignment>({}, { projection }).toArray();
};
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> => {
const entity = await getEntity(id);
if (!entity) return undefined;
const roles = await getRolesByEntity(id);
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 { 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 { Module } from "@/interfaces";
import { getCorporateUser } from "@/resources/user";
import { getUserCorporate } from "./groups.be";
import { Db, ObjectId } from "mongodb";
import { Db } from "mongodb";
import client from "@/lib/mongodb";
import { MODULE_ARRAY } from "./moduleUtils";
import { mapBy } from ".";

View File

@@ -3,10 +3,10 @@ import client from "@/lib/mongodb";
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
.collection("sessions")
.find<Session>({ user: id, ...filter })
.find<Session>({ user: id, ...filter }, { projection })
.limit(limit || 0)
.toArray();

View File

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