diff --git a/LehrerApp.Core/Models/Exam.cs b/LehrerApp.Core/Models/Exam.cs index 69e436b..0a65a14 100644 --- a/LehrerApp.Core/Models/Exam.cs +++ b/LehrerApp.Core/Models/Exam.cs @@ -32,6 +32,10 @@ public class Exam /// erste Arbeit () versäumt haben. Anders als eine Wiederholung /// wird sie nur von diesen Schülern geschrieben und ersetzt deren fehlende Note. public bool IsMakeUp { get; set; } + /// Förderarbeit: inhaltlich eigene Arbeit, die einzelne Schüler statt der verknüpften + /// ersten Arbeit schreiben — typischerweise am selben Tag, daher darf sie am Datum der ersten + /// Arbeit liegen. Zählt wie eine Nachschreibarbeit (ersetzt nur eine fehlende Note). + public bool IsSupportWork { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } @@ -78,6 +82,13 @@ public enum RepeatGradeRule { Better, Repeat, Original } /// Reihenfolge ist persistiert (LiteDB speichert den Enum-Wert) — nur hinten anhängen. public enum ExamKind { Klausur, Test, KontrollierteHausaufgabe, Sonstiges } +public static class ExamFollowUpExtensions +{ + /// Nachschreib- und Förderarbeiten sind inhaltlich eigene Arbeiten nur für einzelne + /// Schüler: ihre Note ersetzt eine fehlende Note der ersten Arbeit, verdrängt sie aber nie. + public static bool ReplacesMissingGrade(this Exam exam) => exam.IsMakeUp || exam.IsSupportWork; +} + public static class ExamKindExtensions { public static bool CountsAsWritten(this ExamKind kind) => kind == ExamKind.Klausur; diff --git a/LehrerApp.Core/Services/ExamCoverSheetService.cs b/LehrerApp.Core/Services/ExamCoverSheetService.cs index 936d9cf..43a0361 100644 --- a/LehrerApp.Core/Services/ExamCoverSheetService.cs +++ b/LehrerApp.Core/Services/ExamCoverSheetService.cs @@ -37,12 +37,12 @@ public static class ExamCoverSheetService /// Wer eine Arbeit mitschreibt — eine Regel für Punkteeingabe und Deckblätter. public static class ExamParticipantService { - /// Bei einer Nachschreibarbeit: Schüler, die die erste Arbeit bereits mit Note - /// geschrieben haben. Sonst leer. + /// Bei einer Nachschreib- oder Förderarbeit: Schüler, die die erste Arbeit bereits mit + /// Note geschrieben haben. Sonst leer. public static HashSet WroteOriginal(Exam exam, Func> resultsByExam) { var wroteOriginal = new HashSet(); - if (exam.IsMakeUp && exam.RepeatOfExamId is { } originalId) + if (exam.ReplacesMissingGrade() && exam.RepeatOfExamId is { } originalId) foreach (var r in resultsByExam(originalId).Where(r => !r.Absent && r.Grade is not null)) wroteOriginal.Add(r.StudentId); return wroteOriginal; @@ -54,7 +54,7 @@ public static class ExamParticipantService // Niveau-Klausur: nur Schüler mit passendem Niveau. Klausuren ohne Niveau-Zuordnung gelten // weiterhin für die ganze Gruppe. if (exam.Niveau.HasValue && membership?.Niveau != exam.Niveau) return false; - // Nachschreibarbeit: wer die erste Arbeit schon mit Note geschrieben hat, taucht nicht auf — + // Nachschreib-/Förderarbeit: wer die erste Arbeit schon mit Note geschrieben hat, taucht nicht auf — // außer es gibt hier bereits ein Ergebnis (nichts verstecken, was schon eingetragen wurde). return hasResult || !wroteOriginal; } diff --git a/LehrerApp.Core/Services/ExamScoringService.cs b/LehrerApp.Core/Services/ExamScoringService.cs index 9b673d1..6e32721 100644 --- a/LehrerApp.Core/Services/ExamScoringService.cs +++ b/LehrerApp.Core/Services/ExamScoringService.cs @@ -45,10 +45,10 @@ public static class ExamScoringService AppliesTo(task, variant) && index < points.Count ? points[index] : 0).Sum(); /// - /// Wählt pro Arbeit samt allen verknüpften Folgearbeiten (Wiederholung, Nachschreibarbeit) + /// Wählt pro Arbeit samt allen verknüpften Folgearbeiten (Wiederholung, Nachschreib-/Förderarbeit) /// genau eine Note. Andere Arbeiten bleiben erhalten. Fehlt die erste Arbeit (Schüler war - /// abwesend), zählt die Nachschreibarbeit allein; hat der Schüler beide geschrieben, zählt bei - /// einer Nachschreibarbeit immer die erste — sie ersetzt nur eine fehlende Note. + /// abwesend), zählt die Nachschreib-/Förderarbeit allein; hat der Schüler beide geschrieben, + /// zählt immer die erste — sie ersetzen nur eine fehlende Note. /// public static List EffectiveGrades(IEnumerable<(Exam Exam, string Grade)> entries, GradingSystem system) @@ -72,7 +72,7 @@ public static class ExamScoringService var current = ordered[0].Grade; foreach (var next in ordered.Skip(1)) current = Choose(current, next.Grade, - next.Exam.IsMakeUp ? RepeatGradeRule.Original : next.Exam.RepeatGradeRule, system); + next.Exam.ReplacesMissingGrade() ? RepeatGradeRule.Original : next.Exam.RepeatGradeRule, system); result.Add(current); } return result; diff --git a/LehrerApp.Desktop.Tests/ExamDialogViewModelTests.cs b/LehrerApp.Desktop.Tests/ExamDialogViewModelTests.cs index fc3796b..429c7ab 100644 --- a/LehrerApp.Desktop.Tests/ExamDialogViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/ExamDialogViewModelTests.cs @@ -84,4 +84,81 @@ public sealed class ExamDialogViewModelTests Assert.Equal(10, result.TotalPoints); Assert.Equal("1", result.Grade); } + + // ── Förderarbeit ───────────────────────────────────────────────────────── + + private static (ExamDialogViewModel Vm, Exam Original) BuildFollowUpViewModel(DateOnly originalDate) + { + var original = new Exam { GroupId = Guid.NewGuid(), Title = "Atombau", Date = originalDate }; + var vm = new ExamDialogViewModel(new FakeExams([original]), new FakeCompetencyDomains(), + new FakeGradingKeyTemplates(), new GradingService(), original.GroupId, null, 9, + GradingSystem.Grades1To6, "Chemie", false, null, null); + vm.Title = "Atombau (Förder)"; + vm.Tasks.Add(new ExamTaskEditItem("A1", 10, 1, [], []) { OnChanged = () => { } }); + vm.Tasks[0].MaxPoints = 10; + vm.SelectedOriginalExam = original; + return (vm, original); + } + + [Fact] + public void Foerderarbeit_UebernimmtDatumDerErstenArbeitUndDarfAmSelbenTagLiegen() + { + var (vm, original) = BuildFollowUpViewModel(new DateOnly(2026, 10, 14)); + + vm.FollowUpKindName = "Förderarbeit"; + + Assert.Equal("14.10.2026", vm.DateText); + vm.SaveCommand.Execute(null); + Assert.Equal("", vm.RepeatError); + Assert.NotNull(vm.Result); + Assert.True(vm.Result!.IsSupportWork); + Assert.False(vm.Result.IsMakeUp); + Assert.Equal(original.Id, vm.Result.RepeatOfExamId); + Assert.Equal(original.Date, vm.Result.Date); + } + + [Fact] + public void Foerderarbeit_VorDerErstenArbeit_WirdAbgelehnt() + { + var (vm, _) = BuildFollowUpViewModel(new DateOnly(2026, 10, 14)); + vm.FollowUpKindName = "Förderarbeit"; + + vm.DateText = "13.10.2026"; + vm.SaveCommand.Execute(null); + + Assert.Contains("Förderarbeit", vm.RepeatError); + Assert.Null(vm.Result); + } + + [Fact] + public void Nachschreibarbeit_AmSelbenTag_BleibtAbgelehnt() + { + var (vm, _) = BuildFollowUpViewModel(new DateOnly(2026, 10, 14)); + vm.FollowUpKindName = "Nachschreibarbeit"; + + vm.DateText = "14.10.2026"; + vm.SaveCommand.Execute(null); + + Assert.Contains("Nachschreibarbeit", vm.RepeatError); + Assert.Null(vm.Result); + } + + [Fact] + public void Foerderarbeit_WirdBeimBearbeitenWiederhergestelltOhneDatumZuUeberschreiben() + { + var original = new Exam { GroupId = Guid.NewGuid(), Title = "Atombau", Date = new DateOnly(2026, 10, 14) }; + var support = new Exam + { + GroupId = original.GroupId, Title = "Förder", Date = new DateOnly(2026, 10, 16), + RepeatOfExamId = original.Id, IsSupportWork = true, + }; + + var vm = new ExamDialogViewModel(new FakeExams([original, support]), new FakeCompetencyDomains(), + new FakeGradingKeyTemplates(), new GradingService(), original.GroupId, null, 9, + GradingSystem.Grades1To6, "Chemie", false, support, null); + + Assert.True(vm.IsSupportWork); + Assert.Equal("16.10.2026", vm.DateText); + Assert.False(vm.ShowRepeatRule); + } } diff --git a/LehrerApp.Desktop/Services/GradeReportDrawingBuilder.cs b/LehrerApp.Desktop/Services/GradeReportDrawingBuilder.cs index 61cdbff..59103dc 100644 --- a/LehrerApp.Desktop/Services/GradeReportDrawingBuilder.cs +++ b/LehrerApp.Desktop/Services/GradeReportDrawingBuilder.cs @@ -22,7 +22,8 @@ public static class GradeReportDrawingBuilder if (exam.RepeatOfExamId is null) return new DrawingValue([], 0); if (original is null || original.Id != exam.RepeatOfExamId || original.GroupId != exam.GroupId) throw new InvalidDataException("Die verknüpfte erste Arbeit ist nicht verfügbar."); - var rule = exam.IsMakeUp ? "Die Nachschreibarbeit ersetzt die versäumte erste Arbeit. Liegen beide Noten vor, zählt die erste Arbeit." + var rule = exam.IsSupportWork ? "Die Förderarbeit ersetzt die erste Arbeit. Liegen beide Noten vor, zählt die erste Arbeit." + : exam.IsMakeUp ? "Die Nachschreibarbeit ersetzt die versäumte erste Arbeit. Liegen beide Noten vor, zählt die erste Arbeit." : exam.RepeatGradeRule switch { RepeatGradeRule.Repeat => "Für die Bewertung zählt die Note der Wiederholungsarbeit.", @@ -35,7 +36,7 @@ public static class GradeReportDrawingBuilder ExamScoringService.Maximum(original.Tasks, originalResult.Variant), original.GradingKey); var firstGrade = originalResult is { Absent: true } ? "abwesend" : Value(grade); return Text(template, ReportTextDrawing.RepeatInfo, - exam.IsMakeUp ? "Nachschreibarbeit" : "Wiederholungsarbeit", + exam.IsSupportWork ? "Förderarbeit" : exam.IsMakeUp ? "Nachschreibarbeit" : "Wiederholungsarbeit", [$"Erste Arbeit: {original.Title} vom {original.Date:dd.MM.yyyy}", $"Note der ersten Arbeit: {firstGrade}", rule]); } diff --git a/LehrerApp.Desktop/ViewModels/Groups/ExamEvaluationViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ExamEvaluationViewModels.cs index a850c0e..bd44e3a 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ExamEvaluationViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ExamEvaluationViewModels.cs @@ -42,7 +42,7 @@ public partial class ExamEvaluationDialogViewModel : ObservableObject public ObservableCollection RepeatComparison { get; } = []; public bool HasRepeatComparison => RepeatComparison.Count > 0; public string RepeatComparisonTitle { get; private set; } = ""; - /// "Wiederholung" bzw. "Nachschreibarbeit" — Beschriftung der Folgearbeit im Vergleich. + /// "Wiederholung", "Nachschreibarbeit" bzw. "Förderarbeit" — Beschriftung der Folgearbeit im Vergleich. public string FollowUpLabel { get; private set; } = "Wiederholung"; public string FollowUpComparisonHeading => $"Vergleich mit der {FollowUpLabel}"; public ObservableCollection GradingKeyEntries { get; } = []; @@ -72,8 +72,8 @@ public partial class ExamEvaluationDialogViewModel : ObservableObject : _exams.GetByGroup(_exam.GroupId).Where(e => e.RepeatOfExamId == _exam.Id) .OrderBy(e => e.Date).FirstOrDefault(); if (repeat is null) return; - FollowUpLabel = repeat.IsMakeUp ? "Nachschreibarbeit" : "Wiederholung"; - var rule = repeat.IsMakeUp ? "ersetzt fehlende Note" : repeat.RepeatGradeRule switch + FollowUpLabel = repeat.IsSupportWork ? "Förderarbeit" : repeat.IsMakeUp ? "Nachschreibarbeit" : "Wiederholung"; + var rule = repeat.ReplacesMissingGrade() ? "ersetzt fehlende Note" : repeat.RepeatGradeRule switch { RepeatGradeRule.Repeat => "Wiederholung zählt", RepeatGradeRule.Original => "Erste Arbeit zählt", diff --git a/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs index e18694b..8ee5101 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs @@ -47,19 +47,33 @@ public partial class ExamDialogViewModel : ObservableObject [ObservableProperty] private string _repeatError = ""; [ObservableProperty] private string _taskPointsError = ""; [ObservableProperty] private string _kindName = ExamKind.Klausur.Label(); - [ObservableProperty] private bool _isMakeUp; + [ObservableProperty] private string _followUpKindName = WiederholungName; + + private const string WiederholungName = "Wiederholung"; + private const string NachschreibarbeitName = "Nachschreibarbeit"; + private const string FoerderarbeitName = "Förderarbeit"; + // Beim Befüllen aus einer vorhandenen Arbeit darf die Datums-Übernahme das gespeicherte Datum + // nicht überschreiben. + private bool _initializing = true; public bool IsDifferentiated { get; } public string[] NiveauOptions => NiveauDisplay.Options; public string[] KindOptions { get; } = Enum.GetValues().Select(k => k.Label()).ToArray(); public string[] RepeatRuleOptions => ["Bessere zählt", "Wiederholung zählt", "Erste Arbeit zählt"]; + public string[] FollowUpKindOptions { get; } = [WiederholungName, NachschreibarbeitName, FoerderarbeitName]; + public bool IsMakeUp => FollowUpKindName == NachschreibarbeitName; + public bool IsSupportWork => FollowUpKindName == FoerderarbeitName; public bool HasSelectedOriginalExam => SelectedOriginalExam is not null; /// Art und Zählweise sind bei einer verknüpften Folgearbeit festgelegt: sie erbt die Art der - /// ersten Arbeit, eine Nachschreibarbeit ersetzt nur fehlende Noten (keine Regelwahl nötig). + /// ersten Arbeit, Nachschreib- und Förderarbeiten ersetzen nur fehlende Noten (keine Regelwahl nötig). public bool CanChooseKind => !HasSelectedOriginalExam; - public bool ShowRepeatRule => HasSelectedOriginalExam && !IsMakeUp; - public string LinkedExamLabel => IsMakeUp - ? "Nachschreibarbeit zu (optional)" : "Wiederholung oder Nachschreibarbeit zu (optional)"; + public bool ShowRepeatRule => HasSelectedOriginalExam && FollowUpKindName == WiederholungName; + public string LinkedExamLabel => FollowUpKindName switch + { + NachschreibarbeitName => "Nachschreibarbeit zu (optional)", + FoerderarbeitName => "Förderarbeit zu (optional)", + _ => "Wiederholung, Nachschreib- oder Förderarbeit zu (optional)", + }; private ExamKind Kind => Enum.GetValues().FirstOrDefault(k => k.Label() == KindName); public ObservableCollection AvailableOriginalExams { get; } = []; @@ -128,7 +142,9 @@ public partial class ExamDialogViewModel : ObservableObject if (!isDuplicate) { SelectedOriginalExam = AvailableOriginalExams.FirstOrDefault(e => e.Id == source.RepeatOfExamId); - IsMakeUp = source.IsMakeUp && SelectedOriginalExam is not null; + if (SelectedOriginalExam is not null) + FollowUpKindName = source.IsSupportWork ? FoerderarbeitName + : source.IsMakeUp ? NachschreibarbeitName : WiederholungName; RepeatRuleName = source.RepeatGradeRule switch { RepeatGradeRule.Repeat => "Wiederholung zählt", @@ -152,6 +168,7 @@ public partial class ExamDialogViewModel : ObservableObject foreach (var t in Tasks) t.ShowWeight = UseWeighting; RecomputeTotals(); UpdateWeekLoadHint(); + _initializing = false; } partial void OnUseWeightingChanged(bool value) @@ -161,17 +178,29 @@ public partial class ExamDialogViewModel : ObservableObject partial void OnSelectedOriginalExamChanged(Exam? value) { if (value is not null) KindName = value.Kind.Label(); - else IsMakeUp = false; + else FollowUpKindName = WiederholungName; OnPropertyChanged(nameof(HasSelectedOriginalExam)); OnPropertyChanged(nameof(CanChooseKind)); OnPropertyChanged(nameof(ShowRepeatRule)); + TakeOverOriginalDateForSupportWork(); UpdateWeekLoadHint(); } - partial void OnIsMakeUpChanged(bool value) + partial void OnFollowUpKindNameChanged(string value) { + OnPropertyChanged(nameof(IsMakeUp)); + OnPropertyChanged(nameof(IsSupportWork)); OnPropertyChanged(nameof(ShowRepeatRule)); OnPropertyChanged(nameof(LinkedExamLabel)); + TakeOverOriginalDateForSupportWork(); + } + + /// Eine Förderarbeit wird meist parallel zur ersten Arbeit geschrieben: das Datum der + /// ersten Arbeit ist der Vorschlag, bleibt aber änderbar. + private void TakeOverOriginalDateForSupportWork() + { + if (_initializing || !IsSupportWork || SelectedOriginalExam is null) return; + DateText = SelectedOriginalExam.Date.ToString("dd.MM.yyyy"); } partial void OnKindNameChanged(string value) => UpdateWeekLoadHint(); @@ -382,12 +411,25 @@ 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) + // Datumsprüfung nur bei gültigem Datum — sonst meldet das Datumsfeld den Fehler bereits. + // Eine Förderarbeit entsteht parallel zur ersten Arbeit und darf am selben Tag liegen. + if (SelectedOriginalExam is not null && DateTextError == "") { - RepeatError = IsMakeUp - ? "Die erste Arbeit muss vor der Nachschreibarbeit liegen." - : "Die erste Arbeit muss vor der Wiederholung liegen."; - valid = false; + if (IsSupportWork) + { + if (SelectedOriginalExam.Date > date) + { + RepeatError = "Die Förderarbeit darf nicht vor der ersten Arbeit liegen."; + valid = false; + } + } + else if (SelectedOriginalExam.Date >= date) + { + RepeatError = IsMakeUp + ? "Die erste Arbeit muss vor der Nachschreibarbeit liegen." + : "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)) @@ -410,6 +452,7 @@ public partial class ExamDialogViewModel : ObservableObject Result.RepeatOfExamId = SelectedOriginalExam?.Id; Result.Kind = SelectedOriginalExam?.Kind ?? Kind; Result.IsMakeUp = IsMakeUp && SelectedOriginalExam is not null; + Result.IsSupportWork = IsSupportWork && SelectedOriginalExam is not null; Result.RepeatGradeRule = RepeatRuleName switch { "Wiederholung zählt" => RepeatGradeRule.Repeat, diff --git a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs index c3498b8..a8ccc99 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs @@ -656,6 +656,7 @@ public class ExamSummary NiveauLabel = e.Niveau.HasValue ? NiveauDisplay.ToName(e.Niveau) : "alle"; var hasVariants = e.Tasks.Any(t => t.Variant is not null); var followUp = e.RepeatOfExamId is null ? null + : e.IsSupportWork ? (hasVariants ? "Förder · A/B" : "Förderarbeit") : e.IsMakeUp ? (hasVariants ? "Nachschr. · A/B" : "Nachschreiben") : hasVariants ? "Wdh. · A/B" : "Wiederholung"; // Folgearbeiten behalten ihre Art im Hintergrund (sie erben sie), die Spalte nennt nur diff --git a/LehrerApp.Desktop/ViewModels/Groups/ReportGradeAssistantViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ReportGradeAssistantViewModels.cs index 4063417..ef3b276 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ReportGradeAssistantViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ReportGradeAssistantViewModels.cs @@ -373,7 +373,7 @@ public partial class ReportGradeAssistantViewModel : ObservableObject if (!ReportGradeCalculator.ExamApplies(exam, membership)) continue; var results = _calculator.ResultsByExam[exam.Id]; results.TryGetValue(studentId, out var own); - // Nachschreib- und Wiederholungsarbeiten betreffen nur einzelne Schüler — ohne eigenes + // Nachschreib-, Förder- und Wiederholungsarbeiten betreffen nur einzelne Schüler — ohne eigenes // Ergebnis gehören sie nicht in diese Übersicht. if (own is null && exam.RepeatOfExamId.HasValue) continue; @@ -396,7 +396,8 @@ public partial class ReportGradeAssistantViewModel : ObservableObject var classAverage = classGrades.Count > 0 ? $"Ø Kurs {Format(_grading.WeightedAverage(classGrades))}" : ""; var kind = exam.Kind.Label(); - if (exam.IsMakeUp) kind += " · Nachschreibarbeit"; + if (exam.IsSupportWork) kind += " · Förderarbeit"; + else if (exam.IsMakeUp) kind += " · Nachschreibarbeit"; else if (exam.RepeatOfExamId.HasValue) kind += " · Wiederholung"; Works.Add(new AssistantWorkCard(exam.Title, exam.Date, kind, gradeDisplay, percentDisplay, diff --git a/LehrerApp.Desktop/Views/Groups/ExamDialog.axaml b/LehrerApp.Desktop/Views/Groups/ExamDialog.axaml index 01ceb31..0b8cb32 100644 --- a/LehrerApp.Desktop/Views/Groups/ExamDialog.axaml +++ b/LehrerApp.Desktop/Views/Groups/ExamDialog.axaml @@ -76,9 +76,9 @@