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:
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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's Gender</label>
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Speaking Instructor'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>
|
||||
|
||||
@@ -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's Gender</label>
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
Speaking Instructor'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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 +
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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 ".";
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user