From bac2a08748793666778855bf196db6ac4d824356 Mon Sep 17 00:00:00 2001 From: Carlos-Mesquita Date: Tue, 24 Dec 2024 11:52:34 +0000 Subject: [PATCH 01/10] ENCOA-289 --- src/components/ImportSummaries/User.tsx | 2 +- src/pages/(admin)/Lists/BatchCreateUser.tsx | 46 ++++++++++++++++++--- src/pages/api/users/controller.ts | 33 +++++++++++++++ 3 files changed, 74 insertions(+), 7 deletions(-) diff --git a/src/components/ImportSummaries/User.tsx b/src/components/ImportSummaries/User.tsx index 6168ae84..c89e3f4a 100644 --- a/src/components/ImportSummaries/User.tsx +++ b/src/components/ImportSummaries/User.tsx @@ -199,7 +199,7 @@ const UserImportSummary: React.FC = ({ parsedExcel, newUsers, enlistedUse - setShowEnlistedModal(false)}> + setShowEnlistedModal(false)} maxWidth='max-w-[85%]'> <>
diff --git a/src/pages/(admin)/Lists/BatchCreateUser.tsx b/src/pages/(admin)/Lists/BatchCreateUser.tsx index 0460babc..6acc7921 100644 --- a/src/pages/(admin)/Lists/BatchCreateUser.tsx +++ b/src/pages/(admin)/Lists/BatchCreateUser.tsx @@ -87,7 +87,15 @@ export default function BatchCreateUser({ user, entities = [], permissions, onFi const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true); const [type, setType] = useState("student"); const [showHelp, setShowHelp] = useState(false); - const [entity, setEntity] = useState((entities || [])[0]?.id || undefined) + const [entity, setEntity] = useState<{id: string | null, label: string | null}| undefined>(() => { + if (!entities?.length) { + return undefined; + } + return { + id: entities[0].id, + label: entities[0].label + }; + }); const { openFilePicker, filesContent, clear } = useFilePicker({ accept: ".xlsx", @@ -291,11 +299,28 @@ export default function BatchCreateUser({ user, entities = [], permissions, onFi if (!!crossRefEmails) { const existingEmails = new Set(crossRefEmails.map((x: any) => x.email)); const dupes = infos.filter(info => existingEmails.has(info.email)); - const newUsersList = infos.filter(info => !existingEmails.has(info.email)); + const newUsersList = infos + .filter(info => !existingEmails.has(info.email)) + .map(info => ({ + ...info, + entityLabels: [entity!.label!] + })); setNewUsers(newUsersList); - setDuplicatedUsers(dupes); + + const {data: emailEntityMap} = await axios.post("/api/users/controller?op=getEntities", { + emails: dupes.map((x) => x.email) + }); + const withLabels = dupes.map((u) => ({ + ...u, + entityLabels: emailEntityMap.find((e: any) => e.email === u.email)?.entityLabels || [] + })) + setDuplicatedUsers(withLabels); } else { - setNewUsers(infos); + const withLabel = infos.map(info => ({ + ...info, + entityLabels: [entity!.label!] + })); + setNewUsers(withLabel); } } catch (error) { toast.error("Something went wrong, please try again later!"); @@ -305,7 +330,7 @@ export default function BatchCreateUser({ user, entities = [], permissions, onFi if (infos.length > 0) { crossReferenceEmails(); } - }, [infos]); + }, [infos, entity]); const makeUsers = async () => { const newUsersSentence = newUsers.length > 0 ? `create ${newUsers.length} user(s)` : undefined; @@ -459,7 +484,16 @@ export default function BatchCreateUser({ user, entities = [], permissions, onFi onOptionChange({ value: o!.value || "", label: o!.label })} + value={localInstructions.chosenOption} + className="w-full" + /> +
+ +
+
+ +
+
+ +
+ {localInstructions.chosenOption?.value === 'Custom' && ( +
+ +
+ )} +
+
+
+ + {(localInstructions.chosenOption?.value === 'Automatic' || + (localInstructions.chosenOption?.value === 'Custom' && localInstructions.currentInstructionsURL.startsWith("blob:")) && localInstructions.currentInstructionsURL !== '') && ( +
+ +
+ +
+
+ )} + + +
+ + + ); +}; + +export default ListeningInstructions; \ No newline at end of file diff --git a/src/components/ExamEditor/Standalone/ListeningInstructions/presets.ts b/src/components/ExamEditor/Standalone/ListeningInstructions/presets.ts new file mode 100644 index 00000000..ccca357e --- /dev/null +++ b/src/components/ExamEditor/Standalone/ListeningInstructions/presets.ts @@ -0,0 +1,74 @@ +const PRESETS = { + "1": { + url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_1.mp3?alt=media&token=abf0dc1a-6d24-4d33-be0e-7e15f4e4bec2", + text: "You will hear one recording and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. The recording can be played three times. The recording consists of a conversation between two people in an everyday social context. Pay close attention to the audio recording and answer the questions accordingly.", + }, + "2": { + url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_2.mp3?alt=media&token=a635f234-e470-4980-9690-e81544bbbe42", + text: "You will hear one recording and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. The recording can be played three times. The recording consists of a monologue set in a social context. Pay close attention to the audio recording and answer the questions accordingly.", + }, + "3": { + url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_3.mp3?alt=media&token=9659155d-0167-4288-9ba7-4135e135151d", + text: "You will hear one recording and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. The recording can be played three times. The recording consists of a conversation between up to four individuals in an educational context. Pay close attention to the audio recording and answer the questions accordingly." + }, + "4": { + url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_4.mp3?alt=media&token=ed50aae9-2bd7-4d09-a5c9-81cb55ec29fb", + text: "You will hear one recording and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. The recording can be played three times. The recording consists of a monologue about an academic subject. Pay close attention to the audio recording and answer the questions accordingly." + }, + "1_2": { + url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_1_2.mp3?alt=media&token=16b1b6a8-6664-40fa-bb10-f8c89798d43d", + text: "You will hear two recordings and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. The recordings can be played three times. The module is in 2 parts. In the first part you will hear a conversation between two people in an everyday social context. In the second part you will hear a monologue set in a social context. Pay close attention to the audio recordings and answer the questions accordingly." + }, + "1_3": { + url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_1_3.mp3?alt=media&token=3c3264b9-d277-4e43-91f9-6fa77cfd701e", + text: "You will hear two recordings and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. The recordings can be played three times. The module is in 2 parts. In the first part you will hear a conversation between two people in an everyday social context. In the second part you will hear a conversation between up to four individuals in an educational context. Pay close attention to the audio recordings and answer the questions accordingly.", + }, + "1_4": { + url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_1_4.mp3?alt=media&token=350511e6-7010-43f7-a258-662e91ff7399", + text: "You will hear two recordings and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. The recordings can be played three times. The module is in 2 parts. In the first part you will hear a conversation between two people in an everyday social context. In the second part you will hear a monologue about an academic subject. Pay close attention to the audio recordings and answer the questions accordingly." + }, + "2_3": { + url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_2_3.mp3?alt=media&token=fd260687-35e9-4386-8843-b58c2146dd48", + text: "You will hear two recordings and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. The recordings can be played three times. The module is in 2 parts. In the first part you will hear a monologue set in a social context. In the second part you will hear a conversation between up to four individuals in an educational context. Pay close attention to the audio recordings and answer the questions accordingly." + }, + "2_4": { + url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_2_4.mp3?alt=media&token=0d85d499-5461-4d0f-8952-20aba319f783", + text: "You will hear two recordings and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. The recordings can be played three times. The module is in 2 parts. In the first part you will hear a monologue set in a social context. In the second part you will hear a monologue about an academic subject. Pay close attention to the audio recordings and answer the questions accordingly." + }, + "3_4": { + url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_3_4.mp3?alt=media&token=79bdabde-3d05-4234-bec7-5a8b385c2479", + text: "You will hear two recordings and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. The recordings can be played three times. The module is in 2 parts. In the first part you will hear a conversation between up to four individuals in an educational context. In the second part you will hear a monologue about an academic subject. Pay close attention to the audio recordings and answer the questions accordingly." + }, + "1_2_3": { + url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_1_2_3.mp3?alt=media&token=8bdb42dd-e3ed-446b-8760-281768c005e6", + text: "You will hear a number of different recordings and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. All the recordings can be played three times. The module is in 3 parts. In the first part you will hear a conversation between two people in an everyday social context. In the second part you will hear a monologue set in a social context. In the third part you will hear a conversation between up to four individuals in an educational context. Pay close attention to the audio recordings and answer the questions accordingly." + }, + "1_2_4": { + url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_1_2_4.mp3?alt=media&token=5458c3c1-d398-453f-be97-ef1785f9d7e3", + text: "You will hear a number of different recordings and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. All the recordings can be played three times. The module is in 3 parts. In the first part you will hear a conversation between two people in an everyday social context. In the second part you will hear a monologue set in a social context. In the third part you will hear a monologue about an academic subject. Pay close attention to the audio recordings and answer the questions accordingly." + }, + "1_3_4": { + url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_1_3_4.mp3?alt=media&token=0380653e-be5b-4c89-9814-a996ae77a74a", + text: "You will hear a number of different recordings and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. All the recordings can be played three times. The module is in 3 parts. In the first part you will hear a conversation between two people in an everyday social context. In the second part you will hear a conversation between up to four individuals in an educational context. In the third part you will hear a monologue about an academic subject. Pay close attention to the audio recordings and answer the questions accordingly." + }, + "2_3_4": { + url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_2_3_4.mp3?alt=media&token=74bf11d6-e3d4-4711-bdc6-b0adbcaf11d4", + text: "You will hear a number of different recordings and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. All the recordings can be played three times. The module is in 3 parts. In the first part you will hear a monologue set in a social context. In the second part you will hear a conversation between up to four individuals in an educational context. In the third part you will hear a monologue about an academic subject. Pay close attention to the audio recordings and answer the questions accordingly." + }, + "1_2_3_4": { + url: "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_1_2_3_4.mp3?alt=media&token=7a7ac516-221d-4e79-bd28-5d6bee9d79d8", + text: "You will hear a number of different recordings and you will have to answer questions on what you hear. There will be time for you to read the instructions and questions and you will have a chance to check your work. All the recordings can be played three times. The module is in 4 parts. In the first part you will hear a conversation between two people in an everyday social context. In the second part you will hear a monologue set in a social context. In the third part you will hear a conversation between up to four individuals in an educational context. In the fourth part you will hear a monologue about an academic subject. Pay close attention to the audio recordings and answer the questions accordingly." + } +} + +type PresetID = keyof typeof PRESETS; + +function isValidPresetID(id: string): id is PresetID { + return id in PRESETS; +} + + +export { + PRESETS, + isValidPresetID +}; diff --git a/src/components/ExamEditor/ResetModule.tsx b/src/components/ExamEditor/Standalone/ResetModule.tsx similarity index 100% rename from src/components/ExamEditor/ResetModule.tsx rename to src/components/ExamEditor/Standalone/ResetModule.tsx diff --git a/src/components/ExamEditor/index.tsx b/src/components/ExamEditor/index.tsx index e2a2d309..dcc3ec1e 100644 --- a/src/components/ExamEditor/index.tsx +++ b/src/components/ExamEditor/index.tsx @@ -18,7 +18,8 @@ import SpeakingSettings from "./SettingsEditor/speaking"; import ImportOrStartFromScratch from "./ImportExam/ImportOrFromScratch"; import { defaultSectionSettings } from "@/stores/examEditor/defaults"; import Button from "../Low/Button"; -import ResetModule from "./ResetModule"; +import ResetModule from "./Standalone/ResetModule"; +import ListeningInstructions from "./Standalone/ListeningInstructions"; const DIFFICULTIES: Difficulty[] = ["A1", "A2", "B1", "B2", "C1", "C2"]; @@ -200,6 +201,7 @@ const ExamEditor: React.FC<{ levelParts?: number }> = ({ levelParts = 0 }) => { required /> + {currentModule === "listening" && } {["reading", "listening", "level"].includes(currentModule) && } diff --git a/src/exams/Listening.tsx b/src/exams/Listening.tsx index a7b4a277..48ba7c18 100644 --- a/src/exams/Listening.tsx +++ b/src/exams/Listening.tsx @@ -135,8 +135,8 @@ const Listening: React.FC> = ({ exam, showSolutions = f />, [partIndex, assignment, timesListened, setShowTextModal, setTimesListened]) const memoizedInstructions = useMemo(() => - - , []) + + , [exam.instructions]) return ( <> diff --git a/src/exams/components/RenderAudioInstructionsPlayer.tsx b/src/exams/components/RenderAudioInstructionsPlayer.tsx index b6e07317..758517ed 100644 --- a/src/exams/components/RenderAudioInstructionsPlayer.tsx +++ b/src/exams/components/RenderAudioInstructionsPlayer.tsx @@ -2,16 +2,23 @@ import AudioPlayer from "@/components/Low/AudioPlayer"; import { v4 } from "uuid"; -const INSTRUCTIONS_AUDIO_SRC = +// Old instructions they were porbably taken from +// an heygen video since I couldn't find the Polly Voice +const OLD_INSTRUCTIONS_AUDIO_SRC = "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/generic_listening_intro_v2.mp3?alt=media&token=16769f5f-1e9b-4a72-86a9-45a6f0fa9f82"; -const RenderAudioInstructionsPlayer: React.FC = () => ( +// New full exam module audio with Polly Matthew voice +const NEW_INSTRUCTIONS_AUDIO_SRC = + "https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/listening_instructions%2FpresetInstructions_1_2_3_4.mp3?alt=media&token=7a7ac516-221d-4e79-bd28-5d6bee9d79d8"; + + +const RenderAudioInstructionsPlayer: React.FC<{instructions?: string}> = ({instructions}) => (

Please listen to the instructions audio attentively.

- +
); diff --git a/src/interfaces/exam.ts b/src/interfaces/exam.ts index e69c6050..b0bf2e8e 100644 --- a/src/interfaces/exam.ts +++ b/src/interfaces/exam.ts @@ -1,3 +1,4 @@ +import instructions from "@/pages/api/exam/media/instructions"; import { Module } from "."; export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam; @@ -68,6 +69,7 @@ export interface LevelPart extends Section { export interface ListeningExam extends ExamBase { parts: ListeningPart[]; module: "listening"; + instructions?: string; } export type Message = { name: string; gender: string; text: string; voice?: string; }; diff --git a/src/pages/api/exam/media/instructions.ts b/src/pages/api/exam/media/instructions.ts new file mode 100644 index 00000000..06f0fa84 --- /dev/null +++ b/src/pages/api/exam/media/instructions.ts @@ -0,0 +1,36 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { withIronSessionApiRoute } from "iron-session/next"; +import { sessionOptions } from "@/lib/session"; +import axios from "axios"; + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "POST") return post(req, res); + + return res.status(404).json({ ok: false }); +} + +async function post(req: NextApiRequest, res: NextApiResponse) { + if (!req.session.user) return res.status(401).json({ ok: false }); + + const response = await axios.post( + `${process.env.BACKEND_URL}/listening/instructions`, + req.body, + { + headers: { + Authorization: `Bearer ${process.env.BACKEND_JWT}`, + Accept: 'audio/mpeg' + }, + responseType: 'arraybuffer', + } + ); + + res.writeHead(200, { + 'Content-Type': 'audio/mpeg', + 'Content-Length': response.data.length + }); + + res.end(response.data); + return; +} diff --git a/src/pages/generation.tsx b/src/pages/generation.tsx index 00f7ff3f..595a6c39 100644 --- a/src/pages/generation.tsx +++ b/src/pages/generation.tsx @@ -110,6 +110,10 @@ export default function Generation({ id, user, exam, examModule, permissions }: } }); + if (state.listening.instructionsState.customInstructionsURL.startsWith('blob:')) { + URL.revokeObjectURL(state.listening.instructionsState.customInstructionsURL); + } + state.speaking.sections.forEach(section => { const sectionState = section.state as Exercise; if (sectionState.type === 'speaking') { diff --git a/src/stores/examEditor/defaults.ts b/src/stores/examEditor/defaults.ts index bd145f2b..26d30787 100644 --- a/src/stores/examEditor/defaults.ts +++ b/src/stores/examEditor/defaults.ts @@ -97,13 +97,13 @@ export const sectionLabels = (module: Module, levelParts?: number) => { label: `Section ${index + 1}` })); case 'level': - return levelParts !== undefined ? - Array.from({ length: levelParts }, (_, index) => ({ - id: index + 1, - label: `Part ${index + 1}` - })) - : - [{ id: 1, label: "Part 1" }]; + return levelParts !== undefined ? + Array.from({ length: levelParts }, (_, index) => ({ + id: index + 1, + label: `Part ${index + 1}` + })) + : + [{ id: 1, label: "Part 1" }]; } } @@ -166,6 +166,16 @@ const defaultModuleSettings = (module: Module, minTimer: number): ModuleState => importModule: true, importing: false, edit: [], + instructionsState: { + isInstructionsOpen: false, + chosenOption: { value: "Automatic", label: "Automatic" }, + currentInstructions: "", + presetInstructions: "", + customInstructions: "", + currentInstructionsURL: "", + presetInstructionsURL: "", + customInstructionsURL: "", + }, }; if (["reading", "writing"].includes(module)) { state["type"] = "general"; diff --git a/src/stores/examEditor/types.ts b/src/stores/examEditor/types.ts index 2635437e..bda1978c 100644 --- a/src/stores/examEditor/types.ts +++ b/src/stores/examEditor/types.ts @@ -38,7 +38,7 @@ export interface ListeningSectionSettings extends SectionSettings { isListeningTopicOpen: boolean; uploadedAudioURL: string | undefined; audioCutURL: string | undefined; - useEntireAudioFile: boolean; + useEntireAudioFile: boolean; } export interface WritingSectionSettings extends SectionSettings { @@ -108,6 +108,19 @@ export interface SectionState { scriptLoading: boolean; } +export interface ListeningInstructionsState { + isInstructionsOpen: boolean; + chosenOption: Option; + + currentInstructions: string; + presetInstructions: string; + customInstructions: string; + + currentInstructionsURL: string; + presetInstructionsURL: string; + customInstructionsURL: string; +} + export interface ModuleState { examLabel: string; sections: SectionState[]; @@ -122,6 +135,7 @@ export interface ModuleState { edit: number[]; type?: "general" | "academic"; academic_url?: string | undefined; + instructionsState: ListeningInstructionsState; } export interface Avatar { From bd9e24970433f6cf044bfc40b6d965aca4a21b5d Mon Sep 17 00:00:00 2001 From: Carlos-Mesquita Date: Sat, 28 Dec 2024 03:30:15 +0000 Subject: [PATCH 03/10] Unfinished grading attempt at solving it --- src/pages/(exam)/ExamPage.tsx | 7 +- src/pages/api/evaluate/interactiveSpeaking.ts | 194 ++++++++++-------- src/pages/api/evaluate/speaking.ts | 31 +++ src/pages/api/evaluate/status.ts | 33 ++- src/pages/api/evaluate/writing.ts | 37 +++- src/pages/api/stats/updateDisabled.ts | 42 ++++ 6 files changed, 237 insertions(+), 107 deletions(-) create mode 100644 src/pages/api/stats/updateDisabled.ts diff --git a/src/pages/(exam)/ExamPage.tsx b/src/pages/(exam)/ExamPage.tsx index 9a041a35..e3ce8305 100644 --- a/src/pages/(exam)/ExamPage.tsx +++ b/src/pages/(exam)/ExamPage.tsx @@ -22,7 +22,6 @@ import ShortUniqueId from "short-unique-id"; import { ExamProps } from "@/exams/types"; import useExamStore from "@/stores/exam"; import useEvaluationPolling from "@/hooks/useEvaluationPolling"; -import PracticeModal from "@/components/PracticeModal"; interface Props { page: "exams" | "exercises"; @@ -37,6 +36,7 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar = const [avoidRepeated, setAvoidRepeated] = useState(false); const [showAbandonPopup, setShowAbandonPopup] = useState(false); const [pendingExercises, setPendingExercises] = useState([]); + const [shouldPoll, setShouldPoll] = useState(false); const { exam, setExam, @@ -60,7 +60,6 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar = setFlags, setShuffles, evaluated, - setEvaluated, } = useExamStore(); const [isFetchingExams, setIsFetchingExams] = useState(false); @@ -150,8 +149,10 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar = useEffect(() => { if (flags.finalizeExam && moduleIndex !== -1) { setModuleIndex(-1); + + } - }, [flags, moduleIndex, setModuleIndex]); + }, [flags.finalizeExam, moduleIndex, setModuleIndex]); useEffect(() => { if (flags.finalizeExam && !flags.pendingEvaluation && pendingExercises.length === 0) { diff --git a/src/pages/api/evaluate/interactiveSpeaking.ts b/src/pages/api/evaluate/interactiveSpeaking.ts index e217c7e5..4623c483 100644 --- a/src/pages/api/evaluate/interactiveSpeaking.ts +++ b/src/pages/api/evaluate/interactiveSpeaking.ts @@ -6,98 +6,128 @@ import axios from "axios"; import formidable from "formidable-serverless"; import fs from "fs"; import FormData from 'form-data'; +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) { if (!req.session.user) { - res.status(401).json({ ok: false }); - return; - } - - const form = formidable({ keepExtensions: true }); - - await form.parse(req, async (err: any, fields: any, files: any) => { - if (err) { - console.error('Error parsing form:', err); - res.status(500).json({ ok: false, error: 'Failed to parse form data' }); + res.status(401).json({ ok: false }); return; - } - - try { - const formData = new FormData(); - - if (!fields.userId || !fields.sessionId || !fields.exerciseId || !fields.task) { - throw new Error('Missing required fields'); + } + + const form = formidable({ keepExtensions: true }); + + await form.parse(req, async (err: any, fields: any, files: any) => { + if (err) { + console.error('Error parsing form:', err); + res.status(500).json({ ok: false, error: 'Failed to parse form data' }); + return; } - - formData.append('userId', fields.userId); - formData.append('sessionId', fields.sessionId); - formData.append('exerciseId', fields.exerciseId); - - for (const fileKey of Object.keys(files)) { - const indexMatch = fileKey.match(/^audio_(\d+)$/); - if (!indexMatch) { - console.warn(`Skipping invalid file key: ${fileKey}`); - continue; - } - - const index = indexMatch[1]; - const questionKey = `question_${index}`; - const audioFile = files[fileKey]; - - if (!audioFile || !audioFile.path) { - throw new Error(`Invalid audio file for ${fileKey}`); - } - - if (!fields[questionKey]) { - throw new Error(`Missing question for audio ${index}`); - } - - try { - const buffer = fs.readFileSync(audioFile.path); - formData.append(`audio_${index}`, buffer, `audio_${index}.wav`); - formData.append(questionKey, fields[questionKey]); - fs.rmSync(audioFile.path); - } catch (fileError) { - console.error(`Error processing file ${fileKey}:`, fileError); - throw new Error(`Failed to process audio file ${index}`); - } - } - - await axios.post( - `${process.env.BACKEND_URL}/grade/speaking/${fields.task}`, - formData, - { - headers: { - ...formData.getHeaders(), - Authorization: `Bearer ${process.env.BACKEND_JWT}`, - }, - } - ); - - res.status(200).json({ ok: true }); - } catch (error) { - console.error('Error processing request:', error); - res.status(500).json({ - ok: false, - error: 'Internal server error' - }); - - Object.keys(files).forEach(fileKey => { - const audioFile = files[fileKey]; - if (audioFile && audioFile.path && fs.existsSync(audioFile.path)) { - try { - fs.rmSync(audioFile.path); - } catch (cleanupError) { - console.error(`Failed to clean up temp file ${audioFile.path}:`, cleanupError); + + try { + const formData = new FormData(); + + if (!fields.userId || !fields.sessionId || !fields.exerciseId || !fields.task) { + throw new Error('Missing required fields'); } - } - }); - } + + formData.append('userId', fields.userId); + formData.append('sessionId', fields.sessionId); + formData.append('exerciseId', fields.exerciseId); + + for (const fileKey of Object.keys(files)) { + const indexMatch = fileKey.match(/^audio_(\d+)$/); + if (!indexMatch) { + console.warn(`Skipping invalid file key: ${fileKey}`); + continue; + } + + const index = indexMatch[1]; + const questionKey = `question_${index}`; + const audioFile = files[fileKey]; + + if (!audioFile || !audioFile.path) { + throw new Error(`Invalid audio file for ${fileKey}`); + } + + if (!fields[questionKey]) { + throw new Error(`Missing question for audio ${index}`); + } + + try { + const buffer = fs.readFileSync(audioFile.path); + formData.append(`audio_${index}`, buffer, `audio_${index}.wav`); + formData.append(questionKey, fields[questionKey]); + fs.rmSync(audioFile.path); + } catch (fileError) { + console.error(`Error processing file ${fileKey}:`, fileError); + throw new Error(`Failed to process audio file ${index}`); + } + } + + // Check if there is one eval for the current exercise + const previousEval = await db.collection("evaluation").findOne({ + user: fields.userId, + session_id: fields.sessionId, + exercise_id: fields.exerciseId, + }) + + // If there is delete it + if (previousEval) { + await db.collection("evaluation").deleteOne({ + user: fields.userId, + session_id: fields.sessionId, + exercise_id: fields.exerciseId, + }) + } + + // Insert the new eval for the backend to place it's result + await db.collection("evaluation").insertOne( + { + user: fields.userId, + session_id: fields.sessionId, + exercise_id: fields.exerciseId, + type: "speaking_interactive", + task: fields.task, + status: "pending" + } + ); + + await axios.post( + `${process.env.BACKEND_URL}/grade/speaking/${fields.task}`, + formData, + { + headers: { + ...formData.getHeaders(), + Authorization: `Bearer ${process.env.BACKEND_JWT}`, + }, + } + ); + + res.status(200).json({ ok: true }); + } catch (error) { + console.error('Error processing request:', error); + res.status(500).json({ + ok: false, + error: 'Internal server error' + }); + + Object.keys(files).forEach(fileKey => { + const audioFile = files[fileKey]; + if (audioFile && audioFile.path && fs.existsSync(audioFile.path)) { + try { + fs.rmSync(audioFile.path); + } catch (cleanupError) { + console.error(`Failed to clean up temp file ${audioFile.path}:`, cleanupError); + } + } + }); + } }); - } +} export const config = { api: { diff --git a/src/pages/api/evaluate/speaking.ts b/src/pages/api/evaluate/speaking.ts index faebf428..89203d2a 100644 --- a/src/pages/api/evaluate/speaking.ts +++ b/src/pages/api/evaluate/speaking.ts @@ -6,6 +6,9 @@ import axios from "axios"; import formidable from "formidable-serverless"; import fs from "fs"; import FormData from 'form-data'; +import client from "@/lib/mongodb"; + +const db = client.db(process.env.MONGODB_DB); export default withIronSessionApiRoute(handler, sessionOptions); @@ -41,6 +44,34 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { formData.append('audio_1', buffer, 'audio_1.wav'); fs.rmSync(audioFile.path); + // Check if there is one eval for the current exercise + const previousEval = await db.collection("evaluation").findOne({ + user: fields.userId, + session_id: fields.sessionId, + exercise_id: fields.exerciseId, + }) + + // If there is delete it + if (previousEval) { + await db.collection("evaluation").deleteOne({ + user: fields.userId, + session_id: fields.sessionId, + exercise_id: fields.exerciseId, + }) + } + + // Insert the new eval for the backend to place it's result + await db.collection("evaluation").insertOne( + { + user: fields.userId, + session_id: fields.sessionId, + exercise_id: fields.exerciseId, + type: "speaking", + task: 2, + status: "pending" + } + ); + await axios.post( `${process.env.BACKEND_URL}/grade/speaking/2`, formData, diff --git a/src/pages/api/evaluate/status.ts b/src/pages/api/evaluate/status.ts index edec09d0..9679a545 100644 --- a/src/pages/api/evaluate/status.ts +++ b/src/pages/api/evaluate/status.ts @@ -1,34 +1,29 @@ -import type {NextApiRequest, NextApiResponse} from "next"; +import type { NextApiRequest, NextApiResponse } from "next"; import client from "@/lib/mongodb"; -import {withIronSessionApiRoute} from "iron-session/next"; -import {sessionOptions} from "@/lib/session"; +import { withIronSessionApiRoute } from "iron-session/next"; +import { sessionOptions } from "@/lib/session"; const db = client.db(process.env.MONGODB_DB); export default withIronSessionApiRoute(handler, sessionOptions); async function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method === "GET") return get(req, res); + if (req.method === "GET") return get(req, res); } async function get(req: NextApiRequest, res: NextApiResponse) { if (!req.session.user) { - res.status(401).json({ok: false}); + res.status(401).json({ ok: false }); return; } - const {sessionId, userId, exerciseIds} = req.query; - const exercises = (exerciseIds! as string).split(','); - const finishedEvaluations = await db.collection("evaluation").find({ - session_id: sessionId, - user: userId, - $or: [ - { status: "completed" }, - { status: "error" } - ], - exercise_id: { $in: exercises } - }).toArray(); + const { sessionId, userId } = req.query; - const finishedExerciseIds = finishedEvaluations.map(evaluation => evaluation.exercise_id); - res.status(200).json({ finishedExerciseIds }); -} \ No newline at end of file + const singleEval = await db.collection("evaluation").findOne({ + session_id: sessionId, + user: userId, + status: "pending", + }); + + res.status(200).json({ hasPendingEvaluation: singleEval !== null}); +} diff --git a/src/pages/api/evaluate/writing.ts b/src/pages/api/evaluate/writing.ts index 8f7b289b..9b4be221 100644 --- a/src/pages/api/evaluate/writing.ts +++ b/src/pages/api/evaluate/writing.ts @@ -3,6 +3,9 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { withIronSessionApiRoute } from "iron-session/next"; import { sessionOptions } from "@/lib/session"; import axios from "axios"; +import client from "@/lib/mongodb"; + +const db = client.db(process.env.MONGODB_DB); interface Body { userId: string; @@ -22,13 +25,41 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { return; } - const { task, ...body} = req.body as Body; - const taskNumber = task.toString() !== "1" && task.toString() !== "2" ? "1" : task.toString(); + const body = req.body as Body; + const taskNumber = body.task.toString() !== "1" && body.task.toString() !== "2" ? "1" : body.task.toString(); + + // Check if there is one eval for the current exercise + const previousEval = await db.collection("evaluation").findOne({ + user: body.userId, + session_id: body.sessionId, + exercise_id: body.exerciseId, + }) + + // If there is delete it + if (previousEval) { + await db.collection("evaluation").deleteOne({ + user: body.userId, + session_id: body.sessionId, + exercise_id: body.exerciseId, + }) + } + + // Insert the new eval for the backend to place it's result + await db.collection("evaluation").insertOne( + { + user: body.userId, + session_id: body.sessionId, + exercise_id: body.exerciseId, + type: "writing", + task: body.task, + status: "pending" + } + ); await axios.post(`${process.env.BACKEND_URL}/grade/writing/${taskNumber}`, body, { headers: { Authorization: `Bearer ${process.env.BACKEND_JWT}`, }, }); - res.status(200).json({ok: true}); + res.status(200).json({ ok: true }); } diff --git a/src/pages/api/stats/updateDisabled.ts b/src/pages/api/stats/updateDisabled.ts new file mode 100644 index 00000000..ba7e688c --- /dev/null +++ b/src/pages/api/stats/updateDisabled.ts @@ -0,0 +1,42 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import type { NextApiRequest, NextApiResponse } from "next"; +import client from "@/lib/mongodb"; +import { withIronSessionApiRoute } from "iron-session/next"; +import { sessionOptions } from "@/lib/session"; +import { Stat } from "@/interfaces/user"; +import { requestUser } from "@/utils/api"; +import { UserSolution } from "@/interfaces/exam"; + +const db = client.db(process.env.MONGODB_DB); + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "POST") return post(req, res); +} + +interface Body { + solutions: UserSolution[]; + sessionID: string; +} + +async function post(req: NextApiRequest, res: NextApiResponse) { + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); + + const { solutions, sessionID } = req.body as Body; + + const disabledStats = await db.collection("stats").find({ user: user.id, session: sessionID, disabled: true }).toArray(); + + await Promise.all(disabledStats.map(async (stat) => { + const matchingSolution = solutions.find(s => s.exercise === stat.exercise); + if (matchingSolution) { + await db.collection("stats").updateOne( + { id: stat.id }, + { $set: { ...matchingSolution } } + ); + } + })); + + return res.status(200).json({ ok: true }); +} From b52259794e3c793fddb74ed6b0e27a6e65c38ed1 Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Mon, 30 Dec 2024 11:11:44 +0000 Subject: [PATCH 04/10] ENCOA-298 --- src/exams/Level/index.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/exams/Level/index.tsx b/src/exams/Level/index.tsx index eb535c80..008897ae 100644 --- a/src/exams/Level/index.tsx +++ b/src/exams/Level/index.tsx @@ -67,13 +67,6 @@ const Level: React.FC> = ({ exam, showSolutions = false, pr const [continueAnyways, setContinueAnyways] = useState(false); const [textRender, setTextRender] = useState(false); - const hasPractice = useMemo(() => { - if (partIndex > -1 && partIndex < exam.parts.length) { - return exam.parts[partIndex].exercises.some(e => e.isPractice) - } - return false - }, [partIndex, exam.parts]) - const [questionModalKwargs, setQuestionModalKwargs] = useState<{ type?: "module" | "blankQuestions" | "submit"; unanswered?: boolean | undefined; onClose: (next?: boolean) => void | undefined; }>({ @@ -108,6 +101,14 @@ const Level: React.FC> = ({ exam, showSolutions = false, pr } ); + const hasPractice = useMemo(() => { + if (partIndex > -1 && partIndex < exam.parts.length && !showPartDivider) { + console.log(exam.parts[partIndex].exercises.some(e => e.isPractice)) + return exam.parts[partIndex].exercises.some(e => e.isPractice) + } + return false + }, [partIndex, showPartDivider, exam.parts]) + const registerSolution = useCallback((updateSolution: () => UserSolution) => { userSolutionRef.current = updateSolution; setSolutionWasUpdated(true); @@ -344,7 +345,7 @@ const Level: React.FC> = ({ exam, showSolutions = false, pr return ( <>
- + Date: Mon, 30 Dec 2024 15:36:20 +0000 Subject: [PATCH 05/10] Updated the expiry date to be based on the expiry date --- src/interfaces/entity.ts | 5 ++ src/interfaces/paypal.ts | 1 + src/pages/api/entities/[id]/index.ts | 21 ++++- src/pages/entities/[id]/index.tsx | 103 ++++++++++++++++++++++- src/pages/entities/[id]/roles/[role].tsx | 4 +- src/pages/payment-record.tsx | 36 +++++--- src/pages/payment.tsx | 4 +- src/resources/entityPermissions.ts | 8 +- 8 files changed, 164 insertions(+), 18 deletions(-) diff --git a/src/interfaces/entity.ts b/src/interfaces/entity.ts index da1e8e5e..b10d547e 100644 --- a/src/interfaces/entity.ts +++ b/src/interfaces/entity.ts @@ -4,6 +4,11 @@ export interface Entity { id: string; label: string; licenses: number; + expiryDate?: Date | null + payment?: { + currency: string + price: number + } } export interface Role { diff --git a/src/interfaces/paypal.ts b/src/interfaces/paypal.ts index b1b3e8b0..d7c7c902 100644 --- a/src/interfaces/paypal.ts +++ b/src/interfaces/paypal.ts @@ -32,6 +32,7 @@ export type DurationUnit = "weeks" | "days" | "months" | "years"; export interface Payment { id: string; corporate: string; + entity?: string agent?: string; agentCommission: number; agentValue: number; diff --git a/src/pages/api/entities/[id]/index.ts b/src/pages/api/entities/[id]/index.ts index c329f832..2f438f20 100644 --- a/src/pages/api/entities/[id]/index.ts +++ b/src/pages/api/entities/[id]/index.ts @@ -6,9 +6,11 @@ import { deleteEntity, getEntity, getEntityWithRoles } from "@/utils/entities.be import client from "@/lib/mongodb"; import { Entity } from "@/interfaces/entity"; import { doesEntityAllow } from "@/utils/permissions"; -import { getUser } from "@/utils/users.be"; +import { getEntityUsers, getUser } from "@/utils/users.be"; import { requestUser } from "@/utils/api"; import { isAdmin } from "@/utils/users"; +import { filterBy, mapBy } from "@/utils"; +import { User } from "@/interfaces/user"; const db = client.db(process.env.MONGODB_DB); @@ -66,5 +68,22 @@ async function patch(req: NextApiRequest, res: NextApiResponse) { return res.status(200).json({ ok: entity.acknowledged }); } + if (req.body.expiryDate !== undefined) { + const entity = await getEntity(id) + const result = await db.collection("entities").updateOne({ id }, { $set: { expiryDate: req.body.expiryDate } }); + + const users = await getEntityUsers(id, 0, { + subscriptionExpirationDate: entity?.expiryDate, + $and: [ + { type: { $ne: "admin" } }, + { type: { $ne: "developer" } }, + ] + }) + + await db.collection("users").updateMany({ id: { $in: mapBy(users, 'id') } }, { $set: { subscriptionExpirationDate: req.body.expiryDate } }) + + return res.status(200).json({ ok: result.acknowledged }); + } + return res.status(200).json({ ok: true }); } diff --git a/src/pages/entities/[id]/index.tsx b/src/pages/entities/[id]/index.tsx index 1546d933..324c2227 100644 --- a/src/pages/entities/[id]/index.tsx +++ b/src/pages/entities/[id]/index.tsx @@ -2,6 +2,7 @@ import CardList from "@/components/High/CardList"; import Layout from "@/components/High/Layout"; import Select from "@/components/Low/Select"; +import Checkbox from "@/components/Low/Checkbox"; import Tooltip from "@/components/Low/Tooltip"; import { useEntityPermission } from "@/hooks/useEntityPermissions"; import { useListSearch } from "@/hooks/useListSearch"; @@ -27,7 +28,10 @@ import Link from "next/link"; import { useRouter } from "next/router"; import { Divider } from "primereact/divider"; import { useEffect, useMemo, useState } from "react"; +import ReactDatePicker from "react-datepicker"; + import { + BsCheck, BsChevronLeft, BsClockFill, BsEnvelopeFill, @@ -43,6 +47,15 @@ import { } from "react-icons/bs"; import { toast } from "react-toastify"; +const expirationDateColor = (date: Date) => { + const momentDate = moment(date); + const today = moment(new Date()); + + if (today.add(1, "days").isAfter(momentDate)) return "!bg-mti-red-ultralight border-mti-red-light"; + if (today.add(3, "days").isAfter(momentDate)) return "!bg-mti-rose-ultralight border-mti-rose-light"; + if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light"; +}; + export const getServerSideProps = withIronSessionSsr(async ({ req, params }) => { const user = req.session.user as User; @@ -88,6 +101,7 @@ export default function Home({ user, entity, users, linkedUsers }: Props) { const [isAdding, setIsAdding] = useState(false); const [isLoading, setIsLoading] = useState(false); const [selectedUsers, setSelectedUsers] = useState([]); + const [expiryDate, setExpiryDate] = useState(entity?.expiryDate) const router = useRouter(); @@ -99,6 +113,7 @@ export default function Home({ user, entity, users, linkedUsers }: Props) { const canRemoveMembers = useEntityPermission(user, entity, "remove_from_entity") const canAssignRole = useEntityPermission(user, entity, "assign_to_role") + const canPay = useEntityPermission(user, entity, 'pay_entity') const toggleUser = (u: User) => setSelectedUsers((prev) => (prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id])); @@ -166,6 +181,23 @@ export default function Home({ user, entity, users, linkedUsers }: Props) { .finally(() => setIsLoading(false)); }; + const updateExpiryDate = () => { + if (!isAdmin(user)) return; + + setIsLoading(true); + axios + .patch(`/api/entities/${entity.id}`, { expiryDate }) + .then(() => { + toast.success("The entity has been updated successfully!"); + router.replace(router.asPath); + }) + .catch((e) => { + console.error(e); + toast.error("Something went wrong!"); + }) + .finally(() => setIsLoading(false)); + }; + const editLicenses = () => { if (!isAdmin(user)) return; @@ -289,7 +321,7 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
-
+

{entity.label} {isAdmin(user) && `- ${entity.licenses || 0} licenses`}

+ + {!isAdmin(user) && canPay && ( + + {!entity.expiryDate && "Unlimited"} + {entity.expiryDate && moment(entity.expiryDate).format("DD/MM/YYYY")} + + )}
+ + {isAdmin(user) && ( + <> + + +
+
+ {!!expiryDate && ( + moment(date).isAfter(new Date())} + dateFormat="dd/MM/yyyy" + selected={expiryDate ? moment(expiryDate).toDate() : null} + onChange={(date) => setExpiryDate(date)} + /> + )} + + {!expiryDate && ( +
+ Unlimited +
+ )} + + setExpiryDate(checked ? entity.expiryDate || new Date() : null)} + > + Enable expiry date + +
+ + + +
+ + )} +
Members ({users.length}) diff --git a/src/pages/entities/[id]/roles/[role].tsx b/src/pages/entities/[id]/roles/[role].tsx index 037fb571..5c41954f 100644 --- a/src/pages/entities/[id]/roles/[role].tsx +++ b/src/pages/entities/[id]/roles/[role].tsx @@ -98,7 +98,9 @@ const ENTITY_MANAGEMENT: PermissionLayout[] = [ { label: "Delete Entity Role", key: "delete_entity_role" }, { label: "Download Statistics Report", key: "download_statistics_report" }, { label: "Edit Grading System", key: "edit_grading_system" }, - { label: "View Student Performance", key: "view_student_performance" } + { label: "View Student Performance", key: "view_student_performance" }, + { label: "Pay for Entity", key: "pay_entity" }, + { label: "View Payment Record", key: "view_payment_record" } ] const ASSIGNMENT_MANAGEMENT: PermissionLayout[] = [ diff --git a/src/pages/payment-record.tsx b/src/pages/payment-record.tsx index 2be5369a..daf277ce 100644 --- a/src/pages/payment-record.tsx +++ b/src/pages/payment-record.tsx @@ -30,9 +30,12 @@ import { toFixedNumber } from "@/utils/number"; import { CSVLink } from "react-csv"; import { Tab } from "@headlessui/react"; import { useListSearch } from "@/hooks/useListSearch"; -import { checkAccess, getTypesOfUser } from "@/utils/permissions"; +import { checkAccess, findAllowedEntities, getTypesOfUser } from "@/utils/permissions"; import { requestUser } from "@/utils/api"; -import { redirect } from "@/utils"; +import { mapBy, redirect, serialize } from "@/utils"; +import { getEntities, getEntitiesWithRoles } from "@/utils/entities.be"; +import { isAdmin } from "@/utils/users"; +import { Entity, EntityWithRoles } from "@/interfaces/entity"; export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { const user = await requestUser(req, res) @@ -42,8 +45,13 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { return redirect("/") } + const entityIDs = mapBy(user.entities, 'id') + const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDs) + + const allowedEntities = findAllowedEntities(user, entities, "view_payment_record") + return { - props: { user }, + props: serialize({ user, entities: allowedEntities }), }; }, sessionOptions); @@ -273,7 +281,13 @@ interface PaypalPaymentWithUserData extends PaypalPayment { } const paypalFilterRows = [["email"], ["name"], ["orderId"], ["value"]]; -export default function PaymentRecord() { + +interface Props { + user: User + entities: EntityWithRoles[] +} + +export default function PaymentRecord({ user, entities }: Props) { const [selectedCorporateUser, setSelectedCorporateUser] = useState(); const [selectedAgentUser, setSelectedAgentUser] = useState(); const [isCreatingPayment, setIsCreatingPayment] = useState(false); @@ -281,9 +295,9 @@ export default function PaymentRecord() { const [displayPayments, setDisplayPayments] = useState([]); const [corporate, setCorporate] = useState(); + const [entity, setEntity] = useState(); const [agent, setAgent] = useState(); - const { user } = useUser({ redirectTo: "/login" }); const { users, reload: reloadUsers } = useUsers(); const { payments: originalPayments, reload: reloadPayment } = usePayments(); const { payments: paypalPayments, reload: reloadPaypalPayment } = usePaypalPayments(); @@ -341,17 +355,17 @@ export default function PaymentRecord() { useEffect(() => { setFilters((prev) => [ - ...prev.filter((x) => x.id !== "corporate-filter"), - ...(!corporate + ...prev.filter((x) => x.id !== "entity-filter"), + ...(!entity ? [] : [ { - id: "corporate-filter", - filter: (p: Payment) => p.corporate === corporate.id, + id: "entity-filter", + filter: (p: Payment) => p.entity === entity.id, }, ]), ]); - }, [corporate]); + }, [entity]); useEffect(() => { setFilters((prev) => [ @@ -675,7 +689,7 @@ export default function PaymentRecord() { { - if (user?.type === agent || user?.type === "corporate" || value) return null; + if (user?.type === "agent" || user?.type === "corporate" || value) return null; if (!info.row.original.commissionTransfer || !info.row.original.corporateTransfer) return alert("All files need to be uploaded to consider it paid!"); if (!confirm(`Are you sure you want to consider this payment paid?`)) return null; diff --git a/src/pages/payment.tsx b/src/pages/payment.tsx index 49854d22..579a88db 100644 --- a/src/pages/payment.tsx +++ b/src/pages/payment.tsx @@ -7,7 +7,7 @@ import PaymentDue from "./(status)/PaymentDue"; import { useRouter } from "next/router"; import { requestUser } from "@/utils/api"; import { mapBy, redirect, serialize } from "@/utils"; -import { getEntities } from "@/utils/entities.be"; +import { getEntities, getEntitiesWithRoles } from "@/utils/entities.be"; import { isAdmin } from "@/utils/users"; import { EntityWithRoles } from "@/interfaces/entity"; import { User } from "@/interfaces/user"; @@ -17,7 +17,7 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { if (!user) return redirect("/login") const entityIDs = mapBy(user.entities, 'id') - const entities = await getEntities(isAdmin(user) ? undefined : entityIDs) + const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDs) return { props: serialize({ user, entities }), diff --git a/src/resources/entityPermissions.ts b/src/resources/entityPermissions.ts index 484672d1..b9c6c165 100644 --- a/src/resources/entityPermissions.ts +++ b/src/resources/entityPermissions.ts @@ -63,7 +63,9 @@ export type RolePermission = "upload_classroom" | "download_user_list" | "view_student_record" | - "download_student_record" + "download_student_record" | + "pay_entity" | + "view_payment_record" export const DEFAULT_PERMISSIONS: RolePermission[] = [ "view_students", @@ -140,5 +142,7 @@ export const ADMIN_PERMISSIONS: RolePermission[] = [ "upload_classroom", "download_user_list", "view_student_record", - "download_student_record" + "download_student_record", + "pay_entity", + "view_payment_record" ] From f64b50df9e74abf39f37f8412b6d18fe5bd76170 Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Mon, 30 Dec 2024 18:39:02 +0000 Subject: [PATCH 06/10] Updated part of the payment --- src/components/High/Layout.tsx | 12 +- src/components/Low/Input.tsx | 5 +- src/components/Navbar.tsx | 10 +- src/components/PaymobPayment.tsx | 17 +- src/interfaces/paymob.ts | 2 +- src/pages/(status)/PaymentDue.tsx | 338 ++++++++++++++------------- src/pages/api/entities/[id]/index.ts | 5 + src/pages/api/paymob/webhook.ts | 64 ++--- src/pages/entities/[id]/index.tsx | 56 +++++ src/pages/payment.tsx | 17 +- src/stores/examEditor/defaults.ts | 288 +++++++++++------------ 11 files changed, 457 insertions(+), 357 deletions(-) diff --git a/src/components/High/Layout.tsx b/src/components/High/Layout.tsx index c29d62c4..502384e1 100644 --- a/src/components/High/Layout.tsx +++ b/src/components/High/Layout.tsx @@ -1,8 +1,8 @@ import useEntities from "@/hooks/useEntities"; import { EntityWithRoles } from "@/interfaces/entity"; -import {User} from "@/interfaces/user"; +import { User } from "@/interfaces/user"; import clsx from "clsx"; -import {useRouter} from "next/router"; +import { useRouter } from "next/router"; import { ToastContainer } from "react-toastify"; import Navbar from "../Navbar"; import Sidebar from "../Sidebar"; @@ -23,19 +23,19 @@ export default function Layout({ user, children, className, - bgColor="bg-white", + bgColor = "bg-white", hideSidebar, navDisabled = false, focusMode = false, onFocusLayerMouseEnter }: Props) { const router = useRouter(); - const {entities} = useEntities() + const { entities } = useEntities() return (
- {!hideSidebar && ( + {!hideSidebar && user && ( )}
- {!hideSidebar && ( + {!hideSidebar && user && ( void; } @@ -29,6 +30,7 @@ export default function Input({ className, roundness = "full", disabled = false, + thin = false, min, onChange, }: Props) { @@ -95,9 +97,10 @@ export default function Input({ min={type === "number" ? (min ?? 0) : undefined} placeholder={placeholder} className={clsx( - "px-8 py-6 text-sm font-normal bg-white border border-mti-gray-platinum focus:outline-none", + "px-8 text-sm font-normal bg-white border border-mti-gray-platinum focus:outline-none", "placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed", roundness === "full" ? "rounded-full" : "rounded-xl", + thin ? 'py-4' : 'py-6' )} required={required} defaultValue={defaultValue} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index f2bc5a42..a18f11e5 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -65,28 +65,28 @@ export default function Navbar({ user, path, navDisabled = false, focusMode = fa { module: "reading", icon: () => , - achieved: user.levels?.reading || 0 >= user.desiredLevels?.reading || 9, + achieved: user?.levels?.reading || 0 >= user?.desiredLevels?.reading || 9, }, { module: "listening", icon: () => , - achieved: user.levels?.listening || 0 >= user.desiredLevels?.listening || 9, + achieved: user?.levels?.listening || 0 >= user?.desiredLevels?.listening || 9, }, { module: "writing", icon: () => , - achieved: user.levels?.writing || 0 >= user.desiredLevels?.writing || 9, + achieved: user?.levels?.writing || 0 >= user?.desiredLevels?.writing || 9, }, { module: "speaking", icon: () => , - achieved: user.levels?.speaking || 0 >= user.desiredLevels?.speaking || 9, + achieved: user?.levels?.speaking || 0 >= user?.desiredLevels?.speaking || 9, }, { module: "level", icon: () => , - achieved: user.levels?.level || 0 >= user.desiredLevels?.level || 9, + achieved: user?.levels?.level || 0 >= user?.desiredLevels?.level || 9, }, ]; diff --git a/src/components/PaymobPayment.tsx b/src/components/PaymobPayment.tsx index 47cec9d6..9df12935 100644 --- a/src/components/PaymobPayment.tsx +++ b/src/components/PaymobPayment.tsx @@ -1,15 +1,17 @@ -import {PaymentIntention} from "@/interfaces/paymob"; -import {DurationUnit} from "@/interfaces/paypal"; -import {User} from "@/interfaces/user"; +import { Entity } from "@/interfaces/entity"; +import { PaymentIntention } from "@/interfaces/paymob"; +import { DurationUnit } from "@/interfaces/paypal"; +import { User } from "@/interfaces/user"; import axios from "axios"; -import {useRouter} from "next/router"; -import {useState} from "react"; +import { useRouter } from "next/router"; +import { useState } from "react"; import Button from "./Low/Button"; import Input from "./Low/Input"; import Modal from "./Modal"; interface Props { user: User; + entity?: Entity currency: string; price: number; setIsPaymentLoading: (v: boolean) => void; @@ -18,7 +20,7 @@ interface Props { onSuccess: (duration: number, duration_unit: DurationUnit) => void; } -export default function PaymobPayment({user, price, setIsPaymentLoading, currency, duration, duration_unit, onSuccess}: Props) { +export default function PaymobPayment({ user, entity, price, setIsPaymentLoading, currency, duration, duration_unit, onSuccess }: Props) { const [isLoading, setIsLoading] = useState(false); const router = useRouter(); @@ -56,10 +58,11 @@ export default function PaymobPayment({user, price, setIsPaymentLoading, currenc userID: user.id, duration, duration_unit, + entity: entity?.id }, }; - const response = await axios.post<{iframeURL: string}>(`/api/paymob`, paymentIntention); + const response = await axios.post<{ iframeURL: string }>(`/api/paymob`, paymentIntention); router.push(response.data.iframeURL); } catch (error) { diff --git a/src/interfaces/paymob.ts b/src/interfaces/paymob.ts index f75445a4..13ed8693 100644 --- a/src/interfaces/paymob.ts +++ b/src/interfaces/paymob.ts @@ -28,7 +28,7 @@ interface Customer { extras: IntentionExtras; } -type IntentionExtras = {[key: string]: string | number}; +type IntentionExtras = { [key: string]: string | number | undefined }; export interface IntentionResult { payment_keys: PaymentKeysItem[]; diff --git a/src/pages/(status)/PaymentDue.tsx b/src/pages/(status)/PaymentDue.tsx index 82a081dc..5dfc9842 100644 --- a/src/pages/(status)/PaymentDue.tsx +++ b/src/pages/(status)/PaymentDue.tsx @@ -5,8 +5,8 @@ import usePackages from "@/hooks/usePackages"; import useUsers from "@/hooks/useUsers"; import { User } from "@/interfaces/user"; import clsx from "clsx"; -import { capitalize } from "lodash"; -import { useEffect, useState } from "react"; +import { capitalize, sortBy } from "lodash"; +import { useEffect, useMemo, useState } from "react"; import useInvites from "@/hooks/useInvites"; import { BsArrowRepeat } from "react-icons/bs"; import InviteCard from "@/components/Medium/InviteCard"; @@ -16,46 +16,50 @@ import useDiscounts from "@/hooks/useDiscounts"; import PaymobPayment from "@/components/PaymobPayment"; import moment from "moment"; import { EntityWithRoles } from "@/interfaces/entity"; +import { Discount, Package } from "@/interfaces/paypal"; +import { isAdmin } from "@/utils/users"; +import { useAllowedEntities } from "@/hooks/useEntityPermissions"; +import Select from "@/components/Low/Select"; interface Props { - user: User; + user: User + discounts: Discount[] + packages: Package[] entities: EntityWithRoles[] hasExpired?: boolean; reload: () => void; } -export default function PaymentDue({ user, hasExpired = false, reload }: Props) { +export default function PaymentDue({ user, discounts = [], entities = [], packages = [], hasExpired = false, reload }: Props) { const [isLoading, setIsLoading] = useState(false); - const [appliedDiscount, setAppliedDiscount] = useState(0); + const [entity, setEntity] = useState() const router = useRouter(); - const { packages } = usePackages(); - const { discounts } = useDiscounts(); const { users } = useUsers(); - const { groups } = useGroups({}); const { invites, isLoading: isInvitesLoading, reload: reloadInvites } = useInvites({ to: user?.id }); - useEffect(() => { - const userDiscounts = discounts.filter((x) => user.email.endsWith(`@${x.domain}`)); - if (userDiscounts.length === 0) return; - - const biggestDiscount = [...userDiscounts].sort((a, b) => b.percentage - a.percentage).shift(); - if (!biggestDiscount || (biggestDiscount.validUntil && moment(biggestDiscount.validUntil).isBefore(moment()))) return; - - setAppliedDiscount(biggestDiscount.percentage); - }, [discounts, user]); - - const isIndividual = () => { - if (user?.type === "developer") return true; + const isIndividual = useMemo(() => { + if (isAdmin(user)) return false; if (user?.type !== "student") return false; - const userGroups = groups.filter((g) => g.participants.includes(user?.id)); - if (userGroups.length === 0) return true; + return user.entities.length === 0 + }, [user]) - const userGroupsAdminTypes = userGroups.map((g) => users?.find((u) => u.id === g.admin)?.type).filter((t) => !!t); - return userGroupsAdminTypes.every((t) => t !== "corporate"); - }; + const appliedDiscount = useMemo(() => { + const biggestDiscount = [...discounts].sort((a, b) => b.percentage - a.percentage).shift(); + + if (!biggestDiscount || (biggestDiscount.validUntil && moment(biggestDiscount.validUntil).isBefore(moment()))) + return 0; + + return biggestDiscount.percentage + }, [discounts]) + + const entitiesThatCanBePaid = useAllowedEntities(user, entities, 'pay_entity') + + useEffect(() => { + if (entitiesThatCanBePaid.length > 0) setEntity(entitiesThatCanBePaid[0]) + }, [entitiesThatCanBePaid]) return ( <> @@ -74,169 +78,185 @@ export default function PaymentDue({ user, hasExpired = false, reload }: Props)
)} - {user ? ( - - {invites.length > 0 && ( -
-
-
- Invites - -
+ + {invites.length > 0 && ( +
+
+
+ Invites +
- - {invites.map((invite) => ( - { - reloadInvites(); - router.reload(); - }} - /> - ))} - -
- )} +
+ + {invites.map((invite) => ( + { + reloadInvites(); + router.reload(); + }} + /> + ))} + +
+ )} -
- {hasExpired && You do not have time credits for your account type!} - {isIndividual() && ( -
- - To add to your use of EnCoach, please purchase one of the time packages available below: - -
- {packages.map((p) => ( -
-
- EnCoach's Logo - - EnCoach - {p.duration}{" "} - {capitalize( - p.duration === 1 ? p.duration_unit.slice(0, p.duration_unit.length - 1) : p.duration_unit, - )} - -
-
- {appliedDiscount === 0 && ( - - {p.price} {p.currency} - - )} - {appliedDiscount > 0 && ( -
- - {p.price} {p.currency} - - - {(p.price - p.price * (appliedDiscount / 100)).toFixed(2)} {p.currency} - -
- )} - { - setTimeout(reload, 500); - }} - currency={p.currency} - duration={p.duration} - duration_unit={p.duration_unit} - price={+(p.price - p.price * (appliedDiscount / 100)).toFixed(2)} - /> -
-
- This includes: -
    -
  • - Train your abilities for the IELTS exam
  • -
  • - Gain insights into your weaknesses and strengths
  • -
  • - Allow yourself to correctly prepare for the exam
  • -
-
-
- ))} -
-
- )} - {!isIndividual() && - (user?.type === "corporate" || user?.type === "mastercorporate") && - user?.corporateInformation.payment && ( -
- - To add to your use of EnCoach and that of your students and teachers, please pay your designated package - below: - -
+
+ {hasExpired && You do not have time credits for your account type!} + {isIndividual && ( +
+ + To add to your use of EnCoach, please purchase one of the time packages available below: + +
+ {packages.map((p) => ( +
EnCoach's Logo - EnCoach - {12} Months + EnCoach - {p.duration}{" "} + {capitalize( + p.duration === 1 ? p.duration_unit.slice(0, p.duration_unit.length - 1) : p.duration_unit, + )}
- - {user.corporateInformation.payment.value} {user.corporateInformation.payment.currency} - + {appliedDiscount === 0 && ( + + {p.price} {p.currency} + + )} + {appliedDiscount > 0 && ( +
+ + {p.price} {p.currency} + + + {(p.price - p.price * (appliedDiscount / 100)).toFixed(2)} {p.currency} + +
+ )} { - setIsLoading(false); setTimeout(reload, 500); }} + currency={p.currency} + duration={p.duration} + duration_unit={p.duration_unit} + price={+(p.price - p.price * (appliedDiscount / 100)).toFixed(2)} />
This includes:
    -
  • - - Allow a total of 0 students and teachers to use EnCoach -
  • -
  • - Train their abilities for the IELTS exam
  • -
  • - Gain insights into your students' weaknesses and strengths
  • -
  • - Allow them to correctly prepare for the exam
  • +
  • - Train your abilities for the IELTS exam
  • +
  • - Gain insights into your weaknesses and strengths
  • +
  • - Allow yourself to correctly prepare for the exam
+ ))} +
+
+ )} + + {!isIndividual && entitiesThatCanBePaid.length > 0 && + entity?.payment && ( +
+
+ + ({ value: e.id, label: e.label, entity: e }))} + onChange={(e) => e?.value ? setEntity(e?.entity) : null} + className="!w-full max-w-[400px] self-center" + /> +
+ + An admin nor your agent have yet set the price intended to your requirements in terms of the amount of users + you desire and your expected monthly duration. - If you believe this to be a mistake, please contact the platform's administration, thank you for your - patience. + Please try again later or contact your agent or an admin, thank you for your patience.
)} - {!isIndividual() && - (user?.type === "corporate" || user?.type === "mastercorporate") && - !user.corporateInformation.payment && ( -
- - An admin nor your agent have yet set the price intended to your requirements in terms of the amount of users - you desire and your expected monthly duration. - - - Please try again later or contact your agent or an admin, thank you for your patience. - -
- )} -
- - ) : ( -
- )} +
+ ); } diff --git a/src/pages/api/entities/[id]/index.ts b/src/pages/api/entities/[id]/index.ts index 2f438f20..0323ac05 100644 --- a/src/pages/api/entities/[id]/index.ts +++ b/src/pages/api/entities/[id]/index.ts @@ -68,6 +68,11 @@ async function patch(req: NextApiRequest, res: NextApiResponse) { return res.status(200).json({ ok: entity.acknowledged }); } + if (req.body.payment) { + const entity = await db.collection("entities").updateOne({ id }, { $set: { payment: req.body.payment } }); + return res.status(200).json({ ok: entity.acknowledged }); + } + if (req.body.expiryDate !== undefined) { const entity = await getEntity(id) const result = await db.collection("entities").updateOne({ id }, { $set: { expiryDate: req.body.expiryDate } }); diff --git a/src/pages/api/paymob/webhook.ts b/src/pages/api/paymob/webhook.ts index 7b428c6c..0fd4b416 100644 --- a/src/pages/api/paymob/webhook.ts +++ b/src/pages/api/paymob/webhook.ts @@ -1,15 +1,19 @@ // 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 {Group, User} from "@/interfaces/user"; -import {DurationUnit, Package, Payment} from "@/interfaces/paypal"; -import {v4} from "uuid"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { withIronSessionApiRoute } from "iron-session/next"; +import { sessionOptions } from "@/lib/session"; +import { Group, User } from "@/interfaces/user"; +import { DurationUnit, Package, Payment } from "@/interfaces/paypal"; +import { v4 } from "uuid"; import ShortUniqueId from "short-unique-id"; import axios from "axios"; -import {IntentionResult, PaymentIntention, TransactionResult} from "@/interfaces/paymob"; +import { IntentionResult, PaymentIntention, TransactionResult } from "@/interfaces/paymob"; import moment from "moment"; import client from "@/lib/mongodb"; +import { getEntity } from "@/utils/entities.be"; +import { Entity } from "@/interfaces/entity"; +import { getEntityUsers } from "@/utils/users.be"; +import { mapBy } from "@/utils"; const db = client.db(process.env.MONGODB_DB); @@ -22,21 +26,22 @@ async function post(req: NextApiRequest, res: NextApiResponse) { const authToken = await authenticatePaymob(); console.log("WEBHOOK: ", transactionResult); - if (!checkTransaction(authToken, transactionResult.transaction.order.id)) return res.status(404).json({ok: false}); - if (!transactionResult.transaction.success) return res.status(400).json({ok: false}); + if (!checkTransaction(authToken, transactionResult.transaction.order.id)) return res.status(404).json({ ok: false }); + if (!transactionResult.transaction.success) return res.status(400).json({ ok: false }); - const {userID, duration, duration_unit} = transactionResult.intention.extras.creation_extras as { + const { userID, duration, duration_unit, entity: entityID } = transactionResult.intention.extras.creation_extras as { userID: string; duration: number; duration_unit: DurationUnit; + entity: string }; const user = await db.collection("users").findOne({ id: userID as string }); - if (!user || !duration || !duration_unit) return res.status(404).json({ok: false}); + if (!user || !duration || !duration_unit) return res.status(404).json({ ok: false }); const subscriptionExpirationDate = user.subscriptionExpirationDate; - if (!subscriptionExpirationDate) return res.status(200).json({ok: false}); + if (!subscriptionExpirationDate) return res.status(200).json({ ok: false }); const initialDate = moment(subscriptionExpirationDate).isAfter(moment()) ? moment(subscriptionExpirationDate) : moment(); @@ -44,8 +49,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) { await db.collection("users").updateOne( { id: userID as string }, - { $set: {subscriptionExpirationDate: updatedSubscriptionExpirationDate, status: "active"} } - ); + { $set: { subscriptionExpirationDate: updatedSubscriptionExpirationDate, status: "active" } } + ); await db.collection("paypalpayments").insertOne({ id: v4(), @@ -60,22 +65,19 @@ async function post(req: NextApiRequest, res: NextApiResponse) { value: transactionResult.transaction.amount_cents / 1000, }); - if (user.type === "corporate") { - const groups = await db.collection("groups").find({ admin: user.id }).toArray(); + if (entityID) { + const entity = await getEntity(entityID) + await db.collection("entities").updateOne({ id: entityID }, { $set: { expiryDate: req.body.expiryDate } }); - const participants = (await Promise.all( - groups.flatMap((x) => x.participants).map(async (x) => ({...(await db.collection("users").findOne({ id: x}))})), - )) as User[]; - const sameExpiryDateParticipants = participants.filter( - (x) => x.subscriptionExpirationDate === subscriptionExpirationDate && x.status !== "disabled", - ); + const users = await getEntityUsers(entityID, 0, { + subscriptionExpirationDate: entity?.expiryDate, + $and: [ + { type: { $ne: "admin" } }, + { type: { $ne: "developer" } }, + ] + }) - for (const participant of sameExpiryDateParticipants) { - await db.collection("users").updateOne( - { id: participant.id }, - { $set: {subscriptionExpirationDate: updatedSubscriptionExpirationDate, status: "active"} } - ); - } + await db.collection("users").updateMany({ id: { $in: mapBy(users, 'id') } }, { $set: { subscriptionExpirationDate: req.body.expiryDate } }) } res.status(200).json({ @@ -84,19 +86,19 @@ async function post(req: NextApiRequest, res: NextApiResponse) { } const authenticatePaymob = async () => { - const response = await axios.post<{token: string}>( + const response = await axios.post<{ token: string }>( "https://oman.paymob.com/api/auth/tokens", { api_key: process.env.PAYMOB_API_KEY, }, - {headers: {Authorization: `Bearer ${process.env.PAYMOB_SECRET_KEY}`}}, + { headers: { Authorization: `Bearer ${process.env.PAYMOB_SECRET_KEY}` } }, ); return response.data.token; }; const checkTransaction = async (token: string, orderID: number) => { - const response = await axios.post("https://oman.paymob.com/api/ecommerce/orders/transaction_inquiry", {auth_token: token, order_id: orderID}); + const response = await axios.post("https://oman.paymob.com/api/ecommerce/orders/transaction_inquiry", { auth_token: token, order_id: orderID }); return response.status === 200; }; diff --git a/src/pages/entities/[id]/index.tsx b/src/pages/entities/[id]/index.tsx index 324c2227..34b35978 100644 --- a/src/pages/entities/[id]/index.tsx +++ b/src/pages/entities/[id]/index.tsx @@ -2,6 +2,7 @@ import CardList from "@/components/High/CardList"; import Layout from "@/components/High/Layout"; import Select from "@/components/Low/Select"; +import Input from "@/components/Low/Input"; import Checkbox from "@/components/Low/Checkbox"; import Tooltip from "@/components/Low/Tooltip"; import { useEntityPermission } from "@/hooks/useEntityPermissions"; @@ -29,6 +30,7 @@ import { useRouter } from "next/router"; import { Divider } from "primereact/divider"; import { useEffect, useMemo, useState } from "react"; import ReactDatePicker from "react-datepicker"; +import { CURRENCIES } from "@/resources/paypal"; import { BsCheck, @@ -56,6 +58,11 @@ const expirationDateColor = (date: Date) => { if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light"; }; +const CURRENCIES_OPTIONS = CURRENCIES.map(({ label, currency }) => ({ + value: currency, + label, +})); + export const getServerSideProps = withIronSessionSsr(async ({ req, params }) => { const user = req.session.user as User; @@ -102,6 +109,8 @@ export default function Home({ user, entity, users, linkedUsers }: Props) { const [isLoading, setIsLoading] = useState(false); const [selectedUsers, setSelectedUsers] = useState([]); const [expiryDate, setExpiryDate] = useState(entity?.expiryDate) + const [paymentPrice, setPaymentPrice] = useState(entity?.payment?.price) + const [paymentCurrency, setPaymentCurrency] = useState(entity?.payment?.currency) const router = useRouter(); @@ -198,6 +207,23 @@ export default function Home({ user, entity, users, linkedUsers }: Props) { .finally(() => setIsLoading(false)); }; + const updatePayment = () => { + if (!isAdmin(user)) return; + + setIsLoading(true); + axios + .patch(`/api/entities/${entity.id}`, { payment: { price: paymentPrice, currency: paymentCurrency } }) + .then(() => { + toast.success("The entity has been updated successfully!"); + router.replace(router.asPath); + }) + .catch((e) => { + console.error(e); + toast.error("Something went wrong!"); + }) + .finally(() => setIsLoading(false)); + }; + const editLicenses = () => { if (!isAdmin(user)) return; @@ -430,6 +456,36 @@ export default function Home({ user, entity, users, linkedUsers }: Props) { Apply Change
+ + + +
+
+ setPaymentPrice(e ? parseInt(e) : undefined)} + type="number" + defaultValue={entity.payment?.price || 0} + thin + /> +