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
@@ -14,11 +14,13 @@ public partial class ExamDialogViewModel : ObservableObject
{
private readonly IExamRepository _exams;
private readonly ICompetencyDomainRepository _competencyDomains;
private readonly IGradingKeyTemplateRepository _gradingKeyTemplates;
private readonly GradingService _grading;
private readonly Guid _groupId;
private readonly Guid? _subjectId;
private readonly int _gradeLevel;
private readonly GradingSystem _gradingSystem;
private readonly Exam? _editingExam;
private readonly List<GradingKeyEntry> _gradingKey;
private readonly string _subjectName;
[ObservableProperty] private string _title = "";
@@ -30,12 +32,17 @@ public partial class ExamDialogViewModel : ObservableObject
[ObservableProperty] private double _totalPoints;
[ObservableProperty] private bool _hasCompetencyCatalog;
[ObservableProperty] private bool _useWeighting;
[ObservableProperty] private GradingKeyTemplate? _selectedTemplate;
[ObservableProperty] private string _newTemplateName = "";
[ObservableProperty] private string _gradingKeyValidation = "";
public bool TotalPointsWarning => TotalPoints <= 0;
public string TotalPointsDisplay =>
$"{TotalPoints.ToString("0.##", CultureInfo.InvariantCulture)} Punkte gesamt";
public ObservableCollection<ExamTaskEditItem> Tasks { get; } = [];
public ObservableCollection<GradingKeyEntryEditItem> GradingKeyEntries { get; } = [];
public ObservableCollection<GradingKeyTemplate> AvailableTemplates { get; } = [];
public Exam? Result { get; private set; }
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}";
public ExamDialogViewModel(IExamRepository exams, ICompetencyDomainRepository competencyDomains,
IGradingKeyTemplateRepository gradingKeyTemplates, GradingService grading,
Guid groupId, Guid? subjectId, int gradeLevel, GradingSystem gradingSystem,
string defaultSubjectName, Exam? editingExam, Exam? duplicateSource)
{
_exams = exams; _competencyDomains = competencyDomains;
_gradingKeyTemplates = gradingKeyTemplates; _grading = grading;
_groupId = groupId; _subjectId = subjectId; _gradeLevel = gradeLevel;
_gradingSystem = gradingSystem;
_editingExam = editingExam;
_subjectName = defaultSubjectName;
HasCompetencyCatalog = subjectId.HasValue
&& _competencyDomains.GetBySubjectAndGrade(subjectId.Value, gradeLevel).Count > 0;
foreach (var t in _gradingKeyTemplates.GetByGradingSystem(gradingSystem))
AvailableTemplates.Add(t);
var source = editingExam ?? duplicateSource;
if (source is not null)
{
@@ -68,16 +81,18 @@ public partial class ExamDialogViewModel : ObservableObject
ExamNumber = source.ExamNumber;
Notes = source.Notes ?? "";
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))
AddTaskInternal(t.Title, t.MaxPoints, t.Weight, [.. t.CompetencyCodes]);
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
{
_gradingKey = gradingSystem == GradingSystem.Grades1To6
var defaults = gradingSystem == GradingSystem.Grades1To6
? GradingService.DefaultKey1To6() : GradingService.DefaultKey0To15();
foreach (var e in defaults)
AddGradingKeyRowInternal(e.Grade, e.MinPercent);
}
foreach (var t in Tasks) t.ShowWeight = UseWeighting;
RecomputeTotals();
@@ -158,8 +173,86 @@ public partial class ExamDialogViewModel : ObservableObject
TotalPoints = Tasks.Sum(t => t.MaxPoints);
OnPropertyChanged(nameof(TotalPointsWarning));
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]
private void Save()
{
@@ -180,6 +273,11 @@ public partial class ExamDialogViewModel : ObservableObject
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.Title = Title.Trim();
Result.Date = date;
@@ -188,11 +286,28 @@ public partial class ExamDialogViewModel : ObservableObject
Result.Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim();
Result.ReturnedAt = returnedAt;
Result.Tasks = Tasks.Select(t => t.ToModel()).ToList();
Result.GradingKey = _gradingKey;
Result.GradingKey = gradingKey;
_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) ─────────────────────────
public partial class ExamTaskEditItem : ObservableObject