diff --git a/LehrerApp.Core/Models/Planning.cs b/LehrerApp.Core/Models/Planning.cs index 9b4a9d3..23d4644 100644 --- a/LehrerApp.Core/Models/Planning.cs +++ b/LehrerApp.Core/Models/Planning.cs @@ -258,6 +258,19 @@ public class ReportGrade public string? OverrideValue { get; set; } public string? OverrideReason { get; set; } public bool IsLocked { get; set; } + /// Nur am Datensatz "Gesamtes Schuljahr": Note der Projektprüfung, wenn sie in + /// diesem Fach abgelegt wurde (Klasse 10). Zählt in der Endjahresnote wie ein weiteres + /// Halbjahr. + public string? ProjectExamGrade { get; set; } + /// Wie , für die mündliche Abschlussprüfung im + /// gewählten Fach. + public string? OralExamGrade { get; set; } + /// Note der schriftlichen Abschlussprüfung (Deutsch, Mathematik, erste + /// Fremdsprache), nur am Jahres-Datensatz. + public string? WrittenExamGrade { get; set; } + /// Note einer zusätzlichen mündlichen Prüfung ("Nachprüfung", § 8 Abs. 3 und 4 + /// PrüfV Sek. I), nur am Jahres-Datensatz. Zur Verrechnung siehe docs/Datenmodell.md. + public string? AdditionalOralExamGrade { get; set; } public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } diff --git a/LehrerApp.Core/Services/GradingService.cs b/LehrerApp.Core/Services/GradingService.cs index f1a1136..1f76b2d 100644 --- a/LehrerApp.Core/Services/GradingService.cs +++ b/LehrerApp.Core/Services/GradingService.cs @@ -87,7 +87,7 @@ public class GradingService /// Wandelt eine Notenangabe mit optionaler Tendenz ("3+", "2-") in einen rechnerischen /// Notenwert um. Tendenzen verschieben um ein Drittel in Richtung der besseren Note /// (z.B. "3+" → 2,67, "2-" → 2,33) — der in deutschen Notensystemen übliche Drittelabstand. - private static double? ParseGradeValue(string grade) + public static double? ParseGradeValue(string grade) { var trimmed = grade.Trim(); if (trimmed.Length == 0) return null; @@ -132,6 +132,18 @@ public class GradingService List<(string Grade, double Weight)> participationGrades, List<(string Grade, double Weight)> otherGrades, GradingScheme scheme, GradingSystem system, RoundingRule rounding) + { + var weighted = CalculateReportGradeValue(examGrades, participationGrades, otherGrades, scheme); + return weighted is null ? null : RoundToGrade(weighted.Value, system, rounding); + } + + /// Wie , liefert aber den ungerundeten gewichteten Wert — + /// für Ansichten, die zeigen sollen, wie knapp eine Note zwischen zwei Stufen liegt. + public double? CalculateReportGradeValue( + List<(string Grade, double Weight)> examGrades, + List<(string Grade, double Weight)> participationGrades, + List<(string Grade, double Weight)> otherGrades, + GradingScheme scheme) { var buckets = new List<(double Avg, double Percent)>(); if (examGrades.Count > 0) buckets.Add((WeightedAverage(examGrades), scheme.ExamsPercent)); @@ -141,8 +153,7 @@ public class GradingService var totalPercent = buckets.Sum(b => b.Percent); if (buckets.Count == 0 || totalPercent <= 0) return null; - var weighted = buckets.Sum(b => b.Avg * b.Percent) / totalPercent; - return RoundToGrade(weighted, system, rounding); + return buckets.Sum(b => b.Avg * b.Percent) / totalPercent; } } diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs index 0df1a47..11ad303 100644 --- a/LehrerApp.Desktop.Tests/Fakes.cs +++ b/LehrerApp.Desktop.Tests/Fakes.cs @@ -256,6 +256,14 @@ public class FakeAspects : IParticipationAspectRepository public void Delete(Guid id) => _all.RemoveAll(a => a.Id == id); } +public class FakeSections(List? initial = null) : IParticipationSectionRepository +{ + private readonly List _all = initial ?? []; + public List GetByGroup(Guid groupId) => _all.Where(s => s.GroupId == groupId).ToList(); + public void Save(ParticipationSection section) { _all.RemoveAll(s => s.Id == section.Id); _all.Add(section); } + public void Delete(Guid id) => _all.RemoveAll(s => s.Id == id); +} + public class FakeGrades : IGradeRepository { private readonly List _all = []; diff --git a/LehrerApp.Desktop.Tests/ReportGradeAssistantViewModelTests.cs b/LehrerApp.Desktop.Tests/ReportGradeAssistantViewModelTests.cs new file mode 100644 index 0000000..d19e466 --- /dev/null +++ b/LehrerApp.Desktop.Tests/ReportGradeAssistantViewModelTests.cs @@ -0,0 +1,388 @@ +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using LehrerApp.Desktop.ViewModels.Groups; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +/// Tests für den Zeugnisnoten-Assistenten: eigenständige Halbjahre (H1 nur als Kontext in H2), +/// Endjahresnote aus den beiden Halbjahresnoten, Speichern/Festschreiben über dieselben +/// ReportGrade-Datensätze wie der Zeugnisnoten-Dialog sowie die beiden Graph-Modi. +public sealed class ReportGradeAssistantViewModelTests +{ + private static readonly Guid GroupId = Guid.NewGuid(); + private static readonly Student Anna = new() { Id = Guid.NewGuid(), FirstName = "Anna", LastName = "Beispiel" }; + private static readonly Student Ben = new() { Id = Guid.NewGuid(), FirstName = "Ben", LastName = "Muster" }; + + private sealed class Fixture + { + public List Exams { get; } = []; + public FakeResults Results { get; } = new(); + public FakeGrades Grades { get; } = new(); + public FakeReportGrades ReportGrades { get; } = new(); + public List Sessions { get; } = []; + public FakeEntries Entries { get; } = new(); + public List Sections { get; } = []; + + public ReportGradeAssistantViewModel Build(DateOnly today, int gradeLevel = 8) + { + var schemes = new FakeSchemes(); + schemes.SetForGroup(GroupId, new GradingScheme { ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 }); + return new ReportGradeAssistantViewModel( + Grades, new FakeExams(Exams), Results, new FakeStudents([Anna, Ben]), new FakeMemberships([]), + schemes, ReportGrades, new FakeSessions(Sessions), Entries, new FakeAspects(), + new FakeSections(Sections), new AttendanceBalanceService(), new GradingService(), + GroupId, GroupType.Class, GradingSystem.Grades1To6, "Testkurs", "2025/26", gradeLevel, today); + } + + public Exam AddExam(string title, DateOnly date, string annaGrade) + { + var exam = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Title = title, Date = date }; + Exams.Add(exam); + Results.Add(new ExamResult { ExamId = exam.Id, StudentId = Anna.Id, Grade = annaGrade }); + return exam; + } + + public void AddParticipation(DateOnly date, string value, string? note = null) => + Grades.Add(new Grade { StudentId = Anna.Id, GroupId = GroupId, Category = GradeCategory.Participation, + Date = date, Value = value, Note = note }); + } + + private static readonly DateOnly InH1 = new(2025, 11, 10); + private static readonly DateOnly InH2 = new(2026, 4, 20); + + [Fact] + public void Vorbelegung_WaehltDasLaufendeHalbjahr() + { + Assert.Equal(ParticipationPeriod.H1, new Fixture().Build(InH1).SelectedPeriod.Period); + Assert.Equal(ParticipationPeriod.H2, new Fixture().Build(InH2).SelectedPeriod.Period); + } + + [Fact] + public void Halbjahr_BerechnetNoteUndZeigtTeilbereiche() + { + var f = new Fixture(); + f.AddExam("K1", new DateOnly(2025, 10, 1), "2"); + f.AddParticipation(new DateOnly(2025, 10, 15), "3"); + + var vm = f.Build(InH1); + + // (2*50 + 3*40) / 90 = 2,44 -> 2 + Assert.Equal("2", vm.CalculatedDisplay); + Assert.Contains("2,44", vm.CalculationDetail); + Assert.Equal(["2,00", "3,00", "–"], vm.Tiles.Select(t => t.Value)); + Assert.Equal("2", vm.Students[0].GradeDisplay); + Assert.Equal(AssistantGradeStatus.Open, vm.Students[0].Status); + } + + [Fact] + public void ZweitesHalbjahr_ErstesZaehltNichtWirdAberAlsKontextGezeigt() + { + var f = new Fixture(); + f.AddExam("K1", new DateOnly(2025, 10, 1), "5"); + f.AddExam("K2", new DateOnly(2026, 3, 1), "2"); + f.AddParticipation(new DateOnly(2025, 12, 1), "5"); + + var vm = f.Build(InH2); + + Assert.Equal("2", vm.CalculatedDisplay); + Assert.Equal(2, vm.Works.Count); + Assert.True(vm.Works[0].IsContext); + Assert.False(vm.Works[1].IsContext); + Assert.True(Assert.Single(vm.ParticipationGrades).IsContext); + + vm.ShowPreviousHalf = false; + + Assert.Equal("K2", Assert.Single(vm.Works).Title); + Assert.Empty(vm.ParticipationGrades); + Assert.Equal("2", vm.CalculatedDisplay); + } + + [Fact] + public void Schuljahr_VerrechnetHalbjahresnotenUndBevorzugtGespeicherteEntscheidung() + { + var f = new Fixture(); + f.AddExam("K1", new DateOnly(2025, 10, 1), "2"); + f.AddExam("K2", new DateOnly(2026, 3, 1), "4"); + var vm = f.Build(InH2); + + vm.SelectedPeriod = vm.PeriodOptions.Single(p => p.Period == ParticipationPeriod.FullYear); + + // (2 + 4) / 2 = 3 + Assert.Equal("3", vm.CalculatedDisplay); + Assert.Equal(["2", "4"], vm.Tiles.Select(t => t.Value)); + + // Pädagogisch übersteuerte Halbjahresnote geht in die Verrechnung ein, nicht die berechnete. + f.ReportGrades.Save(new ReportGrade { StudentId = Anna.Id, GroupId = GroupId, Period = "1. Halbjahr", + CalculatedValue = "2", OverrideValue = "1", OverrideReason = "starke Entwicklung" }); + vm.SelectedPeriod = vm.PeriodOptions.Single(p => p.Period == ParticipationPeriod.H2); + vm.SelectedPeriod = vm.PeriodOptions.Single(p => p.Period == ParticipationPeriod.FullYear); + + // (1 + 4) / 2 = 2,5 -> kaufmännisch 3, pädagogisch 2 + Assert.Equal("3", vm.CalculatedDisplay); + Assert.Contains("2,50", vm.CalculationDetail); + vm.RoundingRule = RoundingRule.Pedagogical; + Assert.Equal("2", vm.CalculatedDisplay); + } + + [Fact] + public void Klasse10_PruefungsnotenZaehlenWieWeitereHalbjahre() + { + var f = new Fixture(); + f.AddExam("K1", new DateOnly(2025, 10, 1), "2"); + f.AddExam("K2", new DateOnly(2026, 3, 1), "2"); + var vm = f.Build(InH2, gradeLevel: 10); + vm.SelectedPeriod = vm.PeriodOptions.Single(p => p.Period == ParticipationPeriod.FullYear); + + Assert.True(vm.CanEnterExamGrades); + Assert.Equal("2", vm.CalculatedDisplay); + + // (2 + 2 + 5) / 3 = 3 + vm.ProjectExamGrade = "5"; + Assert.Equal("3", vm.CalculatedDisplay); + Assert.Equal(3, vm.Tiles.Count); + + // (2 + 2 + 5 + 5) / 4 = 3,5 -> 4 + vm.OralExamGrade = "5"; + Assert.Equal("4", vm.CalculatedDisplay); + Assert.Contains("4 Teilnoten", vm.CalculationDetail); + + vm.SaveCommand.Execute(null); + + var record = f.ReportGrades.GetByStudentGroupPeriod(Anna.Id, GroupId, "Gesamtes Schuljahr"); + Assert.Equal("5", record!.ProjectExamGrade); + Assert.Equal("5", record.OralExamGrade); + Assert.Equal("4", record.CalculatedValue); + Assert.Equal("4", vm.Students[0].GradeDisplay); + } + + private static ReportGradeAssistantViewModel Klasse10Schuljahr(Fixture f, string h1, string h2) + { + f.AddExam("K1", new DateOnly(2025, 10, 1), h1); + f.AddExam("K2", new DateOnly(2026, 3, 1), h2); + var vm = f.Build(InH2, gradeLevel: 10); + vm.SelectedPeriod = vm.PeriodOptions.Single(p => p.Period == ParticipationPeriod.FullYear); + return vm; + } + + [Fact] + public void Klasse10_SchriftlichePruefungGehtMitEinemDrittelEin() + { + var vm = Klasse10Schuljahr(new Fixture(), "2", "2"); + + // § 14 Abs. 3 ObSchVO: (2 * 2 + 5) / 3 = 3,0 + vm.WrittenExamGrade = "5"; + + Assert.Equal("3", vm.CalculatedDisplay); + } + + [Fact] + public void Klasse10_ZusaetzlicheMuendlicheImSchriftlichenFach_EinsZuEinsOhneRundung() + { + var f = new Fixture(); + var vm = Klasse10Schuljahr(f, "4", "4"); + vm.WrittenExamGrade = "6"; + + // (4 + 4 + 6) / 3 = 4,67 -> 5 + Assert.Equal("5", vm.CalculatedDisplay); + + // § 13 Abs. 2 PrüfV: (6 + 3) / 2 = 4,5 — bleibt ungerundet; (4 + 4 + 4,5) / 3 = 4,17 -> 4 + vm.AdditionalOralExamGrade = "3"; + + Assert.Equal("4", vm.CalculatedDisplay); + Assert.Contains("4,17", vm.CalculationDetail); + var tile = vm.Tiles.Single(t => t.Label == "Prüfungsleistung"); + Assert.Equal("4,5", tile.Value); + Assert.Contains("zwischen 4 und 5", tile.Hint); + + vm.SaveCommand.Execute(null); + var record = f.ReportGrades.GetByStudentGroupPeriod(Anna.Id, GroupId, "Gesamtes Schuljahr"); + Assert.Equal("6", record!.WrittenExamGrade); + Assert.Equal("3", record.AdditionalOralExamGrade); + } + + [Fact] + public void GenauZwischenZweiNoten_WeistAufPaedagogischeEntscheidungHin() + { + var f = new Fixture(); + f.AddExam("K1", new DateOnly(2025, 10, 1), "2"); + f.AddExam("K2", new DateOnly(2026, 3, 1), "3"); + var vm = f.Build(InH2); + Assert.Empty(vm.BorderlineHint); + + vm.SelectedPeriod = vm.PeriodOptions.Single(p => p.Period == ParticipationPeriod.FullYear); + + // (2 + 3) / 2 = 2,5 + Assert.Contains("zwischen 2 und 3", vm.BorderlineHint); + + vm.ToggleLockCommand.Execute(null); + Assert.Empty(vm.BorderlineHint); + } + + [Fact] + public void Klasse10_NachpruefungOhneAbschlusspruefung_ZweiZuEins() + { + var vm = Klasse10Schuljahr(new Fixture(), "5", "5"); + + // § 13 Abs. 3 PrüfV: (2 * 5 + 3) / 3 = 4,33 -> 4 + vm.AdditionalOralExamGrade = "3"; + + Assert.Equal("4", vm.CalculatedDisplay); + } + + [Fact] + public void Klasse10_NurEinHalbjahr_UnterrichtlicheLeistungZaehltTrotzdemDoppelt() + { + var f = new Fixture(); + f.AddExam("K2", new DateOnly(2026, 3, 1), "2"); + var vm = f.Build(InH2, gradeLevel: 10); + vm.SelectedPeriod = vm.PeriodOptions.Single(p => p.Period == ParticipationPeriod.FullYear); + + // (2 * 2 + 5) / 3 = 3 — nicht (2 + 5) / 2 = 3,5 -> 4 + vm.OralExamGrade = "5"; + + Assert.Equal("3", vm.CalculatedDisplay); + } + + [Fact] + public void Klasse10_WiderspruechlichePruefungen_WerdenAbgelehnt() + { + var f = new Fixture(); + var vm = Klasse10Schuljahr(f, "3", "3"); + + vm.WrittenExamGrade = "3"; + vm.OralExamGrade = "2"; + vm.SaveCommand.Execute(null); + Assert.NotEmpty(vm.OralExamGradeError); + + vm.WrittenExamGrade = ""; + vm.AdditionalOralExamGrade = "4"; + vm.SaveCommand.Execute(null); + Assert.NotEmpty(vm.AdditionalOralExamGradeError); + Assert.Equal(0, f.ReportGrades.SavedCount); + } + + [Fact] + public void Klasse10_UngueltigePruefungsnote_WirdNichtGespeichert() + { + var f = new Fixture(); + f.AddExam("K1", new DateOnly(2025, 10, 1), "2"); + var vm = f.Build(InH2, gradeLevel: 10); + vm.SelectedPeriod = vm.PeriodOptions.Single(p => p.Period == ParticipationPeriod.FullYear); + + vm.ProjectExamGrade = "gut"; + vm.SaveCommand.Execute(null); + + Assert.NotEmpty(vm.ProjectExamGradeError); + Assert.Equal(0, f.ReportGrades.SavedCount); + } + + [Fact] + public void PruefungsnotenNurInKlasse10UndNurFuerDasSchuljahr() + { + var klasse8 = new Fixture().Build(InH2); + klasse8.SelectedPeriod = klasse8.PeriodOptions.Single(p => p.Period == ParticipationPeriod.FullYear); + Assert.False(klasse8.CanEnterExamGrades); + + Assert.False(new Fixture().Build(InH2, gradeLevel: 10).CanEnterExamGrades); + } + + [Fact] + public void Speichern_UebersteuernOhneBegruendung_WirdAbgelehnt() + { + var f = new Fixture(); + f.AddExam("K1", new DateOnly(2025, 10, 1), "3"); + var vm = f.Build(InH1); + + vm.OverrideValue = "2"; + vm.SaveCommand.Execute(null); + + Assert.NotEmpty(vm.OverrideReasonError); + Assert.Equal(0, f.ReportGrades.SavedCount); + + vm.OverrideReason = "mündlich deutlich stärker"; + vm.SaveCommand.Execute(null); + + var record = f.ReportGrades.GetByStudentGroupPeriod(Anna.Id, GroupId, "1. Halbjahr"); + Assert.NotNull(record); + Assert.Equal("3", record.CalculatedValue); + Assert.Equal("2", record.OverrideValue); + Assert.Equal("2", vm.Students[0].GradeDisplay); + Assert.Equal(AssistantGradeStatus.Saved, vm.Students[0].Status); + } + + [Fact] + public void SpeichernUndWeiter_GehtZumNaechstenSchueler() + { + var f = new Fixture(); + f.AddExam("K1", new DateOnly(2025, 10, 1), "3"); + var vm = f.Build(InH1); + + vm.SaveAndNextCommand.Execute(null); + + Assert.Equal(Ben.Id, vm.SelectedStudent!.StudentId); + Assert.Equal("2 / 2", vm.ProgressText); + Assert.False(vm.NextStudentCommand.CanExecute(null)); + } + + [Fact] + public void Festschreiben_FriertBerechnetenStandEin() + { + var f = new Fixture(); + f.AddExam("K1", new DateOnly(2025, 10, 1), "3"); + var vm = f.Build(InH1); + + vm.ToggleLockCommand.Execute(null); + + Assert.True(vm.IsLocked); + Assert.Equal(AssistantGradeStatus.Locked, vm.Students[0].Status); + + // Eine später eingetragene Arbeit ändert den festgeschriebenen Stand nicht mehr. + f.AddExam("K2", new DateOnly(2025, 12, 1), "1"); + vm.SelectedPeriod = vm.PeriodOptions.Single(p => p.Period == ParticipationPeriod.H2); + vm.SelectedPeriod = vm.PeriodOptions.Single(p => p.Period == ParticipationPeriod.H1); + Assert.Equal("3", vm.CalculatedDisplay); + + vm.ToggleLockCommand.Execute(null); + Assert.False(vm.IsLocked); + Assert.Equal("2", vm.CalculatedDisplay); + } + + [Fact] + public void Mitarbeitsgraph_AbschnitteBlaetternUndVerlaufAmStueck() + { + var f = new Fixture(); + f.Sections.Add(new ParticipationSection { GroupId = GroupId, Label = "Abschnitt 1", + StartDate = new DateOnly(2025, 8, 20), EndDate = new DateOnly(2025, 9, 30) }); + f.Sections.Add(new ParticipationSection { GroupId = GroupId, Label = "Abschnitt 2", + StartDate = new DateOnly(2025, 10, 1), EndDate = new DateOnly(2025, 10, 31) }); + f.AddParticipation(new DateOnly(2025, 9, 30), "2", "Abschnitt: Abschnitt 1"); + foreach (var date in new[] { new DateOnly(2025, 9, 2), new DateOnly(2025, 10, 7), new DateOnly(2025, 11, 4) }) + { + var session = new ParticipationSession { Id = Guid.NewGuid(), GroupId = GroupId, Date = date }; + f.Sessions.Add(session); + f.Entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = Anna.Id, + Ratings = [new AspectRating { Key = "quality", Value = 1 }] }); + } + + var vm = f.Build(InH1); + + Assert.True(vm.IsSectionMode); + Assert.Equal("1 / 3", vm.SectionPosition); + Assert.Contains("Abschnittsnote 2", vm.SectionTitle); + Assert.Single(Assert.Single(vm.ChartGroups).Points); + Assert.False(vm.PreviousSectionCommand.CanExecute(null)); + + vm.NextSectionCommand.Execute(null); + Assert.Contains("keine Abschnittsnote", vm.SectionTitle); + vm.NextSectionCommand.Execute(null); + Assert.Contains("vorläufig", vm.SectionTitle); + Assert.True(vm.ChartGroups[0].IsOpen); + Assert.False(vm.NextSectionCommand.CanExecute(null)); + + vm.IsSectionMode = false; + + Assert.Equal(3, vm.ChartGroups.Count); + Assert.Equal(3, vm.ChartGroups.Sum(g => g.Points.Count)); + } +} diff --git a/LehrerApp.Desktop.Tests/ReportGradeCalculationTests.cs b/LehrerApp.Desktop.Tests/ReportGradeCalculationTests.cs index 98010ab..2d4b204 100644 --- a/LehrerApp.Desktop.Tests/ReportGradeCalculationTests.cs +++ b/LehrerApp.Desktop.Tests/ReportGradeCalculationTests.cs @@ -186,6 +186,33 @@ public class ReportGradeCalculationTests Assert.Equal("1", annaNachSperre.FinalDisplay); } + [Fact] + public void Schuljahr_WirdAusHalbjahresnotenUndPruefungsnotenVerrechnet() + { + // H1: eine 1 und eine 2 -> 1,5 -> 2. H2: eine 4 -> 4. + var h1a = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Date = new DateOnly(2025, 9, 10), Title = "K1" }; + var h1b = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Date = new DateOnly(2025, 11, 10), Title = "K2" }; + var h2 = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Date = new DateOnly(2026, 3, 10), Title = "K3" }; + var results = new FakeResults(); + results.Add(new ExamResult { ExamId = h1a.Id, StudentId = Anna.Id, Grade = "1" }); + results.Add(new ExamResult { ExamId = h1b.Id, StudentId = Anna.Id, Grade = "2" }); + results.Add(new ExamResult { ExamId = h2.Id, StudentId = Anna.Id, Grade = "4" }); + var reportGrades = new FakeReportGrades(); + + var vm = BuildViewModel(new FakeGrades(), new FakeSchemes(), reportGrades, new FakeExams([h1a, h1b, h2]), results); + + // Aus den Halbjahresnoten: (2 + 4) / 2 = 3 — nicht aus allen Arbeiten ((1+2+4)/3 = 2,33 -> 2). + Assert.Equal("3", vm.Rows.Single(r => r.StudentId == Anna.Id).CalculatedValue); + + // Klasse 10: eine im Fach abgelegte Projektprüfung zählt wie ein drittes Halbjahr. + reportGrades.Save(new ReportGrade { StudentId = Anna.Id, GroupId = GroupId, Period = "Gesamtes Schuljahr", + ProjectExamGrade = "6" }); + vm.Refresh(); + + // (2 + 4 + 6) / 3 = 4 + Assert.Equal("4", vm.Rows.Single(r => r.StudentId == Anna.Id).CalculatedValue); + } + [Fact] public void ExportCsv_EnthaeltAlleSchuelerDerGruppe() { diff --git a/LehrerApp.Desktop/ViewModels/Groups/ParticipationTimelineBuilder.cs b/LehrerApp.Desktop/ViewModels/Groups/ParticipationTimelineBuilder.cs new file mode 100644 index 0000000..d626c72 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Groups/ParticipationTimelineBuilder.cs @@ -0,0 +1,209 @@ +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; + +namespace LehrerApp.Desktop.ViewModels.Groups; + +/// +/// Baut die Zeitleisten-Punkte eines Schülers (Mitarbeit-Sitzungen, Arbeiten, sonstige Noten) für +/// ParticipationTimelineChart. Gemeinsame Grundlage von Mitarbeits-Assistent und +/// Zeugnisnoten-Assistent, damit beide denselben Graphen mit denselben Regeln zeigen. +/// +public sealed class ParticipationTimelineBuilder +{ + /// Kennzeichnet in eine Abschnittsnote der Mitarbeit. + public const string SectionNotePrefix = "Abschnitt: "; + + private readonly IParticipationRepository _entries; + private readonly IExamRepository _exams; + private readonly IExamResultRepository _results; + private readonly IGradeRepository _grades; + private readonly IReadOnlyDictionary _membershipsByStudent; + private readonly GradingService _grading; + private readonly Guid _groupId; + private readonly GradingSystem _gradingSystem; + private readonly List _sessions; + private readonly Dictionary _aspectWeights; + // Für die Normierung von Punkte-Aspekten (3.1.3) auf die gemeinsame -2..+2-Achse vor der + // Gewichtung, analog zu ParticipationGradeDialogViewModel. + private readonly Dictionary _aspectsByKey; + + public IReadOnlyList Sessions => _sessions; + + public ParticipationTimelineBuilder( + IParticipationSessionRepository sessions, IParticipationRepository entries, + IParticipationAspectRepository aspects, IExamRepository exams, IExamResultRepository results, + IGradeRepository grades, IReadOnlyDictionary membershipsByStudent, + GradingService grading, Guid groupId, GradingSystem gradingSystem) + { + _entries = entries; _exams = exams; _results = results; _grades = grades; + _membershipsByStudent = membershipsByStudent; _grading = grading; + _groupId = groupId; _gradingSystem = gradingSystem; + + var applicableAspects = aspects.GetDefaults().Concat(aspects.GetByGroup(groupId)) + .GroupBy(a => a.Key).Select(g => g.Last()).ToList(); + _aspectWeights = applicableAspects.ToDictionary(a => a.Key, a => a.Weight); + _aspectsByKey = applicableAspects.ToDictionary(a => a.Key); + _sessions = sessions.GetByGroup(groupId).OrderBy(s => s.Date).ToList(); + } + + public static string BandLabel(ParticipationSection section) => + $"{section.Label}\n{section.StartDate:dd.MM.}–{section.EndDate:dd.MM.}"; + + public static string OpenBandLabel(DateOnly openStart) => $"läuft seit {openStart:dd.MM.}"; + + /// Beginn des laufenden, noch nicht abgeschlossenen Abschnitts. + public DateOnly OpenStart(IReadOnlyCollection sections, DateOnly today) => + sections.Count > 0 ? sections.Max(s => s.EndDate).AddDays(1) + : (_sessions.Count > 0 ? _sessions.Min(s => s.Date) : today); + + public bool IsStudentActiveOn(Guid studentId, DateOnly date) => + !_membershipsByStudent.TryGetValue(studentId, out var membership) + || GroupMembershipService.IsActiveOn(membership, date); + + /// Alle Punkte des Schülers, chronologisch sortiert. + public List BuildPoints(Guid studentId) + { + var points = new List(); + + foreach (var session in _sessions) + { + if (!IsStudentActiveOn(studentId, session.Date)) continue; + var entry = _entries.GetBySessionAndStudent(session.Id, studentId); + points.Add(BuildSessionPoint(session, entry, studentId)); + } + foreach (var exam in _exams.GetByGroup(_groupId).OrderBy(e => e.Date)) + { + if (!IsStudentActiveOn(studentId, exam.Date)) continue; + var result = _results.GetByExamAndStudent(exam.Id, studentId); + if (result is null) continue; + points.Add(BuildExamPoint(exam, result)); + } + foreach (var grade in _grades.GetByStudentAndGroup(studentId, _groupId) + .Where(g => g.Category != GradeCategory.Participation) + .Where(g => IsStudentActiveOn(studentId, g.Date))) + { + points.Add(BuildOtherGradePoint(grade)); + } + return points.OrderBy(p => p.Date).ToList(); + } + + /// Notenvorschlag aus den bewerteten Sitzungen im Zeitraum; null ohne bewertete Sitzung. + public string? ComputeSuggestion(Guid studentId, DateOnly start, DateOnly end) + { + var points = new List(); + foreach (var session in _sessions.Where(s => s.Date >= start && s.Date <= end) + .Where(s => IsStudentActiveOn(studentId, s.Date))) + { + var entry = _entries.GetBySessionAndStudent(session.Id, studentId); + if (entry is null) continue; + var weighted = WeightedRating(entry); + if (weighted is not null) points.Add(weighted.Value); + } + return points.Count == 0 ? null : _grading.ParticipationGrade(points.Average(), _gradingSystem); + } + + private WizardTimelinePoint BuildSessionPoint(ParticipationSession session, ParticipationEntry? entry, Guid studentId) + { + var weightedValue = entry is not null ? WeightedRating(entry) : null; + var ratingLabel = weightedValue is not null ? RatingLabel((int)Math.Round(weightedValue.Value, MidpointRounding.AwayFromZero)) : ""; + var note = entry?.Note; + var tooltip = $"{session.Date:dd.MM.yyyy}" + + (ratingLabel.Length > 0 ? $" · {ratingLabel}" : "") + + (string.IsNullOrWhiteSpace(note) ? "" : $" · {note}"); + + var point = new WizardTimelinePoint(session.Date, isExam: false, ratingLabel, examLabel: "", + hasNote: !string.IsNullOrWhiteSpace(note), tooltip: tooltip) + { + Homework = entry is null ? null : HomeworkDisplay.Effective(entry), + AttendanceIcon = AttendanceDisplay.ShortLabel(entry?.Attendance), + AttendanceTooltip = AttendanceDisplay.Label(entry?.Attendance), + AttendanceColor = AttendanceDisplay.Color(entry?.Attendance), + AspectValues = entry?.Ratings.ToDictionary(r => r.Key, r => r.Value) + ?? new Dictionary(), + WeightedValue = weightedValue, + OverallGrade = weightedValue is null ? "" : _grading.ParticipationGrade(weightedValue.Value, _gradingSystem), + }; + point.ToggleHomeworkCommand = new RelayCommand(() => ToggleHomeworkAt(session.Id, studentId, point)); + point.CycleAttendanceCommand = new RelayCommand(() => CycleAttendanceAt(session.Id, studentId, point)); + return point; + } + + private static WizardTimelinePoint BuildExamPoint(Exam exam, ExamResult result) + { + var examLabel = result.Absent ? $"{exam.Title}: abw." : $"{exam.Title}: {result.Grade}"; + return new WizardTimelinePoint(exam.Date, isExam: true, ratingLabel: "", examLabel: examLabel, + hasNote: false, tooltip: $"{exam.Date:dd.MM.yyyy} · Klausur {examLabel}"); + } + + private static WizardTimelinePoint BuildOtherGradePoint(Grade grade) + { + var category = grade.Category switch + { + GradeCategory.Oral => "Mündlich", + GradeCategory.Homework => "Hausaufgabe", + GradeCategory.Project => "Projekt", + _ => "Sonstige Leistung", + }; + var detail = string.IsNullOrWhiteSpace(grade.Note) ? category : grade.Note.Trim(); + var label = $"{detail}: {grade.Value}"; + return new WizardTimelinePoint(grade.Date, isExam: true, ratingLabel: "", examLabel: label, + hasNote: false, tooltip: $"{grade.Date:dd.MM.yyyy} · {category} · {label}"); + } + + private double? WeightedRating(ParticipationEntry entry) + { + if (entry.Ratings.Count == 0) return null; + var weightSum = 0.0; var valueSum = 0.0; + foreach (var r in entry.Ratings) + { + var w = _aspectWeights.TryGetValue(r.Key, out var aw) ? aw : 1.0; + if (w <= 0) continue; + valueSum += NormalizedValue(r) * w; weightSum += w; + } + return weightSum <= 0 ? null : valueSum / weightSum; + } + + private double NormalizedValue(AspectRating r) => _aspectsByKey.TryGetValue(r.Key, out var aspect) + ? ParticipationRatingScale.Normalize(aspect.ValueType, r.Value, aspect.MaxPoints) + : r.Value; + + private static string RatingLabel(int v) => v switch + { + >= 2 => "++", 1 => "+", 0 => "~", -1 => "−", _ => "−−", + }; + + private void ToggleHomeworkAt(Guid sessionId, Guid studentId, WizardTimelinePoint point) + { + var entry = _entries.GetBySessionAndStudent(sessionId, studentId) + ?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId }; + entry.Homework = HomeworkDisplay.Next(HomeworkDisplay.Effective(entry)); + entry.HomeworkMissing = HomeworkDisplay.CountsAsMissing(entry.Homework); + _entries.Save(entry); + point.Homework = entry.Homework; + } + + private void CycleAttendanceAt(Guid sessionId, Guid studentId, WizardTimelinePoint point) + { + var entry = _entries.GetBySessionAndStudent(sessionId, studentId) + ?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId }; + entry.Attendance = entry.Attendance switch + { + null => AttendanceStatus.Present, + AttendanceStatus.Present => AttendanceStatus.ExcusePending, + AttendanceStatus.ExcusePending => AttendanceStatus.Excused, + AttendanceStatus.Excused => AttendanceStatus.Unexcused, + AttendanceStatus.Unexcused => AttendanceStatus.Truant, + AttendanceStatus.Truant => AttendanceStatus.OtherSchoolEvent, + AttendanceStatus.OtherSchoolEvent => null, + _ => null, + }; + _entries.Save(entry); + // Das Setzen jeder ObservableProperty kann unmittelbar ein Neuzeichnen auslösen. + // Deshalb muss die zum aktiven Symbol gehörende Farbe zuerst bereitstehen. + point.AttendanceColor = AttendanceDisplay.Color(entry.Attendance); + point.AttendanceTooltip = AttendanceDisplay.Label(entry.Attendance); + point.AttendanceIcon = AttendanceDisplay.ShortLabel(entry.Attendance); + } +} diff --git a/LehrerApp.Desktop/ViewModels/Groups/ParticipationWizardViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ParticipationWizardViewModels.cs index cc60b59..d3bf711 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ParticipationWizardViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ParticipationWizardViewModels.cs @@ -12,13 +12,9 @@ namespace LehrerApp.Desktop.ViewModels.Groups; public partial class ParticipationWizardDialogViewModel : ObservableObject { - private const string AbschnittPrefix = "Abschnitt: "; + private const string AbschnittPrefix = ParticipationTimelineBuilder.SectionNotePrefix; - private readonly IParticipationSessionRepository _sessions; - private readonly IParticipationRepository _entries; private readonly IParticipationSectionRepository _sectionRepo; - private readonly IExamRepository _exams; - private readonly IExamResultRepository _results; private readonly IGradeRepository _grades; private readonly IUnitRepository _units; private readonly Dictionary _membershipsByStudent; @@ -28,12 +24,8 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject private readonly GradingSystem _gradingSystem; private readonly List _students; - private readonly List _allSessions; private readonly List _sectionList; - private readonly Dictionary _aspectWeights; - // Für die Normierung von Punkte-Aspekten (3.1.3) auf die gemeinsame -2..+2-Achse vor der - // Gewichtung, analog zu ParticipationGradeDialogViewModel. - private readonly Dictionary _aspectsByKey; + private readonly ParticipationTimelineBuilder _timeline; public string GroupLabel { get; } @@ -79,22 +71,19 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject IGradeRepository grades, IUnitRepository units, GradingService grading, Guid groupId, string schoolYear, GradingSystem gradingSystem, string groupLabel) { - _sessions = sessions; _entries = entries; _sectionRepo = sectionRepo; - _exams = exams; _results = results; _grades = grades; _units = units; _grading = grading; + _sectionRepo = sectionRepo; + _grades = grades; _units = units; _grading = grading; _groupId = groupId; _schoolYear = schoolYear; _gradingSystem = gradingSystem; GroupLabel = groupLabel; _membershipsByStudent = memberships.GetByGroup(groupId).ToDictionary(m => m.StudentId); - var applicableAspects = aspects.GetDefaults().Concat(aspects.GetByGroup(groupId)) - .GroupBy(a => a.Key).Select(g => g.Last()).ToList(); - _aspectWeights = applicableAspects.ToDictionary(a => a.Key, a => a.Weight); - _aspectsByKey = applicableAspects.ToDictionary(a => a.Key); + _timeline = new ParticipationTimelineBuilder(sessions, entries, aspects, exams, results, grades, + _membershipsByStudent, grading, groupId, gradingSystem); var schoolYearRange = GroupMembershipService.SchoolYearPeriod(schoolYear, SchoolYearPeriodKind.FullYear); _students = students.GetByGroup(groupId) .Where(s => !_membershipsByStudent.TryGetValue(s.Id, out var membership) || GroupMembershipService.Overlaps(membership, schoolYearRange.From, schoolYearRange.To)) .OrderBy(s => s.LastName).ThenBy(s => s.FirstName).ToList(); - _allSessions = sessions.GetByGroup(groupId).OrderBy(s => s.Date).ToList(); _sectionList = sectionRepo.GetByGroup(groupId).OrderBy(s => s.StartDate).ToList(); _rollupPeriod = RollupPeriodOptions[0]; @@ -131,153 +120,28 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject private void BuildTimeline(Guid studentId) { - var points = new List<(DateOnly Date, WizardTimelinePoint Point)>(); - - foreach (var session in _allSessions) - { - if (!IsStudentActiveOn(studentId, session.Date)) continue; - var entry = _entries.GetBySessionAndStudent(session.Id, studentId); - points.Add((session.Date, BuildSessionPoint(session, entry, studentId))); - } - foreach (var exam in _exams.GetByGroup(_groupId).OrderBy(e => e.Date)) - { - if (!IsStudentActiveOn(studentId, exam.Date)) continue; - var result = _results.GetByExamAndStudent(exam.Id, studentId); - if (result is null) continue; - points.Add((exam.Date, BuildExamPoint(exam, result))); - } - foreach (var grade in _grades.GetByStudentAndGroup(studentId, _groupId) - .Where(g => g.Category != GradeCategory.Participation) - .Where(g => IsStudentActiveOn(studentId, g.Date))) - { - points.Add((grade.Date, BuildOtherGradePoint(grade))); - } - points = points.OrderBy(p => p.Date).ToList(); + var points = _timeline.BuildPoints(studentId); Timeline.Clear(); foreach (var section in _sectionList) { - var group = new WizardSectionGroup($"{section.Label}\n{section.StartDate:dd.MM.}–{section.EndDate:dd.MM.}", isOpen: false); - foreach (var (date, point) in points.Where(p => p.Date >= section.StartDate && p.Date <= section.EndDate)) + var group = new WizardSectionGroup(ParticipationTimelineBuilder.BandLabel(section), isOpen: false); + foreach (var point in points.Where(p => p.Date >= section.StartDate && p.Date <= section.EndDate)) group.Points.Add(point); Timeline.Add(group); } var openStart = ComputeOpenStart(); - var openGroup = new WizardSectionGroup($"läuft seit {openStart:dd.MM.}", isOpen: true); - foreach (var (date, point) in points.Where(p => p.Date >= openStart)) + var openGroup = new WizardSectionGroup(ParticipationTimelineBuilder.OpenBandLabel(openStart), isOpen: true); + foreach (var point in points.Where(p => p.Date >= openStart)) openGroup.Points.Add(point); Timeline.Add(openGroup); } - private WizardTimelinePoint BuildSessionPoint(ParticipationSession session, ParticipationEntry? entry, Guid studentId) - { - var weightedValue = entry is not null ? WeightedRating(entry) : null; - var ratingLabel = weightedValue is not null ? RatingLabel((int)Math.Round(weightedValue.Value, MidpointRounding.AwayFromZero)) : ""; - var note = entry?.Note; - var tooltip = $"{session.Date:dd.MM.yyyy}" + - (ratingLabel.Length > 0 ? $" · {ratingLabel}" : "") + - (string.IsNullOrWhiteSpace(note) ? "" : $" · {note}"); - - var point = new WizardTimelinePoint(session.Date, isExam: false, ratingLabel, examLabel: "", - hasNote: !string.IsNullOrWhiteSpace(note), tooltip: tooltip) - { - Homework = entry is null ? null : HomeworkDisplay.Effective(entry), - AttendanceIcon = AttendanceDisplay.ShortLabel(entry?.Attendance), - AttendanceTooltip = AttendanceDisplay.Label(entry?.Attendance), - AttendanceColor = AttendanceDisplay.Color(entry?.Attendance), - AspectValues = entry?.Ratings.ToDictionary(r => r.Key, r => r.Value) - ?? new Dictionary(), - WeightedValue = weightedValue, - OverallGrade = weightedValue is null ? "" : _grading.ParticipationGrade(weightedValue.Value, _gradingSystem), - }; - point.ToggleHomeworkCommand = new RelayCommand(() => ToggleHomeworkAt(session.Id, studentId, point)); - point.CycleAttendanceCommand = new RelayCommand(() => CycleAttendanceAt(session.Id, studentId, point)); - return point; - } - - private WizardTimelinePoint BuildExamPoint(Exam exam, ExamResult result) - { - var examLabel = result.Absent ? $"{exam.Title}: abw." : $"{exam.Title}: {result.Grade}"; - return new WizardTimelinePoint(exam.Date, isExam: true, ratingLabel: "", examLabel: examLabel, - hasNote: false, tooltip: $"{exam.Date:dd.MM.yyyy} · Klausur {examLabel}"); - } - - private static WizardTimelinePoint BuildOtherGradePoint(Grade grade) - { - var category = grade.Category switch - { - GradeCategory.Oral => "Mündlich", - GradeCategory.Homework => "Hausaufgabe", - GradeCategory.Project => "Projekt", - _ => "Sonstige Leistung", - }; - var detail = string.IsNullOrWhiteSpace(grade.Note) ? category : grade.Note.Trim(); - var label = $"{detail}: {grade.Value}"; - return new WizardTimelinePoint(grade.Date, isExam: true, ratingLabel: "", examLabel: label, - hasNote: false, tooltip: $"{grade.Date:dd.MM.yyyy} · {category} · {label}"); - } - - private double? WeightedRating(ParticipationEntry entry) - { - if (entry.Ratings.Count == 0) return null; - var weightSum = 0.0; var valueSum = 0.0; - foreach (var r in entry.Ratings) - { - var w = _aspectWeights.TryGetValue(r.Key, out var aw) ? aw : 1.0; - if (w <= 0) continue; - valueSum += NormalizedValue(r) * w; weightSum += w; - } - return weightSum <= 0 ? null : valueSum / weightSum; - } - - private double NormalizedValue(AspectRating r) => _aspectsByKey.TryGetValue(r.Key, out var aspect) - ? ParticipationRatingScale.Normalize(aspect.ValueType, r.Value, aspect.MaxPoints) - : r.Value; - - private static string RatingLabel(int v) => v switch - { - >= 2 => "++", 1 => "+", 0 => "~", -1 => "−", _ => "−−", - }; - - private void ToggleHomeworkAt(Guid sessionId, Guid studentId, WizardTimelinePoint point) - { - var entry = _entries.GetBySessionAndStudent(sessionId, studentId) - ?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId }; - entry.Homework = HomeworkDisplay.Next(HomeworkDisplay.Effective(entry)); - entry.HomeworkMissing = HomeworkDisplay.CountsAsMissing(entry.Homework); - _entries.Save(entry); - point.Homework = entry.Homework; - } - - private void CycleAttendanceAt(Guid sessionId, Guid studentId, WizardTimelinePoint point) - { - var entry = _entries.GetBySessionAndStudent(sessionId, studentId) - ?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId }; - entry.Attendance = entry.Attendance switch - { - null => AttendanceStatus.Present, - AttendanceStatus.Present => AttendanceStatus.ExcusePending, - AttendanceStatus.ExcusePending => AttendanceStatus.Excused, - AttendanceStatus.Excused => AttendanceStatus.Unexcused, - AttendanceStatus.Unexcused => AttendanceStatus.Truant, - AttendanceStatus.Truant => AttendanceStatus.OtherSchoolEvent, - AttendanceStatus.OtherSchoolEvent => null, - _ => null, - }; - _entries.Save(entry); - // Das Setzen jeder ObservableProperty kann unmittelbar ein Neuzeichnen auslösen. - // Deshalb muss die zum aktiven Symbol gehörende Farbe zuerst bereitstehen. - point.AttendanceColor = AttendanceDisplay.Color(entry.Attendance); - point.AttendanceTooltip = AttendanceDisplay.Label(entry.Attendance); - point.AttendanceIcon = AttendanceDisplay.ShortLabel(entry.Attendance); - } - // ── Abschnitte ──────────────────────────────────────────────────────────── private DateOnly ComputeOpenStart() => - _sectionList.Count > 0 ? _sectionList.Max(s => s.EndDate).AddDays(1) - : (_allSessions.Count > 0 ? _allSessions.Min(s => s.Date) : DateOnly.FromDateTime(DateTime.Today)); + _timeline.OpenStart(_sectionList, DateOnly.FromDateTime(DateTime.Today)); /// Schlägt als Abschnittsende bewusst nicht immer "heute" vor: Wenn seit Abschnittsbeginn /// eine Unterrichtseinheit (4.2) abgeschlossen wurde, ist deren Ende meist die fachlich @@ -314,31 +178,11 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject } var openStart = ComputeOpenStart(); - var suggestion = ComputeSuggestion(studentId, openStart, DateOnly.FromDateTime(DateTime.Today)); + var suggestion = _timeline.ComputeSuggestion(studentId, openStart, DateOnly.FromDateTime(DateTime.Today)); Sections.Add(new WizardSectionRow("(läuft)", openStart, DateOnly.FromDateTime(DateTime.Today), suggestion ?? "", isOpen: true)); } - private string? ComputeSuggestion(Guid studentId, DateOnly start, DateOnly end) - { - var points = new List(); - foreach (var session in _allSessions.Where(s => s.Date >= start && s.Date <= end) - .Where(s => IsStudentActiveOn(studentId, s.Date))) - { - var entry = _entries.GetBySessionAndStudent(session.Id, studentId); - if (entry is null || entry.Ratings.Count == 0) continue; - var weightSum = 0.0; var valueSum = 0.0; - foreach (var r in entry.Ratings) - { - var w = _aspectWeights.TryGetValue(r.Key, out var aw) ? aw : 1.0; - if (w <= 0) continue; - valueSum += NormalizedValue(r) * w; weightSum += w; - } - if (weightSum > 0) points.Add(valueSum / weightSum); - } - return points.Count == 0 ? null : _grading.ParticipationGrade(points.Average(), _gradingSystem); - } - private void SaveSectionGrade(WizardSectionRow row) { if (string.IsNullOrWhiteSpace(row.Value)) { row.StatusMessage = "Wert darf nicht leer sein."; return; } @@ -382,7 +226,7 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject foreach (var student in _students) { - var suggestion = ComputeSuggestion(student.Id, start, end); + var suggestion = _timeline.ComputeSuggestion(student.Id, start, end); if (suggestion is null) continue; _grades.Save(new Grade { diff --git a/LehrerApp.Desktop/ViewModels/Groups/ReportGradeAssistantViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ReportGradeAssistantViewModels.cs new file mode 100644 index 0000000..1d2fe88 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Groups/ReportGradeAssistantViewModels.cs @@ -0,0 +1,664 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using System.Collections.ObjectModel; +using System.Globalization; + +namespace LehrerApp.Desktop.ViewModels.Groups; + +// ── Zeugnisnoten-Assistent ─────────────────────────────────────────────────── +// +// Geht die Lerngruppe Schüler für Schüler durch und zieht alles zusammen, was für die +// Halbjahres- bzw. Endjahresnote zählt: berechnete Note mit Teilbereichen, Mitarbeitsgraph +// (abschnittsweise blätterbar oder als durchgehender Verlauf), Arbeiten und Einzelnoten. +// Ergänzt den Zeugnisnoten-Dialog (2.4), ersetzt ihn nicht — beide schreiben dieselben +// ReportGrade-Datensätze (gleiche Period-Bezeichnungen), der Dialog bleibt die schnelle Liste. +// +// Halbjahre sind eigenständig: Leistungen aus dem 1. Halbjahr zählen nicht in die Note des +// 2. Halbjahrs, werden dort aber ausgegraut als Kontext gezeigt (Trends). Die Endjahresnote +// wird aus den Halbjahresnoten verrechnet, nicht aus allen Einzelleistungen; in Klasse 10 lassen +// sich dafür zusätzlich Projekt- und mündliche Prüfung eintragen (siehe ReportGradeCalculator). +public partial class ReportGradeAssistantViewModel : ObservableObject +{ + private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE"); + + private const int ExamGradeLevel = 10; + + private readonly IReportGradeRepository _reportGrades; + private readonly IParticipationRepository _participation; + private readonly AttendanceBalanceService _attendanceBalance; + private readonly GradingService _grading; + private readonly ParticipationTimelineBuilder _timeline; + private readonly ReportGradeCalculator _calculator; + private readonly Guid _groupId; + private readonly int _gradeLevel; + private readonly DateOnly _today; + + private readonly List _allStudents; + private readonly Dictionary _membershipsByStudent; + private readonly List _sections; + + private string? _calculatedValue; + private bool _showing; + private List _sectionViews = []; + private int _sectionIndex; + private bool _loading; + + public string GroupLabel { get; } + + [ObservableProperty] private ParticipationPeriodOption _selectedPeriod; + [ObservableProperty] private RoundingRule _roundingRule = RoundingRule.Commercial; + [ObservableProperty] private string _schemeSummary = ""; + [ObservableProperty] private AssistantStudentItem? _selectedStudent; + [ObservableProperty] private bool _showPreviousHalf = true; + + // Kopf: Note des gewählten Schülers + [ObservableProperty] private string _calculatedDisplay = "–"; + [ObservableProperty] private string _calculationDetail = ""; + [ObservableProperty] private string _borderlineHint = ""; + [ObservableProperty] private string _absenceRateDisplay = ""; + [ObservableProperty] private string _absenceRateColorHex = "#8A8A8A"; + [ObservableProperty] private string? _overrideValue; + [ObservableProperty] private string? _overrideReason; + [ObservableProperty] private string _overrideReasonError = ""; + [ObservableProperty] private bool _isLocked; + [ObservableProperty] private string _statusMessage = ""; + + // Klasse 10: Prüfungsnoten, die in der Endjahresnote wie ein weiteres Halbjahr zählen + [ObservableProperty] private string? _projectExamGrade; + [ObservableProperty] private string? _oralExamGrade; + [ObservableProperty] private string? _writtenExamGrade; + [ObservableProperty] private string? _additionalOralExamGrade; + [ObservableProperty] private string _projectExamGradeError = ""; + [ObservableProperty] private string _oralExamGradeError = ""; + [ObservableProperty] private string _writtenExamGradeError = ""; + [ObservableProperty] private string _additionalOralExamGradeError = ""; + + // Mitarbeitsgraph + [ObservableProperty] private bool _isSectionMode = true; + [ObservableProperty] private double _timelineZoom = 1.0; + [ObservableProperty] private IReadOnlyList _chartGroups = []; + [ObservableProperty] private string _sectionTitle = ""; + [ObservableProperty] private string _sectionPosition = ""; + + public List PeriodOptions { get; } = + [ + new(ParticipationPeriod.H1, "1. Halbjahr"), + new(ParticipationPeriod.H2, "2. Halbjahr"), + new(ParticipationPeriod.FullYear, "Gesamtes Schuljahr"), + ]; + public string[] RoundingOptions { get; } = RoundingRuleDisplay.Options; + + // Für die ComboBox: deutsche Anzeige statt des rohen Enum-Namens. + public string RoundingRuleName + { + get => RoundingRuleDisplay.Label(RoundingRule); + set => RoundingRule = RoundingRuleDisplay.FromLabel(value); + } + + public ObservableCollection Students { get; } = []; + public ObservableCollection Tiles { get; } = []; + public ObservableCollection Works { get; } = []; + public ObservableCollection ParticipationGrades { get; } = []; + public ObservableCollection OtherGrades { get; } = []; + + public bool HasStudent => SelectedStudent is not null; + public string StudentName => SelectedStudent?.Name ?? ""; + public string ProgressText => SelectedStudent is null + ? "" : $"{Students.IndexOf(SelectedStudent) + 1} / {Students.Count}"; + public string LockLabel => IsLocked ? "Entsperren" : "Festschreiben"; + public string FinalDisplay => !string.IsNullOrWhiteSpace(OverrideValue) ? OverrideValue!.Trim() : CalculatedDisplay; + public bool IsContinuousMode => !IsSectionMode; + /// Nur im 2. Halbjahr gibt es ein Vorhalbjahr, das als Kontext eingeblendet werden kann. + public bool CanShowPreviousHalf => SelectedPeriod.Period == ParticipationPeriod.H2; + /// Projekt- und mündliche Prüfung gibt es nur in Klasse 10 und nur für die Endjahresnote. + public bool CanEnterExamGrades => + SelectedPeriod.Period == ParticipationPeriod.FullYear && _gradeLevel == ExamGradeLevel; + public bool HasWorks => Works.Count > 0; + public bool HasParticipationGrades => ParticipationGrades.Count > 0; + public bool HasOtherGrades => OtherGrades.Count > 0; + + public ReportGradeAssistantViewModel(IGradeRepository grades, IExamRepository exams, + IExamResultRepository results, IStudentRepository students, IGroupMembershipRepository memberships, + IGradingSchemeRepository schemes, IReportGradeRepository reportGrades, + IParticipationSessionRepository participationSessions, IParticipationRepository participation, + IParticipationAspectRepository aspects, IParticipationSectionRepository sections, + AttendanceBalanceService attendanceBalance, GradingService grading, + Guid groupId, GroupType groupType, GradingSystem gradingSystem, string groupLabel, string schoolYear, + int gradeLevel, DateOnly? today = null) + { + _reportGrades = reportGrades; _participation = participation; + _attendanceBalance = attendanceBalance; _grading = grading; + _groupId = groupId; _gradeLevel = gradeLevel; + _today = today ?? DateOnly.FromDateTime(DateTime.Today); + GroupLabel = groupLabel; + + _membershipsByStudent = memberships.GetByGroup(groupId).ToDictionary(m => m.StudentId); + _allStudents = students.GetByGroup(groupId).OrderBy(s => s.LastName).ThenBy(s => s.FirstName).ToList(); + _sections = sections.GetByGroup(groupId).OrderBy(s => s.StartDate).ToList(); + _timeline = new ParticipationTimelineBuilder(participationSessions, participation, aspects, exams, results, + grades, _membershipsByStudent, grading, groupId, gradingSystem); + _calculator = new ReportGradeCalculator(grades, exams, results, schemes, reportGrades, + _membershipsByStudent, grading, groupId, groupType, gradingSystem, schoolYear); + + // Vorbelegung: das Halbjahr, in dem wir uns gerade befinden. + var (h1From, h1To) = _calculator.Range(ParticipationPeriod.H1); + _selectedPeriod = _today >= h1From && _today <= h1To ? PeriodOptions[0] : PeriodOptions[1]; + Reload(); + } + + partial void OnSelectedPeriodChanged(ParticipationPeriodOption value) + { + OnPropertyChanged(nameof(CanShowPreviousHalf)); + OnPropertyChanged(nameof(CanEnterExamGrades)); + Reload(); + } + + partial void OnRoundingRuleChanged(RoundingRule value) => Reload(); + partial void OnShowPreviousHalfChanged(bool value) => ShowStudent(); + + partial void OnSelectedStudentChanged(AssistantStudentItem? value) + { + OnPropertyChanged(nameof(HasStudent)); + OnPropertyChanged(nameof(StudentName)); + OnPropertyChanged(nameof(ProgressText)); + PreviousStudentCommand.NotifyCanExecuteChanged(); + NextStudentCommand.NotifyCanExecuteChanged(); + if (!_loading) ShowStudent(); + } + + partial void OnIsSectionModeChanged(bool value) + { + OnPropertyChanged(nameof(IsContinuousMode)); + ApplyChartMode(); + } + + partial void OnIsLockedChanged(bool value) => OnPropertyChanged(nameof(LockLabel)); + partial void OnOverrideValueChanged(string? value) => OnPropertyChanged(nameof(FinalDisplay)); + partial void OnCalculatedDisplayChanged(string value) => OnPropertyChanged(nameof(FinalDisplay)); + + // Eine eingetippte Prüfungsnote wirkt sofort auf die angezeigte Berechnung, gespeichert wird + // sie erst mit der Zeugnisnote. + partial void OnProjectExamGradeChanged(string? value) { if (!_showing) ShowCalculation(); } + partial void OnOralExamGradeChanged(string? value) { if (!_showing) ShowCalculation(); } + partial void OnWrittenExamGradeChanged(string? value) { if (!_showing) ShowCalculation(); } + partial void OnAdditionalOralExamGradeChanged(string? value) { if (!_showing) ShowCalculation(); } + + private void ClearErrors() + { + OverrideReasonError = ""; ProjectExamGradeError = ""; OralExamGradeError = ""; + WrittenExamGradeError = ""; AdditionalOralExamGradeError = ""; + } + + // ── Laden ──────────────────────────────────────────────────────────────── + + private void Reload() + { + _calculator.Reload(); + SchemeSummary = _calculator.SchemeSummary; + + var (from, to) = _calculator.Range(SelectedPeriod.Period); + var previousId = SelectedStudent?.StudentId; + + _loading = true; + Students.Clear(); + foreach (var student in _allStudents) + { + if (_membershipsByStudent.TryGetValue(student.Id, out var membership) + && !GroupMembershipService.Overlaps(membership, from, to)) continue; + var item = new AssistantStudentItem(student.Id, student.FullName); + RefreshItem(item); + Students.Add(item); + } + SelectedStudent = Students.FirstOrDefault(s => s.StudentId == previousId) ?? Students.FirstOrDefault(); + _loading = false; + + OnPropertyChanged(nameof(ProgressText)); + PreviousStudentCommand.NotifyCanExecuteChanged(); + NextStudentCommand.NotifyCanExecuteChanged(); + ShowStudent(); + } + + private void RefreshItem(AssistantStudentItem item) + { + var record = _reportGrades.GetByStudentGroupPeriod(item.StudentId, _groupId, SelectedPeriod.Label); + var calculated = record is { IsLocked: true } + ? NullIfEmpty(record.CalculatedValue) + : _calculator.Calculate(item.StudentId, SelectedPeriod.Period, RoundingRule).Rounded; + item.Update(NullIfEmpty(record?.OverrideValue) ?? calculated ?? "–", record switch + { + null => AssistantGradeStatus.Open, + { IsLocked: true } => AssistantGradeStatus.Locked, + _ => AssistantGradeStatus.Saved, + }); + } + + // ── Berechnung ─────────────────────────────────────────────────────────── + + // ── Anzeige des gewählten Schülers ─────────────────────────────────────── + + private void ShowStudent() + { + StatusMessage = ""; + ClearErrors(); + Works.Clear(); ParticipationGrades.Clear(); OtherGrades.Clear(); + + if (SelectedStudent is null) + { + _calculatedValue = null; + Tiles.Clear(); + CalculatedDisplay = "–"; CalculationDetail = ""; BorderlineHint = ""; AbsenceRateDisplay = ""; + OverrideValue = null; OverrideReason = null; IsLocked = false; + _sectionViews = []; + ApplyChartMode(); + NotifyLists(); + return; + } + + var studentId = SelectedStudent.StudentId; + var (from, to) = _calculator.Range(SelectedPeriod.Period); + _membershipsByStudent.TryGetValue(studentId, out var membership); + + var record = _reportGrades.GetByStudentGroupPeriod(studentId, _groupId, SelectedPeriod.Label); + _showing = true; + IsLocked = record is { IsLocked: true }; + OverrideValue = record?.OverrideValue; + OverrideReason = record?.OverrideReason; + ProjectExamGrade = CanEnterExamGrades ? record?.ProjectExamGrade : null; + OralExamGrade = CanEnterExamGrades ? record?.OralExamGrade : null; + WrittenExamGrade = CanEnterExamGrades ? record?.WrittenExamGrade : null; + AdditionalOralExamGrade = CanEnterExamGrades ? record?.AdditionalOralExamGrade : null; + _showing = false; + + ShowCalculation(); + ShowAbsenceRate(studentId, from, to); + + // Im 2. Halbjahr auf Wunsch das Vorhalbjahr als Kontext mitzeigen — ohne Einfluss auf die Note. + var displayFrom = CanShowPreviousHalf && ShowPreviousHalf + ? _calculator.Range(ParticipationPeriod.FullYear).From : from; + BuildWorks(studentId, membership, displayFrom, from, to); + BuildGradeRows(studentId, membership, displayFrom, from, to); + BuildSections(studentId, displayFrom, to); + NotifyLists(); + } + + /// Berechnete Note und Teilnoten-Kacheln des gewählten Schülers. + private void ShowCalculation() + { + if (SelectedStudent is null) return; + var studentId = SelectedStudent.StudentId; + var period = SelectedPeriod.Period; + // In Klasse 10 gelten die gerade eingetragenen Prüfungsnoten, sonst die gespeicherten. + YearExamGrades? typed = CanEnterExamGrades + ? new YearExamGrades(ProjectExamGrade, OralExamGrade, WrittenExamGrade, AdditionalOralExamGrade) + : null; + var result = _calculator.Calculate(studentId, period, RoundingRule, typed); + + // Festgeschrieben heißt: der gespeicherte Stand gilt, es wird nicht neu berechnet. + _calculatedValue = IsLocked + ? NullIfEmpty(_reportGrades.GetByStudentGroupPeriod(studentId, _groupId, SelectedPeriod.Label)?.CalculatedValue) + : result.Rounded; + CalculatedDisplay = _calculatedValue ?? "–"; + // Genau auf der Grenze entscheidet die gewählte Rundung nur formal — der Hinweis macht + // sichtbar, dass hier die Lehrkraft entscheiden sollte. + var between = !IsLocked && result.Value is { } value ? ReportGradeCalculator.BetweenGrades(value) : null; + BorderlineHint = between is null ? "" : $"Liegt genau {between} — pädagogische Entscheidung."; + + Tiles.Clear(); + if (period == ParticipationPeriod.FullYear) + { + var parts = _calculator.YearParts(studentId, RoundingRule, typed); + foreach (var part in parts) + Tiles.Add(new AssistantTile(part.Label, part.Final ?? "–", part.Hint)); + CalculationDetail = result.Value is null + ? "Noch keine Halbjahresnote vorhanden." + : $"aus {parts.Count(p => p.Final is not null)} Teilnoten {Format(result.Value.Value)} vor Rundung"; + } + else + { + var scheme = _calculator.Scheme; + Tiles.Add(BucketTile("Schriftlich", result.Written, scheme.ExamsPercent)); + Tiles.Add(BucketTile("Mitarbeit", result.Participation, scheme.ParticipationPercent)); + Tiles.Add(BucketTile("Sonstige", result.Other, scheme.OtherPercent)); + CalculationDetail = result.Value is null + ? "Noch keine Leistungen im gewählten Zeitraum." + : $"gewichtet {Format(result.Value.Value)} vor Rundung"; + } + } + + private void NotifyLists() + { + OnPropertyChanged(nameof(HasWorks)); + OnPropertyChanged(nameof(HasParticipationGrades)); + OnPropertyChanged(nameof(HasOtherGrades)); + } + + private static AssistantTile BucketTile(string label, double? average, double percent) => + new(label, average.HasValue ? Format(average.Value) : "–", $"{percent:0.#} %"); + + private void ShowAbsenceRate(Guid studentId, DateOnly from, DateOnly to) + { + // Wie im Zeugnisnoten-Dialog (2.4.5): nur Sitzungen dieser Gruppe im gewählten Zeitraum. + var attendance = _timeline.Sessions + .Where(s => s.Date >= from && s.Date <= to) + .Select(s => (s.Date, _participation.GetBySessionAndStudent(s.Id, studentId)?.Attendance)) + .ToList(); + var rate = _attendanceBalance.Calculate(attendance, from, to).AbsenceRatePercent; + AbsenceRateDisplay = $"{rate:0.#} % gefehlt"; + AbsenceRateColorHex = rate >= ReportGradeRow.HighAbsenceThresholdPercent ? "#D64545" : "#8A8A8A"; + } + + private void BuildWorks(Guid studentId, GroupMembership? membership, DateOnly displayFrom, DateOnly from, DateOnly to) + { + foreach (var exam in _calculator.Exams.Where(e => e.Date >= displayFrom && e.Date <= to)) + { + 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 + // Ergebnis gehören sie nicht in diese Übersicht. + if (own is null && exam.RepeatOfExamId.HasValue) continue; + + var gradeDisplay = own switch + { + null => "–", + { Absent: true } => "abw.", + _ => own.Grade ?? "–", + }; + + var percentDisplay = ""; + if (own is { Absent: false }) + { + var maximum = ExamScoringService.Maximum(exam.Tasks, own.Variant); + if (maximum > 0) percentDisplay = $"{own.TotalPoints / maximum * 100:0} %"; + } + + var classGrades = results.Values.Where(r => !r.Absent && r.Grade is not null) + .Select(r => (r.Grade!, 1.0)).ToList(); + var classAverage = classGrades.Count > 0 ? $"Ø Kurs {Format(_grading.WeightedAverage(classGrades))}" : ""; + + var kind = exam.Kind.Label(); + if (exam.IsMakeUp) kind += " · Nachschreibarbeit"; + else if (exam.RepeatOfExamId.HasValue) kind += " · Wiederholung"; + + Works.Add(new AssistantWorkCard(exam.Title, exam.Date, kind, gradeDisplay, percentDisplay, + classAverage, isContext: exam.Date < from)); + } + } + + private void BuildGradeRows(Guid studentId, GroupMembership? membership, DateOnly displayFrom, DateOnly from, DateOnly to) + { + foreach (var grade in _calculator.Grades + .Where(g => g.StudentId == studentId && g.Date >= displayFrom && g.Date <= to) + .Where(g => membership is null || GroupMembershipService.IsActiveOn(membership, g.Date))) + { + var isParticipation = grade.Category == GradeCategory.Participation; + var category = GradeCategoryDisplay.Label(grade.Category); + var label = string.IsNullOrWhiteSpace(grade.Note) + ? category + : isParticipation ? grade.Note.Trim() : $"{category}: {grade.Note.Trim()}"; + var row = new AssistantGradeRow(label, grade.Date, grade.Value, isContext: grade.Date < from); + (isParticipation ? ParticipationGrades : OtherGrades).Add(row); + } + } + + // ── Mitarbeitsgraph: Abschnitte / Verlauf ──────────────────────────────── + + private sealed record SectionView(WizardSectionGroup Group, string Title); + + private void BuildSections(Guid studentId, DateOnly displayFrom, DateOnly to) + { + var points = _timeline.BuildPoints(studentId).Where(p => p.Date >= displayFrom && p.Date <= to).ToList(); + var sectionGrades = _calculator.Grades + .Where(g => g.StudentId == studentId && g.Category == GradeCategory.Participation && g.Note is not null) + .ToList(); + + var views = new List(); + foreach (var section in _sections.Where(s => s.EndDate >= displayFrom && s.StartDate <= to)) + { + var group = new WizardSectionGroup(ParticipationTimelineBuilder.BandLabel(section), isOpen: false); + foreach (var point in points.Where(p => p.Date >= section.StartDate && p.Date <= section.EndDate)) + group.Points.Add(point); + var grade = sectionGrades.FirstOrDefault(g => + g.Note == ParticipationTimelineBuilder.SectionNotePrefix + section.Label)?.Value; + views.Add(new SectionView(group, + $"{section.Label} · {section.StartDate:dd.MM.}–{section.EndDate:dd.MM.} · " + + (grade is null ? "keine Abschnittsnote" : $"Abschnittsnote {grade}"))); + } + + // Der laufende Abschnitt endet erst mit dem nächsten Abschluss im Mitarbeits-Assistenten; + // bis dahin gibt es nur einen Vorschlag aus den bisherigen Sitzungen. + var openStart = _timeline.OpenStart(_sections, _today); + if (openStart <= to) + { + var group = new WizardSectionGroup(ParticipationTimelineBuilder.OpenBandLabel(openStart), isOpen: true); + foreach (var point in points.Where(p => p.Date >= openStart)) + group.Points.Add(point); + var end = _today < to ? _today : to; + var suggestion = _timeline.ComputeSuggestion(studentId, openStart < displayFrom ? displayFrom : openStart, end); + views.Add(new SectionView(group, + $"Laufender Abschnitt seit {openStart:dd.MM.} · " + + (suggestion is null ? "noch kein Vorschlag" : $"vorläufig {suggestion}"))); + } + + _sectionViews = views; + // Beim Schülerwechsel im selben Abschnitt bleiben — so lassen sich Schüler abschnittsweise vergleichen. + _sectionIndex = Math.Clamp(_sectionIndex, 0, Math.Max(0, views.Count - 1)); + ApplyChartMode(); + } + + private void ApplyChartMode() + { + if (_sectionViews.Count == 0) + { + ChartGroups = []; + SectionTitle = ""; SectionPosition = ""; + } + else if (IsSectionMode) + { + var view = _sectionViews[_sectionIndex]; + ChartGroups = [view.Group]; + SectionTitle = view.Title; + SectionPosition = $"{_sectionIndex + 1} / {_sectionViews.Count}"; + } + else + { + ChartGroups = _sectionViews.Select(v => v.Group).ToList(); + SectionTitle = ""; SectionPosition = ""; + } + PreviousSectionCommand.NotifyCanExecuteChanged(); + NextSectionCommand.NotifyCanExecuteChanged(); + } + + private bool CanGoPreviousSection => IsSectionMode && _sectionIndex > 0; + private bool CanGoNextSection => IsSectionMode && _sectionIndex < _sectionViews.Count - 1; + + [RelayCommand(CanExecute = nameof(CanGoPreviousSection))] + private void PreviousSection() { _sectionIndex--; ApplyChartMode(); } + + [RelayCommand(CanExecute = nameof(CanGoNextSection))] + private void NextSection() { _sectionIndex++; ApplyChartMode(); } + + // ── Navigation ─────────────────────────────────────────────────────────── + + private int StudentIndex => SelectedStudent is null ? -1 : Students.IndexOf(SelectedStudent); + private bool CanGoPreviousStudent => StudentIndex > 0; + private bool CanGoNextStudent => StudentIndex >= 0 && StudentIndex < Students.Count - 1; + + [RelayCommand(CanExecute = nameof(CanGoPreviousStudent))] + private void PreviousStudent() => SelectedStudent = Students[StudentIndex - 1]; + + [RelayCommand(CanExecute = nameof(CanGoNextStudent))] + private void NextStudent() => SelectedStudent = Students[StudentIndex + 1]; + + // ── Speichern / Festschreiben ──────────────────────────────────────────── + + [RelayCommand] + private void Save() => TrySave(); + + [RelayCommand] + private void SaveAndNext() + { + if (!TrySave()) return; + if (CanGoNextStudent) NextStudent(); + } + + private bool TrySave() + { + if (SelectedStudent is null || IsLocked) return false; + ClearErrors(); + var valid = true; + if (!string.IsNullOrWhiteSpace(OverrideValue) && string.IsNullOrWhiteSpace(OverrideReason)) + { + OverrideReasonError = "Für ein manuelles Übersteuern ist eine Begründung Pflicht."; + valid = false; + } + if (CanEnterExamGrades) + { + if (!IsGradeOrEmpty(ProjectExamGrade)) { ProjectExamGradeError = "Keine gültige Note."; valid = false; } + if (!IsGradeOrEmpty(OralExamGrade)) { OralExamGradeError = "Keine gültige Note."; valid = false; } + if (!IsGradeOrEmpty(WrittenExamGrade)) { WrittenExamGradeError = "Keine gültige Note."; valid = false; } + if (!IsGradeOrEmpty(AdditionalOralExamGrade)) { AdditionalOralExamGradeError = "Keine gültige Note."; valid = false; } + + // Ein Fach ist entweder schriftliches oder mündliches Prüfungsfach; eine zusätzliche + // mündliche Prüfung gibt es nur im schriftlichen Prüfungsfach oder ganz ohne Abschlussprüfung. + var hasOral = NullIfEmpty(OralExamGrade) is not null; + if (hasOral && NullIfEmpty(WrittenExamGrade) is not null) + { + OralExamGradeError = "Entweder schriftliche oder mündliche Abschlussprüfung."; + valid = false; + } + if (hasOral && NullIfEmpty(AdditionalOralExamGrade) is not null) + { + AdditionalOralExamGradeError = "Im Fach der mündlichen Abschlussprüfung nicht vorgesehen."; + valid = false; + } + } + if (!valid) return false; + + var record = _reportGrades.GetByStudentGroupPeriod(SelectedStudent.StudentId, _groupId, SelectedPeriod.Label) + ?? new ReportGrade { StudentId = SelectedStudent.StudentId, GroupId = _groupId, Period = SelectedPeriod.Label }; + record.CalculatedValue = _calculatedValue ?? ""; + record.OverrideValue = NullIfEmpty(OverrideValue); + record.OverrideReason = NullIfEmpty(OverrideReason); + if (CanEnterExamGrades) + { + record.ProjectExamGrade = NullIfEmpty(ProjectExamGrade); + record.OralExamGrade = NullIfEmpty(OralExamGrade); + record.WrittenExamGrade = NullIfEmpty(WrittenExamGrade); + record.AdditionalOralExamGrade = NullIfEmpty(AdditionalOralExamGrade); + } + record.UpdatedAt = DateTime.UtcNow; + _reportGrades.Save(record); + + RefreshItem(SelectedStudent); + StatusMessage = "Gespeichert."; + return true; + } + + [RelayCommand] + private void ToggleLock() + { + if (SelectedStudent is null) return; + var studentId = SelectedStudent.StudentId; + var record = _reportGrades.GetByStudentGroupPeriod(studentId, _groupId, SelectedPeriod.Label); + if (!IsLocked) + { + // Festschreiben sichert immer zuerst den aktuell angezeigten Stand. + if (!TrySave()) return; + record = _reportGrades.GetByStudentGroupPeriod(studentId, _groupId, SelectedPeriod.Label); + } + if (record is null) return; + + record.IsLocked = !record.IsLocked; + record.UpdatedAt = DateTime.UtcNow; + _reportGrades.Save(record); + RefreshItem(SelectedStudent); + ShowStudent(); + StatusMessage = record.IsLocked ? "Festgeschrieben." : "Entsperrt."; + } + + private static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + /// Ganze Note bzw. Punktzahl, optional mit Tendenz ("3+", "2-") — dieselbe Schreibweise, die + /// die Notenberechnung versteht; alles andere würde dort stillschweigend ausgelassen. + private static bool IsGradeOrEmpty(string? value) + { + var text = NullIfEmpty(value); + return text is null || int.TryParse(text.TrimEnd('+', '-').TrimEnd(), out _); + } + + private static string Format(double value) => value.ToString("0.00", German); +} + +// ── Bausteine ──────────────────────────────────────────────────────────────── + +public enum AssistantGradeStatus { Open, Saved, Locked } + +public partial class AssistantStudentItem(Guid studentId, string name) : ObservableObject +{ + public Guid StudentId { get; } = studentId; + public string Name { get; } = name; + + [ObservableProperty] private string _gradeDisplay = "–"; + [ObservableProperty] private AssistantGradeStatus _status; + + public string StatusLabel => Status switch + { + AssistantGradeStatus.Locked => "festgeschrieben", + AssistantGradeStatus.Saved => "gespeichert", + _ => "offen", + }; + + public string StatusColorHex => Status switch + { + AssistantGradeStatus.Locked => "#2E9D57", + AssistantGradeStatus.Saved => "#2E86DE", + _ => "#8A8A8A", + }; + + public void Update(string gradeDisplay, AssistantGradeStatus status) + { + GradeDisplay = gradeDisplay; + Status = status; + } + + partial void OnStatusChanged(AssistantGradeStatus value) + { + OnPropertyChanged(nameof(StatusLabel)); + OnPropertyChanged(nameof(StatusColorHex)); + } +} + +public class AssistantTile(string label, string value, string hint) +{ + public string Label { get; } = label; + public string Value { get; } = value; + public string Hint { get; } = hint; +} + +public class AssistantWorkCard(string title, DateOnly date, string kindLabel, string gradeDisplay, + string percentDisplay, string classAverageDisplay, bool isContext) +{ + public string Title { get; } = title; + public string DateDisplay { get; } = date.ToString("dd.MM.yyyy"); + public string KindLabel { get; } = kindLabel; + public string GradeDisplay { get; } = gradeDisplay; + public string PercentDisplay { get; } = percentDisplay; + public string ClassAverageDisplay { get; } = classAverageDisplay; + /// Vorhalbjahr: nur zum Vergleich gezeigt, zählt nicht in die aktuelle Note. + public bool IsContext { get; } = isContext; + public double DisplayOpacity => IsContext ? 0.45 : 1.0; + public string ContextLabel => IsContext ? "1. Halbjahr · zählt nicht" : ""; +} + +public class AssistantGradeRow(string label, DateOnly date, string value, bool isContext) +{ + public string Label { get; } = label; + public string DateDisplay { get; } = date.ToString("dd.MM.yyyy"); + public string Value { get; } = value; + public bool IsContext { get; } = isContext; + public double DisplayOpacity => IsContext ? 0.45 : 1.0; +} diff --git a/LehrerApp.Desktop/ViewModels/Groups/ReportGradeCalculator.cs b/LehrerApp.Desktop/ViewModels/Groups/ReportGradeCalculator.cs new file mode 100644 index 0000000..d894974 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Groups/ReportGradeCalculator.cs @@ -0,0 +1,233 @@ +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; + +namespace LehrerApp.Desktop.ViewModels.Groups; + +public sealed record ReportGradeResult(double? Value, string? Rounded, double? Written, double? Participation, double? Other); + +/// Eine Teilnote der Endjahresnote: Halbjahresnote oder Prüfungsleistung. +/// Rechenwert, wenn er sich nicht aus ergibt — +/// etwa eine Prüfungsleistung, die zwischen zwei Noten liegt. +public sealed record YearPart(string Label, string? Final, string Hint, double Weight = 1.0, double? Value = null); + +/// Prüfungsnoten der Klasse 10, soweit in diesem Fach abgelegt. +public readonly record struct YearExamGrades(string? Project, string? Oral, + string? Written = null, string? AdditionalOral = null); + +/// +/// Gemeinsame Zeugnisnoten-Berechnung für Zeugnisnoten-Dialog (2.4) und Zeugnisnoten-Assistent +/// (2.7), damit beide für denselben Datensatz denselben Wert zeigen. +/// +/// Halbjahre sind eigenständig und werden aus den Einzelleistungen ihres Zeitraums gewichtet. +/// Die Endjahresnote wird dagegen aus den Halbjahresnoten verrechnet (gleich gewichtet). +/// +/// Klasse 10 (Bremen): In einem Prüfungsfach ergibt sich die Gesamtnote zu zwei Dritteln aus der +/// unterrichtlichen Leistung und zu einem Drittel aus der Prüfungsleistung (§ 14 Abs. 3 der +/// Verordnung über die Sekundarstufe I der Oberschule) — die Prüfung zählt also wie ein drittes +/// Halbjahr. Die Prüfungsleistung ist die Note der schriftlichen bzw. mündlichen +/// Abschlussprüfung. Kommt in einem schriftlichen Prüfungsfach eine zusätzliche mündliche +/// Prüfung hinzu, werden beide 1:1 verrechnet (§ 13 Abs. 2 PrüfV Sek. I). In einem Fach ohne +/// Abschlussprüfung zählt eine zusätzliche mündliche Prüfung selbst als Prüfungsleistung, also +/// 2:1 (§ 13 Abs. 3 PrüfV Sek. I). Die Projektprüfung zählt nach Schulpraxis als weiteres +/// Halbjahr. +/// +/// Bewusst nicht umgesetzt sind die Rundungsvorgaben der Verordnungen: Noten sind +/// Leistungsbewertungen, die Entscheidung zwischen zwei Noten ist pädagogisch. Eine +/// Prüfungsleistung zwischen zwei Noten geht deshalb ungerundet in die Rechnung ein, und für die +/// Endnote gilt die von der Lehrkraft gewählte Rundung samt Möglichkeit zum Übersteuern. +/// +public sealed class ReportGradeCalculator +{ + public const string ProjectExamLabel = "Projektprüfung"; + public const string OralExamLabel = "Mündliche Prüfung"; + public const string WrittenExamLabel = "Schriftliche Prüfung"; + public const string AdditionalOralExamLabel = "Zusätzliche mündliche Prüfung"; + + private readonly IGradeRepository _grades; + private readonly IExamRepository _exams; + private readonly IExamResultRepository _results; + private readonly IGradingSchemeRepository _schemes; + private readonly IReportGradeRepository _reportGrades; + private readonly IReadOnlyDictionary _membershipsByStudent; + private readonly GradingService _grading; + private readonly Guid _groupId; + private readonly GroupType _groupType; + private readonly GradingSystem _gradingSystem; + private readonly string _schoolYear; + + public GradingScheme Scheme { get; private set; } = new(); + public List Exams { get; private set; } = []; + public Dictionary> ResultsByExam { get; private set; } = []; + public List Grades { get; private set; } = []; + + public string SchemeSummary => + $"Klausuren {Scheme.ExamsPercent:0.#} % · Mitarbeit {Scheme.ParticipationPercent:0.#} % · " + + $"Sonstige {Scheme.OtherPercent:0.#} %"; + + public ReportGradeCalculator(IGradeRepository grades, IExamRepository exams, IExamResultRepository results, + IGradingSchemeRepository schemes, IReportGradeRepository reportGrades, + IReadOnlyDictionary membershipsByStudent, GradingService grading, + Guid groupId, GroupType groupType, GradingSystem gradingSystem, string schoolYear) + { + _grades = grades; _exams = exams; _results = results; _schemes = schemes; + _reportGrades = reportGrades; _membershipsByStudent = membershipsByStudent; _grading = grading; + _groupId = groupId; _groupType = groupType; _gradingSystem = gradingSystem; _schoolYear = schoolYear; + Reload(); + } + + /// Liest Schema, Arbeiten und Noten neu ein — vor jeder Neuberechnung einer ganzen Ansicht. + public void Reload() + { + Scheme = _schemes.GetByGroup(_groupId) + ?? _schemes.GetDefaultForType(_groupType) + ?? new GradingScheme { ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 }; + Exams = _exams.GetByGroup(_groupId).OrderBy(e => e.Date).ToList(); + ResultsByExam = Exams.ToDictionary(e => e.Id, + e => _results.GetByExam(e.Id).GroupBy(r => r.StudentId).ToDictionary(g => g.Key, g => g.First())); + Grades = _grades.GetByGroup(_groupId).OrderBy(g => g.Date).ToList(); + } + + public (DateOnly From, DateOnly To) Range(ParticipationPeriod period) => + GroupMembershipService.SchoolYearPeriod(_schoolYear, period switch + { + ParticipationPeriod.H1 => SchoolYearPeriodKind.H1, + ParticipationPeriod.H2 => SchoolYearPeriodKind.H2, + _ => SchoolYearPeriodKind.FullYear, + }); + + public static string PeriodLabel(ParticipationPeriod period) => period switch + { + ParticipationPeriod.H1 => "1. Halbjahr", + ParticipationPeriod.H2 => "2. Halbjahr", + _ => "Gesamtes Schuljahr", + }; + + public static bool ExamApplies(Exam exam, GroupMembership? membership) + { + if (membership is not null && !GroupMembershipService.IsActiveOn(membership, exam.Date)) return false; + return !exam.Niveau.HasValue || membership?.Niveau == exam.Niveau; + } + + /// Noch nicht gespeicherte Prüfungsnoten für die Jahresnote; ohne + /// Angabe gelten die am Jahres-Datensatz gespeicherten. + public ReportGradeResult Calculate(Guid studentId, ParticipationPeriod period, RoundingRule rounding, + YearExamGrades? examGrades = null) + { + if (period == ParticipationPeriod.FullYear) + { + var parts = YearParts(studentId, rounding, examGrades) + .Select(p => (Value: p.Value ?? (p.Final is null ? null : GradingService.ParseGradeValue(p.Final)), p.Weight)) + .Where(p => p.Value.HasValue).ToList(); + if (parts.Count == 0) return new ReportGradeResult(null, null, null, null, null); + var mean = parts.Sum(p => p.Value!.Value * p.Weight) / parts.Sum(p => p.Weight); + return new ReportGradeResult(mean, _grading.RoundToGrade(mean, _gradingSystem, rounding), null, null, null); + } + + var (from, to) = Range(period); + _membershipsByStudent.TryGetValue(studentId, out var membership); + + var examEntries = new List<(Exam Exam, string Grade)>(); + foreach (var exam in Exams.Where(e => e.Date >= from && e.Date <= to)) + { + if (!ExamApplies(exam, membership)) continue; + if (!ResultsByExam[exam.Id].TryGetValue(studentId, out var result)) continue; + if (result.Absent || result.Grade is null) continue; + examEntries.Add((exam, result.Grade)); + } + var (writtenGrades, testGrades) = ExamScoringService.EffectiveGradesByKind(examEntries, _gradingSystem); + var written = writtenGrades.Select(grade => (Grade: grade, Weight: 1.0)).ToList(); + + var studentGrades = Grades + .Where(g => g.StudentId == studentId && g.Date >= from && g.Date <= to) + .Where(g => membership is null || GroupMembershipService.IsActiveOn(membership, g.Date)) + .ToList(); + var participation = studentGrades.Where(g => g.Category == GradeCategory.Participation) + .Select(g => (Grade: g.Value, g.Weight)).ToList(); + var other = studentGrades.Where(g => g.Category != GradeCategory.Participation) + .Select(g => (Grade: g.Value, g.Weight)).ToList(); + // Tests, kontrollierte Hausaufgaben u. Ä. aus der Klausurfunktion zählen als Sonstige. + other.AddRange(testGrades.Select(grade => (Grade: grade, Weight: 1.0))); + + var value = _grading.CalculateReportGradeValue(written, participation, other, Scheme); + return new ReportGradeResult(value, + value is null ? null : _grading.RoundToGrade(value.Value, _gradingSystem, rounding), + written.Count > 0 ? _grading.WeightedAverage(written) : null, + participation.Count > 0 ? _grading.WeightedAverage(participation) : null, + other.Count > 0 ? _grading.WeightedAverage(other) : null); + } + + /// Die Teilnoten der Endjahresnote. Bei den Halbjahren geht eine gespeicherte Entscheidung + /// (Übersteuerung bzw. festgeschriebener Wert) der Live-Berechnung vor. + public List YearParts(Guid studentId, RoundingRule rounding, YearExamGrades? examGrades = null) + { + var parts = new List(); + foreach (var half in new[] { ParticipationPeriod.H1, ParticipationPeriod.H2 }) + { + var label = PeriodLabel(half); + var record = _reportGrades.GetByStudentGroupPeriod(studentId, _groupId, label); + var overridden = NullIfEmpty(record?.OverrideValue); + if (overridden is not null) + parts.Add(new YearPart(label, overridden, record!.IsLocked ? "festgeschrieben" : "übersteuert")); + else if (record is { IsLocked: true }) + parts.Add(new YearPart(label, NullIfEmpty(record.CalculatedValue), "festgeschrieben")); + else + parts.Add(new YearPart(label, Calculate(studentId, half, rounding).Rounded, "berechnet")); + } + + // Die unterrichtliche Leistung zählt immer doppelt gegenüber einer Prüfung — auch wenn + // nur eine der beiden Halbjahresnoten vorliegt. + if (parts.Count(p => p.Final is not null) == 1) + parts = parts.Select(p => p.Final is null ? p : p with { Weight = 2.0 }).ToList(); + + var exams = examGrades ?? StoredExamGrades(studentId); + if (NullIfEmpty(exams.Project) is { } project) + parts.Add(new YearPart(ProjectExamLabel, project, "zählt wie ein Halbjahr")); + if (ExamPerformance(exams) is { } performance) + parts.Add(performance); + return parts; + } + + /// Die Prüfungsleistung des Fachs, die mit einem Drittel in die Gesamtnote eingeht. + private YearPart? ExamPerformance(YearExamGrades exams) + { + var written = NullIfEmpty(exams.Written); + var oral = NullIfEmpty(exams.Oral); + var additional = NullIfEmpty(exams.AdditionalOral); + + if (written is not null && additional is not null) + { + // § 13 Abs. 2 PrüfV Sek. I: 1:1. Liegt das Ergebnis zwischen zwei Noten, wird hier + // nicht gerundet — es geht so in die Endnote ein, entschieden wird dort. + var mean = _grading.WeightedAverage([(written, 1.0), (additional, 1.0)]); + var between = BetweenGrades(mean); + return new YearPart("Prüfungsleistung", mean.ToString("0.##", German), + $"schriftlich {written} · mündlich {additional}" + (between is null ? "" : $" — {between}"), + Value: mean); + } + if (written is not null) return new YearPart(WrittenExamLabel, written, "ein Drittel der Note"); + if (oral is not null) return new YearPart(OralExamLabel, oral, "ein Drittel der Note"); + if (additional is not null) return new YearPart(AdditionalOralExamLabel, additional, "ein Drittel der Note"); + return null; + } + + public YearExamGrades StoredExamGrades(Guid studentId) + { + var year = _reportGrades.GetByStudentGroupPeriod(studentId, _groupId, PeriodLabel(ParticipationPeriod.FullYear)); + return new YearExamGrades(year?.ProjectExamGrade, year?.OralExamGrade, + year?.WrittenExamGrade, year?.AdditionalOralExamGrade); + } + + /// "zwischen 4 und 5", wenn der Wert genau auf der Grenze zweier Noten liegt — dann gibt + /// es rechnerisch keine richtige Seite, nur eine pädagogische Entscheidung. + public static string? BetweenGrades(double value) + { + var lower = Math.Floor(value); + return Math.Abs(value - lower - 0.5) < 0.0001 ? $"zwischen {lower:0} und {lower + 1:0}" : null; + } + + private static readonly System.Globalization.CultureInfo German = + System.Globalization.CultureInfo.GetCultureInfo("de-DE"); + + private static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); +} diff --git a/LehrerApp.Desktop/ViewModels/Groups/ReportGradeViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ReportGradeViewModels.cs index 18707e3..c6be0e5 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ReportGradeViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ReportGradeViewModels.cs @@ -13,22 +13,15 @@ namespace LehrerApp.Desktop.ViewModels.Groups; public partial class ReportGradeDialogViewModel : ObservableObject { - private readonly IGradeRepository _grades; - private readonly IExamRepository _exams; - private readonly IExamResultRepository _results; private readonly IStudentRepository _students; private readonly IGroupMembershipRepository _memberships; - private readonly IGradingSchemeRepository _schemes; private readonly IReportGradeRepository _reportGrades; + private readonly ReportGradeCalculator _calculator; private readonly IParticipationSessionRepository _participationSessions; private readonly IParticipationRepository _participation; private readonly AttendanceBalanceService _attendanceBalance; - private readonly GradingService _grading; private readonly Guid _groupId; - private readonly GroupType _groupType; - private readonly GradingSystem _gradingSystem; private readonly string _groupLabel; - private readonly string _schoolYear; [ObservableProperty] private ParticipationPeriodOption _selectedPeriod; [ObservableProperty] private RoundingRule _roundingRule = RoundingRule.Commercial; @@ -59,12 +52,13 @@ public partial class ReportGradeDialogViewModel : ObservableObject AttendanceBalanceService attendanceBalance, GradingService grading, Guid groupId, GroupType groupType, GradingSystem gradingSystem, string groupLabel, string schoolYear) { - _grades = grades; _exams = exams; _results = results; _students = students; - _memberships = memberships; _schemes = schemes; _reportGrades = reportGrades; _grading = grading; + _students = students; _memberships = memberships; _reportGrades = reportGrades; _participationSessions = participationSessions; _participation = participation; _attendanceBalance = attendanceBalance; - _groupId = groupId; _groupType = groupType; _gradingSystem = gradingSystem; _groupLabel = groupLabel; - _schoolYear = schoolYear; + _groupId = groupId; _groupLabel = groupLabel; + _calculator = new ReportGradeCalculator(grades, exams, results, schemes, reportGrades, + memberships.GetByGroup(groupId).ToDictionary(m => m.StudentId), grading, + groupId, groupType, gradingSystem, schoolYear); _selectedPeriod = PeriodOptions[0]; Recompute(); @@ -73,31 +67,19 @@ public partial class ReportGradeDialogViewModel : ObservableObject partial void OnSelectedPeriodChanged(ParticipationPeriodOption value) => Recompute(); partial void OnRoundingRuleChanged(RoundingRule value) => Recompute(); - private GradingScheme ResolveScheme() => - _schemes.GetByGroup(_groupId) - ?? _schemes.GetDefaultForType(_groupType) - ?? new GradingScheme { ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 }; + public void Refresh() => Recompute(); private void Recompute() { - var scheme = ResolveScheme(); - SchemeSummary = $"Klausuren {scheme.ExamsPercent:0.#} % · Mitarbeit {scheme.ParticipationPercent:0.#} % · " + - $"Sonstige {scheme.OtherPercent:0.#} %"; + _calculator.Reload(); + SchemeSummary = _calculator.SchemeSummary; var period = SelectedPeriod.Period; var periodTag = SelectedPeriod.Label; - var (periodFrom, periodTo) = GroupMembershipService.SchoolYearPeriod(_schoolYear, period switch - { - ParticipationPeriod.H1 => SchoolYearPeriodKind.H1, - ParticipationPeriod.H2 => SchoolYearPeriodKind.H2, - _ => SchoolYearPeriodKind.FullYear, - }); + var (periodFrom, periodTo) = _calculator.Range(period); var students = _students.GetByGroup(_groupId); var membershipsByStudent = _memberships.GetByGroup(_groupId).ToDictionary(m => m.StudentId); - var exams = _exams.GetByGroup(_groupId).Where(e => e.Date >= periodFrom && e.Date <= periodTo).ToList(); - var resultsByExam = exams.ToDictionary(e => e.Id, e => _results.GetByExam(e.Id).ToDictionary(r => r.StudentId)); - var allGrades = _grades.GetByGroup(_groupId).Where(g => g.Date >= periodFrom && g.Date <= periodTo).ToList(); // Fehlquote (Nutzer-Feedback): je Schüler die Anwesenheits-Bilanz im gewählten Zeitraum, // nur aus Sitzungen dieser Gruppe (anders als StudentDetailViewModel.LoadAttendanceBalance, @@ -132,31 +114,9 @@ public partial class ReportGradeDialogViewModel : ObservableObject continue; } - 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; - examEntries.Add((exam, result.Grade)); - } - var (writtenGrades, testGrades) = ExamScoringService.EffectiveGradesByKind(examEntries, _gradingSystem); - var examGrades = writtenGrades.Select(grade => (Grade: grade, Weight: 1.0)).ToList(); - - var participationGrades = allGrades - .Where(g => g.StudentId == student.Id && g.Category == GradeCategory.Participation - && (membership is null || GroupMembershipService.IsActiveOn(membership, g.Date))) - .Select(g => (g.Value, g.Weight)).ToList(); - var otherGrades = allGrades - .Where(g => g.StudentId == student.Id && g.Category != GradeCategory.Participation - && (membership is null || GroupMembershipService.IsActiveOn(membership, g.Date))) - .Select(g => (g.Value, g.Weight)).ToList(); - // Tests, kontrollierte Hausaufgaben u. Ä. aus der Klausurfunktion zählen als Sonstige. - otherGrades.AddRange(testGrades.Select(grade => (grade, 1.0))); - - var calculated = _grading.CalculateReportGrade(examGrades, participationGrades, otherGrades, - scheme, _gradingSystem, RoundingRule); + // Halbjahre aus den Einzelleistungen, das Schuljahr aus den Halbjahresnoten + // (plus ggf. Prüfungsnoten) — siehe ReportGradeCalculator. + var calculated = _calculator.Calculate(student.Id, period, RoundingRule).Rounded; Rows.Add(ReportGradeRow.FromCalculated(student.Id, student.FullName, calculated, existing, absenceRate, Save, ToggleLock)); } diff --git a/LehrerApp.Desktop/Views/Groups/GradeOverviewTabView.axaml.cs b/LehrerApp.Desktop/Views/Groups/GradeOverviewTabView.axaml.cs index ef41c96..44e3b79 100644 --- a/LehrerApp.Desktop/Views/Groups/GradeOverviewTabView.axaml.cs +++ b/LehrerApp.Desktop/Views/Groups/GradeOverviewTabView.axaml.cs @@ -75,11 +75,33 @@ public partial class GradeOverviewTabView : UserControl App.Services.GetRequiredService(), _vm!.GroupId, _vm.GroupType, _vm.GradingSystem, _vm.GroupLabel, _vm.SchoolYear); - var dialog = new ReportGradeDialog { DataContext = dialogVm }; + var dialog = new ReportGradeDialog { DataContext = dialogVm, OnOpenAssistant = ShowReportGradeAssistant }; var owner = TopLevel.GetTopLevel(this) as Window; if (owner is not null) await dialog.ShowDialog(owner); } + private async Task ShowReportGradeAssistant(Window owner) + { + var dialogVm = new ReportGradeAssistantViewModel( + App.Services.GetRequiredService(), + App.Services.GetRequiredService(), + App.Services.GetRequiredService(), + App.Services.GetRequiredService(), + App.Services.GetRequiredService(), + App.Services.GetRequiredService(), + App.Services.GetRequiredService(), + App.Services.GetRequiredService(), + App.Services.GetRequiredService(), + App.Services.GetRequiredService(), + App.Services.GetRequiredService(), + App.Services.GetRequiredService(), + App.Services.GetRequiredService(), + _vm!.GroupId, _vm.GroupType, _vm.GradingSystem, _vm.GroupLabel, _vm.SchoolYear, + App.Services.GetRequiredService().GetById(_vm.GroupId)?.GradeLevel ?? 0); + + await new ReportGradeAssistantDialog { DataContext = dialogVm }.ShowDialog(owner); + } + private async Task ShowPerformanceOverviewDialog(GradeOverviewRow row) { var dialogVm = new StudentPerformanceOverviewViewModel( diff --git a/LehrerApp.Desktop/Views/Groups/ReportGradeAssistantDialog.axaml b/LehrerApp.Desktop/Views/Groups/ReportGradeAssistantDialog.axaml new file mode 100644 index 0000000..d1f7da6 --- /dev/null +++ b/LehrerApp.Desktop/Views/Groups/ReportGradeAssistantDialog.axaml @@ -0,0 +1,275 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +