From fa13d879ae9e656f0dc521594b24183ae88f1b9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sedat=20=C3=96ZT=C3=9CRK?= <76204082+iamsedatozturk@users.noreply.github.com> Date: Thu, 16 Oct 2025 22:17:40 +0300 Subject: [PATCH] QuestionAppService --- .../Question/QuestionAppService.cs | 107 +++- .../Question/QuestionAutoMapperProfile.cs | 1 + .../Entities/Tenant/QuestionOption.cs | 12 + ui/src/services/menu.service.ts | 1 - ui/src/types/coordinator.ts | 45 +- ui/src/views/coordinator/QuestionDialog.tsx | 595 +++++------------- 6 files changed, 300 insertions(+), 461 deletions(-) diff --git a/api/src/Kurs.Platform.Application/Question/QuestionAppService.cs b/api/src/Kurs.Platform.Application/Question/QuestionAppService.cs index 38391917..243968b2 100644 --- a/api/src/Kurs.Platform.Application/Question/QuestionAppService.cs +++ b/api/src/Kurs.Platform.Application/Question/QuestionAppService.cs @@ -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 repo) : base(repo) + private readonly IRepository _questionRepository; + private readonly IRepository _questionOptionRepository; + + public QuestionAppService( + IRepository questionRepository, + IRepository 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 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(entity); + } + + public override async Task 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(); + + // 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(entity); + } } diff --git a/api/src/Kurs.Platform.Application/Question/QuestionAutoMapperProfile.cs b/api/src/Kurs.Platform.Application/Question/QuestionAutoMapperProfile.cs index 9dc488bb..1c6cc65c 100644 --- a/api/src/Kurs.Platform.Application/Question/QuestionAutoMapperProfile.cs +++ b/api/src/Kurs.Platform.Application/Question/QuestionAutoMapperProfile.cs @@ -9,5 +9,6 @@ public class QuestionAutoMapperProfile : Profile public QuestionAutoMapperProfile() { CreateMap().ReverseMap(); + CreateMap().ReverseMap(); } } diff --git a/api/src/Kurs.Platform.Domain/Entities/Tenant/QuestionOption.cs b/api/src/Kurs.Platform.Domain/Entities/Tenant/QuestionOption.cs index f6487fe5..f33bc2a1 100644 --- a/api/src/Kurs.Platform.Domain/Entities/Tenant/QuestionOption.cs +++ b/api/src/Kurs.Platform.Domain/Entities/Tenant/QuestionOption.cs @@ -16,4 +16,16 @@ public class QuestionOption : FullAuditedEntity, 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; + } } \ No newline at end of file diff --git a/ui/src/services/menu.service.ts b/ui/src/services/menu.service.ts index a10cd150..6984d922 100644 --- a/ui/src/services/menu.service.ts +++ b/ui/src/services/menu.service.ts @@ -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( { diff --git a/ui/src/types/coordinator.ts b/ui/src/types/coordinator.ts index 67b69214..aab5f9b3 100644 --- a/ui/src/types/coordinator.ts +++ b/ui/src/types/coordinator.ts @@ -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 = { + '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; diff --git a/ui/src/views/coordinator/QuestionDialog.tsx b/ui/src/views/coordinator/QuestionDialog.tsx index 29e3cb2f..f62c8f8e 100644 --- a/ui/src/views/coordinator/QuestionDialog.tsx +++ b/ui/src/views/coordinator/QuestionDialog.tsx @@ -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() - 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([]) - const [correctAnswer, setCorrectAnswer] = useState('') - const [tagInput, setTagInput] = useState('') + const [question, setQuestion] = useState(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 (
-

Yanıtlar (A, B, C, D, E)

- {options.map((option, index) => ( + {question.options?.map((option, index) => (
{ - // 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" /> 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" />
- {options.map((option, index) => ( + {question.options?.map((option, index) => (
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" /> 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" /> -
{option.isCorrect ? 'Doğru' : 'Yanlış'}
-
- - {options.map((option, index) => ( -
- { - 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" - /> - - { - 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" - /> - -
- ))} -
- ) - - case 'ordering': - return ( -
-
-

Sıralanacak Öğeler

- -
- - {options.map((option, index) => ( -
-
- Sıra -
- 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" - /> - 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" - /> - -
- ))} - -

- Öğrenciler bu öğeleri doğru sıraya göre düzenleyecek (drag & drop veya butonlarla) -

-
- ) - - case 'open-ended': - return ( -
- -