Notenschlüssel-Editor (1.3) und Einstellungen in Tabs strukturiert

- Notenschlüssel-Editor im ExamDialog: Stufen (Note/Prozentgrenze)
  bearbeitbar, Vorbelegung passend zum GradingSystem der Gruppe,
  Live-Anzeige der absoluten Punktegrenze je Stufe, Validierung
  (lückenlos, keine Dopplungen) über GradingService.ValidateGradingKey.
- Neues Modell GradingKeyTemplate + Repository: Notenschlüssel als
  Vorlage speichern/anwenden, direkt aus dem ExamDialog heraus.
- Einstellungen: Fächer / Kompetenzen / Notenschlüssel-Vorlagen sind
  jetzt eigene Tabs statt einer gemeinsam wachsenden Liste. Vorlagen
  zeigen nur noch eine kompakte Zeile; die Stufen-Bearbeitung läuft
  über ein Popup-Fenster (GradingKeyTemplateDialog).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 00:34:43 +02:00
co-authored by Claude Sonnet 5
parent d00cea0bfe
commit 3fc506adc1
15 changed files with 631 additions and 148 deletions
@@ -41,6 +41,14 @@ public interface IExamResultRepository
void Save(ExamResult result); void Save(ExamResult result);
void SaveMany(List<ExamResult> results); void SaveMany(List<ExamResult> results);
} }
public interface IGradingKeyTemplateRepository
{
List<GradingKeyTemplate> GetAll();
List<GradingKeyTemplate> GetByGradingSystem(GradingSystem system);
GradingKeyTemplate? GetById(Guid id);
void Save(GradingKeyTemplate template);
void Delete(Guid id);
}
public interface IGradeRepository public interface IGradeRepository
{ {
List<Grade> GetByStudentAndGroup(Guid studentId, Guid groupId); List<Grade> GetByStudentAndGroup(Guid studentId, Guid groupId);
+8
View File
@@ -29,6 +29,14 @@ public class GradingKeyEntry
public string Grade { get; set; } = ""; public string Grade { get; set; } = "";
public double MinPercent { get; set; } public double MinPercent { get; set; }
} }
public class GradingKeyTemplate
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = "";
public GradingSystem GradingSystem { get; set; }
public List<GradingKeyEntry> Entries { get; set; } = [];
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class ExamResult public class ExamResult
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
+15
View File
@@ -40,6 +40,21 @@ public class GradingService
new() { Grade = "1", MinPercent = 20.0 }, new() { Grade = "1", MinPercent = 20.0 },
new() { Grade = "0", MinPercent = 0.0 }, new() { Grade = "0", MinPercent = 0.0 },
]; ];
public string? ValidateGradingKey(List<GradingKeyEntry> entries)
{
if (entries.Count < 2) return "Der Notenschlüssel braucht mindestens zwei Stufen.";
if (entries.Any(e => string.IsNullOrWhiteSpace(e.Grade)))
return "Jede Stufe braucht eine Bezeichnung.";
if (entries.Any(e => e.MinPercent < 0 || e.MinPercent > 100))
return "Prozentgrenzen müssen zwischen 0 und 100 liegen.";
var sorted = entries.OrderByDescending(e => e.MinPercent).ToList();
if (sorted.Select(e => e.MinPercent).Distinct().Count() != sorted.Count)
return "Prozentgrenzen dürfen sich nicht doppeln.";
if (sorted.Last().MinPercent != 0)
return "Die unterste Stufe muss bei 0 % liegen (lückenlose Abdeckung).";
return null;
}
public double WeightedAverage(List<(string Grade, double Weight)> grades) public double WeightedAverage(List<(string Grade, double Weight)> grades)
{ {
var numeric = grades var numeric = grades
+2
View File
@@ -26,6 +26,7 @@ public class LiteDbContext : IDisposable
public ILiteCollection<Exam> Exams => _db.GetCollection<Exam>("exams"); public ILiteCollection<Exam> Exams => _db.GetCollection<Exam>("exams");
public ILiteCollection<ExamResult> ExamResults => _db.GetCollection<ExamResult>("exam_results"); public ILiteCollection<ExamResult> ExamResults => _db.GetCollection<ExamResult>("exam_results");
public ILiteCollection<Grade> Grades => _db.GetCollection<Grade>("grades"); public ILiteCollection<Grade> Grades => _db.GetCollection<Grade>("grades");
public ILiteCollection<GradingKeyTemplate> GradingKeyTemplates => _db.GetCollection<GradingKeyTemplate>("grading_key_templates");
public ILiteCollection<Unit> Units => _db.GetCollection<Unit>("units"); public ILiteCollection<Unit> Units => _db.GetCollection<Unit>("units");
public ILiteCollection<Lesson> Lessons => _db.GetCollection<Lesson>("lessons"); public ILiteCollection<Lesson> Lessons => _db.GetCollection<Lesson>("lessons");
public ILiteCollection<Documentation> Documentation => _db.GetCollection<Documentation>("documentation"); public ILiteCollection<Documentation> Documentation => _db.GetCollection<Documentation>("documentation");
@@ -67,6 +68,7 @@ public class LiteDbContext : IDisposable
ExamResults.EnsureIndex(x => x.StudentId); ExamResults.EnsureIndex(x => x.StudentId);
Grades.EnsureIndex(x => x.StudentId); Grades.EnsureIndex(x => x.StudentId);
Grades.EnsureIndex(x => x.GroupId); Grades.EnsureIndex(x => x.GroupId);
GradingKeyTemplates.EnsureIndex(x => x.GradingSystem);
Units.EnsureIndex(x => x.GroupId); Units.EnsureIndex(x => x.GroupId);
Lessons.EnsureIndex(x => x.UnitId); Lessons.EnsureIndex(x => x.UnitId);
Lessons.EnsureIndex(x => x.GroupId); Lessons.EnsureIndex(x => x.GroupId);
@@ -114,6 +114,17 @@ public class ExamResultRepository(LiteDbContext db) : IExamResultRepository
} }
} }
public class GradingKeyTemplateRepository(LiteDbContext db) : IGradingKeyTemplateRepository
{
public List<GradingKeyTemplate> GetAll() =>
db.GradingKeyTemplates.FindAll().OrderBy(t => t.Name).ToList();
public List<GradingKeyTemplate> GetByGradingSystem(GradingSystem system) =>
db.GradingKeyTemplates.Find(t => t.GradingSystem == system).OrderBy(t => t.Name).ToList();
public GradingKeyTemplate? GetById(Guid id) => db.GradingKeyTemplates.FindById(id);
public void Save(GradingKeyTemplate t) { t.UpdatedAt = DateTime.UtcNow; db.GradingKeyTemplates.Upsert(t); }
public void Delete(Guid id) => db.GradingKeyTemplates.Delete(id);
}
public class GradeRepository(LiteDbContext db) : IGradeRepository public class GradeRepository(LiteDbContext db) : IGradeRepository
{ {
public List<Grade> GetByStudentAndGroup(Guid sid, Guid gid) => public List<Grade> GetByStudentAndGroup(Guid sid, Guid gid) =>
+1
View File
@@ -48,6 +48,7 @@ public static class AppBootstrapper
services.AddSingleton<IExamRepository, ExamRepository>(); services.AddSingleton<IExamRepository, ExamRepository>();
services.AddSingleton<IExamResultRepository, ExamResultRepository>(); services.AddSingleton<IExamResultRepository, ExamResultRepository>();
services.AddSingleton<IGradeRepository, GradeRepository>(); services.AddSingleton<IGradeRepository, GradeRepository>();
services.AddSingleton<IGradingKeyTemplateRepository, GradingKeyTemplateRepository>();
services.AddSingleton<IUnitRepository, UnitRepository>(); services.AddSingleton<IUnitRepository, UnitRepository>();
services.AddSingleton<ILessonRepository, LessonRepository>(); services.AddSingleton<ILessonRepository, LessonRepository>();
services.AddSingleton<IDocumentationRepository, DocumentationRepository>(); services.AddSingleton<IDocumentationRepository, DocumentationRepository>();
@@ -14,11 +14,13 @@ public partial class ExamDialogViewModel : ObservableObject
{ {
private readonly IExamRepository _exams; private readonly IExamRepository _exams;
private readonly ICompetencyDomainRepository _competencyDomains; private readonly ICompetencyDomainRepository _competencyDomains;
private readonly IGradingKeyTemplateRepository _gradingKeyTemplates;
private readonly GradingService _grading;
private readonly Guid _groupId; private readonly Guid _groupId;
private readonly Guid? _subjectId; private readonly Guid? _subjectId;
private readonly int _gradeLevel; private readonly int _gradeLevel;
private readonly GradingSystem _gradingSystem;
private readonly Exam? _editingExam; private readonly Exam? _editingExam;
private readonly List<GradingKeyEntry> _gradingKey;
private readonly string _subjectName; private readonly string _subjectName;
[ObservableProperty] private string _title = ""; [ObservableProperty] private string _title = "";
@@ -30,12 +32,17 @@ public partial class ExamDialogViewModel : ObservableObject
[ObservableProperty] private double _totalPoints; [ObservableProperty] private double _totalPoints;
[ObservableProperty] private bool _hasCompetencyCatalog; [ObservableProperty] private bool _hasCompetencyCatalog;
[ObservableProperty] private bool _useWeighting; [ObservableProperty] private bool _useWeighting;
[ObservableProperty] private GradingKeyTemplate? _selectedTemplate;
[ObservableProperty] private string _newTemplateName = "";
[ObservableProperty] private string _gradingKeyValidation = "";
public bool TotalPointsWarning => TotalPoints <= 0; public bool TotalPointsWarning => TotalPoints <= 0;
public string TotalPointsDisplay => public string TotalPointsDisplay =>
$"{TotalPoints.ToString("0.##", CultureInfo.InvariantCulture)} Punkte gesamt"; $"{TotalPoints.ToString("0.##", CultureInfo.InvariantCulture)} Punkte gesamt";
public ObservableCollection<ExamTaskEditItem> Tasks { get; } = []; public ObservableCollection<ExamTaskEditItem> Tasks { get; } = [];
public ObservableCollection<GradingKeyEntryEditItem> GradingKeyEntries { get; } = [];
public ObservableCollection<GradingKeyTemplate> AvailableTemplates { get; } = [];
public Exam? Result { get; private set; } public Exam? Result { get; private set; }
public string DialogTitle => _editingExam is null ? "Neue Klausur anlegen" : "Klausur bearbeiten"; public string DialogTitle => _editingExam is null ? "Neue Klausur anlegen" : "Klausur bearbeiten";
@@ -46,17 +53,23 @@ public partial class ExamDialogViewModel : ObservableObject
? "Kein Fach hinterlegt (siehe Lerngruppe)" : $"Fach: {_subjectName}"; ? "Kein Fach hinterlegt (siehe Lerngruppe)" : $"Fach: {_subjectName}";
public ExamDialogViewModel(IExamRepository exams, ICompetencyDomainRepository competencyDomains, public ExamDialogViewModel(IExamRepository exams, ICompetencyDomainRepository competencyDomains,
IGradingKeyTemplateRepository gradingKeyTemplates, GradingService grading,
Guid groupId, Guid? subjectId, int gradeLevel, GradingSystem gradingSystem, Guid groupId, Guid? subjectId, int gradeLevel, GradingSystem gradingSystem,
string defaultSubjectName, Exam? editingExam, Exam? duplicateSource) string defaultSubjectName, Exam? editingExam, Exam? duplicateSource)
{ {
_exams = exams; _competencyDomains = competencyDomains; _exams = exams; _competencyDomains = competencyDomains;
_gradingKeyTemplates = gradingKeyTemplates; _grading = grading;
_groupId = groupId; _subjectId = subjectId; _gradeLevel = gradeLevel; _groupId = groupId; _subjectId = subjectId; _gradeLevel = gradeLevel;
_gradingSystem = gradingSystem;
_editingExam = editingExam; _editingExam = editingExam;
_subjectName = defaultSubjectName; _subjectName = defaultSubjectName;
HasCompetencyCatalog = subjectId.HasValue HasCompetencyCatalog = subjectId.HasValue
&& _competencyDomains.GetBySubjectAndGrade(subjectId.Value, gradeLevel).Count > 0; && _competencyDomains.GetBySubjectAndGrade(subjectId.Value, gradeLevel).Count > 0;
foreach (var t in _gradingKeyTemplates.GetByGradingSystem(gradingSystem))
AvailableTemplates.Add(t);
var source = editingExam ?? duplicateSource; var source = editingExam ?? duplicateSource;
if (source is not null) if (source is not null)
{ {
@@ -68,16 +81,18 @@ public partial class ExamDialogViewModel : ObservableObject
ExamNumber = source.ExamNumber; ExamNumber = source.ExamNumber;
Notes = source.Notes ?? ""; Notes = source.Notes ?? "";
ReturnedAtText = isDuplicate ? "" : source.ReturnedAt?.ToString("dd.MM.yyyy") ?? ""; ReturnedAtText = isDuplicate ? "" : source.ReturnedAt?.ToString("dd.MM.yyyy") ?? "";
_gradingKey = source.GradingKey
.Select(k => new GradingKeyEntry { Grade = k.Grade, MinPercent = k.MinPercent }).ToList();
foreach (var t in source.Tasks.OrderBy(t => t.Nr)) foreach (var t in source.Tasks.OrderBy(t => t.Nr))
AddTaskInternal(t.Title, t.MaxPoints, t.Weight, [.. t.CompetencyCodes]); AddTaskInternal(t.Title, t.MaxPoints, t.Weight, [.. t.CompetencyCodes]);
UseWeighting = Tasks.Any(t => Math.Abs(t.Weight - 1.0) > 0.0001); UseWeighting = Tasks.Any(t => Math.Abs(t.Weight - 1.0) > 0.0001);
foreach (var e in source.GradingKey.OrderByDescending(e => e.MinPercent))
AddGradingKeyRowInternal(e.Grade, e.MinPercent);
} }
else else
{ {
_gradingKey = gradingSystem == GradingSystem.Grades1To6 var defaults = gradingSystem == GradingSystem.Grades1To6
? GradingService.DefaultKey1To6() : GradingService.DefaultKey0To15(); ? GradingService.DefaultKey1To6() : GradingService.DefaultKey0To15();
foreach (var e in defaults)
AddGradingKeyRowInternal(e.Grade, e.MinPercent);
} }
foreach (var t in Tasks) t.ShowWeight = UseWeighting; foreach (var t in Tasks) t.ShowWeight = UseWeighting;
RecomputeTotals(); RecomputeTotals();
@@ -158,8 +173,86 @@ public partial class ExamDialogViewModel : ObservableObject
TotalPoints = Tasks.Sum(t => t.MaxPoints); TotalPoints = Tasks.Sum(t => t.MaxPoints);
OnPropertyChanged(nameof(TotalPointsWarning)); OnPropertyChanged(nameof(TotalPointsWarning));
OnPropertyChanged(nameof(TotalPointsDisplay)); OnPropertyChanged(nameof(TotalPointsDisplay));
RecomputeGradingKeyAbsolutes();
} }
// ── Notenschlüssel (1.3) ──────────────────────────────────────────────────
private void RecomputeGradingKeyAbsolutes()
{
foreach (var e in GradingKeyEntries)
e.AbsolutePointsDisplay = TotalPoints <= 0
? ""
: $"ab {(e.MinPercent / 100.0 * TotalPoints).ToString("0.##", CultureInfo.InvariantCulture)} " +
$"von {TotalPoints.ToString("0.##", CultureInfo.InvariantCulture)} Punkten";
}
[RelayCommand]
private void AddGradingKeyRow()
{
AddGradingKeyRowInternal("", 0);
RecomputeGradingKeyAbsolutes();
}
private void AddGradingKeyRowInternal(string grade, double minPercent)
{
var item = new GradingKeyEntryEditItem
{
Grade = grade,
MinPercent = minPercent,
OnChanged = RecomputeGradingKeyAbsolutes,
OnRemove = RemoveGradingKeyRow,
};
GradingKeyEntries.Add(item);
}
private void RemoveGradingKeyRow(GradingKeyEntryEditItem item)
{
GradingKeyEntries.Remove(item);
RecomputeGradingKeyAbsolutes();
}
[RelayCommand(CanExecute = nameof(HasSelectedTemplate))]
private void ApplyTemplate()
{
if (SelectedTemplate is null) return;
GradingKeyEntries.Clear();
foreach (var e in SelectedTemplate.Entries.OrderByDescending(x => x.MinPercent))
AddGradingKeyRowInternal(e.Grade, e.MinPercent);
RecomputeGradingKeyAbsolutes();
GradingKeyValidation = "";
}
private bool HasSelectedTemplate() => SelectedTemplate is not null;
partial void OnSelectedTemplateChanged(GradingKeyTemplate? value) => ApplyTemplateCommand.NotifyCanExecuteChanged();
[RelayCommand]
private void SaveAsTemplate()
{
if (string.IsNullOrWhiteSpace(NewTemplateName)) { GradingKeyValidation = "Vorlagenname erforderlich."; return; }
var entries = BuildGradingKeyEntries();
var error = _grading.ValidateGradingKey(entries);
if (error is not null) { GradingKeyValidation = error; return; }
var template = new GradingKeyTemplate
{
Name = NewTemplateName.Trim(),
GradingSystem = _gradingSystem,
Entries = entries,
};
_gradingKeyTemplates.Save(template);
AvailableTemplates.Add(template);
NewTemplateName = "";
GradingKeyValidation = "";
}
private List<GradingKeyEntry> BuildGradingKeyEntries() => GradingKeyEntries
.Select(e => new GradingKeyEntry { Grade = e.Grade.Trim(), MinPercent = e.MinPercent })
.OrderByDescending(e => e.MinPercent)
.ToList();
[RelayCommand] [RelayCommand]
private void Save() private void Save()
{ {
@@ -180,6 +273,11 @@ public partial class ExamDialogViewModel : ObservableObject
returnedAt = r; returnedAt = r;
} }
var gradingKey = BuildGradingKeyEntries();
var gradingKeyError = _grading.ValidateGradingKey(gradingKey);
if (gradingKeyError is not null) { GradingKeyValidation = gradingKeyError; return; }
GradingKeyValidation = "";
Result = _editingExam ?? new Exam { GroupId = _groupId }; Result = _editingExam ?? new Exam { GroupId = _groupId };
Result.Title = Title.Trim(); Result.Title = Title.Trim();
Result.Date = date; Result.Date = date;
@@ -188,11 +286,28 @@ public partial class ExamDialogViewModel : ObservableObject
Result.Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim(); Result.Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim();
Result.ReturnedAt = returnedAt; Result.ReturnedAt = returnedAt;
Result.Tasks = Tasks.Select(t => t.ToModel()).ToList(); Result.Tasks = Tasks.Select(t => t.ToModel()).ToList();
Result.GradingKey = _gradingKey; Result.GradingKey = gradingKey;
_exams.Save(Result); _exams.Save(Result);
} }
} }
// ── Zeile im Notenschlüssel-Editor (1.3) ──────────────────────────────────────
public partial class GradingKeyEntryEditItem : ObservableObject
{
[ObservableProperty] private string _grade = "";
[ObservableProperty] private double _minPercent;
[ObservableProperty] private string _absolutePointsDisplay = "";
public Action? OnChanged { get; set; }
public Action<GradingKeyEntryEditItem>? OnRemove { get; set; }
partial void OnMinPercentChanged(double value) => OnChanged?.Invoke();
partial void OnGradeChanged(string value) => OnChanged?.Invoke();
[RelayCommand] private void Remove() => OnRemove?.Invoke(this);
}
// ── Zeile im Aufgaben-Editor (1.2.1 / 1.2.2 / 1.2.4) ───────────────────────── // ── Zeile im Aufgaben-Editor (1.2.1 / 1.2.2 / 1.2.4) ─────────────────────────
public partial class ExamTaskEditItem : ObservableObject public partial class ExamTaskEditItem : ObservableObject
@@ -2,6 +2,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces; using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
@@ -14,6 +15,8 @@ public partial class SettingsViewModel : ObservableObject
{ {
private readonly ISubjectRepository _subjects; private readonly ISubjectRepository _subjects;
private readonly ICompetencyDomainRepository _domainRepo; private readonly ICompetencyDomainRepository _domainRepo;
private readonly IGradingKeyTemplateRepository _gradingKeyTemplates;
private readonly GradingService _grading;
// ── Fächer ──────────────────────────────────────────────────────────────── // ── Fächer ────────────────────────────────────────────────────────────────
@@ -33,13 +36,65 @@ public partial class SettingsViewModel : ObservableObject
public ObservableCollection<DomainEditItem> Domains { get; } = []; public ObservableCollection<DomainEditItem> Domains { get; } = [];
// ── Notenschlüssel-Vorlagen (1.3.2) ──────────────────────────────────────
[ObservableProperty] private string _newTemplateName = "";
[ObservableProperty] private string _newTemplateGradingSystemName = "Noten 16";
[ObservableProperty] private string _templateValidationMessage = "";
public List<string> GradingSystemOptions { get; } = ["Noten 16", "Punkte 015"];
public ObservableCollection<GradingKeyTemplateEditItem> GradingKeyTemplateList { get; } = [];
// ── Konstruktor ─────────────────────────────────────────────────────────── // ── Konstruktor ───────────────────────────────────────────────────────────
public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo) public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo,
IGradingKeyTemplateRepository gradingKeyTemplates, GradingService grading)
{ {
_subjects = subjects; _subjects = subjects;
_domainRepo = domainRepo; _domainRepo = domainRepo;
_gradingKeyTemplates = gradingKeyTemplates;
_grading = grading;
LoadSubjects(); LoadSubjects();
LoadGradingKeyTemplates();
}
// ── Notenschlüssel-Vorlagen: Laden / Hinzufügen / Löschen ────────────────
private void LoadGradingKeyTemplates()
{
GradingKeyTemplateList.Clear();
foreach (var t in _gradingKeyTemplates.GetAll())
GradingKeyTemplateList.Add(new GradingKeyTemplateEditItem(t, _gradingKeyTemplates, _grading));
}
[RelayCommand]
private void AddGradingKeyTemplate()
{
if (string.IsNullOrWhiteSpace(NewTemplateName)) { TemplateValidationMessage = "Vorlagenname erforderlich."; return; }
var system = NewTemplateGradingSystemName == "Punkte 015"
? GradingSystem.Points0To15 : GradingSystem.Grades1To6;
var defaults = system == GradingSystem.Grades1To6
? GradingService.DefaultKey1To6() : GradingService.DefaultKey0To15();
var template = new GradingKeyTemplate
{
Name = NewTemplateName.Trim(),
GradingSystem = system,
Entries = defaults.Select(e => new GradingKeyEntry { Grade = e.Grade, MinPercent = e.MinPercent }).ToList(),
};
_gradingKeyTemplates.Save(template);
GradingKeyTemplateList.Add(new GradingKeyTemplateEditItem(template, _gradingKeyTemplates, _grading));
NewTemplateName = "";
TemplateValidationMessage = "";
}
[RelayCommand]
private void DeleteGradingKeyTemplate(GradingKeyTemplateEditItem? item)
{
if (item is null) return;
_gradingKeyTemplates.Delete(item.Id);
GradingKeyTemplateList.Remove(item);
} }
// ── Fächer: Laden / Hinzufügen / Löschen ───────────────────────────────── // ── Fächer: Laden / Hinzufügen / Löschen ─────────────────────────────────
@@ -253,6 +308,88 @@ public class CompetencyItemVm
} }
} }
// ── GradingKeyTemplateEditItem (1.3.2) ─────────────────────────────────────────
public partial class GradingKeyTemplateEditItem : ObservableObject
{
private readonly GradingKeyTemplate _template;
private readonly IGradingKeyTemplateRepository _repo;
private readonly GradingService _grading;
public Guid Id { get; }
public string Name { get; }
public string GradingSystemLabel { get; }
[ObservableProperty] private string _newGrade = "";
[ObservableProperty] private double _newMinPercent;
[ObservableProperty] private string _validation = "";
[ObservableProperty] private string _completenessWarning = "";
public ObservableCollection<GradingKeyEntryVm> Entries { get; } = [];
public GradingKeyTemplateEditItem(GradingKeyTemplate template, IGradingKeyTemplateRepository repo,
GradingService grading)
{
_template = template; _repo = repo; _grading = grading;
Id = template.Id;
Name = template.Name;
GradingSystemLabel = template.GradingSystem == GradingSystem.Grades1To6
? "Noten 16" : "Punkte 015";
foreach (var e in template.Entries.OrderByDescending(e => e.MinPercent))
Entries.Add(new GradingKeyEntryVm(e, DeleteEntry));
RecomputeCompleteness();
}
[RelayCommand]
private void AddEntry()
{
if (string.IsNullOrWhiteSpace(NewGrade)) { Validation = "Bezeichnung erforderlich."; return; }
if (NewMinPercent is < 0 or > 100) { Validation = "Prozentgrenze muss zwischen 0 und 100 liegen."; return; }
if (_template.Entries.Any(e => Math.Abs(e.MinPercent - NewMinPercent) < 0.0001))
{ Validation = "Diese Prozentgrenze existiert bereits."; return; }
var entry = new GradingKeyEntry { Grade = NewGrade.Trim(), MinPercent = NewMinPercent };
_template.Entries.Add(entry);
_template.Entries = _template.Entries.OrderByDescending(e => e.MinPercent).ToList();
_repo.Save(_template);
Entries.Clear();
foreach (var e in _template.Entries) Entries.Add(new GradingKeyEntryVm(e, DeleteEntry));
NewGrade = ""; NewMinPercent = 0; Validation = "";
RecomputeCompleteness();
}
private void DeleteEntry(GradingKeyEntryVm vm)
{
_template.Entries.RemoveAll(e => e.Grade == vm.Grade && Math.Abs(e.MinPercent - vm.MinPercent) < 0.0001);
_repo.Save(_template);
Entries.Remove(vm);
RecomputeCompleteness();
}
private void RecomputeCompleteness() =>
CompletenessWarning = _grading.ValidateGradingKey(_template.Entries) ?? "";
}
// ── GradingKeyEntryVm ─────────────────────────────────────────────────────────
public class GradingKeyEntryVm
{
public string Grade { get; }
public double MinPercent { get; }
public string Display { get; }
public IRelayCommand DeleteCommand { get; }
public GradingKeyEntryVm(GradingKeyEntry e, Action<GradingKeyEntryVm> onDelete)
{
Grade = e.Grade;
MinPercent = e.MinPercent;
Display = $"{Grade} — ab {MinPercent.ToString("0.##")} %";
DeleteCommand = new RelayCommand(() => onDelete(this));
}
}
// ── Hilfklassen ─────────────────────────────────────────────────────────────── // ── Hilfklassen ───────────────────────────────────────────────────────────────
public class SubjectListItem(Subject s) public class SubjectListItem(Subject s)
@@ -1,6 +1,7 @@
<Window xmlns="https://github.com/avaloniaui" <Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups" xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
xmlns:models="clr-namespace:LehrerApp.Core.Models;assembly=LehrerApp.Core"
x:Class="LehrerApp.Desktop.Views.Groups.ExamDialog" x:Class="LehrerApp.Desktop.Views.Groups.ExamDialog"
x:DataType="vm:ExamDialogViewModel" x:DataType="vm:ExamDialogViewModel"
Title="{Binding DialogTitle}" Title="{Binding DialogTitle}"
@@ -112,6 +113,51 @@
<TextBlock Text="{Binding ValidationMessage}" Foreground="Red" FontSize="12" <TextBlock Text="{Binding ValidationMessage}" Foreground="Red" FontSize="12"
IsVisible="{Binding ValidationMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> IsVisible="{Binding ValidationMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Separator Margin="0,4"/>
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="Notenschlüssel" FontSize="14" FontWeight="SemiBold" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content=" Stufe" Command="{Binding AddGradingKeyRowCommand}"/>
</Grid>
<Grid ColumnDefinitions="*,8,Auto">
<ComboBox Grid.Column="0" ItemsSource="{Binding AvailableTemplates}"
SelectedItem="{Binding SelectedTemplate}"
PlaceholderText="Vorlage auswählen" HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="models:GradingKeyTemplate">
<TextBlock Text="{Binding Name}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Grid.Column="2" Content="Anwenden" Command="{Binding ApplyTemplateCommand}"/>
</Grid>
<ItemsControl ItemsSource="{Binding GradingKeyEntries}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:GradingKeyEntryEditItem">
<Grid ColumnDefinitions="90,90,*,Auto" Margin="0,0,0,6">
<TextBox Grid.Column="0" Text="{Binding Grade}" PlaceholderText="Note" Margin="0,0,6,0"/>
<NumericUpDown Grid.Column="1" Value="{Binding MinPercent}" Minimum="0" Maximum="100"
FormatString="0.##" Width="90" ShowButtonSpinner="False" Margin="0,0,6,0"
ToolTip.Tip="Mindest-Prozentgrenze"/>
<TextBlock Grid.Column="2" Text="{Binding AbsolutePointsDisplay}" FontSize="12" Opacity="0.65"
VerticalAlignment="Center"/>
<Button Grid.Column="3" Content="✕" Command="{Binding RemoveCommand}" Padding="6,2"
ToolTip.Tip="Entfernen"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="{Binding GradingKeyValidation}" Foreground="Red" FontSize="12"
IsVisible="{Binding GradingKeyValidation, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Grid ColumnDefinitions="*,8,Auto">
<TextBox Grid.Column="0" Text="{Binding NewTemplateName}" PlaceholderText="Name für neue Vorlage"/>
<Button Grid.Column="2" Content="Als Vorlage speichern" Command="{Binding SaveAsTemplateCommand}"/>
</Grid>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
@@ -1,6 +1,7 @@
using Avalonia.Controls; using Avalonia.Controls;
using LehrerApp.Core.Interfaces; using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Groups; using LehrerApp.Desktop.ViewModels.Groups;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -56,6 +57,8 @@ public partial class GroupDetailView : UserControl
var dialogVm = new ExamDialogViewModel( var dialogVm = new ExamDialogViewModel(
App.Services.GetRequiredService<IExamRepository>(), App.Services.GetRequiredService<IExamRepository>(),
App.Services.GetRequiredService<ICompetencyDomainRepository>(), App.Services.GetRequiredService<ICompetencyDomainRepository>(),
App.Services.GetRequiredService<IGradingKeyTemplateRepository>(),
App.Services.GetRequiredService<GradingService>(),
groupId, vm.Group.SubjectId, vm.Group.GradeLevel, vm.Group.GradingSystem, groupId, vm.Group.SubjectId, vm.Group.GradeLevel, vm.Group.GradingSystem,
vm.Group.Subject ?? "", editingExam, duplicateSource); vm.Group.Subject ?? "", editingExam, duplicateSource);
@@ -0,0 +1,46 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Settings"
x:Class="LehrerApp.Desktop.Views.Settings.GradingKeyTemplateDialog"
x:DataType="vm:GradingKeyTemplateEditItem"
Title="{Binding Name}"
Width="420" SizeToContent="Height" MinHeight="300"
CanResize="False" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="10">
<StackPanel>
<TextBlock Text="{Binding Name}" FontSize="18" FontWeight="SemiBold"/>
<TextBlock Text="{Binding GradingSystemLabel}" FontSize="12" Opacity="0.5"/>
</StackPanel>
<TextBlock Text="{Binding CompletenessWarning}" Foreground="Red" FontSize="12" TextWrapping="Wrap"
IsVisible="{Binding CompletenessWarning, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<ItemsControl ItemsSource="{Binding Entries}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:GradingKeyEntryVm">
<Grid ColumnDefinitions="*,Auto" Margin="0,3">
<TextBlock Grid.Column="0" Text="{Binding Display}" VerticalAlignment="Center" FontSize="13"/>
<Button Grid.Column="1" Content="×" Padding="7,2" FontSize="13" Command="{Binding DeleteCommand}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="{Binding Validation}" Foreground="Red" FontSize="11"
IsVisible="{Binding Validation, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Grid ColumnDefinitions="*,8,90,8,Auto">
<TextBox Grid.Column="0" Text="{Binding NewGrade}"
PlaceholderText="Note (z.B. 2 oder 11)" FontSize="12"/>
<NumericUpDown Grid.Column="2" Value="{Binding NewMinPercent}" Minimum="0" Maximum="100"
FormatString="0.##" ShowButtonSpinner="False" FontSize="12"
ToolTip.Tip="Mindest-Prozentgrenze"/>
<Button Grid.Column="4" Content="" Command="{Binding AddEntryCommand}"/>
</Grid>
</StackPanel>
<Button Grid.Row="1" Content="Fertig" HorizontalAlignment="Stretch" Margin="0,20,0,0" Click="OnClose"/>
</Grid>
</Window>
@@ -0,0 +1,11 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace LehrerApp.Desktop.Views.Settings;
public partial class GradingKeyTemplateDialog : Window
{
public GradingKeyTemplateDialog() => InitializeComponent();
private void OnClose(object? sender, RoutedEventArgs e) => Close();
}
@@ -4,14 +4,17 @@
x:Class="LehrerApp.Desktop.Views.Settings.SettingsView" x:Class="LehrerApp.Desktop.Views.Settings.SettingsView"
x:DataType="vm:SettingsViewModel"> x:DataType="vm:SettingsViewModel">
<Grid RowDefinitions="Auto,*">
<TextBlock Grid.Row="0" Text="Einstellungen" FontSize="22" FontWeight="SemiBold"
Margin="32,28,32,0"/>
<TabbedPage Grid.Row="1" TabPlacement="Top">
<!-- Tab: Fächer -->
<ContentPage Header="Fächer">
<ScrollViewer> <ScrollViewer>
<StackPanel Margin="32,28" Spacing="32" MaxWidth="640"> <StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="640">
<TextBlock Text="Einstellungen" FontSize="22" FontWeight="SemiBold"/>
<!-- ── FÄCHER ────────────────────────────────────────────────── -->
<StackPanel Spacing="10">
<TextBlock Text="FÄCHER" FontSize="10" FontWeight="Bold" Opacity="0.4"/>
<ItemsControl ItemsSource="{Binding Subjects}"> <ItemsControl ItemsSource="{Binding Subjects}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
@@ -48,11 +51,15 @@
IsVisible="{Binding ValidationMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> IsVisible="{Binding ValidationMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel> </StackPanel>
</Border> </Border>
</StackPanel>
<!-- ── KOMPETENZKATALOGE ─────────────────────────────────────── --> </StackPanel>
<StackPanel Spacing="10"> </ScrollViewer>
<TextBlock Text="KOMPETENZKATALOGE" FontSize="10" FontWeight="Bold" Opacity="0.4"/> </ContentPage>
<!-- Tab: Kompetenzen -->
<ContentPage Header="Kompetenzen">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="640">
<!-- Fach + Klassenstufe Auswahl + Import/Export --> <!-- Fach + Klassenstufe Auswahl + Import/Export -->
<Grid ColumnDefinitions="*,12,120,12,Auto,8,Auto"> <Grid ColumnDefinitions="*,12,120,12,Auto,8,Auto">
@@ -151,7 +158,70 @@
</Border> </Border>
</StackPanel> </StackPanel>
</ScrollViewer>
</ContentPage>
<!-- Tab: Notenschlüssel-Vorlagen -->
<ContentPage Header="Notenschlüssel-Vorlagen">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="640">
<TextBlock Text="Noch keine Vorlagen angelegt." Opacity="0.4" FontSize="13"
IsVisible="{Binding !GradingKeyTemplateList.Count}"/>
<ItemsControl ItemsSource="{Binding GradingKeyTemplateList}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:GradingKeyTemplateEditItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1" Padding="0,9">
<Grid ColumnDefinitions="*,Auto,Auto">
<StackPanel Grid.Column="0">
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="14"/>
<TextBlock FontSize="12" Opacity="0.5">
<Run Text="{Binding GradingSystemLabel}"/>
<Run Text=" · "/>
<Run Text="{Binding Entries.Count}"/>
<Run Text=" Stufen"/>
</TextBlock>
<TextBlock Text="{Binding CompletenessWarning}" Foreground="Red" FontSize="11"
TextWrapping="Wrap"
IsVisible="{Binding CompletenessWarning, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<Button Grid.Column="1" Content="Bearbeiten" FontSize="12" Padding="10,4"
Margin="8,0,0,0" Tag="{Binding}" Click="OnEditGradingKeyTemplateClick"/>
<Button Grid.Column="2" Content="Löschen" FontSize="12" Padding="10,4"
Margin="8,0,0,0"
Command="{Binding $parent[ItemsControl].((vm:SettingsViewModel)DataContext).DeleteGradingKeyTemplateCommand}"
CommandParameter="{Binding}"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- Neue Vorlage anlegen -->
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="1" CornerRadius="6" Padding="14,12">
<StackPanel Spacing="10">
<TextBlock Text="Neue Vorlage" FontSize="12" FontWeight="SemiBold" Opacity="0.7"/>
<Grid ColumnDefinitions="*,12,140,12,Auto">
<TextBox Grid.Column="0" Text="{Binding NewTemplateName}"
PlaceholderText="Vorlagenname (z.B. Standard Oberstufe)"/>
<ComboBox Grid.Column="2" ItemsSource="{Binding GradingSystemOptions}"
SelectedItem="{Binding NewTemplateGradingSystemName}"
HorizontalAlignment="Stretch"/>
<Button Grid.Column="4" Content="Anlegen"
Command="{Binding AddGradingKeyTemplateCommand}"/>
</Grid>
<TextBlock Text="{Binding TemplateValidationMessage}" Foreground="Red" FontSize="12"
IsVisible="{Binding TemplateValidationMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</Border>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
</ContentPage>
</TabbedPage>
</Grid>
</UserControl> </UserControl>
@@ -42,4 +42,12 @@ public partial class SettingsView : UserControl
var json = vm.ExportCatalog(); var json = vm.ExportCatalog();
await File.WriteAllTextAsync(file.Path.LocalPath, json); await File.WriteAllTextAsync(file.Path.LocalPath, json);
} }
private async void OnEditGradingKeyTemplateClick(object? sender, RoutedEventArgs e)
{
if (sender is not Button { Tag: GradingKeyTemplateEditItem item }) return;
var dialog = new GradingKeyTemplateDialog { DataContext = item };
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is not null) await dialog.ShowDialog(owner);
}
} }
+6 -4
View File
@@ -50,12 +50,14 @@ Modelle `Exam`, `ExamTask`, `GradingKeyEntry`, `ExamResult` existieren bereits i
Voraussetzung für die Kompetenzauswertung in 8.3. Voraussetzung für die Kompetenzauswertung in 8.3.
### 1.3 Notenschlüssel ### 1.3 Notenschlüssel
- [ ] **1.3.1** Editor für `GradingKey` mit Vorbelegung aus - [x] **1.3.1** Editor für `GradingKey` mit Vorbelegung aus
`GradingService.DefaultKey1To6()` / `DefaultKey0To15()` je nach `GradingSystem` der Gruppe. `GradingService.DefaultKey1To6()` / `DefaultKey0To15()` je nach `GradingSystem` der Gruppe.
- [ ] **1.3.2** Notenschlüssel als wiederverwendbare Vorlage speichern (neues Modell Umgesetzt im `ExamDialog` (Teil von 1.1.1), analog zum Aufgaben-Editor.
- [x] **1.3.2** Notenschlüssel als wiederverwendbare Vorlage speichern (neues Modell
`GradingKeyTemplate` + Repository) und in Einstellungen verwalten. `GradingKeyTemplate` + Repository) und in Einstellungen verwalten.
- [ ] **1.3.3** Live-Vorschau: Punktegrenzen absolut anzeigen (z.B. "Note 2 ab 45 von 60 P."). - [x] **1.3.3** Live-Vorschau: Punktegrenzen absolut anzeigen (z.B. "Note 2 ab 45 von 60 P.").
- [ ] **1.3.4** Validierung: lückenlose, absteigende Prozentgrenzen, keine Dopplungen. - [x] **1.3.4** Validierung: lückenlose, absteigende Prozentgrenzen, keine Dopplungen
(`GradingService.ValidateGradingKey`).
### 1.4 Punkteeingabe & Korrektur ### 1.4 Punkteeingabe & Korrektur
- [ ] **1.4.1** Eingaberaster: Zeilen = Schüler, Spalten = Aufgaben, letzte Spalte Summe + Note. - [ ] **1.4.1** Eingaberaster: Zeilen = Schüler, Spalten = Aufgaben, letzte Spalte Summe + Note.