- Added more panels and lists;
- Added the ability to view more information on the user; - Added the ability to update the user's expiry date
This commit is contained in:
@@ -37,7 +37,7 @@ export default function Layout({user, children, className, navDisabled = false,
|
|||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-full min-h-full h-fit md:mr-8 bg-white shadow-md rounded-2xl p-4 xl:p-10 pb-8 flex flex-col gap-8 md:gap-12 relative overflow-hidden mt-2",
|
"w-full min-h-full h-fit md:mr-8 bg-white shadow-md rounded-2xl p-4 xl:p-10 pb-8 flex flex-col gap-8 relative overflow-hidden mt-2",
|
||||||
className,
|
className,
|
||||||
)}>
|
)}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import countryCodes from "country-codes-list";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value?: string;
|
value?: string;
|
||||||
onChange: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapCountries = (codes: string[]) => {
|
const mapCountries = (codes: string[]) => {
|
||||||
@@ -18,7 +19,7 @@ const mapCountries = (codes: string[]) => {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CountrySelect({value, onChange}: Props) {
|
export default function CountrySelect({value, disabled = false, onChange}: Props) {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
const filteredCountries =
|
const filteredCountries =
|
||||||
@@ -32,11 +33,11 @@ export default function CountrySelect({value, onChange}: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Combobox value={value} onChange={onChange}>
|
<Combobox value={value} onChange={onChange} disabled={disabled}>
|
||||||
<div className="relative mt-1">
|
<div className="relative mt-1">
|
||||||
<div className="relative w-full cursor-default overflow-hidden ">
|
<div className="relative w-full cursor-default overflow-hidden ">
|
||||||
<Combobox.Input
|
<Combobox.Input
|
||||||
className="py-6 w-full px-8 text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
|
className="py-6 w-full px-8 text-sm font-normal placeholder:text-mti-gray-cool bg-white disabled:bg-mti-gray-platinum/40 rounded-full border border-mti-gray-platinum focus:outline-none"
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
displayValue={(code: string) => {
|
displayValue={(code: string) => {
|
||||||
const country = countries[code as unknown as keyof TCountries];
|
const country = countries[code as unknown as keyof TCountries];
|
||||||
|
|||||||
50
src/components/Modal.tsx
Normal file
50
src/components/Modal.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import {Dialog, Transition} from "@headlessui/react";
|
||||||
|
import {Fragment, ReactElement} from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
|
children?: ReactElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Modal({isOpen, title, onClose, children}: Props) {
|
||||||
|
return (
|
||||||
|
<Transition appear show={isOpen} as={Fragment}>
|
||||||
|
<Dialog as="div" className="relative z-10" onClose={onClose}>
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0">
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 overflow-y-auto">
|
||||||
|
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 scale-95"
|
||||||
|
enterTo="opacity-100 scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 scale-100"
|
||||||
|
leaveTo="opacity-0 scale-95">
|
||||||
|
<Dialog.Panel className="w-full max-w-6xl transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
|
||||||
|
{title && (
|
||||||
|
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
|
||||||
|
{title}
|
||||||
|
</Dialog.Title>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Transition.Child>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
|
}
|
||||||
230
src/components/UserCard.tsx
Normal file
230
src/components/UserCard.tsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import useStats from "@/hooks/useStats";
|
||||||
|
import {EMPLOYMENT_STATUS, User} from "@/interfaces/user";
|
||||||
|
import {groupBySession, averageScore} from "@/utils/stats";
|
||||||
|
import {RadioGroup} from "@headlessui/react";
|
||||||
|
import axios from "axios";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import moment from "moment";
|
||||||
|
import {useState} from "react";
|
||||||
|
import ReactDatePicker from "react-datepicker";
|
||||||
|
import {BsFileEarmarkText, BsPencil, BsStar} from "react-icons/bs";
|
||||||
|
import {toast} from "react-toastify";
|
||||||
|
import Button from "./Low/Button";
|
||||||
|
import Checkbox from "./Low/Checkbox";
|
||||||
|
import CountrySelect from "./Low/CountrySelect";
|
||||||
|
import Input from "./Low/Input";
|
||||||
|
import ProfileSummary from "./ProfileSummary";
|
||||||
|
|
||||||
|
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 UserCard = ({onClose, ...user}: User & {onClose: (reload?: boolean) => void}) => {
|
||||||
|
const [expiryDate, setExpiryDate] = useState<Date | null | undefined>(user.subscriptionExpirationDate);
|
||||||
|
const {stats} = useStats(user.id);
|
||||||
|
|
||||||
|
const updateUser = () => {
|
||||||
|
if (!confirm(`Are you sure you want to update ${user.name}'s account?`)) return;
|
||||||
|
|
||||||
|
axios
|
||||||
|
.post<{user?: User; ok?: boolean}>(`/api/users/update?id=${user.id}`, {...user, subscriptionExpirationDate: expiryDate})
|
||||||
|
.then(() => {
|
||||||
|
toast.success("User updated successfully!");
|
||||||
|
onClose(true);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Something went wrong!", {toastId: "update-error"});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ProfileSummary
|
||||||
|
user={user}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
icon: <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||||
|
value: Object.keys(groupBySession(stats)).length,
|
||||||
|
label: "Exams",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||||
|
value: stats.length,
|
||||||
|
label: "Exercises",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <BsStar className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
|
||||||
|
value: `${stats.length > 0 ? averageScore(stats) : 0}%`,
|
||||||
|
label: "Average Score",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-4 justify-between">
|
||||||
|
<div className="flex flex-col md:flex-row gap-8 w-full">
|
||||||
|
<Input
|
||||||
|
label="Name"
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
onChange={() => null}
|
||||||
|
placeholder="Enter your name"
|
||||||
|
defaultValue={user.name}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="E-mail Address"
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
onChange={() => null}
|
||||||
|
placeholder="Enter email address"
|
||||||
|
defaultValue={user.email}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col md:flex-row gap-8 w-full">
|
||||||
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Country</label>
|
||||||
|
<CountrySelect disabled value={user.demographicInformation?.country} />
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
type="tel"
|
||||||
|
name="phone"
|
||||||
|
label="Phone number"
|
||||||
|
onChange={() => null}
|
||||||
|
placeholder="Enter phone number"
|
||||||
|
defaultValue={user.demographicInformation?.phone}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col md:flex-row gap-8 w-full">
|
||||||
|
<div className="relative flex flex-col gap-3 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Employment Status</label>
|
||||||
|
<RadioGroup
|
||||||
|
value={user.demographicInformation?.employment}
|
||||||
|
className="grid grid-cols-2 items-center gap-4 place-items-center">
|
||||||
|
{EMPLOYMENT_STATUS.map(({status, label}) => (
|
||||||
|
<RadioGroup.Option value={status} key={status}>
|
||||||
|
{({checked}) => (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"px-6 py-4 w-40 md:w-48 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
!checked
|
||||||
|
? "bg-white border-mti-gray-platinum"
|
||||||
|
: "bg-mti-purple-light border-mti-purple-dark text-white",
|
||||||
|
)}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</RadioGroup.Option>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-8 w-full">
|
||||||
|
<div className="relative flex flex-col gap-3 w-full">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Gender</label>
|
||||||
|
<RadioGroup value={user.demographicInformation?.gender} className="flex flex-row gap-4 justify-between">
|
||||||
|
<RadioGroup.Option value="male">
|
||||||
|
{({checked}) => (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
!checked
|
||||||
|
? "bg-white border-mti-gray-platinum"
|
||||||
|
: "bg-mti-purple-light border-mti-purple-dark text-white",
|
||||||
|
)}>
|
||||||
|
Male
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</RadioGroup.Option>
|
||||||
|
<RadioGroup.Option value="female">
|
||||||
|
{({checked}) => (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
!checked
|
||||||
|
? "bg-white border-mti-gray-platinum"
|
||||||
|
: "bg-mti-purple-light border-mti-purple-dark text-white",
|
||||||
|
)}>
|
||||||
|
Female
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</RadioGroup.Option>
|
||||||
|
<RadioGroup.Option value="other">
|
||||||
|
{({checked}) => (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
!checked
|
||||||
|
? "bg-white border-mti-gray-platinum"
|
||||||
|
: "bg-mti-purple-light border-mti-purple-dark text-white",
|
||||||
|
)}>
|
||||||
|
Other
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</RadioGroup.Option>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<label className="font-normal text-base text-mti-gray-dim">Expiry Date</label>
|
||||||
|
<Checkbox
|
||||||
|
isChecked={!!expiryDate}
|
||||||
|
onChange={(checked) => setExpiryDate(checked ? user.subscriptionExpirationDate || new Date() : undefined)}>
|
||||||
|
Enabled
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
{!expiryDate && (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"p-6 w-full flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
!expiryDate ? "!bg-mti-green-ultralight !border-mti-green-light" : expirationDateColor(expiryDate),
|
||||||
|
"bg-white border-mti-gray-platinum",
|
||||||
|
)}>
|
||||||
|
{!expiryDate && "Unlimited"}
|
||||||
|
{expiryDate && moment(expiryDate).format("DD/MM/YYYY")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{expiryDate && (
|
||||||
|
<ReactDatePicker
|
||||||
|
className={clsx(
|
||||||
|
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
|
"hover:border-mti-purple tooltip",
|
||||||
|
expirationDateColor(expiryDate),
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
)}
|
||||||
|
filterDate={(date) => moment(date).isAfter(new Date())}
|
||||||
|
dateFormat="dd/MM/yyyy"
|
||||||
|
selected={moment(expiryDate).toDate()}
|
||||||
|
onChange={(date) => setExpiryDate(date)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="flex gap-4 justify-end mt-4 w-full">
|
||||||
|
<Button className="w-full max-w-[200px]" variant="outline" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button onClick={updateUser} className="w-full max-w-[200px]">
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserCard;
|
||||||
@@ -22,6 +22,13 @@ export const PERMISSIONS = {
|
|||||||
owner: ["developer", "owner"],
|
owner: ["developer", "owner"],
|
||||||
developer: ["developer"],
|
developer: ["developer"],
|
||||||
},
|
},
|
||||||
|
updateExpiryDate: {
|
||||||
|
student: ["developer", "owner"],
|
||||||
|
teacher: ["developer", "owner"],
|
||||||
|
admin: ["owner", "developer"],
|
||||||
|
owner: ["developer", "owner"],
|
||||||
|
developer: ["developer"],
|
||||||
|
},
|
||||||
examManagement: {
|
examManagement: {
|
||||||
delete: ["developer", "owner"],
|
delete: ["developer", "owner"],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,64 +1,149 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import ProgressBar from "@/components/Low/ProgressBar";
|
import Modal from "@/components/Modal";
|
||||||
import ProfileSummary from "@/components/ProfileSummary";
|
|
||||||
import useStats from "@/hooks/useStats";
|
import useStats from "@/hooks/useStats";
|
||||||
import useUsers from "@/hooks/useUsers";
|
import useUsers from "@/hooks/useUsers";
|
||||||
import {User} from "@/interfaces/user";
|
import {User} from "@/interfaces/user";
|
||||||
|
import UserList from "@/pages/(admin)/Lists/UserList";
|
||||||
import {dateSorter} from "@/utils";
|
import {dateSorter} from "@/utils";
|
||||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
|
||||||
import {averageScore, groupBySession} from "@/utils/stats";
|
|
||||||
import {capitalize} from "lodash";
|
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {
|
import {useState} from "react";
|
||||||
BsBook,
|
import {BsArrowLeft, BsGlobeCentralSouthAsia, BsPerson, BsPersonFill, BsPersonFillGear, BsPersonGear, BsPersonLinesFill} from "react-icons/bs";
|
||||||
BsFileEarmarkText,
|
import UserCard from "@/components/UserCard";
|
||||||
BsGlobeCentralSouthAsia,
|
|
||||||
BsHeadphones,
|
|
||||||
BsMegaphone,
|
|
||||||
BsPen,
|
|
||||||
BsPencil,
|
|
||||||
BsPeopleFill,
|
|
||||||
BsPerson,
|
|
||||||
BsPersonFill,
|
|
||||||
BsPersonFillGear,
|
|
||||||
BsPersonGear,
|
|
||||||
BsPersonLinesFill,
|
|
||||||
BsStar,
|
|
||||||
} from "react-icons/bs";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OwnerDashboard({user}: Props) {
|
export default function OwnerDashboard({user}: Props) {
|
||||||
const {stats} = useStats(user.id);
|
const [page, setPage] = useState("");
|
||||||
const {users} = useUsers();
|
const [selectedUser, setSelectedUser] = useState<User>();
|
||||||
|
|
||||||
return (
|
const {stats} = useStats(user.id);
|
||||||
|
const {users, reload} = useUsers();
|
||||||
|
|
||||||
|
const UserDisplay = (displayUser: User) => (
|
||||||
|
<div
|
||||||
|
onClick={() => setSelectedUser(displayUser)}
|
||||||
|
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
|
||||||
|
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
|
||||||
|
<div className="flex flex-col gap-1 items-start">
|
||||||
|
<span>{displayUser.name}</span>
|
||||||
|
<span className="text-sm opacity-75">{displayUser.email}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const StudentsList = () => (
|
||||||
<>
|
<>
|
||||||
<div className="w-full flex flex-wrap gap-4 items-center justify-between">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-48 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
<div
|
||||||
|
onClick={() => setPage("")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Students</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UserList user={user} filter={(x) => x.type === "student"} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const TeachersList = () => (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => setPage("")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Teachers</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UserList user={user} filter={(x) => x.type === "teacher"} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CorporateList = () => (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => setPage("")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Corporate</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UserList user={user} filter={(x) => x.type === "admin"} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const InactiveStudentsList = () => (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => setPage("")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Inactive Students</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UserList user={user} filter={(x) => x.type === "student" && (x.isDisabled || moment().isAfter(x.subscriptionExpirationDate))} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const InactiveCorporateList = () => (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => setPage("")}
|
||||||
|
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
|
||||||
|
<BsArrowLeft className="text-xl" />
|
||||||
|
<span>Back</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold">Inactive Corporate</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UserList user={user} filter={(x) => x.type === "admin" && (x.isDisabled || moment().isAfter(x.subscriptionExpirationDate))} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const DefaultDashboard = () => (
|
||||||
|
<>
|
||||||
|
<section className="w-full flex flex-wrap gap-4 items-center justify-between">
|
||||||
|
<div
|
||||||
|
onClick={() => setPage("students")}
|
||||||
|
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-52 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
||||||
<BsPersonFill className="text-mti-purple-light text-6xl" />
|
<BsPersonFill className="text-mti-purple-light text-6xl" />
|
||||||
<span className="flex flex-col gap-1 items-center text-xl">
|
<span className="flex flex-col gap-1 items-center text-xl">
|
||||||
<span className="text-lg">Students</span>
|
<span className="text-lg">Students</span>
|
||||||
<span className="font-semibold text-mti-purple">{users.filter((x) => x.type === "student").length}</span>
|
<span className="font-semibold text-mti-purple">{users.filter((x) => x.type === "student").length}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-48 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
<div
|
||||||
|
onClick={() => setPage("teachers")}
|
||||||
|
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-52 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
||||||
<BsPersonLinesFill className="text-mti-purple-light text-6xl" />
|
<BsPersonLinesFill className="text-mti-purple-light text-6xl" />
|
||||||
<span className="flex flex-col gap-1 items-center text-xl">
|
<span className="flex flex-col gap-1 items-center text-xl">
|
||||||
<span className="text-lg">Teachers</span>
|
<span className="text-lg">Teachers</span>
|
||||||
<span className="font-semibold text-mti-purple">{users.filter((x) => x.type === "teacher").length}</span>
|
<span className="font-semibold text-mti-purple">{users.filter((x) => x.type === "teacher").length}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-48 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
<div
|
||||||
|
onClick={() => setPage("corporate")}
|
||||||
|
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-52 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
||||||
<BsPersonFillGear className="text-mti-purple-light text-6xl" />
|
<BsPersonFillGear className="text-mti-purple-light text-6xl" />
|
||||||
<span className="flex flex-col gap-1 items-center text-xl">
|
<span className="flex flex-col gap-1 items-center text-xl">
|
||||||
<span className="text-lg">Corporate</span>
|
<span className="text-lg">Corporate</span>
|
||||||
<span className="font-semibold text-mti-purple">{users.filter((x) => x.type === "admin").length}</span>
|
<span className="font-semibold text-mti-purple">{users.filter((x) => x.type === "admin").length}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-48 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
<div className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-52 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
||||||
<BsGlobeCentralSouthAsia className="text-mti-purple-light text-6xl" />
|
<BsGlobeCentralSouthAsia className="text-mti-purple-light text-6xl" />
|
||||||
<span className="flex flex-col gap-1 items-center text-xl">
|
<span className="flex flex-col gap-1 items-center text-xl">
|
||||||
<span className="text-lg">Countries</span>
|
<span className="text-lg">Countries</span>
|
||||||
@@ -67,63 +152,135 @@ export default function OwnerDashboard({user}: Props) {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-48 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
<div
|
||||||
<BsPerson className="text-mti-purple-light text-6xl" />
|
onClick={() => setPage("inactiveStudents")}
|
||||||
|
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-52 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
||||||
|
<BsPerson className="text-mti-rose-light text-6xl" />
|
||||||
<span className="flex flex-col gap-1 items-center text-xl">
|
<span className="flex flex-col gap-1 items-center text-xl">
|
||||||
<span className="text-lg">Inactive Students</span>
|
<span className="text-lg">Inactive Students</span>
|
||||||
<span className="font-semibold text-mti-purple">
|
<span className="font-semibold text-mti-rose">
|
||||||
{users.filter((x) => x.type === "student" && (x.isDisabled || moment().isAfter(x.subscriptionExpirationDate))).length}
|
{users.filter((x) => x.type === "student" && (x.isDisabled || moment().isAfter(x.subscriptionExpirationDate))).length}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-48 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
<div
|
||||||
<BsPersonGear className="text-mti-purple-light text-6xl" />
|
onClick={() => setPage("inactiveCorporate")}
|
||||||
|
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-52 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
|
||||||
|
<BsPersonGear className="text-mti-rose-light text-6xl" />
|
||||||
<span className="flex flex-col gap-1 items-center text-xl">
|
<span className="flex flex-col gap-1 items-center text-xl">
|
||||||
<span className="text-lg text-center">Inactive Corporate</span>
|
<span className="text-lg text-center">Inactive Corporate</span>
|
||||||
<span className="font-semibold text-mti-purple">
|
<span className="font-semibold text-mti-rose">
|
||||||
{users.filter((x) => x.type === "admin" && (x.isDisabled || moment().isAfter(x.subscriptionExpirationDate))).length}
|
{users.filter((x) => x.type === "admin" && (x.isDisabled || moment().isAfter(x.subscriptionExpirationDate))).length}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
<div className="flex gap-4 w-full">
|
|
||||||
<div className="bg-white shadow p-4 flex flex-col gap-4 rounded-xl w-96">
|
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 w-full justify-between">
|
||||||
<span>Latest students</span>
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<div className="flex flex-col gap-4 items-start">
|
<span className="p-4">Latest students</span>
|
||||||
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{users
|
||||||
.filter((x) => x.type === "student")
|
.filter((x) => x.type === "student")
|
||||||
.sort((a, b) => dateSorter(a, b, "asc", "registrationDate"))
|
.sort((a, b) => dateSorter(a, b, "asc", "registrationDate"))
|
||||||
.slice(0, (users.filter((x) => x.type === "student").length - 1) / 2)
|
|
||||||
.map((x) => (
|
.map((x) => (
|
||||||
<div className="flex gap-4 items-center" key={x.id}>
|
<UserDisplay key={x.id} {...x} />
|
||||||
<img src={x.profilePicture} alt={x.name} className="rounded-full w-10 h-10" />
|
|
||||||
<div className="flex flex-col gap-1 items-start">
|
|
||||||
<span>{x.name}</span>
|
|
||||||
<span className="text-sm opacity-75">{x.email}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white shadow p-4 flex flex-col gap-4 rounded-xl w-96">
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
<span>Latest corporate</span>
|
<span className="p-4">Latest corporate</span>
|
||||||
<div className="flex flex-col gap-4 items-start">
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
{users
|
{users
|
||||||
.filter((x) => x.type === "admin")
|
.filter((x) => x.type === "admin")
|
||||||
.sort((a, b) => dateSorter(a, b, "asc", "registrationDate"))
|
.sort((a, b) => dateSorter(a, b, "asc", "registrationDate"))
|
||||||
.slice(0, (users.filter((x) => x.type === "admin").length - 1) / 2)
|
|
||||||
.map((x) => (
|
.map((x) => (
|
||||||
<div className="flex gap-4 items-center" key={x.id}>
|
<UserDisplay key={x.id} {...x} />
|
||||||
<img src={x.profilePicture} alt={x.name} className="rounded-full w-10 h-10" />
|
|
||||||
<div className="flex flex-col gap-1 items-start">
|
|
||||||
<span>{x.name}</span>
|
|
||||||
<span className="text-sm opacity-75">{x.email}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
|
<span className="p-4">Disabled Corporate</span>
|
||||||
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
|
{users
|
||||||
|
.filter((x) => x.type === "admin" && x.isDisabled)
|
||||||
|
.map((x) => (
|
||||||
|
<UserDisplay key={x.id} {...x} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
|
<span className="p-4">Students expiring in 1 month</span>
|
||||||
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
|
{users
|
||||||
|
.filter(
|
||||||
|
(x) =>
|
||||||
|
x.type === "student" &&
|
||||||
|
x.subscriptionExpirationDate &&
|
||||||
|
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")),
|
||||||
|
)
|
||||||
|
.map((x) => (
|
||||||
|
<UserDisplay key={x.id} {...x} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
|
<span className="p-4">Teachers expiring in 1 month</span>
|
||||||
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
|
{users
|
||||||
|
.filter(
|
||||||
|
(x) =>
|
||||||
|
x.type === "teacher" &&
|
||||||
|
x.subscriptionExpirationDate &&
|
||||||
|
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")),
|
||||||
|
)
|
||||||
|
.map((x) => (
|
||||||
|
<UserDisplay key={x.id} {...x} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white shadow flex flex-col rounded-xl w-full">
|
||||||
|
<span className="p-4">Corporate expiring in 1 month</span>
|
||||||
|
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
|
||||||
|
{users
|
||||||
|
.filter(
|
||||||
|
(x) =>
|
||||||
|
x.type === "admin" &&
|
||||||
|
x.subscriptionExpirationDate &&
|
||||||
|
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")),
|
||||||
|
)
|
||||||
|
.map((x) => (
|
||||||
|
<UserDisplay key={x.id} {...x} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal isOpen={!!selectedUser} onClose={() => setSelectedUser(undefined)}>
|
||||||
|
<>
|
||||||
|
{selectedUser && (
|
||||||
|
<div className="w-full flex flex-col gap-8">
|
||||||
|
<UserCard
|
||||||
|
onClose={(shouldReload) => {
|
||||||
|
setSelectedUser(undefined);
|
||||||
|
if (shouldReload) reload();
|
||||||
|
}}
|
||||||
|
{...selectedUser}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
</Modal>
|
||||||
|
{page === "students" && <StudentsList />}
|
||||||
|
{page === "teachers" && <TeachersList />}
|
||||||
|
{page === "corporate" && <CorporateList />}
|
||||||
|
{page === "inactiveStudents" && <InactiveStudentsList />}
|
||||||
|
{page === "inactiveCorporate" && <InactiveCorporateList />}
|
||||||
|
{page === "" && <DefaultDashboard />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,17 +10,20 @@ import clsx from "clsx";
|
|||||||
import {capitalize, reverse} from "lodash";
|
import {capitalize, reverse} from "lodash";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import {Fragment, useEffect, useState} from "react";
|
import {Fragment, useEffect, useState} from "react";
|
||||||
import {BsArrowDown, BsArrowDownUp, BsArrowUp, BsCheck, BsCheckCircle, BsFillExclamationOctagonFill, BsPerson, BsTrash} from "react-icons/bs";
|
import {BsArrowDown, BsArrowDownUp, BsArrowUp, BsCheck, BsCheckCircle, BsEye, BsFillExclamationOctagonFill, BsPerson, BsTrash} from "react-icons/bs";
|
||||||
import {toast} from "react-toastify";
|
import {toast} from "react-toastify";
|
||||||
import {countries, TCountries} from "countries-list";
|
import {countries, TCountries} from "countries-list";
|
||||||
import countryCodes from "country-codes-list";
|
import countryCodes from "country-codes-list";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import UserCard from "@/components/UserCard";
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<User>();
|
const columnHelper = createColumnHelper<User>();
|
||||||
|
|
||||||
export default function UserList({user}: {user: User}) {
|
export default function UserList({user, filter}: {user: User; filter?: (user: User) => boolean}) {
|
||||||
const [showDemographicInformation, setShowDemographicInformation] = useState(false);
|
const [showDemographicInformation, setShowDemographicInformation] = useState(false);
|
||||||
const [sorter, setSorter] = useState<string>();
|
const [sorter, setSorter] = useState<string>();
|
||||||
const [displayUsers, setDisplayUsers] = useState<User[]>([]);
|
const [displayUsers, setDisplayUsers] = useState<User[]>([]);
|
||||||
|
const [selectedUser, setSelectedUser] = useState<User>();
|
||||||
|
|
||||||
const {users, reload} = useUsers();
|
const {users, reload} = useUsers();
|
||||||
const {groups} = useGroups(user ? user.id : undefined);
|
const {groups} = useGroups(user ? user.id : undefined);
|
||||||
@@ -30,7 +33,9 @@ export default function UserList({user}: {user: User}) {
|
|||||||
const filterUsers =
|
const filterUsers =
|
||||||
user.type === "admin" || user.type === "student" ? users.filter((u) => groups.flatMap((g) => g.participants).includes(u.id)) : users;
|
user.type === "admin" || user.type === "student" ? users.filter((u) => groups.flatMap((g) => g.participants).includes(u.id)) : users;
|
||||||
|
|
||||||
setDisplayUsers([...filterUsers.sort(sortFunction)]);
|
const filteredUsers = filter ? filterUsers.filter(filter) : filterUsers;
|
||||||
|
|
||||||
|
setDisplayUsers([...filteredUsers.sort(sortFunction)]);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [user, users, sorter, groups]);
|
}, [user, users, sorter, groups]);
|
||||||
@@ -186,7 +191,16 @@ export default function UserList({user}: {user: User}) {
|
|||||||
<SorterArrow name="name" />
|
<SorterArrow name="name" />
|
||||||
</button>
|
</button>
|
||||||
) as any,
|
) as any,
|
||||||
cell: (info) => info.getValue(),
|
cell: ({row, getValue}) => (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
PERMISSIONS.updateExpiryDate[row.original.type].includes(user.type) &&
|
||||||
|
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer",
|
||||||
|
)}
|
||||||
|
onClick={() => (PERMISSIONS.updateExpiryDate[row.original.type].includes(user.type) ? setSelectedUser(row.original) : null)}>
|
||||||
|
{getValue()}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("demographicInformation.country", {
|
columnHelper.accessor("demographicInformation.country", {
|
||||||
header: (
|
header: (
|
||||||
@@ -251,7 +265,16 @@ export default function UserList({user}: {user: User}) {
|
|||||||
<SorterArrow name="name" />
|
<SorterArrow name="name" />
|
||||||
</button>
|
</button>
|
||||||
) as any,
|
) as any,
|
||||||
cell: (info) => info.getValue(),
|
cell: ({row, getValue}) => (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
PERMISSIONS.updateExpiryDate[row.original.type].includes(user.type) &&
|
||||||
|
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer",
|
||||||
|
)}
|
||||||
|
onClick={() => (PERMISSIONS.updateExpiryDate[row.original.type].includes(user.type) ? setSelectedUser(row.original) : null)}>
|
||||||
|
{getValue()}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("email", {
|
columnHelper.accessor("email", {
|
||||||
header: (
|
header: (
|
||||||
@@ -260,7 +283,16 @@ export default function UserList({user}: {user: User}) {
|
|||||||
<SorterArrow name="email" />
|
<SorterArrow name="email" />
|
||||||
</button>
|
</button>
|
||||||
) as any,
|
) as any,
|
||||||
cell: (info) => info.getValue(),
|
cell: ({row, getValue}) => (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
PERMISSIONS.updateExpiryDate[row.original.type].includes(user.type) &&
|
||||||
|
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer",
|
||||||
|
)}
|
||||||
|
onClick={() => (PERMISSIONS.updateExpiryDate[row.original.type].includes(user.type) ? setSelectedUser(row.original) : null)}>
|
||||||
|
{getValue()}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor("type", {
|
columnHelper.accessor("type", {
|
||||||
header: (
|
header: (
|
||||||
@@ -397,6 +429,21 @@ export default function UserList({user}: {user: User}) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
|
<Modal isOpen={!!selectedUser} onClose={() => setSelectedUser(undefined)}>
|
||||||
|
<>
|
||||||
|
{selectedUser && (
|
||||||
|
<div className="w-full flex flex-col gap-8">
|
||||||
|
<UserCard
|
||||||
|
onClose={(shouldReload) => {
|
||||||
|
setSelectedUser(undefined);
|
||||||
|
if (shouldReload) reload();
|
||||||
|
}}
|
||||||
|
{...selectedUser}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
</Modal>
|
||||||
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
|
||||||
<thead>
|
<thead>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
|||||||
Reference in New Issue
Block a user