diff --git a/LehrerApp.Core/Models/Exam.cs b/LehrerApp.Core/Models/Exam.cs index 813862e..b9abe16 100644 --- a/LehrerApp.Core/Models/Exam.cs +++ b/LehrerApp.Core/Models/Exam.cs @@ -22,6 +22,9 @@ public class Exam /// auf der Klausuren-Hauptseite — Id der "Ursprungs"-Klausur, gesetzt beim Duplizieren /// (siehe ExamDialogViewModel.Save). public Guid? SharedExamGroupId { get; set; } + /// Optional verknüpfte ursprüngliche Arbeit derselben Lerngruppe. + public Guid? RepeatOfExamId { get; set; } + public RepeatGradeRule RepeatGradeRule { get; set; } = RepeatGradeRule.Better; public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } @@ -31,6 +34,9 @@ public class ExamTask public string? Title { get; set; } public double MaxPoints { get; set; } public double Weight { get; set; } = 1.0; + /// Null gilt für beide Fassungen; A/B nur für die gewählte Fassung. + public ExamVariant? Variant { get; set; } + public bool IsBonus { get; set; } public List Competencies { get; set; } = []; } public class GradingKeyEntry @@ -55,7 +61,10 @@ public class ExamResult public double TotalPoints { get; set; } public string? Grade { get; set; } public bool Absent { get; set; } + public ExamVariant Variant { get; set; } = ExamVariant.A; public string? Comment { get; set; } public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } public enum ExamStatus { Planned, Conducted, Graded, Returned } +public enum ExamVariant { A, B } +public enum RepeatGradeRule { Better, Repeat, Original } diff --git a/LehrerApp.Core/Services/ExamScoringService.cs b/LehrerApp.Core/Services/ExamScoringService.cs new file mode 100644 index 0000000..7951db7 --- /dev/null +++ b/LehrerApp.Core/Services/ExamScoringService.cs @@ -0,0 +1,65 @@ +using LehrerApp.Core.Models; + +namespace LehrerApp.Core.Services; + +public static class ExamScoringService +{ + public static bool AppliesTo(ExamTask task, ExamVariant variant) => + task.Variant is null || task.Variant == variant; + + public static double Maximum(IEnumerable tasks, ExamVariant variant) => + tasks.Where(t => AppliesTo(t, variant) && !t.IsBonus).Sum(t => t.MaxPoints); + + public static double Achieved(IReadOnlyList tasks, IReadOnlyList points, + ExamVariant variant) => tasks.Select((task, index) => + AppliesTo(task, variant) && index < points.Count ? points[index] : 0).Sum(); + + /// Wählt pro verknüpfter Wiederholung genau eine Note. Andere Arbeiten bleiben erhalten. + public static List EffectiveGrades(IEnumerable<(Exam Exam, string Grade)> entries, + GradingSystem system) + { + var available = entries.ToList(); + var byId = available.ToDictionary(x => x.Exam.Id); + var consumed = new HashSet(); + var result = new List(); + foreach (var entry in available) + { + if (!consumed.Add(entry.Exam.Id)) continue; + if (entry.Exam.RepeatOfExamId is { } originalId && byId.TryGetValue(originalId, out var original)) + { + consumed.Add(originalId); + result.Add(Choose(original.Grade, entry.Grade, entry.Exam.RepeatGradeRule, system)); + } + else if (available.FirstOrDefault(x => x.Exam.RepeatOfExamId == entry.Exam.Id) is var repeat + && repeat.Exam is not null) + { + consumed.Add(repeat.Exam.Id); + result.Add(Choose(entry.Grade, repeat.Grade, repeat.Exam.RepeatGradeRule, system)); + } + else result.Add(entry.Grade); + } + return result; + } + + private static string Choose(string original, string repeat, RepeatGradeRule rule, GradingSystem system) + { + if (rule == RepeatGradeRule.Original) return original; + if (rule == RepeatGradeRule.Repeat) return repeat; + var first = GradeValue(original, system); + var second = GradeValue(repeat, system); + if (first is null || second is null) return original; + return system == GradingSystem.Grades1To6 + ? (second < first ? repeat : original) + : (second > first ? repeat : original); + } + + private static double? GradeValue(string grade, GradingSystem system) + { + var core = grade.Trim(); + var tendency = 0.0; + if (core.EndsWith('+')) { tendency = system == GradingSystem.Grades1To6 ? -1.0 / 3 : 1.0 / 3; core = core[..^1]; } + else if (core.EndsWith('-')) { tendency = system == GradingSystem.Grades1To6 ? 1.0 / 3 : -1.0 / 3; core = core[..^1]; } + return double.TryParse(core.Trim(), System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out var value) ? value + tendency : null; + } +} diff --git a/LehrerApp.Desktop.Tests/ExamDialogViewModelTests.cs b/LehrerApp.Desktop.Tests/ExamDialogViewModelTests.cs index db8f94b..fc3796b 100644 --- a/LehrerApp.Desktop.Tests/ExamDialogViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/ExamDialogViewModelTests.cs @@ -61,4 +61,27 @@ public sealed class ExamDialogViewModelTests Assert.Equal("", vm.WeekLoadHint); } + + [Fact] + public void BonusNachtraeglichMarkiert_BerechnetGespeichertesErgebnisNeu() + { + var exam = MakeExam(new DateOnly(2026, 5, 12)); + exam.Tasks = [new ExamTask { Nr = 1, MaxPoints = 10 }, new ExamTask { Nr = 2, MaxPoints = 2 }]; + exam.GradingKey = GradingService.DefaultKey1To6(); + var result = new ExamResult { ExamId = exam.Id, StudentId = Guid.NewGuid(), + Points = [8, 2], TotalPoints = 10, Grade = "2" }; + var results = new FakeResults(); + results.Add(result); + var vm = new ExamDialogViewModel(new FakeExams([exam]), new FakeCompetencyDomains(), + new FakeGradingKeyTemplates(), new GradingService(), exam.GroupId, null, 9, + GradingSystem.Grades1To6, "Chemie", false, exam, null, + results: results); + vm.Tasks[1].IsBonus = true; + + vm.SaveCommand.Execute(null); + + Assert.True(exam.Tasks[1].IsBonus); + Assert.Equal(10, result.TotalPoints); + Assert.Equal("1", result.Grade); + } } diff --git a/LehrerApp.Desktop.Tests/ExamVariantScoringTests.cs b/LehrerApp.Desktop.Tests/ExamVariantScoringTests.cs new file mode 100644 index 0000000..6140ea2 --- /dev/null +++ b/LehrerApp.Desktop.Tests/ExamVariantScoringTests.cs @@ -0,0 +1,34 @@ +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using LehrerApp.Desktop.ViewModels.Groups; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public class ExamVariantScoringTests +{ + [Fact] + public void Fassungswechsel_BerechnetPunkteUndNoteNeu() + { + var tasks = new List + { + new() { Nr = 1, MaxPoints = 10 }, + new() { Nr = 2, MaxPoints = 5, Variant = ExamVariant.A }, + new() { Nr = 3, MaxPoints = 8, Variant = ExamVariant.B }, + new() { Nr = 4, MaxPoints = 2, IsBonus = true }, + }; + var existing = new ExamResult { Points = [8, 5, 8, 2] }; + var row = new ExamResultRow(Guid.NewGuid(), "Test", tasks, existing, + GradingService.DefaultKey1To6(), 15, new GradingService()); + + Assert.Equal(15, row.TotalPoints); + Assert.False(row.Cells[2].IsApplicable); + + row.VariantName = "B"; + + Assert.Equal(18, row.TotalPoints); + Assert.False(row.Cells[1].IsApplicable); + Assert.True(row.Cells[2].IsApplicable); + Assert.Equal(ExamVariant.B, row.ToModel(Guid.NewGuid()).Variant); + } +} diff --git a/LehrerApp.Desktop/ViewModels/Groups/ExamEvaluationViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ExamEvaluationViewModels.cs index b414d60..5f0f7d8 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ExamEvaluationViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ExamEvaluationViewModels.cs @@ -18,6 +18,7 @@ public partial class ExamEvaluationDialogViewModel : ObservableObject private readonly GradingService _grading; private readonly Exam _exam; private readonly GradingSystem _gradingSystem; + private readonly IStudentRepository? _students; private readonly double _examMaxPoints; private List _gradedResults = []; @@ -38,19 +39,63 @@ public partial class ExamEvaluationDialogViewModel : ObservableObject public ObservableCollection GradeDistribution { get; } = []; public ObservableCollection TaskAnalysis { get; } = []; + public ObservableCollection RepeatComparison { get; } = []; + public bool HasRepeatComparison => RepeatComparison.Count > 0; + public string RepeatComparisonTitle { get; private set; } = ""; public ObservableCollection GradingKeyEntries { get; } = []; public ExamEvaluationDialogViewModel(IExamRepository exams, IExamResultRepository results, - GradingService grading, Exam exam, GradingSystem gradingSystem) + GradingService grading, Exam exam, GradingSystem gradingSystem, + IStudentRepository? students = null) { _exams = exams; _results = results; _grading = grading; _exam = exam; - _gradingSystem = gradingSystem; - _examMaxPoints = exam.Tasks.Sum(t => t.MaxPoints); + _gradingSystem = gradingSystem; _students = students; + _examMaxPoints = ExamScoringService.Maximum(exam.Tasks, ExamVariant.A); foreach (var e in exam.GradingKey.OrderByDescending(e => e.MinPercent)) AddGradingKeyRowInternal(e.Grade, e.MinPercent); LoadResults(); + LoadRepeatComparison(students); + } + + private void LoadRepeatComparison(IStudentRepository? students) + { + RepeatComparison.Clear(); + RepeatComparisonTitle = ""; + var original = _exam.RepeatOfExamId is { } originalId ? _exams.GetById(originalId) : _exam; + if (original is null) return; + var repeat = _exam.RepeatOfExamId is not null ? _exam + : _exams.GetByGroup(_exam.GroupId).FirstOrDefault(e => e.RepeatOfExamId == _exam.Id); + if (repeat is null) return; + var rule = repeat.RepeatGradeRule switch + { + RepeatGradeRule.Repeat => "Wiederholung zählt", + RepeatGradeRule.Original => "Erste Arbeit zählt", + _ => "Bessere zählt", + }; + RepeatComparisonTitle = $"Erste Arbeit: {original.Title} · Wiederholung: {repeat.Title} · {rule}"; + var first = _results.GetByExam(original.Id).Where(r => !r.Absent && r.Grade is not null) + .ToDictionary(r => r.StudentId); + var second = _results.GetByExam(repeat.Id).Where(r => !r.Absent && r.Grade is not null) + .ToDictionary(r => r.StudentId); + var names = students?.GetByGroup(_exam.GroupId).ToDictionary(s => s.Id, s => s.FullName); + foreach (var studentId in first.Keys.Union(second.Keys) + .OrderBy(id => names?.GetValueOrDefault(id) ?? id.ToString())) + { + first.TryGetValue(studentId, out var originalResult); + second.TryGetValue(studentId, out var repeatResult); + var entries = new List<(Exam Exam, string Grade)>(); + if (originalResult?.Grade is { } g1) entries.Add((original, g1)); + if (repeatResult?.Grade is { } g2) entries.Add((repeat, g2)); + var effective = ExamScoringService.EffectiveGrades(entries, _gradingSystem); + RepeatComparison.Add(new RepeatComparisonItem( + names?.GetValueOrDefault(studentId) ?? studentId.ToString(), + originalResult?.Grade ?? "–", repeatResult?.Grade ?? "–", + effective.FirstOrDefault() ?? "–")); + } + OnPropertyChanged(nameof(HasRepeatComparison)); + OnPropertyChanged(nameof(RepeatComparisonTitle)); } private void LoadResults() @@ -98,8 +143,11 @@ public partial class ExamEvaluationDialogViewModel : ObservableObject foreach (var e in GradingKeyEntries) e.AbsolutePointsDisplay = _examMaxPoints <= 0 ? "–" - : $"ab {(e.MinPercent / 100.0 * _examMaxPoints).ToString("0.##", CultureInfo.InvariantCulture)} " + - $"von {_examMaxPoints.ToString("0.##", CultureInfo.InvariantCulture)} Punkten"; + : _exam.Tasks.Any(t => t.Variant is not null) + ? $"A ab {e.MinPercent / 100.0 * _examMaxPoints:0.##} / " + + $"B ab {e.MinPercent / 100.0 * ExamScoringService.Maximum(_exam.Tasks, ExamVariant.B):0.##} Punkten" + : $"ab {(e.MinPercent / 100.0 * _examMaxPoints).ToString("0.##", CultureInfo.InvariantCulture)} " + + $"von {_examMaxPoints.ToString("0.##", CultureInfo.InvariantCulture)} Punkten"; } private List BuildCurrentGradingKey() => GradingKeyEntries @@ -116,6 +164,12 @@ public partial class ExamEvaluationDialogViewModel : ObservableObject GradingKeyValidation = ""; _exam.GradingKey = key; _exams.Save(_exam); + var results = _results.GetByExam(_exam.Id); + foreach (var result in results.Where(r => !r.Absent)) + result.Grade = _grading.CalculateGrade(result.TotalPoints, + ExamScoringService.Maximum(_exam.Tasks, result.Variant), key); + _results.SaveMany(results); + LoadRepeatComparison(_students); GradingKeyStatus = "Notenschlüssel für diese Klausur übernommen."; } @@ -127,7 +181,8 @@ public partial class ExamEvaluationDialogViewModel : ObservableObject GradingKeyValidation = _grading.ValidateGradingKey(currentKey) ?? ""; var grades = _gradedResults - .Select(r => _grading.CalculateGrade(r.TotalPoints, _examMaxPoints, currentKey)) + .Select(r => _grading.CalculateGrade(r.TotalPoints, + ExamScoringService.Maximum(_exam.Tasks, r.Variant), currentKey)) .ToList(); GradeDistribution.Clear(); @@ -174,11 +229,14 @@ public partial class ExamEvaluationDialogViewModel : ObservableObject for (var i = 0; i < tasks.Count; i++) { var task = tasks[i]; - var achieved = _gradedResults.Where(r => i < r.Points.Count).Select(r => r.Points[i]).ToList(); + var achieved = _gradedResults.Where(r => i < r.Points.Count && + ExamScoringService.AppliesTo(task, r.Variant)).Select(r => r.Points[i]).ToList(); var avgPercent = task.MaxPoints <= 0 || achieved.Count == 0 ? 0 : achieved.Average() / task.MaxPoints * 100.0; - TaskAnalysis.Add(new TaskAnalysisItem(task.Nr, task.Title, avgPercent)); + var suffix = (task.Variant is null ? "" : $" ({task.Variant})") + + (task.IsBonus ? " · Bonus" : ""); + TaskAnalysis.Add(new TaskAnalysisItem(task.Nr, (task.Title ?? "") + suffix, avgPercent)); } } @@ -209,8 +267,15 @@ public partial class ExamEvaluationDialogViewModel : ObservableObject if (!string.IsNullOrEmpty(BelowThresholdLabel1)) csv.AddRow(BelowThresholdLabel1, BelowThresholdDisplay1); if (!string.IsNullOrEmpty(BelowThresholdLabel2)) csv.AddRow(BelowThresholdLabel2, BelowThresholdDisplay2); csv.AddRow("Bewertet", GradedCount) - .AddRow("Abwesend", AbsentCount) - .AddBlankRow() + .AddRow("Abwesend", AbsentCount); + if (HasRepeatComparison) + { + csv.AddBlankRow().AddRow("Vergleich", RepeatComparisonTitle) + .AddRow("Schüler", "Erste Arbeit", "Wiederholung", "Zählt"); + foreach (var item in RepeatComparison) + csv.AddRow(item.StudentName, item.OriginalGrade, item.RepeatGrade, item.EffectiveGrade); + } + csv.AddBlankRow() .AddRow("Aufgabenanalyse") .AddRow("Aufgabe", "Ø Erfüllungsgrad", "Auffällig schwach"); foreach (var t in TaskAnalysis) @@ -221,6 +286,9 @@ public partial class ExamEvaluationDialogViewModel : ObservableObject // ── Notenspiegel-Balken ──────────────────────────────────────────────────── +public record RepeatComparisonItem(string StudentName, string OriginalGrade, + string RepeatGrade, string EffectiveGrade); + public class GradeBarItem { public string Grade { get; } diff --git a/LehrerApp.Desktop/ViewModels/Groups/ExamGradingViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ExamGradingViewModels.cs index e05e4e9..f452d80 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ExamGradingViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ExamGradingViewModels.cs @@ -15,12 +15,12 @@ public partial class ExamGradingDialogViewModel : ObservableObject private readonly IExamResultRepository _results; private readonly GradingService _grading; private readonly Exam _exam; - private readonly double _examMaxPoints; public string ExamTitle => _exam.Title; public string ExamDateLabel => _exam.Date.ToString("dd.MM.yyyy"); public string NiveauLabel => _exam.Niveau.HasValue ? NiveauDisplay.ToName(_exam.Niveau) : ""; public List Tasks { get; } + public bool HasVariants => Tasks.Any(t => t.Variant is not null); public ObservableCollection Rows { get; } = []; @@ -29,7 +29,6 @@ public partial class ExamGradingDialogViewModel : ObservableObject { _results = results; _grading = grading; _exam = exam; Tasks = exam.Tasks.OrderBy(t => t.Nr).ToList(); - _examMaxPoints = Tasks.Sum(t => t.MaxPoints); var enrolled = students.GetByGroup(groupId); var membershipList = memberships.GetByGroup(groupId); @@ -45,7 +44,8 @@ public partial class ExamGradingDialogViewModel : ObservableObject if (exam.Niveau.HasValue && membership?.Niveau != exam.Niveau) continue; existing.TryGetValue(s.Id, out var result); - var row = new ExamResultRow(s.Id, s.FullName, Tasks, result, _exam.GradingKey, _examMaxPoints, _grading); + var row = new ExamResultRow(s.Id, s.FullName, Tasks, result, _exam.GradingKey, + ExamScoringService.Maximum(Tasks, ExamVariant.A), _grading); row.OnChanged = SaveRow; Rows.Add(row); } @@ -72,6 +72,7 @@ public partial class ExamResultRow : ObservableObject private readonly Guid _resultId; private readonly List _gradingKey; private readonly double _examMaxPoints; + private readonly List _tasks; private readonly GradingService _grading; public Guid StudentId { get; } @@ -83,6 +84,9 @@ public partial class ExamResultRow : ObservableObject [ObservableProperty] private string _comment = ""; [ObservableProperty] private double _totalPoints; [ObservableProperty] private string _gradeDisplay = ""; + [ObservableProperty] private string _variantName = "A"; + public string[] VariantOptions => ["A", "B"]; + private ExamVariant Variant => VariantName == "B" ? ExamVariant.B : ExamVariant.A; public string TotalPointsDisplay => TotalPoints.ToString("0.##", CultureInfo.InvariantCulture); @@ -92,15 +96,19 @@ public partial class ExamResultRow : ObservableObject List gradingKey, double examMaxPoints, GradingService grading) { StudentId = studentId; Name = name; - _gradingKey = gradingKey; _examMaxPoints = examMaxPoints; _grading = grading; + _gradingKey = gradingKey; _examMaxPoints = examMaxPoints; _grading = grading; _tasks = tasks; _resultId = existing?.Id ?? Guid.NewGuid(); _absent = existing?.Absent ?? false; _comment = existing?.Comment ?? ""; + _variantName = existing?.Variant.ToString() ?? "A"; for (var i = 0; i < tasks.Count; i++) { double? pts = existing is not null && i < existing.Points.Count ? existing.Points[i] : null; - var cell = new PointsCell(tasks[i].MaxPoints, pts); + var cell = new PointsCell(tasks[i].MaxPoints, pts) + { + IsApplicable = ExamScoringService.AppliesTo(tasks[i], Variant), + }; cell.OnChanged = () => { RecomputeTotals(); OnChanged?.Invoke(this); }; Cells.Add(cell); } @@ -114,12 +122,20 @@ public partial class ExamResultRow : ObservableObject } partial void OnCommentChanged(string value) => OnChanged?.Invoke(this); + partial void OnVariantNameChanged(string value) + { + for (var i = 0; i < Cells.Count; i++) + Cells[i].IsApplicable = ExamScoringService.AppliesTo(_tasks[i], Variant); + RecomputeTotals(); + OnChanged?.Invoke(this); + } private void RecomputeTotals() { - TotalPoints = Cells.Sum(c => c.IsInvalid ? 0 : (c.Value ?? 0)); + TotalPoints = Cells.Sum(c => c.IsApplicable && !c.IsInvalid ? (c.Value ?? 0) : 0); OnPropertyChanged(nameof(TotalPointsDisplay)); - GradeDisplay = Absent ? "abwesend" : _grading.CalculateGrade(TotalPoints, _examMaxPoints, _gradingKey); + GradeDisplay = Absent ? "abwesend" : _grading.CalculateGrade(TotalPoints, + ExamScoringService.Maximum(_tasks, Variant), _gradingKey); } public ExamResult ToModel(Guid examId) => new() @@ -131,6 +147,7 @@ public partial class ExamResultRow : ObservableObject TotalPoints = TotalPoints, Grade = Absent ? null : GradeDisplay, Absent = Absent, + Variant = Variant, Comment = string.IsNullOrWhiteSpace(Comment) ? null : Comment.Trim(), }; } @@ -142,6 +159,7 @@ public partial class PointsCell : ObservableObject public double MaxPoints { get; } [ObservableProperty] private double? _value; [ObservableProperty] private bool _isInvalid; + [ObservableProperty] private bool _isApplicable = true; public Action? OnChanged { get; set; } diff --git a/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs index c2702d2..1896730 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs @@ -13,6 +13,7 @@ namespace LehrerApp.Desktop.ViewModels.Groups; public partial class ExamDialogViewModel : ObservableObject { private readonly IExamRepository _exams; + private readonly IExamResultRepository? _results; private readonly ICompetencyDomainRepository _competencyDomains; private readonly IGradingKeyTemplateRepository _gradingKeyTemplates; private readonly GradingService _grading; @@ -41,13 +42,21 @@ public partial class ExamDialogViewModel : ObservableObject [ObservableProperty] private string _newTemplateName = ""; [ObservableProperty] private string _gradingKeyValidation = ""; [ObservableProperty] private string _selectedNiveauName = "–"; + [ObservableProperty] private Exam? _selectedOriginalExam; + [ObservableProperty] private string _repeatRuleName = "Bessere zählt"; + [ObservableProperty] private string _repeatError = ""; + [ObservableProperty] private string _taskPointsError = ""; public bool IsDifferentiated { get; } public string[] NiveauOptions => NiveauDisplay.Options; + public string[] RepeatRuleOptions => ["Bessere zählt", "Wiederholung zählt", "Erste Arbeit zählt"]; + public bool HasSelectedOriginalExam => SelectedOriginalExam is not null; + public ObservableCollection AvailableOriginalExams { get; } = []; public bool TotalPointsWarning => TotalPoints <= 0; - public string TotalPointsDisplay => - $"{TotalPoints.ToString("0.##", CultureInfo.InvariantCulture)} Punkte gesamt"; + public string TotalPointsDisplay => Tasks.Any(t => t.VariantName != "Beide") + ? $"A: {TotalPoints:0.##} · B: {Tasks.Where(t => !t.IsBonus && t.VariantName != "A").Sum(t => t.MaxPoints):0.##} Punkte (ohne Bonus)" + : $"{TotalPoints.ToString("0.##", CultureInfo.InvariantCulture)} Punkte gesamt (ohne Bonus)"; public ObservableCollection Tasks { get; } = []; public ObservableCollection GradingKeyEntries { get; } = []; @@ -65,9 +74,10 @@ public partial class ExamDialogViewModel : ObservableObject IGradingKeyTemplateRepository gradingKeyTemplates, GradingService grading, Guid groupId, Guid? subjectId, int gradeLevel, GradingSystem gradingSystem, string defaultSubjectName, bool isDifferentiated, Exam? editingExam, Exam? duplicateSource, - Guid competencyCatalogVersionId = default) + Guid competencyCatalogVersionId = default, IExamResultRepository? results = null) { _exams = exams; _competencyDomains = competencyDomains; + _results = results; _gradingKeyTemplates = gradingKeyTemplates; _grading = grading; _groupId = groupId; _subjectId = subjectId; _gradeLevel = gradeLevel; _competencyCatalogVersionId = competencyCatalogVersionId; @@ -76,6 +86,12 @@ public partial class ExamDialogViewModel : ObservableObject _duplicateSource = duplicateSource; _subjectName = defaultSubjectName; IsDifferentiated = isDifferentiated; + var groupExams = _exams.GetByGroup(groupId); + foreach (var exam in groupExams + .Where(e => e.Id != editingExam?.Id && e.RepeatOfExamId is null) + .Where(e => !groupExams.Any(r => r.RepeatOfExamId == e.Id && r.Id != editingExam?.Id)) + .OrderByDescending(e => e.Date)) + AvailableOriginalExams.Add(exam); HasCompetencyCatalog = subjectId.HasValue && _competencyDomains.GetBySubjectAndGrade(subjectId.Value, gradeLevel, competencyCatalogVersionId).Count > 0; @@ -97,8 +113,18 @@ public partial class ExamDialogViewModel : ObservableObject // Beim Duplizieren bewusst kein Niveau übernehmen — meist wird dupliziert, um // gerade eine andere Niveau-Variante (E/G/Förder) derselben Klausur anzulegen. SelectedNiveauName = isDuplicate ? "–" : NiveauDisplay.ToName(source.Niveau); + if (!isDuplicate) + { + SelectedOriginalExam = AvailableOriginalExams.FirstOrDefault(e => e.Id == source.RepeatOfExamId); + RepeatRuleName = source.RepeatGradeRule switch + { + RepeatGradeRule.Repeat => "Wiederholung zählt", + RepeatGradeRule.Original => "Erste Arbeit zählt", + _ => "Bessere zählt", + }; + } foreach (var t in source.Tasks.OrderBy(t => t.Nr)) - AddTaskInternal(t.Title, t.MaxPoints, t.Weight, [.. t.Competencies]); + AddTaskInternal(t.Title, t.MaxPoints, t.Weight, [.. t.Competencies], t.Variant, t.IsBonus); 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); @@ -119,6 +145,9 @@ public partial class ExamDialogViewModel : ObservableObject { foreach (var t in Tasks) t.ShowWeight = value; } + partial void OnSelectedOriginalExamChanged(Exam? value) => OnPropertyChanged(nameof(HasSelectedOriginalExam)); + + [RelayCommand] private void ClearOriginalExam() => SelectedOriginalExam = null; partial void OnDateTextChanged(string value) => UpdateWeekLoadHint(); @@ -148,11 +177,14 @@ public partial class ExamDialogViewModel : ObservableObject RecomputeTotals(); } - private void AddTaskInternal(string? title, double maxPoints, double weight, List competencies) + private void AddTaskInternal(string? title, double maxPoints, double weight, List competencies, + ExamVariant? variant = null, bool isBonus = false) { var item = new ExamTaskEditItem(title, maxPoints, weight, competencies, BuildCompetencyTagGroups(competencies)) { + VariantName = variant?.ToString() ?? "Beide", + IsBonus = isBonus, OnChanged = RecomputeTotals, OnRemove = RemoveTask, OnMoveUp = MoveTaskUp, @@ -201,7 +233,7 @@ public partial class ExamDialogViewModel : ObservableObject private void RecomputeTotals() { - TotalPoints = Tasks.Sum(t => t.MaxPoints); + TotalPoints = Tasks.Where(t => !t.IsBonus && t.VariantName != "B").Sum(t => t.MaxPoints); OnPropertyChanged(nameof(TotalPointsWarning)); OnPropertyChanged(nameof(TotalPointsDisplay)); RecomputeGradingKeyAbsolutes(); @@ -214,6 +246,9 @@ public partial class ExamDialogViewModel : ObservableObject foreach (var e in GradingKeyEntries) e.AbsolutePointsDisplay = TotalPoints <= 0 ? "–" + : Tasks.Any(t => t.VariantName != "Beide") + ? $"A ab {e.MinPercent / 100.0 * TotalPoints:0.##} / " + + $"B ab {e.MinPercent / 100.0 * Tasks.Where(t => !t.IsBonus && t.VariantName != "A").Sum(t => t.MaxPoints):0.##} Punkten" : $"ab {(e.MinPercent / 100.0 * TotalPoints).ToString("0.##", CultureInfo.InvariantCulture)} " + $"von {TotalPoints.ToString("0.##", CultureInfo.InvariantCulture)} Punkten"; } @@ -288,6 +323,7 @@ public partial class ExamDialogViewModel : ObservableObject private void Save() { TitleError = ""; DateTextError = ""; ReturnedAtTextError = ""; GradingKeyValidation = ""; + RepeatError = ""; TaskPointsError = ""; var valid = true; if (string.IsNullOrWhiteSpace(Title)) { TitleError = "Titel erforderlich."; valid = false; } @@ -313,6 +349,17 @@ public partial class ExamDialogViewModel : ObservableObject var gradingKey = BuildGradingKeyEntries(); var gradingKeyError = _grading.ValidateGradingKey(gradingKey); if (gradingKeyError is not null) { GradingKeyValidation = gradingKeyError; valid = false; } + if (SelectedOriginalExam is not null && SelectedOriginalExam.Date >= date) + { + RepeatError = "Die erste Arbeit muss vor der Wiederholung liegen."; + valid = false; + } + if (Tasks.Any(t => t.VariantName != "Beide") && + (TotalPoints <= 0 || Tasks.Where(t => !t.IsBonus && t.VariantName != "A").Sum(t => t.MaxPoints) <= 0)) + { + TaskPointsError = "Fassung A und B brauchen jeweils Grundpunkte."; + valid = false; + } if (!valid) return; @@ -325,12 +372,31 @@ public partial class ExamDialogViewModel : ObservableObject Result.Niveau = NiveauDisplay.FromName(SelectedNiveauName); Result.Tasks = Tasks.Select(t => t.ToModel()).ToList(); Result.GradingKey = gradingKey; + Result.RepeatOfExamId = SelectedOriginalExam?.Id; + Result.RepeatGradeRule = RepeatRuleName switch + { + "Wiederholung zählt" => RepeatGradeRule.Repeat, + "Erste Arbeit zählt" => RepeatGradeRule.Original, + _ => RepeatGradeRule.Better, + }; // Parallelkurse (gleiche Arbeit, mehrere Kurse): beim Duplizieren verknüpfen, damit die // Klausuren-Hauptseite zwischen ihnen umschalten kann. Der Anker ist die Id der zuerst // angelegten Klausur der Kette — schon verknüpfte Quellen geben ihren Anker weiter. if (_duplicateSource is not null) Result.SharedExamGroupId = _duplicateSource.SharedExamGroupId ?? _duplicateSource.Id; _exams.Save(Result); + if (_editingExam is not null && _results is not null) + { + var tasks = Result.Tasks.OrderBy(t => t.Nr).ToList(); + var existingResults = _results.GetByExam(Result.Id); + foreach (var result in existingResults.Where(r => !r.Absent)) + { + result.TotalPoints = ExamScoringService.Achieved(tasks, result.Points, result.Variant); + result.Grade = _grading.CalculateGrade(result.TotalPoints, + ExamScoringService.Maximum(tasks, result.Variant), gradingKey); + } + _results.SaveMany(existingResults); + } } } @@ -361,6 +427,9 @@ public partial class ExamTaskEditItem : ObservableObject [ObservableProperty] private double _weight = 1.0; [ObservableProperty] private bool _isCompetencyPanelOpen; [ObservableProperty] private bool _showWeight; + [ObservableProperty] private string _variantName = "Beide"; + [ObservableProperty] private bool _isBonus; + public string[] VariantOptions => ["Beide", "A", "B"]; public List Competencies { get; } public ObservableCollection CompetencyTagGroups { get; } = []; @@ -409,6 +478,8 @@ public partial class ExamTaskEditItem : ObservableObject [RelayCommand] private void MoveDown() => OnMoveDown?.Invoke(this); partial void OnMaxPointsChanged(double value) => OnChanged?.Invoke(); + partial void OnVariantNameChanged(string value) => OnChanged?.Invoke(); + partial void OnIsBonusChanged(bool value) => OnChanged?.Invoke(); public ExamTask ToModel() => new() { @@ -416,6 +487,8 @@ public partial class ExamTaskEditItem : ObservableObject Title = string.IsNullOrWhiteSpace(Title) ? null : Title.Trim(), MaxPoints = MaxPoints, Weight = Weight, + Variant = VariantName switch { "A" => ExamVariant.A, "B" => ExamVariant.B, _ => null }, + IsBonus = IsBonus, Competencies = Competencies, }; } diff --git a/LehrerApp.Desktop/ViewModels/Groups/GradeOverviewViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/GradeOverviewViewModels.cs index 29cc665..db9a3c5 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/GradeOverviewViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/GradeOverviewViewModels.cs @@ -178,6 +178,7 @@ public partial class GradeOverviewTabViewModel : ObservableObject var cells = new List(); var numeric = new List<(string Grade, double Weight)>(); + var examEntries = new List<(Exam Exam, string Grade)>(); foreach (var exam in exams) { @@ -189,8 +190,10 @@ public partial class GradeOverviewTabViewModel : ObservableObject if (result.Absent) { cells.Add("abwesend"); continue; } var display = FormatValue(result.Grade); cells.Add(display); - if (result.Grade is not null) numeric.Add((result.Grade, 1.0)); + if (result.Grade is not null) examEntries.Add((exam, result.Grade)); } + numeric.AddRange(ExamScoringService.EffectiveGrades(examEntries, _gradingSystem) + .Select(grade => (Grade: grade, Weight: 1.0))); foreach (var key in gradeColumnKeys) { diff --git a/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs index c32cf8a..a955461 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs @@ -206,11 +206,15 @@ public partial class GroupOverviewViewModel : ObservableObject private (double Average, int Count)? ComputeExamAverage(Guid groupId) { + var system = _groups.GetById(groupId)?.GradingSystem ?? GradingSystem.Grades1To6; var grades = _exams.GetByGroup(groupId) - .SelectMany(e => _examResults.GetByExam(e.Id)) - .Where(r => !r.Absent && !string.IsNullOrWhiteSpace(r.Grade)) - .Select(r => (Grade: r.Grade!, Weight: 1.0)) - .ToList(); + .SelectMany(e => _examResults.GetByExam(e.Id) + .Where(r => !r.Absent && !string.IsNullOrWhiteSpace(r.Grade)) + .Select(r => (r.StudentId, Exam: e, Grade: r.Grade!))) + .GroupBy(x => x.StudentId) + .SelectMany(g => ExamScoringService.EffectiveGrades( + g.Select(x => (x.Exam, x.Grade)), system)) + .Select(grade => (Grade: grade, Weight: 1.0)).ToList(); return grades.Count == 0 ? null : (_grading.WeightedAverage(grades), grades.Count); } diff --git a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs index 4542332..f8a7c41 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs @@ -427,6 +427,11 @@ public partial class GroupDetailViewModel : ObservableObject if (SelectedExam is null || OnConfirmDeleteExam is null) return; var selected = SelectedExam; if (!await OnConfirmDeleteExam(selected)) return; + foreach (var repeat in _exams.GetByGroup(Group!.Id).Where(e => e.RepeatOfExamId == selected.Id)) + { + repeat.RepeatOfExamId = null; + _exams.Save(repeat); + } _exams.Delete(selected.Id); SelectedExam = null; ReloadExams(); @@ -618,12 +623,15 @@ public class ExamSummary public string StatusColorHex { get; } public string ReturnedAtDisplay { get; } public string NiveauLabel { get; } + public string TypeLabel { get; } public ExamSummary(Core.Models.Exam e) { Id = e.Id; Title = e.Title; Date = e.Date.ToString("dd.MM.yyyy"); Status = e.Status; ReturnedAtDisplay = e.ReturnedAt?.ToString("dd.MM.yyyy") ?? ""; NiveauLabel = e.Niveau.HasValue ? NiveauDisplay.ToName(e.Niveau) : "alle"; + TypeLabel = e.RepeatOfExamId is not null ? (e.Tasks.Any(t => t.Variant is not null) ? "Wdh. · A/B" : "Wiederholung") + : e.Tasks.Any(t => t.Variant is not null) ? "A/B" : "Klausur"; StatusLabel = e.Status switch { ExamStatus.Planned => "Geplant", diff --git a/LehrerApp.Desktop/ViewModels/Groups/ReportGradeViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ReportGradeViewModels.cs index 4daddfd..ab89a15 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ReportGradeViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ReportGradeViewModels.cs @@ -132,15 +132,17 @@ public partial class ReportGradeDialogViewModel : ObservableObject continue; } - var examGrades = new List<(string Grade, double Weight)>(); + var examEntries = new List<(Exam Exam, string Grade)>(); foreach (var exam in exams) { if (membership is not null && !GroupMembershipService.IsActiveOn(membership, exam.Date)) continue; if (exam.Niveau.HasValue && membership?.Niveau != exam.Niveau) continue; if (!resultsByExam[exam.Id].TryGetValue(student.Id, out var result)) continue; if (result.Absent || result.Grade is null) continue; - examGrades.Add((result.Grade, 1.0)); + examEntries.Add((exam, result.Grade)); } + var examGrades = ExamScoringService.EffectiveGrades(examEntries, _gradingSystem) + .Select(grade => (Grade: grade, Weight: 1.0)).ToList(); var participationGrades = allGrades .Where(g => g.StudentId == student.Id && g.Category == GradeCategory.Participation diff --git a/LehrerApp.Desktop/ViewModels/Groups/StudentPerformanceOverviewViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/StudentPerformanceOverviewViewModel.cs index 907a9a1..0b7a218 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/StudentPerformanceOverviewViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/StudentPerformanceOverviewViewModel.cs @@ -143,6 +143,7 @@ public partial class StudentPerformanceOverviewViewModel : ObservableObject ExamRows.Clear(); ExamHistory.Points.Clear(); _examGradesRaw = []; + var effectiveExamEntries = new List<(Exam Exam, string Grade)>(); int? previousNoteEquivalent = null; foreach (var exam in _exams.GetByGroup(_groupId) .Where(e => e.Date >= periodFrom && e.Date <= periodTo) @@ -153,13 +154,15 @@ public partial class StudentPerformanceOverviewViewModel : ObservableObject var allResults = _results.GetByExam(exam.Id); var own = allResults.FirstOrDefault(r => r.StudentId == _studentId); string? ownGrade = own is { Absent: false, Grade: not null } ? own.Grade : null; - if (ownGrade is not null) _examGradesRaw.Add((ownGrade, 1.0)); + if (ownGrade is not null) effectiveExamEntries.Add((exam, ownGrade)); var classGrades = allResults.Where(r => !r.Absent && r.Grade is not null) .Select(r => (r.Grade!, 1.0)).ToList(); var classAverage = classGrades.Count > 0 ? _grading.WeightedAverage(classGrades) : (double?)null; - ExamRows.Add(new StudentExamRow(exam.Title, exam.Date, ownGrade, + var examLabel = exam.Title + (exam.Tasks.Any(t => t.Variant is not null) && own is not null + ? $" (Fassung {own.Variant})" : ""); + ExamRows.Add(new StudentExamRow(examLabel, exam.Date, ownGrade, classAverage.HasValue ? FormatGrade(classAverage.Value) : "–")); // Gleiche Balken-Sparkline wie die bestehende Notenentwicklung (2.5): Rohwert auf die @@ -174,10 +177,12 @@ public partial class StudentPerformanceOverviewViewModel : ObservableObject var warnings = new List(); if (isDrop) warnings.Add("Abfall um ≥ 1 Note"); if (isFailing) warnings.Add("Versetzungsgefährdung"); - ExamHistory.Points.Add(new GradeHistoryPoint(exam.Date, exam.Title, ownGrade ?? "–", + ExamHistory.Points.Add(new GradeHistoryPoint(exam.Date, examLabel, ownGrade ?? "–", noteEquivalent, warnings.Count > 0, string.Join(" · ", warnings))); if (noteEquivalent.HasValue) previousNoteEquivalent = noteEquivalent; } + _examGradesRaw = ExamScoringService.EffectiveGrades(effectiveExamEntries, _gradingSystem) + .Select(grade => (Grade: grade, Weight: 1.0)).ToList(); _examsAverage = _examGradesRaw.Count > 0 ? _grading.WeightedAverage(_examGradesRaw) : null; // ── Mitarbeit & Sonstige ────────────────────────────────────────── diff --git a/LehrerApp.Desktop/Views/Exams/ExamsOverviewView.axaml.cs b/LehrerApp.Desktop/Views/Exams/ExamsOverviewView.axaml.cs index 1434c07..d4af1cd 100644 --- a/LehrerApp.Desktop/Views/Exams/ExamsOverviewView.axaml.cs +++ b/LehrerApp.Desktop/Views/Exams/ExamsOverviewView.axaml.cs @@ -54,7 +54,8 @@ public partial class ExamsOverviewView : UserControl App.Services.GetRequiredService(), App.Services.GetRequiredService(), App.Services.GetRequiredService(), - exam, group.GradingSystem); + exam, group.GradingSystem, + App.Services.GetRequiredService()); var dialog = new ExamEvaluationDialog { DataContext = dialogVm }; var owner = TopLevel.GetTopLevel(this) as Window; diff --git a/LehrerApp.Desktop/Views/Groups/ExamDialog.axaml b/LehrerApp.Desktop/Views/Groups/ExamDialog.axaml index d530465..7d01a17 100644 --- a/LehrerApp.Desktop/Views/Groups/ExamDialog.axaml +++ b/LehrerApp.Desktop/Views/Groups/ExamDialog.axaml @@ -52,6 +52,29 @@ + + + + + + + + + + + + + +