From d9eac0d1d2df5f7430dd455ccb1375bb3cd0b695 Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Tue, 6 Oct 2026 13:01:34 +0200 Subject: [PATCH] =?UTF-8?q?Zwischenstand:=20vorl=C3=A4ufige=20Noten=20zu?= =?UTF-8?q?=20Stichtag=20und=20Anlass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ein Zwischenstand ist ein Durchgang aus Stichtag, Anlass und Schülerauswahl (ganze Gruppe, Teilgruppe oder Einzelfall, mit Schnellfilter für gefährdete Schüler). Der Zeugnisnoten-Assistent geht die gewählten Schüler im Zwischenstand-Modus durch und notiert vorläufige schriftliche, mündliche und Gesamtnote samt Notiz. Gespeichert wird das als eigenes Modell InterimGrade, getrennt von der Zeugnisnote. Die Berechnung endet am Stichtag und rechnet den laufenden Mitarbeits-Abschnitt mit ein. Zwischenstände erscheinen im Assistenten als Liste und Marker im Verlauf sowie als graue Spalte in der Notenübersicht (nicht in "Gesamt", nicht im PDF). Co-Authored-By: Claude Opus 5.5 --- LehrerApp.Core/Interfaces/IRepositories.cs | 7 + LehrerApp.Core/Models/Planning.cs | 23 ++ LehrerApp.Data.Tests/ChangeHookMatrixTests.cs | 9 + LehrerApp.Data/LiteDbContext.cs | 6 + .../Repositories/AllRepositories.cs | 24 +- LehrerApp.Desktop.Tests/Fakes.cs | 11 + .../GroupDetailViewModelTests.cs | 2 +- .../ReportGradeAssistantViewModelTests.cs | 193 +++++++++++++++- LehrerApp.Desktop/AppBootstrapper.cs | 1 + .../Groups/GradeOverviewViewModels.cs | 32 ++- .../Groups/InterimGradeCalculator.cs | 36 +++ .../Groups/InterimRunDialogViewModel.cs | 211 ++++++++++++++++++ .../ReportGradeAssistantViewModel.Interim.cs | 165 ++++++++++++++ .../Groups/ReportGradeAssistantViewModels.cs | 47 +++- .../Groups/ReportGradeCalculator.cs | 10 +- .../ViewModels/Students/StudentViewModels.cs | 2 +- .../Views/Groups/GradeOverviewTabView.axaml | 2 + .../Groups/GradeOverviewTabView.axaml.cs | 56 ++++- .../Views/Groups/InterimRunDialog.axaml | 87 ++++++++ .../Views/Groups/InterimRunDialog.axaml.cs | 17 ++ .../Groups/ReportGradeAssistantDialog.axaml | 85 ++++++- LehrerApp.Sync/EventApplier.cs | 1 + TODO.md | 36 ++- docs/Datenmodell.md | 12 + 24 files changed, 1038 insertions(+), 37 deletions(-) create mode 100644 LehrerApp.Desktop/ViewModels/Groups/InterimGradeCalculator.cs create mode 100644 LehrerApp.Desktop/ViewModels/Groups/InterimRunDialogViewModel.cs create mode 100644 LehrerApp.Desktop/ViewModels/Groups/ReportGradeAssistantViewModel.Interim.cs create mode 100644 LehrerApp.Desktop/Views/Groups/InterimRunDialog.axaml create mode 100644 LehrerApp.Desktop/Views/Groups/InterimRunDialog.axaml.cs diff --git a/LehrerApp.Core/Interfaces/IRepositories.cs b/LehrerApp.Core/Interfaces/IRepositories.cs index 4663e12..b1cb616 100644 --- a/LehrerApp.Core/Interfaces/IRepositories.cs +++ b/LehrerApp.Core/Interfaces/IRepositories.cs @@ -94,6 +94,13 @@ public interface IReportGradeRepository void Save(ReportGrade grade); void Delete(Guid id); } +public interface IInterimGradeRepository +{ + List GetByGroup(Guid groupId); + List GetByStudentAndGroup(Guid studentId, Guid groupId); + void Save(InterimGrade grade); + void Delete(Guid id); +} public interface IUnitRepository { Unit? GetById(Guid id); diff --git a/LehrerApp.Core/Models/Planning.cs b/LehrerApp.Core/Models/Planning.cs index 23d4644..affa55a 100644 --- a/LehrerApp.Core/Models/Planning.cs +++ b/LehrerApp.Core/Models/Planning.cs @@ -274,6 +274,29 @@ public class ReportGrade public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } +/// +/// Zwischenstand: eine vorläufige Note zu einem Stichtag, wie sie zu einem Anlass (Sprechtag, +/// blauer Brief, Konferenz, Elternanfrage) genannt wurde. Bewusst getrennt von +/// — mehrere je Halbjahr möglich, ohne Einfluss auf die Zeugnisnote. +/// Zwischenstände mit gleichem und bilden einen +/// Durchgang durch (einen Teil der) Lerngruppe. +/// +public class InterimGrade +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid StudentId { get; set; } + public Guid GroupId { get; set; } + /// Stichtag: Leistungen bis einschließlich dieses Tages lagen der Note zugrunde. + public DateOnly Date { get; set; } + public string Occasion { get; set; } = ""; + public string? WrittenValue { get; set; } + public string? OralValue { get; set; } + public string OverallValue { get; set; } = ""; + public string? Note { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} + /// Live timing is separate from the original lesson plan. public class TeachingTimelineState { diff --git a/LehrerApp.Data.Tests/ChangeHookMatrixTests.cs b/LehrerApp.Data.Tests/ChangeHookMatrixTests.cs index bebf8ad..2fbd67f 100644 --- a/LehrerApp.Data.Tests/ChangeHookMatrixTests.cs +++ b/LehrerApp.Data.Tests/ChangeHookMatrixTests.cs @@ -61,6 +61,15 @@ public sealed class ChangeHookMatrixTests return (grade.Id, () => repo.Delete(grade.Id)); }); + yield return Case("InterimGrade", nameof(InterimGrade), db => + { + var repo = new InterimGradeRepository(db); + var grade = new InterimGrade { StudentId = Guid.NewGuid(), GroupId = Guid.NewGuid(), + Date = new DateOnly(2025, 11, 12), Occasion = "Sprechtag", OverallValue = "3" }; + repo.Save(grade); + return (grade.Id, () => repo.Delete(grade.Id)); + }); + yield return Case("Unit", nameof(Unit), db => { var repo = new UnitRepository(db); diff --git a/LehrerApp.Data/LiteDbContext.cs b/LehrerApp.Data/LiteDbContext.cs index bb821e7..f97540d 100644 --- a/LehrerApp.Data/LiteDbContext.cs +++ b/LehrerApp.Data/LiteDbContext.cs @@ -48,6 +48,7 @@ public class LiteDbContext : IDisposable public ILiteCollection Grades => _db.GetCollection("grades"); public ILiteCollection GradingSchemes => _db.GetCollection("grading_schemes"); public ILiteCollection ReportGrades => _db.GetCollection("report_grades"); + public ILiteCollection InterimGrades => _db.GetCollection("interim_grades"); public ILiteCollection GradingKeyTemplates => _db.GetCollection("grading_key_templates"); public ILiteCollection Units => _db.GetCollection("units"); public ILiteCollection Lessons => _db.GetCollection("lessons"); @@ -114,6 +115,9 @@ public class LiteDbContext : IDisposable foreach (var reportGrade in ReportGrades.Find(g => g.GroupId == id).ToList()) ReportGrades.Delete(reportGrade.Id); + foreach (var interimGrade in InterimGrades.Find(g => g.GroupId == id).ToList()) + InterimGrades.Delete(interimGrade.Id); + foreach (var scheme in GradingSchemes.Find(s => s.GroupId == id).ToList()) GradingSchemes.Delete(scheme.Id); @@ -681,6 +685,8 @@ public class LiteDbContext : IDisposable ReportGrades.EnsureIndex(x => x.StudentId); ReportGrades.EnsureIndex("ux_student_group_period", BsonExpression.Create("STRING($.StudentId) + ':' + STRING($.GroupId) + ':' + $.Period"), unique: true); + InterimGrades.EnsureIndex(x => x.GroupId); + InterimGrades.EnsureIndex(x => x.StudentId); GradingKeyTemplates.EnsureIndex(x => x.GradingSystem); Units.EnsureIndex(x => x.GroupId); Lessons.EnsureIndex(x => x.UnitId); diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs index 62f9b63..e51836f 100644 --- a/LehrerApp.Data/Repositories/AllRepositories.cs +++ b/LehrerApp.Data/Repositories/AllRepositories.cs @@ -32,7 +32,8 @@ public class StudentRepository(LiteDbContext db) : IStudentRepository db.Memberships.Count(m => m.StudentId == studentId), db.ExamResults.Count(r => r.StudentId == studentId), db.Grades.Count(g => g.StudentId == studentId), - db.ReportGrades.Count(g => g.StudentId == studentId), + // Zwischenstände sind vorläufige Zeugnisnoten und zählen hier mit. + db.ReportGrades.Count(g => g.StudentId == studentId) + db.InterimGrades.Count(g => g.StudentId == studentId), db.ParticipationEntries.Count(e => e.StudentId == studentId), db.Documentation.FindAll().Count(d => d.Concerns(studentId)), db.StudentGroupPlans.FindAll().Count(plan => @@ -330,6 +331,27 @@ public class GradingSchemeRepository(LiteDbContext db) : IGradingSchemeRepositor } } +public class InterimGradeRepository(LiteDbContext db) : IInterimGradeRepository +{ + public List GetByGroup(Guid groupId) => db.InterimGrades.Find(g => g.GroupId == groupId).ToList(); + public List GetByStudentAndGroup(Guid studentId, Guid groupId) => + db.InterimGrades.Find(g => g.StudentId == studentId && g.GroupId == groupId).ToList(); + public void Save(InterimGrade g) + { + ArchivedGroupWriteGuard.EnsureActive(db, g.GroupId); + g.UpdatedAt = DateTime.UtcNow; + db.InterimGrades.Upsert(g); + db.OnChange?.Invoke(nameof(InterimGrade), g.Id.ToString(), "Save", g); + } + public void Delete(Guid id) + { + if (db.InterimGrades.FindById(id) is { } grade) + ArchivedGroupWriteGuard.EnsureActive(db, grade.GroupId); + db.InterimGrades.Delete(id); + db.OnChange?.Invoke(nameof(InterimGrade), id.ToString(), "Delete", null); + } +} + public class ReportGradeRepository(LiteDbContext db) : IReportGradeRepository { public List GetByGroup(Guid groupId) => db.ReportGrades.Find(r => r.GroupId == groupId).ToList(); diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs index 11ad303..3673441 100644 --- a/LehrerApp.Desktop.Tests/Fakes.cs +++ b/LehrerApp.Desktop.Tests/Fakes.cs @@ -681,6 +681,17 @@ public class FakeTimeEntries : ITimeEntryRepository public void Restore(Guid trashId) { } // siehe FakeSeatingPlans.Restore } +public class FakeInterimGrades : IInterimGradeRepository +{ + private readonly List _all = []; + public List All => _all; + public List GetByGroup(Guid groupId) => _all.Where(g => g.GroupId == groupId).ToList(); + public List GetByStudentAndGroup(Guid studentId, Guid groupId) => + _all.Where(g => g.StudentId == studentId && g.GroupId == groupId).ToList(); + public void Save(InterimGrade grade) { _all.RemoveAll(g => g.Id == grade.Id); _all.Add(grade); } + public void Delete(Guid id) => _all.RemoveAll(g => g.Id == id); +} + public class FakeReportGrades : IReportGradeRepository { private readonly List _all = []; diff --git a/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs b/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs index 994a7eb..3c5c8ff 100644 --- a/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs @@ -22,7 +22,7 @@ public sealed class GroupDetailViewModelTests new FakeDocumentation(), tasks, groups, new AttendanceBalanceService(), new GradingService(), new SchoolYearService()), new ParticipationTabViewModel(new FakeSessions([]), new FakeEntries(), new FakeAspects(), students, memberships, groups, new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions()), - new GradeOverviewTabViewModel(grades, exams, new FakeResults(), students, memberships, new GradingService()), + new GradeOverviewTabViewModel(grades, exams, new FakeResults(), students, memberships, new FakeInterimGrades(), new GradingService()), new PlanningTabViewModel(new FakeUnits(), new FakeLessons(), groups, subjects, new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), TestSupport.BuildAiSettingsService(), new FakeSessions([])), new CompetencyOverviewTabViewModel(new FakeUnits(), exams, new FakeResults(), diff --git a/LehrerApp.Desktop.Tests/ReportGradeAssistantViewModelTests.cs b/LehrerApp.Desktop.Tests/ReportGradeAssistantViewModelTests.cs index d19e466..8e39b77 100644 --- a/LehrerApp.Desktop.Tests/ReportGradeAssistantViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/ReportGradeAssistantViewModelTests.cs @@ -23,16 +23,36 @@ public sealed class ReportGradeAssistantViewModelTests public List Sessions { get; } = []; public FakeEntries Entries { get; } = new(); public List Sections { get; } = []; + public FakeInterimGrades InterimGrades { get; } = new(); - public ReportGradeAssistantViewModel Build(DateOnly today, int gradeLevel = 8) + public ReportGradeAssistantViewModel Build(DateOnly today, int gradeLevel = 8, InterimRunOptions? interim = null) { 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); + new FakeSections(Sections), InterimGrades, new AttendanceBalanceService(), new GradingService(), + GroupId, GroupType.Class, GradingSystem.Grades1To6, "Testkurs", "2025/26", gradeLevel, today, interim); + } + + public InterimRunDialogViewModel BuildRunDialog(DateOnly today, Guid? preselected = null) + { + var schemes = new FakeSchemes(); + schemes.SetForGroup(GroupId, new GradingScheme { ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 }); + return new InterimRunDialogViewModel( + Grades, new FakeExams(Exams), Results, new FakeStudents([Anna, Ben]), new FakeMemberships([]), + schemes, ReportGrades, new FakeSessions(Sessions), Entries, new FakeAspects(), + new FakeSections(Sections), InterimGrades, new GradingService(), + GroupId, GroupType.Class, GradingSystem.Grades1To6, "Testkurs", "2025/26", preselected, today); + } + + public void AddSession(DateOnly date, int rating) + { + var session = new ParticipationSession { Id = Guid.NewGuid(), GroupId = GroupId, Date = date }; + Sessions.Add(session); + Entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = Anna.Id, + Ratings = [new AspectRating { Key = "quality", Value = rating }] }); } public Exam AddExam(string title, DateOnly date, string annaGrade) @@ -385,4 +405,171 @@ public sealed class ReportGradeAssistantViewModelTests Assert.Equal(3, vm.ChartGroups.Count); Assert.Equal(3, vm.ChartGroups.Sum(g => g.Points.Count)); } + + // ── Zwischenstand (2.7.5) ──────────────────────────────────────────────── + + private static InterimRunOptions Sprechtag(params Guid[] students) => + new(new DateOnly(2025, 11, 12), "Sprechtag", students); + + [Fact] + public void Zwischenstand_RechnetNurBisZumStichtagUndMitLaufenderMitarbeit() + { + var f = new Fixture(); + f.AddExam("K1", new DateOnly(2025, 10, 1), "2"); + f.AddExam("K2", new DateOnly(2025, 12, 1), "6"); // nach dem Stichtag + f.AddSession(new DateOnly(2025, 10, 20), -2); // laufender Abschnitt, schwache Mitarbeit + + var vm = f.Build(InH1, interim: Sprechtag(Anna.Id)); + + Assert.True(vm.IsInterimMode); + Assert.Equal(Anna.Id, Assert.Single(vm.Students).StudentId); + Assert.Equal("K1", Assert.Single(vm.Works).Title); + Assert.Equal("2", vm.InterimWritten); + Assert.NotNull(vm.InterimOral); + Assert.Contains("laufender Abschnitt", vm.Tiles.Single(t => t.Label == "Mitarbeit").Hint); + // Die schwache laufende Mitarbeit zieht den Gesamtstand unter die reine Klausurnote. + Assert.True(int.Parse(vm.InterimOverall!) > 2); + Assert.Contains("12.11.2025", vm.CalculationDetail); + + // Die Zeugnisnoten-Berechnung selbst bleibt ohne laufenden Abschnitt. + var report = f.Build(InH1); + Assert.Equal("–", report.Tiles.Single(t => t.Label == "Mitarbeit").Value); + } + + [Fact] + public void Zwischenstand_NotierenSpeichertGetrenntVonDerZeugnisnote() + { + var f = new Fixture(); + f.AddExam("K1", new DateOnly(2025, 10, 1), "3"); + var vm = f.Build(InH1, interim: Sprechtag(Anna.Id, Ben.Id)); + + vm.InterimOverall = ""; + vm.NoteInterimCommand.Execute(null); + Assert.NotEmpty(vm.InterimOverallError); + Assert.Empty(f.InterimGrades.All); + + vm.InterimOverall = "3-"; + vm.InterimNote = "Mitarbeit muss besser werden"; + vm.NoteInterimAndNextCommand.Execute(null); + + var entry = Assert.Single(f.InterimGrades.All); + Assert.Equal(Anna.Id, entry.StudentId); + Assert.Equal(new DateOnly(2025, 11, 12), entry.Date); + Assert.Equal("Sprechtag", entry.Occasion); + Assert.Equal("3", entry.WrittenValue); + Assert.Equal("3-", entry.OverallValue); + Assert.Equal(0, f.ReportGrades.SavedCount); + Assert.Equal(AssistantGradeStatus.Noted, vm.Students[0].Status); + Assert.Equal("3-", vm.Students[0].GradeDisplay); + Assert.Equal(Ben.Id, vm.SelectedStudent!.StudentId); + + // Erneutes Notieren im selben Durchgang überschreibt statt zu verdoppeln. + vm.SelectedStudent = vm.Students[0]; + Assert.True(vm.HasInterimEntry); + Assert.Equal("3-", vm.InterimOverall); + vm.InterimOverall = "4"; + vm.NoteInterimCommand.Execute(null); + Assert.Equal("4", Assert.Single(f.InterimGrades.All).OverallValue); + + vm.RemoveInterimCommand.Execute(null); + Assert.Empty(f.InterimGrades.All); + Assert.Equal(AssistantGradeStatus.Open, vm.Students[0].Status); + } + + [Fact] + public void Zwischenstaende_ErscheinenImZeugnisnotenModusAlsListeUndMarker() + { + var f = new Fixture(); + f.AddExam("K1", new DateOnly(2025, 10, 1), "3"); + f.InterimGrades.Save(new InterimGrade { StudentId = Anna.Id, GroupId = GroupId, + Date = new DateOnly(2025, 11, 12), Occasion = "Sprechtag", WrittenValue = "3", OverallValue = "3" }); + + var vm = f.Build(new DateOnly(2025, 12, 10)); + + var row = Assert.Single(vm.InterimEntries); + Assert.Equal("12.11.2025 · Sprechtag", row.Title); + Assert.Equal("schriftlich 3", row.Detail); + Assert.Contains(vm.ChartGroups.SelectMany(g => g.Points), p => p.ExamLabel == "Zwischenstand: 3"); + Assert.True(vm.IsReportMode); + } + + [Fact] + public void Startdialog_VorauswahlGefaehrdeteUndBestaetigen() + { + var f = new Fixture(); + f.AddExam("K1", new DateOnly(2025, 10, 1), "5"); + f.Results.Add(new ExamResult { ExamId = f.Exams[0].Id, StudentId = Ben.Id, Grade = "2" }); + + var vm = f.BuildRunDialog(new DateOnly(2025, 11, 12)); + + Assert.All(vm.Students, s => Assert.True(s.IsSelected)); + Assert.Equal(["5", "2"], vm.Students.Select(s => s.ProvisionalDisplay)); + + vm.SelectAtRiskCommand.Execute(null); + Assert.Equal([Anna.Id], vm.Students.Where(s => s.IsSelected).Select(s => s.StudentId)); + + vm.OccasionName = "Blauer Brief"; + Assert.True(vm.Confirm()); + Assert.Equal(new DateOnly(2025, 11, 12), vm.Result!.Date); + Assert.Equal("Blauer Brief", vm.Result.Occasion); + Assert.Equal([Anna.Id], vm.Result.StudentIds); + } + + [Fact] + public void Startdialog_EinzelfallUndValidierung() + { + var f = new Fixture(); + var vm = f.BuildRunDialog(new DateOnly(2025, 11, 12), preselected: Ben.Id); + + Assert.Equal([Ben.Id], vm.Students.Where(s => s.IsSelected).Select(s => s.StudentId)); + + vm.OccasionName = "Elternanfrage"; + vm.OccasionDetail = "Frau Muster"; + Assert.Equal("Elternanfrage: Frau Muster", vm.Occasion); + + vm.SelectNoneCommand.Execute(null); + Assert.False(vm.Confirm()); + Assert.NotEmpty(vm.SelectionError); + + vm.DateText = "12.11.2030"; + Assert.False(vm.Confirm()); + Assert.NotEmpty(vm.DateTextError); + } + + [Fact] + public void Startdialog_BegonnenenDurchgangFortsetzen() + { + var f = new Fixture(); + f.InterimGrades.Save(new InterimGrade { StudentId = Anna.Id, GroupId = GroupId, + Date = new DateOnly(2025, 11, 12), Occasion = "Sprechtag: 1. Elternsprechtag", OverallValue = "3" }); + + var vm = f.BuildRunDialog(new DateOnly(2025, 12, 1)); + vm.SelectedExistingRun = Assert.Single(vm.ExistingRuns); + + Assert.Equal("12.11.2025", vm.DateText); + Assert.Equal("Sprechtag: 1. Elternsprechtag", vm.Occasion); + Assert.Equal("notiert: 3", vm.Students.Single(s => s.StudentId == Anna.Id).NotedDisplay); + Assert.Equal("", vm.Students.Single(s => s.StudentId == Ben.Id).NotedDisplay); + } + + [Fact] + public void Notenuebersicht_ZeigtZwischenstaendeAlsEigeneSpalteOhneEinflussAufGesamt() + { + var f = new Fixture(); + f.AddExam("K1", new DateOnly(2025, 10, 1), "2"); + f.InterimGrades.Save(new InterimGrade { StudentId = Anna.Id, GroupId = GroupId, + Date = new DateOnly(2025, 11, 12), Occasion = "Sprechtag", OverallValue = "5" }); + + var vm = new GradeOverviewTabViewModel(f.Grades, new FakeExams(f.Exams), f.Results, + new FakeStudents([Anna, Ben]), new FakeMemberships([]), f.InterimGrades, new GradingService()); + vm.Initialize(GroupId, GradingSystem.Grades1To6, GroupType.Class, "Testkurs", "2025/26"); + + var column = vm.Columns.Last(); + Assert.True(column.IsInterim); + Assert.Equal("Zwischenstand 12.11. Sprechtag", column.Header); + var anna = vm.Rows.Single(r => r.StudentId == Anna.Id); + Assert.Equal("5", anna.Cells.Last()); + Assert.Equal("2.00", anna.TotalDisplay); + Assert.Equal("", vm.Rows.Single(r => r.StudentId == Ben.Id).Cells.Last()); + } } diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index c8d026f..b0cee5a 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -164,6 +164,7 @@ public static class AppBootstrapper services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/LehrerApp.Desktop/ViewModels/Groups/GradeOverviewViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/GradeOverviewViewModels.cs index 4a515b7..65e0505 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/GradeOverviewViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/GradeOverviewViewModels.cs @@ -54,12 +54,16 @@ public partial class GradeOverviewTabViewModel : ObservableObject public Func? OnManageStudentGrades { get; set; } public Func? OnCollectiveGrade { get; set; } public Func? OnReportGrades { get; set; } + public Func? OnInterimGrades { get; set; } public Func? OnShowPerformanceOverview { get; set; } + private readonly IInterimGradeRepository _interimGrades; + public GradeOverviewTabViewModel(IGradeRepository grades, IExamRepository exams, IExamResultRepository results, IStudentRepository students, - IGroupMembershipRepository memberships, GradingService grading) + IGroupMembershipRepository memberships, IInterimGradeRepository interimGrades, GradingService grading) { + _interimGrades = interimGrades; _grades = grades; _exams = exams; _results = results; _students = students; _memberships = memberships; _grading = grading; _selectedPeriod = PeriodOptions[0]; @@ -131,6 +135,15 @@ public partial class GradeOverviewTabViewModel : ObservableObject await OnReportGrades(); } + /// Zwischenstand notieren: mit markiertem Schüler nur für diesen vorausgewählt, sonst für alle. + [RelayCommand] + private async Task InterimGrades() + { + if (OnInterimGrades is null) return; + await OnInterimGrades(SelectedRow?.StudentId); + Recompute(); + } + private void Recompute() { var period = SelectedPeriod.Period; @@ -170,6 +183,15 @@ public partial class GradeOverviewTabViewModel : ObservableObject Columns.Add(new GradeOverviewColumnDef(header)); } + // Zwischenstände (2.7.5): je Durchgang eine Spalte, grau dargestellt. Sie sind vorläufig + // und fließen bewusst nicht in "Gesamt" ein. + var interimGrades = _interimGrades.GetByGroup(_groupId) + .Where(g => g.Date >= periodFrom && g.Date <= periodTo) + .ToList(); + var interimRuns = interimGrades.Select(g => (g.Date, g.Occasion)).Distinct().OrderBy(r => r.Date).ToList(); + foreach (var run in interimRuns) + Columns.Add(new GradeOverviewColumnDef($"Zwischenstand {run.Date:dd.MM.} {run.Occasion}", isInterim: true)); + Rows.Clear(); foreach (var student in students) { @@ -207,6 +229,10 @@ public partial class GradeOverviewTabViewModel : ObservableObject numeric.Add((grade.Value, grade.Weight)); } + foreach (var run in interimRuns) + cells.Add(FormatValue(interimGrades.FirstOrDefault(g => + g.StudentId == student.Id && g.Date == run.Date && g.Occasion == run.Occasion)?.OverallValue)); + var total = numeric.Count == 0 ? (double?)null : _grading.WeightedAverage(numeric); var totalDisplay = total is null ? "–" : total.Value.ToString("0.00", CultureInfo.InvariantCulture); @@ -236,9 +262,11 @@ public partial class GradeOverviewTabViewModel : ObservableObject } -public class GradeOverviewColumnDef(string header) +public class GradeOverviewColumnDef(string header, bool isInterim = false) { public string Header { get; } = header; + /// Vorläufige Note eines Zwischenstand-Durchgangs — grau, nicht in "Gesamt", nicht im PDF. + public bool IsInterim { get; } = isInterim; } public class GradeOverviewRow(Guid studentId, string name, List cells, string totalDisplay, double? totalSortValue) diff --git a/LehrerApp.Desktop/ViewModels/Groups/InterimGradeCalculator.cs b/LehrerApp.Desktop/ViewModels/Groups/InterimGradeCalculator.cs new file mode 100644 index 0000000..c6b9ea1 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Groups/InterimGradeCalculator.cs @@ -0,0 +1,36 @@ +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; + +namespace LehrerApp.Desktop.ViewModels.Groups; + +/// Notenvorschlag des am Stichtag laufenden +/// Mitarbeits-Abschnitts, der in bereits eingerechnet ist. +public sealed record InterimResult(ParticipationPeriod Period, ReportGradeResult Result, string? RunningParticipation); + +/// +/// Vorläufige Halbjahresnote zu einem Stichtag (Zwischenstand, 2.7.5). Anders als die +/// Zeugnisnote zählt hier der laufende Mitarbeits-Abschnitt mit seinem Vorschlag mit — sonst +/// bestünde ein Zwischenstand früh im Halbjahr nur aus dem Schriftlichen. +/// +public sealed class InterimGradeCalculator(ReportGradeCalculator calculator, ParticipationTimelineBuilder timeline, + IReadOnlyList sections) +{ + /// Das Halbjahr, zu dem ein Stichtag gehört. + public ParticipationPeriod HalfOf(DateOnly date) => + date <= calculator.Range(ParticipationPeriod.H1).To ? ParticipationPeriod.H1 : ParticipationPeriod.H2; + + public InterimResult Calculate(Guid studentId, DateOnly asOf, RoundingRule rounding) + { + var period = HalfOf(asOf); + var from = calculator.Range(period).From; + + // "Laufend" aus Sicht des Stichtags: alles nach dem letzten bis dahin beendeten Abschnitt. + var closed = sections.Where(s => s.EndDate <= asOf).ToList(); + var openStart = timeline.OpenStart(closed, asOf); + if (openStart < from) openStart = from; + var running = openStart <= asOf ? timeline.ComputeSuggestion(studentId, openStart, asOf) : null; + + var result = calculator.Calculate(studentId, period, rounding, asOf: asOf, runningParticipation: running); + return new InterimResult(period, result, running); + } +} diff --git a/LehrerApp.Desktop/ViewModels/Groups/InterimRunDialogViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/InterimRunDialogViewModel.cs new file mode 100644 index 0000000..ed1d51f --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Groups/InterimRunDialogViewModel.cs @@ -0,0 +1,211 @@ +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; + +// ── Zwischenstand starten (2.7.5) ──────────────────────────────────────────── +// +// Legt einmal Stichtag, Anlass und Schülerauswahl fest; danach geht der Zeugnisnoten-Assistent +// im Zwischenstand-Modus nur die gewählten Schüler durch. Ganze Gruppe (Sprechtag, blaue +// Briefe), Teilgruppe (Konferenz) und Einzelfall (Elternanfrage, Abgang) sind derselbe Ablauf +// mit unterschiedlicher Auswahl. +public partial class InterimRunDialogViewModel : ObservableObject +{ + private const string DateFormat = "dd.MM.yyyy"; + private const string OtherOccasion = "Sonstiges"; + + private readonly IInterimGradeRepository _interimGrades; + private readonly InterimGradeCalculator _calculator; + private readonly ReportGradeCalculator _reportCalculator; + private readonly Dictionary _membershipsByStudent; + private readonly List _allStudents; + private readonly Guid _groupId; + private readonly GradingSystem _gradingSystem; + private readonly Guid? _preselectedStudentId; + + public string GroupLabel { get; } + + [ObservableProperty] private string _dateText; + [ObservableProperty] private string _dateTextError = ""; + [ObservableProperty] private string _occasionName; + [ObservableProperty] private string _occasionDetail = ""; + [ObservableProperty] private string _thresholdText; + [ObservableProperty] private string _thresholdTextError = ""; + [ObservableProperty] private string _selectionError = ""; + [ObservableProperty] private InterimRunChoice? _selectedExistingRun; + + public string[] OccasionOptions { get; } = + ["Sprechtag", "Blauer Brief", "Konferenz", "Elternanfrage", "Schülerrückfrage", "Abgang", OtherOccasion]; + + public ObservableCollection Students { get; } = []; + public List ExistingRuns { get; } + public bool HasExistingRuns => ExistingRuns.Count > 0; + public string AtRiskLabel => _gradingSystem == GradingSystem.Grades1To6 + ? "Nur gefährdete: Note ab" : "Nur gefährdete: Punkte bis"; + + /// Nach erfolgreichem gesetzt. + public InterimRunOptions? Result { get; private set; } + + public InterimRunDialogViewModel(IGradeRepository grades, IExamRepository exams, + IExamResultRepository results, IStudentRepository students, IGroupMembershipRepository memberships, + IGradingSchemeRepository schemes, IReportGradeRepository reportGrades, + IParticipationSessionRepository participationSessions, IParticipationRepository participation, + IParticipationAspectRepository aspects, IParticipationSectionRepository sections, + IInterimGradeRepository interimGrades, GradingService grading, + Guid groupId, GroupType groupType, GradingSystem gradingSystem, string groupLabel, string schoolYear, + Guid? preselectedStudentId = null, DateOnly? today = null) + { + _interimGrades = interimGrades; _groupId = groupId; _gradingSystem = gradingSystem; + _preselectedStudentId = preselectedStudentId; + GroupLabel = groupLabel; + + _membershipsByStudent = memberships.GetByGroup(groupId).ToDictionary(m => m.StudentId); + _allStudents = students.GetByGroup(groupId).OrderBy(s => s.LastName).ThenBy(s => s.FirstName).ToList(); + var timeline = new ParticipationTimelineBuilder(participationSessions, participation, aspects, exams, results, + grades, _membershipsByStudent, grading, groupId, gradingSystem); + _reportCalculator = new ReportGradeCalculator(grades, exams, results, schemes, reportGrades, + _membershipsByStudent, grading, groupId, groupType, gradingSystem, schoolYear); + _calculator = new InterimGradeCalculator(_reportCalculator, timeline, + sections.GetByGroup(groupId).OrderBy(s => s.StartDate).ToList()); + + ExistingRuns = interimGrades.GetByGroup(groupId) + .GroupBy(g => (g.Date, g.Occasion)) + .OrderByDescending(g => g.Key.Date) + .Select(g => new InterimRunChoice(g.Key.Date, g.Key.Occasion, g.Count())) + .ToList(); + + _occasionName = OccasionOptions[0]; + _thresholdText = gradingSystem == GradingSystem.Grades1To6 ? "5" : "4"; + _dateText = (today ?? DateOnly.FromDateTime(DateTime.Today)).ToString(DateFormat); + RebuildStudents(); + } + + public string Occasion + { + get + { + var detail = OccasionDetail.Trim(); + if (detail.Length == 0) return OccasionName; + return OccasionName == OtherOccasion ? detail : $"{OccasionName}: {detail}"; + } + } + + partial void OnDateTextChanged(string value) => RebuildStudents(); + partial void OnOccasionNameChanged(string value) => RefreshNotedMarkers(); + partial void OnOccasionDetailChanged(string value) => RefreshNotedMarkers(); + + partial void OnSelectedExistingRunChanged(InterimRunChoice? value) + { + if (value is null) return; + // Anlass zurück in Auswahl + Ergänzung zerlegen, damit derselbe Durchgang fortgesetzt wird. + var option = OccasionOptions.FirstOrDefault(o => o != OtherOccasion + && (value.Occasion == o || value.Occasion.StartsWith(o + ": ", StringComparison.Ordinal))); + OccasionName = option ?? OtherOccasion; + OccasionDetail = option is null ? value.Occasion + : value.Occasion.Length > option.Length ? value.Occasion[(option.Length + 2)..] : ""; + DateText = value.Date.ToString(DateFormat); + } + + private bool TryParseDate(out DateOnly date) + { + DateTextError = ""; + if (!DateOnly.TryParseExact(DateText.Trim(), DateFormat, null, DateTimeStyles.None, out date)) + { + DateTextError = "Format TT.MM.JJJJ."; + return false; + } + var (from, to) = _reportCalculator.Range(ParticipationPeriod.FullYear); + if (date < from || date > to) + { + DateTextError = "Stichtag liegt außerhalb des Schuljahrs."; + return false; + } + return true; + } + + private void RebuildStudents() + { + // Auswahl über den Neuaufbau retten; beim ersten Aufbau gilt die Vorauswahl. + var previous = Students.ToDictionary(s => s.StudentId, s => s.IsSelected); + Students.Clear(); + if (!TryParseDate(out var date)) return; + + foreach (var student in _allStudents) + { + if (_membershipsByStudent.TryGetValue(student.Id, out var membership) + && !GroupMembershipService.IsActiveOn(membership, date)) continue; + var result = _calculator.Calculate(student.Id, date, RoundingRule.Commercial).Result; + var selected = previous.TryGetValue(student.Id, out var was) + ? was + : _preselectedStudentId is null || _preselectedStudentId == student.Id; + Students.Add(new InterimRunStudentRow(student.Id, student.FullName, result.Rounded) { IsSelected = selected }); + } + RefreshNotedMarkers(); + } + + private void RefreshNotedMarkers() + { + if (!DateOnly.TryParseExact(DateText.Trim(), DateFormat, null, DateTimeStyles.None, out var date)) return; + var noted = _interimGrades.GetByGroup(_groupId) + .Where(g => g.Date == date && g.Occasion == Occasion) + .GroupBy(g => g.StudentId).ToDictionary(g => g.Key, g => g.First().OverallValue); + foreach (var row in Students) + row.NotedDisplay = noted.TryGetValue(row.StudentId, out var value) ? $"notiert: {value}" : ""; + } + + [RelayCommand] + private void SelectAll() { foreach (var row in Students) row.IsSelected = true; } + + [RelayCommand] + private void SelectNone() { foreach (var row in Students) row.IsSelected = false; } + + /// Wählt genau die Schüler, deren vorläufige Note die Schwelle erreicht — für blaue Briefe. + [RelayCommand] + private void SelectAtRisk() + { + ThresholdTextError = ""; + if (!int.TryParse(ThresholdText.Trim(), out var threshold)) + { + ThresholdTextError = "Ganze Zahl erforderlich."; + return; + } + foreach (var row in Students) + row.IsSelected = int.TryParse(row.ProvisionalGrade, out var grade) + && (_gradingSystem == GradingSystem.Grades1To6 ? grade >= threshold : grade <= threshold); + } + + public bool Confirm() + { + SelectionError = ""; + var valid = TryParseDate(out var date); + var selected = Students.Where(s => s.IsSelected).Select(s => s.StudentId).ToList(); + if (valid && selected.Count == 0) { SelectionError = "Mindestens einen Schüler auswählen."; valid = false; } + if (!valid) return false; + + Result = new InterimRunOptions(date, Occasion, selected); + return true; + } +} + +public class InterimRunChoice(DateOnly date, string occasion, int count) +{ + public DateOnly Date { get; } = date; + public string Occasion { get; } = occasion; + public override string ToString() => $"{Date:dd.MM.yyyy} · {Occasion} ({count} notiert)"; +} + +public partial class InterimRunStudentRow(Guid studentId, string name, string? provisionalGrade) : ObservableObject +{ + public Guid StudentId { get; } = studentId; + public string Name { get; } = name; + public string? ProvisionalGrade { get; } = provisionalGrade; + public string ProvisionalDisplay => ProvisionalGrade ?? "–"; + + [ObservableProperty] private bool _isSelected; + [ObservableProperty] private string _notedDisplay = ""; +} diff --git a/LehrerApp.Desktop/ViewModels/Groups/ReportGradeAssistantViewModel.Interim.cs b/LehrerApp.Desktop/ViewModels/Groups/ReportGradeAssistantViewModel.Interim.cs new file mode 100644 index 0000000..f324ba0 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Groups/ReportGradeAssistantViewModel.Interim.cs @@ -0,0 +1,165 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Models; +using System.Collections.ObjectModel; + +namespace LehrerApp.Desktop.ViewModels.Groups; + +/// Ein Zwischenstand-Durchgang: Stichtag und Anlass gelten für alle gewählten Schüler. +public sealed record InterimRunOptions(DateOnly Date, string Occasion, IReadOnlyCollection StudentIds); + +// ── Zwischenstand-Modus des Assistenten (2.7.5) ────────────────────────────── +// +// Mit InterimRunOptions gestartet, geht der Assistent nur die gewählten Schüler durch und +// notiert je Schüler eine vorläufige schriftliche, mündliche und Gesamtnote zum Stichtag — +// als InterimGrade, ohne die Zeugnisnote (ReportGrade) anzufassen. Vorgeschlagen wird der +// Stand aus InterimGradeCalculator, also inklusive des laufenden Mitarbeits-Abschnitts. +public partial class ReportGradeAssistantViewModel +{ + [ObservableProperty] private string? _interimWritten; + [ObservableProperty] private string? _interimOral; + [ObservableProperty] private string? _interimOverall; + [ObservableProperty] private string? _interimNote; + [ObservableProperty] private string _interimWrittenError = ""; + [ObservableProperty] private string _interimOralError = ""; + [ObservableProperty] private string _interimOverallError = ""; + [ObservableProperty] private bool _hasInterimEntry; + + /// Alle bisher notierten Zwischenstände des gewählten Schülers — in beiden Modi sichtbar. + public ObservableCollection InterimEntries { get; } = []; + public bool HasInterimEntries => InterimEntries.Count > 0; + + public bool IsInterimMode => _interim is not null; + public bool IsReportMode => _interim is null; + public string WindowTitle => IsInterimMode ? "Zwischenstand" : "Zeugnisnoten-Assistent"; + public string InterimTitle => _interim is null + ? "" : $"Zwischenstand {_interim.Date:dd.MM.yyyy} · {_interim.Occasion}"; + + /// Startet aus dem Zeugnisnoten-Modus einen Zwischenstand für den gezeigten Schüler; vom + /// Fenster gesetzt, das den Startdialog öffnen kann. + public Func? OnStartInterim { get; set; } + + private InterimGrade? CurrentInterim(Guid studentId) => _interim is null ? null + : _interimGrades.GetByStudentAndGroup(studentId, _groupId) + .FirstOrDefault(g => g.Date == _interim.Date && g.Occasion == _interim.Occasion); + + private void RefreshInterimItem(AssistantStudentItem item) + { + var existing = CurrentInterim(item.StudentId); + if (existing is not null) + item.Update(existing.OverallValue, AssistantGradeStatus.Noted); + else + item.Update(_interimCalculator.Calculate(item.StudentId, _interim!.Date, RoundingRule).Result.Rounded ?? "–", + AssistantGradeStatus.Open); + } + + private void ShowInterim(Guid studentId) + { + var interim = _interimCalculator.Calculate(studentId, _interim!.Date, RoundingRule); + var result = interim.Result; + + _calculatedValue = result.Rounded; + CalculatedDisplay = result.Rounded ?? "–"; + var between = result.Value is { } value ? ReportGradeCalculator.BetweenGrades(value) : null; + BorderlineHint = between is null ? "" : $"Liegt genau {between} — pädagogische Entscheidung."; + CalculationDetail = result.Value is null + ? "Noch keine Leistungen bis zum Stichtag." + : $"Stand {_interim.Date:dd.MM.yyyy} · gewichtet {Format(result.Value.Value)} vor Rundung"; + + var scheme = _calculator.Scheme; + Tiles.Clear(); + Tiles.Add(BucketTile("Schriftlich", result.Written, scheme.ExamsPercent)); + Tiles.Add(new AssistantTile("Mitarbeit", + result.Participation.HasValue ? Format(result.Participation.Value) : "–", + $"{scheme.ParticipationPercent:0.#} %" + + (interim.RunningParticipation is null ? "" : $" · laufender Abschnitt: {interim.RunningParticipation}"))); + Tiles.Add(BucketTile("Sonstige", result.Other, scheme.OtherPercent)); + + // Bereits notiert: der notierte Stand gilt. Sonst der berechnete Vorschlag zum Anpassen. + var existing = CurrentInterim(studentId); + HasInterimEntry = existing is not null; + InterimWritten = existing is not null ? existing.WrittenValue : RoundOrNull(result.Written); + InterimOral = existing is not null ? existing.OralValue : RoundOrNull(result.Participation); + InterimOverall = existing is not null ? existing.OverallValue : result.Rounded; + InterimNote = existing?.Note; + } + + private string? RoundOrNull(double? value) => value.HasValue ? _calculator.Round(value.Value, RoundingRule) : null; + + private void LoadInterimEntries(Guid studentId) + { + InterimEntries.Clear(); + foreach (var entry in _interimGrades.GetByStudentAndGroup(studentId, _groupId) + .OrderByDescending(g => g.Date).ThenByDescending(g => g.UpdatedAt)) + InterimEntries.Add(new AssistantInterimRow(entry)); + OnPropertyChanged(nameof(HasInterimEntries)); + } + + [RelayCommand] + private void NoteInterim() => TryNoteInterim(); + + [RelayCommand] + private void NoteInterimAndNext() + { + if (!TryNoteInterim()) return; + if (CanGoNextStudent) NextStudent(); + } + + private bool TryNoteInterim() + { + if (_interim is null || SelectedStudent is null) return false; + ClearErrors(); + var valid = true; + if (!IsGradeOrEmpty(InterimWritten)) { InterimWrittenError = "Keine gültige Note."; valid = false; } + if (!IsGradeOrEmpty(InterimOral)) { InterimOralError = "Keine gültige Note."; valid = false; } + if (NullIfEmpty(InterimOverall) is null) { InterimOverallError = "Gesamtnote erforderlich."; valid = false; } + else if (!IsGradeOrEmpty(InterimOverall)) { InterimOverallError = "Keine gültige Note."; valid = false; } + if (!valid) return false; + + var studentId = SelectedStudent.StudentId; + var entry = CurrentInterim(studentId) ?? new InterimGrade + { + StudentId = studentId, GroupId = _groupId, Date = _interim.Date, Occasion = _interim.Occasion, + }; + entry.WrittenValue = NullIfEmpty(InterimWritten); + entry.OralValue = NullIfEmpty(InterimOral); + entry.OverallValue = NullIfEmpty(InterimOverall)!; + entry.Note = NullIfEmpty(InterimNote); + _interimGrades.Save(entry); + + RefreshItem(SelectedStudent); + ShowStudent(); + StatusMessage = "Zwischenstand notiert."; + return true; + } + + [RelayCommand] + private void RemoveInterim() + { + if (SelectedStudent is null || CurrentInterim(SelectedStudent.StudentId) is not { } entry) return; + _interimGrades.Delete(entry.Id); + RefreshItem(SelectedStudent); + ShowStudent(); + StatusMessage = "Zwischenstand entfernt."; + } + + [RelayCommand] + private async Task StartInterim() + { + if (OnStartInterim is null || SelectedStudent is null) return; + await OnStartInterim(SelectedStudent.StudentId); + ShowStudent(); + } +} + +public class AssistantInterimRow(InterimGrade entry) +{ + public string Title { get; } = $"{entry.Date:dd.MM.yyyy} · {entry.Occasion}"; + public string OverallValue { get; } = entry.OverallValue; + public string Detail { get; } = string.Join(" · ", new[] + { + entry.WrittenValue is null ? null : $"schriftlich {entry.WrittenValue}", + entry.OralValue is null ? null : $"mündlich {entry.OralValue}", + }.Where(part => part is not null)); + public string Note { get; } = entry.Note ?? ""; +} diff --git a/LehrerApp.Desktop/ViewModels/Groups/ReportGradeAssistantViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ReportGradeAssistantViewModels.cs index 1d2fe88..4063417 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ReportGradeAssistantViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ReportGradeAssistantViewModels.cs @@ -32,6 +32,9 @@ public partial class ReportGradeAssistantViewModel : ObservableObject private readonly GradingService _grading; private readonly ParticipationTimelineBuilder _timeline; private readonly ReportGradeCalculator _calculator; + private readonly InterimGradeCalculator _interimCalculator; + private readonly IInterimGradeRepository _interimGrades; + private readonly InterimRunOptions? _interim; private readonly Guid _groupId; private readonly int _gradeLevel; private readonly DateOnly _today; @@ -125,11 +128,13 @@ public partial class ReportGradeAssistantViewModel : ObservableObject IGradingSchemeRepository schemes, IReportGradeRepository reportGrades, IParticipationSessionRepository participationSessions, IParticipationRepository participation, IParticipationAspectRepository aspects, IParticipationSectionRepository sections, + IInterimGradeRepository interimGrades, AttendanceBalanceService attendanceBalance, GradingService grading, Guid groupId, GroupType groupType, GradingSystem gradingSystem, string groupLabel, string schoolYear, - int gradeLevel, DateOnly? today = null) + int gradeLevel, DateOnly? today = null, InterimRunOptions? interim = null) { _reportGrades = reportGrades; _participation = participation; + _interimGrades = interimGrades; _interim = interim; _attendanceBalance = attendanceBalance; _grading = grading; _groupId = groupId; _gradeLevel = gradeLevel; _today = today ?? DateOnly.FromDateTime(DateTime.Today); @@ -143,9 +148,11 @@ public partial class ReportGradeAssistantViewModel : ObservableObject _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]; + _interimCalculator = new InterimGradeCalculator(_calculator, _timeline, _sections); + + // Vorbelegung: das Halbjahr, in dem wir uns gerade befinden — bzw. das des Stichtags. + var half = _interimCalculator.HalfOf(_interim?.Date ?? _today); + _selectedPeriod = PeriodOptions.First(p => p.Period == half); Reload(); } @@ -190,6 +197,7 @@ public partial class ReportGradeAssistantViewModel : ObservableObject { OverrideReasonError = ""; ProjectExamGradeError = ""; OralExamGradeError = ""; WrittenExamGradeError = ""; AdditionalOralExamGradeError = ""; + InterimWrittenError = ""; InterimOralError = ""; InterimOverallError = ""; } // ── Laden ──────────────────────────────────────────────────────────────── @@ -206,6 +214,7 @@ public partial class ReportGradeAssistantViewModel : ObservableObject Students.Clear(); foreach (var student in _allStudents) { + if (_interim is not null && !_interim.StudentIds.Contains(student.Id)) continue; if (_membershipsByStudent.TryGetValue(student.Id, out var membership) && !GroupMembershipService.Overlaps(membership, from, to)) continue; var item = new AssistantStudentItem(student.Id, student.FullName); @@ -223,6 +232,7 @@ public partial class ReportGradeAssistantViewModel : ObservableObject private void RefreshItem(AssistantStudentItem item) { + if (_interim is not null) { RefreshInterimItem(item); return; } var record = _reportGrades.GetByStudentGroupPeriod(item.StudentId, _groupId, SelectedPeriod.Label); var calculated = record is { IsLocked: true } ? NullIfEmpty(record.CalculatedValue) @@ -251,6 +261,8 @@ public partial class ReportGradeAssistantViewModel : ObservableObject Tiles.Clear(); CalculatedDisplay = "–"; CalculationDetail = ""; BorderlineHint = ""; AbsenceRateDisplay = ""; OverrideValue = null; OverrideReason = null; IsLocked = false; + InterimEntries.Clear(); + OnPropertyChanged(nameof(HasInterimEntries)); _sectionViews = []; ApplyChartMode(); NotifyLists(); @@ -259,9 +271,12 @@ public partial class ReportGradeAssistantViewModel : ObservableObject var studentId = SelectedStudent.StudentId; var (from, to) = _calculator.Range(SelectedPeriod.Period); + // Zwischenstand: alles nach dem Stichtag bleibt außen vor, auch in der Anzeige. + if (_interim is not null && _interim.Date < to) to = _interim.Date; _membershipsByStudent.TryGetValue(studentId, out var membership); - var record = _reportGrades.GetByStudentGroupPeriod(studentId, _groupId, SelectedPeriod.Label); + var record = _interim is null + ? _reportGrades.GetByStudentGroupPeriod(studentId, _groupId, SelectedPeriod.Label) : null; _showing = true; IsLocked = record is { IsLocked: true }; OverrideValue = record?.OverrideValue; @@ -272,7 +287,8 @@ public partial class ReportGradeAssistantViewModel : ObservableObject AdditionalOralExamGrade = CanEnterExamGrades ? record?.AdditionalOralExamGrade : null; _showing = false; - ShowCalculation(); + if (_interim is not null) ShowInterim(studentId); else ShowCalculation(); + LoadInterimEntries(studentId); ShowAbsenceRate(studentId, from, to); // Im 2. Halbjahr auf Wunsch das Vorhalbjahr als Kontext mitzeigen — ohne Einfluss auf die Note. @@ -411,6 +427,13 @@ public partial class ReportGradeAssistantViewModel : ObservableObject private void BuildSections(Guid studentId, DateOnly displayFrom, DateOnly to) { var points = _timeline.BuildPoints(studentId).Where(p => p.Date >= displayFrom && p.Date <= to).ToList(); + // Notierte Zwischenstände erscheinen als Marker im Verlauf. + points.AddRange(_interimGrades.GetByStudentAndGroup(studentId, _groupId) + .Where(g => g.Date >= displayFrom && g.Date <= to) + .Select(g => new WizardTimelinePoint(g.Date, isExam: true, ratingLabel: "", + examLabel: $"Zwischenstand: {g.OverallValue}", hasNote: false, + tooltip: $"{g.Date:dd.MM.yyyy} · {g.Occasion} · Zwischenstand {g.OverallValue}"))); + points = points.OrderBy(p => p.Date).ToList(); var sectionGrades = _calculator.Grades .Where(g => g.StudentId == studentId && g.Category == GradeCategory.Participation && g.Note is not null) .ToList(); @@ -430,13 +453,16 @@ public partial class ReportGradeAssistantViewModel : ObservableObject // 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); + var asOf = _interim?.Date ?? _today; + // Ohne jeden abgeschlossenen Abschnitt gehört der ganze Zeitraum zum laufenden — sonst + // fielen Arbeiten und Zwischenstände vor der ersten Mitarbeit-Sitzung aus dem Verlauf. + var openStart = _sections.Count == 0 ? displayFrom : _timeline.OpenStart(_sections, asOf); 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 end = asOf < to ? asOf : to; var suggestion = _timeline.ComputeSuggestion(studentId, openStart < displayFrom ? displayFrom : openStart, end); views.Add(new SectionView(group, $"Laufender Abschnitt seit {openStart:dd.MM.} · " + @@ -595,7 +621,7 @@ public partial class ReportGradeAssistantViewModel : ObservableObject // ── Bausteine ──────────────────────────────────────────────────────────────── -public enum AssistantGradeStatus { Open, Saved, Locked } +public enum AssistantGradeStatus { Open, Saved, Locked, Noted } public partial class AssistantStudentItem(Guid studentId, string name) : ObservableObject { @@ -609,13 +635,14 @@ public partial class AssistantStudentItem(Guid studentId, string name) : Observa { AssistantGradeStatus.Locked => "festgeschrieben", AssistantGradeStatus.Saved => "gespeichert", + AssistantGradeStatus.Noted => "notiert", _ => "offen", }; public string StatusColorHex => Status switch { AssistantGradeStatus.Locked => "#2E9D57", - AssistantGradeStatus.Saved => "#2E86DE", + AssistantGradeStatus.Saved or AssistantGradeStatus.Noted => "#2E86DE", _ => "#8A8A8A", }; diff --git a/LehrerApp.Desktop/ViewModels/Groups/ReportGradeCalculator.cs b/LehrerApp.Desktop/ViewModels/Groups/ReportGradeCalculator.cs index d894974..93672ca 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ReportGradeCalculator.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ReportGradeCalculator.cs @@ -109,10 +109,16 @@ public sealed class ReportGradeCalculator return !exam.Niveau.HasValue || membership?.Niveau == exam.Niveau; } + public string Round(double value, RoundingRule rounding) => _grading.RoundToGrade(value, _gradingSystem, rounding); + /// Noch nicht gespeicherte Prüfungsnoten für die Jahresnote; ohne /// Angabe gelten die am Jahres-Datensatz gespeicherten. + /// Stichtag für ein Halbjahr: nur Leistungen bis einschließlich dieses + /// Tages zählen. + /// Vorläufige Mitarbeitsnote des laufenden, noch nicht + /// abgeschlossenen Abschnitts — zählt für ein Halbjahr wie eine weitere Abschnittsnote. public ReportGradeResult Calculate(Guid studentId, ParticipationPeriod period, RoundingRule rounding, - YearExamGrades? examGrades = null) + YearExamGrades? examGrades = null, DateOnly? asOf = null, string? runningParticipation = null) { if (period == ParticipationPeriod.FullYear) { @@ -125,6 +131,7 @@ public sealed class ReportGradeCalculator } var (from, to) = Range(period); + if (asOf is { } cutoff && cutoff < to) to = cutoff; _membershipsByStudent.TryGetValue(studentId, out var membership); var examEntries = new List<(Exam Exam, string Grade)>(); @@ -144,6 +151,7 @@ public sealed class ReportGradeCalculator .ToList(); var participation = studentGrades.Where(g => g.Category == GradeCategory.Participation) .Select(g => (Grade: g.Value, g.Weight)).ToList(); + if (!string.IsNullOrWhiteSpace(runningParticipation)) participation.Add((runningParticipation, 1.0)); 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. diff --git a/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs b/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs index 315d6d4..49d890d 100644 --- a/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs @@ -181,7 +181,7 @@ public partial class ManageStudentDialogViewModel : ObservableObject AddReference("Gruppenzuordnungen", References.Memberships); AddReference("Klausurergebnisse", References.ExamResults); AddReference("Einzelnoten", References.Grades); - AddReference("Zeugnisnoten", References.ReportGrades); + AddReference("Zeugnisnoten und Zwischenstände", References.ReportGrades); AddReference("Mitarbeitseinträge", References.ParticipationEntries); AddReference("Dokumentationseinträge", References.DocumentationEntries); } diff --git a/LehrerApp.Desktop/Views/Groups/GradeOverviewTabView.axaml b/LehrerApp.Desktop/Views/Groups/GradeOverviewTabView.axaml index 2366e7a..38bf9c2 100644 --- a/LehrerApp.Desktop/Views/Groups/GradeOverviewTabView.axaml +++ b/LehrerApp.Desktop/Views/Groups/GradeOverviewTabView.axaml @@ -24,6 +24,8 @@ ToolTip.Tip="Leistungsüberblick mit Zielnoten-Rechner für diesen Schüler"/>