Merged with develop

This commit is contained in:
Tiago Ribeiro
2024-12-30 19:04:18 +00:00
37 changed files with 955 additions and 645 deletions

View File

@@ -35,7 +35,7 @@ export default function Layout({
return (
<main className={clsx("w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke relative")}>
<ToastContainer />
{!hideSidebar && (
{!hideSidebar && user && (
<Navbar
path={router.pathname}
user={user}
@@ -45,7 +45,7 @@ export default function Layout({
/>
)}
<div className={clsx("h-full w-full flex gap-2")}>
{!hideSidebar && (
{!hideSidebar && user && (
<Sidebar
path={router.pathname}
navDisabled={navDisabled}

View File

@@ -13,6 +13,7 @@ interface Props {
disabled?: boolean;
max?: number;
min?: number;
thin?: boolean
name: string;
onChange: (value: string) => void;
}
@@ -29,6 +30,7 @@ export default function Input({
className,
roundness = "full",
disabled = false,
thin = false,
min,
onChange,
}: Props) {
@@ -95,9 +97,10 @@ export default function Input({
min={type === "number" ? (min ?? 0) : undefined}
placeholder={placeholder}
className={clsx(
"px-8 py-6 text-sm font-normal bg-white border border-mti-gray-platinum focus:outline-none",
"px-8 text-sm font-normal bg-white border border-mti-gray-platinum focus:outline-none",
"placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed",
roundness === "full" ? "rounded-full" : "rounded-xl",
thin ? 'py-4' : 'py-6'
)}
required={required}
defaultValue={defaultValue}

View File

@@ -8,6 +8,7 @@ import useGroups from "@/hooks/useGroups";
import useRecordStore from "@/stores/recordStore";
import { EntityWithRoles } from "@/interfaces/entity";
import { mapBy } from "@/utils";
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
type TimeFilter = "months" | "weeks" | "days";
@@ -47,6 +48,8 @@ const RecordFilter: React.FC<Props> = ({
state.setSelectedUser
]);
const allowedViewEntities = useAllowedEntities(user, entities, 'view_student_record')
const entityUsers = useMemo(() => !entity ? users : users.filter(u => mapBy(u.entities, 'id').includes(entity)), [users, entity])
useEffect(() => setStatsUserId(user.id), [setStatsUserId, user.id])
@@ -64,7 +67,7 @@ const RecordFilter: React.FC<Props> = ({
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
<Select
options={entities.map((e) => ({value: e.id, label: e.label}))}
options={allowedViewEntities.map((e) => ({ value: e.id, label: e.label }))}
onChange={(value) => setEntity(value?.value || undefined)}
isClearable
styles={{

View File

@@ -82,7 +82,7 @@ interface StatsGridItemProps {
selectedTrainingExams?: string[];
maxTrainingExams?: number;
setSelectedTrainingExams?: React.Dispatch<React.SetStateAction<string[]>>;
renderPdfIcon: (session: string, color: string, textColor: string) => React.ReactNode;
renderPdfIcon?: (session: string, color: string, textColor: string) => React.ReactNode;
}
const StatsGridItem: React.FC<StatsGridItemProps> = ({
@@ -236,7 +236,7 @@ const StatsGridItem: React.FC<StatsGridItemProps> = ({
{renderLevelScore()}
</span>
)}
{shouldRenderPDFIcon() && renderPdfIcon(session, textColor, textColor)}
{shouldRenderPDFIcon() && renderPdfIcon && renderPdfIcon(session, textColor, textColor)}
</div>
{examNumber === undefined ? (
<>

View File

@@ -65,28 +65,28 @@ export default function Navbar({ user, path, navDisabled = false, focusMode = fa
{
module: "reading",
icon: () => <BsBook className="h-4 w-4 text-white" />,
achieved: user.levels?.reading || 0 >= user.desiredLevels?.reading || 9,
achieved: user?.levels?.reading || 0 >= user?.desiredLevels?.reading || 9,
},
{
module: "listening",
icon: () => <BsHeadphones className="h-4 w-4 text-white" />,
achieved: user.levels?.listening || 0 >= user.desiredLevels?.listening || 9,
achieved: user?.levels?.listening || 0 >= user?.desiredLevels?.listening || 9,
},
{
module: "writing",
icon: () => <BsPen className="h-4 w-4 text-white" />,
achieved: user.levels?.writing || 0 >= user.desiredLevels?.writing || 9,
achieved: user?.levels?.writing || 0 >= user?.desiredLevels?.writing || 9,
},
{
module: "speaking",
icon: () => <BsMegaphone className="h-4 w-4 text-white" />,
achieved: user.levels?.speaking || 0 >= user.desiredLevels?.speaking || 9,
achieved: user?.levels?.speaking || 0 >= user?.desiredLevels?.speaking || 9,
},
{
module: "level",
icon: () => <BsClipboard className="h-4 w-4 text-white" />,
achieved: user.levels?.level || 0 >= user.desiredLevels?.level || 9,
achieved: user?.levels?.level || 0 >= user?.desiredLevels?.level || 9,
},
];

View File

@@ -1,3 +1,4 @@
import { Entity } from "@/interfaces/entity";
import { PaymentIntention } from "@/interfaces/paymob";
import { DurationUnit } from "@/interfaces/paypal";
import { User } from "@/interfaces/user";
@@ -10,6 +11,7 @@ import Modal from "./Modal";
interface Props {
user: User;
entity?: Entity
currency: string;
price: number;
setIsPaymentLoading: (v: boolean) => void;
@@ -18,7 +20,7 @@ interface Props {
onSuccess: (duration: number, duration_unit: DurationUnit) => void;
}
export default function PaymobPayment({user, price, setIsPaymentLoading, currency, duration, duration_unit, onSuccess}: Props) {
export default function PaymobPayment({ user, entity, price, setIsPaymentLoading, currency, duration, duration_unit, onSuccess }: Props) {
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
@@ -56,6 +58,7 @@ export default function PaymobPayment({user, price, setIsPaymentLoading, currenc
userID: user.id,
duration,
duration_unit,
entity: entity?.id
},
};

View File

@@ -25,6 +25,7 @@ import ProgressButtons from "../components/ProgressButtons";
import useExamNavigation from "../Navigation/useExamNavigation";
import { calculateExerciseIndex } from "../utils/calculateExerciseIndex";
import { defaultExamUserSolutions } from "@/utils/exams";
import PracticeModal from "@/components/PracticeModal";
const Level: React.FC<ExamProps<LevelExam>> = ({ exam, showSolutions = false, preview = false }) => {
@@ -66,7 +67,6 @@ const Level: React.FC<ExamProps<LevelExam>> = ({ exam, showSolutions = false, pr
const [continueAnyways, setContinueAnyways] = useState(false);
const [textRender, setTextRender] = useState(false);
const [questionModalKwargs, setQuestionModalKwargs] = useState<{
type?: "module" | "blankQuestions" | "submit"; unanswered?: boolean | undefined; onClose: (next?: boolean) => void | undefined;
}>({
@@ -101,6 +101,14 @@ const Level: React.FC<ExamProps<LevelExam>> = ({ exam, showSolutions = false, pr
}
);
const hasPractice = useMemo(() => {
if (partIndex > -1 && partIndex < exam.parts.length && !showPartDivider) {
console.log(exam.parts[partIndex].exercises.some(e => e.isPractice))
return exam.parts[partIndex].exercises.some(e => e.isPractice)
}
return false
}, [partIndex, showPartDivider, exam.parts])
const registerSolution = useCallback((updateSolution: () => UserSolution) => {
userSolutionRef.current = updateSolution;
setSolutionWasUpdated(true);
@@ -337,6 +345,7 @@ const Level: React.FC<ExamProps<LevelExam>> = ({ exam, showSolutions = false, pr
return (
<>
<div className={clsx("flex flex-col h-full w-full gap-8 items-center", showPartDivider && "justify-center")}>
<PracticeModal key={`${partIndex}_${showPartDivider}`} open={hasPractice} />
<Modal
className={"!w-2/6 !p-8"}
titleClassName={"font-bold text-3xl text-mti-rose-light"}

View File

@@ -4,6 +4,11 @@ export interface Entity {
id: string;
label: string;
licenses: number;
expiryDate?: Date | null
payment?: {
currency: string
price: number
}
}
export interface Role {

View File

@@ -20,6 +20,7 @@ export interface ExamBase {
variant?: Variant;
difficulty?: Difficulty;
owners?: string[];
entities?: string[]
shuffle?: boolean;
createdBy?: string; // option as it has been added later
createdAt?: string; // option as it has been added later

View File

@@ -28,7 +28,7 @@ interface Customer {
extras: IntentionExtras;
}
type IntentionExtras = {[key: string]: string | number};
type IntentionExtras = { [key: string]: string | number | undefined };
export interface IntentionResult {
payment_keys: PaymentKeysItem[];

View File

@@ -32,6 +32,7 @@ export type DurationUnit = "weeks" | "days" | "months" | "years";
export interface Payment {
id: string;
corporate: string;
entity?: string
agent?: string;
agentCommission: number;
agentValue: number;

View File

@@ -28,6 +28,7 @@ export interface BasicUser {
export interface StudentUser extends BasicUser {
type: "student";
studentID?: string;
averageLevel?: number
preferredGender?: InstructorGender;
demographicInformation?: DemographicInformation;
preferredTopics?: string[];

View File

@@ -24,6 +24,7 @@ import { WithLabeledEntities } from "@/interfaces/entity";
import Table from "@/components/High/Table";
import useEntities from "@/hooks/useEntities";
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import { findAllowedEntities } from "@/utils/permissions";
const columnHelper = createColumnHelper<WithLabeledEntities<User>>();
const searchFields = [["name"], ["email"], ["entities", ""]];
@@ -45,8 +46,6 @@ export default function UserList({
const { users, reload } = useEntitiesUsers(type)
const { entities } = useEntities()
const { balance } = useUserBalance();
const isAdmin = useMemo(() => ["admin", "developer"].includes(user?.type), [user?.type])
const entitiesViewStudents = useAllowedEntities(user, entities, "view_students")
@@ -65,6 +64,8 @@ export default function UserList({
const entitiesEditMasterCorporates = useAllowedEntities(user, entities, "edit_mastercorporates")
const entitiesDeleteMasterCorporates = useAllowedEntities(user, entities, "delete_mastercorporates")
const entitiesDownloadUsers = useAllowedEntities(user, entities, "download_user_list")
const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
const router = useRouter();
@@ -342,7 +343,10 @@ export default function UserList({
];
const downloadExcel = (rows: WithLabeledEntities<User>[]) => {
const csv = exportListToExcel(rows);
if (entitiesDownloadUsers.length === 0) return toast.error("You are not allowed to download the user list.")
const allowedRows = rows.filter(r => mapBy(r.entities, 'id').some(e => mapBy(entitiesDownloadUsers, 'id').includes(e)))
const csv = exportListToExcel(allowedRows);
const element = document.createElement("a");
const file = new Blob([csv], { type: "text/csv" });
@@ -437,7 +441,7 @@ export default function UserList({
data={displayUsers}
columns={(!showDemographicInformation ? defaultColumns : demographicColumns) as any}
searchFields={searchFields}
onDownload={downloadExcel}
onDownload={entitiesDownloadUsers.length > 0 ? downloadExcel : undefined}
/>
</div>
</>

View File

@@ -5,8 +5,8 @@ import usePackages from "@/hooks/usePackages";
import useUsers from "@/hooks/useUsers";
import { User } from "@/interfaces/user";
import clsx from "clsx";
import { capitalize } from "lodash";
import { useEffect, useState } from "react";
import { capitalize, sortBy } from "lodash";
import { useEffect, useMemo, useState } from "react";
import useInvites from "@/hooks/useInvites";
import { BsArrowRepeat } from "react-icons/bs";
import InviteCard from "@/components/Medium/InviteCard";
@@ -16,46 +16,50 @@ import useDiscounts from "@/hooks/useDiscounts";
import PaymobPayment from "@/components/PaymobPayment";
import moment from "moment";
import { EntityWithRoles } from "@/interfaces/entity";
import { Discount, Package } from "@/interfaces/paypal";
import { isAdmin } from "@/utils/users";
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import Select from "@/components/Low/Select";
interface Props {
user: User;
user: User
discounts: Discount[]
packages: Package[]
entities: EntityWithRoles[]
hasExpired?: boolean;
reload: () => void;
}
export default function PaymentDue({ user, hasExpired = false, reload }: Props) {
export default function PaymentDue({ user, discounts = [], entities = [], packages = [], hasExpired = false, reload }: Props) {
const [isLoading, setIsLoading] = useState(false);
const [appliedDiscount, setAppliedDiscount] = useState(0);
const [entity, setEntity] = useState<EntityWithRoles>()
const router = useRouter();
const { packages } = usePackages();
const { discounts } = useDiscounts();
const { users } = useUsers();
const { groups } = useGroups({});
const { invites, isLoading: isInvitesLoading, reload: reloadInvites } = useInvites({ to: user?.id });
useEffect(() => {
const userDiscounts = discounts.filter((x) => user.email.endsWith(`@${x.domain}`));
if (userDiscounts.length === 0) return;
const biggestDiscount = [...userDiscounts].sort((a, b) => b.percentage - a.percentage).shift();
if (!biggestDiscount || (biggestDiscount.validUntil && moment(biggestDiscount.validUntil).isBefore(moment()))) return;
setAppliedDiscount(biggestDiscount.percentage);
}, [discounts, user]);
const isIndividual = () => {
if (user?.type === "developer") return true;
const isIndividual = useMemo(() => {
if (isAdmin(user)) return false;
if (user?.type !== "student") return false;
const userGroups = groups.filter((g) => g.participants.includes(user?.id));
if (userGroups.length === 0) return true;
return user.entities.length === 0
}, [user])
const userGroupsAdminTypes = userGroups.map((g) => users?.find((u) => u.id === g.admin)?.type).filter((t) => !!t);
return userGroupsAdminTypes.every((t) => t !== "corporate");
};
const appliedDiscount = useMemo(() => {
const biggestDiscount = [...discounts].sort((a, b) => b.percentage - a.percentage).shift();
if (!biggestDiscount || (biggestDiscount.validUntil && moment(biggestDiscount.validUntil).isBefore(moment())))
return 0;
return biggestDiscount.percentage
}, [discounts])
const entitiesThatCanBePaid = useAllowedEntities(user, entities, 'pay_entity')
useEffect(() => {
if (entitiesThatCanBePaid.length > 0) setEntity(entitiesThatCanBePaid[0])
}, [entitiesThatCanBePaid])
return (
<>
@@ -74,7 +78,6 @@ export default function PaymentDue({ user, hasExpired = false, reload }: Props)
</div>
</div>
)}
{user ? (
<Layout user={user} navDisabled={hasExpired}>
{invites.length > 0 && (
<section className="flex flex-col gap-1 md:gap-3">
@@ -104,7 +107,7 @@ export default function PaymentDue({ user, hasExpired = false, reload }: Props)
<div className="flex w-full flex-col items-center justify-center gap-4 text-center">
{hasExpired && <span className="text-lg font-bold">You do not have time credits for your account type!</span>}
{isIndividual() && (
{isIndividual && (
<div className="scrollbar-hide flex w-full flex-col items-center gap-12 overflow-x-scroll">
<span className="max-w-lg">
To add to your use of EnCoach, please purchase one of the time packages available below:
@@ -162,10 +165,20 @@ export default function PaymentDue({ user, hasExpired = false, reload }: Props)
</div>
</div>
)}
{!isIndividual() &&
(user?.type === "corporate" || user?.type === "mastercorporate") &&
user?.corporateInformation.payment && (
<div className="flex flex-col items-center">
{!isIndividual && entitiesThatCanBePaid.length > 0 &&
entity?.payment && (
<div className="flex flex-col items-center gap-8">
<div className={clsx("flex flex-col items-center gap-4 w-full")}>
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
<Select
defaultValue={{ value: entity?.id, label: entity?.label }}
options={entitiesThatCanBePaid.map((e) => ({ value: e.id, label: e.label, entity: e }))}
onChange={(e) => e?.value ? setEntity(e?.entity) : null}
className="!w-full max-w-[400px] self-center"
/>
</div>
<span className="max-w-lg">
To add to your use of EnCoach and that of your students and teachers, please pay your designated package
below:
@@ -179,13 +192,14 @@ export default function PaymentDue({ user, hasExpired = false, reload }: Props)
</div>
<div className="flex w-full flex-col items-start gap-2">
<span className="text-2xl">
{user.corporateInformation.payment.value} {user.corporateInformation.payment.currency}
{entity.payment.price} {entity.payment.currency}
</span>
<PaymobPayment
user={user}
setIsPaymentLoading={setIsLoading}
currency={user.corporateInformation.payment.currency}
price={user.corporateInformation.payment.value}
entity={entity}
currency={entity.payment.currency}
price={entity.payment.price}
duration={12}
duration_unit="months"
onSuccess={() => {
@@ -198,7 +212,7 @@ export default function PaymentDue({ user, hasExpired = false, reload }: Props)
<span>This includes:</span>
<ul className="flex flex-col items-start text-sm">
<li>
- Allow a total of 0 students and teachers to use EnCoach
- Allow a total of {entity.licenses} students and teachers to use EnCoach
</li>
<li>- Train their abilities for the IELTS exam</li>
<li>- Gain insights into your students&apos; weaknesses and strengths</li>
@@ -208,7 +222,7 @@ export default function PaymentDue({ user, hasExpired = false, reload }: Props)
</div>
</div>
)}
{!isIndividual() && !(user?.type === "corporate" || user?.type === "mastercorporate") && (
{!isIndividual && entitiesThatCanBePaid.length === 0 && (
<div className="flex flex-col items-center">
<span className="max-w-lg">
You are not the person in charge of your time credits, please contact your administrator about this situation.
@@ -219,10 +233,19 @@ export default function PaymentDue({ user, hasExpired = false, reload }: Props)
</span>
</div>
)}
{!isIndividual() &&
(user?.type === "corporate" || user?.type === "mastercorporate") &&
!user.corporateInformation.payment && (
<div className="flex flex-col items-center">
{!isIndividual &&
entitiesThatCanBePaid.length > 0 &&
!entity?.payment && (
<div className="flex flex-col items-center gap-8">
<div className={clsx("flex flex-col items-center gap-4 w-full")}>
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
<Select
defaultValue={{ value: entity?.id || "", label: entity?.label || "" }}
options={entitiesThatCanBePaid.map((e) => ({ value: e.id, label: e.label, entity: e }))}
onChange={(e) => e?.value ? setEntity(e?.entity) : null}
className="!w-full max-w-[400px] self-center"
/>
</div>
<span className="max-w-lg">
An admin nor your agent have yet set the price intended to your requirements in terms of the amount of users
you desire and your expected monthly duration.
@@ -234,9 +257,6 @@ export default function PaymentDue({ user, hasExpired = false, reload }: Props)
)}
</div>
</Layout>
) : (
<div />
)}
</>
);
}

View File

@@ -6,9 +6,11 @@ import { deleteEntity, getEntity, getEntityWithRoles } from "@/utils/entities.be
import client from "@/lib/mongodb";
import { Entity } from "@/interfaces/entity";
import { doesEntityAllow } from "@/utils/permissions";
import { getUser } from "@/utils/users.be";
import { getEntityUsers, getUser } from "@/utils/users.be";
import { requestUser } from "@/utils/api";
import { isAdmin } from "@/utils/users";
import { filterBy, mapBy } from "@/utils";
import { User } from "@/interfaces/user";
const db = client.db(process.env.MONGODB_DB);
@@ -66,5 +68,27 @@ async function patch(req: NextApiRequest, res: NextApiResponse) {
return res.status(200).json({ ok: entity.acknowledged });
}
if (req.body.payment) {
const entity = await db.collection<Entity>("entities").updateOne({ id }, { $set: { payment: req.body.payment } });
return res.status(200).json({ ok: entity.acknowledged });
}
if (req.body.expiryDate !== undefined) {
const entity = await getEntity(id)
const result = await db.collection<Entity>("entities").updateOne({ id }, { $set: { expiryDate: req.body.expiryDate } });
const users = await getEntityUsers(id, 0, {
subscriptionExpirationDate: entity?.expiryDate,
$and: [
{ type: { $ne: "admin" } },
{ type: { $ne: "developer" } },
]
})
await db.collection<User>("users").updateMany({ id: { $in: mapBy(users, 'id') } }, { $set: { subscriptionExpirationDate: req.body.expiryDate } })
return res.status(200).json({ ok: result.acknowledged });
}
return res.status(200).json({ ok: true });
}

View File

@@ -7,6 +7,9 @@ import { Exam, ExamBase, InstructorGender, Variant } from "@/interfaces/exam";
import { getExams } from "@/utils/exams.be";
import { Module } from "@/interfaces";
import { getUserCorporate } from "@/utils/groups.be";
import { requestUser } from "@/utils/api";
import { isAdmin } from "@/utils/users";
import { mapBy } from "@/utils";
const db = client.db(process.env.MONGODB_DB);
@@ -37,25 +40,20 @@ async function GET(req: NextApiRequest, res: NextApiResponse) {
}
async function POST(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ ok: false });
return;
}
const user = await requestUser(req, res)
if (!user) return res.status(401).json({ ok: false });
const { module } = req.query as { module: string };
const corporate = await getUserCorporate(req.session.user.id);
const session = client.startSession();
const entities = isAdmin(user) ? [] : mapBy(user.entities, 'id')
try {
const exam = {
...req.body,
module: module,
owners: [
...(["mastercorporate", "corporate"].includes(req.session.user.type) ? [req.session.user.id] : []),
...(!!corporate ? [corporate.id] : []),
],
createdBy: req.session.user.id,
entities,
createdBy: user.id,
createdAt: new Date().toISOString(),
};

View File

@@ -1,6 +1,13 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import { CorporateUser, User } from "@/interfaces/user";
import client from "@/lib/mongodb";
import { getLinkedUsers } from "@/utils/users.be";
import { uniqBy } from "lodash";
import type { NextApiRequest, NextApiResponse } from "next";
import { v4 } from "uuid";
import fs from 'fs'
import { findBy, mapBy } from "@/utils";
import { addUsersToEntity, getEntitiesWithRoles } from "@/utils/entities.be";
const db = client.db(process.env.MONGODB_DB);

View File

@@ -10,6 +10,10 @@ import axios from "axios";
import { IntentionResult, PaymentIntention, TransactionResult } from "@/interfaces/paymob";
import moment from "moment";
import client from "@/lib/mongodb";
import { getEntity } from "@/utils/entities.be";
import { Entity } from "@/interfaces/entity";
import { getEntityUsers } from "@/utils/users.be";
import { mapBy } from "@/utils";
const db = client.db(process.env.MONGODB_DB);
@@ -25,10 +29,11 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
if (!checkTransaction(authToken, transactionResult.transaction.order.id)) return res.status(404).json({ ok: false });
if (!transactionResult.transaction.success) return res.status(400).json({ ok: false });
const {userID, duration, duration_unit} = transactionResult.intention.extras.creation_extras as {
const { userID, duration, duration_unit, entity: entityID } = transactionResult.intention.extras.creation_extras as {
userID: string;
duration: number;
duration_unit: DurationUnit;
entity: string
};
const user = await db.collection("users").findOne<User>({ id: userID as string });
@@ -60,22 +65,19 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
value: transactionResult.transaction.amount_cents / 1000,
});
if (user.type === "corporate") {
const groups = await db.collection("groups").find<Group>({ admin: user.id }).toArray();
if (entityID) {
const entity = await getEntity(entityID)
await db.collection<Entity>("entities").updateOne({ id: entityID }, { $set: { expiryDate: req.body.expiryDate } });
const participants = (await Promise.all(
groups.flatMap((x) => x.participants).map(async (x) => ({...(await db.collection("users").findOne({ id: x}))})),
)) as User[];
const sameExpiryDateParticipants = participants.filter(
(x) => x.subscriptionExpirationDate === subscriptionExpirationDate && x.status !== "disabled",
);
const users = await getEntityUsers(entityID, 0, {
subscriptionExpirationDate: entity?.expiryDate,
$and: [
{ type: { $ne: "admin" } },
{ type: { $ne: "developer" } },
]
})
for (const participant of sameExpiryDateParticipants) {
await db.collection("users").updateOne(
{ id: participant.id },
{ $set: {subscriptionExpirationDate: updatedSubscriptionExpirationDate, status: "active"} }
);
}
await db.collection<User>("users").updateMany({ id: { $in: mapBy(users, 'id') } }, { $set: { subscriptionExpirationDate: req.body.expiryDate } })
}
res.status(200).json({

View File

@@ -3,7 +3,7 @@ import {app} from "@/firebase";
import { Module } from "@/interfaces";
import { Stat, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session";
import {calculateBandScore} from "@/utils/score";
import { calculateAverageLevel, calculateBandScore } from "@/utils/score";
import { groupByModule, groupBySession } from "@/utils/stats";
import { MODULE_ARRAY } from "@/utils/moduleUtils";
import client from "@/lib/mongodb";
@@ -100,9 +100,11 @@ async function update(req: NextApiRequest, res: NextApiResponse) {
level: calculateBandScore(levelLevel.correct, levelLevel.total, "level", user.focus),
};
const averageLevel = calculateAverageLevel(levels)
await db.collection("users").updateOne(
{ id: user.id },
{ $set: {levels} }
{ $set: { levels, averageLevel } }
);
res.status(200).json({ ok: true });

View File

@@ -139,7 +139,6 @@ export default function Dashboard({
value={entities.length}
color="purple"
/>
<IconCard Icon={BsClipboard2Data} label="Exams Performed" value={uniqBy(stats, "exam").length} color="purple" />
<IconCard Icon={BsPersonFillGear}
onClick={() => router.push("/statistical")}
label="Entity Statistics"
@@ -149,7 +148,7 @@ export default function Dashboard({
<IconCard Icon={BsPersonFillGear}
onClick={() => router.push("/users/performance")}
label="Student Performance"
value={students.length}
value={usersCount.student}
color="purple"
/>
<IconCard

View File

@@ -3,18 +3,18 @@ import Layout from "@/components/High/Layout";
import UserDisplayList from "@/components/UserDisplayList";
import IconCard from "@/components/IconCard";
import { EntityWithRoles } from "@/interfaces/entity";
import { Stat, Type, User } from "@/interfaces/user";
import { Stat, StudentUser, Type, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session";
import { dateSorter, filterBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api";
import { countEntitiesAssignments } from "@/utils/assignments.be";
import { getEntitiesWithRoles } from "@/utils/entities.be";
import { countGroupsByEntities } from "@/utils/groups.be";
import { checkAccess } from "@/utils/permissions";
import { checkAccess, findAllowedEntities } from "@/utils/permissions";
import { calculateAverageLevel } from "@/utils/score";
import { groupByExam } from "@/utils/stats";
import { getStatsByUsers } from "@/utils/stats.be";
import { countAllowedUsers, filterAllowedUsers } from "@/utils/users.be";
import { countAllowedUsers, filterAllowedUsers, getUsers } from "@/utils/users.be";
import { withIronSessionSsr } from "iron-session/next";
import { uniqBy } from "lodash";
import moment from "moment";
@@ -37,7 +37,9 @@ import { isAdmin } from "@/utils/users";
interface Props {
user: User;
users: User[];
students: StudentUser[]
latestStudents: User[]
latestTeachers: User[]
userCounts: { [key in Type]: number }
entities: EntityWithRoles[];
assignmentsCount: number;
@@ -53,24 +55,28 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const entityIDS = mapBy(user.entities, "id") || [];
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS);
const users = await filterAllowedUsers(user, entities)
const allowedStudentEntities = findAllowedEntities(user, entities, "view_students")
const allowedTeacherEntities = findAllowedEntities(user, entities, "view_teachers")
const students =
await getUsers({ type: 'student', "entities.id": { $in: mapBy(allowedStudentEntities, 'id') } }, 10, { averageLevel: -1 });
const latestStudents =
await getUsers({ type: 'student', "entities.id": { $in: mapBy(allowedStudentEntities, 'id') } }, 10, { registrationDate: -1 })
const latestTeachers =
await getUsers({ type: 'teacher', "entities.id": { $in: mapBy(allowedTeacherEntities, 'id') } }, 10, { registrationDate: -1 })
const userCounts = await countAllowedUsers(user, entities)
const assignmentsCount = await countEntitiesAssignments(mapBy(entities, "id"), { archived: { $ne: true } });
const groupsCount = await countGroupsByEntities(mapBy(entities, "id"));
const stats = await getStatsByUsers(users.map((u) => u.id));
return { props: serialize({ user, users, userCounts, entities, assignmentsCount, stats, groupsCount }) };
return { props: serialize({ user, students, latestStudents, latestTeachers, userCounts, entities, assignmentsCount, groupsCount }) };
}, sessionOptions);
export default function Dashboard({ user, users, userCounts, entities, assignmentsCount, stats, groupsCount }: Props) {
const students = useMemo(() => users.filter((u) => u.type === "student"), [users]);
const teachers = useMemo(() => users.filter((u) => u.type === "teacher"), [users]);
export default function Dashboard({ user, students, latestStudents, latestTeachers, userCounts, entities, assignmentsCount, stats = [], groupsCount }: Props) {
const totalCount = useMemo(() =>
userCounts.corporate + userCounts.mastercorporate + userCounts.student + userCounts.teacher, [userCounts])
const totalLicenses = useMemo(() => entities.reduce((acc, curr) => acc + curr.licenses, 0), [entities])
const totalLicenses = useMemo(() => entities.reduce((acc, curr) => acc + parseInt(curr.licenses.toString()), 0), [entities])
const allowedEntityStatistics = useAllowedEntities(user, entities, 'view_entity_statistics')
const allowedStudentPerformance = useAllowedEntities(user, entities, 'view_student_performance')
@@ -124,7 +130,6 @@ export default function Dashboard({ user, users, userCounts, entities, assignmen
value={`${entities.length} - ${totalCount}/${totalLicenses}`}
color="purple"
/>
<IconCard Icon={BsClipboard2Data} label="Exams Performed" value={uniqBy(stats, "exam").length} color="purple" />
{allowedEntityStatistics.length > 0 && (
<IconCard Icon={BsPersonFillGear}
onClick={() => router.push("/statistical")}
@@ -160,15 +165,15 @@ export default function Dashboard({ user, users, userCounts, entities, assignmen
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<UserDisplayList
users={students.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))}
users={latestStudents}
title="Latest Students"
/>
<UserDisplayList
users={teachers.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))}
users={latestTeachers}
title="Latest Teachers"
/>
<UserDisplayList
users={students.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))}
users={students}
title="Highest level students"
/>
<UserDisplayList

View File

@@ -4,7 +4,7 @@ import UserDisplayList from "@/components/UserDisplayList";
import IconCard from "@/components/IconCard";
import { EntityWithRoles } from "@/interfaces/entity";
import { Assignment } from "@/interfaces/results";
import { Group, Stat, Type, User } from "@/interfaces/user";
import { Group, Stat, StudentUser, Type, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session";
import { dateSorter, filterBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api";
@@ -51,7 +51,7 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
if (!checkAccess(user, ["admin", "developer"])) return redirect("/")
const students = await getUsers({ type: 'student' });
const students = await getUsers({ type: 'student' }, 10, { averageLevel: -1 });
const usersCount = {
student: await countUsers({ type: "student" }),
teacher: await countUsers({ type: "teacher" }),
@@ -66,20 +66,18 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const assignmentsCount = await countEntitiesAssignments(mapBy(entities, 'id'), { archived: { $ne: true } });
const groupsCount = await countGroups();
const stats = await getStatsByUsers(mapBy(students, 'id'));
return { props: serialize({ user, students, latestStudents, latestTeachers, usersCount, entities, assignmentsCount, stats, groupsCount }) };
return { props: serialize({ user, students, latestStudents, latestTeachers, usersCount, entities, assignmentsCount, groupsCount }) };
}, sessionOptions);
export default function Dashboard({
user,
students,
students = [],
latestStudents,
latestTeachers,
usersCount,
entities,
assignmentsCount,
stats,
stats = [],
groupsCount
}: Props) {
const router = useRouter();
@@ -139,7 +137,6 @@ export default function Dashboard({
value={entities.length}
color="purple"
/>
<IconCard Icon={BsClipboard2Data} label="Exams Performed" value={uniqBy(stats, "exam").length} color="purple" />
<IconCard Icon={BsPersonFillGear}
onClick={() => router.push("/statistical")}
label="Entity Statistics"
@@ -171,7 +168,7 @@ export default function Dashboard({
title="Latest Teachers"
/>
<UserDisplayList
users={students.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))}
users={students}
title="Highest level students"
/>
<UserDisplayList

View File

@@ -6,7 +6,7 @@ import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import { Module } from "@/interfaces";
import { EntityWithRoles } from "@/interfaces/entity";
import { Assignment } from "@/interfaces/results";
import { Group, Stat, Type, User } from "@/interfaces/user";
import { Group, Stat, StudentUser, Type, User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session";
import { dateSorter, filterBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api";
@@ -17,7 +17,7 @@ import { checkAccess, findAllowedEntities } from "@/utils/permissions";
import { calculateAverageLevel, calculateBandScore } from "@/utils/score";
import { groupByExam } from "@/utils/stats";
import { getStatsByUsers } from "@/utils/stats.be";
import { countAllowedUsers, filterAllowedUsers } from "@/utils/users.be";
import { countAllowedUsers, filterAllowedUsers, getUsers } from "@/utils/users.be";
import { getEntitiesUsers } from "@/utils/users.be";
import { clsx } from "clsx";
import { withIronSessionSsr } from "iron-session/next";
@@ -44,7 +44,9 @@ import { isAdmin } from "@/utils/users";
interface Props {
user: User;
users: User[];
students: StudentUser[]
latestStudents: User[]
latestTeachers: User[]
userCounts: { [key in Type]: number }
entities: EntityWithRoles[];
assignmentsCount: number;
@@ -56,29 +58,34 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res)
if (!user || !user.isVerified) return redirect("/login")
if (!checkAccess(user, ["admin", "developer", "mastercorporate"]))
return redirect("/")
if (!checkAccess(user, ["admin", "developer", "mastercorporate"])) return redirect("/")
const entityIDS = mapBy(user.entities, "id") || [];
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS);
const users = await filterAllowedUsers(user, entities)
const allowedStudentEntities = findAllowedEntities(user, entities, "view_students")
const allowedTeacherEntities = findAllowedEntities(user, entities, "view_teachers")
const students =
await getUsers({ type: 'student', "entities.id": { $in: mapBy(allowedStudentEntities, 'id') } }, 10, { averageLevel: -1 });
const latestStudents =
await getUsers({ type: 'student', "entities.id": { $in: mapBy(allowedStudentEntities, 'id') } }, 10, { registrationDate: -1 })
const latestTeachers =
await getUsers({ type: 'teacher', "entities.id": { $in: mapBy(allowedTeacherEntities, 'id') } }, 10, { registrationDate: -1 })
const userCounts = await countAllowedUsers(user, entities)
const assignmentsCount = await countEntitiesAssignments(mapBy(entities, "id"), { archived: { $ne: true } });
const groupsCount = await countGroupsByEntities(mapBy(entities, "id"));
const stats = await getStatsByUsers(users.map((u) => u.id));
return { props: serialize({ user, users, userCounts, entities, assignmentsCount, stats, groupsCount }) };
return { props: serialize({ user, students, latestStudents, latestTeachers, userCounts, entities, assignmentsCount, groupsCount }) };
}, sessionOptions);
export default function Dashboard({ user, users, userCounts, entities, assignmentsCount, stats, groupsCount }: Props) {
const students = useMemo(() => users.filter((u) => u.type === "student"), [users]);
const teachers = useMemo(() => users.filter((u) => u.type === "teacher"), [users]);
export default function Dashboard({ user, students, latestStudents, latestTeachers, userCounts, entities, assignmentsCount, stats = [], groupsCount }: Props) {
const totalCount = useMemo(() =>
userCounts.corporate + userCounts.mastercorporate + userCounts.student + userCounts.teacher, [userCounts])
const totalLicenses = useMemo(() => entities.reduce((acc, curr) => acc + curr.licenses, 0), [entities])
const totalLicenses = useMemo(() => entities.reduce((acc, curr) => acc + parseInt(curr.licenses.toString()), 0), [entities])
const router = useRouter();
@@ -133,7 +140,6 @@ export default function Dashboard({ user, users, userCounts, entities, assignmen
value={`${entities.length} - ${totalCount}/${totalLicenses}`}
color="purple"
/>
<IconCard Icon={BsClipboard2Data} label="Exams Performed" value={uniqBy(stats, "exam").length} color="purple" />
{allowedStudentPerformance.length > 0 && (
<IconCard Icon={BsPersonFillGear}
onClick={() => router.push("/users/performance")}
@@ -168,15 +174,15 @@ export default function Dashboard({ user, users, userCounts, entities, assignmen
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<UserDisplayList
users={students.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))}
users={latestStudents}
title="Latest Students"
/>
<UserDisplayList
users={teachers.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))}
users={latestTeachers}
title="Latest Teachers"
/>
<UserDisplayList
users={students.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))}
users={students}
title="Highest level students"
/>
<UserDisplayList

View File

@@ -96,7 +96,6 @@ export default function Dashboard({ user, users, entities, assignments, stats, g
value={groups.length}
color="purple"
/>
<IconCard Icon={BsClipboard2Data} label="Exams Performed" value={uniqBy(stats, "exam").length} color="purple" />
{allowedStudentPerformance.length > 0 && (
<IconCard Icon={BsPersonFillGear}
onClick={() => router.push("/users/performance")}

View File

@@ -2,6 +2,8 @@
import CardList from "@/components/High/CardList";
import Layout from "@/components/High/Layout";
import Select from "@/components/Low/Select";
import Input from "@/components/Low/Input";
import Checkbox from "@/components/Low/Checkbox";
import Tooltip from "@/components/Low/Tooltip";
import { useEntityPermission } from "@/hooks/useEntityPermissions";
import { useListSearch } from "@/hooks/useListSearch";
@@ -27,7 +29,11 @@ import Link from "next/link";
import { useRouter } from "next/router";
import { Divider } from "primereact/divider";
import { useEffect, useMemo, useState } from "react";
import ReactDatePicker from "react-datepicker";
import { CURRENCIES } from "@/resources/paypal";
import {
BsCheck,
BsChevronLeft,
BsClockFill,
BsEnvelopeFill,
@@ -43,6 +49,20 @@ import {
} from "react-icons/bs";
import { toast } from "react-toastify";
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";
};
const CURRENCIES_OPTIONS = CURRENCIES.map(({ label, currency }) => ({
value: currency,
label,
}));
export const getServerSideProps = withIronSessionSsr(async ({ req, params }) => {
const user = req.session.user as User;
@@ -88,6 +108,9 @@ 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 router = useRouter();
@@ -99,6 +122,7 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
const canRemoveMembers = useEntityPermission(user, entity, "remove_from_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]));
@@ -166,6 +190,40 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
.finally(() => setIsLoading(false));
};
const updateExpiryDate = () => {
if (!isAdmin(user)) return;
setIsLoading(true);
axios
.patch(`/api/entities/${entity.id}`, { expiryDate })
.then(() => {
toast.success("The entity has been updated successfully!");
router.replace(router.asPath);
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
};
const updatePayment = () => {
if (!isAdmin(user)) return;
setIsLoading(true);
axios
.patch(`/api/entities/${entity.id}`, { payment: { price: paymentPrice, currency: paymentCurrency } })
.then(() => {
toast.success("The entity has been updated successfully!");
router.replace(router.asPath);
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
};
const editLicenses = () => {
if (!isAdmin(user)) return;
@@ -289,7 +347,7 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<Layout user={user}>
<section className="flex flex-col gap-0">
<div className="flex flex-col gap-3">
<div className="flex items-end justify-between">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<Link
href="/entities"
@@ -298,6 +356,20 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
</Link>
<h2 className="font-bold text-2xl">{entity.label} {isAdmin(user) && `- ${entity.licenses || 0} licenses`}</h2>
</div>
{!isAdmin(user) && canPay && (
<Link
href="/payment"
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 && "Unlimited"}
{entity.expiryDate && moment(entity.expiryDate).format("DD/MM/YYYY")}
</Link>
)}
</div>
<div className="flex items-center gap-2">
<button
@@ -332,6 +404,91 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
</button>
</div>
</div>
{isAdmin(user) && (
<>
<Divider />
<div className="w-full flex justify-between items-center">
<div className="flex items-center gap-4 w-full">
{!!expiryDate && (
<ReactDatePicker
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",
)}
filterDate={(date) => moment(date).isAfter(new Date())}
dateFormat="dd/MM/yyyy"
selected={expiryDate ? moment(expiryDate).toDate() : null}
onChange={(date) => setExpiryDate(date)}
/>
)}
{!expiryDate && (
<div
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",
)}
>
Unlimited
</div>
)}
<Checkbox
isChecked={!!expiryDate}
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">
<BsCheck />
<span className="text-xs">Apply Change</span>
</button>
</div>
<Divider />
<div className="w-full flex items-center justify-between gap-8">
<div className="w-full max-w-xl flex items-center gap-4">
<Input
name="paymentValue"
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",
)}
options={CURRENCIES_OPTIONS}
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">
<BsCheck />
<span className="text-xs">Apply Change</span>
</button>
</div>
</>
)}
<Divider />
<div className="flex items-center justify-between mb-4">
<span className="font-semibold text-xl">Members ({users.length})</span>

View File

@@ -47,6 +47,7 @@ const USER_MANAGEMENT: PermissionLayout[] = [
{ label: "Create Users in Batch", key: "create_user_batch" },
{ label: "Create a Single Code", key: "create_code" },
{ label: "Create Codes in Batch", key: "create_code_batch" },
{ label: "Download User List", key: "download_user_list" },
{ label: "View Code List", key: "view_code_list" },
{ label: "Delete Code", key: "delete_code" },
]
@@ -75,8 +76,11 @@ const CLASSROOM_MANAGEMENT: PermissionLayout[] = [
{ label: "Create Classrooms", key: "create_classroom" },
{ label: "Rename Classrooms", key: "rename_classrooms" },
{ label: "Add to Classroom", key: "add_to_classroom" },
{ label: "Upload to Classroom", key: "upload_classroom" },
{ label: "Remove from Classroom", key: "remove_from_classroom" },
{ label: "Delete Classroom", key: "delete_classroom" },
{ label: "View Student Record", key: "view_student_record" },
{ label: "Download Student Report", key: "download_student_record" },
]
const ENTITY_MANAGEMENT: PermissionLayout[] = [
@@ -94,7 +98,9 @@ const ENTITY_MANAGEMENT: PermissionLayout[] = [
{ label: "Delete Entity Role", key: "delete_entity_role" },
{ label: "Download Statistics Report", key: "download_statistics_report" },
{ label: "Edit Grading System", key: "edit_grading_system" },
{ label: "View Student Performance", key: "view_student_performance" }
{ label: "View Student Performance", key: "view_student_performance" },
{ label: "Pay for Entity", key: "pay_entity" },
{ label: "View Payment Record", key: "view_payment_record" }
]
const ASSIGNMENT_MANAGEMENT: PermissionLayout[] = [

View File

@@ -32,7 +32,7 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res, query })
const { assignment: assignmentID, destination } = query as { assignment?: string, destination?: string }
const destinationURL = !!destination ? Buffer.from(destination, 'base64').toString() : undefined
if (assignmentID) {
if (!!assignmentID) {
const assignment = await getAssignment(assignmentID)
if (!assignment) return redirect(destinationURL || "/exam")

View File

@@ -30,9 +30,12 @@ import { toFixedNumber } from "@/utils/number";
import { CSVLink } from "react-csv";
import { Tab } from "@headlessui/react";
import { useListSearch } from "@/hooks/useListSearch";
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
import { checkAccess, findAllowedEntities, getTypesOfUser } from "@/utils/permissions";
import { requestUser } from "@/utils/api";
import { redirect } from "@/utils";
import { mapBy, redirect, serialize } from "@/utils";
import { getEntities, getEntitiesWithRoles } from "@/utils/entities.be";
import { isAdmin } from "@/utils/users";
import { Entity, EntityWithRoles } from "@/interfaces/entity";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res)
@@ -42,8 +45,13 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
return redirect("/")
}
const entityIDs = mapBy(user.entities, 'id')
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDs)
const allowedEntities = findAllowedEntities(user, entities, "view_payment_record")
return {
props: { user },
props: serialize({ user, entities: allowedEntities }),
};
}, sessionOptions);
@@ -273,7 +281,13 @@ interface PaypalPaymentWithUserData extends PaypalPayment {
}
const paypalFilterRows = [["email"], ["name"], ["orderId"], ["value"]];
export default function PaymentRecord() {
interface Props {
user: User
entities: EntityWithRoles[]
}
export default function PaymentRecord({ user, entities }: Props) {
const [selectedCorporateUser, setSelectedCorporateUser] = useState<User>();
const [selectedAgentUser, setSelectedAgentUser] = useState<User>();
const [isCreatingPayment, setIsCreatingPayment] = useState(false);
@@ -281,9 +295,9 @@ export default function PaymentRecord() {
const [displayPayments, setDisplayPayments] = useState<Payment[]>([]);
const [corporate, setCorporate] = useState<User>();
const [entity, setEntity] = useState<Entity>();
const [agent, setAgent] = useState<User>();
const { user } = useUser({ redirectTo: "/login" });
const { users, reload: reloadUsers } = useUsers();
const { payments: originalPayments, reload: reloadPayment } = usePayments();
const { payments: paypalPayments, reload: reloadPaypalPayment } = usePaypalPayments();
@@ -341,17 +355,17 @@ export default function PaymentRecord() {
useEffect(() => {
setFilters((prev) => [
...prev.filter((x) => x.id !== "corporate-filter"),
...(!corporate
...prev.filter((x) => x.id !== "entity-filter"),
...(!entity
? []
: [
{
id: "corporate-filter",
filter: (p: Payment) => p.corporate === corporate.id,
id: "entity-filter",
filter: (p: Payment) => p.entity === entity.id,
},
]),
]);
}, [corporate]);
}, [entity]);
useEffect(() => {
setFilters((prev) => [
@@ -675,7 +689,7 @@ export default function PaymentRecord() {
<Checkbox
isChecked={value}
onChange={(e) => {
if (user?.type === agent || user?.type === "corporate" || value) return null;
if (user?.type === "agent" || user?.type === "corporate" || value) return null;
if (!info.row.original.commissionTransfer || !info.row.original.corporateTransfer)
return alert("All files need to be uploaded to consider it paid!");
if (!confirm(`Are you sure you want to consider this payment paid?`)) return null;

View File

@@ -7,29 +7,39 @@ import PaymentDue from "./(status)/PaymentDue";
import { useRouter } from "next/router";
import { requestUser } from "@/utils/api";
import { mapBy, redirect, serialize } from "@/utils";
import { getEntities } from "@/utils/entities.be";
import { getEntities, getEntitiesWithRoles } from "@/utils/entities.be";
import { isAdmin } from "@/utils/users";
import { EntityWithRoles } from "@/interfaces/entity";
import { User } from "@/interfaces/user";
import client from "@/lib/mongodb";
import { Discount, Package } from "@/interfaces/paypal";
const db = client.db(process.env.MONGODB_DB);
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res)
if (!user) return redirect("/login")
const entityIDs = mapBy(user.entities, 'id')
const entities = await getEntities(isAdmin(user) ? undefined : entityIDs)
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()
return {
props: serialize({ user, entities }),
props: serialize({ user, entities, discounts, packages }),
};
}, sessionOptions);
interface Props {
user: User,
entities: EntityWithRoles[]
discounts: Discount[]
packages: Package[]
}
export default function Home({ user, entities }: Props) {
export default function Home(props: Props) {
const router = useRouter();
return (
@@ -43,7 +53,8 @@ export default function Home({ user, entities }: Props) {
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<PaymentDue entities={entities} user={user} reload={router.reload} />
<PaymentDue {...props} reload={router.reload} />
</>
);
}

View File

@@ -22,7 +22,7 @@ import { Assignment } from "@/interfaces/results";
import { getEntitiesUsers, getUsers } from "@/utils/users.be";
import { getAssignments, getEntitiesAssignments } from "@/utils/assignments.be";
import useGradingSystem from "@/hooks/useGrading";
import { mapBy, redirect, serialize } from "@/utils";
import { findBy, mapBy, redirect, serialize } from "@/utils";
import { getEntitiesWithRoles } from "@/utils/entities.be";
import { checkAccess } from "@/utils/permissions";
import { getGroups, getGroupsByEntities } from "@/utils/groups.be";
@@ -31,6 +31,7 @@ import { Grading } from "@/interfaces";
import { EntityWithRoles } from "@/interfaces/entity";
import CardList from "@/components/High/CardList";
import { requestUser } from "@/utils/api";
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res)
@@ -74,6 +75,7 @@ export default function History({ user, users, assignments, entities, gradingSys
const [filter, setFilter] = useState<Filter>();
const { data: stats, isLoading: isStatsLoading } = useFilterRecordsByUser<Stat[]>(statsUserId || user?.id);
const allowedDownloadEntities = useAllowedEntities(user, entities, 'download_student_record')
const renderPdfIcon = usePDFDownload("stats");
@@ -155,6 +157,9 @@ export default function History({ user, users, assignments, entities, gradingSys
const customContent = (timestamp: string) => {
const dateStats = groupedStats[timestamp];
const statUser = findBy(users, 'id', dateStats[0]?.user)
const canDownload = mapBy(statUser?.entities, 'id').some(e => mapBy(allowedDownloadEntities, 'id').includes(e))
return (
<StatsGridItem
@@ -169,7 +174,7 @@ export default function History({ user, users, assignments, entities, gradingSys
selectedTrainingExams={selectedTrainingExams}
setSelectedTrainingExams={setSelectedTrainingExams}
maxTrainingExams={MAX_TRAINING_EXAMS}
renderPdfIcon={renderPdfIcon}
renderPdfIcon={canDownload ? renderPdfIcon : undefined}
/>
);
};

View File

@@ -59,7 +59,13 @@ export type RolePermission =
"view_statistics" |
"download_statistics_report" |
"edit_grading_system" |
"view_student_performance"
"view_student_performance" |
"upload_classroom" |
"download_user_list" |
"view_student_record" |
"download_student_record" |
"pay_entity" |
"view_payment_record"
export const DEFAULT_PERMISSIONS: RolePermission[] = [
"view_students",
@@ -132,5 +138,11 @@ export const ADMIN_PERMISSIONS: RolePermission[] = [
"view_statistics",
"download_statistics_report",
"edit_grading_system",
"view_student_performance"
"view_student_performance",
"upload_classroom",
"download_user_list",
"view_student_record",
"download_student_record",
"pay_entity",
"view_payment_record"
]

View File

@@ -158,7 +158,7 @@ const defaultModuleSettings = (module: Module, minTimer: number): ModuleState =>
examLabel: defaultExamLabel(module),
minTimer,
difficulty: sample(["A1", "A2", "B1", "B2", "C1", "C2"] as Difficulty[])!,
isPrivate: false,
isPrivate: true,
sectionLabels: sectionLabels(module),
expandedSections: [1],
focusedSection: 1,

View File

@@ -82,6 +82,17 @@ export const addUsersToEntity = async (users: string[], entity: string, role: st
},
);
export const removeUsersFromEntity = async (users: string[], entity: string) =>
await db.collection("users").updateMany(
{ id: { $in: users } },
{
// @ts-expect-error
$pull: {
entities: { id: entity },
},
},
);
export const deleteEntity = async (entity: Entity) => {
await db.collection("entities").deleteOne({ id: entity.id })
await db.collection("roles").deleteMany({ entityID: entity.id })

View File

@@ -9,6 +9,7 @@ import { Db, ObjectId } from "mongodb";
import client from "@/lib/mongodb";
import { MODULE_ARRAY } from "./moduleUtils";
import { mapBy } from ".";
import { getUser } from "./users.be";
const db = client.db(process.env.MONGODB_DB);
@@ -76,7 +77,7 @@ export const getExams = async (
})) as Exam[],
).filter((x) => !x.private);
let exams: Exam[] = await filterByOwners(shuffledPublicExams, userId);
let exams: Exam[] = await filterByEntities(shuffledPublicExams, userId);
exams = filterByVariant(exams, variant);
exams = filterByInstructorGender(exams, instructorGender);
exams = await filterByDifficulty(db, exams, module, userId);
@@ -109,16 +110,17 @@ const filterByVariant = (exams: Exam[], variant?: Variant) => {
return filtered.length > 0 ? filtered : exams;
};
const filterByOwners = async (exams: Exam[], userID?: string) => {
if (!userID) return exams.filter((x) => !x.owners || x.owners.length === 0);
const filterByEntities = async (exams: Exam[], userID?: string) => {
if (!userID) return exams.filter((x) => !x.entities || x.entities.length === 0);
const user = await getUser(userID)
return await Promise.all(
exams.filter(async (x) => {
if (!x.owners) return true;
if (x.owners.length === 0) return true;
if (x.owners.includes(userID)) return true;
if (!x.entities) return true;
if (x.entities.length === 0) return true;
const corporate = await getUserCorporate(userID);
return !corporate ? false : x.owners.includes(corporate.id);
return mapBy(user?.entities || [], 'id').some(e => x.entities!.includes(e))
}),
);
};

View File

@@ -3,6 +3,7 @@ import { WithEntity } from "@/interfaces/entity";
import { Assignment } from "@/interfaces/results";
import { CorporateUser, Group, GroupWithUsers, MasterCorporateUser, StudentUser, TeacherUser, Type, User } from "@/interfaces/user";
import client from "@/lib/mongodb";
import { uniq } from "lodash";
import moment from "moment";
import { getLinkedUsers, getUser } from "./users.be";
import { getSpecificUsers } from "./users.be";
@@ -116,7 +117,7 @@ export const getUsersGroups = async (ids: string[]) => {
export const convertToUsers = (group: Group, users: User[]): GroupWithUsers =>
Object.assign(group, {
admin: users.find((u) => u.id === group.admin),
participants: group.participants.map((p) => users.find((u) => u.id === p)).filter((x) => !!x) as User[],
participants: uniq(group.participants).map((p) => users.find((u) => u.id === p)).filter((x) => !!x) as User[],
});
export const getAllAssignersByCorporate = async (corporateID: string, type: Type): Promise<string[]> => {

View File

@@ -14,6 +14,8 @@ export const getRolesByEntity = async (entityID: string) => await db.collection(
export const getRoles = async (ids?: string[]) => await db.collection("roles").find<Role>(!ids ? {} : { id: { $in: ids } }).toArray();
export const getRole = async (id: string) => (await db.collection("roles").findOne<Role>({ id })) ?? undefined;
export const getDefaultRole = async (entityID: string) => await db.collection("roles").findOne<Role>({ isDefault: true, entityID })
export const createRole = async (role: Role) => await db.collection("roles").insertOne(role)
export const deleteRole = async (id: string) => await db.collection("roles").deleteOne({ id })

View File

@@ -171,7 +171,7 @@ export const countAllowedUsers = async (user: User, entities: EntityWithRoles[])
const student = await countEntitiesUsers(mapBy(studentsAllowedEntities, 'id'), { type: "student" })
const teacher = await countEntitiesUsers(mapBy(teachersAllowedEntities, 'id'), { type: "teacher" })
const corporate = await countEntitiesUsers(mapBy(corporateAllowedEntities, 'id'), { type: "corporate" })
const masterCorporate = await countEntitiesUsers(mapBy(masterCorporateAllowedEntities, 'id'), { type: "mastercorporate" })
const mastercorporate = await countEntitiesUsers(mapBy(masterCorporateAllowedEntities, 'id'), { type: "mastercorporate" })
return { student, teacher, corporate, masterCorporate }
return { student, teacher, corporate, mastercorporate }
}