Remove unused imports and changed and improved layout design and responsiveness in some components and fixed some bugs.

This commit is contained in:
José Marques Lima
2025-02-02 23:58:23 +00:00
parent 54a9f6869a
commit 5a685ebe80
25 changed files with 1504 additions and 1031 deletions

View File

@@ -95,7 +95,7 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({
}, [updateLocalAndScheduleGlobal]); }, [updateLocalAndScheduleGlobal]);
return ( return (
<div className={`flex flex-col gap-8 border bg-ielts-${module}/20 rounded-3xl p-8 w-1/3 h-fit`}> <div className={`flex flex-col gap-8 border bg-ielts-${module}/20 rounded-3xl p-8 w-1/3 h-fit -2xl:w-full`}>
<div className={`w-full flex justify-center text-ielts-${module} font-bold text-xl`}>{sectionLabel} Settings</div> <div className={`w-full flex justify-center text-ielts-${module} font-bold text-xl`}>{sectionLabel} Settings</div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Dropdown <Dropdown

View File

@@ -233,7 +233,7 @@ const ListeningComponents: React.FC<Props> = ({ currentSection, localSettings, u
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isAudioContextOpen: isOpen }, false)} setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isAudioContextOpen: isOpen }, false)}
contentWrapperClassName={level ? `border border-ielts-listening` : ''} contentWrapperClassName={level ? `border border-ielts-listening` : ''}
> >
<div className="flex flex-row gap-2 items-center px-2 pb-4"> <div className="flex flex-row flex-wrap gap-2 items-center px-2 pb-4">
<div className="flex flex-col flex-grow gap-4 px-2"> <div className="flex flex-col flex-grow gap-4 px-2">
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label> <label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
<Input <Input

View File

@@ -5,103 +5,140 @@ import ExercisePicker from "../../ExercisePicker";
import { generate } from "../Shared/Generate"; import { generate } from "../Shared/Generate";
import GenerateBtn from "../Shared/GenerateBtn"; import GenerateBtn from "../Shared/GenerateBtn";
import { LevelPart, ReadingPart } from "@/interfaces/exam"; import { LevelPart, ReadingPart } from "@/interfaces/exam";
import { LevelSectionSettings, ReadingSectionSettings } from "@/stores/examEditor/types"; import {
LevelSectionSettings,
ReadingSectionSettings,
} from "@/stores/examEditor/types";
import useExamEditorStore from "@/stores/examEditor"; import useExamEditorStore from "@/stores/examEditor";
interface Props { interface Props {
localSettings: ReadingSectionSettings | LevelSectionSettings; localSettings: ReadingSectionSettings | LevelSectionSettings;
updateLocalAndScheduleGlobal: (updates: Partial<ReadingSectionSettings | LevelSectionSettings>, schedule?: boolean) => void; updateLocalAndScheduleGlobal: (
currentSection: ReadingPart | LevelPart; updates: Partial<ReadingSectionSettings | LevelSectionSettings>,
generatePassageDisabled?: boolean; schedule?: boolean
levelId?: number; ) => void;
level?: boolean; currentSection: ReadingPart | LevelPart;
generatePassageDisabled?: boolean;
levelId?: number;
level?: boolean;
} }
const ReadingComponents: React.FC<Props> = ({localSettings, updateLocalAndScheduleGlobal, currentSection, levelId, level = false, generatePassageDisabled = false}) => { const ReadingComponents: React.FC<Props> = ({
const { currentModule } = useExamEditorStore(); localSettings,
const { updateLocalAndScheduleGlobal,
focusedSection, currentSection,
difficulty, levelId,
} = useExamEditorStore(state => state.modules[currentModule]); level = false,
generatePassageDisabled = false,
}) => {
const { currentModule } = useExamEditorStore();
const { focusedSection, difficulty } = useExamEditorStore(
(state) => state.modules[currentModule]
);
const generatePassage = useCallback(() => { const generatePassage = useCallback(() => {
generate( generate(
levelId ? levelId : focusedSection, levelId ? levelId : focusedSection,
"reading", "reading",
"passage", "passage",
{ {
method: 'GET', method: "GET",
queryParams: { queryParams: {
difficulty, difficulty,
...(localSettings.readingTopic && { topic: localSettings.readingTopic }) ...(localSettings.readingTopic && {
} topic: localSettings.readingTopic,
}, }),
(data: any) => [{ },
title: data.title, },
text: data.text (data: any) => [
}], {
level ? focusedSection : undefined, title: data.title,
level text: data.text,
); },
// eslint-disable-next-line react-hooks/exhaustive-deps ],
}, [localSettings.readingTopic, difficulty, focusedSection, levelId]); level ? focusedSection : undefined,
level
const onTopicChange = useCallback((readingTopic: string) => {
updateLocalAndScheduleGlobal({ readingTopic });
}, [updateLocalAndScheduleGlobal]);
return (
<>
<Dropdown
title="Generate Passage"
module="reading"
open={localSettings.isPassageOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isPassageOpen: isOpen }, false)}
contentWrapperClassName={level ? `border border-ielts-reading`: ''}
disabled={generatePassageDisabled}
>
<div className="flex flex-row gap-2 items-center px-2 pb-4">
<div className="flex flex-col flex-grow gap-4 px-2">
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
<Input
key={`section-${focusedSection}`}
type="text"
placeholder="Topic"
name="category"
onChange={onTopicChange}
roundness="full"
value={localSettings.readingTopic}
/>
</div>
<div className="flex self-end h-16 mb-1">
<GenerateBtn
module="reading"
genType="passage"
sectionId={focusedSection}
generateFnc={generatePassage}
level={level}
/>
</div>
</div>
</Dropdown>
<Dropdown
title="Add Exercises"
module="reading"
open={localSettings.isReadingTopicOpean}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isReadingTopicOpean: isOpen })}
contentWrapperClassName={level ? `border border-ielts-reading`: ''}
disabled={currentSection === undefined || currentSection.text === undefined || currentSection.text.content === "" || currentSection.text.title === ""}
>
<ExercisePicker
module="reading"
sectionId={levelId !== undefined ? levelId : focusedSection}
extraArgs={{ text: currentSection === undefined || currentSection.text === undefined ? "" : currentSection.text.content }}
levelSectionId={focusedSection}
level={level}
/>
</Dropdown>
</>
); );
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localSettings.readingTopic, difficulty, focusedSection, levelId]);
const onTopicChange = useCallback(
(readingTopic: string) => {
updateLocalAndScheduleGlobal({ readingTopic });
},
[updateLocalAndScheduleGlobal]
);
return (
<>
<Dropdown
title="Generate Passage"
module="reading"
open={localSettings.isPassageOpen}
setIsOpen={(isOpen: boolean) =>
updateLocalAndScheduleGlobal({ isPassageOpen: isOpen }, false)
}
contentWrapperClassName={level ? `border border-ielts-reading` : ""}
disabled={generatePassageDisabled}
>
<div
className="flex flex-row flex-wrap gap-2 items-center px-2 pb-4 "
>
<div className="flex flex-col flex-grow gap-4 px-2">
<label className="font-normal text-base text-mti-gray-dim">
Topic (Optional)
</label>
<Input
key={`section-${focusedSection}`}
type="text"
placeholder="Topic"
name="category"
onChange={onTopicChange}
roundness="full"
value={localSettings.readingTopic}
/>
</div>
<div className="flex self-end h-16 mb-1">
<GenerateBtn
module="reading"
genType="passage"
sectionId={focusedSection}
generateFnc={generatePassage}
level={level}
/>
</div>
</div>
</Dropdown>
<Dropdown
title="Add Exercises"
module="reading"
open={localSettings.isReadingTopicOpean}
setIsOpen={(isOpen: boolean) =>
updateLocalAndScheduleGlobal({ isReadingTopicOpean: isOpen })
}
contentWrapperClassName={level ? `border border-ielts-reading` : ""}
disabled={
currentSection === undefined ||
currentSection.text === undefined ||
currentSection.text.content === "" ||
currentSection.text.title === ""
}
>
<ExercisePicker
module="reading"
sectionId={levelId !== undefined ? levelId : focusedSection}
extraArgs={{
text:
currentSection === undefined || currentSection.text === undefined
? ""
: currentSection.text.content,
}}
levelSectionId={focusedSection}
level={level}
/>
</Dropdown>
</>
);
}; };
export default ReadingComponents; export default ReadingComponents;

View File

@@ -19,8 +19,8 @@ const label = (type: string, firstId: string, lastId: string) => {
const ExerciseLabel: React.FC<Props> = ({type, firstId, lastId, prompt}) => { const ExerciseLabel: React.FC<Props> = ({type, firstId, lastId, prompt}) => {
return ( return (
<div className="flex w-full justify-between items-center mr-4"> <div className="flex w-full justify-between items-center mr-4">
<span className="font-semibold">{label(type, firstId, lastId)}</span> <span className="font-semibold ellipsis-2">{label(type, firstId, lastId)}</span>
<div className="text-sm font-light italic">{previewLabel(prompt)}</div> <div className="text-sm font-light italic ellipsis-2">{previewLabel(prompt)}</div>
</div> </div>
); );
} }

View File

@@ -24,216 +24,265 @@ import ListeningInstructions from "./Standalone/ListeningInstructions";
const DIFFICULTIES: Difficulty[] = ["A1", "A2", "B1", "B2", "C1", "C2"]; const DIFFICULTIES: Difficulty[] = ["A1", "A2", "B1", "B2", "C1", "C2"];
const ExamEditor: React.FC<{ levelParts?: number }> = ({ levelParts = 0 }) => { const ExamEditor: React.FC<{ levelParts?: number }> = ({ levelParts = 0 }) => {
const { currentModule, dispatch } = useExamEditorStore(); const { currentModule, dispatch } = useExamEditorStore();
const { const {
sections, sections,
minTimer, minTimer,
expandedSections, expandedSections,
examLabel, examLabel,
isPrivate, isPrivate,
difficulty, difficulty,
sectionLabels, sectionLabels,
importModule importModule,
} = useExamEditorStore(state => state.modules[currentModule]); } = useExamEditorStore((state) => state.modules[currentModule]);
const [numberOfLevelParts, setNumberOfLevelParts] = useState(levelParts !== 0 ? levelParts : 1); const [numberOfLevelParts, setNumberOfLevelParts] = useState(
const [isResetModuleOpen, setIsResetModuleOpen] = useState(false); levelParts !== 0 ? levelParts : 1
);
const [isResetModuleOpen, setIsResetModuleOpen] = useState(false);
// For exam edits // For exam edits
useEffect(() => { useEffect(() => {
if (levelParts !== 0) { if (levelParts !== 0) {
setNumberOfLevelParts(levelParts); setNumberOfLevelParts(levelParts);
dispatch({ dispatch({
type: 'UPDATE_MODULE', type: "UPDATE_MODULE",
payload: { payload: {
updates: { updates: {
sectionLabels: Array.from({ length: levelParts }).map((_, i) => ({ sectionLabels: Array.from({ length: levelParts }).map((_, i) => ({
id: i + 1, id: i + 1,
label: `Part ${i + 1}` label: `Part ${i + 1}`,
})) })),
}, },
module: "level" module: "level",
} },
}) });
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelParts]) }, [levelParts]);
useEffect(() => { useEffect(() => {
const currentSections = sections; const currentSections = sections;
const currentLabels = sectionLabels; const currentLabels = sectionLabels;
let updatedSections: SectionState[]; let updatedSections: SectionState[];
let updatedLabels: any; let updatedLabels: any;
if (currentModule === "level" && currentSections.length !== currentLabels.length || numberOfLevelParts !== currentSections.length) { if (
const newSections = [...currentSections]; (currentModule === "level" &&
const newLabels = [...currentLabels]; currentSections.length !== currentLabels.length) ||
for (let i = currentLabels.length; i < numberOfLevelParts; i++) { numberOfLevelParts !== currentSections.length
if (currentSections.length !== numberOfLevelParts) newSections.push(defaultSectionSettings(currentModule, i + 1)); ) {
newLabels.push({ const newSections = [...currentSections];
id: i + 1, const newLabels = [...currentLabels];
label: `Part ${i + 1}` for (let i = currentLabels.length; i < numberOfLevelParts; i++) {
}); if (currentSections.length !== numberOfLevelParts)
} newSections.push(defaultSectionSettings(currentModule, i + 1));
updatedSections = newSections; newLabels.push({
updatedLabels = newLabels; id: i + 1,
} else if (numberOfLevelParts < currentSections.length) { label: `Part ${i + 1}`,
updatedSections = currentSections.slice(0, numberOfLevelParts);
updatedLabels = currentLabels.slice(0, numberOfLevelParts);
} else {
return;
}
const updatedExpandedSections = expandedSections.filter(
sectionId => updatedSections.some(section => section.sectionId === sectionId)
);
dispatch({
type: 'UPDATE_MODULE',
payload: {
updates: {
sections: updatedSections,
sectionLabels: updatedLabels,
expandedSections: updatedExpandedSections
}
}
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps }
}, [numberOfLevelParts]); updatedSections = newSections;
updatedLabels = newLabels;
const sectionIds = sections.map((section) => section.sectionId) } else if (numberOfLevelParts < currentSections.length) {
updatedSections = currentSections.slice(0, numberOfLevelParts);
const updateModule = useCallback((updates: Partial<ModuleState>) => { updatedLabels = currentLabels.slice(0, numberOfLevelParts);
dispatch({ type: 'UPDATE_MODULE', payload: { updates } }); } else {
}, [dispatch]); return;
const toggleSection = (sectionId: number) => {
if (expandedSections.length === 1 && sectionIds.includes(sectionId)) {
toast.error("Include at least one section!");
return;
}
dispatch({ type: 'TOGGLE_SECTION', payload: { sectionId } });
};
const ModuleSettings: Record<Module, React.ComponentType> = {
reading: ReadingSettings,
writing: WritingSettings,
speaking: SpeakingSettings,
listening: ListeningSettings,
level: LevelSettings
};
const Settings = ModuleSettings[currentModule];
const showImport = importModule && ["reading", "listening", "level"].includes(currentModule);
const updateLevelParts = (parts: number) => {
setNumberOfLevelParts(parts);
} }
return ( const updatedExpandedSections = expandedSections.filter((sectionId) =>
<> updatedSections.some((section) => section.sectionId === sectionId)
{showImport ? <ImportOrStartFromScratch module={currentModule} setNumberOfLevelParts={updateLevelParts} /> : (
<>
{isResetModuleOpen && <ResetModule module={currentModule} isOpen={isResetModuleOpen} setIsOpen={setIsResetModuleOpen} setNumberOfLevelParts={setNumberOfLevelParts}/>}
<div className="flex gap-4 w-full items-center">
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Timer</label>
<Input
type="number"
name="minTimer"
onChange={(e) => updateModule({ minTimer: parseInt(e) < 15 ? 15 : parseInt(e) })}
value={minTimer}
className="max-w-[300px]"
/>
</div>
<div className="flex flex-col gap-3 flex-grow">
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label>
<Select
isMulti={true}
options={DIFFICULTIES.map((x) => ({
value: x,
label: capitalize(x)
}))}
onChange={(values) => {
const selectedDifficulties = values ? values.map(v => v.value as Difficulty) : [];
updateModule({ difficulty: selectedDifficulties });
}}
value={
difficulty
? difficulty.map(d => ({
value: d,
label: capitalize(d)
}))
: null
}
/>
</div>
{(sectionLabels.length != 0 && currentModule !== "level") ? (
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">{sectionLabels[0].label.split(" ")[0]}</label>
<div className="flex flex-row gap-8">
{sectionLabels.map(({ id, label }) => (
<span
key={id}
className={clsx(
"px-6 py-4 w-48 h-[72px] flex justify-center items-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
sectionIds.includes(id)
? `bg-ielts-${currentModule}/70 border-ielts-${currentModule} text-white`
: "bg-white border-mti-gray-platinum"
)}
onClick={() => toggleSection(id)}
>
{label}
</span>
))}
</div>
</div>
) : (
<div className="flex flex-col gap-3 w-1/3">
<label className="font-normal text-base text-mti-gray-dim">Number of Parts</label>
<Input type="number" name="Number of Parts" min={1} onChange={(v) => setNumberOfLevelParts(parseInt(v))} value={numberOfLevelParts} />
</div>
)}
<div className="flex flex-col gap-3 w-fit h-fit">
<div className="h-6" />
<Checkbox isChecked={isPrivate} onChange={(checked) => updateModule({ isPrivate: checked })}>
Privacy (Only available for Assignments)
</Checkbox>
</div>
</div>
<div className="flex flex-row gap-3 w-full">
<div className="flex flex-col gap-3 flex-grow">
<label className="font-normal text-base text-mti-gray-dim">Exam Label *</label>
<Input
type="text"
placeholder="Exam Label"
name="label"
onChange={(text) => updateModule({ examLabel: text })}
roundness="xl"
value={examLabel}
required
/>
</div>
{currentModule === "listening" && <ListeningInstructions />}
<Button
onClick={() => setIsResetModuleOpen(true)}
customColor={`bg-ielts-${currentModule}/70 hover:bg-ielts-${currentModule} border-ielts-${currentModule}`}
className={`text-white self-end`}
>
Reset Module
</Button>
</div>
<div className="flex flex-row gap-8">
<Settings />
<div className="flex-grow max-w-[66%]">
<SectionRenderer />
</div>
</div>
</>
)}
</>
); );
dispatch({
type: "UPDATE_MODULE",
payload: {
updates: {
sections: updatedSections,
sectionLabels: updatedLabels,
expandedSections: updatedExpandedSections,
},
},
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [numberOfLevelParts]);
const sectionIds = sections.map((section) => section.sectionId);
const updateModule = useCallback(
(updates: Partial<ModuleState>) => {
dispatch({ type: "UPDATE_MODULE", payload: { updates } });
},
[dispatch]
);
const toggleSection = (sectionId: number) => {
if (expandedSections.length === 1 && sectionIds.includes(sectionId)) {
toast.error("Include at least one section!");
return;
}
dispatch({ type: "TOGGLE_SECTION", payload: { sectionId } });
};
const ModuleSettings: Record<Module, React.ComponentType> = {
reading: ReadingSettings,
writing: WritingSettings,
speaking: SpeakingSettings,
listening: ListeningSettings,
level: LevelSettings,
};
const Settings = ModuleSettings[currentModule];
const showImport =
importModule && ["reading", "listening", "level"].includes(currentModule);
const updateLevelParts = (parts: number) => {
setNumberOfLevelParts(parts);
};
return (
<>
{showImport ? (
<ImportOrStartFromScratch
module={currentModule}
setNumberOfLevelParts={updateLevelParts}
/>
) : (
<>
{isResetModuleOpen && (
<ResetModule
module={currentModule}
isOpen={isResetModuleOpen}
setIsOpen={setIsResetModuleOpen}
setNumberOfLevelParts={setNumberOfLevelParts}
/>
)}
<div className="flex gap-4 w-full items-center -xl:flex-col">
<div className="flex flex-row gap-3 w-full">
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">
Timer
</label>
<Input
type="number"
name="minTimer"
onChange={(e) =>
updateModule({
minTimer: parseInt(e) < 15 ? 15 : parseInt(e),
})
}
value={minTimer}
className="max-w-[300px]"
/>
</div>
<div className="flex flex-col gap-3 flex-grow">
<label className="font-normal text-base text-mti-gray-dim">
Difficulty
</label>
<Select
isMulti={true}
options={DIFFICULTIES.map((x) => ({
value: x,
label: capitalize(x),
}))}
onChange={(values) => {
const selectedDifficulties = values
? values.map((v) => v.value as Difficulty)
: [];
updateModule({ difficulty: selectedDifficulties });
}}
value={
difficulty
? difficulty.map((d) => ({
value: d,
label: capitalize(d),
}))
: null
}
/>
</div>
</div>
{sectionLabels.length != 0 && currentModule !== "level" ? (
<div className="flex flex-col gap-3 -xl:w-full">
<label className="font-normal text-base text-mti-gray-dim">
{sectionLabels[0].label.split(" ")[0]}
</label>
<div className="flex flex-row gap-8">
{sectionLabels.map(({ id, label }) => (
<span
key={id}
className={clsx(
"px-6 py-4 w-48 h-[72px] flex justify-center items-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
sectionIds.includes(id)
? `bg-ielts-${currentModule}/70 border-ielts-${currentModule} text-white`
: "bg-white border-mti-gray-platinum"
)}
onClick={() => toggleSection(id)}
>
{label}
</span>
))}
</div>
</div>
) : (
<div className="flex flex-col gap-3 w-1/3">
<label className="font-normal text-base text-mti-gray-dim">
Number of Parts
</label>
<Input
type="number"
name="Number of Parts"
min={1}
onChange={(v) => setNumberOfLevelParts(parseInt(v))}
value={numberOfLevelParts}
/>
</div>
)}
<div className="flex flex-col gap-3 w-fit h-fit">
<div className="h-6" />
<Checkbox
isChecked={isPrivate}
onChange={(checked) => updateModule({ isPrivate: checked })}
>
Privacy (Only available for Assignments)
</Checkbox>
</div>
</div>
<div className="flex flex-row gap-3 w-full">
<div className="flex flex-col gap-3 flex-grow">
<label className="font-normal text-base text-mti-gray-dim">
Exam Label *
</label>
<Input
type="text"
placeholder="Exam Label"
name="label"
onChange={(text) => updateModule({ examLabel: text })}
roundness="xl"
value={examLabel}
required
/>
</div>
{currentModule === "listening" && <ListeningInstructions />}
<Button
onClick={() => setIsResetModuleOpen(true)}
customColor={`bg-ielts-${currentModule}/70 hover:bg-ielts-${currentModule} border-ielts-${currentModule}`}
className={`text-white self-end`}
>
Reset Module
</Button>
</div>
<div className="flex flex-row gap-8 -2xl:flex-col">
<Settings />
<div className="flex-grow max-w-[66%] -2xl:max-w-full">
<SectionRenderer />
</div>
</div>
</>
)}
</>
);
}; };
export default ExamEditor; export default ExamEditor;

View File

@@ -24,7 +24,7 @@ const DroppableQuestionArea: React.FC<DroppableQuestionAreaProps> = ({ question,
</div> </div>
<div <div
key={`answer_${question.id}_${answer}`} key={`answer_${question.id}_${answer}`}
className={clsx("w-48 h-10 border rounded-xl flex items-center justify-center", isOver && "border-mti-purple-light")}> className={clsx("w-48 h-10 border-2 border-mti-purple-light self-center rounded-xl flex items-center justify-center", isOver && "border-mti-purple-dark")}>
{answer && `Paragraph ${answer}`} {answer && `Paragraph ${answer}`}
</div> </div>
</div> </div>

View File

@@ -2,8 +2,6 @@ import {useListSearch} from "@/hooks/useListSearch";
import usePagination from "@/hooks/usePagination"; import usePagination from "@/hooks/usePagination";
import { clsx } from "clsx"; import { clsx } from "clsx";
import {ReactNode} from "react"; import {ReactNode} from "react";
import Checkbox from "../Low/Checkbox";
import Separator from "../Low/Separator";
interface Props<T> { interface Props<T> {
list: T[]; list: T[];

View File

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

View File

@@ -260,7 +260,7 @@ export default function Sidebar({
<section <section
className={clsx( className={clsx(
"relative flex h-full flex-col justify-between bg-transparent px-4 py-4 pb-8", "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", isMinimized ? "w-fit" : "-xl:w-20 w-1/6",
className className
)} )}
> >

View File

@@ -29,7 +29,7 @@ function QuestionSolutionArea({
</div> </div>
<div <div
className={clsx( className={clsx(
"w-56 h-10 border rounded-xl items-center justify-center flex gap-3 px-2", "w-56 h-10 border self-center rounded-xl items-center justify-center flex gap-3 px-2",
!userSolution !userSolution
? "border-mti-gray-davy" ? "border-mti-gray-davy"
: userSolution.option.toString() === question.solution.toString() : userSolution.option.toString() === question.solution.toString()

View File

@@ -89,7 +89,7 @@ export default function Selection({ user, page, onStart }: Props) {
<BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" /> <BsBook className="text-ielts-reading h-6 w-6 md:h-8 md:w-8" />
), ),
label: "Reading", label: "Reading",
value: reading, value: reading || 0,
tooltip: "The amount of reading exams performed.", tooltip: "The amount of reading exams performed.",
}, },
{ {
@@ -97,7 +97,7 @@ export default function Selection({ user, page, onStart }: Props) {
<BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" /> <BsHeadphones className="text-ielts-listening h-6 w-6 md:h-8 md:w-8" />
), ),
label: "Listening", label: "Listening",
value: listening, value: listening || 0,
tooltip: "The amount of listening exams performed.", tooltip: "The amount of listening exams performed.",
}, },
{ {
@@ -105,7 +105,7 @@ export default function Selection({ user, page, onStart }: Props) {
<BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" /> <BsPen className="text-ielts-writing h-6 w-6 md:h-8 md:w-8" />
), ),
label: "Writing", label: "Writing",
value: writing, value: writing || 0,
tooltip: "The amount of writing exams performed.", tooltip: "The amount of writing exams performed.",
}, },
{ {
@@ -113,7 +113,7 @@ export default function Selection({ user, page, onStart }: Props) {
<BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" /> <BsMegaphone className="text-ielts-speaking h-6 w-6 md:h-8 md:w-8" />
), ),
label: "Speaking", label: "Speaking",
value: speaking, value: speaking || 0,
tooltip: "The amount of speaking exams performed.", tooltip: "The amount of speaking exams performed.",
}, },
{ {
@@ -121,7 +121,7 @@ export default function Selection({ user, page, onStart }: Props) {
<BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" /> <BsClipboard className="text-ielts-level h-6 w-6 md:h-8 md:w-8" />
), ),
label: "Level", label: "Level",
value: level, value: level || 0,
tooltip: "The amount of level exams performed.", tooltip: "The amount of level exams performed.",
}, },
]} ]}

View File

@@ -1,6 +1,5 @@
import { EntityWithRoles, WithLabeledEntities } from "@/interfaces/entity"; import { WithLabeledEntities } from "@/interfaces/entity";
import { Discount } from "@/interfaces/paypal"; import { Type, User } from "@/interfaces/user";
import { Code, Group, Type, User } from "@/interfaces/user";
import axios from "axios"; import axios from "axios";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -12,7 +11,9 @@ export default function useEntitiesUsers(type?: Type) {
const getData = () => { const getData = () => {
setIsLoading(true); setIsLoading(true);
axios axios
.get<WithLabeledEntities<User>[]>(`/api/entities/users${type ? "?type=" + type : ""}`) .get<WithLabeledEntities<User>[]>(
`/api/entities/users${type ? "?type=" + type : ""}`
)
.then((response) => setUsers(response.data)) .then((response) => setUsers(response.data))
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };

View File

@@ -5,192 +5,329 @@ import Separator from "@/components/Low/Separator";
import { Grading, Step } from "@/interfaces"; import { Grading, Step } from "@/interfaces";
import { Entity } from "@/interfaces/entity"; import { Entity } from "@/interfaces/entity";
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import { CEFR_STEPS, GENERAL_STEPS, IELTS_STEPS, TOFEL_STEPS } from "@/resources/grading"; import {
import { mapBy } from "@/utils"; CEFR_STEPS,
GENERAL_STEPS,
IELTS_STEPS,
TOFEL_STEPS,
} from "@/resources/grading";
import { checkAccess } from "@/utils/permissions"; import { checkAccess } from "@/utils/permissions";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import { Divider } from "primereact/divider"; import {
import { useEffect, useState } from "react"; Dispatch,
memo,
SetStateAction,
useCallback,
useEffect,
useState,
} from "react";
import { BsPlusCircle, BsTrash } from "react-icons/bs"; import { BsPlusCircle, BsTrash } from "react-icons/bs";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
const areStepsOverlapped = (steps: Step[]) => { const areStepsOverlapped = (steps: Step[]) => {
for (let i = 0; i < steps.length; i++) { for (let i = 0; i < steps.length; i++) {
if (i === 0) continue; if (i === 0) continue;
const step = steps[i]; const step = steps[i];
const previous = steps[i - 1]; const previous = steps[i - 1];
if (previous.max >= step.min) return true; if (previous.max >= step.min) return true;
} }
return false; return false;
}; };
interface RowProps {
min: number;
max: number;
index: number;
label: string;
isLast: boolean;
isLoading: boolean;
setSteps: Dispatch<SetStateAction<Step[]>>;
addRow: (index: number) => void;
}
function GradingRow({
min,
max,
label,
index,
isLoading,
isLast,
setSteps,
addRow,
}: RowProps) {
const onChangeMin = useCallback(
(e: string) => {
setSteps((prev) =>
prev.map((x, i) => (i === index ? { ...x, min: parseInt(e) } : x))
);
},
[index, setSteps]
);
const onChangeMax = useCallback(
(e: string) => {
setSteps((prev) =>
prev.map((x, i) => (i === index ? { ...x, max: parseInt(e) } : x))
);
},
[index, setSteps]
);
const onChangeLabel = useCallback(
(e: string) => {
setSteps((prev) =>
prev.map((x, i) => (i === index ? { ...x, label: e } : x))
);
},
[index, setSteps]
);
const onAddRow = useCallback(() => addRow(index), [addRow, index]);
const removeRow = useCallback(
() => setSteps((prev) => prev.filter((_, i) => i !== index)),
[index, setSteps]
);
return (
<>
<div className="flex items-center gap-4">
<div className="grid grid-cols-3 gap-4 w-full">
<Input
label="Min. Percentage"
value={min}
type="number"
disabled={index === 0 || isLoading}
onChange={onChangeMin}
name="min"
/>
<Input
label="Grade"
value={label}
type="text"
disabled={isLoading}
onChange={onChangeLabel}
name="min"
/>
<Input
label="Max. Percentage"
value={max}
type="number"
disabled={isLast || isLoading}
onChange={onChangeMax}
name="max"
/>
</div>
{index !== 0 && !isLast && (
<button
disabled={isLoading}
className="pt-9 text-xl group"
onClick={removeRow}
>
<div className="w-full h-full flex items-center justify-center group-hover:bg-neutral-200 rounded-full p-3 transition ease-in-out duration-300">
<BsTrash />
</div>
</button>
)}
</div>
{!isLast && (
<Button
className="w-full flex items-center justify-center"
disabled={isLoading}
onClick={onAddRow}
>
<BsPlusCircle />
</Button>
)}
</>
);
}
const GradingRowMemo = memo(GradingRow);
interface Props { interface Props {
user: User; user: User;
entitiesGrading: Grading[]; entitiesGrading: Grading[];
entities: Entity[] entities: Entity[];
mutate: () => void mutate: () => void;
} }
export default function CorporateGradingSystem({ user, entitiesGrading = [], entities = [], mutate }: Props) { export default function CorporateGradingSystem({
const [entity, setEntity] = useState(entitiesGrading[0]?.entity || undefined) user,
const [isLoading, setIsLoading] = useState(false); entitiesGrading = [],
const [steps, setSteps] = useState<Step[]>([]); entities = [],
const [otherEntities, setOtherEntities] = useState<string[]>([]) mutate,
}: Props) {
const [entity, setEntity] = useState(entitiesGrading[0]?.entity || undefined);
const [isLoading, setIsLoading] = useState(false);
const [steps, setSteps] = useState<Step[]>([]);
const [otherEntities, setOtherEntities] = useState<string[]>([]);
useEffect(() => { useEffect(() => {
if (entity) { if (entity) {
const entitySteps = entitiesGrading.find(e => e.entity === entity)!.steps const entitySteps = entitiesGrading.find(
setSteps(entitySteps || []) (e) => e.entity === entity
} )!.steps;
}, [entitiesGrading, entity]) setSteps(entitySteps || []);
}
}, [entitiesGrading, entity]);
const saveGradingSystem = () => { const saveGradingSystem = () => {
if (!steps.every((x) => x.min < x.max)) return toast.error("One of your steps has a minimum threshold inferior to its superior threshold."); if (!steps.every((x) => x.min < x.max))
if (areStepsOverlapped(steps)) return toast.error("There seems to be an overlap in one of your steps."); return toast.error(
if ( "One of your steps has a minimum threshold inferior to its superior threshold."
steps.reduce((acc, curr) => { );
return acc - (curr.max - curr.min + 1); if (areStepsOverlapped(steps))
}, 100) > 0 return toast.error("There seems to be an overlap in one of your steps.");
) if (
return toast.error("There seems to be an open interval in your steps."); steps.reduce((acc, curr) => {
return acc - (curr.max - curr.min + 1);
}, 100) > 0
)
return toast.error("There seems to be an open interval in your steps.");
setIsLoading(true); setIsLoading(true);
axios axios
.post("/api/grading", { user: user.id, entity, steps }) .post("/api/grading", { user: user.id, entity, steps })
.then(() => toast.success("Your grading system has been saved!")) .then(() => toast.success("Your grading system has been saved!"))
.then(mutate) .then(mutate)
.catch(() => toast.error("Something went wrong, please try again later")) .catch(() => toast.error("Something went wrong, please try again later"))
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
const applyToOtherEntities = () => { const applyToOtherEntities = () => {
if (!steps.every((x) => x.min < x.max)) return toast.error("One of your steps has a minimum threshold inferior to its superior threshold."); if (!steps.every((x) => x.min < x.max))
if (areStepsOverlapped(steps)) return toast.error("There seems to be an overlap in one of your steps."); return toast.error(
if ( "One of your steps has a minimum threshold inferior to its superior threshold."
steps.reduce((acc, curr) => { );
return acc - (curr.max - curr.min + 1); if (areStepsOverlapped(steps))
}, 100) > 0 return toast.error("There seems to be an overlap in one of your steps.");
) if (
return toast.error("There seems to be an open interval in your steps."); steps.reduce((acc, curr) => {
return acc - (curr.max - curr.min + 1);
}, 100) > 0
)
return toast.error("There seems to be an open interval in your steps.");
if (otherEntities.length === 0) return toast.error("Select at least one entity") if (otherEntities.length === 0)
return toast.error("Select at least one entity");
setIsLoading(true); setIsLoading(true);
axios axios
.post("/api/grading/multiple", { user: user.id, entities: otherEntities, steps }) .post("/api/grading/multiple", {
.then(() => toast.success("Your grading system has been saved!")) user: user.id,
.then(mutate) entities: otherEntities,
.catch(() => toast.error("Something went wrong, please try again later")) steps,
.finally(() => setIsLoading(false)); })
}; .then(() => toast.success("Your grading system has been saved!"))
.then(mutate)
.catch(() => toast.error("Something went wrong, please try again later"))
.finally(() => setIsLoading(false));
};
return ( const addRow = useCallback((index: number) => {
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl"> setSteps((prev) => {
<label className="font-normal text-base text-mti-gray-dim">Grading System</label> const item = {
<div className={clsx("flex flex-col gap-4")}> min: prev[index === 0 ? 0 : index - 1].max + 1,
<label className="font-normal text-base text-mti-gray-dim">Entity</label> max: prev[index + 1].min - 1,
<Select label: "",
defaultValue={{ value: (entities || [])[0]?.id, label: (entities || [])[0]?.label }} };
options={entities.map((e) => ({ value: e.id, label: e.label }))} return [
onChange={(e) => setEntity(e?.value || undefined)} ...prev.slice(0, index + 1),
isClearable={checkAccess(user, ["admin", "developer"])} item,
/> ...prev.slice(index + 1, prev.length),
</div> ];
});
}, []);
{entities.length > 1 && ( return (
<> <div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
<Separator /> <label className="font-normal text-base text-mti-gray-dim">
<label className="font-normal text-base text-mti-gray-dim">Apply this grading system to other entities</label> Grading System
<Select </label>
options={entities.map((e) => ({ value: e.id, label: e.label }))} <div className={clsx("flex flex-col gap-4")}>
onChange={(e) => !e ? setOtherEntities([]) : setOtherEntities(e.map(o => o.value!))} <label className="font-normal text-base text-mti-gray-dim">
isMulti Entity
/> </label>
<Button onClick={applyToOtherEntities} isLoading={isLoading} disabled={isLoading || otherEntities.length === 0} variant="outline"> <Select
Apply to {otherEntities.length} other entities defaultValue={{
</Button> value: (entities || [])[0]?.id,
<Separator /> label: (entities || [])[0]?.label,
</> }}
)} options={entities.map((e) => ({ value: e.id, label: e.label }))}
onChange={(e) => setEntity(e?.value || undefined)}
isClearable={checkAccess(user, ["admin", "developer"])}
/>
</div>
<label className="font-normal text-base text-mti-gray-dim">Preset Systems</label> {entities.length > 1 && (
<div className="grid grid-cols-4 gap-4"> <>
<Button variant="outline" onClick={() => setSteps(CEFR_STEPS)}> <Separator />
CEFR <label className="font-normal text-base text-mti-gray-dim">
</Button> Apply this grading system to other entities
<Button variant="outline" onClick={() => setSteps(GENERAL_STEPS)}> </label>
General English <Select
</Button> options={entities.map((e) => ({ value: e.id, label: e.label }))}
<Button variant="outline" onClick={() => setSteps(IELTS_STEPS)}> onChange={(e) =>
IELTS !e
</Button> ? setOtherEntities([])
<Button variant="outline" onClick={() => setSteps(TOFEL_STEPS)}> : setOtherEntities(e.map((o) => o.value!))
TOFEL iBT }
</Button> isMulti
</div> />
<Button
onClick={applyToOtherEntities}
isLoading={isLoading}
disabled={isLoading || otherEntities.length === 0}
variant="outline"
>
Apply to {otherEntities.length} other entities
</Button>
<Separator />
</>
)}
{steps.map((step, index) => ( <label className="font-normal text-base text-mti-gray-dim">
<> Preset Systems
<div className="flex items-center gap-4"> </label>
<div className="grid grid-cols-3 gap-4 w-full" key={step.min}> <div className="grid grid-cols-4 gap-4">
<Input <Button variant="outline" onClick={() => setSteps(CEFR_STEPS)}>
label="Min. Percentage" CEFR
value={step.min} </Button>
type="number" <Button variant="outline" onClick={() => setSteps(GENERAL_STEPS)}>
disabled={index === 0 || isLoading} General English
onChange={(e) => setSteps((prev) => prev.map((x, i) => (i === index ? { ...x, min: parseInt(e) } : x)))} </Button>
name="min" <Button variant="outline" onClick={() => setSteps(IELTS_STEPS)}>
/> IELTS
<Input </Button>
label="Grade" <Button variant="outline" onClick={() => setSteps(TOFEL_STEPS)}>
value={step.label} TOFEL iBT
type="text" </Button>
disabled={isLoading} </div>
onChange={(e) => setSteps((prev) => prev.map((x, i) => (i === index ? { ...x, label: e } : x)))}
name="min"
/>
<Input
label="Max. Percentage"
value={step.max}
type="number"
disabled={index === steps.length - 1 || isLoading}
onChange={(e) => setSteps((prev) => prev.map((x, i) => (i === index ? { ...x, max: parseInt(e) } : x)))}
name="max"
/>
</div>
{index !== 0 && index !== steps.length - 1 && (
<button
disabled={isLoading}
className="pt-9 text-xl group"
onClick={() => setSteps((prev) => prev.filter((_, i) => i !== index))}>
<div className="w-full h-full flex items-center justify-center group-hover:bg-neutral-200 rounded-full p-3 transition ease-in-out duration-300">
<BsTrash />
</div>
</button>
)}
</div>
{index < steps.length - 1 && ( {steps.map((step, index) => (
<Button <GradingRowMemo
className="w-full flex items-center justify-center" key={index}
disabled={isLoading} min={step.min}
onClick={() => { max={step.max}
const item = { min: steps[index === 0 ? 0 : index - 1].max + 1, max: steps[index + 1].min - 1, label: "" }; label={step.label}
setSteps((prev) => [...prev.slice(0, index + 1), item, ...prev.slice(index + 1, steps.length)]); index={index}
}}> isLoading={isLoading}
<BsPlusCircle /> isLast={index === steps.length - 1}
</Button> setSteps={setSteps}
)} addRow={addRow}
</> />
))} ))}
<Button onClick={saveGradingSystem} isLoading={isLoading} disabled={isLoading} className="mt-8"> <Button
Save Grading System onClick={saveGradingSystem}
</Button> isLoading={isLoading}
</div> disabled={isLoading}
); className="mt-8"
>
Save Grading System
</Button>
</div>
);
} }

View File

@@ -6,7 +6,12 @@ import clsx from "clsx";
import { capitalize } from "lodash"; import { capitalize } from "lodash";
import moment from "moment"; import moment from "moment";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { BsCheck, BsCheckCircle, BsFillExclamationOctagonFill, BsTrash } from "react-icons/bs"; import {
BsCheck,
BsCheckCircle,
BsFillExclamationOctagonFill,
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";
@@ -24,426 +29,597 @@ import { WithLabeledEntities } from "@/interfaces/entity";
import Table from "@/components/High/Table"; import Table from "@/components/High/Table";
import useEntities from "@/hooks/useEntities"; import useEntities from "@/hooks/useEntities";
import { useAllowedEntities } from "@/hooks/useEntityPermissions"; import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import { findAllowedEntities } from "@/utils/permissions";
const columnHelper = createColumnHelper<WithLabeledEntities<User>>(); const columnHelper = createColumnHelper<WithLabeledEntities<User>>();
const searchFields = [["name"], ["email"], ["entities", ""]]; const searchFields = [["name"], ["email"], ["entities", ""]];
export default function UserList({ export default function UserList({
user, user,
filters = [], filters = [],
type, type,
renderHeader, renderHeader,
}: { }: {
user: User; user: User;
filters?: ((user: User) => boolean)[]; filters?: ((user: User) => boolean)[];
type?: Type; type?: Type;
renderHeader?: (total: number) => JSX.Element; renderHeader?: (total: number) => JSX.Element;
}) { }) {
const [showDemographicInformation, setShowDemographicInformation] = useState(false); const [showDemographicInformation, setShowDemographicInformation] =
const [selectedUser, setSelectedUser] = useState<User>(); useState(false);
const [selectedUser, setSelectedUser] = useState<User>();
const { users, reload } = useEntitiesUsers(type) const { users, isLoading, reload } = useEntitiesUsers(type);
const { entities } = useEntities() const { entities } = useEntities();
const isAdmin = useMemo(() => ["admin", "developer"].includes(user?.type), [user?.type]) const isAdmin = useMemo(
() => ["admin", "developer"].includes(user?.type),
[user?.type]
);
const entitiesViewStudents = useAllowedEntities(user, entities, "view_students") const entitiesViewStudents = useAllowedEntities(
const entitiesEditStudents = useAllowedEntities(user, entities, "edit_students") user,
const entitiesDeleteStudents = useAllowedEntities(user, entities, "delete_students") entities,
"view_students"
);
const entitiesEditStudents = useAllowedEntities(
user,
entities,
"edit_students"
);
const entitiesDeleteStudents = useAllowedEntities(
user,
entities,
"delete_students"
);
const entitiesViewTeachers = useAllowedEntities(user, entities, "view_teachers") const entitiesViewTeachers = useAllowedEntities(
const entitiesEditTeachers = useAllowedEntities(user, entities, "edit_teachers") user,
const entitiesDeleteTeachers = useAllowedEntities(user, entities, "delete_teachers") entities,
"view_teachers"
);
const entitiesEditTeachers = useAllowedEntities(
user,
entities,
"edit_teachers"
);
const entitiesDeleteTeachers = useAllowedEntities(
user,
entities,
"delete_teachers"
);
const entitiesViewCorporates = useAllowedEntities(user, entities, "view_corporates") const entitiesViewCorporates = useAllowedEntities(
const entitiesEditCorporates = useAllowedEntities(user, entities, "edit_corporates") user,
const entitiesDeleteCorporates = useAllowedEntities(user, entities, "delete_corporates") entities,
"view_corporates"
);
const entitiesEditCorporates = useAllowedEntities(
user,
entities,
"edit_corporates"
);
const entitiesDeleteCorporates = useAllowedEntities(
user,
entities,
"delete_corporates"
);
const entitiesViewMasterCorporates = useAllowedEntities(user, entities, "view_mastercorporates") const entitiesViewMasterCorporates = useAllowedEntities(
const entitiesEditMasterCorporates = useAllowedEntities(user, entities, "edit_mastercorporates") user,
const entitiesDeleteMasterCorporates = useAllowedEntities(user, entities, "delete_mastercorporates") entities,
"view_mastercorporates"
);
const entitiesEditMasterCorporates = useAllowedEntities(
user,
entities,
"edit_mastercorporates"
);
const entitiesDeleteMasterCorporates = useAllowedEntities(
user,
entities,
"delete_mastercorporates"
);
const entitiesDownloadUsers = useAllowedEntities(user, entities, "download_user_list") const entitiesDownloadUsers = useAllowedEntities(
user,
entities,
"download_user_list"
);
const appendUserFilters = useFilterStore((state) => state.appendUserFilter); const appendUserFilters = useFilterStore((state) => state.appendUserFilter);
const router = useRouter(); const router = useRouter();
const expirationDateColor = (date: Date) => { const expirationDateColor = (date: Date) => {
const momentDate = moment(date); const momentDate = moment(date);
const today = moment(new Date()); const today = moment(new Date());
if (today.isAfter(momentDate)) return "!text-mti-red-light font-bold line-through"; if (today.isAfter(momentDate))
if (today.add(1, "weeks").isAfter(momentDate)) return "!text-mti-red-light"; return "!text-mti-red-light font-bold line-through";
if (today.add(2, "weeks").isAfter(momentDate)) return "!text-mti-rose-light"; if (today.add(1, "weeks").isAfter(momentDate)) return "!text-mti-red-light";
if (today.add(1, "months").isAfter(momentDate)) return "!text-mti-orange-light"; if (today.add(2, "weeks").isAfter(momentDate))
}; return "!text-mti-rose-light";
if (today.add(1, "months").isAfter(momentDate))
return "!text-mti-orange-light";
};
const allowedUsers = useMemo(() => users.filter((u) => { const allowedUsers = useMemo(
if (isAdmin) return true () =>
if (u.id === user?.id) return false users.filter((u) => {
if (isAdmin) return true;
if (u.id === user?.id) return false;
switch (u.type) { switch (u.type) {
case "student": return mapBy((u.entities || []), 'id').some((id) => mapBy(entitiesViewStudents, 'id').includes(id)) case "student":
case "teacher": return mapBy((u.entities || []), 'id').some((id) => mapBy(entitiesViewTeachers, 'id').includes(id)) return mapBy(u.entities || [], "id").some((id) =>
case 'corporate': return mapBy((u.entities || []), 'id').some((id) => mapBy(entitiesViewCorporates, 'id').includes(id)) mapBy(entitiesViewStudents, "id").includes(id)
case 'mastercorporate': return mapBy((u.entities || []), 'id').some((id) => mapBy(entitiesViewMasterCorporates, 'id').includes(id)) );
default: return false case "teacher":
} return mapBy(u.entities || [], "id").some((id) =>
}) mapBy(entitiesViewTeachers, "id").includes(id)
, [entitiesViewCorporates, entitiesViewMasterCorporates, entitiesViewStudents, entitiesViewTeachers, isAdmin, user?.id, users]) );
case "corporate":
return mapBy(u.entities || [], "id").some((id) =>
mapBy(entitiesViewCorporates, "id").includes(id)
);
case "mastercorporate":
return mapBy(u.entities || [], "id").some((id) =>
mapBy(entitiesViewMasterCorporates, "id").includes(id)
);
default:
return false;
}
}),
[
entitiesViewCorporates,
entitiesViewMasterCorporates,
entitiesViewStudents,
entitiesViewTeachers,
isAdmin,
user?.id,
users,
]
);
const displayUsers = useMemo(() => const displayUsers = useMemo(
filters.length > 0 ? filters.reduce((d, f) => d.filter(f), allowedUsers) : allowedUsers, () =>
[filters, allowedUsers]) filters.length > 0
? filters.reduce((d, f) => d.filter(f), allowedUsers)
: allowedUsers,
[filters, allowedUsers]
);
const deleteAccount = (user: User) => { const deleteAccount = (user: User) => {
if (!confirm(`Are you sure you want to delete ${user.name}'s account?`)) return; if (!confirm(`Are you sure you want to delete ${user.name}'s account?`))
return;
axios axios
.delete<{ ok: boolean }>(`/api/user?id=${user.id}`) .delete<{ ok: boolean }>(`/api/user?id=${user.id}`)
.then(() => { .then(() => {
toast.success("User deleted successfully!"); toast.success("User deleted successfully!");
reload() reload();
}) })
.catch(() => { .catch(() => {
toast.error("Something went wrong!", { toastId: "delete-error" }); toast.error("Something went wrong!", { toastId: "delete-error" });
}) })
.finally(reload); .finally(reload);
}; };
const verifyAccount = (user: User) => { const verifyAccount = (user: User) => {
axios axios
.post<{ user?: User; ok?: boolean }>(`/api/users/update?id=${user.id}`, { .post<{ user?: User; ok?: boolean }>(`/api/users/update?id=${user.id}`, {
...user, ...user,
isVerified: true, isVerified: true,
}) })
.then(() => { .then(() => {
toast.success("User verified successfully!"); toast.success("User verified successfully!");
reload(); reload();
}) })
.catch(() => { .catch(() => {
toast.error("Something went wrong!", { toastId: "update-error" }); toast.error("Something went wrong!", { toastId: "update-error" });
}); });
}; };
const toggleDisableAccount = (user: User) => { const toggleDisableAccount = (user: User) => {
if ( if (
!confirm( !confirm(
`Are you sure you want to ${user.status === "disabled" ? "enable" : "disable"} ${user.name `Are you sure you want to ${
}'s account? This change is usually related to their payment state.`, user.status === "disabled" ? "enable" : "disable"
) } ${
) user.name
return; }'s account? This change is usually related to their payment state.`
)
)
return;
axios axios
.post<{ user?: User; ok?: boolean }>(`/api/users/update?id=${user.id}`, { .post<{ user?: User; ok?: boolean }>(`/api/users/update?id=${user.id}`, {
...user, ...user,
status: user.status === "disabled" ? "active" : "disabled", status: user.status === "disabled" ? "active" : "disabled",
}) })
.then(() => { .then(() => {
toast.success(`User ${user.status === "disabled" ? "enabled" : "disabled"} successfully!`); toast.success(
reload(); `User ${
}) user.status === "disabled" ? "enabled" : "disabled"
.catch(() => { } successfully!`
toast.error("Something went wrong!", { toastId: "update-error" }); );
}); reload();
}; })
.catch(() => {
toast.error("Something went wrong!", { toastId: "update-error" });
});
};
const getEditPermission = (type: Type) => { const getEditPermission = (type: Type) => {
if (type === "student") return entitiesEditStudents if (type === "student") return entitiesEditStudents;
if (type === "teacher") return entitiesEditTeachers if (type === "teacher") return entitiesEditTeachers;
if (type === "corporate") return entitiesEditCorporates if (type === "corporate") return entitiesEditCorporates;
if (type === "mastercorporate") return entitiesEditMasterCorporates if (type === "mastercorporate") return entitiesEditMasterCorporates;
return [] return [];
} };
const getDeletePermission = (type: Type) => { const getDeletePermission = (type: Type) => {
if (type === "student") return entitiesDeleteStudents if (type === "student") return entitiesDeleteStudents;
if (type === "teacher") return entitiesDeleteTeachers if (type === "teacher") return entitiesDeleteTeachers;
if (type === "corporate") return entitiesDeleteCorporates if (type === "corporate") return entitiesDeleteCorporates;
if (type === "mastercorporate") return entitiesDeleteMasterCorporates if (type === "mastercorporate") return entitiesDeleteMasterCorporates;
return [] return [];
} };
const canEditUser = (u: User) => const canEditUser = (u: User) =>
isAdmin || u.entities.some(e => mapBy(getEditPermission(u.type), 'id').includes(e.id)) isAdmin ||
u.entities.some((e) =>
mapBy(getEditPermission(u.type), "id").includes(e.id)
);
const canDeleteUser = (u: User) => const canDeleteUser = (u: User) =>
isAdmin || u.entities.some(e => mapBy(getDeletePermission(u.type), 'id').includes(e.id)) isAdmin ||
u.entities.some((e) =>
mapBy(getDeletePermission(u.type), "id").includes(e.id)
);
const actionColumn = ({ row }: { row: { original: User } }) => { const actionColumn = ({ row }: { row: { original: User } }) => {
const canEdit = canEditUser(row.original) const canEdit = canEditUser(row.original);
const canDelete = canDeleteUser(row.original) const canDelete = canDeleteUser(row.original);
return ( return (
<div className="flex gap-4"> <div className="flex gap-4">
{!row.original.isVerified && canEdit && ( {!row.original.isVerified && canEdit && (
<div data-tip="Verify User" className="cursor-pointer tooltip" onClick={() => verifyAccount(row.original)}> <div
<BsCheck className="hover:text-mti-purple-light transition ease-in-out duration-300" /> data-tip="Verify User"
</div> className="cursor-pointer tooltip"
)} onClick={() => verifyAccount(row.original)}
{canEdit && ( >
<div <BsCheck className="hover:text-mti-purple-light transition ease-in-out duration-300" />
data-tip={row.original.status === "disabled" ? "Enable User" : "Disable User"} </div>
className="cursor-pointer tooltip" )}
onClick={() => toggleDisableAccount(row.original)}> {canEdit && (
{row.original.status === "disabled" ? ( <div
<BsCheckCircle className="hover:text-mti-purple-light transition ease-in-out duration-300" /> data-tip={
) : ( row.original.status === "disabled"
<BsFillExclamationOctagonFill className="hover:text-mti-purple-light transition ease-in-out duration-300" /> ? "Enable User"
)} : "Disable User"
</div> }
)} className="cursor-pointer tooltip"
{canDelete && ( onClick={() => toggleDisableAccount(row.original)}
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteAccount(row.original)}> >
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" /> {row.original.status === "disabled" ? (
</div> <BsCheckCircle className="hover:text-mti-purple-light transition ease-in-out duration-300" />
)} ) : (
</div> <BsFillExclamationOctagonFill className="hover:text-mti-purple-light transition ease-in-out duration-300" />
); )}
}; </div>
)}
{canDelete && (
<div
data-tip="Delete"
className="cursor-pointer tooltip"
onClick={() => deleteAccount(row.original)}
>
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div>
)}
</div>
);
};
const demographicColumns = [ const demographicColumns = [
columnHelper.accessor("name", { columnHelper.accessor("name", {
header: "Name", header: "Name",
cell: ({ row, getValue }) => ( cell: ({ row, getValue }) => (
<div <div
className={clsx( className={clsx(
canEditUser(row.original) && canEditUser(row.original) &&
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer", "underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer"
)} )}
onClick={() => onClick={() =>
canEditUser(row.original) ? setSelectedUser(row.original) : null canEditUser(row.original) ? setSelectedUser(row.original) : null
}> }
{getValue()} >
</div> {getValue()}
), </div>
}), ),
columnHelper.accessor("demographicInformation.country", { }),
header: "Country", columnHelper.accessor("demographicInformation.country", {
cell: (info) => header: "Country",
info.getValue() cell: (info) =>
? `${countryCodes.findOne("countryCode" as any, info.getValue())?.flag} ${countries[info.getValue() as unknown as keyof TCountries]?.name info.getValue()
} (+${countryCodes.findOne("countryCode" as any, info.getValue())?.countryCallingCode})` ? `${
: "N/A", countryCodes.findOne("countryCode" as any, info.getValue())?.flag
}), } ${
columnHelper.accessor("demographicInformation.phone", { countries[info.getValue() as unknown as keyof TCountries]?.name
header: "Phone", } (+${
cell: (info) => info.getValue() || "N/A", countryCodes.findOne("countryCode" as any, info.getValue())
enableSorting: true, ?.countryCallingCode
}), })`
columnHelper.accessor( : "N/A",
(x) => }),
x.type === "corporate" || x.type === "mastercorporate" ? x.demographicInformation?.position : x.demographicInformation?.employment, columnHelper.accessor("demographicInformation.phone", {
{ header: "Phone",
id: "employment", cell: (info) => info.getValue() || "N/A",
header: "Employment", enableSorting: true,
cell: (info) => (info.row.original.type === "corporate" ? info.getValue() : capitalize(info.getValue())) || "N/A", }),
enableSorting: true, columnHelper.accessor(
}, (x) =>
), x.type === "corporate" || x.type === "mastercorporate"
columnHelper.accessor("lastLogin", { ? x.demographicInformation?.position
header: "Last Login", : x.demographicInformation?.employment,
cell: (info) => (!!info.getValue() ? moment(info.getValue()).format("YYYY-MM-DD HH:mm") : "N/A"), {
}), id: "employment",
columnHelper.accessor("demographicInformation.gender", { header: "Employment",
header: "Gender", cell: (info) =>
cell: (info) => capitalize(info.getValue()) || "N/A", (info.row.original.type === "corporate"
enableSorting: true, ? info.getValue()
}), : capitalize(info.getValue())) || "N/A",
{ enableSorting: true,
header: ( }
<span className="cursor-pointer" onClick={() => setShowDemographicInformation((prev) => !prev)}> ),
Switch columnHelper.accessor("lastLogin", {
</span> header: "Last Login",
), cell: (info) =>
id: "actions", !!info.getValue()
cell: actionColumn, ? moment(info.getValue()).format("YYYY-MM-DD HH:mm")
sortable: false : "N/A",
}, }),
]; columnHelper.accessor("demographicInformation.gender", {
header: "Gender",
cell: (info) => capitalize(info.getValue()) || "N/A",
enableSorting: true,
}),
{
header: (
<span
className="cursor-pointer"
onClick={() => setShowDemographicInformation((prev) => !prev)}
>
Switch
</span>
),
id: "actions",
cell: actionColumn,
sortable: false,
},
];
const defaultColumns = [ const defaultColumns = [
columnHelper.accessor("name", { columnHelper.accessor("name", {
header: "Name", header: "Name",
cell: ({ row, getValue }) => ( cell: ({ row, getValue }) => (
<div <div
className={clsx( className={clsx(
canEditUser(row.original) && canEditUser(row.original) &&
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer", "underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer"
)} )}
onClick={() => onClick={() =>
canEditUser(row.original) ? setSelectedUser(row.original) : null canEditUser(row.original) ? setSelectedUser(row.original) : null
}> }
{getValue()} >
</div> {getValue()}
), </div>
}), ),
columnHelper.accessor("email", { }),
header: "E-mail", columnHelper.accessor("email", {
cell: ({ row, getValue }) => ( header: "E-mail",
<div cell: ({ row, getValue }) => (
className={clsx( <div
canEditUser(row.original) && className={clsx(
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer", canEditUser(row.original) &&
)} "underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer"
onClick={() => (canEditUser(row.original) ? setSelectedUser(row.original) : null)}> )}
{getValue()} onClick={() =>
</div> canEditUser(row.original) ? setSelectedUser(row.original) : null
), }
}), >
columnHelper.accessor("type", { {getValue()}
header: "Type", </div>
cell: (info) => USER_TYPE_LABELS[info.getValue()], ),
}), }),
columnHelper.accessor("studentID", { columnHelper.accessor("type", {
header: "Student ID", header: "Type",
cell: (info) => info.getValue() || "N/A", cell: (info) => USER_TYPE_LABELS[info.getValue()],
}), }),
columnHelper.accessor("entities", { columnHelper.accessor("studentID", {
header: "Entities", header: "Student ID",
cell: ({ getValue }) => mapBy(getValue(), 'label').join(', '), cell: (info) => info.getValue() || "N/A",
}), }),
columnHelper.accessor("subscriptionExpirationDate", { columnHelper.accessor("entities", {
header: "Expiration", header: "Entities",
cell: (info) => ( cell: ({ getValue }) => mapBy(getValue(), "label").join(", "),
<span className={clsx(info.getValue() ? expirationDateColor(moment(info.getValue()).toDate()) : "")}> }),
{!info.getValue() ? "No expiry date" : moment(info.getValue()).format("DD/MM/YYYY")} columnHelper.accessor("subscriptionExpirationDate", {
</span> header: "Expiration",
), cell: (info) => (
}), <span
columnHelper.accessor("isVerified", { className={clsx(
header: "Verified", info.getValue()
cell: (info) => ( ? expirationDateColor(moment(info.getValue()).toDate())
<div className="flex gap-3 items-center text-mti-gray-dim text-sm self-center"> : ""
<div )}
className={clsx( >
"w-6 h-6 rounded-md flex items-center justify-center border border-mti-purple-light bg-white", {!info.getValue()
"transition duration-300 ease-in-out", ? "No expiry date"
info.getValue() && "!bg-mti-purple-light ", : moment(info.getValue()).format("DD/MM/YYYY")}
)}> </span>
<BsCheck color="white" className="w-full h-full" /> ),
</div> }),
</div> columnHelper.accessor("isVerified", {
), header: "Verified",
}), cell: (info) => (
{ <div className="flex gap-3 items-center text-mti-gray-dim text-sm self-center">
header: ( <div
<span className="cursor-pointer" onClick={() => setShowDemographicInformation((prev) => !prev)}> className={clsx(
Switch "w-6 h-6 rounded-md flex items-center justify-center border border-mti-purple-light bg-white",
</span> "transition duration-300 ease-in-out",
), info.getValue() && "!bg-mti-purple-light "
id: "actions", )}
cell: actionColumn, >
sortable: false <BsCheck color="white" className="w-full h-full" />
}, </div>
]; </div>
),
}),
{
header: (
<span
className="cursor-pointer"
onClick={() => setShowDemographicInformation((prev) => !prev)}
>
Switch
</span>
),
id: "actions",
cell: actionColumn,
sortable: false,
},
];
const downloadExcel = (rows: WithLabeledEntities<User>[]) => { const downloadExcel = (rows: WithLabeledEntities<User>[]) => {
if (entitiesDownloadUsers.length === 0) return toast.error("You are not allowed to download the user list.") 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 allowedRows = rows.filter((r) =>
const csv = exportListToExcel(allowedRows); mapBy(r.entities, "id").some((e) =>
mapBy(entitiesDownloadUsers, "id").includes(e)
)
);
const csv = exportListToExcel(allowedRows);
const element = document.createElement("a"); const element = document.createElement("a");
const file = new Blob([csv], { type: "text/csv" }); const file = new Blob([csv], { type: "text/csv" });
element.href = URL.createObjectURL(file); element.href = URL.createObjectURL(file);
element.download = "users.csv"; element.download = "users.csv";
document.body.appendChild(element); document.body.appendChild(element);
element.click(); element.click();
document.body.removeChild(element); document.body.removeChild(element);
}; };
const viewStudentFilter = (x: User) => x.type === "student"; const viewStudentFilter = (x: User) => x.type === "student";
const viewTeacherFilter = (x: User) => x.type === "teacher"; const viewTeacherFilter = (x: User) => x.type === "teacher";
const belongsToAdminFilter = (x: User) => x.entities.some(({ id }) => mapBy(selectedUser?.entities || [], 'id').includes(id)); const belongsToAdminFilter = (x: User) =>
x.entities.some(({ id }) =>
mapBy(selectedUser?.entities || [], "id").includes(id)
);
const viewStudentFilterBelongsToAdmin = (x: User) => viewStudentFilter(x) && belongsToAdminFilter(x); const viewStudentFilterBelongsToAdmin = (x: User) =>
const viewTeacherFilterBelongsToAdmin = (x: User) => viewTeacherFilter(x) && belongsToAdminFilter(x); viewStudentFilter(x) && belongsToAdminFilter(x);
const viewTeacherFilterBelongsToAdmin = (x: User) =>
viewTeacherFilter(x) && belongsToAdminFilter(x);
const renderUserCard = (selectedUser: User) => { const renderUserCard = (selectedUser: User) => {
const studentsFromAdmin = users.filter(viewStudentFilterBelongsToAdmin); const studentsFromAdmin = users.filter(viewStudentFilterBelongsToAdmin);
const teachersFromAdmin = users.filter(viewTeacherFilterBelongsToAdmin); const teachersFromAdmin = users.filter(viewTeacherFilterBelongsToAdmin);
return ( return (
<div className="w-full flex flex-col gap-8"> <div className="w-full flex flex-col gap-8">
<UserCard <UserCard
maxUserAmount={0} maxUserAmount={0}
loggedInUser={user} loggedInUser={user}
onViewStudents={ onViewStudents={
(selectedUser.type === "corporate" || selectedUser.type === "teacher") && studentsFromAdmin.length > 0 (selectedUser.type === "corporate" ||
? () => { selectedUser.type === "teacher") &&
appendUserFilters({ studentsFromAdmin.length > 0
id: "view-students", ? () => {
filter: viewStudentFilter, appendUserFilters({
}); id: "view-students",
appendUserFilters({ filter: viewStudentFilter,
id: "belongs-to-admin", });
filter: belongsToAdminFilter, appendUserFilters({
}); id: "belongs-to-admin",
filter: belongsToAdminFilter,
});
router.push("/users"); router.push("/users");
} }
: undefined : undefined
} }
onViewTeachers={ onViewTeachers={
(selectedUser.type === "corporate" || selectedUser.type === "student") && teachersFromAdmin.length > 0 (selectedUser.type === "corporate" ||
? () => { selectedUser.type === "student") &&
appendUserFilters({ teachersFromAdmin.length > 0
id: "view-teachers", ? () => {
filter: viewTeacherFilter, appendUserFilters({
}); id: "view-teachers",
appendUserFilters({ filter: viewTeacherFilter,
id: "belongs-to-admin", });
filter: belongsToAdminFilter, appendUserFilters({
}); id: "belongs-to-admin",
filter: belongsToAdminFilter,
});
router.push("/users"); router.push("/users");
} }
: undefined : undefined
} }
onViewCorporate={ onViewCorporate={
selectedUser.type === "teacher" || selectedUser.type === "student" selectedUser.type === "teacher" || selectedUser.type === "student"
? () => { ? () => {
appendUserFilters({ appendUserFilters({
id: "view-corporate", id: "view-corporate",
filter: (x: User) => x.type === "corporate", filter: (x: User) => x.type === "corporate",
}); });
appendUserFilters({ appendUserFilters({
id: "belongs-to-admin", id: "belongs-to-admin",
filter: belongsToAdminFilter filter: belongsToAdminFilter,
}); });
router.push("/users"); router.push("/users");
} }
: undefined : undefined
} }
onClose={(shouldReload) => { onClose={(shouldReload) => {
setSelectedUser(undefined); setSelectedUser(undefined);
if (shouldReload) reload(); if (shouldReload) reload();
}} }}
user={selectedUser} user={selectedUser}
/> />
</div> </div>
); );
}; };
return ( return (
<> <>
{renderHeader && renderHeader(displayUsers.length)} {renderHeader && renderHeader(displayUsers.length)}
<div className="w-full"> <div className="w-full">
<Modal isOpen={!!selectedUser} onClose={() => setSelectedUser(undefined)}> <Modal
{selectedUser && renderUserCard(selectedUser)} isOpen={!!selectedUser}
</Modal> onClose={() => setSelectedUser(undefined)}
<Table<WithLabeledEntities<User>> >
data={displayUsers} {selectedUser && renderUserCard(selectedUser)}
columns={(!showDemographicInformation ? defaultColumns : demographicColumns) as any} </Modal>
searchFields={searchFields} <Table<WithLabeledEntities<User>>
onDownload={entitiesDownloadUsers.length > 0 ? downloadExcel : undefined} data={displayUsers}
/> columns={
</div> (!showDemographicInformation
</> ? defaultColumns
); : demographicColumns) as any
}
searchFields={searchFields}
onDownload={
entitiesDownloadUsers.length > 0 ? downloadExcel : undefined
}
isLoading={isLoading}
/>
</div>
</>
);
} }

View File

@@ -12,8 +12,6 @@ import useExamStore from "@/stores/exam";
import usePreferencesStore from "@/stores/preferencesStore"; import usePreferencesStore from "@/stores/preferencesStore";
import Layout from "../components/High/Layout"; import Layout from "../components/High/Layout";
import useEntities from "../hooks/useEntities"; import useEntities from "../hooks/useEntities";
import UserProfileSkeleton from "../components/Medium/UserProfileSkeleton";
export default function App({ Component, pageProps }: AppProps) { export default function App({ Component, pageProps }: AppProps) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);

View File

@@ -33,7 +33,7 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
res.status(401).json({ ok: false }); res.status(401).json({ ok: false });
return; return;
} }
const docs = await db.collection("tickets").find<Ticket>({ assignedTo: req.session.user.id }).toArray(); const docs = await db.collection("tickets").find<Ticket>({ assignedTo: req.session.user.id, status: { $ne: "completed" } }).toArray();
res.status(200).json(docs); res.status(200).json(docs);
} }

View File

@@ -80,13 +80,12 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
{ _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 } { _id: 0, id: 1, name: 1, email: 1, profilePicture: 1 }
), ),
]); ]);
const [assignmentsCount, stats] = await Promise.all([
const assignmentsCount = await countEntitiesAssignments( countEntitiesAssignments(mapBy(entities, "id"), {
mapBy(entities, "id"), archived: { $ne: true },
{ archived: { $ne: true } } }),
); getStatsByUsers(mapBy(students, "id")),
]);
const stats = await getStatsByUsers(mapBy(students, "id"));
return { return {
props: serialize({ props: serialize({

View File

@@ -32,7 +32,6 @@ import {
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import { useAllowedEntities } from "@/hooks/useEntityPermissions"; import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import { isAdmin } from "@/utils/users"; import { isAdmin } from "@/utils/users";
import { count } from "console";
interface Props { interface Props {
user: User; user: User;

View File

@@ -1,4 +1,3 @@
import { User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { redirect } from "@/utils"; import { redirect } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";

View File

@@ -34,7 +34,6 @@ import {
} from "react-icons/bs"; } from "react-icons/bs";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import { isAdmin } from "@/utils/users"; import { isAdmin } from "@/utils/users";
import { count } from "console";
interface Props { interface Props {
user: User; user: User;

View File

@@ -16,7 +16,6 @@ import useExamStore from "@/stores/exam";
import { findBy, mapBy, redirect, serialize } from "@/utils"; import { findBy, mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { getAssignmentsForStudent } from "@/utils/assignments.be"; import { getAssignmentsForStudent } from "@/utils/assignments.be";
import { getEntities } from "@/utils/entities.be";
import { getExamsByIds } from "@/utils/exams.be"; import { getExamsByIds } from "@/utils/exams.be";
import { getGradingSystemByEntity } from "@/utils/grading.be"; import { getGradingSystemByEntity } from "@/utils/grading.be";
import { import {
@@ -78,14 +77,12 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
}), }),
]); ]);
const assignmentsIDs = mapBy(assignments, "id"); const assignmentsIDs = mapBy(assignments, "id");
const [sessions, ...formattedInvites] = await Promise.all([
const sessions = await getSessionsByUser(user.id, 10, { getSessionsByUser(user.id, 10, {
["assignment.id"]: { $in: assignmentsIDs }, ["assignment.id"]: { $in: assignmentsIDs },
}); }),
...invites.map(convertInvitersToEntity),
const formattedInvites = await Promise.all( ]);
invites.map(convertInvitersToEntity)
);
const examIDs = uniqBy( const examIDs = uniqBy(
assignments.reduce<{ module: Module; id: string; key: string }[]>( assignments.reduce<{ module: Module; id: string; key: string }[]>(
@@ -110,7 +107,7 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
props: serialize({ props: serialize({
user, user,
assignments, assignments,
stats, stats: stats ,
exams, exams,
sessions, sessions,
invites: formattedInvites, invites: formattedInvites,
@@ -184,7 +181,7 @@ export default function Dashboard({
icon: ( icon: (
<BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" /> <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />
), ),
value: stats.fullExams, value: stats?.fullExams || 0,
label: "Exams", label: "Exams",
tooltip: "Number of all conducted completed exams", tooltip: "Number of all conducted completed exams",
}, },
@@ -192,7 +189,7 @@ export default function Dashboard({
icon: ( icon: (
<BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" /> <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />
), ),
value: stats.uniqueModules, value: stats?.uniqueModules || 0,
label: "Modules", label: "Modules",
tooltip: tooltip:
"Number of all exam modules performed including Level Test", "Number of all exam modules performed including Level Test",

View File

@@ -173,7 +173,7 @@ export default function Generation({ id, user, exam, examModule, permissions }:
<RadioGroup <RadioGroup
value={currentModule} value={currentModule}
onChange={(currentModule) => updateRoot({ currentModule })} onChange={(currentModule) => updateRoot({ currentModule })}
className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between"> className="flex flex-row flex-wrap w-full gap-4 -md:justify-center justify-between">
{[...MODULE_ARRAY].filter(m => permissions[m]).map((x) => ( {[...MODULE_ARRAY].filter(m => permissions[m]).map((x) => (
<Radio value={x} key={x}> <Radio value={x} key={x}>
{({ checked }) => ( {({ checked }) => (

View File

@@ -27,7 +27,6 @@ import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import { uniqBy } from "lodash"; import { uniqBy } from "lodash";
import moment from "moment";
import Head from "next/head"; import Head from "next/head";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";

View File

@@ -3,88 +3,126 @@
@tailwind utilities; @tailwind utilities;
@layer utilities { @layer utilities {
.scrollbar-hide { .scrollbar-hide {
-ms-overflow-style: none; -ms-overflow-style: none;
/* IE and Edge */ /* IE and Edge */
scrollbar-width: none; scrollbar-width: none;
/* Firefox */ /* Firefox */
} }
.scrollbar-hide::-webkit-scrollbar { .scrollbar-hide::-webkit-scrollbar {
display: none; display: none;
/* Chrome, Safari and Opera */ /* Chrome, Safari and Opera */
} }
}
.ellipsis {
@apply line-clamp-1 max-h-20 overflow-hidden text-ellipsis whitespace-break-spaces leading-relaxed [-webkit-box-orient:vertical] [display:-webkit-box];
}
.ellipsis-2 {
@apply line-clamp-2 max-h-20 overflow-hidden text-ellipsis whitespace-break-spaces leading-relaxed [-webkit-box-orient:vertical] [display:-webkit-box];
}
.ellipsis-3 {
@apply line-clamp-3 max-h-20 overflow-hidden text-ellipsis whitespace-break-spaces leading-relaxed [-webkit-box-orient:vertical] [display:-webkit-box];
} }
.training-scrollbar::-webkit-scrollbar { .training-scrollbar::-webkit-scrollbar {
@apply w-1.5; @apply w-1.5;
} }
.training-scrollbar::-webkit-scrollbar-track { .training-scrollbar::-webkit-scrollbar-track {
@apply bg-transparent; @apply bg-transparent;
} }
.training-scrollbar::-webkit-scrollbar-thumb { .training-scrollbar::-webkit-scrollbar-thumb {
@apply bg-gray-400 hover:bg-gray-500 rounded-full transition-colors opacity-50 hover:opacity-75; @apply bg-gray-400 hover:bg-gray-500 rounded-full transition-colors opacity-50 hover:opacity-75;
} }
.training-scrollbar { .training-scrollbar {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: rgba(156, 163, 175, 0.5) transparent; scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
} }
:root { :root {
--max-width: 1100px; --max-width: 1100px;
--border-radius: 12px; --border-radius: 12px;
--font-mono: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro", --font-mono: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono",
"Fira Mono", "Droid Sans Mono", "Courier New", monospace; "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro",
"Fira Mono", "Droid Sans Mono", "Courier New", monospace;
--foreground-rgb: 53, 51, 56; --foreground-rgb: 53, 51, 56;
--background-start-rgb: 245, 245, 245; --background-start-rgb: 245, 245, 245;
--background-end-rgb: 245, 245, 245; --background-end-rgb: 245, 245, 245;
--primary-glow: conic-gradient(from 180deg at 50% 50%, #16abff33 0deg, #0885ff33 55deg, #54d6ff33 120deg, #0071ff33 160deg, transparent 360deg); --primary-glow: conic-gradient(
--secondary-glow: radial-gradient(rgba(255, 255, 255, 1), rgba(255, 255, 255, 0)); from 180deg at 50% 50%,
#16abff33 0deg,
#0885ff33 55deg,
#54d6ff33 120deg,
#0071ff33 160deg,
transparent 360deg
);
--secondary-glow: radial-gradient(
rgba(255, 255, 255, 1),
rgba(255, 255, 255, 0)
);
--tile-start-rgb: 239, 245, 249; --tile-start-rgb: 239, 245, 249;
--tile-end-rgb: 228, 232, 233; --tile-end-rgb: 228, 232, 233;
--tile-border: conic-gradient(#00000080, #00000040, #00000030, #00000020, #00000010, #00000010, #00000080); --tile-border: conic-gradient(
#00000080,
#00000040,
#00000030,
#00000020,
#00000010,
#00000010,
#00000080
);
--callout-rgb: 238, 240, 241; --callout-rgb: 238, 240, 241;
--callout-border-rgb: 172, 175, 176; --callout-border-rgb: 172, 175, 176;
--card-rgb: 180, 185, 188; --card-rgb: 180, 185, 188;
--card-border-rgb: 131, 134, 135; --card-border-rgb: 131, 134, 135;
} }
* { * {
box-sizing: border-box; box-sizing: border-box;
padding: 0; padding: 0;
margin: 0; margin: 0;
} }
html { html {
min-height: 100vh !important; min-height: 100vh !important;
height: 100%; height: 100%;
max-width: 100vw; max-width: 100vw;
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
font-family: "Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif; font-family: "Open Sans", system-ui, -apple-system, "Helvetica Neue",
sans-serif;
} }
body { body {
min-height: 100vh !important; min-height: 100vh !important;
height: 100%; height: 100%;
max-width: 100vw; max-width: 100vw;
overflow-x: hidden; overflow-x: hidden;
font-family: "Open Sans", system-ui, -apple-system, "Helvetica Neue", sans-serif; font-family: "Open Sans", system-ui, -apple-system, "Helvetica Neue",
sans-serif;
} }
body { body {
color: rgb(var(--foreground-rgb)); color: rgb(var(--foreground-rgb));
background: linear-gradient(to bottom, transparent, rgb(var(--background-end-rgb))) rgb(var(--background-start-rgb)); background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
} }
a { a {
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
} }

View File

@@ -1,11 +1,8 @@
import { EntityWithRoles, WithLabeledEntities } from "@/interfaces/entity"; import { WithLabeledEntities } from "@/interfaces/entity";
import { Group, User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import { getUserCompanyName, USER_TYPE_LABELS } from "@/resources/user"; import { USER_TYPE_LABELS } from "@/resources/user";
import { capitalize } from "lodash"; import { capitalize } from "lodash";
import moment from "moment"; import moment from "moment";
import { mapBy } from ".";
import { findAllowedEntities } from "./permissions";
import { getEntitiesUsers } from "./users.be";
export interface UserListRow { export interface UserListRow {
name: string; name: string;