admin tests and questions

This commit is contained in:
David Katrinka 2026-01-01 19:43:01 +01:00
parent b7313488a4
commit 69382f2f4c
38 changed files with 1173 additions and 31 deletions

View File

@ -1,3 +1,4 @@
import type { Variant } from "../components/shared/types/QuestionTypes";
import axiosInstance from "./axiosInstance";
type GetQuestionsParams = {
@ -5,14 +6,33 @@ type GetQuestionsParams = {
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) => {
const params: Record<string, any> = { page };
if (id) params.category_id = id;
const { data } = await axiosInstance.get(
"/api/questions",
{ params }
);
const { data } = await axiosInstance.get("/api/questions", { params });
return data;
};
@ -21,3 +41,29 @@ export const getQuestionById = async (id: number) => {
const response = await axiosInstance.get(`/api/questions/${id}`);
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;
};

View File

@ -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) => {
const res = await axiosInstance.post<{ data: UserTestType }>(
"/api/user-tests",
@ -104,3 +112,18 @@ export const deleteUserTest = async (id: number) => {
const res = await axiosInstance.delete(`/api/user-tests/${id}`);
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;
};

View 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 (110)"
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;

View File

@ -11,7 +11,7 @@ import { StartTestWrapper } from "./StartTestForm.styles";
import { useState } from "react";
import { useCategories } from "../../hooks/categories/useCategories";
import type { Category } from "../shared/types/QuestionTypes";
import { useStartTest } from "../../hooks/Tests/useStartTest";
import { useStartTest } from "../../hooks/tests/useStartTest";
import { toast } from "react-toastify";
const StartTestForm = () => {

View File

@ -2,7 +2,7 @@ import { Box, Button, Chip, Typography } from "@mui/material";
import { Link } from "react-router-dom";
import { formatDate } from "../../utils/functions";
import type { UserTestType } from "../shared/types/TestTypes";
import { useGetTestById } from "../../hooks/Tests/useGetTestById";
import { useGetTestById } from "../../hooks/tests/useGetTestById";
interface TestCardProps {
test: UserTestType;

View File

@ -1,7 +1,7 @@
import { Box, Button, Chip, Tooltip, Typography } from "@mui/material";
import CategoryIcon from "@mui/icons-material/Category";
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";
interface TestCardProps {

View File

@ -1,7 +1,7 @@
import { TableRow, TableCell, Button } from "@mui/material";
import { useNavigate } from "react-router-dom";
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";
type Props = {

View 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);
},
});
};

View 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);
},
});
};

View 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,
});
};

View File

@ -4,6 +4,7 @@ import { getQuestionById } from "../../api/questionsApi";
export const useQuestionById = (id?: number) => {
return useQuery({
queryKey: ["single-question", id],
queryFn: () => getQuestionById(Number(id)),
queryFn: () => getQuestionById(Number(id!)),
enabled: !!id,
});
};

View 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);
},
});
};

View 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);
},
});
};

View 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");
},
});
};

View File

@ -2,9 +2,10 @@ import { useQuery } from "@tanstack/react-query";
import { getTestById } from "../../api/testApi";
import type { TestType } from "../../components/shared/types/TestTypes";
export const useGetTestById = (id: number) => {
export const useGetTestById = (id?: number) => {
return useQuery<TestType>({
queryKey: ["tests", id],
queryFn: () => getTestById(id),
queryFn: () => getTestById(id!),
enabled: !!id,
});
};

View 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");
},
});
};

View 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;

View File

@ -19,8 +19,8 @@ import {
} from "@mui/material";
import Container from "../../components/shared/Container";
import { useGetAllUserTests } from "../../hooks/Tests/useGetAllUserTests";
import { useDeleteUserTest } from "../../hooks/Tests/useDeteleUserTest";
import { useGetAllUserTests } from "../../hooks/tests/useGetAllUserTests";
import { useDeleteUserTest } from "../../hooks/tests/useDeteleUserTest";
import UserTestRow from "../../components/UserTestRow/UserTestRow";

View File

@ -12,7 +12,7 @@ import Container from "../../components/shared/Container";
import { WelcomeContainer } from "./IndexPage.styles";
import StartTestForm from "../../components/StartTestForm/StartTestForm";
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 { useAuth } from "../../context/AuthContext";
import { useCategories } from "../../hooks/categories/useCategories";

View File

@ -2,7 +2,7 @@ import { Box, Button, Tab, Tabs, Typography } from "@mui/material";
import { useAuth } from "../../context/AuthContext";
import Container from "../../components/shared/Container";
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 { useState } from "react";

View 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;

View 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;

View File

@ -1,6 +1,6 @@
import { Container } from "@mui/material";
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 LearningAnswers from "../../components/Answers/Answers";

View File

@ -1,5 +1,5 @@
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 {
Box,
@ -22,9 +22,9 @@ import LearningAnswers from "../../components/Answers/Answers";
import NotFoundPage from "../NotFoundPage/NotFoundPage";
import { useAuth } from "../../context/AuthContext";
import { useState } from "react";
import { useGetAllUserTests } from "../../hooks/Tests/useGetAllUserTests";
import { useGetAllUserTests } from "../../hooks/tests/useGetAllUserTests";
import UserTestRow from "../../components/UserTestRow/UserTestRow";
import { useDeleteUserTest } from "../../hooks/Tests/useDeteleUserTest";
import { useDeleteUserTest } from "../../hooks/tests/useDeteleUserTest";
export const SingleTestPage = () => {
const { id } = useParams();

View 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;

View File

@ -1,11 +1,11 @@
import { useParams } from "react-router-dom";
import Container from "../../components/shared/Container";
import { useUserTestById } from "../../hooks/Tests/useUserTestById";
import { useUserTestById } from "../../hooks/tests/useUserTestById";
import { useState } from "react";
import { Box, Button, Pagination, Typography } from "@mui/material";
import TestQuestion from "../../components/TestQuestion/TestQuestion";
import { useSubmitAnswer } from "../../hooks/Tests/useSubmitAnswer";
import { useCompleteTest } from "../../hooks/Tests/useCompleteTest";
import { useSubmitAnswer } from "../../hooks/tests/useSubmitAnswer";
import { useCompleteTest } from "../../hooks/tests/useCompleteTest";
import LearningAnswers from "../../components/Answers/Answers";
import { formatDate } from "../../utils/functions";
import NotFoundPage from "../NotFoundPage/NotFoundPage";

View File

@ -3,7 +3,7 @@ import Pagination from "@mui/material/Pagination";
import CircularProgress from "@mui/material/CircularProgress";
import Alert from "@mui/material/Alert";
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 TestListCard from "../../components/TestListCard/TestListCard";
import {

View File

@ -13,7 +13,6 @@ import SingleQuestionPage from "../pages/SingleQuestionPage/SingleQuestionPage";
import { TestPage } from "../pages/TestPage/TestPage";
import TestsPage from "../pages/TestsPage/TestsPage";
import AdminLayout from "../layouts/AdminLayout/AdminLayout";
import Container from "../components/shared/Container";
import UsersPage from "../pages/UsersPage/UsersPage";
import UserForm from "../pages/UserForm/UserForm";
import CategoriesPage from "../pages/CategoriesPage/CategoriesPage";
@ -22,6 +21,10 @@ import LogsPage from "../pages/LogsPage/LogsPage";
import HitCountsPage from "../pages/HitcountsPage/HitcountsPage";
import UserTestsPage from "../pages/AdminUserTestsPage/AdminUserTestsPage";
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([
{
@ -72,9 +75,7 @@ const router = createBrowserRouter([
</ProtectedRoute>
),
},
{path: "/tests/view/:id",
element: <SingleTestPage/>
},
{ path: "/tests/view/:id", element: <SingleTestPage /> },
{
path: "/tests",
@ -99,8 +100,22 @@ const router = createBrowserRouter([
{ 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: "categories",
@ -111,7 +126,7 @@ const router = createBrowserRouter([
],
},
{ path: "logs", element: <LogsPage /> },
{path: "hitcounts", element: <HitCountsPage/>}
{ path: "hitcounts", element: <HitCountsPage /> },
],
},
]);