Merged in ENCOA-131_MasterStatistical (pull request #91)

ENCOA-131 MasterStatistical

Approved-by: Tiago Ribeiro
This commit is contained in:
João Ramos
2024-09-06 08:53:07 +00:00
committed by Tiago Ribeiro
8 changed files with 405 additions and 100 deletions

View File

@@ -1,22 +1,24 @@
import React from "react"; import React from "react";
import { CorporateUser, User } from "@/interfaces/user"; import { CorporateUser, User } from "@/interfaces/user";
import { BsBank, BsPersonFill } from "react-icons/bs"; import { BsFileExcel, BsBank, BsPersonFill } from "react-icons/bs";
import IconCard from "./IconCard"; import IconCard from "./IconCard";
import useAssignmentsCorporates from "@/hooks/useAssignmentCorporates"; import useAssignmentsCorporates from "@/hooks/useAssignmentCorporates";
import ReactDatePicker from "react-datepicker"; import ReactDatePicker from "react-datepicker";
import moment from "moment"; import moment from "moment";
import { Assignment, AssignmentWithCorporateId } from "@/interfaces/results"; import { AssignmentWithCorporateId } from "@/interfaces/results";
import { import {
CellContext,
createColumnHelper,
flexRender, flexRender,
createColumnHelper,
getCoreRowModel, getCoreRowModel,
HeaderGroup,
Table,
useReactTable, useReactTable,
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import Checkbox from "@/components/Low/Checkbox"; import Checkbox from "@/components/Low/Checkbox";
import { useListSearch } from "@/hooks/useListSearch";
import axios from "axios";
import { toast } from "react-toastify";
import Button from "@/components/Low/Button";
interface Props { interface Props {
corporateUsers: User[]; corporateUsers: User[];
users: User[]; users: User[];
@@ -24,6 +26,7 @@ interface Props {
interface TableData { interface TableData {
user: string; user: string;
email: string;
correct: number; correct: number;
corporate: string; corporate: string;
submitted: boolean; submitted: boolean;
@@ -37,6 +40,8 @@ interface UserCount {
maxUserCount: number; maxUserCount: number;
} }
const searchFilters = [["email"], ["user"], ["userId"]];
const MasterStatistical = (props: Props) => { const MasterStatistical = (props: Props) => {
const { users, corporateUsers } = props; const { users, corporateUsers } = props;
@@ -66,16 +71,20 @@ const MasterStatistical = (props: Props) => {
endDate, endDate,
}); });
const [downloading, setDownloading] = React.useState<boolean>(false);
const tableResults = React.useMemo( const tableResults = React.useMemo(
() => () =>
assignments.reduce((accmA: TableData[], a: AssignmentWithCorporateId) => { assignments.reduce((accmA: TableData[], a: AssignmentWithCorporateId) => {
const userResults = a.assignees.map((assignee) => { const userResults = a.assignees.map((assignee) => {
const userStats = const userStats =
a.results.find((r) => r.user === assignee)?.stats || []; a.results.find((r) => r.user === assignee)?.stats || [];
const userName = users.find((u) => u.id === assignee)?.name || ""; const userData = users.find((u) => u.id === assignee);
const corporate = users.find((u) => u.id === a.assigner)?.name || ""; const corporate = users.find((u) => u.id === a.assigner)?.name || "";
const commonData = { const commonData = {
user: userName, user: userData?.name || "",
email: userData?.email || "",
userId: assignee,
corporateId: a.corporateId, corporateId: a.corporateId,
corporate, corporate,
assignment: a.name, assignment: a.name,
@@ -146,6 +155,13 @@ const MasterStatistical = (props: Props) => {
return <span>{info.getValue()}</span>; return <span>{info.getValue()}</span>;
}, },
}), }),
columnHelper.accessor("email", {
header: "Email",
id: "email",
cell: (info) => {
return <span>{info.getValue()}</span>;
},
}),
columnHelper.accessor("corporate", { columnHelper.accessor("corporate", {
header: "Corporate", header: "Corporate",
id: "corporate", id: "corporate",
@@ -192,8 +208,14 @@ const MasterStatistical = (props: Props) => {
}), }),
]; ];
const {
rows: filteredRows,
renderSearch,
text: searchText,
} = useListSearch(searchFilters, tableResults);
const table = useReactTable({ const table = useReactTable({
data: tableResults, data: filteredRows,
columns: defaultColumns, columns: defaultColumns,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
}); });
@@ -220,6 +242,32 @@ const MasterStatistical = (props: Props) => {
); );
}; };
const triggerDownload = async () => {
try {
setDownloading(true);
const res = await axios.post("/api/assignments/statistical/excel", {
ids: selectedCorporates,
...(startDate ? { startDate: startDate.toISOString() } : {}),
...(endDate ? { endDate: endDate.toISOString() } : {}),
searchText,
});
toast.success("Report ready!");
const link = document.createElement("a");
link.href = res.data;
// download should have worked but there are some CORS issues
// https://firebase.google.com/docs/storage/web/download-files#cors_configuration
// link.download="report.pdf";
link.target = "_blank";
link.rel = "noreferrer";
link.click();
setDownloading(false);
} catch (err) {
toast.error("Failed to display the report!");
console.error(err);
setDownloading(false);
}
};
const consolidateResults = getStudentsConsolidateScore(); const consolidateResults = getStudentsConsolidateScore();
return ( return (
<> <>
@@ -261,8 +309,11 @@ const MasterStatistical = (props: Props) => {
); );
})} })}
</div> </div>
<div className="flex flex-col gap-3 w-full"> <div className="flex gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Date</label> <div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">
Date
</label>
<ReactDatePicker <ReactDatePicker
dateFormat="dd/MM/yyyy" dateFormat="dd/MM/yyyy"
className="px-4 py-6 w-52 text-sm text-center font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed rounded-full border border-mti-gray-platinum focus:outline-none" className="px-4 py-6 w-52 text-sm text-center font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed rounded-full border border-mti-gray-platinum focus:outline-none"
@@ -283,6 +334,18 @@ const MasterStatistical = (props: Props) => {
}} }}
/> />
</div> </div>
{renderSearch()}
<div className="flex flex-col gap-3 justify-end">
<Button
className="max-w-[200px] h-[70px]"
variant="outline"
onClick={triggerDownload}
>
Download
</Button>
</div>
</div>
<div> <div>
<table className="rounded-xl h-full bg-mti-purple-ultralight/40 w-full"> <table className="rounded-xl h-full bg-mti-purple-ultralight/40 w-full">
<thead> <thead>

View File

@@ -1,18 +1,6 @@
import {useState, useMemo} from "react"; import {useState, useMemo} from "react";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import { search } from "@/utils/search";
/*fields example = [
['id'],
['companyInformation', 'companyInformation', 'name']
]*/
const getFieldValue = (fields: string[], data: any): string => {
if (fields.length === 0) return data;
const [key, ...otherFields] = fields;
if (data[key]) return getFieldValue(otherFields, data[key]);
return data;
};
export function useListSearch<T>(fields: string[][], rows: T[]) { export function useListSearch<T>(fields: string[][], rows: T[]) {
const [text, setText] = useState(""); const [text, setText] = useState("");
@@ -20,22 +8,11 @@ export function useListSearch<T>(fields: string[][], rows: T[]) {
const renderSearch = () => <Input label="Search" type="text" name="search" onChange={setText} placeholder="Enter search text" value={text} />; const renderSearch = () => <Input label="Search" type="text" name="search" onChange={setText} placeholder="Enter search text" value={text} />;
const updatedRows = useMemo(() => { const updatedRows = useMemo(() => {
const searchText = text.toLowerCase(); return search(text, fields, rows);
return rows.filter((row) => {
return fields.some((fieldsKeys) => {
const value = getFieldValue(fieldsKeys, row);
if (typeof value === "string") {
return value.toLowerCase().includes(searchText);
}
if (typeof value === "number") {
return (value as Number).toString().includes(searchText);
}
});
});
}, [fields, rows, text]); }, [fields, rows, text]);
return { return {
text,
rows: updatedRows, rows: updatedRows,
renderSearch, renderSearch,
}; };

View File

@@ -2,8 +2,7 @@
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {getAllAssignersByCorporate} from "@/utils/groups.be"; import {getAssignmentsByAssigner, getAssignmentsForCorporates} from "@/utils/assignments.be";
import {getAssignmentsByAssigners} from "@/utils/assignments.be";
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -30,34 +29,8 @@ async function GET(req: NextApiRequest, res: NextApiResponse) {
try { try {
const idsList = ids.split(","); const idsList = ids.split(",");
const assigners = await Promise.all( const assignments = await getAssignmentsForCorporates(idsList, startDateParsed, endDateParsed);
idsList.map(async (id) => { res.status(200).json(assignments);
const assigners = await getAllAssignersByCorporate(id);
return {
corporateId: id,
assigners,
};
}),
);
const assignments = await Promise.all(
assigners.map(async (data) => {
try {
const assigners = [...new Set([...data.assigners, data.corporateId])];
const assignments = await getAssignmentsByAssigners(assigners, startDateParsed, endDateParsed);
return assignments.map((assignment) => ({
...assignment,
corporateId: data.corporateId,
}));
} catch (err) {
console.error(err);
return [];
}
}),
);
// const assignments = await getAssignmentsByAssigners(assignmentList, startDateParsed, endDateParsed);
res.status(200).json(assignments.flat());
} catch (err: any) { } catch (err: any) {
res.status(500).json({error: err.message}); res.status(500).json({error: err.message});
} }

View File

@@ -0,0 +1,219 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { app, storage } from "@/firebase";
import { getFirestore } from "firebase/firestore";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { ref, uploadBytes, getDownloadURL } from "firebase/storage";
import { AssignmentWithCorporateId } from "@/interfaces/results";
import moment from "moment-timezone";
import ExcelJS from "exceljs";
import { getSpecificUsers } from "@/utils/users.be";
import { checkAccess } from "@/utils/permissions";
import { getAssignmentsForCorporates } from "@/utils/assignments.be";
import { search } from "@/utils/search";
import { getGradingSystem } from "@/utils/grading.be";
import { Exam } from "@/interfaces/exam";
import { User } from "@/interfaces/user";
import { calculateBandScore, getGradingLabel } from "@/utils/score";
import { Module } from "@/interfaces";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
interface TableData {
user: string;
email: string;
correct: number;
corporate: string;
submitted: boolean;
date: moment.Moment;
assignment: string;
corporateId: string;
level: string;
}
async function handler(req: NextApiRequest, res: NextApiResponse) {
// if (req.method === "GET") return get(req, res);
if (req.method === "POST") return await post(req, res);
}
const searchFilters = [["email"], ["user"], ["userId"]];
async function post(req: NextApiRequest, res: NextApiResponse) {
// verify if it's a logged user that is trying to export
if (req.session.user) {
if (
!checkAccess(req.session.user, ["mastercorporate", "developer", "admin"])
) {
return res.status(401).json({ error: "Unauthorized" });
}
const { ids, startDate, endDate, searchText } = req.body as {
ids: string[];
startDate?: string;
endDate?: string;
searchText: string;
};
const startDateParsed = startDate ? new Date(startDate) : undefined;
const endDateParsed = endDate ? new Date(endDate) : undefined;
const assignments = await getAssignmentsForCorporates(
ids,
startDateParsed,
endDateParsed
);
const assignmentUsers = [
...new Set(assignments.flatMap((a) => a.assignees)),
];
const assigners = [...new Set(assignments.map((a) => a.assigner))];
const users = await getSpecificUsers(assignmentUsers);
const assignerUsers = await getSpecificUsers(assigners);
const assignerUsersGradingSystems = await Promise.all(
assignerUsers.map(async (user: User) => {
const data = await getGradingSystem(user);
// in this context I need to override as I'll have to match to the assigner
return { ...data, user: user.id };
})
);
const getGradingSystemHelper = (
exams: {id: string; module: Module; assignee: string}[],
assigner: string,
user: User,
correct: number,
total: number
) => {
if (exams.some((e) => e.module === "level")) {
const gradingSystem = assignerUsersGradingSystems.find(
(gs) => gs.user === assigner
);
if (gradingSystem) {
const bandScore = calculateBandScore(
correct,
total,
"level",
user.focus
);
return getGradingLabel(bandScore, gradingSystem?.steps || []);
}
}
return "N/A";
};
const tableResults = assignments.reduce(
(accmA: TableData[], a: AssignmentWithCorporateId) => {
const userResults = a.assignees.map((assignee) => {
const userStats =
a.results.find((r) => r.user === assignee)?.stats || [];
const userData = users.find((u) => u.id === assignee);
const corporateUser = users.find((u) => u.id === a.assigner);
const correct = userStats.reduce((n, e) => n + e.score.correct, 0);
const total = userStats.reduce((n, e) => n + e.score.total, 0);
const level = getGradingSystemHelper(
a.exams,
a.assigner,
userData!,
correct,
total
);
console.log("Level", level);
const commonData = {
user: userData?.name || "",
email: userData?.email || "",
userId: assignee,
corporateId: a.corporateId,
corporate: corporateUser?.name || "",
assignment: a.name,
level,
};
if (userStats.length === 0) {
return {
...commonData,
correct: 0,
submitted: false,
// date: moment(),
};
}
return {
...commonData,
correct,
submitted: true,
date: moment.max(userStats.map((e) => moment(e.date))),
};
}) as TableData[];
return [...accmA, ...userResults];
},
[]
);
// Create a new workbook and add a worksheet
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet("Master Statistical");
const headers = [
{
label: "User",
value: (entry: TableData) => entry.user,
},
{
label: "Email",
value: (entry: TableData) => entry.email,
},
{
label: "Corporate",
value: (entry: TableData) => entry.corporate,
},
{
label: "Assignment",
value: (entry: TableData) => entry.assignment,
},
{
label: "Submitted",
value: (entry: TableData) => (entry.submitted ? "Yes" : "No"),
},
{
label: "Correct",
value: (entry: TableData) => entry.correct,
},
{
label: "Date",
value: (entry: TableData) => entry.date?.format("YYYY/MM/DD") || "",
},
{
label: "Level",
value: (entry: TableData) => entry.level,
}
];
const filteredSearch = searchText
? search(searchText, searchFilters, tableResults)
: tableResults;
worksheet.addRow(headers.map((h) => h.label));
(filteredSearch as TableData[]).forEach((entry) => {
worksheet.addRow(headers.map((h) => h.value(entry)));
});
// Convert workbook to Buffer (Node.js) or Blob (Browser)
const buffer = await workbook.xlsx.writeBuffer();
// generate the file ref for storage
const fileName = `${Date.now().toString()}.xlsx`;
const refName = `statistical/${fileName}`;
const fileRef = ref(storage, refName);
// upload the pdf to storage
const snapshot = await uploadBytes(fileRef, buffer, {
contentType:
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
const url = await getDownloadURL(fileRef);
res.status(200).end(url);
return;
}
return res.status(401).json({ error: "Unauthorized" });
}

View File

@@ -15,6 +15,7 @@ import {Grading} from "@/interfaces";
import {getGroupsForUser} from "@/utils/groups.be"; import {getGroupsForUser} from "@/utils/groups.be";
import {uniq} from "lodash"; import {uniq} from "lodash";
import {getUser} from "@/utils/users.be"; import {getUser} from "@/utils/users.be";
import { getGradingSystem } from "@/utils/grading.be";
const db = getFirestore(app); const db = getFirestore(app);
@@ -31,19 +32,8 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
return; return;
} }
const snapshot = await getDoc(doc(db, "grading", req.session.user.id)); const gradingSystem = await getGradingSystem(req.session.user);
if (snapshot.exists()) return res.status(200).json(snapshot.data()); return res.status(200).json(gradingSystem);
if (req.session.user.type !== "teacher" && req.session.user.type !== "student")
return res.status(200).json({steps: CEFR_STEPS, user: req.session.user.id});
const corporate = await getUserCorporate(req.session.user.id);
if (!corporate) return res.status(200).json(CEFR_STEPS);
const corporateSnapshot = await getDoc(doc(db, "grading", corporate.id));
if (corporateSnapshot.exists()) return res.status(200).json(snapshot.data());
return res.status(200).json({steps: CEFR_STEPS, user: req.session.user.id});
} }
async function post(req: NextApiRequest, res: NextApiResponse) { async function post(req: NextApiRequest, res: NextApiResponse) {

View File

@@ -1,6 +1,7 @@
import {app} from "@/firebase"; import {app} from "@/firebase";
import {Assignment} from "@/interfaces/results"; import {Assignment} from "@/interfaces/results";
import {collection, getDocs, getFirestore, query, where} from "firebase/firestore"; import {collection, getDocs, getFirestore, query, where} from "firebase/firestore";
import {getAllAssignersByCorporate} from "@/utils/groups.be";
const db = getFirestore(app); const db = getFirestore(app);
@@ -34,3 +35,33 @@ export const getAssignmentsByAssignerBetweenDates = async (id: string, startDate
export const getAssignmentsByAssigners = async (ids: string[], startDate?: Date, endDate?: Date) => { export const getAssignmentsByAssigners = async (ids: string[], startDate?: Date, endDate?: Date) => {
return (await Promise.all(ids.map((id) => getAssignmentsByAssigner(id, startDate, endDate)))).flat(); return (await Promise.all(ids.map((id) => getAssignmentsByAssigner(id, startDate, endDate)))).flat();
}; };
export const getAssignmentsForCorporates = async (idsList: string[], startDate?: Date, endDate?: Date) => {
const assigners = await Promise.all(
idsList.map(async (id) => {
const assigners = await getAllAssignersByCorporate(id);
return {
corporateId: id,
assigners,
};
}),
);
const assignments = await Promise.all(
assigners.map(async (data) => {
try {
const assigners = [...new Set([...data.assigners, data.corporateId])];
const assignments = await getAssignmentsByAssigners(assigners, startDate, endDate);
return assignments.map((assignment) => ({
...assignment,
corporateId: data.corporateId,
}));
} catch (err) {
console.error(err);
return [];
}
}),
);
return assignments.flat();
}

23
src/utils/grading.be.ts Normal file
View File

@@ -0,0 +1,23 @@
import { app } from "@/firebase";
import { getFirestore, doc, getDoc } from "firebase/firestore";
import { CEFR_STEPS } from "@/resources/grading";
import { getUserCorporate } from "@/utils/groups.be";
import { User } from "@/interfaces/user";
import { Grading } from "@/interfaces";
const db = getFirestore(app);
export const getGradingSystem = async (user: User): Promise<Grading> => {
const snapshot = await getDoc(doc(db, "grading", user.id));
if (snapshot.exists()) return snapshot.data() as Grading;
if (user.type !== "teacher" && user.type !== "student")
return { steps: CEFR_STEPS, user: user.id };
const corporate = await getUserCorporate(user.id);
if (!corporate) return { steps: CEFR_STEPS, user: user.id };
const corporateSnapshot = await getDoc(doc(db, "grading", corporate.id));
if (corporateSnapshot.exists()) return corporateSnapshot.data() as Grading;
return { steps: CEFR_STEPS, user: user.id };
};

29
src/utils/search.ts Normal file
View File

@@ -0,0 +1,29 @@
/*fields example = [
['id'],
['companyInformation', 'companyInformation', 'name']
]*/
const getFieldValue = (fields: string[], data: any): string => {
if (fields.length === 0) return data;
const [key, ...otherFields] = fields;
if (data[key]) return getFieldValue(otherFields, data[key]);
return data;
};
export const search = (text: string, fields: string[][], rows: any[]) => {
const searchText = text.toLowerCase();
return rows.filter((row) => {
return fields.some((fieldsKeys) => {
const value = getFieldValue(fieldsKeys, row);
if (typeof value === "string") {
return value.toLowerCase().includes(searchText);
}
if (typeof value === "number") {
return (value as Number).toString().includes(searchText);
}
});
});
}