admin tests and questions
This commit is contained in:
parent
b7313488a4
commit
69382f2f4c
@ -1,3 +1,4 @@
|
|||||||
|
import type { Variant } from "../components/shared/types/QuestionTypes";
|
||||||
import axiosInstance from "./axiosInstance";
|
import axiosInstance from "./axiosInstance";
|
||||||
|
|
||||||
type GetQuestionsParams = {
|
type GetQuestionsParams = {
|
||||||
@ -5,14 +6,33 @@ type GetQuestionsParams = {
|
|||||||
id?: number;
|
id?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type QuestionPayload = {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
type: "single" | "multiple" | "text";
|
||||||
|
difficulty: number;
|
||||||
|
variants: Variant[];
|
||||||
|
correct_answers: number[] | string[];
|
||||||
|
category_id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OpenaiPayload = {
|
||||||
|
type: "single" | "multiple" | "text";
|
||||||
|
category_id: number;
|
||||||
|
language: string;
|
||||||
|
difficulty: number;
|
||||||
|
promt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OpenaiGenerateResponse = {
|
||||||
|
question: QuestionPayload;
|
||||||
|
};
|
||||||
|
|
||||||
export const getQuestions = async ({ page = 1, id }: GetQuestionsParams) => {
|
export const getQuestions = async ({ page = 1, id }: GetQuestionsParams) => {
|
||||||
const params: Record<string, any> = { page };
|
const params: Record<string, any> = { page };
|
||||||
if (id) params.category_id = id;
|
if (id) params.category_id = id;
|
||||||
|
|
||||||
const { data } = await axiosInstance.get(
|
const { data } = await axiosInstance.get("/api/questions", { params });
|
||||||
"/api/questions",
|
|
||||||
{ params }
|
|
||||||
);
|
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
@ -21,3 +41,29 @@ export const getQuestionById = async (id: number) => {
|
|||||||
const response = await axiosInstance.get(`/api/questions/${id}`);
|
const response = await axiosInstance.get(`/api/questions/${id}`);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const createQuestion = async (payload: QuestionPayload) => {
|
||||||
|
const res = await axiosInstance.post("/api/questions", payload);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateQuestion = async (id: number, payload: QuestionPayload) => {
|
||||||
|
const res = await axiosInstance.put(`/api/questions/${id}`, payload);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteQuestion = async (id: number) => {
|
||||||
|
const res = await axiosInstance.delete(`/api/questions/${id}`);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const openaiGenerateQuestion = async (
|
||||||
|
payload: OpenaiPayload
|
||||||
|
): Promise<QuestionPayload> => {
|
||||||
|
const res = await axiosInstance.post<OpenaiGenerateResponse>(
|
||||||
|
'/api/questions/openai-generate',
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.data.question;
|
||||||
|
};
|
||||||
|
|||||||
@ -31,6 +31,14 @@ export type UserTestsResponse = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TestPayload = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
closed_at: string | null;
|
||||||
|
category_id: number;
|
||||||
|
questions: number[];
|
||||||
|
};
|
||||||
|
|
||||||
export const startTest = async (data: StartTestPayload) => {
|
export const startTest = async (data: StartTestPayload) => {
|
||||||
const res = await axiosInstance.post<{ data: UserTestType }>(
|
const res = await axiosInstance.post<{ data: UserTestType }>(
|
||||||
"/api/user-tests",
|
"/api/user-tests",
|
||||||
@ -104,3 +112,18 @@ export const deleteUserTest = async (id: number) => {
|
|||||||
const res = await axiosInstance.delete(`/api/user-tests/${id}`);
|
const res = await axiosInstance.delete(`/api/user-tests/${id}`);
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const createTest = async (payload: TestPayload) => {
|
||||||
|
const res = await axiosInstance.post("/api/tests", payload);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateTest = async (id: number, payload: TestPayload) => {
|
||||||
|
const res = await axiosInstance.put(`/api/tests/${id}`, payload);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteTest = async (id: number) => {
|
||||||
|
const res = await axiosInstance.delete(`/api/tests/${id}`);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|||||||
134
src/components/GenerateQuestionModal/GenerateQuestionModal.tsx
Normal file
134
src/components/GenerateQuestionModal/GenerateQuestionModal.tsx
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Button,
|
||||||
|
TextField,
|
||||||
|
MenuItem,
|
||||||
|
CircularProgress,
|
||||||
|
} from "@mui/material";
|
||||||
|
|
||||||
|
import { useOpenaiGenerateQuestion } from "../../hooks/questions/useOpenaiGenerateQuestion";
|
||||||
|
import type { QuestionPayload } from "../../api/questionsApi";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onGenerated: (question: QuestionPayload) => void;
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
const GenerateQuestionModal = ({ open, onClose, onGenerated }: Props) => {
|
||||||
|
const generateMutation = useOpenaiGenerateQuestion();
|
||||||
|
|
||||||
|
const [type, setType] = useState<"single" | "multiple" | "text">("single");
|
||||||
|
const [categoryId, setCategoryId] = useState<number | "">("");
|
||||||
|
const [language, setLanguage] = useState<"en" | "sr" | "hu" | "ru">("en");
|
||||||
|
const [difficulty, setDifficulty] = useState(5);
|
||||||
|
const [prompt, setPrompt] = useState("");
|
||||||
|
|
||||||
|
const handleGenerate = () => {
|
||||||
|
if (!categoryId || !prompt.trim()) return;
|
||||||
|
|
||||||
|
generateMutation.mutate(
|
||||||
|
{
|
||||||
|
type,
|
||||||
|
category_id: Number(categoryId),
|
||||||
|
language,
|
||||||
|
difficulty,
|
||||||
|
promt: prompt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: (data) => {
|
||||||
|
onGenerated(data);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>Generate Question (AI)</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent dividers>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Type"
|
||||||
|
value={type}
|
||||||
|
onChange={(e) =>
|
||||||
|
setType(e.target.value as "single" | "multiple" | "text")
|
||||||
|
}
|
||||||
|
fullWidth
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
>
|
||||||
|
<MenuItem value="single">Single</MenuItem>
|
||||||
|
<MenuItem value="multiple">Multiple</MenuItem>
|
||||||
|
<MenuItem value="text">Text</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Category ID"
|
||||||
|
type="number"
|
||||||
|
value={categoryId}
|
||||||
|
onChange={(e) => setCategoryId(Number(e.target.value))}
|
||||||
|
fullWidth
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Language"
|
||||||
|
value={language}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLanguage(e.target.value as "en" | "sr" | "hu" | "ru")
|
||||||
|
}
|
||||||
|
fullWidth
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
>
|
||||||
|
<MenuItem value="en">English</MenuItem>
|
||||||
|
<MenuItem value="sr">Serbian</MenuItem>
|
||||||
|
<MenuItem value="hu">Hungarian</MenuItem>
|
||||||
|
<MenuItem value="ru">Russian</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Difficulty (1–10)"
|
||||||
|
type="number"
|
||||||
|
value={difficulty}
|
||||||
|
onChange={(e) => setDifficulty(Number(e.target.value))}
|
||||||
|
fullWidth
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Prompt"
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={generateMutation.isPending}
|
||||||
|
>
|
||||||
|
{generateMutation.isPending ? (
|
||||||
|
<CircularProgress size={20} />
|
||||||
|
) : (
|
||||||
|
"Generate"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GenerateQuestionModal;
|
||||||
@ -11,7 +11,7 @@ import { StartTestWrapper } from "./StartTestForm.styles";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useCategories } from "../../hooks/categories/useCategories";
|
import { useCategories } from "../../hooks/categories/useCategories";
|
||||||
import type { Category } from "../shared/types/QuestionTypes";
|
import type { Category } from "../shared/types/QuestionTypes";
|
||||||
import { useStartTest } from "../../hooks/Tests/useStartTest";
|
import { useStartTest } from "../../hooks/tests/useStartTest";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
const StartTestForm = () => {
|
const StartTestForm = () => {
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { Box, Button, Chip, Typography } from "@mui/material";
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { formatDate } from "../../utils/functions";
|
import { formatDate } from "../../utils/functions";
|
||||||
import type { UserTestType } from "../shared/types/TestTypes";
|
import type { UserTestType } from "../shared/types/TestTypes";
|
||||||
import { useGetTestById } from "../../hooks/Tests/useGetTestById";
|
import { useGetTestById } from "../../hooks/tests/useGetTestById";
|
||||||
|
|
||||||
interface TestCardProps {
|
interface TestCardProps {
|
||||||
test: UserTestType;
|
test: UserTestType;
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Box, Button, Chip, Tooltip, Typography } from "@mui/material";
|
import { Box, Button, Chip, Tooltip, Typography } from "@mui/material";
|
||||||
import CategoryIcon from "@mui/icons-material/Category";
|
import CategoryIcon from "@mui/icons-material/Category";
|
||||||
import type { TestType } from "../shared/types/TestTypes";
|
import type { TestType } from "../shared/types/TestTypes";
|
||||||
import { useStartTestById } from "../../hooks/Tests/useStartTestById";
|
import { useStartTestById } from "../../hooks/tests/useStartTestById";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
interface TestCardProps {
|
interface TestCardProps {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { TableRow, TableCell, Button } from "@mui/material";
|
import { TableRow, TableCell, Button } from "@mui/material";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import type { UserTestType } from "../../components/shared/types/TestTypes";
|
import type { UserTestType } from "../../components/shared/types/TestTypes";
|
||||||
import { useGetTestById } from "../../hooks/Tests/useGetTestById";
|
import { useGetTestById } from "../../hooks/tests/useGetTestById";
|
||||||
import { formatDate } from "../../utils/functions";
|
import { formatDate } from "../../utils/functions";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|||||||
18
src/hooks/questions/useCreateQuestion.ts
Normal file
18
src/hooks/questions/useCreateQuestion.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { createQuestion, type QuestionPayload } from "../../api/questionsApi";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
|
export const useCreateQuestion = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: QuestionPayload) => createQuestion(payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["questions"] });
|
||||||
|
toast.success("Question Created");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
18
src/hooks/questions/useDeleteQuestion.ts
Normal file
18
src/hooks/questions/useDeleteQuestion.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { deleteQuestion } from "../../api/questionsApi";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
|
export const useDeleteQuestion = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => deleteQuestion(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["questions"] });
|
||||||
|
toast.success("Question Deleted");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
10
src/hooks/questions/useOpenaiGenerateQuestion.ts
Normal file
10
src/hooks/questions/useOpenaiGenerateQuestion.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { openaiGenerateQuestion } from "../../api/questionsApi";
|
||||||
|
import type { OpenaiPayload } from "../../api/questionsApi";
|
||||||
|
import type { QuestionPayload } from "../../api/questionsApi";
|
||||||
|
|
||||||
|
export const useOpenaiGenerateQuestion = () => {
|
||||||
|
return useMutation<QuestionPayload, Error, OpenaiPayload>({
|
||||||
|
mutationFn: openaiGenerateQuestion,
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -4,6 +4,7 @@ import { getQuestionById } from "../../api/questionsApi";
|
|||||||
export const useQuestionById = (id?: number) => {
|
export const useQuestionById = (id?: number) => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["single-question", id],
|
queryKey: ["single-question", id],
|
||||||
queryFn: () => getQuestionById(Number(id)),
|
queryFn: () => getQuestionById(Number(id!)),
|
||||||
|
enabled: !!id,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
19
src/hooks/questions/useUpdateQuestion.ts
Normal file
19
src/hooks/questions/useUpdateQuestion.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { updateQuestion, type QuestionPayload } from "../../api/questionsApi";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
|
export const useUpdateQuestion = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, payload }: { id: number; payload: QuestionPayload }) =>
|
||||||
|
updateQuestion(id, payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["questions"] });
|
||||||
|
toast.success("Question Updated");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
18
src/hooks/tests/useCreateTest.ts
Normal file
18
src/hooks/tests/useCreateTest.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { createTest } from "../../api/testApi";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
|
export const useCreateTest = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: createTest,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tests"] });
|
||||||
|
toast.success("Test Created");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
18
src/hooks/tests/useDeleteTest.ts
Normal file
18
src/hooks/tests/useDeleteTest.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { deleteTest } from "../../api/testApi";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
|
export const useDeleteTest = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => deleteTest(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tests"] });
|
||||||
|
toast.success("Test deleted");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message ?? "Failed to delete test");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -2,9 +2,10 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { getTestById } from "../../api/testApi";
|
import { getTestById } from "../../api/testApi";
|
||||||
import type { TestType } from "../../components/shared/types/TestTypes";
|
import type { TestType } from "../../components/shared/types/TestTypes";
|
||||||
|
|
||||||
export const useGetTestById = (id: number) => {
|
export const useGetTestById = (id?: number) => {
|
||||||
return useQuery<TestType>({
|
return useQuery<TestType>({
|
||||||
queryKey: ["tests", id],
|
queryKey: ["tests", id],
|
||||||
queryFn: () => getTestById(id),
|
queryFn: () => getTestById(id!),
|
||||||
|
enabled: !!id,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
23
src/hooks/tests/useUpdateTest.ts
Normal file
23
src/hooks/tests/useUpdateTest.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { updateTest, type TestPayload } from "../../api/testApi";
|
||||||
|
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
|
export const useUpdateTest = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, payload }: { id: number; payload: TestPayload }) =>
|
||||||
|
updateTest(id, payload),
|
||||||
|
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tests"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tests", variables.id] });
|
||||||
|
toast.success("Test updated");
|
||||||
|
},
|
||||||
|
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.message ?? "Failed to update test");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
159
src/pages/AdminTestsPage/AdminTestsPage.tsx
Normal file
159
src/pages/AdminTestsPage/AdminTestsPage.tsx
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Typography,
|
||||||
|
Pagination,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import Container from "../../components/shared/Container";
|
||||||
|
|
||||||
|
import type { TestType } from "../../components/shared/types/TestTypes";
|
||||||
|
import { useGetTests } from "../../hooks/tests/useGetTests";
|
||||||
|
import { useDeleteTest } from "../../hooks/tests/useDeleteTest";
|
||||||
|
import { formatDate } from "../../utils/functions";
|
||||||
|
|
||||||
|
const AdminTestsPage = () => {
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [deleteTestId, setDeleteTestId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading, isError } = useGetTests({ page: currentPage });
|
||||||
|
const deleteMutation = useDeleteTest();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleViewTest = (id: number) => {
|
||||||
|
navigate(`/tests/view/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateTest = (id: number) => {
|
||||||
|
navigate(`/dashboard/tests/${id}/update`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTest = () => {
|
||||||
|
if (deleteTestId !== null) {
|
||||||
|
deleteMutation.mutate(deleteTestId, {
|
||||||
|
onSuccess: () => setDeleteTestId(null),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) return <CircularProgress />;
|
||||||
|
if (isError)
|
||||||
|
return <Typography color="error">Error loading tests.</Typography>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 3 }}>
|
||||||
|
<Typography variant="h4">Tests</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => navigate("/dashboard/tests/create")}
|
||||||
|
>
|
||||||
|
Create Test
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>ID</TableCell>
|
||||||
|
<TableCell>Title</TableCell>
|
||||||
|
<TableCell>Category</TableCell>
|
||||||
|
<TableCell>Author</TableCell>
|
||||||
|
<TableCell>Available</TableCell>
|
||||||
|
<TableCell>Created At</TableCell>
|
||||||
|
<TableCell>Closed At</TableCell>
|
||||||
|
<TableCell align="right">Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{data?.data.map((test: TestType) => (
|
||||||
|
<TableRow key={test.id}>
|
||||||
|
<TableCell>{test.id}</TableCell>
|
||||||
|
<TableCell>{test.title}</TableCell>
|
||||||
|
<TableCell>{test.category?.name}</TableCell>
|
||||||
|
<TableCell>{test.author?.username}</TableCell>
|
||||||
|
<TableCell>{test.is_available ? "Yes" : "No"}</TableCell>
|
||||||
|
<TableCell>{formatDate(test.created_at)}</TableCell>
|
||||||
|
<TableCell>{formatDate(test.closed_at)}</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => handleViewTest(test.id)}
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => handleUpdateTest(test.id)}
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
onClick={() => setDeleteTestId(test.id)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
color="primary"
|
||||||
|
shape="rounded"
|
||||||
|
count={data?.meta.last_page}
|
||||||
|
page={currentPage}
|
||||||
|
onChange={(_, value) => setCurrentPage(value)}
|
||||||
|
sx={{ mt: 3, display: "flex", justifyContent: "center" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={deleteTestId !== null}
|
||||||
|
onClose={() => setDeleteTestId(null)}
|
||||||
|
>
|
||||||
|
<DialogTitle>Confirm Delete</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
Are you sure you want to delete this test?
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setDeleteTestId(null)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
color="error"
|
||||||
|
onClick={handleDeleteTest}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminTestsPage;
|
||||||
@ -19,8 +19,8 @@ import {
|
|||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
|
||||||
import Container from "../../components/shared/Container";
|
import Container from "../../components/shared/Container";
|
||||||
import { useGetAllUserTests } from "../../hooks/Tests/useGetAllUserTests";
|
import { useGetAllUserTests } from "../../hooks/tests/useGetAllUserTests";
|
||||||
import { useDeleteUserTest } from "../../hooks/Tests/useDeteleUserTest";
|
import { useDeleteUserTest } from "../../hooks/tests/useDeteleUserTest";
|
||||||
|
|
||||||
import UserTestRow from "../../components/UserTestRow/UserTestRow";
|
import UserTestRow from "../../components/UserTestRow/UserTestRow";
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import Container from "../../components/shared/Container";
|
|||||||
import { WelcomeContainer } from "./IndexPage.styles";
|
import { WelcomeContainer } from "./IndexPage.styles";
|
||||||
import StartTestForm from "../../components/StartTestForm/StartTestForm";
|
import StartTestForm from "../../components/StartTestForm/StartTestForm";
|
||||||
import Question from "../../components/Question/Question";
|
import Question from "../../components/Question/Question";
|
||||||
import { useQuestions } from "../../hooks/Question/useQuestions";
|
import { useQuestions } from "../../hooks/questions/useQuestions";
|
||||||
import type { QuestionType } from "../../components/shared/types/QuestionTypes";
|
import type { QuestionType } from "../../components/shared/types/QuestionTypes";
|
||||||
import { useAuth } from "../../context/AuthContext";
|
import { useAuth } from "../../context/AuthContext";
|
||||||
import { useCategories } from "../../hooks/categories/useCategories";
|
import { useCategories } from "../../hooks/categories/useCategories";
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { Box, Button, Tab, Tabs, Typography } from "@mui/material";
|
|||||||
import { useAuth } from "../../context/AuthContext";
|
import { useAuth } from "../../context/AuthContext";
|
||||||
import Container from "../../components/shared/Container";
|
import Container from "../../components/shared/Container";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useGetUserTests } from "../../hooks/Tests/useGetUserTests";
|
import { useGetUserTests } from "../../hooks/tests/useGetUserTests";
|
||||||
import TestCard from "../../components/TestCard/TestCard";
|
import TestCard from "../../components/TestCard/TestCard";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
|
|||||||
285
src/pages/QuestionForm/QuestionForm.tsx
Normal file
285
src/pages/QuestionForm/QuestionForm.tsx
Normal file
@ -0,0 +1,285 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
MenuItem,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
IconButton,
|
||||||
|
FormControlLabel,
|
||||||
|
Checkbox,
|
||||||
|
Paper,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { Add, Remove } from "@mui/icons-material";
|
||||||
|
|
||||||
|
import { useCategories } from "../../hooks/categories/useCategories";
|
||||||
|
import { useCreateQuestion } from "../../hooks/questions/useCreateQuestion";
|
||||||
|
import { useUpdateQuestion } from "../../hooks/questions/useUpdateQuestion";
|
||||||
|
|
||||||
|
import type { QuestionPayload } from "../../api/questionsApi";
|
||||||
|
import type { Variant } from "../../components/shared/types/QuestionTypes";
|
||||||
|
import Container from "../../components/shared/Container";
|
||||||
|
import { useQuestionById } from "../../hooks/questions/useQuestionById";
|
||||||
|
import GenerateQuestionModal from "../../components/GenerateQuestionModal/GenerateQuestionModal";
|
||||||
|
|
||||||
|
const QuestionForm = () => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const isUpdate = Boolean(id);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [type, setType] = useState<"single" | "multiple" | "text">("single");
|
||||||
|
const [difficulty, setDifficulty] = useState(1);
|
||||||
|
const [categoryId, setCategoryId] = useState<number | "">("");
|
||||||
|
const [variants, setVariants] = useState<Variant[]>([{ id: 1, text: "" }]);
|
||||||
|
const [correctAnswers, setCorrectAnswers] = useState<number[]>([]);
|
||||||
|
const [textAnswer, setTextAnswer] = useState("");
|
||||||
|
const [openAiModal, setOpenAiModal] = useState(false);
|
||||||
|
const [generatedQuestion, setGeneratedQuestion] =
|
||||||
|
useState<QuestionPayload | null>(null);
|
||||||
|
|
||||||
|
const { data: categories } = useCategories();
|
||||||
|
const { data: question, isLoading: isLoadingQuestion } = useQuestionById(
|
||||||
|
isUpdate ? Number(id) : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const createMutation = useCreateQuestion();
|
||||||
|
const updateMutation = useUpdateQuestion();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (question || generatedQuestion) {
|
||||||
|
const q = question ?? generatedQuestion;
|
||||||
|
console.log(q);
|
||||||
|
setTitle(q.title);
|
||||||
|
setDescription(q.description ?? "");
|
||||||
|
setType(q.type);
|
||||||
|
setDifficulty(q.difficulty);
|
||||||
|
setCategoryId(q.category_id);
|
||||||
|
if (q.type === "text") {
|
||||||
|
setTextAnswer(q.correct_answers[0] as string);
|
||||||
|
} else {
|
||||||
|
setVariants(q.variants.length ? q.variants : [{ id: 1, text: "" }]);
|
||||||
|
|
||||||
|
setCorrectAnswers(
|
||||||
|
q.correct_answers.map((i: number) =>
|
||||||
|
typeof i === "number" ? i - 1 : 0
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [question, generatedQuestion]);
|
||||||
|
|
||||||
|
if (isUpdate && isLoadingQuestion) return <CircularProgress />;
|
||||||
|
|
||||||
|
const handleVariantChange = (index: number, value: string) => {
|
||||||
|
const newVariants = [...variants];
|
||||||
|
newVariants[index].text = value;
|
||||||
|
setVariants(newVariants);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addVariant = () => {
|
||||||
|
setVariants([...variants, { id: variants.length + 1, text: "" }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeVariant = (index: number) => {
|
||||||
|
const newVariants = variants.filter((_, i) => i !== index);
|
||||||
|
setVariants(newVariants);
|
||||||
|
setCorrectAnswers((prev) => prev.filter((i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCorrectAnswer = (index: number) => {
|
||||||
|
if (type === "single") {
|
||||||
|
setCorrectAnswers([index]);
|
||||||
|
} else {
|
||||||
|
setCorrectAnswers((prev) =>
|
||||||
|
prev.includes(index)
|
||||||
|
? prev.filter((i) => i !== index)
|
||||||
|
: [...prev, index]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validate = () => {
|
||||||
|
if (!title || !categoryId) return false;
|
||||||
|
if (type !== "text") {
|
||||||
|
if (variants.length < 2) return false;
|
||||||
|
if (correctAnswers.length === 0) return false;
|
||||||
|
if (type === "single" && correctAnswers.length !== 1) return false;
|
||||||
|
} else {
|
||||||
|
if (!textAnswer.trim()) return false;
|
||||||
|
}
|
||||||
|
if (difficulty < 1 || difficulty > 10) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!validate()) return;
|
||||||
|
|
||||||
|
const payload: QuestionPayload = {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
type,
|
||||||
|
difficulty,
|
||||||
|
category_id: Number(categoryId),
|
||||||
|
variants: type === "text" ? [] : variants,
|
||||||
|
correct_answers:
|
||||||
|
type === "text" ? [textAnswer] : correctAnswers.map((i) => i + 1),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isUpdate && id) {
|
||||||
|
updateMutation.mutate(
|
||||||
|
{ id: Number(id), payload },
|
||||||
|
{ onSuccess: () => navigate("/dashboard/questions") }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(payload, {
|
||||||
|
onSuccess: () => navigate("/dashboard/questions"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Typography variant="h5" mb={3}>
|
||||||
|
{isUpdate ? "Update Question" : "Create Question"}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<TextField
|
||||||
|
label="Title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Type"
|
||||||
|
value={type}
|
||||||
|
onChange={(e) =>
|
||||||
|
setType(e.target.value as "single" | "multiple" | "text")
|
||||||
|
}
|
||||||
|
fullWidth
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
>
|
||||||
|
<MenuItem value="single">Single Choice</MenuItem>
|
||||||
|
<MenuItem value="multiple">Multiple Choice</MenuItem>
|
||||||
|
<MenuItem value="text">Text Answer</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Difficulty (1-10)"
|
||||||
|
type="number"
|
||||||
|
value={difficulty}
|
||||||
|
onChange={(e) => setDifficulty(Number(e.target.value))}
|
||||||
|
fullWidth
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Category"
|
||||||
|
value={categoryId}
|
||||||
|
onChange={(e) => setCategoryId(Number(e.target.value))}
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
sx={{ mb: 3 }}
|
||||||
|
>
|
||||||
|
{categories?.map((cat) => (
|
||||||
|
<MenuItem key={cat.id} value={cat.id}>
|
||||||
|
{cat.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
{type === "text" ? (
|
||||||
|
<TextField
|
||||||
|
label="Correct Answer"
|
||||||
|
value={textAnswer}
|
||||||
|
onChange={(e) => setTextAnswer(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Typography variant="subtitle1">
|
||||||
|
Variants & Correct Answer
|
||||||
|
</Typography>
|
||||||
|
{variants.map((v, i) => (
|
||||||
|
<Paper
|
||||||
|
key={i}
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
mb: 1,
|
||||||
|
p: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
value={v.text}
|
||||||
|
onChange={(e) => handleVariantChange(i, e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={correctAnswers.includes(i)}
|
||||||
|
onChange={() => toggleCorrectAnswer(i)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Correct"
|
||||||
|
sx={{ ml: 1 }}
|
||||||
|
/>
|
||||||
|
<IconButton onClick={() => removeVariant(i)}>
|
||||||
|
<Remove />
|
||||||
|
</IconButton>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
<Button variant="outlined" startIcon={<Add />} onClick={addVariant}>
|
||||||
|
Add Variant
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button variant="contained" type="submit">
|
||||||
|
{isUpdate ? "Update Question" : "Create Question"}
|
||||||
|
</Button>
|
||||||
|
{!isUpdate && (
|
||||||
|
<Button
|
||||||
|
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ ml: 2 }}
|
||||||
|
onClick={() => setOpenAiModal(true)}
|
||||||
|
>
|
||||||
|
Generate with AI
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
<GenerateQuestionModal
|
||||||
|
open={openAiModal}
|
||||||
|
onClose={() => setOpenAiModal(false)}
|
||||||
|
onGenerated={(q) => setGeneratedQuestion(q)}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QuestionForm;
|
||||||
161
src/pages/QuestionsPage/QuestionsPage.tsx
Normal file
161
src/pages/QuestionsPage/QuestionsPage.tsx
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Typography,
|
||||||
|
Pagination,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useQuestions } from "../../hooks/questions/useQuestions";
|
||||||
|
import { useDeleteQuestion } from "../../hooks/questions/useDeleteQuestion";
|
||||||
|
import Container from "../../components/shared/Container";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import type { QuestionType } from "../../components/shared/types/QuestionTypes";
|
||||||
|
|
||||||
|
const QuestionsPage = () => {
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [deleteQuestionId, setDeleteQuestionId] = useState<number | null>(null);
|
||||||
|
const { data, isLoading, isError } = useQuestions({ page: currentPage });
|
||||||
|
const deleteMutation = useDeleteQuestion();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleCreateQuestion = () => {
|
||||||
|
navigate("/dashboard/questions/create");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateQuestion = (id: number) => {
|
||||||
|
navigate(`/dashboard/questions/${id}/update`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewQuestion = (id: number) => {
|
||||||
|
navigate(`/questions/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteQuestion = () => {
|
||||||
|
if (deleteQuestionId !== null) {
|
||||||
|
deleteMutation.mutate(deleteQuestionId, {
|
||||||
|
onSuccess: () => setDeleteQuestionId(null),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) return <CircularProgress />;
|
||||||
|
if (isError)
|
||||||
|
return <Typography color="error">Error loading questions.</Typography>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 3 }}>
|
||||||
|
<Typography variant="h4">Questions</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={handleCreateQuestion}
|
||||||
|
>
|
||||||
|
Create Question
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>ID</TableCell>
|
||||||
|
<TableCell>Title</TableCell>
|
||||||
|
<TableCell>Type</TableCell>
|
||||||
|
<TableCell>Difficulty</TableCell>
|
||||||
|
<TableCell>Category</TableCell>
|
||||||
|
<TableCell>Author</TableCell>
|
||||||
|
<TableCell>Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{data?.data.map((question: QuestionType) => (
|
||||||
|
<TableRow key={question.id}>
|
||||||
|
<TableCell>{question.id}</TableCell>
|
||||||
|
<TableCell>{question.title}</TableCell>
|
||||||
|
<TableCell>{question.type}</TableCell>
|
||||||
|
<TableCell>{question.difficulty}</TableCell>
|
||||||
|
<TableCell>{question.category?.name ?? "—"}</TableCell>
|
||||||
|
<TableCell>{question.author.username}</TableCell>
|
||||||
|
|
||||||
|
<TableCell sx={{ display: "flex" }}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="info"
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleViewQuestion(question.id)}
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleUpdateQuestion(question.id)}
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
size="small"
|
||||||
|
onClick={() => setDeleteQuestionId(question.id)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
color="primary"
|
||||||
|
shape="rounded"
|
||||||
|
count={data?.meta.last_page}
|
||||||
|
page={currentPage}
|
||||||
|
onChange={(_, value) => setCurrentPage(value)}
|
||||||
|
sx={{ mt: 3, mb: 3, display: "flex", justifyContent: "center" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={deleteQuestionId !== null}
|
||||||
|
onClose={() => setDeleteQuestionId(null)}
|
||||||
|
>
|
||||||
|
<DialogTitle>Confirm Delete</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
Are you sure you want to delete this question?
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setDeleteQuestionId(null)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
color="error"
|
||||||
|
onClick={handleDeleteQuestion}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QuestionsPage;
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { Container } from "@mui/material";
|
import { Container } from "@mui/material";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { useQuestionById } from "../../hooks/Question/useQuestionById";
|
import { useQuestionById } from "../../hooks/questions/useQuestionById";
|
||||||
import Question from "../../components/Question/Question";
|
import Question from "../../components/Question/Question";
|
||||||
import LearningAnswers from "../../components/Answers/Answers";
|
import LearningAnswers from "../../components/Answers/Answers";
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { useGetTestById } from "../../hooks/Tests/useGetTestById";
|
import { useGetTestById } from "../../hooks/tests/useGetTestById";
|
||||||
import Container from "../../components/shared/Container";
|
import Container from "../../components/shared/Container";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@ -22,9 +22,9 @@ import LearningAnswers from "../../components/Answers/Answers";
|
|||||||
import NotFoundPage from "../NotFoundPage/NotFoundPage";
|
import NotFoundPage from "../NotFoundPage/NotFoundPage";
|
||||||
import { useAuth } from "../../context/AuthContext";
|
import { useAuth } from "../../context/AuthContext";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useGetAllUserTests } from "../../hooks/Tests/useGetAllUserTests";
|
import { useGetAllUserTests } from "../../hooks/tests/useGetAllUserTests";
|
||||||
import UserTestRow from "../../components/UserTestRow/UserTestRow";
|
import UserTestRow from "../../components/UserTestRow/UserTestRow";
|
||||||
import { useDeleteUserTest } from "../../hooks/Tests/useDeteleUserTest";
|
import { useDeleteUserTest } from "../../hooks/tests/useDeteleUserTest";
|
||||||
|
|
||||||
export const SingleTestPage = () => {
|
export const SingleTestPage = () => {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
|
|||||||
193
src/pages/TestForm/TestForm.tsx
Normal file
193
src/pages/TestForm/TestForm.tsx
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
FormControlLabel,
|
||||||
|
Checkbox,
|
||||||
|
MenuItem,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
Pagination,
|
||||||
|
} from "@mui/material";
|
||||||
|
|
||||||
|
import { useCategories } from "../../hooks/categories/useCategories";
|
||||||
|
import { useGetTestById } from "../../hooks/tests/useGetTestById";
|
||||||
|
import { useQuestions } from "../../hooks/questions/useQuestions";
|
||||||
|
import { useCreateTest } from "../../hooks/tests/useCreateTest";
|
||||||
|
import { useUpdateTest } from "../../hooks/tests/useUpdateTest";
|
||||||
|
import type { QuestionType } from "../../components/shared/types/QuestionTypes";
|
||||||
|
import Container from "../../components/shared/Container";
|
||||||
|
|
||||||
|
const TestForm = () => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const isUpdate = Boolean(id);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [closedAt, setClosedAt] = useState("");
|
||||||
|
const [categoryId, setCategoryId] = useState<number | "">("");
|
||||||
|
const [selectedQuestions, setSelectedQuestions] = useState<number[]>([]);
|
||||||
|
const [questionsPage, setQuestionsPage] = useState(1);
|
||||||
|
|
||||||
|
const { data: test, isLoading: isLoadingTest } = useGetTestById(
|
||||||
|
isUpdate ? Number(id) : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: categories } = useCategories();
|
||||||
|
const { data: questions, isLoading: isLoadingQuestions } = useQuestions({
|
||||||
|
page: questionsPage,
|
||||||
|
id: categoryId || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useCreateTest();
|
||||||
|
const updateMutation = useUpdateTest();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (test) {
|
||||||
|
setTitle(test.title);
|
||||||
|
setDescription(test.description ?? "");
|
||||||
|
setClosedAt(test.closed_at);
|
||||||
|
setCategoryId(test.category_id);
|
||||||
|
setSelectedQuestions(test.questions?.map((q) => q.id) ?? []);
|
||||||
|
}
|
||||||
|
}, [test]);
|
||||||
|
|
||||||
|
if (isUpdate && isLoadingTest) return <CircularProgress />;
|
||||||
|
|
||||||
|
const toggleQuestion = (id: number) => {
|
||||||
|
setSelectedQuestions((prev) =>
|
||||||
|
prev.includes(id) ? prev.filter((q) => q !== id) : [...prev, id]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
closed_at: closedAt || null,
|
||||||
|
category_id: Number(categoryId),
|
||||||
|
questions: selectedQuestions,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isUpdate && id) {
|
||||||
|
updateMutation.mutate(
|
||||||
|
{ id: Number(id), payload },
|
||||||
|
{ onSuccess: () => navigate("/dashboard/tests") }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(payload, {
|
||||||
|
onSuccess: () => navigate("/dashboard/tests"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Typography variant="h5" mb={3}>
|
||||||
|
{isUpdate ? "Update Test" : "Create Test"}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<TextField
|
||||||
|
label="Title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
type="datetime-local"
|
||||||
|
label="Closed At"
|
||||||
|
value={closedAt}
|
||||||
|
onChange={(e) => setClosedAt(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Category"
|
||||||
|
value={categoryId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCategoryId(Number(e.target.value));
|
||||||
|
setQuestionsPage(1);
|
||||||
|
setSelectedQuestions([]);
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
sx={{ mb: 3 }}
|
||||||
|
>
|
||||||
|
{categories?.map((cat) => (
|
||||||
|
<MenuItem key={cat.id} value={cat.id}>
|
||||||
|
{cat.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
{categoryId && (
|
||||||
|
<>
|
||||||
|
<Typography variant="h6" mb={1}>
|
||||||
|
Questions
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column" }}>
|
||||||
|
{isLoadingQuestions ? (
|
||||||
|
<CircularProgress />
|
||||||
|
) : (
|
||||||
|
questions?.data.map((q: QuestionType) => (
|
||||||
|
<FormControlLabel
|
||||||
|
key={q.id}
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedQuestions.includes(q.id)}
|
||||||
|
onChange={() => toggleQuestion(q.id)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={q.title}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!categoryId && (
|
||||||
|
<Typography variant="h6" mb={1}>
|
||||||
|
Please select category to be able to select questions
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
color="primary"
|
||||||
|
shape="rounded"
|
||||||
|
page={questionsPage}
|
||||||
|
count={questions?.meta.last_page}
|
||||||
|
onChange={(_, page) => setQuestionsPage(page)}
|
||||||
|
sx={{ mt: 3, mb: 3, display: "flex", justifyContent: "start" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button variant="contained" type="submit">
|
||||||
|
{isUpdate ? "Update Test" : "Create Test"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TestForm;
|
||||||
@ -1,11 +1,11 @@
|
|||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import Container from "../../components/shared/Container";
|
import Container from "../../components/shared/Container";
|
||||||
import { useUserTestById } from "../../hooks/Tests/useUserTestById";
|
import { useUserTestById } from "../../hooks/tests/useUserTestById";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Box, Button, Pagination, Typography } from "@mui/material";
|
import { Box, Button, Pagination, Typography } from "@mui/material";
|
||||||
import TestQuestion from "../../components/TestQuestion/TestQuestion";
|
import TestQuestion from "../../components/TestQuestion/TestQuestion";
|
||||||
import { useSubmitAnswer } from "../../hooks/Tests/useSubmitAnswer";
|
import { useSubmitAnswer } from "../../hooks/tests/useSubmitAnswer";
|
||||||
import { useCompleteTest } from "../../hooks/Tests/useCompleteTest";
|
import { useCompleteTest } from "../../hooks/tests/useCompleteTest";
|
||||||
import LearningAnswers from "../../components/Answers/Answers";
|
import LearningAnswers from "../../components/Answers/Answers";
|
||||||
import { formatDate } from "../../utils/functions";
|
import { formatDate } from "../../utils/functions";
|
||||||
import NotFoundPage from "../NotFoundPage/NotFoundPage";
|
import NotFoundPage from "../NotFoundPage/NotFoundPage";
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import Pagination from "@mui/material/Pagination";
|
|||||||
import CircularProgress from "@mui/material/CircularProgress";
|
import CircularProgress from "@mui/material/CircularProgress";
|
||||||
import Alert from "@mui/material/Alert";
|
import Alert from "@mui/material/Alert";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import { useGetTests } from "../../hooks/Tests/useGetTests";
|
import { useGetTests } from "../../hooks/tests/useGetTests";
|
||||||
import Container from "../../components/shared/Container";
|
import Container from "../../components/shared/Container";
|
||||||
import TestListCard from "../../components/TestListCard/TestListCard";
|
import TestListCard from "../../components/TestListCard/TestListCard";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@ -13,7 +13,6 @@ import SingleQuestionPage from "../pages/SingleQuestionPage/SingleQuestionPage";
|
|||||||
import { TestPage } from "../pages/TestPage/TestPage";
|
import { TestPage } from "../pages/TestPage/TestPage";
|
||||||
import TestsPage from "../pages/TestsPage/TestsPage";
|
import TestsPage from "../pages/TestsPage/TestsPage";
|
||||||
import AdminLayout from "../layouts/AdminLayout/AdminLayout";
|
import AdminLayout from "../layouts/AdminLayout/AdminLayout";
|
||||||
import Container from "../components/shared/Container";
|
|
||||||
import UsersPage from "../pages/UsersPage/UsersPage";
|
import UsersPage from "../pages/UsersPage/UsersPage";
|
||||||
import UserForm from "../pages/UserForm/UserForm";
|
import UserForm from "../pages/UserForm/UserForm";
|
||||||
import CategoriesPage from "../pages/CategoriesPage/CategoriesPage";
|
import CategoriesPage from "../pages/CategoriesPage/CategoriesPage";
|
||||||
@ -22,6 +21,10 @@ import LogsPage from "../pages/LogsPage/LogsPage";
|
|||||||
import HitCountsPage from "../pages/HitcountsPage/HitcountsPage";
|
import HitCountsPage from "../pages/HitcountsPage/HitcountsPage";
|
||||||
import UserTestsPage from "../pages/AdminUserTestsPage/AdminUserTestsPage";
|
import UserTestsPage from "../pages/AdminUserTestsPage/AdminUserTestsPage";
|
||||||
import { SingleTestPage } from "../pages/SingleTestPage/SingleTestPage";
|
import { SingleTestPage } from "../pages/SingleTestPage/SingleTestPage";
|
||||||
|
import AdminTestsPage from "../pages/AdminTestsPage/AdminTestsPage";
|
||||||
|
import TestForm from "../pages/TestForm/TestForm";
|
||||||
|
import QuestionsPage from "../pages/QuestionsPage/QuestionsPage";
|
||||||
|
import QuestionForm from "../pages/QuestionForm/QuestionForm";
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
@ -72,9 +75,7 @@ const router = createBrowserRouter([
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{path: "/tests/view/:id",
|
{ path: "/tests/view/:id", element: <SingleTestPage /> },
|
||||||
element: <SingleTestPage/>
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
{
|
||||||
path: "/tests",
|
path: "/tests",
|
||||||
@ -99,8 +100,22 @@ const router = createBrowserRouter([
|
|||||||
{ path: ":id/update", element: <UserForm /> },
|
{ path: ":id/update", element: <UserForm /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ path: "questions", element: <Container>questions</Container> },
|
{
|
||||||
{ path: "tests", element: <Container>tests</Container> },
|
path: "questions",
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <QuestionsPage /> },
|
||||||
|
{ path: "create", element: <QuestionForm /> },
|
||||||
|
{ path: ":id/update", element: <QuestionForm /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "tests",
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <AdminTestsPage /> },
|
||||||
|
{ path: "create", element: <TestForm /> },
|
||||||
|
{ path: ":id/update", element: <TestForm /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
{ path: "user-tests", element: <UserTestsPage /> },
|
{ path: "user-tests", element: <UserTestsPage /> },
|
||||||
{
|
{
|
||||||
path: "categories",
|
path: "categories",
|
||||||
@ -111,7 +126,7 @@ const router = createBrowserRouter([
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ path: "logs", element: <LogsPage /> },
|
{ path: "logs", element: <LogsPage /> },
|
||||||
{path: "hitcounts", element: <HitCountsPage/>}
|
{ path: "hitcounts", element: <HitCountsPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user