Question Option App Service
This commit is contained in:
parent
fa13d879ae
commit
d1a254495e
12 changed files with 102 additions and 592 deletions
|
|
@ -8,5 +8,6 @@ public class QuestionOptionDto : FullAuditedEntityDto<Guid>
|
|||
public string Text { get; set; }
|
||||
public bool IsCorrect { get; set; }
|
||||
|
||||
public Guid QuestionPoolId { get; set; }
|
||||
public Guid QuestionId { get; set; }
|
||||
}
|
||||
|
|
@ -379,7 +379,6 @@ public class ListFormSelectAppService : PlatformAppService, IListFormSelectAppSe
|
|||
var lookup = JsonSerializer.Deserialize<LookupDto>(field.LookupJson);
|
||||
if (!string.IsNullOrWhiteSpace(lookup?.LookupQuery))
|
||||
{
|
||||
var lookupQuery = defaultValueHelper.GetDefaultValue(lookup.LookupQuery);
|
||||
var parameters = new Dictionary<string, object>();
|
||||
if (input.Filters != null)
|
||||
{
|
||||
|
|
@ -388,6 +387,7 @@ public class ListFormSelectAppService : PlatformAppService, IListFormSelectAppSe
|
|||
parameters.Add("@param" + i, input.Filters[i]);
|
||||
}
|
||||
}
|
||||
var lookupQuery = defaultValueHelper.GetDefaultValue(lookup.LookupQuery);
|
||||
return await dynamicDataRepository.QueryAsync(lookupQuery, connectionString, parameters);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,34 +73,9 @@ public class QuestionAppService : CrudAppService<
|
|||
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>();
|
||||
var incomingOptions = input.Options ?? [];
|
||||
|
||||
// Silinecekleri bul
|
||||
var toDelete = existingOptions.Where(e => !incomingOptions.Any(i => i.Id == e.Id)).ToList();
|
||||
|
|
@ -118,12 +93,10 @@ public class QuestionAppService : CrudAppService<
|
|||
}
|
||||
else
|
||||
{
|
||||
entity.Options.Add(new QuestionOption(optDto.Id, entity.Id, optDto.Text, optDto.IsCorrect));
|
||||
}
|
||||
entity.Options.Add(new QuestionOption(optDto.Id, optDto.QuestionPoolId, entity.Id, optDto.Text, optDto.IsCorrect));
|
||||
}
|
||||
}
|
||||
|
||||
// 🟢 Kaydet
|
||||
await _questionRepository.UpdateAsync(entity, autoSave: true);
|
||||
|
||||
return ObjectMapper.Map<Question, QuestionDto>(entity);
|
||||
|
|
|
|||
|
|
@ -30900,11 +30900,11 @@ public class ListFormsSeeder : IDataSeedContributor, ITransientDependency
|
|||
new EditingFormItemDto { Order = 2, DataField = "QuestionType", ColSpan = 2, IsRequired = true, EditorType2 = EditorTypes.dxSelectBox },
|
||||
new EditingFormItemDto { Order = 3, DataField = "Points", ColSpan = 2, IsRequired = true, EditorType2 = EditorTypes.dxNumberBox },
|
||||
new EditingFormItemDto { Order = 4, DataField = "Title", ColSpan = 2, IsRequired = true, EditorType2 = EditorTypes.dxTextBox },
|
||||
new EditingFormItemDto { Order = 5, DataField = "Content", ColSpan = 2, EditorType2 = EditorTypes.dxTextArea },
|
||||
new EditingFormItemDto { Order = 6, DataField = "MediaType", ColSpan = 2, EditorType2 = EditorTypes.dxSelectBox },
|
||||
new EditingFormItemDto { Order = 7, DataField = "MediaUrl", ColSpan = 2, EditorType2 = EditorTypes.dxTextBox },
|
||||
new EditingFormItemDto { Order = 8, DataField = "CorrectAnswer", ColSpan = 2, EditorType2 = EditorTypes.dxTextBox },
|
||||
new EditingFormItemDto { Order = 9, DataField = "Difficulty", ColSpan = 2, IsRequired = true, EditorType2 = EditorTypes.dxSelectBox },
|
||||
new EditingFormItemDto { Order = 5, DataField = "Content", ColSpan = 2, IsRequired = true, EditorType2 = EditorTypes.dxTextArea },
|
||||
new EditingFormItemDto { Order = 6, DataField = "Difficulty", ColSpan = 2, IsRequired = true, EditorType2 = EditorTypes.dxSelectBox },
|
||||
new EditingFormItemDto { Order = 7, DataField = "MediaType", ColSpan = 2, EditorType2 = EditorTypes.dxSelectBox },
|
||||
new EditingFormItemDto { Order = 8, DataField = "MediaUrl", ColSpan = 2, EditorType2 = EditorTypes.dxTextBox },
|
||||
new EditingFormItemDto { Order = 9, DataField = "CorrectAnswer", ColSpan = 2, EditorType2 = EditorTypes.dxTextBox, EditorOptions="{\"disabled\": true}" },
|
||||
new EditingFormItemDto { Order = 10, DataField = "TimeLimit", ColSpan = 2, EditorType2 = EditorTypes.dxNumberBox },
|
||||
new EditingFormItemDto { Order = 11, DataField = "Explanation", ColSpan = 2, EditorType2 = EditorTypes.dxTextArea },
|
||||
]
|
||||
|
|
@ -31194,7 +31194,7 @@ public class ListFormsSeeder : IDataSeedContributor, ITransientDependency
|
|||
CultureName = LanguageCodes.En,
|
||||
SourceDbType = DbType.String,
|
||||
FieldName = "MediaUrl",
|
||||
Width = 300,
|
||||
Width = 170,
|
||||
ListOrderNo = 8,
|
||||
Visible = true,
|
||||
IsActive = true,
|
||||
|
|
@ -31225,7 +31225,7 @@ public class ListFormsSeeder : IDataSeedContributor, ITransientDependency
|
|||
CultureName = LanguageCodes.En,
|
||||
SourceDbType = DbType.String,
|
||||
FieldName = "CorrectAnswer",
|
||||
Width = 100,
|
||||
Width = 150,
|
||||
ListOrderNo = 9,
|
||||
Visible = true,
|
||||
IsActive = true,
|
||||
|
|
@ -31327,7 +31327,7 @@ public class ListFormsSeeder : IDataSeedContributor, ITransientDependency
|
|||
RoleId = null,
|
||||
UserId = null,
|
||||
CultureName = LanguageCodes.En,
|
||||
SourceDbType = DbType.Int32,
|
||||
SourceDbType = DbType.String,
|
||||
FieldName = "Explanation",
|
||||
Width = 100,
|
||||
ListOrderNo = 12,
|
||||
|
|
|
|||
|
|
@ -11,19 +11,18 @@ public class QuestionOption : FullAuditedEntity<Guid>, IMultiTenant
|
|||
public string Text { get; set; }
|
||||
public bool IsCorrect { get; set; }
|
||||
|
||||
// Foreign key
|
||||
public Guid QuestionPoolId { get; set; }
|
||||
public Guid QuestionId { get; set; }
|
||||
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)
|
||||
public QuestionOption(Guid id, Guid questionPoolId, Guid questionId, string text, bool isCorrect)
|
||||
{
|
||||
Id = id; // burada atayabilirsin çünkü ctor içinde protected set erişilebilir
|
||||
Id = id;
|
||||
QuestionPoolId = questionPoolId;
|
||||
QuestionId = questionId;
|
||||
Text = text;
|
||||
IsCorrect = isCorrect;
|
||||
|
|
|
|||
|
|
@ -1598,8 +1598,8 @@ public class PlatformDbContext :
|
|||
b.Property(x => x.Title).IsRequired().HasMaxLength(500);
|
||||
b.Property(x => x.Content).HasMaxLength(500);
|
||||
b.Property(x => x.MediaUrl).HasMaxLength(500);
|
||||
b.Property(x => x.MediaType).HasMaxLength(500);
|
||||
b.Property(x => x.CorrectAnswer).HasMaxLength(50);
|
||||
b.Property(x => x.MediaType).HasMaxLength(10);
|
||||
b.Property(x => x.CorrectAnswer).HasMaxLength(500);
|
||||
b.Property(x => x.Difficulty).HasMaxLength(10);
|
||||
b.Property(x => x.TimeLimit).HasDefaultValue(0);
|
||||
b.Property(x => x.Explanation).HasMaxLength(500);
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ using Volo.Abp.EntityFrameworkCore;
|
|||
namespace Kurs.Platform.Migrations
|
||||
{
|
||||
[DbContext(typeof(PlatformDbContext))]
|
||||
[Migration("20251016120353_Initial")]
|
||||
[Migration("20251016203825_Initial")]
|
||||
partial class Initial
|
||||
{
|
||||
/// <inheritdoc />
|
||||
|
|
@ -5113,8 +5113,8 @@ namespace Kurs.Platform.Migrations
|
|||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<string>("CorrectAnswer")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<DateTime>("CreationTime")
|
||||
.HasColumnType("datetime2")
|
||||
|
|
@ -5155,8 +5155,8 @@ namespace Kurs.Platform.Migrations
|
|||
.HasColumnName("LastModifierId");
|
||||
|
||||
b.Property<string>("MediaType")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("nvarchar(10)");
|
||||
|
||||
b.Property<string>("MediaUrl")
|
||||
.HasMaxLength(500)
|
||||
|
|
@ -5239,6 +5239,9 @@ namespace Kurs.Platform.Migrations
|
|||
b.Property<Guid>("QuestionId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid>("QuestionPoolId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid?>("TenantId")
|
||||
.HasColumnType("uniqueidentifier")
|
||||
.HasColumnName("TenantId");
|
||||
|
|
@ -2461,8 +2461,8 @@ namespace Kurs.Platform.Migrations
|
|||
Title = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
|
||||
Content = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||
MediaUrl = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||
MediaType = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||
CorrectAnswer = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
|
||||
MediaType = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: true),
|
||||
CorrectAnswer = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||
Difficulty = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: true),
|
||||
TimeLimit = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
|
||||
Explanation = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||
|
|
@ -3280,6 +3280,7 @@ namespace Kurs.Platform.Migrations
|
|||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
Text = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
|
||||
IsCorrect = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
|
||||
QuestionPoolId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
QuestionId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
TenantId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
CreationTime = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
|
|
@ -5110,8 +5110,8 @@ namespace Kurs.Platform.Migrations
|
|||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<string>("CorrectAnswer")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<DateTime>("CreationTime")
|
||||
.HasColumnType("datetime2")
|
||||
|
|
@ -5152,8 +5152,8 @@ namespace Kurs.Platform.Migrations
|
|||
.HasColumnName("LastModifierId");
|
||||
|
||||
b.Property<string>("MediaType")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("nvarchar(10)");
|
||||
|
||||
b.Property<string>("MediaUrl")
|
||||
.HasMaxLength(500)
|
||||
|
|
@ -5236,6 +5236,9 @@ namespace Kurs.Platform.Migrations
|
|||
b.Property<Guid>("QuestionId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid>("QuestionPoolId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid?>("TenantId")
|
||||
.HasColumnType("uniqueidentifier")
|
||||
.HasColumnName("TenantId");
|
||||
|
|
|
|||
|
|
@ -51,7 +51,6 @@ export interface QuestionDto extends FullAuditedEntityDto {
|
|||
export interface QuestionOptionDto extends FullAuditedEntityDto {
|
||||
text: string;
|
||||
isCorrect: boolean;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface Exam {
|
||||
|
|
|
|||
|
|
@ -54,7 +54,6 @@ function QuestionDialog({
|
|||
id: crypto.randomUUID(),
|
||||
text: '',
|
||||
isCorrect: false,
|
||||
order: question.options?.length || 0,
|
||||
}
|
||||
setQuestion((prev) =>
|
||||
prev ? { ...prev, options: [...(prev.options || []), newOption] } : prev,
|
||||
|
|
@ -79,8 +78,8 @@ function QuestionDialog({
|
|||
|
||||
// 💾 Kaydetme
|
||||
const handleSave = async () => {
|
||||
if (!question.title.trim() || !question.content.trim()) {
|
||||
alert('Please fill in the title and content fields.')
|
||||
if (!question.title.trim()) {
|
||||
alert('Please fill in the title field.')
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -122,23 +121,20 @@ function QuestionDialog({
|
|||
|
||||
const getCorrectAnswer = (q: QuestionDto): string | string[] => {
|
||||
switch (q.questionType) {
|
||||
case 'multiple-choice':
|
||||
return q.options?.find((opt) => opt.isCorrect)?.id || ''
|
||||
case 'multiple-answer':
|
||||
return q.options?.filter((opt) => opt.isCorrect).map((opt) => opt.id!) || []
|
||||
const result = q.options?.filter((opt) => opt.isCorrect).map((opt) => opt.text!) || []
|
||||
return result.join(', ')
|
||||
case 'multiple-choice':
|
||||
case 'true-false':
|
||||
case 'fill-blank':
|
||||
case 'open-ended':
|
||||
case 'calculation':
|
||||
return q.correctAnswer as string
|
||||
return q.options?.find((opt) => opt.isCorrect)?.text || ''
|
||||
case 'matching':
|
||||
return q.options?.map((opt) => opt.id!) || []
|
||||
case 'ordering':
|
||||
return (
|
||||
q.options?.sort((a, b) => (a.order || 0) - (b.order || 0)).map((opt) => opt.id!) || []
|
||||
)
|
||||
return q.options?.map((opt) => opt.text!) || []
|
||||
default:
|
||||
return q.correctAnswer as string
|
||||
return q.options?.find((opt) => opt.isCorrect)?.text || ''
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -268,51 +264,61 @@ function QuestionDialog({
|
|||
)
|
||||
|
||||
default:
|
||||
// 🔹 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>
|
||||
<div className="flex items-center justify-between">
|
||||
{question?.options?.length! === 0 && (
|
||||
<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>Seçenek Ekle</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{question.options?.map((option) => (
|
||||
{question.options?.map((option, index) => (
|
||||
<div
|
||||
key={option.id}
|
||||
className="flex items-center space-x-3 p-2.5 border border-gray-200 rounded-lg"
|
||||
className="flex items-center space-x-2.5 p-2.5 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-8 text-center">
|
||||
{String.fromCharCode(65 + index)}
|
||||
</div>
|
||||
<input
|
||||
type="radio"
|
||||
name="single-correct"
|
||||
value={option.id}
|
||||
checked={question.correctAnswer === option.id}
|
||||
onChange={() => handleChange('correctAnswer', option.id)}
|
||||
name="correct-answer"
|
||||
checked={option.isCorrect}
|
||||
onChange={() =>
|
||||
setQuestion((prev) => {
|
||||
if (!prev) return prev
|
||||
const updated = prev.options?.map((opt, i) => ({
|
||||
...opt,
|
||||
isCorrect: i === index,
|
||||
}))
|
||||
return { ...prev, options: updated }
|
||||
})
|
||||
}
|
||||
className="h-4 w-4 text-blue-600 border-gray-300"
|
||||
/>
|
||||
<span className="text-sm text-gray-800">{option.text}</span>
|
||||
<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"
|
||||
/>
|
||||
<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-1">
|
||||
Bu soru tipinde yalnızca tek doğru cevap seçeneği bulunur.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,475 +0,0 @@
|
|||
import React, { useState } from "react";
|
||||
import { FaPlus, FaSearch, FaFilter, FaEdit, FaTrash } from "react-icons/fa";
|
||||
import { generateMockPools } from "@/mocks/mockPools";
|
||||
import { QuestionPoolDto, QuestionDto } from "@/types/coordinator";
|
||||
import QuestionDialog from "./QuestionDialog";
|
||||
|
||||
export const QuestionPoolManager: React.FC = () => {
|
||||
const [pools, setPools] = useState<QuestionPoolDto[]>(generateMockPools());
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedTag, setSelectedTag] = useState("");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [editingPool, setEditingPool] = useState<QuestionPoolDto | null>(null);
|
||||
const [editingQuestion, setEditingQuestion] = useState<QuestionDto | null>(null);
|
||||
const [showQuestionEditor, setShowQuestionEditor] = useState(false);
|
||||
const [selectedPoolForQuestion, setSelectedPoolForQuestion] =
|
||||
useState<string>("");
|
||||
|
||||
const [newPool, setNewPool] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
tags: [] as string[],
|
||||
});
|
||||
|
||||
const filteredPools = pools.filter((pool) => {
|
||||
const matchesSearch =
|
||||
pool.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
pool.description.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesTag = !selectedTag || pool.tags.includes(selectedTag);
|
||||
return matchesSearch && matchesTag;
|
||||
});
|
||||
|
||||
const allTags = Array.from(new Set(pools.flatMap((pool) => pool.tags)));
|
||||
|
||||
const handleCreatePool = () => {
|
||||
if (!newPool.name.trim()) return;
|
||||
|
||||
const newPoolData: QuestionPoolDto = {
|
||||
id: `pool-${Date.now()}`,
|
||||
name: newPool.name,
|
||||
description: newPool.description,
|
||||
tags: newPool.tags,
|
||||
questions: [],
|
||||
createdBy: "current-user",
|
||||
creationTime: new Date(),
|
||||
};
|
||||
|
||||
setPools((prev) => [...prev, newPoolData]);
|
||||
setNewPool({ name: "", description: "", tags: [] });
|
||||
setIsCreating(false);
|
||||
};
|
||||
|
||||
const handleUpdatePool = () => {
|
||||
if (!editingPool) return;
|
||||
setPools((prev) =>
|
||||
prev.map((pool) => (pool.id === editingPool.id ? editingPool : pool))
|
||||
);
|
||||
setEditingPool(null);
|
||||
};
|
||||
|
||||
const handleCreateQuestion = (poolId: string) => {
|
||||
setSelectedPoolForQuestion(poolId);
|
||||
setEditingQuestion(null);
|
||||
setShowQuestionEditor(true);
|
||||
};
|
||||
|
||||
const handleEditQuestion = (question: QuestionDto, poolId: string) => {
|
||||
setSelectedPoolForQuestion(poolId);
|
||||
setEditingQuestion(question);
|
||||
setShowQuestionEditor(true);
|
||||
};
|
||||
|
||||
const handleSaveQuestion = (
|
||||
questionData: Omit<QuestionDto, "id" | "creationTime" | "lastModificationTime">
|
||||
) => {
|
||||
const pool = pools.find((p) => p.id === selectedPoolForQuestion);
|
||||
if (!pool) return;
|
||||
|
||||
const newQuestion: QuestionDto = {
|
||||
...questionData,
|
||||
id: editingQuestion?.id || `q-${Date.now()}`,
|
||||
creationTime: editingQuestion?.creationTime || new Date(),
|
||||
lastModificationTime: new Date(),
|
||||
};
|
||||
|
||||
const updatedQuestions = editingQuestion
|
||||
? pool.questions.map((q) =>
|
||||
q.id === editingQuestion.id ? newQuestion : q
|
||||
)
|
||||
: [...pool.questions, newQuestion];
|
||||
|
||||
const updatedPool = { ...pool, questions: updatedQuestions };
|
||||
setPools((prev) =>
|
||||
prev.map((p) => (p.id === updatedPool.id ? updatedPool : p))
|
||||
);
|
||||
setShowQuestionEditor(false);
|
||||
};
|
||||
|
||||
const handleDeleteQuestion = (questionId: string, poolId: string) => {
|
||||
const pool = pools.find((p) => p.id === poolId);
|
||||
if (!pool) return;
|
||||
|
||||
if (window.confirm("Are you sure you want to delete this question?")) {
|
||||
const updatedQuestions = pool.questions.filter(
|
||||
(q) => q.id !== questionId
|
||||
);
|
||||
const updatedPool = { ...pool, questions: updatedQuestions };
|
||||
setPools((prev) =>
|
||||
prev.map((p) => (p.id === updatedPool.id ? updatedPool : p))
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900">Soru Havuzları</h2>
|
||||
<p className="text-sm text-gray-600 mt-0.5">
|
||||
Soru havuzlarınızı oluşturun ve yönetin
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setIsCreating(true)}
|
||||
className="flex items-center space-x-2 bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
<FaPlus className="w-3.5 h-3.5" />
|
||||
<span>Yeni Havuz</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div className="relative">
|
||||
<FaSearch className="absolute left-3 top-2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Havuz ara..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-8 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 className="relative">
|
||||
<FaFilter className="absolute left-3 top-2 h-4 w-4 text-gray-400" />
|
||||
<select
|
||||
value={selectedTag}
|
||||
onChange={(e) => setSelectedTag(e.target.value)}
|
||||
className="pl-10 w-full text-sm border border-gray-300 rounded-lg px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500 appearance-none"
|
||||
>
|
||||
<option value="">Tüm etiketler</option>
|
||||
{allTags.map((tag) => (
|
||||
<option key={tag} value={tag}>
|
||||
{tag}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-600 flex items-center">
|
||||
Toplam: {filteredPools.length} havuz
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Pool Modal */}
|
||||
{(isCreating || editingPool) && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-5 w-full max-w-md">
|
||||
<h3 className="text-base font-semibold mb-3">
|
||||
{isCreating ? "Yeni Soru Havuzu" : "Havuzu Düzenle"}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Havuz Adı
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={isCreating ? newPool.name : editingPool?.name || ""}
|
||||
onChange={(e) => {
|
||||
if (isCreating) {
|
||||
setNewPool((prev) => ({ ...prev, name: e.target.value }));
|
||||
} else if (editingPool) {
|
||||
setEditingPool({ ...editingPool, name: 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"
|
||||
placeholder="Havuz adını girin"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Açıklama
|
||||
</label>
|
||||
<textarea
|
||||
value={
|
||||
isCreating
|
||||
? newPool.description
|
||||
: editingPool?.description || ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
if (isCreating) {
|
||||
setNewPool((prev) => ({
|
||||
...prev,
|
||||
description: e.target.value,
|
||||
}));
|
||||
} else if (editingPool) {
|
||||
setEditingPool({
|
||||
...editingPool,
|
||||
description: 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"
|
||||
rows={3}
|
||||
placeholder="Açıklama girin"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Etiketler (virgülle ayırın)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={
|
||||
isCreating
|
||||
? newPool.tags.join(", ")
|
||||
: editingPool?.tags.join(", ") || ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
const tags = e.target.value
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean);
|
||||
if (isCreating) {
|
||||
setNewPool((prev) => ({ ...prev, tags }));
|
||||
} else if (editingPool) {
|
||||
setEditingPool({ ...editingPool, tags });
|
||||
}
|
||||
}}
|
||||
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"
|
||||
placeholder="matematik, geometri, cebir"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2 mt-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isCreating) {
|
||||
setIsCreating(false);
|
||||
setNewPool({ name: "", description: "", tags: [] });
|
||||
} else {
|
||||
setEditingPool(null);
|
||||
}
|
||||
}}
|
||||
className="flex-1 bg-gray-100 hover:bg-gray-200 text-gray-800 px-3 py-1.5 text-sm rounded-lg font-medium transition-colors"
|
||||
>
|
||||
İptal
|
||||
</button>
|
||||
<button
|
||||
onClick={isCreating ? handleCreatePool : handleUpdatePool}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 text-sm rounded-lg font-medium transition-colors"
|
||||
>
|
||||
{isCreating ? "Oluştur" : "Güncelle"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Question Editor Modal */}
|
||||
{showQuestionEditor && (
|
||||
<QuestionDialog
|
||||
question={editingQuestion || undefined}
|
||||
onSave={handleSaveQuestion}
|
||||
onCancel={() => setShowQuestionEditor(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Pool List */}
|
||||
<div className="space-y-4">
|
||||
{filteredPools.map((pool) => (
|
||||
<div
|
||||
key={pool.id}
|
||||
className="bg-white border border-gray-200 rounded-lg"
|
||||
>
|
||||
{/* Pool Header */}
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-1">
|
||||
{pool.name}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-600 mb-1.5">
|
||||
{pool.description}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center text-xs text-gray-500 mb-2">
|
||||
<span>{pool.questions.length} questions</span>
|
||||
<span className="mx-2">•</span>
|
||||
<span>{pool.creationTime.toLocaleDateString("en-US")}</span>
|
||||
</div>
|
||||
|
||||
{pool.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{pool.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 ml-2">
|
||||
<button
|
||||
onClick={() => handleCreateQuestion(pool.id)}
|
||||
className="flex items-center space-x-1.5 bg-blue-600 hover:bg-blue-700 text-white px-2.5 py-1.5 text-xs rounded-lg font-medium transition-colors"
|
||||
>
|
||||
<FaPlus className="w-3 h-3" />
|
||||
<span>Add Question</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingPool(pool)}
|
||||
className="p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
title="Edit Pool"
|
||||
>
|
||||
<FaEdit className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
"Are you sure you want to delete this pool?"
|
||||
)
|
||||
) {
|
||||
setPools((prev) => prev.filter((p) => p.id !== pool.id));
|
||||
}
|
||||
}}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Delete Pool"
|
||||
>
|
||||
<FaTrash className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Questions List */}
|
||||
<div className="p-4">
|
||||
{pool.questions.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-2">
|
||||
Questions ({pool.questions.length})
|
||||
</h4>
|
||||
{pool.questions.map((question) => (
|
||||
<div
|
||||
key={question.id}
|
||||
className="flex items-center justify-between p-3 border border-gray-200 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-1.5 mb-1">
|
||||
<span className="bg-blue-100 text-blue-800 text-xs font-medium px-1.5 py-0.5 rounded">
|
||||
{getQuestionTypeLabel(question.type)}
|
||||
</span>
|
||||
<span className="bg-green-100 text-green-800 text-xs font-medium px-1.5 py-0.5 rounded">
|
||||
{question.points} pts
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium px-1.5 py-0.5 rounded ${
|
||||
question.difficulty === "easy"
|
||||
? "bg-green-100 text-green-800"
|
||||
: question.difficulty === "medium"
|
||||
? "bg-yellow-100 text-yellow-800"
|
||||
: "bg-red-100 text-red-800"
|
||||
}`}
|
||||
>
|
||||
{question.difficulty}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs font-medium text-gray-900 mb-0.5">
|
||||
{question.title}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600 truncate">
|
||||
{question.content}
|
||||
</p>
|
||||
{question.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{question.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="text-xs bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-1 ml-4">
|
||||
<button
|
||||
onClick={() => handleEditQuestion(question, pool.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
title="Edit Question"
|
||||
>
|
||||
<FaEdit className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleDeleteQuestion(question.id, pool.id)
|
||||
}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Delete Question"
|
||||
>
|
||||
<FaTrash className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-6 text-gray-500">
|
||||
<p className="text-sm">No questions in this pool yet</p>
|
||||
<p className="text-xs">Click "Add Question" to get started</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredPools.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-12 h-12 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<FaSearch className="w-6 h-6 text-gray-400" />
|
||||
</div>
|
||||
<h3 className="text-base font-medium text-gray-900 mb-1">
|
||||
No pools found
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
{searchTerm || selectedTag
|
||||
? "Try adjusting your search criteria or create a new pool."
|
||||
: "Create your first question pool to get started."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getQuestionTypeLabel = (type: string): string => {
|
||||
const labels: Record<string, string> = {
|
||||
"multiple-choice": "Multiple Choice",
|
||||
"fill-blank": "Fill Blank",
|
||||
"true-false": "True/False",
|
||||
"open-ended": "Open Ended",
|
||||
"multiple-answer": "Multiple Answer",
|
||||
matching: "Matching",
|
||||
ordering: "Ordering",
|
||||
calculation: "Calculation",
|
||||
};
|
||||
return labels[type] || type;
|
||||
};
|
||||
Loading…
Reference in a new issue