Classroom Exam Bilgileri
This commit is contained in:
parent
a3c524579a
commit
f64f13557e
45 changed files with 6631 additions and 7 deletions
|
|
@ -7879,6 +7879,12 @@
|
||||||
"tr": "Ders Dönemleri",
|
"tr": "Ders Dönemleri",
|
||||||
"en": "Lesson Periods"
|
"en": "Lesson Periods"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.Classroom.Tag",
|
||||||
|
"tr": "Etiketler",
|
||||||
|
"en": "Tags"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"resourceName": "Platform",
|
"resourceName": "Platform",
|
||||||
"key": "App.Definitions.RegistrationType",
|
"key": "App.Definitions.RegistrationType",
|
||||||
|
|
@ -14591,6 +14597,16 @@
|
||||||
"RequiredPermissionName": "App.Definitions.LessonPeriod",
|
"RequiredPermissionName": "App.Definitions.LessonPeriod",
|
||||||
"IsDisabled": false
|
"IsDisabled": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"ParentCode": "App.Coordinator.Definitions",
|
||||||
|
"Code": "App.Classroom.Tag",
|
||||||
|
"DisplayName": "App.Classroom.Tag",
|
||||||
|
"Order": 10,
|
||||||
|
"Url": "/admin/list/list-tag",
|
||||||
|
"Icon": "FcTags",
|
||||||
|
"RequiredPermissionName": "App.Classroom.Tag",
|
||||||
|
"IsDisabled": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"ParentCode": "App.Coordinator",
|
"ParentCode": "App.Coordinator",
|
||||||
"Code": "App.Classroom",
|
"Code": "App.Classroom",
|
||||||
|
|
@ -21922,6 +21938,70 @@
|
||||||
"MultiTenancySide": 2,
|
"MultiTenancySide": 2,
|
||||||
"MenuGroup": "Kurs"
|
"MenuGroup": "Kurs"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"GroupName": "App.Coordinator",
|
||||||
|
"Name": "App.Classroom.Tag",
|
||||||
|
"ParentName": null,
|
||||||
|
"DisplayName": "App.Classroom.Tag",
|
||||||
|
"IsEnabled": true,
|
||||||
|
"MultiTenancySide": 3,
|
||||||
|
"MenuGroup": "Kurs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"GroupName": "App.Coordinator",
|
||||||
|
"Name": "App.Classroom.Tag.Create",
|
||||||
|
"ParentName": "App.Classroom.Tag",
|
||||||
|
"DisplayName": "Create",
|
||||||
|
"IsEnabled": true,
|
||||||
|
"MultiTenancySide": 3,
|
||||||
|
"MenuGroup": "Kurs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"GroupName": "App.Coordinator",
|
||||||
|
"Name": "App.Classroom.Tag.Update",
|
||||||
|
"ParentName": "App.Classroom.Tag",
|
||||||
|
"DisplayName": "Update",
|
||||||
|
"IsEnabled": true,
|
||||||
|
"MultiTenancySide": 3,
|
||||||
|
"MenuGroup": "Kurs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"GroupName": "App.Coordinator",
|
||||||
|
"Name": "App.Classroom.Tag.Delete",
|
||||||
|
"ParentName": "App.Classroom.Tag",
|
||||||
|
"DisplayName": "Delete",
|
||||||
|
"IsEnabled": true,
|
||||||
|
"MultiTenancySide": 3,
|
||||||
|
"MenuGroup": "Kurs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"GroupName": "App.Coordinator",
|
||||||
|
"Name": "App.Classroom.Tag.Export",
|
||||||
|
"ParentName": "App.Classroom.Tag",
|
||||||
|
"DisplayName": "Export",
|
||||||
|
"IsEnabled": true,
|
||||||
|
"MultiTenancySide": 3,
|
||||||
|
"MenuGroup": "Kurs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"GroupName": "App.Coordinator",
|
||||||
|
"Name": "App.Classroom.Tag.Import",
|
||||||
|
"ParentName": "App.Classroom.Tag",
|
||||||
|
"DisplayName": "Import",
|
||||||
|
"IsEnabled": true,
|
||||||
|
"MultiTenancySide": 3,
|
||||||
|
"MenuGroup": "Kurs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"GroupName": "App.Coordinator",
|
||||||
|
"Name": "App.Classroom.Tag.Activity",
|
||||||
|
"ParentName": "App.Classroom.Tag",
|
||||||
|
"DisplayName": "Activity",
|
||||||
|
"IsEnabled": true,
|
||||||
|
"MultiTenancySide": 3,
|
||||||
|
"MenuGroup": "Kurs"
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
"GroupName": "App.SupplyChain",
|
"GroupName": "App.SupplyChain",
|
||||||
"Name": "App.SupplyChain.MaterialTypes",
|
"Name": "App.SupplyChain.MaterialTypes",
|
||||||
|
|
|
||||||
|
|
@ -29929,6 +29929,275 @@ public class ListFormsSeeder : IDataSeedContributor, ITransientDependency
|
||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
//Classroom
|
||||||
|
#region Tag
|
||||||
|
if (!await _listFormRepository.AnyAsync(a => a.ListFormCode == ListFormCodes.Lists.Tag))
|
||||||
|
{
|
||||||
|
var listFormTag = await _listFormRepository.InsertAsync(
|
||||||
|
new ListForm()
|
||||||
|
{
|
||||||
|
ListFormType = ListFormTypeEnum.List,
|
||||||
|
IsSubForm = false,
|
||||||
|
LayoutJson = JsonSerializer.Serialize(new LayoutDto()
|
||||||
|
{
|
||||||
|
Grid = true,
|
||||||
|
Card = true,
|
||||||
|
Pivot = true,
|
||||||
|
Chart = true,
|
||||||
|
DefaultLayout = "grid",
|
||||||
|
CardLayoutColumn = 3
|
||||||
|
}),
|
||||||
|
CultureName = LanguageCodes.En,
|
||||||
|
ListFormCode = ListFormCodes.Lists.Tag,
|
||||||
|
Name = AppCodes.Classroom.Tag,
|
||||||
|
Title = AppCodes.Classroom.Tag,
|
||||||
|
DataSourceCode = SeedConsts.DataSources.DefaultCode,
|
||||||
|
IsTenant = true,
|
||||||
|
IsBranch = false,
|
||||||
|
IsOrganizationUnit = false,
|
||||||
|
Description = AppCodes.Classroom.Tag,
|
||||||
|
SelectCommandType = SelectCommandTypeEnum.Table,
|
||||||
|
SelectCommand = SelectCommandByTableName("Tag", Prefix.DbTableCoordinator),
|
||||||
|
KeyFieldName = "Id",
|
||||||
|
KeyFieldDbSourceType = DbType.Guid,
|
||||||
|
DefaultFilter = "\"IsDeleted\" = 'false'",
|
||||||
|
SortMode = GridOptions.SortModeSingle,
|
||||||
|
FilterRowJson = JsonSerializer.Serialize(new GridFilterRowDto { Visible = true }),
|
||||||
|
HeaderFilterJson = JsonSerializer.Serialize(new { Visible = true }),
|
||||||
|
SearchPanelJson = JsonSerializer.Serialize(new { Visible = true }),
|
||||||
|
GroupPanelJson = JsonSerializer.Serialize(new { Visible = true }),
|
||||||
|
SelectionJson = JsonSerializer.Serialize(new SelectionDto
|
||||||
|
{
|
||||||
|
Mode = GridOptions.SelectionModeSingle,
|
||||||
|
AllowSelectAll = false
|
||||||
|
}),
|
||||||
|
ColumnOptionJson = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
ColumnFixingEnabled = true,
|
||||||
|
ColumnAutoWidth = true,
|
||||||
|
ColumnChooserEnabled = true,
|
||||||
|
AllowColumnResizing = true,
|
||||||
|
AllowColumnReordering = true,
|
||||||
|
ColumnResizingMode = "widget",
|
||||||
|
}),
|
||||||
|
PermissionJson = JsonSerializer.Serialize(new PermissionCrudDto
|
||||||
|
{
|
||||||
|
C = AppCodes.Classroom.Tag + ".Create",
|
||||||
|
R = AppCodes.Classroom.Tag,
|
||||||
|
U = AppCodes.Classroom.Tag + ".Update",
|
||||||
|
D = AppCodes.Classroom.Tag + ".Delete",
|
||||||
|
E = AppCodes.Classroom.Tag + ".Export",
|
||||||
|
I = AppCodes.Classroom.Tag + ".Import",
|
||||||
|
A = AppCodes.Classroom.Tag + ".Activity",
|
||||||
|
}),
|
||||||
|
DeleteCommand = $"UPDATE \"{SelectCommandByTableName("Tag", Prefix.DbTableCoordinator)}\" SET \"DeleterId\"=@DeleterId, \"DeletionTime\"=CURRENT_TIMESTAMP, \"IsDeleted\"='true' WHERE \"Id\"=@Id",
|
||||||
|
DeleteFieldsDefaultValueJson = JsonSerializer.Serialize(new FieldsDefaultValue[] {
|
||||||
|
new() {
|
||||||
|
FieldName = "DeleterId",
|
||||||
|
FieldDbType = DbType.Guid,
|
||||||
|
Value = "@USERID",
|
||||||
|
CustomValueType = FieldCustomValueTypeEnum.CustomKey },
|
||||||
|
new() {
|
||||||
|
FieldName = "Id",
|
||||||
|
FieldDbType = DbType.Guid,
|
||||||
|
Value = "@ID",
|
||||||
|
CustomValueType = FieldCustomValueTypeEnum.CustomKey }
|
||||||
|
}),
|
||||||
|
PagerOptionJson = JsonSerializer.Serialize(new GridPagerOptionDto
|
||||||
|
{
|
||||||
|
Visible = true,
|
||||||
|
AllowedPageSizes = "10,20,50,100",
|
||||||
|
ShowPageSizeSelector = true,
|
||||||
|
ShowNavigationButtons = true,
|
||||||
|
ShowInfo = false,
|
||||||
|
InfoText = "Page {0} of {1} ({2} items)",
|
||||||
|
DisplayMode = GridColumnOptions.PagerDisplayModeAdaptive,
|
||||||
|
ScrollingMode = GridColumnOptions.ScrollingModeStandard,
|
||||||
|
LoadPanelEnabled = "auto",
|
||||||
|
LoadPanelText = "Loading..."
|
||||||
|
}),
|
||||||
|
EditingOptionJson = JsonSerializer.Serialize(new GridEditingDto
|
||||||
|
{
|
||||||
|
Popup = new GridEditingPopupDto()
|
||||||
|
{
|
||||||
|
Title = "Tag Form",
|
||||||
|
Width = 500,
|
||||||
|
Height = 250
|
||||||
|
},
|
||||||
|
AllowDeleting = true,
|
||||||
|
AllowAdding = true,
|
||||||
|
AllowUpdating = true,
|
||||||
|
SendOnlyChangedFormValuesUpdate = false,
|
||||||
|
}),
|
||||||
|
EditingFormJson = JsonSerializer.Serialize(new List<EditingFormDto>() {
|
||||||
|
new() { Order=1,ColCount=1,ColSpan=2,ItemType="group", Items=
|
||||||
|
[
|
||||||
|
new EditingFormItemDto { Order = 1, DataField = "Name", ColSpan = 2, IsRequired = true, EditorType2=EditorTypes.dxTextBox },
|
||||||
|
new EditingFormItemDto { Order = 2, DataField = "Description", ColSpan = 2, EditorType2=EditorTypes.dxTextBox },
|
||||||
|
new EditingFormItemDto { Order = 3, DataField = "Color", ColSpan = 2, IsRequired = true, EditorType2=EditorTypes.dxColorBox },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
InsertFieldsDefaultValueJson = JsonSerializer.Serialize(new FieldsDefaultValue[] {
|
||||||
|
new() {
|
||||||
|
FieldName = "CreationTime",
|
||||||
|
FieldDbType = DbType.DateTime,
|
||||||
|
Value = "@NOW",
|
||||||
|
CustomValueType = FieldCustomValueTypeEnum.CustomKey },
|
||||||
|
new() {
|
||||||
|
FieldName = "CreatorId",
|
||||||
|
FieldDbType = DbType.Guid,
|
||||||
|
Value = "@USERID",
|
||||||
|
CustomValueType = FieldCustomValueTypeEnum.CustomKey },
|
||||||
|
new() {
|
||||||
|
FieldName = "IsDeleted",
|
||||||
|
FieldDbType = DbType.Boolean,
|
||||||
|
Value = "false",
|
||||||
|
CustomValueType = FieldCustomValueTypeEnum.Value }
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
#region Tag Fields
|
||||||
|
await _listFormFieldRepository.InsertManyAsync(new ListFormField[] {
|
||||||
|
new() {
|
||||||
|
ListFormCode = listFormTag.ListFormCode,
|
||||||
|
RoleId = null,
|
||||||
|
UserId = null,
|
||||||
|
CultureName = LanguageCodes.En,
|
||||||
|
SourceDbType = DbType.Guid,
|
||||||
|
FieldName = "Id",
|
||||||
|
Width = 100,
|
||||||
|
ListOrderNo = 1,
|
||||||
|
Visible = false,
|
||||||
|
IsActive = true,
|
||||||
|
IsDeleted = false,
|
||||||
|
SortIndex = 0,
|
||||||
|
ValidationRuleJson = JsonSerializer.Serialize(new ValidationRuleDto[] {
|
||||||
|
new ValidationRuleDto() { Type = Enum.GetName(UiColumnValidationRuleTypeEnum.required) }
|
||||||
|
}),
|
||||||
|
ColumnCustomizationJson = JsonSerializer.Serialize(new ColumnCustomizationDto
|
||||||
|
{
|
||||||
|
AllowReordering = true,
|
||||||
|
}),
|
||||||
|
PermissionJson = JsonSerializer.Serialize(new ListFormFieldPermissionDto
|
||||||
|
{
|
||||||
|
C = AppCodes.Classroom.Tag + ".Create",
|
||||||
|
R = AppCodes.Classroom.Tag,
|
||||||
|
U = AppCodes.Classroom.Tag + ".Update",
|
||||||
|
E = true,
|
||||||
|
I = true,
|
||||||
|
Deny = false
|
||||||
|
}),
|
||||||
|
PivotSettingsJson = JsonSerializer.Serialize(new ListFormFieldPivotSettingsDto
|
||||||
|
{
|
||||||
|
IsPivot = true
|
||||||
|
})
|
||||||
|
},
|
||||||
|
new() {
|
||||||
|
ListFormCode = listFormTag.ListFormCode,
|
||||||
|
RoleId = null,
|
||||||
|
UserId = null,
|
||||||
|
CultureName = LanguageCodes.En,
|
||||||
|
SourceDbType = DbType.String,
|
||||||
|
FieldName = "Name",
|
||||||
|
Width = 250,
|
||||||
|
ListOrderNo = 2,
|
||||||
|
Visible = true,
|
||||||
|
IsActive = true,
|
||||||
|
IsDeleted = false,
|
||||||
|
SortIndex = 1,
|
||||||
|
SortDirection = GridColumnOptions.SortOrderAsc,
|
||||||
|
AllowSearch = true,
|
||||||
|
ValidationRuleJson = JsonSerializer.Serialize(new ValidationRuleDto[] {
|
||||||
|
new ValidationRuleDto() { Type = Enum.GetName(UiColumnValidationRuleTypeEnum.required) }
|
||||||
|
}),
|
||||||
|
ColumnCustomizationJson = JsonSerializer.Serialize(new ColumnCustomizationDto
|
||||||
|
{
|
||||||
|
AllowReordering = true,
|
||||||
|
}),
|
||||||
|
PermissionJson = JsonSerializer.Serialize(new ListFormFieldPermissionDto
|
||||||
|
{
|
||||||
|
C = AppCodes.Classroom.Tag + ".Create",
|
||||||
|
R = AppCodes.Classroom.Tag,
|
||||||
|
U = AppCodes.Classroom.Tag + ".Update",
|
||||||
|
E = true,
|
||||||
|
I = true,
|
||||||
|
Deny = false
|
||||||
|
}),
|
||||||
|
PivotSettingsJson = JsonSerializer.Serialize(new ListFormFieldPivotSettingsDto
|
||||||
|
{
|
||||||
|
IsPivot = true
|
||||||
|
})
|
||||||
|
},
|
||||||
|
new() {
|
||||||
|
ListFormCode = listFormTag.ListFormCode,
|
||||||
|
RoleId = null,
|
||||||
|
UserId = null,
|
||||||
|
CultureName = LanguageCodes.En,
|
||||||
|
SourceDbType = DbType.String,
|
||||||
|
FieldName = "Description",
|
||||||
|
Width = 400,
|
||||||
|
ListOrderNo = 3,
|
||||||
|
Visible = true,
|
||||||
|
IsActive = true,
|
||||||
|
IsDeleted = false,
|
||||||
|
AllowSearch = true,
|
||||||
|
ColumnCustomizationJson = JsonSerializer.Serialize(new ColumnCustomizationDto
|
||||||
|
{
|
||||||
|
AllowReordering = true,
|
||||||
|
}),
|
||||||
|
PermissionJson = JsonSerializer.Serialize(new ListFormFieldPermissionDto
|
||||||
|
{
|
||||||
|
C = AppCodes.Classroom.Tag + ".Create",
|
||||||
|
R = AppCodes.Classroom.Tag,
|
||||||
|
U = AppCodes.Classroom.Tag + ".Update",
|
||||||
|
E = true,
|
||||||
|
I = true,
|
||||||
|
Deny = false
|
||||||
|
}),
|
||||||
|
PivotSettingsJson = JsonSerializer.Serialize(new ListFormFieldPivotSettingsDto
|
||||||
|
{
|
||||||
|
IsPivot = true
|
||||||
|
})
|
||||||
|
},
|
||||||
|
new() {
|
||||||
|
ListFormCode = listFormTag.ListFormCode,
|
||||||
|
RoleId = null,
|
||||||
|
UserId = null,
|
||||||
|
CultureName = LanguageCodes.En,
|
||||||
|
SourceDbType = DbType.String,
|
||||||
|
FieldName = "Color",
|
||||||
|
Width = 100,
|
||||||
|
ListOrderNo = 4,
|
||||||
|
Visible = true,
|
||||||
|
IsActive = true,
|
||||||
|
IsDeleted = false,
|
||||||
|
AllowSearch = true,
|
||||||
|
ColumnCustomizationJson = JsonSerializer.Serialize(new ColumnCustomizationDto
|
||||||
|
{
|
||||||
|
AllowReordering = true,
|
||||||
|
}),
|
||||||
|
PermissionJson = JsonSerializer.Serialize(new ListFormFieldPermissionDto
|
||||||
|
{
|
||||||
|
C = AppCodes.Classroom.Tag + ".Create",
|
||||||
|
R = AppCodes.Classroom.Tag,
|
||||||
|
U = AppCodes.Classroom.Tag + ".Update",
|
||||||
|
E = true,
|
||||||
|
I = true,
|
||||||
|
Deny = false
|
||||||
|
}),
|
||||||
|
PivotSettingsJson = JsonSerializer.Serialize(new ListFormFieldPivotSettingsDto
|
||||||
|
{
|
||||||
|
IsPivot = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -595,6 +595,7 @@ public static class PlatformConsts
|
||||||
public const string ClassType = "list-classtype";
|
public const string ClassType = "list-classtype";
|
||||||
public const string Class = "list-class";
|
public const string Class = "list-class";
|
||||||
public const string Level = "list-level";
|
public const string Level = "list-level";
|
||||||
|
public const string Tag = "list-tag";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -440,6 +440,12 @@ public static class SeedConsts
|
||||||
public const string Level = Default + ".Level";
|
public const string Level = Default + ".Level";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static class Classroom
|
||||||
|
{
|
||||||
|
public const string Default = Prefix.App + ".Classroom";
|
||||||
|
public const string Tag = Default + ".Tag";
|
||||||
|
}
|
||||||
|
|
||||||
public static class Accounting
|
public static class Accounting
|
||||||
{
|
{
|
||||||
public const string Default = Prefix.App + ".Accounting";
|
public const string Default = Prefix.App + ".Accounting";
|
||||||
|
|
|
||||||
17
api/src/Kurs.Platform.Domain/Entities/Tenant/Tag.cs
Normal file
17
api/src/Kurs.Platform.Domain/Entities/Tenant/Tag.cs
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
using System;
|
||||||
|
using Volo.Abp.Domain.Entities.Auditing;
|
||||||
|
using Volo.Abp.MultiTenancy;
|
||||||
|
|
||||||
|
namespace Kurs.Platform.Entities;
|
||||||
|
|
||||||
|
public class Tag : FullAuditedEntity<Guid>, IMultiTenant
|
||||||
|
{
|
||||||
|
public Guid? TenantId;
|
||||||
|
|
||||||
|
public string Name { get; set; } // Etiket adı (ör: Grammar)
|
||||||
|
public string Description { get; set; } // Açıklama
|
||||||
|
public string Color { get; set; } // Renk kodu (ör: #3B82F6)
|
||||||
|
public int UsageCount { get; set; } // Kullanım sayısı
|
||||||
|
|
||||||
|
Guid? IMultiTenant.TenantId => TenantId;
|
||||||
|
}
|
||||||
|
|
@ -139,6 +139,9 @@ public class PlatformDbContext :
|
||||||
public DbSet<ClassroomParticipant> Participants { get; set; }
|
public DbSet<ClassroomParticipant> Participants { get; set; }
|
||||||
public DbSet<ClassroomAttandance> AttendanceRecords { get; set; }
|
public DbSet<ClassroomAttandance> AttendanceRecords { get; set; }
|
||||||
public DbSet<ClassroomChat> ChatMessages { get; set; }
|
public DbSet<ClassroomChat> ChatMessages { get; set; }
|
||||||
|
|
||||||
|
public DbSet<Tag> Tags { get; set; }
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Entities from the modules
|
#region Entities from the modules
|
||||||
|
|
@ -1561,5 +1564,17 @@ public class PlatformDbContext :
|
||||||
b.Property(x => x.Subject).IsRequired().HasMaxLength(256);
|
b.Property(x => x.Subject).IsRequired().HasMaxLength(256);
|
||||||
b.Property(x => x.Content).IsRequired().HasMaxLength(2000);
|
b.Property(x => x.Content).IsRequired().HasMaxLength(2000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Classroom
|
||||||
|
builder.Entity<Tag>(b =>
|
||||||
|
{
|
||||||
|
b.ToTable(Prefix.DbTableCoordinator + nameof(Tag), Prefix.DbSchema);
|
||||||
|
b.ConfigureByConvention();
|
||||||
|
|
||||||
|
b.Property(x => x.Name).IsRequired().HasMaxLength(100);
|
||||||
|
b.Property(x => x.Description).HasMaxLength(500);
|
||||||
|
b.Property(x => x.Color).HasMaxLength(7);
|
||||||
|
b.Property(x => x.UsageCount).HasDefaultValue(0);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ using Volo.Abp.EntityFrameworkCore;
|
||||||
namespace Kurs.Platform.Migrations
|
namespace Kurs.Platform.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(PlatformDbContext))]
|
[DbContext(typeof(PlatformDbContext))]
|
||||||
[Migration("20251013214306_Initial")]
|
[Migration("20251015192202_Initial")]
|
||||||
partial class Initial
|
partial class Initial
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|
@ -6112,6 +6112,69 @@ namespace Kurs.Platform.Migrations
|
||||||
b.ToTable("DSource", (string)null);
|
b.ToTable("DSource", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Kurs.Platform.Entities.Tag", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("Color")
|
||||||
|
.HasMaxLength(7)
|
||||||
|
.HasColumnType("nvarchar(7)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreationTime")
|
||||||
|
.HasColumnType("datetime2")
|
||||||
|
.HasColumnName("CreationTime");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CreatorId")
|
||||||
|
.HasColumnType("uniqueidentifier")
|
||||||
|
.HasColumnName("CreatorId");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DeleterId")
|
||||||
|
.HasColumnType("uniqueidentifier")
|
||||||
|
.HasColumnName("DeleterId");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletionTime")
|
||||||
|
.HasColumnType("datetime2")
|
||||||
|
.HasColumnName("DeletionTime");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("nvarchar(500)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bit")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("IsDeleted");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastModificationTime")
|
||||||
|
.HasColumnType("datetime2")
|
||||||
|
.HasColumnName("LastModificationTime");
|
||||||
|
|
||||||
|
b.Property<Guid?>("LastModifierId")
|
||||||
|
.HasColumnType("uniqueidentifier")
|
||||||
|
.HasColumnName("LastModifierId");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("TenantId")
|
||||||
|
.HasColumnType("uniqueidentifier")
|
||||||
|
.HasColumnName("TenantId");
|
||||||
|
|
||||||
|
b.Property<int>("UsageCount")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int")
|
||||||
|
.HasDefaultValue(0);
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("CTag", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Kurs.Platform.Entities.Uom", b =>
|
modelBuilder.Entity("Kurs.Platform.Entities.Uom", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
|
|
@ -434,6 +434,29 @@ namespace Kurs.Platform.Migrations
|
||||||
table.PrimaryKey("PK_AbpUsers", x => x.Id);
|
table.PrimaryKey("PK_AbpUsers", x => x.Id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "CTag",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
Name = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||||
|
Description = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||||
|
Color = table.Column<string>(type: "nvarchar(7)", maxLength: 7, nullable: true),
|
||||||
|
UsageCount = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
|
||||||
|
TenantId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||||
|
CreationTime = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
CreatorId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||||
|
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
LastModifierId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||||
|
IsDeleted = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
|
||||||
|
DeleterId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||||
|
DeletionTime = table.Column<DateTime>(type: "datetime2", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_CTag", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "DBank",
|
name: "DBank",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
|
|
@ -4142,6 +4165,9 @@ namespace Kurs.Platform.Migrations
|
||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "AbpUserTokens");
|
name: "AbpUserTokens");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "CTag");
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "DBankAccount");
|
name: "DBankAccount");
|
||||||
|
|
||||||
|
|
@ -6109,6 +6109,69 @@ namespace Kurs.Platform.Migrations
|
||||||
b.ToTable("DSource", (string)null);
|
b.ToTable("DSource", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Kurs.Platform.Entities.Tag", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("Color")
|
||||||
|
.HasMaxLength(7)
|
||||||
|
.HasColumnType("nvarchar(7)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreationTime")
|
||||||
|
.HasColumnType("datetime2")
|
||||||
|
.HasColumnName("CreationTime");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CreatorId")
|
||||||
|
.HasColumnType("uniqueidentifier")
|
||||||
|
.HasColumnName("CreatorId");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DeleterId")
|
||||||
|
.HasColumnType("uniqueidentifier")
|
||||||
|
.HasColumnName("DeleterId");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletionTime")
|
||||||
|
.HasColumnType("datetime2")
|
||||||
|
.HasColumnName("DeletionTime");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("nvarchar(500)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bit")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("IsDeleted");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastModificationTime")
|
||||||
|
.HasColumnType("datetime2")
|
||||||
|
.HasColumnName("LastModificationTime");
|
||||||
|
|
||||||
|
b.Property<Guid?>("LastModifierId")
|
||||||
|
.HasColumnType("uniqueidentifier")
|
||||||
|
.HasColumnName("LastModifierId");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("TenantId")
|
||||||
|
.HasColumnType("uniqueidentifier")
|
||||||
|
.HasColumnName("TenantId");
|
||||||
|
|
||||||
|
b.Property<int>("UsageCount")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int")
|
||||||
|
.HasDefaultValue(0);
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("CTag", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Kurs.Platform.Entities.Uom", b =>
|
modelBuilder.Entity("Kurs.Platform.Entities.Uom", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
|
|
|
||||||
|
|
@ -1754,7 +1754,6 @@
|
||||||
"Sunday": true
|
"Sunday": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
||||||
"Classrooms": [
|
"Classrooms": [
|
||||||
{
|
{
|
||||||
"name": "Matematik 101 - Diferansiyel Denklemler",
|
"name": "Matematik 101 - Diferansiyel Denklemler",
|
||||||
|
|
@ -1807,5 +1806,27 @@
|
||||||
"participantCount": 0,
|
"participantCount": 0,
|
||||||
"settingsJson": "{\"AllowHandRaise\":true,\"AllowStudentChat\":true,\"AllowPrivateMessages\":true,\"AllowStudentScreenShare\":true,\"DefaultMicrophoneState\":\"muted\",\"DefaultCameraState\":\"off\",\"DefaultLayout\":\"grid\",\"AutoMuteNewParticipants\":true}"
|
"settingsJson": "{\"AllowHandRaise\":true,\"AllowStudentChat\":true,\"AllowPrivateMessages\":true,\"AllowStudentScreenShare\":true,\"DefaultMicrophoneState\":\"muted\",\"DefaultCameraState\":\"off\",\"DefaultLayout\":\"grid\",\"AutoMuteNewParticipants\":true}"
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"Tags": [
|
||||||
|
{
|
||||||
|
"Name": "Grammar",
|
||||||
|
"Description": "Grammar related questions",
|
||||||
|
"Color": "#3B82F6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "Vocabulary",
|
||||||
|
"Description": "Vocabulary building exercises",
|
||||||
|
"Color": "#10B981"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "Reading",
|
||||||
|
"Description": "Reading comprehension and analysis",
|
||||||
|
"Color": "#F59E0B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "Writing",
|
||||||
|
"Description": "Writing skills development",
|
||||||
|
"Color": "#EF4444"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ public class TenantDataSeeder : IDataSeedContributor, ITransientDependency
|
||||||
private readonly IRepository<ClassCancellationReason, Guid> _classCancellationReasonRepository;
|
private readonly IRepository<ClassCancellationReason, Guid> _classCancellationReasonRepository;
|
||||||
private readonly IRepository<WorkHour, Guid> _workHourRepository;
|
private readonly IRepository<WorkHour, Guid> _workHourRepository;
|
||||||
private readonly IRepository<Classroom, Guid> _classroomRepository;
|
private readonly IRepository<Classroom, Guid> _classroomRepository;
|
||||||
|
private readonly IRepository<Tag, Guid> _tagRepository;
|
||||||
|
|
||||||
public TenantDataSeeder(
|
public TenantDataSeeder(
|
||||||
IRepository<GlobalSearch, int> globalSearch,
|
IRepository<GlobalSearch, int> globalSearch,
|
||||||
|
|
@ -90,7 +91,8 @@ public class TenantDataSeeder : IDataSeedContributor, ITransientDependency
|
||||||
IRepository<Source, Guid> sourceRepository,
|
IRepository<Source, Guid> sourceRepository,
|
||||||
IRepository<Interesting, Guid> interestingRepository,
|
IRepository<Interesting, Guid> interestingRepository,
|
||||||
IRepository<Program, Guid> programRepository,
|
IRepository<Program, Guid> programRepository,
|
||||||
IRepository<ForumCategory, Guid> forumCategoryRepository
|
IRepository<ForumCategory, Guid> forumCategoryRepository,
|
||||||
|
IRepository<Tag, Guid> tagRepository
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
_globalSearch = globalSearch;
|
_globalSearch = globalSearch;
|
||||||
|
|
@ -130,6 +132,7 @@ public class TenantDataSeeder : IDataSeedContributor, ITransientDependency
|
||||||
_interestingRepository = interestingRepository;
|
_interestingRepository = interestingRepository;
|
||||||
_programRepository = programRepository;
|
_programRepository = programRepository;
|
||||||
_forumCategoryRepository = forumCategoryRepository;
|
_forumCategoryRepository = forumCategoryRepository;
|
||||||
|
_tagRepository = tagRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IConfigurationRoot BuildConfiguration()
|
private static IConfigurationRoot BuildConfiguration()
|
||||||
|
|
@ -725,5 +728,20 @@ public class TenantDataSeeder : IDataSeedContributor, ITransientDependency
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (var item in items.Tags)
|
||||||
|
{
|
||||||
|
var exists = await _tagRepository.AnyAsync(x => x.Name == item.Name);
|
||||||
|
|
||||||
|
if (!exists)
|
||||||
|
{
|
||||||
|
await _tagRepository.InsertAsync(new Tag
|
||||||
|
{
|
||||||
|
Name = item.Name,
|
||||||
|
Description = item.Description,
|
||||||
|
Color = item.Color
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ public class TenantSeederDto
|
||||||
public List<BlogPostSeedDto> BlogPosts { get; set; }
|
public List<BlogPostSeedDto> BlogPosts { get; set; }
|
||||||
public List<ContactSeedDto> Contacts { get; set; }
|
public List<ContactSeedDto> Contacts { get; set; }
|
||||||
public List<ClassroomSeedDto> Classrooms { get; set; }
|
public List<ClassroomSeedDto> Classrooms { get; set; }
|
||||||
|
public List<TagSeedDto> Tags { get; set; }
|
||||||
|
|
||||||
//Tanımlamalar
|
//Tanımlamalar
|
||||||
public List<SectorSeedDto> Sectors { get; set; }
|
public List<SectorSeedDto> Sectors { get; set; }
|
||||||
|
|
@ -344,3 +345,10 @@ public class ProgramSeedDto
|
||||||
public string Name { get; set; }
|
public string Name { get; set; }
|
||||||
public string Status { get; set; }
|
public string Status { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class TagSeedDto
|
||||||
|
{
|
||||||
|
public string Name { get; set; }
|
||||||
|
public string Description { get; set; }
|
||||||
|
public string Color { get; set; }
|
||||||
|
}
|
||||||
79
ui/src/mocks/mockCoordinator.ts
Normal file
79
ui/src/mocks/mockCoordinator.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
export const adminNavigationItems = [
|
||||||
|
{
|
||||||
|
id: "dashboard",
|
||||||
|
title: "Dashboard",
|
||||||
|
icon: "LayoutDashboard",
|
||||||
|
path: "/admin/dashboard",
|
||||||
|
description: "Genel bakış ve istatistikler",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tags",
|
||||||
|
title: "Etiket Yönetimi",
|
||||||
|
icon: "Tags",
|
||||||
|
path: "/admin/tags",
|
||||||
|
description: "Soru ve içerik etiketleri",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "question-pools",
|
||||||
|
title: "Soru Havuzları",
|
||||||
|
icon: "Database",
|
||||||
|
path: "/admin/question-pools",
|
||||||
|
description: "Soru havuzları ve kategoriler",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "exams",
|
||||||
|
title: "Sınav Yönetimi",
|
||||||
|
icon: "FileText",
|
||||||
|
path: "/admin/exams",
|
||||||
|
description: "Sınavları yönetin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "assignments",
|
||||||
|
title: "Ödev Yönetimi",
|
||||||
|
icon: "BookOpen",
|
||||||
|
path: "/admin/assignments",
|
||||||
|
description: "Ödevleri yönetin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tests",
|
||||||
|
title: "Test Yönetimi",
|
||||||
|
icon: "ClipboardList",
|
||||||
|
path: "/admin/tests",
|
||||||
|
description: "PDF testler ve cevap anahtarları",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const questionTypes = [
|
||||||
|
{ value: "multiple-choice", label: "Çoktan Seçmeli", icon: "CheckCircle" },
|
||||||
|
{ value: "fill-blank", label: "Boşluk Doldurma", icon: "Edit3" },
|
||||||
|
{ value: "multiple-answer", label: "Çok Yanıtlı", icon: "CheckSquare" },
|
||||||
|
{ value: "matching", label: "Eşleştirme", icon: "Link" },
|
||||||
|
{ value: "ordering", label: "Sıralama", icon: "ArrowUpDown" },
|
||||||
|
{ value: "open-ended", label: "Açık Uçlu", icon: "MessageSquare" },
|
||||||
|
{ value: "true-false", label: "Doğru-Yanlış", icon: "ToggleLeft" },
|
||||||
|
{ value: "calculation", label: "Hesaplama", icon: "Calculator" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const difficultyLevels = [
|
||||||
|
{ value: "easy", label: "Kolay", color: "green" },
|
||||||
|
{ value: "medium", label: "Orta", color: "yellow" },
|
||||||
|
{ value: "hard", label: "Zor", color: "red" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const examTypes = [
|
||||||
|
{ value: "exam", label: "Sınav", icon: "FileText" },
|
||||||
|
{ value: "assignment", label: "Ödev", icon: "BookOpen" },
|
||||||
|
{ value: "test", label: "Test", icon: "ClipboardList" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const getBreadcrumb = (currentPath: string): string[] => {
|
||||||
|
const pathMap: Record<string, string[]> = {
|
||||||
|
"/admin/dashboard": ["Admin", "Dashboard"],
|
||||||
|
"/admin/tags": ["Admin", "Etiket Yönetimi"],
|
||||||
|
"/admin/question-pools": ["Admin", "Soru Havuzları"],
|
||||||
|
"/admin/exams": ["Admin", "Sınav Yönetimi"],
|
||||||
|
"/admin/assignments": ["Admin", "Ödev Yönetimi"],
|
||||||
|
"/admin/tests": ["Admin", "Test Yönetimi"],
|
||||||
|
};
|
||||||
|
return pathMap[currentPath] || ["Admin"];
|
||||||
|
};
|
||||||
39
ui/src/mocks/mockExams.ts
Normal file
39
ui/src/mocks/mockExams.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { Exam } from "@/types/coordinator";
|
||||||
|
import { generateMockPools } from "./mockPools";
|
||||||
|
|
||||||
|
export const generateMockExam = (): Exam[] => [
|
||||||
|
{
|
||||||
|
id: "exam-1",
|
||||||
|
title: "Grammar Assessment",
|
||||||
|
description: "Comprehensive grammar evaluation",
|
||||||
|
type: "exam",
|
||||||
|
questions: generateMockPools()[0].questions,
|
||||||
|
timeLimit: 30,
|
||||||
|
totalPoints: 25,
|
||||||
|
passingScore: 60,
|
||||||
|
allowReview: true,
|
||||||
|
randomizeQuestions: false,
|
||||||
|
showResults: true,
|
||||||
|
maxAttempts: 1,
|
||||||
|
isActive: true,
|
||||||
|
creationTime: new Date(),
|
||||||
|
lastModificationTime: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "assignment-1",
|
||||||
|
title: "Assignment 1",
|
||||||
|
description: "First assignment on grammar topics",
|
||||||
|
type: "assignment",
|
||||||
|
questions: generateMockPools()[0].questions,
|
||||||
|
timeLimit: 30,
|
||||||
|
totalPoints: 25,
|
||||||
|
passingScore: 60,
|
||||||
|
allowReview: true,
|
||||||
|
randomizeQuestions: false,
|
||||||
|
showResults: true,
|
||||||
|
maxAttempts: 1,
|
||||||
|
isActive: true,
|
||||||
|
creationTime: new Date(),
|
||||||
|
lastModificationTime: new Date(),
|
||||||
|
},
|
||||||
|
];
|
||||||
46
ui/src/mocks/mockPools.ts
Normal file
46
ui/src/mocks/mockPools.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { QuestionPool } from "@/types/coordinator";
|
||||||
|
|
||||||
|
// Dynamic data - no hardcoded content
|
||||||
|
export const generateMockPools = (): QuestionPool[] => [
|
||||||
|
{
|
||||||
|
id: "pool-1",
|
||||||
|
name: "Grammar Fundamentals",
|
||||||
|
description: "Essential grammar concepts and structures",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: "q1",
|
||||||
|
type: "multiple-choice",
|
||||||
|
title: "Verb Tenses",
|
||||||
|
content: "Select the appropriate tense form for the given context.",
|
||||||
|
options: [
|
||||||
|
{ id: "opt1", text: "Option A", isCorrect: false },
|
||||||
|
{ id: "opt2", text: "Option B", isCorrect: true },
|
||||||
|
{ id: "opt3", text: "Option C", isCorrect: false },
|
||||||
|
{ id: "opt4", text: "Option D", isCorrect: false },
|
||||||
|
],
|
||||||
|
correctAnswer: "opt2",
|
||||||
|
points: 10,
|
||||||
|
difficulty: "easy",
|
||||||
|
tags: ["grammar", "verbs"],
|
||||||
|
creationTime: new Date(),
|
||||||
|
lastModificationTime: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "q2",
|
||||||
|
type: "fill-blank",
|
||||||
|
title: "Articles",
|
||||||
|
content:
|
||||||
|
'Fill in the blank: "I saw _____ elephant at the zoo yesterday."',
|
||||||
|
correctAnswer: "an",
|
||||||
|
points: 15,
|
||||||
|
difficulty: "medium",
|
||||||
|
tags: ["grammar", "articles"],
|
||||||
|
creationTime: new Date(),
|
||||||
|
lastModificationTime: new Date(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tags: ["grammar", "fundamentals"],
|
||||||
|
createdBy: "admin",
|
||||||
|
creationTime: new Date(),
|
||||||
|
},
|
||||||
|
];
|
||||||
36
ui/src/mocks/mockTags.ts
Normal file
36
ui/src/mocks/mockTags.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { TagItem } from "@/types/coordinator";
|
||||||
|
|
||||||
|
export const generateMockTags = (): TagItem[] => [
|
||||||
|
{
|
||||||
|
id: "tag-1",
|
||||||
|
name: "Grammar",
|
||||||
|
description: "Grammar related questions",
|
||||||
|
color: "#3B82F6",
|
||||||
|
usageCount: 25,
|
||||||
|
creationTime: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tag-2",
|
||||||
|
name: "Vocabulary",
|
||||||
|
description: "Vocabulary building exercises",
|
||||||
|
color: "#10B981",
|
||||||
|
usageCount: 18,
|
||||||
|
creationTime: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tag-3",
|
||||||
|
name: "Reading",
|
||||||
|
description: "Reading comprehension tasks",
|
||||||
|
color: "#F59E0B",
|
||||||
|
usageCount: 12,
|
||||||
|
creationTime: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tag-4",
|
||||||
|
name: "Writing",
|
||||||
|
description: "Writing skill assessments",
|
||||||
|
color: "#EF4444",
|
||||||
|
usageCount: 8,
|
||||||
|
creationTime: new Date(),
|
||||||
|
},
|
||||||
|
];
|
||||||
48
ui/src/mocks/mockTests.ts
Normal file
48
ui/src/mocks/mockTests.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { Exam } from "@/types/coordinator";
|
||||||
|
|
||||||
|
export const generateMockPDFTest = (): Exam => ({
|
||||||
|
id: "test-1",
|
||||||
|
title: "Reading Assessment",
|
||||||
|
description: "Document-based reading evaluation",
|
||||||
|
type: "test",
|
||||||
|
testDocument: {
|
||||||
|
url: "/sample-documents/reading-test.pdf",
|
||||||
|
type: "pdf",
|
||||||
|
name: "reading-assessment.pdf",
|
||||||
|
},
|
||||||
|
answerKeyTemplate: [
|
||||||
|
{
|
||||||
|
id: "ak1",
|
||||||
|
questionNumber: 1,
|
||||||
|
type: "multiple-choice",
|
||||||
|
options: ["A", "B", "C", "D"],
|
||||||
|
points: 10,
|
||||||
|
correctAnswer: "A",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ak2",
|
||||||
|
questionNumber: 2,
|
||||||
|
type: "true-false",
|
||||||
|
points: 5,
|
||||||
|
correctAnswer: "true",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ak3",
|
||||||
|
questionNumber: 3,
|
||||||
|
type: "fill-blank",
|
||||||
|
points: 15,
|
||||||
|
correctAnswer: "answer",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
questions: [],
|
||||||
|
timeLimit: 45,
|
||||||
|
totalPoints: 30,
|
||||||
|
passingScore: 70,
|
||||||
|
allowReview: true,
|
||||||
|
randomizeQuestions: false,
|
||||||
|
showResults: true,
|
||||||
|
maxAttempts: 1,
|
||||||
|
isActive: true,
|
||||||
|
creationTime: new Date(),
|
||||||
|
lastModificationTime: new Date(),
|
||||||
|
});
|
||||||
130
ui/src/types/coordinator.ts
Normal file
130
ui/src/types/coordinator.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
export type QuestionType =
|
||||||
|
| "multiple-choice"
|
||||||
|
| "fill-blank"
|
||||||
|
| "multiple-answer"
|
||||||
|
| "matching"
|
||||||
|
| "ordering"
|
||||||
|
| "open-ended"
|
||||||
|
| "true-false"
|
||||||
|
| "calculation";
|
||||||
|
|
||||||
|
export type ExamType = "exam" | "assignment" | "test";
|
||||||
|
export type TestType = 'pdf' | 'image';
|
||||||
|
export type MediaType = 'image' | 'video';
|
||||||
|
export type QuestionDifficulty = 'easy' | 'medium' | 'hard';
|
||||||
|
export type ExamSessionStatus = 'in-progress' | 'completed' | 'submitted';
|
||||||
|
|
||||||
|
export interface QuestionPool {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
questions: Question[];
|
||||||
|
tags: string[];
|
||||||
|
createdBy: string;
|
||||||
|
creationTime: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Question {
|
||||||
|
id: string;
|
||||||
|
type: QuestionType;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
mediaUrl?: string;
|
||||||
|
mediaType?: MediaType;
|
||||||
|
options?: QuestionOption[];
|
||||||
|
correctAnswer?: string | string[];
|
||||||
|
points: number;
|
||||||
|
timeLimit?: number;
|
||||||
|
explanation?: string;
|
||||||
|
tags: string[];
|
||||||
|
difficulty: QuestionDifficulty;
|
||||||
|
creationTime: Date;
|
||||||
|
lastModificationTime: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuestionOption {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
isCorrect: boolean;
|
||||||
|
order?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Exam {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
type: ExamType;
|
||||||
|
testDocument?: {
|
||||||
|
url: string;
|
||||||
|
type: TestType;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
answerKeyTemplate?: AnswerKeyItem[];
|
||||||
|
questions: Question[];
|
||||||
|
timeLimit: number;
|
||||||
|
totalPoints: number;
|
||||||
|
passingScore: number;
|
||||||
|
allowReview: boolean;
|
||||||
|
randomizeQuestions: boolean;
|
||||||
|
showResults: boolean;
|
||||||
|
maxAttempts: number;
|
||||||
|
startTime?: Date;
|
||||||
|
endTime?: Date;
|
||||||
|
isActive: boolean;
|
||||||
|
creationTime: Date;
|
||||||
|
lastModificationTime: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnswerKeyItem {
|
||||||
|
id: string;
|
||||||
|
questionNumber: number;
|
||||||
|
type: QuestionType;
|
||||||
|
options?: string[];
|
||||||
|
points: number;
|
||||||
|
correctAnswer?: string | string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StudentAnswer {
|
||||||
|
questionId: string;
|
||||||
|
answer: string | string[];
|
||||||
|
timeSpent: number;
|
||||||
|
isCorrect?: boolean;
|
||||||
|
points?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExamSession {
|
||||||
|
id: string;
|
||||||
|
examId: string;
|
||||||
|
studentId: string;
|
||||||
|
startTime: Date;
|
||||||
|
endTime?: Date;
|
||||||
|
answers: StudentAnswer[];
|
||||||
|
totalScore?: number;
|
||||||
|
status: ExamSessionStatus;
|
||||||
|
timeRemaining: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TagItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
color: string;
|
||||||
|
usageCount: number;
|
||||||
|
creationTime: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavigationItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
icon: string;
|
||||||
|
path: string;
|
||||||
|
description?: string;
|
||||||
|
badge?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminRoute {
|
||||||
|
path: string;
|
||||||
|
component: string;
|
||||||
|
title: string;
|
||||||
|
breadcrumb: string[];
|
||||||
|
}
|
||||||
131
ui/src/utils/hooks/useCoordinator.ts
Normal file
131
ui/src/utils/hooks/useCoordinator.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { QuestionPool, Exam, TagItem, ExamSession } from "@/types/coordinator";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export function useCoordinator() {
|
||||||
|
const [currentPath, setCurrentPath] = useState("/admin/dashboard");
|
||||||
|
const [pools, setPools] = useState<QuestionPool[]>([]);
|
||||||
|
const [exams, setExams] = useState<Exam[]>([]);
|
||||||
|
const [tags, setTags] = useState<TagItem[]>([]);
|
||||||
|
const [currentExam, setCurrentExam] = useState<Exam | null>(null);
|
||||||
|
|
||||||
|
const handleUpdateTest = (updatedTest: Exam) => {
|
||||||
|
setExams((prev) =>
|
||||||
|
prev.map((exam) => (exam.id === updatedTest.id ? updatedTest : exam))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTest = (testId: string) => {
|
||||||
|
setExams((prev) => prev.filter((exam) => exam.id !== testId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreatePool = (
|
||||||
|
poolData: Omit<QuestionPool, "id" | "creationTime">
|
||||||
|
) => {
|
||||||
|
const newPool: QuestionPool = {
|
||||||
|
...poolData,
|
||||||
|
id: `pool-${Date.now()}`,
|
||||||
|
creationTime: new Date(),
|
||||||
|
};
|
||||||
|
setPools((prev) => [...prev, newPool]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdatePool = (updatedPool: QuestionPool) => {
|
||||||
|
setPools((prev) =>
|
||||||
|
prev.map((pool) => (pool.id === updatedPool.id ? updatedPool : pool))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeletePool = (poolId: string) => {
|
||||||
|
setPools((prev) => prev.filter((pool) => pool.id !== poolId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateExam = (
|
||||||
|
examData: Omit<Exam, "id" | "creationTime" | "lastModificationTime">
|
||||||
|
) => {
|
||||||
|
const newExam: Exam = {
|
||||||
|
...examData,
|
||||||
|
id: `exam-${Date.now()}`,
|
||||||
|
creationTime: new Date(),
|
||||||
|
lastModificationTime: new Date(),
|
||||||
|
};
|
||||||
|
setExams((prev) => [...prev, newExam]);
|
||||||
|
setCurrentPath("/admin/exams");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveExam = (exam: Exam) => {
|
||||||
|
setExams((prev) => prev.map((e) => (e.id === exam.id ? exam : e)));
|
||||||
|
setCurrentPath("/admin/exams");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateTest = (
|
||||||
|
testData: Omit<Exam, "id" | "creationTime" | "lastModificationTime">
|
||||||
|
) => {
|
||||||
|
const newTest: Exam = {
|
||||||
|
...testData,
|
||||||
|
id: `test-${Date.now()}`,
|
||||||
|
creationTime: new Date(),
|
||||||
|
lastModificationTime: new Date(),
|
||||||
|
};
|
||||||
|
setExams((prev) => [...prev, newTest]);
|
||||||
|
setCurrentPath("/admin/tests");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateTag = (
|
||||||
|
tagData: Omit<TagItem, "id" | "usageCount" | "creationTime">
|
||||||
|
) => {
|
||||||
|
const newTag: TagItem = {
|
||||||
|
...tagData,
|
||||||
|
id: `tag-${Date.now()}`,
|
||||||
|
usageCount: 0,
|
||||||
|
creationTime: new Date(),
|
||||||
|
};
|
||||||
|
setTags((prev) => [...prev, newTag]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateTag = (updatedTag: TagItem) => {
|
||||||
|
setTags((prev) =>
|
||||||
|
prev.map((tag) => (tag.id === updatedTag.id ? updatedTag : tag))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTag = (tagId: string) => {
|
||||||
|
setTags((prev) => prev.filter((tag) => tag.id !== tagId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExamComplete = (session: ExamSession) => {
|
||||||
|
console.log("Exam completed:", session);
|
||||||
|
alert("Assessment completed successfully!");
|
||||||
|
setCurrentPath("/admin/dashboard");
|
||||||
|
setCurrentExam(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startExam = (exam: Exam) => {
|
||||||
|
setCurrentExam(exam);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentPath,
|
||||||
|
setCurrentPath,
|
||||||
|
pools,
|
||||||
|
setPools,
|
||||||
|
exams,
|
||||||
|
setExams,
|
||||||
|
tags,
|
||||||
|
setTags,
|
||||||
|
currentExam,
|
||||||
|
setCurrentExam,
|
||||||
|
handleUpdateTest,
|
||||||
|
handleDeleteTest,
|
||||||
|
handleCreatePool,
|
||||||
|
handleUpdatePool,
|
||||||
|
handleDeletePool,
|
||||||
|
handleCreateExam,
|
||||||
|
handleSaveExam,
|
||||||
|
handleCreateTest,
|
||||||
|
handleCreateTag,
|
||||||
|
handleUpdateTag,
|
||||||
|
handleDeleteTag,
|
||||||
|
handleExamComplete,
|
||||||
|
startExam,
|
||||||
|
};
|
||||||
|
}
|
||||||
95
ui/src/utils/hooks/useExamSecurity.ts
Normal file
95
ui/src/utils/hooks/useExamSecurity.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
interface SecurityConfig {
|
||||||
|
disableRightClick: boolean;
|
||||||
|
disableCopyPaste: boolean;
|
||||||
|
disableDevTools: boolean;
|
||||||
|
fullScreenMode: boolean;
|
||||||
|
preventTabSwitch: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useExamSecurity = (config: SecurityConfig, isActive: boolean = true) => {
|
||||||
|
const handleRightClick = useCallback((e: MouseEvent) => {
|
||||||
|
if (config.disableRightClick && isActive) {
|
||||||
|
e.preventDefault();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [config.disableRightClick, isActive]);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||||
|
if (!isActive) return;
|
||||||
|
|
||||||
|
// Disable copy/paste shortcuts
|
||||||
|
if (config.disableCopyPaste) {
|
||||||
|
if (e.ctrlKey && (e.key === 'c' || e.key === 'v' || e.key === 'x' || e.key === 'a')) {
|
||||||
|
e.preventDefault();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable developer tools
|
||||||
|
if (config.disableDevTools) {
|
||||||
|
if (e.key === 'F12' || (e.ctrlKey && e.shiftKey && e.key === 'I')) {
|
||||||
|
e.preventDefault();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable Alt+Tab for tab switching
|
||||||
|
if (config.preventTabSwitch && e.altKey && e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [config, isActive]);
|
||||||
|
|
||||||
|
const handleVisibilityChange = useCallback(() => {
|
||||||
|
if (config.preventTabSwitch && isActive && document.hidden) {
|
||||||
|
// Log tab switch attempt - in real app, this would call an API
|
||||||
|
console.warn('Tab switch detected during exam');
|
||||||
|
}
|
||||||
|
}, [config.preventTabSwitch, isActive]);
|
||||||
|
|
||||||
|
const enterFullScreen = useCallback(async () => {
|
||||||
|
if (config.fullScreenMode && isActive) {
|
||||||
|
try {
|
||||||
|
await document.documentElement.requestFullscreen();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to enter fullscreen:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [config.fullScreenMode, isActive]);
|
||||||
|
|
||||||
|
const exitFullScreen = useCallback(async () => {
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
try {
|
||||||
|
await document.exitFullscreen();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to exit fullscreen:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isActive) {
|
||||||
|
document.addEventListener('contextmenu', handleRightClick);
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('contextmenu', handleRightClick);
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
|
||||||
|
if (config.fullScreenMode) {
|
||||||
|
exitFullScreen();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [handleRightClick, handleKeyDown, handleVisibilityChange, exitFullScreen, isActive, config.fullScreenMode]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
enterFullScreen,
|
||||||
|
exitFullScreen,
|
||||||
|
isFullScreen: !!document.fullscreenElement
|
||||||
|
};
|
||||||
|
};
|
||||||
94
ui/src/utils/hooks/useExamTimer.ts
Normal file
94
ui/src/utils/hooks/useExamTimer.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
interface UseExamTimerProps {
|
||||||
|
initialTime: number; // in seconds
|
||||||
|
onTimeUp?: () => void;
|
||||||
|
onTick?: (timeRemaining: number) => void;
|
||||||
|
autoStart?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useExamTimer = ({
|
||||||
|
initialTime,
|
||||||
|
onTimeUp,
|
||||||
|
onTick,
|
||||||
|
autoStart = false
|
||||||
|
}: UseExamTimerProps) => {
|
||||||
|
const [timeRemaining, setTimeRemaining] = useState(initialTime);
|
||||||
|
const [isRunning, setIsRunning] = useState(autoStart);
|
||||||
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
|
|
||||||
|
const start = useCallback(() => {
|
||||||
|
setIsRunning(true);
|
||||||
|
setIsPaused(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pause = useCallback(() => {
|
||||||
|
setIsPaused(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resume = useCallback(() => {
|
||||||
|
setIsPaused(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setTimeRemaining(initialTime);
|
||||||
|
setIsRunning(false);
|
||||||
|
setIsPaused(false);
|
||||||
|
}, [initialTime]);
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
setIsRunning(false);
|
||||||
|
setIsPaused(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let interval: NodeJS.Timeout;
|
||||||
|
|
||||||
|
if (isRunning && !isPaused && timeRemaining > 0) {
|
||||||
|
interval = setInterval(() => {
|
||||||
|
setTimeRemaining((prev) => {
|
||||||
|
const newTime = prev - 1;
|
||||||
|
onTick?.(newTime);
|
||||||
|
|
||||||
|
if (newTime <= 0) {
|
||||||
|
setIsRunning(false);
|
||||||
|
onTimeUp?.();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newTime;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (interval) {
|
||||||
|
clearInterval(interval);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isRunning, isPaused, timeRemaining, onTimeUp, onTick]);
|
||||||
|
|
||||||
|
const formatTime = useCallback((seconds: number) => {
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
timeRemaining,
|
||||||
|
formattedTime: formatTime(timeRemaining),
|
||||||
|
isRunning,
|
||||||
|
isPaused,
|
||||||
|
start,
|
||||||
|
pause,
|
||||||
|
resume,
|
||||||
|
reset,
|
||||||
|
stop,
|
||||||
|
progress: ((initialTime - timeRemaining) / initialTime) * 100
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -263,7 +263,7 @@ const ClassList: React.FC = () => {
|
||||||
></Helmet>
|
></Helmet>
|
||||||
<Container>
|
<Container>
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4 sm:gap-6 mb-6 sm:mb-8">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4 sm:gap-6 mb-3">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
|
@ -440,7 +440,8 @@ const ClassList: React.FC = () => {
|
||||||
{/* Sağ kısım: buton */}
|
{/* Sağ kısım: buton */}
|
||||||
{showButtons && (
|
{showButtons && (
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
{user.role === 'teacher' && classSession.teacherId === user.id && (
|
{/* {user.role === 'teacher' && classSession.teacherId === user.id && ( */}
|
||||||
|
{user.role === 'teacher' && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => handlePlanningClass(classSession)}
|
onClick={() => handlePlanningClass(classSession)}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { Container } from '@/components/shared'
|
||||||
import { Helmet } from 'react-helmet'
|
import { Helmet } from 'react-helmet'
|
||||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||||
|
|
||||||
const ClassroomPlannerPage: React.FC = () => {
|
const PlanningPage: React.FC = () => {
|
||||||
const [students, setStudents] = useState<Student[]>([])
|
const [students, setStudents] = useState<Student[]>([])
|
||||||
const [seats, setSeats] = useState<Seat[]>([])
|
const [seats, setSeats] = useState<Seat[]>([])
|
||||||
const [selectedClassroom, setSelectedClassroom] = useState<Classroom | null>(null)
|
const [selectedClassroom, setSelectedClassroom] = useState<Classroom | null>(null)
|
||||||
|
|
@ -295,4 +295,4 @@ const ClassroomPlannerPage: React.FC = () => {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ClassroomPlannerPage
|
export default PlanningPage
|
||||||
|
|
|
||||||
502
ui/src/views/coordinator/AdminPanel/ExamCreator.tsx
Normal file
502
ui/src/views/coordinator/AdminPanel/ExamCreator.tsx
Normal file
|
|
@ -0,0 +1,502 @@
|
||||||
|
import { QuestionPool, Exam, Question } from "@/types/coordinator";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { FaPlus, FaClock, FaUsers, FaCog, FaSave } from "react-icons/fa";
|
||||||
|
|
||||||
|
interface ExamCreatorProps {
|
||||||
|
pools: QuestionPool[];
|
||||||
|
onCreateExam: (exam: Omit<Exam, "id" | "creationTime" | "lastModificationTime">) => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
editingExam?: Exam;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ExamCreator: React.FC<ExamCreatorProps> = ({
|
||||||
|
pools,
|
||||||
|
onCreateExam,
|
||||||
|
onCancel,
|
||||||
|
editingExam,
|
||||||
|
}) => {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
title: editingExam?.title || "",
|
||||||
|
description: editingExam?.description || "",
|
||||||
|
type: editingExam?.type || ("exam" as "exam" | "assignment" | "test"),
|
||||||
|
timeLimit: editingExam?.timeLimit || 60,
|
||||||
|
passingScore: editingExam?.passingScore || 60,
|
||||||
|
maxAttempts: editingExam?.maxAttempts || 1,
|
||||||
|
allowReview: editingExam?.allowReview ?? true,
|
||||||
|
randomizeQuestions: editingExam?.randomizeQuestions ?? false,
|
||||||
|
showResults: editingExam?.showResults ?? true,
|
||||||
|
startTime: editingExam?.startTime?.toISOString().slice(0, 16) || "",
|
||||||
|
endTime: editingExam?.endTime?.toISOString().slice(0, 16) || "",
|
||||||
|
isActive: editingExam?.isActive ?? true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [selectedQuestions, setSelectedQuestions] = useState<Question[]>(
|
||||||
|
editingExam?.questions || []
|
||||||
|
);
|
||||||
|
|
||||||
|
const [selectedPool, setSelectedPool] = useState<string>("");
|
||||||
|
const [questionFilters, setQuestionFilters] = useState({
|
||||||
|
type: "",
|
||||||
|
difficulty: "",
|
||||||
|
tag: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleInputChange = (field: string, value: any) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addQuestionsFromPool = () => {
|
||||||
|
const pool = pools.find((p) => p.id === selectedPool);
|
||||||
|
if (!pool) return;
|
||||||
|
|
||||||
|
let questionsToAdd = pool.questions;
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
if (questionFilters.type) {
|
||||||
|
questionsToAdd = questionsToAdd.filter(
|
||||||
|
(q) => q.type === questionFilters.type
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (questionFilters.difficulty) {
|
||||||
|
questionsToAdd = questionsToAdd.filter(
|
||||||
|
(q) => q.difficulty === questionFilters.difficulty
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (questionFilters.tag) {
|
||||||
|
questionsToAdd = questionsToAdd.filter((q) =>
|
||||||
|
q.tags.includes(questionFilters.tag)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add questions that aren't already selected
|
||||||
|
const newQuestions = questionsToAdd.filter(
|
||||||
|
(q) => !selectedQuestions.some((sq) => sq.id === q.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
setSelectedQuestions((prev) => [...prev, ...newQuestions]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeQuestion = (questionId: string) => {
|
||||||
|
setSelectedQuestions((prev) => prev.filter((q) => q.id !== questionId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveQuestion = (index: number, direction: "up" | "down") => {
|
||||||
|
const newQuestions = [...selectedQuestions];
|
||||||
|
const targetIndex = direction === "up" ? index - 1 : index + 1;
|
||||||
|
|
||||||
|
if (targetIndex >= 0 && targetIndex < newQuestions.length) {
|
||||||
|
[newQuestions[index], newQuestions[targetIndex]] = [
|
||||||
|
newQuestions[targetIndex],
|
||||||
|
newQuestions[index],
|
||||||
|
];
|
||||||
|
setSelectedQuestions(newQuestions);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!formData.title.trim() || selectedQuestions.length === 0) {
|
||||||
|
alert("Please enter exam title and select at least one question.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPoints = selectedQuestions.reduce((sum, q) => sum + q.points, 0);
|
||||||
|
|
||||||
|
const examData = {
|
||||||
|
...formData,
|
||||||
|
questions: selectedQuestions,
|
||||||
|
totalPoints,
|
||||||
|
startTime: formData.startTime ? new Date(formData.startTime) : undefined,
|
||||||
|
endTime: formData.endTime ? new Date(formData.endTime) : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
onCreateExam(examData);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900">
|
||||||
|
{editingExam ? "Sınavı Düzenle" : "Yeni Sınav Oluştur"}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-600 mt-0.5">
|
||||||
|
Sınav ayarlarını yapılandırın ve soruları seçin
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{onCancel && (
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="flex items-center space-x-2 bg-gray-100 hover:bg-gray-200 text-gray-700 px-4 py-1.5 text-sm rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<span>İptal</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="flex items-center space-x-2 bg-blue-600 hover:bg-blue-700 text-white px-4 py-1.5 text-sm rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<FaSave className="w-3.5 h-3.5" />
|
||||||
|
<span>{editingExam ? "Güncelle" : "Oluştur"}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Basic Information */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<h3 className="text-base font-semibold text-gray-900 mb-3 flex items-center">
|
||||||
|
<FaCog className="w-4 h-4 mr-2" />
|
||||||
|
Temel Bilgiler
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Exam Title
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.title}
|
||||||
|
onChange={(e) => handleInputChange("title", 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="Enter exam title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange("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="Enter exam description"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
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="exam">Exam</option>
|
||||||
|
<option value="assignment">Assignment</option>
|
||||||
|
<option value="test">Test</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Settings */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<h3 className="text-base font-semibold text-gray-900 mb-3 flex items-center">
|
||||||
|
<FaClock className="w-4 h-4 mr-2" />
|
||||||
|
Ayarlar
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Time Limit (minutes)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.timeLimit}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange("timeLimit", parseInt(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"
|
||||||
|
min="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Passing Score (%)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.passingScore}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange("passingScore", parseInt(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"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Maximum Attempts
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.maxAttempts}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange("maxAttempts", parseInt(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"
|
||||||
|
min="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Start Date
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={formData.startTime}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange("startTime", 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
End Date
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={formData.endTime}
|
||||||
|
onChange={(e) => handleInputChange("endTime", 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.allowReview}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange("allowReview", e.target.checked)
|
||||||
|
}
|
||||||
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="ml-2 text-sm text-gray-700">
|
||||||
|
Allow answer review
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.randomizeQuestions}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange("randomizeQuestions", e.target.checked)
|
||||||
|
}
|
||||||
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="ml-2 text-sm text-gray-700">
|
||||||
|
Randomize questions
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.showResults}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange("showResults", e.target.checked)
|
||||||
|
}
|
||||||
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="ml-2 text-sm text-gray-700">Show results</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.isActive}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange("isActive", e.target.checked)
|
||||||
|
}
|
||||||
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="ml-2 text-sm text-gray-700">Active</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Question Selection */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<h3 className="text-base font-semibold text-gray-900 mb-3 flex items-center">
|
||||||
|
<FaUsers className="w-4 h-4 mr-2" />
|
||||||
|
Question Selection
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Pool Selection */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Question Pool
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedPool}
|
||||||
|
onChange={(e) => setSelectedPool(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="">Select pool</option>
|
||||||
|
{pools.map((pool) => (
|
||||||
|
<option key={pool.id} value={pool.id}>
|
||||||
|
{pool.name} ({pool.questions.length} questions)
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Question Type
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={questionFilters.type}
|
||||||
|
onChange={(e) =>
|
||||||
|
setQuestionFilters((prev) => ({
|
||||||
|
...prev,
|
||||||
|
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="">All types</option>
|
||||||
|
<option value="multiple-choice">Multiple Choice</option>
|
||||||
|
<option value="true-false">True/False</option>
|
||||||
|
<option value="fill-blank">Fill Blank</option>
|
||||||
|
<option value="open-ended">Open Ended</option>
|
||||||
|
<option value="multiple-answer">Multiple Answer</option>
|
||||||
|
<option value="matching">Matching</option>
|
||||||
|
<option value="ordering">Ordering</option>
|
||||||
|
<option value="calculation">Calculation</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Difficulty
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={questionFilters.difficulty}
|
||||||
|
onChange={(e) =>
|
||||||
|
setQuestionFilters((prev) => ({
|
||||||
|
...prev,
|
||||||
|
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="">All levels</option>
|
||||||
|
<option value="easy">Easy</option>
|
||||||
|
<option value="medium">Medium</option>
|
||||||
|
<option value="hard">Hard</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-end">
|
||||||
|
<button
|
||||||
|
onClick={addQuestionsFromPool}
|
||||||
|
disabled={!selectedPool}
|
||||||
|
className="w-full flex items-center justify-center space-x-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-300 text-white py-2 rounded-lg text-xs transition-colors"
|
||||||
|
>
|
||||||
|
<FaPlus className="w-4 h-4" />
|
||||||
|
<span>Add Questions</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected Questions */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h4 className="text-md font-medium text-gray-900">
|
||||||
|
Selected Questions ({selectedQuestions.length})
|
||||||
|
</h4>
|
||||||
|
{selectedQuestions.length > 0 && (
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
Total Points:{" "}
|
||||||
|
{selectedQuestions.reduce((sum, q) => sum + q.points, 0)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedQuestions.length > 0 ? (
|
||||||
|
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||||
|
{selectedQuestions.map((question, index) => (
|
||||||
|
<div
|
||||||
|
key={question.id}
|
||||||
|
className="flex items-center justify-between p-4 border border-gray-200 rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center space-x-2 mb-1">
|
||||||
|
<span className="bg-blue-100 text-blue-800 text-xs font-medium px-2 py-1 rounded">
|
||||||
|
{question.type}
|
||||||
|
</span>
|
||||||
|
<span className="bg-green-100 text-green-800 text-xs font-medium px-2 py-1 rounded">
|
||||||
|
{question.points} pts
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-gray-900">
|
||||||
|
{question.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-600 truncate">
|
||||||
|
{question.content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-1 ml-4">
|
||||||
|
<button
|
||||||
|
onClick={() => moveQuestion(index, "up")}
|
||||||
|
disabled={index === 0}
|
||||||
|
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30 rounded"
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => moveQuestion(index, "down")}
|
||||||
|
disabled={index === selectedQuestions.length - 1}
|
||||||
|
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30 rounded"
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => removeQuestion(question.id)}
|
||||||
|
className="p-1 text-red-400 hover:text-red-600 rounded ml-2"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
<p>No questions selected yet</p>
|
||||||
|
<p className="text-sm">Select a pool above and add questions</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
749
ui/src/views/coordinator/AdminPanel/QuestionEditor.tsx
Normal file
749
ui/src/views/coordinator/AdminPanel/QuestionEditor.tsx
Normal file
|
|
@ -0,0 +1,749 @@
|
||||||
|
import { Question, QuestionType, QuestionDifficulty, QuestionOption } from "@/types/coordinator";
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { FaSave, FaPlus, FaTrash, FaTimes } from "react-icons/fa";
|
||||||
|
|
||||||
|
interface QuestionEditorProps {
|
||||||
|
question?: Question;
|
||||||
|
onSave: (question: Omit<Question, "id" | "creationTime" | "lastModificationTime">) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QuestionEditor: React.FC<QuestionEditorProps> = ({
|
||||||
|
question,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
}) => {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
type: "multiple-choice" as QuestionType,
|
||||||
|
title: "",
|
||||||
|
content: "",
|
||||||
|
mediaUrl: "",
|
||||||
|
mediaType: "image" as "image" | "video",
|
||||||
|
points: 10,
|
||||||
|
timeLimit: 0,
|
||||||
|
explanation: "",
|
||||||
|
tags: [] as string[],
|
||||||
|
difficulty: "medium" as QuestionDifficulty,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [options, setOptions] = useState<QuestionOption[]>([]);
|
||||||
|
const [correctAnswer, setCorrectAnswer] = useState<string | string[]>("");
|
||||||
|
const [tagInput, setTagInput] = useState("");
|
||||||
|
|
||||||
|
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 || "",
|
||||||
|
tags: question.tags,
|
||||||
|
difficulty: question.difficulty,
|
||||||
|
});
|
||||||
|
setOptions(question.options || []);
|
||||||
|
setCorrectAnswer(question.correctAnswer || "");
|
||||||
|
}
|
||||||
|
}, [question]);
|
||||||
|
|
||||||
|
const handleInputChange = (field: string, value: any) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addOption = () => {
|
||||||
|
const newOption: QuestionOption = {
|
||||||
|
id: `opt-${Date.now()}`,
|
||||||
|
text: "",
|
||||||
|
isCorrect: false,
|
||||||
|
order: options.length,
|
||||||
|
};
|
||||||
|
setOptions((prev) => [...prev, newOption]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateOption = (index: number, field: string, value: any) => {
|
||||||
|
setOptions((prev) =>
|
||||||
|
prev.map((opt, i) => (i === index ? { ...opt, [field]: value } : opt))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeOption = (index: number) => {
|
||||||
|
setOptions((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addTag = () => {
|
||||||
|
if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
tags: [...prev.tags, tagInput.trim()],
|
||||||
|
}));
|
||||||
|
setTagInput("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTag = (tag: string) => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
tags: prev.tags.filter((t) => t !== tag),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!formData.title.trim() || !formData.content.trim()) {
|
||||||
|
alert("Please fill in the title and content fields.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.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
|
||||||
|
) {
|
||||||
|
alert("Please add at least 2 options.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
formData.type === "multiple-choice" &&
|
||||||
|
!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(),
|
||||||
|
};
|
||||||
|
|
||||||
|
onSave(questionData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCorrectAnswer = (): string | string[] => {
|
||||||
|
switch (formData.type) {
|
||||||
|
case "multiple-choice":
|
||||||
|
return options.find((opt) => opt.isCorrect)?.id || "";
|
||||||
|
case "multiple-answer":
|
||||||
|
return 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;
|
||||||
|
case "matching":
|
||||||
|
return options.map((opt) => opt.id);
|
||||||
|
case "ordering":
|
||||||
|
return options
|
||||||
|
.sort((a, b) => (a.order || 0) - (b.order || 0))
|
||||||
|
.map((opt) => opt.id);
|
||||||
|
default:
|
||||||
|
return correctAnswer as string;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderQuestionTypeSpecificFields = () => {
|
||||||
|
switch (formData.type) {
|
||||||
|
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>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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"
|
||||||
|
>
|
||||||
|
<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="correct-answer"
|
||||||
|
checked={option.isCorrect}
|
||||||
|
onChange={(e) => {
|
||||||
|
// For single choice, uncheck others
|
||||||
|
setOptions((prev) =>
|
||||||
|
prev.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"
|
||||||
|
/>
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeOption(index)}
|
||||||
|
className="text-red-500 hover:text-red-700"
|
||||||
|
>
|
||||||
|
<FaTrash className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "multiple-answer":
|
||||||
|
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}
|
||||||
|
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>Yanıt Ekle</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
{option.isCorrect ? "Doğru" : "Yanlış"}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeOption(index)}
|
||||||
|
className="text-red-500 hover:text-red-700"
|
||||||
|
>
|
||||||
|
<FaTrash className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "true-false":
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
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>
|
||||||
|
<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>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Add Tag
|
||||||
|
</label>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={tagInput}
|
||||||
|
onChange={(e) => setTagInput(e.target.value)}
|
||||||
|
onKeyPress={(e) => e.key === "Enter" && addTag()}
|
||||||
|
placeholder="grammar, vocabulary..."
|
||||||
|
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={addTag}
|
||||||
|
className="px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
<FaPlus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags Display */}
|
||||||
|
{formData.tags.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||||
|
Tags
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{formData.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeTag(tag)}
|
||||||
|
className="ml-1.5 text-blue-600 hover:text-blue-800"
|
||||||
|
>
|
||||||
|
<FaTimes className="w-2.5 h-2.5" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</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={onCancel}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<FaSave className="w-3.5 h-3.5" />
|
||||||
|
<span>Save Question</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
475
ui/src/views/coordinator/AdminPanel/QuestionPoolManager.tsx
Normal file
475
ui/src/views/coordinator/AdminPanel/QuestionPoolManager.tsx
Normal file
|
|
@ -0,0 +1,475 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { QuestionEditor } from "./QuestionEditor";
|
||||||
|
import { FaPlus, FaSearch, FaFilter, FaEdit, FaTrash } from "react-icons/fa";
|
||||||
|
import { generateMockPools } from "@/mocks/mockPools";
|
||||||
|
import { QuestionPool, Question } from "@/types/coordinator";
|
||||||
|
|
||||||
|
export const QuestionPoolManager: React.FC = () => {
|
||||||
|
const [pools, setPools] = useState<QuestionPool[]>(generateMockPools());
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [selectedTag, setSelectedTag] = useState("");
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [editingPool, setEditingPool] = useState<QuestionPool | null>(null);
|
||||||
|
const [editingQuestion, setEditingQuestion] = useState<Question | 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: QuestionPool = {
|
||||||
|
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: Question, poolId: string) => {
|
||||||
|
setSelectedPoolForQuestion(poolId);
|
||||||
|
setEditingQuestion(question);
|
||||||
|
setShowQuestionEditor(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveQuestion = (
|
||||||
|
questionData: Omit<Question, "id" | "creationTime" | "lastModificationTime">
|
||||||
|
) => {
|
||||||
|
const pool = pools.find((p) => p.id === selectedPoolForQuestion);
|
||||||
|
if (!pool) return;
|
||||||
|
|
||||||
|
const newQuestion: Question = {
|
||||||
|
...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 && (
|
||||||
|
<QuestionEditor
|
||||||
|
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;
|
||||||
|
};
|
||||||
601
ui/src/views/coordinator/AdminPanel/TestCreator.tsx
Normal file
601
ui/src/views/coordinator/AdminPanel/TestCreator.tsx
Normal file
|
|
@ -0,0 +1,601 @@
|
||||||
|
import { Exam, AnswerKeyItem } from "@/types/coordinator";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
FaUpload,
|
||||||
|
FaFileAlt,
|
||||||
|
FaImage,
|
||||||
|
FaPlus,
|
||||||
|
FaTrash,
|
||||||
|
FaSave,
|
||||||
|
} from "react-icons/fa";
|
||||||
|
|
||||||
|
interface TestCreatorProps {
|
||||||
|
onCreateTest: (
|
||||||
|
test: Omit<Exam, "id" | "creationTime" | "lastModificationTime">
|
||||||
|
) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
editingTest?: Exam;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TestCreator: React.FC<TestCreatorProps> = ({
|
||||||
|
onCreateTest,
|
||||||
|
onCancel,
|
||||||
|
editingTest,
|
||||||
|
}) => {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
title: editingTest?.title || "",
|
||||||
|
description: editingTest?.description || "",
|
||||||
|
timeLimit: editingTest?.timeLimit || 60,
|
||||||
|
passingScore: editingTest?.passingScore || 60,
|
||||||
|
maxAttempts: editingTest?.maxAttempts || 1,
|
||||||
|
allowReview: editingTest?.allowReview ?? true,
|
||||||
|
showResults: editingTest?.showResults ?? true,
|
||||||
|
isActive: editingTest?.isActive ?? true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [testDocument, setTestDocument] = useState<{
|
||||||
|
url: string;
|
||||||
|
type: "pdf" | "image";
|
||||||
|
name: string;
|
||||||
|
}>(editingTest?.testDocument || { url: "", type: "pdf", name: "" });
|
||||||
|
|
||||||
|
const [answerKeyTemplate, setAnswerKeyTemplate] = useState<AnswerKeyItem[]>(
|
||||||
|
editingTest?.answerKeyTemplate || []
|
||||||
|
);
|
||||||
|
const [bulkAnswerMode, setBulkAnswerMode] = useState(false);
|
||||||
|
const [bulkAnswers, setBulkAnswers] = useState("");
|
||||||
|
|
||||||
|
const handleInputChange = (field: string, value: any) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDocumentChange = (field: string, value: any) => {
|
||||||
|
setTestDocument((prev) => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addAnswerKeyItem = () => {
|
||||||
|
const newItem: AnswerKeyItem = {
|
||||||
|
id: `item-${Date.now()}`,
|
||||||
|
questionNumber: answerKeyTemplate.length + 1,
|
||||||
|
type: "multiple-choice",
|
||||||
|
options: ["A", "B", "C", "D"],
|
||||||
|
points: 10,
|
||||||
|
};
|
||||||
|
setAnswerKeyTemplate((prev) => [...prev, newItem]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateAnswerKeyItem = (index: number, field: string, value: any) => {
|
||||||
|
setAnswerKeyTemplate((prev) =>
|
||||||
|
prev.map((item, i) => (i === index ? { ...item, [field]: value } : item))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAnswerKeyItem = (index: number) => {
|
||||||
|
setAnswerKeyTemplate((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
// Renumber remaining items
|
||||||
|
setAnswerKeyTemplate((prev) =>
|
||||||
|
prev.map((item, i) => ({
|
||||||
|
...item,
|
||||||
|
questionNumber: i + 1,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkAnswerSubmit = () => {
|
||||||
|
const answers = bulkAnswers.split("\n").filter((line) => line.trim());
|
||||||
|
const newItems: AnswerKeyItem[] = answers.map((answer, index) => ({
|
||||||
|
id: `item-${Date.now()}-${index}`,
|
||||||
|
questionNumber: index + 1,
|
||||||
|
type: "multiple-choice",
|
||||||
|
options: ["A", "B", "C", "D"],
|
||||||
|
points: 10,
|
||||||
|
correctAnswer: answer.trim().toUpperCase(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
setAnswerKeyTemplate(newItems);
|
||||||
|
setBulkAnswerMode(false);
|
||||||
|
setBulkAnswers("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!formData.title.trim()) {
|
||||||
|
alert("Lütfen test başlığını girin.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!testDocument.url.trim()) {
|
||||||
|
alert("Lütfen test dokümanını yükleyin.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (answerKeyTemplate.length === 0) {
|
||||||
|
alert("Lütfen en az bir cevap anahtarı öğesi ekleyin.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPoints = answerKeyTemplate.reduce(
|
||||||
|
(sum, item) => sum + item.points,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
const testData = {
|
||||||
|
...formData,
|
||||||
|
type: "test" as const,
|
||||||
|
testDocument,
|
||||||
|
answerKeyTemplate,
|
||||||
|
questions: [], // PDF testlerde soru listesi boş
|
||||||
|
totalPoints,
|
||||||
|
randomizeQuestions: false, // PDF testlerde sabit sıralama
|
||||||
|
};
|
||||||
|
|
||||||
|
onCreateTest(testData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderAnswerKeyItemEditor = (item: AnswerKeyItem, index: number) => {
|
||||||
|
return (
|
||||||
|
<div key={item.id} className="border border-gray-200 rounded-lg p-3">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h4 className="text-sm font-medium text-gray-900">
|
||||||
|
<span className="inline-block bg-blue-100 text-blue-800 text-xs font-semibold px-2 py-1 rounded">
|
||||||
|
Soru {item.questionNumber}
|
||||||
|
</span>
|
||||||
|
</h4>
|
||||||
|
<button
|
||||||
|
onClick={() => removeAnswerKeyItem(index)}
|
||||||
|
className="text-red-500 hover:text-red-700"
|
||||||
|
>
|
||||||
|
<FaTrash className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Soru Tipi
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={item.type}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateAnswerKeyItem(index, "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">Çoktan Seçmeli</option>
|
||||||
|
<option value="fill-blank">Boşluk Doldurma</option>
|
||||||
|
<option value="true-false">Doğru-Yanlış</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Soru Numarası
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={item.questionNumber}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateAnswerKeyItem(
|
||||||
|
index,
|
||||||
|
"questionNumber",
|
||||||
|
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>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Puan
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={item.points}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateAnswerKeyItem(index, "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>
|
||||||
|
|
||||||
|
{item.type === "multiple-choice" && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Seçenekler (virgülle ayırın)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={item.options?.join(", ") || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const options = e.target.value
|
||||||
|
.split(",")
|
||||||
|
.map((opt) => opt.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
updateAnswerKeyItem(index, "options", options);
|
||||||
|
}}
|
||||||
|
placeholder="A, B, C, D"
|
||||||
|
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 className="mt-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Doğru Cevap (Opsiyonel - değerlendirme için)
|
||||||
|
</label>
|
||||||
|
{item.type === "multiple-choice" ? (
|
||||||
|
<select
|
||||||
|
value={(item.correctAnswer as string) || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateAnswerKeyItem(index, "correctAnswer", 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="">Seçin...</option>
|
||||||
|
{item.options?.map((option) => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : item.type === "true-false" ? (
|
||||||
|
<select
|
||||||
|
value={(item.correctAnswer as string) || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateAnswerKeyItem(index, "correctAnswer", 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="">Seçin...</option>
|
||||||
|
<option value="true">Doğru</option>
|
||||||
|
<option value="false">Yanlış</option>
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={(item.correctAnswer as string) || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateAnswerKeyItem(index, "correctAnswer", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="Doğru cevabı girin..."
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900">
|
||||||
|
{editingTest ? "Testi Düzenle" : "Yeni PDF Test Oluştur"}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-600 mt-0.5">
|
||||||
|
PDF test belgesi yükleyin ve cevap anahtarını tanımlayın
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="flex items-center space-x-2 bg-gray-100 hover:bg-gray-200 text-gray-700 px-4 py-1.5 text-sm rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<span>İptal</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="flex items-center space-x-2 bg-blue-600 hover:bg-blue-700 text-white px-4 py-1.5 text-sm rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<FaSave className="w-3.5 h-3.5" />
|
||||||
|
<span>{editingTest ? "Güncelle" : "Oluştur"}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Basic Information */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<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">
|
||||||
|
Test Başlığı
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.title}
|
||||||
|
onChange={(e) => handleInputChange("title", 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="Test başlığını girin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||||
|
Süre (dakika)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.timeLimit}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange("timeLimit", 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-1.5">
|
||||||
|
Açıklama
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => handleInputChange("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="Test açıklamasını girin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Document Upload */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<h3 className="text-base font-semibold text-gray-900 mb-3 flex items-center">
|
||||||
|
<FaUpload className="w-4 h-4 mr-2" />
|
||||||
|
Test Dokümanı
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Dosya Tipi
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={testDocument.type}
|
||||||
|
onChange={(e) => handleDocumentChange("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="pdf">PDF</option>
|
||||||
|
<option value="image">Resim</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Dosya Adı
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={testDocument.name}
|
||||||
|
onChange={(e) => handleDocumentChange("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="Dosya adını girin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Dosya URL'si
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={testDocument.url}
|
||||||
|
onChange={(e) => handleDocumentChange("url", 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="https://example.com/test.pdf"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{testDocument.url && (
|
||||||
|
<div className="mt-3 p-3 bg-gray-50 rounded-lg">
|
||||||
|
<div className="flex items-center space-x-2 text-xs text-gray-600">
|
||||||
|
{testDocument.type === "pdf" ? (
|
||||||
|
<FaFileAlt className="w-3.5 h-3.5 text-red-600" />
|
||||||
|
) : (
|
||||||
|
<FaImage className="w-3.5 h-3.5 text-blue-600" />
|
||||||
|
)}
|
||||||
|
<span>Önizleme: {testDocument.name || "Dosya"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Answer Key Template */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-base font-semibold text-gray-900">
|
||||||
|
Cevap Anahtarı Şablonu
|
||||||
|
</h3>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setBulkAnswerMode(true)}
|
||||||
|
className="flex items-center space-x-2 bg-green-600 hover:bg-green-700 text-white px-3 py-1.5 text-sm rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<span>Toplu Cevap</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={addAnswerKeyItem}
|
||||||
|
className="flex items-center space-x-2 bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 text-sm rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<FaPlus className="w-3.5 h-3.5" />
|
||||||
|
<span>Soru Ekle</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bulk Answer Mode */}
|
||||||
|
{bulkAnswerMode && (
|
||||||
|
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<h4 className="text-sm font-medium text-blue-900 mb-2">
|
||||||
|
Toplu Cevap Girişi
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-blue-700 mb-2">
|
||||||
|
Her satıra bir cevap yazın (A, B, C, D). Tüm sorular çoktan
|
||||||
|
seçmeli olarak oluşturulacak.
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
value={bulkAnswers}
|
||||||
|
onChange={(e) => setBulkAnswers(e.target.value)}
|
||||||
|
placeholder="A B C D A"
|
||||||
|
className="w-full text-sm h-32 border border-blue-300 rounded-lg px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<div className="flex space-x-2 mt-2">
|
||||||
|
<button
|
||||||
|
onClick={handleBulkAnswerSubmit}
|
||||||
|
className="px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Uygula
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setBulkAnswerMode(false);
|
||||||
|
setBulkAnswers("");
|
||||||
|
}}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
İptal
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{answerKeyTemplate.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{answerKeyTemplate.map((item, index) =>
|
||||||
|
renderAnswerKeyItemEditor(item, index)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Special Question Types Warning */}
|
||||||
|
{answerKeyTemplate.some(
|
||||||
|
(item) => item.type !== "multiple-choice"
|
||||||
|
) && (
|
||||||
|
<div className="mt-3 p-2.5 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 text-yellow-600"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="text-xs font-medium text-yellow-800">
|
||||||
|
Bu testte çoktan seçmeli olmayan sorular bulunmaktadır
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-blue-800 font-medium">
|
||||||
|
Toplam Soru: {answerKeyTemplate.length}
|
||||||
|
</span>
|
||||||
|
<span className="text-blue-800 font-medium">
|
||||||
|
Toplam Puan:{" "}
|
||||||
|
{answerKeyTemplate.reduce(
|
||||||
|
(sum, item) => sum + item.points,
|
||||||
|
0
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-6 text-gray-500">
|
||||||
|
<p className="text-sm">Henüz cevap anahtarı öğesi eklenmedi</p>
|
||||||
|
<p className="text-xs">
|
||||||
|
Yukarıdaki "Soru Ekle" butonuna tıklayarak başlayın
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Settings */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<h3 className="text-base font-semibold text-gray-900 mb-3">Ayarlar</h3>
|
||||||
|
<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">
|
||||||
|
Geçme Notu (%)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.passingScore}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange("passingScore", parseInt(e.target.value))
|
||||||
|
}
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
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">
|
||||||
|
Maksimum Deneme
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.maxAttempts}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange("maxAttempts", 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 className="space-y-2.5">
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.allowReview}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange("allowReview", e.target.checked)
|
||||||
|
}
|
||||||
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="ml-2 text-sm text-gray-700">
|
||||||
|
Cevap incelemeye izin ver
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.showResults}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange("showResults", e.target.checked)
|
||||||
|
}
|
||||||
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="ml-2 text-sm text-gray-700">Sonuçları göster</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.isActive}
|
||||||
|
onChange={(e) => handleInputChange("isActive", e.target.checked)}
|
||||||
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="ml-2 text-sm text-gray-700">Aktif</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
249
ui/src/views/coordinator/Assignments.tsx
Normal file
249
ui/src/views/coordinator/Assignments.tsx
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { ExamCreator } from './AdminPanel/ExamCreator'
|
||||||
|
import {
|
||||||
|
FaPlus,
|
||||||
|
FaSearch,
|
||||||
|
FaFilter,
|
||||||
|
FaEdit,
|
||||||
|
FaTrash,
|
||||||
|
FaClock,
|
||||||
|
FaUsers,
|
||||||
|
FaCalendar,
|
||||||
|
FaPlay,
|
||||||
|
} from 'react-icons/fa'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { generateMockExam } from '@/mocks/mockExams'
|
||||||
|
import { generateMockPools } from '@/mocks/mockPools'
|
||||||
|
import { Exam, QuestionPool } from '@/types/coordinator'
|
||||||
|
|
||||||
|
const Assignments: React.FC = () => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [assignments, setAssignments] = useState<Exam[]>(generateMockExam())
|
||||||
|
const [pools] = useState<QuestionPool[]>(generateMockPools())
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const [statusFilter, setStatusFilter] = useState('')
|
||||||
|
const [isCreating, setIsCreating] = useState(false)
|
||||||
|
const [editingAssignment, setEditingAssignment] = useState<Exam | null>(null)
|
||||||
|
|
||||||
|
const assignmentList = assignments.filter((assignment) => assignment.type === 'assignment')
|
||||||
|
|
||||||
|
const filteredAssignments = assignmentList.filter((assignment) => {
|
||||||
|
const matchesSearch =
|
||||||
|
assignment.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
assignment.description.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
const matchesStatus =
|
||||||
|
!statusFilter ||
|
||||||
|
(statusFilter === 'active' && assignment.isActive) ||
|
||||||
|
(statusFilter === 'inactive' && !assignment.isActive)
|
||||||
|
return matchesSearch && matchesStatus
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleCreateAssignment = (
|
||||||
|
assignmentData: Omit<Exam, 'id' | 'creationTime' | 'lastModificationTime'>,
|
||||||
|
) => {
|
||||||
|
const newAssignment: Exam = {
|
||||||
|
...assignmentData,
|
||||||
|
type: 'assignment',
|
||||||
|
id: `assignment-${Date.now()}`,
|
||||||
|
creationTime: new Date(),
|
||||||
|
lastModificationTime: new Date(),
|
||||||
|
}
|
||||||
|
setAssignments((prev) => [...prev, newAssignment])
|
||||||
|
setIsCreating(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateAssignment = (
|
||||||
|
assignmentData: Omit<Exam, 'id' | 'creationTime' | 'lastModificationTime'>,
|
||||||
|
) => {
|
||||||
|
if (editingAssignment) {
|
||||||
|
const updatedAssignment: Exam = {
|
||||||
|
...assignmentData,
|
||||||
|
id: editingAssignment.id,
|
||||||
|
creationTime: editingAssignment.creationTime,
|
||||||
|
lastModificationTime: new Date(),
|
||||||
|
}
|
||||||
|
setAssignments((prev) =>
|
||||||
|
prev.map((a) => (a.id === updatedAssignment.id ? updatedAssignment : a)),
|
||||||
|
)
|
||||||
|
setEditingAssignment(null)
|
||||||
|
setIsCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (assignment: Exam) => {
|
||||||
|
setEditingAssignment(assignment)
|
||||||
|
setIsCreating(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setIsCreating(false)
|
||||||
|
setEditingAssignment(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCreating) {
|
||||||
|
return (
|
||||||
|
<ExamCreator
|
||||||
|
pools={pools}
|
||||||
|
onCreateExam={editingAssignment ? handleUpdateAssignment : handleCreateAssignment}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
editingExam={editingAssignment || undefined}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">Ödev Yönetimi</h1>
|
||||||
|
<p className="text-sm text-gray-600 mt-0.5">Ödevleri 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 Ödev</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="Ödev ara..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="pl-8 text-sm w-full 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={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="pl-8 text-sm w-full 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 durumlar</option>
|
||||||
|
<option value="active">Aktif</option>
|
||||||
|
<option value="inactive">Pasif</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-gray-600 flex items-center">
|
||||||
|
Toplam: {filteredAssignments.length} ödev
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Assignments List */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filteredAssignments.length > 0 ? (
|
||||||
|
filteredAssignments.map((assignment) => (
|
||||||
|
<div
|
||||||
|
key={assignment.id}
|
||||||
|
className="bg-white border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center space-x-2 mb-1.5">
|
||||||
|
<h3 className="text-base font-semibold text-gray-900">{assignment.title}</h3>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
assignment.isActive
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: 'bg-gray-100 text-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{assignment.isActive ? 'Aktif' : 'Pasif'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-600 mb-2">{assignment.description}</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<FaClock className="w-4 h-4 text-gray-400" />
|
||||||
|
<span className="text-gray-600">{assignment.timeLimit} dakika</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<FaUsers className="w-4 h-4 text-gray-400" />
|
||||||
|
<span className="text-gray-600">{assignment.questions.length} soru</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<FaCalendar className="w-4 h-4 text-gray-400" />
|
||||||
|
<span className="text-gray-600">Toplam: {assignment.totalPoints} puan</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="text-gray-600">Geçme: %{assignment.passingScore}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{assignment.startTime && assignment.endTime && (
|
||||||
|
<div className="mt-2 p-2 bg-orange-50 rounded">
|
||||||
|
<div className="text-xs text-orange-700">
|
||||||
|
<strong>Başlangıç:</strong> {assignment.startTime.toLocaleString('tr-TR')} -
|
||||||
|
<strong> Bitiş:</strong> {assignment.endTime.toLocaleString('tr-TR')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-1.5 ml-4">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/assignment/${assignment.id}`)}
|
||||||
|
className="flex items-center space-x-1 px-2.5 py-1.5 bg-green-600 hover:bg-green-700 text-white text-xs rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<FaPlay className="w-3 h-3" />
|
||||||
|
<span>Başlat</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(assignment)}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
title="Düzenle"
|
||||||
|
>
|
||||||
|
<FaEdit className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm('Bu ödevi silmek istediğinizden emin misiniz?')) {
|
||||||
|
setAssignments((prev) => prev.filter((a) => a.id !== assignment.id))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
title="Sil"
|
||||||
|
>
|
||||||
|
<FaTrash className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<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">Ödev bulunamadı</h3>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{searchTerm || statusFilter
|
||||||
|
? 'Arama kriterlerinizi değiştirin veya yeni ödev oluşturun.'
|
||||||
|
: 'İlk ödevinizi oluşturun.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Assignments
|
||||||
83
ui/src/views/coordinator/ExamInterface/ExamNavigation.tsx
Normal file
83
ui/src/views/coordinator/ExamInterface/ExamNavigation.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { Question, StudentAnswer } from '@/types/coordinator';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface ExamNavigationProps {
|
||||||
|
questions: Question[];
|
||||||
|
answers: StudentAnswer[];
|
||||||
|
currentQuestionIndex: number;
|
||||||
|
onQuestionSelect: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ExamNavigation: React.FC<ExamNavigationProps> = ({
|
||||||
|
questions,
|
||||||
|
answers,
|
||||||
|
currentQuestionIndex,
|
||||||
|
onQuestionSelect
|
||||||
|
}) => {
|
||||||
|
const getQuestionStatus = (index: number) => {
|
||||||
|
const answer = answers.find(a => a.questionId === questions[index].id);
|
||||||
|
if (!answer || !answer.answer) return 'unanswered';
|
||||||
|
|
||||||
|
if (Array.isArray(answer.answer)) {
|
||||||
|
return answer.answer.some(a => a && a.toString().trim() !== '') ? 'answered' : 'unanswered';
|
||||||
|
}
|
||||||
|
|
||||||
|
return answer.answer.toString().trim() !== '' ? 'answered' : 'unanswered';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (index: number) => {
|
||||||
|
const status = getQuestionStatus(index);
|
||||||
|
const isCurrent = index === currentQuestionIndex;
|
||||||
|
|
||||||
|
if (isCurrent) {
|
||||||
|
return status === 'answered'
|
||||||
|
? 'bg-blue-600 text-white border-blue-600'
|
||||||
|
: 'bg-blue-100 text-blue-800 border-blue-300';
|
||||||
|
}
|
||||||
|
|
||||||
|
return status === 'answered'
|
||||||
|
? 'bg-green-100 text-green-800 border-green-300 hover:bg-green-200'
|
||||||
|
: 'bg-white text-gray-600 border-gray-300 hover:bg-gray-50';
|
||||||
|
};
|
||||||
|
|
||||||
|
const answeredCount = questions.filter((_, index) => getQuestionStatus(index) === 'answered').length;
|
||||||
|
const unansweredCount = questions.length - answeredCount;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-4 sticky top-4">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">Soru Haritası</h3>
|
||||||
|
<div className="flex items-center space-x-4 text-sm">
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<div className="w-3 h-3 bg-green-100 border border-green-300 rounded"></div>
|
||||||
|
<span className="text-gray-600">{answeredCount} Cevaplanmış</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<div className="w-3 h-3 bg-white border border-gray-300 rounded"></div>
|
||||||
|
<span className="text-gray-600">{unansweredCount} Cevaplanmamış</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-5 gap-2">
|
||||||
|
{questions.map((_, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => onQuestionSelect(index)}
|
||||||
|
className={`w-10 h-10 text-sm font-medium rounded-lg border-2 transition-all ${getStatusColor(index)}`}
|
||||||
|
title={`Soru ${index + 1} - ${getQuestionStatus(index) === 'answered' ? 'Cevaplanmış' : 'Cevaplanmamış'}`}
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
<div>Toplam: {questions.length} soru</div>
|
||||||
|
<div>Kalan: {unansweredCount} soru</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
93
ui/src/views/coordinator/ExamInterface/ExamTimer.tsx
Normal file
93
ui/src/views/coordinator/ExamInterface/ExamTimer.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { useExamTimer } from '@/utils/hooks/useExamTimer';
|
||||||
|
import React from 'react';
|
||||||
|
import { FaPlay } from 'react-icons/fa';
|
||||||
|
|
||||||
|
interface ExamTimerProps {
|
||||||
|
initialTime: number;
|
||||||
|
onTimeUp: () => void;
|
||||||
|
autoStart?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ExamTimer: React.FC<ExamTimerProps> = ({
|
||||||
|
initialTime,
|
||||||
|
onTimeUp,
|
||||||
|
autoStart = true,
|
||||||
|
className = ''
|
||||||
|
}) => {
|
||||||
|
const { timeRemaining, formattedTime, isRunning, isPaused, start, pause, resume, progress } = useExamTimer({
|
||||||
|
initialTime,
|
||||||
|
onTimeUp,
|
||||||
|
autoStart
|
||||||
|
});
|
||||||
|
|
||||||
|
const getTimerColor = () => {
|
||||||
|
const percentage = (timeRemaining / initialTime) * 100;
|
||||||
|
if (percentage <= 10) return 'text-red-600 bg-red-50';
|
||||||
|
if (percentage <= 25) return 'text-orange-600 bg-orange-50';
|
||||||
|
return 'text-blue-600 bg-blue-50';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProgressColor = () => {
|
||||||
|
const percentage = (timeRemaining / initialTime) * 100;
|
||||||
|
if (percentage <= 10) return 'bg-red-500';
|
||||||
|
if (percentage <= 25) return 'bg-orange-500';
|
||||||
|
return 'bg-blue-500';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${className}`}>
|
||||||
|
<div className={`inline-flex items-center px-4 py-2 rounded-lg border ${getTimerColor()} transition-colors`}>
|
||||||
|
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span className="font-mono font-semibold text-lg">
|
||||||
|
{formattedTime}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{!isRunning && !isPaused && timeRemaining > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={start}
|
||||||
|
className="ml-2 p-1 hover:bg-gray-200 rounded"
|
||||||
|
title="Başlat"
|
||||||
|
>
|
||||||
|
<FaPlay className="w-4 h-4" />
|
||||||
|
<span>Başlat</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isRunning && !isPaused && (
|
||||||
|
<button
|
||||||
|
onClick={pause}
|
||||||
|
className="ml-2 p-1 hover:bg-gray-200 rounded"
|
||||||
|
title="Duraklat"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isPaused && (
|
||||||
|
<button
|
||||||
|
onClick={resume}
|
||||||
|
className="ml-2 p-1 hover:bg-gray-200 rounded"
|
||||||
|
title="Devam Et"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="mt-2 w-full bg-gray-200 rounded-full h-1.5">
|
||||||
|
<div
|
||||||
|
className={`h-1.5 rounded-full transition-all duration-1000 ${getProgressColor()}`}
|
||||||
|
style={{ width: `${100 - progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
396
ui/src/views/coordinator/ExamInterface/PDFTestInterface.tsx
Normal file
396
ui/src/views/coordinator/ExamInterface/PDFTestInterface.tsx
Normal file
|
|
@ -0,0 +1,396 @@
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { ExamTimer } from "./ExamTimer";
|
||||||
|
import { SecurityWarning } from "./SecurityWarning";
|
||||||
|
import { FaFileAlt, FaImage, FaCheckCircle } from "react-icons/fa";
|
||||||
|
import { Exam, ExamSession, StudentAnswer, AnswerKeyItem } from "@/types/coordinator";
|
||||||
|
import { useExamSecurity } from "@/utils/hooks/useExamSecurity";
|
||||||
|
|
||||||
|
interface PDFTestInterfaceProps {
|
||||||
|
exam: Exam;
|
||||||
|
studentId: string;
|
||||||
|
onExamComplete: (session: ExamSession) => void;
|
||||||
|
onExamSave?: (session: ExamSession) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PDFTestInterface: React.FC<PDFTestInterfaceProps> = ({
|
||||||
|
exam,
|
||||||
|
studentId,
|
||||||
|
onExamComplete,
|
||||||
|
onExamSave,
|
||||||
|
}) => {
|
||||||
|
const [session, setSession] = useState<ExamSession>({
|
||||||
|
id: `session-${Date.now()}`,
|
||||||
|
examId: exam.id,
|
||||||
|
studentId,
|
||||||
|
startTime: new Date(),
|
||||||
|
answers: [],
|
||||||
|
status: "in-progress",
|
||||||
|
timeRemaining: exam.timeLimit * 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [answerKeyResponses, setAnswerKeyResponses] = useState<
|
||||||
|
Record<string, string | string[]>
|
||||||
|
>({});
|
||||||
|
const [securityWarning, setSecurityWarning] = useState<{
|
||||||
|
show: boolean;
|
||||||
|
message: string;
|
||||||
|
type: "warning" | "error" | "info";
|
||||||
|
}>({ show: false, message: "", type: "warning" });
|
||||||
|
|
||||||
|
// Security configuration
|
||||||
|
const securityConfig = {
|
||||||
|
disableRightClick: true,
|
||||||
|
disableCopyPaste: true,
|
||||||
|
disableDevTools: true,
|
||||||
|
fullScreenMode: true,
|
||||||
|
preventTabSwitch: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
useExamSecurity(securityConfig, session.status === "in-progress");
|
||||||
|
|
||||||
|
// Auto-save mechanism
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (session.status === "in-progress" && onExamSave) {
|
||||||
|
onExamSave(session);
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [session, onExamSave]);
|
||||||
|
|
||||||
|
const handleAnswerChange = (itemId: string, answer: string | string[]) => {
|
||||||
|
setAnswerKeyResponses((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[itemId]: answer,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Update session answers
|
||||||
|
setSession((prev) => {
|
||||||
|
const existingAnswerIndex = prev.answers.findIndex(
|
||||||
|
(a) => a.questionId === itemId
|
||||||
|
);
|
||||||
|
const newAnswer: StudentAnswer = {
|
||||||
|
questionId: itemId,
|
||||||
|
answer,
|
||||||
|
timeSpent: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const newAnswers =
|
||||||
|
existingAnswerIndex >= 0
|
||||||
|
? prev.answers.map((a, i) =>
|
||||||
|
i === existingAnswerIndex ? newAnswer : a
|
||||||
|
)
|
||||||
|
: [...prev.answers, newAnswer];
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
answers: newAnswers,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimeUp = () => {
|
||||||
|
setSecurityWarning({
|
||||||
|
show: true,
|
||||||
|
message: "Süre doldu! Test otomatik olarak teslim ediliyor...",
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
completeExam();
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const completeExam = () => {
|
||||||
|
const completedSession: ExamSession = {
|
||||||
|
...session,
|
||||||
|
endTime: new Date(),
|
||||||
|
status: "completed",
|
||||||
|
};
|
||||||
|
|
||||||
|
setSession(completedSession);
|
||||||
|
onExamComplete(completedSession);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmitExam = () => {
|
||||||
|
const unanswered =
|
||||||
|
exam.answerKeyTemplate?.filter(
|
||||||
|
(item) =>
|
||||||
|
!answerKeyResponses[item.id] ||
|
||||||
|
(Array.isArray(answerKeyResponses[item.id]) &&
|
||||||
|
(answerKeyResponses[item.id] as string[]).length === 0) ||
|
||||||
|
(typeof answerKeyResponses[item.id] === "string" &&
|
||||||
|
!answerKeyResponses[item.id])
|
||||||
|
) || [];
|
||||||
|
|
||||||
|
if (unanswered.length > 0) {
|
||||||
|
const confirmSubmit = window.confirm(
|
||||||
|
`${unanswered.length} soru cevaplanmamış. Yine de testi teslim etmek istiyor musunuz?`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmSubmit) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
completeExam();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAnsweredCount = () => {
|
||||||
|
return (
|
||||||
|
exam.answerKeyTemplate?.filter((item) => {
|
||||||
|
const answer = answerKeyResponses[item.id];
|
||||||
|
if (Array.isArray(answer)) {
|
||||||
|
return answer.length > 0 && answer.some((a) => a.trim() !== "");
|
||||||
|
}
|
||||||
|
return answer && answer.toString().trim() !== "";
|
||||||
|
}).length || 0
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderAnswerKeyItem = (item: AnswerKeyItem) => {
|
||||||
|
const currentAnswer = answerKeyResponses[item.id];
|
||||||
|
|
||||||
|
switch (item.type) {
|
||||||
|
case "multiple-choice":
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{item.options?.map((option, index) => (
|
||||||
|
<label
|
||||||
|
key={index}
|
||||||
|
className="flex items-center space-x-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`question-${item.id}`}
|
||||||
|
value={option}
|
||||||
|
checked={currentAnswer === option}
|
||||||
|
onChange={(e) => handleAnswerChange(item.id, e.target.value)}
|
||||||
|
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700">{option}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "fill-blank":
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={(currentAnswer as string) || ""}
|
||||||
|
onChange={(e) => handleAnswerChange(item.id, 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="Cevabınızı yazın..."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "true-false":
|
||||||
|
return (
|
||||||
|
<div className="flex space-x-4">
|
||||||
|
<label className="flex items-center space-x-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`question-${item.id}`}
|
||||||
|
value="true"
|
||||||
|
checked={currentAnswer === "true"}
|
||||||
|
onChange={(e) => handleAnswerChange(item.id, e.target.value)}
|
||||||
|
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700">Doğru</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center space-x-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`question-${item.id}`}
|
||||||
|
value="false"
|
||||||
|
checked={currentAnswer === "false"}
|
||||||
|
onChange={(e) => handleAnswerChange(item.id, e.target.value)}
|
||||||
|
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700">Yanlış</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (session.status === "completed") {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||||
|
<div className="max-w-md w-full bg-white rounded-lg shadow-lg p-6 text-center">
|
||||||
|
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<FaCheckCircle className="w-8 h-8 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||||
|
Test Tamamlandı!
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
Testiniz başarıyla teslim edildi. Sonuçlarınız değerlendirildikten
|
||||||
|
sonra bilgilendirileceksiniz.
|
||||||
|
</p>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
Başlama: {session.startTime.toLocaleString("tr-TR")}
|
||||||
|
<br />
|
||||||
|
Bitiş: {session.endTime?.toLocaleString("tr-TR")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<SecurityWarning
|
||||||
|
isVisible={securityWarning.show}
|
||||||
|
onDismiss={() =>
|
||||||
|
setSecurityWarning((prev) => ({ ...prev, show: false }))
|
||||||
|
}
|
||||||
|
message={securityWarning.message}
|
||||||
|
type={securityWarning.type}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<header className="bg-white border-b border-gray-200 px-4 py-3 sticky top-0 z-40">
|
||||||
|
<div className="max-w-7xl mx-auto flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-gray-900">
|
||||||
|
{exam.title}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
PDF Test - {getAnsweredCount()} /{" "}
|
||||||
|
{exam.answerKeyTemplate?.length || 0} cevaplandı
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<ExamTimer
|
||||||
|
initialTime={exam.timeLimit * 60}
|
||||||
|
onTimeUp={handleTimeUp}
|
||||||
|
autoStart={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSubmitExam}
|
||||||
|
className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Testi Teslim Et
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="max-w-7xl mx-auto p-4">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Document Viewer */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||||
|
<div className="flex items-center space-x-2 mb-4">
|
||||||
|
{exam.testDocument?.type === "pdf" ? (
|
||||||
|
<FaFileAlt className="w-5 h-5 text-red-600" />
|
||||||
|
) : (
|
||||||
|
<FaImage className="w-5 h-5 text-blue-600" />
|
||||||
|
)}
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">
|
||||||
|
Test Dokümanı
|
||||||
|
</h3>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
({exam.testDocument?.name})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{exam.testDocument?.type === "pdf" ? (
|
||||||
|
<div
|
||||||
|
className="border border-gray-300 rounded-lg overflow-hidden"
|
||||||
|
style={{ height: "600px" }}
|
||||||
|
>
|
||||||
|
<iframe
|
||||||
|
src={exam.testDocument.url}
|
||||||
|
className="w-full h-full"
|
||||||
|
title="Test PDF"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border border-gray-300 rounded-lg overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={exam.testDocument?.url}
|
||||||
|
alt="Test Image"
|
||||||
|
className="w-full h-auto max-h-96 object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Answer Key */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
|
Cevap Anahtarı
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-6 max-h-96 overflow-y-auto">
|
||||||
|
{exam.answerKeyTemplate?.map((item) => {
|
||||||
|
const isAnswered =
|
||||||
|
answerKeyResponses[item.id] &&
|
||||||
|
(Array.isArray(answerKeyResponses[item.id])
|
||||||
|
? (answerKeyResponses[item.id] as string[]).length > 0
|
||||||
|
: answerKeyResponses[item.id].toString().trim() !== "");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={`p-4 border-2 rounded-lg transition-all ${
|
||||||
|
isAnswered
|
||||||
|
? "border-green-200 bg-green-50"
|
||||||
|
: "border-gray-200 bg-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="bg-blue-100 text-blue-800 text-sm font-medium px-2 py-1 rounded">
|
||||||
|
Soru {item.questionNumber}
|
||||||
|
</span>
|
||||||
|
<span className="bg-green-100 text-green-800 text-sm font-medium px-2 py-1 rounded">
|
||||||
|
{item.points} Puan
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isAnswered && (
|
||||||
|
<FaCheckCircle className="w-5 h-5 text-green-600" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{renderAnswerKeyItem(item)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Summary */}
|
||||||
|
<div className="mt-6 pt-4 border-t border-gray-200">
|
||||||
|
<div className="flex items-center justify-between text-sm text-gray-600">
|
||||||
|
<span>
|
||||||
|
İlerleme: {getAnsweredCount()} /{" "}
|
||||||
|
{exam.answerKeyTemplate?.length || 0}
|
||||||
|
</span>
|
||||||
|
<span>Toplam Puan: {exam.totalPoints}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 w-full bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||||
|
style={{
|
||||||
|
width: `${
|
||||||
|
(getAnsweredCount() /
|
||||||
|
(exam.answerKeyTemplate?.length || 1)) *
|
||||||
|
100
|
||||||
|
}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
128
ui/src/views/coordinator/ExamInterface/QuestionRenderer.tsx
Normal file
128
ui/src/views/coordinator/ExamInterface/QuestionRenderer.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { CalculationQuestion } from '../QuestionTypes/CalculationQuestion';
|
||||||
|
import { FillBlankQuestion } from '../QuestionTypes/FillBlankQuestion';
|
||||||
|
import { MatchingQuestion } from '../QuestionTypes/MatchingQuestion';
|
||||||
|
import { MultipleAnswerQuestion } from '../QuestionTypes/MultipleAnswerQuestion';
|
||||||
|
import { MultipleChoiceQuestion } from '../QuestionTypes/MultipleChoiceQuestion';
|
||||||
|
import { OpenEndedQuestion } from '../QuestionTypes/OpenEndedQuestion';
|
||||||
|
import { OrderingQuestion } from '../QuestionTypes/OrderingQuestion';
|
||||||
|
import { TrueFalseQuestion } from '../QuestionTypes/TrueFalseQuestion';
|
||||||
|
import { Question, StudentAnswer } from '@/types/coordinator';
|
||||||
|
|
||||||
|
interface QuestionRendererProps {
|
||||||
|
question: Question;
|
||||||
|
answer?: StudentAnswer;
|
||||||
|
onAnswerChange: (questionId: string, answer: string | string[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
showCorrectAnswer?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QuestionRenderer: React.FC<QuestionRendererProps> = ({
|
||||||
|
question,
|
||||||
|
answer,
|
||||||
|
onAnswerChange,
|
||||||
|
disabled = false,
|
||||||
|
showCorrectAnswer = false
|
||||||
|
}) => {
|
||||||
|
const renderQuestion = () => {
|
||||||
|
const commonProps = {
|
||||||
|
question,
|
||||||
|
answer,
|
||||||
|
onAnswerChange,
|
||||||
|
disabled,
|
||||||
|
showCorrectAnswer
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (question.type) {
|
||||||
|
case 'multiple-choice':
|
||||||
|
return <MultipleChoiceQuestion {...commonProps} />;
|
||||||
|
|
||||||
|
case 'fill-blank':
|
||||||
|
return <FillBlankQuestion {...commonProps} />;
|
||||||
|
|
||||||
|
case 'true-false':
|
||||||
|
return <TrueFalseQuestion {...commonProps} />;
|
||||||
|
|
||||||
|
case 'open-ended':
|
||||||
|
return <OpenEndedQuestion {...commonProps} />;
|
||||||
|
|
||||||
|
case 'multiple-answer':
|
||||||
|
return <MultipleAnswerQuestion {...commonProps} />;
|
||||||
|
|
||||||
|
case 'matching':
|
||||||
|
return <MatchingQuestion {...commonProps} />;
|
||||||
|
|
||||||
|
case 'ordering':
|
||||||
|
return <OrderingQuestion {...commonProps} />;
|
||||||
|
|
||||||
|
case 'calculation':
|
||||||
|
return <CalculationQuestion {...commonProps} />;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div className="p-4 border border-gray-200 rounded-lg bg-gray-50">
|
||||||
|
<p className="text-gray-500">Desteklenmeyen soru tipi: {question.type}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
{getQuestionTypeLabel(question.type)}
|
||||||
|
</span>
|
||||||
|
{question.points && (
|
||||||
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||||
|
{question.points} Puan
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{question.difficulty && (
|
||||||
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
question.difficulty === 'easy'
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: question.difficulty === 'medium'
|
||||||
|
? 'bg-yellow-100 text-yellow-800'
|
||||||
|
: 'bg-red-100 text-red-800'
|
||||||
|
}`}>
|
||||||
|
{getDifficultyLabel(question.difficulty)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{question.timeLimit && (
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
Süre: {question.timeLimit} dk
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{renderQuestion()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getQuestionTypeLabel = (type: string): string => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
'multiple-choice': 'Çoktan Seçmeli',
|
||||||
|
'fill-blank': 'Boşluk Doldurma',
|
||||||
|
'true-false': 'Doğru-Yanlış',
|
||||||
|
'open-ended': 'Açık Uçlu',
|
||||||
|
'multiple-answer': 'Çok Yanıtlı',
|
||||||
|
'matching': 'Eşleştirme',
|
||||||
|
'ordering': 'Sıralama',
|
||||||
|
'calculation': 'Hesaplama'
|
||||||
|
};
|
||||||
|
return labels[type] || type;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDifficultyLabel = (difficulty: string): string => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
'easy': 'Kolay',
|
||||||
|
'medium': 'Orta',
|
||||||
|
'hard': 'Zor'
|
||||||
|
};
|
||||||
|
return labels[difficulty] || difficulty;
|
||||||
|
};
|
||||||
81
ui/src/views/coordinator/ExamInterface/SecurityWarning.tsx
Normal file
81
ui/src/views/coordinator/ExamInterface/SecurityWarning.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface SecurityWarningProps {
|
||||||
|
isVisible: boolean;
|
||||||
|
onDismiss: () => void;
|
||||||
|
message: string;
|
||||||
|
type: 'warning' | 'error' | 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SecurityWarning: React.FC<SecurityWarningProps> = ({
|
||||||
|
isVisible,
|
||||||
|
onDismiss,
|
||||||
|
message,
|
||||||
|
type = 'warning'
|
||||||
|
}) => {
|
||||||
|
if (!isVisible) return null;
|
||||||
|
|
||||||
|
const getColors = () => {
|
||||||
|
switch (type) {
|
||||||
|
case 'error':
|
||||||
|
return 'bg-red-50 border-red-200 text-red-800';
|
||||||
|
case 'info':
|
||||||
|
return 'bg-blue-50 border-blue-200 text-blue-800';
|
||||||
|
default:
|
||||||
|
return 'bg-yellow-50 border-yellow-200 text-yellow-800';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIcon = () => {
|
||||||
|
switch (type) {
|
||||||
|
case 'error':
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'info':
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5 text-blue-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed top-4 left-1/2 transform -translate-x-1/2 z-50 max-w-md w-full mx-4">
|
||||||
|
<div className={`border rounded-lg p-4 ${getColors()}`}>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{getIcon()}
|
||||||
|
</div>
|
||||||
|
<div className="ml-3">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto pl-3">
|
||||||
|
<div className="-mx-1.5 -my-1.5">
|
||||||
|
<button
|
||||||
|
onClick={onDismiss}
|
||||||
|
className="inline-flex rounded-md p-1.5 hover:bg-yellow-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-yellow-50 focus:ring-yellow-600"
|
||||||
|
>
|
||||||
|
<span className="sr-only">Dismiss</span>
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
250
ui/src/views/coordinator/ExamInterface/StudentExamInterface.tsx
Normal file
250
ui/src/views/coordinator/ExamInterface/StudentExamInterface.tsx
Normal file
|
|
@ -0,0 +1,250 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { QuestionRenderer } from './QuestionRenderer';
|
||||||
|
import { ExamNavigation } from './ExamNavigation';
|
||||||
|
import { ExamTimer } from './ExamTimer';
|
||||||
|
import { SecurityWarning } from './SecurityWarning';
|
||||||
|
import { Exam, ExamSession, StudentAnswer } from '@/types/coordinator';
|
||||||
|
import { useExamSecurity } from '@/utils/hooks/useExamSecurity';
|
||||||
|
|
||||||
|
interface StudentExamInterfaceProps {
|
||||||
|
exam: Exam;
|
||||||
|
studentId: string;
|
||||||
|
onExamComplete: (session: ExamSession) => void;
|
||||||
|
onExamSave?: (session: ExamSession) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StudentExamInterface: React.FC<StudentExamInterfaceProps> = ({
|
||||||
|
exam,
|
||||||
|
studentId,
|
||||||
|
onExamComplete,
|
||||||
|
onExamSave
|
||||||
|
}) => {
|
||||||
|
const [session, setSession] = useState<ExamSession>({
|
||||||
|
id: `session-${Date.now()}`,
|
||||||
|
examId: exam.id,
|
||||||
|
studentId,
|
||||||
|
startTime: new Date(),
|
||||||
|
answers: [],
|
||||||
|
status: 'in-progress',
|
||||||
|
timeRemaining: exam.timeLimit * 60
|
||||||
|
});
|
||||||
|
|
||||||
|
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||||
|
const [securityWarning, setSecurityWarning] = useState<{
|
||||||
|
show: boolean;
|
||||||
|
message: string;
|
||||||
|
type: 'warning' | 'error' | 'info';
|
||||||
|
}>({ show: false, message: '', type: 'warning' });
|
||||||
|
|
||||||
|
// Security configuration
|
||||||
|
const securityConfig = {
|
||||||
|
disableRightClick: true,
|
||||||
|
disableCopyPaste: true,
|
||||||
|
disableDevTools: true,
|
||||||
|
fullScreenMode: true,
|
||||||
|
preventTabSwitch: true
|
||||||
|
};
|
||||||
|
|
||||||
|
useExamSecurity(securityConfig, session.status === 'in-progress');
|
||||||
|
|
||||||
|
// Auto-save mechanism
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (session.status === 'in-progress' && onExamSave) {
|
||||||
|
onExamSave(session);
|
||||||
|
}
|
||||||
|
}, 30000); // Save every 30 seconds
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [session, onExamSave]);
|
||||||
|
|
||||||
|
const handleAnswerChange = (questionId: string, answer: string | string[]) => {
|
||||||
|
setSession(prev => {
|
||||||
|
const existingAnswerIndex = prev.answers.findIndex(a => a.questionId === questionId);
|
||||||
|
const newAnswer: StudentAnswer = {
|
||||||
|
questionId,
|
||||||
|
answer,
|
||||||
|
timeSpent: 0 // In a real app, track time spent per question
|
||||||
|
};
|
||||||
|
|
||||||
|
const newAnswers = existingAnswerIndex >= 0
|
||||||
|
? prev.answers.map((a, i) => i === existingAnswerIndex ? newAnswer : a)
|
||||||
|
: [...prev.answers, newAnswer];
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
answers: newAnswers
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimeUp = () => {
|
||||||
|
setSecurityWarning({
|
||||||
|
show: true,
|
||||||
|
message: 'Süre doldu! Sınav otomatik olarak teslim ediliyor...',
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
completeExam();
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const completeExam = () => {
|
||||||
|
const completedSession: ExamSession = {
|
||||||
|
...session,
|
||||||
|
endTime: new Date(),
|
||||||
|
status: 'completed'
|
||||||
|
};
|
||||||
|
|
||||||
|
setSession(completedSession);
|
||||||
|
onExamComplete(completedSession);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmitExam = () => {
|
||||||
|
const unanswered = exam.questions.filter(q =>
|
||||||
|
!session.answers.find(a => a.questionId === q.id && a.answer)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (unanswered.length > 0) {
|
||||||
|
const confirmSubmit = window.confirm(
|
||||||
|
`${unanswered.length} soru cevaplanmamış. Yine de sınavı teslim etmek istiyor musunuz?`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmSubmit) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
completeExam();
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentQuestion = exam.questions[currentQuestionIndex];
|
||||||
|
const currentAnswer = session.answers.find(a => a.questionId === currentQuestion?.id);
|
||||||
|
|
||||||
|
const navigateQuestion = (direction: 'next' | 'prev' | number) => {
|
||||||
|
if (typeof direction === 'number') {
|
||||||
|
setCurrentQuestionIndex(Math.max(0, Math.min(exam.questions.length - 1, direction)));
|
||||||
|
} else if (direction === 'next') {
|
||||||
|
setCurrentQuestionIndex(prev => Math.min(exam.questions.length - 1, prev + 1));
|
||||||
|
} else {
|
||||||
|
setCurrentQuestionIndex(prev => Math.max(0, prev - 1));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (session.status === 'completed') {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||||
|
<div className="max-w-md w-full bg-white rounded-lg shadow-lg p-6 text-center">
|
||||||
|
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">Sınav Tamamlandı!</h2>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
Sınavınız başarıyla teslim edildi. Sonuçlarınız değerlendirildikten sonra bilgilendirileceksiniz.
|
||||||
|
</p>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
Başlama: {session.startTime.toLocaleString('tr-TR')}<br />
|
||||||
|
Bitiş: {session.endTime?.toLocaleString('tr-TR')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<SecurityWarning
|
||||||
|
isVisible={securityWarning.show}
|
||||||
|
onDismiss={() => setSecurityWarning(prev => ({ ...prev, show: false }))}
|
||||||
|
message={securityWarning.message}
|
||||||
|
type={securityWarning.type}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<header className="bg-white border-b border-gray-200 px-4 py-3">
|
||||||
|
<div className="max-w-7xl mx-auto flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-gray-900">{exam.title}</h1>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Soru {currentQuestionIndex + 1} / {exam.questions.length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<ExamTimer
|
||||||
|
initialTime={exam.timeLimit * 60}
|
||||||
|
onTimeUp={handleTimeUp}
|
||||||
|
autoStart={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSubmitExam}
|
||||||
|
className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Sınavı Teslim Et
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="max-w-7xl mx-auto p-4">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||||
|
{/* Question Content */}
|
||||||
|
<div className="lg:col-span-3 space-y-6">
|
||||||
|
{currentQuestion && (
|
||||||
|
<QuestionRenderer
|
||||||
|
question={currentQuestion}
|
||||||
|
answer={currentAnswer}
|
||||||
|
onAnswerChange={handleAnswerChange}
|
||||||
|
disabled={session.status !== 'in-progress'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Navigation Controls */}
|
||||||
|
<div className="flex items-center justify-between bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<button
|
||||||
|
onClick={() => navigateQuestion('prev')}
|
||||||
|
disabled={currentQuestionIndex === 0}
|
||||||
|
className="flex items-center space-x-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 disabled:bg-gray-50 disabled:text-gray-400 text-gray-700 rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
<span>Önceki</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
{currentQuestionIndex + 1} / {exam.questions.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => navigateQuestion('next')}
|
||||||
|
disabled={currentQuestionIndex === exam.questions.length - 1}
|
||||||
|
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-50 disabled:text-gray-400 text-white rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<span>Sonraki</span>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Sidebar */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<ExamNavigation
|
||||||
|
questions={exam.questions}
|
||||||
|
answers={session.answers}
|
||||||
|
currentQuestionIndex={currentQuestionIndex}
|
||||||
|
onQuestionSelect={navigateQuestion}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
245
ui/src/views/coordinator/Exams.tsx
Normal file
245
ui/src/views/coordinator/Exams.tsx
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { ExamCreator } from './AdminPanel/ExamCreator'
|
||||||
|
import {
|
||||||
|
FaPlus,
|
||||||
|
FaSearch,
|
||||||
|
FaFilter,
|
||||||
|
FaEdit,
|
||||||
|
FaTrash,
|
||||||
|
FaClock,
|
||||||
|
FaUsers,
|
||||||
|
FaCalendar,
|
||||||
|
FaPlay,
|
||||||
|
} from 'react-icons/fa'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { generateMockExam } from '@/mocks/mockExams'
|
||||||
|
import { generateMockPools } from '@/mocks/mockPools'
|
||||||
|
import { Exam, QuestionPool } from '@/types/coordinator'
|
||||||
|
|
||||||
|
const Exams: React.FC = () => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [exams, setExams] = useState<Exam[]>(generateMockExam())
|
||||||
|
const [pools] = useState<QuestionPool[]>(generateMockPools())
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const [statusFilter, setStatusFilter] = useState('')
|
||||||
|
const [isCreating, setIsCreating] = useState(false)
|
||||||
|
const [editingExam, setEditingExam] = useState<Exam | null>(null)
|
||||||
|
|
||||||
|
const examList = exams.filter((exam) => exam.type === 'exam')
|
||||||
|
|
||||||
|
const filteredExams = examList.filter((exam) => {
|
||||||
|
const matchesSearch =
|
||||||
|
exam.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
exam.description.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
const matchesStatus =
|
||||||
|
!statusFilter ||
|
||||||
|
(statusFilter === 'active' && exam.isActive) ||
|
||||||
|
(statusFilter === 'inactive' && !exam.isActive)
|
||||||
|
return matchesSearch && matchesStatus
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleCreateExam = (
|
||||||
|
examData: Omit<Exam, 'id' | 'creationTime' | 'lastModificationTime'>,
|
||||||
|
) => {
|
||||||
|
const newExam: Exam = {
|
||||||
|
...examData,
|
||||||
|
type: 'exam',
|
||||||
|
id: `exam-${Date.now()}`,
|
||||||
|
creationTime: new Date(),
|
||||||
|
lastModificationTime: new Date(),
|
||||||
|
}
|
||||||
|
setExams((prev) => [...prev, newExam])
|
||||||
|
setIsCreating(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateExam = (
|
||||||
|
examData: Omit<Exam, 'id' | 'creationTime' | 'lastModificationTime'>,
|
||||||
|
) => {
|
||||||
|
if (editingExam) {
|
||||||
|
const updatedExam: Exam = {
|
||||||
|
...examData,
|
||||||
|
id: editingExam.id,
|
||||||
|
creationTime: editingExam.creationTime,
|
||||||
|
lastModificationTime: new Date(),
|
||||||
|
}
|
||||||
|
setExams((prev) => prev.map((exam) => (exam.id === updatedExam.id ? updatedExam : exam)))
|
||||||
|
setEditingExam(null)
|
||||||
|
setIsCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (exam: Exam) => {
|
||||||
|
setEditingExam(exam)
|
||||||
|
setIsCreating(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setIsCreating(false)
|
||||||
|
setEditingExam(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCreating) {
|
||||||
|
return (
|
||||||
|
<ExamCreator
|
||||||
|
pools={pools}
|
||||||
|
onCreateExam={editingExam ? handleUpdateExam : handleCreateExam}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
editingExam={editingExam || undefined}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">Sınav Yönetimi</h1>
|
||||||
|
<p className="text-sm text-gray-600 mt-0.5">Sınavları 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 Sınav</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="Sınav ara..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="pl-8 text-sm w-full 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={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="pl-8 text-sm w-full 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 durumlar</option>
|
||||||
|
<option value="active">Aktif</option>
|
||||||
|
<option value="inactive">Pasif</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-gray-600 flex items-center">
|
||||||
|
Toplam: {filteredExams.length} sınav
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Exams List */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filteredExams.length > 0 ? (
|
||||||
|
filteredExams.map((exam) => (
|
||||||
|
<div
|
||||||
|
key={exam.id}
|
||||||
|
className="bg-white border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center space-x-2 mb-1.5">
|
||||||
|
<h3 className="text-base font-semibold text-gray-900">{exam.title}</h3>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
exam.isActive ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{exam.isActive ? 'Aktif' : 'Pasif'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-600 mb-2">{exam.description}</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<FaClock className="w-4 h-4 text-gray-400" />
|
||||||
|
<span className="text-gray-600">{exam.timeLimit} dakika</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<FaUsers className="w-4 h-4 text-gray-400" />
|
||||||
|
<span className="text-gray-600">{exam.questions.length} soru</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<FaCalendar className="w-4 h-4 text-gray-400" />
|
||||||
|
<span className="text-gray-600">Toplam: {exam.totalPoints} puan</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="text-gray-600">Geçme: %{exam.passingScore}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{exam.startTime && exam.endTime && (
|
||||||
|
<div className="mt-2 p-2 bg-blue-50 rounded">
|
||||||
|
<div className="text-xs text-blue-700">
|
||||||
|
<strong>Başlangıç:</strong> {exam.startTime.toLocaleString('tr-TR')} -
|
||||||
|
<strong> Bitiş:</strong> {exam.endTime.toLocaleString('tr-TR')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-1.5 ml-4">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/exam/${exam.id}`)}
|
||||||
|
className="flex items-center space-x-1 px-2.5 py-1.5 bg-green-600 hover:bg-green-700 text-white text-xs rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<FaPlay className="w-3 h-3" />
|
||||||
|
<span>Başlat</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(exam)}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
title="Düzenle"
|
||||||
|
>
|
||||||
|
<FaEdit className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm('Bu sınavı silmek istediğinizden emin misiniz?')) {
|
||||||
|
setExams((prev) => prev.filter((e) => e.id !== exam.id))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
title="Sil"
|
||||||
|
>
|
||||||
|
<FaTrash className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<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">Sınav bulunamadı</h3>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{searchTerm || statusFilter
|
||||||
|
? 'Arama kriterlerinizi değiştirin veya yeni sınav oluşturun.'
|
||||||
|
: 'İlk sınavınızı oluşturun.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Exams
|
||||||
116
ui/src/views/coordinator/QuestionTypes/CalculationQuestion.tsx
Normal file
116
ui/src/views/coordinator/QuestionTypes/CalculationQuestion.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { Question, StudentAnswer } from '@/types/coordinator';
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface CalculationQuestionProps {
|
||||||
|
question: Question;
|
||||||
|
answer?: StudentAnswer;
|
||||||
|
onAnswerChange: (questionId: string, answer: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
showCorrectAnswer?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CalculationQuestion: React.FC<CalculationQuestionProps> = ({
|
||||||
|
question,
|
||||||
|
answer,
|
||||||
|
onAnswerChange,
|
||||||
|
disabled = false,
|
||||||
|
showCorrectAnswer = false
|
||||||
|
}) => {
|
||||||
|
const [result, setResult] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (answer?.answer) {
|
||||||
|
setResult(answer.answer as string);
|
||||||
|
}
|
||||||
|
}, [answer?.answer]);
|
||||||
|
|
||||||
|
const handleResultChange = (value: string) => {
|
||||||
|
if (!disabled) {
|
||||||
|
setResult(value);
|
||||||
|
onAnswerChange(question.id, value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const correctAnswer = question.correctAnswer as string || '';
|
||||||
|
|
||||||
|
const isResultCorrect = showCorrectAnswer &&
|
||||||
|
result.trim() === correctAnswer.trim();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="prose max-w-none">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
|
{question.title}
|
||||||
|
</h3>
|
||||||
|
{question.content && (
|
||||||
|
<p className="text-gray-700 mb-4">{question.content}</p>
|
||||||
|
)}
|
||||||
|
{question.mediaUrl && (
|
||||||
|
<div className="mb-4">
|
||||||
|
{question.mediaType === 'image' ? (
|
||||||
|
<img
|
||||||
|
src={question.mediaUrl}
|
||||||
|
alt="Question media"
|
||||||
|
className="max-w-full h-auto rounded-lg shadow-sm"
|
||||||
|
/>
|
||||||
|
) : question.mediaType === 'video' ? (
|
||||||
|
<video
|
||||||
|
src={question.mediaUrl}
|
||||||
|
controls
|
||||||
|
className="max-w-full h-auto rounded-lg shadow-sm"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Cevap (Sayısal):
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={result}
|
||||||
|
onChange={(e) => handleResultChange(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder="Sayısal cevabınızı yazın (örn: 3, 15.5)"
|
||||||
|
className={`w-full text-sm p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||||
|
disabled ? 'bg-gray-50 cursor-not-allowed' : 'bg-white'
|
||||||
|
} ${
|
||||||
|
isResultCorrect
|
||||||
|
? 'border-green-500 bg-green-50'
|
||||||
|
: showCorrectAnswer && result
|
||||||
|
? 'border-red-500 bg-red-50'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCorrectAnswer && correctAnswer && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<h4 className="text-sm font-medium text-blue-800 mb-2">
|
||||||
|
Doğru Cevap:
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-blue-700 font-mono">
|
||||||
|
{correctAnswer}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{question.explanation && showCorrectAnswer && (
|
||||||
|
<div className="mt-4 p-4 bg-gray-50 border border-gray-200 rounded-lg">
|
||||||
|
<h4 className="text-sm font-medium text-gray-800 mb-2">
|
||||||
|
Açıklama:
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-700">
|
||||||
|
{question.explanation}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
115
ui/src/views/coordinator/QuestionTypes/FillBlankQuestion.tsx
Normal file
115
ui/src/views/coordinator/QuestionTypes/FillBlankQuestion.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
import { Question, StudentAnswer } from '@/types/coordinator';
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface FillBlankQuestionProps {
|
||||||
|
question: Question;
|
||||||
|
answer?: StudentAnswer;
|
||||||
|
onAnswerChange: (questionId: string, answer: string[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
showCorrectAnswer?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FillBlankQuestion: React.FC<FillBlankQuestionProps> = ({
|
||||||
|
question,
|
||||||
|
answer,
|
||||||
|
onAnswerChange,
|
||||||
|
disabled = false,
|
||||||
|
showCorrectAnswer = false
|
||||||
|
}) => {
|
||||||
|
const [blanks, setBlanks] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Parse content to find blanks marked with _____ or [blank]
|
||||||
|
const parseContent = (content: string): { parts: string[]; blankCount: number } => {
|
||||||
|
const parts = content.split(/(_____|\[blank\])/g);
|
||||||
|
const blankCount = parts.filter(part => part === '_____' || part === '[blank]').length;
|
||||||
|
return { parts, blankCount };
|
||||||
|
};
|
||||||
|
|
||||||
|
const { parts, blankCount } = parseContent(question.content);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (answer?.answer && Array.isArray(answer.answer)) {
|
||||||
|
setBlanks(answer.answer);
|
||||||
|
} else {
|
||||||
|
setBlanks(new Array(blankCount).fill(''));
|
||||||
|
}
|
||||||
|
}, [answer?.answer, blankCount]);
|
||||||
|
|
||||||
|
const handleBlankChange = (index: number, value: string) => {
|
||||||
|
if (!disabled) {
|
||||||
|
const newBlanks = [...blanks];
|
||||||
|
newBlanks[index] = value;
|
||||||
|
setBlanks(newBlanks);
|
||||||
|
onAnswerChange(question.id, newBlanks);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const correctAnswers = (question.correctAnswer as string || '').split('|').filter(Boolean);
|
||||||
|
let blankIndex = 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="prose max-w-none">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
|
{question.title}
|
||||||
|
</h3>
|
||||||
|
{question.mediaUrl && (
|
||||||
|
<div className="mb-4">
|
||||||
|
{question.mediaType === 'image' ? (
|
||||||
|
<img
|
||||||
|
src={question.mediaUrl}
|
||||||
|
alt="Question media"
|
||||||
|
className="max-w-full h-auto rounded-lg shadow-sm"
|
||||||
|
/>
|
||||||
|
) : question.mediaType === 'video' ? (
|
||||||
|
<video
|
||||||
|
src={question.mediaUrl}
|
||||||
|
controls
|
||||||
|
className="max-w-full h-auto rounded-lg shadow-sm"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-base leading-relaxed">
|
||||||
|
{parts.map((part, index) => {
|
||||||
|
if (part === '_____' || part === '[blank]') {
|
||||||
|
const currentBlankIndex = blankIndex++;
|
||||||
|
const currentAnswer = blanks[currentBlankIndex] || '';
|
||||||
|
const correctAnswer = correctAnswers[currentBlankIndex] || '';
|
||||||
|
const isCorrect = showCorrectAnswer && currentAnswer.toLowerCase().trim() === correctAnswer.toLowerCase().trim();
|
||||||
|
const isIncorrect = showCorrectAnswer && currentAnswer && !isCorrect;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span key={index} className="inline-block mx-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={currentAnswer}
|
||||||
|
onChange={(e) => handleBlankChange(currentBlankIndex, e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`inline-block text-sm px-3 py-1 border-b-2 bg-transparent focus:outline-none focus:border-blue-500 min-w-24 text-center ${
|
||||||
|
disabled ? 'cursor-not-allowed opacity-50' : ''
|
||||||
|
} ${
|
||||||
|
isCorrect
|
||||||
|
? 'border-green-500 text-green-700'
|
||||||
|
: isIncorrect
|
||||||
|
? 'border-red-500 text-red-700'
|
||||||
|
: 'border-gray-300'
|
||||||
|
}`}
|
||||||
|
placeholder="____"
|
||||||
|
/>
|
||||||
|
{showCorrectAnswer && correctAnswer && (
|
||||||
|
<div className="text-xs text-gray-600 mt-1 text-center">
|
||||||
|
{isCorrect ? '✓' : `Doğru cevap: ${correctAnswer}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span key={index}>{part}</span>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
173
ui/src/views/coordinator/QuestionTypes/MatchingQuestion.tsx
Normal file
173
ui/src/views/coordinator/QuestionTypes/MatchingQuestion.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
import { Question, StudentAnswer } from '@/types/coordinator';
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface MatchingQuestionProps {
|
||||||
|
question: Question;
|
||||||
|
answer?: StudentAnswer;
|
||||||
|
onAnswerChange: (questionId: string, answer: string[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
showCorrectAnswer?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MatchingPair {
|
||||||
|
id: string;
|
||||||
|
left: string;
|
||||||
|
right: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MatchingQuestion: React.FC<MatchingQuestionProps> = ({
|
||||||
|
question,
|
||||||
|
answer,
|
||||||
|
onAnswerChange,
|
||||||
|
disabled = false,
|
||||||
|
showCorrectAnswer = false
|
||||||
|
}) => {
|
||||||
|
const [matches, setMatches] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// Parse options into left and right items
|
||||||
|
const matchingPairs: MatchingPair[] = question.options?.map(option => ({
|
||||||
|
id: option.id,
|
||||||
|
left: option.text.split('|')[0] || option.text,
|
||||||
|
right: option.text.split('|')[1] || ''
|
||||||
|
})) || [];
|
||||||
|
|
||||||
|
const leftItems = matchingPairs.map(pair => ({ id: pair.id, text: pair.left }));
|
||||||
|
const rightItems = matchingPairs
|
||||||
|
.map(pair => ({ id: pair.id, text: pair.right }))
|
||||||
|
.sort(() => Math.random() - 0.5); // Shuffle right items
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (answer?.answer && Array.isArray(answer.answer)) {
|
||||||
|
const matchObj: Record<string, string> = {};
|
||||||
|
answer.answer.forEach((match, index) => {
|
||||||
|
if (leftItems[index]) {
|
||||||
|
matchObj[leftItems[index].id] = match;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setMatches(matchObj);
|
||||||
|
}
|
||||||
|
}, [answer?.answer]);
|
||||||
|
|
||||||
|
const handleMatch = (leftId: string, rightId: string) => {
|
||||||
|
if (!disabled) {
|
||||||
|
const newMatches = { ...matches };
|
||||||
|
|
||||||
|
// Remove any existing match for this right item
|
||||||
|
Object.keys(newMatches).forEach(key => {
|
||||||
|
if (newMatches[key] === rightId) {
|
||||||
|
delete newMatches[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add new match
|
||||||
|
newMatches[leftId] = rightId;
|
||||||
|
setMatches(newMatches);
|
||||||
|
|
||||||
|
// Convert to array format
|
||||||
|
const answerArray = leftItems.map(item => newMatches[item.id] || '');
|
||||||
|
onAnswerChange(question.id, answerArray);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const correctMatches = question.correctAnswer as string[] || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="prose max-w-none">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
|
{question.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 mb-2">
|
||||||
|
Sol taraftaki öğeleri sağ taraftaki uygun öğelerle eşleştirin
|
||||||
|
</p>
|
||||||
|
{question.content && (
|
||||||
|
<p className="text-gray-700 mb-4">{question.content}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-6">
|
||||||
|
{/* Left Column */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="font-medium text-gray-700 mb-3">Sol Taraf</h4>
|
||||||
|
{leftItems.map((item, index) => {
|
||||||
|
const matchedRightId = matches[item.id];
|
||||||
|
const correctRightId = correctMatches[index];
|
||||||
|
const isCorrect = showCorrectAnswer && matchedRightId === correctRightId;
|
||||||
|
const isIncorrect = showCorrectAnswer && matchedRightId && matchedRightId !== correctRightId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={`p-3 rounded-lg border-2 ${
|
||||||
|
isCorrect
|
||||||
|
? 'border-green-500 bg-green-50'
|
||||||
|
: isIncorrect
|
||||||
|
? 'border-red-500 bg-red-50'
|
||||||
|
: matchedRightId
|
||||||
|
? 'border-blue-500 bg-blue-50'
|
||||||
|
: 'border-gray-200 bg-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">{item.text}</span>
|
||||||
|
{matchedRightId && (
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
→ {rightItems.find(r => r.id === matchedRightId)?.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showCorrectAnswer && correctRightId && (
|
||||||
|
<div className="text-xs mt-1 text-gray-600">
|
||||||
|
Doğru eşleşme: {rightItems.find(r => r.id === correctRightId)?.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="font-medium text-gray-700 mb-3">Sağ Taraf</h4>
|
||||||
|
{rightItems.map((item) => {
|
||||||
|
const isMatched = Object.values(matches).includes(item.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.id} className="space-y-2">
|
||||||
|
<div
|
||||||
|
className={`p-3 rounded-lg border-2 cursor-pointer transition-all ${
|
||||||
|
disabled ? 'cursor-not-allowed opacity-50' : ''
|
||||||
|
} ${
|
||||||
|
isMatched
|
||||||
|
? 'border-blue-500 bg-blue-50'
|
||||||
|
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium">{item.text}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Match buttons for each left item */}
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{leftItems.map((leftItem) => (
|
||||||
|
<button
|
||||||
|
key={leftItem.id}
|
||||||
|
onClick={() => handleMatch(leftItem.id, item.id)}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`px-2 py-1 text-xs rounded transition-all ${
|
||||||
|
matches[leftItem.id] === item.id
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||||
|
} ${disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
|
||||||
|
>
|
||||||
|
{leftItem.text.substring(0, 10)}...
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
import { Question, StudentAnswer } from '@/types/coordinator';
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface MultipleAnswerQuestionProps {
|
||||||
|
question: Question;
|
||||||
|
answer?: StudentAnswer;
|
||||||
|
onAnswerChange: (questionId: string, answer: string[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
showCorrectAnswer?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MultipleAnswerQuestion: React.FC<MultipleAnswerQuestionProps> = ({
|
||||||
|
question,
|
||||||
|
answer,
|
||||||
|
onAnswerChange,
|
||||||
|
disabled = false,
|
||||||
|
showCorrectAnswer = false
|
||||||
|
}) => {
|
||||||
|
const [selectedOptions, setSelectedOptions] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (answer?.answer && Array.isArray(answer.answer)) {
|
||||||
|
setSelectedOptions(answer.answer);
|
||||||
|
}
|
||||||
|
}, [answer?.answer]);
|
||||||
|
|
||||||
|
const handleOptionToggle = (optionId: string) => {
|
||||||
|
if (!disabled) {
|
||||||
|
const newSelection = selectedOptions.includes(optionId)
|
||||||
|
? selectedOptions.filter(id => id !== optionId)
|
||||||
|
: [...selectedOptions, optionId];
|
||||||
|
|
||||||
|
setSelectedOptions(newSelection);
|
||||||
|
onAnswerChange(question.id, newSelection);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const correctAnswers = question.correctAnswer as string[] || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="prose max-w-none">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
|
{question.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 mb-2">
|
||||||
|
(Birden fazla seçenek işaretleyebilirsiniz)
|
||||||
|
</p>
|
||||||
|
{question.content && (
|
||||||
|
<p className="text-gray-700 mb-4">{question.content}</p>
|
||||||
|
)}
|
||||||
|
{question.mediaUrl && (
|
||||||
|
<div className="mb-4">
|
||||||
|
{question.mediaType === 'image' ? (
|
||||||
|
<img
|
||||||
|
src={question.mediaUrl}
|
||||||
|
alt="Question media"
|
||||||
|
className="max-w-full h-auto rounded-lg shadow-sm"
|
||||||
|
/>
|
||||||
|
) : question.mediaType === 'video' ? (
|
||||||
|
<video
|
||||||
|
src={question.mediaUrl}
|
||||||
|
controls
|
||||||
|
className="max-w-full h-auto rounded-lg shadow-sm"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{question.options?.map((option) => {
|
||||||
|
const isSelected = selectedOptions.includes(option.id);
|
||||||
|
const isCorrect = showCorrectAnswer && correctAnswers.includes(option.id);
|
||||||
|
const isIncorrect = showCorrectAnswer && isSelected && !correctAnswers.includes(option.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={option.id}
|
||||||
|
className={`relative flex items-center p-4 rounded-lg border-2 cursor-pointer transition-all ${
|
||||||
|
disabled ? 'cursor-not-allowed opacity-50' : ''
|
||||||
|
} ${
|
||||||
|
isSelected
|
||||||
|
? isCorrect
|
||||||
|
? 'border-green-500 bg-green-50'
|
||||||
|
: isIncorrect
|
||||||
|
? 'border-red-500 bg-red-50'
|
||||||
|
: 'border-blue-500 bg-blue-50'
|
||||||
|
: isCorrect && showCorrectAnswer
|
||||||
|
? 'border-green-500 bg-green-50'
|
||||||
|
: 'border-gray-200 hover:border-gray-300 bg-white'
|
||||||
|
}`}
|
||||||
|
onClick={() => handleOptionToggle(option.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => handleOptionToggle(option.id)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<label className="ml-3 text-sm font-medium text-gray-900 cursor-pointer">
|
||||||
|
{option.text}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{showCorrectAnswer && isCorrect && (
|
||||||
|
<div className="absolute right-4 text-green-600">
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { Question, StudentAnswer } from '@/types/coordinator';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface MultipleChoiceQuestionProps {
|
||||||
|
question: Question;
|
||||||
|
answer?: StudentAnswer;
|
||||||
|
onAnswerChange: (questionId: string, answer: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
showCorrectAnswer?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MultipleChoiceQuestion: React.FC<MultipleChoiceQuestionProps> = ({
|
||||||
|
question,
|
||||||
|
answer,
|
||||||
|
onAnswerChange,
|
||||||
|
disabled = false,
|
||||||
|
showCorrectAnswer = false
|
||||||
|
}) => {
|
||||||
|
const selectedAnswer = answer?.answer as string || '';
|
||||||
|
|
||||||
|
const handleOptionSelect = (optionId: string) => {
|
||||||
|
if (!disabled) {
|
||||||
|
onAnswerChange(question.id, optionId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="prose max-w-none">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
|
{question.title}
|
||||||
|
</h3>
|
||||||
|
{question.content && (
|
||||||
|
<p className="text-gray-700 mb-4">{question.content}</p>
|
||||||
|
)}
|
||||||
|
{question.mediaUrl && (
|
||||||
|
<div className="mb-4">
|
||||||
|
{question.mediaType === 'image' ? (
|
||||||
|
<img
|
||||||
|
src={question.mediaUrl}
|
||||||
|
alt="Question media"
|
||||||
|
className="max-w-full h-auto rounded-lg shadow-sm"
|
||||||
|
/>
|
||||||
|
) : question.mediaType === 'video' ? (
|
||||||
|
<video
|
||||||
|
src={question.mediaUrl}
|
||||||
|
controls
|
||||||
|
className="max-w-full h-auto rounded-lg shadow-sm"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{question.options?.map((option) => {
|
||||||
|
const isSelected = selectedAnswer === option.id;
|
||||||
|
const isCorrect = showCorrectAnswer && option.isCorrect;
|
||||||
|
const isIncorrect = showCorrectAnswer && isSelected && !option.isCorrect;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={option.id}
|
||||||
|
className={`relative flex items-center p-4 rounded-lg border-2 cursor-pointer transition-all ${
|
||||||
|
disabled ? 'cursor-not-allowed opacity-50' : ''
|
||||||
|
} ${
|
||||||
|
isSelected
|
||||||
|
? isCorrect
|
||||||
|
? 'border-green-500 bg-green-50'
|
||||||
|
: isIncorrect
|
||||||
|
? 'border-red-500 bg-red-50'
|
||||||
|
: 'border-blue-500 bg-blue-50'
|
||||||
|
: isCorrect && showCorrectAnswer
|
||||||
|
? 'border-green-500 bg-green-50'
|
||||||
|
: 'border-gray-200 hover:border-gray-300 bg-white'
|
||||||
|
}`}
|
||||||
|
onClick={() => handleOptionSelect(option.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`question-${question.id}`}
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => handleOptionSelect(option.id)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
|
||||||
|
/>
|
||||||
|
<label className="ml-3 text-sm font-medium text-gray-900 cursor-pointer">
|
||||||
|
{option.text}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{showCorrectAnswer && isCorrect && (
|
||||||
|
<div className="absolute right-4 text-green-600">
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
105
ui/src/views/coordinator/QuestionTypes/OpenEndedQuestion.tsx
Normal file
105
ui/src/views/coordinator/QuestionTypes/OpenEndedQuestion.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { Question, StudentAnswer } from '@/types/coordinator';
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface OpenEndedQuestionProps {
|
||||||
|
question: Question;
|
||||||
|
answer?: StudentAnswer;
|
||||||
|
onAnswerChange: (questionId: string, answer: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
showCorrectAnswer?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OpenEndedQuestion: React.FC<OpenEndedQuestionProps> = ({
|
||||||
|
question,
|
||||||
|
answer,
|
||||||
|
onAnswerChange,
|
||||||
|
disabled = false,
|
||||||
|
showCorrectAnswer = false
|
||||||
|
}) => {
|
||||||
|
const [text, setText] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (answer?.answer && typeof answer.answer === 'string') {
|
||||||
|
setText(answer.answer);
|
||||||
|
}
|
||||||
|
}, [answer?.answer]);
|
||||||
|
|
||||||
|
const handleTextChange = (value: string) => {
|
||||||
|
if (!disabled) {
|
||||||
|
setText(value);
|
||||||
|
onAnswerChange(question.id, value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const wordCount = text.trim().split(/\s+/).filter(word => word.length > 0).length;
|
||||||
|
const charCount = text.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="prose max-w-none">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
|
{question.title}
|
||||||
|
</h3>
|
||||||
|
{question.content && (
|
||||||
|
<p className="text-gray-700 mb-4">{question.content}</p>
|
||||||
|
)}
|
||||||
|
{question.mediaUrl && (
|
||||||
|
<div className="mb-4">
|
||||||
|
{question.mediaType === 'image' ? (
|
||||||
|
<img
|
||||||
|
src={question.mediaUrl}
|
||||||
|
alt="Question media"
|
||||||
|
className="max-w-full h-auto rounded-lg shadow-sm"
|
||||||
|
/>
|
||||||
|
) : question.mediaType === 'video' ? (
|
||||||
|
<video
|
||||||
|
src={question.mediaUrl}
|
||||||
|
controls
|
||||||
|
className="max-w-full h-auto rounded-lg shadow-sm"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<textarea
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => handleTextChange(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder="Cevabınızı buraya yazın..."
|
||||||
|
className={`w-full text-sm p-4 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-vertical min-h-32 ${
|
||||||
|
disabled ? 'bg-gray-50 cursor-not-allowed' : 'bg-white'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-between text-sm text-gray-500">
|
||||||
|
<span>{wordCount} kelime</span>
|
||||||
|
<span>{charCount} karakter</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCorrectAnswer && question.correctAnswer && (
|
||||||
|
<div className="mt-4 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||||
|
<h4 className="text-sm font-medium text-green-800 mb-2">
|
||||||
|
Örnek Cevap:
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-green-700">
|
||||||
|
{question.correctAnswer as string}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{question.explanation && showCorrectAnswer && (
|
||||||
|
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<h4 className="text-sm font-medium text-blue-800 mb-2">
|
||||||
|
Açıklama:
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-blue-700">
|
||||||
|
{question.explanation}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
191
ui/src/views/coordinator/QuestionTypes/OrderingQuestion.tsx
Normal file
191
ui/src/views/coordinator/QuestionTypes/OrderingQuestion.tsx
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
import { Question, StudentAnswer } from '@/types/coordinator';
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface OrderingQuestionProps {
|
||||||
|
question: Question;
|
||||||
|
answer?: StudentAnswer;
|
||||||
|
onAnswerChange: (questionId: string, answer: string[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
showCorrectAnswer?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrderItem {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
originalOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OrderingQuestion: React.FC<OrderingQuestionProps> = ({
|
||||||
|
question,
|
||||||
|
answer,
|
||||||
|
onAnswerChange,
|
||||||
|
disabled = false,
|
||||||
|
showCorrectAnswer = false
|
||||||
|
}) => {
|
||||||
|
const [orderedItems, setOrderedItems] = useState<OrderItem[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (question.options) {
|
||||||
|
const items: OrderItem[] = question.options.map((option, index) => ({
|
||||||
|
id: option.id,
|
||||||
|
text: option.text,
|
||||||
|
originalOrder: option.order || index
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (answer?.answer && Array.isArray(answer.answer)) {
|
||||||
|
// Restore order from saved answer
|
||||||
|
const orderedIds = answer.answer;
|
||||||
|
const restored = orderedIds
|
||||||
|
.map(id => items.find(item => item.id === id))
|
||||||
|
.filter(Boolean) as OrderItem[];
|
||||||
|
setOrderedItems(restored);
|
||||||
|
} else {
|
||||||
|
// Shuffle items initially
|
||||||
|
const shuffled = [...items].sort(() => Math.random() - 0.5);
|
||||||
|
setOrderedItems(shuffled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [question.options, answer?.answer]);
|
||||||
|
|
||||||
|
const handleDragStart = (e: React.DragEvent, index: number) => {
|
||||||
|
if (!disabled) {
|
||||||
|
e.dataTransfer.setData('text/plain', index.toString());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent, dropIndex: number) => {
|
||||||
|
if (!disabled) {
|
||||||
|
e.preventDefault();
|
||||||
|
const dragIndex = parseInt(e.dataTransfer.getData('text/plain'));
|
||||||
|
|
||||||
|
if (dragIndex !== dropIndex) {
|
||||||
|
const newItems = [...orderedItems];
|
||||||
|
const draggedItem = newItems[dragIndex];
|
||||||
|
newItems.splice(dragIndex, 1);
|
||||||
|
newItems.splice(dropIndex, 0, draggedItem);
|
||||||
|
|
||||||
|
setOrderedItems(newItems);
|
||||||
|
onAnswerChange(question.id, newItems.map(item => item.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveItem = (fromIndex: number, direction: 'up' | 'down') => {
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
const toIndex = direction === 'up' ? fromIndex - 1 : fromIndex + 1;
|
||||||
|
if (toIndex < 0 || toIndex >= orderedItems.length) return;
|
||||||
|
|
||||||
|
const newItems = [...orderedItems];
|
||||||
|
[newItems[fromIndex], newItems[toIndex]] = [newItems[toIndex], newItems[fromIndex]];
|
||||||
|
|
||||||
|
setOrderedItems(newItems);
|
||||||
|
onAnswerChange(question.id, newItems.map(item => item.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const correctOrder = question.correctAnswer as string[] || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="prose max-w-none">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
|
{question.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 mb-2">
|
||||||
|
Aşağıdaki öğeleri doğru sıraya göre düzenleyin
|
||||||
|
</p>
|
||||||
|
{question.content && (
|
||||||
|
<p className="text-gray-700 mb-4">{question.content}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{orderedItems.map((item, index) => {
|
||||||
|
const correctIndex = correctOrder.indexOf(item.id);
|
||||||
|
const isInCorrectPosition = showCorrectAnswer && correctIndex === index;
|
||||||
|
const isInWrongPosition = showCorrectAnswer && correctIndex !== -1 && correctIndex !== index;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
draggable={!disabled}
|
||||||
|
onDragStart={(e) => handleDragStart(e, index)}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={(e) => handleDrop(e, index)}
|
||||||
|
className={`flex items-center p-4 rounded-lg border-2 transition-all ${
|
||||||
|
disabled ? 'cursor-not-allowed opacity-50' : 'cursor-move'
|
||||||
|
} ${
|
||||||
|
isInCorrectPosition
|
||||||
|
? 'border-green-500 bg-green-50'
|
||||||
|
: isInWrongPosition
|
||||||
|
? 'border-red-500 bg-red-50'
|
||||||
|
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-3 flex-1">
|
||||||
|
<div className="flex flex-col space-y-1">
|
||||||
|
<svg className="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
|
||||||
|
</svg>
|
||||||
|
<svg className="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-3 flex-1">
|
||||||
|
<div className="bg-blue-100 text-blue-800 text-sm font-medium px-2 py-1 rounded">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium">{item.text}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!disabled && (
|
||||||
|
<div className="flex flex-col space-y-1">
|
||||||
|
<button
|
||||||
|
onClick={() => moveItem(index, 'up')}
|
||||||
|
disabled={index === 0}
|
||||||
|
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => moveItem(index, 'down')}
|
||||||
|
disabled={index === orderedItems.length - 1}
|
||||||
|
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showCorrectAnswer && (
|
||||||
|
<div className="ml-4">
|
||||||
|
{isInCorrectPosition ? (
|
||||||
|
<div className="text-green-600">
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
) : correctIndex !== -1 ? (
|
||||||
|
<div className="text-red-600 text-xs">
|
||||||
|
Doğru sıra: {correctIndex + 1}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
101
ui/src/views/coordinator/QuestionTypes/TrueFalseQuestion.tsx
Normal file
101
ui/src/views/coordinator/QuestionTypes/TrueFalseQuestion.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { Question, StudentAnswer } from '@/types/coordinator';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface TrueFalseQuestionProps {
|
||||||
|
question: Question;
|
||||||
|
answer?: StudentAnswer;
|
||||||
|
onAnswerChange: (questionId: string, answer: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
showCorrectAnswer?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TrueFalseQuestion: React.FC<TrueFalseQuestionProps> = ({
|
||||||
|
question,
|
||||||
|
answer,
|
||||||
|
onAnswerChange,
|
||||||
|
disabled = false,
|
||||||
|
showCorrectAnswer = false
|
||||||
|
}) => {
|
||||||
|
const selectedAnswer = answer?.answer as string || '';
|
||||||
|
const correctAnswer = question.correctAnswer as string;
|
||||||
|
|
||||||
|
const handleAnswerSelect = (value: string) => {
|
||||||
|
if (!disabled) {
|
||||||
|
onAnswerChange(question.id, value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{ value: 'true', label: 'Doğru' },
|
||||||
|
{ value: 'false', label: 'Yanlış' }
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="prose max-w-none">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
|
{question.title}
|
||||||
|
</h3>
|
||||||
|
{question.content && (
|
||||||
|
<p className="text-gray-700 mb-4">{question.content}</p>
|
||||||
|
)}
|
||||||
|
{question.mediaUrl && (
|
||||||
|
<div className="mb-4">
|
||||||
|
{question.mediaType === 'image' ? (
|
||||||
|
<img
|
||||||
|
src={question.mediaUrl}
|
||||||
|
alt="Question media"
|
||||||
|
className="max-w-full h-auto rounded-lg shadow-sm"
|
||||||
|
/>
|
||||||
|
) : question.mediaType === 'video' ? (
|
||||||
|
<video
|
||||||
|
src={question.mediaUrl}
|
||||||
|
controls
|
||||||
|
className="max-w-full h-auto rounded-lg shadow-sm"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex space-x-4">
|
||||||
|
{options.map((option) => {
|
||||||
|
const isSelected = selectedAnswer === option.value;
|
||||||
|
const isCorrect = showCorrectAnswer && correctAnswer === option.value;
|
||||||
|
const isIncorrect = showCorrectAnswer && isSelected && correctAnswer !== option.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => handleAnswerSelect(option.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`flex-1 flex items-center justify-center p-6 rounded-lg border-2 font-medium transition-all ${
|
||||||
|
disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:shadow-md'
|
||||||
|
} ${
|
||||||
|
isSelected
|
||||||
|
? isCorrect
|
||||||
|
? 'border-green-500 bg-green-50 text-green-700'
|
||||||
|
: isIncorrect
|
||||||
|
? 'border-red-500 bg-red-50 text-red-700'
|
||||||
|
: 'border-blue-500 bg-blue-50 text-blue-700'
|
||||||
|
: isCorrect && showCorrectAnswer
|
||||||
|
? 'border-green-500 bg-green-50 text-green-700'
|
||||||
|
: 'border-gray-200 bg-white text-gray-700 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl mb-2">
|
||||||
|
{option.value === 'true' ? '✓' : '✗'}
|
||||||
|
</div>
|
||||||
|
<div>{option.label}</div>
|
||||||
|
{showCorrectAnswer && isCorrect && (
|
||||||
|
<div className="text-sm mt-2 text-green-600">Doğru Cevap</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
223
ui/src/views/coordinator/Tags.tsx
Normal file
223
ui/src/views/coordinator/Tags.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
import { generateMockTags } from '@/mocks/mockTags'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { FaPlus, FaSearch, FaEdit, FaTrash, FaTag } from 'react-icons/fa'
|
||||||
|
|
||||||
|
interface TagItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
color: string
|
||||||
|
usageCount: number
|
||||||
|
creationTime: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
const Tags: React.FC = () => {
|
||||||
|
const [tags, setTags] = useState<TagItem[]>(generateMockTags())
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const [isCreating, setIsCreating] = useState(false)
|
||||||
|
const [editingTag, setEditingTag] = useState<TagItem | null>(null)
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
color: '#3B82F6',
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredTags = tags.filter(
|
||||||
|
(tag) =>
|
||||||
|
tag.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
tag.description.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!formData.name.trim()) return
|
||||||
|
|
||||||
|
if (editingTag) {
|
||||||
|
setTags((prev) =>
|
||||||
|
prev.map((tag) => (tag.id === editingTag.id ? { ...editingTag, ...formData } : tag)),
|
||||||
|
)
|
||||||
|
setEditingTag(null)
|
||||||
|
} else {
|
||||||
|
const newTag: TagItem = {
|
||||||
|
...formData,
|
||||||
|
id: `tag-${Date.now()}`,
|
||||||
|
usageCount: 0,
|
||||||
|
creationTime: new Date(),
|
||||||
|
}
|
||||||
|
setTags((prev) => [...prev, newTag])
|
||||||
|
setIsCreating(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormData({ name: '', description: '', color: '#3B82F6' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (tag: TagItem) => {
|
||||||
|
setEditingTag(tag)
|
||||||
|
setFormData({
|
||||||
|
name: tag.name,
|
||||||
|
description: tag.description,
|
||||||
|
color: tag.color,
|
||||||
|
})
|
||||||
|
setIsCreating(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setIsCreating(false)
|
||||||
|
setEditingTag(null)
|
||||||
|
setFormData({ name: '', description: '', color: '#3B82F6' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">Etiket Yönetimi</h1>
|
||||||
|
<p className="text-sm text-gray-600 mt-0.5">Soru ve içerik etiketlerini 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 Etiket</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-3">
|
||||||
|
<div className="relative">
|
||||||
|
<FaSearch className="absolute left-3 top-2 h-4 w-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Etiket ara..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="pl-8 text-sm w-full border border-gray-300 rounded-lg px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create/Edit Form */}
|
||||||
|
{(isCreating || editingTag) && (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<h3 className="text-base font-semibold mb-3">
|
||||||
|
{editingTag ? 'Etiket Düzenle' : 'Yeni Etiket Oluştur'}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Etiket Adı</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData((prev) => ({ ...prev, 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="Etiket adını girin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">Renk</label>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={formData.color}
|
||||||
|
onChange={(e) => setFormData((prev) => ({ ...prev, color: e.target.value }))}
|
||||||
|
className="w-full text-sm h-9 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">Açıklama</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
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"
|
||||||
|
placeholder="Açıklama girin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-700 hover:bg-gray-50 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
İptal
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{editingTag ? 'Güncelle' : 'Oluştur'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tags List */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg">
|
||||||
|
{filteredTags.length > 0 ? (
|
||||||
|
<div className="divide-y divide-gray-200">
|
||||||
|
{filteredTags.map((tag) => (
|
||||||
|
<div key={tag.id} className="p-3 hover:bg-gray-50">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-2.5">
|
||||||
|
<div
|
||||||
|
className="w-3.5 h-3.5 rounded-full"
|
||||||
|
style={{ backgroundColor: tag.color }}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-900">{tag.name}</h3>
|
||||||
|
<p className="text-xs text-gray-600">{tag.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<span className="text-xs text-gray-500">{tag.usageCount} kullanım</span>
|
||||||
|
<div className="flex space-x-1">
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(tag)}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<FaEdit className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm('Bu etiketi silmek istediğinizden emin misiniz?')) {
|
||||||
|
setTags((prev) => prev.filter((t) => t.id !== tag.id))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<FaTrash className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<FaTag className="w-10 h-10 text-gray-400 mx-auto mb-3" />
|
||||||
|
<h3 className="text-base font-medium text-gray-900 mb-1">Etiket bulunamadı</h3>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{searchTerm
|
||||||
|
? 'Arama kriterlerinizi değiştirin veya yeni etiket oluşturun.'
|
||||||
|
: 'İlk etiketinizi oluşturun.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Tags
|
||||||
237
ui/src/views/coordinator/Tests.tsx
Normal file
237
ui/src/views/coordinator/Tests.tsx
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { TestCreator } from './AdminPanel/TestCreator'
|
||||||
|
import {
|
||||||
|
FaPlus,
|
||||||
|
FaSearch,
|
||||||
|
FaFilter,
|
||||||
|
FaEdit,
|
||||||
|
FaTrash,
|
||||||
|
FaFileAlt,
|
||||||
|
FaImage,
|
||||||
|
FaCalendar,
|
||||||
|
FaUsers,
|
||||||
|
FaPlay,
|
||||||
|
} from 'react-icons/fa'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Exam } from '@/types/coordinator'
|
||||||
|
import { generateMockPDFTest } from '@/mocks/mockTests'
|
||||||
|
|
||||||
|
const Tests: React.FC = () => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [tests, setTests] = useState<Exam[]>([generateMockPDFTest()])
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const [statusFilter, setStatusFilter] = useState('')
|
||||||
|
const [isCreating, setIsCreating] = useState(false)
|
||||||
|
const [editingTest, setEditingTest] = useState<Exam | null>(null)
|
||||||
|
|
||||||
|
const filteredTests = tests.filter((test) => {
|
||||||
|
const matchesSearch =
|
||||||
|
test.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
test.description.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
const matchesStatus =
|
||||||
|
!statusFilter ||
|
||||||
|
(statusFilter === 'active' && test.isActive) ||
|
||||||
|
(statusFilter === 'inactive' && !test.isActive)
|
||||||
|
return matchesSearch && matchesStatus && test.type === 'test'
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleCreateTest = (
|
||||||
|
testData: Omit<Exam, 'id' | 'creationTime' | 'lastModificationTime'>,
|
||||||
|
) => {
|
||||||
|
const newTest: Exam = {
|
||||||
|
...testData,
|
||||||
|
id: `test-${Date.now()}`,
|
||||||
|
creationTime: new Date(),
|
||||||
|
lastModificationTime: new Date(),
|
||||||
|
}
|
||||||
|
setTests((prev) => [...prev, newTest])
|
||||||
|
setIsCreating(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateTest = (
|
||||||
|
testData: Omit<Exam, 'id' | 'creationTime' | 'lastModificationTime'>,
|
||||||
|
) => {
|
||||||
|
if (editingTest) {
|
||||||
|
const updatedTest: Exam = {
|
||||||
|
...testData,
|
||||||
|
id: editingTest.id,
|
||||||
|
creationTime: editingTest.creationTime,
|
||||||
|
lastModificationTime: new Date(),
|
||||||
|
}
|
||||||
|
setTests((prev) => prev.map((test) => (test.id === updatedTest.id ? updatedTest : test)))
|
||||||
|
setEditingTest(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (test: Exam) => {
|
||||||
|
setEditingTest(test)
|
||||||
|
setIsCreating(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setIsCreating(false)
|
||||||
|
setEditingTest(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCreating) {
|
||||||
|
return (
|
||||||
|
<TestCreator
|
||||||
|
onCreateTest={editingTest ? handleUpdateTest : handleCreateTest}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
editingTest={editingTest || undefined}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">Test Yönetimi</h1>
|
||||||
|
<p className="text-sm text-gray-600 mt-0.5">PDF testler ve cevap anahtarlarını 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 Test</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="Test ara..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="pl-8 text-sm w-full 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={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="pl-8 text-sm w-full 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 durumlar</option>
|
||||||
|
<option value="active">Aktif</option>
|
||||||
|
<option value="inactive">Pasif</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-gray-600 flex items-center">
|
||||||
|
Toplam: {filteredTests.length} test
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tests List */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filteredTests.length > 0 ? (
|
||||||
|
filteredTests.map((test) => (
|
||||||
|
<div
|
||||||
|
key={test.id}
|
||||||
|
className="bg-white border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center space-x-2 mb-1.5">
|
||||||
|
<h3 className="text-base font-semibold text-gray-900">{test.title}</h3>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
test.isActive ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{test.isActive ? 'Aktif' : 'Pasif'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-600 mb-2">{test.description}</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{test.testDocument?.type === 'pdf' ? (
|
||||||
|
<FaFileAlt className="w-4 h-4 text-red-600" />
|
||||||
|
) : (
|
||||||
|
<FaImage className="w-4 h-4 text-blue-600" />
|
||||||
|
)}
|
||||||
|
<span className="text-gray-600">
|
||||||
|
{test.testDocument?.type === 'pdf' ? 'PDF' : 'Resim'}
|
||||||
|
<span>: {test.testDocument?.name}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<FaCalendar className="w-4 h-4 text-gray-400" />
|
||||||
|
<span className="text-gray-600">{test.timeLimit} dakika</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<FaUsers className="w-4 h-4 text-gray-400" />
|
||||||
|
<span className="text-gray-600">
|
||||||
|
{test.answerKeyTemplate?.length || 0} soru
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="text-gray-600">Toplam: {test.totalPoints} puan</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-1.5 ml-4">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/test/${test.id}`)}
|
||||||
|
className="flex items-center space-x-1 px-2.5 py-1.5 bg-green-600 hover:bg-green-700 text-white text-xs rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<FaPlay className="w-3 h-3" />
|
||||||
|
<span>Başlat</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(test)}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
title="Düzenle"
|
||||||
|
>
|
||||||
|
<FaEdit className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm('Bu testi silmek istediğinizden emin misiniz?')) {
|
||||||
|
setTests((prev) => prev.filter((t) => t.id !== test.id))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
title="Sil"
|
||||||
|
>
|
||||||
|
<FaTrash className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<FaFileAlt className="w-10 h-10 text-gray-400 mx-auto mb-3" />
|
||||||
|
<h3 className="text-base font-medium text-gray-900 mb-1">Test bulunamadı</h3>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{searchTerm || statusFilter
|
||||||
|
? 'Arama kriterlerinizi değiştirin veya yeni test oluşturun.'
|
||||||
|
: 'İlk testinizi oluşturun.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Tests
|
||||||
Loading…
Reference in a new issue