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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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