Question Option App Service

This commit is contained in:
Sedat Öztürk 2025-10-17 00:32:53 +03:00
parent fa13d879ae
commit d1a254495e
12 changed files with 102 additions and 592 deletions

View file

@ -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; }
}

View file

@ -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);
}
}

View file

@ -73,57 +73,30 @@ 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" };
// 🔹 Çoktan seçmeli türlerde mevcut mantık devam eder
var existingOptions = entity.Options.ToList();
var incomingOptions = input.Options ?? [];
// 🔸 Eğer çoklu şık tipi değilse, sadece 1 option olacak
if (!multiOptionTypes.Contains(input.QuestionType))
// 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)
{
entity.Options.Clear();
// Sadece CorrectAnswer bilgisinden tek option oluştur
if (!string.IsNullOrWhiteSpace(input.CorrectAnswer))
var existing = existingOptions.FirstOrDefault(o => o.Id == optDto.Id);
if (existing != null)
{
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
existing.Text = optDto.Text;
existing.IsCorrect = optDto.IsCorrect;
}
}
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)
else
{
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));
}
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);

View file

@ -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 },
]
@ -31083,7 +31083,7 @@ public class ListFormsSeeder : IDataSeedContributor, ITransientDependency
{
IsPivot = true
})
},
},
new() {
ListFormCode = listFormQuestion.ListFormCode,
RoleId = null,
@ -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,

View file

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

View file

@ -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);

View file

@ -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");

View file

@ -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),

View file

@ -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");

View file

@ -51,7 +51,6 @@ export interface QuestionDto extends FullAuditedEntityDto {
export interface QuestionOptionDto extends FullAuditedEntityDto {
text: string;
isCorrect: boolean;
order?: number;
}
export interface Exam {

View file

@ -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>
)
}

View file

@ -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">
ı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;
};