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"/>