Merge with develop

This commit is contained in:
Carlos-Mesquita
2024-11-06 10:59:26 +00:00
135 changed files with 9517 additions and 3617 deletions

View File

@@ -43,7 +43,7 @@ export default function Diagnostic({onFinish}: Props) {
if (exams.every((x) => !!x)) {
setExams(exams.map((x) => x!));
setSelectedModules(exams.map((x) => x!.module));
router.push("/exercises");
router.push("/exam");
}
});
};

View File

@@ -0,0 +1,34 @@
import {useListSearch} from "@/hooks/useListSearch";
import usePagination from "@/hooks/usePagination";
import { clsx } from "clsx";
import {ReactNode} from "react";
import Checkbox from "../Low/Checkbox";
import Separator from "../Low/Separator";
interface Props<T> {
list: T[];
searchFields: string[][];
pageSize?: number;
firstCard?: () => ReactNode;
renderCard: (item: T) => ReactNode;
className?: string
}
export default function CardList<T>({list, searchFields, renderCard, firstCard, className, pageSize = 20}: Props<T>) {
const {rows, renderSearch} = useListSearch(searchFields, list);
const {items, page, render, renderMinimal} = usePagination(rows, pageSize);
return (
<section className="flex flex-col gap-4 w-full">
<div className="w-full flex items-center gap-4">
{searchFields.length > 0 && renderSearch()}
{searchFields.length > 0 ? renderMinimal() : render()}
</div>
<div className={clsx("w-full h-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4", className)}>
{page === 0 && !!firstCard && firstCard()}
{items.map(renderCard)}
</div>
</section>
);
}

View File

@@ -1,45 +1,66 @@
import useEntities from "@/hooks/useEntities";
import { EntityWithRoles } from "@/interfaces/entity";
import {User} from "@/interfaces/user";
import clsx from "clsx";
import {useRouter} from "next/router";
import BottomBar from "../BottomBar";
import { ToastContainer } from "react-toastify";
import Navbar from "../Navbar";
import Sidebar from "../Sidebar";
interface Props {
user: User;
entities?: EntityWithRoles[]
children: React.ReactNode;
className?: string;
navDisabled?: boolean;
focusMode?: boolean;
hideSidebar?: boolean
bgColor?: string;
onFocusLayerMouseEnter?: () => void;
}
export default function Layout({user, children, className, bgColor="bg-white", navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
export default function Layout({
user,
children,
className,
bgColor="bg-white",
hideSidebar,
navDisabled = false,
focusMode = false,
onFocusLayerMouseEnter
}: Props) {
const router = useRouter();
const {entities} = useEntities()
return (
<main className={clsx("w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke relative")}>
<Navbar
path={router.pathname}
user={user}
navDisabled={navDisabled}
focusMode={focusMode}
onFocusLayerMouseEnter={onFocusLayerMouseEnter}
/>
<div className="h-full w-full flex gap-2">
<Sidebar
<ToastContainer />
{!hideSidebar && (
<Navbar
path={router.pathname}
user={user}
navDisabled={navDisabled}
focusMode={focusMode}
onFocusLayerMouseEnter={onFocusLayerMouseEnter}
className="-md:hidden"
user={user}
/>
)}
<div className={clsx("h-full w-full flex gap-2")}>
{!hideSidebar && (
<Sidebar
path={router.pathname}
navDisabled={navDisabled}
focusMode={focusMode}
onFocusLayerMouseEnter={onFocusLayerMouseEnter}
className="-md:hidden"
user={user}
entities={entities}
/>
)}
<div
className={clsx(
`w-full min-h-full md:mr-8 ${bgColor} shadow-md rounded-2xl p-4 xl:p-10 pb-8 flex flex-col gap-8 relative overflow-hidden mt-2`,
`w-full min-h-full ${bgColor} shadow-md rounded-2xl p-4 xl:p-10 pb-8 flex flex-col gap-8 relative overflow-hidden mt-2`,
bgColor !== "bg-white" ? "justify-center" : "h-fit",
hideSidebar ? "md:mx-8" : "md:mr-8",
className,
)}>
{children}

View File

@@ -0,0 +1,107 @@
import { useListSearch } from "@/hooks/useListSearch"
import { ColumnDef, flexRender, getCoreRowModel, getPaginationRowModel, getSortedRowModel, PaginationState, useReactTable } from "@tanstack/react-table"
import clsx from "clsx"
import { useState } from "react"
import { BsArrowDown, BsArrowUp } from "react-icons/bs"
import Button from "../Low/Button"
interface Props<T> {
data: T[]
columns: ColumnDef<any, any>[]
searchFields: string[][]
size?: number
onDownload?: (rows: T[]) => void
}
export default function Table<T>({ data, columns, searchFields, size = 16, onDownload }: Props<T>) {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 16,
})
const { rows, renderSearch } = useListSearch<T>(searchFields, data);
const table = useReactTable({
data: rows,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
state: {
pagination
}
});
return (
<div className="w-full flex flex-col gap-2">
<div className="w-full flex gap-2 items-end">
{renderSearch()}
{onDownload && (
<Button className="w-full max-w-[200px] mb-1" variant="outline" onClick={() => onDownload(rows)}>
Download List
</Button>
)
}
</div>
<div className="w-full flex gap-2 justify-between items-center">
<div className="flex items-center gap-4 w-fit">
<Button className="w-[200px] h-fit" disabled={!table.getCanPreviousPage()} onClick={() => table.previousPage()}>
Previous Page
</Button>
</div>
<div className="flex items-center gap-4 w-fit">
<span className="flex items-center gap-1">
<div>Page</div>
<strong>
{table.getState().pagination.pageIndex + 1} of{' '}
{table.getPageCount().toLocaleString()}
</strong>
<div>| Total: {table.getRowCount().toLocaleString()}</div>
</span>
<Button className="w-[200px]" disabled={!table.getCanNextPage()} onClick={() => table.nextPage()}>
Next Page
</Button>
</div>
</div>
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th className="py-4 px-4 text-left" key={header.id} colSpan={header.colSpan}>
<div
className={clsx(header.column.getCanSort() && 'cursor-pointer select-none', 'flex items-center gap-2')}
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{{
asc: <BsArrowUp />,
desc: <BsArrowDown />,
}[header.column.getIsSorted() as string] ?? null}
</div>
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2 w-full">
{table.getRowModel().rows.map((row) => (
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2 items-center w-fit" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@@ -1,13 +1,26 @@
import {useListSearch} from "@/hooks/useListSearch";
import usePagination from "@/hooks/usePagination";
import {Column, flexRender, getCoreRowModel, getSortedRowModel, useReactTable} from "@tanstack/react-table";
import clsx from "clsx";
import {useMemo, useState} from "react";
import Button from "./Low/Button";
const SIZE = 25;
export default function List<T>({data, columns}: {data: T[]; columns: any[]}) {
const [page, setPage] = useState(0);
export default function List<T>({
data,
columns,
searchFields = [],
pageSize = SIZE,
}: {
data: T[];
columns: any[];
searchFields?: string[][];
pageSize?: number;
}) {
const {rows, renderSearch} = useListSearch(searchFields, data);
const items = useMemo(() => data.slice(page * SIZE, (page + 1) * SIZE > data.length ? data.length : (page + 1) * SIZE), [data, page]);
const {items, page, renderMinimal} = usePagination(rows, pageSize);
const table = useReactTable({
data: items,
@@ -17,19 +30,10 @@ export default function List<T>({data, columns}: {data: T[]; columns: any[]}) {
});
return (
<div className="w-full h-full flex flex-col gap-2">
<div className="w-full flex gap-2 justify-between">
<Button className="w-full max-w-[200px]" disabled={page === 0} onClick={() => setPage((prev) => prev - 1)}>
Previous Page
</Button>
<div className="flex items-center gap-4 w-fit">
<span className="opacity-80">
{page * SIZE + 1} - {(page + 1) * SIZE > data.length ? data.length : (page + 1) * SIZE} / {data.length}
</span>
<Button className="w-[200px]" disabled={(page + 1) * SIZE >= data.length} onClick={() => setPage((prev) => prev + 1)}>
Next Page
</Button>
</div>
<div className="w-full h-full flex flex-col gap-6">
<div className={clsx("w-full flex items-center gap-4", searchFields.length === 0 && "justify-end")}>
{searchFields.length > 0 && renderSearch()}
{renderMinimal()}
</div>
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">

View File

@@ -5,7 +5,7 @@ import {BsCheck} from "react-icons/bs";
interface Props {
isChecked: boolean;
onChange: (isChecked: boolean) => void;
children: ReactNode;
children?: ReactNode;
disabled?: boolean;
}

View File

@@ -13,9 +13,10 @@ interface Props {
isClearable?: boolean;
styles?: StylesConfig<Option, boolean, GroupBase<Option>>;
className?: string;
label?: string;
}
export default function Select({value, defaultValue, options, placeholder, disabled, onChange, styles, isClearable, className}: Props) {
export default function Select({value, defaultValue, options, placeholder, disabled, onChange, styles, isClearable, label, className}: Props) {
const [target, setTarget] = useState<HTMLElement>();
useEffect(() => {
@@ -23,43 +24,46 @@ export default function Select({value, defaultValue, options, placeholder, disab
}, []);
return (
<ReactSelect
className={
styles
? undefined
: clsx(
"placeholder:text-mti-gray-cool border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none",
disabled && "!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
className,
)
}
options={options}
value={value}
onChange={onChange as any}
placeholder={placeholder}
menuPortalTarget={target}
defaultValue={defaultValue}
styles={
styles || {
menuPortal: (base) => ({...base, zIndex: 9999}),
control: (styles) => ({
...styles,
paddingLeft: "4px",
border: "none",
outline: "none",
":focus": {
outline: "none",
},
}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
<div className="w-full flex flex-col gap-3">
{label && <label className="font-normal text-base text-mti-gray-dim">{label}</label>}
<ReactSelect
className={
styles
? undefined
: clsx(
"placeholder:text-mti-gray-cool border-mti-gray-platinum w-full rounded-full border bg-white px-4 py-4 text-sm font-normal focus:outline-none",
disabled && "!bg-mti-gray-platinum/40 !text-mti-gray-dim cursor-not-allowed",
className,
)
}
}
isDisabled={disabled}
isClearable={isClearable}
/>
options={options}
value={value}
onChange={onChange as any}
placeholder={placeholder}
menuPortalTarget={target}
defaultValue={defaultValue}
styles={
styles || {
menuPortal: (base) => ({...base, zIndex: 9999}),
control: (styles) => ({
...styles,
paddingLeft: "4px",
border: "none",
outline: "none",
":focus": {
outline: "none",
},
}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}
}
isDisabled={disabled}
isClearable={isClearable}
/>
</div>
);
}

View File

@@ -0,0 +1,3 @@
const Separator = () => <div className="w-full h-[1px] bg-mti-gray-platinum rounded-full" />;
export default Separator;

View File

@@ -0,0 +1,17 @@
import clsx from "clsx";
import {ReactNode} from "react";
interface Props {
tooltip: string;
disabled?: boolean;
className?: string;
children: ReactNode;
}
export default function Tooltip({tooltip, disabled = false, className, children}: Props) {
return (
<div className={clsx(!disabled && "tooltip", className)} data-tip={tooltip}>
{children}
</div>
);
}

View File

@@ -0,0 +1,67 @@
import {Invite, InviteWithEntity} from "@/interfaces/invite";
import {User} from "@/interfaces/user";
import {getUserName} from "@/utils/users";
import axios from "axios";
import {useMemo, useState} from "react";
import {BsArrowRepeat} from "react-icons/bs";
import {toast} from "react-toastify";
interface Props {
invite: InviteWithEntity;
reload: () => void;
}
export default function InviteWithUserCard({invite, reload}: Props) {
const [isLoading, setIsLoading] = useState(false);
const name = useMemo(() => (!invite.entity ? null : invite.entity.label), [invite.entity]);
const decide = (decision: "accept" | "decline") => {
if (!confirm(`Are you sure you want to ${decision} this invite?`)) return;
setIsLoading(true);
axios
.get(`/api/invites/${decision}/${invite.id}`)
.then(() => {
toast.success(`Successfully ${decision === "accept" ? "accepted" : "declined"} the invite!`, {toastId: "success"});
reload();
})
.catch((e) => {
toast.success(`Something went wrong, please try again later!`, {
toastId: "error",
});
reload();
})
.finally(() => setIsLoading(false));
};
return (
<div className="border-mti-gray-anti-flash flex min-w-[200px] flex-col gap-6 rounded-xl border p-4 text-black">
<span>Invited to <b>{name}</b></span>
<div className="flex items-center gap-2">
<button
onClick={() => decide("accept")}
disabled={isLoading}
className="bg-mti-green-ultralight hover:bg-mti-green-light w-24 rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed">
{!isLoading && "Accept"}
{isLoading && (
<div className="flex items-center justify-center">
<BsArrowRepeat className="animate-spin text-white" size={25} />
</div>
)}
</button>
<button
onClick={() => decide("decline")}
disabled={isLoading}
className="bg-mti-red-ultralight hover:bg-mti-red-light w-24 rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed">
{!isLoading && "Decline"}
{isLoading && (
<div className="flex items-center justify-center">
<BsArrowRepeat className="animate-spin text-white" size={25} />
</div>
)}
</button>
</div>
</div>
);
}

View File

@@ -1,11 +1,13 @@
import { User } from "@/interfaces/user";
import { checkAccess } from "@/utils/permissions";
import Select from "../Low/Select";
import { ReactNode, useEffect, useState } from "react";
import { ReactNode, useEffect, useMemo, useState } from "react";
import clsx from "clsx";
import useUsers from "@/hooks/useUsers";
import useGroups from "@/hooks/useGroups";
import useRecordStore from "@/stores/recordStore";
import { EntityWithRoles } from "@/interfaces/entity";
import { mapBy } from "@/utils";
type TimeFilter = "months" | "weeks" | "days";
@@ -13,6 +15,8 @@ type Filter = TimeFilter | "assignments" | undefined;
interface Props {
user: User;
entities: EntityWithRoles[]
users: User[]
filterState: {
filter: Filter,
setFilter: React.Dispatch<React.SetStateAction<Filter>>
@@ -28,83 +32,41 @@ const defaultSelectableCorporate = {
const RecordFilter: React.FC<Props> = ({
user,
entities,
users,
filterState,
assignments = true,
children
}) => {
const { filter, setFilter } = filterState;
const [statsUserId, setStatsUserId] = useRecordStore((state) => [
const [entity, setEntity] = useState<string>()
const [, setStatsUserId] = useRecordStore((state) => [
state.selectedUser,
state.setSelectedUser
]);
const { users } = useUsers();
const { groups: allGroups } = useGroups({});
const { groups } = useGroups({ admin: user?.id, userType: user?.type });
const entityUsers = useMemo(() => !entity ? users : users.filter(u => mapBy(u.entities, 'id').includes(entity)), [users, entity])
useEffect(() => setStatsUserId(user.id), [setStatsUserId, user.id])
const toggleFilter = (value: "months" | "weeks" | "days" | "assignments") => {
setFilter((prev) => (prev === value ? undefined : value));
};
const selectableCorporates = [
defaultSelectableCorporate,
...users
.filter((x) => groups.flatMap((g) => [g.admin, ...g.participants]).includes(x.id))
.filter((x) => x.type === "corporate")
.map((x) => ({
value: x.id,
label: `${x.name} - ${x.email}`,
})),
];
const [selectedCorporate, setSelectedCorporate] = useState<string>(defaultSelectableCorporate.value);
const getUsersList = (): User[] => {
if (selectedCorporate) {
const selectedCorporateGroups = allGroups.filter((x) => x.admin === selectedCorporate);
const selectedCorporateGroupsParticipants = selectedCorporateGroups.flatMap((x) => x.participants);
const userListWithUsers = selectedCorporateGroupsParticipants.map((x) => users.find((y) => y.id === x)) as User[];
return userListWithUsers.filter((x) => x);
}
return user.type !== "mastercorporate" ? users : users.filter((x) => groups.flatMap((g) => [g.admin, ...g.participants]).includes(x.id));
};
const corporateFilteredUserList = getUsersList();
const getSelectedUser = () => {
if (selectedCorporate) {
const userInCorporate = corporateFilteredUserList.find((x) => x.id === statsUserId);
return userInCorporate || corporateFilteredUserList[0];
}
return users.find((x) => x.id === statsUserId) || user;
};
const selectedUser = getSelectedUser();
const selectedUserSelectValue = selectedUser
? {
value: selectedUser.id,
label: `${selectedUser.name} - ${selectedUser.email}`,
}
: {
value: "",
label: "",
};
return (
<div className="w-full flex -xl:flex-col -xl:gap-4 justify-between items-center">
<div className="xl:w-3/4">
<div className="xl:w-3/4 flex gap-2">
{checkAccess(user, ["developer", "admin", "mastercorporate"]) && !children && (
<>
<label className="font-normal text-base text-mti-gray-dim">Corporate</label>
<div className="flex flex-col gap-2 w-full">
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
<Select
options={selectableCorporates}
value={selectableCorporates.find((x) => x.value === selectedCorporate)}
onChange={(value) => setSelectedCorporate(value?.value || "")}
options={entities.map((e) => ({value: e.id, label: e.label}))}
onChange={(value) => setEntity(value?.value || undefined)}
isClearable
styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
option: (styles, state) => ({
@@ -112,15 +74,17 @@ const RecordFilter: React.FC<Props> = ({
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}></Select>
}} />
</div>
<div className="flex flex-col gap-2 w-full">
<label className="font-normal text-base text-mti-gray-dim">User</label>
<Select
options={corporateFilteredUserList.map((x) => ({
options={entityUsers.map((x) => ({
value: x.id,
label: `${x.name} - ${x.email}`,
}))}
value={selectedUserSelectValue}
defaultValue={{value: user.id, label: `${user.name} - ${user.email}`}}
onChange={(value) => setStatsUserId(value?.value!)}
styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
@@ -131,20 +95,20 @@ const RecordFilter: React.FC<Props> = ({
}),
}}
/>
</div>
</>
)}
{(user.type === "corporate" || user.type === "teacher") && groups.length > 0 && !children && (
<>
{(user.type === "corporate" || user.type === "teacher") && !children && (
<div className="flex flex-col gap-2">
<label className="font-normal text-base text-mti-gray-dim">User</label>
<Select
options={users
.filter((x) => groups.flatMap((y) => y.participants).includes(x.id))
.map((x) => ({
value: x.id,
label: `${x.name} - ${x.email}`,
}))}
value={selectedUserSelectValue}
defaultValue={{value: user.id, label: `${user.name} - ${user.email}`}}
onChange={(value) => setStatsUserId(value?.value!)}
styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
@@ -155,7 +119,7 @@ const RecordFilter: React.FC<Props> = ({
}),
}}
/>
</>
</div>
)}
{children}
</div>
@@ -203,4 +167,4 @@ const RecordFilter: React.FC<Props> = ({
);
}
export default RecordFilter;
export default RecordFilter;

View File

@@ -182,7 +182,7 @@ const StatsGridItem: React.FC<StatsGridItemProps> = ({
.sort(sortByModule)
.map((x) => x!.module),
);
router.push("/exercises");
router.push("/exam");
}
});
}

View File

@@ -105,16 +105,6 @@ export default function MobileMenu({
>
Exams
</Link>
<Link
href={disableNavigation ? "" : "/exercises"}
className={clsx(
"w-fit transition duration-300 ease-in-out",
path === "/exercises" &&
"text-mti-purple-light border-b-mti-purple-light border-b-2 font-semibold "
)}
>
Exercises
</Link>
</>
)}
<Link

View File

@@ -1,242 +1,237 @@
import clsx from "clsx";
import {IconType} from "react-icons";
import {MdSpaceDashboard} from "react-icons/md";
import { IconType } from "react-icons";
import { MdSpaceDashboard } from "react-icons/md";
import {
BsFileEarmarkText,
BsClockHistory,
BsPencil,
BsGraphUp,
BsChevronBarRight,
BsChevronBarLeft,
BsShieldFill,
BsCloudFill,
BsCurrencyDollar,
BsClipboardData,
BsFileLock,
BsPeople,
BsFileEarmarkText,
BsClockHistory,
BsPencil,
BsGraphUp,
BsChevronBarRight,
BsChevronBarLeft,
BsShieldFill,
BsCloudFill,
BsCurrencyDollar,
BsClipboardData,
BsPeople,
} from "react-icons/bs";
import {CiDumbbell} from "react-icons/ci";
import {RiLogoutBoxFill} from "react-icons/ri";
import {SlPencil} from "react-icons/sl";
import {FaAward} from "react-icons/fa";
import { CiDumbbell } from "react-icons/ci";
import { RiLogoutBoxFill } from "react-icons/ri";
import Link from "next/link";
import {useRouter} from "next/router";
import { useRouter } from "next/router";
import axios from "axios";
import FocusLayer from "@/components/FocusLayer";
import {preventNavigation} from "@/utils/navigation.disabled";
import {useEffect, useState} from "react";
import { preventNavigation } from "@/utils/navigation.disabled";
import usePreferencesStore from "@/stores/preferencesStore";
import {User} from "@/interfaces/user";
import { User } from "@/interfaces/user";
import useTicketsListener from "@/hooks/useTicketsListener";
import {checkAccess, getTypesOfUser} from "@/utils/permissions";
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
import usePermissions from "@/hooks/usePermissions";
import { EntityWithRoles } from "@/interfaces/entity";
import { useAllowedEntitiesSomePermissions } from "@/hooks/useEntityPermissions";
import { useMemo } from "react";
interface Props {
path: string;
navDisabled?: boolean;
focusMode?: boolean;
onFocusLayerMouseEnter?: () => void;
className?: string;
user: User;
path: string;
navDisabled?: boolean;
focusMode?: boolean;
onFocusLayerMouseEnter?: () => void;
className?: string;
user: User;
entities?: EntityWithRoles[]
}
interface NavProps {
Icon: IconType;
label: string;
path: string;
keyPath: string;
disabled?: boolean;
isMinimized?: boolean;
badge?: number;
Icon: IconType;
label: string;
path: string;
keyPath: string;
disabled?: boolean;
isMinimized?: boolean;
badge?: number;
}
const Nav = ({Icon, label, path, keyPath, disabled = false, isMinimized = false, badge}: NavProps) => {
return (
<Link
href={!disabled ? keyPath : ""}
className={clsx(
"flex items-center gap-4 rounded-full p-4 text-gray-500 hover:text-white",
"transition-all duration-300 ease-in-out relative",
disabled ? "hover:bg-mti-gray-dim cursor-not-allowed" : "hover:bg-mti-purple-light cursor-pointer",
path === keyPath && "bg-mti-purple-light text-white",
isMinimized ? "w-fit" : "w-full min-w-[200px] px-8 2xl:min-w-[220px]",
)}>
<Icon size={24} />
{!isMinimized && <span className="text-lg font-semibold">{label}</span>}
{!!badge && badge > 0 && (
<div
className={clsx(
"bg-mti-purple-light h-5 w-5 text-xs rounded-full flex items-center justify-center text-white",
"transition ease-in-out duration-300",
isMinimized && "absolute right-0 top-0",
)}>
{badge}
</div>
)}
</Link>
);
const Nav = ({ Icon, label, path, keyPath, disabled = false, isMinimized = false, badge }: NavProps) => {
return (
<Link
href={!disabled ? keyPath : ""}
className={clsx(
"flex items-center gap-4 rounded-full p-4 text-gray-500 hover:text-white",
"transition-all duration-300 ease-in-out relative",
disabled ? "hover:bg-mti-gray-dim cursor-not-allowed" : "hover:bg-mti-purple-light cursor-pointer",
path.startsWith(keyPath) && "bg-mti-purple-light text-white",
isMinimized ? "w-fit" : "w-full min-w-[200px] px-8 2xl:min-w-[220px]",
)}>
<Icon size={24} />
{!isMinimized && <span className="text-lg font-semibold">{label}</span>}
{!!badge && badge > 0 && (
<div
className={clsx(
"bg-mti-purple-light h-5 w-5 text-xs rounded-full flex items-center justify-center text-white",
"transition ease-in-out duration-300",
isMinimized && "absolute right-0 top-0",
)}>
{badge}
</div>
)}
</Link>
);
};
export default function Sidebar({path, navDisabled = false, focusMode = false, user, onFocusLayerMouseEnter, className}: Props) {
const router = useRouter();
export default function Sidebar({
path,
entities = [],
navDisabled = false,
focusMode = false,
user,
onFocusLayerMouseEnter,
className
}: Props) {
const router = useRouter();
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]);
const isAdmin = useMemo(() => ['developer', 'admin'].includes(user?.type), [user?.type])
const {totalAssignedTickets} = useTicketsListener(user.id);
const {permissions} = usePermissions(user.id);
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]);
const logout = async () => {
axios.post("/api/logout").finally(() => {
setTimeout(() => router.reload(), 500);
});
};
const { totalAssignedTickets } = useTicketsListener(user.id);
const { permissions } = usePermissions(user.id);
const disableNavigation = preventNavigation(navDisabled, focusMode);
const entitiesAllowGeneration = useAllowedEntitiesSomePermissions(user, entities, [
"generate_reading", "generate_listening", "generate_writing", "generate_speaking", "generate_level"
])
return (
<section
className={clsx(
"relative flex h-full flex-col justify-between bg-transparent px-4 py-4 pb-8",
isMinimized ? "w-fit" : "-xl:w-fit w-1/6",
className,
)}>
<div className="-xl:hidden flex-col gap-3 xl:flex">
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={isMinimized} />
{checkAccess(user, ["student", "teacher", "developer"], permissions, "viewExams") && (
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={isMinimized} />
)}
{checkAccess(user, ["student", "teacher", "developer"], permissions, "viewExercises") && (
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={isMinimized} />
)}
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewStats") && (
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
)}
{checkAccess(user, ["developer", "admin", "teacher", "student"], permissions) && (
<Nav disabled={disableNavigation} Icon={BsPeople} label="Groups" path={path} keyPath="/groups" isMinimized={isMinimized} />
)}
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
)}
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
<Nav disabled={disableNavigation} Icon={CiDumbbell} label="Training" path={path} keyPath="/training" isMinimized={isMinimized} />
)}
{checkAccess(user, ["admin", "developer", "agent", "corporate", "mastercorporate"], permissions, "viewPaymentRecords") && (
<Nav
disabled={disableNavigation}
Icon={BsCurrencyDollar}
label="Payment Record"
path={path}
keyPath="/payment-record"
isMinimized={isMinimized}
/>
)}
{checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"]) && (
<Nav
disabled={disableNavigation}
Icon={BsShieldFill}
label="Settings"
path={path}
keyPath="/settings"
isMinimized={isMinimized}
/>
)}
{checkAccess(user, ["admin", "developer", "agent"], permissions, "viewTickets") && (
<Nav
disabled={disableNavigation}
Icon={BsClipboardData}
label="Tickets"
path={path}
keyPath="/tickets"
isMinimized={isMinimized}
badge={totalAssignedTickets}
/>
)}
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && (
<Nav
disabled={disableNavigation}
Icon={BsCloudFill}
label="Generation"
path={path}
keyPath="/generation"
isMinimized={isMinimized}
/>
)}
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate", "agent"]) && (
<Nav
disabled={disableNavigation}
Icon={BsFileLock}
label="Permissions"
path={path}
keyPath="/permissions"
isMinimized={isMinimized}
/>
)}
</div>
<div className="-xl:flex flex-col gap-3 xl:hidden">
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={true} />
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={true} />
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={true} />
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewStats") && (
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={true} />
)}
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={true} />
)}
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
<Nav disabled={disableNavigation} Icon={CiDumbbell} label="Training" path={path} keyPath="/training" isMinimized={true} />
)}
{checkAccess(user, getTypesOfUser(["student"])) && (
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Settings" path={path} keyPath="/settings" isMinimized={true} />
)}
{checkAccess(user, getTypesOfUser(["student"])) && (
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Permissions" path={path} keyPath="/permissions" isMinimized={true} />
)}
{checkAccess(user, ["developer"]) && (
<>
<Nav
disabled={disableNavigation}
Icon={BsCloudFill}
label="Generation"
path={path}
keyPath="/generation"
isMinimized={true}
/>
<Nav
disabled={disableNavigation}
Icon={BsFileLock}
label="Permissions"
path={path}
keyPath="/permissions"
isMinimized={true}
/>
</>
)}
</div>
const logout = async () => {
axios.post("/api/logout").finally(() => {
setTimeout(() => router.reload(), 500);
});
};
<div className="2xl:fixed bottom-12 flex flex-col gap-0 -2xl:mt-8">
<div
role="button"
tabIndex={1}
onClick={toggleMinimize}
className={clsx(
"hover:text-mti-rose -xl:hidden flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
)}>
{isMinimized ? <BsChevronBarRight size={24} /> : <BsChevronBarLeft size={24} />}
{!isMinimized && <span className="text-lg font-medium">Minimize</span>}
</div>
<div
role="button"
tabIndex={1}
onClick={focusMode ? () => {} : logout}
className={clsx(
"hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
)}>
<RiLogoutBoxFill size={24} />
{!isMinimized && <span className="-xl:hidden text-lg font-medium">Log Out</span>}
</div>
</div>
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
</section>
);
const disableNavigation = preventNavigation(navDisabled, focusMode);
return (
<section
className={clsx(
"relative flex h-full flex-col justify-between bg-transparent px-4 py-4 pb-8",
isMinimized ? "w-fit" : "-xl:w-fit w-1/6",
className,
)}>
<div className="-xl:hidden flex-col gap-3 xl:flex">
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/dashboard" isMinimized={isMinimized} />
{checkAccess(user, ["student", "teacher", "developer"], permissions, "viewExams") && (
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={isMinimized} />
)}
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewStats") && (
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
)}
{checkAccess(user, ["developer", "admin", "mastercorporate", "corporate", "teacher", "student"], permissions) && (
<Nav
disabled={disableNavigation}
Icon={BsPeople}
label="Classrooms"
path={path}
keyPath="/classrooms"
isMinimized={isMinimized}
/>
)}
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
)}
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
<Nav disabled={disableNavigation} Icon={CiDumbbell} label="Training" path={path} keyPath="/training" isMinimized={isMinimized} />
)}
{checkAccess(user, ["admin", "developer", "agent", "corporate", "mastercorporate"], permissions, "viewPaymentRecords") && (
<Nav
disabled={disableNavigation}
Icon={BsCurrencyDollar}
label="Payment Record"
path={path}
keyPath="/payment-record"
isMinimized={isMinimized}
/>
)}
{checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"]) && (
<Nav
disabled={disableNavigation}
Icon={BsShieldFill}
label="Settings"
path={path}
keyPath="/settings"
isMinimized={isMinimized}
/>
)}
{checkAccess(user, ["admin", "developer", "agent"], permissions, "viewTickets") && (
<Nav
disabled={disableNavigation}
Icon={BsClipboardData}
label="Tickets"
path={path}
keyPath="/tickets"
isMinimized={isMinimized}
badge={totalAssignedTickets}
/>
)}
{(entitiesAllowGeneration.length > 0 || isAdmin) && (
<Nav
disabled={disableNavigation}
Icon={BsCloudFill}
label="Generation"
path={path}
keyPath="/generation"
isMinimized={isMinimized}
/>
)}
</div>
<div className="-xl:flex flex-col gap-3 xl:hidden">
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized />
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized />
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewStats") && (
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized />
)}
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized />
)}
{checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewRecords") && (
<Nav disabled={disableNavigation} Icon={CiDumbbell} label="Training" path={path} keyPath="/training" isMinimized />
)}
{checkAccess(user, getTypesOfUser(["student"])) && (
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Settings" path={path} keyPath="/settings" isMinimized />
)}
{entitiesAllowGeneration.length > 0 && (
<Nav
disabled={disableNavigation}
Icon={BsCloudFill}
label="Generation"
path={path}
keyPath="/generation"
isMinimized
/>
)}
</div>
<div className="2xl:fixed bottom-12 flex flex-col gap-0 -2xl:mt-8">
<div
role="button"
tabIndex={1}
onClick={toggleMinimize}
className={clsx(
"hover:text-mti-rose -xl:hidden flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
)}>
{isMinimized ? <BsChevronBarRight size={24} /> : <BsChevronBarLeft size={24} />}
{!isMinimized && <span className="text-lg font-medium">Minimize</span>}
</div>
<div
role="button"
tabIndex={1}
onClick={focusMode ? () => { } : logout}
className={clsx(
"hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out",
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
)}>
<RiLogoutBoxFill size={24} />
{!isMinimized && <span className="-xl:hidden text-lg font-medium">Log Out</span>}
</div>
</div>
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
</section>
);
}

View File

@@ -0,0 +1,30 @@
/** eslint-disable @next/next/no-img-element */
import { User } from "@/interfaces/user"
interface Props {
users: User[]
title: string;
}
const UserDisplay = (displayUser: User) => (
<div 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>
);
export default function UserDisplayList({ title, users }: Props) {
return (<div className="bg-white border shadow flex flex-col rounded-xl w-full">
<span className="p-4">{title}</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.slice(0, 10)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>)
}