QuestionAppService
This commit is contained in:
parent
7fdf4627d0
commit
fa13d879ae
6 changed files with 300 additions and 461 deletions
|
|
@ -1,9 +1,14 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Kurs.Platform.Entities;
|
||||
using Kurs.Platform.Questions;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Volo.Abp.Application.Dtos;
|
||||
using Volo.Abp.Application.Services;
|
||||
using Volo.Abp.Domain.Entities;
|
||||
using Volo.Abp.Domain.Repositories;
|
||||
using static Kurs.Platform.Data.Seeds.SeedConsts;
|
||||
|
||||
|
|
@ -15,12 +20,112 @@ public class QuestionAppService : CrudAppService<
|
|||
Guid,
|
||||
PagedAndSortedResultRequestDto>
|
||||
{
|
||||
public QuestionAppService(IRepository<Question, Guid> repo) : base(repo)
|
||||
private readonly IRepository<Question, Guid> _questionRepository;
|
||||
private readonly IRepository<QuestionOption, Guid> _questionOptionRepository;
|
||||
|
||||
public QuestionAppService(
|
||||
IRepository<Question, Guid> questionRepository,
|
||||
IRepository<QuestionOption, Guid> questionOptionRepository
|
||||
) : base(questionRepository)
|
||||
{
|
||||
_questionRepository = questionRepository;
|
||||
_questionOptionRepository = questionOptionRepository;
|
||||
|
||||
GetPolicyName = AppCodes.Definitions.Question;
|
||||
GetListPolicyName = AppCodes.Definitions.Question;
|
||||
CreatePolicyName = AppCodes.Definitions.Question + ".Create";
|
||||
UpdatePolicyName = AppCodes.Definitions.Question + ".Update";
|
||||
DeletePolicyName = AppCodes.Definitions.Question + ".Delete";
|
||||
}
|
||||
|
||||
public override async Task<QuestionDto> GetAsync(Guid id)
|
||||
{
|
||||
var queryable = await _questionRepository.GetQueryableAsync();
|
||||
|
||||
var entity = await queryable
|
||||
.Include(q => q.Options)
|
||||
.FirstOrDefaultAsync(q => q.Id == id);
|
||||
|
||||
if (entity == null)
|
||||
throw new EntityNotFoundException(typeof(Question), id);
|
||||
|
||||
return ObjectMapper.Map<Question, QuestionDto>(entity);
|
||||
}
|
||||
|
||||
public override async Task<QuestionDto> UpdateAsync(Guid id, QuestionDto input)
|
||||
{
|
||||
var entity = await (await _questionRepository.GetDbSetAsync())
|
||||
.Include(q => q.Options)
|
||||
.FirstOrDefaultAsync(q => q.Id == id);
|
||||
|
||||
if (entity == null)
|
||||
throw new EntityNotFoundException(typeof(Question), id);
|
||||
|
||||
// 🟦 Ana alanları güncelle
|
||||
entity.Title = input.Title;
|
||||
entity.Content = input.Content;
|
||||
entity.MediaUrl = input.MediaUrl;
|
||||
entity.MediaType = input.MediaType;
|
||||
entity.Points = input.Points;
|
||||
entity.Difficulty = input.Difficulty;
|
||||
entity.TimeLimit = input.TimeLimit;
|
||||
entity.Explanation = input.Explanation;
|
||||
entity.CorrectAnswer = input.CorrectAnswer;
|
||||
entity.QuestionType = input.QuestionType;
|
||||
|
||||
// 🟨 Şık tiplerine göre davranış belirle
|
||||
var multiOptionTypes = new[] { "multiple-choice", "multiple-answer", "true-false" };
|
||||
|
||||
// 🔸 Eğer çoklu şık tipi değilse, sadece 1 option olacak
|
||||
if (!multiOptionTypes.Contains(input.QuestionType))
|
||||
{
|
||||
entity.Options.Clear();
|
||||
|
||||
// Sadece CorrectAnswer bilgisinden tek option oluştur
|
||||
if (!string.IsNullOrWhiteSpace(input.CorrectAnswer))
|
||||
{
|
||||
var option = new QuestionOption(
|
||||
Guid.NewGuid(),
|
||||
entity.Id,
|
||||
input.CorrectAnswer,
|
||||
true // tek seçenek, doğru kabul edilir
|
||||
);
|
||||
|
||||
entity.Options.Add(option);
|
||||
|
||||
entity.CorrectAnswer = option.Id.ToString(); // CorrectAnswer alanına option Id'si yazılır
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 🔹 Çoktan seçmeli türlerde mevcut mantık devam eder
|
||||
var existingOptions = entity.Options.ToList();
|
||||
var incomingOptions = input.Options ?? new List<QuestionOptionDto>();
|
||||
|
||||
// Silinecekleri bul
|
||||
var toDelete = existingOptions.Where(e => !incomingOptions.Any(i => i.Id == e.Id)).ToList();
|
||||
foreach (var del in toDelete)
|
||||
entity.Options.Remove(del);
|
||||
|
||||
// Güncelle / ekle
|
||||
foreach (var optDto in incomingOptions)
|
||||
{
|
||||
var existing = existingOptions.FirstOrDefault(o => o.Id == optDto.Id);
|
||||
if (existing != null)
|
||||
{
|
||||
existing.Text = optDto.Text;
|
||||
existing.IsCorrect = optDto.IsCorrect;
|
||||
}
|
||||
else
|
||||
{
|
||||
entity.Options.Add(new QuestionOption(optDto.Id, entity.Id, optDto.Text, optDto.IsCorrect));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🟢 Kaydet
|
||||
await _questionRepository.UpdateAsync(entity, autoSave: true);
|
||||
|
||||
return ObjectMapper.Map<Question, QuestionDto>(entity);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,5 +9,6 @@ public class QuestionAutoMapperProfile : Profile
|
|||
public QuestionAutoMapperProfile()
|
||||
{
|
||||
CreateMap<Question, QuestionDto>().ReverseMap();
|
||||
CreateMap<QuestionOption, QuestionOptionDto>().ReverseMap();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,4 +16,16 @@ public class QuestionOption : FullAuditedEntity<Guid>, IMultiTenant
|
|||
public Question Question { get; set; }
|
||||
|
||||
Guid? IMultiTenant.TenantId => TenantId;
|
||||
|
||||
// 🟢 EF Core ve ABP için parametresiz constructor ZORUNLU
|
||||
protected QuestionOption() { }
|
||||
|
||||
// 🟢 Yeni kayıt oluşturmak için custom constructor
|
||||
public QuestionOption(Guid id, Guid questionId, string text, bool isCorrect)
|
||||
{
|
||||
Id = id; // burada atayabilirsin çünkü ctor içinde protected set erişilebilir
|
||||
QuestionId = questionId;
|
||||
Text = text;
|
||||
IsCorrect = isCorrect;
|
||||
}
|
||||
}
|
||||
|
|
@ -72,7 +72,6 @@ export class MenuService {
|
|||
|
||||
export const getMenus = async (skipCount = 0, maxResultCount = 1000, sorting = 'order') => {
|
||||
const menuService = new MenuService()
|
||||
const tenant = useStoreState((state) => state.auth.tenant)
|
||||
|
||||
return await menuService.getList(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,12 +1,25 @@
|
|||
import { FullAuditedEntityDto } from "@/proxy";
|
||||
|
||||
export type QuestionType =
|
||||
| "multiple-choice"
|
||||
| "fill-blank"
|
||||
| "multiple-answer"
|
||||
| "matching"
|
||||
| "ordering"
|
||||
| "open-ended"
|
||||
| "true-false"
|
||||
| "calculation";
|
||||
| 'multiple-choice'
|
||||
| 'fill-blank'
|
||||
| 'multiple-answer'
|
||||
| 'matching'
|
||||
| 'ordering'
|
||||
| 'open-ended'
|
||||
| 'true-false'
|
||||
| 'calculation'
|
||||
|
||||
export const QUESTION_TYPE_LABELS: Record<QuestionType, string> = {
|
||||
'multiple-choice': 'Multiple Choice',
|
||||
'fill-blank': 'Fill in the Blank',
|
||||
'multiple-answer': 'Multiple Answer',
|
||||
'matching': 'Matching',
|
||||
'ordering': 'Ordering',
|
||||
'open-ended': 'Open Ended',
|
||||
'true-false': 'True / False',
|
||||
'calculation': 'Calculation',
|
||||
}
|
||||
|
||||
export type ExamType = "exam" | "assignment" | "test";
|
||||
export type TestType = 'pdf' | 'image';
|
||||
|
|
@ -14,19 +27,15 @@ export type MediaType = 'image' | 'video';
|
|||
export type QuestionDifficulty = 'easy' | 'medium' | 'hard';
|
||||
export type ExamSessionStatus = 'in-progress' | 'completed' | 'submitted';
|
||||
|
||||
export interface QuestionPoolDto {
|
||||
id: string;
|
||||
export interface QuestionPoolDto extends FullAuditedEntityDto {
|
||||
name: string;
|
||||
description: string;
|
||||
questions: QuestionDto[];
|
||||
tags: string[];
|
||||
createdBy: string;
|
||||
creationTime: Date;
|
||||
}
|
||||
|
||||
export interface QuestionDto {
|
||||
id: string;
|
||||
type: QuestionType;
|
||||
export interface QuestionDto extends FullAuditedEntityDto {
|
||||
questionType: QuestionType;
|
||||
title: string;
|
||||
content: string;
|
||||
mediaUrl?: string;
|
||||
|
|
@ -36,14 +45,10 @@ export interface QuestionDto {
|
|||
points: number;
|
||||
timeLimit?: number;
|
||||
explanation?: string;
|
||||
tags: string[];
|
||||
difficulty: QuestionDifficulty;
|
||||
creationTime: Date;
|
||||
lastModificationTime: Date;
|
||||
}
|
||||
|
||||
export interface QuestionOptionDto {
|
||||
id: string;
|
||||
export interface QuestionOptionDto extends FullAuditedEntityDto {
|
||||
text: string;
|
||||
isCorrect: boolean;
|
||||
order?: number;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,5 @@
|
|||
import { questionService } from '@/services/question.service'
|
||||
import {
|
||||
QuestionDto,
|
||||
QuestionType,
|
||||
QuestionDifficulty,
|
||||
QuestionOptionDto,
|
||||
} from '@/types/coordinator'
|
||||
import { QUESTION_TYPE_LABELS, QuestionDto, QuestionOptionDto } from '@/types/coordinator'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { FaSave, FaPlus, FaTrash, FaTimes } from 'react-icons/fa'
|
||||
|
||||
|
|
@ -15,150 +10,155 @@ function QuestionDialog({
|
|||
}: {
|
||||
open: boolean
|
||||
onDialogClose: () => void
|
||||
id: string
|
||||
id?: string
|
||||
}) {
|
||||
const [question, setQuestion] = useState<QuestionDto>()
|
||||
const [formData, setFormData] = useState({
|
||||
type: 'multiple-choice' as QuestionType,
|
||||
title: '',
|
||||
content: '',
|
||||
mediaUrl: '',
|
||||
mediaType: 'image' as 'image' | 'video',
|
||||
points: 10,
|
||||
timeLimit: 0,
|
||||
explanation: '',
|
||||
difficulty: 'medium' as QuestionDifficulty,
|
||||
})
|
||||
|
||||
const [options, setOptions] = useState<QuestionOptionDto[]>([])
|
||||
const [correctAnswer, setCorrectAnswer] = useState<string | string[]>('')
|
||||
const [tagInput, setTagInput] = useState('')
|
||||
const [question, setQuestion] = useState<QuestionDto | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchQuestion = async () => {
|
||||
if (open) {
|
||||
if (!open) return
|
||||
if (id) {
|
||||
const entity = await questionService.getQuestion(id)
|
||||
setQuestion(entity)
|
||||
} else {
|
||||
setQuestion({
|
||||
id: '',
|
||||
questionType: 'multiple-choice',
|
||||
points: 10,
|
||||
title: '',
|
||||
content: '',
|
||||
mediaUrl: '',
|
||||
mediaType: 'image',
|
||||
correctAnswer: '',
|
||||
difficulty: 'medium',
|
||||
timeLimit: 0,
|
||||
explanation: '',
|
||||
options: [],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fetchQuestion()
|
||||
}, [open])
|
||||
}, [open, id])
|
||||
|
||||
useEffect(() => {
|
||||
if (question) {
|
||||
setFormData({
|
||||
type: question.type,
|
||||
title: question.title,
|
||||
content: question.content,
|
||||
mediaUrl: question.mediaUrl || '',
|
||||
mediaType: question.mediaType || 'image',
|
||||
points: question.points,
|
||||
timeLimit: question.timeLimit || 0,
|
||||
explanation: question.explanation || '',
|
||||
difficulty: question.difficulty,
|
||||
})
|
||||
setOptions(question.options || [])
|
||||
setCorrectAnswer(question.correctAnswer || '')
|
||||
}
|
||||
}, [question])
|
||||
if (!open || !question) return null
|
||||
|
||||
const handleInputChange = (field: string, value: any) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }))
|
||||
// 🔧 Ortak alan değişimi
|
||||
const handleChange = (field: keyof QuestionDto, value: any) => {
|
||||
setQuestion((prev) => (prev ? { ...prev, [field]: value } : prev))
|
||||
}
|
||||
|
||||
// 🔹 Option işlemleri
|
||||
const addOption = () => {
|
||||
const newOption: QuestionOptionDto = {
|
||||
id: `opt-${Date.now()}`,
|
||||
id: crypto.randomUUID(),
|
||||
text: '',
|
||||
isCorrect: false,
|
||||
order: options.length,
|
||||
order: question.options?.length || 0,
|
||||
}
|
||||
setOptions((prev) => [...prev, newOption])
|
||||
setQuestion((prev) =>
|
||||
prev ? { ...prev, options: [...(prev.options || []), newOption] } : prev,
|
||||
)
|
||||
}
|
||||
|
||||
const updateOption = (index: number, field: string, value: any) => {
|
||||
setOptions((prev) => prev.map((opt, i) => (i === index ? { ...opt, [field]: value } : opt)))
|
||||
const updateOption = (index: number, field: keyof QuestionOptionDto, value: any) => {
|
||||
setQuestion((prev) => {
|
||||
if (!prev) return prev
|
||||
const newOpts = [...(prev.options || [])]
|
||||
newOpts[index] = { ...newOpts[index], [field]: value }
|
||||
return { ...prev, options: newOpts }
|
||||
})
|
||||
}
|
||||
|
||||
const removeOption = (index: number) => {
|
||||
setOptions((prev) => prev.filter((_, i) => i !== index))
|
||||
setQuestion((prev) => {
|
||||
if (!prev) return prev
|
||||
return { ...prev, options: prev.options?.filter((_, i) => i !== index) }
|
||||
})
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (!formData.title.trim() || !formData.content.trim()) {
|
||||
// 💾 Kaydetme
|
||||
const handleSave = async () => {
|
||||
if (!question.title.trim() || !question.content.trim()) {
|
||||
alert('Please fill in the title and content fields.')
|
||||
return
|
||||
}
|
||||
|
||||
if (formData.points <= 0) {
|
||||
if (question.points <= 0) {
|
||||
alert('Points must be greater than 0.')
|
||||
return
|
||||
}
|
||||
|
||||
// Validate based on question type
|
||||
if (['multiple-choice', 'multiple-answer'].includes(formData.type) && options.length < 2) {
|
||||
if (
|
||||
['multiple-choice', 'multiple-answer'].includes(question.questionType) &&
|
||||
(question.options?.length || 0) < 2
|
||||
) {
|
||||
alert('Please add at least 2 options.')
|
||||
return
|
||||
}
|
||||
|
||||
if (formData.type === 'multiple-choice' && !options.some((opt) => opt.isCorrect)) {
|
||||
if (
|
||||
question.questionType === 'multiple-choice' &&
|
||||
!question.options?.some((opt) => opt.isCorrect)
|
||||
) {
|
||||
alert('Please mark the correct answer.')
|
||||
return
|
||||
}
|
||||
|
||||
const questionData = {
|
||||
...formData,
|
||||
options: ['multiple-choice', 'multiple-answer', 'matching', 'ordering'].includes(
|
||||
formData.type,
|
||||
)
|
||||
? options
|
||||
: undefined,
|
||||
correctAnswer: getCorrectAnswer(),
|
||||
const dataToSave: QuestionDto = {
|
||||
...question,
|
||||
correctAnswer: getCorrectAnswer(question),
|
||||
}
|
||||
|
||||
// onSave(questionData)
|
||||
try {
|
||||
if (question.id) await questionService.updateQuestion(question.id, dataToSave)
|
||||
else await questionService.createQuestion(dataToSave)
|
||||
onDialogClose()
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
alert('Error while saving question.')
|
||||
}
|
||||
}
|
||||
|
||||
const getCorrectAnswer = (): string | string[] => {
|
||||
switch (formData.type) {
|
||||
const getCorrectAnswer = (q: QuestionDto): string | string[] => {
|
||||
switch (q.questionType) {
|
||||
case 'multiple-choice':
|
||||
return options.find((opt) => opt.isCorrect)?.id || ''
|
||||
return q.options?.find((opt) => opt.isCorrect)?.id || ''
|
||||
case 'multiple-answer':
|
||||
return options.filter((opt) => opt.isCorrect).map((opt) => opt.id)
|
||||
return q.options?.filter((opt) => opt.isCorrect).map((opt) => opt.id!) || []
|
||||
case 'true-false':
|
||||
return correctAnswer as string
|
||||
case 'fill-blank':
|
||||
case 'open-ended':
|
||||
case 'calculation':
|
||||
return correctAnswer as string
|
||||
return q.correctAnswer as string
|
||||
case 'matching':
|
||||
return options.map((opt) => opt.id)
|
||||
return q.options?.map((opt) => opt.id!) || []
|
||||
case 'ordering':
|
||||
return options.sort((a, b) => (a.order || 0) - (b.order || 0)).map((opt) => opt.id)
|
||||
return (
|
||||
q.options?.sort((a, b) => (a.order || 0) - (b.order || 0)).map((opt) => opt.id!) || []
|
||||
)
|
||||
default:
|
||||
return correctAnswer as string
|
||||
return q.correctAnswer as string
|
||||
}
|
||||
}
|
||||
|
||||
const renderQuestionTypeSpecificFields = () => {
|
||||
switch (formData.type) {
|
||||
switch (question.questionType) {
|
||||
case 'multiple-choice':
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-gray-900">Yanıtlar (A, B, C, D, E)</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addOption}
|
||||
className="flex items-center space-x-1 text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
<FaPlus className="w-3.5 h-3.5" />
|
||||
<span>Şık Ekle</span>
|
||||
<span>Seçenek Ekle</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{options.map((option, index) => (
|
||||
{question.options?.map((option, index) => (
|
||||
<div
|
||||
key={option.id}
|
||||
className="flex items-center space-x-2.5 p-2.5 border border-gray-200 rounded-lg"
|
||||
|
|
@ -170,23 +170,24 @@ function QuestionDialog({
|
|||
type="radio"
|
||||
name="correct-answer"
|
||||
checked={option.isCorrect}
|
||||
onChange={(e) => {
|
||||
// For single choice, uncheck others
|
||||
setOptions((prev) =>
|
||||
prev.map((opt, i) => ({
|
||||
onChange={() =>
|
||||
setQuestion((prev) => {
|
||||
if (!prev) return prev
|
||||
const updated = prev.options?.map((opt, i) => ({
|
||||
...opt,
|
||||
isCorrect: i === index ? e.target.checked : false,
|
||||
})),
|
||||
)
|
||||
}}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
|
||||
isCorrect: i === index,
|
||||
}))
|
||||
return { ...prev, options: updated }
|
||||
})
|
||||
}
|
||||
className="h-4 w-4 text-blue-600 border-gray-300"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={option.text}
|
||||
onChange={(e) => updateOption(index, 'text', e.target.value)}
|
||||
placeholder={`${String.fromCharCode(65 + index)} şıkkı`}
|
||||
className="flex-1 text-sm border border-gray-300 rounded-lg px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="flex-1 text-sm border border-gray-300 rounded-lg px-2 py-1"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -204,9 +205,6 @@ function QuestionDialog({
|
|||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-gray-900">
|
||||
Yanıtlar (Birden fazla doğru seçilebilir)
|
||||
</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addOption}
|
||||
|
|
@ -217,7 +215,7 @@ function QuestionDialog({
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{options.map((option, index) => (
|
||||
{question.options?.map((option, index) => (
|
||||
<div
|
||||
key={option.id}
|
||||
className="flex items-center space-x-2.5 p-2.5 border border-gray-200 rounded-lg"
|
||||
|
|
@ -226,16 +224,15 @@ function QuestionDialog({
|
|||
type="checkbox"
|
||||
checked={option.isCorrect}
|
||||
onChange={(e) => updateOption(index, 'isCorrect', e.target.checked)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
className="h-4 w-4 text-blue-600 border-gray-300"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={option.text}
|
||||
onChange={(e) => updateOption(index, 'text', e.target.value)}
|
||||
placeholder={`Yanıt ${index + 1}`}
|
||||
className="flex-1 text-sm border border-gray-300 rounded-lg px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="flex-1 text-sm border border-gray-300 rounded-lg px-2 py-1"
|
||||
/>
|
||||
<div className="text-sm text-gray-600">{option.isCorrect ? 'Doğru' : 'Yanlış'}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeOption(index)}
|
||||
|
|
@ -253,219 +250,71 @@ function QuestionDialog({
|
|||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Doğru Cevap</label>
|
||||
<div className="flex space-x-4">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="true-false"
|
||||
value="true"
|
||||
checked={correctAnswer === 'true'}
|
||||
onChange={(e) => setCorrectAnswer(e.target.value)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
|
||||
/>
|
||||
<span className="ml-2">Doğru</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="true-false"
|
||||
value="false"
|
||||
checked={correctAnswer === 'false'}
|
||||
onChange={(e) => setCorrectAnswer(e.target.value)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
|
||||
/>
|
||||
<span className="ml-2">Yanlış</span>
|
||||
</label>
|
||||
{['true', 'false'].map((val) => (
|
||||
<label key={val} className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="true-false"
|
||||
value={val}
|
||||
checked={question.correctAnswer === val}
|
||||
onChange={(e) => handleChange('correctAnswer', e.target.value)}
|
||||
className="h-4 w-4 text-blue-600 border-gray-300"
|
||||
/>
|
||||
<span className="ml-2">{val === 'true' ? 'Doğru' : 'Yanlış'}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'fill-blank':
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-md font-medium text-gray-900">
|
||||
Boşluk Cevapları (Maksimum 10 boşluk)
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 10 }, (_, index) => {
|
||||
const blankAnswers = ((correctAnswer as string) || '').split('|')
|
||||
return (
|
||||
<div key={index} className="flex items-center space-x-3">
|
||||
<div className="bg-blue-100 text-blue-800 text-sm font-medium px-3 py-1 rounded min-w-16 text-center">
|
||||
Boşluk {index + 1}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={blankAnswers[index] || ''}
|
||||
onChange={(e) => {
|
||||
const newAnswers = [...blankAnswers]
|
||||
newAnswers[index] = e.target.value
|
||||
// Remove empty answers from the end
|
||||
while (newAnswers.length > 0 && !newAnswers[newAnswers.length - 1]) {
|
||||
newAnswers.pop()
|
||||
}
|
||||
setCorrectAnswer(newAnswers.join('|'))
|
||||
}}
|
||||
placeholder={`${index + 1}. boşluk için kelime/cümle`}
|
||||
className="flex-1 text-sm border border-gray-300 rounded-lg px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Soru içeriğinde _____ veya [blank] kullanarak boşlukları işaretleyin
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'matching':
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-md font-medium text-gray-900">Eşleştirme Çiftleri</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addOption}
|
||||
className="flex items-center space-x-1 text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
<FaPlus className="w-4 h-4" />
|
||||
<span>Çift Ekle</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{options.map((option, index) => (
|
||||
<div
|
||||
key={option.id}
|
||||
className="flex items-center space-x-3 p-3 border border-gray-200 rounded-lg"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={option.text.split('|')[0] || ''}
|
||||
onChange={(e) => {
|
||||
const rightSide = option.text.split('|')[1] || ''
|
||||
updateOption(index, 'text', `${e.target.value}|${rightSide}`)
|
||||
}}
|
||||
placeholder="Sol taraf (örn: PLUS)"
|
||||
className="flex-1 text-sm border border-gray-300 rounded-lg px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-gray-400">↔</span>
|
||||
<input
|
||||
type="text"
|
||||
value={option.text.split('|')[1] || ''}
|
||||
onChange={(e) => {
|
||||
const leftSide = option.text.split('|')[0] || ''
|
||||
updateOption(index, 'text', `${leftSide}|${e.target.value}`)
|
||||
}}
|
||||
placeholder="Sağ taraf (örn: ARTI)"
|
||||
className="flex-1 text-sm border border-gray-300 rounded-lg px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeOption(index)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<FaTrash className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'ordering':
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-md font-medium text-gray-900">Sıralanacak Öğeler</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addOption}
|
||||
className="flex items-center space-x-1 text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
<FaPlus className="w-4 h-4" />
|
||||
<span>Öğe Ekle</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{options.map((option, index) => (
|
||||
<div
|
||||
key={option.id}
|
||||
className="flex items-center space-x-3 p-3 border border-gray-200 rounded-lg"
|
||||
>
|
||||
<div className="bg-blue-100 text-blue-800 text-sm font-medium px-2 py-1 rounded min-w-16 text-center">
|
||||
Sıra
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
value={option.order || index + 1}
|
||||
onChange={(e) => updateOption(index, 'order', parseInt(e.target.value))}
|
||||
min="1"
|
||||
className="w-20 text-sm border border-gray-300 rounded-lg px-2 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={option.text}
|
||||
onChange={(e) => updateOption(index, 'text', e.target.value)}
|
||||
placeholder={`Öğe ${index + 1} (örn: I, TAKE, A SHOWER)`}
|
||||
className="flex-1 text-sm border border-gray-300 rounded-lg px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeOption(index)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<FaTrash className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Öğrenciler bu öğeleri doğru sıraya göre düzenleyecek (drag & drop veya butonlarla)
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'open-ended':
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Örnek Cevap (Opsiyonel)
|
||||
</label>
|
||||
<textarea
|
||||
value={correctAnswer as string}
|
||||
onChange={(e) => setCorrectAnswer(e.target.value)}
|
||||
placeholder="Örnek bir cevap veya anahtar noktalar yazın..."
|
||||
className="w-full text-sm border border-gray-300 rounded-lg px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
rows={4}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Öğrenci bu soruya açıklama yazabilecek</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'calculation':
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Doğru Cevap (Sadece sayısal sonuç)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={correctAnswer as string}
|
||||
onChange={(e) => setCorrectAnswer(e.target.value)}
|
||||
placeholder="Örn: 3, 15.5, 42"
|
||||
className="w-full text-sm border border-gray-300 rounded-lg px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Öğrenci matematiksel hesaplama yapıp sayısal sonuç yazacak
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
// 🔹 Diğer tüm tipler: tek radio + text gösterilir
|
||||
// Eğer hiç option yoksa bir tane oluştur
|
||||
if (!question.options || question.options.length === 0) {
|
||||
setQuestion((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
options: [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
text: prev.correctAnswer || 'Doğru cevap metni',
|
||||
isCorrect: true,
|
||||
order: 0,
|
||||
},
|
||||
],
|
||||
}
|
||||
: prev,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Doğru Cevap (Tek Seçenek)
|
||||
</label>
|
||||
|
||||
{question.options?.map((option) => (
|
||||
<div
|
||||
key={option.id}
|
||||
className="flex items-center space-x-3 p-2.5 border border-gray-200 rounded-lg"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="single-correct"
|
||||
value={option.id}
|
||||
checked={question.correctAnswer === option.id}
|
||||
onChange={() => handleChange('correctAnswer', option.id)}
|
||||
className="h-4 w-4 text-blue-600 border-gray-300"
|
||||
/>
|
||||
<span className="text-sm text-gray-800">{option.text}</span>
|
||||
</div>
|
||||
))}
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Bu soru tipinde yalnızca tek doğru cevap seçeneği bulunur.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -473,156 +322,24 @@ function QuestionDialog({
|
|||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="sticky top-0 bg-white border-b border-gray-200 px-5 py-3 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{question ? 'Edit Question' : 'Create New Question'}
|
||||
</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900">{question.title}</h2>
|
||||
<button onClick={onDialogClose} className="text-gray-400 hover:text-gray-600">
|
||||
<FaTimes className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-4">
|
||||
{/* Basic Information */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
Question Type
|
||||
</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={(e) => handleInputChange('type', e.target.value)}
|
||||
className="w-full text-sm border border-gray-300 rounded-lg px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="multiple-choice">Multiple Choice</option>
|
||||
<option value="fill-blank">Fill in the Blank</option>
|
||||
<option value="multiple-answer">Multiple Answer</option>
|
||||
<option value="matching">Matching</option>
|
||||
<option value="ordering">Ordering</option>
|
||||
<option value="open-ended">Open Ended</option>
|
||||
<option value="true-false">True/False</option>
|
||||
<option value="calculation">Calculation</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">{renderQuestionTypeSpecificFields()}</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Points</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.points}
|
||||
onChange={(e) => handleInputChange('points', parseInt(e.target.value))}
|
||||
min="1"
|
||||
className="w-full text-sm border border-gray-300 rounded-lg px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Question Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => handleInputChange('title', e.target.value)}
|
||||
placeholder="Enter question title..."
|
||||
className="w-full text-sm border border-gray-300 rounded-lg px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Question Content</label>
|
||||
<textarea
|
||||
value={formData.content}
|
||||
onChange={(e) => handleInputChange('content', e.target.value)}
|
||||
placeholder="Enter the question content..."
|
||||
className="w-full text-sm border border-gray-300 rounded-lg px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Media Upload */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
Media URL (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={formData.mediaUrl}
|
||||
onChange={(e) => handleInputChange('mediaUrl', e.target.value)}
|
||||
placeholder="https://example.com/image.jpg"
|
||||
className="w-full text-sm border border-gray-300 rounded-lg px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">Media Type</label>
|
||||
<select
|
||||
value={formData.mediaType}
|
||||
onChange={(e) => handleInputChange('mediaType', e.target.value)}
|
||||
className="w-full text-sm border border-gray-300 rounded-lg px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="image">Image</option>
|
||||
<option value="video">Video</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Question Type Specific Fields */}
|
||||
{renderQuestionTypeSpecificFields()}
|
||||
|
||||
{/* Additional Settings */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Difficulty</label>
|
||||
<select
|
||||
value={formData.difficulty}
|
||||
onChange={(e) => handleInputChange('difficulty', e.target.value)}
|
||||
className="w-full text-sm border border-gray-300 rounded-lg px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="easy">Easy</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="hard">Hard</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Time Limit (minutes, 0 = no limit)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.timeLimit}
|
||||
onChange={(e) => handleInputChange('timeLimit', parseInt(e.target.value))}
|
||||
min="0"
|
||||
className="w-full text-sm border border-gray-300 rounded-lg px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
Explanation (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.explanation}
|
||||
onChange={(e) => handleInputChange('explanation', e.target.value)}
|
||||
placeholder="Provide an explanation for the correct answer..."
|
||||
className="w-full text-sm border border-gray-300 rounded-lg px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="sticky bottom-0 bg-gray-50 border-t border-gray-200 px-5 py-3 flex justify-end space-x-2">
|
||||
<button
|
||||
onClick={onDialogClose}
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 font-medium transition-colors"
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="flex items-center space-x-2 px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors"
|
||||
className="flex items-center space-x-2 px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-lg"
|
||||
>
|
||||
<FaSave className="w-3.5 h-3.5" />
|
||||
<span>Save Question</span>
|
||||
|
|
|
|||
Loading…
Reference in a new issue