Merge branch 'develop' of bitbucket.org:ecropdev/ielts-ui into develop

This commit is contained in:
Tiago Ribeiro
2024-12-23 09:36:19 +00:00
31 changed files with 2514 additions and 195 deletions

View File

@@ -9,7 +9,6 @@ import { Module } from "@/interfaces";
import useExamEditorStore from "@/stores/examEditor"; import useExamEditorStore from "@/stores/examEditor";
import { LevelPart, ListeningPart, Message, ReadingPart } from "@/interfaces/exam"; import { LevelPart, ListeningPart, Message, ReadingPart } from "@/interfaces/exam";
import { BsArrowRepeat } from "react-icons/bs"; import { BsArrowRepeat } from "react-icons/bs";
import { writingTask } from "@/stores/examEditor/sections";
interface ExercisePickerProps { interface ExercisePickerProps {
module: string; module: string;

View File

@@ -6,6 +6,7 @@ import { Card, CardContent } from '@/components/ui/card';
import Input from '@/components/Low/Input'; import Input from '@/components/Low/Input';
import { FaFemale, FaMale, FaPlus } from 'react-icons/fa'; import { FaFemale, FaMale, FaPlus } from 'react-icons/fa';
import clsx from 'clsx'; import clsx from 'clsx';
import { toast } from 'react-toastify';
export interface Speaker { export interface Speaker {
id: number; id: number;
@@ -217,6 +218,12 @@ const ScriptEditor: React.FC<Props> = ({ section, editing = false, local, setLoc
}, [local]); }, [local]);
if (!isConversation) { if (!isConversation) {
if (typeof local !== 'string') {
toast.error(`Section ${section} is monologue based, but the import contained a conversation!`);
setLocal('');
return null;
}
return ( return (
<Card> <Card>
<CardContent className="py-10"> <CardContent className="py-10">
@@ -238,6 +245,12 @@ const ScriptEditor: React.FC<Props> = ({ section, editing = false, local, setLoc
); );
} }
if (typeof local === 'string') {
toast.error(`Section ${section} is conversation based, but the import contained a monologue!`);
setLocal([]);
return null;
}
return ( return (
<Card> <Card>
<CardContent className="py-10"> <CardContent className="py-10">

View File

@@ -16,11 +16,10 @@ interface Props {
onDiscard: () => void; onDiscard: () => void;
onEdit?: () => void; onEdit?: () => void;
module: Module; module: Module;
listeningSection?: number;
context: Generating; context: Generating;
} }
const SectionContext: React.FC<Props> = ({ sectionId, title, description, renderContent, editing, onSave, onDiscard, onEdit, mode = "edit", module, context, listeningSection }) => { const SectionContext: React.FC<Props> = ({ sectionId, title, description, renderContent, editing, onSave, onDiscard, onEdit, mode = "edit", module, context }) => {
const { currentModule } = useExamEditorStore(); const { currentModule } = useExamEditorStore();
const { generating, levelGenerating } = useExamEditorStore( const { generating, levelGenerating } = useExamEditorStore(
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)! (state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
@@ -54,7 +53,7 @@ const SectionContext: React.FC<Props> = ({ sectionId, title, description, render
{loading ? ( {loading ? (
<GenLoader module={module} /> <GenLoader module={module} />
) : ( ) : (
renderContent(editing, listeningSection) renderContent(editing)
)} )}
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { LevelPart, ListeningPart } from "@/interfaces/exam"; import { LevelPart, ListeningPart, Script } from "@/interfaces/exam";
import SectionContext from "."; import SectionContext from ".";
import useExamEditorStore from "@/stores/examEditor"; import useExamEditorStore from "@/stores/examEditor";
import useSectionEdit from "../../Hooks/useSectionEdit"; import useSectionEdit from "../../Hooks/useSectionEdit";
@@ -22,9 +22,10 @@ interface Props {
const ListeningContext: React.FC<Props> = ({ sectionId, module, listeningSection, level = false }) => { const ListeningContext: React.FC<Props> = ({ sectionId, module, listeningSection, level = false }) => {
const { dispatch } = useExamEditorStore(); const { dispatch } = useExamEditorStore();
const { genResult, state, generating, levelGenResults, levelGenerating } = useExamEditorStore( const { genResult, state, generating, levelGenResults, levelGenerating, scriptLoading } = useExamEditorStore(
(state) => state.modules[module].sections.find((section) => section.sectionId === sectionId)! (state) => state.modules[module].sections.find((section) => section.sectionId === sectionId)!
); );
const listeningPart = state as ListeningPart | LevelPart; const listeningPart = state as ListeningPart | LevelPart;
const [isDialogDropdownOpen, setIsDialogDropdownOpen] = useState(false); const [isDialogDropdownOpen, setIsDialogDropdownOpen] = useState(false);
@@ -51,9 +52,11 @@ const ListeningContext: React.FC<Props> = ({ sectionId, module, listeningSection
}, },
}); });
useEffect(()=> { useEffect(() => {
if (listeningPart.script == undefined) { if (listeningPart.script == undefined) {
setScriptLocal(undefined); setScriptLocal(undefined);
} else {
setScriptLocal(listeningPart.script);
} }
}, [listeningPart]) }, [listeningPart])
@@ -93,8 +96,8 @@ const ListeningContext: React.FC<Props> = ({ sectionId, module, listeningSection
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelGenResults]); }, [levelGenResults]);
const renderContent = (editing: boolean, listeningSection?: number) => { const memoizedRenderContent = useCallback(() => {
if (scriptLocal === undefined && !editing) { if (scriptLocal === undefined && !editing && !scriptLoading) {
return ( return (
<Card> <Card>
<CardContent className="py-10"> <CardContent className="py-10">
@@ -105,18 +108,23 @@ const ListeningContext: React.FC<Props> = ({ sectionId, module, listeningSection
} }
return ( return (
<> <>
{generating === "audio" ? (<GenLoader module="listening" custom="Generating audio ..." />) : ( {(generating === "audio" || scriptLoading) ? (
<GenLoader
module="listening"
custom={scriptLoading ? 'Transcribing Audio ...' : 'Generating audio ...'}
/>
) : (
<> <>
{listeningPart.audio?.source && ( {listeningPart.audio?.source !== undefined && (
<AudioPlayer <AudioPlayer
key={sectionId} key={`${sectionId}-${scriptLocal?.length}`}
src={listeningPart.audio?.source ?? ''} src={listeningPart.audio?.source ?? ''}
color="listening" color="listening"
/> />
)} )}
</> </>
)} )}
<Dropdown {!scriptLoading && <Dropdown
className="mt-8 w-full flex items-center justify-between p-4 bg-white hover:bg-gray-50 transition-colors border rounded-xl border-gray-200" className="mt-8 w-full flex items-center justify-between p-4 bg-white hover:bg-gray-50 transition-colors border rounded-xl border-gray-200"
contentWrapperClassName="rounded-xl mt-2" contentWrapperClassName="rounded-xl mt-2"
customTitle={ customTitle={
@@ -125,10 +133,10 @@ const ListeningContext: React.FC<Props> = ({ sectionId, module, listeningSection
"h-5 w-5", "h-5 w-5",
`text-ielts-${module}` `text-ielts-${module}`
)} /> )} />
<span className="font-medium text-gray-900">{ <span className="font-medium text-gray-900">
listeningSection === undefined ? {listeningSection === undefined
([1, 3].includes(sectionId) ? "Conversation" : "Monologue") : ? ([1, 3].includes(sectionId) ? "Conversation" : "Monologue")
([1, 3].includes(listeningSection) ? "Conversation" : "Monologue")} : ([1, 3].includes(listeningSection) ? "Conversation" : "Monologue")}
</span> </span>
</div> </div>
} }
@@ -136,15 +144,32 @@ const ListeningContext: React.FC<Props> = ({ sectionId, module, listeningSection
setIsOpen={setIsDialogDropdownOpen} setIsOpen={setIsDialogDropdownOpen}
> >
<ScriptRender <ScriptRender
key={scriptLocal?.length}
local={scriptLocal} local={scriptLocal}
setLocal={setScriptLocal} setLocal={setScriptLocal}
section={level ? listeningSection! : sectionId} section={level ? listeningSection! : sectionId}
editing={editing} editing={editing}
/> />
</Dropdown> </Dropdown>
}
</> </>
); );
}; // eslint-disable-next-line react-hooks/exhaustive-deps
}, [
scriptLoading,
generating,
listeningPart.audio?.source,
listeningPart.script,
sectionId,
module,
isDialogDropdownOpen,
setIsDialogDropdownOpen,
setScriptLocal,
level,
scriptLocal,
editing,
listeningSection
]);
return ( return (
<SectionContext <SectionContext
@@ -155,14 +180,13 @@ const ListeningContext: React.FC<Props> = ({ sectionId, module, listeningSection
([1, 3].includes(listeningSection) ? "Conversation" : "Monologue") ([1, 3].includes(listeningSection) ? "Conversation" : "Monologue")
} }
description={`Enter the section's ${(sectionId === 1 || sectionId === 3) ? "conversation" : "monologue"} or import your own`} description={`Enter the section's ${(sectionId === 1 || sectionId === 3) ? "conversation" : "monologue"} or import your own`}
renderContent={renderContent} renderContent={memoizedRenderContent}
editing={editing} editing={editing}
onSave={handleSave} onSave={handleSave}
onEdit={handleEdit} onEdit={handleEdit}
onDiscard={handleDiscard} onDiscard={handleDiscard}
module={module} module={module}
context="listeningScript" context="listeningScript"
listeningSection={listeningSection}
/> />
); );
}; };

View File

@@ -68,7 +68,6 @@ export function generate(
const url = `/api/exam/generate/${module}/${sectionId}${queryString ? `?${queryString}` : ''}`; const url = `/api/exam/generate/${module}/${sectionId}${queryString ? `?${queryString}` : ''}`;
let body = null; let body = null;
console.log(config.files);
if (config.files && Object.keys(config.files).length > 0 && config.method === 'POST') { if (config.files && Object.keys(config.files).length > 0 && config.method === 'POST') {
const formData = new FormData(); const formData = new FormData();

View File

@@ -13,9 +13,10 @@ interface Props {
generateFnc: (sectionId: number) => void generateFnc: (sectionId: number) => void
className?: string; className?: string;
level?: boolean; level?: boolean;
disabled?: boolean;
} }
const GenerateBtn: React.FC<Props> = ({ module, sectionId, genType, generateFnc, className, level = false }) => { const GenerateBtn: React.FC<Props> = ({ module, sectionId, genType, generateFnc, className, level = false, disabled = false }) => {
const section = useExamEditorStore((store) => store.modules[level ? "level" : module].sections.find((s) => s.sectionId == sectionId)); const section = useExamEditorStore((store) => store.modules[level ? "level" : module].sections.find((s) => s.sectionId == sectionId));
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -24,7 +25,7 @@ const GenerateBtn: React.FC<Props> = ({ module, sectionId, genType, generateFnc,
const levelGenerating = section?.levelGenerating; const levelGenerating = section?.levelGenerating;
const levelGenResults = section?.levelGenResults; const levelGenResults = section?.levelGenResults;
useEffect(()=> { useEffect(() => {
const gen = level ? levelGenerating?.find(g => g === genType) !== undefined : (generating !== undefined && generating === genType); const gen = level ? levelGenerating?.find(g => g === genType) !== undefined : (generating !== undefined && generating === genType);
if (loading !== gen) { if (loading !== gen) {
setLoading(gen); setLoading(gen);
@@ -42,8 +43,8 @@ const GenerateBtn: React.FC<Props> = ({ module, sectionId, genType, generateFnc,
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module} disabled:bg-ielts-${module}/40`, `bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module} disabled:bg-ielts-${module}/40`,
className className
)} )}
disabled={loading} disabled={loading || disabled}
onClick={loading ? () => { } : () => generateFnc(sectionId)} onClick={(loading || disabled) ? () => { } : () => generateFnc(sectionId)}
> >
{loading ? ( {loading ? (
<div key={`section-${sectionId}`} className="flex items-center justify-center"> <div key={`section-${sectionId}`} className="flex items-center justify-center">

View File

@@ -0,0 +1,148 @@
import Button from '@/components/Low/Button';
import Modal from '@/components/Modal';
import dynamic from 'next/dynamic';
import React, { useCallback, useState } from 'react';
import { MdAudioFile, MdCloudUpload, MdDelete } from 'react-icons/md';
const Waveform = dynamic(() => import("@/components/Waveform"), { ssr: false });
interface AudioUploadProps {
isOpen: boolean;
audioFile: string | undefined;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
onFileSelect: (file: File | null) => void;
transcribeAudio: () => void;
setAudioUrl: React.Dispatch<React.SetStateAction<string | undefined>>;
}
const AudioUpload: React.FC<AudioUploadProps> = ({ isOpen, audioFile, setIsOpen, onFileSelect, transcribeAudio, setAudioUrl }) => {
const [isDragging, setIsDragging] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleDrag = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDragIn = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragOut = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const validateFile = (file: File): boolean => {
if (!file.type.startsWith('audio/')) {
setError('Please upload an audio file');
return false;
}
setError(null);
return true;
};
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
setError(null);
const file = e.dataTransfer.files?.[0];
if (file && validateFile(file)) {
onFileSelect(file);
}
}, [onFileSelect]);
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file && validateFile(file)) {
onFileSelect(file);
}
};
const handleRemoveAudio = () => {
onFileSelect(null);
};
return (
<Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
<div className="w-full space-y-4">
{!audioFile && (
<div
className={`relative border-2 border-dashed rounded-lg p-8 text-center
${isDragging
? 'border-blue-500 bg-blue-50'
: 'border-gray-300 hover:border-gray-400'
}
transition-all duration-200 ease-in-out`}
onDragEnter={handleDragIn}
onDragLeave={handleDragOut}
onDragOver={handleDrag}
onDrop={handleDrop}
>
<input
type="file"
accept="audio/*"
onChange={handleFileUpload}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
title="Choose audio file"
/>
<div className="space-y-4">
<div className="flex justify-center">
{error ? (
<MdAudioFile className="w-16 h-16 text-red-500" />
) : (
<MdCloudUpload className="w-16 h-16 text-gray-400" />
)}
</div>
<div className="space-y-2">
<h3 className="text-lg font-medium text-gray-700">
{error ? error : 'Upload Audio File'}
</h3>
<p className="text-sm text-gray-500">
Drag and drop your audio file here, or click to select
</p>
</div>
</div>
</div>
)}
{audioFile && (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium text-gray-700">Audio Upload</h3>
<button
onClick={handleRemoveAudio}
className="flex items-center gap-2 px-3 py-1.5 text-sm text-white bg-red-500 hover:bg-red-600 rounded-md transition-colors duration-200 w-36"
>
<MdDelete className="w-4 h-4" />
Remove Audio
</button>
</div>
<Waveform
variant='edit'
audio={audioFile}
waveColor="#ddd"
progressColor="#4a90e2"
setAudioUrl={setAudioUrl}
/>
<div className="flex w-full justify-between pt-8">
<Button color="purple" onClick={() => setIsOpen(false)} variant="outline" className="max-w-[200px] self-end w-full">
Cancel
</Button>
<Button color="purple" onClick={()=> { transcribeAudio(); setIsOpen(false);}} className="max-w-[200px] self-end w-full">
Upload
</Button>
</div>
</div>
)}
</div>
</Modal>
);
};
export default AudioUpload;

View File

@@ -1,15 +1,20 @@
import Dropdown from "../Shared/SettingsDropdown"; import Dropdown from "../Shared/SettingsDropdown";
import ExercisePicker from "../../ExercisePicker"; import ExercisePicker from "../../ExercisePicker";
import GenerateBtn from "../Shared/GenerateBtn"; import GenerateBtn from "../Shared/GenerateBtn";
import { useCallback } from "react"; import { useCallback, useState } from "react";
import { generate } from "../Shared/Generate"; import { generate } from "../Shared/Generate";
import { LevelSectionSettings, ListeningSectionSettings } from "@/stores/examEditor/types"; import { LevelSectionSettings, ListeningSectionSettings } from "@/stores/examEditor/types";
import useExamEditorStore from "@/stores/examEditor"; import useExamEditorStore from "@/stores/examEditor";
import { LevelPart, ListeningPart } from "@/interfaces/exam"; import { LevelPart, ListeningPart, Script } from "@/interfaces/exam";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import axios from "axios"; import axios from "axios";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { playSound } from "@/utils/sound"; import { playSound } from "@/utils/sound";
import { FaFileUpload } from "react-icons/fa";
import clsx from "clsx";
import AudioUpload from "./AudioUpload";
import { downloadBlob } from "@/utils/evaluation";
import { BsArrowRepeat } from "react-icons/bs";
interface Props { interface Props {
localSettings: ListeningSectionSettings | LevelSectionSettings; localSettings: ListeningSectionSettings | LevelSectionSettings;
@@ -25,9 +30,29 @@ const ListeningComponents: React.FC<Props> = ({ currentSection, localSettings, u
const { const {
focusedSection, focusedSection,
difficulty, difficulty,
sections
} = useExamEditorStore(state => state.modules[currentModule]); } = useExamEditorStore(state => state.modules[currentModule]);
const [originalAudioUrl, setOriginalAudioUrl] = useState<string | undefined>();
const [audioUrl, setAudioUrl] = useState<string | undefined>();
const [isUploaderOpen, setIsUploaderOpen] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const generateScript = useCallback(() => { const generateScript = useCallback(() => {
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
setAudioUrl(undefined);
dispatch({
type: "UPDATE_SECTION_STATE",
payload: {
sectionId: focusedSection,
module: "listening",
update: {
audio: undefined
}
}
});
}
generate( generate(
levelId ? levelId : focusedSection, levelId ? levelId : focusedSection,
"listening", "listening",
@@ -124,8 +149,83 @@ const ListeningComponents: React.FC<Props> = ({ currentSection, localSettings, u
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentSection?.script, dispatch, level, levelId]); }, [currentSection?.script, dispatch, level, levelId]);
const handleFileSelect = (file: File | null) => {
if (file) {
const url = URL.createObjectURL(file);
setOriginalAudioUrl(url);
setAudioUrl(url);
dispatch({
type: "UPDATE_SECTION_STATE",
payload: {
sectionId: focusedSection,
module: "listening",
update: {
audio: {
source: url,
repeatableTimes: 3
}
}
}
});
} else {
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
URL.revokeObjectURL(originalAudioUrl!);
dispatch({
type: "UPDATE_SECTION_STATE",
payload: {
sectionId: focusedSection,
module: "listening",
update: { audio: undefined }
}
});
}
setAudioUrl(undefined);
setOriginalAudioUrl(undefined);
}
};
const transcribeAudio = async () => {
try {
setIsUploading(true);
dispatch({type: "UPDATE_SECTION_SINGLE_FIELD", payload: {module: "listening", sectionId: focusedSection, field: "scriptLoading", value: true}})
const formData = new FormData();
const audioBlob = await downloadBlob(audioUrl!);
const audioFile = new File([audioBlob], "audio");
formData.append("audio", audioFile);
const config = {
headers: {
"Content-Type": "multipart/form-data",
},
};
const response = await axios.post(`/api/transcribe`, formData, config);
dispatch({
type: "UPDATE_SECTION_STATE",
payload: {
sectionId: focusedSection,
module: "listening",
update: {
script: (response.data as any).dialog as Script,
audio: { source: audioUrl!, repeatableTimes: 3 }
}
}
});
} catch (error) {
toast.error("An unexpected error has occurred, try again later!");
} finally {
setIsUploading(false);
dispatch({type: "UPDATE_SECTION_SINGLE_FIELD", payload: {module: "listening", sectionId: focusedSection, field: "scriptLoading", value: false}})
}
};
return ( return (
<> <>
<AudioUpload isOpen={isUploaderOpen} setIsOpen={setIsUploaderOpen} audioFile={originalAudioUrl} onFileSelect={handleFileSelect} transcribeAudio={transcribeAudio} setAudioUrl={setAudioUrl} />
<Dropdown <Dropdown
title="Audio Context" title="Audio Context"
module="listening" module="listening"
@@ -154,10 +254,39 @@ const ListeningComponents: React.FC<Props> = ({ currentSection, localSettings, u
sectionId={focusedSection} sectionId={focusedSection}
generateFnc={generateScript} generateFnc={generateScript}
level={level} level={level}
disabled={isUploading}
/> />
</div> </div>
</div> </div>
</Dropdown> <div className="flex justify-center text-mti-gray-dim font-semibold">Or</div>
<div className="flex flex-col w-full gap-2 px-2 pb-4">
<div className="flex flex-row items-center text-mti-gray-dim justify-between w-full gap-4 py-2 pl-2">
<div className="flex-1 bg-gray-100 px-3.5 py-2.5 rounded-lg border border-gray-300">
Import your own audio file
</div>
<div className="flex self-end h-16 mb-1 flex-shrink-0">
<button
className={clsx(
"flex items-center w-[140px] justify-center px-4 py-2 text-white rounded-xl transition-colors duration-300 text-lg disabled:cursor-not-allowed",
"bg-ielts-listening/70 border border-ielts-listening hover:bg-ielts-listening disabled:bg-ielts-listening/40"
)}
onClick={() => setIsUploaderOpen(true)}
>
<div className="flex flex-row">
{isUploading ? (
<BsArrowRepeat className="mr-2 text-white animate-spin" size={25} />
) : (
<>
<FaFileUpload className="mr-2" size={24} />
<span>Upload</span>
</>
)}
</div>
</button>
</div>
</div>
</div >
</Dropdown >
<Dropdown <Dropdown
title="Add Exercises" title="Add Exercises"
module="listening" module="listening"
@@ -181,10 +310,10 @@ const ListeningComponents: React.FC<Props> = ({ currentSection, localSettings, u
module="listening" module="listening"
open={localSettings.isAudioGenerationOpen} open={localSettings.isAudioGenerationOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isAudioGenerationOpen: isOpen }, false)} setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isAudioGenerationOpen: isOpen }, false)}
disabled={currentSection === undefined || currentSection.script === undefined && currentSection.audio === undefined || currentSection.exercises.length === 0} disabled={currentSection === undefined || currentSection.script === undefined || currentSection.exercises.length === 0 || audioUrl !== undefined}
contentWrapperClassName={level ? `border border-ielts-listening` : ''} contentWrapperClassName={level ? `border border-ielts-listening` : ''}
> >
<div className="flex flex-row items-center text-mti-gray-dim justify-center mb-4 gap-2 p-2"> <div className="flex flex-row items-center text-mti-gray-dim justify-center mb-4 gap-4 p-2">
<span className="bg-gray-100 px-3.5 py-2.5 rounded-lg border border-gray-300"> <span className="bg-gray-100 px-3.5 py-2.5 rounded-lg border border-gray-300">
Generate audio recording for this section Generate audio recording for this section
</span> </span>

View File

@@ -61,7 +61,6 @@ const WritingSettings: React.FC = () => {
exercises: sections.map((s, index) => { exercises: sections.map((s, index) => {
const exercise = s.state as WritingExercise; const exercise = s.state as WritingExercise;
if (type === "academic" && index == 0 && academic_url) { if (type === "academic" && index == 0 && academic_url) {
console.log("Added the URL");
exercise["attachment"] = { exercise["attachment"] = {
url: academic_url, url: academic_url,
description: "Visual Information" description: "Visual Information"

View File

@@ -20,7 +20,7 @@ import { defaultSectionSettings } from "@/stores/examEditor/defaults";
import Button from "../Low/Button"; import Button from "../Low/Button";
import ResetModule from "./ResetModule"; import ResetModule from "./ResetModule";
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"]; 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();

View File

@@ -0,0 +1,332 @@
import React, { useState } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import {
FaCheckCircle,
FaTimesCircle,
FaExclamationCircle,
FaInfoCircle,
FaUsers,
FaExclamationTriangle,
FaLock
} from 'react-icons/fa';
import Modal from '../Modal';
import UserTable from '../Tables/UserTable';
import ParseExcelErrors from './ExcelError';
import { errorsByRows } from '@/utils/excel.errors';
import { ClassroomTransferState } from '../Imports/StudentClassroomTransfer';
import { ExcelUserDuplicatesMap } from './User';
const ClassroomImportSummary: React.FC<{state: ClassroomTransferState}> = ({ state }) => {
const [showErrorsModal, setShowErrorsModal] = useState(false);
const [showDuplicatesModal, setShowDuplicatesModal] = useState(false);
const [showNotFoundModal, setShowNotFoundModal] = useState(false);
const [showOtherEntityModal, setShowOtherEntityModal] = useState(false);
const [showAlreadyInClassModal, setShowAlreadyInClassModal] = useState(false);
const [showNotOwnedModal, setShowNotOwnedModal] = useState(false);
const errorCount = state.parsedExcel?.errors ?
Object.entries(errorsByRows(state.parsedExcel.errors)).length : 0;
return (
<>
<Card>
<CardHeader>
<CardTitle className="flex justify-center font-semibold text-xl">
Import Summary
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center gap-2">
<FaInfoCircle className="h-5 w-5 text-blue-500" />
<span>
{`${state.parsedExcel?.rows?.length || 0} total spreadsheet rows`}
{state.parsedExcel && state.parsedExcel.rows && state.parsedExcel.rows.filter(row => row === null).length > 0 && (
<span className="text-gray-500">
{` (${state.parsedExcel.rows.filter((row: any) => row === null).length} empty)`}
</span>
)}
</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{state.imports.length > 0 ? (
<FaCheckCircle className="h-5 w-5 text-green-500" />
) : (
<FaTimesCircle className="h-5 w-5 text-red-500" />
)}
<span>{`${state.imports.length} user${state.imports.length !== 1 ? 's' : ''} ready for transfer`}</span>
</div>
</div>
{state.notFoundUsers.length > 0 && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FaTimesCircle className="h-5 w-5 text-red-500" />
<span>{`${state.notFoundUsers.length} user${state.notFoundUsers.length !== 1 ? 's' : ''} not found`}</span>
</div>
<button
onClick={() => setShowNotFoundModal(true)}
className="inline-flex items-center justify-center px-3 py-1.5 text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-md transition-colors"
>
View details
</button>
</div>
)}
{state.otherEntityUsers.length > 0 && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FaExclamationCircle className="h-5 w-5 text-yellow-500" />
<span>{`${state.otherEntityUsers.length} user${state.otherEntityUsers.length !== 1 ? 's' : ''} from different entities`}</span>
</div>
<button
onClick={() => setShowOtherEntityModal(true)}
className="inline-flex items-center justify-center px-3 py-1.5 text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-md transition-colors"
>
View details
</button>
</div>
)}
{state.alreadyInClass.length > 0 && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FaUsers className="h-5 w-5 text-blue-500" />
<span>{`${state.alreadyInClass.length} user${state.alreadyInClass.length !== 1 ? 's' : ''} already in class`}</span>
</div>
<button
onClick={() => setShowAlreadyInClassModal(true)}
className="inline-flex items-center justify-center px-3 py-1.5 text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-md transition-colors"
>
View details
</button>
</div>
)}
{state.notOwnedClassrooms.length > 0 && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FaLock className="h-5 w-5 text-red-500" />
<span>{`${state.notOwnedClassrooms.length} classroom${state.notOwnedClassrooms.length !== 1 ? 's' : ''} not owned`}</span>
</div>
<button
onClick={() => setShowNotOwnedModal(true)}
className="inline-flex items-center justify-center px-3 py-1.5 text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-md transition-colors"
>
View details
</button>
</div>
)}
{state.duplicatedRows && state.duplicatedRows.count > 0 && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FaExclamationCircle className="h-5 w-5 text-yellow-500" />
<span>{state.duplicatedRows.count} duplicate entries in file</span>
</div>
<button
onClick={() => setShowDuplicatesModal(true)}
className="inline-flex items-center justify-center px-3 py-1.5 text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-md transition-colors"
>
View duplicates
</button>
</div>
)}
{errorCount > 0 && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FaTimesCircle className="h-5 w-5 text-red-500" />
<span>{errorCount} invalid rows</span>
</div>
<button
onClick={() => setShowErrorsModal(true)}
className="inline-flex items-center justify-center px-3 py-1.5 text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-md transition-colors"
>
View errors
</button>
</div>
)}
</div>
{((state.duplicatedRows?.count ?? 0) > 0 || errorCount > 0 ||
state.notFoundUsers.length > 0 || state.otherEntityUsers.length > 0 ||
state.notOwnedClassrooms.length > 0) && (
<div className="mt-6 rounded-lg border border-red-100 bg-white p-6">
<div className="flex items-start gap-4">
<div className="mt-1">
<FaExclamationTriangle className="h-5 w-5 text-red-400" />
</div>
<div className="flex-1 space-y-3">
<p className="font-medium text-gray-900">
The following will be excluded from transfer:
</p>
<ul className="space-y-4">
{state.notFoundUsers.length > 0 && (
<li>
<div className="text-gray-700">
<span className="font-medium">{state.notFoundUsers.length}</span> users not found
</div>
</li>
)}
{state.otherEntityUsers.length > 0 && (
<li>
<div className="text-gray-700">
<span className="font-medium">{state.otherEntityUsers.length}</span> users from different entities
</div>
</li>
)}
{state.notOwnedClassrooms.length > 0 && (
<li>
<div className="text-gray-700">
<span className="font-medium">{state.notOwnedClassrooms.length}</span> classrooms not owned:
<div className="mt-1 ml-4 text-sm text-gray-600">
{state.notOwnedClassrooms.join(', ')}
</div>
</div>
</li>
)}
{state.duplicatedRows && state.duplicatedRows.count > 0 && (
<li>
<div className="text-gray-700">
<span className="font-medium">{state.duplicatedRows.count}</span> duplicate entries
</div>
</li>
)}
{errorCount > 0 && (
<li>
<div className="text-gray-700">
<span className="font-medium">{errorCount}</span> rows with invalid information
</div>
</li>
)}
</ul>
</div>
</div>
</div>
)}
</CardContent>
</Card>
{/* Modals */}
<Modal isOpen={showErrorsModal} onClose={() => setShowErrorsModal(false)}>
<>
<div className="flex items-center gap-2 mb-6">
<FaTimesCircle className="w-5 h-5 text-red-500" />
<h2 className="text-lg font-semibold text-gray-900">Validation Errors</h2>
</div>
<ParseExcelErrors errors={state.parsedExcel?.errors || []} />
</>
</Modal>
<Modal isOpen={showNotFoundModal} onClose={() => setShowNotFoundModal(false)}>
<>
<div className="flex items-center gap-2 mb-6">
<FaTimesCircle className="w-5 h-5 text-red-500" />
<h2 className="text-lg font-semibold text-gray-900">Users Not Found</h2>
</div>
<UserTable users={state.notFoundUsers} />
</>
</Modal>
<Modal isOpen={showOtherEntityModal} onClose={() => setShowOtherEntityModal(false)}>
<>
<div className="flex items-center gap-2 mb-6">
<FaExclamationCircle className="w-5 h-5 text-yellow-500" />
<h2 className="text-lg font-semibold text-gray-900">Users from Different Entities</h2>
</div>
<UserTable users={state.otherEntityUsers} />
</>
</Modal>
<Modal isOpen={showAlreadyInClassModal} onClose={() => setShowAlreadyInClassModal(false)}>
<>
<div className="flex items-center gap-2 mb-6">
<FaUsers className="w-5 h-5 text-blue-500" />
<h2 className="text-lg font-semibold text-gray-900">Users Already in Class</h2>
</div>
<UserTable users={state.alreadyInClass} />
</>
</Modal>
<Modal isOpen={showNotOwnedModal} onClose={() => setShowNotOwnedModal(false)}>
<>
<div className="flex items-center gap-2 mb-6">
<FaLock className="w-5 h-5 text-red-500" />
<h2 className="text-lg font-semibold text-gray-900">Classrooms Not Owned</h2>
</div>
<div className="space-y-3">
{state.notOwnedClassrooms.map(classroom => (
<div
key={classroom}
className="flex justify-between items-center rounded-lg border border-gray-200 bg-gray-50 p-3"
>
<span className="text-gray-700">{classroom}</span>
</div>
))}
</div>
</>
</Modal>
<Modal isOpen={showDuplicatesModal} onClose={() => setShowDuplicatesModal(false)}>
<>
<div className="flex items-center gap-2 mb-6">
<FaExclamationCircle className="w-5 h-5 text-yellow-500" />
<h2 className="text-lg font-semibold text-gray-900">Duplicate Entries</h2>
</div>
{state.duplicatedRows && (
<div className="space-y-6">
{(Object.keys(state.duplicatedRows.duplicates) as Array<keyof ExcelUserDuplicatesMap>).map(field => {
const duplicates = Array.from(state.duplicatedRows!.duplicates[field].entries())
.filter((entry): entry is [string, number[]] => entry[1].length > 1);
if (duplicates.length === 0) return null;
return (
<div key={field} className="relative">
<div className="flex items-center gap-2 mb-3">
<h2 className="text-md font-medium text-gray-700">
{field} duplicates
</h2>
<span className="text-xs text-gray-500 ml-auto">
{duplicates.length} {duplicates.length === 1 ? 'duplicate' : 'duplicates'}
</span>
</div>
<div className="space-y-2">
{duplicates.map(([value, rows]) => (
<div
key={value}
className="group relative rounded-lg border border-gray-200 bg-gray-50 p-3 hover:bg-gray-100 transition-colors"
>
<div className="flex items-start justify-between">
<div>
<span className="font-medium text-gray-900">{value}</span>
<div className="mt-1 text-sm text-gray-600">
Appears in rows:
<span className="ml-1 text-blue-600 font-medium">
{rows.join(', ')}
</span>
</div>
</div>
<span className="text-xs text-gray-500">
{rows.length} occurrences
</span>
</div>
</div>
))}
</div>
</div>
);
})}
</div>
)}
</>
</Modal>
</>
);
};
export default ClassroomImportSummary;

View File

@@ -0,0 +1,215 @@
import React, { useState, useMemo } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import {
FaCheckCircle,
FaTimesCircle,
FaExclamationCircle,
FaInfoCircle,
FaExclamationTriangle
} from 'react-icons/fa';
import Modal from '../Modal';
import ParseExcelErrors from './ExcelError';
import { errorsByRows, ExcelError } from '@/utils/excel.errors';
interface Props {
parsedExcel: { rows?: any[]; errors?: any[] },
duplicateRows?: { duplicates: ExcelCodegenDuplicatesMap, count: number },
infos: { email: string; name: string; passport_id: string }[];
}
export interface ExcelCodegenDuplicatesMap {
email: Map<string, number[]>;
passport_id: Map<string, number[]>;
}
const CodeGenImportSummary: React.FC<Props> = ({ infos, parsedExcel, duplicateRows }) => {
const [showErrorsModal, setShowErrorsModal] = useState(false);
const [showDuplicatesModal, setShowDuplicatesModal] = useState(false);
const errorCount = Object.entries(errorsByRows(parsedExcel.errors as ExcelError[])).length || 0;
const fieldMapper = {
"passport_id": "Passport/National ID",
"email": "E-mail"
}
return (
<>
<Card>
<CardHeader>
<CardTitle className='flex justify-center font-semibold text-xl'>Import Summary</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center gap-2">
<FaInfoCircle className="h-5 w-5 text-blue-500" />
<span>
{`${parsedExcel.rows?.length} total spreadsheet rows`}
{parsedExcel.rows && parsedExcel.rows.filter(row => row === null).length > 0 && (
<span className="text-gray-500">
{` (${parsedExcel.rows.filter(row => row === null).length} empty)`}
</span>
)}
</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{infos.length > 0 ? (
<FaCheckCircle className="h-5 w-5 text-green-500" />
) : (
<FaTimesCircle className="h-5 w-5 text-red-500" />
)}
<span>{`${infos.length} new code${infos.length > 1 ? "s" : ''} to generate`}</span>
</div>
</div>
{(duplicateRows && duplicateRows.count > 0) && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FaExclamationCircle className="h-5 w-5 text-yellow-500" />
<span>{duplicateRows.count} duplicate entries in file</span>
</div>
<button
onClick={() => setShowDuplicatesModal(true)}
className="inline-flex items-center justify-center px-3 py-1.5 text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-md transition-colors"
>
View duplicates
</button>
</div>
)}
{errorCount > 0 && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FaTimesCircle className="h-5 w-5 text-red-500" />
<span>{errorCount} invalid rows</span>
</div>
<button
onClick={() => setShowErrorsModal(true)}
className="inline-flex items-center justify-center px-3 py-1.5 text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-md transition-colors"
>
View errors
</button>
</div>
)}
</div>
{((duplicateRows && duplicateRows.count > 0) || errorCount > 0) && (
<div className="mt-6 rounded-lg border border-red-100 bg-white p-6">
<div className="flex items-start gap-4">
<div className="mt-1">
<FaExclamationTriangle className="h-5 w-5 text-red-400" />
</div>
<div className="flex-1 space-y-3">
<p className="font-medium text-gray-900">
The following will be excluded from import:
</p>
<ul className="space-y-4">
{duplicateRows && duplicateRows.count > 0 && (
<li>
<div className="text-gray-700">
<span className="font-medium">{duplicateRows.count}</span> rows with duplicate values
</div>
<div className="mt-2 space-y-1.5">
{(Object.keys(duplicateRows.duplicates) as Array<keyof ExcelCodegenDuplicatesMap>).map(field => {
const duplicates = Array.from(duplicateRows.duplicates[field].entries())
.filter((entry) => entry[1].length > 1);
if (duplicates.length === 0) return null;
return (
<div key={field} className="ml-4 text-sm text-gray-600">
<span className="text-gray-500">{field}:</span> rows {
duplicates.map(([_, rows]) => rows.join(', ')).join('; ')
}
</div>
);
})}
</div>
</li>
)}
{errorCount > 0 && (
<li>
<div className="text-gray-700">
<span className="font-medium">{errorCount}</span> rows with invalid or missing information
</div>
<div className="mt-1 ml-4 text-sm text-gray-600">
Rows: {Object.keys(errorsByRows(parsedExcel.errors || [])).join(', ')}
</div>
</li>
)}
</ul>
</div>
</div>
</div>
)}
</CardContent>
</Card>
<Modal isOpen={showErrorsModal} onClose={() => setShowErrorsModal(false)}>
<>
<div className="flex items-center gap-2 mb-6">
<FaTimesCircle className="w-5 h-5 text-red-500" />
<h2 className="text-lg font-semibold text-gray-900">Validation Errors</h2>
</div>
<ParseExcelErrors errors={parsedExcel.errors!} />
</>
</Modal>
<Modal isOpen={showDuplicatesModal} onClose={() => setShowDuplicatesModal(false)}>
<>
<div className="flex items-center gap-2 mb-6">
<FaExclamationCircle className="w-5 h-5 text-amber-500" />
<h2 className="text-lg font-semibold text-gray-900">Duplicate Entries</h2>
</div>
{duplicateRows &&
< div className="space-y-6">
{(Object.keys(duplicateRows.duplicates) as Array<keyof ExcelCodegenDuplicatesMap>).map(field => {
const duplicates = Array.from(duplicateRows!.duplicates[field].entries())
.filter((entry): entry is [string, number[]] => entry[1].length > 1);
if (duplicates.length === 0) return null;
return (
<div key={field} className="relative">
<div className="flex items-center gap-2 mb-3">
<h2 className="text-md font-medium text-gray-700">
{fieldMapper[field]} duplicates
</h2>
<span className="text-xs text-gray-500 ml-auto">
{duplicates.length} {duplicates.length === 1 ? 'duplicate' : 'duplicates'}
</span>
</div>
<div className="space-y-2">
{duplicates.map(([value, rows]) => (
<div
key={value}
className="group relative rounded-lg border border-gray-200 bg-gray-50 p-3 hover:bg-gray-100 transition-colors"
>
<div className="flex items-start justify-between">
<div>
<span className="font-medium text-gray-900">{value}</span>
<div className="mt-1 text-sm text-gray-600">
Appears in rows:
<span className="ml-1 text-blue-600 font-medium">
{rows.join(', ')}
</span>
</div>
</div>
<span className="text-xs text-gray-500">
{rows.length} occurrences
</span>
</div>
</div>
))}
</div>
</div>
);
})}
</div>
}
</>
</Modal >
</>
);
};
export default CodeGenImportSummary;

View File

@@ -12,16 +12,16 @@ import { UserImport } from '@/interfaces/IUserImport';
import Modal from '../Modal'; import Modal from '../Modal';
import ParseExcelErrors from './ExcelError'; import ParseExcelErrors from './ExcelError';
import { errorsByRows, ExcelError } from '@/utils/excel.errors'; import { errorsByRows, ExcelError } from '@/utils/excel.errors';
import UserTable from '../UserTable'; import UserTable from '../Tables/UserTable';
interface Props { interface Props {
parsedExcel: { rows?: any[]; errors?: any[] }, parsedExcel: { rows?: any[]; errors?: any[] },
newUsers: UserImport[], newUsers: UserImport[],
enlistedUsers: UserImport[], enlistedUsers: UserImport[],
duplicateRows?: { duplicates: DuplicatesMap, count: number } duplicateRows?: { duplicates: ExcelUserDuplicatesMap, count: number }
} }
export interface DuplicatesMap { export interface ExcelUserDuplicatesMap {
studentID: Map<string, number[]>; studentID: Map<string, number[]>;
email: Map<string, number[]>; email: Map<string, number[]>;
passport_id: Map<string, number[]>; passport_id: Map<string, number[]>;
@@ -139,7 +139,7 @@ const UserImportSummary: React.FC<Props> = ({ parsedExcel, newUsers, enlistedUse
</div> </div>
)} )}
</div> </div>
{(enlistedUsers.length > 0 || (duplicateRows && duplicateRows.count > 0) || errorCount > 0) && ( {((duplicateRows && duplicateRows.count > 0) || errorCount > 0) && (
<div className="mt-6 rounded-lg border border-red-100 bg-white p-6"> <div className="mt-6 rounded-lg border border-red-100 bg-white p-6">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="mt-1"> <div className="mt-1">
@@ -156,7 +156,7 @@ const UserImportSummary: React.FC<Props> = ({ parsedExcel, newUsers, enlistedUse
<span className="font-medium">{duplicateRows.count}</span> rows with duplicate values <span className="font-medium">{duplicateRows.count}</span> rows with duplicate values
</div> </div>
<div className="mt-2 space-y-1.5"> <div className="mt-2 space-y-1.5">
{(Object.keys(duplicateRows.duplicates) as Array<keyof DuplicatesMap>).map(field => { {(Object.keys(duplicateRows.duplicates) as Array<keyof ExcelUserDuplicatesMap>).map(field => {
const duplicates = Array.from(duplicateRows.duplicates[field].entries()) const duplicates = Array.from(duplicateRows.duplicates[field].entries())
.filter((entry) => entry[1].length > 1); .filter((entry) => entry[1].length > 1);
if (duplicates.length === 0) return null; if (duplicates.length === 0) return null;
@@ -219,7 +219,7 @@ const UserImportSummary: React.FC<Props> = ({ parsedExcel, newUsers, enlistedUse
</div> </div>
{duplicateRows && {duplicateRows &&
< div className="space-y-6"> < div className="space-y-6">
{(Object.keys(duplicateRows.duplicates) as Array<keyof DuplicatesMap>).map(field => { {(Object.keys(duplicateRows.duplicates) as Array<keyof ExcelUserDuplicatesMap>).map(field => {
const duplicates = Array.from(duplicateRows!.duplicates[field].entries()) const duplicates = Array.from(duplicateRows!.duplicates[field].entries())
.filter((entry): entry is [string, number[]] => entry[1].length > 1); .filter((entry): entry is [string, number[]] => entry[1].length > 1);

View File

@@ -0,0 +1,566 @@
import { useEffect, useState } from "react";
import Button from "../Low/Button";
import Modal from "../Modal";
import { useFilePicker } from "use-file-picker";
import readXlsxFile from "read-excel-file";
import countryCodes from "country-codes-list";
import { ExcelUserDuplicatesMap } from "../ImportSummaries/User";
import { UserImport } from "@/interfaces/IUserImport";
import axios from "axios";
import { toast } from "react-toastify";
import { HiOutlineDocumentText } from "react-icons/hi";
import { IoInformationCircleOutline } from "react-icons/io5";
import { FaFileDownload } from "react-icons/fa";
import clsx from "clsx";
import { checkAccess } from "@/utils/permissions";
import { Group, User } from "@/interfaces/user";
import UserTable from "../Tables/UserTable";
import ClassroomImportSummary from "../ImportSummaries/Classroom";
import Select from "../Low/Select";
import { EntityWithRoles } from "@/interfaces/entity";
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
export interface ClassroomTransferState {
stage: number;
parsedExcel: { rows?: any[]; errors?: any[] } | undefined;
duplicatedRows: { duplicates: ExcelUserDuplicatesMap, count: number } | undefined;
imports: UserImport[];
notFoundUsers: UserImport[];
otherEntityUsers: UserImport[];
alreadyInClass: UserImport[];
notOwnedClassrooms: string[];
classroomsToCreate: string[];
}
const StudentClassroomTransfer: React.FC<{ user: User; entities?: EntityWithRoles[], onFinish: () => void; }> = ({ user, entities = [], onFinish }) => {
const [isLoading, setIsLoading] = useState(false);
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined);
const [showHelp, setShowHelp] = useState(false);
const [classroomTransferState, setClassroomTransferState] = useState<ClassroomTransferState>({
stage: 0,
parsedExcel: undefined,
duplicatedRows: undefined,
imports: [],
notFoundUsers: [],
otherEntityUsers: [],
alreadyInClass: [],
notOwnedClassrooms: [],
classroomsToCreate: []
})
const { openFilePicker, filesContent, clear } = useFilePicker({
accept: ".xlsx",
multiple: false,
readAs: "ArrayBuffer",
});
const schema = {
'First Name': {
prop: 'firstName',
type: String,
required: true,
validate: (value: string) => {
if (!value || value.trim() === '') {
throw new Error('First Name cannot be empty')
}
return true
}
},
'Last Name': {
prop: 'lastName',
type: String,
required: true,
validate: (value: string) => {
if (!value || value.trim() === '') {
throw new Error('Last Name cannot be empty')
}
return true
}
},
'Student ID': {
prop: 'studentID',
type: String,
required: true,
validate: (value: string) => {
if (!value || value.trim() === '') {
throw new Error('Student ID cannot be empty')
}
return true
}
},
'Passport/National ID': {
prop: 'passport_id',
type: String,
required: true,
validate: (value: string) => {
if (!value || value.trim() === '') {
throw new Error('Passport/National ID cannot be empty')
}
return true
}
},
'E-mail': {
prop: 'email',
required: true,
type: (value: any) => {
if (!value || value.trim() === '') {
throw new Error('Email cannot be empty')
}
if (!EMAIL_REGEX.test(value.trim())) {
throw new Error('Invalid Email')
}
return value
}
},
'Phone Number': {
prop: 'phone',
type: Number,
required: true,
validate: (value: number) => {
if (value === null || value === undefined || String(value).trim() === '') {
throw new Error('Phone Number cannot be empty')
}
return true
}
},
'Classroom Name': {
prop: 'group',
type: String,
required: true,
validate: (value: string) => {
if (!value || value.trim() === '') {
throw new Error('Classroom Name cannot be empty')
}
return true
}
},
'Country': {
prop: 'country',
type: (value: any) => {
if (!value || value.trim() === '') {
throw new Error('Country cannot be empty')
}
const validCountry = countryCodes.findOne("countryCode" as any, value.toUpperCase()) ||
countryCodes.all().find((x) => x.countryNameEn.toLowerCase() === value.toLowerCase());
if (!validCountry) {
throw new Error('Invalid Country/Country Code')
}
return validCountry;
},
required: true
}
}
useEffect(() => {
if (filesContent.length > 0) {
const file = filesContent[0];
readXlsxFile(
file.content, { schema, ignoreEmptyRows: false })
.then((data) => {
setClassroomTransferState((prev) => ({...prev, parsedExcel: data}))
console.log(data);
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filesContent]);
// Stage 1 - Excel Parsing
// - Find duplicate rows
// - Parsing errors
// - Set the data
useEffect(() => {
if (classroomTransferState.parsedExcel && classroomTransferState.parsedExcel.rows) {
const duplicates: ExcelUserDuplicatesMap = {
studentID: new Map(),
email: new Map(),
passport_id: new Map(),
phone: new Map()
};
const duplicateValues = new Set<string>();
const duplicateRowIndices = new Set<number>();
const errorRowIndices = new Set(
classroomTransferState.parsedExcel.errors?.map(error => error.row) || []
);
classroomTransferState.parsedExcel.rows.forEach((row, index) => {
if (!errorRowIndices.has(index + 2)) {
(Object.keys(duplicates) as Array<keyof ExcelUserDuplicatesMap>).forEach(field => {
if (row !== null) {
const value = row[field];
if (value) {
if (!duplicates[field].has(value)) {
duplicates[field].set(value, [index + 2]);
} else {
const existingRows = duplicates[field].get(value);
if (existingRows) {
existingRows.push(index + 2);
duplicateValues.add(value);
existingRows.forEach(rowNum => duplicateRowIndices.add(rowNum));
}
}
}
}
});
}
});
const infos = classroomTransferState.parsedExcel.rows
.map((row, index) => {
if (errorRowIndices.has(index + 2) || duplicateRowIndices.has(index + 2) || row === null) {
return undefined;
}
const { firstName, lastName, studentID, passport_id, email, phone, group, country } = row;
if (!email || !EMAIL_REGEX.test(email.toString().trim())) {
return undefined;
}
return {
email: email.toString().trim().toLowerCase(),
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
passport_id: passport_id?.toString().trim() || undefined,
groupName: group,
studentID,
demographicInformation: {
country: country?.countryCode,
passport_id: passport_id?.toString().trim() || undefined,
phone: phone.toString(),
},
entity: undefined,
type: undefined
} as UserImport;
})
.filter((item): item is UserImport => item !== undefined);
console.log(infos);
// On import reset state except excel parsing
setClassroomTransferState((prev) => ({
...prev,
stage: 1,
duplicatedRows: { duplicates, count: duplicateRowIndices.size },
imports: infos,
notFoundUsers: [],
otherEntityUsers: [],
notOwnedClassrooms: [],
classroomsToCreate: [],
}));
}
}, [classroomTransferState.parsedExcel]);
// Stage 2 - Student Filter
// - Filter non existant students
// - Filter non entity students
// - Filter already in classroom students
useEffect(() => {
const emails = classroomTransferState.imports.map((i) => i.email);
const crossRefUsers = async () => {
try {
console.log(user.entities);
const { data: nonExistantUsers } = await axios.post("/api/users/controller?op=dontExist", { emails });
const { data: nonEntityUsers } = await axios.post("/api/users/controller?op=entityCheck", { entities: user.entities, emails });
const { data: alreadyPlaced } = await axios.post("/api/users/controller?op=crossRefClassrooms", {
sets: classroomTransferState.imports.map((info) => ({
email: info.email,
classroom: info.groupName
}))
});
const excludeEmails = new Set([
...nonExistantUsers,
...nonEntityUsers,
...alreadyPlaced
]);
const filteredImports = classroomTransferState.imports.filter(i => !excludeEmails.has(i.email));
const nonExistantEmails = new Set(nonExistantUsers);
const nonEntityEmails = new Set(nonEntityUsers);
const alreadyPlacedEmails = new Set(alreadyPlaced);
setClassroomTransferState((prev) => ({
...prev,
stage: 2,
imports: filteredImports,
notFoundUsers: classroomTransferState.imports.filter(i => nonExistantEmails.has(i.email)),
otherEntityUsers: classroomTransferState.imports.filter(i => nonEntityEmails.has(i.email)),
alreadyInClass: classroomTransferState.imports.filter(i => alreadyPlacedEmails.has(i.email)),
}));
} catch (error) {
toast.error("Something went wrong, please try again later!");
}
}
if (classroomTransferState.imports.length > 0 && classroomTransferState.stage === 1) {
crossRefUsers();
}
}, [classroomTransferState.imports, user.entities, classroomTransferState.stage])
// Stage 3 - Classroom Filter
// - See if there are classrooms with same name but different admin
// - Find which new classrooms need to be created
useEffect(() => {
const crossRefClassrooms = async () => {
const classrooms = new Set(classroomTransferState.imports.map((i) => i.groupName));
try {
const { data: notOwnedClassroomsSameName } = await axios.post("/api/groups/controller?op=crossRefOwnership", {
userId: user.id,
classrooms
});
setClassroomTransferState((prev) => ({
...prev,
stage: 3,
notOwnedClassrooms: notOwnedClassroomsSameName,
classroomsToCreate: Array.from(classrooms).filter(
(name) => !new Set(notOwnedClassroomsSameName).has(name)
)
}))
} catch (error) {
toast.error("Something went wrong, please try again later!");
}
};
if (classroomTransferState.imports.length > 0 && classroomTransferState.stage === 2) {
crossRefClassrooms();
}
}, [classroomTransferState.imports, classroomTransferState.stage, user.id])
const clearAndReset = () => {
setIsLoading(false);
setClassroomTransferState({
stage: 0,
parsedExcel: undefined,
duplicatedRows: undefined,
imports: [],
notFoundUsers: [],
otherEntityUsers: [],
alreadyInClass: [],
notOwnedClassrooms: [],
classroomsToCreate: []
});
clear();
};
const createNewGroupsAndAssignStudents = async () => {
if (!confirm(`You are about to assign ${classroomTransferState.imports.length} to new classrooms, are you sure you want to continue?`)) {
return;
}
if (classroomTransferState.imports.length === 0) {
clearAndReset();
return;
}
try {
setIsLoading(true);
const groupedUsers = classroomTransferState.imports.reduce((acc, user) => {
if (!acc[user.groupName]) {
acc[user.groupName] = [];
}
acc[user.groupName].push(user);
return acc;
}, {} as Record<string, UserImport[]>);
const newGroupUsers = Object.fromEntries(
Object.entries(groupedUsers)
.filter(([groupName]) => classroomTransferState.classroomsToCreate.includes(groupName))
);
const createGroupPromises = Object.entries(newGroupUsers).map(([groupName, users]) => {
const groupData: Partial<Group> = {
admin: user.id,
name: groupName,
participants: users.map(user => user.id),
entity: entity
};
return axios.post('/api/groups', groupData);
});
const existingGroupUsers = Object.fromEntries(
Object.entries(groupedUsers)
.filter(([groupName]) => !classroomTransferState.classroomsToCreate.includes(groupName))
);
const { groups: groupNameToId, users: userEmailToId } = await axios.post('/api/groups?op=getIds', {
names: Object.keys(existingGroupUsers),
userEmails: Object.values(existingGroupUsers).flat().map(user => user.email)
}).then(response => response.data);
const existingGroupsWithIds = Object.entries(existingGroupUsers).reduce((acc, [groupName, users]) => {
acc[groupNameToId[groupName]] = users;
return acc;
}, {} as Record<string, any[]>);
const updatePromises = Object.entries(existingGroupsWithIds).map(([groupId, users]) => {
const userIds = users.map(user => userEmailToId[user.email]);
return axios.patch(`/api/groups/${groupId}`, {
participants: userIds
});
});
await Promise.all([
...createGroupPromises,
...updatePromises
]);
toast.success(`Successfully assigned all ${classroomTransferState.imports.length} user(s)!`);
onFinish();
} catch (error) {
console.error(error);
toast.error("Something went wrong, please try again later!");
} finally {
clearAndReset();
}
};
const handleTemplateDownload = () => {
const fileName = "UsersTemplate.xlsx";
const url = `https://firebasestorage.googleapis.com/v0/b/encoach-staging.appspot.com/o/import_templates%2F${fileName}?alt=media&token=b771a535-bf95-4060-889c-a086df65d480`;
const link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
return (
<>
<Modal isOpen={showHelp} onClose={() => setShowHelp(false)}>
<>
<div className="flex font-bold text-xl justify-center text-gray-700"><span>Excel File Format</span></div>
<div className="mt-4 flex flex-col gap-4">
<div className="flex flex-col gap-3 bg-gray-100 rounded-lg p-4">
<div className="flex items-center gap-2">
<HiOutlineDocumentText className={`w-5 h-5 text-mti-purple-light`} />
<h2 className="text-lg font-semibold">
The uploaded document must:
</h2>
</div>
<ul className="flex flex-col pl-10 gap-2">
<li className="text-gray-700 list-disc">
be an Excel .xlsx document.
</li>
<li className="text-gray-700 list-disc">
only have a single spreadsheet with the following <b>exact same name</b> columns:
<div className="py-4 pr-4">
<table className="w-full bg-white">
<thead>
<tr>
<th className="border border-neutral-200 px-2 py-1">First Name</th>
<th className="border border-neutral-200 px-2 py-1">Last Name</th>
<th className="border border-neutral-200 px-2 py-1">Student ID</th>
<th className="border border-neutral-200 px-2 py-1">Passport/National ID</th>
<th className="border border-neutral-200 px-2 py-1">E-mail</th>
<th className="border border-neutral-200 px-2 py-1">Phone Number</th>
<th className="border border-neutral-200 px-2 py-1">Classroom Name</th>
<th className="border border-neutral-200 px-2 py-1">Country</th>
</tr>
</thead>
</table>
</div>
</li>
</ul>
</div>
<div className="flex flex-col gap-3 bg-gray-100 rounded-lg p-4">
<div className="flex items-center gap-2">
<IoInformationCircleOutline className={`w-5 h-5 text-mti-purple-light`} />
<h2 className="text-lg font-semibold">
Note that:
</h2>
</div>
<ul className="flex flex-col pl-10 gap-2">
<li className="text-gray-700 list-disc">
all incorrect e-mails will be ignored.
</li>
<li className="text-gray-700 list-disc">
all non registered users will be ignored.
</li>
<li className="text-gray-700 list-disc">
all students that already are present in the destination classroom will be ignored.
</li>
<li className="text-gray-700 list-disc">
all registered students that are not associated to your institution will be ignored.
</li>
<li className="text-gray-700 list-disc">
all rows which contain duplicate values in the columns: &quot;Student ID&quot;, &quot;Passport/National ID&quot;, &quot;E-mail&quot;, will be ignored.
</li>
</ul>
</div>
<div className="bg-gray-100 rounded-lg p-4">
<p className="text-gray-600">
{`The downloadable template is an example of a file that can be imported. Your document doesn't need to be a carbon copy of the template - it can have different styling but it must adhere to the previous requirements.`}
</p>
</div>
<div className="w-full flex justify-between mt-6 gap-8">
<Button color="purple" onClick={() => setShowHelp(false)} variant="outline" className="self-end w-full bg-white">
Close
</Button>
<Button color="purple" onClick={handleTemplateDownload} variant="solid" className="self-end w-full">
<div className="flex items-center gap-2">
<FaFileDownload size={24} />
Download Template
</div>
</Button>
</div>
</div>
</>
</Modal>
<div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
<div className="grid grid-cols-2 -md:grid-cols-1 gap-4">
<div className="flex flex-col gap-4">
<div className="flex items-end justify-between">
<label className="text-mti-gray-dim text-base font-normal">Choose an Excel file</label>
<button
onClick={() => setShowHelp(true)}
className="tooltip cursor-pointer p-1.5 hover:bg-gray-200 rounded-full transition-colors duration-200"
data-tip="Excel File Format"
>
<IoInformationCircleOutline size={24} />
</button>
</div>
<Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}>
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
</Button>
</div>
<div className={clsx("flex flex-col gap-4")}>
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
<Select
defaultValue={{ value: (entities || [])[0]?.id, 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>
</div>
{classroomTransferState.parsedExcel?.rows !== undefined && (
<ClassroomImportSummary state={classroomTransferState} />
)}
{classroomTransferState.imports.length !== 0 && (
<div className="flex w-full flex-col gap-4">
<span className="text-mti-gray-dim text-base font-normal">New Classroom Assignments:</span>
<UserTable users={classroomTransferState.imports} />
</div>
)}
<Button className="my-auto mt-4" onClick={createNewGroupsAndAssignStudents} disabled={classroomTransferState.imports.length === 0}>
Assign {classroomTransferState.imports.length !== 0 ? `${classroomTransferState.imports.length} user${classroomTransferState.imports.length > 1 ? 's' : ''}` : ''} to {`${new Set(classroomTransferState.imports.map((i) => i.groupName)).size} classrooms`}
</Button>
</div>
</>
);
}
export default StudentClassroomTransfer;

View File

@@ -0,0 +1,180 @@
import React, { useState } from 'react';
import {
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
getPaginationRowModel,
getSortedRowModel,
getFilteredRowModel,
FilterFn,
} from '@tanstack/react-table';
interface CodeInfo { email: string; name: string; passport_id: string }
const globalFilterFn: FilterFn<any> = (row, columnId, filterValue: string) => {
const value = row.getValue(columnId);
return String(value).toLowerCase().includes(filterValue.toLowerCase());
};
const columnHelper = createColumnHelper<CodeInfo>();
const columns = [
columnHelper.accessor('name', {
cell: info => info.getValue(),
header: () => 'Name',
}),
columnHelper.accessor('passport_id', {
cell: info => info.getValue(),
header: () => 'Passport/National ID',
}),
columnHelper.accessor('email', {
cell: info => info.getValue(),
header: () => 'E-mail',
}),
];
const CodegenTable: React.FC<{ infos: CodeInfo[]; }> = ({ infos }) => {
const [globalFilter, setGlobalFilter] = useState('');
const table = useReactTable({
data: infos,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
globalFilterFn: globalFilterFn,
state: {
globalFilter,
},
onGlobalFilterChange: setGlobalFilter,
initialState: {
pagination: {
pageSize: 5,
},
}
});
return (
<div className='flex flex-col'>
<div className="flex flew-row w-full mb-4 justify-between gap-4">
<input
type="text"
value={globalFilter ?? ''}
onChange={e => setGlobalFilter(e.target.value)}
placeholder="Search ..."
className="p-2 border rounded flex-grow"
/>
<select
value={table.getState().pagination.pageSize}
onChange={e => {
table.setPageSize(Number(e.target.value));
}}
className="p-2 border rounded"
>
{[5, 10, 15, 20].map(pageSize => (
<option key={pageSize} value={pageSize}>
Show {pageSize}
</option>
))}
</select>
</div>
<table className="w-full">
<thead>
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => (
<th
key={header.id}
className='bg-mti-purple-ultralight/80 first:rounded-tl-3xl last:rounded-tr-3xl py-4 first:pl-6 text-mti-purple-light cursor-pointer'
onClick={header.column.getToggleSortingHandler()}
>
{header.isPlaceholder ? null : (
<div className='flex flex-row justify-between'>
<span>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</span>
<span className='pr-6'>
{{
asc: ' 🔼',
desc: ' 🔽',
}[header.column.getIsSorted() as string] ?? null}
</span>
</div>
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row, index, array) => {
const isLastRow = index === array.length - 1;
return (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => {
return (
<td
key={cell.id}
className={
isLastRow
? `first:rounded-bl-3xl last:rounded-br-3xl py-4 first:pl-6 bg-mti-purple-ultralight/40`
: `first:pl-6 py-4 border-b bg-mti-purple-ultralight/40`
}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
<div className="mt-4 flex items-center gap-4 mx-auto">
<button
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
className="px-4 py-2 bg-mti-purple-light text-white rounded disabled:opacity-50"
>
{'<<'}
</button>
<button
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
className="px-4 py-2 bg-mti-purple-light text-white rounded disabled:opacity-50"
>
{'<'}
</button>
<span>
Page{' '}
<strong>
{table.getState().pagination.pageIndex + 1} of{' '}
{table.getPageCount()}
</strong>
</span>
<button
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
className="px-4 py-2 bg-mti-purple-light text-white rounded disabled:opacity-50"
>
{'>'}
</button>
<button
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
className="px-4 py-2 bg-mti-purple-light text-white rounded disabled:opacity-50"
>
{'>>'}
</button>
</div>
</div>
);
};
export default CodegenTable;

View File

@@ -9,7 +9,7 @@ import {
getFilteredRowModel, getFilteredRowModel,
FilterFn, FilterFn,
} from '@tanstack/react-table'; } from '@tanstack/react-table';
import { UserImport } from "../interfaces/IUserImport"; import { UserImport } from "../../interfaces/IUserImport";
const globalFilterFn: FilterFn<any> = (row, columnId, filterValue: string) => { const globalFilterFn: FilterFn<any> = (row, columnId, filterValue: string) => {
const value = row.getValue(columnId); const value = row.getValue(columnId);

View File

@@ -1,15 +1,20 @@
import { Switch } from "@headlessui/react";
import clsx from "clsx";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { BsPauseFill, BsPlayFill, BsScissors, BsTrash } from "react-icons/bs"; import { BsPauseFill, BsPlayFill, BsScissors, BsTrash } from "react-icons/bs";
import { MdAllInclusive } from "react-icons/md";
import { BsFillFileEarmarkMusicFill } from "react-icons/bs";
import WaveSurfer from "wavesurfer.js"; import WaveSurfer from "wavesurfer.js";
// @ts-ignore // @ts-ignore
import RegionsPlugin from 'wavesurfer.js/dist/plugin/wavesurfer.regions.min.js'; import RegionsPlugin from 'wavesurfer.js/dist/plugin/wavesurfer.regions.min.js';
import { toast } from "react-toastify";
interface Props { interface Props {
audio: string; audio: string;
waveColor: string; waveColor: string;
progressColor: string; progressColor: string;
variant?: 'exercise' | 'edit'; variant?: 'exercise' | 'edit';
onCutsChange?: (cuts: AudioCut[]) => void; setAudioUrl?: React.Dispatch<React.SetStateAction<string | undefined>>;
} }
interface AudioCut { interface AudioCut {
@@ -23,14 +28,32 @@ const Waveform = ({
waveColor, waveColor,
progressColor, progressColor,
variant = 'exercise', variant = 'exercise',
onCutsChange setAudioUrl
}: Props) => { }: Props) => {
const containerRef = useRef(null); const containerRef = useRef(null);
const previewContainerRef = useRef(null);
const waveSurferRef = useRef<WaveSurfer | null>(null); const waveSurferRef = useRef<WaveSurfer | null>(null);
const previewWaveSurferRef = useRef<WaveSurfer | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [cuts, setCuts] = useState<AudioCut[]>([]); const [isPreviewPlaying, setIsPreviewPlaying] = useState(false);
const [currentRegion, setCurrentRegion] = useState<any | null>(null); const [currentCut, setCurrentCut] = useState<AudioCut | null>(null);
const [duration, setDuration] = useState<number>(0); const [duration, setDuration] = useState<number>(0);
const [isProcessing, setIsProcessing] = useState(false);
const [cutAudioUrl, setCutAudioUrl] = useState<string | null>(null);
const [useFullAudio, setUseFullAudio] = useState(true);
const cleanupPreview = () => {
if (cutAudioUrl) {
URL.revokeObjectURL(cutAudioUrl);
setCutAudioUrl(null);
}
if (previewWaveSurferRef.current) {
previewWaveSurferRef.current.destroy();
previewWaveSurferRef.current = null;
}
setIsPreviewPlaying(false);
};
useEffect(() => { useEffect(() => {
const waveSurfer = WaveSurfer.create({ const waveSurfer = WaveSurfer.create({
@@ -67,51 +90,183 @@ const Waveform = ({
waveSurfer.on("finish", () => setIsPlaying(false)); waveSurfer.on("finish", () => setIsPlaying(false));
if (variant === 'edit') { if (variant === 'edit') {
waveSurfer.on('region-created', (region) => { waveSurfer.on('region-created', (region) => {
setCurrentRegion(region); const regions = waveSurfer.regions.list;
Object.keys(regions).forEach(id => {
if (id !== region.id) {
regions[id].remove();
}
});
cleanupPreview();
const newCut: AudioCut = { const newCut: AudioCut = {
id: region.id, id: region.id,
start: region.start, start: region.start,
end: region.end end: region.end
}; };
setCuts(prev => [...prev, newCut]); setCurrentCut(newCut);
onCutsChange?.([...cuts, newCut]);
}); });
waveSurfer.on('region-updated', (region) => { waveSurfer.on('region-updated', (region) => {
setCuts(prev => prev.map(cut => const updatedCut: AudioCut = {
cut.id === region.id id: region.id,
? { ...cut, start: region.start, end: region.end } start: region.start,
: cut end: region.end
)); };
onCutsChange?.(cuts.map(cut => setCurrentCut(updatedCut);
cut.id === region.id cleanupPreview();
? { ...cut, start: region.start, end: region.end }
: cut
));
}); });
} }
return () => { return () => {
waveSurfer.destroy(); waveSurfer.destroy();
cleanupPreview();
if (audioContextRef.current?.state !== 'closed') {
audioContextRef.current?.close();
}
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [audio, progressColor, waveColor, variant]); }, [audio, progressColor, waveColor, variant]);
useEffect(() => {
if (cutAudioUrl && previewContainerRef.current) {
const previewWaveSurfer = WaveSurfer.create({
container: previewContainerRef.current,
responsive: true,
cursorWidth: 0,
height: 48,
waveColor,
progressColor,
barGap: 5,
barWidth: 8,
barRadius: 4,
fillParent: true,
hideScrollbar: true,
normalize: true,
autoCenter: true,
barMinHeight: 4,
});
previewWaveSurfer.load(cutAudioUrl);
previewWaveSurfer.on("finish", () => setIsPreviewPlaying(false));
previewWaveSurferRef.current = previewWaveSurfer;
return () => {
previewWaveSurfer.destroy();
previewWaveSurferRef.current = null;
};
}
}, [cutAudioUrl, waveColor, progressColor]);
const handlePlayPause = () => { const handlePlayPause = () => {
setIsPlaying(prev => !prev); setIsPlaying(prev => !prev);
waveSurferRef.current?.playPause(); waveSurferRef.current?.playPause();
}; };
const handleDeleteRegion = (cutId: string) => { const handlePreviewPlayPause = () => {
const region = waveSurferRef.current?.regions?.list[cutId]; setIsPreviewPlaying(prev => !prev);
if (region) { previewWaveSurferRef.current?.playPause();
region.remove(); };
setCuts(prev => prev.filter(cut => cut.id !== cutId));
onCutsChange?.(cuts.filter(cut => cut.id !== cutId)); const handleDeleteRegion = () => {
if (currentCut && waveSurferRef.current?.regions?.list[currentCut.id]) {
waveSurferRef.current.regions.list[currentCut.id].remove();
setCurrentCut(null);
cleanupPreview();
}
};
const applyCuts = async () => {
if (!waveSurferRef.current || !currentCut) return;
setIsProcessing(true);
try {
if (!audioContextRef.current) {
audioContextRef.current = new AudioContext();
}
const response = await fetch(audio);
const arrayBuffer = await response.arrayBuffer();
const originalBuffer = await audioContextRef.current.decodeAudioData(arrayBuffer);
const duration = currentCut.end - currentCut.start;
const newBuffer = audioContextRef.current.createBuffer(
originalBuffer.numberOfChannels,
Math.ceil(audioContextRef.current.sampleRate * duration),
audioContextRef.current.sampleRate
);
for (let channel = 0; channel < originalBuffer.numberOfChannels; channel++) {
const newChannelData = newBuffer.getChannelData(channel);
const originalChannelData = originalBuffer.getChannelData(channel);
const startSample = Math.floor(currentCut.start * audioContextRef.current.sampleRate);
const endSample = Math.floor(currentCut.end * audioContextRef.current.sampleRate);
const cutLength = endSample - startSample;
for (let i = 0; i < cutLength; i++) {
newChannelData[i] = originalChannelData[startSample + i];
}
}
const offlineContext = new OfflineAudioContext(
newBuffer.numberOfChannels,
newBuffer.length,
newBuffer.sampleRate
);
const source = offlineContext.createBufferSource();
source.buffer = newBuffer;
source.connect(offlineContext.destination);
source.start();
const renderedBuffer = await offlineContext.startRendering();
const wavBlob = await new Promise<Blob>((resolve) => {
const numberOfChannels = renderedBuffer.numberOfChannels;
const length = renderedBuffer.length * numberOfChannels * 2;
const buffer = new ArrayBuffer(44 + length);
const view = new DataView(buffer);
writeString(view, 0, 'RIFF');
view.setUint32(4, 36 + length, true);
writeString(view, 8, 'WAVE');
writeString(view, 12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, numberOfChannels, true);
view.setUint32(24, renderedBuffer.sampleRate, true);
view.setUint32(28, renderedBuffer.sampleRate * numberOfChannels * 2, true);
view.setUint16(32, numberOfChannels * 2, true);
view.setUint16(34, 16, true);
writeString(view, 36, 'data');
view.setUint32(40, length, true);
let offset = 44;
for (let i = 0; i < renderedBuffer.length; i++) {
for (let channel = 0; channel < numberOfChannels; channel++) {
const sample = renderedBuffer.getChannelData(channel)[i];
view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7FFF, true);
offset += 2;
}
}
resolve(new Blob([buffer], { type: 'audio/wav' }));
});
const newUrl = URL.createObjectURL(wavBlob);
if (cutAudioUrl) {
URL.revokeObjectURL(cutAudioUrl);
}
setCutAudioUrl(newUrl);
setUseFullAudio(false);
setAudioUrl?.(newUrl);
} catch (error) {
console.error('Error applying cuts:', error);
} finally {
setIsProcessing(false);
} }
}; };
@@ -121,8 +276,24 @@ const Waveform = ({
return `${minutes}:${seconds.toString().padStart(2, '0')}`; return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}; };
const writeString = (view: DataView, offset: number, string: string) => {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
};
const switchAudio = () => {
if (!cutAudioUrl) {
toast.info("Apply an audio cut first!");
} else {
setUseFullAudio(!useFullAudio);
setAudioUrl?.(useFullAudio ? audio : cutAudioUrl!)
}
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{isPlaying ? ( {isPlaying ? (
<BsPauseFill <BsPauseFill
@@ -142,30 +313,79 @@ const Waveform = ({
</div> </div>
)} )}
</div> </div>
{variant === 'edit' && (
<div className={clsx(
"flex items-center gap-3 px-3 py-1.5 text-sm text-white rounded-md w-36 justify-center",
useFullAudio ? "bg-green-600" : "bg-blue-600"
)}>
<BsFillFileEarmarkMusicFill className="w-4 h-4" />
<Switch
checked={useFullAudio}
onChange={() => switchAudio()}
className={clsx(
"relative inline-flex h-[30px] w-[58px] shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-white/75",
useFullAudio ? "bg-green-200" : "bg-blue-200"
)}
>
<span
aria-hidden="true"
className={clsx(
"pointer-events-none inline-block h-[26px] w-[26px] transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out",
useFullAudio ? 'translate-x-7' : 'translate-x-0'
)}
/>
</Switch>
<BsScissors className="w-4 h-4" />
</div>
)}
</div>
<div className="w-full max-w-4xl h-fit" ref={containerRef} /> <div className="w-full max-w-4xl h-fit" ref={containerRef} />
{variant === 'edit' && cuts.length > 0 && ( {variant === 'edit' && currentCut && (
<div className="space-y-2"> <div className="space-y-2">
<h3 className="font-medium text-gray-700">Audio Cuts</h3> <div className="flex items-center justify-between">
<div className="space-y-2"> <h3 className="font-medium text-gray-700">Selected Region</h3>
{cuts.map((cut) => ( <button
<div onClick={applyCuts}
key={cut.id} disabled={isProcessing}
className="flex items-center justify-between p-2 bg-gray-50 rounded-md" className="flex items-center gap-2 px-3 py-1.5 text-sm text-white bg-blue-600 hover:bg-blue-700 disabled:bg-blue-300 rounded-md"
> >
<BsScissors className="w-4 h-4" />
{isProcessing ? 'Processing...' : 'Apply Cut'}
</button>
</div>
<div className="flex items-center justify-between p-2 bg-gray-50 rounded-md">
<div className="text-sm text-gray-600"> <div className="text-sm text-gray-600">
{formatTime(cut.start)} - {formatTime(cut.end)} {formatTime(currentCut.start)} - {formatTime(currentCut.end)}
</div> </div>
<button <button
onClick={() => handleDeleteRegion(cut.id)} onClick={handleDeleteRegion}
className="p-1 text-red-500 hover:bg-red-50 rounded" className="p-1 text-red-500 hover:bg-red-50 rounded"
> >
<BsTrash className="w-4 h-4" /> <BsTrash className="w-4 h-4" />
</button> </button>
</div> </div>
))}
</div> </div>
)}
{cutAudioUrl && (
<div className="mt-8 space-y-4 border-t pt-4">
<div className="flex items-center gap-4">
<h3 className="font-medium text-gray-700">Cut Preview</h3>
{isPreviewPlaying ? (
<BsPauseFill
className="text-mti-gray-cool cursor-pointer w-5 h-5"
onClick={handlePreviewPlayPause}
/>
) : (
<BsPlayFill
className="text-mti-gray-cool cursor-pointer w-5 h-5"
onClick={handlePreviewPlayPause}
/>
)}
</div>
<div className="w-full max-w-4xl h-fit" ref={previewContainerRef} />
</div> </div>
)} )}
</div> </div>

View File

@@ -7,9 +7,9 @@ export interface UserImport {
email: string; email: string;
name: string; name: string;
passport_id: string; passport_id: string;
type: Type; type?: Type;
groupName: string; groupName: string;
entity: string; entity?: string;
studentID: string; studentID: string;
demographicInformation: { demographicInformation: {
country: string; country: string;

View File

@@ -3,7 +3,13 @@ import { Module } from ".";
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam; export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
export type Variant = "full" | "partial"; export type Variant = "full" | "partial";
export type InstructorGender = "male" | "female" | "varied"; export type InstructorGender = "male" | "female" | "varied";
export type Difficulty = "easy" | "medium" | "hard";
export type Difficulty = BasicDifficulty | CEFRLevels;
// Left easy, medium and hard to support older exam versions
export type BasicDifficulty = "easy" | "medium" | "hard";
export type CEFRLevels = "A1" | "A2" | "B1" | "B2" | "C1" | "C2";
export interface ExamBase { export interface ExamBase {
id: string; id: string;

View File

@@ -21,6 +21,11 @@ import { PermissionType } from "@/interfaces/permissions";
import usePermissions from "@/hooks/usePermissions"; import usePermissions from "@/hooks/usePermissions";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
import CodeGenImportSummary, { ExcelCodegenDuplicatesMap } from "@/components/ImportSummaries/Codegen";
import { FaFileDownload } from "react-icons/fa";
import { IoInformationCircleOutline } from "react-icons/io5";
import { HiOutlineDocumentText } from "react-icons/hi";
import CodegenTable from "@/components/Tables/CodeGenTable";
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/); const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
@@ -74,7 +79,9 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true); const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
const [type, setType] = useState<Type>("student"); const [type, setType] = useState<Type>("student");
const [showHelp, setShowHelp] = useState(false); const [showHelp, setShowHelp] = useState(false);
const [entity, setEntity] = useState((entities || [])[0]?.id || undefined) const [entity, setEntity] = useState((entities || [])[0]?.id || undefined);
const [parsedExcel, setParsedExcel] = useState<{ rows?: any[]; errors?: any[] }>({ rows: undefined, errors: undefined });
const [duplicatedRows, setDuplicatedRows] = useState<{ duplicates: ExcelCodegenDuplicatesMap, count: number }>();
const { openFilePicker, filesContent, clear } = useFilePicker({ const { openFilePicker, filesContent, clear } = useFilePicker({
accept: ".xlsx", accept: ".xlsx",
@@ -86,46 +93,123 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
if (!isExpiryDateEnabled) setExpiryDate(null); if (!isExpiryDateEnabled) setExpiryDate(null);
}, [isExpiryDateEnabled]); }, [isExpiryDateEnabled]);
const schema = {
'First Name': {
prop: 'firstName',
type: String,
required: true,
validate: (value: string) => {
if (!value || value.trim() === '') {
throw new Error('First Name cannot be empty')
}
return true
}
},
'Last Name': {
prop: 'lastName',
type: String,
required: true,
validate: (value: string) => {
if (!value || value.trim() === '') {
throw new Error('Last Name cannot be empty')
}
return true
}
},
'Passport/National ID': {
prop: 'passport_id',
type: String,
required: true,
validate: (value: string) => {
if (!value || value.trim() === '') {
throw new Error('Passport/National ID cannot be empty')
}
return true
}
},
'E-mail': {
prop: 'email',
required: true,
type: (value: any) => {
if (!value || value.trim() === '') {
throw new Error('Email cannot be empty')
}
if (!EMAIL_REGEX.test(value.trim())) {
throw new Error('Invalid Email')
}
return value
}
}
}
useEffect(() => { useEffect(() => {
if (filesContent.length > 0) { if (filesContent.length > 0) {
const file = filesContent[0]; const file = filesContent[0];
readXlsxFile(file.content).then((rows) => { readXlsxFile(
try { file.content, { schema, ignoreEmptyRows: false })
const information = uniqBy( .then((data) => {
rows setParsedExcel(data)
.map((row) => {
const [firstName, lastName, country, passport_id, email, phone] = row as string[];
return EMAIL_REGEX.test(email.toString().trim())
? {
email: email.toString().trim().toLowerCase(),
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
passport_id: passport_id?.toString().trim() || undefined,
}
: undefined;
})
.filter((x) => !!x) as typeof infos,
(x) => x.email,
);
if (information.length === 0) {
toast.error(
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!",
);
return clear();
}
setInfos(information);
} catch {
toast.error(
"Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!",
);
return clear();
}
}); });
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [filesContent]); }, [filesContent]);
useEffect(() => {
if (parsedExcel.rows) {
const duplicates: ExcelCodegenDuplicatesMap = {
email: new Map(),
passport_id: new Map(),
};
const duplicateValues = new Set<string>();
const duplicateRowIndices = new Set<number>();
const errorRowIndices = new Set(
parsedExcel.errors?.map(error => error.row) || []
);
parsedExcel.rows.forEach((row, index) => {
if (!errorRowIndices.has(index + 2)) {
(Object.keys(duplicates) as Array<keyof ExcelCodegenDuplicatesMap>).forEach(field => {
if (row !== null) {
const value = row[field];
if (value) {
if (!duplicates[field].has(value)) {
duplicates[field].set(value, [index + 2]);
} else {
const existingRows = duplicates[field].get(value);
if (existingRows) {
existingRows.push(index + 2);
duplicateValues.add(value);
existingRows.forEach(rowNum => duplicateRowIndices.add(rowNum));
}
}
}
}
});
}
});
const info = parsedExcel.rows
.map((row, index) => {
if (errorRowIndices.has(index + 2) || duplicateRowIndices.has(index + 2) || row === null) {
return undefined;
}
const { firstName, lastName, studentID, passport_id, email, phone, group, country } = row;
if (!email || !EMAIL_REGEX.test(email.toString().trim())) {
return undefined;
}
return {
email: email.toString().trim().toLowerCase(),
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
passport_id: passport_id?.toString().trim() || undefined,
};
}).filter((x) => !!x) as typeof infos;
setInfos(info);
}
}, [entity, parsedExcel, type]);
const generateAndInvite = async () => { const generateAndInvite = async () => {
const newUsers = infos.filter((x) => !users.map((u) => u.email).includes(x.email)); const newUsers = infos.filter((x) => !users.map((u) => u.email).includes(x.email));
const existingUsers = infos const existingUsers = infos
@@ -199,40 +283,106 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
}); });
}; };
const handleTemplateDownload = () => {
const fileName = "BatchCodeTemplate.xlsx";
const url = `https://firebasestorage.googleapis.com/v0/b/encoach-staging.appspot.com/o/import_templates%2F${fileName}?alt=media&token=b771a535-bf95-4060-889c-a086df65d480`;
const link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
return ( return (
<> <>
<Modal isOpen={showHelp} onClose={() => setShowHelp(false)} title="Excel File Format"> <Modal isOpen={showHelp} onClose={() => setShowHelp(false)}>
<div className="mt-4 flex flex-col gap-2"> <>
<span>Please upload an Excel file with the following format:</span> <div className="flex font-bold text-xl justify-center text-gray-700"><span>Excel File Format</span></div>
<table className="w-full"> <div className="mt-4 flex flex-col gap-4">
<div className="flex flex-col gap-3 bg-gray-100 rounded-lg p-4">
<div className="flex items-center gap-2">
<HiOutlineDocumentText className={`w-5 h-5 text-mti-purple-light`} />
<h2 className="text-lg font-semibold">
The uploaded document must:
</h2>
</div>
<ul className="flex flex-col pl-10 gap-2">
<li className="text-gray-700 list-disc">
be an Excel .xlsx document.
</li>
<li className="text-gray-700 list-disc">
only have a single spreadsheet with the following <b>exact same name</b> columns:
<div className="py-4 pr-4">
<table className="w-full bg-white">
<thead> <thead>
<tr> <tr>
<th className="border border-neutral-200 px-2 py-1">First Name</th> <th className="border border-neutral-200 px-2 py-1">First Name</th>
<th className="border border-neutral-200 px-2 py-1">Last Name</th> <th className="border border-neutral-200 px-2 py-1">Last Name</th>
<th className="border border-neutral-200 px-2 py-1">Country</th>
<th className="border border-neutral-200 px-2 py-1">Passport/National ID</th> <th className="border border-neutral-200 px-2 py-1">Passport/National ID</th>
<th className="border border-neutral-200 px-2 py-1">E-mail</th> <th className="border border-neutral-200 px-2 py-1">E-mail</th>
<th className="border border-neutral-200 px-2 py-1">Phone Number</th>
</tr> </tr>
</thead> </thead>
</table> </table>
<span className="mt-4">
<b>Notes:</b>
<ul>
<li>- All incorrect e-mails will be ignored;</li>
<li>- All already registered e-mails will be ignored;</li>
<li>- You may have a header row with the format above, however, it is not necessary;</li>
<li>- All of the e-mails in the file will receive an e-mail to join EnCoach with the role selected below.</li>
</ul>
</span>
</div> </div>
</li>
</ul>
</div>
<div className="flex flex-col gap-3 bg-gray-100 rounded-lg p-4">
<div className="flex items-center gap-2">
<IoInformationCircleOutline className={`w-5 h-5 text-mti-purple-light`} />
<h2 className="text-lg font-semibold">
Note that:
</h2>
</div>
<ul className="flex flex-col pl-10 gap-2">
<li className="text-gray-700 list-disc">
all incorrect e-mails will be ignored.
</li>
<li className="text-gray-700 list-disc">
all already registered e-mails will be ignored.
</li>
<li className="text-gray-700 list-disc">
all rows which contain duplicate values in the columns: &quot;Passport/National ID&quot;, &quot;E-mail&quot;, will be ignored.
</li>
<li className="text-gray-700 list-disc">
all of the e-mails in the file will receive an e-mail to join EnCoach with the role selected below.
</li>
</ul>
</div>
<div className="bg-gray-100 rounded-lg p-4">
<p className="text-gray-600">
{`The downloadable template is an example of a file that can be imported. Your document doesn't need to be a carbon copy of the template - it can have different styling but it must adhere to the previous requirements.`}
</p>
</div>
<div className="w-full flex justify-between mt-6 gap-8">
<Button color="purple" onClick={() => setShowHelp(false)} variant="outline" className="self-end w-full bg-white">
Close
</Button>
<Button color="purple" onClick={handleTemplateDownload} variant="solid" className="self-end w-full">
<div className="flex items-center gap-2">
<FaFileDownload size={24} />
Download Template
</div>
</Button>
</div>
</div>
</>
</Modal> </Modal>
<div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4"> <div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
<div className="flex items-end justify-between"> <div className="flex items-end justify-between">
<label className="text-mti-gray-dim text-base font-normal">Choose an Excel file</label> <label className="text-mti-gray-dim text-base font-normal">Choose an Excel file</label>
<div className="tooltip cursor-pointer" data-tip="Excel File Format" onClick={() => setShowHelp(true)}> <button
<BsQuestionCircleFill /> onClick={() => setShowHelp(true)}
</div> className="tooltip cursor-pointer p-1.5 hover:bg-gray-200 rounded-full transition-colors duration-200"
data-tip="Excel File Format"
>
<IoInformationCircleOutline size={24} />
</button>
</div> </div>
<Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}> <Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}>
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"} {filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
@@ -290,6 +440,13 @@ export default function BatchCodeGenerator({ user, users, entities = [], permiss
))} ))}
</select> </select>
)} )}
{infos.length > 0 && <CodeGenImportSummary infos={infos} parsedExcel={parsedExcel} duplicateRows={duplicatedRows}/>}
{infos.length !== 0 && (
<div className="flex w-full flex-col gap-4">
<span className="text-mti-gray-dim text-base font-normal">Codes will be sent to:</span>
<CodegenTable infos={infos} />
</div>
)}
{checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "createCodes") && ( {checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"], permissions, "createCodes") && (
<Button onClick={generateAndInvite} disabled={infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)}> <Button onClick={generateAndInvite} disabled={infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)}>
Generate & Send Generate & Send

View File

@@ -12,16 +12,16 @@ import { checkAccess, getTypesOfUser } from "@/utils/permissions";
import Checkbox from "@/components/Low/Checkbox"; import Checkbox from "@/components/Low/Checkbox";
import ReactDatePicker from "react-datepicker"; import ReactDatePicker from "react-datepicker";
import clsx from "clsx"; import clsx from "clsx";
import countryCodes, { CountryData } from "country-codes-list"; import countryCodes from "country-codes-list";
import { User, Type as UserType } from "@/interfaces/user"; import { User, Type as UserType } from "@/interfaces/user";
import { Type, UserImport } from "../../../interfaces/IUserImport"; import { Type, UserImport } from "../../../interfaces/IUserImport";
import UserTable from "../../../components/UserTable"; import UserTable from "../../../components/Tables/UserTable";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
import { IoInformationCircleOutline } from "react-icons/io5"; import { IoInformationCircleOutline } from "react-icons/io5";
import { FaFileDownload } from "react-icons/fa"; import { FaFileDownload } from "react-icons/fa";
import { HiOutlineDocumentText } from "react-icons/hi"; import { HiOutlineDocumentText } from "react-icons/hi";
import UserImportSummary, { DuplicatesMap } from "@/components/UserImportSummary"; import UserImportSummary, { ExcelUserDuplicatesMap } from "@/components/ImportSummaries/User";
const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/); const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/);
@@ -78,7 +78,7 @@ export default function BatchCreateUser({ user, entities = [], permissions, onFi
const [duplicatedUsers, setDuplicatedUsers] = useState<UserImport[]>([]); const [duplicatedUsers, setDuplicatedUsers] = useState<UserImport[]>([]);
const [newUsers, setNewUsers] = useState<UserImport[]>([]); const [newUsers, setNewUsers] = useState<UserImport[]>([]);
const [duplicatedRows, setDuplicatedRows] = useState<{ duplicates: DuplicatesMap, count: number }>(); const [duplicatedRows, setDuplicatedRows] = useState<{ duplicates: ExcelUserDuplicatesMap, count: number }>();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [expiryDate, setExpiryDate] = useState<Date | null>( const [expiryDate, setExpiryDate] = useState<Date | null>(
@@ -210,7 +210,7 @@ export default function BatchCreateUser({ user, entities = [], permissions, onFi
useEffect(() => { useEffect(() => {
if (parsedExcel.rows) { if (parsedExcel.rows) {
const duplicates: DuplicatesMap = { const duplicates: ExcelUserDuplicatesMap = {
studentID: new Map(), studentID: new Map(),
email: new Map(), email: new Map(),
passport_id: new Map(), passport_id: new Map(),
@@ -225,7 +225,7 @@ export default function BatchCreateUser({ user, entities = [], permissions, onFi
parsedExcel.rows.forEach((row, index) => { parsedExcel.rows.forEach((row, index) => {
if (!errorRowIndices.has(index + 2)) { if (!errorRowIndices.has(index + 2)) {
(Object.keys(duplicates) as Array<keyof DuplicatesMap>).forEach(field => { (Object.keys(duplicates) as Array<keyof ExcelUserDuplicatesMap>).forEach(field => {
if (row !== null) { if (row !== null) {
const value = row[field]; const value = row[field];
if (value) { if (value) {

View File

@@ -0,0 +1,74 @@
import { sessionOptions } from '@/lib/session';
import { withIronSessionApiRoute } from 'iron-session/next';
import type { NextApiRequest, NextApiResponse } from 'next'
import client from "@/lib/mongodb";
const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
const { op } = req.query
if (req.method === 'GET') {
switch (op) {
default:
res.status(400).json({ error: 'Invalid operation!' })
}
}
else if (req.method === 'POST') {
switch (op) {
case 'crossRefOwnership':
res.status(200).json(await crossRefOwnership(req.body));
break;
case 'getIds':
res.status(200).json(await getIds(req.body));
break;
default:
res.status(400).json({ error: 'Invalid operation!' })
}
} else {
res.status(400).end(`Method ${req.method} Not Allowed`)
}
}
async function crossRefOwnership(body: any): Promise<string[]> {
const { userId, classrooms } = body;
// First find which classrooms from input exist
const existingClassrooms = await db.collection('groups')
.find({ name: { $in: classrooms } })
.project({ name: 1, admin: 1, _id: 0 })
.toArray();
// From those existing classrooms, return the ones where user is NOT the admin
return existingClassrooms
.filter(classroom => classroom.admin !== userId)
.map(classroom => classroom.name);
}
async function getIds(body: any): Promise<Record<string, string>> {
const { names, userEmails } = body;
const existingGroups: any[] = await db.collection('groups')
.find({ name: { $in: names } })
.project({ name: 1, id: 1, _id: 0 })
.toArray();
const users: any[] = await db.collection('users')
.find({ email: { $in: userEmails } })
.project({ email: 1, id: 1, _id: 0 })
.toArray();
return {
groups: existingGroups.reduce((acc, group) => {
acc[group.name] = group.id;
return acc;
}, {} as Record<string, string>),
users: users.reduce((acc, user) => {
acc[user.email] = user.id;
return acc;
}, {} as Record<string, string>)
};
}

View File

@@ -0,0 +1,67 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import axios from "axios";
import formidable from "formidable-serverless";
import fs from "fs";
import FormData from 'form-data';
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
return res.status(401).json({ ok: false });
}
const form = new formidable.IncomingForm();
try {
const files = await new Promise((resolve, reject) => {
form.parse(req, (err: any, _: any, files: any) => {
if (err) reject(err);
else resolve(files);
});
});
const audioFile = (files as any).audio;
if (!audioFile) {
return res.status(400).json({ ok: false, error: 'Audio file not found in request' });
}
const formData = new FormData();
const buffer = fs.readFileSync(audioFile.path);
formData.append('audio', buffer, audioFile.name);
try {
const response = await axios.post(
`${process.env.BACKEND_URL}/listening/transcribe`,
formData,
{
headers: {
...formData.getHeaders(),
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
},
}
);
return res.status(200).json(response.data);
} finally {
if (fs.existsSync(audioFile.path)) {
fs.rmSync(audioFile.path);
}
}
} catch (error) {
console.error('Error:', error);
return res.status(500).json({ ok: false });
}
}
export const config = {
api: {
bodyParser: false,
},
};

View File

@@ -20,7 +20,16 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
else if (req.method === 'POST') { else if (req.method === 'POST') {
switch (op) { switch (op) {
case 'crossRefEmails': case 'crossRefEmails':
res.status(200).json(await crossRefEmails(req.body.emails)) res.status(200).json(await crossRefEmails(req.body.emails));
break;
case 'dontExist':
res.status(200).json(await dontExist(req.body.emails))
break;
case 'entityCheck':
res.status(200).json(await entityCheck(req.body));
break;
case 'crossRefClassrooms':
res.status(200).json(await crossRefClassrooms(req.body.sets));
break; break;
default: default:
res.status(400).json({ error: 'Invalid operation!' }) res.status(400).json({ error: 'Invalid operation!' })
@@ -45,3 +54,125 @@ async function crossRefEmails(emails: string[]) {
} }
]).toArray(); ]).toArray();
} }
async function dontExist(emails: string[]): Promise<string[]> {
const existingUsers = await db.collection('users')
.find(
{ email: { $in: emails } },
{ projection: { _id: 0, email: 1 } }
)
.toArray();
const existingEmails = new Set(existingUsers.map(u => u.email));
return emails.filter(email => !existingEmails.has(email));
}
async function entityCheck(body: Record<string, any>): Promise<string[]> {
const { entities, emails } = body;
const pipeline = [
// Match users with the provided emails
{
$match: {
email: { $in: emails }
}
},
// Match users who don't have any of the entities
{
$match: {
$or: [
// Either they have no entities array
{ entities: { $exists: false } },
// Or their entities array is empty
{ entities: { $size: 0 } },
// Or none of their entities match the provided IDs
{
entities: {
$not: {
$elemMatch: {
id: { $in: entities.map((e: any) => e.id) }
}
}
}
}
]
}
},
// Project only the email field
{
$project: {
_id: 0,
email: 1
}
}
];
const results = await db.collection('users').aggregate(pipeline).toArray();
return results.map((result: any) => result.email);
}
async function crossRefClassrooms(sets: { email: string, classroom: string }[]) {
const pipeline = [
// Match users with the provided emails
{
$match: {
email: { $in: sets.map(set => set.email) }
}
},
// Lookup groups that contain the user's ID in participants
{
$lookup: {
from: 'groups',
let: { userId: '$id', userEmail: '$email' },
pipeline: [
{
$match: {
$expr: {
$and: [
{ $in: ['$$userId', '$participants'] },
{
// Match the classroom that corresponds to this user's email
$let: {
vars: {
matchingSet: {
$arrayElemAt: [
{
$filter: {
input: sets,
cond: { $eq: ['$$this.email', '$$userEmail'] }
}
},
0
]
}
},
in: { $eq: ['$name', '$$matchingSet.classroom'] }
}
}
]
}
}
}
],
as: 'matchingGroups'
}
},
// Only keep users who have matching groups
{
$match: {
matchingGroups: { $ne: [] }
}
},
// Project only the email
{
$project: {
_id: 0,
email: 1
}
}
];
const results = await db.collection('users').aggregate(pipeline).toArray();
return results.map((result: any) => result.email);
}

View File

@@ -21,6 +21,11 @@ import { getEntities, getEntitiesWithRoles } from "@/utils/entities.be";
import { useAllowedEntities } from "@/hooks/useEntityPermissions"; import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { FaPersonChalkboard } from "react-icons/fa6"; import { FaPersonChalkboard } from "react-icons/fa6";
import { FaFileUpload } from "react-icons/fa";
import clsx from "clsx";
import { useState } from "react";
import StudentClassroomTransfer from "@/components/Imports/StudentClassroomTransfer";
import Modal from "@/components/Modal";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res)
@@ -58,7 +63,8 @@ interface Props {
entities: EntityWithRoles[] entities: EntityWithRoles[]
} }
export default function Home({ user, groups, entities }: Props) { export default function Home({ user, groups, entities }: Props) {
const entitiesAllowCreate = useAllowedEntities(user, entities, 'create_classroom') const entitiesAllowCreate = useAllowedEntities(user, entities, 'create_classroom');
const [showImport, setShowImport] = useState(false);
const renderCard = (group: GroupWithUsers) => ( const renderCard = (group: GroupWithUsers) => (
<Link <Link
@@ -112,8 +118,25 @@ export default function Home({ user, groups, entities }: Props) {
<ToastContainer /> <ToastContainer />
<Layout user={user} className="!gap-4"> <Layout user={user} className="!gap-4">
<section className="flex flex-col gap-4 w-full h-full"> <section className="flex flex-col gap-4 w-full h-full">
<Modal isOpen={showImport} onClose={() => setShowImport(false)}>
<StudentClassroomTransfer user={user} entities={entities} onFinish={() => setShowImport(false)} />
</Modal>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex justify-between">
<h2 className="font-bold text-2xl">Classrooms</h2> <h2 className="font-bold text-2xl">Classrooms</h2>
{entitiesAllowCreate.length !== 0 && <button
className={clsx(
"flex flex-row gap-3 items-center py-1.5 px-4 text-lg",
"bg-mti-purple-light border border-mti-purple-light rounded-xl text-white",
"hover:bg-white hover:text-mti-purple-light transition duration-300 ease-in-out",
)}
onClick={() => setShowImport(true)}
>
<FaFileUpload className="w-5 h-5" />
Transfer Students
</button>
}
</div>
<Separator /> <Separator />
</div> </div>

View File

@@ -167,7 +167,6 @@ export default function Generation({ id, user, exam, examModule, permissions }:
defaultValue={title} defaultValue={title}
required required
/> />
{/*<MultipleAudioUploader />*/}
<label className="font-normal text-base text-mti-gray-dim">Module</label> <label className="font-normal text-base text-mti-gray-dim">Module</label>
<RadioGroup <RadioGroup
value={currentModule} value={currentModule}

View File

@@ -147,7 +147,8 @@ export const defaultSectionSettings = (module: Module, sectionId: number, part?:
focusedExercise: undefined, focusedExercise: undefined,
expandedSubSections: [], expandedSubSections: [],
levelGenerating: [], levelGenerating: [],
levelGenResults: [] levelGenResults: [],
scriptLoading: false,
} }
} }
@@ -156,7 +157,7 @@ const defaultModuleSettings = (module: Module, minTimer: number): ModuleState =>
const state: ModuleState = { const state: ModuleState = {
examLabel: defaultExamLabel(module), examLabel: defaultExamLabel(module),
minTimer, minTimer,
difficulty: sample(["easy", "medium", "hard"] as Difficulty[])!, difficulty: sample(["A1", "A2", "B1", "B2", "C1", "C2"] as Difficulty[])!,
isPrivate: false, isPrivate: false,
sectionLabels: sectionLabels(module), sectionLabels: sectionLabels(module),
expandedSections: [1], expandedSections: [1],

View File

@@ -6,7 +6,7 @@ import { reorderSection } from "../reorder/global";
export type SectionActions = export type SectionActions =
| { type: 'UPDATE_SECTION_SINGLE_FIELD'; payload: { module: Module; sectionId: number; field: string; value: any } } | { type: 'UPDATE_SECTION_SINGLE_FIELD'; payload: { module: Module; sectionId: number; field: string; value: any } }
| { type: 'UPDATE_SECTION_SETTINGS'; payload: { sectionId: number; module: Module; update: Partial<SectionSettings | ReadingSectionSettings>; } } | { type: 'UPDATE_SECTION_SETTINGS'; payload: { sectionId: number; module: Module; update: Partial<SectionSettings>; } }
| { type: 'UPDATE_SECTION_STATE'; payload: { sectionId: number; module: Module; update: Partial<Section>; } } | { type: 'UPDATE_SECTION_STATE'; payload: { sectionId: number; module: Module; update: Partial<Section>; } }
| { type: 'REORDER_EXERCISES'; payload: { event: DragEndEvent, module: Module; sectionId: number; } }; | { type: 'REORDER_EXERCISES'; payload: { event: DragEndEvent, module: Module; sectionId: number; } };

View File

@@ -1,4 +1,4 @@
import { Difficulty, InteractiveSpeakingExercise, LevelPart, ListeningPart, ReadingPart, SpeakingExercise, WritingExercise } from "@/interfaces/exam"; import { Difficulty, InteractiveSpeakingExercise, LevelPart, ListeningPart, ReadingPart, Script, SpeakingExercise, WritingExercise } from "@/interfaces/exam";
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import Option from "@/interfaces/option"; import Option from "@/interfaces/option";
@@ -36,6 +36,9 @@ export interface ListeningSectionSettings extends SectionSettings {
isAudioGenerationOpen: boolean; isAudioGenerationOpen: boolean;
listeningTopic: string; listeningTopic: string;
isListeningTopicOpen: boolean; isListeningTopicOpen: boolean;
uploadedAudioURL: string | undefined;
audioCutURL: string | undefined;
useEntireAudioFile: boolean;
} }
export interface WritingSectionSettings extends SectionSettings { export interface WritingSectionSettings extends SectionSettings {
@@ -90,7 +93,7 @@ export type ExamPart = ListeningPart | ReadingPart | LevelPart;
export interface SectionState { export interface SectionState {
sectionId: number; sectionId: number;
settings: SectionSettings | ReadingSectionSettings; settings: SectionSettings;
state: Section; state: Section;
expandedSubSections: number[]; expandedSubSections: number[];
generating: Generating; generating: Generating;
@@ -102,6 +105,7 @@ export interface SectionState {
speakingSection?: number; speakingSection?: number;
readingSection?: number; readingSection?: number;
listeningSection?: number; listeningSection?: number;
scriptLoading: boolean;
} }
export interface ModuleState { export interface ModuleState {

View File

@@ -1,6 +1,6 @@
import { collection, getDocs, query, where, setDoc, doc, Firestore, getDoc, and } from "firebase/firestore"; import { collection, getDocs, query, where, setDoc, doc, Firestore, getDoc, and } from "firebase/firestore";
import { groupBy, shuffle } from "lodash"; import { groupBy, shuffle } from "lodash";
import { Difficulty, Exam, InstructorGender, SpeakingExam, Variant, WritingExam } from "@/interfaces/exam"; import { CEFRLevels, Difficulty, Exam, InstructorGender, SpeakingExam, Variant, WritingExam } from "@/interfaces/exam";
import { DeveloperUser, Stat, StudentUser, User } from "@/interfaces/user"; import { DeveloperUser, Stat, StudentUser, User } from "@/interfaces/user";
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import { getCorporateUser } from "@/resources/user"; import { getCorporateUser } from "@/resources/user";
@@ -128,9 +128,42 @@ const filterByDifficulty = async (db: Db, exams: Exam[], module: Module, userID?
const user = await db.collection("users").findOne<User>({ id: userID }); const user = await db.collection("users").findOne<User>({ id: userID });
if (!user) return exams; if (!user) return exams;
const difficulty = user.levels[module] <= 3 ? "easy" : user.levels[module] <= 6 ? "medium" : "hard"; const basicDifficulty = user.levels[module] <= 3 ? "easy" : user.levels[module] <= 6 ? "medium" : "hard";
let CEFRLevel: CEFRLevels;
// Adjust the levels if necessary
switch (user.levels[module]) {
case 1:
case 2:
CEFRLevel = "A1";
break;
case 3:
case 4:
CEFRLevel = "A2";
break;
case 4:
case 5:
CEFRLevel = "B1";
break;
case 6:
CEFRLevel = "B2";
break;
case 7:
case 8:
CEFRLevel = "C1";
break;
case 9:
CEFRLevel = "C2";
break;
default:
CEFRLevel = "B1";
}
const filteredExams = exams.filter((exam) =>
exam.difficulty === basicDifficulty || exam.difficulty === CEFRLevel
);
const filteredExams = exams.filter((exam) => exam.difficulty === difficulty);
return filteredExams.length === 0 ? exams : filteredExams; return filteredExams.length === 0 ? exams : filteredExams;
}; };