Remove unused imports and changed and improved layout design and responsiveness in some components and fixed some bugs.
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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.",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -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));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 }) => (
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user